Merge pull request #11813 from Kilo-Org/luck-family

fix(jetbrains): resolve workspace by project id
This commit is contained in:
Kirill Kalishev
2026-06-30 09:26:31 -04:00
committed by GitHub
8 changed files with 79 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep JetBrains sessions scoped to the correct worktree when multiple IntelliJ windows are open.
@@ -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>): 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<String, Boolean> = ConcurrentHashMap()): Boolean {
if (Files.exists(base.resolve(".git"))) return true
return cache.getOrPut(base.toString()) {
@@ -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
@@ -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<KiloWorkspaceService>()
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)
@@ -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)
@@ -79,7 +79,7 @@ internal abstract class BaseSettingsUi<C : BaseContentPanel, D, P, R, W>(
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
@@ -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
}
@@ -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<Unit> {
/**
* 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<KiloWorkspaceStateDto>