From 106b5f4d5aea047e21a0ddc923d98e49c78ccef8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 12 Aug 2026 19:42:02 -0400 Subject: [PATCH] feat(jetbrains): open worktrees in new window --- .../jetbrains-worktree-open-in-new-window.md | 5 ++ .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 45 +++++++++++ .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 5 ++ .../worktree/KiloWorktreeService.kt | 10 +++ .../worktree/WorktreeSessionEditorPanel.kt | 75 ++++++++++++++++++- .../resources/messages/KiloBundle.properties | 1 + .../agentManager/WorktreeControllerTest.kt | 19 +++++ .../WorktreeSessionEditorPanelTest.kt | 31 +++++++- .../client/testing/FakeWorktreeRpcApi.kt | 8 ++ .../ai/kilocode/rpc/KiloWorktreeRpcApi.kt | 8 ++ 10 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 .changeset/jetbrains-worktree-open-in-new-window.md diff --git a/.changeset/jetbrains-worktree-open-in-new-window.md b/.changeset/jetbrains-worktree-open-in-new-window.md new file mode 100644 index 00000000000..1e9235a3f28 --- /dev/null +++ b/.changeset/jetbrains-worktree-open-in-new-window.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add an "Open worktree in new window" button to the Agent Manager worktree toolbar that opens the worktree directory in a new IDE frame. The project is opened on the backend/host, so it also works in remote development. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index d17e6e8a268..b7cee490f79 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -18,6 +18,13 @@ import ai.kilocode.rpc.dto.WorktreeStatsListDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.configurations.GeneralCommandLine.ParentEnvironmentType import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.ide.impl.OpenProjectTask +import com.intellij.ide.impl.ProjectUtil +import com.intellij.openapi.application.EDT +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.openapi.wm.WindowManager +import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -33,6 +40,7 @@ import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive +import java.awt.Frame import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption @@ -66,6 +74,43 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { WorktreeListDto(orderWorktrees(named, state.worktreeOrder)) } + override suspend fun open(directory: String): Boolean { + val dir = Path.of(directory).normalize() + val exists = withContext(Dispatchers.IO) { Files.isDirectory(dir) } + if (!exists) { + LOG.warn("worktree open skipped, not a directory: $directory") + return false + } + // The frontend focuses an already-open frame itself because it owns the windows the user sees. + // This is the safety net: never force-open an already-open path, because IntelliJ skips the + // existing-project guard when forceOpenInNewFrame is true and may reopen after the first frame + // closes. + val open = withContext(Dispatchers.EDT) { ProjectUtil.findProject(dir) } + if (open != null) { + withContext(Dispatchers.EDT) { focusFrame(open) } + LOG.info("worktree open: already open, focused on host: dir=$dir") + return true + } + val project = ProjectUtil.openOrImportAsync(dir, OpenProjectTask { forceOpenInNewFrame = true }) + LOG.info("worktree open requested: dir=$dir opened=${project != null}") + return project != null + } + + /** + * Brings [project]'s frame to the front and moves focus into it, mirroring the platform window + * switcher (com.intellij.openapi.wm.impl.ProjectWindowAction). Only effective in monolithic mode; + * in remote development the visible windows live in the frontend client, which focuses them itself. + */ + @RequiresEdt + private fun focusFrame(project: Project) { + val frame = WindowManager.getInstance().getFrame(project) ?: return + val state = frame.extendedState + if (state and Frame.ICONIFIED != 0) frame.extendedState = state and Frame.ICONIFIED.inv() + frame.toFront() + val focus = IdeFocusManager.getGlobalInstance() + focus.doWhenFocusSettlesDown { frame.mostRecentFocusOwner?.let { focus.requestFocus(it, true) } } + } + override suspend fun listBranches(directory: String): WorktreeBranchesDto = withContext(Dispatchers.IO) { val base = Path.of(directory).normalize() val refs = runGit(base, "for-each-ref", "--format=%(refname:short)", "refs/heads") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index d373ead0977..82c7454267e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -25,6 +25,11 @@ class KiloWorktreeRpcApiImplTest { delete(repo) } + @Test + fun `open returns false when the directory does not exist`() = runBlocking { + assertFalse(api.open(repo.resolve("missing").toString())) + } + @Test fun `parseWorktreeList reads porcelain output and flags the main tree`() { val raw = """ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt index 6f2711e51ac..17c79db18ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsListDto import com.intellij.openapi.components.Service import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope /** @@ -52,6 +53,15 @@ class KiloWorktreeService internal constructor( WorktreeBranchesDto() } + suspend fun open(directory: String): Boolean = try { + call { open(directory) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("worktree open failed for $directory", e) + false + } + suspend fun stats(directory: String): WorktreeStatsListDto = try { call { stats(directory) } } catch (e: Exception) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index 5ba6dc629f3..0dcd689b077 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.history.HistorySection import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.util.bindTheme import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListBadge @@ -25,6 +26,7 @@ import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloVfsManager +import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto @@ -40,9 +42,13 @@ import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.components.service +import com.intellij.openapi.progress.currentThreadCoroutineScope import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.openapi.wm.WindowManager import com.intellij.ui.IdeBorderFactory import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SideBorder @@ -50,8 +56,12 @@ import com.intellij.ui.awt.RelativePoint import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.awt.BorderLayout import java.awt.Color +import java.awt.Frame +import java.nio.file.Path import javax.swing.JComponent import javax.swing.Icon import javax.swing.ListSelectionModel @@ -67,12 +77,14 @@ class WorktreeSessionEditorPanel( private val project: Project? = null, private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null, private val edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null, + private val openWorktree: ((String) -> Unit)? = null, ) : BorderLayoutPanel(), Disposable, UiDataProvider { private val add = NewAction() private val rename = RenameAction() private val delete = DeleteAction() private val toggle = ToggleAction() - private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(toggle, add, rename, delete), true) + private val openAction = OpenAction() + private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(toggle, openAction, add, rename, delete), true) private val group = ActionManager.getInstance().getAction("Kilo.WorktreeSession.RowMenu") as? ActionGroup ?: DefaultActionGroup() private val list = ActiveList( KiloBundle.message("worktree.session.list.empty"), @@ -249,6 +261,50 @@ class WorktreeSessionEditorPanel( splitter.repaint() } + @RequiresEdt + private fun openInNewFrame() { + val dir = worktree.directory.takeIf { it.isNotBlank() } ?: return + Telemetry.send("Worktree Opened In New Frame", mapOf("surface" to "worktree_toolbar")) + if (openWorktree != null) { + openWorktree.invoke(dir) + return + } + if (focusExistingFrame(dir)) return + currentThreadCoroutineScope().launch(Dispatchers.Default) { + service().open(dir) + } + } + + /** + * Focus runs in the frontend client because it owns the visible windows in remote development. + * Match on presentableUrl, the same project identity the platform Window menu uses, and never ask + * the backend to reopen once the worktree is already open. + */ + @RequiresEdt + private fun focusExistingFrame(dir: String): Boolean { + val target = Path.of(dir).normalize() + val projects = ProjectManager.getInstance().openProjects + val item = projects.firstOrNull { same(it.presentableUrl, target) || same(it.basePath, target) } + if (item == null) { + LOG.info("worktree focus: no open frame for $dir; open=" + projects.joinToString { "${it.name}@${it.presentableUrl}" }) + return false + } + val frame = WindowManager.getInstance().getFrame(item) + if (frame == null) { + LOG.info("worktree focus: ${item.name} is open but has no frame yet") + return true + } + val state = frame.extendedState + if (state and Frame.ICONIFIED != 0) frame.extendedState = state and Frame.ICONIFIED.inv() + frame.toFront() + val focus = IdeFocusManager.getGlobalInstance() + focus.doWhenFocusSettlesDown { frame.mostRecentFocusOwner?.let { focus.requestFocus(it, true) } } + return true + } + + private fun same(path: String?, target: Path): Boolean = + path != null && runCatching { Path.of(path).normalize() == target }.getOrDefault(false) + @RequiresEdt private fun openBranchDiff() { val target = project ?: return @@ -367,6 +423,22 @@ class WorktreeSessionEditorPanel( } } + private inner class OpenAction : AnAction( + KiloBundle.message("worktree.session.open.action"), + null, + AllIcons.Actions.MoveToWindow, + ) { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = worktree.directory.isNotBlank() + } + + override fun actionPerformed(e: AnActionEvent) { + openInNewFrame() + } + } + private inner class NewAction : AnAction( KiloBundle.message("worktree.session.new.action"), null, @@ -422,6 +494,7 @@ class WorktreeSessionEditorPanel( } private companion object { + private val LOG = KiloLog.create(WorktreeSessionEditorPanel::class.java) val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", WorktreeSessionEditorPanel::class.java) val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", WorktreeSessionEditorPanel::class.java) } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 1de3a3fc320..a38804607ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -353,6 +353,7 @@ worktree.session.fileType.displayName=Kilo Worktree Session worktree.session.fileType.description=Kilo worktree session virtual file worktree.session.list.empty=No sessions worktree.session.new.action=New session +worktree.session.open.action=Open worktree in new window worktree.session.rename.action=Rename session worktree.session.delete.action=Delete session worktree.session.list.expand=Show sessions diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index a5d2e55f1c9..a3f6f5569ee 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -22,6 +22,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch @Suppress("UnstableApiUsage") class WorktreeControllerTest : BasePlatformTestCase() { @@ -57,6 +58,24 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertEquals("feature/x", controller.model.getElementAt(0).branch) } + fun `test service open routes the directory to the backend rpc`() { + var result: Boolean? = null + coroutines.scope.launch { result = service.open("/repo/.kilo/worktrees/feature-x") } + flush() + + assertEquals(true, result) + assertEquals(listOf("/repo/.kilo/worktrees/feature-x"), rpc.opens.toList()) + } + + fun `test service open returns false when the backend call fails`() { + rpc.openResult = { error("boom") } + var result: Boolean? = null + coroutines.scope.launch { result = service.open("/repo/x") } + flush() + + assertEquals(false, result) + } + fun `test create invokes rpc and adds the created worktree`() { val controller = controller() val selected = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index f99d772fb91..459d70529c1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -86,6 +86,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java)) val buttons = components(panel).filterIsInstance().mapNotNull { it.presentation.text } assertTrue(buttons.contains("Hide sessions")) + assertTrue(buttons.contains("Open worktree in new window")) assertTrue(buttons.contains("New session")) assertTrue(buttons.contains("Rename session")) assertTrue(buttons.contains("Delete session")) @@ -174,6 +175,34 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals(1, manager.newCount) } + fun `test open action opens worktree directory in new frame`() { + val opened = mutableListOf() + val view = edt { + WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, openWorktree = { opened += it }) + } + + edt { + components(view).filterIsInstance().single { it.presentation.text == "Open worktree in new window" }.click() + } + + assertEquals(listOf(DIR), opened) + } + + fun `test open action disabled without a worktree directory`() { + val blank = Workspace("", kotlinx.coroutines.flow.MutableStateFlow(ai.kilocode.rpc.dto.KiloWorkspaceStateDto(ai.kilocode.rpc.dto.KiloWorkspaceStatusDto.READY)), {}, {}) + val opened = mutableListOf() + val view = edt { + WorktreeSessionEditorPanel(testRootDisposable, manager, controller, blank, openWorktree = { opened += it }) + } + + val button = edt { + components(view).filterIsInstance().single { it.presentation.text == "Open worktree in new window" } + } + + assertFalse(edt { button.isEnabled }) + assertTrue(opened.isEmpty()) + } + fun `test pending new session appears in list`() { manager.pending = true @@ -469,7 +498,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { } private fun toggle(root: java.awt.Component = panel): ActionButton { - val actions = setOf("New session", "Rename session", "Delete session") + val actions = setOf("Open worktree in new window", "New session", "Rename session", "Delete session") return components(root).filterIsInstance().first { it.presentation.text !in actions } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index eb976eba9d4..b80f90c598f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -27,6 +27,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val removeForces = CopyOnWriteArrayList() val renames = CopyOnWriteArrayList>() val adopts = CopyOnWriteArrayList>() + val opens = CopyOnWriteArrayList() var beforeCreate: suspend () -> Unit = {} var beforeRemove: suspend () -> Unit = {} var beforeRename: suspend () -> Unit = {} @@ -40,6 +41,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { var importPrResult: (String) -> CreateWorktreeResultDto = { url -> CreateWorktreeResultDto(WorktreeDto(url, "pr", "pr", url)) } + var openResult: (String) -> Boolean = { true } var removeResult: (String, String?, Boolean) -> RemoveWorktreeResultDto = { _, _, _ -> RemoveWorktreeResultDto(ok = true) } var renameResult: (String, String) -> RenameWorktreeResultDto = { path, name -> val idx = listed.indexOfFirst { it.path == path } @@ -70,6 +72,12 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { return prResult } + override suspend fun open(directory: String): Boolean { + assertNotEdt("open") + opens.add(directory) + return openResult(directory) + } + override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto { assertNotEdt("create") creates.add(request) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index 05f156c2855..412ff91f6e1 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -28,6 +28,14 @@ interface KiloWorktreeRpcApi : RemoteApi { } suspend fun list(directory: String): WorktreeListDto + + /** + * Opens the worktree [directory] as a project in a new IDE frame. Runs on the backend/host so it + * works in remote development, where the frontend is a JetBrains Client that cannot open local + * projects. Returns true when a project was opened or was already open. + */ + suspend fun open(directory: String): Boolean + suspend fun stats(directory: String): WorktreeStatsListDto suspend fun prStatus(directory: String): WorktreePrListDto suspend fun listBranches(directory: String): WorktreeBranchesDto