Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-worktree-editor-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Move a session into a new worktree directly from the base checkout's worktree editor tab: a "Move to Worktree" entry now leads its session list's row menu, and sessions there get the same New Worktree / Move to Worktree toolbar above the prompt that the tool window shows. The tab's header now also reports the base checkout's uncommitted changes, the ones a move would carry.
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,16 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) })
}

/**
* Uncommitted counts for every working tree of [directory]'s repo, the main checkout included: its
* own session editor tab shows them in its header, and they are what a move to a worktree carries.
* Unlike [stats], which compares a worktree against the base branch and so has nothing to say about
* the checkout that branch lives on, this comparison is local to each working tree.
*/
override suspend fun dirty(directory: String): WorktreeDirtyListDto = withContext(Dispatchers.IO) {
val root = Path.of(directory).normalize()
val items = sync(root) ?: return@withContext WorktreeDirtyListDto()
WorktreeDirtyListDto(parallel(items.filter { !it.main }) { item -> dirty(item) })
WorktreeDirtyListDto(parallel(items) { item -> dirty(item) })
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ class BranchLocalDiffTest {
git(dir, "push", "-u", "origin", "feature")

assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file })
assertEquals(0, trees.dirty(repo.toString()).items.single().unpushed)
assertEquals(0, unpushed(dir))
Files.writeString(dir.resolve("feature.txt"), "one\ntwo\n")
commit(dir)
assertEquals(2, parity(dir).first.single().additions)
assertEquals(1, trees.dirty(repo.toString()).items.single().unpushed)
assertEquals(1, unpushed(dir))
}

@Test
Expand Down Expand Up @@ -295,7 +295,7 @@ class BranchLocalDiffTest {
}

@Test
fun `bulk stats and dirty retain managed-only scope excluding primary`() = runBlocking {
fun `bulk stats and dirty retain managed-only scope`() = runBlocking {
init()
val dir = worktree()
val outside = root.resolve("outside")
Expand All @@ -304,8 +304,12 @@ class BranchLocalDiffTest {
Files.writeString(outside.resolve("outside.txt"), "outside\n")
Files.writeString(dir.resolve("managed.txt"), "managed\n")

// The primary checkout holds the branch the worktrees are compared against, so it has no base
// stats of its own but does have uncommitted counts, which its session editor tab reports. A
// worktree nobody manages stays out of both.
assertEquals(listOf(dir.toString()), trees.stats(outside.toString()).items.map { it.path })
assertEquals(listOf(dir.toString()), trees.dirty(outside.toString()).items.map { it.path })
assertEquals(listOf(repo.toString(), dir.toString()), trees.dirty(outside.toString()).items.map { it.path })
assertEquals(1, trees.dirty(outside.toString()).items.single { it.path == repo.toString() }.untracked)
assertEquals(listOf("managed.txt"), parity(dir).second.map { it.file })
assertEquals(listOf("outside.txt"), api.localDiff(outside.toString(), false).map { it.file })
}
Expand Down Expand Up @@ -371,6 +375,9 @@ class BranchLocalDiffTest {
commit(repo)
}

private suspend fun unpushed(dir: Path): Int =
trees.dirty(repo.toString()).items.single { it.path == dir.toString() }.unpushed

private fun worktree(): Path {
val dir = repo.resolve(".kilo/worktrees/feature")
git(repo, "worktree", "add", "-b", "feature", dir.toString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,35 @@ class KiloWorktreeRpcApiImplTest {
assertEquals(0, item.unpushed, "no upstream means no unpushed count")
}

@Test
fun `dirty reports the main checkout too`() = runBlocking {
initRepo()
api.create(repo.toString(), CreateWorktreeRequestDto("feature/x"))
val root = repo.toRealPath()
// Creating a worktree leaves its own traces in the main checkout, so the edits below are
// measured as a delta rather than against an assumed-clean starting point.
val items = api.dirty(repo.toString()).items
val before = assertNotNull(items.singleOrNull { Path.of(it.path) == root }, "main checkout missing from $items")

Files.writeString(repo.resolve("README.md"), "hello there\n")
Files.writeString(repo.resolve("untracked.txt"), "u\n")
val after = assertNotNull(api.dirty(repo.toString()).items.singleOrNull { Path.of(it.path) == root })

assertEquals(before.files + 2, after.files, "the README edit plus the untracked file")
assertEquals(before.untracked + 1, after.untracked)
}

@Test
fun `stats leaves the main checkout out`() = runBlocking {
initRepo()
api.create(repo.toString(), CreateWorktreeRequestDto("feature/x"))
val root = repo.toRealPath()

// The main checkout holds the branch the others are compared against, so it has no base stats
// to report -- only its uncommitted counts, which dirty() answers for.
assertTrue(api.stats(repo.toString()).items.none { Path.of(it.path) == root })
}

@Test
fun `dirty counts commits missing from the upstream`() = runBlocking {
initRepo()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ai.kilocode.client.session.SessionSidePanelManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.agentManager.worktree.GhStatusCoordinator
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
import ai.kilocode.client.agentManager.AgentManagerHost
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import ai.kilocode.client.agentManager.applySidePanelMode
Expand Down Expand Up @@ -42,6 +43,9 @@ import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import javax.swing.JPanel

/** Registered id of the Kilo Code tool window (`kilo.jetbrains.frontend.xml`'s `<toolWindow id=...>`). */
const val KILO_TOOL_WINDOW_ID = "Kilo Code"

/**
* Creates the Kilo Code tool window and delegates session content management.
*
Expand Down Expand Up @@ -153,6 +157,19 @@ internal class KiloToolWindowSetupService(
agents()
agentManagerPanel.move(id, dir)
}
// Same two flows, reachable from a worktree editor tab, which cannot see
// agentManagerPanel directly: see AgentManagerHost.
project.service<AgentManagerHost>().bind(
manager,
move = { id, dir, surface ->
agents()
agentManagerPanel.move(id, dir, surface)
},
newWorktree = {
Telemetry.send("New Worktree Clicked", mapOf("surface" to "worktree_editor"))
agentManagerPanel.configure(anchor = chat, onCreate = { agents() })
},
)
val listener = object : ContentManagerListener {
override fun selectionChanged(event: ContentManagerEvent) {
if (event.operation != ContentManagerEvent.ContentOperation.add) return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ai.kilocode.client.actions

import ai.kilocode.client.agentManager.worktree.WorktreeSessionDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAware

class MoveWorktreeSessionAction : AnAction(), DumbAware {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT

override fun update(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL)
val item = e.getData(WorktreeSessionDataKeys.SESSION)
e.presentation.isEnabledAndVisible = panel != null && panel.canMove(item)
}

override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL) ?: return
val item = e.getData(WorktreeSessionDataKeys.SESSION) ?: return
if (panel.canMove(item)) panel.moveRow(item)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package ai.kilocode.client.agentManager

import ai.kilocode.client.KILO_TOOL_WINDOW_ID
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.util.concurrency.annotations.RequiresEdt

/**
* Project-level seam between a worktree session editor tab and the Agent Manager tool window panel,
* which the editor cannot reach directly (it lives inside [ai.kilocode.client.KiloToolWindowSetupService]).
* The tool window binds its two worktree flows here once it is created; an editor calls [move] /
* [newWorktree] the same way the chat branch dock does. When nothing is bound yet -- a tab restored
* before the tool window has been shown, or after a plugin reload -- the request is queued (the latest
* call wins) and the tool window is activated, which creates its content and flushes the queue via
* [bind].
*/
@Service(Service.Level.PROJECT)
class AgentManagerHost(private val project: Project) {
private var onMove: ((String?, String, String) -> Unit)? = null
private var onNew: (() -> Unit)? = null
private var queued: (() -> Unit)? = null
// Which bind owns the callbacks currently installed. A tool window is not guaranteed to be
// disposed before its replacement is created -- a plugin reload creates the new one first -- so a
// disposer that cleared unconditionally would take the live callbacks down with the dead window.
private var generation = 0

/**
* Registers the tool window's worktree flows for the lifetime of [parent] (the tool window's
* disposable). A later [bind] call from a fresh tool window setup replaces the callbacks and
* flushes anything queued while none were bound.
*/
@RequiresEdt
fun bind(parent: Disposable, move: (String?, String, String) -> Unit, newWorktree: () -> Unit) {
val gen = ++generation
onMove = move
onNew = newWorktree
Disposer.register(parent) {
Comment thread
kirillk marked this conversation as resolved.
if (gen != generation) return@register
onMove = null
onNew = null
}
val pending = queued
queued = null
pending?.invoke()
}

/** Moves [sessionId] (or just the local changes in [directory] when null) into a new worktree. */
@RequiresEdt
fun move(sessionId: String?, directory: String, surface: String) {
val handler = onMove
if (handler != null) {
handler(sessionId, directory, surface)
return
}
queued = { onMove?.invoke(sessionId, directory, surface) }
activate()
}

/** Opens the New Worktree dialog. */
@RequiresEdt
fun newWorktree() {
val handler = onNew
if (handler != null) {
handler()
return
}
queued = { onNew?.invoke() }
activate()
}

private fun activate() {
ToolWindowManager.getInstance(project).getToolWindow(KILO_TOOL_WINDOW_ID)?.activate(null)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ class AgentManagerPanel(
}
}

internal fun move(sessionId: String?, directory: String) = controller.move(sessionId, directory)
internal fun move(sessionId: String?, directory: String, surface: String = "sidebar") =
controller.move(sessionId, directory, surface)

private fun remove(item: WorktreeDto, force: Boolean) {
controller.remove(item, force, onFailure = { result -> notifyFailed(item, result, force) })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,12 @@ class WorktreeController(
/**
* Copies working-tree changes into a new worktree. When [sessionId] is set, the source session is
* also forked into the worktree; otherwise the opened worktree starts with a fresh session.
* [surface] is reported only on the "Continue in Worktree" telemetry event, so callers other than
* the sidebar chat dock (e.g. the worktree editor's session list or its own action toolbar) can be
* told apart.
*/
@RequiresEdt
fun move(sessionId: String?, source: String = directory) {
fun move(sessionId: String?, source: String = directory, surface: String = "sidebar") {
val key = sessionId ?: source
if (!moves.add(key)) return
val branch = suggestName()
Expand Down Expand Up @@ -278,7 +281,7 @@ class WorktreeController(
onCreated?.invoke(worktree)
telemetry(
"Continue in Worktree",
mapOf("surface" to "sidebar", "session" to (sessionId != null).toString()),
mapOf("surface" to surface, "session" to (sessionId != null).toString()),
)
}
MoveStage.ERROR -> failMove(key, temp, event.error, stage)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.kilocode.client.agentManager.worktree

import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.agentManager.AgentManagerHost
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
Expand Down Expand Up @@ -72,8 +73,33 @@ open class WorktreeSessionEditorManager(
project.service<KiloVfsManager>().updatePresentation(WorktreeSessionEditorKind.ID, worktreeSessionParams(updated))
}
},
// Whether this tab's directory is the repo's main working tree rather than a linked worktree.
// `git worktree list` answers this from any of the repo's worktrees, so no separate "repo root"
// is needed -- just the tab's own directory.
private val resolveBase: suspend (String) -> Boolean = { dir ->
service<KiloWorktreeService>().list(dir).worktrees
.firstOrNull { normalizeWorktreePath(it.path) == normalizeWorktreePath(dir) }
?.main == true
},
private val moveHost: (String?, String, String) -> Unit = { id, dir, surface ->
project.service<AgentManagerHost>().move(id, dir, surface)
},
private val newWorktreeHost: () -> Unit = {
project.service<AgentManagerHost>().newWorktree()
},
) : SessionHost(project, worktree, create, resolve, status, timers, request) {
override val showsBranchDock: Boolean get() = false
// Both the branch dock and the New Worktree / Move to Worktree flows only make sense from the
// base checkout -- a linked worktree's own editor tab keeps today's plain session view. Resolved
// once in start(), before the first session opens; see resolveBase().
private var resolvedBase = false
private var baseResolved = false
// The answer lands on the EDT after the lookup coroutine has already finished, so a second start()
// in between would launch a second lookup and open the first session twice. This spans the whole
// gap; the job's own lifetime does not.
private var resolving = false
override val showsBranchDock: Boolean get() = base()
override val supportsNewWorktree: Boolean get() = base()
override val supportsMoveToWorktree: Boolean get() = base()
override val hostedInEditorTab: Boolean get() = true
private val right = JPanel(BorderLayout())
private val deleting = linkedSetOf<String>()
Expand All @@ -94,9 +120,44 @@ open class WorktreeSessionEditorManager(
bindMigration()
}

/** Whether this tab's directory is the repo's main working tree; see [resolveBase]. */
@RequiresEdt
open fun base(): Boolean = resolvedBase

@RequiresEdt
override fun newWorktree() {
if (base()) newWorktreeHost()
}

@RequiresEdt
override fun moveToWorktree(sessionId: String?, directory: String) {
if (base()) moveHost(sessionId, directory, "worktree_editor")
}

@RequiresEdt
fun start() {
startedOnce = true
if (baseResolved) {
startSessions()
return
}
// A start() that arrives mid-lookup needs nothing: the lookup in flight opens the first session
// when it lands, and that is what this call would have done itself.
if (resolving) return
resolving = true
cs.launch {
Comment thread
kirillk marked this conversation as resolved.
val resolved = runCatching { resolveBase(worktree.directory) }.getOrDefault(false)
edt({ !Disposer.isDisposed(this@WorktreeSessionEditorManager) }) {
resolving = false
resolvedBase = resolved
baseResolved = true
startSessions()
}
}
}

@RequiresEdt
private fun startSessions() {
list.reload {
val target = session
if (target != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class WorktreeSessionEditorPanel @RequiresEdt constructor(
onCell = { _, _ -> },
onOpen = { row, focus -> open(row, focus) },
menu = ActiveListMenu(WorktreeSessionDataKeys.SESSION, group, element = { row ->
(row as? SessionRow)?.session?.takeIf { canRename(it) || canDelete(it) }
(row as? SessionRow)?.session?.takeIf { canMove(it) || canRename(it) || canDelete(it) }
}),
)
private val run = if (project != null && worktree.directory.isNotBlank()) {
Expand Down Expand Up @@ -221,6 +221,22 @@ class WorktreeSessionEditorPanel @RequiresEdt constructor(
@RequiresEdt
internal fun renameRow(item: SessionDto) = beginRename(item.id)

/**
* Only offered from the base checkout's tab (not a linked worktree's own tab, see
* [WorktreeSessionEditorManager.base]), for a real session that is not already being deleted, and
* hidden rather than disabled while the session's turn is in flight -- the same states the chat
* branch dock hides its own Move to Worktree action in, see [SessionActivityKind.busy].
*/
@RequiresEdt
internal fun canMove(item: SessionDto?): Boolean =
manager.base() && canDelete(item) && manager.activity()[item?.id]?.busy() != true

@RequiresEdt
internal fun moveRow(item: SessionDto) {
if (!canMove(item)) return
manager.moveToWorktree(item.id, worktree.directory)
}

@RequiresEdt
private fun confirmDelete(ids: List<String>, cell: String? = null) {
val active = ids.filter { it != SessionHost.NEW && it !in manager.deleting() }.distinct()
Expand Down
Loading
Loading