From 0bfd15ceac3db9657b354bc89ed61207cf88fa9c Mon Sep 17 00:00:00 2001 From: monosoul Date: Wed, 5 Nov 2025 20:26:09 +0600 Subject: [PATCH 1/6] Do not show popup when fetching skipped worktree --- .../view/GetSkippedWorktreeFilesTask.kt | 47 +++-- .../SkippedWorktreeChangesViewModifier.kt | 20 ++- .../changes/view/SkippedWorktreeFilesCache.kt | 79 ++++++++ src/main/resources/META-INF/plugin.xml | 3 + .../view/GetSkippedWorktreeFilesTaskTest.kt | 22 ++- .../SkippedWorktreeChangesViewModifierTest.kt | 45 +++-- .../view/SkippedWorktreeFilesCacheTest.kt | 168 ++++++++++++++++++ 7 files changed, 318 insertions(+), 66 deletions(-) create mode 100644 src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt create mode 100644 src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCacheTest.kt diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt index 2fabaf3..59d9723 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt @@ -2,8 +2,6 @@ package com.github.monosoul.git.updateindex.extended.changes.view import com.github.monosoul.git.updateindex.extended.changes.view.Constants.SKIPPED_FILE import com.intellij.externalProcessAuthHelper.AuthenticationMode.NONE -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.Task.WithResult import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.ProjectLevelVcsManager @@ -16,33 +14,28 @@ import git4idea.commands.GitCommand.LS_FILES import git4idea.commands.GitCommandResult import git4idea.commands.GitLineHandler -class GetSkippedWorktreeFilesTask( - project: Project -) : WithResult, RuntimeException>(project, "Getting Skipped Files", false) { +fun getSkippedWorktreeFiles(project: Project): List { + val vcsManager = ProjectLevelVcsManager.getInstance(project) ?: return emptyList() - override fun compute(indicator: ProgressIndicator): List { - val vcsManager = ProjectLevelVcsManager.getInstance(project) ?: return emptyList() - - return vcsManager.allVcsRoots.map(VcsRoot::getPath).map { vcsRoot -> - GitLineHandler(project, vcsRoot, LS_FILES).apply { - addParameters("-v") - ignoreAuthenticationMode = NONE - }.let(Git.getInstance()::runCommand).mapOrThrow { result -> - result.filter { - it.startsWith(SKIPPED_FILE) - }.map { - it.removePrefix("$SKIPPED_FILE ") - }.map { - VcsUtil.getFilePath(vcsRoot, GitUtil.unescapePath(it)) - } + return vcsManager.allVcsRoots.map(VcsRoot::getPath).map { vcsRoot -> + GitLineHandler(project, vcsRoot, LS_FILES).apply { + addParameters("-v") + ignoreAuthenticationMode = NONE + }.let(Git.getInstance()::runCommand).mapOrThrow { result -> + result.filter { + it.startsWith(SKIPPED_FILE) + }.map { + it.removePrefix("$SKIPPED_FILE ") + }.map { + VcsUtil.getFilePath(vcsRoot, GitUtil.unescapePath(it)) } - }.flatten() - } + } + }.flatten() +} - @Throws(VcsException::class) - private fun GitCommandResult.mapOrThrow(mapper: (List) -> T): T { - throwOnError() +@Throws(VcsException::class) +private fun GitCommandResult.mapOrThrow(mapper: (List) -> T): T { + throwOnError() - return mapper(output) - } + return mapper(output) } diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifier.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifier.kt index c2d45db..3c4e757 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifier.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifier.kt @@ -4,15 +4,12 @@ import com.github.monosoul.git.updateindex.extended.changes.view.Constants.PROPE import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.diagnostic.debug import com.intellij.openapi.diagnostic.logger -import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.changes.ChangesViewModifier import com.intellij.openapi.vcs.changes.ui.ChangesViewModelBuilder class SkippedWorktreeChangesViewModifier(private val project: Project) : ChangesViewModifier { - private val logger = logger() - override fun modifyTreeModelBuilder(modelBuilder: ChangesViewModelBuilder) { val showSkippedTree = PropertiesComponent.getInstance().getBoolean(PROPERTY, false) if (!showSkippedTree) { @@ -20,12 +17,19 @@ class SkippedWorktreeChangesViewModifier(private val project: Project) : Changes return } - val skippedFiles = ProgressManager.getInstance().run(GetSkippedWorktreeFilesTask(project)) + val cache = SkippedWorktreeFilesCache.getInstance(project) + val skippedFiles = cache.getOrLoad() + + if (skippedFiles != null && skippedFiles.isNotEmpty()) { + logger.debug { "Skipped files: $skippedFiles" } - logger.debug { "Skipped files: $skippedFiles" } + val rootNode = ChangesBrowserSkippedWorktreeNode(project, skippedFiles) + modelBuilder.insertSubtreeRoot(rootNode) + modelBuilder.insertFilesIntoNode(skippedFiles.mapNotNull { it.virtualFile }, rootNode) + } + } - val rootNode = ChangesBrowserSkippedWorktreeNode(project, skippedFiles) - modelBuilder.insertSubtreeRoot(rootNode) - modelBuilder.insertFilesIntoNode(skippedFiles.mapNotNull { it.virtualFile }, rootNode) + companion object { + private val logger = logger() } } diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt new file mode 100644 index 0000000..d8db54e --- /dev/null +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt @@ -0,0 +1,79 @@ +package com.github.monosoul.git.updateindex.extended.changes.view + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.changes.ChangesViewManager +import com.intellij.platform.ide.progress.withBackgroundProgress +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +@Service(Service.Level.PROJECT) +class SkippedWorktreeFilesCache(private val project: Project) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var cachedFiles: List? = null + private var isLoading = false + + init { + // Cancel the scope when the project is disposed + Disposer.register(project, object : Disposable { + override fun dispose() { + scope.cancel() + } + }) + } + + fun getOrLoad(): List? { + if (cachedFiles != null) { + return cachedFiles + } + + if (!isLoading) { + isLoading = true + scope.launch { + try { + val files = withBackgroundProgress(project, "Getting Skipped Files", cancellable = false) { + getSkippedWorktreeFiles(project) + } + cachedFiles = files + ApplicationManager.getApplication().invokeLater { + ChangesViewManager.getInstanceEx(project).scheduleRefresh() + } + } catch (e: Exception) { + // Log error but don't crash + logger.warn("Failed to load skipped worktree files", e) + } finally { + isLoading = false + } + } + } + + return null + } + + fun clear() { + cachedFiles = null + } + + /** + * For testing purposes: set cached files directly without async loading + */ + internal fun setCachedFiles(files: List) { + cachedFiles = files + } + + companion object { + private val logger = logger() + + fun getInstance(project: Project): SkippedWorktreeFilesCache = project.service() + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 36cf2bc..9964fe2 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -35,6 +35,9 @@ + + diff --git a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt index 0cbcb2f..05e3acb 100644 --- a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt +++ b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt @@ -24,9 +24,9 @@ import git4idea.config.GitExecutableManager import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension -import io.mockk.mockk import io.mockk.slot import io.mockk.verifyAll +import kotlinx.coroutines.runBlocking import org.apache.commons.lang3.RandomStringUtils import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -70,8 +70,6 @@ internal class GetSkippedWorktreeFilesTaskTest { @MockK private lateinit var git: Git - private lateinit var task: GetSkippedWorktreeFilesTask - @BeforeEach fun setUp() { parent = TestDisposable() @@ -95,8 +93,6 @@ internal class GetSkippedWorktreeFilesTaskTest { vcsRoot = VcsRoot(vcs, MockVirtualFile(true, "vcsRoot")) every { vcsManager.allVcsRoots } returns arrayOf(vcsRoot) - - task = GetSkippedWorktreeFilesTask(project) } @AfterEach @@ -112,8 +108,9 @@ internal class GetSkippedWorktreeFilesTaskTest { fun `should do nothing if the result doesn't contain skipped files`(result: GitCommandResult) { every { git.runCommand(any()) } returns result - task.run(mockk()) - val actual = task.result + val actual = runBlocking { + getSkippedWorktreeFiles(project) + } expectThat(actual).isEmpty() @@ -125,8 +122,9 @@ internal class GetSkippedWorktreeFilesTaskTest { fun `should return a list of skipped files`(result: GitCommandResult) { every { git.runCommand(any()) } returns result - task.run(mockk()) - val actual = task.result + val actual = runBlocking { + getSkippedWorktreeFiles(project) + } expectThat(actual) .hasSize(result.output.size) @@ -141,10 +139,10 @@ internal class GetSkippedWorktreeFilesTaskTest { fun `should throw an exception in case of an error`(result: GitCommandResult) { every { git.runCommand(any()) } returns result - task.run(mockk()) - expectThrows { - task.result + runBlocking { + getSkippedWorktreeFiles(project) + } }.message isEqualTo result.errorOutputAsJoinedString verifyGitCall() diff --git a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifierTest.kt b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifierTest.kt index ddc0f11..e196c44 100644 --- a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifierTest.kt +++ b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeChangesViewModifierTest.kt @@ -10,13 +10,16 @@ import com.intellij.ide.util.PropertiesComponent import com.intellij.mock.MockApplication import com.intellij.mock.MockLocalFileSystem import com.intellij.mock.MockProject +import com.intellij.mock.MockVirtualFile import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.AsyncExecutionService -import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.util.Disposer.dispose +import com.intellij.openapi.vcs.ProjectLevelVcsManager import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.LocalFilePath +import com.intellij.openapi.vcs.VcsRoot import com.intellij.openapi.vcs.actions.VcsContextFactory +import com.intellij.openapi.vcs.changes.committed.MockAbstractVcs import com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode import com.intellij.openapi.vcs.changes.ui.ChangesViewModelBuilder import com.intellij.openapi.vcs.changes.ui.NoneChangesGroupingFactory @@ -25,11 +28,9 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.peer.impl.VcsContextFactoryImpl -import io.mockk.Called import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension -import io.mockk.verify import org.apache.commons.lang3.RandomStringUtils import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -61,12 +62,18 @@ internal class SkippedWorktreeChangesViewModifierTest { @MockK(relaxed = true) private lateinit var asyncExecutionService: AsyncExecutionService - @MockK(relaxUnitFun = true) - private lateinit var progressManager: ProgressManager - @MockK(relaxUnitFun = true) private lateinit var virtualFileManager: VirtualFileManager + @MockK + private lateinit var vcsManager: ProjectLevelVcsManager + + @MockK + private lateinit var git: git4idea.commands.Git + + @MockK(relaxed = true) + private lateinit var gitExecutableManager: git4idea.config.GitExecutableManager + private lateinit var modifier: SkippedWorktreeChangesViewModifier @BeforeEach @@ -81,8 +88,6 @@ internal class SkippedWorktreeChangesViewModifierTest { propertiesComponent = AppPropertyService() application.registerService(propertiesComponent, parent) - application.registerService(progressManager, parent) - localFileSystem = MockLocalFileSystem() application.registerService(localFileSystem, parent) @@ -94,6 +99,14 @@ internal class SkippedWorktreeChangesViewModifierTest { application.registerService(asyncExecutionService, parent) + application.registerService(git, parent) + application.registerService(gitExecutableManager, parent) + project.registerService(vcsManager, parent) + + // Register the cache service + val cache = SkippedWorktreeFilesCache(project) + project.registerService(SkippedWorktreeFilesCache::class.java, cache, parent) + modifier = SkippedWorktreeChangesViewModifier(project) } @@ -107,10 +120,6 @@ internal class SkippedWorktreeChangesViewModifierTest { @MockK modelBuilder: ChangesViewModelBuilder ) { modifier.modifyTreeModelBuilder(modelBuilder) - - verify { - progressManager wasNot Called - } } @Test @@ -120,20 +129,18 @@ internal class SkippedWorktreeChangesViewModifierTest { propertiesComponent.setValue(PROPERTY, false) modifier.modifyTreeModelBuilder(modelBuilder) - - verify { - progressManager wasNot Called - } } @ParameterizedTest @ArgumentsSource(FilesArgumentSource::class) fun `should add a new root node with skipped files`(files: List) { - every { progressManager.run(any()) } returns files - propertiesComponent.setValue(PROPERTY, true) - val builder = TreeModelBuilder(project, NoneChangesGroupingFactory) + + // Pre-populate the cache with test data + val cache = SkippedWorktreeFilesCache.getInstance(project) + cache.setCachedFiles(files) + val builder = TreeModelBuilder(project, NoneChangesGroupingFactory) modifier.modifyTreeModelBuilder(builder) val model = builder.build() diff --git a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCacheTest.kt b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCacheTest.kt new file mode 100644 index 0000000..2067ee6 --- /dev/null +++ b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCacheTest.kt @@ -0,0 +1,168 @@ +package com.github.monosoul.git.updateindex.extended.changes.view + +import com.github.monosoul.git.updateindex.extended.AbstractMultiArgumentsSource +import com.github.monosoul.git.updateindex.extended.LIMIT +import com.github.monosoul.git.updateindex.extended.TestDisposable +import com.intellij.mock.MockApplication +import com.intellij.mock.MockProject +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer.dispose +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.LocalFilePath +import io.mockk.junit5.MockKExtension +import org.apache.commons.lang3.RandomStringUtils +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import strikt.api.expect +import strikt.api.expectThat +import strikt.assertions.containsExactlyInAnyOrder +import strikt.assertions.hasSize +import strikt.assertions.isEmpty +import strikt.assertions.isEqualTo +import strikt.assertions.isNotNull +import strikt.assertions.isNull +import strikt.assertions.isSameInstanceAs +import strikt.assertions.map +import kotlin.random.Random +import kotlin.random.nextInt + +@ExtendWith(MockKExtension::class) +internal class SkippedWorktreeFilesCacheTest { + + private lateinit var parent: TestDisposable + private lateinit var application: MockApplication + private lateinit var project: MockProject + private lateinit var cache: SkippedWorktreeFilesCache + + @BeforeEach + fun setUp() { + parent = TestDisposable() + + application = MockApplication(parent) + ApplicationManager.setApplication(application, parent) + + project = MockProject(null, parent) + + cache = SkippedWorktreeFilesCache(project) + project.registerService(SkippedWorktreeFilesCache::class.java, cache, parent) + } + + @AfterEach + fun tearDown() { + dispose(parent) + } + + @Test + fun `getOrLoad should return null when cache is empty`() { + expectThat(cache.getOrLoad()).isNull() + } + + @ParameterizedTest + @ArgumentsSource(FilesArgumentSource::class) + fun `getOrLoad should return cached files when cache is populated`(files: List) { + cache.setCachedFiles(files) + + val result = cache.getOrLoad() + + expectThat(result).isNotNull().and { + get { size } isEqualTo files.size + get { map { it.path } } containsExactlyInAnyOrder files.map { it.path } + } + } + + @Test + fun `getOrLoad should return the same cached files on multiple calls`() { + val files = generateFiles(5) + cache.setCachedFiles(files) + + val firstCall = cache.getOrLoad() + val secondCall = cache.getOrLoad() + val thirdCall = cache.getOrLoad() + + expect { + that(firstCall).isNotNull() + that(secondCall).isNotNull() + that(thirdCall).isNotNull() + that(firstCall).isEqualTo(secondCall) + that(secondCall).isEqualTo(thirdCall) + } + } + + @Test + fun `clear should remove cached files`() { + val files = generateFiles(3) + cache.setCachedFiles(files) + + expectThat(cache.getOrLoad()).isNotNull() + + cache.clear() + + expectThat(cache.getOrLoad()).isNull() + } + + @Test + fun `clear should allow repopulating cache`() { + val firstFiles = generateFiles(2) + val secondFiles = generateFiles(3) + + cache.setCachedFiles(firstFiles) + expectThat(cache.getOrLoad()).isNotNull().and { + get { size } isEqualTo firstFiles.size + } + + cache.clear() + + cache.setCachedFiles(secondFiles) + expectThat(cache.getOrLoad()).isNotNull().and { + get { size } isEqualTo secondFiles.size + get { map { it.path } } containsExactlyInAnyOrder secondFiles.map { it.path } + } + } + + @Test + fun `getOrLoad should return null immediately after clear even if files were cached`() { + val files = generateFiles(5) + cache.setCachedFiles(files) + + expectThat(cache.getOrLoad()).isNotNull() + + cache.clear() + + expectThat(cache.getOrLoad()).isNull() + } + + @Test + fun `getInstance should return the same instance for the same project`() { + val instance1 = SkippedWorktreeFilesCache.getInstance(project) + val instance2 = SkippedWorktreeFilesCache.getInstance(project) + + expectThat(instance1).isSameInstanceAs(instance2) + } + + @Test + fun `cache should handle empty list`() { + cache.setCachedFiles(emptyList()) + + val result = cache.getOrLoad() + + expectThat(result).isNotNull().and { + get { isEmpty() } isEqualTo true + } + } + + private fun generateFiles(count: Int): List { + return List(count) { + LocalFilePath(RandomStringUtils.randomAlphabetic(LIMIT), false) + } + } + + private class FilesArgumentSource : AbstractMultiArgumentsSource({ + List(Random.nextInt(1..10)) { + LocalFilePath(RandomStringUtils.randomAlphabetic(LIMIT), false) + } + }) +} From 757a86fccc48d1e67625c90a3821d4ef0f7a82b3 Mon Sep 17 00:00:00 2001 From: monosoul Date: Wed, 5 Nov 2025 21:37:02 +0600 Subject: [PATCH 2/6] Refresh ChangesView when calling a git update index command --- .../extended/ExtendedUpdateIndexTask.kt | 7 +++++ .../changes/view/SkippedWorktreeFilesCache.kt | 15 ++-------- .../extended/ExtendedUpdateIndexTaskTest.kt | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTask.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTask.kt index 7701558..a1ae77d 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTask.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTask.kt @@ -1,9 +1,11 @@ package com.github.monosoul.git.updateindex.extended +import com.github.monosoul.git.updateindex.extended.changes.view.SkippedWorktreeFilesCache import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangesViewManager import com.intellij.openapi.vcs.changes.VcsDirtyScopeManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.vcsUtil.VcsUtil @@ -29,6 +31,11 @@ class ExtendedUpdateIndexTask( files.forEach(vcsDirtyScopeManager::fileDirty) } } + + logger.debug("Git update index command executed, refreshing Changes view") + + SkippedWorktreeFilesCache.getInstance(project).clear() + ChangesViewManager.getInstanceEx(project).scheduleRefresh() } private fun GitLineHandler.runAndLog() = run(Git.getInstance()::runCommand) diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt index d8db54e..22e80a0 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt @@ -1,11 +1,9 @@ package com.github.monosoul.git.updateindex.extended.changes.view -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project -import com.intellij.openapi.Disposable import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.changes.ChangesViewManager @@ -24,12 +22,7 @@ class SkippedWorktreeFilesCache(private val project: Project) { private var isLoading = false init { - // Cancel the scope when the project is disposed - Disposer.register(project, object : Disposable { - override fun dispose() { - scope.cancel() - } - }) + Disposer.register(project) { scope.cancel() } } fun getOrLoad(): List? { @@ -45,9 +38,7 @@ class SkippedWorktreeFilesCache(private val project: Project) { getSkippedWorktreeFiles(project) } cachedFiles = files - ApplicationManager.getApplication().invokeLater { - ChangesViewManager.getInstanceEx(project).scheduleRefresh() - } + ChangesViewManager.getInstanceEx(project).scheduleRefresh() } catch (e: Exception) { // Log error but don't crash logger.warn("Failed to load skipped worktree files", e) diff --git a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTaskTest.kt b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTaskTest.kt index abe96e6..2b6b846 100644 --- a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTaskTest.kt +++ b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/ExtendedUpdateIndexTaskTest.kt @@ -2,12 +2,16 @@ package com.github.monosoul.git.updateindex.extended import com.github.monosoul.git.updateindex.extended.ExtendedUpdateIndexTaskTest.FilesAndCommandArgumentsSource.NoVcsRoot import com.github.monosoul.git.updateindex.extended.ExtendedUpdateIndexTaskTest.FilesAndCommandArgumentsSource.WithVcsRoot +import com.github.monosoul.git.updateindex.extended.changes.view.SkippedWorktreeFilesCache import com.intellij.mock.MockApplication import com.intellij.mock.MockProject import com.intellij.openapi.application.ApplicationManager.setApplication import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.util.Disposer.dispose import com.intellij.openapi.vcs.ProjectLevelVcsManager +import com.intellij.openapi.vcs.changes.ChangesViewEx +import com.intellij.openapi.vcs.changes.ChangesViewI +import com.intellij.openapi.vcs.changes.ChangesViewManager import com.intellij.openapi.vcs.changes.VcsDirtyScopeManager import com.intellij.openapi.vfs.VirtualFile import git4idea.commands.Git @@ -18,6 +22,7 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension import io.mockk.mockk +import io.mockk.spyk import io.mockk.verify import io.mockk.verifyAll import io.mockk.verifyOrder @@ -56,6 +61,11 @@ internal class ExtendedUpdateIndexTaskTest { @MockK private lateinit var gitCommandResult: GitCommandResult + @MockK(relaxUnitFun = true) + private lateinit var changesViewManager: ChangesViewManager + + private lateinit var cache: SkippedWorktreeFilesCache + @BeforeEach fun setUp() { parent = TestDisposable() @@ -68,6 +78,12 @@ internal class ExtendedUpdateIndexTaskTest { project.registerService(vcsManager, parent) project.registerService(dirtyScopeManager, parent) project.registerService(updateIndexLineHandlerFactory, parent) + project.registerService(changesViewManager, parent) + project.registerService(changesViewManager, parent) + + // Register the cache service with a spy to verify clear() calls + cache = spyk(SkippedWorktreeFilesCache(project)) + project.registerService(SkippedWorktreeFilesCache::class.java, cache, parent) every { updateIndexLineHandlerFactory.invoke(any(), any(), any()) } returns gitLineHandler every { git.runCommand(any()) } returns gitCommandResult @@ -98,6 +114,10 @@ internal class ExtendedUpdateIndexTaskTest { gitLineHandler wasNot Called dirtyScopeManager wasNot Called } + verify { + cache.clear() + changesViewManager.scheduleRefresh() + } } @ParameterizedTest @@ -127,6 +147,10 @@ internal class ExtendedUpdateIndexTaskTest { verify(exactly = files.size) { dirtyScopeManager.fileDirty(any()) } + verify { + cache.clear() + changesViewManager.scheduleRefresh() + } } @ParameterizedTest @@ -159,6 +183,10 @@ internal class ExtendedUpdateIndexTaskTest { verify(exactly = files.size) { dirtyScopeManager.fileDirty(any()) } + verify { + cache.clear() + changesViewManager.scheduleRefresh() + } } private sealed class FilesAndCommandArgumentsSource( From d427934151f421675cc2ecd94a18577e28e63ded Mon Sep 17 00:00:00 2001 From: monosoul Date: Wed, 5 Nov 2025 23:53:22 +0600 Subject: [PATCH 3/6] Make SkippedWorktreeFilesCache thread safe --- .../changes/view/SkippedWorktreeFilesCache.kt | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt index 22e80a0..221bd99 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt @@ -10,41 +10,38 @@ import com.intellij.openapi.vcs.changes.ChangesViewManager import com.intellij.platform.ide.progress.withBackgroundProgress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicReference @Service(Service.Level.PROJECT) class SkippedWorktreeFilesCache(private val project: Project) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var cachedFiles: List? = null - private var isLoading = false + @OptIn(ExperimentalCoroutinesApi::class) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) + private val cachedFiles = AtomicReference?>(null) init { Disposer.register(project) { scope.cancel() } } fun getOrLoad(): List? { - if (cachedFiles != null) { - return cachedFiles + val cached = cachedFiles.get() + if (cached != null) { + return cached } - if (!isLoading) { - isLoading = true - scope.launch { - try { - val files = withBackgroundProgress(project, "Getting Skipped Files", cancellable = false) { - getSkippedWorktreeFiles(project) - } - cachedFiles = files - ChangesViewManager.getInstanceEx(project).scheduleRefresh() - } catch (e: Exception) { - // Log error but don't crash - logger.warn("Failed to load skipped worktree files", e) - } finally { - isLoading = false + scope.launch { + try { + val files = withBackgroundProgress(project, "Getting Skipped Files", cancellable = false) { + getSkippedWorktreeFiles(project) } + cachedFiles.set(files) + ChangesViewManager.getInstanceEx(project).scheduleRefresh() + } catch (e: Exception) { + logger.warn("Failed to load skipped worktree files", e) } } @@ -52,14 +49,14 @@ class SkippedWorktreeFilesCache(private val project: Project) { } fun clear() { - cachedFiles = null + cachedFiles.set(null) } /** * For testing purposes: set cached files directly without async loading */ internal fun setCachedFiles(files: List) { - cachedFiles = files + cachedFiles.set(files) } companion object { From 0cd0d2d4749cbbd06e818e9b165862d7aa032422 Mon Sep 17 00:00:00 2001 From: monosoul Date: Wed, 5 Nov 2025 23:57:58 +0600 Subject: [PATCH 4/6] rename files to follow conventions --- ...etSkippedWorktreeFilesTask.kt => GetSkippedWorktreeFiles.kt} | 0 ...orktreeFilesTaskTest.kt => GetSkippedWorktreeFilesKtTest.kt} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/{GetSkippedWorktreeFilesTask.kt => GetSkippedWorktreeFiles.kt} (100%) rename src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/{GetSkippedWorktreeFilesTaskTest.kt => GetSkippedWorktreeFilesKtTest.kt} (99%) diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt similarity index 100% rename from src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt rename to src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt diff --git a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesKtTest.kt similarity index 99% rename from src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt rename to src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesKtTest.kt index 05e3acb..9f24058 100644 --- a/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTaskTest.kt +++ b/src/test/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesKtTest.kt @@ -46,7 +46,7 @@ import kotlin.random.Random import kotlin.random.nextInt @ExtendWith(MockKExtension::class) -internal class GetSkippedWorktreeFilesTaskTest { +internal class GetSkippedWorktreeFilesKtTest { private lateinit var parent: TestDisposable private lateinit var application: MockApplication From dea7c08e7e6c441e94c97be806cf2a0907693dec Mon Sep 17 00:00:00 2001 From: monosoul Date: Thu, 6 Nov 2025 00:03:55 +0600 Subject: [PATCH 5/6] Use IO dispatcher since call to git is blockig --- .../extended/changes/view/SkippedWorktreeFilesCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt index 221bd99..06abda3 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt @@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicReference class SkippedWorktreeFilesCache(private val project: Project) { @OptIn(ExperimentalCoroutinesApi::class) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) private val cachedFiles = AtomicReference?>(null) init { From 744e4f1708ec6505f25ace34e500ba3d8ffae62e Mon Sep 17 00:00:00 2001 From: monosoul Date: Thu, 6 Nov 2025 00:05:41 +0600 Subject: [PATCH 6/6] Mark getSkippedWorktreeFiles fun as blocking --- .../extended/changes/view/GetSkippedWorktreeFiles.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt index 59d9723..b86b1a3 100644 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt @@ -13,7 +13,9 @@ import git4idea.commands.Git import git4idea.commands.GitCommand.LS_FILES import git4idea.commands.GitCommandResult import git4idea.commands.GitLineHandler +import org.jetbrains.annotations.Blocking +@Blocking fun getSkippedWorktreeFiles(project: Project): List { val vcsManager = ProjectLevelVcsManager.getInstance(project) ?: return emptyList()