From a824c4cba9604e85f857215ef772efec13af2278 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 29 Jun 2026 18:41:17 -0400 Subject: [PATCH] fix(jetbrains): resolve workspace by project id --- .changeset/smart-melons-float.md | 5 +++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 32 ++++++++++++----- .../backend/rpc/WorkspacePathScopingTest.kt | 34 +++++++++++++++++++ .../kilocode/client/KiloToolWindowFactory.kt | 5 ++- .../client/app/KiloWorkspaceService.kt | 7 ++-- .../client/settings/base/BaseSettingsUi.kt | 2 +- .../client/testing/FakeWorkspaceRpcApi.kt | 3 +- .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 9 ++--- 8 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 .changeset/smart-melons-float.md diff --git a/.changeset/smart-melons-float.md b/.changeset/smart-melons-float.md new file mode 100644 index 0000000000..811f356995 --- /dev/null +++ b/.changeset/smart-melons-float.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains sessions scoped to the correct worktree when multiple IntelliJ windows are open. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 5b54f93cfa..9d2ceab3f8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -40,6 +40,8 @@ import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.findProjectOrNull import com.intellij.navigation.NavigationItem import com.intellij.psi.PsiFileSystemItem import com.intellij.psi.search.GlobalSearchScope @@ -68,8 +70,8 @@ import kotlin.coroutines.resume * Backend implementation of [KiloWorkspaceRpcApi]. * * Routes through the [KiloBackendWorkspaceManager] to get a workspace - * for the given directory. No [ProjectManager] dependency — any - * directory (including worktrees) can get a workspace. + * for the given directory. Project lookup is only used to resolve the + * calling frontend project to the correct backend directory. */ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { companion object { @@ -94,12 +96,15 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { private val manager: KiloBackendWorkspaceManager get() = app.workspaces - override suspend fun resolveProjectDirectory(hint: String): String { - // In monolith mode, find the open project whose basePath matches the hint. - // In split mode, the backend's project.basePath is the real directory. - val projects = ProjectManager.getInstance().openProjects - val match = projects.firstOrNull { !it.isDefault } - return match?.basePath ?: hint + override suspend fun resolveProjectDirectory(projectId: ProjectId?, hint: String): String { + // Experimental IntelliJ ProjectId API: maps the calling frontend project + // to the matching backend project across monolith windows and split mode. + val base = projectId?.findProjectOrNull()?.takeIf { !it.isDefault }?.basePath + if (base != null) return base + val bases = ProjectManager.getInstance().openProjects + .filter { !it.isDefault } + .mapNotNull { it.basePath } + return resolveProjectDirectoryHint(hint, bases) } /** @@ -445,6 +450,17 @@ internal fun normalizeWorkspacePath(path: String): String? { } } +internal fun resolveProjectDirectoryHint(hint: String, bases: List): String { + val clean = normalizeWorkspacePath(hint) + val match = bases.firstOrNull { base -> + val path = normalizeWorkspacePath(base) + path != null && clean != null && path == clean + } + if (match != null) return match + if (hint.isNotBlank()) return hint + return bases.firstOrNull() ?: hint +} + internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap = ConcurrentHashMap()): Boolean { if (Files.exists(base.resolve(".git"))) return true return cache.getOrPut(base.toString()) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt index 673220ad08..3da5f00e30 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt @@ -77,6 +77,40 @@ class WorkspacePathScopingTest { assertNull(normalizeWorkspacePath("file://%")) } + @Test + fun `project directory hint matches second open project`() { + assertEquals( + "/repo/wt-b", + resolveProjectDirectoryHint("/repo/wt-b", listOf("/repo/wt-a", "/repo/wt-b")), + ) + } + + @Test + fun `unmatched project directory hint is preserved`() { + assertEquals( + "/repo/wt-c", + resolveProjectDirectoryHint("/repo/wt-c", listOf("/repo/wt-a", "/repo/wt-b")), + ) + } + + @Test + fun `blank project directory hint falls back to first project`() { + assertEquals("/repo/wt-a", resolveProjectDirectoryHint("", listOf("/repo/wt-a", "/repo/wt-b"))) + } + + @Test + fun `blank project directory hint without projects stays blank`() { + assertEquals("", resolveProjectDirectoryHint("", emptyList())) + } + + @Test + fun `project directory hint comparison normalizes paths`() { + assertEquals( + "/repo/wt-b", + resolveProjectDirectoryHint("/repo/wt-b/./", listOf("/repo/wt-a", "/repo/wt-b")), + ) + } + @Test fun `git availability detects temp repository`() { val dir = repo() ?: return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index b0b640d3ea..d2a015f951 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -12,6 +12,7 @@ import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.platform.project.projectIdOrNull import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -44,9 +45,11 @@ internal class KiloToolWindowSetupService( try { val workspaces = service() val hint = project.basePath ?: "" + // Experimental IntelliJ ProjectId API keeps multi-window and split-mode routing exact. + val pid = project.projectIdOrNull() cs.launch { - val dir = workspaces.resolveProjectDirectory(hint) + val dir = workspaces.resolveProjectDirectory(pid, hint) val workspace = workspaces.workspace(dir) withContext(Dispatchers.Main) { setup(project, toolWindow, workspace) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 3b9558bdbd..14aa80eb69 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog +import com.intellij.platform.project.ProjectId import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException @@ -88,10 +89,10 @@ class KiloWorkspaceService internal constructor( * `/home/.cache/JetBrains/RemoteDev/...`). The backend resolves * it to the actual project root on the host. */ - suspend fun resolveProjectDirectory(hint: String): String { + suspend fun resolveProjectDirectory(projectId: ProjectId?, hint: String): String { return try { - val resolved = call { resolveProjectDirectory(hint) } - LOG.info("Resolved project directory: hint=$hint → $resolved") + val resolved = call { resolveProjectDirectory(projectId, hint) } + LOG.info("Resolved project directory: projectId=$projectId hint=$hint -> $resolved") resolved } catch (e: Exception) { LOG.warn("Failed to resolve directory, falling back to hint=$hint", e) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt index 727cf0b0e8..63ff3bf2f7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -79,7 +79,7 @@ internal abstract class BaseSettingsUi( jobs += scope.launch { app.connect() } val path = hint ?: return jobs += scope.launch { - val dir = workspaces.resolveProjectDirectory(path) + val dir = workspaces.resolveProjectDirectory(null, path) withContext(edt) { projectDirectory = dir workspaceLoaded = false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index c2cf8d39da..0bb64d19a6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -7,6 +7,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.platform.project.ProjectId import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -50,7 +51,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var globalConfigPathCalls = 0 private set - override suspend fun resolveProjectDirectory(hint: String): String { + override suspend fun resolveProjectDirectory(projectId: ProjectId?, hint: String): String { assertNotEdt("resolveProjectDirectory") return directory } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 3c8c0d00da..f3ed8fde5d 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.platform.project.ProjectId import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -29,11 +30,11 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** * Resolve the real project directory as seen by the backend. * - * In split mode, the frontend's [Project.getBasePath] returns a - * synthetic sandbox path. This method returns the backend's actual - * project directory so the frontend can use it for CLI server calls. + * [projectId] identifies the exact calling frontend project across the + * frontend/backend boundary. [hint] is the frontend's project path and is + * used as a fallback if the project cannot be resolved on the backend. */ - suspend fun resolveProjectDirectory(hint: String): String + suspend fun resolveProjectDirectory(projectId: ProjectId?, hint: String): String /** Observe workspace state loading progress. */ suspend fun state(directory: String): Flow