diff --git a/java/java-tests/BUILD.bazel b/java/java-tests/BUILD.bazel index ef54ebfd7130..895adeebf0a0 100644 --- a/java/java-tests/BUILD.bazel +++ b/java/java-tests/BUILD.bazel @@ -46,6 +46,7 @@ jvm_library( "//platform/util/diff", "//platform/vcs-api:vcs", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/editor-ui-ex:editor-ex", "//java/execution/impl", "//platform/diff-api:diff", diff --git a/platform/collaboration-tools/BUILD.bazel b/platform/collaboration-tools/BUILD.bazel index 49746043b050..46b74346f924 100644 --- a/platform/collaboration-tools/BUILD.bazel +++ b/platform/collaboration-tools/BUILD.bazel @@ -112,6 +112,7 @@ jvm_library( "@lib//:app-cash-turbine-jvm", "@lib//:assert_j", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", ] ) ### auto-generated section `build intellij.platform.collaborationTools` end diff --git a/platform/dvcs-impl/BUILD.bazel b/platform/dvcs-impl/BUILD.bazel index d5ce8721fe2a..d14bcac129da 100644 --- a/platform/dvcs-impl/BUILD.bazel +++ b/platform/dvcs-impl/BUILD.bazel @@ -65,6 +65,7 @@ jvm_library( "//platform/vcs-impl:vcs-impl_test_lib", "//platform/project/shared:project", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/kernel/shared:kernel", "//platform/core-impl", "//platform/platform-impl:ide-impl", diff --git a/platform/lvcs-impl/BUILD.bazel b/platform/lvcs-impl/BUILD.bazel index d00fc1780be5..bc9fc16d7c0b 100644 --- a/platform/lvcs-impl/BUILD.bazel +++ b/platform/lvcs-impl/BUILD.bazel @@ -82,6 +82,7 @@ jvm_library( "//tools/intellij.tools.ide.metrics.benchmark:ide-metrics-benchmark", "//tools/intellij.tools.ide.metrics.benchmark:ide-metrics-benchmark_test_lib", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "@lib//:jetbrains-annotations", "//platform/projectModel-impl", ] diff --git a/platform/platform-tests/BUILD.bazel b/platform/platform-tests/BUILD.bazel index da9c27948656..42e8d6b8ccc5 100644 --- a/platform/platform-tests/BUILD.bazel +++ b/platform/platform-tests/BUILD.bazel @@ -176,6 +176,7 @@ jvm_library( "//platform/locking.impl", "//platform/testFramework/junit5/highlighting", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", ], runtime_deps = [ "//xml/dom-impl", diff --git a/platform/vcs-api/shared/src/com/intellij/platform/vcs/changes/ChangesUtil.kt b/platform/vcs-api/shared/src/com/intellij/platform/vcs/changes/ChangesUtil.kt index cc3e8a3a4052..1a08f8e9f7d7 100644 --- a/platform/vcs-api/shared/src/com/intellij/platform/vcs/changes/ChangesUtil.kt +++ b/platform/vcs-api/shared/src/com/intellij/platform/vcs/changes/ChangesUtil.kt @@ -82,4 +82,7 @@ object ChangesUtil { status === FileStatus.MERGED_WITH_BOTH_CONFLICTS || status === FileStatus.MERGED_WITH_PROPERTY_CONFLICTS } + + @JvmStatic + fun matches(change: Change, path: FilePath): Boolean = path == getAfterPath(change) || path == getBeforePath(change) } \ No newline at end of file diff --git a/platform/vcs-impl/BUILD.bazel b/platform/vcs-impl/BUILD.bazel index 21d311a08365..fe520fd497ea 100644 --- a/platform/vcs-impl/BUILD.bazel +++ b/platform/vcs-impl/BUILD.bazel @@ -155,6 +155,7 @@ jvm_library( "//libraries/microba", "//platform/kernel/shared:kernel", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "@lib//:jgoodies-forms", "//platform/observable", "//platform/platform-impl/concurrency", diff --git a/platform/vcs-impl/frontend/BUILD.bazel b/platform/vcs-impl/frontend/BUILD.bazel index 1e2c4de11b93..2f389aa0aa49 100644 --- a/platform/vcs-impl/frontend/BUILD.bazel +++ b/platform/vcs-impl/frontend/BUILD.bazel @@ -38,4 +38,48 @@ jvm_library( "//platform/vcs-api/vcs-api-core:vcs-core", ] ) -### auto-generated section `build intellij.platform.vcs.impl.frontend` end \ No newline at end of file + +jvm_library( + name = "frontend_test_lib", + visibility = ["//visibility:public"], + srcs = glob(["testSrc/**/*.kt", "testSrc/**/*.java", "testSrc/**/*.form"], allow_empty = True), + associates = [":frontend"], + deps = [ + "@lib//:kotlinx-coroutines-core", + "@lib//:kotlin-stdlib", + "//platform/core-api:core", + "//platform/editor-ui-api:editor-ui", + "//platform/projectModel-api:projectModel", + "//platform/util:util-ui", + "//platform/diagnostic", + "//platform/core-ui", + "//platform/platform-impl:ide-impl", + "//platform/diff-impl", + "//platform/util/jdom", + "//platform/kernel/shared:kernel", + "//platform/lang-core", + "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", + "//platform/project/shared:project", + "//platform/util/coroutines", + "//libraries/kotlinx/serialization/core", + "//libraries/kotlinx/serialization/json", + "//platform/recentFiles/frontend", + "//platform/analysis-api:analysis", + "//platform/vcs-api/shared", + "//platform/vcs-api/vcs-api-core:vcs-core", + "//platform/vcs-api/vcs-api-core:vcs-core_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + ] +) +### auto-generated section `build intellij.platform.vcs.impl.frontend` end + +### auto-generated section `test intellij.platform.vcs.impl.frontend` start +load("@community//build:tests-options.bzl", "jps_test") + +jps_test( + name = "frontend_test", + runtime_deps = [":frontend_test_lib"] +) +### auto-generated section `test intellij.platform.vcs.impl.frontend` end \ No newline at end of file diff --git a/platform/vcs-impl/frontend/intellij.platform.vcs.impl.frontend.iml b/platform/vcs-impl/frontend/intellij.platform.vcs.impl.frontend.iml index 1deb1e492d98..4fc61c858384 100644 --- a/platform/vcs-impl/frontend/intellij.platform.vcs.impl.frontend.iml +++ b/platform/vcs-impl/frontend/intellij.platform.vcs.impl.frontend.iml @@ -30,6 +30,7 @@ + @@ -55,5 +56,6 @@ + \ No newline at end of file diff --git a/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/ChangesViewDiffableSelectionHelper.kt b/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/ChangesViewDiffableSelectionHelper.kt new file mode 100644 index 000000000000..a8cf16b35ac4 --- /dev/null +++ b/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/ChangesViewDiffableSelectionHelper.kt @@ -0,0 +1,110 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.vcs.impl.frontend.changes + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode +import com.intellij.openapi.vcs.changes.ui.ChangesListView +import com.intellij.openapi.vcs.changes.ui.VcsTreeModelData +import com.intellij.platform.vcs.changes.ChangesUtil +import com.intellij.platform.vcs.impl.shared.changes.ChangesTreePath +import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewDiffableSelection +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +private val LOG = logger() + +// TODO support hijacked changes +internal class ChangesViewDiffableSelectionHelper(private val changesView: ChangesListView) { + private val _diffableSelection = MutableStateFlow(null) + val diffableSelection = _diffableSelection.asStateFlow() + + @RequiresEdt + fun tryUpdateSelection() { + _diffableSelection.update { currentValue -> + if (shouldUpdateSelection(currentValue)) getDiffableSelection() else currentValue + } + } + + @RequiresEdt + private fun shouldUpdateSelection(previousSelection: ChangesViewDiffableSelection?): Boolean { + if (previousSelection == null) return true + + val currentValueIsSelected = changesView.selectedChangesNodes.any { node -> + when (val obj = node.userObject) { + is Change -> ChangesUtil.matches(obj, previousSelection.selectedChange.filePath.filePath) + is FilePath -> obj == previousSelection.selectedChange.filePath.filePath + else -> false + } + } + + return !currentValueIsSelected + } + + @RequiresEdt + private fun getDiffableSelection(): ChangesViewDiffableSelection? { + val selectedDiffableNode = changesView.selectedChanges.firstOrNull() + ?: changesView.selectedUnversionedFiles.firstOrNull() + ?: return null + val selectedNodePath = + getPathOrLog(selectedDiffableNode) { LOG.warn("Could not create path for selected node: $it") } ?: return null + + + val (prevNode, nextNode) = findPreviousAndNextDiffableNodes(selectedDiffableNode) + + return ChangesViewDiffableSelection( + selectedChange = selectedNodePath, + previousChange = getPathOrLog(prevNode) { LOG.warn("Could not create path for previous node: $it") }, + nextChange = getPathOrLog(nextNode) { LOG.warn("Could not create path for next node: $it") }) + } + + private fun getPathOrLog(prevNode: Any?, log: (Any) -> Unit): ChangesTreePath? = prevNode?.let { node -> + ChangesTreePath.create(node).also { path -> if (path == null) log(node) } + } + + @RequiresEdt + private fun findPreviousAndNextDiffableNodes(selectedDiffableNode: Any): Pair { + var previousNode: Any? = null + var nextNode: Any? = null + + var selectedNodeWasFound = false + for (node in VcsTreeModelData.all(changesView).iterateNodes()) { + if (nextNode != null) break + + val userObject = node.userObject + if (userObject === selectedDiffableNode) { + selectedNodeWasFound = true + } + else { + if (isDiffableNode(node)) { + if (selectedNodeWasFound) { + nextNode = userObject + } + else { + previousNode = userObject + } + } + } + } + + return previousNode to nextNode + } + + private fun isUnderUnversioned(node: ChangesBrowserNode<*>): Boolean { + var current: ChangesBrowserNode<*>? = node.parent + while (current != null) { + if (current.userObject == ChangesBrowserNode.UNVERSIONED_FILES_TAG) return true + current = current.parent + } + return false + } + + private fun isDiffableNode(node: ChangesBrowserNode<*>): Boolean = when (node.userObject) { + is Change -> true + is FilePath -> isUnderUnversioned(node) + else -> false + } +} \ No newline at end of file diff --git a/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/FrontendCommitChangesViewWithToolbarPanel.kt b/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/FrontendCommitChangesViewWithToolbarPanel.kt index 3ee580dae2f6..a89840822f4e 100644 --- a/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/FrontendCommitChangesViewWithToolbarPanel.kt +++ b/platform/vcs-impl/frontend/src/com/intellij/platform/vcs/impl/frontend/changes/FrontendCommitChangesViewWithToolbarPanel.kt @@ -13,6 +13,7 @@ import com.intellij.platform.vcs.impl.shared.changes.ChangesViewSettings import com.intellij.platform.vcs.impl.shared.rpc.BackendChangesViewEvent import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewApi import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewDiffApi +import com.intellij.util.asDisposable import fleet.rpc.client.durable import fleet.util.logging.logger import kotlinx.coroutines.CoroutineScope @@ -21,11 +22,15 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +private val LOG = logger() + internal class FrontendCommitChangesViewWithToolbarPanel( changesView: ChangesListView, cs: CoroutineScope, val inclusionModel: ChangesViewDelegatingInclusionModel, ) : CommitChangesViewWithToolbarPanel(changesView, cs) { + private val diffableSelectionHelper = ChangesViewDiffableSelectionHelper(changesView) + init { changesView.setInclusionModel(inclusionModel) ChangeListsViewModel.getInstance(project).changeListsState.onEach { scheduleRefresh() }.launchIn(cs) @@ -35,6 +40,12 @@ internal class FrontendCommitChangesViewWithToolbarPanel( cs.launch { forwardDiffActionsToBackend() } + cs.launch { + diffableSelectionHelper.diffableSelection.collectLatest { + ChangesViewDiffApi.getInstance().notifySelectionUpdated(project.projectId(), it) + } + } + changesView.addSelectionListener({ diffableSelectionHelper.tryUpdateSelection() }, cs.asDisposable()) } override fun getModelData(): ModelData { @@ -85,8 +96,6 @@ internal class FrontendCommitChangesViewWithToolbarPanel( } companion object { - private val LOG = logger() - fun create(project: Project, cs: CoroutineScope): FrontendCommitChangesViewWithToolbarPanel { val tree = LocalChangesListView(project) val inclusionModel = ChangesViewDelegatingInclusionModel(project, cs) @@ -94,4 +103,5 @@ internal class FrontendCommitChangesViewWithToolbarPanel( return FrontendCommitChangesViewWithToolbarPanel(tree, cs, inclusionModel) } } -} \ No newline at end of file +} + diff --git a/platform/vcs-impl/frontend/testSrc/com/intellij/platform/vcs/impl/frontend/changes/ChangesViewDiffableSelectionHelperTest.kt b/platform/vcs-impl/frontend/testSrc/com/intellij/platform/vcs/impl/frontend/changes/ChangesViewDiffableSelectionHelperTest.kt new file mode 100644 index 000000000000..5056bc7c0d95 --- /dev/null +++ b/platform/vcs-impl/frontend/testSrc/com/intellij/platform/vcs/impl/frontend/changes/ChangesViewDiffableSelectionHelperTest.kt @@ -0,0 +1,244 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.vcs.impl.frontend.changes + +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vcs.changes.LocalChangeListImpl +import com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode +import com.intellij.openapi.vcs.changes.ui.VcsTreeModelData +import com.intellij.platform.vcs.impl.changes.ChangesViewTestBase +import com.intellij.platform.vcs.impl.shared.changes.ChangesTreePath +import com.intellij.platform.vcs.impl.shared.rpc.ContentRevisionDto +import com.intellij.platform.vcs.impl.shared.rpc.FilePathDto +import com.intellij.testFramework.runInEdtAndWait +import com.intellij.util.ui.tree.TreeUtil + +internal class ChangesViewDiffableSelectionHelperTest : ChangesViewTestBase() { + fun `test current change then next unversioned and no previous`() { + val changePath = path("a.txt") + val unversioned = path("u1.txt") + + val defaultList = defaultChangeList(changePath) + + val model = buildModel(view, listOf(defaultList), listOf(unversioned)) + updateModelAndSelect(view, model, changePath) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, changePath, expectChangeId = true) + assertNull(selection.previousChange) + assertTreePath(selection.nextChange!!, unversioned, expectChangeId = false) + } + + fun `test current change with previous and next changes`() { + val p1 = path("c1.txt") + val p2 = path("c2.txt") + val p3 = path("c3.txt") + + val defaultList = LocalChangeListImpl.Builder(project, "Default").setDefault(true) + .setChanges(listOf(change(p1), change(p2), change(p3))) + .build() + + val model = buildModel(view, listOf(defaultList), emptyList()) + updateModelAndSelect(view, model, p2) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, p2, expectChangeId = true) + assertTreePath(selection.previousChange!!, p1, expectChangeId = true) + assertTreePath(selection.nextChange!!, p3, expectChangeId = true) + } + + fun `test current unversioned then next unversioned and previous change`() { + val c1 = path("c1.txt") + val u1 = path("u1.txt") + val u2 = path("u2.txt") + + val defaultList = defaultChangeList(c1) + + val model = buildModel(view, listOf(defaultList), listOf(u1, u2)) + updateModelAndSelect(view, model, u1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, u1, expectChangeId = false) + assertTreePath(selection.previousChange!!, c1, expectChangeId = true) + assertTreePath(selection.nextChange!!, u2, expectChangeId = false) + } + + fun `test only unversioned files present`() { + val u1 = path("u1.txt") + val u2 = path("u2.txt") + val u3 = path("u3.txt") + + val model = buildModel(view, emptyList(), listOf(u1, u2, u3)) + updateModelAndSelect(view, model, u1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, u1, expectChangeId = false) + assertNull(selection.previousChange) + assertTreePath(selection.nextChange!!, u2, expectChangeId = false) + } + + fun `test only current change`() { + val c1 = path("c1.txt") + val defaultList = defaultChangeList(c1) + + val model = buildModel(view, listOf(defaultList), emptyList()) + updateModelAndSelect(view, model, c1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, c1, expectChangeId = true) + assertNull(selection.previousChange) + assertNull(selection.nextChange) + } + + fun `test only current unversioned`() { + val u1 = path("u1.txt") + val model = buildModel(view, emptyList(), listOf(u1)) + updateModelAndSelect(view, model, u1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, u1, expectChangeId = false) + assertNull(selection.previousChange) + assertNull(selection.nextChange) + } + + fun `test change selected then selection removed`() { + val c1 = path("c1.txt") + val c2 = path("c2.txt") + + val defaultList = LocalChangeListImpl.Builder(project, "Default").setDefault(true) + .setChanges(listOf(change(c1), change(c2))) + .build() + + val model = buildModel(view, listOf(defaultList), emptyList()) + updateModelAndSelect(view, model, c1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + assertNotNull(helper.diffableSelection.value) + + runInEdtAndWait { + view.clearSelection() + helper.tryUpdateSelection() + } + assertNull(helper.diffableSelection.value) + } + + + fun `test change selected then select changelist node doesn't cause update`() { + val c1 = path("c1.txt") + val c2 = path("c2.txt") + + val defaultList = LocalChangeListImpl.Builder(project, "Default").setDefault(true) + .setChanges(listOf(change(c1), change(c2))) + .build() + + val model = buildModel(view, listOf(defaultList), emptyList()) + updateModelAndSelect(view, model, c1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val previous = helper.diffableSelection.value + + // Switch selection to the changelist node + runInEdtAndWait { + val pathToList = view.findNodePathInTree(defaultList) + assertNotNull(pathToList) + view.clearSelection() + view.addSelectionPath(pathToList) + assertEquals(1, view.selectionCount) + helper.tryUpdateSelection() + } + + val updated = helper.diffableSelection.value + assertSame(previous, updated) + } + + fun `test unversioned selected then select unversioned root doesn't cause update`() { + val u1 = path("u1.txt") + val u2 = path("u2.txt") + + val model = buildModel(view, emptyList(), listOf(u1, u2)) + updateModelAndSelect(view, model, u1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val previous = helper.diffableSelection.value + + // Switch selection to Unversioned root node + runInEdtAndWait { + val unvNode = VcsTreeModelData.findTagNode(view, ChangesBrowserNode.UNVERSIONED_FILES_TAG) + checkNotNull(unvNode) + val pathToUnv = TreeUtil.getPathFromRoot(unvNode) + view.clearSelection() + view.addSelectionPath(pathToUnv) + assertEquals(1, view.selectionCount) + helper.tryUpdateSelection() + } + + val updated = helper.diffableSelection.value + assertSame(previous, updated) + } + + fun `test switch from change to unversioned`() { + val c1 = path("c1.txt") + val u1 = path("u1.txt") + + val defaultList = defaultChangeList(c1) + val model = buildModel(view, listOf(defaultList), listOf(u1)) + updateModelAndSelect(view, model, c1) + + val helper = ChangesViewDiffableSelectionHelper(view) + runInEdtAndWait { helper.tryUpdateSelection() } + val previous = helper.diffableSelection.value + checkNotNull(previous) + assertTreePath(previous.selectedChange, c1, true) + + runInEdtAndWait { + val pathToU1 = view.findNodePathInTree(u1) + assertNotNull(pathToU1) + view.clearSelection() + view.addSelectionPath(pathToU1) + assertEquals(1, view.selectionCount) + helper.tryUpdateSelection() + } + + val selection = helper.diffableSelection.value + checkNotNull(selection) + assertTreePath(selection.selectedChange, u1, false) + assertTreePath(selection.previousChange!!, c1, expectChangeId = true) + assertNull(selection.nextChange) + } + + private fun defaultChangeList(changePath: FilePath): LocalChangeListImpl = LocalChangeListImpl.Builder(project, "Default") + .setDefault(true) + .setChanges(listOf(change(changePath))) + .build() + + private fun change(path: FilePath): Change { + val contentRevisionDto = ContentRevisionDto("0", FilePathDto.toDto(path)) + return Change(null, contentRevisionDto.contentRevision) + } + + private fun assertTreePath(path: ChangesTreePath, expected: FilePath, expectChangeId: Boolean) { + assertEquals(expected, path.filePath.filePath) + if (expectChangeId) assertNotNull(path.changeId) else assertNull(path.changeId) + } +} diff --git a/platform/vcs-impl/shared/BUILD.bazel b/platform/vcs-impl/shared/BUILD.bazel index 514469592106..6916658e0b08 100644 --- a/platform/vcs-impl/shared/BUILD.bazel +++ b/platform/vcs-impl/shared/BUILD.bazel @@ -41,4 +41,52 @@ jvm_library( "//platform/diagnostic/telemetry", ] ) -### auto-generated section `build intellij.platform.vcs.impl.shared` end \ No newline at end of file + +jvm_library( + name = "shared_test_lib", + visibility = ["//visibility:public"], + srcs = glob(["testSrc/**/*.kt", "testSrc/**/*.java", "testSrc/**/*.form"], allow_empty = True), + associates = [":shared"], + deps = [ + "@lib//:kotlin-stdlib", + "//platform/core-api:core", + "//platform/kernel/shared:kernel", + "//platform/project/shared:project", + "//platform/platform-api:ide", + "//platform/diff-impl", + "//platform/projectModel-api:projectModel", + "//platform/util:util-ui", + "//libraries/kotlinx/serialization/core", + "//libraries/kotlinx/serialization/json", + "//platform/platform-impl/rpc", + "//platform/diagnostic", + "@lib//:opentelemetry", + "//platform/platform-impl:ide-impl", + "//platform/core-ui", + "//platform/util/coroutines", + "//platform/vcs-api/vcs-api-core:vcs-core", + "//platform/vcs-api/vcs-api-core:vcs-core_test_lib", + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//platform/util/concurrency", + "//platform/util/concurrency:concurrency_test_lib", + "//platform/vcs-api/shared", + "//libraries/fastutil", + "//platform/util/jdom", + "//platform/ide-core-impl", + "//platform/diagnostic/telemetry", + "//platform/diagnostic/telemetry:telemetry_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + ] +) +### auto-generated section `build intellij.platform.vcs.impl.shared` end + +### auto-generated section `test intellij.platform.vcs.impl.shared` start +load("@community//build:tests-options.bzl", "jps_test") + +jps_test( + name = "shared_test", + runtime_deps = [":shared_test_lib"] +) +### auto-generated section `test intellij.platform.vcs.impl.shared` end \ No newline at end of file diff --git a/platform/vcs-impl/shared/intellij.platform.vcs.impl.shared.iml b/platform/vcs-impl/shared/intellij.platform.vcs.impl.shared.iml index 30b30303b00b..040484d628f2 100644 --- a/platform/vcs-impl/shared/intellij.platform.vcs.impl.shared.iml +++ b/platform/vcs-impl/shared/intellij.platform.vcs.impl.shared.iml @@ -30,6 +30,7 @@ + @@ -58,5 +59,6 @@ + \ No newline at end of file diff --git a/platform/vcs-impl/shared/src/com/intellij/openapi/vcs/changes/ui/ChangesTree.java b/platform/vcs-impl/shared/src/com/intellij/openapi/vcs/changes/ui/ChangesTree.java index 663c844470a5..bb11230db38d 100644 --- a/platform/vcs-impl/shared/src/com/intellij/openapi/vcs/changes/ui/ChangesTree.java +++ b/platform/vcs-impl/shared/src/com/intellij/openapi/vcs/changes/ui/ChangesTree.java @@ -522,16 +522,13 @@ public abstract class ChangesTree extends Tree implements UiCompatibleDataProvid if (node instanceof DefaultMutableTreeNode mutableTreeNode) { Object userObject = mutableTreeNode.getUserObject(); return (userObject instanceof Change change) ? - matches(change, toSelect) : + ChangesUtil.matches(change, toSelect) : toSelect.equals(VcsTreeModelData.mapUserObjectToFilePath(userObject)); } return false; }); } - private static boolean matches(@NotNull Change change, @NotNull FilePath toSelect) { - return toSelect.equals(ChangesUtil.getAfterPath(change)) || toSelect.equals(ChangesUtil.getBeforePath(change)); - } public @NotNull ChangesBrowserNode getRoot() { return (ChangesBrowserNode)getModel().getRoot(); diff --git a/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/changes/ChangesTreePath.kt b/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/changes/ChangesTreePath.kt index a656ab142986..cff4e42aca80 100644 --- a/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/changes/ChangesTreePath.kt +++ b/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/changes/ChangesTreePath.kt @@ -14,7 +14,7 @@ data class ChangesTreePath( val filePath: FilePathDto, val changeId: ChangeId?, ) { - internal companion object { + companion object { fun create(userObject: Any): ChangesTreePath? { val filePath = VcsTreeModelData.mapUserObjectToFilePath(userObject) ?: return null return ChangesTreePath( diff --git a/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffApi.kt b/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffApi.kt index 42c79ac330ee..0310f715d46e 100644 --- a/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffApi.kt +++ b/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffApi.kt @@ -14,6 +14,8 @@ import org.jetbrains.annotations.ApiStatus interface ChangesViewDiffApi : RemoteApi { suspend fun performDiffAction(projectId: ProjectId, action: ChangesViewDiffAction) + suspend fun notifySelectionUpdated(projectId: ProjectId, selection: ChangesViewDiffableSelection?) + companion object { suspend fun getInstance(): ChangesViewDiffApi = RemoteApiProviderService.resolve(remoteApiDescriptor()) } diff --git a/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffableSelection.kt b/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffableSelection.kt new file mode 100644 index 000000000000..afd479b98535 --- /dev/null +++ b/platform/vcs-impl/shared/src/com/intellij/platform/vcs/impl/shared/rpc/ChangesViewDiffableSelection.kt @@ -0,0 +1,19 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.vcs.impl.shared.rpc + +import com.intellij.platform.vcs.impl.shared.changes.ChangesTreePath +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +/** + * Represents the currently selected change in the Changes View + * and the changes below and above it, which can be navigated using + * "Next/Previous Difference" or "Compare Previous/Next File" action + */ +@ApiStatus.Internal +@Serializable +data class ChangesViewDiffableSelection( + val selectedChange: ChangesTreePath, + val previousChange: ChangesTreePath?, + val nextChange: ChangesTreePath?, +) diff --git a/platform/vcs-impl/shared/testSrc/com/intellij/platform/vcs/impl/changes/ChangesViewTestBase.kt b/platform/vcs-impl/shared/testSrc/com/intellij/platform/vcs/impl/changes/ChangesViewTestBase.kt new file mode 100644 index 000000000000..d7d53f4bf5fd --- /dev/null +++ b/platform/vcs-impl/shared/testSrc/com/intellij/platform/vcs/impl/changes/ChangesViewTestBase.kt @@ -0,0 +1,42 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.vcs.impl.changes + +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.actions.VcsContextFactory +import com.intellij.openapi.vcs.changes.LocalChangeListImpl +import com.intellij.openapi.vcs.changes.LocalChangesListView +import com.intellij.openapi.vcs.changes.ui.ChangesTree +import com.intellij.openapi.vcs.changes.ui.TreeModelBuilder +import com.intellij.testFramework.LightPlatformTestCase +import com.intellij.testFramework.runInEdtAndWait +import javax.swing.tree.DefaultTreeModel + +abstract class ChangesViewTestBase : LightPlatformTestCase() { + protected lateinit var view: LocalChangesListView + + override fun setUp() { + super.setUp() + view = LocalChangesListView(project) + } + + protected fun updateModelAndSelect(view: LocalChangesListView, model: DefaultTreeModel, toSelect: FilePath) { + runInEdtAndWait { + view.updateTreeModel(model, ChangesTree.ALWAYS_RESET) + assertEquals(0, view.selectionCount) + view.selectFile(toSelect) + assertEquals(1, view.selectionCount) + } + } + + protected fun path(fileName: String): FilePath = + VcsContextFactory.getInstance().createFilePath("/ChangesViewTest/$fileName", false) + + protected fun buildModel( + view: LocalChangesListView, + lists: List, + unversioned: List, + ): DefaultTreeModel = TreeModelBuilder(project, view.grouping) + .setChangeLists(lists, false, null) + .setUnversioned(unversioned) + .build() +} \ No newline at end of file diff --git a/platform/vcs-impl/src/com/intellij/vcs/changes/ChangesViewDiffApiImpl.kt b/platform/vcs-impl/src/com/intellij/vcs/changes/ChangesViewDiffApiImpl.kt index 67958a388511..1aec5a95cf10 100644 --- a/platform/vcs-impl/src/com/intellij/vcs/changes/ChangesViewDiffApiImpl.kt +++ b/platform/vcs-impl/src/com/intellij/vcs/changes/ChangesViewDiffApiImpl.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.diagnostic.trace import com.intellij.openapi.vcs.changes.ChangesViewDiffAction import com.intellij.platform.project.ProjectId import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewDiffApi +import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewDiffableSelection import com.intellij.vcs.changes.viewModel.getRpcChangesView import com.intellij.vcs.rpc.ProjectScopeRpcHelper.projectScoped @@ -16,6 +17,11 @@ internal class ChangesViewDiffApiImpl : ChangesViewDiffApi { project.getRpcChangesView().diffRequests.emit(action to ClientId.current) } + override suspend fun notifySelectionUpdated(projectId: ProjectId, selection: ChangesViewDiffableSelection?) = projectScoped(projectId) { project -> + LOG.trace { "Selection updated for diff: $selection" } + project.getRpcChangesView().selectionUpdated(selection) + } + companion object { private val LOG = logger() } diff --git a/platform/vcs-impl/src/com/intellij/vcs/changes/viewModel/RpcChangesViewProxy.kt b/platform/vcs-impl/src/com/intellij/vcs/changes/viewModel/RpcChangesViewProxy.kt index 9b9b92088b0a..1eb92b1c6986 100644 --- a/platform/vcs-impl/src/com/intellij/vcs/changes/viewModel/RpcChangesViewProxy.kt +++ b/platform/vcs-impl/src/com/intellij/vcs/changes/viewModel/RpcChangesViewProxy.kt @@ -12,6 +12,7 @@ import com.intellij.platform.kernel.ids.BackendValueIdType import com.intellij.platform.kernel.ids.storeValueGlobally import com.intellij.platform.vcs.impl.shared.rpc.BackendChangesViewEvent import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewApi +import com.intellij.platform.vcs.impl.shared.rpc.ChangesViewDiffableSelection import com.intellij.ui.split.createComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow @@ -42,6 +43,8 @@ internal class RpcChangesViewProxy(project: Project, scope: CoroutineScope) : Ch val inclusionModel = MutableStateFlow(null) + val diffableSelection = MutableStateFlow(null) + private var _panel: JComponent? = null override val panel: JComponent get() = _panel ?: error("Panel is not initialized yet") @@ -108,6 +111,10 @@ internal class RpcChangesViewProxy(project: Project, scope: CoroutineScope) : Ch } fun refreshPerformed(counter: Int) = refresher.refreshPerformed(counter) + + fun selectionUpdated(selection: ChangesViewDiffableSelection?) { + diffableSelection.value = selection + } } private val REFRESH_TIMEOUT = 1.minutes @@ -117,6 +124,7 @@ private class BackendRemoteCommitChangesViewModelRefresher( private val requestsSink: MutableSharedFlow, ) { private val refreshRequestCounter = AtomicInteger(0) + /** * Once backend was notified about refresh applied with the given counter, * all the pending callbacks having counter less than or equal to it will be executed. diff --git a/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/LocalChangesListViewSelectFileTest.kt b/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/LocalChangesListViewSelectFileTest.kt index 5475e7a4eb80..158fae33557e 100644 --- a/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/LocalChangesListViewSelectFileTest.kt +++ b/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/LocalChangesListViewSelectFileTest.kt @@ -1,23 +1,19 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.openapi.vcs.changes.ui -import com.intellij.openapi.vcs.FilePath -import com.intellij.openapi.vcs.actions.VcsContextFactory -import com.intellij.openapi.vcs.changes.* +import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vcs.changes.CurrentContentRevision +import com.intellij.openapi.vcs.changes.LocalChangeListImpl import com.intellij.platform.vcs.changes.ChangesUtil -import com.intellij.testFramework.LightPlatformTestCase -import com.intellij.testFramework.runInEdtAndWait +import com.intellij.platform.vcs.impl.changes.ChangesViewTestBase import javax.swing.tree.DefaultMutableTreeNode -import javax.swing.tree.DefaultTreeModel -internal class LocalChangesListViewSelectFileTest : LightPlatformTestCase() { +internal class LocalChangesListViewSelectFileTest : ChangesViewTestBase() { fun `test selects unversioned file path`() { - val view = LocalChangesListView(project) - - val unversionedPath = fakePath("unversioned.txt") + val unversionedPath = path("unversioned.txt") val defaultList = LocalChangeListImpl.Builder(project, "Default") .setDefault(true) - .setChanges(listOf(Change(null, CurrentContentRevision.create(fakePath("default-change.txt"))))) + .setChanges(listOf(Change(null, CurrentContentRevision.create(path("default-change.txt"))))) .build() val model = buildModel(view, listOf(defaultList), listOf(unversionedPath)) @@ -27,11 +23,9 @@ internal class LocalChangesListViewSelectFileTest : LightPlatformTestCase() { } fun `test selects change by file path`() { - val view = LocalChangesListView(project) - - val targetPath = fakePath("added.txt") + val targetPath = path("added.txt") val targetChange = Change(null, CurrentContentRevision.create(targetPath)) - val anotherChange = Change(null, CurrentContentRevision.create(fakePath("another.txt"))) + val anotherChange = Change(null, CurrentContentRevision.create(path("another.txt"))) val defaultList = LocalChangeListImpl.Builder(project, "Default") .setDefault(true) .setChanges(listOf(targetChange, anotherChange)) @@ -48,15 +42,13 @@ internal class LocalChangesListViewSelectFileTest : LightPlatformTestCase() { } fun `test selects change in multiple change lists`() { - val view = LocalChangesListView(project) - - val targetPath = fakePath("default.txt") + val targetPath = path("default.txt") val change = Change(null, CurrentContentRevision.create(targetPath)) val workList = LocalChangeListImpl.Builder(project, "Work").setDefault(false) .setChanges( listOf( change, - Change(null, CurrentContentRevision.create(fakePath("work-other.txt"))), + Change(null, CurrentContentRevision.create(path("work-other.txt"))), ) ) .build() @@ -74,25 +66,4 @@ internal class LocalChangesListViewSelectFileTest : LightPlatformTestCase() { val changelistNode = view.selectionPath!!.path[1] as DefaultMutableTreeNode assertEquals("Selected change must be under the Default changelist node", defaultList, changelistNode.userObject) } - - private fun buildModel( - view: LocalChangesListView, - changeLists: List, - unversionedFiles: List, - ): DefaultTreeModel = TreeModelBuilder(project, view.grouping) - .setChangeLists(changeLists, false, null) - .setUnversioned(unversionedFiles) - .build() - - private fun updateModelAndSelect(view: LocalChangesListView, model: DefaultTreeModel, pathToSelect: FilePath) { - runInEdtAndWait { - view.updateTreeModel(model, ChangesTree.ALWAYS_RESET) - assertEquals(0, view.selectionCount) - view.selectFile(pathToSelect) - assertEquals(1, view.selectionCount) - } - } - - private fun fakePath(fileName: String): FilePath = - VcsContextFactory.getInstance().createFilePath("/LocalChangesListViewSelectFileTest/$fileName", false) } diff --git a/platform/vcs-log/impl/BUILD.bazel b/platform/vcs-log/impl/BUILD.bazel index 713d1ce43749..288e1fb5e52d 100644 --- a/platform/vcs-log/impl/BUILD.bazel +++ b/platform/vcs-log/impl/BUILD.bazel @@ -119,6 +119,7 @@ jvm_library( "//platform/util/coroutines", "//platform/util/storages", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//libraries/mockito", "//platform/bookmarks", ] diff --git a/platform/vcs-tests/BUILD.bazel b/platform/vcs-tests/BUILD.bazel index 6891fe7c9c3d..6c3ba620a993 100644 --- a/platform/vcs-tests/BUILD.bazel +++ b/platform/vcs-tests/BUILD.bazel @@ -76,6 +76,7 @@ jvm_library( "//tools/intellij.tools.ide.metrics.benchmark:ide-metrics-benchmark", "//tools/intellij.tools.ide.metrics.benchmark:ide-metrics-benchmark_test_lib", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/core-impl", ], exports = ["//platform/vcs-tests:vcs-testFramework"] diff --git a/plugins/git-features-trainer/BUILD.bazel b/plugins/git-features-trainer/BUILD.bazel index da608cd79388..bce879f1260f 100644 --- a/plugins/git-features-trainer/BUILD.bazel +++ b/plugins/git-features-trainer/BUILD.bazel @@ -74,6 +74,7 @@ jvm_library( "//platform/testFramework:testFramework_test_lib", "//platform/dvcs-impl/shared", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", ] ) ### auto-generated section `build intellij.vcs.git.featuresTrainer` end diff --git a/plugins/git4idea/BUILD.bazel b/plugins/git4idea/BUILD.bazel index dc1a7ab7b525..cbc761cda880 100644 --- a/plugins/git4idea/BUILD.bazel +++ b/plugins/git4idea/BUILD.bazel @@ -145,6 +145,7 @@ jvm_library( "//platform/vcs-impl", "//platform/vcs-impl:vcs-impl_test_lib", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/lang-api:lang", "@lib//:junit4", "//platform/lvcs-api:lvcs", diff --git a/plugins/git4idea/intellij.vcs.git.coverage/BUILD.bazel b/plugins/git4idea/intellij.vcs.git.coverage/BUILD.bazel index 94ae77d63a57..cda1e9d29c0f 100644 --- a/plugins/git4idea/intellij.vcs.git.coverage/BUILD.bazel +++ b/plugins/git4idea/intellij.vcs.git.coverage/BUILD.bazel @@ -57,6 +57,7 @@ jvm_library( "//platform/vcs-log/graph:vcs-log-graph-impl_test_lib", "//platform/vcs-api/shared", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", ] ) ### auto-generated section `build intellij.vcs.git.coverage` end diff --git a/plugins/git4idea/terminal/BUILD.bazel b/plugins/git4idea/terminal/BUILD.bazel index 6a46905d5aea..ea52ed6d6d1d 100644 --- a/plugins/git4idea/terminal/BUILD.bazel +++ b/plugins/git4idea/terminal/BUILD.bazel @@ -52,6 +52,7 @@ jvm_library( "//platform/testFramework", "//platform/testFramework:testFramework_test_lib", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/vcs-api/shared", "@lib//:assert_j", "//libraries/mockito", diff --git a/plugins/github/github-core/BUILD.bazel b/plugins/github/github-core/BUILD.bazel index a0103a463c23..80300d6882c1 100644 --- a/plugins/github/github-core/BUILD.bazel +++ b/plugins/github/github-core/BUILD.bazel @@ -168,6 +168,7 @@ jvm_library( "//platform/dvcs-impl/shared", "//platform/ide-core-impl", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", ] ) ### auto-generated section `build intellij.vcs.github` end diff --git a/plugins/gitlab/gitlab-core/BUILD.bazel b/plugins/gitlab/gitlab-core/BUILD.bazel index c3e49ab2dc52..81bc1bc66e5d 100644 --- a/plugins/gitlab/gitlab-core/BUILD.bazel +++ b/plugins/gitlab/gitlab-core/BUILD.bazel @@ -139,6 +139,7 @@ jvm_library( "//platform/dvcs-impl/shared", "//platform/ide-core-impl", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/code-style-api:codeStyle", ] ) diff --git a/plugins/hg4idea/BUILD.bazel b/plugins/hg4idea/BUILD.bazel index 3d2a1b56f811..58d3fa94ad33 100644 --- a/plugins/hg4idea/BUILD.bazel +++ b/plugins/hg4idea/BUILD.bazel @@ -81,6 +81,7 @@ jvm_library( "//platform/util/coroutines", "//platform/dvcs-impl/shared", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/core-impl", ] ) diff --git a/plugins/kotlin/idea/tests/BUILD.bazel b/plugins/kotlin/idea/tests/BUILD.bazel index 3297a68f800f..ca606017b684 100644 --- a/plugins/kotlin/idea/tests/BUILD.bazel +++ b/plugins/kotlin/idea/tests/BUILD.bazel @@ -58,6 +58,7 @@ jvm_library( "//platform/usageView-impl", "//platform/vcs-api:vcs", "//platform/vcs-impl/shared", + "//platform/vcs-impl/shared:shared_test_lib", "//platform/workspace/storage", "//plugins/kotlin/plugin/k1", "//plugins/kotlin/code-insight/api",