diff --git a/platform/editor-ui-api/api-dump.txt b/platform/editor-ui-api/api-dump.txt index 6a7f5115da72..f87aebd3ec99 100644 --- a/platform/editor-ui-api/api-dump.txt +++ b/platform/editor-ui-api/api-dump.txt @@ -2974,6 +2974,8 @@ e:com.intellij.ui.tree.LeafState - s:values():com.intellij.ui.tree.LeafState[] com.intellij.ui.tree.LeafState$Supplier - a:getLeafState():com.intellij.ui.tree.LeafState +*:com.intellij.ui.tree.SuspendingTreeVisitor +- a:visit(javax.swing.tree.TreePath,kotlin.coroutines.Continuation):java.lang.Object f:com.intellij.ui.tree.TreeCollector - add(java.lang.Object):Z - get():java.util.List diff --git a/platform/editor-ui-api/src/com/intellij/ui/tree/SuspendingTreeVisitor.kt b/platform/editor-ui-api/src/com/intellij/ui/tree/SuspendingTreeVisitor.kt new file mode 100644 index 000000000000..d7c08e611f5e --- /dev/null +++ b/platform/editor-ui-api/src/com/intellij/ui/tree/SuspendingTreeVisitor.kt @@ -0,0 +1,10 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.tree + +import org.jetbrains.annotations.ApiStatus +import javax.swing.tree.TreePath + +@ApiStatus.Experimental +interface SuspendingTreeVisitor { + suspend fun visit(path: TreePath): TreeVisitor.Action +} diff --git a/platform/platform-api/api-dump-unreviewed.txt b/platform/platform-api/api-dump-unreviewed.txt index 1c65e2e7d2d9..3bbb3083ea48 100644 --- a/platform/platform-api/api-dump-unreviewed.txt +++ b/platform/platform-api/api-dump-unreviewed.txt @@ -7040,9 +7040,17 @@ a:com.intellij.ui.ListUtil$Updatable - pa:update():V c:com.intellij.ui.LoadingNode - javax.swing.tree.DefaultMutableTreeNode +- com.intellij.ui.treeStructure.TreeNodeViewModel +- sf:Companion:com.intellij.ui.LoadingNode$Companion - ():V - (java.lang.String):V -- s:getText():java.lang.String +- b:(java.lang.String,I,kotlin.jvm.internal.DefaultConstructorMarker):V +- getChildren():kotlinx.coroutines.flow.Flow +- getPresentation():kotlinx.coroutines.flow.Flow +- sf:getText():java.lang.String +- presentationSnapshot():com.intellij.ui.treeStructure.TreeNodePresentation +f:com.intellij.ui.LoadingNode$Companion +- f:getText():java.lang.String a:com.intellij.ui.MouseDragHelper - java.awt.event.MouseAdapter - com.intellij.openapi.util.Weighted @@ -11105,6 +11113,56 @@ com.intellij.ui.treeStructure.TreeBulkExpansionListener - treeBulkCollapseStarted(com.intellij.ui.treeStructure.TreeBulkExpansionEvent):V - treeBulkExpansionEnded(com.intellij.ui.treeStructure.TreeBulkExpansionEvent):V - treeBulkExpansionStarted(com.intellij.ui.treeStructure.TreeBulkExpansionEvent):V +*:com.intellij.ui.treeStructure.TreeDomainModel +- a:accessData(kotlin.jvm.functions.Function0,kotlin.coroutines.Continuation):java.lang.Object +- a:computeRoot(kotlin.coroutines.Continuation):java.lang.Object +f:com.intellij.ui.treeStructure.TreeDomainModelKt +- *sf:TreeDomainModel(com.intellij.ide.util.treeView.AbstractTreeStructure,Z,I):com.intellij.ui.treeStructure.TreeDomainModel +*:com.intellij.ui.treeStructure.TreeNodeDomainModel +- a:computeChildren(kotlin.coroutines.Continuation):java.lang.Object +- a:computeLeafState(kotlin.coroutines.Continuation):java.lang.Object +- a:computePresentation(com.intellij.ui.treeStructure.TreeNodePresentationBuilder,kotlin.coroutines.Continuation):java.lang.Object +- a:getUserObject():java.lang.Object +*:com.intellij.ui.treeStructure.TreeNodePresentation +- a:getFullText():java.util.List +- a:getIcon():javax.swing.Icon +- a:getMainText():java.lang.String +- a:getToolTip():java.lang.String +- a:isLeaf():Z +*:com.intellij.ui.treeStructure.TreeNodePresentationBuilder +- a:appendTextFragment(java.lang.String,com.intellij.ui.SimpleTextAttributes):V +- a:build():com.intellij.ui.treeStructure.TreeNodePresentation +- a:setIcon(javax.swing.Icon):V +- a:setMainText(java.lang.String):V +- a:setToolTipText(java.lang.String):V +*:com.intellij.ui.treeStructure.TreeNodeTextFragment +- a:getAttributes():com.intellij.ui.SimpleTextAttributes +- a:getText():java.lang.String +*:com.intellij.ui.treeStructure.TreeNodeViewModel +- a:getChildren():kotlinx.coroutines.flow.Flow +- a:getPresentation():kotlinx.coroutines.flow.Flow +- a:getUserObject():java.lang.Object +- a:presentationSnapshot():com.intellij.ui.treeStructure.TreeNodePresentation +*:com.intellij.ui.treeStructure.TreeSwingModel +- com.intellij.ui.tree.TreeVisitor$LoadingAwareAcceptor +- com.intellij.ui.treeStructure.BgtAwareTreeModel +- javax.swing.tree.TreeModel +- a:getChild(java.lang.Object,I):com.intellij.ui.treeStructure.TreeNodeViewModel +- a:getRoot():com.intellij.ui.treeStructure.TreeNodeViewModel +- a:getShowLoadingNode():Z +- a:setShowLoadingNode(Z):V +f:com.intellij.ui.treeStructure.TreeSwingModelKt +- *sf:TreeSwingModel(kotlinx.coroutines.CoroutineScope,com.intellij.ui.treeStructure.TreeViewModel):com.intellij.ui.treeStructure.TreeSwingModel +*:com.intellij.ui.treeStructure.TreeViewModel +- a:accept(com.intellij.ui.tree.SuspendingTreeVisitor,Z,kotlin.coroutines.Continuation):java.lang.Object +- a:accept(com.intellij.ui.tree.TreeVisitor,Z,kotlin.coroutines.Continuation):java.lang.Object +- a:getComparator():java.util.Comparator +- a:getDomainModel():com.intellij.ui.treeStructure.TreeDomainModel +- a:getRoot():kotlinx.coroutines.flow.Flow +- a:invalidate(javax.swing.tree.TreePath,Z,kotlin.coroutines.Continuation):java.lang.Object +- a:setComparator(java.util.Comparator):V +f:com.intellij.ui.treeStructure.TreeViewModelKt +- *sf:TreeViewModel(kotlinx.coroutines.CoroutineScope,com.intellij.ui.treeStructure.TreeDomainModel):com.intellij.ui.treeStructure.TreeViewModel c:com.intellij.ui.treeStructure.WeightBasedComparator - java.util.Comparator - sf:FULL_INSTANCE:com.intellij.ui.treeStructure.WeightBasedComparator diff --git a/platform/platform-api/src/com/intellij/ide/util/treeView/CachedTreePresentation.kt b/platform/platform-api/src/com/intellij/ide/util/treeView/CachedTreePresentation.kt index 0d1edc533007..533fe5c04363 100644 --- a/platform/platform-api/src/com/intellij/ide/util/treeView/CachedTreePresentation.kt +++ b/platform/platform-api/src/com/intellij/ide/util/treeView/CachedTreePresentation.kt @@ -173,7 +173,10 @@ class CachedTreePresentation(rootPresentation: CachedTreePresentationData) { } private fun getCachedNode(node: Any): CachedTreePresentationNode? { - return if (node is CachedTreePresentationNode) return node else cachedNodeByRealNode[node] + if (node is CachedTreePresentationNode) return node + val userObject = TreeUtil.getUserObject(node) + if (userObject is CachedTreePresentationNode) return userObject + return cachedNodeByRealNode[node] } fun setExpanded(path: TreePath, isExpanded: Boolean) { diff --git a/platform/platform-api/src/com/intellij/ide/util/treeView/NodeRenderer.java b/platform/platform-api/src/com/intellij/ide/util/treeView/NodeRenderer.java index 9727387dacf5..2cb015e3a6bf 100644 --- a/platform/platform-api/src/com/intellij/ide/util/treeView/NodeRenderer.java +++ b/platform/platform-api/src/com/intellij/ide/util/treeView/NodeRenderer.java @@ -16,6 +16,9 @@ import com.intellij.openapi.util.registry.Registry; import com.intellij.openapi.util.text.StringUtil; import com.intellij.ui.ColoredTreeCellRenderer; import com.intellij.ui.SimpleTextAttributes; +import com.intellij.ui.treeStructure.TreeNodePresentation; +import com.intellij.ui.treeStructure.TreeNodeTextFragment; +import com.intellij.ui.treeStructure.TreeNodeViewModel; import com.intellij.util.ui.StartupUiUtil; import com.intellij.util.ui.tree.TreeUtil; import org.jetbrains.annotations.NotNull; @@ -35,6 +38,26 @@ public class NodeRenderer extends ColoredTreeCellRenderer { @Override public void customizeCellRenderer(@NotNull JTree tree, @NlsSafe Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { + if (value instanceof TreeNodeViewModel vm) { + customizeViewModelRenderer(vm.presentationSnapshot(), selected, hasFocus); + } + else { + customizeLegacyRenderer(tree, value, selected, expanded, leaf, row, hasFocus); + } + } + + private void customizeViewModelRenderer(@NotNull TreeNodePresentation presentation, boolean selected, boolean hasFocus) { + setIcon(fixIconIfNeeded(presentation.getIcon(), selected, hasFocus)); + boolean isMain = true; + for (@NotNull TreeNodeTextFragment fragment : presentation.getFullText()) { + var simpleTextAttributes = fragment.getAttributes(); + isMain = isMain && !Comparing.equal(simpleTextAttributes.getFgColor(), SimpleTextAttributes.GRAYED_ATTRIBUTES.getFgColor()); + append(fragment.getText(), simpleTextAttributes, isMain); + } + setToolTipText(presentation.getToolTip()); + } + + private void customizeLegacyRenderer(@NotNull JTree tree, @NlsSafe Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { @NlsSafe Object node = TreeUtil.getUserObject(value); if (node instanceof NodeDescriptor descriptor) { diff --git a/platform/platform-api/src/com/intellij/ui/LoadingNode.java b/platform/platform-api/src/com/intellij/ui/LoadingNode.java deleted file mode 100644 index 2e767266b8f8..000000000000 --- a/platform/platform-api/src/com/intellij/ui/LoadingNode.java +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.intellij.ui; - -import com.intellij.ide.IdeBundle; -import org.jetbrains.annotations.Nls; -import org.jetbrains.annotations.NotNull; - -import javax.swing.tree.DefaultMutableTreeNode; - -public class LoadingNode extends DefaultMutableTreeNode { - public LoadingNode() { - this(getText()); - } - - public static @NotNull @Nls String getText() { - return IdeBundle.message("treenode.loading"); - } - - public LoadingNode(@Nls @NotNull String text) { - super(text); - } -} \ No newline at end of file diff --git a/platform/platform-api/src/com/intellij/ui/LoadingNode.kt b/platform/platform-api/src/com/intellij/ui/LoadingNode.kt new file mode 100644 index 000000000000..ecc034b8f26c --- /dev/null +++ b/platform/platform-api/src/com/intellij/ui/LoadingNode.kt @@ -0,0 +1,33 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui + +import com.intellij.ide.IdeBundle +import com.intellij.ui.treeStructure.TreeNodePresentation +import com.intellij.ui.treeStructure.TreeNodePresentationImpl +import com.intellij.ui.treeStructure.TreeNodeTextFragmentImpl +import com.intellij.ui.treeStructure.TreeNodeViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.jetbrains.annotations.Nls +import javax.swing.tree.DefaultMutableTreeNode + +open class LoadingNode @JvmOverloads constructor(text: @Nls String = getText()) : DefaultMutableTreeNode(text), TreeNodeViewModel { + companion object { + @JvmStatic fun getText(): @Nls String = IdeBundle.message("treenode.loading") + } + + private val presentationValue = TreeNodePresentationImpl( + isLeaf = true, + icon = null, + mainText = userObject as String, + fullText = listOf(TreeNodeTextFragmentImpl(userObject as String, SimpleTextAttributes.GRAY_ATTRIBUTES)), + toolTip = null, + ) + + override val presentation: Flow = flowOf(presentationValue) + + override val children: Flow> + get() = flowOf(emptyList()) + + override fun presentationSnapshot(): TreeNodePresentation = presentationValue +} diff --git a/platform/platform-api/src/com/intellij/ui/treeStructure/TreeDomainModel.kt b/platform/platform-api/src/com/intellij/ui/treeStructure/TreeDomainModel.kt new file mode 100644 index 000000000000..7ed487a86b75 --- /dev/null +++ b/platform/platform-api/src/com/intellij/ui/treeStructure/TreeDomainModel.kt @@ -0,0 +1,60 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.treeStructure + +import com.intellij.ide.util.treeView.AbstractTreeStructure +import com.intellij.openapi.components.service +import com.intellij.openapi.util.NlsContexts +import com.intellij.openapi.util.NlsSafe +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.tree.LeafState +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.ApiStatus.Internal +import javax.swing.Icon + +@ApiStatus.Experimental +fun TreeDomainModel(structure: AbstractTreeStructure, useReadAction: Boolean, concurrency: Int): TreeDomainModel = + service().createTreeDomainModel(structure, useReadAction, concurrency) + +@ApiStatus.Experimental +interface TreeDomainModel { + suspend fun accessData(accessor: () -> T): T + suspend fun computeRoot(): TreeNodeDomainModel? +} + +@ApiStatus.Experimental +interface TreeNodeDomainModel { + val userObject: Any + suspend fun computeLeafState(): LeafState + suspend fun computePresentation(builder: TreeNodePresentationBuilder): Flow + suspend fun computeChildren(): List +} + +@ApiStatus.Experimental +interface TreeNodePresentationBuilder { + fun setIcon(icon: Icon?) + fun setMainText(text: String) + fun appendTextFragment(text: String, attributes: SimpleTextAttributes) + fun setToolTipText(toolTip: String?) + fun build(): TreeNodePresentation +} + +@ApiStatus.Experimental +sealed interface TreeNodePresentation { + val isLeaf: Boolean + val icon: Icon? + @get:NlsSafe val mainText: String + val fullText: List + @get:NlsContexts.Tooltip val toolTip: String? +} + +@ApiStatus.Experimental +sealed interface TreeNodeTextFragment { + @get:NlsSafe val text: String + val attributes: SimpleTextAttributes +} + +@Internal +interface TreeDomainModelFactory { + fun createTreeDomainModel(structure: AbstractTreeStructure, useReadAction: Boolean, concurrency: Int): TreeDomainModel +} diff --git a/platform/platform-api/src/com/intellij/ui/treeStructure/TreeSwingModel.kt b/platform/platform-api/src/com/intellij/ui/treeStructure/TreeSwingModel.kt new file mode 100644 index 000000000000..33478547f558 --- /dev/null +++ b/platform/platform-api/src/com/intellij/ui/treeStructure/TreeSwingModel.kt @@ -0,0 +1,24 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.treeStructure + +import com.intellij.openapi.components.service +import com.intellij.ui.tree.TreeVisitor +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.annotations.ApiStatus +import javax.swing.tree.TreeModel + +@ApiStatus.Experimental +fun TreeSwingModel(coroutineScope: CoroutineScope, viewModel: TreeViewModel): TreeSwingModel = + (service()).createTreeSwingModel(coroutineScope, viewModel) + +@ApiStatus.Experimental +interface TreeSwingModel : TreeModel, TreeVisitor.LoadingAwareAcceptor, BgtAwareTreeModel { + var showLoadingNode: Boolean + override fun getRoot(): TreeNodeViewModel? + override fun getChild(parent: Any?, index: Int): TreeNodeViewModel? +} + +@ApiStatus.Internal +interface TreeSwingModelFactory { + fun createTreeSwingModel(coroutineScope: CoroutineScope, viewModel: TreeViewModel): TreeSwingModel +} diff --git a/platform/platform-api/src/com/intellij/ui/treeStructure/TreeViewModel.kt b/platform/platform-api/src/com/intellij/ui/treeStructure/TreeViewModel.kt new file mode 100644 index 000000000000..1fe7bfb71614 --- /dev/null +++ b/platform/platform-api/src/com/intellij/ui/treeStructure/TreeViewModel.kt @@ -0,0 +1,54 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.treeStructure + +import com.intellij.openapi.components.service +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.tree.SuspendingTreeVisitor +import com.intellij.ui.tree.TreeVisitor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon +import javax.swing.tree.TreePath + +@ApiStatus.Experimental +fun TreeViewModel(coroutineScope: CoroutineScope, domainModel: TreeDomainModel): TreeViewModel = + (service()).createTreeViewModel(coroutineScope, domainModel) + +@ApiStatus.Experimental +interface TreeViewModel { + val domainModel: TreeDomainModel + val root: Flow + suspend fun invalidate(path: TreePath?, recursive: Boolean) + var comparator: Comparator? + suspend fun accept(visitor: TreeVisitor, allowLoading: Boolean): TreePath? + suspend fun accept(visitor: SuspendingTreeVisitor, allowLoading: Boolean): TreePath? +} + +@ApiStatus.Experimental +interface TreeNodeViewModel { + fun getUserObject(): Any + val presentation: Flow + val children: Flow> + fun presentationSnapshot(): TreeNodePresentation +} + +@ApiStatus.Internal +interface TreeViewModelFactory { + fun createTreeViewModel(coroutineScope: CoroutineScope, domainModel: TreeDomainModel): TreeViewModel +} + +@ApiStatus.Internal +data class TreeNodePresentationImpl( + override val isLeaf: Boolean, + override val icon: Icon?, + override val mainText: String, + override val fullText: List, + override val toolTip: String?, +) : TreeNodePresentation + +@ApiStatus.Internal +data class TreeNodeTextFragmentImpl( + override val text: String, + override val attributes: SimpleTextAttributes, +) : TreeNodeTextFragment diff --git a/platform/platform-api/src/com/intellij/util/ui/tree/TreeUtil.java b/platform/platform-api/src/com/intellij/util/ui/tree/TreeUtil.java index c5cfc3f4005f..88951b6871c4 100644 --- a/platform/platform-api/src/com/intellij/util/ui/tree/TreeUtil.java +++ b/platform/platform-api/src/com/intellij/util/ui/tree/TreeUtil.java @@ -23,6 +23,7 @@ import com.intellij.ui.tree.DelegatingEdtBgtTreeVisitor; import com.intellij.ui.tree.TreeVisitor; import com.intellij.ui.treeStructure.CachingTreePath; import com.intellij.ui.treeStructure.Tree; +import com.intellij.ui.treeStructure.TreeNodeViewModel; import com.intellij.util.ObjectUtils; import com.intellij.util.Range; import com.intellij.util.concurrency.EdtScheduler; @@ -1354,7 +1355,9 @@ public final class TreeUtil { } public static @Nullable Object getUserObject(@Nullable Object node) { - return node instanceof DefaultMutableTreeNode ? ((DefaultMutableTreeNode)node).getUserObject() : node; + if (node instanceof DefaultMutableTreeNode treeNode) return treeNode.getUserObject(); + if (node instanceof TreeNodeViewModel nodeModel) return nodeModel.getUserObject(); + return node; } public static @Nullable T getUserObject(@NotNull Class type, @Nullable Object node) { diff --git a/platform/platform-impl/src/com/intellij/ui/tree/AbstractTreeNodeVisitor.java b/platform/platform-impl/src/com/intellij/ui/tree/AbstractTreeNodeVisitor.java index b3b14dc6bc14..f720d6ccde61 100644 --- a/platform/platform-impl/src/com/intellij/ui/tree/AbstractTreeNodeVisitor.java +++ b/platform/platform-impl/src/com/intellij/ui/tree/AbstractTreeNodeVisitor.java @@ -5,10 +5,10 @@ import com.intellij.ide.util.treeView.AbstractTreeNode; import com.intellij.ide.util.treeView.CachedTreePresentationNode; import com.intellij.openapi.diagnostic.Logger; import com.intellij.util.concurrency.annotations.RequiresBackgroundThread; +import com.intellij.util.ui.tree.TreeUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.TreePath; import java.util.function.Predicate; import java.util.function.Supplier; @@ -48,31 +48,19 @@ public abstract class AbstractTreeNodeVisitor implements TreeVisitor { if (LOG.isTraceEnabled()) LOG.debug("process ", path); T element = getElement(); if (element == null) return Action.SKIP_SIBLINGS; - Object component = path.getLastPathComponent(); - if (component instanceof AbstractTreeNode) { - return visit(path, (AbstractTreeNode)component, element); + Object object = TreeUtil.getLastUserObject(path); + if (object instanceof AbstractTreeNode) { + return visit(path, (AbstractTreeNode)object, element); } - if (component instanceof DefaultMutableTreeNode node) { - Object object = node.getUserObject(); - if (object instanceof AbstractTreeNode) { - return visit(path, (AbstractTreeNode)object, element); - } - else if (object instanceof String) { - LOG.debug("ignore children: ", object); - } - else { - LOG.warn(object == null ? "no object" : "unexpected object " + object.getClass()); - } + else if (object instanceof String) { + LOG.debug("ignore children: ", object); } - else if (component instanceof CachedTreePresentationNode) { + else if (object instanceof CachedTreePresentationNode) { // Cached presentation nodes don't contain the actual object because it's not loaded yet. return Action.SKIP_CHILDREN; } - else if (component instanceof String) { - LOG.debug("ignore children: ", component); - } else { - LOG.warn(component == null ? "no component" : "unexpected component " + component.getClass()); + LOG.warn(object == null ? "no object" : "unexpected object " + object.getClass()); } return Action.SKIP_CHILDREN; } diff --git a/platform/platform-impl/src/com/intellij/ui/tree/TreeDomainModelDelegatingVisitor.kt b/platform/platform-impl/src/com/intellij/ui/tree/TreeDomainModelDelegatingVisitor.kt new file mode 100644 index 000000000000..1f81514bc4c0 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/tree/TreeDomainModelDelegatingVisitor.kt @@ -0,0 +1,62 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.tree + +import com.intellij.openapi.application.EDT +import com.intellij.ui.treeStructure.TreeDomainModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.swing.tree.TreePath + +internal class TreeDomainModelDelegatingVisitor( + private val model: TreeDomainModel, + private val delegate: TreeVisitor, +) : SuspendingTreeVisitor { + + override suspend fun visit(path: TreePath): TreeVisitor.Action { + if (delegate.visitThread() == TreeVisitor.VisitThread.EDT) { + return actualVisitEdt(delegate, path) // For EDT visiting, the visitor does all three steps in visit(). + } + else { + val preVisitResult = preVisit(path, delegate) + if (preVisitResult != null) return preVisitResult + val visitResult = actualVisitBgt(delegate, path) + val postVisitResult = postVisit(visitResult, path, delegate) + return postVisitResult + } + } + + private suspend fun preVisit(path: TreePath, visitor: TreeVisitor): TreeVisitor.Action? = + (visitor as? EdtBgtTreeVisitor)?.let { + withContext(Dispatchers.EDT) { + visitor.preVisitEDT(path) + } + } + + private suspend fun actualVisitEdt( + visitor: TreeVisitor, + path: TreePath, + ): TreeVisitor.Action = + withContext(Dispatchers.EDT) { + visitor.visit(path) + } + + private suspend fun actualVisitBgt( + visitor: TreeVisitor, + path: TreePath, + ): TreeVisitor.Action = + withContext(Dispatchers.Default) { + model.accessData { + visitor.visit(path) + } + } + + private suspend fun postVisit(action: TreeVisitor.Action, path: TreePath, visitor: TreeVisitor): TreeVisitor.Action = + (visitor as? EdtBgtTreeVisitor)?.let { + withContext(Dispatchers.EDT) { + visitor.postVisitEDT(path, action) + } + } ?: action + + override fun toString(): String = + "TreeDomainModelDelegatingVisitor(model=$model, delegate=$delegate)" +} diff --git a/platform/platform-impl/src/com/intellij/ui/tree/TreeDomainModelFactoryImpl.kt b/platform/platform-impl/src/com/intellij/ui/tree/TreeDomainModelFactoryImpl.kt new file mode 100644 index 000000000000..dffa475970e3 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/tree/TreeDomainModelFactoryImpl.kt @@ -0,0 +1,11 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.tree + +import com.intellij.ide.util.treeView.AbstractTreeStructure +import com.intellij.ui.treeStructure.TreeDomainModel +import com.intellij.ui.treeStructure.TreeDomainModelFactory + +internal class TreeDomainModelFactoryImpl : TreeDomainModelFactory { + override fun createTreeDomainModel(structure: AbstractTreeStructure, useReadAction: Boolean, concurrency: Int): TreeDomainModel = + TreeStructureDomainModelAdapter(structure, useReadAction, concurrency) +} diff --git a/platform/platform-impl/src/com/intellij/ui/tree/TreeStructureDomainModelAdapter.kt b/platform/platform-impl/src/com/intellij/ui/tree/TreeStructureDomainModelAdapter.kt new file mode 100644 index 000000000000..03b6ed0e9f19 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/tree/TreeStructureDomainModelAdapter.kt @@ -0,0 +1,111 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.tree + +import com.intellij.ide.util.treeView.* +import com.intellij.openapi.application.readAction +import com.intellij.openapi.progress.blockingContext +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.treeStructure.TreeDomainModel +import com.intellij.ui.treeStructure.TreeNodeDomainModel +import com.intellij.ui.treeStructure.TreeNodePresentation +import com.intellij.ui.treeStructure.TreeNodePresentationBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +internal class TreeStructureDomainModelAdapter( + private val structure: AbstractTreeStructure, + private val useReadAction: Boolean, + concurrency: Int, +) : TreeDomainModel { + private val semaphore = Semaphore(concurrency) + + override suspend fun computeRoot(): TreeNodeDomainModel? = accessData { + structure.rootElement.validate()?.let { TreeStructureNodeDomainModel(structure.createDescriptor(it, null)) } + } + + override suspend fun accessData(accessor: () -> T): T = semaphore.withPermit { + if (useReadAction) { + readAction { + accessor() + } + } + else { + blockingContext { + accessor() + } + } + } + + private fun Any.validate(): Any? { + if (this is AbstractTreeNode<*> && value == null) return null + if (this is ValidateableNode && !isValid) return null + if (!structure.isValid(this)) return null + return this + } + + private inner class TreeStructureNodeDomainModel(override val userObject: NodeDescriptor<*>) : TreeNodeDomainModel { + override suspend fun computeLeafState(): LeafState = accessData { + structure.getLeafState(userObject.element) + } + + override suspend fun computePresentation(builder: TreeNodePresentationBuilder): Flow = + flowOf(accessData { + buildPresentation(builder, userObject) + }) + + override suspend fun computeChildren(): List = accessData { + userObject.element.validate()?.let { parentElement -> + structure.getChildElements(parentElement) + .asSequence() + .mapNotNull { it.validate() } + .map { childElement -> + TreeStructureNodeDomainModel(structure.createDescriptor(childElement, userObject)) + } + .toList() + } ?: emptyList() + } + + override fun toString(): String { + return "TreeStructureNodeDomainModel(userObject=$userObject)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TreeStructureNodeDomainModel + + return userObject == other.userObject + } + + override fun hashCode(): Int { + return userObject.hashCode() + } + } +} + +internal fun buildPresentation(builder: TreeNodePresentationBuilder, userObject: Any): TreeNodePresentation { + if (userObject !is PresentableNodeDescriptor<*>) { + builder.setMainText(userObject.toString()) + return builder.build() + } + userObject.update() + val presentation = userObject.presentation + return builder.run { + setIcon(presentation.getIcon(false)) + setMainText(presentation.presentableText ?: "") + for (fragment in presentation.coloredText) { + appendTextFragment(fragment.text, fragment.attributes ?: SimpleTextAttributes.REGULAR_ATTRIBUTES) + } + val location = presentation.locationString + if (!location.isNullOrEmpty()) { + val prefix = presentation.locationPrefix + val suffix = presentation.locationSuffix + appendTextFragment(prefix + location + suffix, SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + setToolTipText(presentation.tooltip) + build() + } +} diff --git a/platform/platform-impl/src/com/intellij/ui/tree/TreeSwingModelImpl.kt b/platform/platform-impl/src/com/intellij/ui/tree/TreeSwingModelImpl.kt new file mode 100644 index 000000000000..7c3f0ea80ab1 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/tree/TreeSwingModelImpl.kt @@ -0,0 +1,503 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ui.tree + +import com.intellij.ide.util.treeView.CachedTreePresentation +import com.intellij.ide.util.treeView.CachedTreePresentationSupport +import com.intellij.openapi.application.EDT +import com.intellij.openapi.diagnostic.logger +import com.intellij.platform.util.coroutines.childScope +import com.intellij.ui.LoadingNode +import com.intellij.ui.treeStructure.* +import com.intellij.util.ui.tree.TreeModelListenerList +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.Promise +import org.jetbrains.concurrency.isPending +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference +import javax.swing.SwingUtilities +import javax.swing.event.TreeModelEvent +import javax.swing.event.TreeModelListener +import javax.swing.tree.TreePath + +internal class TreeSwingModelFactoryImpl : TreeSwingModelFactory { + override fun createTreeSwingModel(coroutineScope: CoroutineScope, viewModel: TreeViewModel): TreeSwingModel = + TreeSwingModelImpl(coroutineScope, viewModel) +} + +private class TreeSwingModelImpl( + parentScope: CoroutineScope, + private val viewModel: TreeViewModel, +) : TreeSwingModel, CachedTreePresentationSupport { + private val treeScope = parentScope.childScope("Root of $this", Dispatchers.EDT) + private val listeners = TreeModelListenerList() + private var root: Node? = null + // These things must be thread-safe because of the "cancellation can happen anywhere, anytime" thing. + private val nodes = ConcurrentHashMap() + private val nodeLoadedListeners = CopyOnWriteArrayList() + private var cachedPresentation: CachedTreePresentation? = null + + override var showLoadingNode: Boolean = true + + private interface NodeLoadedListener { + fun nodePublished(node: Node) + } + + init { + treeScope.launch(CoroutineName("Root updates of $this")) { + viewModel.root.collect { rootViewModel -> + val root = rootViewModel?.let { + findOrLoadNode(null, rootViewModel) + } + if (this@TreeSwingModelImpl.root != root) { + this@TreeSwingModelImpl.root?.dispose() + this@TreeSwingModelImpl.root = root + if (root != null) { + cachedPresentation?.rootLoaded(root.viewModel) + } + treeStructureChanged(root) + if (root != null) { + markPublished(root) + } + } + } + } + } + + override fun getRoot(): TreeNodeViewModel? = root?.viewModel + + override fun getChild(parent: Any?, index: Int): TreeNodeViewModel? = getChildren(parent).getOrNull(index)?.viewModel + + override fun getChildCount(parent: Any?): Int = getChildren(parent).size + + override fun getIndexOfChild(parent: Any?, child: Any): Int = getChildren(parent).indexOfFirst { it == child } + + override fun isLeaf(nodeViewModel: Any?): Boolean { + if (nodeViewModel == null) return true + nodeViewModel as TreeNodeViewModel + return nodes[nodeViewModel]?.isLeaf ?: return true + } + + private fun getChildren(nodeViewModel: Any?): List { + if (nodeViewModel == null) return emptyList() + nodeViewModel as TreeNodeViewModel + val node = nodes[nodeViewModel] ?: return emptyList() + node.ensureChildrenAreLoading() + return node.children ?: emptyList() + } + + override fun valueForPathChanged(path: TreePath, newValue: Any) { + throw UnsupportedOperationException("Not an editable model") + } + + override fun setCachedPresentation(presentation: CachedTreePresentation?) { + this.cachedPresentation = presentation + if (root == null && presentation != null) { + root = createCachedNode(presentation, null, presentation.getRoot()) + treeStructureChanged(root) + } + } + + private fun treeStructureChanged(node: Node?) { + listeners.treeStructureChanged(TreeModelEvent(this, node?.path, null, null)) + } + + private fun treeNodesRemoved(parent: Node, nodes: Map) { + fireEvent(parent, nodes) { + treeNodesRemoved(it) + } + } + + private fun treeNodesInserted(parent: Node, nodes: Map) { + fireEvent(parent, nodes) { + treeNodesInserted(it) + } + } + + private fun treeNodesChanged(parent: Node, nodes: Map?) { + fireEvent(parent, nodes) { + treeNodesChanged(it) + } + } + + private inline fun fireEvent(parent: Node, nodes: Map?, event: TreeModelListenerList.(TreeModelEvent) -> Unit) { + // A special case of nodes == null means that the parent itself has changed. + if (nodes?.isEmpty() == true || listeners.isEmpty) return + val indices = nodes?.values?.toIntArray() + val values = nodes?.keys?.map { it.viewModel }?.toTypedArray() + listeners.event(TreeModelEvent(this, parent.path, indices, values)) + } + + private fun addNodeLoadedListener(listener: NodeLoadedListener) { + nodeLoadedListeners += listener + } + + private fun removeNodeLoadedListener(listener: NodeLoadedListener) { + nodeLoadedListeners -= listener + } + + /** + * Marks the node as published and notifies the waiting visitors. + * + * A node is first created, then its presentation and isLeaf properties are loaded, + * then the usual tree listeners are notified, and only then the node can be visited. + * This is because some visitors interact with the tree and its UI, + * for example, to expand the node being visited, + * and this is only possible once the UI (which is a regular listener) is notified. + */ + private fun markPublished(node: Node) { + if (node.state == NodeState.PUBLISHED) return + if (node.state != NodeState.LOADED) { + LOG.warn(Throwable("Marking the node as published, but it's not loaded: $node")) + // proceed anyway, as an inconsistent state is still better than a visitor waiting forever + } + node.state = NodeState.PUBLISHED + nodeLoadedListeners.forEach { it.nodePublished(node) } + } + + override fun accept(visitor: TreeVisitor, allowLoading: Boolean): Promise { + val promise = AsyncPromise() + val job = treeScope.launch(CoroutineName("Accept $visitor")) { + try { + promise.setResult(viewModel.accept(SwingAwareVisitorDelegate(visitor, allowLoading), allowLoading)) + } + catch (e: Exception) { + if (e !is CancellationException) promise.setError(e) + } + } + job.invokeOnCompletion { + if (promise.isPending) { + SwingUtilities.invokeLater { promise.cancel() } + } + } + return promise + } + + private inner class SwingAwareVisitorDelegate(delegate: TreeVisitor, private val allowLoading: Boolean) : SuspendingTreeVisitor { + private val viewModelVisitor = TreeDomainModelDelegatingVisitor(viewModel.domainModel, delegate) + + override suspend fun visit(path: TreePath): TreeVisitor.Action { + val nodeViewModel = path.lastPathComponent as TreeNodeViewModel + // Before visiting, we must make sure that the node we're about to visit actually exists in this model. + // For the root node, it means that it was already collected and fully loaded. + // For child nodes, it means that its parent's children are loaded. + // In both cases, it's very important that it isn't just created, but actually reported to the tree and its UI! + // Otherwise, Swing can be in an inconsistent state: the node exists, but, for example, can't be expanded. + // So here it's very important to wait until it's fully loaded and reported via the listeners. + // The view model invokes this whole thing under the node view mode's scope, + // so we can be sure we won't wait forever: + // if the node still exists, it'll make its way here sooner or later, + // otherwise its scope will be canceled along with our waiting. + val node = awaitNode(nodeViewModel) + val result = viewModelVisitor.visit(path) + if (result == TreeVisitor.Action.CONTINUE && allowLoading) { + // This is needed to ensure that awaitNode() calls for children won't wait forever, + // because we don't even try to load nodes unless they're requested. + node.ensureChildrenAreLoading() + } + return result + } + + private suspend fun awaitNode(viewModel: TreeNodeViewModel): Node = suspendCancellableCoroutine { continuation -> + // This whole thing works strictly on the EDT, so no worries about concurrency issues here. + // If we check the node, and it's not loaded yet, + // it's guaranteed that it won't suddenly become loaded before we register the listener. + // The only possibly non-EDT part here is the invokeOnCancellation() call, but it's just last-chance cleanup. + val existingNode = nodes[viewModel] + if (existingNode?.state == NodeState.PUBLISHED) { + continuation.resumeWith(Result.success(existingNode)) + return@suspendCancellableCoroutine + } + val listener = object : NodeLoadedListener { + override fun nodePublished(node: Node) { + if (node.viewModel == viewModel) { + removeNodeLoadedListener(this) + continuation.resumeWith(Result.success(node)) + } + } + } + addNodeLoadedListener(listener) + continuation.invokeOnCancellation { removeNodeLoadedListener(listener) } + } + + override fun toString(): String { + return "SwingAwareVisitorDelegate(allowLoading=$allowLoading, viewModelVisitor=$viewModelVisitor)" + } + } + + private fun createCachedNode(treePresentation: CachedTreePresentation, parent: Node?, cachedObject: Any): Node { + val cachedViewModel = cachedObject as? CachedViewModel ?: CachedViewModel(treePresentation, cachedObject) + val cachedNode = CachedNode(treePresentation, parent, cachedViewModel) + nodes[cachedNode.viewModel] = cachedNode + return cachedNode + } + + private suspend fun loadNode(parent: RealNode?, viewModel: TreeNodeViewModel): Node? { + val existingNode = nodes[viewModel] + if (existingNode != null) { + LOG.warn(Throwable("The node $viewModel already exists elsewhere as $existingNode")) + return null + } + var result = RealNode(parent, viewModel) + nodes[viewModel] = result + return result.awaitLoaded() + } + + private suspend fun findOrLoadNode(parent: RealNode?, viewModel: TreeNodeViewModel): Node? { + val existingNode = nodes[viewModel] + val result = when (existingNode?.state) { + NodeState.CACHED -> { + LOG.warn(Throwable("Attempt to load a cached $viewModel: $existingNode")) + null + } + // We have to call awaitLoaded() even if it's already loaded, to ensure that the presentation is up to date. + NodeState.CREATED, NodeState.LOADED, NodeState.PUBLISHED -> existingNode.awaitLoaded() + NodeState.DISPOSED -> { + LOG.warn(Throwable("Attempt to load $viewModel when its node has already been disposed: $existingNode")) + null + } + null -> loadNode(parent, viewModel) + } + if (result != null && result.path.parentPath != parent?.path) { + LOG.warn(Throwable("Attempt to load $viewModel when it has already been registered: $existingNode")) + return null + } + return result + } + + override fun addTreeModelListener(l: TreeModelListener) { + listeners.add(l) + } + + override fun removeTreeModelListener(l: TreeModelListener) { + listeners.remove(l) + } + + override fun toString(): String { + return "SwingTreeViewModel@${System.identityHashCode(this)}(viewModel=$viewModel)" + } + + private inner class RealNode( + parent: RealNode?, + override val viewModel: TreeNodeViewModel, + ) : Node { + // Must be thread-safe because set by cancellation. + private val stateReference = AtomicReference(NodeState.CREATED) + + override var state: NodeState + get() = stateReference.get() + set(value) = stateReference.set(value) + + override val path: CachingTreePath = parent?.path?.pathByAddingChild(viewModel) as CachingTreePath? ?: CachingTreePath(viewModel) + val nodeScope: CoroutineScope = (parent?.nodeScope ?: treeScope).childScope(path.toString()) + + private var childrenLoadingJob: Job? = null + + override var children: List? = null + + override var isLeaf: Boolean = true + + init { + nodeScope.coroutineContext.job.invokeOnCompletion { + state = NodeState.DISPOSED + nodes.remove(viewModel) + } + nodeScope.launch(CoroutineName("Presentation updates of $this")) { + viewModel.presentation.collectLatest { presentation -> + // Only fire value updates after the node has been published to the UI part of the tree. + // For two reasons: to avoid unnecessary updates (optimization) and to avoid confusing the UI state. + if (state == NodeState.PUBLISHED) { + isLeaf = presentation.isLeaf + treeNodesChanged(this@RealNode, null) + } + } + } + } + + override suspend fun awaitLoaded(): Node? { + val job = nodeScope.launch { + isLeaf = viewModel.presentation.first().isLeaf + } + job.join() + // If the node was canceled, then either this job will be canceled or, + // if the cancellation happened after the job has completed, the node will be disposed. + // There's also a slight chance of it being asynchronously canceled right now, + // but then we care little about it: some code will notice it later and get rid of it. + // There shouldn't be much async stuff here anyway, as this thing is mostly-EDT, + // except maybe the case of an external async cancellation. + return if (job.isCancelled || state == NodeState.DISPOSED) { + null + } + else { + // Possible states: + // CREATED → it's the first load attempt, mark as loaded; + // LOADED or VISITABLE → already loaded, do nothing; + // DISPOSED → handled above, unless it just happened, but then we don't care. + stateReference.compareAndSet(NodeState.CREATED, NodeState.LOADED) + this + } + } + + override fun ensureChildrenAreLoading() { + if (childrenLoadingJob != null) return + val cachedChildren = getChildrenFromCachedPresentation() + if (cachedChildren != null) { + children = cachedChildren + } + else if (this@TreeSwingModelImpl.showLoadingNode) { + // Need this for clients who expect the "loading..." node to appear immediately, + // e.g. for com.intellij.ide.projectView.impl.ProjectViewDirectoryExpandDurationMeasurer. + children = listOf(RealNode(this, LoadingNode())) + } + childrenLoadingJob = nodeScope.launch(CoroutineName("Load children of $this")) { + viewModel.children.collect { loaded -> + val children = loaded.map { childViewModel -> + async(CoroutineName("Load $childViewModel")) { + findOrLoadNode(this@RealNode, childViewModel) + } + }.awaitAll() + updateChildren(children.filterNotNull().toSet().toList()) // remove duplicates + } + } + } + + private fun getChildrenFromCachedPresentation(): List? = + cachedPresentation?.let { cachedPresentation -> + cachedPresentation.getChildren(viewModel)?.map { child -> + createCachedNode(cachedPresentation, this, child) + } + } + + private fun updateChildren(children: List) { + val removedChildren = this.children?.withIndex()?.associateTo(mutableMapOf()) { it.value to it.index } ?: hashMapOf() + val insertedChildren = children.withIndex().associateTo(mutableMapOf()) { it.value to it.index } + val changedChildren = removedChildren.keys.intersect(insertedChildren.keys) + .associateWith { insertedChildren.getValue(it) } + removedChildren.keys -= changedChildren.keys + insertedChildren.keys -= changedChildren.keys + + for (removedChild in removedChildren.keys) { + removedChild.dispose() + } + + this.children = children + + cachedPresentation?.childrenLoaded(viewModel, children.map { it.viewModel }) + + treeNodesRemoved(this, removedChildren) + treeNodesInserted(this, insertedChildren) + treeNodesChanged(this, changedChildren) + + for (newChild in insertedChildren.keys) { + markPublished(newChild) + } + } + + override fun dispose() { + nodeScope.cancel() + } + + override fun toString(): String { + return "Node(" + + "viewModel=$viewModel, " + + "path=$path, " + + "isLeaf=$isLeaf, " + + "state=$state, " + + "${children?.size} children (${if (childrenLoadingJob == null) "not loading" else "loading"})" + + ")" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Node + + return viewModel == other.viewModel + } + + override fun hashCode(): Int { + return viewModel.hashCode() + } + } + + private inner class CachedNode( + treePresentation: CachedTreePresentation, + parent: Node?, + override val viewModel: CachedViewModel + ) : Node { + override val path: CachingTreePath = parent?.path?.pathByAddingChild(viewModel) as CachingTreePath? ?: CachingTreePath(viewModel) + + override val isLeaf: Boolean + get() = viewModel.cachedPresentation.isLeaf + + override val children: List? = viewModel.cachedChildren?.map { child -> createCachedNode(treePresentation, this, child) } + + override var state: NodeState = NodeState.CACHED + + override fun ensureChildrenAreLoading() { } + + override suspend fun awaitLoaded(): Node? = this + + override fun dispose() { + state = NodeState.DISPOSED + nodes.remove(viewModel) + } + } +} + +private enum class NodeState { + CACHED, + CREATED, + LOADED, + PUBLISHED, + DISPOSED +} + +private sealed interface Node { + val path: CachingTreePath + val viewModel: TreeNodeViewModel + val isLeaf: Boolean + val children: List? + var state: NodeState + fun ensureChildrenAreLoading() + suspend fun awaitLoaded(): Node? + fun dispose() +} + +private class CachedViewModel( + treePresentation: CachedTreePresentation, + private val cachedObject: Any, +) : TreeNodeViewModel { + val cachedPresentation: TreeNodePresentation = buildCachedPresentation(treePresentation, cachedObject) + val cachedChildren: List? = treePresentation.getChildren(cachedObject)?.map { + CachedViewModel(treePresentation, it) + } + + override fun getUserObject(): Any = cachedObject + + override val presentation: Flow + get() = flowOf(cachedPresentation) + + override val children: Flow> + get() = cachedChildren?.let { flowOf(it) } ?: flowOf(emptyList()) + + override fun presentationSnapshot(): TreeNodePresentation = cachedPresentation + + override fun toString(): String { + return "CachedViewModel(cachedObject=$cachedObject)" + } +} + +private fun buildCachedPresentation(treePresentation: CachedTreePresentation, cachedObject: Any): TreeNodePresentation { + val builder = TreeNodePresentationBuilderImpl(treePresentation.isLeaf(cachedObject)) + buildPresentation(builder, cachedObject) + return builder.build() +} + +private val LOG = logger() diff --git a/platform/platform-impl/src/com/intellij/ui/tree/TreeViewModelImpl.kt b/platform/platform-impl/src/com/intellij/ui/tree/TreeViewModelImpl.kt new file mode 100644 index 000000000000..ba2e43784ef4 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ui/tree/TreeViewModelImpl.kt @@ -0,0 +1,452 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.intellij.ui.tree + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.platform.util.coroutines.childScope +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.treeStructure.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import javax.swing.Icon +import javax.swing.tree.TreePath + +internal class TreeViewModelFactoryImpl : TreeViewModelFactory { + override fun createTreeViewModel(coroutineScope: CoroutineScope, domainModel: TreeDomainModel): TreeViewModel = + TreeViewModelImpl(coroutineScope, domainModel) +} + +private class TreeViewModelImpl(private val treeScope: CoroutineScope, override val domainModel: TreeDomainModel) : TreeViewModel { + private val rootUpdateRequests = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val rootViewModelFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val lastComputedRoot = AtomicReference() + + override val root: Flow + get() = rootViewModelFlow + + private val comparatorRef = AtomicReference?>() + + override var comparator: Comparator? + get() = comparatorRef.get() + set(value) { + comparatorRef.set(value) + treeScope.launch(CoroutineName("Invalidating on comparator change for $this")) { + invalidate(null, true) + } + } + + init { + treeScope.launch(CoroutineName("Root updates for $this")) { + rootUpdateRequests.collectLatest { + val rootDomainModel = domainModel.computeRoot() + if (rootDomainModel == null) { + lastComputedRoot.set(null) + rootViewModelFlow.emit(null) + } + else { + val previousModel = lastComputedRoot.get() + val newLeafState = rootDomainModel.computeLeafState() + if (previousModel == null || previousModel.domainModel != rootDomainModel || previousModel.leafStateValue != newLeafState) { + previousModel?.nodeScope?.cancel() + val newRoot = rootDomainModel.toViewModel(treeScope, newLeafState, comparatorRef) + lastComputedRoot.set(newRoot) + rootViewModelFlow.emit(newRoot) + } + else { + previousModel.updatePresentation() + previousModel.updateChildren() + rootViewModelFlow.emit(previousModel) + } + } + } + } + loadRoot() + } + + private fun loadRoot() { + rootViewModelFlow.resetReplayCache() + check(rootUpdateRequests.tryEmit(Unit)) + } + + override suspend fun invalidate(path: TreePath?, recursive: Boolean) { + val job = treeScope.launch(CoroutineName("Invalidate $path, recursive=$recursive")) { + val node = path?.node ?: lastComputedRoot.get() + node?.invalidate(recursive) + if (path == null) { + loadRoot() + } + } + job.join() + } + + override suspend fun accept(visitor: TreeVisitor, allowLoading: Boolean): TreePath? = acceptImpl(visitor, allowLoading) + + override suspend fun accept(visitor: SuspendingTreeVisitor, allowLoading: Boolean): TreePath? = acceptImpl(visitor, allowLoading) + + private suspend fun acceptImpl(visitor: T, allowLoading: Boolean): TreePath? { + val root = getFlowValue(rootViewModelFlow, allowLoading) + if (root == null) return null + return visit(null, listOf(root), visitor, allowLoading) + } + + private suspend fun visit(parentPath: TreePath?, nodes: List, visitor: T, allowLoading: Boolean): TreePath? { + for (node in nodes) { + val path = parentPath?.pathByAddingChild(node) ?: CachingTreePath(node) + if (!node.awaitPresentation()) { + continue // The node was canceled and disappeared. + } + val visit = try { + node.nodeScope.async(CoroutineName("Visiting $node for $visitor")) { + // Either a visitor is "smart" and knows how to handle contexts, + if (visitor is SuspendingTreeVisitor) { + visitor.visit(path) + } + // or we have to wrap it into the proper context, + else if (visitor is TreeVisitor) { + // which is either EDT + if (visitor.visitThread() == TreeVisitor.VisitThread.EDT) { + withContext(Dispatchers.EDT) { + visitor.visit(path) + } + } + // or whatever the model considers the proper context (usually a read action). + else { + domainModel.accessData { + visitor.visit(path) + } + } + } + else { + thisLogger().error(Throwable("Unknown visitor type: $visitor")) + TreeVisitor.Action.SKIP_SIBLINGS + } + }.await() + } + catch (_: CancellationException) { + currentCoroutineContext().ensureActive() // Throw if OUR coroutine was canceled. + TreeVisitor.Action.SKIP_CHILDREN // Skip the node with its children if ITS scope was canceled. + } + when (visit) { + TreeVisitor.Action.INTERRUPT -> return path + TreeVisitor.Action.CONTINUE -> { + if (allowLoading) { + node.ensureChildrenAreLoading() // Otherwise getFlowValue() may wait forever. + } + val children = getFlowValue(node.childrenFlow, allowLoading) ?: continue + val result = visit(path, children, visitor, allowLoading) ?: continue + return result + } + TreeVisitor.Action.SKIP_CHILDREN -> continue + TreeVisitor.Action.SKIP_SIBLINGS -> break + } + } + return null + } + + override fun toString(): String { + return "TreeViewModelImpl@${System.identityHashCode(this)}(domainModel=$domainModel)" + } +} + +internal class TreeNodePresentationBuilderImpl(val isLeaf: Boolean) : TreeNodePresentationBuilder { + // these "Value" suffixes to avoid signature clashes with the setters + private var iconValue: Icon? = null + private var mainTextValue: String? = null + private var fullTextValue: MutableList? = null + private var toolTipValue: String? = null + + override fun setIcon(icon: Icon?) { + this.iconValue = icon + } + + override fun setMainText(text: String) { + this.mainTextValue = text + } + + override fun appendTextFragment(text: String, attributes: SimpleTextAttributes) { + val coloredText = this.fullTextValue ?: mutableListOf() + coloredText.add(TreeNodeTextFragmentImpl(text, attributes)) + this.fullTextValue = coloredText + } + + override fun setToolTipText(toolTip: String?) { + this.toolTipValue = toolTip + } + + override fun build(): TreeNodePresentation { + val specifiedMainText = this.mainTextValue + val specifiedFullText = this.fullTextValue + val mainText: String + val fullText: List + if (specifiedMainText != null) { + if (specifiedFullText != null) { + mainText = specifiedMainText + fullText = specifiedFullText + } + else { + mainText = specifiedMainText + fullText = buildColoredText(mainText) + } + } + else { + if (specifiedFullText != null) { + mainText = buildMainText(specifiedFullText) + fullText = specifiedFullText + } + else { + throw IllegalStateException("Either the main text or the full text must be specified") + } + } + return TreeNodePresentationImpl( + isLeaf = isLeaf, + icon = iconValue, + mainText = mainText, + fullText = fullText, + toolTip = toolTipValue, + ) + } + + private fun buildColoredText(mainText: String): List = + listOf(TreeNodeTextFragmentImpl(mainText, SimpleTextAttributes.REGULAR_ATTRIBUTES)) + + private fun buildMainText(fullText: List): String { + val builder = StringBuilder() + for (fragment in fullText) { + val attributes = fragment.attributes + if (attributes.fgColor == SimpleTextAttributes.GRAYED_ATTRIBUTES.fgColor) break + builder.append(fragment.text) + } + return builder.toString() + } +} + +private class TreeNodeViewModelImpl( + val nodeScope: CoroutineScope, + val domainModel: TreeNodeDomainModel, + val leafStateValue: LeafState, + val comparator: AtomicReference?>, +) : TreeNodeViewModel { + private val presentationLoaded = AtomicBoolean() + private val presentationUpdateRequests = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val childrenLoaded = AtomicBoolean() + private val childrenUpdateRequests = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val presentationFlow = MutableSharedFlow(replay = 1) + private val lastComputedPresentation = AtomicReference() + val childrenFlow = MutableSharedFlow>(replay = 1) + private val lastComputedChildren = AtomicReference>() + + override fun getUserObject(): Any = domainModel.userObject + + override val presentation: Flow + get() { + ensurePresentationIsLoading() + return presentationFlow + } + + override val children: Flow> + get() { + ensureChildrenAreLoading() + return childrenFlow + } + + private fun ensurePresentationIsLoading() { + if (presentationLoaded.compareAndSet(false, true)) { + updatePresentation() + } + } + + fun ensureChildrenAreLoading() { + if (childrenLoaded.compareAndSet(false, true)) { + updateChildren() + } + } + + fun invalidate(recursive: Boolean) { + if (recursive && childrenLoaded.get()) { + lastComputedChildren.get()?.forEach { it.invalidate(true) } + childrenFlow.resetReplayCache() + updateChildren() + } + if (presentationLoaded.get()) { + presentationFlow.resetReplayCache() + updatePresentation() + } + } + + fun updatePresentation() { + check(presentationUpdateRequests.tryEmit(Unit)) + } + + fun updateChildren() { + check(childrenUpdateRequests.tryEmit(Unit)) + } + + init { + // It's a bit complicated. + // If we know whether the node is a leaf or not, we don't need to compute the children to compute the presentation. + // If we don't, we need the children, but not their presentations. + // Computing the children can be expensive, computing their presentations almost certainly is. + // Therefore, for "unsure if leaf" nodes we maintain a separate flow of "raw" children, + // which we then use to compute both the presentation and the "real" children (to avoid computing them twice). + val unpublishedChildrenFlow: MutableSharedFlow>? + + if (leafStateValue != LeafState.ALWAYS && leafStateValue != LeafState.NEVER) { + unpublishedChildrenFlow = MutableSharedFlow>(replay = 1) + nodeScope.launch(CoroutineName("Loading children of $this")) { + merge(childrenUpdateRequests, presentationUpdateRequests).collectLatest { + unpublishedChildrenFlow.emit(domainModel.computeChildren()) + } + } + } + else { + unpublishedChildrenFlow = null + } + + nodeScope.launch(CoroutineName("Value updates of $this")) { + if (unpublishedChildrenFlow != null) { + presentationUpdateRequests.combine(unpublishedChildrenFlow) { _, children -> children.isEmpty() }.collectLatest { isLeaf -> + emitPresentations(isLeaf) + } + } + else { + presentationUpdateRequests.collectLatest { + emitPresentations(isLeaf = leafStateValue == LeafState.ALWAYS) + } + } + } + + nodeScope.launch(CoroutineName("Children updates of $this")) { + if (unpublishedChildrenFlow != null) { + // This take(1) so this thing is only gets "kick-started" by the first update request, + // and then it follows unpublishedChildrenFlow, as that flow itself is updated on the same requests. + // But it can also be updated on presentation update requests, and we don't want to start loading children on those. + childrenUpdateRequests.take(1).combine(unpublishedChildrenFlow) { _, children -> children }.collectLatest { children -> + emitChildren(children) + } + } + else { + childrenUpdateRequests.collectLatest { + emitChildren( + if (leafStateValue == LeafState.ALWAYS) { + emptyList() + } + else { + domainModel.computeChildren() + } + ) + } + } + } + } + + private suspend fun emitPresentations(isLeaf: Boolean) { + val builder = TreeNodePresentationBuilderImpl(isLeaf) + val lastPresentation = lastComputedPresentation.get() + // The flow provided by the domain model may cause flickering, + // as it's supposed to start from "simple" presentations and then add "heavy" parts. + // To avoid this flickering, we only use all provided presentations on the first load, + // and then just keep the cached one until the new presentation is computed fully. + // It's better to have an outdated presentation than flickering. + if (lastPresentation == null) { + domainModel.computePresentation(builder).collect { presentation -> + lastComputedPresentation.set(presentation) + presentationFlow.emit(presentation) + } + } + else { + val presentation = domainModel.computePresentation(builder).last() + lastComputedPresentation.set(presentation) + presentationFlow.emit(presentation) + } + } + + private suspend fun emitChildren(domainChildren: List) { + val children = computeChildren(domainChildren) + lastComputedChildren.set(children) + childrenFlow.emit(children) + } + + private suspend fun computeChildren(domainChildren: List): List { + val oldChildren = lastComputedChildren.get()?.associateBy { it.domainModel } ?: emptyMap() + val childViewModels = domainChildren.map { childDomainModel -> + val newLeafState = childDomainModel.computeLeafState() + val oldChild = oldChildren[childDomainModel] + if (oldChild == null || oldChild.leafStateValue != newLeafState) { + oldChild?.nodeScope?.cancel() + childDomainModel.toViewModel(nodeScope, newLeafState, comparator) + } + else { + oldChild.apply { + updatePresentation() + } + } + } + return sort(childViewModels) + } + + private suspend fun sort(childViewModels: List): List { + val comparator = comparator.get() + if (comparator == null) return childViewModels + val sorted: MutableList = childViewModels.map { child -> + nodeScope.async(CoroutineName("Loading to sort: $child")) { + if (child.awaitPresentation()) child else null + } + }.awaitAll().filterNotNull().toMutableList() + sorted.sortWith(comparator) + return sorted + } + + override fun presentationSnapshot(): TreeNodePresentation { + val presentationSnapshot = lastComputedPresentation.get() + checkNotNull(presentationSnapshot) { "Presentation has not been computed yet" } + return presentationSnapshot + } + + override fun toString(): String { + return "TreeNodeViewModelImpl@${System.identityHashCode(this)}(" + + "domainModel=$domainModel, " + + "leafState=$leafStateValue, " + + "presentationLoaded=${presentationLoaded.get()}, " + + "childrenLoaded=${childrenLoaded.get()}" + + ")" + } + + suspend fun awaitPresentation(): Boolean { + val result = nodeScope.launch { + presentation.first() + } + result.join() + return !result.isCancelled + } +} + +private fun TreeNodeDomainModel.toViewModel( + parentScope: CoroutineScope, + leafState: LeafState, + comparator: AtomicReference?>, +): TreeNodeViewModelImpl = + TreeNodeViewModelImpl(parentScope.childScope(toString()), this, leafState, comparator) + +private suspend inline fun getFlowValue(flow: MutableSharedFlow, allowLoading: Boolean = false): T? = + if (allowLoading || flow.replayCache.isNotEmpty()) flow.first() else null + +private val TreePath.node: TreeNodeViewModelImpl + get() = lastPathComponent as TreeNodeViewModelImpl diff --git a/platform/platform-resources/src/META-INF/PlatformExtensions.xml b/platform/platform-resources/src/META-INF/PlatformExtensions.xml index bc2797523687..a39aacb78d91 100644 --- a/platform/platform-resources/src/META-INF/PlatformExtensions.xml +++ b/platform/platform-resources/src/META-INF/PlatformExtensions.xml @@ -306,6 +306,12 @@ serviceImplementation="com.intellij.ui.content.ContentFactoryImpl"/> + + +