Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -201,33 +200,25 @@ class ContinuePluginStartupActivity : StartupActivity, DumbAware {
ModuleListener.TOPIC,
object : ModuleListener {
override fun modulesAdded(project: Project, modules: MutableList<out Module>) {

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(
project: Project,
modules: MutableList<out Module>,
oldNameProvider: Function<in Module, String>
) {
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)
}
}
)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -56,14 +55,7 @@ class GitService(
return diffs
}

private fun workspaceDirectories(): Array<String> {
val dirs = this.continuePluginService.workspacePaths
private fun workspaceDirectories(): Array<String> =
resolveWorkspacePathsOrGuess(project, continuePluginService.workspacePaths)

if (dirs?.isNotEmpty() == true) {
return dirs
}

return listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray()
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -724,14 +724,7 @@ class IntelliJIDE(
}
}

private fun workspaceDirectories(): Array<String> {
val dirs = this.continuePluginService.workspacePaths

if (dirs?.isNotEmpty() == true) {
return dirs
}

return listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray()
}
private fun workspaceDirectories(): Array<String> =
resolveWorkspacePathsOrGuess(project, continuePluginService.workspacePaths)

}
Original file line number Diff line number Diff line change
@@ -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<String>): List<String> {
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<String> = 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<String>?): Array<String> {
if (storedPaths?.isNotEmpty() == true) {
return storedPaths
}

val scanned = resolveWorkspacePaths(project)
if (scanned.isNotEmpty()) {
return scanned
}

return runReadAction { listOfNotNull(project.guessProjectDir()?.toUriOrNull()).toTypedArray() }
}
Original file line number Diff line number Diff line change
@@ -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<Project>()

override fun setUp() {
// Make runReadAction { body } just execute body (no real Application needed).
mockkStatic(ApplicationManager::class)
val app = mockk<Application>()
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<Computable<Array<String>>>()) } answers {
firstArg<Computable<Array<String>>>().compute()
}
every { app.runReadAction(any<ThrowableComputable<Array<String>, Throwable>>()) } answers {
firstArg<ThrowableComputable<Array<String>, 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<Module>()
val rootManager = mockk<ModuleRootManager>()
val roots = nioPaths.map { p ->
val fs = mockk<VirtualFileSystem>()
val vf = mockk<VirtualFile>()
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<ModuleManager>()
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<String>(), resolveWorkspacePaths(project).toList())
}
}
Loading
Loading