mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
feat(jetbrains): open worktrees in new window
This commit is contained in:
@@ -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.
|
||||
+45
@@ -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")
|
||||
|
||||
+5
@@ -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 = """
|
||||
|
||||
+10
@@ -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) {
|
||||
|
||||
+74
-1
@@ -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
|
||||
|
||||
+19
@@ -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>()
|
||||
|
||||
+30
-1
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user