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/GetSkippedWorktreeFiles.kt b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt new file mode 100644 index 0000000..b86b1a3 --- /dev/null +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFiles.kt @@ -0,0 +1,43 @@ +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.project.Project +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.ProjectLevelVcsManager +import com.intellij.openapi.vcs.VcsException +import com.intellij.openapi.vcs.VcsRoot +import com.intellij.vcsUtil.VcsUtil +import git4idea.GitUtil +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() + + 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() +} + +@Throws(VcsException::class) +private fun GitCommandResult.mapOrThrow(mapper: (List) -> T): T { + throwOnError() + + return mapper(output) +} 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 deleted file mode 100644 index 2fabaf3..0000000 --- a/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/GetSkippedWorktreeFilesTask.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 -import com.intellij.openapi.vcs.VcsException -import com.intellij.openapi.vcs.VcsRoot -import com.intellij.vcsUtil.VcsUtil -import git4idea.GitUtil -import git4idea.commands.Git -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) { - - 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)) - } - } - }.flatten() - } - - @Throws(VcsException::class) - private fun GitCommandResult.mapOrThrow(mapper: (List) -> T): T { - throwOnError() - - 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..06abda3 --- /dev/null +++ b/src/main/kotlin/com/github/monosoul/git/updateindex/extended/changes/view/SkippedWorktreeFilesCache.kt @@ -0,0 +1,67 @@ +package com.github.monosoul.git.updateindex.extended.changes.view + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +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 +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) { + + @OptIn(ExperimentalCoroutinesApi::class) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) + private val cachedFiles = AtomicReference?>(null) + + init { + Disposer.register(project) { scope.cancel() } + } + + fun getOrLoad(): List? { + val cached = cachedFiles.get() + if (cached != null) { + return cached + } + + 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) + } + } + + return null + } + + fun clear() { + cachedFiles.set(null) + } + + /** + * For testing purposes: set cached files directly without async loading + */ + internal fun setCachedFiles(files: List) { + cachedFiles.set(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/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( 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 94% 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 0cbcb2f..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 @@ -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 @@ -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 @@ -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) + } + }) +}