diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/activities/ContinuePluginStartupActivity.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/activities/ContinuePluginStartupActivity.kt index 429f5d9ee6f..c89ca13b3d4 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/activities/ContinuePluginStartupActivity.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/activities/ContinuePluginStartupActivity.kt @@ -9,6 +9,7 @@ import com.github.continuedev.continueintellijextension.listeners.ContinuePlugin import com.github.continuedev.continueintellijextension.services.ContinueExtensionSettings import com.github.continuedev.continueintellijextension.services.ContinuePluginService import com.github.continuedev.continueintellijextension.services.SettingsListener +import com.github.continuedev.continueintellijextension.utils.resolveWorkspacePaths import com.github.continuedev.continueintellijextension.utils.toUriOrNull import com.intellij.openapi.actionSystem.KeyboardShortcut import com.intellij.openapi.application.ApplicationManager @@ -28,9 +29,7 @@ import java.nio.file.Paths import javax.swing.* import com.intellij.openapi.components.service import com.intellij.openapi.module.Module -import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.ModuleListener -import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.BulkFileListener import com.intellij.openapi.vfs.newvfs.events.VFileEvent @@ -201,19 +200,17 @@ class ContinuePluginStartupActivity : StartupActivity, DumbAware { ModuleListener.TOPIC, object : ModuleListener { override fun modulesAdded(project: Project, modules: MutableList) { - - val allModulePaths = ModuleManager.getInstance(project).modules - .flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } } - - val topLevelModulePaths = allModulePaths - .filter { modulePath -> allModulePaths.none { it != modulePath && modulePath.startsWith(it) } } - - continuePluginService.workspacePaths = topLevelModulePaths.toTypedArray(); + continuePluginService.workspacePaths = resolveWorkspacePaths(project) } override fun moduleRemoved(project: Project, module: Module) { - val removedPaths = ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } ; - continuePluginService.workspacePaths = continuePluginService.workspacePaths?.toList()?.filter { path -> removedPaths.none {removedPath -> path == removedPath }}?.toTypedArray(); + // Re-derive top-level roots from the remaining modules instead of a plain + // subtraction: removing a module can promote a formerly-nested sibling to a + // top-level root, which an exact-match removal would never re-add. + // Contract: moduleRemoved fires *after* the module has left ModuleManager + // (beforeModuleRemoved is the pre-removal hook), so the rescan below already + // excludes it — and we never touch the disposed `module` param. + continuePluginService.workspacePaths = resolveWorkspacePaths(project) } override fun modulesRenamed( @@ -221,13 +218,7 @@ class ContinuePluginStartupActivity : StartupActivity, DumbAware { modules: MutableList, oldNameProvider: Function ) { - val allModulePaths = ModuleManager.getInstance(project).modules - .flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } } - - val topLevelModulePaths = allModulePaths - .filter { modulePath -> allModulePaths.none { it != modulePath && modulePath.startsWith(it) } } - - continuePluginService.workspacePaths = topLevelModulePaths.toTypedArray() + continuePluginService.workspacePaths = resolveWorkspacePaths(project) } } ) @@ -262,13 +253,8 @@ class ContinuePluginStartupActivity : StartupActivity, DumbAware { // Reload the WebView continuePluginService?.let { pluginService -> - val allModulePaths = ModuleManager.getInstance(project).modules - .flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } } - - val topLevelModulePaths = allModulePaths - .filter { modulePath -> allModulePaths.none { it != modulePath && modulePath.startsWith(it) } } - - pluginService.workspacePaths = topLevelModulePaths.toTypedArray() + val topLevelModulePaths = resolveWorkspacePaths(project) + pluginService.workspacePaths = topLevelModulePaths } EditorFactory.getInstance().eventMulticaster.addSelectionListener( diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/GitService.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/GitService.kt index 1f3b00825d4..c7fa24fa258 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/GitService.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/GitService.kt @@ -1,9 +1,8 @@ package com.github.continuedev.continueintellijextension.`continue` import com.github.continuedev.continueintellijextension.services.ContinuePluginService -import com.github.continuedev.continueintellijextension.utils.toUriOrNull +import com.github.continuedev.continueintellijextension.utils.resolveWorkspacePathsOrGuess import com.intellij.openapi.project.Project -import com.intellij.openapi.project.guessProjectDir import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.BufferedReader @@ -56,14 +55,7 @@ class GitService( return diffs } - private fun workspaceDirectories(): Array { - val dirs = this.continuePluginService.workspacePaths + private fun workspaceDirectories(): Array = + resolveWorkspacePathsOrGuess(project, continuePluginService.workspacePaths) - if (dirs?.isNotEmpty() == true) { - return dirs - } - - return listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray() - } - -} \ No newline at end of file +} diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/IntelliJIde.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/IntelliJIde.kt index bcddd49aafb..0331bc162ed 100644 --- a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/IntelliJIde.kt +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/IntelliJIde.kt @@ -724,14 +724,7 @@ class IntelliJIDE( } } - private fun workspaceDirectories(): Array { - val dirs = this.continuePluginService.workspacePaths - - if (dirs?.isNotEmpty() == true) { - return dirs - } - - return listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray() - } + private fun workspaceDirectories(): Array = + resolveWorkspacePathsOrGuess(project, continuePluginService.workspacePaths) } diff --git a/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/utils/WorkspacePaths.kt b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/utils/WorkspacePaths.kt new file mode 100644 index 00000000000..255fd3271c6 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/utils/WorkspacePaths.kt @@ -0,0 +1,125 @@ +package com.github.continuedev.continueintellijextension.utils + +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.roots.ModuleRootManager + +/** + * Helpers for resolving the set of workspace directories that Continue exposes to the + * core (file references, @-context providers, indexing, git diff, ...). + * + * A JetBrains project can hold several modules — and several *attached* projects living side + * by side in the same Project view, without necessarily sharing a common parent directory. + * Each module contributes one or more content-root URIs. We only want to keep the "top-level" + * roots, dropping any root that is physically nested inside another one (a module whose content + * root lives under another module's content root). + * + * IMPORTANT: containment MUST be computed on URI *path segments*, never on a raw string prefix. + * Two sibling roots can share a textual prefix without one being nested in the other, e.g. + * "file:///ws/service" and "file:///ws/service-api". Using [String.startsWith] would wrongly + * treat "service-api" as nested in "service" and silently drop it, so the second project's + * files would never reach the core and every path under it would look broken. + * + * This mirrors the core's own resolution logic — see `core/util/uri.ts` `findUriInDirs`: + * "Can't just use startsWith because e.g. file:///folder/file is not within file:///fold". + */ +object WorkspacePaths { + + /** + * Returns the top-level content-root URIs out of [moduleRootUris], i.e. every root that is + * not nested inside another root. + * + * - Duplicates are removed and the input order of the surviving roots is preserved. + * - Trailing slashes are ignored when comparing, and blank entries are discarded. + */ + fun topLevelWorkspacePaths(moduleRootUris: List): List { + val roots = moduleRootUris + .map { trimTrailingSlash(it) } + .filter { it.isNotEmpty() } + .distinct() + + return roots.filter { candidate -> + roots.none { other -> other != candidate && isNestedUnder(candidate, other) } + } + } + + /** + * True when [childUri] lives strictly under [parentUri], comparing on "/"-delimited URI + * segments so that textual siblings (e.g. ".../api" vs ".../api-v2") are never mistaken for + * a parent/child relationship. A root is never considered nested under itself. + */ + fun isNestedUnder(childUri: String, parentUri: String): Boolean { + val child = childUri.removeSuffix("/").split("/") + val parent = parentUri.removeSuffix("/").split("/") + // The child must be strictly deeper than the parent (equal or shallower can't be nested). + if (child.size <= parent.size) return false + for (i in parent.indices) { + if (parent[i] != child[i]) return false + } + return true + } + + /** + * Removes a single trailing "/" so that ".../proj" and ".../proj/" compare equal, but leaves a + * root URI intact: we must never turn "file:///" into the malformed "file://", nor a bare drive + * root "file:///C:/" into "file:///C:". + */ + private fun trimTrailingSlash(uri: String): String { + if (!uri.endsWith("/")) return uri + val trimmed = uri.dropLast(1) + val lastSegment = trimmed.substringAfter("://", trimmed).substringAfterLast('/') + return if (lastSegment.isEmpty() || lastSegment.endsWith(":")) uri else trimmed + } +} + +/** + * Resolves the de-nested, de-duplicated top-level workspace directory URIs for [project] by + * scanning every module's content roots. + * + * This is the single source of truth used by all workspace-path writers — the startup activity, + * every [com.intellij.openapi.project.ModuleListener] callback (added / removed / renamed), and the + * runtime fallback in `IntelliJIDE.workspaceDirectories()`. Routing them all through here guarantees + * that a JetBrains window holding several modules / attached projects always exposes the same set of + * roots, and that removing a module re-derives top-level status (e.g. promoting a formerly-nested + * child once its parent module is gone) instead of leaving a stale array. + * + * Wrapped in a read action so it is safe to call from any thread. + */ +fun resolveWorkspacePaths(project: Project): Array = runReadAction { + val moduleRootUris = ModuleManager.getInstance(project).modules + .flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } } + WorkspacePaths.topLevelWorkspacePaths(moduleRootUris).toTypedArray() +} + +/** + * Resolves the workspace directory URIs to expose to the core, with a robust three-step fallback: + * + * 1. [storedPaths] — the array maintained by the startup activity and the ModuleListener + * callbacks (the normal, steady-state path); + * 2. a fresh [resolveWorkspacePaths] scan of the project's modules — so a window holding several + * modules / attached projects still gets *every* top-level root even when the core queries + * before the listener has populated the cache (transient window right after open); + * 3. the single guessed project dir, as a last resort when no module exposes a content root. + * + * Both `IntelliJIDE.workspaceDirectories()` and `GitService.workspaceDirectories()` route through + * this, so the two consumers can never drift apart and the multi-project case is handled identically + * for @-references, indexing and git diff alike. + * + * Note on performance: step 2 only runs while [storedPaths] is empty (a brief window before the + * listener fires); once the cache is populated every call returns in step 1, so the module scan is + * not on the steady-state hot path. + */ +fun resolveWorkspacePathsOrGuess(project: Project, storedPaths: Array?): Array { + if (storedPaths?.isNotEmpty() == true) { + return storedPaths + } + + val scanned = resolveWorkspacePaths(project) + if (scanned.isNotEmpty()) { + return scanned + } + + return runReadAction { listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray() } +} diff --git a/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/ResolveWorkspacePathsTest.kt b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/ResolveWorkspacePathsTest.kt new file mode 100644 index 00000000000..a76e08da56b --- /dev/null +++ b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/ResolveWorkspacePathsTest.kt @@ -0,0 +1,115 @@ +package com.github.continuedev.continueintellijextension.unit + +import com.github.continuedev.continueintellijextension.utils.resolveWorkspacePaths +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.util.Computable +import com.intellij.openapi.util.ThrowableComputable +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileSystem +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import junit.framework.TestCase +import java.nio.file.Path + +/** + * Pure (no IntelliJ platform boot) test of the [resolveWorkspacePaths] glue. + * + * It mocks ModuleManager / ModuleRootManager / VirtualFile so the *whole* chain runs in a plain + * JVM — `ModuleManager.modules` → each module's content roots → [VirtualFile.toUriOrNull] → + * de-nesting — and asserts a multi-module window exposes every top-level root (including a sibling + * pair sharing a textual prefix, the Problem B regression). This runs everywhere, including the + * sandboxes where the heavy platform test framework cannot boot. + */ +class ResolveWorkspacePathsTest : TestCase() { + + private val project = mockk() + + override fun setUp() { + // Make runReadAction { body } just execute body (no real Application needed). + mockkStatic(ApplicationManager::class) + val app = mockk() + every { ApplicationManager.getApplication() } returns app + // runReadAction's inline wrapper may forward to either the Computable or the + // ThrowableComputable overload depending on the platform version — stub both. + every { app.runReadAction(any>>()) } answers { + firstArg>>().compute() + } + every { app.runReadAction(any, Throwable>>()) } answers { + firstArg, Throwable>>().compute() + } + mockkStatic(ModuleManager::class) + mockkStatic(ModuleRootManager::class) + } + + override fun tearDown() = unmockkAll() + + /** A module whose content roots resolve (via toUriOrNull's NIO branch) to the given paths. */ + private fun moduleWithRoots(vararg nioPaths: String): Module { + val module = mockk() + val rootManager = mockk() + val roots = nioPaths.map { p -> + val fs = mockk() + val vf = mockk() + every { vf.fileSystem } returns fs + every { fs.getNioPath(vf) } returns Path.of(p) + // `name` may be read by toUriOrNull in some code paths; stub defensively. + every { vf.name } returns Path.of(p).fileName.toString() + vf + }.toTypedArray() + every { ModuleRootManager.getInstance(module) } returns rootManager + every { rootManager.contentRoots } returns roots + return module + } + + private fun setModules(vararg modules: Module) { + val mm = mockk() + every { ModuleManager.getInstance(project) } returns mm + every { mm.modules } returns arrayOf(*modules) + } + + /** + * Mirrors how toUriOrNull's NIO branch derives a URI, so the assertions stay OS-independent: + * Path.of("/ws/x").toUri() yields "file:///ws/x" on Unix but "file:///C:/ws/x" on Windows. + */ + private fun expectedUri(nioPath: String): String = + Path.of(nioPath).toUri().toString().removeSuffix("/") + + fun `test multi-module sibling roots with shared prefix all surface`() { + setModules( + moduleWithRoots("/ws/service"), + moduleWithRoots("/ws/service-api"), // shares the "service" textual prefix + moduleWithRoots("/ws/backend"), + ) + + val result = resolveWorkspacePaths(project).toList() + + assertEquals( + listOf(expectedUri("/ws/service"), expectedUri("/ws/service-api"), expectedUri("/ws/backend")), + result, + ) + } + + fun `test nested content root is dropped while siblings are kept`() { + setModules( + moduleWithRoots("/ws/app", "/ws/app/submodule"), // one module, second root nested + moduleWithRoots("/ws/app-extras"), // prefix sibling of /ws/app + ) + + val result = resolveWorkspacePaths(project).toList() + + assertEquals(listOf(expectedUri("/ws/app"), expectedUri("/ws/app-extras")), result) + } + + fun `test empty project yields no roots`() { + setModules() + + assertEquals(emptyList(), resolveWorkspacePaths(project).toList()) + } +} diff --git a/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/WorkspacePathsTest.kt b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/WorkspacePathsTest.kt new file mode 100644 index 00000000000..195dbe54019 --- /dev/null +++ b/extensions/intellij/src/test/kotlin/com/github/continuedev/continueintellijextension/unit/WorkspacePathsTest.kt @@ -0,0 +1,210 @@ +package com.github.continuedev.continueintellijextension.unit + +import com.github.continuedev.continueintellijextension.utils.WorkspacePaths +import junit.framework.TestCase + +/** + * Unit tests for [WorkspacePaths.topLevelWorkspacePaths]. + * + * These pin down the multi-project behaviour in the IntelliJ Project view (several modules / + * attached projects, possibly without a common parent) and guard against the regression where + * sibling roots sharing a textual prefix were dropped because containment was computed with a + * naive [String.startsWith] instead of a path-segment comparison. + */ +class WorkspacePathsTest : TestCase() { + + fun `test empty input returns empty list`() { + assertEquals(emptyList(), WorkspacePaths.topLevelWorkspacePaths(emptyList())) + } + + fun `test single root is kept`() { + val roots = listOf("file:///home/user/project") + assertEquals(roots, WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test two unrelated projects under different parents are both kept`() { + // Problem B: several projects in the same view, not under a common parent. + val roots = listOf( + "file:///home/user/frontend", + "file:///var/lib/backend", + ) + assertEquals(roots, WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test two sibling projects sharing a textual prefix are both kept`() { + // Regression: "service-api" must NOT be treated as nested inside "service". + val roots = listOf( + "file:///ws/service", + "file:///ws/service-api", + ) + val result = WorkspacePaths.topLevelWorkspacePaths(roots) + assertTrue("service must be kept", result.contains("file:///ws/service")) + assertTrue("service-api must be kept", result.contains("file:///ws/service-api")) + assertEquals(2, result.size) + } + + fun `test prefix sibling is kept regardless of declaration order`() { + val roots = listOf( + "file:///ws/service-api", + "file:///ws/service", + ) + assertEquals(2, WorkspacePaths.topLevelWorkspacePaths(roots).size) + } + + fun `test genuinely nested module root is dropped`() { + val roots = listOf( + "file:///ws/app", + "file:///ws/app/submodule", + ) + assertEquals(listOf("file:///ws/app"), WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test nested root is dropped even when listed before its parent`() { + val roots = listOf( + "file:///ws/app/submodule", + "file:///ws/app", + ) + assertEquals(listOf("file:///ws/app"), WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test mixed nested and prefix siblings`() { + val roots = listOf( + "file:///ws/service", + "file:///ws/service/core", // nested -> dropped + "file:///ws/service-api", // sibling -> kept + "file:///other/standalone", // unrelated -> kept + ) + val result = WorkspacePaths.topLevelWorkspacePaths(roots) + assertEquals( + listOf( + "file:///ws/service", + "file:///ws/service-api", + "file:///other/standalone", + ), + result, + ) + } + + fun `test duplicates are removed and order preserved`() { + val roots = listOf( + "file:///ws/a", + "file:///ws/b", + "file:///ws/a", + ) + assertEquals(listOf("file:///ws/a", "file:///ws/b"), WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test trailing slashes are normalized`() { + val roots = listOf( + "file:///ws/a/", + "file:///ws/a", + ) + assertEquals(listOf("file:///ws/a"), WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test blank entries are discarded`() { + val roots = listOf( + "file:///ws/a", + "", + "/", + ) + assertEquals(listOf("file:///ws/a"), WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test remote WSL siblings sharing a prefix are both kept`() { + // Problem A context (remote/WSL URIs) combined with Problem B (multiple projects). + val roots = listOf( + "file://wsl.localhost/Ubuntu/home/user/proj", + "file://wsl.localhost/Ubuntu/home/user/proj-tests", + ) + assertEquals(2, WorkspacePaths.topLevelWorkspacePaths(roots).size) + } + + fun `test remote WSL nested root is dropped`() { + val roots = listOf( + "file://wsl.localhost/Ubuntu/home/user/proj", + "file://wsl.localhost/Ubuntu/home/user/proj/module", + ) + assertEquals( + listOf("file://wsl.localhost/Ubuntu/home/user/proj"), + WorkspacePaths.topLevelWorkspacePaths(roots), + ) + } + + fun `test roots with different authorities are not nested`() { + val roots = listOf( + "file://hostA/ws/proj", + "file://hostB/ws/proj/inner", + ) + // Different authorities -> neither is nested under the other. + assertEquals(2, WorkspacePaths.topLevelWorkspacePaths(roots).size) + } + + fun `test windows drive siblings sharing a prefix are both kept`() { + val roots = listOf( + "file:///C:/dev/service", + "file:///C:/dev/service-api", + ) + assertEquals(2, WorkspacePaths.topLevelWorkspacePaths(roots).size) + } + + fun `test removing a parent module promotes the previously nested child to top-level`() { + // Models what moduleRemoved now does: recompute top-level roots from the REMAINING modules. + // Before removal the child is (correctly) hidden under its parent... + val beforeRemoval = listOf("file:///ws/app", "file:///ws/app/submodule") + assertEquals(listOf("file:///ws/app"), WorkspacePaths.topLevelWorkspacePaths(beforeRemoval)) + // ...and once the parent module is gone, the child must surface as its own top-level root + // (a plain exact-match subtraction from the stored array would never re-add it). + val afterRemoval = listOf("file:///ws/app/submodule") + assertEquals(listOf("file:///ws/app/submodule"), WorkspacePaths.topLevelWorkspacePaths(afterRemoval)) + } + + fun `test deeply nested chain collapses to the single shallowest root`() { + val roots = listOf( + "file:///ws/a/b/c", + "file:///ws/a", + "file:///ws/a/b", + ) + assertEquals(listOf("file:///ws/a"), WorkspacePaths.topLevelWorkspacePaths(roots)) + } + + fun `test segment boundary - parent of parent is not confused with a same-prefixed sibling`() { + val roots = listOf( + "file:///ws/a", + "file:///ws/ab/c", // sibling tree sharing the 'a' textual prefix -> kept + ) + assertEquals(2, WorkspacePaths.topLevelWorkspacePaths(roots).size) + } + + fun `test filesystem root URI is preserved`() { + // Regression: blindly trimming the trailing "/" would turn "file:///" into "file://". + assertEquals(listOf("file:///"), WorkspacePaths.topLevelWorkspacePaths(listOf("file:///"))) + } + + fun `test windows drive root URI is preserved`() { + // Regression: "file:///C:/" must not be trimmed to the rootless "file:///C:". + assertEquals(listOf("file:///C:/"), WorkspacePaths.topLevelWorkspacePaths(listOf("file:///C:/"))) + } + + // --- isNestedUnder direct checks ------------------------------------------------------- + + fun `test isNestedUnder true for real child`() { + assertTrue(WorkspacePaths.isNestedUnder("file:///ws/app/sub", "file:///ws/app")) + } + + fun `test isNestedUnder false for textual prefix sibling`() { + assertFalse(WorkspacePaths.isNestedUnder("file:///ws/app-2", "file:///ws/app")) + } + + fun `test isNestedUnder false for identical paths`() { + assertFalse(WorkspacePaths.isNestedUnder("file:///ws/app", "file:///ws/app")) + } + + fun `test isNestedUnder false when child is shallower`() { + assertFalse(WorkspacePaths.isNestedUnder("file:///ws", "file:///ws/app")) + } + + fun `test isNestedUnder ignores trailing slash`() { + assertTrue(WorkspacePaths.isNestedUnder("file:///ws/app/sub/", "file:///ws/app/")) + } +}