Skip to content

Commit 04f23f2

Browse files
committed
fix(intellij): resolve workspace paths for multi-project windows
When an IntelliJ window holds several modules or attached projects and two sibling roots share a textual prefix (e.g. .../service and .../service-api), the naive String.startsWith() containment check treated one as nested in the other and dropped it from workspacePaths — so @-references, file lists, listDir and indexing were broken for that project. Containment is now computed on URI path segments (WorkspacePaths.isNestedUnder), so prefix-sharing siblings are kept as separate top-level roots. All workspace-path writers (startup + the modulesAdded / moduleRemoved / modulesRenamed listeners) and both consumers (IntelliJIDE and GitService workspaceDirectories()) route through a single resolveWorkspacePaths / resolveWorkspacePathsOrGuess helper, so the discovered roots can never drift between callers. Adds unit tests for the segment-aware de-nesting and the module-scan glue.
1 parent d0a3c0b commit 04f23f2

6 files changed

Lines changed: 468 additions & 47 deletions

File tree

extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/activities/ContinuePluginStartupActivity.kt

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.github.continuedev.continueintellijextension.listeners.ContinuePlugin
99
import com.github.continuedev.continueintellijextension.services.ContinueExtensionSettings
1010
import com.github.continuedev.continueintellijextension.services.ContinuePluginService
1111
import com.github.continuedev.continueintellijextension.services.SettingsListener
12+
import com.github.continuedev.continueintellijextension.utils.resolveWorkspacePaths
1213
import com.github.continuedev.continueintellijextension.utils.toUriOrNull
1314
import com.intellij.openapi.actionSystem.KeyboardShortcut
1415
import com.intellij.openapi.application.ApplicationManager
@@ -28,9 +29,7 @@ import java.nio.file.Paths
2829
import javax.swing.*
2930
import com.intellij.openapi.components.service
3031
import com.intellij.openapi.module.Module
31-
import com.intellij.openapi.module.ModuleManager
3232
import com.intellij.openapi.project.ModuleListener
33-
import com.intellij.openapi.roots.ModuleRootManager
3433
import com.intellij.openapi.vfs.VirtualFileManager
3534
import com.intellij.openapi.vfs.newvfs.BulkFileListener
3635
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
@@ -201,33 +200,25 @@ class ContinuePluginStartupActivity : StartupActivity, DumbAware {
201200
ModuleListener.TOPIC,
202201
object : ModuleListener {
203202
override fun modulesAdded(project: Project, modules: MutableList<out Module>) {
204-
205-
val allModulePaths = ModuleManager.getInstance(project).modules
206-
.flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } }
207-
208-
val topLevelModulePaths = allModulePaths
209-
.filter { modulePath -> allModulePaths.none { it != modulePath && modulePath.startsWith(it) } }
210-
211-
continuePluginService.workspacePaths = topLevelModulePaths.toTypedArray();
203+
continuePluginService.workspacePaths = resolveWorkspacePaths(project)
212204
}
213205

214206
override fun moduleRemoved(project: Project, module: Module) {
215-
val removedPaths = ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } ;
216-
continuePluginService.workspacePaths = continuePluginService.workspacePaths?.toList()?.filter { path -> removedPaths.none {removedPath -> path == removedPath }}?.toTypedArray();
207+
// Re-derive top-level roots from the remaining modules instead of a plain
208+
// subtraction: removing a module can promote a formerly-nested sibling to a
209+
// top-level root, which an exact-match removal would never re-add.
210+
// Contract: moduleRemoved fires *after* the module has left ModuleManager
211+
// (beforeModuleRemoved is the pre-removal hook), so the rescan below already
212+
// excludes it — and we never touch the disposed `module` param.
213+
continuePluginService.workspacePaths = resolveWorkspacePaths(project)
217214
}
218215

219216
override fun modulesRenamed(
220217
project: Project,
221218
modules: MutableList<out Module>,
222219
oldNameProvider: Function<in Module, String>
223220
) {
224-
val allModulePaths = ModuleManager.getInstance(project).modules
225-
.flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } }
226-
227-
val topLevelModulePaths = allModulePaths
228-
.filter { modulePath -> allModulePaths.none { it != modulePath && modulePath.startsWith(it) } }
229-
230-
continuePluginService.workspacePaths = topLevelModulePaths.toTypedArray()
221+
continuePluginService.workspacePaths = resolveWorkspacePaths(project)
231222
}
232223
}
233224
)
@@ -262,13 +253,8 @@ class ContinuePluginStartupActivity : StartupActivity, DumbAware {
262253

263254
// Reload the WebView
264255
continuePluginService?.let { pluginService ->
265-
val allModulePaths = ModuleManager.getInstance(project).modules
266-
.flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } }
267-
268-
val topLevelModulePaths = allModulePaths
269-
.filter { modulePath -> allModulePaths.none { it != modulePath && modulePath.startsWith(it) } }
270-
271-
pluginService.workspacePaths = topLevelModulePaths.toTypedArray()
256+
val topLevelModulePaths = resolveWorkspacePaths(project)
257+
pluginService.workspacePaths = topLevelModulePaths
272258
}
273259

274260
EditorFactory.getInstance().eventMulticaster.addSelectionListener(

extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/GitService.kt

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.github.continuedev.continueintellijextension.`continue`
22

33
import com.github.continuedev.continueintellijextension.services.ContinuePluginService
4-
import com.github.continuedev.continueintellijextension.utils.toUriOrNull
4+
import com.github.continuedev.continueintellijextension.utils.resolveWorkspacePathsOrGuess
55
import com.intellij.openapi.project.Project
6-
import com.intellij.openapi.project.guessProjectDir
76
import kotlinx.coroutines.Dispatchers
87
import kotlinx.coroutines.withContext
98
import java.io.BufferedReader
@@ -56,14 +55,7 @@ class GitService(
5655
return diffs
5756
}
5857

59-
private fun workspaceDirectories(): Array<String> {
60-
val dirs = this.continuePluginService.workspacePaths
58+
private fun workspaceDirectories(): Array<String> =
59+
resolveWorkspacePathsOrGuess(project, continuePluginService.workspacePaths)
6160

62-
if (dirs?.isNotEmpty() == true) {
63-
return dirs
64-
}
65-
66-
return listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray()
67-
}
68-
69-
}
61+
}

extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/continue/IntelliJIde.kt

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -724,14 +724,7 @@ class IntelliJIDE(
724724
}
725725
}
726726

727-
private fun workspaceDirectories(): Array<String> {
728-
val dirs = this.continuePluginService.workspacePaths
729-
730-
if (dirs?.isNotEmpty() == true) {
731-
return dirs
732-
}
733-
734-
return listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray()
735-
}
727+
private fun workspaceDirectories(): Array<String> =
728+
resolveWorkspacePathsOrGuess(project, continuePluginService.workspacePaths)
736729

737730
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.github.continuedev.continueintellijextension.utils
2+
3+
import com.intellij.openapi.application.runReadAction
4+
import com.intellij.openapi.module.ModuleManager
5+
import com.intellij.openapi.project.Project
6+
import com.intellij.openapi.project.guessProjectDir
7+
import com.intellij.openapi.roots.ModuleRootManager
8+
9+
/**
10+
* Helpers for resolving the set of workspace directories that Continue exposes to the
11+
* core (file references, @-context providers, indexing, git diff, ...).
12+
*
13+
* A JetBrains project can hold several modules — and several *attached* projects living side
14+
* by side in the same Project view, without necessarily sharing a common parent directory.
15+
* Each module contributes one or more content-root URIs. We only want to keep the "top-level"
16+
* roots, dropping any root that is physically nested inside another one (a module whose content
17+
* root lives under another module's content root).
18+
*
19+
* IMPORTANT: containment MUST be computed on URI *path segments*, never on a raw string prefix.
20+
* Two sibling roots can share a textual prefix without one being nested in the other, e.g.
21+
* "file:///ws/service" and "file:///ws/service-api". Using [String.startsWith] would wrongly
22+
* treat "service-api" as nested in "service" and silently drop it, so the second project's
23+
* files would never reach the core and every path under it would look broken.
24+
*
25+
* This mirrors the core's own resolution logic — see `core/util/uri.ts` `findUriInDirs`:
26+
* "Can't just use startsWith because e.g. file:///folder/file is not within file:///fold".
27+
*/
28+
object WorkspacePaths {
29+
30+
/**
31+
* Returns the top-level content-root URIs out of [moduleRootUris], i.e. every root that is
32+
* not nested inside another root.
33+
*
34+
* - Duplicates are removed and the input order of the surviving roots is preserved.
35+
* - Trailing slashes are ignored when comparing, and blank entries are discarded.
36+
*/
37+
fun topLevelWorkspacePaths(moduleRootUris: List<String>): List<String> {
38+
val roots = moduleRootUris
39+
.map { trimTrailingSlash(it) }
40+
.filter { it.isNotEmpty() }
41+
.distinct()
42+
43+
return roots.filter { candidate ->
44+
roots.none { other -> other != candidate && isNestedUnder(candidate, other) }
45+
}
46+
}
47+
48+
/**
49+
* True when [childUri] lives strictly under [parentUri], comparing on "/"-delimited URI
50+
* segments so that textual siblings (e.g. ".../api" vs ".../api-v2") are never mistaken for
51+
* a parent/child relationship. A root is never considered nested under itself.
52+
*/
53+
fun isNestedUnder(childUri: String, parentUri: String): Boolean {
54+
val child = childUri.removeSuffix("/").split("/")
55+
val parent = parentUri.removeSuffix("/").split("/")
56+
// The child must be strictly deeper than the parent (equal or shallower can't be nested).
57+
if (child.size <= parent.size) return false
58+
for (i in parent.indices) {
59+
if (parent[i] != child[i]) return false
60+
}
61+
return true
62+
}
63+
64+
/**
65+
* Removes a single trailing "/" so that ".../proj" and ".../proj/" compare equal, but leaves a
66+
* root URI intact: we must never turn "file:///" into the malformed "file://", nor a bare drive
67+
* root "file:///C:/" into "file:///C:".
68+
*/
69+
private fun trimTrailingSlash(uri: String): String {
70+
if (!uri.endsWith("/")) return uri
71+
val trimmed = uri.dropLast(1)
72+
val lastSegment = trimmed.substringAfter("://", trimmed).substringAfterLast('/')
73+
return if (lastSegment.isEmpty() || lastSegment.endsWith(":")) uri else trimmed
74+
}
75+
}
76+
77+
/**
78+
* Resolves the de-nested, de-duplicated top-level workspace directory URIs for [project] by
79+
* scanning every module's content roots.
80+
*
81+
* This is the single source of truth used by all workspace-path writers — the startup activity,
82+
* every [com.intellij.openapi.project.ModuleListener] callback (added / removed / renamed), and the
83+
* runtime fallback in `IntelliJIDE.workspaceDirectories()`. Routing them all through here guarantees
84+
* that a JetBrains window holding several modules / attached projects always exposes the same set of
85+
* roots, and that removing a module re-derives top-level status (e.g. promoting a formerly-nested
86+
* child once its parent module is gone) instead of leaving a stale array.
87+
*
88+
* Wrapped in a read action so it is safe to call from any thread.
89+
*/
90+
fun resolveWorkspacePaths(project: Project): Array<String> = runReadAction {
91+
val moduleRootUris = ModuleManager.getInstance(project).modules
92+
.flatMap { module -> ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.toUriOrNull() } }
93+
WorkspacePaths.topLevelWorkspacePaths(moduleRootUris).toTypedArray()
94+
}
95+
96+
/**
97+
* Resolves the workspace directory URIs to expose to the core, with a robust three-step fallback:
98+
*
99+
* 1. [storedPaths] — the array maintained by the startup activity and the ModuleListener
100+
* callbacks (the normal, steady-state path);
101+
* 2. a fresh [resolveWorkspacePaths] scan of the project's modules — so a window holding several
102+
* modules / attached projects still gets *every* top-level root even when the core queries
103+
* before the listener has populated the cache (transient window right after open);
104+
* 3. the single guessed project dir, as a last resort when no module exposes a content root.
105+
*
106+
* Both `IntelliJIDE.workspaceDirectories()` and `GitService.workspaceDirectories()` route through
107+
* this, so the two consumers can never drift apart and the multi-project case is handled identically
108+
* for @-references, indexing and git diff alike.
109+
*
110+
* Note on performance: step 2 only runs while [storedPaths] is empty (a brief window before the
111+
* listener fires); once the cache is populated every call returns in step 1, so the module scan is
112+
* not on the steady-state hot path.
113+
*/
114+
fun resolveWorkspacePathsOrGuess(project: Project, storedPaths: Array<String>?): Array<String> {
115+
if (storedPaths?.isNotEmpty() == true) {
116+
return storedPaths
117+
}
118+
119+
val scanned = resolveWorkspacePaths(project)
120+
if (scanned.isNotEmpty()) {
121+
return scanned
122+
}
123+
124+
return runReadAction { listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray() }
125+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.github.continuedev.continueintellijextension.unit
2+
3+
import com.github.continuedev.continueintellijextension.utils.resolveWorkspacePaths
4+
import com.intellij.openapi.application.Application
5+
import com.intellij.openapi.application.ApplicationManager
6+
import com.intellij.openapi.module.Module
7+
import com.intellij.openapi.module.ModuleManager
8+
import com.intellij.openapi.project.Project
9+
import com.intellij.openapi.roots.ModuleRootManager
10+
import com.intellij.openapi.util.Computable
11+
import com.intellij.openapi.util.ThrowableComputable
12+
import com.intellij.openapi.vfs.VirtualFile
13+
import com.intellij.openapi.vfs.VirtualFileSystem
14+
import io.mockk.every
15+
import io.mockk.mockk
16+
import io.mockk.mockkStatic
17+
import io.mockk.unmockkAll
18+
import junit.framework.TestCase
19+
import java.nio.file.Path
20+
21+
/**
22+
* Pure (no IntelliJ platform boot) test of the [resolveWorkspacePaths] glue.
23+
*
24+
* It mocks ModuleManager / ModuleRootManager / VirtualFile so the *whole* chain runs in a plain
25+
* JVM — `ModuleManager.modules` → each module's content roots → [VirtualFile.toUriOrNull] →
26+
* de-nesting — and asserts a multi-module window exposes every top-level root (including a sibling
27+
* pair sharing a textual prefix, the Problem B regression). This runs everywhere, including the
28+
* sandboxes where the heavy platform test framework cannot boot.
29+
*/
30+
class ResolveWorkspacePathsTest : TestCase() {
31+
32+
private val project = mockk<Project>()
33+
34+
override fun setUp() {
35+
// Make runReadAction { body } just execute body (no real Application needed).
36+
mockkStatic(ApplicationManager::class)
37+
val app = mockk<Application>()
38+
every { ApplicationManager.getApplication() } returns app
39+
// runReadAction's inline wrapper may forward to either the Computable or the
40+
// ThrowableComputable overload depending on the platform version — stub both.
41+
every { app.runReadAction(any<Computable<Array<String>>>()) } answers {
42+
firstArg<Computable<Array<String>>>().compute()
43+
}
44+
every { app.runReadAction(any<ThrowableComputable<Array<String>, Throwable>>()) } answers {
45+
firstArg<ThrowableComputable<Array<String>, Throwable>>().compute()
46+
}
47+
mockkStatic(ModuleManager::class)
48+
mockkStatic(ModuleRootManager::class)
49+
}
50+
51+
override fun tearDown() = unmockkAll()
52+
53+
/** A module whose content roots resolve (via toUriOrNull's NIO branch) to the given paths. */
54+
private fun moduleWithRoots(vararg nioPaths: String): Module {
55+
val module = mockk<Module>()
56+
val rootManager = mockk<ModuleRootManager>()
57+
val roots = nioPaths.map { p ->
58+
val fs = mockk<VirtualFileSystem>()
59+
val vf = mockk<VirtualFile>()
60+
every { vf.fileSystem } returns fs
61+
every { fs.getNioPath(vf) } returns Path.of(p)
62+
// `name` may be read by toUriOrNull in some code paths; stub defensively.
63+
every { vf.name } returns Path.of(p).fileName.toString()
64+
vf
65+
}.toTypedArray()
66+
every { ModuleRootManager.getInstance(module) } returns rootManager
67+
every { rootManager.contentRoots } returns roots
68+
return module
69+
}
70+
71+
private fun setModules(vararg modules: Module) {
72+
val mm = mockk<ModuleManager>()
73+
every { ModuleManager.getInstance(project) } returns mm
74+
every { mm.modules } returns arrayOf(*modules)
75+
}
76+
77+
/**
78+
* Mirrors how toUriOrNull's NIO branch derives a URI, so the assertions stay OS-independent:
79+
* Path.of("/ws/x").toUri() yields "file:///ws/x" on Unix but "file:///C:/ws/x" on Windows.
80+
*/
81+
private fun expectedUri(nioPath: String): String =
82+
Path.of(nioPath).toUri().toString().removeSuffix("/")
83+
84+
fun `test multi-module sibling roots with shared prefix all surface`() {
85+
setModules(
86+
moduleWithRoots("/ws/service"),
87+
moduleWithRoots("/ws/service-api"), // shares the "service" textual prefix
88+
moduleWithRoots("/ws/backend"),
89+
)
90+
91+
val result = resolveWorkspacePaths(project).toList()
92+
93+
assertEquals(
94+
listOf(expectedUri("/ws/service"), expectedUri("/ws/service-api"), expectedUri("/ws/backend")),
95+
result,
96+
)
97+
}
98+
99+
fun `test nested content root is dropped while siblings are kept`() {
100+
setModules(
101+
moduleWithRoots("/ws/app", "/ws/app/submodule"), // one module, second root nested
102+
moduleWithRoots("/ws/app-extras"), // prefix sibling of /ws/app
103+
)
104+
105+
val result = resolveWorkspacePaths(project).toList()
106+
107+
assertEquals(listOf(expectedUri("/ws/app"), expectedUri("/ws/app-extras")), result)
108+
}
109+
110+
fun `test empty project yields no roots`() {
111+
setModules()
112+
113+
assertEquals(emptyList<String>(), resolveWorkspacePaths(project).toList())
114+
}
115+
}

0 commit comments

Comments
 (0)