feat(jetbrains): open worktrees in new window

This commit is contained in:
kirillk
2026-08-12 19:42:02 -04:00
parent 24ff698273
commit 106b5f4d5a
10 changed files with 205 additions and 2 deletions
@@ -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.
@@ -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")
@@ -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 = """
@@ -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) {
@@ -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<KiloWorktreeService>().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)
}
@@ -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
@@ -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<String>()
@@ -86,6 +86,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
val buttons = components(panel).filterIsInstance<ActionButton>().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<String>()
val view = edt {
WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, openWorktree = { opened += it })
}
edt {
components(view).filterIsInstance<ActionButton>().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<String>()
val view = edt {
WorktreeSessionEditorPanel(testRootDisposable, manager, controller, blank, openWorktree = { opened += it })
}
val button = edt {
components(view).filterIsInstance<ActionButton>().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<ActionButton>().first { it.presentation.text !in actions }
}
@@ -27,6 +27,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
val removeForces = CopyOnWriteArrayList<Boolean>()
val renames = CopyOnWriteArrayList<Triple<String, String, String>>()
val adopts = CopyOnWriteArrayList<Triple<String, String, String>>()
val opens = CopyOnWriteArrayList<String>()
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)
@@ -28,6 +28,14 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
}
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