feature (vcs): Implement API required for showing changes view diff (IJPL-173924)

It exposes currently selected diffable change in the tree and next/previous nodes. If selection is changed, but previous selection can still be considered selected, the n ew value is not reported

GitOrigin-RevId: b4f649765cb31dae7d3fbba540bf7f673d2944e3
This commit is contained in:
Ilia.Shulgin
2025-11-26 11:08:40 +00:00
committed by intellij-monorepo-bot
parent 3476cf8782
commit 3fead1c6ab
32 changed files with 574 additions and 50 deletions
+1
View File
@@ -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",
+1
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
]
+1
View File
@@ -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",
@@ -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)
}
+1
View File
@@ -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",
+45 -1
View File
@@ -38,4 +38,48 @@ jvm_library(
"//platform/vcs-api/vcs-api-core:vcs-core",
]
)
### auto-generated section `build intellij.platform.vcs.impl.frontend` end
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
@@ -30,6 +30,7 @@
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/testSrc" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
@@ -55,5 +56,6 @@
<orderEntry type="module" module-name="intellij.platform.analysis" />
<orderEntry type="module" module-name="intellij.platform.vcs.shared" />
<orderEntry type="module" module-name="intellij.platform.vcs.core" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
</component>
</module>
@@ -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<ChangesViewDiffableSelectionHelper>()
// TODO support hijacked changes
internal class ChangesViewDiffableSelectionHelper(private val changesView: ChangesListView) {
private val _diffableSelection = MutableStateFlow<ChangesViewDiffableSelection?>(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<Any?, Any?> {
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
}
}
@@ -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<FrontendCommitChangesViewWithToolbarPanel>()
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<FrontendCommitChangesViewWithToolbarPanel>()
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)
}
}
}
}
@@ -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)
}
}
+49 -1
View File
@@ -41,4 +41,52 @@ jvm_library(
"//platform/diagnostic/telemetry",
]
)
### auto-generated section `build intellij.platform.vcs.impl.shared` end
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
@@ -30,6 +30,7 @@
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/testSrc" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
@@ -58,5 +59,6 @@
<orderEntry type="module" module-name="intellij.platform.util.jdom" />
<orderEntry type="module" module-name="intellij.platform.ide.core.impl" />
<orderEntry type="module" module-name="intellij.platform.diagnostic.telemetry" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
</component>
</module>
@@ -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();
@@ -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(
@@ -14,6 +14,8 @@ import org.jetbrains.annotations.ApiStatus
interface ChangesViewDiffApi : RemoteApi<Unit> {
suspend fun performDiffAction(projectId: ProjectId, action: ChangesViewDiffAction)
suspend fun notifySelectionUpdated(projectId: ProjectId, selection: ChangesViewDiffableSelection?)
companion object {
suspend fun getInstance(): ChangesViewDiffApi = RemoteApiProviderService.resolve(remoteApiDescriptor<ChangesViewDiffApi>())
}
@@ -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?,
)
@@ -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<LocalChangeListImpl>,
unversioned: List<FilePath>,
): DefaultTreeModel = TreeModelBuilder(project, view.grouping)
.setChangeLists(lists, false, null)
.setUnversioned(unversioned)
.build()
}
@@ -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<ChangesViewDiffApiImpl>()
}
@@ -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<InclusionModel?>(null)
val diffableSelection = MutableStateFlow<ChangesViewDiffableSelection?>(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<in BackendChangesViewEvent.RefreshRequested>,
) {
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.
@@ -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<ChangeList>,
unversionedFiles: List<FilePath>,
): 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)
}
+1
View File
@@ -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",
]
+1
View File
@@ -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"]
+1
View File
@@ -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
+1
View File
@@ -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",
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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
+1
View File
@@ -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",
]
)
+1
View File
@@ -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",
]
)
+1
View File
@@ -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",