From e6de1ebfa3148ecc285629e00e1862116ea8ed24 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 12:19:11 -0400 Subject: [PATCH 01/19] feat(jetbrains): show uncommitted changes separately from the PR diff on worktree rows Agent Manager worktree rows showed no +/- badge for branches that were fully pushed, because stats() diffed against the worktree's own @{upstream} instead of the repo's base branch. Once a branch is pushed, merge-base(HEAD, upstream) equals HEAD, so the diff reported nothing. stats() now resolves the base branch the same way the branch-diff editor does (origin/HEAD, then origin/main/master, then local main/master, falling back to the main worktree's branch), diffs committed work only (trailing HEAD on the numstat call excludes the working tree), and reports which base ref it used. Uncommitted work moves to a new dirty() RPC and gets its own muted badge next to the PR diff, since it has a different baseline (HEAD) and refresh cadence than the PR-parity number. --- .changeset/jetbrains-worktree-pr-diff.md | 5 + .../jetbrains-worktree-uncommitted-badge.md | 5 + .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 97 ++++++++++++++++--- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 95 +++++++++++++++++- .../client/agentManager/AgentManagerPanel.kt | 26 ++++- .../worktree/KiloWorktreeService.kt | 8 ++ .../worktree/WorktreeStatsView.kt | 68 ++++++++++--- .../worktree/WorktreeStatusBinding.kt | 7 +- .../worktree/WorktreeStatusService.kt | 14 ++- .../client/ui/list/ActiveListActions.kt | 1 + .../client/ui/list/ActiveListModel.kt | 6 ++ .../client/ui/list/ActiveListRenderer.kt | 11 ++- .../kilocode/client/ui/list/ActiveListView.kt | 4 + .../resources/messages/KiloBundle.properties | 1 + .../agentManager/AgentManagerPanelTest.kt | 44 +++++++++ .../worktree/WorktreeStatsViewTest.kt | 41 ++++++++ .../worktree/WorktreeStatusServiceTest.kt | 18 ++++ .../client/testing/FakeWorktreeRpcApi.kt | 7 ++ .../ai/kilocode/rpc/KiloWorktreeRpcApi.kt | 8 ++ .../kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt | 22 +++++ 20 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 .changeset/jetbrains-worktree-pr-diff.md create mode 100644 .changeset/jetbrains-worktree-uncommitted-badge.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt diff --git a/.changeset/jetbrains-worktree-pr-diff.md b/.changeset/jetbrains-worktree-pr-diff.md new file mode 100644 index 000000000000..8ade7f469175 --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-diff.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show the full branch change count on Agent Manager worktree rows after the branch is pushed diff --git a/.changeset/jetbrains-worktree-uncommitted-badge.md b/.changeset/jetbrains-worktree-uncommitted-badge.md new file mode 100644 index 000000000000..dc10db6f13c9 --- /dev/null +++ b/.changeset/jetbrains-worktree-uncommitted-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Show uncommitted changes as a separate badge on Agent Manager worktree rows 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 50f0fc1457b8..e343d1de4a4c 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 @@ -14,6 +14,8 @@ import ai.kilocode.rpc.dto.MoveStage import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrDto @@ -68,6 +70,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { private const val GH_PROBE_TTL = 300_000L private const val GH_STATUS_TTL = 3_000L private const val PR_TTL = 90_000L + + /** + * Base-branch candidates in preference order, as (verify ref, diff ref). Fully-qualified on + * the left so a tag named "main" cannot be mistaken for the branch. Mirrors + * `KiloWorkspaceRpcApiImpl.defaultBranch`, which the branch-diff editor already uses. + */ + private val BASE_CANDIDATES = listOf( + "refs/remotes/origin/main" to "origin/main", + "refs/remotes/origin/master" to "origin/master", + "refs/heads/main" to "main", + "refs/heads/master" to "master", + ) } private val bases = ConcurrentHashMap>() @@ -157,6 +171,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) }) } + 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) }) + } + /** * Lists the managed worktrees of [root] after reconciling git's metadata with the disk, so * callers never probe a directory that no longer exists. Returns null when [root] itself is gone @@ -626,27 +646,32 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() } } + /** + * Committed changes this worktree introduces relative to the base branch, i.e. what GitHub shows + * under "Files changed" for a pull request. The trailing `HEAD` on the diff is load-bearing: + * without it git diffs the working tree and mixes uncommitted edits into the PR number. Untracked + * files are excluded for the same reason — they are not in a PR. Both live in [dirty] instead. + * + * Rename detection is left on (no `--no-renames`) so a pure file move counts 0/0 like GitHub + * does. `parseNumstat` only consumes the two count columns, so the `{old => new}` path form in + * rename output is harmless here. + */ private fun stats(item: WorktreeDto, fallback: String): WorktreeStatsDto { val dir = Path.of(item.path).normalize() return runCatching { val base = base(item, fallback) val anc = runGit(dir, "merge-base", "HEAD", base).stdout.trim().takeIf { it.isNotBlank() } ?: base - val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", anc) + val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", anc, "HEAD") val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList() - val untrackedFiles = runGit(dir, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") - .stdout - .lineSequence() - .filter { it.isNotBlank() } - .toList() - val untracked = untrackedFiles.sumOf { countUntracked(dir, it) } val counts = aheadBehind(dir, base) WorktreeStatsDto( item.path, - tracked.sumOf { it.additions } + untracked, + tracked.sumOf { it.additions }, tracked.sumOf { it.deletions }, counts.second, counts.first, - files = tracked.size + untrackedFiles.size, + files = tracked.size, + base = base, ) }.getOrElse { err -> LOG.warn("worktree stats failed: path=${item.path} message=${err.message}", err) @@ -654,16 +679,66 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } + /** + * Diff/commit baseline for a worktree: the branch a pull request would target. Prefers the + * remote's declared default (origin HEAD), then the common origin and local main/master names, + * then [fallback] (the branch checked out in the main working tree) for repos that have none of + * those. + * + * Never the worktree's own `@{upstream}`: once a branch is pushed, its upstream contains every + * commit, so the merge-base is HEAD and the diff reports zero changes. + */ private fun base(item: WorktreeDto, fallback: String): String { val now = System.currentTimeMillis() bases[item.path]?.takeIf { now - it.time < BASE_TTL }?.let { return it.value } val dir = Path.of(item.path).normalize() - val upstream = runGit(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") - val value = upstream.stdout.trim().takeIf { upstream.ok && it.isNotBlank() } ?: fallback + val value = defaultBase(dir) ?: fallback bases[item.path] = Timed(now, value) return value } + private fun defaultBase(dir: Path): String? { + val head = runGit(dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD") + head.stdout.trim().removePrefix("refs/remotes/").takeIf { head.ok && it.isNotBlank() }?.let { return it } + return BASE_CANDIDATES.firstOrNull { runGit(dir, "rev-parse", "--verify", "--quiet", it.first).ok }?.second + } + + /** + * Everything not yet committed in [item]: `diff --numstat HEAD` covers the index and the working + * tree in one call, then untracked files are line-counted the same way [stats] used to. An unborn + * branch makes the diff exit non-zero; untracked files are still reported. + */ + private fun dirty(item: WorktreeDto): WorktreeDirtyDto { + val dir = Path.of(item.path).normalize() + return runCatching { + val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", "HEAD") + val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList() + val others = runGit(dir, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") + .stdout + .lineSequence() + .filter { it.isNotBlank() } + .toList() + WorktreeDirtyDto( + item.path, + additions = tracked.sumOf { it.additions } + others.sumOf { countUntracked(dir, it) }, + deletions = tracked.sumOf { it.deletions }, + files = tracked.size + others.size, + untracked = others.size, + unpushed = unpushed(dir), + ) + }.getOrElse { err -> + LOG.warn("worktree dirty failed: path=${item.path} message=${err.message}", err) + WorktreeDirtyDto(item.path) + } + } + + /** Commits on HEAD that the branch's upstream does not have. 0 when there is no upstream. */ + private fun unpushed(dir: Path): Int { + val out = runGit(dir, "rev-list", "--count", "@{upstream}..HEAD") + if (!out.ok) return 0 + return out.stdout.trim().toIntOrNull() ?: 0 + } + private fun aheadBehind(dir: Path, base: String): Pair { val out = runGit(dir, "rev-list", "--left-right", "--count", "$base...HEAD") if (!out.ok) return 0 to 0 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 b708e5d57f1a..3ae29c484ba6 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 @@ -694,23 +694,110 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `stats reports managed worktree diff and ahead counts`() = runBlocking { + fun `stats reports committed diff against the base branch`() = runBlocking { initRepo() val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) val dir = Path.of(created.path) Files.writeString(dir.resolve("tracked.txt"), "one\n") git(dir, "add", "tracked.txt") git(dir, "commit", "-m", "feature") + // Working-tree noise a pull request would not contain. Files.writeString(dir.resolve("notes.txt"), "two\nthree\n") val item = api.stats(repo.toString()).items.single { it.path == created.path } - assertEquals(3, item.additions) + assertEquals(1, item.additions, "only the committed line belongs in the PR number") assertEquals(0, item.deletions) + assertEquals(1, item.files, "untracked notes.txt must not count") assertEquals(1, item.ahead) assertEquals(0, item.behind) - // tracked.txt (committed ahead of base) + notes.txt (untracked) = 2 changed files. - assertEquals(2, item.files) + } + + @Test + fun `stats ignores the branch upstream and uses the base branch`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + // Emulate a fully pushed branch: an upstream that already contains the commit. + git(repo, "branch", "shadow", "feature/x") + git(dir, "branch", "--set-upstream-to=shadow", "feature/x") + + val item = api.stats(repo.toString()).items.single { it.path == created.path } + + assertEquals(1, item.additions, "an in-sync upstream must not zero out the diff") + assertEquals(1, item.ahead) + } + + @Test + fun `stats is unchanged by uncommitted edits`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + val before = api.stats(repo.toString()).items.single { it.path == created.path } + + Files.writeString(dir.resolve("tracked.txt"), "one\ntwo\n") + Files.writeString(dir.resolve("untracked.txt"), "three\n") + + val after = api.stats(repo.toString()).items.single { it.path == created.path } + + assertEquals(before.additions, after.additions) + assertEquals(before.files, after.files) + } + + @Test + fun `dirty reports staged unstaged and untracked changes`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + // Staged edit, unstaged edit, and an untracked file. + Files.writeString(dir.resolve("staged.txt"), "s\n") + git(dir, "add", "staged.txt") + Files.writeString(dir.resolve("tracked.txt"), "one\ntwo\n") + Files.writeString(dir.resolve("untracked.txt"), "u\n") + + val item = api.dirty(repo.toString()).items.single { it.path == created.path } + + assertEquals(3, item.additions, "staged + unstaged + untracked lines") + assertEquals(0, item.deletions) + assertEquals(3, item.files) + assertEquals(1, item.untracked) + } + + @Test + fun `dirty reports zero for a clean worktree`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + val item = api.dirty(repo.toString()).items.single { it.path == created.path } + + assertEquals(0, item.files) + assertEquals(0, item.unpushed, "no upstream means no unpushed count") + } + + @Test + fun `dirty counts commits missing from the upstream`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val dir = Path.of(created.path) + git(repo, "branch", "shadow", "feature/x") + git(dir, "branch", "--set-upstream-to=shadow", "feature/x") + Files.writeString(dir.resolve("tracked.txt"), "one\n") + git(dir, "add", "tracked.txt") + git(dir, "commit", "-m", "feature") + + val item = api.dirty(repo.toString()).items.single { it.path == created.path } + + assertEquals(1, item.unpushed) + assertEquals(0, item.files, "committed work is not uncommitted") } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 8f44a2c8f879..43ed036fe656 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -43,6 +43,7 @@ import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.rpc.dto.RemoveWorktreeResultDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto @@ -121,6 +122,7 @@ class AgentManagerPanel( ), ) private var stats: Map = emptyMap() + private var dirty: Map = emptyMap() private var prs: Map = emptyMap() init { @@ -402,6 +404,8 @@ class AgentManagerPanel( stats = null, // The main checkout can sit on a PR branch just like a worktree can. pr = prs[normalizeWorktreePath(item.path)], + // Neither stats() nor dirty() compute the main checkout; both RPCs filter it out. + dirty = null, current = true, ) } @@ -417,6 +421,7 @@ class AgentManagerPanel( controller.kind(item.path), stats[key], pull, + dirty[key], ) }, ActiveListSelection.Preserve, @@ -456,6 +461,7 @@ class AgentManagerPanel( this, onStats = { value -> stats = value; sync() }, onPr = { value -> prs = value; sync() }, + onDirty = { value -> dirty = value; sync() }, ) } @@ -509,10 +515,10 @@ class AgentManagerPanel( } /** - * Inner (not data) class so [metrics] can bind each row's changes/PR handlers to the panel. - * Value equality is over the data fields only — the derived handlers are intentionally excluded - * so a stats/PR refresh that produced identical rows still skips the model rebuild in - * [ActiveListView]. + * Inner (not data) class so [metrics] can bind each row's changes/dirty/PR handlers to the + * panel. Value equality is over the data fields only — the derived handlers are intentionally + * excluded so a stats/PR/dirty refresh that produced identical rows still skips the model + * rebuild in [ActiveListView]. */ private inner class WorktreeRow( val dto: WorktreeDto, @@ -520,6 +526,7 @@ class AgentManagerPanel( val kind: SessionActivityKind?, val stats: WorktreeStatsDto?, val pr: WorktreePrDto?, + val dirty: WorktreeDirtyDto? = null, val current: Boolean = false, ) : ActiveListItem { override val key: String get() = dto.id @@ -537,7 +544,8 @@ class AgentManagerPanel( if (progress != null) return null val s = stats val p = pr - if (s == null && p == null) return null + val d = dirty + if (s == null && p == null && d == null) return null return ActiveListMetrics( additions = s?.additions ?: 0, deletions = s?.deletions ?: 0, @@ -547,6 +555,12 @@ class AgentManagerPanel( prTooltip = p?.let { prTooltip(it, customName) }, onChanges = s?.let { { openBranchDiff(dto.path) } }, onPr = p?.url?.let { url -> { BrowserUtil.browse(url) } }, + dirtyAdditions = d?.additions ?: 0, + dirtyDeletions = d?.deletions ?: 0, + dirtyFiles = d?.files ?: 0, + // Reuses the branch diff editor for now, which already diffs the working tree + // against the merge-base, so it contains the uncommitted work this badge reports. + onDirty = d?.let { { openBranchDiff(dto.path) } }, ) } @@ -557,6 +571,7 @@ class AgentManagerPanel( kind == row.kind && stats == row.stats && pr == row.pr && + dirty == row.dirty && current == row.current } @@ -566,6 +581,7 @@ class AgentManagerPanel( result = 31 * result + (kind?.hashCode() ?: 0) result = 31 * result + (stats?.hashCode() ?: 0) result = 31 * result + (pr?.hashCode() ?: 0) + result = 31 * result + (dirty?.hashCode() ?: 0) result = 31 * result + current.hashCode() return result } 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 71afcb6a28db..152118584434 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 @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.MoveProgressDto import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsListDto @@ -86,6 +87,13 @@ class KiloWorktreeService internal constructor( WorktreeStatsListDto() } + suspend fun dirty(directory: String): WorktreeDirtyListDto = try { + call { dirty(directory) } + } catch (e: Exception) { + LOG.warn("worktree dirty failed for $directory", e) + WorktreeDirtyListDto() + } + /** * Reports gh availability, or rethrows on RPC/backend failure. Callers ([GhStatusCoordinator]) * distinguish a healthy gh from an unhealthy backend via their own `runCatching` + backoff; diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt index 686712543502..5c492b9855cf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL +import ai.kilocode.client.ui.list.ACTIVE_LIST_DIRTY_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListHitCell @@ -48,16 +49,20 @@ internal class WorktreeStatsView( private val behind = count(DOWN) private val ahead = count(UP) private val diff = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) + // Muted and unfilled so it reads as a different baseline than the committed-diff badge above. + private val dirty = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = false) private val pr = JBLabel() private val change = Stack.horizontal(UiStyle.Gap.sm()).next(behind).next(ahead).next(diff) - // The change and PR badges are hit regions so the list can drive their clicks: inside the list - // the view is a render stamp whose own mouse listeners never fire, so the ActiveList reads these - // ids back and routes the click. Standalone (toolbar) usage keeps its own listeners below. + // The change, dirty, and PR badges are hit regions so the list can drive their clicks: inside + // the list the view is a render stamp whose own mouse listeners never fire, so the ActiveList + // reads these ids back and routes the click. Standalone (toolbar) usage keeps its own listeners + // below. private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) } + private val dirtyHit = HitRegion(ACTIVE_LIST_DIRTY_CELL).apply { add(dirty, BorderLayout.CENTER) } private val prHit = HitRegion(ACTIVE_LIST_PR_CELL).apply { add(pr, BorderLayout.CENTER) } - // Change badge and PR link share one row: the changes trail first, the PR link is pinned last so + // The changes badge trails first, the uncommitted badge next, and the PR link is pinned last so // it is always the rightmost element. - private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(prHit) + private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(dirtyHit).next(prHit) private var url: String? = null private var state: State? = null @@ -71,6 +76,11 @@ internal class WorktreeStatsView( changeHit.act?.invoke() } }) + installClick(dirtyHit, object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + dirtyHit.act?.invoke() + } + }) installClick(prHit, object : MouseAdapter() { override fun mouseClicked(event: MouseEvent) { prHit.act?.invoke() @@ -80,28 +90,45 @@ internal class WorktreeStatsView( } /** - * Replaces the click handlers, used by the list renderer to bind each row's changes/PR actions - * to the single reused stamp. Standalone usage leaves the constructor defaults in place. + * Replaces the click handlers, used by the list renderer to bind each row's changes/dirty/PR + * actions to the single reused stamp. Standalone usage leaves the constructor defaults in place. */ - fun setActions(onChanges: (() -> Unit)?, onPr: (() -> Unit)?) { + fun setActions(onChanges: (() -> Unit)?, onPr: (() -> Unit)?, onDirty: (() -> Unit)? = null) { changeHit.act = onChanges prHit.act = onPr + dirtyHit.act = onDirty applyCursors() } - fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?) { + fun update( + stats: WorktreeStatsDto?, + pull: WorktreePrDto?, + dirtyAdd: Int = 0, + dirtyDel: Int = 0, + dirtyFiles: Int = 0, + ) { sync( State( stats, pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, pull?.url, pull?.let(::prTooltip), + dirtyAdd, + dirtyDel, + dirtyFiles, ), ) } - fun update(stats: WorktreeStatsDto?, badge: ActiveListBadge?, prTip: String? = badge?.text) { - sync(State(stats, badge, null, prTip)) + fun update( + stats: WorktreeStatsDto?, + badge: ActiveListBadge?, + prTip: String? = badge?.text, + dirtyAdd: Int = 0, + dirtyDel: Int = 0, + dirtyFiles: Int = 0, + ) { + sync(State(stats, badge, null, prTip, dirtyAdd, dirtyDel, dirtyFiles)) } /** @@ -127,6 +154,13 @@ internal class WorktreeStatsView( diff.toolTipText = changeTip changeHit.tip = changeTip changeHit.toolTipText = changeTip + dirty.update(next.dirtyAdd, next.dirtyDel) + dirty.isVisible = next.dirtyFiles > 0 + val dirtyTip = KiloBundle.message("worktree.dirty.tooltip", next.dirtyFiles, next.dirtyAdd, next.dirtyDel) + dirty.toolTipText = dirtyTip + dirtyHit.tip = dirtyTip + dirtyHit.toolTipText = dirtyTip + dirtyHit.isVisible = dirty.isVisible url = next.link pr.icon = next.badge?.let { FilledBadgeIcon(it.text, it.style) } pr.toolTipText = next.tip @@ -136,7 +170,7 @@ internal class WorktreeStatsView( val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible changeHit.isVisible = changesVisible prHit.isVisible = pr.isVisible - isVisible = changesVisible || pr.isVisible + isVisible = changesVisible || dirtyHit.isVisible || pr.isVisible applyCursors() revalidate() repaint() @@ -144,6 +178,7 @@ internal class WorktreeStatsView( private fun applyCursors() { applyCursor(changeHit, changeHit.act != null) + applyCursor(dirtyHit, dirtyHit.act != null) applyCursor(prHit, prHit.act != null) } @@ -168,16 +203,23 @@ internal class WorktreeStatsView( border = JBUI.Borders.empty() } + internal fun dirtyBadgeForTest() = dirty + + internal fun dirtyHitForTest() = dirtyHit + /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ private data class State( val stats: WorktreeStatsDto?, val badge: ActiveListBadge?, val link: String?, val tip: String?, + val dirtyAdd: Int, + val dirtyDel: Int, + val dirtyFiles: Int, ) /** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */ - private class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { + internal class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { var act: (() -> Unit)? = null var tip: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt index c5dacbb4fbed..305975006dbe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusBinding.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.util.edt +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.openapi.Disposable @@ -17,14 +18,15 @@ import kotlinx.coroutines.launch /** * Shared wiring to the project [WorktreeStatusService] for the surfaces that show worktree * stats/PR badges (the Agent Manager list and the worktree session editor). Owns the attach - * handle and a coroutine scope, forwards each flow value to [onStats]/[onPr] on the EDT, and - * skips delivery once [parent] or the project is disposed. Cleanup is tied to [parent]. + * handle and a coroutine scope, forwards each flow value to [onStats]/[onDirty]/[onPr] on the EDT, + * and skips delivery once [parent] or the project is disposed. Cleanup is tied to [parent]. */ internal class WorktreeStatusBinding( private val project: Project, private val parent: Disposable, private val onStats: (Map) -> Unit, private val onPr: (Map) -> Unit, + private val onDirty: (Map) -> Unit = {}, ) { private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val handle: AutoCloseable @@ -33,6 +35,7 @@ internal class WorktreeStatusBinding( val service = project.service() handle = service.attach() cs.launch { service.stats.collectLatest { value -> alive { onStats(value) } } } + cs.launch { service.dirty.collectLatest { value -> alive { onDirty(value) } } } cs.launch { service.pr.collectLatest { value -> alive { onPr(value) } } } Disposer.register(parent) { close() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt index 8c18dbd77ff5..dd599af1e654 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.openapi.components.Service @@ -32,6 +33,7 @@ class WorktreeStatusService internal constructor( } private val statsFlow = MutableStateFlow>(emptyMap()) + private val dirtyFlow = MutableStateFlow>(emptyMap()) private val prFlow = MutableStateFlow>(emptyMap()) private val ghFlow = MutableStateFlow(GhAvailability.OK) private var debounce: UiTimer? = null @@ -41,6 +43,7 @@ class WorktreeStatusService internal constructor( private var lastPr = 0L val stats: StateFlow> get() = statsFlow + val dirty: StateFlow> get() = dirtyFlow val pr: StateFlow> get() = prFlow val gh: StateFlow get() = ghFlow @@ -55,7 +58,7 @@ class WorktreeStatusService internal constructor( fun refreshStats() { if (project.isDisposed || refs == 0) return - val timer = debounce ?: timers.timer(STATS_DEBOUNCE, repeats = false) { loadStats() }.also { debounce = it } + val timer = debounce ?: timers.timer(STATS_DEBOUNCE, repeats = false) { loadStats(); loadDirty() }.also { debounce = it } timer.restart() } @@ -92,6 +95,15 @@ class WorktreeStatusService internal constructor( } } + private fun loadDirty() { + val dir = project.basePath ?: return + cs.launch { + runCatching { service().dirty(dir) } + .onSuccess { dto -> dirtyFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } } + .onFailure { err -> LOG.warn("worktree dirty refresh failed dir=$dir", err) } + } + } + private fun loadPr() { val dir = project.basePath ?: return cs.launch { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt index c0c8f529e11d..daa3be71cb21 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt @@ -16,6 +16,7 @@ internal const val ACTIVE_LIST_MENU_CELL = "__menu__" /** Ids for the trailing metrics badges hit-tested in place (see [ActiveListMetrics]). */ internal const val ACTIVE_LIST_CHANGES_CELL = "__changes__" internal const val ACTIVE_LIST_PR_CELL = "__pr__" +internal const val ACTIVE_LIST_DIRTY_CELL = "__dirty__" internal fun activeListRenameCell(label: String = KiloBundle.message("common.rename")) = ActiveListCell( ACTIVE_LIST_RENAME_CELL, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 9698bf5e0877..7cf274050b24 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -28,6 +28,12 @@ internal data class ActiveListMetrics( val onChanges: (() -> Unit)? = null, /** Click handler for the PR badge, e.g. open the pull request. Null leaves it inert. */ val onPr: (() -> Unit)? = null, + /** Uncommitted counts, measured against HEAD rather than the base branch. */ + val dirtyAdditions: Int = 0, + val dirtyDeletions: Int = 0, + val dirtyFiles: Int = 0, + /** Click handler for the uncommitted badge. Null leaves it inert. */ + val onDirty: (() -> Unit)? = null, ) internal enum class ActiveListRowHeight { EQUAL, PREFERRED } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 735f46a93996..98b4a6565f2a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -238,8 +238,15 @@ internal class ActiveListRenderer( } desc.foreground = weak val data = if (value.progress != null) null else value.metrics - metrics.update(data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, data?.pr, data?.prTooltip ?: data?.pr?.text) - metrics.setActions(data?.onChanges, data?.onPr) + metrics.update( + data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, + data?.pr, + data?.prTooltip ?: data?.pr?.text, + data?.dirtyAdditions ?: 0, + data?.dirtyDeletions ?: 0, + data?.dirtyFiles ?: 0, + ) + metrics.setActions(data?.onChanges, data?.onPr, data?.onDirty) val end = value.progress ?: value.trailing.orEmpty() trail.text = end trail.isVisible = end.isNotBlank() && data == null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index 0d04cb9a02d2..fd0ded28b1c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -590,6 +590,10 @@ internal class ActiveListView( item.metrics?.onChanges?.invoke() return } + ACTIVE_LIST_DIRTY_CELL -> { + item.metrics?.onDirty?.invoke() + return + } ACTIVE_LIST_PR_CELL -> { item.metrics?.onPr?.invoke() return 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 f165285cc1f7..ca242c5d7304 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -460,6 +460,7 @@ worktree.stats.diff.tooltip={0} additions, {1} deletions worktree.stats.ahead.tooltip=Commits ahead of base branch worktree.stats.behind.tooltip=Commits behind base branch worktree.stats.tooltip={0} commits ahead, {1} behind, +{2} -{3}.
Click to open the diff against the base branch. +worktree.dirty.tooltip={0} uncommitted files, +{1} -{2}.
Not included in the pull request until committed. worktree.pr.state.open=Open worktree.pr.state.draft=Draft worktree.pr.state.merged=Merged diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 5733e92c29fa..25686db34fdc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -38,6 +38,8 @@ import ai.kilocode.client.vfs.KiloVirtualFile import ai.kilocode.client.vfs.KiloVirtualFileSystem import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreePrListDto @@ -644,6 +646,48 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(3, metrics.behind) } + fun `test worktree row shows metrics for stats-only dirty-only and both`() { + val statsOnly = worktree("stats-only") + val dirtyOnly = worktree("dirty-only") + val both = worktree("both") + rpc.listed += statsOnly + rpc.listed += dirtyOnly + rpc.listed += both + rpc.statsResult = WorktreeStatsListDto( + listOf( + WorktreeStatsDto(statsOnly.path, additions = 5, deletions = 1), + WorktreeStatsDto(both.path, additions = 3, deletions = 0), + ), + ) + rpc.dirtyResult = WorktreeDirtyListDto( + listOf( + WorktreeDirtyDto(dirtyOnly.path, additions = 2, files = 1), + WorktreeDirtyDto(both.path, additions = 4, files = 2), + ), + ) + val timers = TestUiTimers() + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + waitUntil { row(panel, 0).metrics != null && row(panel, 1).metrics != null && row(panel, 2).metrics != null } + + val statsOnlyMetrics = row(panel, 0).metrics ?: error("expected metrics for stats-only row") + assertEquals(5, statsOnlyMetrics.additions) + assertEquals(0, statsOnlyMetrics.dirtyFiles) + + val dirtyOnlyMetrics = row(panel, 1).metrics ?: error("expected metrics for dirty-only row") + assertEquals(0, dirtyOnlyMetrics.additions) + assertEquals(2, dirtyOnlyMetrics.dirtyAdditions) + assertEquals(1, dirtyOnlyMetrics.dirtyFiles) + + val bothMetrics = row(panel, 2).metrics ?: error("expected metrics for both row") + assertEquals(3, bothMetrics.additions) + assertEquals(4, bothMetrics.dirtyAdditions) + assertEquals(2, bothMetrics.dirtyFiles) + } + fun `test open diff opens the branch diff editor`() { val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt new file mode 100644 index 000000000000..2ab1938920df --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt @@ -0,0 +1,41 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.util.edtWait +import ai.kilocode.rpc.dto.WorktreeStatsDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Regression coverage for the [WorktreeStatsView] memo key: the dirty badge lives on the same + * render stamp as the committed-diff badge, so a change confined to the dirty counts must still + * bust [WorktreeStatsView]'s `state == next` early return in `sync()`. + */ +class WorktreeStatsViewTest : BasePlatformTestCase() { + fun `test dirty badge updates when only dirty counts change`() { + val view = edt { WorktreeStatsView() } + val stats = WorktreeStatsDto("/repo", additions = 5, deletions = 1) + + edt { view.update(stats, badge = null, dirtyAdd = 1, dirtyDel = 0, dirtyFiles = 1) } + assertTrue(edt { view.dirtyHitForTest().isVisible }) + assertEquals("+1", edt { view.dirtyBadgeForTest().addedLabelForTest().text }) + + // Same stats DTO, different dirty counts: the State memo key must not skip this update. + edt { view.update(stats, badge = null, dirtyAdd = 3, dirtyDel = 2, dirtyFiles = 2) } + + assertTrue(edt { view.dirtyHitForTest().isVisible }) + assertEquals("+3", edt { view.dirtyBadgeForTest().addedLabelForTest().text }) + assertEquals("-2", edt { view.dirtyBadgeForTest().removedLabelForTest().text }) + } + + fun `test dirty badge hides when there are no uncommitted changes`() { + val view = edt { WorktreeStatsView() } + val stats = WorktreeStatsDto("/repo", additions = 5, deletions = 1) + + edt { view.update(stats, badge = null, dirtyAdd = 2, dirtyDel = 0, dirtyFiles = 1) } + assertTrue(edt { view.dirtyHitForTest().isVisible }) + + edt { view.update(stats, badge = null, dirtyAdd = 0, dirtyDel = 0, dirtyFiles = 0) } + assertFalse(edt { view.dirtyHitForTest().isVisible }) + } + + private fun edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt index 9cd5b74b02ae..5628ba9e3cc9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt @@ -6,6 +6,8 @@ import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsDto @@ -57,6 +59,22 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test attach loads dirty on the same debounce as stats`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 4))) + rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(path, additions = 2, files = 1))) + val key = normalizeWorktreePath(path) + + val handle = service.attach() + timers.advanceBy(300) + drain() + + assertEquals(4, service.stats.value[key]?.additions) + assertEquals(2, service.dirty.value[key]?.additions) + assertEquals(1, service.dirty.value[key]?.files) + handle.close() + } + fun `test refresh is ignored after the last handle closes`() { val path = "${project.basePath}/.kilo/worktrees/feature-x" val key = normalizeWorktreePath(path) 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 54d9aa68f562..34d17fcd84e0 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 @@ -9,6 +9,7 @@ import ai.kilocode.rpc.dto.MoveProgressDto import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrListDto @@ -26,6 +27,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val listed = CopyOnWriteArrayList() val branchesList = CopyOnWriteArrayList() var statsResult = WorktreeStatsListDto() + var dirtyResult = WorktreeDirtyListDto() var ghResult = GhAvailability.OK var prResult = WorktreePrListDto() var branchResult = BranchStatusDto() @@ -90,6 +92,11 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { return statsResult } + override suspend fun dirty(directory: String): WorktreeDirtyListDto { + assertNotEdt("dirty") + return dirtyResult + } + override suspend fun ghStatus(directory: String): GhAvailability { assertNotEdt("ghStatus") ghCalls.add(directory) 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 bf2d90229fbd..dbc7c0a01f87 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 @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.MoveProgressDto import ai.kilocode.rpc.dto.RemoveWorktreeResultDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.WorktreeBranchesDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeListDto import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsListDto @@ -41,6 +42,13 @@ interface KiloWorktreeRpcApi : RemoteApi { suspend fun open(directory: String): Boolean suspend fun stats(directory: String): WorktreeStatsListDto + + /** + * Uncommitted changes per managed worktree, vs each worktree's own HEAD. Separate from [stats] + * because the baseline differs (HEAD vs the base branch) and because it changes on every file + * save, so callers may refresh it independently. + */ + suspend fun dirty(directory: String): WorktreeDirtyListDto suspend fun ghStatus(directory: String): GhAvailability suspend fun prStatus(directory: String): WorktreePrListDto diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index 54ee736293d1..28c4bde33c6a 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -20,16 +20,38 @@ data class WorktreeListDto(val worktrees: List = emptyList()) @Serializable data class WorktreeStatsDto( val path: String, + /** Committed additions vs `merge-base(base, HEAD)` — GitHub's "Files changed" number. */ val additions: Int = 0, val deletions: Int = 0, val ahead: Int = 0, val behind: Int = 0, val files: Int = 0, + /** Resolved base ref the counts are relative to, e.g. `origin/main`. Empty when unresolved. */ + val base: String = "", ) @Serializable data class WorktreeStatsListDto(val items: List = emptyList()) +/** + * Uncommitted state of one worktree, relative to its own HEAD — staged, unstaged, and untracked + * together. Deliberately separate from [WorktreeStatsDto], which is measured against the base branch. + */ +@Serializable +data class WorktreeDirtyDto( + val path: String, + val additions: Int = 0, + val deletions: Int = 0, + val files: Int = 0, + /** How many of [files] are untracked. Their additions are line counts; deletions are always 0. */ + val untracked: Int = 0, + /** Commits ahead of `@{upstream}`. 0 when the branch has no upstream. */ + val unpushed: Int = 0, +) + +@Serializable +data class WorktreeDirtyListDto(val items: List = emptyList()) + @Serializable enum class GhState { OPEN, DRAFT, MERGED, CLOSED } From 94169371f6eeecf201ec0a4bb9ca76ad600293f3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 14:03:32 -0400 Subject: [PATCH 02/19] refactor(jetbrains): decouple PR badges from ActiveList Keep PR badges beside the diff statistics while exposing generic clickable badge slots and model-based action dispatch. Let Agent Manager own PR semantics instead of the shared list and metrics components. --- .changeset/jetbrains-pr-badge-leading.md | 5 + .../client/agentManager/AgentManagerPanel.kt | 22 +- .../worktree/WorktreeStatsView.kt | 57 +--- .../client/ui/list/ActiveListActions.kt | 17 +- .../client/ui/list/ActiveListModel.kt | 33 +- .../client/ui/list/ActiveListRenderer.kt | 87 +++-- .../kilocode/client/ui/list/ActiveListView.kt | 32 +- .../agentManager/AgentManagerPanelTest.kt | 98 +++++- .../worktree/WorktreeStatsViewTest.kt | 8 +- .../settings/base/SettingsListViewTest.kt | 305 ++++++++++++++++-- 10 files changed, 497 insertions(+), 167 deletions(-) create mode 100644 .changeset/jetbrains-pr-badge-leading.md diff --git a/.changeset/jetbrains-pr-badge-leading.md b/.changeset/jetbrains-pr-badge-leading.md new file mode 100644 index 000000000000..f1993fa1422b --- /dev/null +++ b/.changeset/jetbrains-pr-badge-leading.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep clickable pull request badges beside the change statistics using reusable list badges diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 43ed036fe656..a360fe44a347 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -515,7 +515,7 @@ class AgentManagerPanel( } /** - * Inner (not data) class so [metrics] can bind each row's changes/dirty/PR handlers to the + * Inner (not data) class so [metrics] can bind each row's changes/dirty handlers to the * panel. Value equality is over the data fields only — the derived handlers are intentionally * excluded so a stats/PR/dirty refresh that produced identical rows still skips the model * rebuild in [ActiveListView]. @@ -539,22 +539,32 @@ class AgentManagerPanel( override val section: String? get() = if (current) null else KiloBundle.message("worktree.section.local") override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ") private val customName: String? get() = WorktreeTitle.custom(dto.name, dto.path) + override val secondaryBadges: List + get() { + if (progress != null) return emptyList() + val p = pr ?: return emptyList() + return listOf( + ActiveListBadge( + "#${p.number}", + style(p.state), + id = "pull-request", + tooltip = prTooltip(p, customName), + action = { BrowserUtil.browse(p.url) }, + ), + ) + } override val metrics: ActiveListMetrics? get() { if (progress != null) return null val s = stats - val p = pr val d = dirty - if (s == null && p == null && d == null) return null + if (s == null && d == null) return null return ActiveListMetrics( additions = s?.additions ?: 0, deletions = s?.deletions ?: 0, ahead = s?.ahead ?: 0, behind = s?.behind ?: 0, - pr = p?.let { ActiveListBadge("#${it.number}", style(it.state)) }, - prTooltip = p?.let { prTooltip(it, customName) }, onChanges = s?.let { { openBranchDiff(dto.path) } }, - onPr = p?.url?.let { url -> { BrowserUtil.browse(url) } }, dirtyAdditions = d?.additions ?: 0, dirtyDeletions = d?.deletions ?: 0, dirtyFiles = d?.files ?: 0, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt index 5c492b9855cf..99ebc585382c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -2,19 +2,12 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.DiffStatBadge -import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_DIRTY_CELL -import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL -import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListHitCell import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.client.ui.prTooltip -import ai.kilocode.client.ui.style -import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto -import com.intellij.ide.BrowserUtil import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBFont @@ -29,7 +22,7 @@ import javax.swing.Icon import javax.swing.JPanel /** - * The trailing ahead/behind/diff and PR badges of a worktree row. + * The trailing ahead/behind/diff badges of a worktree row. * * Uses a real layout manager on purpose: a `null` layout resolves min/preferred size through the * peer, which reports the component's *current* size. Inside the list this view is a single render @@ -51,25 +44,19 @@ internal class WorktreeStatsView( private val diff = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) // Muted and unfilled so it reads as a different baseline than the committed-diff badge above. private val dirty = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = false) - private val pr = JBLabel() private val change = Stack.horizontal(UiStyle.Gap.sm()).next(behind).next(ahead).next(diff) - // The change, dirty, and PR badges are hit regions so the list can drive their clicks: inside + // The change and dirty badges are hit regions so the list can drive their clicks: inside // the list the view is a render stamp whose own mouse listeners never fire, so the ActiveList // reads these ids back and routes the click. Standalone (toolbar) usage keeps its own listeners // below. private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) } private val dirtyHit = HitRegion(ACTIVE_LIST_DIRTY_CELL).apply { add(dirty, BorderLayout.CENTER) } - private val prHit = HitRegion(ACTIVE_LIST_PR_CELL).apply { add(pr, BorderLayout.CENTER) } - // The changes badge trails first, the uncommitted badge next, and the PR link is pinned last so - // it is always the rightmost element. - private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(dirtyHit).next(prHit) - private var url: String? = null + private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(dirtyHit) private var state: State? = null init { add(row, BorderLayout.CENTER) changeHit.act = openDiff - prHit.act = { url?.let(BrowserUtil::browse) } diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0) installClick(changeHit, object : MouseAdapter() { override fun mouseClicked(event: MouseEvent) { @@ -81,28 +68,21 @@ internal class WorktreeStatsView( dirtyHit.act?.invoke() } }) - installClick(prHit, object : MouseAdapter() { - override fun mouseClicked(event: MouseEvent) { - prHit.act?.invoke() - } - }) applyCursors() } /** - * Replaces the click handlers, used by the list renderer to bind each row's changes/dirty/PR + * Replaces the click handlers, used by the list renderer to bind each row's changes/dirty * actions to the single reused stamp. Standalone usage leaves the constructor defaults in place. */ - fun setActions(onChanges: (() -> Unit)?, onPr: (() -> Unit)?, onDirty: (() -> Unit)? = null) { + fun setActions(onChanges: (() -> Unit)?, onDirty: (() -> Unit)? = null) { changeHit.act = onChanges - prHit.act = onPr dirtyHit.act = onDirty applyCursors() } fun update( stats: WorktreeStatsDto?, - pull: WorktreePrDto?, dirtyAdd: Int = 0, dirtyDel: Int = 0, dirtyFiles: Int = 0, @@ -110,9 +90,6 @@ internal class WorktreeStatsView( sync( State( stats, - pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, - pull?.url, - pull?.let(::prTooltip), dirtyAdd, dirtyDel, dirtyFiles, @@ -120,17 +97,6 @@ internal class WorktreeStatsView( ) } - fun update( - stats: WorktreeStatsDto?, - badge: ActiveListBadge?, - prTip: String? = badge?.text, - dirtyAdd: Int = 0, - dirtyDel: Int = 0, - dirtyFiles: Int = 0, - ) { - sync(State(stats, badge, null, prTip, dirtyAdd, dirtyDel, dirtyFiles)) - } - /** * Applies [next] unless it is already rendered. The memo key must cover everything this method * writes: inside the list one instance renders every row, so a field left out of the key would @@ -161,16 +127,9 @@ internal class WorktreeStatsView( dirtyHit.tip = dirtyTip dirtyHit.toolTipText = dirtyTip dirtyHit.isVisible = dirty.isVisible - url = next.link - pr.icon = next.badge?.let { FilledBadgeIcon(it.text, it.style) } - pr.toolTipText = next.tip - prHit.tip = next.tip - prHit.toolTipText = next.tip - pr.isVisible = next.badge != null val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible changeHit.isVisible = changesVisible - prHit.isVisible = pr.isVisible - isVisible = changesVisible || dirtyHit.isVisible || pr.isVisible + isVisible = changesVisible || dirtyHit.isVisible applyCursors() revalidate() repaint() @@ -179,7 +138,6 @@ internal class WorktreeStatsView( private fun applyCursors() { applyCursor(changeHit, changeHit.act != null) applyCursor(dirtyHit, dirtyHit.act != null) - applyCursor(prHit, prHit.act != null) } private fun actionCursor(active: Boolean) = @@ -210,9 +168,6 @@ internal class WorktreeStatsView( /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ private data class State( val stats: WorktreeStatsDto?, - val badge: ActiveListBadge?, - val link: String?, - val tip: String?, val dirtyAdd: Int, val dirtyDel: Int, val dirtyFiles: Int, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt index daa3be71cb21..a9934801e329 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt @@ -13,11 +13,24 @@ internal const val ACTIVE_LIST_RENAME_CELL = "rename" internal const val ACTIVE_LIST_DELETE_CELL = "delete" internal const val ACTIVE_LIST_MENU_CELL = "__menu__" -/** Ids for the trailing metrics badges hit-tested in place (see [ActiveListMetrics]). */ +/** Well-known ids for rich badges hit-tested in place. */ internal const val ACTIVE_LIST_CHANGES_CELL = "__changes__" -internal const val ACTIVE_LIST_PR_CELL = "__pr__" internal const val ACTIVE_LIST_DIRTY_CELL = "__dirty__" +internal fun activeListRegions(item: ActiveListItem): Map Unit> { + if (item.progress != null) return emptyMap() + val out = linkedMapOf Unit>() + for (badge in item.leading + item.badges + item.secondaryBadges) { + val id = badge.id + val act = badge.action + if (!id.isNullOrBlank() && act != null) out[id] = act + } + val data = item.metrics + data?.onChanges?.let { out[ACTIVE_LIST_CHANGES_CELL] = it } + data?.onDirty?.let { out[ACTIVE_LIST_DIRTY_CELL] = it } + return out +} + internal fun activeListRenameCell(label: String = KiloBundle.message("common.rename")) = ActiveListCell( ACTIVE_LIST_RENAME_CELL, label, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 7cf274050b24..20ca885fd25f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -15,19 +15,25 @@ import javax.swing.SwingUtilities private const val CELL_GAP = 8 -internal data class ActiveListBadge(val text: String, val style: UiStyle.Badge.Style = UiStyle.Badge.Secondary) +/** + * A pill rendered before or after an [ActiveListItem] title. A non-null [id] opts the badge into + * hit-testing; [action] requires an id to have any effect. + */ +internal data class ActiveListBadge( + val text: String, + val style: UiStyle.Badge.Style = UiStyle.Badge.Secondary, + val id: String? = null, + val tooltip: String? = null, + val action: (() -> Unit)? = null, +) internal data class ActiveListMetrics( val additions: Int = 0, val deletions: Int = 0, val ahead: Int = 0, val behind: Int = 0, - val pr: ActiveListBadge? = null, - val prTooltip: String? = null, /** Click handler for the changes badge, e.g. open the branch diff. Null leaves it inert. */ val onChanges: (() -> Unit)? = null, - /** Click handler for the PR badge, e.g. open the pull request. Null leaves it inert. */ - val onPr: (() -> Unit)? = null, /** Uncommitted counts, measured against HEAD rather than the base branch. */ val dirtyAdditions: Int = 0, val dirtyDeletions: Int = 0, @@ -84,8 +90,7 @@ internal data class ActiveListCell( /** * A component in a rendered [ActiveListItem] row that the list hit-tests for clicks, hover cursor, - * and tooltips. Implemented by the action-cell buttons and by rich trailing badges (the worktree - * changes/PR metrics) so both flow through the same click/cursor/tooltip plumbing. + * and tooltips. */ internal interface ActiveListHitCell { val cellId: String @@ -98,9 +103,10 @@ internal interface ActiveListHitCell { /** * A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree * list, and the session history stack: a leading icon, a title whose weight follows - * [ActiveListConfig.title] with an inline [note], a secondary [description] line, inline [badges], - * optional right-aligned [trailing] text, and action [cells]. Action cells are shown only for the - * active focused selection unless [ActiveListCell.alwaysVisible] is true. + * [ActiveListConfig.title] with an inline [note], a secondary [description] line, [leading] badges + * before the title, inline [badges] after it, optional right-aligned [trailing] text, and action + * [cells]. Action cells are shown only for the active focused selection unless + * [ActiveListCell.alwaysVisible] is true. */ internal interface ActiveListItem { val key: String @@ -123,7 +129,10 @@ internal interface ActiveListItem { */ val tinted: Boolean get() = false val section: String? get() = null + val leading: List get() = emptyList() + /** Badges rendered after the title. */ val badges: List get() = emptyList() + val secondaryBadges: List get() = emptyList() /** Right-aligned secondary text, such as a relative timestamp. */ val trailing: String? get() = null val metrics: ActiveListMetrics? get() = null @@ -236,8 +245,6 @@ internal fun activeListCellAt( .firstOrNull { cell -> cell.enabled && bounds[cell.id]?.contains(point) == true } ?.id if (fromCell != null) return fromCell - // Regions that are not backed by an ActiveListCell (the changes/PR badges) are actionable in - // place: match the first enabled one with a handler under the point. return hits.firstOrNull { it.enabled && it.action != null && it.bounds.contains(point) }?.id } @@ -276,7 +283,7 @@ private fun forEachHitCell(component: Component, action: (ActiveListHitCell) -> // Skip hidden subtrees so a badge left visible inside a hidden trailing panel is not // collected as a live hit target. if (!c.isVisible) return - if (c is ActiveListHitCell) action(c) + if (c is ActiveListHitCell && c.cellId.isNotBlank()) action(c) if (c is Container) c.components.forEach(::visit) } visit(component) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 98b4a6565f2a..c28752865879 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -83,10 +83,13 @@ internal class ActiveListRenderer( private val icon = JBLabel().apply { verticalAlignment = SwingConstants.TOP } private val mark = icon.align(HAlign.CENTER, VAlign.CENTER) private val title = SimpleColoredComponent() - private val badges = Stack.horizontal() - // Title in CENTER clips when the group is narrow; trailing tags in EAST keep their full - // preferred width. A squeezed row sacrifices the title text but never drops the tags. + private val leading = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()) + private val badges = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()) + private val secondary = Stack.horizontal(UiStyle.Gap.md()) + // Title in CENTER clips when the group is narrow; tags in WEST/EAST keep their full preferred + // width. A squeezed row sacrifices the title text but never drops the tags. private val titleGroup = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)).apply { + add(leading, BorderLayout.WEST) add(title, BorderLayout.CENTER) add(badges, BorderLayout.EAST) } @@ -95,12 +98,11 @@ internal class ActiveListRenderer( private val header = titleGroup.align(HAlign.LEFT, VAlign.CENTER) private val desc = JBLabel() private val metrics = WorktreeStatsView() - private val metricsPane = metrics.align(HAlign.RIGHT, VAlign.CENTER) - // The description (branch) line carries the changes/PR metrics on its trailing edge so they sit - // on the branch row instead of spanning the full row height. + private val details = Stack.horizontal(UiStyle.Gap.md()).next(metrics).next(secondary) + private val detailsPane = details.align(HAlign.RIGHT, VAlign.CENTER) private val descLine = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { add(desc, BorderLayout.CENTER) - add(metricsPane, BorderLayout.EAST) + add(detailsPane, BorderLayout.EAST) } private val text = Stack.vertical().next(header).next(descLine) private val textPane = text.align(HAlign.TRACK, VAlign.CENTER) @@ -145,6 +147,7 @@ internal class ActiveListRenderer( mark, icon, title, + leading, badges, titleGroup, header, @@ -153,7 +156,9 @@ internal class ActiveListRenderer( desc, descLine, metrics, - metricsPane, + secondary, + details, + detailsPane, trail, trailPane, endPane, @@ -240,22 +245,16 @@ internal class ActiveListRenderer( val data = if (value.progress != null) null else value.metrics metrics.update( data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, - data?.pr, - data?.prTooltip ?: data?.pr?.text, data?.dirtyAdditions ?: 0, data?.dirtyDeletions ?: 0, data?.dirtyFiles ?: 0, ) - metrics.setActions(data?.onChanges, data?.onPr, data?.onDirty) + metrics.setActions(data?.onChanges, data?.onDirty) val end = value.progress ?: value.trailing.orEmpty() trail.text = end trail.isVisible = end.isNotBlank() && data == null - metrics.isVisible = data != null - // Hide the wrapper too so a metrics-less row does not reserve the trailing gap on its - // description, and collapse the whole second row when it would be empty so title-only rows - // stay vertically centered. - metricsPane.isVisible = metrics.isVisible - descLine.isVisible = desc.isVisible || metrics.isVisible + detailsPane.isVisible = metrics.isVisible || secondary.isVisible + descLine.isVisible = desc.isVisible || detailsPane.isVisible trail.foreground = weak val hovered = (list as? ActiveListActive)?.hoveredIndex() == index @@ -372,21 +371,21 @@ internal class ActiveListRenderer( } private fun syncBadges(item: ActiveListItem) { - val items = if (item.progress != null) emptyList() else item.badges - while (badges.componentCount > items.size) badges.remove(badges.componentCount - 1) - while (badges.componentCount < items.size) { - badges.add(JBLabel().apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - }) - } - badges.isVisible = items.isNotEmpty() + val hidden = item.progress != null + val gap = JBUI.CurrentTheme.ActionsList.elementIconGap() + leading.border = JBUI.Borders.emptyRight(gap) + badges.border = JBUI.Borders.emptyLeft(gap) + syncBadges(leading, if (hidden) emptyList() else item.leading) + syncBadges(badges, if (hidden) emptyList() else item.badges) + syncBadges(secondary, if (hidden) emptyList() else item.secondaryBadges) + } + + private fun syncBadges(stack: JPanel, items: List) { + while (stack.componentCount > items.size) stack.remove(stack.componentCount - 1) + while (stack.componentCount < items.size) stack.add(ActiveListBadgeCell()) + stack.isVisible = items.isNotEmpty() for (i in items.indices) { - val badge = items[i] - val label = badges.getComponent(i) as JBLabel - val current = label.icon as? FilledBadgeIcon - if (current?.text != badge.text || current.style != badge.style) { - label.icon = FilledBadgeIcon(badge.text, badge.style) - } + (stack.getComponent(i) as ActiveListBadgeCell).update(items[i]) } } @@ -437,3 +436,29 @@ internal class ActiveListActionCell : JBLabel(), ActiveListHitCell { if (text.isNotBlank()) UiStyle.Components.actionLabel(this, enabled) } } + +/** A retained badge pill that opts into list hit-testing when its model carries an id. */ +internal class ActiveListBadgeCell : JBLabel(), ActiveListHitCell { + private var badge: ActiveListBadge? = null + + override var cellId: String = "" + private set + + fun update(badge: ActiveListBadge) { + this.badge = badge + cellId = badge.id.orEmpty() + val current = icon as? FilledBadgeIcon + if (current?.text != badge.text || current.style != badge.style) { + icon = FilledBadgeIcon(badge.text, badge.style) + } + toolTipText = cellTooltip() + } + + override fun cellEnabled(): Boolean = badge?.action != null + + override fun cellCursor(): Int = Cursor.HAND_CURSOR + + override fun cellTooltip(): String? = badge?.tooltip?.takeIf { it.isNotBlank() } + + override fun cellAction(): (() -> Unit)? = badge?.action +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index fd0ded28b1c8..f707a87c120a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -580,27 +580,19 @@ internal class ActiveListView( ?: onActivate?.invoke(item) } - /** - * Dispatches a click on a button. A per-cell action or a metrics handler ([ActiveListMetrics]) - * takes precedence; otherwise the click routes through the list-level [onCell] callback. - */ + /** Dispatches a click from the row model, never the renderer stamp reused across rows. */ private fun fire(item: ActiveListItem, id: String) { - when (id) { - ACTIVE_LIST_CHANGES_CELL -> { - item.metrics?.onChanges?.invoke() - return - } - ACTIVE_LIST_DIRTY_CELL -> { - item.metrics?.onDirty?.invoke() - return - } - ACTIVE_LIST_PR_CELL -> { - item.metrics?.onPr?.invoke() - return - } + val cell = item.cells.firstOrNull { it.id == id }?.action + if (cell != null) { + cell() + return + } + val region = activeListRegions(item)[id] + if (region != null) { + region() + return } - val action = item.cells.firstOrNull { it.id == id }?.action - if (action != null) action() else onCell(item.key, id) + onCell(item.key, id) } @RequiresEdt @@ -718,7 +710,7 @@ internal class ActiveListView( if (!bounds.contains(point)) return baseCursor val item = model.getElementAt(idx) if (item is ActiveListGap) return baseCursor - if (menu == null && item.cells.isEmpty() && item.metrics == null) return baseCursor + if (menu == null && item.cells.isEmpty() && activeListRegions(item).isEmpty()) return baseCursor val hit = activeListHits(list, idx, list.isSelectedIndex(idx)) .firstOrNull { it.enabled && it.bounds.contains(point) } ?: return baseCursor diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 25686db34fdc..8f07e518a129 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -29,7 +29,8 @@ import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.ActiveListMetrics import ai.kilocode.client.ui.list.ActiveListView -import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL +import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL +import ai.kilocode.client.ui.list.ACTIVE_LIST_DIRTY_CELL import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloPath @@ -56,11 +57,14 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container +import java.awt.Cursor +import java.awt.event.InputEvent import java.awt.event.MouseEvent import java.awt.Point import javax.swing.JComponent @@ -751,12 +755,14 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } edt { controller.reload() } timers.advanceBy(300) - waitUntil { rows(panel) > 0 && row(panel, 0).metrics != null } + waitUntil { rows(panel) > 0 && row(panel, 0).secondaryBadges.isNotEmpty() } - // The current row keeps the branch as its title; the PR arrives as a badge beside it. val current = row(panel, 0) assertEquals("main", current.title) - assertEquals("#12", current.metrics?.pr?.text) + assertTrue(current.leading.isEmpty()) + assertTrue(current.badges.isEmpty()) + assertEquals("#12", current.secondaryBadges.single().text) + assertEquals("pull-request", current.secondaryBadges.single().id) assertTrue(edt { panel.canOpenPr(main) }) } @@ -776,7 +782,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val row = row(panel, 0) assertEquals("Fix bug", row.title) - val tip = row.metrics?.prTooltip ?: error("expected PR tooltip") + assertTrue(row.leading.isEmpty()) + assertTrue(row.badges.isEmpty()) + val tip = row.secondaryBadges.single().tooltip ?: error("expected PR tooltip") assertEquals("Draft #7 Fix <login> bug
(Feature Label)
Click to open the pull request in your browser.", tip) val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } edt { @@ -784,7 +792,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { list.doLayout() UIUtil.dispatchAllInvocationEvents() } - val area = edt { activeListCellBounds(list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) } + val area = edt { activeListCellBounds(list, 0, selected = false).getValue("pull-request") } assertEquals(tip, edt { list.getToolTipText(MouseEvent(list, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, center(area).x, center(area).y, 0, false)) }) } @@ -805,7 +813,81 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val row = row(panel, 0) assertEquals("Feature Label", row.title) - assertEquals("Open #8
Click to open the pull request in your browser.", row.metrics?.prTooltip) + assertTrue(row.leading.isEmpty()) + assertEquals("Open #8
Click to open the pull request in your browser.", row.secondaryBadges.single().tooltip) + } + + fun `test pr badge follows stats on the description line and opens the browser without opening an editor`() { + val browser = installBrowser() + val item = worktree("feature-x") + val url = "https://example.test/pr/7" + rpc.listed += item + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, url, "Feature title"))) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1))) + rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(item.path, additions = 3, files = 1))) + val timers = TestUiTimers() + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + flush() + + edt { + val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! + val list = view.list + list.setSize(560, 160) + list.doLayout() + UIUtil.dispatchAllInvocationEvents() + list.clearSelection() + val row = list.model.getElementAt(0) + assertTrue(row.leading.isEmpty()) + assertTrue(row.badges.isEmpty()) + assertEquals("#7", row.secondaryBadges.single().text) + assertEquals("pull-request", row.secondaryBadges.single().id) + val areas = activeListCellBounds(list, 0, selected = false) + val badge = areas.getValue("pull-request") + val changes = areas.getValue(ACTIVE_LIST_CHANGES_CELL) + val dirty = areas.getValue(ACTIVE_LIST_DIRTY_CELL) + val renderer = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, true) + renderer.setSize(list.width, list.getCellBounds(0, 0).height) + components(renderer).filterIsInstance().forEach { it.doLayout() } + val desc = components(renderer).filterIsInstance().single { it.text == row.description } + val title = components(renderer).filterIsInstance().single() + val origin = SwingUtilities.convertPoint(desc, 0, 0, renderer) + val header = SwingUtilities.convertPoint(title, 0, 0, renderer) + val bounds = list.getCellBounds(0, 0) + assertTrue(origin.x + desc.width + bounds.x <= changes.x) + assertTrue(changes.x + changes.width <= dirty.x) + assertTrue(dirty.x + dirty.width <= badge.x) + assertTrue(kotlin.math.abs(center(badge).y - center(changes).y) <= 1) + assertTrue(kotlin.math.abs(center(badge).y - center(dirty).y) <= 1) + assertTrue(kotlin.math.abs(center(badge).y - (bounds.y + origin.y + desc.height / 2)) <= 1) + assertTrue(badge.y >= bounds.y + header.y + title.height) + assertTrue(bounds.contains(badge)) + assertTrue(browser.urls.isEmpty()) + + val point = center(badge) + val hover = MouseEvent(list, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, point.x, point.y, 0, false) + list.mouseMotionListeners.forEach { it.mouseMoved(hover) } + assertEquals(Cursor.HAND_CURSOR, list.cursor.type) + assertEquals(row.secondaryBadges.single().tooltip, list.getToolTipText(hover)) + for (id in listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + fire(list, MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + )) + } + assertEquals(listOf(url), browser.urls) + assertTrue(FileEditorManager.getInstance(project).openFiles.isEmpty()) + } } fun `test worktree row hides badge while in progress`() { @@ -824,7 +906,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val pending = row(panel, 0) assertSame(WorktreeIcons.spinner, pending.icon) assertEquals(KiloBundle.message("worktree.progress.creating"), pending.progress) + assertEquals(emptyList(), pending.leading) assertEquals(emptyList(), pending.badges) + assertEquals(emptyList(), pending.secondaryBadges) assertNull(pending.metrics) gate.complete(Unit) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt index 2ab1938920df..b1f70d67e8cb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt @@ -14,12 +14,12 @@ class WorktreeStatsViewTest : BasePlatformTestCase() { val view = edt { WorktreeStatsView() } val stats = WorktreeStatsDto("/repo", additions = 5, deletions = 1) - edt { view.update(stats, badge = null, dirtyAdd = 1, dirtyDel = 0, dirtyFiles = 1) } + edt { view.update(stats, dirtyAdd = 1, dirtyDel = 0, dirtyFiles = 1) } assertTrue(edt { view.dirtyHitForTest().isVisible }) assertEquals("+1", edt { view.dirtyBadgeForTest().addedLabelForTest().text }) // Same stats DTO, different dirty counts: the State memo key must not skip this update. - edt { view.update(stats, badge = null, dirtyAdd = 3, dirtyDel = 2, dirtyFiles = 2) } + edt { view.update(stats, dirtyAdd = 3, dirtyDel = 2, dirtyFiles = 2) } assertTrue(edt { view.dirtyHitForTest().isVisible }) assertEquals("+3", edt { view.dirtyBadgeForTest().addedLabelForTest().text }) @@ -30,10 +30,10 @@ class WorktreeStatsViewTest : BasePlatformTestCase() { val view = edt { WorktreeStatsView() } val stats = WorktreeStatsDto("/repo", additions = 5, deletions = 1) - edt { view.update(stats, badge = null, dirtyAdd = 2, dirtyDel = 0, dirtyFiles = 1) } + edt { view.update(stats, dirtyAdd = 2, dirtyDel = 0, dirtyFiles = 1) } assertTrue(edt { view.dirtyHitForTest().isVisible }) - edt { view.update(stats, badge = null, dirtyAdd = 0, dirtyDel = 0, dirtyFiles = 0) } + edt { view.update(stats, dirtyAdd = 0, dirtyDel = 0, dirtyFiles = 0) } assertFalse(edt { view.dirtyHitForTest().isVisible }) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 1e17cce1bca6..a04118d83d8b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListBadge +import ai.kilocode.client.ui.list.ActiveListBadgeCell import ai.kilocode.client.ui.list.ActiveListActive import ai.kilocode.client.ui.list.ActiveListCell import ai.kilocode.client.ui.list.ActiveListConfig @@ -22,7 +23,6 @@ import ai.kilocode.client.ui.list.ActiveListView import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_MENU_CELL -import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL import ai.kilocode.client.ui.list.activeListCellAt import ai.kilocode.client.ui.list.activeListCellBounds import com.intellij.icons.AllIcons @@ -300,6 +300,27 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test leading badge renders before the title`() { + edt { + val badge = ActiveListBadge("#12", id = "badge", action = {}) + val row = badgeItem("wt", "Implement feature", badge) + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + renderer.setSize(360, renderer.preferredSize.height) + layout(renderer) + + val pill = components(renderer).filterIsInstance().single() + val title = components(renderer).filterIsInstance().single() + val pillX = SwingUtilities.convertPoint(pill.parent, pill.location, renderer).x + val titleX = SwingUtilities.convertPoint(title.parent, title.location, renderer).x + + assertTrue(pillX + pill.width <= titleX) + } + } + fun `test renderer centers leading icon vertically in the row`() { edt { val row = object : ActiveListItem { @@ -692,15 +713,15 @@ class SettingsListViewTest : BasePlatformTestCase() { val key = DataKey.create("test.activeList.menu.edge") val menu = ActiveListMenu(key, DefaultActionGroup(), element = { it }) val view = ActiveListView("Empty", menu = menu) { _, _ -> } - view.update(listOf(metricsItem("with", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"))))) + view.update(listOf(metricsItem("with", "Alpha", ActiveListMetrics(additions = 5, onChanges = {})))) layout(view) hover(view, center(view.list.getCellBounds(0, 0))) val cells = activeListCellBounds(view.list, 0, selected = true) - val pull = cells.getValue(ACTIVE_LIST_PR_CELL) + val changes = cells.getValue(ACTIVE_LIST_CHANGES_CELL) val menuArea = cells.getValue(ACTIVE_LIST_MENU_CELL) - assertEquals("menu glyph should touch trailing row content", pull.x + pull.width, menuArea.x) + assertEquals("menu glyph should touch trailing row content", changes.x + changes.width, menuArea.x) } } @@ -1065,36 +1086,59 @@ class SettingsListViewTest : BasePlatformTestCase() { } } - fun `test pr badge is hit tested and invokes its action`() { + fun `test leading badge is hit tested and invokes its action`() { edt { val calls = mutableListOf() val view = ActiveListView("Empty") { _, _ -> } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"), onPr = { calls += "pr" })))) + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("#12", id = "badge", tooltip = "#12", action = { calls += "badge" })))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_PR_CELL) + val area = activeListCellBounds(view.list, 0, selected = true).getValue("badge") assertEquals("#12", view.list.getToolTipText(event(view.list, center(area)))) hover(view, center(area)) assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) click(view, center(area)) - assertEquals(listOf("pr"), calls) + assertEquals(listOf("badge"), calls) } } - fun `test pr badge uses custom tooltip when supplied`() { + fun `test leading badge uses custom tooltip when supplied`() { edt { val view = ActiveListView("Empty") { _, _ -> } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"), prTooltip = "PR details")))) + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("#12", id = "badge", tooltip = "Badge details", action = {})))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_PR_CELL) + val area = activeListCellBounds(view.list, 0, selected = true).getValue("badge") + + assertEquals("Badge details", view.list.getToolTipText(event(view.list, center(area)))) + } + } + + fun `test decorative badge is not hit tested`() { + edt { + val view = ActiveListView("Empty") { _, _ -> } + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("Local")))) + layout(view) - assertEquals("PR details", view.list.getToolTipText(event(view.list, center(area)))) + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + } + } + + fun `test badge action without an id is inert`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + view.update(listOf(badgeItem("wt", "Alpha", ActiveListBadge("Local", action = { calls += "badge" })))) + layout(view) + + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + click(view, center(view.list.getCellBounds(0, 0))) + assertTrue(calls.isEmpty()) } } @@ -1115,43 +1159,31 @@ class SettingsListViewTest : BasePlatformTestCase() { } } - fun `test pr badge hit region ignores the metrics of other rows`() { + fun `test leading badge hit region ignores other rows`() { edt { val calls = mutableListOf() val view = ActiveListView("Empty") { _, _ -> } view.update( listOf( - metricsItem( - "wide", - "Alpha", - ActiveListMetrics( - additions = 1234, - deletions = 987, - ahead = 42, - behind = 17, - pr = ActiveListBadge("#12345"), - onPr = { calls += "wide" }, - ), - ), - metricsItem("narrow", "Beta", ActiveListMetrics(pr = ActiveListBadge("#7"), onPr = { calls += "narrow" })), + badgeItem("wide", "Alpha", ActiveListBadge("#12345", id = "badge", action = { calls += "wide" })), + badgeItem("narrow", "Beta", ActiveListBadge("#7", id = "badge", action = { calls += "narrow" })), ), ) view.list.size = Dimension(360, 160) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - val wide = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) - val narrow = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL) - // Both badges trail their row, so they share a right edge no matter how wide the changes - // beside them are. - assertEquals(wide.x + wide.width, narrow.x + narrow.width) + val wide = activeListCellBounds(view.list, 0, selected = false).getValue("badge") + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue("badge") + // Both badges lead their title, so they share a left edge regardless of badge width. + assertEquals(wide.x, narrow.x) // The renderer is one reused stamp: rendering the wide row, or a full paint pass over // every row, must not move the narrow row's hit region. activeListCellBounds(view.list, 0, selected = false) - assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue("badge")) paint(view.list) - assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue("badge")) click(view, center(narrow)) click(view, center(wide)) @@ -1159,6 +1191,192 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test secondary badge without metrics supports cursor click and tooltip with or without description`() { + edt { + for (note in listOf(null, "Description")) { + val calls = mutableListOf() + val fallback = mutableListOf() + val view = ActiveListView("Empty") { key, id -> fallback += "$key:$id" } + val row = secondaryItem( + "row", + "Alpha", + ActiveListBadge("Ready", id = "badge", tooltip = "Badge details", action = { calls += "badge" }), + note, + ) + view.update(listOf(row)) + layout(view) + view.list.clearSelection() + + assertNull(row.metrics) + val area = activeListCellBounds(view.list, 0, selected = false).getValue("badge") + assertTrue(area.width > 0 && area.height > 0) + val renderer = rendered(view) + val title = components(renderer).filterIsInstance().single() + assertTrue(area.y >= topY(renderer, title) + title.height) + assertEquals("badge", activeListCellAt(view.list, 0, center(area), selected = false)) + assertEquals("Badge details", view.list.getToolTipText(event(view.list, center(area)))) + hover(view, center(area)) + assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) + + click(view, center(area)) + assertEquals(listOf("badge"), calls) + assertTrue(fallback.isEmpty()) + hover(view, Point(2, 2)) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + } + } + } + + fun `test secondary badge is right aligned after metrics on the description line while title badges remain supported`() { + edt { + val calls = mutableListOf() + val row = object : ActiveListItem { + override val key = "row" + override val title = "Alpha" + override val description = "Description" + override val leading = listOf(ActiveListBadge("Local", id = "leading", action = { calls += "leading" })) + override val badges = listOf(ActiveListBadge("Ready", id = "trailing", action = { calls += "trailing" })) + override val secondaryBadges = listOf(ActiveListBadge("Details", id = "badge", action = { calls += "badge" })) + override val metrics = ActiveListMetrics(additions = 5, deletions = 2, ahead = 1, onChanges = { calls += "changes" }) + } + val view = ActiveListView("Empty") { _, _ -> } + view.update(listOf(row)) + view.list.size = Dimension(480, 160) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + + val areas = activeListCellBounds(view.list, 0, selected = false) + val badge = areas.getValue("badge") + val changes = areas.getValue(ACTIVE_LIST_CHANGES_CELL) + val leading = areas.getValue("leading") + val trailing = areas.getValue("trailing") + val renderer = rendered(view) + val title = components(renderer).filterIsInstance().single() + val desc = components(renderer).filterIsInstance().single { it.text == "Description" } + val panel = rowPanel(renderer) + val text = (panel.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) + val origin = SwingUtilities.convertPoint(text.parent, text.location, renderer) + val start = SwingUtilities.convertPoint(title.parent, title.location, renderer) + val description = SwingUtilities.convertPoint(desc.parent, desc.location, renderer) + + assertTrue(description.x + desc.width <= changes.x) + assertTrue(changes.x + changes.width <= badge.x) + assertEquals(origin.x + text.width, badge.x + badge.width) + assertTrue(kotlin.math.abs(center(badge).y - center(changes).y) <= 1) + assertTrue(kotlin.math.abs(center(badge).y - centerY(renderer, desc)) <= 1) + assertTrue(badge.y >= start.y + title.height) + assertTrue(leading.x + leading.width <= start.x) + assertTrue(start.x + title.width <= trailing.x) + assertTrue(kotlin.math.abs(center(leading).y - centerY(renderer, title)) <= 1) + assertTrue(kotlin.math.abs(center(trailing).y - centerY(renderer, title)) <= 1) + + click(view, center(leading)) + click(view, center(trailing)) + click(view, center(changes)) + click(view, center(badge)) + assertEquals(listOf("leading", "trailing", "changes", "badge"), calls) + } + } + + fun `test secondary badge regions stay correct across renderer reuse row widths and rows without badges`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + val plain = item("plain", "Gamma", null) + assertTrue(plain.secondaryBadges.isEmpty()) + view.update(listOf( + object : ActiveListItem by secondaryItem("wide", "Alpha", ActiveListBadge("Available", id = "badge", tooltip = "Wide", action = { calls += "wide" })) { + override val metrics = ActiveListMetrics(additions = 123, deletions = 9, ahead = 2) + }, + secondaryItem("narrow", "Beta", ActiveListBadge("On", id = "badge", tooltip = "Narrow", action = { calls += "narrow" })), + plain, + )) + + for (width in listOf(260, 480, 320)) { + calls.clear() + view.list.size = Dimension(width, 240) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + val renderer = rendered(view) + val wide = activeListCellBounds(view.list, 0, selected = false).getValue("badge") + val cell = components(renderer).filterIsInstance().single() + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue("badge") + assertSame(cell, components(renderer).filterIsInstance().single()) + assertTrue(wide.width > narrow.width) + assertEquals(wide.x + wide.width, narrow.x + narrow.width) + assertTrue(view.list.getCellBounds(0, 0).contains(wide)) + assertTrue(view.list.getCellBounds(1, 1).contains(narrow)) + assertTrue(activeListCellBounds(view.list, 2, selected = false).isEmpty()) + assertTrue(components(renderer).filterIsInstance().none { it.isVisible }) + + paint(view.list) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue("badge")) + assertEquals(wide, activeListCellBounds(view.list, 0, selected = false).getValue("badge")) + assertEquals("Narrow", view.list.getToolTipText(event(view.list, center(narrow)))) + assertEquals("Wide", view.list.getToolTipText(event(view.list, center(wide)))) + assertNull(activeListCellAt(view.list, 0, center(narrow), selected = false)) + + val bounds = view.list.getCellBounds(2, 2) + val point = Point(center(narrow).x, bounds.y + center(narrow).y - view.list.getCellBounds(1, 1).y) + assertNull(activeListCellAt(view.list, 2, point, selected = false)) + assertNull(view.list.getToolTipText(event(view.list, point))) + hover(view, point) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + click(view, point) + assertTrue(calls.isEmpty()) + click(view, center(narrow)) + click(view, center(wide)) + assertEquals(listOf("narrow", "wide"), calls) + } + } + } + + fun `test progress hides secondary and title badges and restores their actions afterward`() { + edt { + val calls = mutableListOf() + val row = object : ActiveListItem { + override val key = "row" + override val title = "Alpha" + override val description = "Description" + override val leading = listOf(ActiveListBadge("Local", id = "leading", action = { calls += "leading" })) + override val badges = listOf(ActiveListBadge("Ready", id = "trailing", action = { calls += "trailing" })) + override val secondaryBadges = listOf(ActiveListBadge("Details", id = "badge", tooltip = "Badge details", action = { calls += "badge" })) + override val metrics = ActiveListMetrics(additions = 5, onChanges = { calls += "changes" }) + } + val pending = object : ActiveListItem by row { + override val progress = "Working" + } + val view = ActiveListView("Empty") { key, id -> calls += "$key:$id" } + view.update(listOf(row)) + layout(view) + val areas = activeListCellBounds(view.list, 0, selected = false) + assertEquals(setOf("leading", "trailing", "badge", ACTIVE_LIST_CHANGES_CELL), areas.keys) + + view.update(listOf(pending)) + layout(view) + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + val renderer = rendered(view) + assertTrue(components(renderer).filterIsInstance().none { it.isVisible }) + assertTrue(components(renderer).filterIsInstance().single { it.text == "Working" }.isVisible) + for (area in areas.values) { + assertNull(activeListCellAt(view.list, 0, center(area), selected = false)) + hover(view, center(area)) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + click(view, center(area)) + } + assertTrue(calls.isEmpty()) + + view.update(listOf(row)) + layout(view) + val restored = activeListCellBounds(view.list, 0, selected = false) + assertEquals(areas, restored) + val area = restored.getValue("badge") + assertEquals("Badge details", view.list.getToolTipText(event(view.list, center(area)))) + click(view, center(area)) + assertEquals(listOf("badge"), calls) + } + } + private fun paint(list: JBList<*>) { val image = UIUtil.createImage(list, list.width, list.height, BufferedImage.TYPE_INT_ARGB) val g = image.createGraphics() @@ -1182,6 +1400,19 @@ class SettingsListViewTest : BasePlatformTestCase() { override val metrics = data } + private fun badgeItem(id: String, name: String, badge: ActiveListBadge) = object : ActiveListItem { + override val key = id + override val title = name + override val leading = listOf(badge) + } + + private fun secondaryItem(id: String, name: String, badge: ActiveListBadge, note: String? = null) = object : ActiveListItem { + override val key = id + override val title = name + override val description = note + override val secondaryBadges = listOf(badge) + } + private fun sectionItem(id: String, name: String, group: String) = object : ActiveListItem { override val key = id override val title = name @@ -1218,6 +1449,14 @@ class SettingsListViewTest : BasePlatformTestCase() { return actionCells(comp).filter { it.isVisible }.map { it.cellId } } + private fun rendered(view: ActiveListView): ActiveListRenderer { + val list = view.list + val comp = list.cellRenderer.getListCellRendererComponent(list, list.model.getElementAt(0), 0, false, true) + comp.setSize(list.width, list.getCellBounds(0, 0).height) + layout(comp as Container) + return components(comp).filterIsInstance().single() + } + private fun rowPanel(renderer: ActiveListRenderer): JPanel = components(renderer).filterIsInstance().single().content.getComponent(0) as JPanel From 3f2766f28bba7c4e6062f2c3171d690442181d23 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 14:50:40 -0400 Subject: [PATCH 03/19] fix(jetbrains): preserve changes when moving to worktrees Reject unresolved indexes before creating a worktree and snapshot untracked content so transfers cannot silently omit or change source work. Propagate index and copy failures through rollback, and cover conflict states, checkout scope, staging fidelity, and source preservation with real Git tests. --- .../quiet-worktrees-refuse-conflicts.md | 5 + .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 2 +- .../kilocode/backend/rpc/WorktreeTransfer.kt | 151 ++-- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 736 ++++++++++++++++-- 4 files changed, 795 insertions(+), 99 deletions(-) create mode 100644 .changeset/quiet-worktrees-refuse-conflicts.md diff --git a/.changeset/quiet-worktrees-refuse-conflicts.md b/.changeset/quiet-worktrees-refuse-conflicts.md new file mode 100644 index 000000000000..065ceed6cb98 --- /dev/null +++ b/.changeset/quiet-worktrees-refuse-conflicts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix moving a session to a worktree so it reliably transfers all changes, including from a repository subdirectory, and clearly explains that unresolved merge conflicts must be resolved first instead of failing with a confusing git error. 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 50f0fc1457b8..e0b8c726bc5a 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 @@ -278,7 +278,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { leftover = worktree val target = Path.of(worktree.path).normalize() emit(MoveProgressDto(MoveStage.TRANSFERRING)) - val applied = withContext(Dispatchers.IO) { WorktreeTransfer.apply(captured, base, target) } + val applied = withContext(Dispatchers.IO) { WorktreeTransfer.apply(captured, target) } if (!applied.ok) { leftover = null rollback(directory, worktree, branch, "transfer failure") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt index d7b2fa39c32e..4845923a9107 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/WorktreeTransfer.kt @@ -6,6 +6,9 @@ import com.intellij.execution.process.CapturingProcessHandler import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption.COPY_ATTRIBUTES +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.TimeUnit import kotlin.io.path.fileSize @@ -26,54 +29,63 @@ internal object WorktreeTransfer { private const val MAX_FILE = 10L * 1024 * 1024 // 10 MB, same cap as VS Code private const val TIMEOUT = 30_000 - /** Read-only snapshot of a working tree. Patch fields point at temp files owned by the caller. */ + /** Read-only snapshot of a working tree. Patch and untracked paths point at temp files owned by the caller. */ data class Snapshot( val branch: String, val head: String, val staged: Path?, val unstaged: Path?, - val untracked: List, + val untracked: Map, + val storage: Path, ) data class ApplyResult(val ok: Boolean, val error: String? = null) /** - * Captures the current git state from [root]. Read-only: [root] is never modified. The returned - * snapshot owns temp patch files that the caller must delete via [cleanup] in a `finally`. + * Captures the current git state from the worktree containing [dir] without modifying it. The returned + * snapshot owns temp files that the caller must delete via [cleanup] in a `finally`. */ - fun capture(root: Path): Snapshot { - val branch = git(root, "branch", "--show-current").stdout.trim() + fun capture(dir: Path): Snapshot { + val resolved = git(dir, "rev-parse", "--show-toplevel") + if (!resolved.ok) error("git rev-parse --show-toplevel failed: ${resolved.stderr.trim()}") + val root = Path.of(resolved.stdout.trimEnd('\r', '\n').ifEmpty { + error("git rev-parse --show-toplevel returned no directory") + }).toAbsolutePath().normalize() + val current = git(root, "branch", "--show-current") + if (!current.ok) error("git branch --show-current failed: ${current.stderr.trim()}") + val branch = current.stdout.trim() val rev = git(root, "rev-parse", "HEAD") if (!rev.ok) error("git rev-parse HEAD failed: ${rev.stderr.trim()}") val head = rev.stdout.trim().ifEmpty { error("git rev-parse HEAD returned no commit") } - val temps = mutableListOf() + val conflicts = gitLines(root, "ls-files", "--unmerged", "-z") + .mapNotNull { it.substringAfter('\t', "").takeIf(String::isNotEmpty) } + .distinct() + if (conflicts.isNotEmpty()) { + error("Resolve merge conflicts before moving to a worktree: ${conflicts.joinToString(", ")}") + } + val storage = Files.createTempDirectory("kilo-worktree-snapshot") try { - val unstaged = capturePatch(root, "diff", "--binary")?.also { temps.add(it) } - val staged = capturePatch(root, "diff", "--cached", "--binary")?.also { temps.add(it) } - val listed = git(root, "ls-files", "--others", "--exclude-standard") - if (!listed.ok) error("git ls-files failed: ${listed.stderr.trim()}") - val untracked = listed.stdout - .lineSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .filter { rel -> - val full = root.resolve(rel).normalize() - if (!full.startsWith(root)) { - LOG.warn("worktree move: skipping untracked file outside root: $rel") - return@filter false - } - val size = runCatching { full.fileSize() }.getOrDefault(0L) - if (size > MAX_FILE) { - LOG.info("worktree move: skipping untracked file $rel: $size bytes exceeds ${MAX_FILE} limit") - false - } else { - true + val unstaged = capturePatch(root, storage, "diff", "--binary") + val staged = capturePatch(root, storage, "diff", "--cached", "--binary") + val untracked = gitLines(root, "ls-files", "--others", "--exclude-standard", "-z") + .associateWith { rel -> + runCatching { + val full = root.resolve(rel).normalize() + require(full.startsWith(root)) { "File is outside the source worktree" } + val attrs = Files.readAttributes(full, BasicFileAttributes::class.java) + require(attrs.isRegularFile) { "Not a regular file" } + require(attrs.size() <= MAX_FILE) { "File exceeds the $MAX_FILE byte transfer limit" } + val copy = Files.createTempFile(storage, "file", ".tmp") + Files.copy(full, copy, REPLACE_EXISTING) + require(copy.fileSize() <= MAX_FILE) { "File exceeds the $MAX_FILE byte transfer limit" } + copy + }.getOrElse { err -> + throw IllegalStateException("Failed to capture untracked file $rel: ${err.message}", err) } } - .toList() - return Snapshot(branch, head, staged, unstaged, untracked) + return Snapshot(branch, head, staged, unstaged, untracked, storage) } catch (e: Exception) { - temps.forEach { Files.deleteIfExists(it) } + discard(storage) throw e } } @@ -82,39 +94,56 @@ internal object WorktreeTransfer { * Applies [snapshot] into [target]: staged patch (re-staged), unstaged patch, then untracked * file copies. Returns the first failure encountered so the caller can roll the worktree back. */ - fun apply(snapshot: Snapshot, source: Path, target: Path): ApplyResult { + fun apply(snapshot: Snapshot, target: Path): ApplyResult { + val root = target.toAbsolutePath().normalize() snapshot.staged?.let { patch -> - val res = git(target, "apply", "--whitespace=nowarn", patch.toString()) + val res = git(root, "apply", "--index", "--whitespace=nowarn", patch.toString()) if (!res.ok) { val msg = res.stderr.trim().ifBlank { "Patch did not apply" } LOG.warn("worktree move: staged patch failed: $msg") return ApplyResult(false, "Staged patch failed: $msg") } - val files = parsePatchFiles(patch) - if (files.isNotEmpty()) git(target, "add", "--", *files.toTypedArray()) } snapshot.unstaged?.let { patch -> - val res = git(target, "apply", "--whitespace=nowarn", patch.toString()) + val res = git(root, "apply", "--whitespace=nowarn", patch.toString()) if (!res.ok) { val msg = res.stderr.trim().ifBlank { "Patch did not apply" } LOG.warn("worktree move: unstaged patch failed: $msg") return ApplyResult(false, "Unstaged patch failed: $msg") } } - for (rel in snapshot.untracked) { - runCatching { - val src = source.resolve(rel).normalize() - val dst = target.resolve(rel).normalize() + snapshot.untracked.forEach { (rel, copy) -> + val dst = root.resolve(rel).normalize() + if (!dst.startsWith(root)) return ApplyResult(false, "Untracked file is outside the target worktree: $rel") + val err = runCatching { Files.createDirectories(dst.parent) - Files.copy(src, dst) - }.onFailure { err -> LOG.warn("worktree move: failed to copy untracked file $rel: ${err.message}", err) } + Files.copy(copy, dst, COPY_ATTRIBUTES) + }.exceptionOrNull() + if (err != null) { + LOG.warn("worktree move: failed to write untracked file $rel: ${err.message}", err) + return ApplyResult(false, "Failed to write untracked file $rel: ${err.message}") + } } return ApplyResult(true) } fun cleanup(snapshot: Snapshot?) { snapshot ?: return - listOfNotNull(snapshot.staged, snapshot.unstaged).forEach { Files.deleteIfExists(it) } + discard(snapshot.storage) + } + + private fun discard(dir: Path) { + if (!Files.exists(dir)) return + runCatching { + Files.walk(dir).use { files -> files.sorted(Comparator.reverseOrder()).forEach(::delete) } + }.onFailure { err -> LOG.warn("worktree move: failed to clean up snapshot storage $dir: ${err.message}", err) } + } + + private fun delete(file: Path) { + if (runCatching { Files.deleteIfExists(file) }.isSuccess) return + runCatching { file.toFile().setWritable(true) } + runCatching { Files.deleteIfExists(file) } + .onFailure { err -> LOG.warn("worktree move: failed to delete $file: ${err.message}", err) } } /** @@ -127,8 +156,8 @@ internal object WorktreeTransfer { * inherits a minimal environment where a bare `git` can be missing. Output is still redirected * to a file because decoding stdout would corrupt `--binary` patches. */ - private fun capturePatch(root: Path, vararg args: String): Path? { - val file = Files.createTempFile("kilo-worktree-patch", ".diff") + private fun capturePatch(root: Path, storage: Path, vararg args: String): Path? { + val file = Files.createTempFile(storage, "patch", ".diff") val label = "git ${args.joinToString(" ")}" try { val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(root.toFile()) @@ -154,14 +183,28 @@ internal object WorktreeTransfer { } } - /** Extracts target paths from a unified diff's `diff --git a/... b/...` headers. */ - private fun parsePatchFiles(patch: Path): List { - // Read as Latin-1 so every byte maps to a char; header lines stay intact even in binary patches. - val header = Regex("^diff --git a/.+ b/(.+)$") - return runCatching { - Files.readAllLines(patch, StandardCharsets.ISO_8859_1) - .mapNotNull { header.find(it)?.groupValues?.get(1) } - }.getOrDefault(emptyList()) + private fun gitLines(root: Path, vararg args: String): List { + val file = Files.createTempFile("kilo-worktree-list", ".raw") + val label = "git ${args.joinToString(" ")}" + try { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(root.toFile()) + val proc = cmd.toProcessBuilder() + .redirectOutput(file.toFile()) + .redirectErrorStream(false) + .start() + val err = proc.errorStream.use { it.readBytes().toString(StandardCharsets.UTF_8) } + if (!proc.waitFor(TIMEOUT.toLong(), TimeUnit.MILLISECONDS)) { + proc.destroyForcibly() + error("$label timed out after ${TIMEOUT}ms") + } + val exit = proc.exitValue() + if (exit != 0) error("$label failed (exit $exit): ${err.trim()}") + return String(Files.readAllBytes(file), StandardCharsets.UTF_8) + .split('\u0000') + .filter { it.isNotEmpty() } + } finally { + Files.deleteIfExists(file) + } } private data class GitResult(val exit: Int, val stdout: String, val stderr: String) { @@ -170,7 +213,9 @@ internal object WorktreeTransfer { private fun git(root: Path, vararg args: String): GitResult { return try { - val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(root.toFile()) + val cmd = GeneralCommandLine(listOf("git") + args) + .withWorkDirectory(root.toFile()) + .withCharset(StandardCharsets.UTF_8) val out = CapturingProcessHandler(cmd).runProcess(TIMEOUT) GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) } catch (e: Exception) { 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 b708e5d57f1a..cf086bdeb588 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 @@ -8,12 +8,17 @@ import ai.kilocode.rpc.dto.MoveStage import ai.kilocode.rpc.dto.WorktreeDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.util.SystemInfo +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assumptions.assumeFalse +import org.junit.jupiter.api.Assumptions.assumeTrue import java.nio.file.Files import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFalse @@ -905,24 +910,20 @@ class KiloWorktreeRpcApiImplTest { val binary = byteArrayOf(0, 1, 2, 3, 0, 5, 127, -1) Files.write(repo.resolve("blob.bin"), binary) - val snapshot = WorktreeTransfer.capture(repo) - val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) - val target = Path.of(created.path) - // Baseline the source status after worktree creation (which adds .kilo bookkeeping) so the - // assertion isolates the effect of apply, which must not touch the source tree. - val before = statusOf(repo) - - val result = WorktreeTransfer.apply(snapshot, repo, target) - assertTrue(result.ok, "apply should succeed: ${result.error}") - - assertEquals("modified\n", Files.readString(target.resolve("tracked.txt"))) - assertEquals("staged content\n", Files.readString(target.resolve("staged.txt"))) - assertEquals("brand new\n", Files.readString(target.resolve("untracked.txt"))) - assertTrue(binary.contentEquals(Files.readAllBytes(target.resolve("blob.bin"))), "binary file should round-trip") - - // The source working tree must be untouched by capture + apply. - assertEquals(before, statusOf(repo), "source working tree must be unchanged") - WorktreeTransfer.cleanup(snapshot) + preserved { + captured { snapshot -> + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + assertTrue(result.ok, "apply should succeed: ${result.error}") + + assertEquals("modified\n", Files.readString(target.resolve("tracked.txt"))) + assertEquals("original\n", output(target, "show", ":tracked.txt")) + assertEquals("staged content\n", Files.readString(target.resolve("staged.txt"))) + assertEquals("staged content\n", output(target, "show", ":staged.txt")) + assertEquals("brand new\n", Files.readString(target.resolve("untracked.txt"))) + assertContentEquals(binary, Files.readAllBytes(target.resolve("blob.bin"))) + } + } } @Test @@ -931,35 +932,44 @@ class KiloWorktreeRpcApiImplTest { Files.writeString(repo.resolve("staged.txt"), "staged content\n") git(repo, "add", "staged.txt") - val snapshot = WorktreeTransfer.capture(repo) - val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) - val target = Path.of(created.path) - // Pre-create the staged file in the target so the new-file patch cannot apply cleanly. - Files.writeString(target.resolve("staged.txt"), "conflicting\n") + preserved { + captured { snapshot -> + val target = target() + Files.writeString(target.resolve("staged.txt"), "conflicting\n") - val result = WorktreeTransfer.apply(snapshot, repo, target) + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) - assertFalse(result.ok, "apply should fail when the patch conflicts") - assertNotNull(result.error) - WorktreeTransfer.cleanup(snapshot) + assertFalse(result.ok, "apply should fail when the patch conflicts") + assertTrue(assertNotNull(result.error).contains("staged.txt"), result.error) + } + } + } } @Test fun `capture fails instead of reporting a clean tree when git cannot run`() { // No git repository: a failed capture must throw rather than look like "nothing to move". - val err = assertFails { WorktreeTransfer.capture(repo) } + val err = failure(repo) - assertTrue(err.message.orEmpty().isNotBlank(), "capture failure should explain itself") + assertTrue(err.message.orEmpty().contains("git rev-parse"), "capture failure should explain itself: $err") + } + + @Test + fun `moveToWorktree rejects unresolved merge conflicts before creating a worktree`() = runBlocking { + initRepo() + diverge("README.md") + + conflicted("README.md") } @Test fun `moveToWorktree emits ERROR when capture fails`() = runBlocking { val events = api.moveToWorktree(repo.toString(), "ses_1", "feature/x").toList() - assertEquals(MoveStage.CAPTURING, events.first().stage) - val last = events.last() - assertEquals(MoveStage.ERROR, last.stage) - assertTrue(last.error != null, "the error event should explain the failure") + assertEquals(listOf(MoveStage.CAPTURING, MoveStage.ERROR), events.map { it.stage }) + assertTrue(assertNotNull(events.last().error).isNotBlank(), "the error event should explain the failure") + assertFalse(Files.exists(repo.resolve(".kilo/worktrees/feature-x"))) } @Test @@ -969,15 +979,16 @@ class KiloWorktreeRpcApiImplTest { // The fork resolves a project-level service that no plain unit test provides, so this move // throws after the worktree exists — exactly the case that used to end the flow silently. - val events = api.moveToWorktree(repo.toString(), "ses_1", "feature/x").toList() - - assertEquals(MoveStage.ERROR, events.last().stage) - assertTrue(events.map { it.stage }.containsAll(listOf(MoveStage.CREATING, MoveStage.TRANSFERRING))) - assertFalse( - Files.exists(repo.resolve(".kilo").resolve("worktrees").resolve("feature-x")), - "a failed move must not leave its worktree behind", - ) - assertTrue(api.list(repo.toString()).worktrees.none { it.branch == "feature/x" }, "git must not track the worktree") + preserved { + val events = api.moveToWorktree(repo.toString(), "ses_1", "feature/x").toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.FORKING, MoveStage.ERROR), + events.map { it.stage }, + ) + assertTrue(assertNotNull(events.last().error).isNotBlank()) + absent() + } } @Test @@ -1003,11 +1014,646 @@ class KiloWorktreeRpcApiImplTest { assertEquals("hello edited\n", Files.readString(repo.resolve("README.md"))) } - private fun statusOf(dir: Path): String { - val cmd = GeneralCommandLine(listOf("git", "status", "--porcelain")).withWorkDirectory(dir.toFile()) - return CapturingProcessHandler(cmd).runProcess(30_000).stdout + @Test + fun `moveToWorktree rejects unresolved conflicts left by a stash pop without a MERGE_HEAD marker`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "stashed\n") + git(repo, "stash", "push") + Files.writeString(repo.resolve("README.md"), "advanced\n") + git(repo, "commit", "-am", "advance") + val popped = runner(repo)(listOf("stash", "pop")) + assertFalse(popped.ok, "the fixture must produce an unresolved stash conflict") + assertFalse( + Files.exists(metadata(repo, "MERGE_HEAD")), + "this fixture must reproduce a conflict with no MERGE_HEAD marker", + ) + + conflicted("README.md") + } + + @Test + fun `moveToWorktree rejects merge conflicts even alongside unrelated staged and unstaged edits`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("other.txt"), "line\n") + git(repo, "add", "other.txt") + git(repo, "commit", "-m", "add other") + diverge("README.md") + Files.writeString(repo.resolve("other.txt"), "staged change\n") + git(repo, "add", "other.txt") + Files.writeString(repo.resolve("other.txt"), "unstaged change\n") + Files.write(repo.resolve("untracked.bin"), byteArrayOf(0, 1, -1, 127)) + assertEquals("staged change\n", output(repo, "show", ":other.txt")) + assertEquals("MM other.txt\u0000", output(repo, "status", "--porcelain=v1", "-z", "--", "other.txt")) + + conflicted("README.md") + } + + @Test + fun `moveToWorktree rejects an add-add conflict with no common ancestor stage`() = runBlocking { + initRepo() + diverge("new.txt") + + conflicted("new.txt", stages = listOf("2", "3")) + } + + @Test + fun `moveToWorktree lists every conflicted path exactly once when multiple files conflict`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("b.txt"), "base\n") + git(repo, "add", "b.txt") + git(repo, "commit", "-m", "add b") + diverge("README.md", "b.txt") + + conflicted("README.md", "b.txt") + } + + @Test + fun `moveToWorktree reports conflicted filenames without quoting or splitting control characters`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "Windows forbids quotes and control characters in filenames") + initRepo() + val name = "my \"文件\"\tname\n.md" + Files.writeString(repo.resolve(name), "hello\n") + git(repo, "add", name) + git(repo, "commit", "-m", "add unusual filename") + diverge(name) + + conflicted(name) + } + + @Test + fun `moveToWorktree rejects a merge conflict outside the given subdirectory`() = runBlocking { + initRepo() + Files.createDirectories(repo.resolve("sub")) + Files.writeString(repo.resolve("sub/file.txt"), "hello\n") + git(repo, "add", "sub/file.txt") + git(repo, "commit", "-m", "add sub file") + diverge("README.md") + + conflicted("README.md", dir = repo.resolve("sub")) + } + + @Test + fun `moveToWorktree from a subdirectory transfers root nested and sibling paths without flattening`() = runBlocking { + initRepo() + val tracked = listOf("README.md", "sub/nested/tracked.txt", "sibling/tracked.txt") + tracked.forEach { name -> + Files.createDirectories(repo.resolve(name).parent) + Files.writeString(repo.resolve(name), "base $name\n") + } + git(repo, "add", "--", *tracked.toTypedArray()) + git(repo, "commit", "-m", "add nested files") + tracked.forEach { Files.writeString(repo.resolve(it), "staged $it\n") } + git(repo, "add", "--", *tracked.toTypedArray()) + tracked.forEach { Files.writeString(repo.resolve(it), "unstaged $it\n") } + val untracked = listOf("notes.txt", "sub/nested/notes.txt", "sibling/notes.txt") + untracked.forEach { Files.writeString(repo.resolve(it), "untracked $it\n") } + val rev = head(repo, "HEAD") + + preserved { + val events = api.moveToWorktree(repo.resolve("sub").toString(), null, "feature/x").toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.DONE), + events.map { it.stage }, + events.last().error, + ) + val target = Path.of(assertNotNull(events.last().worktree).path) + assertEquals(rev, head(target, "HEAD")) + tracked.forEach { name -> + assertEquals("staged $name\n", output(target, "show", ":$name")) + assertEquals("unstaged $name\n", Files.readString(target.resolve(name))) + assertEquals("MM $name\u0000", output(target, "status", "--porcelain=v1", "-z", "--", name)) + } + untracked.forEach { name -> + assertEquals("untracked $name\n", Files.readString(target.resolve(name))) + } + assertEquals( + untracked.toSet(), + output(target, "ls-files", "--others", "--exclude-standard", "-z").split('\u0000').filter { it.isNotEmpty() }.toSet(), + ) + assertFalse(Files.exists(target.resolve("tracked.txt"))) + assertFalse(Files.exists(target.resolve("nested"))) + } + } + + @Test + fun `moveToWorktree from a linked checkout captures its own HEAD index and files instead of the main checkout`() = runBlocking { + initRepo() + val source = remote.resolve("source") + git(repo, "worktree", "add", "-b", "source", source.toString()) + Files.writeString(source.resolve("README.md"), "linked base\n") + git(source, "commit", "-am", "advance linked checkout") + val rev = head(source, "HEAD") + assertFalse(rev == head(repo, "HEAD"), "linked and main fixtures must have different commits") + Files.writeString(repo.resolve("README.md"), "main staged\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "main unstaged\n") + Files.writeString(repo.resolve("notes.txt"), "main notes\n") + Files.writeString(repo.resolve("main-only.txt"), "main only\n") + Files.writeString(source.resolve("README.md"), "linked staged\n") + git(source, "add", "README.md") + Files.writeString(source.resolve("README.md"), "linked unstaged\n") + Files.writeString(source.resolve("notes.txt"), "linked notes\n") + Files.createDirectories(source.resolve("sub")) + + preserved { + preserved(source) { + val events = api.moveToWorktree(source.resolve("sub").toString(), null, "feature/x").toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.DONE), + events.map { it.stage }, + events.last().error, + ) + val target = Path.of(assertNotNull(events.last().worktree).path) + assertEquals(repo.resolve(".kilo/worktrees/feature-x").toRealPath(), target.toRealPath()) + assertEquals(rev, head(target, "HEAD")) + assertEquals("linked staged\n", output(target, "show", ":README.md")) + assertEquals("linked unstaged\n", Files.readString(target.resolve("README.md"))) + assertEquals("MM README.md\u0000", output(target, "status", "--porcelain=v1", "-z", "--", "README.md")) + assertEquals("linked notes\n", Files.readString(target.resolve("notes.txt"))) + assertFalse(Files.exists(target.resolve("main-only.txt"))) + assertFalse(Files.exists(source.resolve(".kilo/worktrees/feature-x"))) + } + } + } + + @Test + fun `worktree transfer preserves staged and unstaged edits to the same file`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + + preserved { + captured { snapshot -> + assertEquals(head(repo, "HEAD"), snapshot.head) + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertEquals("staged version\n", output(target, "show", ":README.md")) + assertEquals("unstaged version\n", Files.readString(target.resolve("README.md"))) + assertEquals("MM README.md\u0000", output(target, "status", "--porcelain=v1", "-z", "--", "README.md")) + } + } + } + + @Test + fun `worktree transfer preserves staged and unstaged versions of the same tracked binary`() = runBlocking { + initRepo() + Files.write(repo.resolve("blob.bin"), byteArrayOf(0, 1, 2, 3, 4, 5, 127, -1)) + git(repo, "add", "blob.bin") + git(repo, "commit", "-m", "add binary") + val staged = byteArrayOf(9, 8, 7, 0, 6, 5, -1, 127) + Files.write(repo.resolve("blob.bin"), staged) + git(repo, "add", "blob.bin") + val index = head(repo, ":blob.bin") + val unstaged = byteArrayOf(0, -1, 5, 6, 7, 8, 9, 10) + Files.write(repo.resolve("blob.bin"), unstaged) + + preserved { + captured { snapshot -> + assertTrue(Files.readString(assertNotNull(snapshot.staged)).contains("GIT binary patch")) + assertTrue(Files.readString(assertNotNull(snapshot.unstaged)).contains("GIT binary patch")) + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertEquals(index, head(target, ":blob.bin")) + assertContentEquals(unstaged, Files.readAllBytes(target.resolve("blob.bin"))) + assertEquals("MM blob.bin\u0000", output(target, "status", "--porcelain=v1", "-z", "--", "blob.bin")) + } + } + } + + @Test + fun `worktree transfer round trips untracked Unicode quoted and control character filenames`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "Windows forbids quotes and control characters in filenames") + initRepo() + val names = listOf("my new file.txt", "文件.txt", "quoted \"name\"\twith\nlines.txt") + names.forEach { Files.writeString(repo.resolve(it), "content for $it\n") } + + preserved { + captured { snapshot -> + assertEquals(names.toSet(), snapshot.untracked.keys) + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + names.forEach { name -> + assertEquals("content for $name\n", Files.readString(target.resolve(name))) + } + assertEquals( + names.toSet(), + output(target, "ls-files", "--others", "--exclude-standard", "-z").split('\u0000').filter { it.isNotEmpty() }.toSet(), + ) + } + } + } + + @Test + fun `captured tracked and untracked content survives edits to the source after capture`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.writeString(repo.resolve("untracked.txt"), "original\n") + + captured { snapshot -> + val copy = assertNotNull(snapshot.untracked["untracked.txt"]) + assertFalse(copy.toRealPath().startsWith(repo.toRealPath()), "the snapshot must own an independent temporary copy") + Files.writeString(repo.resolve("README.md"), "mutated after capture\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("untracked.txt"), "mutated after capture\n") + assertEquals("original\n", Files.readString(copy)) + + preserved { + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertEquals("staged version\n", output(target, "show", ":README.md")) + assertEquals("unstaged version\n", Files.readString(target.resolve("README.md"))) + assertEquals("original\n", Files.readString(target.resolve("untracked.txt"))) + } + } + } + + @Test + fun `captured untracked file content survives deletion of the source after capture`() = runBlocking { + initRepo() + val bytes = byteArrayOf(0, -1, 127, 10, 13) + Files.write(repo.resolve("untracked.bin"), bytes) + + captured { snapshot -> + val copy = assertNotNull(snapshot.untracked["untracked.bin"]) + Files.delete(repo.resolve("untracked.bin")) + assertContentEquals(bytes, Files.readAllBytes(copy)) + + preserved { + val target = target() + val result = WorktreeTransfer.apply(snapshot, target) + + assertTrue(result.ok, "apply should succeed: ${result.error}") + assertContentEquals(bytes, Files.readAllBytes(target.resolve("untracked.bin"))) + assertFalse(Files.exists(repo.resolve("untracked.bin")), "apply must not recreate the deleted source") + } + } + } + + @Test + fun `worktree transfer reports failure when an unstaged patch cannot apply`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + + preserved { + captured { snapshot -> + assertNull(snapshot.staged) + assertNotNull(snapshot.unstaged) + val target = target() + Files.writeString(target.resolve("README.md"), "conflicting target\n") + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "an unstaged patch failure must not report success") + val error = assertNotNull(result.error) + assertTrue(error.contains("Unstaged patch failed"), error) + assertTrue(error.contains("README.md"), error) + } + } + } + } + + @Test + fun `worktree transfer reports a locked target index without changing its index or working files`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + + preserved { + captured { snapshot -> + assertNotNull(snapshot.staged) + val target = target() + val lock = metadata(target, "index.lock") + Files.createFile(lock) + try { + val index = metadata(target, "index") + val bytes = Files.readAllBytes(index) + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "apply must not report success when the target index is locked") + val error = assertNotNull(result.error) + assertTrue(error.contains("Staged patch failed"), error) + assertTrue(error.contains("index.lock"), error) + assertContentEquals(bytes, Files.readAllBytes(index), "a failed index write must leave the index intact") + assertTrue(Files.exists(lock), "apply must not remove another process's lock") + } + } finally { + Files.deleteIfExists(lock) + } + } + } + } + + @Test + fun `worktree transfer refuses to overwrite an existing untracked destination`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("untracked.txt"), "source content\n") + + preserved { + captured { snapshot -> + val target = target() + Files.writeString(target.resolve("untracked.txt"), "keep target content\n") + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "a pre-existing destination must not be overwritten") + assertTrue(assertNotNull(result.error).contains("untracked.txt"), result.error) + } + } + } + } + + @Test + fun `worktree transfer fails when an untracked file cannot be written to the target`() = runBlocking { + initRepo() + Files.createDirectories(repo.resolve("sub")) + Files.writeString(repo.resolve("sub/deep.txt"), "deep\n") + + preserved { + captured { snapshot -> + val target = target() + Files.writeString(target.resolve("sub"), "blocking file\n") + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "apply should fail when an untracked file cannot be written") + assertTrue(assertNotNull(result.error).contains("sub/deep.txt"), result.error) + assertFalse(Files.exists(target.resolve("sub/deep.txt"))) + } + } + } + } + + @Test + fun `worktree transfer names the untracked path when its captured copy is missing`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("untracked.txt"), "source content\n") + + preserved { + captured { snapshot -> + Files.delete(assertNotNull(snapshot.untracked["untracked.txt"])) + val target = target() + + preserved(target) { + val result = WorktreeTransfer.apply(snapshot, target) + + assertFalse(result.ok, "missing snapshot content must not be silently skipped") + assertTrue(assertNotNull(result.error).contains("untracked.txt"), result.error) + assertFalse(Files.exists(target.resolve("untracked.txt"))) + } + } + } + } + + @Test + fun `moveToWorktree rolls back the worktree and branch when copying an untracked file fails`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.createDirectories(repo.resolve("sub")) + Files.writeString(repo.resolve("sub/deep.txt"), "source content\n") + + preserved { + val events = api.moveToWorktree(repo.toString(), null, "feature/x").onEach { event -> + if (event.stage == MoveStage.TRANSFERRING) { + val target = repo.resolve(".kilo/worktrees/feature-x") + assertTrue(Files.isDirectory(target)) + assertEquals(head(repo, "HEAD"), head(repo, "refs/heads/feature/x")) + Files.writeString(target.resolve("sub"), "blocking file\n") + } + }.toList() + + assertEquals( + listOf(MoveStage.CAPTURING, MoveStage.CREATING, MoveStage.TRANSFERRING, MoveStage.ERROR), + events.map { it.stage }, + ) + assertTrue(assertNotNull(events.last().error).contains("sub/deep.txt"), events.last().error) + assertNull(events.last().worktree) + absent() + } + } + + @Test + fun `moveToWorktree rejects oversized untracked content instead of moving a partial snapshot`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.writeString(repo.resolve("a-small.txt"), "capture before the oversized file\n") + Files.write(repo.resolve("z-large.bin"), ByteArray(10 * 1024 * 1024 + 1) { (it % 251).toByte() }) + + rejected { + assertTrue(it.contains("z-large.bin"), it) + assertTrue(it.contains("exceeds"), it) + assertTrue(it.contains("10485760"), it) + assertTrue(it.contains("limit"), it) + } + } + + @Test + fun `moveToWorktree rejects an unreadable untracked regular file without creating a branch`() = runBlocking { + assumeTrue(Files.getFileStore(repo).supportsFileAttributeView("posix"), "the fixture requires POSIX permissions") + initRepo() + val file = repo.resolve("unreadable.txt") + Files.writeString(file, "must not be skipped\n") + val permissions = Files.getPosixFilePermissions(file) + + preserved { + Files.setPosixFilePermissions(file, emptySet()) + try { + assumeFalse(Files.isReadable(file), "the fixture requires read access to be denied") + val message = "Failed to capture untracked file unreadable.txt" + assertTrue(assertNotNull(failure(repo).message).contains(message)) + val events = api.moveToWorktree(repo.toString(), null, "feature/x").toList() + + assertEquals(listOf(MoveStage.CAPTURING, MoveStage.ERROR), events.map { it.stage }) + assertTrue(assertNotNull(events.last().error).contains(message), events.last().error) + assertNull(events.last().worktree) + absent() + } finally { + Files.setPosixFilePermissions(file, permissions) + } + } + } + + @Test + fun `moveToWorktree rejects an unsupported untracked directory symlink`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "creating symbolic links on Windows may require elevated privileges") + initRepo() + Files.createSymbolicLink(repo.resolve("unsupported"), Path.of(".")) + + rejected { + assertTrue(it.contains("Failed to capture untracked file unsupported"), it) + assertTrue(it.contains("Not a regular file"), it) + } + } + + @Test + fun `moveToWorktree rejects untracked content that cannot be read through a dangling symlink`() = runBlocking { + assumeFalse(SystemInfo.isWindows, "creating symbolic links on Windows may require elevated privileges") + initRepo() + Files.createSymbolicLink(repo.resolve("unreadable.txt"), Path.of("missing.txt")) + + rejected { + assertTrue(it.contains("Failed to capture untracked file unreadable.txt"), it) + } + } + + @Test + fun `cleanup deletes every staged unstaged and untracked temporary file`() = runBlocking { + initRepo() + Files.writeString(repo.resolve("README.md"), "staged version\n") + git(repo, "add", "README.md") + Files.writeString(repo.resolve("README.md"), "unstaged version\n") + Files.writeString(repo.resolve("untracked.txt"), "temp content\n") + Files.write(repo.resolve("untracked.bin"), byteArrayOf(0, -1, 127)) + + preserved { + captured { snapshot -> + assertEquals(setOf("untracked.txt", "untracked.bin"), snapshot.untracked.keys) + val files = listOf(assertNotNull(snapshot.staged), assertNotNull(snapshot.unstaged)) + snapshot.untracked.values + assertEquals(4, files.distinct().size) + assertTrue(Files.isDirectory(snapshot.storage)) + files.forEach { file -> + assertTrue(Files.isRegularFile(file), "snapshot file must exist: $file") + assertTrue(file.toRealPath().startsWith(snapshot.storage.toRealPath())) + assertFalse(file.toRealPath().startsWith(repo.toRealPath()), "snapshot file must be independently owned: $file") + } + + WorktreeTransfer.cleanup(snapshot) + + files.forEach { assertFalse(Files.exists(it), "cleanup must remove $it") } + assertFalse(Files.exists(snapshot.storage), "cleanup must remove the snapshot's owned directory") + } + } + } + + private fun diverge(vararg names: String) { + val base = output(repo, "branch", "--show-current").trim() + git(repo, "checkout", "-b", "conflicting") + names.forEach { Files.writeString(repo.resolve(it), "branch $it\n") } + git(repo, "add", "--", *names) + git(repo, "commit", "-m", "change on branch") + git(repo, "checkout", base) + names.forEach { Files.writeString(repo.resolve(it), "main $it\n") } + git(repo, "add", "--", *names) + git(repo, "commit", "-m", "change on main") + val merged = runner(repo)(listOf("merge", "conflicting")) + assertFalse(merged.ok, "the fixture must produce unresolved conflicts") + } + + private suspend fun conflicted( + vararg names: String, + dir: Path = repo, + stages: List = listOf("1", "2", "3"), + ) { + val entries = output(repo, "ls-files", "--unmerged", "-z").split('\u0000').filter { it.isNotEmpty() } + assertEquals(names.toSet(), entries.map { it.substringAfter('\t') }.toSet(), "the fixture must contain real unmerged entries") + names.forEach { name -> + assertEquals( + stages, + entries.filter { it.substringAfter('\t') == name }.map { it.substringBefore('\t').substringAfterLast(' ') }, + "unexpected index stages for $name", + ) + } + rejected(dir) { + assertEquals("Resolve merge conflicts before moving to a worktree: ${names.joinToString(", ")}", it) + } + } + + private suspend fun rejected(dir: Path = repo, check: (String) -> Unit) { + preserved { + val message = assertNotNull(failure(dir).message) + check(message) + val events = api.moveToWorktree(dir.toString(), null, "feature/x").toList() + + assertEquals(listOf(MoveStage.CAPTURING, MoveStage.ERROR), events.map { it.stage }) + assertEquals(message, events.last().error) + events.forEach { + assertNull(it.worktree) + assertNull(it.session) + } + absent() + } + } + + private fun absent() { + assertFalse(Files.exists(repo.resolve(".kilo/worktrees/feature-x")), "a failed move must not leave a worktree directory") + val listed = parseWorktreeList(output(repo, "worktree", "list", "--porcelain")) + assertEquals(1, listed.size, "a failed move must not leave a worktree registration: $listed") + assertTrue(listed.single().main) + assertEquals( + "", + output(repo, "for-each-ref", "--format=%(refname)", "refs/heads/feature/x"), + "a failed move must not leave its branch ref even if the worktree was removed", + ) } + private inline fun preserved(dir: Path = repo, block: () -> Unit) { + val rev = head(dir, "HEAD") + val ref = output(dir, "symbolic-ref", "HEAD") + val index = output(dir, "ls-files", "--stage", "-z") + val status = output(dir, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", ":(exclude).kilo") + val files = output(dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ".", ":(exclude).kilo") + .split('\u0000').filter { it.isNotEmpty() }.distinct().associateWith { bytes(dir.resolve(it)) } + + block() + + assertEquals(rev, head(dir, "HEAD"), "HEAD must be unchanged in $dir") + assertEquals(ref, output(dir, "symbolic-ref", "HEAD"), "branch ref must be unchanged in $dir") + assertEquals(index, output(dir, "ls-files", "--stage", "-z"), "index stages must be unchanged in $dir") + assertEquals( + status, + output(dir, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", ":(exclude).kilo"), + "status must be unchanged in $dir", + ) + files.forEach { (name, content) -> + assertContentEquals(content, bytes(dir.resolve(name)), "bytes must be unchanged: ${dir.resolve(name)}") + } + } + + private fun bytes(file: Path): ByteArray? { + if (Files.isSymbolicLink(file)) return Files.readSymbolicLink(file).toString().toByteArray() + if (!Files.exists(file)) return null + return Files.readAllBytes(file) + } + + private inline fun captured(dir: Path = repo, block: (WorktreeTransfer.Snapshot) -> Unit) { + val snapshot = WorktreeTransfer.capture(dir) + try { + block(snapshot) + } finally { + WorktreeTransfer.cleanup(snapshot) + } + } + + private fun failure(dir: Path): Throwable = assertFails { captured(dir) { Unit } } + + private suspend fun target(): Path { + val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")) + return Path.of(assertNotNull(result.worktree, "create failed: ${result.error}").path) + } + + private fun metadata(dir: Path, name: String): Path = + Path.of(output(dir, "rev-parse", "--path-format=absolute", "--git-path", name).trimEnd('\r', '\n')) + private fun initRepo() { git(repo, "init") git(repo, "config", "user.email", "test@kilo.ai") From 4b53c4eb05e699288526fcb77e0fd05091999bdb Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 16:59:53 -0400 Subject: [PATCH 04/19] refactor(jetbrains): consolidate change summaries into ChangesPanel Unify base and local Git comparisons behind shared backend/frontend boundaries instead of overlapping ad-hoc implementations: - Extract reusable comparison logic into backend/diff/GitComparison.kt and add a committed-only branchDiff plus a new localDiff endpoint. - Consolidate WorktreeStatsView and BranchChangesBadge into a single ChangesPanel with Compact/Full modes, reused across the Agent Manager worktree list, session toolbar, and worktree editor header. - Centralize diff editor opening/naming in KiloDiffEditorKind/Content so every surface produces identical params for the same comparison. - Migrate AgentManagerPanel, ActiveListRenderer, BranchDock, WorktreePrHeaderView, and WorktreeSessionEditorPanel onto the new component and RPC surface, removing redundant presentation code. Consolidates the three prior changesets covering PR badge placement, worktree PR diff counts, and the uncommitted changes badge into one release note. --- .changeset/jetbrains-pr-badge-leading.md | 5 - .changeset/jetbrains-worktree-pr-diff.md | 5 - .../jetbrains-worktree-uncommitted-badge.md | 2 +- .../ai/kilocode/backend/diff/GitComparison.kt | 249 +++++++++++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 185 +------- .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 176 ++------ .../ai/kilocode/backend/rpc/BranchDiffTest.kt | 76 ++-- .../backend/rpc/BranchLocalDiffTest.kt | 397 ++++++++++++++++++ .../client/agentManager/AgentManagerPanel.kt | 55 +-- .../worktree/WorktreePrHeaderView.kt | 27 +- .../worktree/WorktreeSessionEditorPanel.kt | 35 +- .../worktree/WorktreeStatsView.kt | 193 --------- .../client/app/KiloWorkspaceService.kt | 7 + .../ai/kilocode/client/diff/DiffFileStatus.kt | 2 +- .../client/diff/KiloDiffEditorContent.kt | 16 +- .../client/diff/KiloDiffEditorKind.kt | 71 +++- .../ai/kilocode/client/session/SessionUi.kt | 76 ++-- .../session/ui/header/BranchChangesBadge.kt | 145 ------- .../client/session/ui/header/BranchDock.kt | 82 ++-- .../client/session/ui/header/PrHeaderView.kt | 105 ++--- .../ai/kilocode/client/ui/ChangesPanel.kt | 336 +++++++++++++++ .../client/ui/list/ActiveListActions.kt | 5 +- .../client/ui/list/ActiveListModel.kt | 11 +- .../client/ui/list/ActiveListRenderer.kt | 47 ++- .../resources/messages/KiloBundle.properties | 13 +- .../agentManager/AgentManagerPanelTest.kt | 125 ++++-- .../worktree/WorktreePrHeaderViewTest.kt | 84 +++- .../WorktreeSessionEditorPanelTest.kt | 175 +++++++- .../worktree/WorktreeStatsViewTest.kt | 41 -- .../client/app/KiloWorkspaceServiceTest.kt | 47 +++ .../client/diff/KiloDiffEditorContentTest.kt | 102 ++++- .../client/diff/KiloInlineDiffStoreTest.kt | 276 ++++++++++++ .../client/session/SessionUiLayoutTest.kt | 240 ++++++++++- .../session/ui/header/BranchDockTest.kt | 61 ++- .../session/ui/header/PrHeaderViewTest.kt | 26 +- .../settings/base/SettingsListViewTest.kt | 116 ++++- .../client/testing/FakeWorkspaceRpcApi.kt | 18 + .../ai/kilocode/client/ui/ChangesPanelTest.kt | 341 +++++++++++++++ .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 8 +- 39 files changed, 2919 insertions(+), 1062 deletions(-) delete mode 100644 .changeset/jetbrains-pr-badge-leading.md delete mode 100644 .changeset/jetbrains-worktree-pr-diff.md create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt diff --git a/.changeset/jetbrains-pr-badge-leading.md b/.changeset/jetbrains-pr-badge-leading.md deleted file mode 100644 index f1993fa1422b..000000000000 --- a/.changeset/jetbrains-pr-badge-leading.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Keep clickable pull request badges beside the change statistics using reusable list badges diff --git a/.changeset/jetbrains-worktree-pr-diff.md b/.changeset/jetbrains-worktree-pr-diff.md deleted file mode 100644 index 8ade7f469175..000000000000 --- a/.changeset/jetbrains-worktree-pr-diff.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show the full branch change count on Agent Manager worktree rows after the branch is pushed diff --git a/.changeset/jetbrains-worktree-uncommitted-badge.md b/.changeset/jetbrains-worktree-uncommitted-badge.md index dc10db6f13c9..0e3b8c74316b 100644 --- a/.changeset/jetbrains-worktree-uncommitted-badge.md +++ b/.changeset/jetbrains-worktree-uncommitted-badge.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": minor --- -Show uncommitted changes as a separate badge on Agent Manager worktree rows +Show committed changes against the base branch on Agent Manager worktree rows, and split uncommitted changes into their own comparison in the worktree session editor header diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt new file mode 100644 index 000000000000..85e3492132d1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/GitComparison.kt @@ -0,0 +1,249 @@ +package ai.kilocode.backend.diff + +import ai.kilocode.backend.rpc.CmdOut +import ai.kilocode.backend.rpc.baseBranch +import ai.kilocode.backend.rpc.parseWorktreeList +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.progress.ProcessCanceledException +import kotlinx.coroutines.CancellationException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import kotlin.io.path.fileSize +import kotlin.io.path.inputStream + +internal class GitComparison private constructor( + private val dir: Path, + private val head: String, + val base: String, + private val tip: String, + private val from: String, + private val to: String?, +) { + enum class Mode { Base, Local } + + companion object { + private const val CAP = 8 * 1024 * 1024 + private const val LARGE = 2 * 1024 * 1024L + private val CANDIDATES = listOf( + "refs/remotes/origin/main", + "refs/remotes/origin/master", + "refs/heads/main", + "refs/heads/master", + ) + + fun open(dir: Path, mode: Mode, fallback: String? = null): GitComparison? { + if (!Files.isDirectory(dir)) return null + val inside = runGitCommand(dir, listOf("rev-parse", "--is-inside-work-tree")) + if (inside.exit == 128 && inside.stderr.startsWith("fatal: not a git repository")) return null + if (inside.checked().trim() != "true") return null + val head = revision(dir, "HEAD") ?: return null + if (mode == Mode.Local) return GitComparison(dir, head, "", head, head, null) + val declared = git(dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD", optional = true).trim() + val selected = (listOf(declared).filter { it.isNotEmpty() } + CANDIDATES) + .firstNotNullOfOrNull { ref -> revision(dir, ref)?.let { ref to it } } + val ref = selected?.first ?: (fallback + ?: baseBranch(parseWorktreeList(git(dir, "worktree", "list", "--porcelain")))) + ?.takeIf { it != "HEAD" }?.let { "refs/heads/$it" } ?: "HEAD" + val tip = selected?.second ?: if (ref == "HEAD") head else revision(dir, ref) ?: head + val ancestor = git(dir, "merge-base", tip, head, optional = true).trim().ifEmpty { tip } + val label = ref.removePrefix("refs/remotes/").removePrefix("refs/heads/") + return GitComparison(dir, head, label, tip, ancestor, head) + } + + private fun revision(dir: Path, ref: String): String? = + git(dir, "rev-parse", "--verify", "--quiet", "$ref^{commit}", optional = true).trim().ifEmpty { null } + + private fun git(dir: Path, vararg args: String, optional: Boolean = false): String { + val out = runGitCommand(dir, args.toList()) + if (optional && out.exit == 1 && out.stderr.isBlank()) return "" + return out.checked() + } + } + + fun files(patches: Boolean): List { + val stats = parseNumstat(diff("--numstat", "-z")).associateBy { it.path } + val status = parseNameStatus(diff("--name-status", "-z")) + val others = if (to != null) emptyList() else { + git(dir, "ls-files", "--others", "--exclude-standard", "-z").split('\u0000').filter { it.isNotEmpty() } + } + val files = stats.values.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } + + others.map { untracked(it, false) } + if (!patches) return files + return capDiff(files, CAP) { file, budget -> + if (file.status == "untracked") untracked(file.file, true) + else detail(file, stats.getValue(file.file), budget) + } + } + + fun counts(): Pair { + val parts = git(dir, "rev-list", "--left-right", "--count", "$tip...$head").trim().split(Regex("\\s+")) + return parts[0].toInt() to parts[1].toInt() + } + + fun unpushed(): Int { + val out = runGitCommand(dir, listOf("rev-list", "--count", "@{upstream}..$head")) + if (!out.ok) return 0 + return out.stdout.trim().toIntOrNull() ?: 0 + } + + private fun diff(vararg args: String, paths: List = emptyList()): String { + val revs = if (to == null) listOf(from) else listOf(from, to) + val opts = listOf( + "--literal-pathspecs", "-c", "core.quotepath=false", "diff", "--relative", "--no-color", + "--no-ext-diff", "--no-textconv", "--find-renames", "--rename-empty", + ) + args + revs + listOf("--") + paths + return runGitCommand(dir, opts).checked() + } + + private fun detail(file: DiffFileDto, stat: DiffStat, budget: Int): DiffFileDto? { + val paths = listOfNotNull(stat.old, stat.path).distinct() + val patch = diff("--unified=2147483647", paths = paths) + if (patch.length > budget) return null + if (stat.binary || patch.lineSequence().any { it.startsWith("@@ ") || it.startsWith("Binary files ") }) { + return file.copy(patch = patch) + } + val old = "$from:./${stat.old ?: stat.path}" + val next = to?.let { "$it:./${stat.path}" } + val before = if (file.status == "added") 0L else git(dir, "cat-file", "-s", old).trim().toLong() + val path = dir.resolve(stat.path).normalize() + val after = when { + file.status == "deleted" -> 0L + next != null -> git(dir, "cat-file", "-s", next).trim().toLong() + Files.isSymbolicLink(path) -> Files.readSymbolicLink(path).toString().length.toLong() + else -> path.fileSize() + } + if (before + after + patch.length > budget) return null + val left = if (file.status == "added") "" else git(dir, "show", old) + val right = when { + file.status == "deleted" -> "" + next != null -> git(dir, "show", next) + Files.isSymbolicLink(path) -> Files.readSymbolicLink(path).toString() + else -> Files.readString(path) + } + if ('\u0000' in left || '\u0000' in right) return file.copy(patch = patch) + return file.copy(patch = patch, before = left, after = right) + } + + private fun untracked(rel: String, patches: Boolean): DiffFileDto { + val file = DiffFileDto(rel, 0, 0, "", "untracked") + val path = dir.resolve(rel).normalize() + if (!path.startsWith(dir) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || path.fileSize() > LARGE) return file + if (!patches) return file.copy(additions = countLines(path) ?: 0) + val bytes = path.inputStream().use { it.readNBytes(LARGE.toInt() + 1) } + if (bytes.size > LARGE || bytes.any { it == 0.toByte() }) return file + val text = bytes.toString(StandardCharsets.UTF_8) + val additions = lines(text).size + return file.copy(additions = additions, patch = untrackedPatch(rel, text, additions)) + } +} + +internal fun runGitCommand(dir: Path, args: List): CmdOut { + return try { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + .withCharset(StandardCharsets.UTF_8).withEnvironment("LC_ALL", "C") + val out = CapturingProcessHandler(cmd).runProcess(30_000) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + } catch (err: CancellationException) { + throw err + } catch (err: ProcessCanceledException) { + throw err + } catch (err: Exception) { + CmdOut(-1, "", err.message ?: "git failed") + } +} + +private fun CmdOut.checked(): String { + check(ok) { "Git comparison failed (exit=$exit): ${stderr.trim()}" } + return stdout +} + +internal fun capDiff(files: List, cap: Int, fetch: (DiffFileDto, Int) -> DiffFileDto?): List { + var used = 0 + var misses = 0 + var full = false + return files.map { file -> + if (full) return@map file + val data = fetch(file, cap - used) + val size = data?.let { it.patch.orEmpty().length + it.before.orEmpty().length + it.after.orEmpty().length } + if (data != null && size != null && size <= cap - used) { + used += size + if (used >= cap) full = true + return@map data + } + misses++ + full = misses >= 3 + file + } +} + +private fun untrackedPatch(path: String, text: String, additions: Int): String = buildString { + appendLine("diff --git a/$path b/$path") + appendLine("new file mode 100644") + appendLine("--- /dev/null") + appendLine("+++ b/$path") + appendLine("@@ -0,0 +1,$additions @@") + lines(text).forEach { line -> appendLine("+$line") } + if (text.isNotEmpty() && !text.endsWith("\n")) appendLine("\\ No newline at end of file") +}.removeSuffix("\n") + +private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + return text.removeSuffix("\n").split('\n') +} + +private fun countLines(path: Path): Int? { + var newlines = 0 + var last = 0 + var any = false + path.inputStream().buffered().use { input -> + val buf = ByteArray(8192) + while (true) { + val n = input.read(buf) + if (n <= 0) break + any = true + for (i in 0 until n) { + val b = buf[i].toInt() + if (b == 0) return null + if (b == '\n'.code) newlines++ + } + last = buf[n - 1].toInt() + } + } + if (!any) return 0 + return if (last == '\n'.code) newlines else newlines + 1 +} + +internal data class DiffStat(val path: String, val additions: Int, val deletions: Int, val old: String? = null, val binary: Boolean = false) + +internal fun parseNameStatus(text: String): Map = buildMap { + val tokens = text.split('\u0000').iterator() + while (tokens.hasNext()) { + val code = tokens.next() + if (code.isEmpty()) continue + val first = tokens.next() + val path = if (code.startsWith('R') || code.startsWith('C')) tokens.next() else first + val status = when (code.first()) { + 'A' -> "added" + 'D' -> "deleted" + 'R' -> "renamed" + else -> "modified" + } + put(path, status) + } +} + +internal fun parseNumstat(text: String): List = buildList { + val tokens = text.split('\u0000').iterator() + while (tokens.hasNext()) { + val token = tokens.next() + if (token.isEmpty()) continue + val parts = token.split('\t', limit = 3) + val old = if (parts[2].isEmpty()) tokens.next() else null + val path = if (old != null) tokens.next() else parts[2] + add(DiffStat(path, parts[0].toIntOrNull() ?: 0, parts[1].toIntOrNull() ?: 0, old, parts[0] == "-")) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 15bfc0664279..000128b11a30 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -6,6 +6,7 @@ import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.cli.buildKiloCliEnv import ai.kilocode.backend.cli.KiloCliConfigPath +import ai.kilocode.backend.diff.GitComparison import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager @@ -58,10 +59,6 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path -import kotlin.io.path.fileSize -import kotlin.io.path.inputStream -import kotlin.io.path.isRegularFile -import kotlin.io.path.readBytes import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.resume @@ -83,8 +80,6 @@ class KiloWorkspaceRpcApiImpl internal constructor( private val GLOBAL = MODERN + LEGACY + "config.json" private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") private const val DIFF_CAP = 200_000 - private const val BRANCH_DIFF_CAP = 8 * 1024 * 1024 - private const val LARGE_FILE = 2 * 1024 * 1024L private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ "${'$'}schema": "$SCHEMA" @@ -258,28 +253,13 @@ class KiloWorkspaceRpcApiImpl internal constructor( } override suspend fun branchDiff(directory: String, patches: Boolean): List = withContext(Dispatchers.IO) { - val base = file(clean(directory) ?: directory) ?: return@withContext emptyList() - if (!gitAvailable(base)) return@withContext emptyList() - val anc = mergeBase(base) - // --relative scopes diff output to the opened directory and emits project-relative paths, so - // tracked entries match the untracked list (ls-files is already cwd-relative) in monorepos. - val stats = parseNumstat(git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--relative", "--no-color", "--no-renames", anc)) - val status = parseNameStatus(git(base, "-c", "core.quotepath=false", "diff", "--name-status", "--relative", "--no-color", "--no-renames", anc)) - val untrackedPaths = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") - .lineSequence() - .filter { it.isNotBlank() } - .toList() - // Stats-only DTOs (empty patch); the badge path stops here. untracked() streams a line - // count on this path instead of materializing each file as a String. - val files = stats.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } + - untrackedPaths.map { untracked(base, it, withPatch = false) } - if (!patches) return@withContext files - // Fetch patches lazily and stop once the running total reaches BRANCH_DIFF_CAP, so a branch with - // hundreds of changed files doesn't spawn a git subprocess (or read a file) per entry. - capDiff(files, BRANCH_DIFF_CAP) { file -> - if (file.status == "untracked") untracked(base, file.file, withPatch = true).patch.orEmpty() - else fileDiff(base, anc, file.file) - } + val dir = file(clean(directory) ?: directory) ?: return@withContext emptyList() + GitComparison.open(dir, GitComparison.Mode.Base)?.files(patches).orEmpty() + } + + override suspend fun localDiff(directory: String, patches: Boolean): List = withContext(Dispatchers.IO) { + val dir = file(clean(directory) ?: directory) ?: return@withContext emptyList() + GitComparison.open(dir, GitComparison.Mode.Local)?.files(patches).orEmpty() } override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) { @@ -428,55 +408,6 @@ class KiloWorkspaceRpcApiImpl internal constructor( return runWorkspaceGit(base, *args) } - /** Merge-base of the resolved default branch and HEAD, or HEAD when no base can be determined. */ - private fun mergeBase(base: Path): String { - val ref = defaultBranch(base) ?: return "HEAD" - return git(base, "merge-base", ref, "HEAD").trim().ifBlank { "HEAD" } - } - - /** - * Resolve the base branch ref, preferring the remote's declared default (origin HEAD), then the - * common origin and local main or master branches, so repos whose default is develop or trunk — - * or worktrees where only the remote branch exists locally — still resolve. Fully-qualified refs - * are used so a tag named "main" can't be mistaken for the branch. - */ - private fun defaultBranch(base: Path): String? { - git(base, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD").trim() - .removePrefix("refs/remotes/") - .takeIf { it.isNotBlank() } - ?.let { return it } - return listOf( - "refs/remotes/origin/main" to "origin/main", - "refs/remotes/origin/master" to "origin/master", - "refs/heads/main" to "main", - "refs/heads/master" to "master", - ).firstOrNull { git(base, "rev-parse", "--verify", "--quiet", it.first).isNotBlank() }?.second - } - - private fun fileDiff(base: Path, anc: String, path: String): String = - git(base, "-c", "core.quotepath=false", "diff", "--relative", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc, "--", path) - - private fun untracked(base: Path, rel: String, withPatch: Boolean): DiffFileDto { - return runCatching { - val path = base.resolve(rel).normalize() - if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") - if (!withPatch) { - // Badge path (runs on every turn end / revert): count lines by streaming bytes rather - // than allocating the whole file. null = binary (NUL byte), reported as 0/0. - val count = countLines(path) ?: return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") - return@runCatching DiffFileDto(rel, count, 0, "", "untracked") - } - val bytes = path.readBytes() - if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked") - val text = bytes.toString(StandardCharsets.UTF_8) - val additions = lines(text).size - DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions), "untracked") - }.getOrElse { err -> - LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" } - DiffFileDto(rel, 0, 0, "", "untracked") - } - } - private fun agent(a: Agent) = AgentInfo( name = a.name, displayName = a.displayName, @@ -544,106 +475,6 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List): Str return bases.firstOrNull() ?: hint } -/** - * Assemble capped diff DTOs. [fetch] lazily produces each file's full-context patch. Oversized - * patches are skipped, but a single generated/large file should not blank every later small file; - * after a bounded number of misses, stop fetching so large branches still don't run a git subprocess - * (or read a file) per entry only to discard the output. Files past the cap keep their stats but - * carry an empty patch, which the client renders from stats alone. - */ -internal fun capDiff(files: List, cap: Int, fetch: (DiffFileDto) -> String): List { - var used = 0 - var misses = 0 - var full = false - return files.map { file -> - if (full) return@map file.copy(patch = "") - val text = fetch(file) - when { - text.isBlank() -> file.copy(patch = "") - used + text.length <= cap -> { - used += text.length - if (used >= cap) full = true - file.copy(patch = text) - } - else -> { - misses++ - full = misses >= MAX_OVERSIZED_PATCHES || used >= cap - file.copy(patch = "") - } - } - } -} - -private const val MAX_OVERSIZED_PATCHES = 3 - -private fun untrackedPatch(path: String, text: String, additions: Int): String = buildString { - appendLine("diff --git a/$path b/$path") - appendLine("new file mode 100644") - appendLine("--- /dev/null") - appendLine("+++ b/$path") - appendLine("@@ -0,0 +1,$additions @@") - lines(text).forEach { line -> appendLine("+$line") } - if (text.isNotEmpty() && !text.endsWith("\n")) appendLine("\\ No newline at end of file") -}.removeSuffix("\n") - -private fun lines(text: String): List { - if (text.isEmpty()) return emptyList() - return text.removeSuffix("\n").split('\n') -} - -/** - * Count lines the way [lines] does (trailing newline ignored, empty file = 0) by streaming bytes, - * so the stats-only untracked path doesn't allocate the whole file. Returns null for binary content - * (a NUL byte), matching the with-patch path's binary guard. - */ -private fun countLines(path: Path): Int? { - var newlines = 0 - var last = 0 - var any = false - path.inputStream().buffered().use { input -> - val buf = ByteArray(8192) - while (true) { - val n = input.read(buf) - if (n <= 0) break - any = true - for (i in 0 until n) { - val b = buf[i].toInt() - if (b == 0) return null - if (b == '\n'.code) newlines++ - } - last = buf[n - 1].toInt() - } - } - if (!any) return 0 - return if (last == '\n'.code) newlines else newlines + 1 -} - -internal data class DiffStat(val path: String, val additions: Int, val deletions: Int) - -internal fun parseNameStatus(text: String): Map = text.lineSequence() - .mapNotNull { line -> - val parts = line.split('\t') - if (parts.size < 2) return@mapNotNull null - val path = parts.drop(1).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null - val status = when (parts[0].firstOrNull()) { - 'A' -> "added" - 'D' -> "deleted" - 'M' -> "modified" - else -> null - } ?: return@mapNotNull null - path to status - } - .toMap() - -internal fun parseNumstat(text: String): List = text.lineSequence() - .mapNotNull { line -> - val parts = line.split('\t') - if (parts.size < 3) return@mapNotNull null - val path = parts.drop(2).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null - DiffStat(path, parts[0].toIntOrNull() ?: 0, parts[1].toIntOrNull() ?: 0) - } - .toList() - internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap = ConcurrentHashMap()): Boolean { if (Files.exists(base.resolve(".git"))) return true return cache.getOrPut(base.toString()) { 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 e343d1de4a4c..9dde2a6d7a7a 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 @@ -1,6 +1,8 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.diff.GitComparison +import ai.kilocode.backend.diff.runGitCommand import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorktreeRpcApi import ai.kilocode.rpc.parsePrUrl @@ -58,33 +60,16 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.concurrent.ConcurrentHashMap -import kotlin.io.path.fileSize -import kotlin.io.path.inputStream -import kotlin.io.path.isRegularFile class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { companion object { internal val LOG = KiloLog.create(KiloWorktreeRpcApiImpl::class.java) - private const val BASE_TTL = 60_000L private const val GH_PROBE_TTL = 300_000L private const val GH_STATUS_TTL = 3_000L private const val PR_TTL = 90_000L - - /** - * Base-branch candidates in preference order, as (verify ref, diff ref). Fully-qualified on - * the left so a tag named "main" cannot be mistaken for the branch. Mirrors - * `KiloWorkspaceRpcApiImpl.defaultBranch`, which the branch-diff editor already uses. - */ - private val BASE_CANDIDATES = listOf( - "refs/remotes/origin/main" to "origin/main", - "refs/remotes/origin/master" to "origin/master", - "refs/heads/main" to "main", - "refs/heads/master" to "master", - ) } - private val bases = ConcurrentHashMap>() private val prs = ConcurrentHashMap>() private val branches = ConcurrentHashMap>() private val resolver = PrResolver(gh = ::runGh, git = ::runGit) @@ -603,15 +588,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList()) - private fun runGit(base: Path, args: List): CmdOut { - return try { - val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile()) - val out = CapturingProcessHandler(cmd).runProcess(30_000) - CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) - } catch (e: Exception) { - CmdOut(-1, "", e.message ?: "git failed") - } - } + private fun runGit(base: Path, args: List): CmdOut = runGitCommand(base, args) private fun runGh(base: Path, vararg args: String): CmdOut = runGh(base, args.toList()) @@ -646,104 +623,34 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() } } - /** - * Committed changes this worktree introduces relative to the base branch, i.e. what GitHub shows - * under "Files changed" for a pull request. The trailing `HEAD` on the diff is load-bearing: - * without it git diffs the working tree and mixes uncommitted edits into the PR number. Untracked - * files are excluded for the same reason — they are not in a PR. Both live in [dirty] instead. - * - * Rename detection is left on (no `--no-renames`) so a pure file move counts 0/0 like GitHub - * does. `parseNumstat` only consumes the two count columns, so the `{old => new}` path form in - * rename output is harmless here. - */ private fun stats(item: WorktreeDto, fallback: String): WorktreeStatsDto { val dir = Path.of(item.path).normalize() - return runCatching { - val base = base(item, fallback) - val anc = runGit(dir, "merge-base", "HEAD", base).stdout.trim().takeIf { it.isNotBlank() } ?: base - val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", anc, "HEAD") - val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList() - val counts = aheadBehind(dir, base) - WorktreeStatsDto( - item.path, - tracked.sumOf { it.additions }, - tracked.sumOf { it.deletions }, - counts.second, - counts.first, - files = tracked.size, - base = base, - ) - }.getOrElse { err -> - LOG.warn("worktree stats failed: path=${item.path} message=${err.message}", err) - WorktreeStatsDto(item.path) - } - } - - /** - * Diff/commit baseline for a worktree: the branch a pull request would target. Prefers the - * remote's declared default (origin HEAD), then the common origin and local main/master names, - * then [fallback] (the branch checked out in the main working tree) for repos that have none of - * those. - * - * Never the worktree's own `@{upstream}`: once a branch is pushed, its upstream contains every - * commit, so the merge-base is HEAD and the diff reports zero changes. - */ - private fun base(item: WorktreeDto, fallback: String): String { - val now = System.currentTimeMillis() - bases[item.path]?.takeIf { now - it.time < BASE_TTL }?.let { return it.value } - val dir = Path.of(item.path).normalize() - val value = defaultBase(dir) ?: fallback - bases[item.path] = Timed(now, value) - return value - } - - private fun defaultBase(dir: Path): String? { - val head = runGit(dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD") - head.stdout.trim().removePrefix("refs/remotes/").takeIf { head.ok && it.isNotBlank() }?.let { return it } - return BASE_CANDIDATES.firstOrNull { runGit(dir, "rev-parse", "--verify", "--quiet", it.first).ok }?.second + val comparison = GitComparison.open(dir, GitComparison.Mode.Base, fallback) ?: return WorktreeStatsDto(item.path) + val files = comparison.files(false) + val counts = comparison.counts() + return WorktreeStatsDto( + item.path, + additions = files.sumOf { it.additions }, + deletions = files.sumOf { it.deletions }, + ahead = counts.second, + behind = counts.first, + files = files.size, + base = comparison.base, + ) } - /** - * Everything not yet committed in [item]: `diff --numstat HEAD` covers the index and the working - * tree in one call, then untracked files are line-counted the same way [stats] used to. An unborn - * branch makes the diff exit non-zero; untracked files are still reported. - */ private fun dirty(item: WorktreeDto): WorktreeDirtyDto { val dir = Path.of(item.path).normalize() - return runCatching { - val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", "HEAD") - val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList() - val others = runGit(dir, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard") - .stdout - .lineSequence() - .filter { it.isNotBlank() } - .toList() - WorktreeDirtyDto( - item.path, - additions = tracked.sumOf { it.additions } + others.sumOf { countUntracked(dir, it) }, - deletions = tracked.sumOf { it.deletions }, - files = tracked.size + others.size, - untracked = others.size, - unpushed = unpushed(dir), - ) - }.getOrElse { err -> - LOG.warn("worktree dirty failed: path=${item.path} message=${err.message}", err) - WorktreeDirtyDto(item.path) - } - } - - /** Commits on HEAD that the branch's upstream does not have. 0 when there is no upstream. */ - private fun unpushed(dir: Path): Int { - val out = runGit(dir, "rev-list", "--count", "@{upstream}..HEAD") - if (!out.ok) return 0 - return out.stdout.trim().toIntOrNull() ?: 0 - } - - private fun aheadBehind(dir: Path, base: String): Pair { - val out = runGit(dir, "rev-list", "--left-right", "--count", "$base...HEAD") - if (!out.ok) return 0 to 0 - val parts = out.stdout.trim().split(Regex("\\s+")) - return (parts.getOrNull(0)?.toIntOrNull() ?: 0) to (parts.getOrNull(1)?.toIntOrNull() ?: 0) + val comparison = GitComparison.open(dir, GitComparison.Mode.Local) ?: return WorktreeDirtyDto(item.path) + val files = comparison.files(false) + return WorktreeDirtyDto( + item.path, + additions = files.sumOf { it.additions }, + deletions = files.sumOf { it.deletions }, + files = files.size, + untracked = files.count { it.status == "untracked" }, + unpushed = comparison.unpushed(), + ) } private fun ghAvailable(root: Path): GhAvailability { @@ -1125,36 +1032,3 @@ private fun realPath(path: String): Path { val file = Path.of(path).normalize() return if (Files.exists(file)) file.toRealPath() else file } - -private fun countUntracked(base: Path, rel: String): Int { - return runCatching { - val path = base.resolve(rel).normalize() - if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > 2 * 1024 * 1024L) return@runCatching 0 - countLines(path) ?: 0 - }.getOrElse { err -> - KiloWorktreeRpcApiImpl.LOG.debug { "worktree stats untracked read failed: path=$rel message=${err.message}" } - 0 - } -} - -private fun countLines(path: Path): Int? { - var newlines = 0 - var last = 0 - var any = false - path.inputStream().buffered().use { input -> - val buf = ByteArray(8192) - while (true) { - val n = input.read(buf) - if (n <= 0) break - any = true - for (i in 0 until n) { - val b = buf[i].toInt() - if (b == 0) return null - if (b == '\n'.code) newlines++ - } - last = buf[n - 1].toInt() - } - } - if (!any) return 0 - return if (last == '\n'.code) newlines else newlines + 1 -} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt index 6521c61ba44a..760120582b78 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchDiffTest.kt @@ -1,5 +1,9 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.diff.DiffStat +import ai.kilocode.backend.diff.capDiff +import ai.kilocode.backend.diff.parseNameStatus +import ai.kilocode.backend.diff.parseNumstat import ai.kilocode.rpc.dto.DiffFileDto import kotlin.test.Test import kotlin.test.assertEquals @@ -10,13 +14,16 @@ class BranchDiffTest { @Test fun `capDiff fills patches in order until the cap is reached`() { val files = listOf(stat("a.txt"), stat("b.txt"), stat("c.txt")) + val fetched = mutableListOf() - val diff = capDiff(files, cap = 5) { "12345" } + val diff = capDiff(files, cap = 5) { file, _ -> + fetched += file.file + file.copy(patch = "12345") + } - assertEquals(listOf("a.txt", "b.txt", "c.txt"), diff.map { it.file }) - assertEquals("12345", diff[0].patch) - assertEquals("", diff[1].patch) - assertEquals("", diff[2].patch) + assertEquals(listOf("a.txt"), fetched) + assertEquals(files.map { it.file }, diff.map { it.file }) + assertEquals(listOf("12345", "", ""), diff.map { it.patch }) } @Test @@ -24,9 +31,9 @@ class BranchDiffTest { val fetched = mutableListOf() val files = listOf(stat("big.txt"), stat("small.txt"), stat("tiny.txt")) - val diff = capDiff(files, cap = 4) { file -> + val diff = capDiff(files, cap = 4) { file, _ -> fetched += file.file - if (file.file == "big.txt") "0123456789" else "x" + file.copy(patch = if (file.file == "big.txt") "0123456789" else "x") } assertEquals(listOf("big.txt", "small.txt", "tiny.txt"), fetched) @@ -38,13 +45,13 @@ class BranchDiffTest { val fetched = mutableListOf() val files = listOf(stat("big1.txt"), stat("big2.txt"), stat("big3.txt"), stat("later.txt")) - val diff = capDiff(files, cap = 4) { file -> + val diff = capDiff(files, cap = 4) { file, _ -> fetched += file.file - "0123456789" + file.copy(patch = "0123456789") } assertEquals(listOf("big1.txt", "big2.txt", "big3.txt"), fetched) - assertEquals(listOf("", "", "", ""), diff.map { it.patch }) + assertEquals(files, diff) } @Test @@ -52,64 +59,83 @@ class BranchDiffTest { val fetched = mutableListOf() val files = listOf(stat("empty.txt"), stat("kept.txt")) - val diff = capDiff(files, cap = 10) { file -> + val diff = capDiff(files, cap = 10) { file, _ -> fetched += file.file - if (file.file == "empty.txt") "" else "patch" + file.copy(patch = if (file.file == "empty.txt") "" else "patch") } assertEquals(listOf("empty.txt", "kept.txt"), fetched) - assertEquals("", diff[0].patch) - assertEquals("patch", diff[1].patch) + assertEquals(listOf("", "patch"), diff.map { it.patch }) } @Test fun `capDiff dispatches fetch per file so tracked and untracked share the budget`() { val files = listOf(stat("A.kt", "modified"), stat("New.kt", "untracked")) - val diff = capDiff(files, cap = 100) { file -> - if (file.status == "untracked") "untracked-patch" else "tracked-patch" + val diff = capDiff(files, cap = 100) { file, _ -> + file.copy(patch = if (file.status == "untracked") "untracked-patch" else "tracked-patch") } - assertEquals("tracked-patch", diff[0].patch) - assertEquals("untracked-patch", diff[1].patch) - assertEquals("untracked", diff[1].status) + assertEquals(listOf("tracked-patch", "untracked-patch"), diff.map { it.patch }) + assertEquals("untracked", diff.last().status) } @Test fun `capDiff keeps all patches under a large budget`() { val files = (1..110).map { i -> stat("src/File$i.kt") } - val diff = capDiff(files, cap = 8 * 1024 * 1024) { file -> "patch-${file.file}\n".repeat(30) } + val diff = capDiff(files, cap = 8 * 1024 * 1024) { file, _ -> file.copy(patch = "patch-${file.file}\n".repeat(30)) } assertEquals(files.map { it.file }, diff.map { it.file }) assertEquals(files.map { file -> "patch-${file.file}\n".repeat(30) }, diff.map { it.patch }) } @Test - fun `parses git numstat output`() { - val stats = parseNumstat("1\t2\tsrc/A.kt\n0\t3\tsrc/B.kt\n-\t-\tbin.png\n") + fun `capDiff includes whole contents in the shared budget and preserves counts when omitted`() { + val files = listOf(stat("rename.txt", "renamed"), stat("later.txt")) + val budgets = mutableListOf() + val diff = capDiff(files, 8) { file, budget -> + budgets += budget + file.copy(patch = "ab", before = "old", after = "new") + } + + assertEquals(listOf(8), budgets) + assertEquals("old", diff.first().before) + assertEquals(files.last(), diff.last()) + assertEquals(files, capDiff(files, 7) { file, _ -> file.copy(patch = "ab", before = "old", after = "new") }) + assertEquals(files, capDiff(files, 7) { _, _ -> null }) + } + + @Test + fun `parses NUL numstat with old and new rename paths and binary and empty entries`() { + val stats = parseNumstat("1\t2\tsrc/A.kt\u00000\t3\tsrc/B.kt\u0000-\t-\tbin.png\u00000\t0\t\u0000old\tname\n.txt\u0000new é.txt\u00000\t0\tempty\u0000") assertEquals( listOf( DiffStat("src/A.kt", 1, 2), DiffStat("src/B.kt", 0, 3), - DiffStat("bin.png", 0, 0), + DiffStat("bin.png", 0, 0, binary = true), + DiffStat("new é.txt", 0, 0, old = "old\tname\n.txt"), + DiffStat("empty", 0, 0), ), stats, ) + assertEquals(emptyList(), parseNumstat("")) } @Test - fun `parses git name status output`() { - val status = parseNameStatus("M\tsrc/A.kt\nA\tsrc/B.kt\nD\tsrc/Old.kt\n??\tsrc/Skip.kt\n") + fun `parses NUL name status without treating renamed paths as extra files`() { + val status = parseNameStatus("M\u0000src/A.kt\u0000A\u0000src/B.kt\u0000D\u0000src/Old.kt\u0000R100\u0000old\tname\n.txt\u0000new é.txt\u0000") assertEquals( mapOf( "src/A.kt" to "modified", "src/B.kt" to "added", "src/Old.kt" to "deleted", + "new é.txt" to "renamed", ), status, ) + assertEquals(emptyMap(), parseNameStatus("")) } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt new file mode 100644 index 000000000000..3163e52cfc4f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/BranchLocalDiffTest.kt @@ -0,0 +1,397 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.diff.GitComparison +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BranchLocalDiffTest { + private val root = Files.createTempDirectory("kilo-branch-local-diff").toRealPath() + private val repo = Files.createDirectory(root.resolve("repo")) + private val config = Files.createFile(root.resolve("empty.config")) + private val hooks = Files.createDirectory(root.resolve("hooks")) + private val api = KiloWorkspaceRpcApiImpl() + private val trees = KiloWorktreeRpcApiImpl() + private val timeout = 30_000 + + @AfterTest + fun tearDown() { + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `mixed committed and net local changes match their summaries and patches`() = runBlocking { + init() + val dir = worktree() + Files.writeString(dir.resolve("tracked.txt"), "one\ncommitted\nthree\n") + Files.writeString(dir.resolve("committed.txt"), "committed only\n") + git(dir, "rm", "deleted.txt") + commit(dir) + Files.writeString(dir.resolve("tracked.txt"), "one\nstaged\nthree\n") + Files.writeString(dir.resolve("staged.txt"), "index\n") + Files.writeString(dir.resolve("net.txt"), "temporary\n") + git(dir, "add", "tracked.txt", "staged.txt", "net.txt") + Files.writeString(dir.resolve("tracked.txt"), "one\nunstaged\nthree\nextra\n") + Files.writeString(dir.resolve("staged.txt"), "index\nworktree\n") + Files.writeString(dir.resolve("net.txt"), "same\n") + Files.delete(dir.resolve("committed.txt")) + Files.writeString(dir.resolve("untracked.txt"), "untracked\nlast") + Files.writeString(dir.resolve("ignored.txt"), "ignored\n") + val status = git(dir, "status", "--porcelain", "-z") + + val (base, local) = parity(dir) + + assertEquals(setOf("tracked.txt", "committed.txt", "deleted.txt"), base.map { it.file }.toSet()) + assertEquals(2, base.sumOf { it.additions }) + assertEquals(2, base.sumOf { it.deletions }) + assertEquals(setOf("tracked.txt", "committed.txt", "staged.txt", "untracked.txt"), local.map { it.file }.toSet()) + assertEquals(6, local.sumOf { it.additions }) + assertEquals(2, local.sumOf { it.deletions }) + val patch = base.single { it.file == "tracked.txt" }.patch.orEmpty() + assertTrue(patch.contains(" one\n") && patch.contains("+committed\n") && patch.contains(" three\n"), patch) + assertFalse(patch.contains("staged"), patch) + assertEquals(status, git(dir, "status", "--porcelain", "-z")) + } + + @Test + fun `fully pushed branch keeps base changes and retains unpushed metadata separately`() = runBlocking { + init() + val origin = Files.createDirectory(root.resolve("origin")) + git(origin, "init", "--bare", "--initial-branch=main") + git(repo, "remote", "add", "origin", origin.toString()) + git(repo, "push", "-u", "origin", "main") + git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main") + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "one\n") + commit(dir) + 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) + 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) + } + + @Test + fun `diverged base reports ahead and behind in the correct direction`() = runBlocking { + init() + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "feature\n") + commit(dir) + Files.writeString(repo.resolve("main.txt"), "main one\n") + commit(repo) + Files.writeString(repo.resolve("main.txt"), "main one\nmain two\n") + commit(repo) + + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + val stats = trees.stats(repo.toString()).items.single() + assertEquals(1, stats.ahead) + assertEquals(2, stats.behind) + assertEquals("main", stats.base) + } + + @Test + fun `subdirectory comparisons stay relative and exclude changes outside their scope`() = runBlocking { + init() + Files.createDirectories(repo.resolve("module")) + Files.writeString(repo.resolve("module/old name[1].txt"), "rename contents\n") + commit(repo) + val dir = worktree() + git(dir, "mv", "module/old name[1].txt", "module/new name[2].txt") + Files.writeString(dir.resolve("outside.txt"), "outside\n") + commit(dir) + Files.writeString(dir.resolve("module/new name[2].txt"), "rename contents\nlocal\n") + Files.writeString(dir.resolve("module/untracked.txt"), "untracked\n") + Files.writeString(dir.resolve("outside.txt"), "outside\nlocal\n") + Files.writeString(dir.resolve("untracked.txt"), "outside untracked\n") + + val (base, local) = parity(dir) + val subdir = dir.resolve("module").toString() + val committed = api.branchDiff(subdir, true) + val working = api.localDiff(subdir, true) + assertEquals(base.filter { it.file.startsWith("module/") }.map { it.copy(file = it.file.removePrefix("module/"), patch = "") }, committed.map { it.copy(patch = "") }) + assertEquals(local.filter { it.file.startsWith("module/") }.map { it.copy(file = it.file.removePrefix("module/"), patch = "") }, working.map { it.copy(patch = "") }) + assertEquals("rename contents\n", committed.single().before) + assertEquals("rename contents\n", committed.single().after) + assertTrue(committed.single().patch.orEmpty().contains("rename from old name[1].txt")) + assertEquals(setOf("new name[2].txt", "untracked.txt"), working.map { it.file }.toSet()) + } + + @Test + fun `rename binary and empty files remain counted in both comparisons`() = runBlocking { + init() + val dir = worktree() + git(dir, "mv", "rename.txt", "renamed.txt") + Files.write(dir.resolve("binary.bin"), byteArrayOf(0, 1, 2)) + Files.writeString(dir.resolve("empty.txt"), "") + commit(dir) + git(dir, "mv", "local.txt", "local-renamed.txt") + Files.write(dir.resolve("binary.bin"), byteArrayOf(0, 3, 4)) + Files.writeString(dir.resolve("local-empty.txt"), "") + Files.write(dir.resolve("untracked.bin"), byteArrayOf(0, 5)) + Files.writeString(dir.resolve("renamed.txt"), "local drift\n") + + val (base, local) = parity(dir) + + assertEquals(3, base.size) + assertEquals(0, base.sumOf { it.additions + it.deletions }) + val renamed = base.single { it.file == "renamed.txt" } + assertEquals("renamed", renamed.status) + assertEquals("rename contents\n", renamed.before) + assertEquals("rename contents\n", renamed.after) + assertTrue(renamed.patch.orEmpty().contains("rename from rename.txt")) + val moved = local.single { it.file == "local-renamed.txt" } + assertEquals("renamed", moved.status) + assertEquals(0, moved.additions + moved.deletions) + assertEquals("local contents\n", moved.before) + assertEquals("local contents\n", moved.after) + assertEquals(5, local.size) + assertNull(base.single { it.file == "binary.bin" }.before) + assertTrue(local.single { it.file == "untracked.bin" }.patch.isNullOrEmpty()) + assertEquals("", base.single { it.file == "empty.txt" }.after) + } + + @Test + fun `committed full context and renamed contents survive working file modifications and deletions`() = runBlocking { + init() + val text = (1..30).joinToString("\n", postfix = "\n") { "context $it" } + Files.writeString(repo.resolve("edited.txt"), text) + Files.writeString(repo.resolve("removed.txt"), text) + commit(repo) + val dir = worktree() + val committed = text.replace("context 15", "committed line") + Files.writeString(dir.resolve("edited.txt"), committed) + Files.writeString(dir.resolve("removed.txt"), committed) + git(dir, "mv", "rename.txt", "renamed.txt") + commit(dir) + Files.writeString(dir.resolve("edited.txt"), "DRIFTED\n") + Files.delete(dir.resolve("removed.txt")) + Files.delete(dir.resolve("renamed.txt")) + + val base = parity(dir).first + + for (name in listOf("edited.txt", "removed.txt")) { + val patch = base.single { it.file == name }.patch.orEmpty() + assertTrue(patch.contains(" context 1\n"), patch) + assertTrue(patch.contains(" context 30\n"), patch) + assertTrue(patch.contains("-context 15\n+committed line\n"), patch) + assertFalse(patch.contains("DRIFTED"), patch) + } + val renamed = base.single { it.file == "renamed.txt" } + assertEquals("rename contents\n", renamed.before) + assertEquals("rename contents\n", renamed.after) + } + + @Test + fun `default selection follows declared origin remote local and primary fallbacks without caching`() = runBlocking { + init() + git(repo, "branch", "-m", "trunk") + val dir = worktree() + val refs = listOf("refs/remotes/origin/develop", "refs/remotes/origin/main", "refs/remotes/origin/master", "refs/heads/main", "refs/heads/master") + refs.forEach { git(repo, "update-ref", it, "HEAD") } + git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", refs.first()) + Files.writeString(dir.resolve("feature.txt"), "feature\n") + commit(dir) + git(dir, "tag", "main") + git(dir, "tag", "master") + git(dir, "branch", "shadow") + git(dir, "branch", "--set-upstream-to=shadow", "feature") + + for (ref in refs) { + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + assertEquals(ref.removePrefix("refs/remotes/").removePrefix("refs/heads/"), trees.stats(repo.toString()).items.single().base) + git(repo, "update-ref", "-d", ref) + } + assertEquals(listOf("feature.txt"), parity(dir).first.map { it.file }) + assertEquals("trunk", trees.stats(repo.toString()).items.single().base) + git(repo, "checkout", "--detach") + assertTrue(parity(dir).first.isEmpty()) + assertEquals("HEAD", trees.stats(repo.toString()).items.single().base) + } + + @Test + fun `unrelated base falls back to its committed tip without mixing local changes`() = runBlocking { + init() + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "feature\n") + commit(dir) + git(repo, "checkout", "--orphan", "isolated") + git(repo, "rm", "-rf", ".") + Files.writeString(repo.resolve("unrelated.txt"), "unrelated\n") + git(repo, "add", "unrelated.txt") + git(repo, "commit", "-m", "unrelated") + git(repo, "branch", "-f", "main", "HEAD") + Files.writeString(dir.resolve("feature.txt"), "local drift\n") + Files.writeString(dir.resolve("untracked.txt"), "untracked\n") + + val base = parity(dir).first + + assertEquals("deleted", base.single { it.file == "unrelated.txt" }.status) + assertTrue(base.single { it.file == "feature.txt" }.patch.orEmpty().contains("+feature\n")) + assertFalse(base.any { it.file == "untracked.txt" }) + assertEquals("main", trees.stats(repo.toString()).items.single().base) + } + + @Test + fun `comparison freezes both committed revisions and local HEAD before loading files`() { + init() + val dir = worktree() + Files.writeString(dir.resolve("feature.txt"), "first\n") + commit(dir) + val base = assertNotNull(GitComparison.open(dir, GitComparison.Mode.Base)) + val local = assertNotNull(GitComparison.open(dir, GitComparison.Mode.Local)) + Files.writeString(dir.resolve("feature.txt"), "second\n") + commit(dir) + git(repo, "update-ref", "refs/heads/main", git(dir, "rev-parse", "HEAD").trim()) + + assertEquals(0 to 1, base.counts()) + assertTrue(base.files(true).single().patch.orEmpty().contains("+first\n")) + assertEquals(1, local.files(false).single().additions) + assertTrue(local.files(true).single().patch.orEmpty().contains("-first\n+second\n")) + assertTrue(assertNotNull(GitComparison.open(dir, GitComparison.Mode.Base)).files(true).isEmpty()) + } + + @Test + fun `large renamed contents and untracked guards retain stats while limiting payloads`() = runBlocking { + init() + Files.writeString(repo.resolve("large.txt"), "line\n".repeat(900_000)) + commit(repo) + val dir = worktree() + git(dir, "mv", "large.txt", "a-large.txt") + Files.writeString(dir.resolve("z-small.txt"), "small\n") + commit(dir) + Files.writeString(dir.resolve("oversized.txt"), "x".repeat(2 * 1024 * 1024 + 1)) + Files.write(dir.resolve("untracked.bin"), byteArrayOf(1, 0, 2)) + Files.writeString(dir.resolve("untracked-empty.txt"), "") + Files.writeString(dir.resolve("untracked-text.txt"), "one\ntwo") + + val (base, local) = parity(dir) + + val large = base.single { it.file == "a-large.txt" } + assertEquals(0, large.additions + large.deletions) + assertTrue(large.patch.isNullOrEmpty()) + assertNull(large.before) + assertNull(large.after) + assertTrue(base.single { it.file == "z-small.txt" }.patch.orEmpty().contains("+small")) + assertEquals(4, local.size) + assertEquals(2, local.sumOf { it.additions }) + assertTrue(local.single { it.file == "oversized.txt" }.patch.isNullOrEmpty()) + assertTrue((base + local).sumOf { it.patch.orEmpty().length + it.before.orEmpty().length + it.after.orEmpty().length } < 8 * 1024 * 1024) + } + + @Test + fun `bulk stats and dirty retain managed-only scope excluding primary`() = runBlocking { + init() + val dir = worktree() + val outside = root.resolve("outside") + git(repo, "worktree", "add", "-b", "outside", outside.toString()) + Files.writeString(repo.resolve("primary.txt"), "primary\n") + Files.writeString(outside.resolve("outside.txt"), "outside\n") + Files.writeString(dir.resolve("managed.txt"), "managed\n") + + 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("managed.txt"), parity(dir).second.map { it.file }) + assertEquals(listOf("outside.txt"), api.localDiff(outside.toString(), false).map { it.file }) + } + + @Test + fun `clean missing nonGit and noHEAD comparisons are empty`() = runBlocking { + for (dir in listOf(repo, repo.resolve("missing"))) { + assertTrue(api.branchDiff(dir.toString(), true).isEmpty()) + assertTrue(api.localDiff(dir.toString(), true).isEmpty()) + } + git(repo, "init", "--initial-branch=main") + Files.writeString(repo.resolve("untracked.txt"), "unborn\n") + assertTrue(api.branchDiff(repo.toString(), true).isEmpty()) + assertTrue(api.localDiff(repo.toString(), true).isEmpty()) + Files.delete(repo.resolve("untracked.txt")) + init() + val dir = worktree() + val (base, local) = parity(dir) + assertTrue(base.isEmpty()) + assertTrue(local.isEmpty()) + } + + @Test + fun `unexpected Git comparison failures propagate instead of appearing clean`() = runBlocking { + init() + val dir = worktree() + git(repo, "config", "diff.algorithm", "invalid-algorithm") + + assertFailsWith { api.branchDiff(dir.toString(), false) } + assertFailsWith { api.localDiff(dir.toString(), true) } + assertFailsWith { trees.stats(repo.toString()) } + assertFailsWith { trees.dirty(repo.toString()) } + } + + private suspend fun parity(dir: Path): Pair, List> { + val base = api.branchDiff(dir.toString(), true) + val local = api.localDiff(dir.toString(), true) + assertEquals(api.branchDiff(dir.toString(), false), base.map { it.copy(patch = "", before = null, after = null) }) + assertEquals(api.localDiff(dir.toString(), false), local.map { it.copy(patch = "", before = null, after = null) }) + val stats = trees.stats(repo.toString()).items.single { it.path == dir.toString() } + val dirty = trees.dirty(repo.toString()).items.single { it.path == dir.toString() } + assertEquals(base.size, stats.files) + assertEquals(base.sumOf { it.additions }, stats.additions) + assertEquals(base.sumOf { it.deletions }, stats.deletions) + assertEquals(local.size, dirty.files) + assertEquals(local.sumOf { it.additions }, dirty.additions) + assertEquals(local.sumOf { it.deletions }, dirty.deletions) + assertEquals(local.count { it.status == "untracked" }, dirty.untracked) + return base to local + } + + private fun init() { + git(repo, "init", "--initial-branch=main") + git(repo, "config", "user.email", "test@kilo.ai") + git(repo, "config", "user.name", "Kilo Test") + git(repo, "config", "core.autocrlf", "false") + Files.writeString(repo.resolve(".gitignore"), ".kilo/\nignored.txt\n") + Files.writeString(repo.resolve("tracked.txt"), "one\ntwo\nthree\n") + Files.writeString(repo.resolve("deleted.txt"), "remove\n") + Files.writeString(repo.resolve("rename.txt"), "rename contents\n") + Files.writeString(repo.resolve("local.txt"), "local contents\n") + Files.writeString(repo.resolve("net.txt"), "same\n") + commit(repo) + } + + private fun worktree(): Path { + val dir = repo.resolve(".kilo/worktrees/feature") + git(repo, "worktree", "add", "-b", "feature", dir.toString()) + return dir.toRealPath() + } + + private fun commit(dir: Path) { + git(dir, "add", "-A") + git(dir, "commit", "-m", "fixture") + } + + private fun git(dir: Path, vararg args: String): String { + val cmd = GeneralCommandLine(listOf("git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=$hooks") + args) + .withWorkDirectory(dir.toFile()) + .withEnvironment("GIT_CONFIG_NOSYSTEM", "1") + .withEnvironment("GIT_CONFIG_GLOBAL", config.toString()) + .withEnvironment("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z") + .withEnvironment("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z") + val out = CapturingProcessHandler(cmd).runProcess(timeout) + assertFalse(out.isTimeout, "git ${args.joinToString(" ")} timed out: ${out.stderr}") + assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") + return out.stdout + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index a360fe44a347..cdf0fc888fae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -22,9 +22,8 @@ import ai.kilocode.client.agentManager.worktree.normalizeWorktreePath import ai.kilocode.client.agentManager.worktree.worktreeSessionParams import ai.kilocode.client.ui.prTooltip import ai.kilocode.client.ui.style -import ai.kilocode.client.diff.KiloDiffEditorKind -import ai.kilocode.client.diff.diffParams -import ai.kilocode.client.diff.ensureDiffEditorKind +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.telemetry.Telemetry @@ -43,7 +42,6 @@ import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.rpc.dto.RemoveWorktreeResultDto -import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreeDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto @@ -122,7 +120,6 @@ class AgentManagerPanel( ), ) private var stats: Map = emptyMap() - private var dirty: Map = emptyMap() private var prs: Map = emptyMap() init { @@ -274,8 +271,11 @@ class AgentManagerPanel( return controller.progress(item.id) == null } + @RequiresEdt internal fun openDiff(item: WorktreeDto) { - if (canOpenDiff(item)) openBranchDiff(item.path) + val target = project ?: return + if (!canOpenDiff(item)) return + openKiloDiff(target, item.path, KiloDiffComparison.BASE, item.branch.takeUnless { it == "(detached)" }) } private fun showDeletePopup(item: WorktreeDto, cell: String? = null) { @@ -404,8 +404,6 @@ class AgentManagerPanel( stats = null, // The main checkout can sit on a PR branch just like a worktree can. pr = prs[normalizeWorktreePath(item.path)], - // Neither stats() nor dirty() compute the main checkout; both RPCs filter it out. - dirty = null, current = true, ) } @@ -421,7 +419,6 @@ class AgentManagerPanel( controller.kind(item.path), stats[key], pull, - dirty[key], ) }, ActiveListSelection.Preserve, @@ -461,7 +458,6 @@ class AgentManagerPanel( this, onStats = { value -> stats = value; sync() }, onPr = { value -> prs = value; sync() }, - onDirty = { value -> dirty = value; sync() }, ) } @@ -514,19 +510,12 @@ class AgentManagerPanel( } } - /** - * Inner (not data) class so [metrics] can bind each row's changes/dirty handlers to the - * panel. Value equality is over the data fields only — the derived handlers are intentionally - * excluded so a stats/PR/dirty refresh that produced identical rows still skips the model - * rebuild in [ActiveListView]. - */ private inner class WorktreeRow( val dto: WorktreeDto, override val progress: String?, val kind: SessionActivityKind?, val stats: WorktreeStatsDto?, val pr: WorktreePrDto?, - val dirty: WorktreeDirtyDto? = null, val current: Boolean = false, ) : ActiveListItem { override val key: String get() = dto.id @@ -556,21 +545,13 @@ class AgentManagerPanel( override val metrics: ActiveListMetrics? get() { if (progress != null) return null - val s = stats - val d = dirty - if (s == null && d == null) return null + val s = stats?.takeIf { it.files > 0 } ?: return null return ActiveListMetrics( - additions = s?.additions ?: 0, - deletions = s?.deletions ?: 0, - ahead = s?.ahead ?: 0, - behind = s?.behind ?: 0, - onChanges = s?.let { { openBranchDiff(dto.path) } }, - dirtyAdditions = d?.additions ?: 0, - dirtyDeletions = d?.deletions ?: 0, - dirtyFiles = d?.files ?: 0, - // Reuses the branch diff editor for now, which already diffs the working tree - // against the merge-base, so it contains the uncommitted work this badge reports. - onDirty = d?.let { { openBranchDiff(dto.path) } }, + files = s.files, + additions = s.additions, + deletions = s.deletions, + base = s.base, + onChanges = { openDiff(dto) }, ) } @@ -581,7 +562,6 @@ class AgentManagerPanel( kind == row.kind && stats == row.stats && pr == row.pr && - dirty == row.dirty && current == row.current } @@ -591,21 +571,10 @@ class AgentManagerPanel( result = 31 * result + (kind?.hashCode() ?: 0) result = 31 * result + (stats?.hashCode() ?: 0) result = 31 * result + (pr?.hashCode() ?: 0) - result = 31 * result + (dirty?.hashCode() ?: 0) result = 31 * result + current.hashCode() return result } } - - @RequiresEdt - private fun openBranchDiff(path: String) { - val target = project ?: return - ensureDiffEditorKind() - target.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", path, null, KiloBundle.message("diff.editor.branch.title")), - ) - } } internal fun worktreeDeletable(item: WorktreeDto?, busy: Boolean): Boolean = item != null && !item.main && !busy diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt index af87c6f80ca1..c18717ef6fd6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt @@ -2,8 +2,10 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.header.PrHeaderView +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.hoverTextButton +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.ide.ui.ProductIcons @@ -11,17 +13,14 @@ import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.components.BorderLayoutPanel import org.jetbrains.plugins.terminal.TerminalIcons -/** - * Worktree session editor header. A thin wrapper over the shared [PrHeaderView] that adds the - * Terminal and Open-in-window actions into the header's trailing slot. - */ -internal class WorktreePrHeaderView( +internal class WorktreePrHeaderView @RequiresEdt constructor( openWorktree: () -> Unit = {}, openEnabled: Boolean = true, openTerminal: () -> Unit = {}, + onLocal: (() -> Unit)? = null, openDiff: () -> Unit, ) : BorderLayoutPanel() { - private val core = PrHeaderView(openDiff = openDiff) + private val core = PrHeaderView(mode = ChangesPanel.Mode.FULL, openDiff = openDiff, onLocal = onLocal) private val terminal = hoverTextButton( ToolbarButtonAction(TerminalIcons.OpenTerminal_13x13, KiloBundle.message("worktree.session.terminal.action"), openTerminal), tooltip = KiloBundle.message("worktree.session.terminal.tooltip"), @@ -41,7 +40,19 @@ internal class WorktreePrHeaderView( } @RequiresEdt - fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String) { - core.update(stats, pull, name) + fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String, dirty: WorktreeDirtyDto? = null) { + core.update( + files = stats?.files ?: 0, + additions = stats?.additions ?: 0, + deletions = stats?.deletions ?: 0, + pull = pull, + name = name, + ahead = stats?.ahead ?: 0, + behind = stats?.behind ?: 0, + localFiles = dirty?.files ?: 0, + localAdditions = dirty?.additions ?: 0, + localDeletions = dirty?.deletions ?: 0, + base = stats?.base.orEmpty(), + ) } } 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 dcae53acf66b..d68871b38e07 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 @@ -1,9 +1,8 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.diff.KiloDiffEditorKind -import ai.kilocode.client.diff.diffParams -import ai.kilocode.client.diff.ensureDiffEditorKind +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.SessionHost import ai.kilocode.client.session.SessionManager @@ -29,9 +28,9 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.icons.AllIcons @@ -79,7 +78,7 @@ import javax.swing.border.Border import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener -class WorktreeSessionEditorPanel( +class WorktreeSessionEditorPanel @RequiresEdt constructor( parent: Disposable, private val manager: WorktreeSessionEditorManager, private val controller: WorktreeSessionListController, @@ -120,10 +119,17 @@ class WorktreeSessionEditorPanel( (row as? SessionRow)?.session?.takeIf { canRename(it) || canDelete(it) } }), ) - private val prHeader = WorktreePrHeaderView(openWorktree = ::openInNewFrame, openEnabled = worktree.directory.isNotBlank(), openDiff = ::openBranchDiff, openTerminal = ::openTerminal) + private val prHeader = WorktreePrHeaderView( + openWorktree = ::openInNewFrame, + openEnabled = worktree.directory.isNotBlank(), + openDiff = { openDiff(KiloDiffComparison.BASE) }, + onLocal = { openDiff(KiloDiffComparison.LOCAL) }, + openTerminal = ::openTerminal, + ) private val splitter = OnePixelSplitter(false, 0.25f) private var started = false private var stats: WorktreeStatsDto? = null + private var dirty: WorktreeDirtyDto? = null private var pr: WorktreePrDto? = null // Last known persisted visibility, and whether it is known at all: until the stored value arrives // (or the user clicks) the list stays hidden and nothing is written. @@ -424,13 +430,9 @@ class WorktreeSessionEditorPanel( private fun same(path: String?, dir: String): Boolean = FileUtil.pathsEqual(path, dir) @RequiresEdt - private fun openBranchDiff() { + private fun openDiff(comparison: KiloDiffComparison) { val target = project ?: return - ensureDiffEditorKind() - target.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", worktree.directory, null, KiloBundle.message("diff.editor.branch.title")), - ) + openKiloDiff(target, worktree.directory, comparison, parent = this) } /** @@ -517,18 +519,23 @@ class WorktreeSessionEditorPanel( @RequiresEdt private fun selectedKeys(): List = list.selectedKeys().filter { it != SessionHost.NEW && it !in manager.deleting() } + @RequiresEdt private fun bindModel() { val listener = object : ListDataListener { + @RequiresEdt override fun intervalAdded(e: ListDataEvent) = sync() + @RequiresEdt override fun intervalRemoved(e: ListDataEvent) = sync() + @RequiresEdt override fun contentsChanged(e: ListDataEvent) = sync() } controller.model.addListDataListener(listener) Disposer.register(this) { controller.model.removeListDataListener(listener) } } + @RequiresEdt private fun bindStatus() { val key = normalizeWorktreePath(worktree.directory) syncHeader() @@ -544,12 +551,13 @@ class WorktreeSessionEditorPanel( this, onStats = { value -> stats = value[key]; syncHeader() }, onPr = { value -> pr = value[key]; syncHeader() }, + onDirty = { value -> dirty = value[key]; syncHeader() }, ) } @RequiresEdt private fun syncHeader() { - prHeader.update(stats, pr, worktreeName()) + prHeader.update(stats, pr, worktreeName(), dirty) } @RequiresEdt @@ -560,6 +568,7 @@ class WorktreeSessionEditorPanel( ?: key.trimEnd('/').substringAfterLast('/').ifBlank { key } } + @RequiresEdt override fun uiDataSnapshot(sink: DataSink) { sink[WorktreeSessionDataKeys.PANEL] = this selectedSession()?.let { sink[WorktreeSessionDataKeys.SESSION] = it } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt deleted file mode 100644 index 99ebc585382c..000000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ /dev/null @@ -1,193 +0,0 @@ -package ai.kilocode.client.agentManager.worktree - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.ui.DiffStatBadge -import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL -import ai.kilocode.client.ui.list.ACTIVE_LIST_DIRTY_CELL -import ai.kilocode.client.ui.list.ActiveListHitCell -import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.rpc.dto.WorktreeStatsDto -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import java.awt.BorderLayout -import java.awt.Component -import java.awt.Container -import java.awt.Cursor -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Icon -import javax.swing.JPanel - -/** - * The trailing ahead/behind/diff badges of a worktree row. - * - * Uses a real layout manager on purpose: a `null` layout resolves min/preferred size through the - * peer, which reports the component's *current* size. Inside the list this view is a single render - * stamp reused for every row, and [Stack] and [ai.kilocode.client.ui.layout.Align] clamp a child's - * preferred width into its `[min, max]` range - so a peer-reported minimum would carry the previous - * row's width into the next row's layout and drift the badges off their hit regions. - */ -internal class WorktreeStatsView( - openDiff: (() -> Unit)? = null, - fill: Boolean = true, -) : JPanel(BorderLayout()) { - companion object { - private val UP: Icon = IconLoader.getIcon("/icons/arrow-up.svg", WorktreeStatsView::class.java) - private val DOWN: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", WorktreeStatsView::class.java) - } - - private val behind = count(DOWN) - private val ahead = count(UP) - private val diff = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) - // Muted and unfilled so it reads as a different baseline than the committed-diff badge above. - private val dirty = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = false) - private val change = Stack.horizontal(UiStyle.Gap.sm()).next(behind).next(ahead).next(diff) - // The change and dirty badges are hit regions so the list can drive their clicks: inside - // the list the view is a render stamp whose own mouse listeners never fire, so the ActiveList - // reads these ids back and routes the click. Standalone (toolbar) usage keeps its own listeners - // below. - private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) } - private val dirtyHit = HitRegion(ACTIVE_LIST_DIRTY_CELL).apply { add(dirty, BorderLayout.CENTER) } - private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(dirtyHit) - private var state: State? = null - - init { - add(row, BorderLayout.CENTER) - changeHit.act = openDiff - diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0) - installClick(changeHit, object : MouseAdapter() { - override fun mouseClicked(event: MouseEvent) { - changeHit.act?.invoke() - } - }) - installClick(dirtyHit, object : MouseAdapter() { - override fun mouseClicked(event: MouseEvent) { - dirtyHit.act?.invoke() - } - }) - applyCursors() - } - - /** - * Replaces the click handlers, used by the list renderer to bind each row's changes/dirty - * actions to the single reused stamp. Standalone usage leaves the constructor defaults in place. - */ - fun setActions(onChanges: (() -> Unit)?, onDirty: (() -> Unit)? = null) { - changeHit.act = onChanges - dirtyHit.act = onDirty - applyCursors() - } - - fun update( - stats: WorktreeStatsDto?, - dirtyAdd: Int = 0, - dirtyDel: Int = 0, - dirtyFiles: Int = 0, - ) { - sync( - State( - stats, - dirtyAdd, - dirtyDel, - dirtyFiles, - ), - ) - } - - /** - * Applies [next] unless it is already rendered. The memo key must cover everything this method - * writes: inside the list one instance renders every row, so a field left out of the key would - * carry another row's badge, tooltip, or visibility. - */ - private fun sync(next: State) { - if (state == next) return - state = next - val s = next.stats ?: WorktreeStatsDto("") - behind.text = s.behind.toString() - behind.toolTipText = KiloBundle.message("worktree.stats.behind.tooltip") - behind.isVisible = s.behind > 0 - ahead.text = s.ahead.toString() - ahead.toolTipText = KiloBundle.message("worktree.stats.ahead.tooltip") - ahead.isVisible = s.ahead > 0 - diff.update(s.additions, s.deletions) - diff.isVisible = s.additions > 0 || s.deletions > 0 - // One descriptive tooltip for the whole changes region: ahead/behind, the +/- diff, and what - // clicking does. - val changeTip = KiloBundle.message("worktree.stats.tooltip", s.ahead, s.behind, s.additions, s.deletions) - diff.toolTipText = changeTip - changeHit.tip = changeTip - changeHit.toolTipText = changeTip - dirty.update(next.dirtyAdd, next.dirtyDel) - dirty.isVisible = next.dirtyFiles > 0 - val dirtyTip = KiloBundle.message("worktree.dirty.tooltip", next.dirtyFiles, next.dirtyAdd, next.dirtyDel) - dirty.toolTipText = dirtyTip - dirtyHit.tip = dirtyTip - dirtyHit.toolTipText = dirtyTip - dirtyHit.isVisible = dirty.isVisible - val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible - changeHit.isVisible = changesVisible - isVisible = changesVisible || dirtyHit.isVisible - applyCursors() - revalidate() - repaint() - } - - private fun applyCursors() { - applyCursor(changeHit, changeHit.act != null) - applyCursor(dirtyHit, dirtyHit.act != null) - } - - private fun actionCursor(active: Boolean) = - if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() - - private fun installClick(comp: Component, listener: MouseAdapter) { - comp.addMouseListener(listener) - if (comp is Container) comp.components.forEach { installClick(it, listener) } - } - - private fun applyCursor(comp: Component, active: Boolean) { - comp.cursor = actionCursor(active) - if (comp is Container) comp.components.forEach { applyCursor(it, active) } - } - - private fun count(icon: Icon) = JBLabel().apply { - this.icon = icon - iconTextGap = UiStyle.Gap.xs() - font = JBFont.small() - foreground = UiStyle.Colors.weak() - border = JBUI.Borders.empty() - } - - internal fun dirtyBadgeForTest() = dirty - - internal fun dirtyHitForTest() = dirtyHit - - /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ - private data class State( - val stats: WorktreeStatsDto?, - val dirtyAdd: Int, - val dirtyDel: Int, - val dirtyFiles: Int, - ) - - /** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */ - internal class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { - var act: (() -> Unit)? = null - var tip: String? = null - - init { - isOpaque = false - } - - override fun cellEnabled(): Boolean = act != null && isVisible - - override fun cellCursor(): Int = Cursor.HAND_CURSOR - - override fun cellTooltip(): String? = tip - - override fun cellAction(): (() -> Unit)? = act - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 33d628d2a2e3..6c2fe0d07d04 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -15,6 +15,7 @@ import com.intellij.ide.ActivityTracker import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog import com.intellij.platform.project.ProjectId +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException @@ -165,9 +166,15 @@ class KiloWorkspaceService internal constructor( * can surface a retry (a swallowed failure is indistinguishable from "no changes"); pass * [patches] = false on the badge path to fetch stats only and skip materializing patch text. */ + @RequiresBackgroundThread suspend fun branchDiff(directory: String, patches: Boolean = true): List = call { branchDiff(directory, patches) } + @RequiresBackgroundThread + suspend fun localDiff(directory: String, patches: Boolean = true): List = + call { localDiff(directory, patches) } + + @RequiresBackgroundThread suspend fun branchName(directory: String): String? { return try { call { branchName(directory) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt index ff316baa9610..6e088dbac9a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffFileStatus.kt @@ -7,7 +7,7 @@ internal fun fileStatus(dto: DiffFileDto): FileStatus = when (dto.status) { "added" -> FileStatus.ADDED "deleted" -> FileStatus.DELETED "untracked" -> FileStatus.UNKNOWN - "modified" -> FileStatus.MODIFIED + "modified", "renamed" -> FileStatus.MODIFIED else -> when { DiffPatchReconstruct.added(dto.patch) -> FileStatus.ADDED DiffPatchReconstruct.deleted(dto.patch) -> FileStatus.DELETED diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt index e364b2f019f8..3babf59df8b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorContent.kt @@ -91,6 +91,13 @@ internal fun buildDiffEditor( internal val DIFF_FILE_KEY: Key = Key.create("kilo.diff.file") +/** Pane labels for a comparison [source]: committed base/HEAD, uncommitted HEAD/working tree, or a session diff's original/modified. */ +internal fun diffLabels(source: String?): Pair = when (source) { + "branch" -> KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.head") + "local" -> KiloBundle.message("diff.editor.side.head") to KiloBundle.message("diff.editor.side.working") + else -> KiloBundle.message("diff.editor.side.original") to KiloBundle.message("diff.editor.side.modified") +} + internal class DiffEditorView( private val project: Project, private val params: Map, @@ -205,7 +212,7 @@ internal class DiffEditorView( internal fun refresh() { if (disposed.get() || project.isDisposed) return if (!refreshing.compareAndSet(false, true)) return - saveDocuments() + if (params["source"] != "branch" && params["source"] != "local") saveDocuments() outdated.set(false) banner.isVisible = false root.revalidate() @@ -307,12 +314,7 @@ internal class DiffEditorView( } } - private fun labels(): Pair { - if (params["source"] == "branch") { - return KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current") - } - return KiloBundle.message("diff.editor.side.original") to KiloBundle.message("diff.editor.side.modified") - } + private fun labels(): Pair = diffLabels(params["source"]) private fun syncTree() { if (disposed.get()) return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index e257e62d971d..b38ba3d0daa0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -4,10 +4,12 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.vfs.KiloEditorKind import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.client.vfs.KiloVirtualFile import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.DiffFileDto @@ -17,6 +19,7 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel @@ -26,8 +29,8 @@ import com.intellij.util.ui.JBUI import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout @@ -41,9 +44,11 @@ internal object KiloDiffEditorKind : KiloEditorKind { override val id: String = ID override fun title(params: Map): String { - return params["title"].takeIfPresent() + params["title"].takeIfPresent()?.let { return it } + val comparison = KiloDiffComparison.entries.firstOrNull { it.source == params["source"] } + return comparison?.title(params["branch"].takeIfPresent()) ?: params["branch"].takeIfPresent()?.let { KiloBundle.message("diff.editor.branch.title.named", it) } - ?: KiloBundle.message(if (params["source"] == "branch") "diff.editor.branch.title" else "diff.editor.session.title") + ?: KiloBundle.message("diff.editor.session.title") } override fun presentablePath(params: Map): String = title(params) @@ -51,7 +56,7 @@ internal object KiloDiffEditorKind : KiloEditorKind { override fun isValid(params: Map): Boolean { val dir = params["directory"].takeIfPresent() ?: return false if (dir.isBlank()) return false - if (params["source"] == "branch") return true + if (params["source"] == "branch" || params["source"] == "local") return true if (params["source"] == "inline") return params["token"].takeIfPresent() != null return params["sessionId"].takeIfPresent() != null } @@ -101,6 +106,37 @@ internal class KiloDiffEditorService( internal val scope: CoroutineScope get() = cs + @RequiresEdt + fun open(directory: String, comparison: KiloDiffComparison, branch: String?, parent: Disposable) { + if (project.isDisposed || Disposer.isDisposed(parent) || !cs.isActive) return + val dir = FileUtil.toCanonicalPath(FileUtil.toSystemIndependentName(directory), '/', true) + .takeIfPresent() ?: return + val name = branch.takeIfPresent() + if (name != null) { + show(dir, comparison, name) + return + } + val job = cs.launch { + val branch = service().branchName(dir) + withContext(Dispatchers.Main) { + if (!project.isDisposed && !Disposer.isDisposed(parent)) show(dir, comparison, branch) + } + } + val guard = Disposable { job.cancel() } + Disposer.register(parent, guard) + job.invokeOnCompletion { Disposer.dispose(guard) } + } + + @RequiresEdt + private fun show(dir: String, comparison: KiloDiffComparison, branch: String?) { + ensureDiffEditorKind() + val opened = project.service().open( + KiloDiffEditorKind.ID, + diffParams(comparison.source, dir, null, comparison.title(branch), branch), + ) + if (opened) Telemetry.send("Diff Editor Opened", mapOf("source" to comparison.source)) + } + fun load(params: Map, parent: Disposable, done: (DiffEditorData) -> Unit) { val disposed = AtomicBoolean(false) val job = cs.launch { @@ -152,15 +188,14 @@ internal class KiloDiffEditorService( val store = project.service() val session = project.service() val files = when (params["source"]) { - // branch is authoritative here (no store seeding): recompute on every load/refresh so a - // re-open or Refresh always reflects the current worktree instead of a stale click seed. "branch" -> workspace.branchDiff(dir) + "local" -> workspace.localDiff(dir) "inline" -> store.get(params["token"].orEmpty()).orEmpty() else -> session.diff(params["sessionId"].orEmpty(), dir) } if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() - ?: if (params["source"] == "branch") workspace.branchName(dir) else null + ?: if (params["source"] == "branch" || params["source"] == "local") workspace.branchName(dir) else null return DiffEditorData.Files(detail(params, dir, files, session), branch) } @@ -178,6 +213,7 @@ internal class KiloDiffEditorService( // return the whole-session before/after and splice in changes from kept turns. Render the // scoped hunk patches directly instead. if (params["token"].takeIfPresent()?.startsWith("revert:") == true) return files + if (params["source"] == "branch" || params["source"] == "local") return files val sessionId = params["sessionId"].takeIfPresent() val message = message(params) LOG.info("diff editor detail source=${params["source"]} files=${files.size} session=${!sessionId.isNullOrBlank()} message=${!message.isNullOrBlank()}") @@ -231,6 +267,27 @@ fun ensureDiffEditorKind() { service().register(KiloDiffEditorKind) } +enum class KiloDiffComparison(internal val source: String, private val title: String, private val named: String) { + BASE("branch", "diff.editor.branch.title", "diff.editor.branch.title.named"), + LOCAL("local", "diff.editor.local.title", "diff.editor.local.title.named"), + ; + + internal fun title(branch: String?): String = + branch.takeIfPresent()?.let { KiloBundle.message(named, it) } ?: KiloBundle.message(title) +} + +@RequiresEdt +fun openKiloDiff( + project: Project, + directory: String, + comparison: KiloDiffComparison, + branch: String? = null, + parent: Disposable = project, +) { + if (project.isDisposed || Disposer.isDisposed(parent)) return + project.service().open(directory, comparison, branch, parent) +} + private fun connecting(): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply { border = JBUI.Borders.empty(UiStyle.Gap.pad()) next(JBLabel(AnimatedIcon.Default())) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index dee18a05ad98..02f65f39f3d1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -4,7 +4,9 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.diff.KiloDiffComparison import ai.kilocode.client.diff.KiloDiffEditorKind +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.diff.KiloInlineDiffStore import ai.kilocode.client.diff.diffParams import ai.kilocode.client.diff.ensureDiffEditorKind @@ -234,10 +236,7 @@ class SessionUi( } } private var wasBusy = false - // Kept separate so a background stat refresh (turn end / revert) can supersede another refresh - // but never cancel an in-flight user-initiated open. private var refreshJob: Job? = null - private var openJob: Job? = null private var branchJob: Job? = null private var disposed = false @@ -963,43 +962,45 @@ class SessionUi( Telemetry.send("Subagent Session Opened", mapOf("sessionId" to sessionId)) } - /** Badge-only refresh: fetches stats (no patch text) and updates the dock's changes badge. */ + @RequiresEdt private fun refreshBranchChanges() { val dock = dock ?: return + if (disposed || project.isDisposed) return + val local = manager?.supportsMoveToWorktree == true || manager?.supportsNewWorktree == true refreshJob?.cancel() refreshJob = cs.launch { - val files = runCatching { workspaces.branchDiff(workspace.directory, patches = false) } - .getOrElse { - if (it is CancellationException) throw it - LOG.warn("branch changes badge refresh failed dir=${workspace.directory}", it) - return@launch + launch { + val files = runCatching { workspaces.branchDiff(workspace.directory, patches = false) } + .getOrElse { + if (it is CancellationException) throw it + LOG.warn("branch changes badge refresh failed dir=${workspace.directory}", it) + emptyList() + } + withContext(Dispatchers.Main) { + if (disposed || project.isDisposed) return@withContext + dock.setChanges(files) + } + } + if (local) launch { + val files = runCatching { workspaces.localDiff(workspace.directory, patches = false) } + .getOrElse { + if (it is CancellationException) throw it + LOG.warn("local changes refresh failed dir=${workspace.directory}", it) + emptyList() + } + withContext(Dispatchers.Main) { + if (disposed || project.isDisposed) return@withContext + dock.setLocal(files) } - withContext(Dispatchers.Main) { - if (disposed || project.isDisposed) return@withContext - dock.setChanges(files) } } } - /** User clicked the badge: opens the branch diff editor. Never cancelled by a background refresh. */ + @RequiresEdt private fun openBranchChanges() { - val dock = dock ?: return - openJob?.cancel() - openJob = cs.launch { - val dir = workspace.directory - val branch = workspaces.branchName(dir) - val files = runCatching { workspaces.branchDiff(dir, patches = false) } - .getOrElse { - if (it is CancellationException) throw it - LOG.warn("branch changes open failed dir=$dir", it) - emptyList() - } - withContext(Dispatchers.Main) { - if (disposed || project.isDisposed) return@withContext - dock.setChanges(files) - openBranchDiff(branch) - } - } + if (disposed || project.isDisposed || dock == null) return + refreshBranchChanges() + openBranchDiff() } /** Starts the Move to Worktree flow for the current session through the side-panel manager. */ @@ -1009,18 +1010,8 @@ class SessionUi( } @RequiresEdt - private fun openBranchDiff(branch: String?) { - // No store seeding: the diff editor's fetch recomputes branchDiff authoritatively, so a - // re-open or Refresh always reflects the current worktree (and nothing is retained for its life). - ensureDiffEditorKind() - val dir = workspace.directory - val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) } - ?: KiloBundle.message("diff.editor.branch.title") - project.service().open( - KiloDiffEditorKind.ID, - diffParams("branch", dir, null, title, branch), - ) - Telemetry.send("Diff Editor Opened", mapOf("source" to "branch")) + private fun openBranchDiff() { + openKiloDiff(project, workspace.directory, KiloDiffComparison.BASE, parent = this) } private fun showBranchDock(): Boolean = manager?.showsBranchDock != false @@ -1179,7 +1170,6 @@ class SessionUi( override fun dispose() { disposed = true refreshJob?.cancel() - openJob?.cancel() branchJob?.cancel() hide.stop() popup.hideAll() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt deleted file mode 100644 index ff3e300e4d57..000000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt +++ /dev/null @@ -1,145 +0,0 @@ -package ai.kilocode.client.session.ui.header - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.ui.DiffStatBadge -import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.rpc.dto.DiffFileDto -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.RenderingHints -import java.awt.event.ActionEvent -import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.AbstractAction -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.KeyStroke - -internal class BranchChangesBadge( - private val open: () -> Unit, -) : JPanel(null) { - private val count = JBLabel() - private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT) - private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(count).next(stat) - private var files = 0 - private var additions = 0 - private var deletions = 0 - private var over = false - - init { - isOpaque = false - isVisible = false - isFocusable = true - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - toolTipText = KiloBundle.message("diff.editor.branch.tooltip") - getAccessibleContext().accessibleName = KiloBundle.message("diff.editor.branch.tooltip") - border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) - add(row) - addMouseListener(object : MouseAdapter() { - override fun mouseEntered(event: MouseEvent) = hover(true) - override fun mouseExited(event: MouseEvent) = hover(false) - override fun mouseClicked(event: MouseEvent) = activate() - }) - // Keep the action reachable without a mouse (the HoverIcon this replaced was an - // AbstractButton). Enter/Space fire the same guarded action as a click. - val action = object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) = activate() - } - getInputMap(JComponent.WHEN_FOCUSED).apply { - put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE) - put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE) - } - actionMap.put(ACTIVATE, action) - } - - private fun activate() { - if (isEnabled) open() - } - - fun applyStyle(style: SessionEditorStyle) { - count.font = style.smallFont - count.foreground = SessionUiStyle.Text.Secondary.foreground() - } - - override fun getPreferredSize(): Dimension { - val ins = insets - val size = row.preferredSize - return Dimension(size.width + ins.left + ins.right, JBUI.scale(24)) - } - - override fun getMinimumSize(): Dimension = preferredSize - - override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) - - override fun doLayout() { - val ins = insets - val w = maxOf(0, width - ins.left - ins.right) - val h = maxOf(0, height - ins.top - ins.bottom) - val size = row.preferredSize - val rowW = minOf(size.width, w) - val rowH = minOf(size.height, h) - row.setBounds(ins.left, ins.top + (h - rowH) / 2, rowW, rowH) - } - - fun update(next: List): Boolean = - set(next.size, next.sumOf { it.additions }, next.sumOf { it.deletions }) - - /** Update from precomputed aggregates (e.g. worktree branch stats without a per-file list). */ - fun update(files: Int, additions: Int, deletions: Int): Boolean = - set(files, additions, deletions) - - private fun set(n: Int, add: Int, del: Int): Boolean { - if (files == n && additions == add && deletions == del) return false - files = n - additions = add - deletions = del - count.text = KiloBundle.message( - if (n == 1) "session.changes.count.one" else "session.changes.count.other", - n, - ) - stat.update(add, del) - isVisible = n > 0 - revalidate() - repaint() - return true - } - - override fun paintComponent(g: Graphics) { - if (over && isEnabled) paintHover(g) - super.paintComponent(g) - } - - internal fun countText() = count.text - - internal fun stats() = additions to deletions - - private fun hover(value: Boolean) { - if (over == value) return - over = value - repaint() - } - - private fun paintHover(g: Graphics) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = UiStyle.Colors.actionHoverBackground() - val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) - g2.fillRoundRect(0, 0, width, height, arc, arc) - } finally { - g2.dispose() - } - } - - private companion object { - const val ACTIVATE = "kilo.branch.changes.activate" - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt index 22ebc4080ca2..47d22859ced9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack @@ -24,30 +25,14 @@ import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Color import java.awt.Dimension -/** - * Branch/PR dock shown above the prompt in the chat side panel. - * - * Two mutually exclusive layouts, stacked so the row height stays stable: - * - PR present: the shared [PrHeaderView] (state badge + PR title + changes badge). - * - No PR: a horizontally centered row with the New Worktree / Move to Worktree toolbar actions and - * the changes badge. - * - * The two actions are registered platform actions (`Kilo.Chat.NewWorktree`, `Kilo.Chat.MoveToWorktree`) - * rendered through an [com.intellij.openapi.actionSystem.ActionToolbar]; each is invisible when it is - * not enabled. The dock exposes its state to those actions via [ChatDockKeys.DOCK]. Collapses to - * nothing unless it has a PR, changes, messages, or an enabled action. - * - * The action row is offered only while the session is idle: an active turn ([setBusy]) withdraws it. - * The PR row stays through a turn — it is informational, not an action. - */ -internal class BranchDock( +internal class BranchDock @RequiresEdt constructor( openDiff: () -> Unit, private val onMove: (() -> Unit)?, private val onNewWorktree: (() -> Unit)? = null, titleStyle: Int = SimpleTextAttributes.STYLE_PLAIN, ) : BorderLayoutPanel(), SessionEditorStyleTarget, UiDataProvider { - private val core = PrHeaderView(titleStyle = titleStyle, openDiff = openDiff) - private val changes = BranchChangesBadge(openDiff) + private val core = PrHeaderView(titleStyle = titleStyle, mode = ChangesPanel.Mode.COMPACT, openDiff = openDiff) + private val changes = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = openDiff) private val group = DefaultActionGroup().apply { ActionManager.getInstance().getAction("Kilo.Chat.NewWorktree")?.let { add(it) } ActionManager.getInstance().getAction("Kilo.Chat.MoveToWorktree")?.let { add(it) } @@ -58,6 +43,7 @@ internal class BranchDock( .next(changes.align(HAlign.CENTER, VAlign.CENTER)) .align(HAlign.CENTER, VAlign.CENTER) private var files = emptyList() + private var local = 0 private var branch: BranchStatusDto? = null private var hasMessages = false private var hasSession = false @@ -66,15 +52,16 @@ internal class BranchDock( init { isOpaque = true toolbar.targetComponent = this - // Transparent so the toolbar shows the dock's prompt-matching background and tracks LaF. toolbar.component.isOpaque = false addToCenter(Stack.vertical().next(core).next(actionRow).align(HAlign.TRACK, VAlign.CENTER)) isVisible = false sync() } + @RequiresEdt override fun getBackground(): Color = SessionUiStyle.Colors.codeBlockBackground() + @RequiresEdt override fun updateUI() { super.updateUI() border = JBUI.Borders.compound( @@ -83,18 +70,28 @@ internal class BranchDock( ) } + @RequiresEdt override fun uiDataSnapshot(sink: DataSink) { sink[ChatDockKeys.DOCK] = this } @RequiresEdt fun setChanges(files: List) { + if (this.files == files) return this.files = files sync() } + @RequiresEdt + fun setLocal(files: List) { + if (local == files.size) return + local = files.size + sync() + } + @RequiresEdt fun setBranch(branch: BranchStatusDto?) { + if (this.branch == branch) return this.branch = branch sync() } @@ -113,10 +110,6 @@ internal class BranchDock( sync() } - /** - * Whether the chat already has a persisted session. A session-less move transfers only local - * changes; this value affects action wording, not visibility. - */ @RequiresEdt fun setHasSession(value: Boolean) { if (hasSession == value) return @@ -124,48 +117,53 @@ internal class BranchDock( syncToolbar() } - // ---- state read by the toolbar actions ---- - + @RequiresEdt fun newWorktreeEnabled(): Boolean = onNewWorktree != null && dockActive() + @RequiresEdt fun moveEnabled(): Boolean = onMove != null && dockActive() - fun changeCount(): Int = files.size + @RequiresEdt + fun changeCount(): Int = local + @RequiresEdt fun hasSession(): Boolean = hasSession + @RequiresEdt fun triggerNewWorktree() = onNewWorktree?.invoke() ?: Unit + @RequiresEdt fun triggerMove() = onMove?.invoke() ?: Unit - private fun dockActive(): Boolean = gitAvailable() && !busy && (hasMessages || files.isNotEmpty()) + @RequiresEdt + private fun dockActive(): Boolean = gitAvailable() && !busy && (hasMessages || local > 0) private fun gitAvailable(): Boolean { val branch = branch ?: return false return branch.availability != GhAvailability.GIT_MISSING } + @RequiresEdt private fun sync() { val pull = branch?.pr val count = files.size - core.update(count, files.sumOf { it.additions }, files.sumOf { it.deletions }, pull, branch?.branch.orEmpty()) - changes.update(count, files.sumOf { it.additions }, files.sumOf { it.deletions }) - - // PR present -> the informational PR header; otherwise the centered action row. + val additions = files.sumOf { it.additions } + val deletions = files.sumOf { it.deletions } + core.update(count, additions, deletions, pull, branch?.branch.orEmpty()) + changes.update(count, additions, deletions) core.isVisible = pull != null - val rowVisible = pull == null && dockActive() - actionRow.isVisible = rowVisible - - val next = pull != null || rowVisible + val visible = pull == null && gitAvailable() && !busy && + (files.isNotEmpty() || hasMessages || newWorktreeEnabled() || moveEnabled()) + if (actionRow.isVisible != visible) actionRow.isVisible = visible + val next = pull != null || visible if (isVisible != next) isVisible = next syncToolbar() revalidate() repaint() } + @RequiresEdt private fun syncToolbar() { - // Tests need a synchronous refresh to assert action presentations; production nudges the - // platform's action-update pass instead of the deprecated blocking updateActionsImmediately(). if (ApplicationManager.getApplication().isUnitTestMode) { @Suppress("DEPRECATION") toolbar.updateActionsImmediately() @@ -174,19 +172,23 @@ internal class BranchDock( ActivityTracker.getInstance().inc() } + @RequiresEdt override fun getPreferredSize(): Dimension { val base = super.getPreferredSize() - // Reserve a stable row height so the transcript does not jump as stats/PR arrive late. return Dimension(base.width, maxOf(base.height, JBUI.scale(ROW_HEIGHT))) } + @RequiresEdt override fun getMinimumSize(): Dimension = preferredSize + @RequiresEdt override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { core.applyStyle(style) - changes.applyStyle(style) + changes.font = style.smallFont + changes.foreground = SessionUiStyle.Text.Secondary.foreground() } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt index eec87f1dcffe..593f4ee89adf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign @@ -13,7 +15,6 @@ import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.rpc.dto.GhState import ai.kilocode.rpc.dto.WorktreePrDto -import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.ide.BrowserUtil import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -26,27 +27,21 @@ import java.awt.Component import java.awt.Cursor import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import javax.swing.SwingUtilities -/** - * Neutral core of the branch/PR header: a state badge (LEFT), the PR title (CENTER, click opens the - * PR in the browser), and a changes badge plus a host-fillable trailing action slot (RIGHT). - * - * Shared by the Agent Manager worktree editor ([ai.kilocode.client.agentManager.worktree.WorktreePrHeaderView]) - * and the chat [BranchDock]. Retained-mode: [update] mutates existing nodes rather than rebuilding. - * Stays non-opaque so the host owns background and borders. - */ -internal class PrHeaderView( +internal class PrHeaderView @RequiresEdt constructor( private val titleStyle: Int = SimpleTextAttributes.STYLE_BOLD, + mode: ChangesPanel.Mode = ChangesPanel.Mode.COMPACT, + onLocal: (() -> Unit)? = null, openDiff: () -> Unit, ) : BorderLayoutPanel(), SessionEditorStyleTarget { private val status = JBLabel() private val title = SimpleColoredComponent() - private val changes = BranchChangesBadge(openDiff) + private val changes = ChangesPanel(mode, onBase = openDiff, onLocal = onLocal) private val statusPane = status.align(HAlign.LEFT, VAlign.CENTER) private val actions = Stack.horizontal(UiStyle.Gap.sm()) .next(changes.align(HAlign.CENTER, VAlign.CENTER)) private var style = SessionEditorStyle.current() - private var pull: WorktreePrDto? = null private var state: GhState? = null private var number: String? = null private var body: String? = null @@ -55,47 +50,53 @@ internal class PrHeaderView( init { isOpaque = false - actions.isOpaque = false actions.border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) status.border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, UiStyle.Gap.xs()) + status.isVisible = false title.border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) title.isOpaque = false + title.isVisible = false addToLeft(statusPane) addToCenter(title) addToRight(actions.align(HAlign.RIGHT, VAlign.CENTER)) val listener = object : MouseAdapter() { + @RequiresEdt override fun mouseClicked(event: MouseEvent) { - url?.let(BrowserUtil::browse) + if (event.isConsumed || event.isPopupTrigger || !SwingUtilities.isLeftMouseButton(event) || event.clickCount != 1) return + if (isEnabled && event.component.isEnabled) url?.let(BrowserUtil::browse) } } status.addMouseListener(listener) title.addMouseListener(listener) - changes.applyStyle(style) - syncClick(null) + changes.font = style.smallFont + changes.foreground = SessionUiStyle.Text.Secondary.foreground() } - /** Adds a host action into the trailing slot, to the right of the changes badge. */ + @RequiresEdt fun addAction(component: Component) { actions.next(component.align(HAlign.CENTER, VAlign.CENTER)) } - /** Update from worktree branch stats (files/additions/deletions on the stats DTO). */ - @RequiresEdt - fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?, name: String) { - changes.update(stats?.files ?: 0, stats?.additions ?: 0, stats?.deletions ?: 0) - applyPr(pull, name) - } - - /** Update from precomputed change aggregates (chat dock aggregates a DiffFileDto list). */ @RequiresEdt - fun update(files: Int, additions: Int, deletions: Int, pull: WorktreePrDto?, name: String) { - changes.update(files, additions, deletions) + fun update( + files: Int, + additions: Int, + deletions: Int, + pull: WorktreePrDto?, + name: String, + ahead: Int = 0, + behind: Int = 0, + localFiles: Int = 0, + localAdditions: Int = 0, + localDeletions: Int = 0, + base: String = "", + ) { + changes.update(files, additions, deletions, ahead, behind, localFiles, localAdditions, localDeletions, base) applyPr(pull, name) } @RequiresEdt private fun applyPr(pull: WorktreePrDto?, name: String) { - this.pull = pull if (pull == null) { syncPr(false) syncStatus(null) @@ -110,30 +111,26 @@ internal class PrHeaderView( syncStatus(pull.state) syncTitle("#${pull.number}", body, tip) syncClick(pull.url) - status.toolTipText = tip + if (status.toolTipText != tip) status.toolTipText = tip } + @RequiresEdt private fun syncStatus(next: GhState?) { - if (state == next) { - val visible = next != null - if (status.isVisible != visible) status.isVisible = visible - return - } + if (state == next) return state = next status.icon = next?.let { FilledBadgeIcon(stateLabel(it), style(it)) } status.isVisible = next != null changed() } + @RequiresEdt private fun syncPr(value: Boolean) { - if (value) { - title.isVisible = true - return - } - title.isVisible = false + if (title.isVisible == value) return + title.isVisible = value changed() } + @RequiresEdt private fun clearTitle() { if (number == null && tip == null) return number = null @@ -145,36 +142,38 @@ internal class PrHeaderView( changed() } - private fun syncTitle(number: String, body: String?, nextTip: String?) { + @RequiresEdt + private fun syncTitle(number: String, body: String?, next: String?) { var changed = false if (this.number != number || this.body != body) { this.number = number this.body = body - renderTitle() + syncText() changed = true } - if (tip != nextTip) { - tip = nextTip - title.toolTipText = nextTip - if (pull == null) status.toolTipText = null + if (tip != next) { + tip = next + title.toolTipText = next changed = true } if (changed) changed() } - private fun renderTitle() { + @RequiresEdt + private fun syncText() { val number = number ?: return title.clear() val body = body val attrs = SimpleTextAttributes(titleStyle, UIUtil.getLabelForeground()) if (body == null) { title.append(number, attrs) - } else { - title.append(body, attrs) - title.append(" $number", SimpleTextAttributes.GRAYED_ATTRIBUTES) + return } + title.append(body, attrs) + title.append(" $number", SimpleTextAttributes.GRAYED_ATTRIBUTES) } + @RequiresEdt private fun syncClick(next: String?) { if (url == next) return url = next @@ -183,14 +182,16 @@ internal class PrHeaderView( title.cursor = cursor } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style - changes.applyStyle(style) - // Re-render the title so its foreground follows the theme, then repaint. - renderTitle() + changes.font = style.smallFont + changes.foreground = SessionUiStyle.Text.Secondary.foreground() + syncText() changed() } + @RequiresEdt private fun changed() { revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt new file mode 100644 index 000000000000..52d6c5819c4c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt @@ -0,0 +1,336 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.openapi.util.IconLoader +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Font +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.ActionEvent +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.accessibility.AccessibleAction +import javax.accessibility.AccessibleContext +import javax.accessibility.AccessibleRole +import javax.swing.AbstractAction +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JSeparator +import javax.swing.KeyStroke +import javax.swing.SwingConstants +import javax.swing.SwingUtilities + +internal class ChangesPanel @RequiresEdt constructor( + private val mode: Mode, + onBase: (() -> Unit)? = null, + onLocal: (() -> Unit)? = null, +) : JPanel(BorderLayout()) { + internal enum class Mode { COMPACT, FULL } + + private val base = Group(fill = true) + private val local = if (mode == Mode.FULL) Group(fill = false) else null + private val ahead = if (mode == Mode.FULL) counter("/icons/arrow-up.svg", "worktree.stats.ahead.tooltip") else null + private val behind = if (mode == Mode.FULL) counter("/icons/arrow-down-to-line.svg", "worktree.stats.behind.tooltip") else null + private val separator = if (mode == Mode.FULL) JSeparator(SwingConstants.VERTICAL) else null + private var state: State? = null + + init { + isOpaque = false + isFocusable = false + isVisible = false + if (mode == Mode.COMPACT) { + add(base, BorderLayout.CENTER) + addMouseListener(base.listener) + } else { + add(Stack.horizontal(UiStyle.Gap.md()).next(local!!).next(separator!!).next(ahead!!).next(behind!!).next(base)) + } + visit(this) { it.isFocusable = false } + font = JBFont.small() + foreground = JBColor.lazy { UiStyle.Colors.weak() } + setActions(onBase, onLocal) + } + + @RequiresEdt + fun setActions(onBase: (() -> Unit)?, onLocal: (() -> Unit)? = null) { + base.action = onBase + local?.action = onLocal + syncActions() + } + + @RequiresEdt + fun update( + files: Int, + additions: Int, + deletions: Int, + ahead: Int = 0, + behind: Int = 0, + localFiles: Int = 0, + localAdditions: Int = 0, + localDeletions: Int = 0, + base: String = "", + ) { + val next = if (mode == Mode.COMPACT) { + State(files, additions, deletions, base = base) + } else { + State(files, additions, deletions, ahead, behind, localFiles, localAdditions, localDeletions, base) + } + if (state == next) return + state = next + val caption = if (base.isBlank()) KiloBundle.message("changes.base.label") else KiloBundle.message("changes.base.named", base) + val tip = if (base.isBlank()) { + KiloBundle.message("worktree.stats.tooltip", files, additions, deletions) + } else { + KiloBundle.message("worktree.stats.base.tooltip", files, additions, deletions, base) + } + this.base.update(files, additions, deletions, caption.takeIf { mode == Mode.FULL }, tip) + local?.update( + localFiles, localAdditions, localDeletions, KiloBundle.message("changes.local.label"), + KiloBundle.message("worktree.dirty.tooltip", localFiles, localAdditions, localDeletions), + ) + this.ahead?.let { counter(it, ahead) } + this.behind?.let { counter(it, behind) } + val right = files > 0 || next.ahead > 0 || next.behind > 0 + separator?.let { if (it.isVisible != (localFiles > 0 && right)) it.isVisible = localFiles > 0 && right } + val visible = right || next.localFiles > 0 + if (isVisible != visible) isVisible = visible + val tooltip = tip.takeIf { mode == Mode.COMPACT && files > 0 } + if (toolTipText != tooltip) toolTipText = tooltip + syncActions() + revalidate() + repaint() + } + + @RequiresEdt + override fun setEnabled(enabled: Boolean) { + if (isEnabled == enabled) return + super.setEnabled(enabled) + syncActions() + } + + @RequiresEdt + override fun setFont(font: Font?) { + if (getFont() != font) super.setFont(font) + visit(this) { if (it is JBLabel && it.font != font) it.font = font } + } + + @RequiresEdt + override fun setForeground(color: Color?) { + if (foreground != color) super.setForeground(color) + visit(this) { + if (it is JBLabel && SwingUtilities.getAncestorOfClass(DiffStatBadge::class.java, it) == null && it.foreground != color) { + it.foreground = color + } + } + } + + @RequiresEdt + private fun syncActions() { + visit(this) { if (it is Group) it.syncAction() } + val next = if (mode == Mode.COMPACT) base.cursor else Cursor.getDefaultCursor() + if (cursor != next) cursor = next + } + + @RequiresEdt + private fun counter(path: String, key: String) = JBLabel().apply { + icon = IconLoader.getIcon(path, ChangesPanel::class.java) + iconTextGap = UiStyle.Gap.xs() + foreground = JBColor.lazy { UiStyle.Colors.weak() } + toolTipText = KiloBundle.message(key) + isFocusable = false + isVisible = false + } + + @RequiresEdt + private fun counter(label: JBLabel, value: Int) { + val text = value.toString() + if (label.text != text) label.text = text + if (label.isVisible != (value > 0)) label.isVisible = value > 0 + label.accessibleContext.accessibleName = "${label.toolTipText}: $value" + } + + @RequiresEdt + private fun visit(component: Component, block: (Component) -> Unit) { + block(component) + if (component is Container) component.components.forEach { visit(it, block) } + } + + private inner class Group @RequiresEdt constructor(fill: Boolean) : JPanel(BorderLayout()) { + private val caption = JBLabel().apply { foreground = JBColor.lazy { UiStyle.Colors.weak() } } + private val count = JBLabel().apply { foreground = JBColor.lazy { UiStyle.Colors.weak() } } + private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) + private var over = false + var action: (() -> Unit)? = null + val listener = object : MouseAdapter() { + @RequiresEdt + override fun mouseEntered(event: MouseEvent) = hover(interactive()) + + @RequiresEdt + override fun mouseExited(event: MouseEvent) { + hover(interactive() && contains(SwingUtilities.convertPoint(event.component, event.point, this@Group))) + } + + @RequiresEdt + override fun mouseClicked(event: MouseEvent) { + if (event.isConsumed || event.isPopupTrigger || !SwingUtilities.isLeftMouseButton(event) || event.clickCount != 1) return + if (activate(event.component)) event.consume() + } + } + private val activation = object : AbstractAction() { + @RequiresEdt + override fun actionPerformed(event: ActionEvent) { + activate() + } + } + + init { + isOpaque = false + isVisible = false + stat.isVisible = false + caption.isVisible = false + add(Stack.horizontal(UiStyle.Gap.sm()).next(caption).next(count).next(stat).align(HAlign.LEFT, VAlign.CENTER)) + visit(this) { + it.isFocusable = false + it.addMouseListener(listener) + } + getInputMap(WHEN_FOCUSED).apply { + put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE) + put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE) + } + actionMap.put(ACTIVATE, activation) + addFocusListener(object : FocusAdapter() { + @RequiresEdt + override fun focusGained(event: FocusEvent) = repaint() + + @RequiresEdt + override fun focusLost(event: FocusEvent) = repaint() + }) + addPropertyChangeListener("enabled") { syncAction() } + } + + @RequiresEdt + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) + } + + @RequiresEdt + override fun getPreferredSize(): Dimension = super.getPreferredSize().apply { + height = maxOf(height, JBUI.CurrentTheme.Button.minimumSize().height) + } + + @RequiresEdt + fun update(files: Int, additions: Int, deletions: Int, label: String?, tip: String) { + val text = KiloBundle.message(if (files == 1) "session.changes.count.one" else "session.changes.count.other", files) + if (count.text != text) count.text = text + if (count.isVisible != (files > 0)) count.isVisible = files > 0 + if (caption.text != label) caption.text = label + if (caption.isVisible != (label != null && files > 0)) caption.isVisible = label != null && files > 0 + stat.update(additions, deletions) + val lines = files > 0 && (additions > 0 || deletions > 0) + if (stat.isVisible != lines) stat.isVisible = lines + if (isVisible != (files > 0)) isVisible = files > 0 + val tooltip = tip.takeIf { files > 0 } + visit(this) { if (it is JComponent && it.toolTipText != tooltip) it.toolTipText = tooltip } + val name = tooltip?.let { StringUtil.stripHtml(it, " ") } + if (getAccessibleContext().accessibleName != name) getAccessibleContext().accessibleName = name + } + + @RequiresEdt + fun syncAction() { + val active = interactive() + if (isFocusable != active) isFocusable = active + if (activation.isEnabled != active) activation.isEnabled = active + val cursor = if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + visit(this) { if (it.cursor != cursor) it.cursor = cursor } + if (!active) hover(false) + } + + @RequiresEdt + private fun interactive(): Boolean = + action != null && isVisible && isEnabled && this@ChangesPanel.isEnabled && this@ChangesPanel.isVisible + + @RequiresEdt + private fun activate(source: Component = this): Boolean { + if (!interactive() || generateSequence(source) { it.parent }.any { !it.isEnabled || !it.isVisible }) return false + action?.invoke() ?: return false + return true + } + + @RequiresEdt + private fun hover(value: Boolean) { + if (over == value) return + over = value + repaint() + } + + @RequiresEdt + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + if (!interactive() || (!over && !hasFocus())) return + val canvas = g.create() as Graphics2D + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + canvas.color = UiStyle.Colors.actionHoverBackground() + val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) + canvas.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + canvas.dispose() + } + } + + @RequiresEdt + override fun getAccessibleContext(): AccessibleContext { + if (accessibleContext == null) { + accessibleContext = object : AccessibleJPanel(), AccessibleAction { + override fun getAccessibleRole(): AccessibleRole = AccessibleRole.PUSH_BUTTON + override fun getAccessibleAction(): AccessibleAction = this + + @RequiresEdt + override fun getAccessibleActionCount(): Int = if (interactive()) 1 else 0 + + @RequiresEdt + override fun getAccessibleActionDescription(index: Int): String? = if (index == 0) accessibleName else null + + @RequiresEdt + override fun doAccessibleAction(index: Int): Boolean = index == 0 && activate() + } + } + return accessibleContext + } + } + + private data class State( + val files: Int, + val additions: Int, + val deletions: Int, + val ahead: Int = 0, + val behind: Int = 0, + val localFiles: Int = 0, + val localAdditions: Int = 0, + val localDeletions: Int = 0, + val base: String = "", + ) + + private companion object { + const val ACTIVATE = "kilo.changes.activate" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt index a9934801e329..0e098884516c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt @@ -15,7 +15,6 @@ internal const val ACTIVE_LIST_MENU_CELL = "__menu__" /** Well-known ids for rich badges hit-tested in place. */ internal const val ACTIVE_LIST_CHANGES_CELL = "__changes__" -internal const val ACTIVE_LIST_DIRTY_CELL = "__dirty__" internal fun activeListRegions(item: ActiveListItem): Map Unit> { if (item.progress != null) return emptyMap() @@ -25,9 +24,7 @@ internal fun activeListRegions(item: ActiveListItem): Map Unit> { val act = badge.action if (!id.isNullOrBlank() && act != null) out[id] = act } - val data = item.metrics - data?.onChanges?.let { out[ACTIVE_LIST_CHANGES_CELL] = it } - data?.onDirty?.let { out[ACTIVE_LIST_DIRTY_CELL] = it } + item.metrics?.onChanges?.let { out[ACTIVE_LIST_CHANGES_CELL] = it } return out } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 20ca885fd25f..3f977a1f30c1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -28,18 +28,11 @@ internal data class ActiveListBadge( ) internal data class ActiveListMetrics( + val files: Int = 0, val additions: Int = 0, val deletions: Int = 0, - val ahead: Int = 0, - val behind: Int = 0, - /** Click handler for the changes badge, e.g. open the branch diff. Null leaves it inert. */ + val base: String = "", val onChanges: (() -> Unit)? = null, - /** Uncommitted counts, measured against HEAD rather than the base branch. */ - val dirtyAdditions: Int = 0, - val dirtyDeletions: Int = 0, - val dirtyFiles: Int = 0, - /** Click handler for the uncommitted badge. Null leaves it inert. */ - val onDirty: (() -> Unit)? = null, ) internal enum class ActiveListRowHeight { EQUAL, PREFERRED } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index c28752865879..9ff81599c98b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.ui.list -import ai.kilocode.client.agentManager.worktree.WorktreeStatsView import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle @@ -17,10 +17,10 @@ import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel import com.intellij.util.IconUtil +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil -import ai.kilocode.rpc.dto.WorktreeStatsDto import java.awt.AlphaComposite import java.awt.BasicStroke import java.awt.BorderLayout @@ -97,7 +97,7 @@ internal class ActiveListRenderer( // instead of drifting to the far right of the row. private val header = titleGroup.align(HAlign.LEFT, VAlign.CENTER) private val desc = JBLabel() - private val metrics = WorktreeStatsView() + private val metrics = ActiveListChangesCell() private val details = Stack.horizontal(UiStyle.Gap.md()).next(metrics).next(secondary) private val detailsPane = details.align(HAlign.RIGHT, VAlign.CENTER) private val descLine = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { @@ -188,6 +188,7 @@ internal class ActiveListRenderer( add(wrap, BorderLayout.CENTER) } + @RequiresEdt override fun getListCellRendererComponent( list: JList, value: ActiveListItem, @@ -243,13 +244,8 @@ internal class ActiveListRenderer( } desc.foreground = weak val data = if (value.progress != null) null else value.metrics - metrics.update( - data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, - data?.dirtyAdditions ?: 0, - data?.dirtyDeletions ?: 0, - data?.dirtyFiles ?: 0, - ) - metrics.setActions(data?.onChanges, data?.onDirty) + metrics.isEnabled = list.isEnabled && !value.disabled + metrics.update(data) val end = value.progress ?: value.trailing.orEmpty() trail.text = end trail.isVisible = end.isNotBlank() && data == null @@ -406,6 +402,37 @@ internal interface ActiveListActive { fun hoveredIndex(): Int = -1 } +internal class ActiveListChangesCell @RequiresEdt constructor() : JPanel(BorderLayout()), ActiveListHitCell { + private val panel = ChangesPanel(ChangesPanel.Mode.COMPACT) + private var data: ActiveListMetrics? = null + + override val cellId = ACTIVE_LIST_CHANGES_CELL + + init { + UiStyle.Components.transparent(this) + add(panel, BorderLayout.CENTER) + } + + @RequiresEdt + fun update(data: ActiveListMetrics?) { + this.data = data + panel.update(data?.files ?: 0, data?.additions ?: 0, data?.deletions ?: 0, base = data?.base.orEmpty()) + panel.setActions(data?.onChanges.takeIf { isEnabled }) + isVisible = panel.isVisible + toolTipText = panel.toolTipText + } + + @RequiresEdt + override fun cellEnabled(): Boolean = isVisible && isEnabled && data?.onChanges != null + + override fun cellCursor(): Int = Cursor.HAND_CURSOR + + @RequiresEdt + override fun cellTooltip(): String? = toolTipText + + override fun cellAction(): (() -> Unit)? = data?.onChanges +} + internal class ActiveListActionCell : JBLabel(), ActiveListHitCell { private var cell: ActiveListCell? = null 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 ca242c5d7304..8215a508c436 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -172,12 +172,17 @@ diff.editor.inline.title.named={0} ({1}) diff.editor.changedFiles.title=Changed files diff.editor.branch.title=Changes vs base branch diff.editor.branch.title.named=Changes vs base branch ({0}) +diff.editor.local.title=Uncommitted changes +diff.editor.local.title.named=Uncommitted changes ({0}) diff.editor.file.title={0} ({1}) diff.editor.side.base=Base diff.editor.side.current=Current +diff.editor.side.head=HEAD +diff.editor.side.working=Working tree diff.editor.side.original=Original diff.editor.side.modified=Modified diff.editor.branch.tooltip=Compare with base branch +diff.editor.local.tooltip=Compare with the last commit diff.editor.session.tooltip=Open changes in editor diff.editor.empty=No changes diff.editor.patch.unavailable=Diff unavailable @@ -459,8 +464,12 @@ worktree.import.branch.failed.title=Couldn''t import branch "{0}" worktree.stats.diff.tooltip={0} additions, {1} deletions worktree.stats.ahead.tooltip=Commits ahead of base branch worktree.stats.behind.tooltip=Commits behind base branch -worktree.stats.tooltip={0} commits ahead, {1} behind, +{2} -{3}.
Click to open the diff against the base branch. -worktree.dirty.tooltip={0} uncommitted files, +{1} -{2}.
Not included in the pull request until committed. +worktree.stats.tooltip={0} files changed, +{1} -{2}.
Click to open committed changes against the base branch. +worktree.stats.base.tooltip={0} files changed, +{1} -{2} vs {3}. Click to open committed changes. +worktree.dirty.tooltip={0} uncommitted files, +{1} -{2}.
Click to compare with HEAD. +changes.base.label=vs base +changes.base.named=vs {0} +changes.local.label=Uncommitted worktree.pr.state.open=Open worktree.pr.state.draft=Draft worktree.pr.state.merged=Merged diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 8f07e518a129..d6c9f2edc786 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -30,7 +30,6 @@ import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.ActiveListMetrics import ai.kilocode.client.ui.list.ActiveListView import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL -import ai.kilocode.client.ui.list.ACTIVE_LIST_DIRTY_CELL import ai.kilocode.client.ui.list.activeListCellBounds import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloPath @@ -633,7 +632,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { fun `test worktree row shows metrics from status service`() { val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item - rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, behind = 3))) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, behind = 3, files = 2, base = "origin/main"))) val timers = TestUiTimers() ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable) project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) @@ -646,26 +645,27 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val metrics: ActiveListMetrics = row(panel, 0).metrics ?: error("expected metrics") assertEquals(5, metrics.additions) assertEquals(2, metrics.deletions) - assertEquals(1, metrics.ahead) - assertEquals(3, metrics.behind) + assertEquals(2, metrics.files) + assertEquals("origin/main", metrics.base) } - fun `test worktree row shows metrics for stats-only dirty-only and both`() { - val statsOnly = worktree("stats-only") - val dirtyOnly = worktree("dirty-only") + fun `test worktree rows show only base files and ignore local changes and commit counters`() { + val committed = worktree("committed") + val local = worktree("local") val both = worktree("both") - rpc.listed += statsOnly - rpc.listed += dirtyOnly - rpc.listed += both + val renamed = worktree("renamed") + rpc.listed += listOf(committed, local, both, renamed) rpc.statsResult = WorktreeStatsListDto( listOf( - WorktreeStatsDto(statsOnly.path, additions = 5, deletions = 1), - WorktreeStatsDto(both.path, additions = 3, deletions = 0), + WorktreeStatsDto(committed.path, additions = 5, deletions = 1, files = 2), + WorktreeStatsDto(local.path, ahead = 3, behind = 2), + WorktreeStatsDto(both.path, additions = 3, files = 1), + WorktreeStatsDto(renamed.path, files = 1), ), ) rpc.dirtyResult = WorktreeDirtyListDto( listOf( - WorktreeDirtyDto(dirtyOnly.path, additions = 2, files = 1), + WorktreeDirtyDto(local.path, additions = 2, files = 1), WorktreeDirtyDto(both.path, additions = 4, files = 2), ), ) @@ -675,21 +675,30 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } edt { controller.reload() } timers.advanceBy(300) - waitUntil { row(panel, 0).metrics != null && row(panel, 1).metrics != null && row(panel, 2).metrics != null } - - val statsOnlyMetrics = row(panel, 0).metrics ?: error("expected metrics for stats-only row") - assertEquals(5, statsOnlyMetrics.additions) - assertEquals(0, statsOnlyMetrics.dirtyFiles) + flush() - val dirtyOnlyMetrics = row(panel, 1).metrics ?: error("expected metrics for dirty-only row") - assertEquals(0, dirtyOnlyMetrics.additions) - assertEquals(2, dirtyOnlyMetrics.dirtyAdditions) - assertEquals(1, dirtyOnlyMetrics.dirtyFiles) + val first = row(panel, 0).metrics ?: error("expected committed changes") + assertEquals(5, first.additions) + assertEquals(2, first.files) + assertNull(row(panel, 1).metrics) + val mixed = row(panel, 2).metrics ?: error("expected base changes only") + assertEquals(3, mixed.additions) + assertEquals(1, mixed.files) + val move = row(panel, 3).metrics ?: error("expected file-only changes") + assertEquals(1, move.files) + assertEquals(0, move.additions) + assertEquals(0, move.deletions) - val bothMetrics = row(panel, 2).metrics ?: error("expected metrics for both row") - assertEquals(3, bothMetrics.additions) - assertEquals(4, bothMetrics.dirtyAdditions) - assertEquals(2, bothMetrics.dirtyFiles) + edt { + val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! + view.list.setSize(480, 400) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + for (index in listOf(0, 2, 3)) { + assertTrue(activeListCellBounds(view.list, index, selected = false).containsKey(ACTIVE_LIST_CHANGES_CELL)) + } + assertFalse(activeListCellBounds(view.list, 1, selected = false).containsKey(ACTIVE_LIST_CHANGES_CELL)) + } } fun `test open diff opens the branch diff editor`() { @@ -706,6 +715,62 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val file = edt { FileEditorManager.getInstance(project).openFiles.single() as KiloVirtualFile } assertEquals(KiloDiffEditorKind.ID, file.path.kind) assertEquals(item.path, file.path.params["directory"]) + assertEquals("branch", file.path.params["source"]) + assertEquals(item.branch, file.path.params["branch"]) + assertEquals(KiloBundle.message("diff.editor.branch.title.named", item.branch), file.name) + } + + fun `test worktree changes clicks use the correct branch after equal-count rows render`() { + val first = worktree("first").copy(name = "Custom title") + val second = worktree("second") + rpc.listed += listOf(first, second) + rpc.statsResult = WorktreeStatsListDto(listOf( + WorktreeStatsDto(first.path, additions = 5, deletions = 1, files = 2, base = "origin/main"), + WorktreeStatsDto(second.path, additions = 5, deletions = 1, files = 2, base = "origin/main"), + )) + val timers = TestUiTimers() + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + flush() + + edt { + val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! + val list = view.list + list.setSize(480, 320) + list.doLayout() + UIUtil.dispatchAllInvocationEvents() + val areas = (0..1).associateWith { activeListCellBounds(list, it, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) } + activeListCellBounds(list, 0, selected = true) + for (index in listOf(1, 0, 1)) { + val point = center(areas.getValue(index)) + for (id in listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + fire(list, MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + )) + } + val item = if (index == 0) first else second + val file = FileEditorManager.getInstance(project).selectedFiles.single() as KiloVirtualFile + assertEquals(KiloDiffEditorKind.ID, file.path.kind) + assertEquals(item.path, file.path.params["directory"]) + assertEquals("branch", file.path.params["source"]) + assertEquals(item.branch, file.path.params["branch"]) + assertEquals(KiloBundle.message("diff.editor.branch.title.named", item.branch), file.name) + } + val files = FileEditorManager.getInstance(project).openFiles.filterIsInstance() + assertEquals(2, files.size) + assertTrue(files.all { it.path.kind == KiloDiffEditorKind.ID }) + } } fun `test open pr availability reflects pr status`() { @@ -823,7 +888,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val url = "https://example.test/pr/7" rpc.listed += item rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, url, "Feature title"))) - rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1))) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, files = 2))) rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(item.path, additions = 3, files = 1))) val timers = TestUiTimers() project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) @@ -848,7 +913,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val areas = activeListCellBounds(list, 0, selected = false) val badge = areas.getValue("pull-request") val changes = areas.getValue(ACTIVE_LIST_CHANGES_CELL) - val dirty = areas.getValue(ACTIVE_LIST_DIRTY_CELL) + assertEquals(setOf("pull-request", ACTIVE_LIST_CHANGES_CELL), areas.keys) val renderer = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, true) renderer.setSize(list.width, list.getCellBounds(0, 0).height) components(renderer).filterIsInstance().forEach { it.doLayout() } @@ -858,10 +923,8 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val header = SwingUtilities.convertPoint(title, 0, 0, renderer) val bounds = list.getCellBounds(0, 0) assertTrue(origin.x + desc.width + bounds.x <= changes.x) - assertTrue(changes.x + changes.width <= dirty.x) - assertTrue(dirty.x + dirty.width <= badge.x) + assertTrue(changes.x + changes.width <= badge.x) assertTrue(kotlin.math.abs(center(badge).y - center(changes).y) <= 1) - assertTrue(kotlin.math.abs(center(badge).y - center(dirty).y) <= 1) assertTrue(kotlin.math.abs(center(badge).y - (bounds.y + origin.y + desc.height / 2)) <= 1) assertTrue(badge.y >= bounds.y + header.y + title.height) assertTrue(bounds.contains(badge)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt index 099c38b19e22..ec87a22547b9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt @@ -1,18 +1,21 @@ package ai.kilocode.client.agentManager.worktree -import ai.kilocode.client.session.ui.header.BranchChangesBadge +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import java.awt.Container import java.awt.Cursor @@ -69,7 +72,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { fun `test no PR keeps changes badge on the right`() { val view = edt { WorktreePrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } val open = edt { components(view).filterIsInstance().single { it.text == "Open" } } edt { @@ -90,7 +93,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { fun `test changes view visibility follows stats`() { val view = edt { WorktreePrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } edt { view.update(WorktreeStatsDto("/repo"), null, "feature-x") } assertFalse(edt { changes.isVisible }) @@ -104,7 +107,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { val view = edt { WorktreePrHeaderView { opened++ } } edt { view.update(WorktreeStatsDto("/repo", additions = 2, files = 1), null, "feature-x") } - edt { click(UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!!) } + edt { click(components(view).filterIsInstance().single { it.text == "1 file" }) } assertEquals(1, opened) } @@ -117,25 +120,68 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { edt { view.update(stats, pull, "feature-x") } val labels = edt { components(view).filterIsInstance() } val title = edt { title(view) } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } edt { view.update(stats, pull, "feature-x") } assertEquals(labels, edt { components(view).filterIsInstance() }) assertSame(title, edt { title(view) }) - assertSame(changes, edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java) }) + assertSame(changes, edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java) }) } - fun `test changes badge reuses session branch badge with file count`() { + fun `test full changes retain local and base labels with and without a PR`() { val view = edt { WorktreePrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } + val stats = WorktreeStatsDto("/repo", additions = 10, deletions = 4, ahead = 3, behind = 2, files = 5, base = "origin/main") + val dirty = WorktreeDirtyDto("/repo", additions = 6, deletions = 1, files = 1, unpushed = 19) + + listOf(null, pull(GhState.OPEN)).forEach { pull -> + edt { view.update(stats, pull, "feature-x", dirty) } + assertEquals( + listOf("Uncommitted", "1 file", "-1", "+6", "3", "2", "vs origin/main", "5 files", "-4", "+10"), + edt { labels(changes) }, + ) + assertTrue(edt { changes.isVisible }) + assertEquals( + KiloBundle.message("worktree.stats.base.tooltip", 5, 10, 4, "origin/main"), + edt { components(changes).filterIsInstance().single { it.text == "5 files" }.toolTipText }, + ) + } + } - edt { view.update(WorktreeStatsDto("/repo", additions = 10, deletions = 4, ahead = 3, files = 2), null, "feature-x") } + fun `test local and base child clicks route independently`() { + val opened = mutableListOf() + val view = edt { WorktreePrHeaderView(onLocal = { opened += "local" }, openDiff = { opened += "base" }) } + edt { + view.update(WorktreeStatsDto("/repo", files = 2), null, "feature-x", WorktreeDirtyDto("/repo", files = 1)) + click(components(view).filterIsInstance().single { it.text == "1 file" }) + click(components(view).filterIsInstance().single { it.text == "2 files" }) + } + assertEquals(listOf("local", "base"), opened) + } - assertEquals("2 files", edt { changes.countText() }) - assertEquals(10 to 4, edt { changes.stats() }) - assertTrue(edt { changes.isVisible }) - assertEquals("Compare with base branch", edt { changes.toolTipText }) + fun `test clearing either comparison preserves the other and controls`() { + val view = edt { WorktreePrHeaderView {} } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } + val dirty = WorktreeDirtyDto("/repo", files = 1) + edt { view.update(null, null, "feature-x", dirty) } + assertEquals(listOf("Uncommitted", "1 file"), edt { labels(changes) }) + edt { view.update(WorktreeStatsDto("/repo", ahead = 2), null, "feature-x") } + assertEquals(listOf("2"), edt { labels(changes) }) + edt { view.update(null, null, "feature-x") } + assertFalse(edt { changes.isVisible }) + assertEquals(listOf("Terminal", "Open"), edt { components(view).filterIsInstance().filter { it.isVisible }.map { it.text } }) + } + + fun `test open and terminal controls remain disabled for unavailable worktrees`() { + var opened = 0 + val view = edt { WorktreePrHeaderView(openEnabled = false, openWorktree = { opened++ }, openTerminal = { opened++ }, openDiff = {}) } + edt { + val buttons = components(view).filterIsInstance() + assertTrue(buttons.all { !it.isEnabled }) + buttons.forEach(::click) + } + assertEquals(0, opened) } fun `test terminal button triggers callback`() { @@ -156,14 +202,17 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { title = "Implement header", ) + @RequiresEdt private fun badge(view: WorktreePrHeaderView): JBLabel { return components(view).filterIsInstance().single { it.icon is FilledBadgeIcon } } + @RequiresEdt private fun title(view: WorktreePrHeaderView): SimpleColoredComponent { return components(view).filterIsInstance().single() } + @RequiresEdt private fun fragments(title: SimpleColoredComponent): List { val out = mutableListOf() val iter = title.iterator() @@ -176,6 +225,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { private data class Fragment(val text: String, val attrs: SimpleTextAttributes) + @RequiresEdt private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { @@ -186,6 +236,14 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { return out } + @RequiresEdt + private fun labels(root: java.awt.Component): List { + if (!root.isVisible) return emptyList() + if (root is JBLabel) return listOfNotNull(root.text) + return if (root is Container) root.components.flatMap(::labels) else emptyList() + } + + @RequiresEdt private fun click(target: JComponent) { target.setSize(target.preferredSize) val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2) 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 e81f84e401c7..6c8c11832c68 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 @@ -1,7 +1,13 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.util.edtWait +import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.KiloDiffEditorKind +import ai.kilocode.client.diff.KiloDiffEditorService +import ai.kilocode.client.diff.openKiloDiff import ai.kilocode.client.app.Workspace import ai.kilocode.client.migration.FakeMigrationUiController import ai.kilocode.client.session.SessionActivityKind @@ -10,18 +16,43 @@ import ai.kilocode.client.session.SessionRef 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.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.fire import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.client.vfs.KiloVirtualFileSystem import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.activeListSectionTitle import ai.kilocode.client.ui.list.activeListToolWindowBackground +import ai.kilocode.rpc.KiloWorktreeRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.RenameWorktreeResultDto +import ai.kilocode.rpc.dto.WorktreeDirtyDto +import ai.kilocode.rpc.dto.WorktreeDirtyListDto +import ai.kilocode.rpc.dto.WorktreeStatsDto +import ai.kilocode.rpc.dto.WorktreeStatsListDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.replaceService +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.actionSystem.DataKey @@ -94,12 +125,15 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { private fun view( stored: Boolean? = null, load: ((Boolean?) -> Unit) -> Unit = { done -> done(stored) }, + target: Project? = null, + parent: Disposable = testRootDisposable, ): WorktreeSessionEditorPanel = edt { WorktreeSessionEditorPanel( - testRootDisposable, + parent, manager, controller, workspace, + project = target, confirm = { _, _, run -> run() }, load = load, save = { saves += it }, @@ -731,6 +765,143 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertSame(workspace, sink.workspace) } + fun `test project status delivers base and dirty independently and stops after disposal`() { + val stats = Channel(Channel.UNLIMITED) + val dirty = Channel(Channel.UNLIMITED) + val api = object : KiloWorktreeRpcApi by FakeWorktreeRpcApi() { + override suspend fun stats(directory: String): WorktreeStatsListDto = stats.receive() + override suspend fun dirty(directory: String): WorktreeDirtyListDto = dirty.receive() + } + val (status, timers) = edt { status(api) } + val owner = Disposer.newDisposable(testRootDisposable, "status header") + val view = view(target = project, parent = owner) + val summary = edt { components(view).filterIsInstance().single() } + val nodes = edt { components(summary) } + val handle = edt { status.attach() } + try { + timers.advanceBy(300) + stats.trySend(WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 2, additions = 5, base = "origin/main")))).getOrThrow() + await(summary, listOf("vs origin/main", "2 files", "+5")) + dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 1, additions = 7)))).getOrThrow() + await(summary, listOf("Uncommitted", "1 file", "+7", "vs origin/main", "2 files", "+5")) + + edt { status.refreshStats() } + timers.advanceBy(300) + dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 3, deletions = 4)))).getOrThrow() + await(summary, listOf("Uncommitted", "3 files", "-4", "vs origin/main", "2 files", "+5")) + stats.trySend(WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 1, behind = 2, base = "origin/trunk")))).getOrThrow() + await(summary, listOf("Uncommitted", "3 files", "-4", "2", "vs origin/trunk", "1 file")) + + edt { status.refreshStats() } + timers.advanceBy(300) + dirty.trySend(WorktreeDirtyListDto()).getOrThrow() + await(summary, listOf("2", "vs origin/trunk", "1 file")) + stats.trySend(WorktreeStatsListDto()).getOrThrow() + await(summary, emptyList()) + edt { + assertFalse(summary.isVisible) + assertEquals(nodes, components(summary)) + assertFalse(manager.showsBranchDock) + Disposer.dispose(owner) + status.refreshStats() + } + timers.advanceBy(300) + val next = WorktreeStatsDto(DIR, files = 9) + stats.trySend(WorktreeStatsListDto(listOf(next))).getOrThrow() + dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 8)))).getOrThrow() + assertTrue(coroutines.pumpUntil { status.stats.value[DIR] == next && status.dirty.value[DIR]?.files == 8 }) + assertTrue(edt { labels(summary).isEmpty() }) + } finally { + edt { + Disposer.dispose(owner) + handle.close() + } + stats.close() + dirty.close() + } + } + + fun `test worktree header opens distinct reusable comparison tabs with actual branch name`() { + val rpc = FakeWorktreeRpcApi().apply { + statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 2))) + dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 1))) + } + val (_, timers) = edt { status(rpc) } + val workspace = FakeWorkspaceRpcApi().apply { branchName = "feature/topic" } + val app = FakeAppRpcApi().apply { state.value = KiloAppStateDto(KiloAppStatusDto.READY) } + ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(coroutines.scope, app), testRootDisposable) + project.replaceService(KiloSessionService::class.java, sessions, testRootDisposable) + project.replaceService(KiloDiffEditorService::class.java, KiloDiffEditorService(project, coroutines.scope), testRootDisposable) + val view = view(target = project) + val summary = edt { components(view).filterIsInstance().single() } + val editors = FileEditorManager.getInstance(project) + try { + timers.advanceBy(300) + await(summary, listOf("Uncommitted", "1 file", "vs base", "2 files")) + edt { + click(components(summary).filterIsInstance().single { it.text == "1 file" }) + click(components(summary).filterIsInstance().single { it.text == "2 files" }) + } + assertTrue(coroutines.pumpUntil { edt { editors.openFiles.size == 2 } }) + edt { + val files = editors.openFiles.filterIsInstance() + assertEquals(setOf("branch", "local"), files.map { it.path.params["source"] }.toSet()) + files.forEach { file -> + assertEquals(KiloDiffEditorKind.ID, file.path.kind) + assertEquals(DIR, file.path.params["directory"]) + assertEquals("feature/topic", file.path.params["branch"]) + assertFalse(file.path.params.containsKey("sessionId")) + assertFalse(file.path.params.containsKey("token")) + val comparison = KiloDiffComparison.entries.single { it.source == file.path.params["source"] } + assertEquals(comparison.title("feature/topic"), file.name) + openKiloDiff(project, "$DIR/./", comparison, "feature/topic") + assertSame(file, editors.openFiles.filterIsInstance().single { it.path.params["source"] == comparison.source }) + } + assertFalse(manager.showsBranchDock) + } + val gate = CompletableDeferred() + workspace.beforeBranchName = { gate.await() } + edt { + editors.openFiles.forEach { editors.closeFile(it) } + click(components(summary).filterIsInstance().single { it.text == "1 file" }) + } + flush() + edt { Disposer.dispose(view) } + gate.complete(Unit) + flush() + assertTrue(edt { editors.openFiles.isEmpty() }) + } finally { + edt { + editors.openFiles.forEach { editors.closeFile(it) } + service().unregister(KiloDiffEditorKind.ID) + KiloVirtualFileSystem.getInstance().clear() + } + } + } + + @RequiresEdt + private fun status(rpc: KiloWorktreeRpcApi): Pair { + ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, KiloWorktreeService(coroutines.scope, rpc), testRootDisposable) + ApplicationManager.getApplication().replaceService(GhStatusCoordinator::class.java, GhStatusCoordinator(coroutines.scope, TestUiTimers()), testRootDisposable) + val timers = TestUiTimers() + val status = WorktreeStatusService(project, coroutines.scope, timers) + project.replaceService(WorktreeStatusService::class.java, status, testRootDisposable) + return status to timers + } + + private fun await(summary: ChangesPanel, expected: List) { + val arrived = coroutines.pumpUntil { edt { labels(summary) == expected } } + assertTrue("Expected $expected, last header ${edt { labels(summary) }}", arrived) + } + + @RequiresEdt + private fun labels(root: java.awt.Component): List { + if (!root.isVisible) return emptyList() + if (root is JBLabel) return listOfNotNull(root.text) + return if (root is Container) root.components.flatMap(::labels) else emptyList() + } + private fun session(id: String, updated: Double) = SessionDto( id = id, projectID = "proj_test", @@ -755,6 +926,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { private fun edt(block: () -> T): T = edtWait(block) + @RequiresEdt private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { @@ -793,6 +965,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { return components(root).filterIsInstance().firstOrNull { it.presentation.text == text }?.isVisible == true } + @RequiresEdt private fun click(target: javax.swing.JComponent) { target.setSize(target.preferredSize) val point = Point(target.width.coerceAtLeast(2) / 2, target.height.coerceAtLeast(2) / 2) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt deleted file mode 100644 index b1f70d67e8cb..000000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsViewTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package ai.kilocode.client.agentManager.worktree - -import ai.kilocode.client.util.edtWait -import ai.kilocode.rpc.dto.WorktreeStatsDto -import com.intellij.testFramework.fixtures.BasePlatformTestCase - -/** - * Regression coverage for the [WorktreeStatsView] memo key: the dirty badge lives on the same - * render stamp as the committed-diff badge, so a change confined to the dirty counts must still - * bust [WorktreeStatsView]'s `state == next` early return in `sync()`. - */ -class WorktreeStatsViewTest : BasePlatformTestCase() { - fun `test dirty badge updates when only dirty counts change`() { - val view = edt { WorktreeStatsView() } - val stats = WorktreeStatsDto("/repo", additions = 5, deletions = 1) - - edt { view.update(stats, dirtyAdd = 1, dirtyDel = 0, dirtyFiles = 1) } - assertTrue(edt { view.dirtyHitForTest().isVisible }) - assertEquals("+1", edt { view.dirtyBadgeForTest().addedLabelForTest().text }) - - // Same stats DTO, different dirty counts: the State memo key must not skip this update. - edt { view.update(stats, dirtyAdd = 3, dirtyDel = 2, dirtyFiles = 2) } - - assertTrue(edt { view.dirtyHitForTest().isVisible }) - assertEquals("+3", edt { view.dirtyBadgeForTest().addedLabelForTest().text }) - assertEquals("-2", edt { view.dirtyBadgeForTest().removedLabelForTest().text }) - } - - fun `test dirty badge hides when there are no uncommitted changes`() { - val view = edt { WorktreeStatsView() } - val stats = WorktreeStatsDto("/repo", additions = 5, deletions = 1) - - edt { view.update(stats, dirtyAdd = 2, dirtyDel = 0, dirtyFiles = 1) } - assertTrue(edt { view.dirtyHitForTest().isVisible }) - - edt { view.update(stats, dirtyAdd = 0, dirtyDel = 0, dirtyFiles = 0) } - assertFalse(edt { view.dirtyHitForTest().isVisible }) - } - - private fun edt(block: () -> T): T = edtWait(block) -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 6b437061033d..35b5ad9d5a49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.WorkspaceFileDto +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException @@ -110,6 +111,52 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(0, rpc.globalConfigPathCalls) } + fun `test comparison wrappers preserve contents and patches flags`() = runBlocking { + val base = DiffFileDto("src/Base.kt", 1, 1, before = "base", after = "head") + val local = DiffFileDto("src/Local.kt", 2, 1, before = "head", after = "working") + rpc.branchDiffs.add(base) + rpc.localDiffs.add(local) + + withContext(Dispatchers.Default) { + assertEquals(listOf(base), service.branchDiff("/test")) + assertEquals(listOf(base), service.branchDiff("/test", patches = false)) + assertEquals(listOf(local), service.localDiff("/other")) + assertEquals(listOf(local), service.localDiff("/other", patches = false)) + } + + assertEquals(listOf("/test", "/test"), rpc.branchDiffCalls) + assertEquals(listOf("/other", "/other"), rpc.localDiffCalls) + assertEquals(listOf(true, false), rpc.branchDiffPatchCalls) + assertEquals(listOf(true, false), rpc.localDiffPatchCalls) + } + + fun `test comparison failures and cancellation propagate`() = runBlocking { + for (err in listOf(IllegalStateException("unavailable"), CancellationException("cancelled"))) { + rpc.beforeBranchDiff = { throw err } + rpc.beforeLocalDiff = { throw err } + for (local in listOf(false, true)) { + val failure = withContext(Dispatchers.Default) { + runCatching { + if (local) service.localDiff("/test") else service.branchDiff("/test") + }.exceptionOrNull() + } + assertSame(err, failure) + } + } + } + + fun `test branch lookup preserves detached SHA and cancellation`() = runBlocking { + rpc.branchName = "a1b2c3d" + val name = withContext(Dispatchers.Default) { service.branchName("/test") } + assertEquals("a1b2c3d", name) + + val err = CancellationException("cancelled") + rpc.beforeBranchName = { throw err } + val failure = withContext(Dispatchers.Default) { runCatching { service.branchName("/test") }.exceptionOrNull() } + assertSame(err, failure) + assertEquals(listOf("/test", "/test"), rpc.branchNameCalls) + } + fun `test searchFiles sends query to RPC`() = runBlocking { withContext(Dispatchers.Default) { service.searchFiles("/test", "src") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index de63af8b0331..0b3ec7f23a32 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -497,6 +497,105 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { } } + fun `test comparisons validate without sessions and have distinct titles`() { + for (comparison in KiloDiffComparison.entries) { + assertTrue(KiloDiffEditorKind.isValid(mapOf("source" to comparison.source, "directory" to "/repo"))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to comparison.source, "directory" to " "))) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to comparison.source))) + assertEquals(comparison.title(null), KiloDiffEditorKind.title(mapOf("source" to comparison.source))) + assertEquals( + comparison.title("feature/topic"), + KiloDiffEditorKind.title(mapOf("source" to comparison.source, "branch" to "feature/topic")), + ) + } + assertFalse(KiloDiffComparison.BASE.title(null) == KiloDiffComparison.LOCAL.title(null)) + assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "session", "directory" to "/repo"))) + } + + fun `test comparison panes match source and authoritative contents survive refresh`() { + for (source in listOf("branch", "local")) { + val labels = diffLabels(source) + assertEquals( + if (source == "branch") listOf("Base", "HEAD") else listOf("HEAD", "Working tree"), + labels.toList(), + ) + + val first = file("src/App.kt", 1, 1).copy(before = "original revision\n", after = "compared revision\n") + val shown = diffRequest(project, first, labels = labels) as SimpleDiffRequest + assertEquals(labels.toList(), shown.contentTitles) + assertEquals(listOf(first.before, first.after), shown.contents.map(::content)) + + // A pure rename (no textual hunk) must still render its authoritative revision contents. + val renamed = file("src/Renamed.kt", 0, 0, patch = null, status = "renamed").copy(before = "unchanged\n", after = "unchanged\n") + val refreshed = diffRequest(project, renamed, labels = labels) as SimpleDiffRequest + assertEquals(labels.toList(), refreshed.contentTitles) + assertEquals(listOf(renamed.before, renamed.after), refreshed.contents.map(::content)) + } + } + + fun `test refreshing to a zero line rename keeps it visible and selectable in the tree`() { + val parent = Disposer.newDisposable(testRootDisposable) + try { + val editor = editor(listOf(file("src/App.kt", 1, 1)), parent, source = "branch") + val tree = components(editor.component).filterIsInstance().single() + assertEquals("App.kt", text(tree, leaf(tree))) + + val renamed = file("src/Renamed.kt", 0, 0, patch = null, status = "renamed") + editor.applyFiles(listOf(renamed)) + + val refreshedTree = components(editor.component).filterIsInstance().single() + assertEquals("Renamed.kt", text(refreshedTree, leaf(refreshedTree))) + assertSame(leaf(refreshedTree), refreshedTree.lastSelectedPathComponent) + } finally { + Disposer.dispose(parent) + } + } + + fun `test git comparison refresh leaves unsaved editor buffers untouched`() { + val psi = myFixture.addFileToProject("src/App.kt", "saved\n") + val manager = FileDocumentManager.getInstance() + val doc = manager.getDocument(psi.virtualFile)!! + ApplicationManager.getApplication().runWriteAction { doc.setText("unsaved\n") } + for (source in listOf("branch", "local")) { + val parent = Disposer.newDisposable(testRootDisposable) + try { + val editor = editor(files(), parent, dir = psi.virtualFile.parent.parent.path, source = source) + editor.refresh() + assertTrue(manager.isDocumentUnsaved(doc)) + assertEquals("unsaved\n", doc.text) + } finally { + Disposer.dispose(parent) + } + } + } + + fun `test session refresh still saves unsaved editor buffers`() { + val psi = myFixture.addFileToProject("src/App.kt", "saved\n") + val manager = FileDocumentManager.getInstance() + val doc = manager.getDocument(psi.virtualFile)!! + ApplicationManager.getApplication().runWriteAction { doc.setText("unsaved\n") } + val parent = Disposer.newDisposable(testRootDisposable) + try { + val editor = editor(files(), parent, dir = psi.virtualFile.parent.parent.path, source = "session") + editor.refresh() + assertFalse(manager.isDocumentUnsaved(doc)) + } finally { + Disposer.dispose(parent) + } + } + + fun `test disposing comparison views cancels pending refreshes`() { + for (source in listOf("branch", "local")) { + val parent = Disposer.newDisposable(testRootDisposable) + val job = Job() + val editor = editor(files(), parent, source = source) { job } + editor.refresh() + assertTrue(job.isActive) + Disposer.dispose(parent) + assertTrue(job.isCancelled) + } + } + fun `test reverse sync skips active requested path`() { assertNull(reverseSyncTarget("src/App.kt", "src/App.kt", "test/AppTest.kt")) } @@ -551,13 +650,14 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { files: List, parent: Disposable, dir: String = project.basePath.orEmpty(), + source: String = "branch", load: ((DiffEditorData) -> Unit) -> Job = { Job().also { it.complete() } }, ): DiffEditorView { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) Disposer.register(parent) { scope.cancel() } return DiffEditorView( project, - mapOf("directory" to dir, "source" to "branch"), + mapOf("directory" to dir, "source" to source), files, parent, "feature/test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt index dae1b36342a8..92acc2daf841 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloInlineDiffStoreTest.kt @@ -1,11 +1,28 @@ package ai.kilocode.client.diff +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.telemetry.KiloTelemetryService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.components.service import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import java.util.concurrent.CopyOnWriteArrayList import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import com.intellij.util.ui.UIUtil @@ -15,21 +32,40 @@ import kotlinx.coroutines.withContext class KiloInlineDiffStoreTest : BasePlatformTestCase() { private lateinit var coroutines: TestCoroutines private lateinit var workspace: FakeWorkspaceRpcApi + private lateinit var session: FakeSessionRpcApi private lateinit var service: KiloDiffEditorService + private val sides = CopyOnWriteArrayList>() override fun setUp() { super.setUp() coroutines = TestCoroutines() workspace = FakeWorkspaceRpcApi() + session = FakeSessionRpcApi() service = KiloDiffEditorService(project, coroutines.scope) + val rpc = object : KiloSessionRpcApi by session { + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { + sides.add(Triple(sessionId, directory, messageId)) + return session.diffSides(sessionId, directory, file, messageId) + } + } + val app = FakeAppRpcApi().apply { state.value = KiloAppStateDto(KiloAppStatusDto.READY) } project.replaceService(KiloInlineDiffStore::class.java, KiloInlineDiffStore(), testRootDisposable) + project.replaceService(KiloDiffEditorService::class.java, service, testRootDisposable) + project.replaceService(KiloSessionService::class.java, KiloSessionService(project, coroutines.scope, rpc), testRootDisposable) ApplicationManager.getApplication() .replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloAppService::class.java, KiloAppService(coroutines.scope, app), testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloTelemetryService::class.java, KiloTelemetryService(coroutines.scope, app), testRootDisposable) } override fun tearDown() { try { + FileEditorManager.getInstance(project).let { manager -> manager.openFiles.forEach { manager.closeFile(it) } } coroutines.close { UIUtil.dispatchAllInvocationEvents() } + ApplicationManager.getApplication().getService(KiloEditorKindRegistry::class.java).unregister(KiloDiffEditorKind.ID) + KiloVirtualFileSystem.getInstance().clear() } finally { super.tearDown() } @@ -68,5 +104,245 @@ class KiloInlineDiffStoreTest : BasePlatformTestCase() { assertEquals(stale, store.get("branch:/test")) } + fun `test comparisons route independently and bypass drifted enrichment`() = runBlocking { + val patch = "@@ -1 +1 @@\n-before\n+after" + val base = DiffFileDto("src/Committed.kt", 1, 1, patch, before = "base\n", after = "committed\n") + val local = DiffFileDto("src/Local.kt", 1, 1, patch, before = "head\n", after = "working\n") + val rename = DiffFileDto("src/Renamed.kt", 0, 0, before = "same\n", after = "same\n") + workspace.branchDiffs.addAll(listOf(base, rename)) + workspace.localDiffs.addAll(listOf(local, rename)) + session.diffSides[base.file] = base.copy(after = "drifted local edit\n") + session.diffSides[local.file] = local.copy(after = "drifted after request\n") + session.diffSides[rename.file] = rename.copy(after = "deleted locally\n") + + for (comparison in KiloDiffComparison.entries) { + val params = diffParams(comparison.source, "/test", null, "Changes", "feature/topic") + val data = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files + assertEquals(listOf(if (comparison == KiloDiffComparison.BASE) base else local, rename), data.files) + assertEquals("feature/topic", data.branch) + } + + assertEquals(listOf("/test"), workspace.branchDiffCalls) + assertEquals(listOf("/test"), workspace.localDiffCalls) + assertEquals(listOf(true), workspace.branchDiffPatchCalls) + assertEquals(listOf(true), workspace.localDiffPatchCalls) + assertTrue(sides.isEmpty()) + assertTrue(workspace.branchNameCalls.isEmpty()) + } + + fun `test patch-only comparisons do not request snapshot sides`() = runBlocking { + val file = DiffFileDto("src/A.kt", 1, 1, "@@ -1 +1 @@\n-base\n+head") + workspace.branchDiffs.add(file) + workspace.localDiffs.add(file) + session.diffSides[file.file] = file.copy(before = "wrong base", after = "local drift") + for (comparison in KiloDiffComparison.entries) { + val data = withContext(coroutines.dispatcher) { + service.fetch(diffParams(comparison.source, "/test", null, "Changes")) + } as DiffEditorData.Files + assertEquals(listOf(file), data.files) + } + assertTrue(sides.isEmpty()) + } + + fun `test session turn tool and inline still enrich while revert stays scoped`() = runBlocking { + val file = DiffFileDto("src/A.kt", 1, 1, "@@ -1 +1 @@\n-old\n+new") + val full = file.copy(before = "whole before", after = "whole after") + session.diffs["ses_1"] = mutableListOf(file) + session.diffSides[file.file] = full + val store = project.service() + for (token in listOf(null, "turn:ses_1:msg1", "tool:ses_1:msg2", "inline")) { + if (token != null) store.put(token, listOf(file)) + val params = diffParams(if (token == null) "session" else "inline", "/test", "ses_1", "Session", token = token) + val data = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files + assertEquals(listOf(full), data.files) + } + assertEquals(listOf(null, "msg1", "msg2", null), sides.map { it.third }) + assertTrue(sides.all { it.first == "ses_1" && it.second == "/test" }) + + store.put("revert:ses_1:msg1", listOf(file)) + val data = withContext(coroutines.dispatcher) { + service.fetch(diffParams("inline", "/test", "ses_1", "Revert", token = "revert:ses_1:msg1")) + } as DiffEditorData.Files + assertEquals(listOf(file), data.files) + assertEquals(4, sides.size) + assertTrue(workspace.branchDiffCalls.isEmpty()) + assertTrue(workspace.localDiffCalls.isEmpty()) + } + + fun `test known and looked-up branch reuse normalized comparison tabs`() { + workspace.branchName = "feature/topic" + for (comparison in KiloDiffComparison.entries) { + openKiloDiff(project, "/repo/work/../wt//", comparison, "feature/topic") + val file = opened().single { it.path.params["source"] == comparison.source } + val params = diffParams(comparison.source, "/repo/wt", null, comparison.title("feature/topic"), "feature/topic") + assertEquals(params, file.path.params) + assertEquals(comparison.title("feature/topic"), file.name) + assertTrue(workspace.branchNameCalls.isEmpty()) + + openKiloDiff(project, "/repo/./wt/", comparison) + coroutines.drain() + + assertEquals(listOf("/repo/wt"), workspace.branchNameCalls) + assertSame(file, opened().single { it.path.params["source"] == comparison.source }) + assertEquals(params, file.path.params) + workspace.branchNameCalls.clear() + } + assertEquals(2, opened().size) + } + + fun `test detached lookup and known SHA share Windows path identity`() { + workspace.branchName = "a1b2c3d" + openKiloDiff(project, "C:\\repo\\temp\\..\\wt\\", KiloDiffComparison.LOCAL) + coroutines.drain() + val file = opened().single() + assertEquals("C:/repo/wt", file.path.params["directory"]) + assertEquals("a1b2c3d", file.path.params["branch"]) + assertEquals(KiloBundle.message("diff.editor.local.title.named", "a1b2c3d"), file.name) + assertEquals(listOf("C:/repo/wt"), workspace.branchNameCalls) + + openKiloDiff(project, "C:/repo/wt/", KiloDiffComparison.LOCAL, "a1b2c3d") + assertSame(file, opened().single()) + } + + fun `test unavailable branch uses stable generic comparison title`() { + for (comparison in KiloDiffComparison.entries) { + openKiloDiff(project, "/repo", comparison) + coroutines.drain() + val file = opened().single { it.path.params["source"] == comparison.source } + assertNull(file.path.params["branch"]) + assertEquals(comparison.title(null), file.name) + openKiloDiff(project, "/repo/", comparison, " ") + coroutines.drain() + assertSame(file, opened().single { it.path.params["source"] == comparison.source }) + } + } + + fun `test closing and reopening local comparison fetches again without seeds`() { + val manager = FileEditorManager.getInstance(project) + val store = project.service() + val stale = listOf(file("src/Stale.kt", 3, 1)) + store.put("local:/test", stale) + openKiloDiff(project, "/test", KiloDiffComparison.LOCAL, "main") + val first = opened().single() + assertNotNull(manager.getSelectedEditor(first)?.component) + coroutines.drain() + assertEquals(listOf("/test"), workspace.localDiffCalls) + manager.closeFile(first) + + openKiloDiff(project, "/test/", KiloDiffComparison.LOCAL, "main") + assertNotNull(manager.getSelectedEditor(opened().single())?.component) + coroutines.drain() + assertEquals(first.path.params, opened().single().path.params) + assertEquals(listOf("/test", "/test"), workspace.localDiffCalls) + assertTrue(workspace.branchDiffCalls.isEmpty()) + assertEquals(stale, store.get("local:/test")) + } + + fun `test disposing opener parent cancels lookup and never opens a tab`() { + val gate = CompletableDeferred() + val cancelled = CompletableDeferred() + val parent = Disposer.newDisposable(testRootDisposable) + workspace.beforeBranchName = { + try { + gate.await() + } finally { + cancelled.complete(Unit) + } + } + openKiloDiff(project, "/test", KiloDiffComparison.LOCAL, parent = parent) + coroutines.drain() + assertEquals(listOf("/test"), workspace.branchNameCalls) + + Disposer.dispose(parent) + coroutines.drain() + assertTrue(cancelled.isCompleted) + gate.complete(Unit) + coroutines.drain() + assertTrue(opened().isEmpty()) + openKiloDiff(project, "/test", KiloDiffComparison.BASE, "main", parent) + assertTrue(opened().isEmpty()) + } + + fun `test cancelled service scope prevents delayed and synchronous opening`() { + val gate = CompletableDeferred() + workspace.beforeBranchName = { gate.await() } + openKiloDiff(project, "/test", KiloDiffComparison.BASE) + coroutines.drain() + coroutines.scope.cancel() + gate.complete(Unit) + UIUtil.dispatchAllInvocationEvents() + openKiloDiff(project, "/test", KiloDiffComparison.LOCAL, "main") + assertTrue(opened().isEmpty()) + } + + fun `test refresh keeps each comparison and loads fresh contents`() { + for (comparison in KiloDiffComparison.entries) { + val files = if (comparison == KiloDiffComparison.BASE) workspace.branchDiffs else workspace.localDiffs + val params = diffParams(comparison.source, "/test", null, comparison.title("main"), "main") + val results = mutableListOf() + val first = file("src/First.kt", 1, 0) + files.add(first) + service.refresh(params, results::add) + coroutines.drain() + assertEquals(DiffEditorData.Files(listOf(first), "main"), results.last()) + + val next = file("src/Next.kt", 2, 1) + files.clear() + files.add(next) + service.refresh(params, results::add) + coroutines.drain() + assertEquals(DiffEditorData.Files(listOf(next), "main"), results.last()) + + files.clear() + service.refresh(params, results::add) + coroutines.drain() + assertEquals(DiffEditorData.Empty, results.last()) + } + assertEquals(listOf(true, true, true), workspace.branchDiffPatchCalls) + assertEquals(listOf(true, true, true), workspace.localDiffPatchCalls) + } + + fun `test local load and refresh cancel without delivering stale contents`() { + val gate = CompletableDeferred() + workspace.beforeLocalDiff = { gate.await() } + workspace.localDiffs.add(file("src/Old.kt", 1, 0)) + val params = diffParams("local", "/test", null, "Local", "main") + val parent = Disposer.newDisposable(testRootDisposable) + val results = mutableListOf() + service.load(params, parent, results::add) + coroutines.drain() + assertEquals(listOf("/test"), workspace.localDiffCalls) + Disposer.dispose(parent) + val job = service.refresh(params, results::add) + coroutines.drain() + job.cancel() + gate.complete(Unit) + coroutines.drain() + assertEquals(listOf(DiffEditorData.Connecting), results) + + workspace.localDiffs.clear() + val next = file("src/New.kt", 2, 0) + workspace.localDiffs.add(next) + service.load(params, Disposer.newDisposable(testRootDisposable), results::add) + coroutines.drain() + assertEquals(DiffEditorData.Files(listOf(next), "main"), results.last()) + } + + fun `test comparison failures surface editor errors without changing sources`() { + workspace.beforeBranchDiff = { error("base unavailable") } + workspace.beforeLocalDiff = { error("local unavailable") } + for (comparison in KiloDiffComparison.entries) { + val results = mutableListOf() + service.refresh(diffParams(comparison.source, "/test", null, "Changes"), results::add) + coroutines.drain() + assertTrue(results.single() is DiffEditorData.Error) + } + assertEquals(listOf("/test"), workspace.branchDiffCalls) + assertEquals(listOf("/test"), workspace.localDiffCalls) + assertTrue(sides.isEmpty()) + } + + private fun opened() = FileEditorManager.getInstance(project).openFiles.filterIsInstance() + private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto(path, additions, deletions) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index f3589667685f..2add894d1233 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -1,6 +1,21 @@ package ai.kilocode.client.session import ai.kilocode.client.agentManager.worktree.KiloWorktreeService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.diff.KiloDiffComparison +import ai.kilocode.client.diff.KiloDiffEditorService +import ai.kilocode.client.diff.openKiloDiff +import ai.kilocode.client.ui.ChangesPanel +import ai.kilocode.client.telemetry.KiloTelemetryService +import ai.kilocode.client.vfs.KiloVirtualFile +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import java.awt.Component +import java.awt.Container +import java.awt.event.MouseEvent import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta @@ -236,7 +251,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { } ApplicationManager.getApplication() .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, worktree), testRootDisposable) - workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/A.kt", 2, 1)) // A brand-new sidebar session has no id until its first prompt, but the local changes alone // are worth moving: the worktree gets the changes and starts its own session. ui = newUi(manager = owner) @@ -267,13 +282,13 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test dock branch changes refresh on finish and revert`() { workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1)) - val badge = find(ui) + val badge = find(ui) controller().model.setState(SessionState.Busy("running")) controller().model.setState(SessionState.Idle) settle() - assertEquals(2 to 1, badge.stats()) + assertEquals(2 to 1, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/B.kt", 4, 3)) @@ -281,7 +296,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.Idle) settle() - assertEquals(4 to 3, badge.stats()) + assertEquals(4 to 3, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/C.kt", 1, 0)) @@ -289,7 +304,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.TurnEnded(Outcome.INCOMPLETE, "unknown")) settle() - assertEquals(1 to 0, badge.stats()) + assertEquals(1 to 0, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/D.kt", 5, 2)) @@ -297,14 +312,194 @@ class SessionUiLayoutTest : SessionUiTestBase() { controller().model.setState(SessionState.Error("failed")) settle() - assertEquals(5 to 2, badge.stats()) + assertEquals(5 to 2, stats(badge)) workspaceRpc.branchDiffs.clear() workspaceRpc.branchDiffs.add(DiffFileDto("src/E.kt", 1, 0)) controller().model.setRevert(SessionRevertDto("msg1", "part1", diff = "patch")) settle() - assertEquals(1 to 0, badge.stats()) + assertEquals(1 to 0, stats(badge)) + } + + fun `test dock fetches stats only and does not display local changes`() { + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panels = components(dock).filterIsInstance() + + assertTrue(dock.isVisible) + assertTrue(dock.moveEnabled()) + assertTrue(dock.newWorktreeEnabled()) + assertEquals(1, dock.changeCount()) + assertTrue(panels.none { it.isVisible }) + assertTrue(workspaceRpc.branchDiffPatchCalls.all { !it }) + assertEquals(listOf(false), workspaceRpc.localDiffPatchCalls) + + workspaceRpc.branchDiffs.addAll(listOf(DiffFileDto("src/Base.kt", 2, 1), DiffFileDto("src/Rename.kt", 0, 0))) + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + settle() + + assertTrue(panels.all { it.isVisible }) + assertTrue(panels.all { stats(it) == (2 to 1) }) + assertEquals(1, dock.changeCount()) + assertTrue(workspaceRpc.branchDiffPatchCalls.all { !it }) + assertEquals(listOf(false, false), workspaceRpc.localDiffPatchCalls) + } + + fun `test committed-only summary does not enable empty-session worktree actions`() { + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + val dock = dock() + + assertTrue(dock.isVisible) + assertTrue(find(dock).isVisible) + assertEquals(2 to 1, stats(find(dock))) + assertEquals(0, dock.changeCount()) + assertFalse(dock.moveEnabled()) + assertFalse(dock.newWorktreeEnabled()) + } + + fun `test local actions update independently while committed summary is loading`() { + val gate = CompletableDeferred() + workspaceRpc.beforeBranchDiff = { gate.await() } + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panel = find(dock) + + assertFalse(panel.isVisible) + assertTrue(dock.moveEnabled()) + assertEquals(1, dock.changeCount()) + assertTrue(workspaceRpc.branchDiffCalls.isNotEmpty()) + assertEquals(listOf("/test"), workspaceRpc.localDiffCalls) + + gate.complete(Unit) + settle() + + assertEquals(2 to 1, stats(panel)) + assertEquals(1, dock.changeCount()) + assertTrue(dock.moveEnabled()) + } + + fun `test committed summary updates independently while local actions are loading`() { + val gate = CompletableDeferred() + workspaceRpc.beforeLocalDiff = { gate.await() } + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panel = find(dock) + + assertTrue(panel.isVisible) + assertEquals(2 to 1, stats(panel)) + assertFalse(dock.moveEnabled()) + assertEquals(0, dock.changeCount()) + + gate.complete(Unit) + settle() + + assertEquals(2 to 1, stats(panel)) + assertEquals(1, dock.changeCount()) + assertTrue(dock.moveEnabled()) + } + + fun `test refresh failures clear only their comparison state`() { + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + val dock = dock() + val panel = find(dock) + workspaceRpc.beforeBranchDiff = { error("branch unavailable") } + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + settle() + + assertFalse(panel.isVisible) + assertTrue(dock.moveEnabled()) + assertEquals(1, dock.changeCount()) + + workspaceRpc.beforeBranchDiff = null + workspaceRpc.beforeLocalDiff = { error("local unavailable") } + controller().model.setRevert(SessionRevertDto("msg1")) + settle() + + assertTrue(panel.isVisible) + assertEquals(2 to 1, stats(panel)) + assertEquals(0, dock.changeCount()) + assertFalse(dock.moveEnabled()) + assertFalse(dock.newWorktreeEnabled()) + } + + fun `test unavailable worktree actions avoid local comparison requests`() { + settle() + assertTrue(workspaceRpc.localDiffCalls.isEmpty()) + controller().model.setState(SessionState.Busy("running")) + controller().model.setState(SessionState.Idle) + controller().model.setRevert(SessionRevertDto("msg1")) + settle() + + assertTrue(workspaceRpc.localDiffCalls.isEmpty()) + assertTrue(workspaceRpc.branchDiffPatchCalls.all { !it }) + } + + fun `test disposed session cancels independent comparison requests`() { + val gate = CompletableDeferred() + val base = CompletableDeferred() + val local = CompletableDeferred() + workspaceRpc.beforeBranchDiff = { + try { + gate.await() + } finally { + base.complete(Unit) + } + } + workspaceRpc.beforeLocalDiff = { + try { + gate.await() + } finally { + local.complete(Unit) + } + } + val dock = dock() + assertTrue(workspaceRpc.branchDiffCalls.isNotEmpty()) + assertTrue(workspaceRpc.localDiffCalls.isNotEmpty()) + Disposer.dispose(ui) + settle() + assertTrue(base.isCompleted) + assertTrue(local.isCompleted) + gate.complete(Unit) + settle() + assertEquals(0, dock.changeCount()) + assertFalse(find(dock).isVisible) + } + + fun `test session summary uses common normalized base editor identity`() { + workspaceRpc.branchName = "feature/topic" + workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) + val dock = dock() + ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, workspaces, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) + ApplicationManager.getApplication() + .replaceService(KiloTelemetryService::class.java, KiloTelemetryService(scope, appRpc), testRootDisposable) + project.replaceService(KiloSessionService::class.java, sessions, testRootDisposable) + project.replaceService(KiloDiffEditorService::class.java, KiloDiffEditorService(project, scope), testRootDisposable) + val manager = FileEditorManager.getInstance(project) + try { + val panel = components(dock).filterIsInstance().last() + val event = MouseEvent(panel, MouseEvent.MOUSE_CLICKED, 0, 0, 1, 1, 1, false, MouseEvent.BUTTON1) + panel.mouseListeners.forEach { it.mouseClicked(event) } + settle() + val file = manager.openFiles.filterIsInstance().single() + assertEquals("branch", file.path.params["source"]) + assertEquals("/test", file.path.params["directory"]) + assertEquals("feature/topic", file.path.params["branch"]) + assertEquals(KiloDiffComparison.BASE.title("feature/topic"), file.name) + assertFalse(file.path.params.containsKey("sessionId")) + assertFalse(file.path.params.containsKey("token")) + + openKiloDiff(project, "/test/./", KiloDiffComparison.BASE, "feature/topic") + assertSame(file, manager.openFiles.filterIsInstance().single()) + } finally { + manager.openFiles.forEach { manager.closeFile(it) } + } } fun `test prompt file drag leave does not immediately hide drop overlay`() { @@ -991,6 +1186,37 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(root.overlay.width - overlay.width - right, overlay.x) } + // Reassigns [ui] without disposing the previous instance first, matching every other `ui = + // newUi(...)` swap in this file: SessionController.dispose() cancels the shared `scope`, which + // would kill every later coroutine (including the replacement UI's own branch/local refresh) + // launched on that same scope for the rest of the test. + private fun dock(): BranchDock { + val worktree = FakeWorktreeRpcApi().apply { + branchResult = BranchStatusDto(branch = "feature/topic", availability = GhAvailability.OK) + } + ApplicationManager.getApplication() + .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, worktree), testRootDisposable) + ui = newUi(manager = object : SessionManager { + override fun newSession() {} + override fun showHistory(back: (() -> Unit)?) {} + override fun openSession(ref: SessionRef) {} + override val supportsMoveToWorktree: Boolean get() = true + override val supportsNewWorktree: Boolean get() = true + }) + settle() + return find(ui) + } + + private fun stats(panel: ChangesPanel): Pair { + val labels = components(panel).filterIsInstance().filter { it.isVisible }.map { it.text.orEmpty() } + val added = labels.firstOrNull { it.startsWith("+") }?.drop(1)?.toIntOrNull() ?: 0 + val removed = labels.firstOrNull { it.startsWith("-") }?.drop(1)?.toIntOrNull() ?: 0 + return added to removed + } + + private fun components(root: Component): List = + listOf(root) + (root as? Container)?.components?.flatMap(::components).orEmpty() + private fun dropCard(drop: SessionDropOverlay) = drop.components .single() .let { it as javax.swing.JComponent } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt index 517ffc7d1823..e7331d615955 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.actions.ChatMoveToWorktreeAction import ai.kilocode.client.actions.ChatNewWorktreeAction import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.BranchStatusDto @@ -19,6 +20,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.Component import java.awt.Container import java.awt.Point @@ -121,7 +123,7 @@ class BranchDockTest : BasePlatformTestCase() { val dock = dock() edt { dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK)) - dock.setChanges(listOf(DiffFileDto("src/A.kt", 2, 1))) + dock.setLocal(listOf(DiffFileDto("src/A.kt", 2, 1))) dock.setBusy(true) } assertFalse(edt { dock.isVisible }) @@ -184,7 +186,7 @@ class BranchDockTest : BasePlatformTestCase() { val dock = dock() edt { dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK)) - dock.setChanges(listOf(DiffFileDto("src/A.kt", 2, 1))) + dock.setLocal(listOf(DiffFileDto("src/A.kt", 2, 1))) dock.setHasSession(true) } val p = update(ChatMoveToWorktreeAction(), dock) @@ -196,7 +198,7 @@ class BranchDockTest : BasePlatformTestCase() { val dock = dock() edt { dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK)) - dock.setChanges(listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 1, 0))) + dock.setLocal(listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 1, 0))) } // A new sidebar session has no id until its first prompt; the local changes alone are worth // moving, so the action is offered with changes-only wording. @@ -282,6 +284,56 @@ class BranchDockTest : BasePlatformTestCase() { assertEquals(1, fired) } + fun `test local only changes enable both actions without displaying a summary`() { + val dock = dockWithNewWorktree() + edt { + dock.setBranch(BranchStatusDto(branch = "feature-x", availability = GhAvailability.OK)) + dock.setLocal(listOf(DiffFileDto("rename.kt", 0, 0))) + } + assertTrue(edt { dock.isVisible }) + assertTrue(update(ChatMoveToWorktreeAction(), dock).isEnabledAndVisible) + assertTrue(update(ChatNewWorktreeAction(), dock).isEnabledAndVisible) + assertEquals(1, edt { dock.changeCount() }) + assertTrue(edt { components(dock).filterIsInstance().all { !it.isVisible } }) + edt { dock.setLocal(emptyList()) } + assertFalse(edt { dock.isVisible }) + assertFalse(update(ChatMoveToWorktreeAction(), dock).isVisible) + } + + fun `test base changes do not enable transfer actions or inflate local counts`() { + val dock = dockWithNewWorktree() + edt { + dock.setBranch(BranchStatusDto(branch = "feature-x", availability = GhAvailability.OK)) + dock.setChanges(listOf(DiffFileDto("base.kt", 11, 3), DiffFileDto("binary.dat", 0, 0))) + } + assertTrue(edt { dock.isVisible }) + assertFalse(update(ChatMoveToWorktreeAction(), dock).isVisible) + assertFalse(update(ChatNewWorktreeAction(), dock).isVisible) + assertEquals(0, edt { dock.changeCount() }) + edt { dock.setLocal(listOf(DiffFileDto("local.kt", 99, 21))) } + assertEquals(1, edt { dock.changeCount() }) + assertEquals(KiloBundle.message("session.dock.move.tooltip.changes.one"), update(ChatMoveToWorktreeAction(), dock).description) + edt { dock.setChanges(emptyList()) } + assertEquals(1, edt { dock.changeCount() }) + assertTrue(update(ChatMoveToWorktreeAction(), dock).isVisible) + } + + fun `test PR and standalone paths use compact retained summaries`() { + val dock = dock() + edt { + dock.setChanges(listOf(DiffFileDto("base.kt", 5, 2))) + dock.setLocal(listOf(DiffFileDto("local.kt", 99, 21))) + val nodes = components(dock).filterIsInstance() + assertEquals(2, nodes.size) + repeat(100) { n -> + dock.setBranch(prBranch().copy(pr = if (n % 2 == 0) prBranch().pr else null)) + val shown = nodes.single { generateSequence(it as Component) { item -> item.parent }.all { item -> item.isVisible } } + assertEquals(listOf("1 file", "-2", "+5"), components(shown).filterIsInstance().filter { it.isVisible }.map { it.text }) + assertEquals(nodes, components(dock).filterIsInstance()) + } + } + } + private fun dock(): BranchDock = edt { BranchDock(openDiff = {}, onMove = {}) } private fun dockWithNewWorktree(): BranchDock = edt { BranchDock(openDiff = {}, onMove = {}, onNewWorktree = {}) } @@ -293,6 +345,7 @@ class BranchDockTest : BasePlatformTestCase() { pr = WorktreePrDto("/repo", 7, GhState.OPEN, "https://pr/7", "Title"), ) + @RequiresEdt private fun firstAttrs(title: SimpleColoredComponent): SimpleTextAttributes { val iter = title.iterator() check(iter.hasNext()) { "missing title fragment" } @@ -300,11 +353,13 @@ class BranchDockTest : BasePlatformTestCase() { return iter.textAttributes } + @RequiresEdt private fun layout(root: Component) { root.doLayout() if (root is Container) root.components.forEach(::layout) } + @RequiresEdt private fun components(root: Component): List { val out = mutableListOf() fun visit(item: Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt index 8c39ba9bdc7e..3c4307faf2c0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt @@ -1,17 +1,18 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhState import ai.kilocode.rpc.dto.WorktreePrDto -import ai.kilocode.rpc.dto.WorktreeStatsDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container @@ -50,14 +51,13 @@ class PrHeaderViewTest : BasePlatformTestCase() { assertFalse(edt { title(view).isVisible }) } - fun `test changes aggregate from stats overload`() { + fun `test changes default to compact aggregate presentation`() { val view = edt { PrHeaderView {} } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } - edt { view.update(WorktreeStatsDto("/repo", additions = 7, deletions = 4, files = 3), null, "feature-x") } + edt { view.update(3, 7, 4, null, "feature-x", ahead = 2, localFiles = 1, localAdditions = 8) } - assertEquals("3 files", edt { changes.countText() }) - assertEquals(7 to 4, edt { changes.stats() }) + assertEquals(listOf("3 files", "-4", "+7"), edt { components(changes).filterIsInstance().filter { it.isVisible }.map { it.text } }) assertTrue(edt { changes.isVisible }) } @@ -70,20 +70,19 @@ class PrHeaderViewTest : BasePlatformTestCase() { fun `test repeated update keeps child instances and bounded count`() { val view = edt { PrHeaderView {} } - val stats = WorktreeStatsDto("/repo", additions = 2, files = 1) val pull = pull(GhState.DRAFT) - edt { view.update(stats, pull, "feature-x") } + edt { view.update(1, 2, 0, pull, "feature-x") } val labels = edt { components(view).filterIsInstance() } val title = edt { title(view) } - val changes = edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java)!! } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } val count = edt { components(view).size } - repeat(20) { edt { view.update(stats, pull, "feature-x") } } + repeat(20) { edt { view.update(1, 2, 0, pull, "feature-x") } } assertEquals(labels, edt { components(view).filterIsInstance() }) assertSame(title, edt { title(view) }) - assertSame(changes, edt { UIUtil.findComponentOfType(view, BranchChangesBadge::class.java) }) + assertSame(changes, edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java) }) assertEquals(count, edt { components(view).size }) } @@ -106,12 +105,15 @@ class PrHeaderViewTest : BasePlatformTestCase() { title = "Implement header", ) + @RequiresEdt private fun badge(view: PrHeaderView): JBLabel = components(view).filterIsInstance().single { it.icon is FilledBadgeIcon } + @RequiresEdt private fun title(view: PrHeaderView): SimpleColoredComponent = components(view).filterIsInstance().single() + @RequiresEdt private fun fragments(title: SimpleColoredComponent): List { val out = mutableListOf() val iter = title.iterator() @@ -122,6 +124,7 @@ class PrHeaderViewTest : BasePlatformTestCase() { return out } + @RequiresEdt private fun firstAttrs(title: SimpleColoredComponent): SimpleTextAttributes { val iter = title.iterator() check(iter.hasNext()) { "missing title fragment" } @@ -129,6 +132,7 @@ class PrHeaderViewTest : BasePlatformTestCase() { return iter.textAttributes } + @RequiresEdt private fun components(root: Component): List { val out = mutableListOf() fun visit(item: Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index a04118d83d8b..3d4874c76239 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.util.edtWait import ai.kilocode.client.testing.fire import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle @@ -713,7 +714,7 @@ class SettingsListViewTest : BasePlatformTestCase() { val key = DataKey.create("test.activeList.menu.edge") val menu = ActiveListMenu(key, DefaultActionGroup(), element = { it }) val view = ActiveListView("Empty", menu = menu) { _, _ -> } - view.update(listOf(metricsItem("with", "Alpha", ActiveListMetrics(additions = 5, onChanges = {})))) + view.update(listOf(metricsItem("with", "Alpha", ActiveListMetrics(files = 1, additions = 5, onChanges = {})))) layout(view) hover(view, center(view.list.getCellBounds(0, 0))) @@ -1069,13 +1070,13 @@ class SettingsListViewTest : BasePlatformTestCase() { val calls = mutableListOf() val onCellCalls = mutableListOf() val view = ActiveListView("Empty") { key, id -> onCellCalls += "$key:$id" } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(additions = 3, deletions = 2, onChanges = { calls += "changes" })))) + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, additions = 3, deletions = 2, onChanges = { calls += "changes" })))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL) - assertEquals(KiloBundle.message("worktree.stats.tooltip", 0, 0, 3, 2), view.list.getToolTipText(event(view.list, center(area)))) + assertEquals(KiloBundle.message("worktree.stats.tooltip", 1, 3, 2), view.list.getToolTipText(event(view.list, center(area)))) hover(view, center(area)) assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) @@ -1086,6 +1087,107 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test compact file-only changes reuse the shared panel and open from the count or padding`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { _, _ -> calls += "fallback" } + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, onChanges = { calls += "base" })))) + layout(view) + + val area = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + val renderer = rendered(view) + val panel = components(renderer).filterIsInstance().single() + val count = components(panel).filterIsInstance().single { it.text == "1 file" } + val point = SwingUtilities.convertPoint(count, count.width / 2, count.height / 2, renderer) + val bounds = view.list.getCellBounds(0, 0) + point.translate(bounds.x, bounds.y) + assertTrue(panel.isVisible) + assertTrue(area.contains(point)) + for (target in listOf(point, Point(area.x + area.width - 1, center(area).y))) { + click(view, target) + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, target)) + } + + assertEquals(listOf("base", "base"), calls) + assertTrue(opened.isEmpty()) + } + } + + fun `test equal compact summaries keep row actions after renderer reuse and width changes`() { + edt { + val calls = mutableListOf() + val opened = mutableListOf() + val view = ActiveListView("Empty", onOpen = { row, _ -> opened += row.key }) { _, _ -> calls += "fallback" } + view.update(listOf( + metricsItem("first", "Alpha", ActiveListMetrics(files = 2, additions = 5, deletions = 1, base = "origin/main", onChanges = { calls += "first" })), + metricsItem("second", "Beta", ActiveListMetrics(files = 2, additions = 5, deletions = 1, base = "origin/main", onChanges = { calls += "second" })), + metricsItem("wide", "Gamma", ActiveListMetrics(files = 123, additions = 4567, base = "origin/develop", onChanges = { calls += "wide" })), + item("plain", "Delta", null), + )) + layout(view) + val renderer = rendered(view) + val panel = components(renderer).filterIsInstance().single() + val retained = components(panel) + + for (width in listOf(260, 480, 320)) { + calls.clear() + view.list.size = Dimension(width, 320) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + val first = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + val second = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + activeListCellBounds(view.list, 2, selected = true) + assertTrue(activeListCellBounds(view.list, 3, selected = false).isEmpty()) + paint(view.list) + + assertEquals(first, activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL)) + assertEquals(second, activeListCellBounds(view.list, 1, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL)) + assertTrue(view.list.getCellBounds(0, 0).contains(first)) + assertTrue(view.list.getCellBounds(1, 1).contains(second)) + assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 2, 5, 1, "origin/main"), view.list.getToolTipText(event(view.list, center(second)))) + hover(view, center(second)) + assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type) + for (point in listOf(center(second), center(first))) { + click(view, point) + fire(view.list, mouse(view, MouseEvent.MOUSE_CLICKED, point)) + } + assertEquals(listOf("second", "first"), calls) + assertTrue(opened.isEmpty()) + assertSame(panel, components(renderer).filterIsInstance().single()) + assertEquals(retained, components(panel)) + } + } + } + + fun `test compact summary clears hit regions for zero files and disables actions with the row`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> calls += "fallback" } + val row = metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, onChanges = { calls += "base" })) + view.update(listOf(row)) + layout(view) + val area = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL) + + view.update(listOf(object : ActiveListItem by row { + override val disabled = true + })) + layout(view) + assertNull(activeListCellAt(view.list, 0, center(area), selected = false)) + hover(view, center(area)) + assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type) + click(view, center(area)) + assertTrue(calls.isEmpty()) + + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(onChanges = { calls += "base" })))) + layout(view) + assertTrue(activeListCellBounds(view.list, 0, selected = false).isEmpty()) + assertNull(activeListCellAt(view.list, 0, center(area), selected = false)) + click(view, center(area)) + assertTrue(calls.isEmpty()) + } + } + fun `test leading badge is hit tested and invokes its action`() { edt { val calls = mutableListOf() @@ -1145,7 +1247,7 @@ class SettingsListViewTest : BasePlatformTestCase() { fun `test inert changes badge is not hit tested`() { edt { val view = ActiveListView("Empty") { _, _ -> } - view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(additions = 3, deletions = 2)))) + view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(files = 1, additions = 3, deletions = 2)))) view.list.size = Dimension(360, 80) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() @@ -1237,7 +1339,7 @@ class SettingsListViewTest : BasePlatformTestCase() { override val leading = listOf(ActiveListBadge("Local", id = "leading", action = { calls += "leading" })) override val badges = listOf(ActiveListBadge("Ready", id = "trailing", action = { calls += "trailing" })) override val secondaryBadges = listOf(ActiveListBadge("Details", id = "badge", action = { calls += "badge" })) - override val metrics = ActiveListMetrics(additions = 5, deletions = 2, ahead = 1, onChanges = { calls += "changes" }) + override val metrics = ActiveListMetrics(files = 1, additions = 5, deletions = 2, onChanges = { calls += "changes" }) } val view = ActiveListView("Empty") { _, _ -> } view.update(listOf(row)) @@ -1286,7 +1388,7 @@ class SettingsListViewTest : BasePlatformTestCase() { assertTrue(plain.secondaryBadges.isEmpty()) view.update(listOf( object : ActiveListItem by secondaryItem("wide", "Alpha", ActiveListBadge("Available", id = "badge", tooltip = "Wide", action = { calls += "wide" })) { - override val metrics = ActiveListMetrics(additions = 123, deletions = 9, ahead = 2) + override val metrics = ActiveListMetrics(files = 123, additions = 123, deletions = 9) }, secondaryItem("narrow", "Beta", ActiveListBadge("On", id = "badge", tooltip = "Narrow", action = { calls += "narrow" })), plain, @@ -1341,7 +1443,7 @@ class SettingsListViewTest : BasePlatformTestCase() { override val leading = listOf(ActiveListBadge("Local", id = "leading", action = { calls += "leading" })) override val badges = listOf(ActiveListBadge("Ready", id = "trailing", action = { calls += "trailing" })) override val secondaryBadges = listOf(ActiveListBadge("Details", id = "badge", tooltip = "Badge details", action = { calls += "badge" })) - override val metrics = ActiveListMetrics(additions = 5, onChanges = { calls += "changes" }) + override val metrics = ActiveListMetrics(files = 1, additions = 5, onChanges = { calls += "changes" }) } val pending = object : ActiveListItem by row { override val progress = "Working" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index da98aa58b5a4..5a4e83e71790 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -38,7 +38,14 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val branchDiffs = mutableListOf() val branchDiffCalls = CopyOnWriteArrayList() val branchDiffPatchCalls = CopyOnWriteArrayList() + val localDiffs = mutableListOf() + val localDiffCalls = CopyOnWriteArrayList() + val localDiffPatchCalls = CopyOnWriteArrayList() + var beforeBranchDiff: (suspend () -> Unit)? = null + var beforeLocalDiff: (suspend () -> Unit)? = null var branchName: String? = null + val branchNameCalls = CopyOnWriteArrayList() + var beforeBranchName: (suspend () -> Unit)? = null var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" var globalConfigPath = "/config/kilo.jsonc" @@ -103,11 +110,22 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { assertNotEdt("branchDiff") branchDiffCalls.add(directory) branchDiffPatchCalls.add(patches) + beforeBranchDiff?.invoke() return branchDiffs.toList() } + override suspend fun localDiff(directory: String, patches: Boolean): List { + assertNotEdt("localDiff") + localDiffCalls.add(directory) + localDiffPatchCalls.add(patches) + beforeLocalDiff?.invoke() + return localDiffs.toList() + } + override suspend fun branchName(directory: String): String? { assertNotEdt("branchName") + branchNameCalls.add(directory) + beforeBranchName?.invoke() return branchName } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt new file mode 100644 index 000000000000..95fbc8768b2a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt @@ -0,0 +1,341 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.text.StringUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBFont +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.accessibility.AccessibleRole +import javax.swing.JComponent +import javax.swing.JSeparator +import javax.swing.KeyStroke +import javax.swing.RepaintManager +import javax.swing.SwingUtilities + +class ChangesPanelTest : BasePlatformTestCase() { + fun `test compact renders only the shared base file and diff group`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT) + assertFalse(view.isVisible) + + view.update(3, 10, 7, ahead = 4, behind = 5, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") + + assertEquals(listOf("3 files", "-7", "+10"), labels(view)) + assertEquals(1, groups(view).size) + assertEquals(1, components(view).filterIsInstance().size) + assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 3, 10, 7, "origin/main"), view.toolTipText) + assertEquals(view.toolTipText, groups(view).single().toolTipText) + } + + fun `test full orders local separator ahead behind and labelled base`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(3, 10, 7, ahead = 4, behind = 5, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") + layout(view) + + assertEquals(listOf("Uncommitted", "2 files", "-3", "+9", "4", "5", "vs origin/main", "3 files", "-7", "+10"), labels(view)) + val row = view.components.single() as Container + val order = row.components.toList() + assertEquals(5, order.size) + assertSame(groups(view).first(), order[0]) + assertTrue(order[1] is JSeparator) + assertEquals("4", (order[2] as JBLabel).text) + assertEquals("5", (order[3] as JBLabel).text) + assertSame(groups(view).last(), order[4]) + assertTrue(order.zipWithNext().all { (left, right) -> left.x + left.width <= right.x }) + assertNull(view.toolTipText) + } + + fun `test zero line files retain targets without empty stat pills`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) + view.update(1, 0, 0, localFiles = 2) + + assertEquals(listOf("Uncommitted", "2 files", "vs base", "1 file"), labels(view)) + assertTrue(groups(view).all { it.isVisible && it.isFocusable }) + assertTrue(components(view).filterIsInstance().all { !it.isVisible }) + assertTrue(separator(view).isVisible) + } + + fun `test clean and missing data clear targets tooltips and separator`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) + view.update(1, 2, 3, ahead = 4, behind = 5, localFiles = 6, localAdditions = 7, localDeletions = 8) + view.update(0, 0, 0) + + assertFalse(view.isVisible) + assertFalse(separator(view).isVisible) + assertTrue(groups(view).all { !it.isVisible && !it.isFocusable && it.toolTipText == null }) + assertTrue(labels(view).isEmpty()) + + val compact = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + compact.update(1, 2, 0) + compact.update(0, 0, 0, ahead = 8, localFiles = 2) + assertFalse(compact.isVisible) + assertNull(compact.toolTipText) + } + + fun `test ahead behind remain independent from file groups`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(0, 0, 0, ahead = 2) + assertEquals(listOf("2"), labels(view)) + assertFalse(separator(view).isVisible) + + view.update(0, 0, 0, behind = 4, localFiles = 1) + assertEquals(listOf("Uncommitted", "1 file", "4"), labels(view)) + assertTrue(separator(view).isVisible) + + view.update(0, 0, 0, localFiles = 1) + assertEquals(listOf("Uncommitted", "1 file"), labels(view)) + assertFalse(separator(view).isVisible) + + view.update(1, 0, 0) + assertEquals(listOf("vs base", "1 file"), labels(view)) + assertFalse(separator(view).isVisible) + } + + fun `test each side updates without stale counts or base tooltip`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(2, 5, 1, localFiles = 1, localAdditions = 2, base = "origin/main") + val groups = groups(view) + + view.update(2, 5, 1, localFiles = 3, localDeletions = 4, base = "origin/main") + assertEquals(listOf("Uncommitted", "3 files", "-4", "vs origin/main", "2 files", "-1", "+5"), labels(view)) + assertEquals(KiloBundle.message("worktree.dirty.tooltip", 3, 0, 4), groups.first().toolTipText) + + view.update(2, 5, 1, localFiles = 3, localDeletions = 4, base = "origin/trunk") + assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 2, 5, 1, "origin/trunk"), groups.last().toolTipText) + assertTrue(labels(view).contains("vs origin/trunk")) + assertEquals(groups, groups(view)) + } + + fun `test mouse children whitespace keyboard and accessibility activate once`() = edt { + var base = 0 + var local = 0 + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = { base++ }, onLocal = { local++ }) + view.update(2, 3, 1, localFiles = 1, localAdditions = 4) + layout(view) + val groups = groups(view) + val left = groups.first() + val right = groups.last() + val targets = visible(right).filterIsInstance() + targets.forEach { click(it) } + assertEquals(targets.size, base) + assertEquals(0, local) + + click(left) + click(visible(left).filterIsInstance().first()) + key(left, KeyEvent.VK_ENTER) + key(left, KeyEvent.VK_SPACE) + assertEquals(4, local) + assertTrue(right.accessibleContext.accessibleAction.doAccessibleAction(0)) + assertFalse(right.accessibleContext.accessibleAction.doAccessibleAction(1)) + assertEquals(targets.size + 1, base) + assertEquals(StringUtil.stripHtml(right.toolTipText, " "), right.accessibleContext.accessibleName) + assertEquals(AccessibleRole.PUSH_BUTTON, right.accessibleContext.accessibleRole) + + val compact = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = { base++ }) + compact.update(1, 0, 0) + layout(compact) + click(compact) + assertEquals(targets.size + 2, base) + } + + fun `test disabled missing action and nonprimary clicks do not activate`() = edt { + var calls = 0 + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = { calls++ }) + view.update(1, 2, 3) + layout(view) + val group = groups(view).single() + val child = visible(group).filterIsInstance().first() + click(child, button = MouseEvent.BUTTON3) + click(child, count = 2) + click(child, consumed = true) + child.isEnabled = false + click(child) + child.isEnabled = true + group.isEnabled = false + click(child) + key(group, KeyEvent.VK_ENTER) + assertEquals(Cursor.DEFAULT_CURSOR, group.cursor.type) + group.isEnabled = true + view.isEnabled = false + click(child) + key(group, KeyEvent.VK_SPACE) + assertFalse(group.accessibleContext.accessibleAction.doAccessibleAction(0)) + assertFalse(group.isFocusable) + assertEquals(Cursor.DEFAULT_CURSOR, child.cursor.type) + view.isEnabled = true + view.setActions(null) + click(child) + key(group, KeyEvent.VK_ENTER) + assertFalse(group.isFocusable) + assertEquals(0, group.accessibleContext.accessibleAction.accessibleActionCount) + assertEquals(0, calls) + view.setActions({ calls++ }) + click(child) + assertEquals(1, calls) + } + + fun `test equal aggregate updates still use rebound actions`() = edt { + val calls = mutableListOf() + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = { calls += "old base" }, onLocal = { calls += "old local" }) + view.update(1, 0, 0, localFiles = 1) + view.setActions({ calls += "base" }, { calls += "local" }) + view.update(1, 0, 0, localFiles = 1) + layout(view) + groups(view).forEach { click(it) } + assertEquals(listOf("local", "base"), calls) + } + + fun `test child hover paints group and disabled or inert groups do not hover`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + view.update(1, 0, 0) + layout(view) + val group = groups(view).single() + val child = visible(group).filterIsInstance().first() + val plain = pixels(group) + child.dispatchEvent(MouseEvent(child, MouseEvent.MOUSE_ENTERED, 0, 0, 1, 1, 0, false)) + assertFalse(plain.contentEquals(pixels(group))) + group.dispatchEvent(MouseEvent(group, MouseEvent.MOUSE_EXITED, 0, 0, -1, -1, 0, false)) + assertTrue(plain.contentEquals(pixels(group))) + view.setActions(null) + child.dispatchEvent(MouseEvent(child, MouseEvent.MOUSE_ENTERED, 0, 0, 1, 1, 0, false)) + assertTrue(plain.contentEquals(pixels(group))) + } + + fun `test identical data actions and fonts do not repaint or invalidate`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + view.update(1, 2, 3) + val previous = RepaintManager.currentManager(view) + val tracker = Tracker(view) + RepaintManager.setCurrentManager(tracker) + try { + repeat(100) { + view.update(1, 2, 3, localFiles = it, ahead = it) + view.setActions({}) + view.font = view.font + } + assertEquals(0, tracker.invalidations) + assertEquals(0, tracker.paints) + } finally { + RepaintManager.setCurrentManager(previous) + } + } + + fun `test host styling preserves semantic diff colors and retained nodes through UI refresh`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.FULL) + view.update(2, 3, 4, ahead = 1, localFiles = 1, localAdditions = 5) + val nodes = components(view) + val stats = nodes.filterIsInstance().flatMap(::components).filterIsInstance() + val colors = stats.map { it.foreground } + val font = JBFont.small().asBold() + val color = UiStyle.Colors.fg() + view.font = font + view.foreground = color + SwingUtilities.updateComponentTreeUI(view) + assertEquals(nodes, components(view)) + assertTrue(nodes.filterIsInstance().all { it.font == font }) + assertTrue(nodes.filterIsInstance().filter { it !in stats }.all { it.foreground == color }) + assertEquals(colors, stats.map { it.foreground }) + } + + fun `test retained groups fonts listeners and editor resources stay bounded under churn`() = edt { + val baseline = EditorFactory.getInstance().allEditors.size + val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) + view.update(1, 2, 3, localFiles = 1, localAdditions = 2) + val nodes = components(view) + val listeners = nodes.map { it.mouseListeners.size } + val font = JBFont.small().asBold() + view.font = font + repeat(500) { n -> + view.setActions({}, {}) + view.update(n % 5, n % 11, n % 7, n % 3, n % 2, n % 4, n % 13, n % 17, "origin/main") + view.update(0, 0, 0) + } + view.update(1, 2, 3, localFiles = 1, localAdditions = 2) + assertEquals(nodes, components(view)) + assertEquals(listeners, nodes.map { it.mouseListeners.size }) + assertTrue(nodes.filterIsInstance().all { it.font == font }) + assertEquals(baseline, EditorFactory.getInstance().allEditors.size) + } + + @RequiresEdt + private fun components(root: Component): List = + listOf(root) + if (root is Container) root.components.flatMap(::components) else emptyList() + + @RequiresEdt + private fun visible(root: Component): List = + if (!root.isVisible) emptyList() else listOf(root) + if (root is Container) root.components.flatMap(::visible) else emptyList() + + @RequiresEdt + private fun labels(root: Component): List = visible(root).filterIsInstance().mapNotNull { it.text } + + @RequiresEdt + private fun groups(view: ChangesPanel): List = components(view).filterIsInstance() + .filter { it.accessibleContext?.accessibleRole == AccessibleRole.PUSH_BUTTON } + + @RequiresEdt + private fun separator(view: ChangesPanel): JSeparator = components(view).filterIsInstance().single() + + @RequiresEdt + private fun layout(view: ChangesPanel) { + view.setSize(view.preferredSize) + fun lay(component: Component) { + component.doLayout() + if (component is Container) component.components.forEach(::lay) + } + lay(view) + } + + @RequiresEdt + private fun click(target: JComponent, button: Int = MouseEvent.BUTTON1, count: Int = 1, consumed: Boolean = false) { + listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED).forEach { id -> + val event = MouseEvent(target, id, 0, 0, target.width / 2, target.height / 2, count, false, button) + if (consumed) event.consume() + target.dispatchEvent(event) + } + } + + @RequiresEdt + private fun key(target: JComponent, key: Int) { + target.getActionForKeyStroke(KeyStroke.getKeyStroke(key, 0))!!.actionPerformed(ActionEvent(target, ActionEvent.ACTION_PERFORMED, "")) + } + + @RequiresEdt + private fun pixels(target: JComponent): IntArray { + val image = BufferedImage(target.width, target.height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + target.paint(graphics) + } finally { + graphics.dispose() + } + return image.getRGB(0, 0, image.width, image.height, null, 0, image.width) + } + + private class Tracker(private val root: JComponent) : RepaintManager() { + var invalidations = 0 + var paints = 0 + + @RequiresEdt + override fun addInvalidComponent(component: JComponent) { + if (SwingUtilities.isDescendingFrom(component, root)) invalidations++ + super.addInvalidComponent(component) + } + + @RequiresEdt + override fun addDirtyRegion(component: JComponent, x: Int, y: Int, width: Int, height: Int) { + if (SwingUtilities.isDescendingFrom(component, root)) paints++ + super.addDirtyRegion(component, x, y, width, height) + } + } + + private fun edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index bebd2c386900..4a34ad0b7e44 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -55,14 +55,10 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ suspend fun gitChanges(directory: String): String? - /** - * Committed branch changes compared with the default branch merge-base. - * - * [patches] = false returns file stats only (additions/deletions/status) and skips materializing - * the full patch text — used by the header badge, which only needs counts. - */ suspend fun branchDiff(directory: String, patches: Boolean = true): List + suspend fun localDiff(directory: String, patches: Boolean = true): List + /** Current git branch name for branch-scoped UI labels. */ suspend fun branchName(directory: String): String? From cc73d6f7c7da0c9810ff591a975daf46b8e855fb Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 17:30:23 -0400 Subject: [PATCH 05/19] fix(jetbrains): drop text captions from full worktree changes panel Remove the "Uncommitted" and "vs " text labels from the worktree session editor header's full ChangesPanel groups, leaving only the file count and +/- badges. The distinguishing context is still available via each group's tooltip, so the header stays compact without redundant text. --- .../kotlin/ai/kilocode/client/ui/ChangesPanel.kt | 13 ++++--------- .../main/resources/messages/KiloBundle.properties | 3 --- .../worktree/WorktreePrHeaderViewTest.kt | 6 +++--- .../worktree/WorktreeSessionEditorPanelTest.kt | 12 ++++++------ .../ai/kilocode/client/ui/ChangesPanelTest.kt | 15 +++++++-------- 5 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt index 52d6c5819c4c..4afd9bf1fac0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt @@ -95,15 +95,14 @@ internal class ChangesPanel @RequiresEdt constructor( } if (state == next) return state = next - val caption = if (base.isBlank()) KiloBundle.message("changes.base.label") else KiloBundle.message("changes.base.named", base) val tip = if (base.isBlank()) { KiloBundle.message("worktree.stats.tooltip", files, additions, deletions) } else { KiloBundle.message("worktree.stats.base.tooltip", files, additions, deletions, base) } - this.base.update(files, additions, deletions, caption.takeIf { mode == Mode.FULL }, tip) + this.base.update(files, additions, deletions, tip) local?.update( - localFiles, localAdditions, localDeletions, KiloBundle.message("changes.local.label"), + localFiles, localAdditions, localDeletions, KiloBundle.message("worktree.dirty.tooltip", localFiles, localAdditions, localDeletions), ) this.ahead?.let { counter(it, ahead) } @@ -174,7 +173,6 @@ internal class ChangesPanel @RequiresEdt constructor( } private inner class Group @RequiresEdt constructor(fill: Boolean) : JPanel(BorderLayout()) { - private val caption = JBLabel().apply { foreground = JBColor.lazy { UiStyle.Colors.weak() } } private val count = JBLabel().apply { foreground = JBColor.lazy { UiStyle.Colors.weak() } } private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill) private var over = false @@ -205,8 +203,7 @@ internal class ChangesPanel @RequiresEdt constructor( isOpaque = false isVisible = false stat.isVisible = false - caption.isVisible = false - add(Stack.horizontal(UiStyle.Gap.sm()).next(caption).next(count).next(stat).align(HAlign.LEFT, VAlign.CENTER)) + add(Stack.horizontal(UiStyle.Gap.sm()).next(count).next(stat).align(HAlign.LEFT, VAlign.CENTER)) visit(this) { it.isFocusable = false it.addMouseListener(listener) @@ -238,12 +235,10 @@ internal class ChangesPanel @RequiresEdt constructor( } @RequiresEdt - fun update(files: Int, additions: Int, deletions: Int, label: String?, tip: String) { + fun update(files: Int, additions: Int, deletions: Int, tip: String) { val text = KiloBundle.message(if (files == 1) "session.changes.count.one" else "session.changes.count.other", files) if (count.text != text) count.text = text if (count.isVisible != (files > 0)) count.isVisible = files > 0 - if (caption.text != label) caption.text = label - if (caption.isVisible != (label != null && files > 0)) caption.isVisible = label != null && files > 0 stat.update(additions, deletions) val lines = files > 0 && (additions > 0 || deletions > 0) if (stat.isVisible != lines) stat.isVisible = lines 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 68424e6e422c..e436a4a26236 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -486,9 +486,6 @@ worktree.stats.behind.tooltip=Commits behind base branch worktree.stats.tooltip={0} files changed, +{1} -{2}.
Click to open committed changes against the base branch. worktree.stats.base.tooltip={0} files changed, +{1} -{2} vs {3}. Click to open committed changes. worktree.dirty.tooltip={0} uncommitted files, +{1} -{2}.
Click to compare with HEAD. -changes.base.label=vs base -changes.base.named=vs {0} -changes.local.label=Uncommitted worktree.pr.state.open=Open worktree.pr.state.draft=Draft worktree.pr.state.merged=Merged diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt index 285ab307a330..e94dc2f363f7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt @@ -134,7 +134,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { assertSame(changes, edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java) }) } - fun `test full changes retain local and base labels with and without a PR`() { + fun `test full changes retain local and base counts with and without a PR`() { val view = edt { WorktreePrHeaderView {} } val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } val stats = WorktreeStatsDto("/repo", additions = 10, deletions = 4, ahead = 3, behind = 2, files = 5, base = "origin/main") @@ -143,7 +143,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { listOf(null, pull(GhState.OPEN)).forEach { pull -> edt { view.update(stats, pull, "feature-x", dirty) } assertEquals( - listOf("Uncommitted", "1 file", "-1", "+6", "3", "2", "vs origin/main", "5 files", "-4", "+10"), + listOf("1 file", "-1", "+6", "3", "2", "5 files", "-4", "+10"), edt { labels(changes) }, ) assertTrue(edt { changes.isVisible }) @@ -170,7 +170,7 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } val dirty = WorktreeDirtyDto("/repo", files = 1) edt { view.update(null, null, "feature-x", dirty) } - assertEquals(listOf("Uncommitted", "1 file"), edt { labels(changes) }) + assertEquals(listOf("1 file"), edt { labels(changes) }) edt { view.update(WorktreeStatsDto("/repo", ahead = 2), null, "feature-x") } assertEquals(listOf("2"), edt { labels(changes) }) edt { view.update(null, null, "feature-x") } 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 6c8c11832c68..7f25372768a1 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 @@ -781,21 +781,21 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { try { timers.advanceBy(300) stats.trySend(WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 2, additions = 5, base = "origin/main")))).getOrThrow() - await(summary, listOf("vs origin/main", "2 files", "+5")) + await(summary, listOf("2 files", "+5")) dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 1, additions = 7)))).getOrThrow() - await(summary, listOf("Uncommitted", "1 file", "+7", "vs origin/main", "2 files", "+5")) + await(summary, listOf("1 file", "+7", "2 files", "+5")) edt { status.refreshStats() } timers.advanceBy(300) dirty.trySend(WorktreeDirtyListDto(listOf(WorktreeDirtyDto(DIR, files = 3, deletions = 4)))).getOrThrow() - await(summary, listOf("Uncommitted", "3 files", "-4", "vs origin/main", "2 files", "+5")) + await(summary, listOf("3 files", "-4", "2 files", "+5")) stats.trySend(WorktreeStatsListDto(listOf(WorktreeStatsDto(DIR, files = 1, behind = 2, base = "origin/trunk")))).getOrThrow() - await(summary, listOf("Uncommitted", "3 files", "-4", "2", "vs origin/trunk", "1 file")) + await(summary, listOf("3 files", "-4", "2", "1 file")) edt { status.refreshStats() } timers.advanceBy(300) dirty.trySend(WorktreeDirtyListDto()).getOrThrow() - await(summary, listOf("2", "vs origin/trunk", "1 file")) + await(summary, listOf("2", "1 file")) stats.trySend(WorktreeStatsListDto()).getOrThrow() await(summary, emptyList()) edt { @@ -838,7 +838,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { val editors = FileEditorManager.getInstance(project) try { timers.advanceBy(300) - await(summary, listOf("Uncommitted", "1 file", "vs base", "2 files")) + await(summary, listOf("1 file", "2 files")) edt { click(components(summary).filterIsInstance().single { it.text == "1 file" }) click(components(summary).filterIsInstance().single { it.text == "2 files" }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt index 95fbc8768b2a..d573728e448e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/ChangesPanelTest.kt @@ -36,12 +36,12 @@ class ChangesPanelTest : BasePlatformTestCase() { assertEquals(view.toolTipText, groups(view).single().toolTipText) } - fun `test full orders local separator ahead behind and labelled base`() = edt { + fun `test full orders local separator ahead behind and base`() = edt { val view = ChangesPanel(ChangesPanel.Mode.FULL) view.update(3, 10, 7, ahead = 4, behind = 5, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") layout(view) - assertEquals(listOf("Uncommitted", "2 files", "-3", "+9", "4", "5", "vs origin/main", "3 files", "-7", "+10"), labels(view)) + assertEquals(listOf("2 files", "-3", "+9", "4", "5", "3 files", "-7", "+10"), labels(view)) val row = view.components.single() as Container val order = row.components.toList() assertEquals(5, order.size) @@ -58,7 +58,7 @@ class ChangesPanelTest : BasePlatformTestCase() { val view = ChangesPanel(ChangesPanel.Mode.FULL, onBase = {}, onLocal = {}) view.update(1, 0, 0, localFiles = 2) - assertEquals(listOf("Uncommitted", "2 files", "vs base", "1 file"), labels(view)) + assertEquals(listOf("2 files", "1 file"), labels(view)) assertTrue(groups(view).all { it.isVisible && it.isFocusable }) assertTrue(components(view).filterIsInstance().all { !it.isVisible }) assertTrue(separator(view).isVisible) @@ -88,15 +88,15 @@ class ChangesPanelTest : BasePlatformTestCase() { assertFalse(separator(view).isVisible) view.update(0, 0, 0, behind = 4, localFiles = 1) - assertEquals(listOf("Uncommitted", "1 file", "4"), labels(view)) + assertEquals(listOf("1 file", "4"), labels(view)) assertTrue(separator(view).isVisible) view.update(0, 0, 0, localFiles = 1) - assertEquals(listOf("Uncommitted", "1 file"), labels(view)) + assertEquals(listOf("1 file"), labels(view)) assertFalse(separator(view).isVisible) view.update(1, 0, 0) - assertEquals(listOf("vs base", "1 file"), labels(view)) + assertEquals(listOf("1 file"), labels(view)) assertFalse(separator(view).isVisible) } @@ -106,12 +106,11 @@ class ChangesPanelTest : BasePlatformTestCase() { val groups = groups(view) view.update(2, 5, 1, localFiles = 3, localDeletions = 4, base = "origin/main") - assertEquals(listOf("Uncommitted", "3 files", "-4", "vs origin/main", "2 files", "-1", "+5"), labels(view)) + assertEquals(listOf("3 files", "-4", "2 files", "-1", "+5"), labels(view)) assertEquals(KiloBundle.message("worktree.dirty.tooltip", 3, 0, 4), groups.first().toolTipText) view.update(2, 5, 1, localFiles = 3, localDeletions = 4, base = "origin/trunk") assertEquals(KiloBundle.message("worktree.stats.base.tooltip", 2, 5, 1, "origin/trunk"), groups.last().toolTipText) - assertTrue(labels(view).contains("vs origin/trunk")) assertEquals(groups, groups(view)) } From 3521fdf58a9d96155452d2dfeed48efb18aec624 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 17:58:29 -0400 Subject: [PATCH 06/19] fix(jetbrains): separate worktree header actions and fix button sizing Add a vertical separator between the changes summary and the trailing action buttons (Run, Open, Terminal) in the worktree editor header, shown only when both sides have visible content. Fix HoverIcon's labelled variant (used by the Open and Build/Run buttons) to size itself from its actual icon+text content instead of falling through to DarculaButtonUI's default preferred size, which enforces a 72x24 dialog-button minimum. That minimum was inflating short labels like "Open" into much wider buttons than the icon-only Terminal button, which already bypassed it. --- .../client/session/ui/header/PrHeaderView.kt | 16 ++++++++++++++++ .../kotlin/ai/kilocode/client/ui/HoverIcon.kt | 15 ++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt index 593f4ee89adf..7a1aa8c49c48 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt @@ -27,6 +27,8 @@ import java.awt.Component import java.awt.Cursor import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import javax.swing.JSeparator +import javax.swing.SwingConstants import javax.swing.SwingUtilities internal class PrHeaderView @RequiresEdt constructor( @@ -39,9 +41,14 @@ internal class PrHeaderView @RequiresEdt constructor( private val title = SimpleColoredComponent() private val changes = ChangesPanel(mode, onBase = openDiff, onLocal = onLocal) private val statusPane = status.align(HAlign.LEFT, VAlign.CENTER) + // Hidden until the first action is added: hosts with no trailing actions (e.g. BranchDock) show + // just the changes summary, so an always-visible separator would dangle with nothing after it. + private val actionsSeparator = JSeparator(SwingConstants.VERTICAL).apply { isVisible = false } private val actions = Stack.horizontal(UiStyle.Gap.sm()) .next(changes.align(HAlign.CENTER, VAlign.CENTER)) + .next(actionsSeparator) private var style = SessionEditorStyle.current() + private var actionCount = 0 private var state: GhState? = null private var number: String? = null private var body: String? = null @@ -74,7 +81,9 @@ internal class PrHeaderView @RequiresEdt constructor( @RequiresEdt fun addAction(component: Component) { + actionCount++ actions.next(component.align(HAlign.CENTER, VAlign.CENTER)) + syncSeparator() } @RequiresEdt @@ -92,9 +101,16 @@ internal class PrHeaderView @RequiresEdt constructor( base: String = "", ) { changes.update(files, additions, deletions, ahead, behind, localFiles, localAdditions, localDeletions, base) + syncSeparator() applyPr(pull, name) } + @RequiresEdt + private fun syncSeparator() { + val visible = actionCount > 0 && changes.isVisible + if (actionsSeparator.isVisible != visible) actionsSeparator.isVisible = visible + } + @RequiresEdt private fun applyPr(pull: WorktreePrDto?, name: String) { if (pull == null) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index d453208bf793..6a1742fa5fdd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -8,6 +8,7 @@ import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JButton +import javax.swing.plaf.basic.BasicGraphicsUtils class HoverIcon(private val fill: Boolean = false) : JButton() { private var over = false @@ -27,12 +28,16 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { // Icon-only buttons are square: a 24x24 hit target for the usual 16px icon, growing with the icon // so a larger one keeps the same padding inside the hover pill. Labelled buttons size to their - // content plus their (symmetric) border, which already gives equal padding on every side. + // icon+text content plus their (symmetric) border/margin only -- bypassing + // DarculaButtonUI.getPreferredSize()'s dialog-button minimum (72x24 by default), which would + // otherwise pad a short label like "Open" out to a much wider button than a toolbar action needs. override fun getPreferredSize(): Dimension { - if (!text.isNullOrEmpty()) return super.getPreferredSize() - val icon = icon ?: return JBUI.size(MIN, MIN) - val side = maxOf(JBUI.scale(MIN), icon.iconWidth + JBUI.scale(PAD), icon.iconHeight + JBUI.scale(PAD)) - return Dimension(side, side) + val icon = icon ?: return if (text.isNullOrEmpty()) JBUI.size(MIN, MIN) else BasicGraphicsUtils.getPreferredButtonSize(this, 0) + if (text.isNullOrEmpty()) { + val side = maxOf(JBUI.scale(MIN), icon.iconWidth + JBUI.scale(PAD), icon.iconHeight + JBUI.scale(PAD)) + return Dimension(side, side) + } + return BasicGraphicsUtils.getPreferredButtonSize(this, iconTextGap) } override fun getMinimumSize(): Dimension = preferredSize From 723190843b2f13a1d7e6c472516ebaeb93d855dd Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 19:08:31 -0400 Subject: [PATCH 07/19] fix(jetbrains): fence worktree toolbar and align icon-only action height Revert the labelled-button sizing change: Build/Run and Open go back to the platform's preferred size, so their insets match what they were. Instead give the icon-only Terminal button the labelled buttons' margin and toolbar insets, plus a new HoverIcon.match mode that squares it to the height the look-and-feel gives a button. Deriving that height from the LAF (rather than a fixed token) keeps it level with its labelled siblings under both Darcula and the plain test LAF. Add a leading separator to the header toolbar so the cluster reads as separator, standard padding, changes summary, separator, actions. Both separators stay hidden until an action exists, and the padding is a Stack gap rather than a filler so it collapses with the hidden separator -- leaving dock hosts with no trailing actions unchanged. --- .../worktree/WorktreePrHeaderView.kt | 4 +- .../client/session/ui/header/PrHeaderView.kt | 12 ++++- .../kotlin/ai/kilocode/client/ui/HoverIcon.kt | 28 +++++++---- .../ai/kilocode/client/ui/ToolbarButton.kt | 16 +++++++ .../worktree/WorktreePrHeaderViewTest.kt | 12 +++++ .../session/ui/header/PrHeaderViewTest.kt | 47 +++++++++++++++++++ 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt index aa774f8d1291..df3d36043eb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderView.kt @@ -4,8 +4,8 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.header.PrHeaderView import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.ToolbarButtonAction +import ai.kilocode.client.ui.hoverIconButton import ai.kilocode.client.ui.hoverTextButton -import ai.kilocode.client.ui.toolbarButton import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto @@ -33,7 +33,7 @@ internal class WorktreePrHeaderView @RequiresEdt constructor( tooltip = KiloBundle.message("worktree.session.open.tooltip"), ) // Icon-only: the tooltip text stands in for the visible label and doubles as the accessible name. - private val terminal = toolbarButton( + private val terminal = hoverIconButton( ToolbarButtonAction(TerminalIcons.OpenTerminal_13x13, KiloBundle.message("worktree.session.terminal.tooltip"), openTerminal), ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt index 7a1aa8c49c48..7c34e4ff0f01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt @@ -41,10 +41,17 @@ internal class PrHeaderView @RequiresEdt constructor( private val title = SimpleColoredComponent() private val changes = ChangesPanel(mode, onBase = openDiff, onLocal = onLocal) private val statusPane = status.align(HAlign.LEFT, VAlign.CENTER) - // Hidden until the first action is added: hosts with no trailing actions (e.g. BranchDock) show - // just the changes summary, so an always-visible separator would dangle with nothing after it. + // Both separators stay hidden until the first action is added: hosts with no trailing actions + // (e.g. BranchDock) show a bare changes summary, where a separator would dangle with nothing on + // one side of it. [leadSeparator] fences the whole toolbar off from the PR title, so it is + // followed by standard padding before the changes summary starts. + private val leadSeparator = JSeparator(SwingConstants.VERTICAL).apply { isVisible = false } private val actionsSeparator = JSeparator(SwingConstants.VERTICAL).apply { isVisible = false } private val actions = Stack.horizontal(UiStyle.Gap.sm()) + .next(leadSeparator) + // A gap rather than a filler: it collapses along with a hidden [leadSeparator], so hosts + // without actions keep a flush changes summary instead of stray leading whitespace. + .gap(UiStyle.Gap.md()) .next(changes.align(HAlign.CENTER, VAlign.CENTER)) .next(actionsSeparator) private var style = SessionEditorStyle.current() @@ -107,6 +114,7 @@ internal class PrHeaderView @RequiresEdt constructor( @RequiresEdt private fun syncSeparator() { + if (leadSeparator.isVisible != (actionCount > 0)) leadSeparator.isVisible = actionCount > 0 val visible = actionCount > 0 && changes.isVisible if (actionsSeparator.isVisible != visible) actionsSeparator.isVisible = visible } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index 6a1742fa5fdd..b8b337583bcf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -8,11 +8,23 @@ import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JButton -import javax.swing.plaf.basic.BasicGraphicsUtils class HoverIcon(private val fill: Boolean = false) : JButton() { private var over = false + /** + * Makes an icon-only button a square of the height the look-and-feel would give it as a button, + * so it lines up with labelled siblings in the same cluster instead of looking shorter. Taking + * the height from the LAF rather than a fixed token keeps the two in step wherever the LAF + * applies its own button minimum (Darcula raises both to the same floor). + */ + var match = false + set(value) { + if (field == value) return + field = value + revalidate() + } + init { iconButton(this) addMouseListener(object : MouseAdapter() { @@ -28,16 +40,16 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { // Icon-only buttons are square: a 24x24 hit target for the usual 16px icon, growing with the icon // so a larger one keeps the same padding inside the hover pill. Labelled buttons size to their - // icon+text content plus their (symmetric) border/margin only -- bypassing - // DarculaButtonUI.getPreferredSize()'s dialog-button minimum (72x24 by default), which would - // otherwise pad a short label like "Open" out to a much wider button than a toolbar action needs. + // content plus their (symmetric) border, which already gives equal padding on every side. override fun getPreferredSize(): Dimension { - val icon = icon ?: return if (text.isNullOrEmpty()) JBUI.size(MIN, MIN) else BasicGraphicsUtils.getPreferredButtonSize(this, 0) - if (text.isNullOrEmpty()) { - val side = maxOf(JBUI.scale(MIN), icon.iconWidth + JBUI.scale(PAD), icon.iconHeight + JBUI.scale(PAD)) + if (!text.isNullOrEmpty()) return super.getPreferredSize() + if (match) { + val side = super.getPreferredSize().height return Dimension(side, side) } - return BasicGraphicsUtils.getPreferredButtonSize(this, iconTextGap) + val icon = icon ?: return JBUI.size(MIN, MIN) + val side = maxOf(JBUI.scale(MIN), icon.iconWidth + JBUI.scale(PAD), icon.iconHeight + JBUI.scale(PAD)) + return Dimension(side, side) } override fun getMinimumSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt index d297ddd0a465..a6030be7a58a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ToolbarButton.kt @@ -35,3 +35,19 @@ internal fun hoverTextButton(action: ToolbarButtonAction, tooltip: String? = nul accessibleContext.accessibleName = action.text addActionListener { action.handler() } } + +/** + * Icon-only sibling of [hoverTextButton]: same margin, insets, and overall height, so an icon-only + * action sitting next to labelled ones shares their hover-pill metrics instead of falling back to + * the smaller square [toolbarButton] size. The tooltip stands in for the missing label. + */ +internal fun hoverIconButton(action: ToolbarButtonAction) = HoverIcon().apply { + match = true + icon = action.icon + margin = JBUI.emptyInsets() + border = JBUI.Borders.empty(JBUI.CurrentTheme.Toolbar.toolbarButtonInsets()) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = action.text + accessibleContext.accessibleName = action.text + addActionListener { action.handler() } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt index e94dc2f363f7..afa0bf731356 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreePrHeaderViewTest.kt @@ -191,6 +191,18 @@ class WorktreePrHeaderViewTest : BasePlatformTestCase() { assertEquals(0, opened) } + fun `test icon only terminal shares labelled action metrics`() { + val view = edt { WorktreePrHeaderView(openDiff = {}) } + val open = edt { components(view).filterIsInstance().single { it.text == "Open" } } + val terminal = edt { components(view).filterIsInstance().single { it.icon === TerminalIcons.OpenTerminal_13x13 } } + + assertEquals(edt { open.insets }, edt { terminal.insets }) + assertEquals(edt { open.preferredSize.height }, edt { terminal.preferredSize.height }) + // Icon-only stays square: it matches the labelled height without inheriting its wide minimum. + assertEquals(edt { terminal.preferredSize.height }, edt { terminal.preferredSize.width }) + assertTrue(edt { terminal.preferredSize.width < open.preferredSize.width }) + } + fun `test terminal button is icon-only and triggers callback`() { var opened = 0 val view = edt { WorktreePrHeaderView(openDiff = {}, openTerminal = { opened++ }) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt index 3c4307faf2c0..4ce973162eda 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.stateLabel import ai.kilocode.client.ui.style import ai.kilocode.client.util.edtWait @@ -18,6 +19,8 @@ import java.awt.Component import java.awt.Container import java.awt.Cursor import javax.swing.JButton +import javax.swing.JSeparator +import javax.swing.SwingUtilities class PrHeaderViewTest : BasePlatformTestCase() { fun `test PR renders state badge title and link`() { @@ -68,6 +71,44 @@ class PrHeaderViewTest : BasePlatformTestCase() { assertTrue(edt { components(view).contains(button) }) } + fun `test toolbar separators track actions and visible changes`() { + val view = edt { PrHeaderView {} } + val separators = edt { components(view).filterIsInstance() } + assertEquals(2, separators.size) + + // Changes alone are not a toolbar: with no actions there is nothing to fence off. + edt { view.update(2, 1, 0, null, "feature-x") } + assertEquals(listOf(false, false), edt { separators.map { it.isVisible } }) + + edt { view.addAction(JButton("Open")) } + assertEquals(listOf(true, true), edt { separators.map { it.isVisible } }) + + // A clean worktree drops the trailing separator, but the toolbar fence stays. + edt { view.update(0, 0, 0, null, "feature-x") } + assertEquals(listOf(true, false), edt { separators.map { it.isVisible } }) + } + + fun `test lead separator precedes changes by standard padding`() { + val view = edt { PrHeaderView {} } + val button = JButton("Open") + edt { + view.addAction(button) + view.update(2, 1, 0, null, "feature-x") + layout(view) + } + val lead = edt { components(view).filterIsInstance().first() } + val trailing = edt { components(view).filterIsInstance().last() } + val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } + val row = edt { lead.parent } + val wrapper = edt { row.components.single { SwingUtilities.isDescendingFrom(changes, it) } } + + assertEquals(UiStyle.Gap.md(), edt { wrapper.x - (lead.x + lead.width) }) + // Order is separator, padding, changes, separator, then the actions. + assertTrue(edt { lead.x < wrapper.x }) + assertTrue(edt { wrapper.x + wrapper.width <= trailing.x }) + assertTrue(edt { trailing.x < SwingUtilities.convertPoint(button, 0, 0, view).x }) + } + fun `test repeated update keeps child instances and bounded count`() { val view = edt { PrHeaderView {} } val pull = pull(GhState.DRAFT) @@ -143,5 +184,11 @@ class PrHeaderViewTest : BasePlatformTestCase() { return out } + @RequiresEdt + private fun layout(view: PrHeaderView) { + view.setSize(view.preferredSize) + components(view).forEach { if (it is Container) it.doLayout() } + } + private fun edt(block: () -> T): T = edtWait(block) } From e0be1a7faf8c5746ffcfaf496b2cb6ec8a7c7f5f Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 19:37:59 -0400 Subject: [PATCH 08/19] fix(jetbrains): drop leading toolbar separator, keep the left inset The separator before the changes summary read as clutter next to the PR title. Replace it with standard left padding on the toolbar row itself, which keeps the summary spaced off the title without drawing a rule. Padding now lives in the row's border instead of a Stack gap so it no longer depends on a separator being visible to apply. --- .../client/session/ui/header/PrHeaderView.kt | 15 +++------ .../session/ui/header/PrHeaderViewTest.kt | 33 +++++++++---------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt index 7c34e4ff0f01..aacc8de86388 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/PrHeaderView.kt @@ -41,17 +41,10 @@ internal class PrHeaderView @RequiresEdt constructor( private val title = SimpleColoredComponent() private val changes = ChangesPanel(mode, onBase = openDiff, onLocal = onLocal) private val statusPane = status.align(HAlign.LEFT, VAlign.CENTER) - // Both separators stay hidden until the first action is added: hosts with no trailing actions - // (e.g. BranchDock) show a bare changes summary, where a separator would dangle with nothing on - // one side of it. [leadSeparator] fences the whole toolbar off from the PR title, so it is - // followed by standard padding before the changes summary starts. - private val leadSeparator = JSeparator(SwingConstants.VERTICAL).apply { isVisible = false } + // Hidden until the first action is added: hosts with no trailing actions (e.g. BranchDock) show + // just the changes summary, so an always-visible separator would dangle with nothing after it. private val actionsSeparator = JSeparator(SwingConstants.VERTICAL).apply { isVisible = false } private val actions = Stack.horizontal(UiStyle.Gap.sm()) - .next(leadSeparator) - // A gap rather than a filler: it collapses along with a hidden [leadSeparator], so hosts - // without actions keep a flush changes summary instead of stray leading whitespace. - .gap(UiStyle.Gap.md()) .next(changes.align(HAlign.CENTER, VAlign.CENTER)) .next(actionsSeparator) private var style = SessionEditorStyle.current() @@ -64,7 +57,8 @@ internal class PrHeaderView @RequiresEdt constructor( init { isOpaque = false - actions.border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) + // Standard padding fences the toolbar off from the PR title on the left. + actions.border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, UiStyle.Gap.sm()) status.border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, UiStyle.Gap.xs()) status.isVisible = false title.border = JBUI.Borders.empty(0, UiStyle.Gap.sm()) @@ -114,7 +108,6 @@ internal class PrHeaderView @RequiresEdt constructor( @RequiresEdt private fun syncSeparator() { - if (leadSeparator.isVisible != (actionCount > 0)) leadSeparator.isVisible = actionCount > 0 val visible = actionCount > 0 && changes.isVisible if (actionsSeparator.isVisible != visible) actionsSeparator.isVisible = visible } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt index 4ce973162eda..ed9f67f61f3b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/PrHeaderViewTest.kt @@ -71,24 +71,23 @@ class PrHeaderViewTest : BasePlatformTestCase() { assertTrue(edt { components(view).contains(button) }) } - fun `test toolbar separators track actions and visible changes`() { + fun `test action separator tracks actions and visible changes`() { val view = edt { PrHeaderView {} } - val separators = edt { components(view).filterIsInstance() } - assertEquals(2, separators.size) + val separator = edt { components(view).filterIsInstance().single() } - // Changes alone are not a toolbar: with no actions there is nothing to fence off. + // Changes alone are not a toolbar: with no actions there is nothing to separate them from. edt { view.update(2, 1, 0, null, "feature-x") } - assertEquals(listOf(false, false), edt { separators.map { it.isVisible } }) + assertFalse(edt { separator.isVisible }) edt { view.addAction(JButton("Open")) } - assertEquals(listOf(true, true), edt { separators.map { it.isVisible } }) + assertTrue(edt { separator.isVisible }) - // A clean worktree drops the trailing separator, but the toolbar fence stays. + // A clean worktree leaves the separator with nothing on its left, so it goes away too. edt { view.update(0, 0, 0, null, "feature-x") } - assertEquals(listOf(true, false), edt { separators.map { it.isVisible } }) + assertFalse(edt { separator.isVisible }) } - fun `test lead separator precedes changes by standard padding`() { + fun `test toolbar keeps standard left padding before the changes summary`() { val view = edt { PrHeaderView {} } val button = JButton("Open") edt { @@ -96,17 +95,17 @@ class PrHeaderViewTest : BasePlatformTestCase() { view.update(2, 1, 0, null, "feature-x") layout(view) } - val lead = edt { components(view).filterIsInstance().first() } - val trailing = edt { components(view).filterIsInstance().last() } + val separator = edt { components(view).filterIsInstance().single() } val changes = edt { UIUtil.findComponentOfType(view, ChangesPanel::class.java)!! } - val row = edt { lead.parent } + val row = edt { separator.parent } val wrapper = edt { row.components.single { SwingUtilities.isDescendingFrom(changes, it) } } - assertEquals(UiStyle.Gap.md(), edt { wrapper.x - (lead.x + lead.width) }) - // Order is separator, padding, changes, separator, then the actions. - assertTrue(edt { lead.x < wrapper.x }) - assertTrue(edt { wrapper.x + wrapper.width <= trailing.x }) - assertTrue(edt { trailing.x < SwingUtilities.convertPoint(button, 0, 0, view).x }) + // The row's left inset pads the summary off the PR title without a leading separator. + assertEquals(UiStyle.Gap.md(), edt { wrapper.x }) + assertTrue(edt { components(view).filterIsInstance().none { it.x < wrapper.x } }) + // Order is padding, changes, separator, then the actions. + assertTrue(edt { wrapper.x + wrapper.width <= separator.x }) + assertTrue(edt { separator.x < SwingUtilities.convertPoint(button, 0, 0, view).x }) } fun `test repeated update keeps child instances and bounded count`() { From db6d5a5cbcead752856341d32b8ed6fd23116e2f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 15:10:04 -0400 Subject: [PATCH 09/19] feat(jetbrains): run and manage worktree setup scripts Port VS Code's worktree setup script (.kilo/setup-script[.sh|.ps1|.cmd| .bat]) to the JetBrains plugin: - Resolve the script per-platform with disjoint POSIX/Windows candidate lists, and create it from VS Code's own templates when opened and missing (backend/rpc/SetupScriptTemplate.kt). - Run it in a terminal tab dedicated to the worktree (distinct from the Terminal button's tab), with WORKTREE_PATH/REPO_PATH set via envVariables() so the sent command needs no shell-specific env-prefix syntax. - Fire automatically, non-blocking, when a worktree is created (covers create, import, and move-to-worktree); silent no-op when no script exists. - Add Open/Create + Run to the worktree row menu (hidden on the main row); Open/Create only, not Run, in the tool-window settings popup. Also shorten several worktree row action labels and add "Open Uncommitted Changes" (KiloDiffComparison.LOCAL) alongside the existing base-diff action: - Open Pull Request in Browser -> Open in Browser - Open Diff Against Base Branch -> Open Diff Against Base - Open/Run Worktree Setup Script -> Open/Run Worktree Setup - Delete Worktree -> Delete --- .changeset/jetbrains-worktree-setup-script.md | 5 + .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 51 +++++- .../backend/rpc/SetupScriptTemplate.kt | 99 +++++++++++ .../backend/rpc/SetupScriptResolutionTest.kt | 156 ++++++++++++++++++ .../client/actions/OpenConfigActions.kt | 36 ++++ .../actions/OpenWorktreeLocalDiffAction.kt | 23 +++ .../actions/RunWorktreeSetupScriptAction.kt | 23 +++ .../client/agentManager/AgentManagerPanel.kt | 60 ++++++- .../worktree/WorktreeSetupScript.kt | 59 +++++++ .../client/app/KiloWorkspaceService.kt | 50 ++++++ .../resources/kilo.jetbrains.frontend.xml | 13 ++ .../resources/messages/KiloBundle.properties | 14 +- .../actions/HistorySessionActionsTest.kt | 28 +++- .../client/actions/KiloRecoveryActionsTest.kt | 104 +++++++++++- .../agentManager/AgentManagerPanelTest.kt | 67 ++++++++ .../worktree/WorktreeSetupScriptTest.kt | 49 ++++++ .../client/app/KiloWorkspaceServiceTest.kt | 24 +++ .../client/testing/FakeWorkspaceRpcApi.kt | 22 +++ .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 7 + .../kilocode/rpc/dto/SetupScriptTargetDto.kt | 15 ++ 20 files changed, 888 insertions(+), 17 deletions(-) create mode 100644 .changeset/jetbrains-worktree-setup-script.md create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt diff --git a/.changeset/jetbrains-worktree-setup-script.md b/.changeset/jetbrains-worktree-setup-script.md new file mode 100644 index 000000000000..65a8336565e0 --- /dev/null +++ b/.changeset/jetbrains-worktree-setup-script.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Run the worktree setup script (`.kilo/setup-script`) in a terminal when a new worktree is created, and add actions to open, create, or run it from the worktree row menu and the settings menu. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 000128b11a30..dad362cf92de 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -21,6 +21,8 @@ import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler @@ -32,6 +34,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem @@ -305,10 +308,20 @@ class KiloWorkspaceRpcApiImpl internal constructor( globalConfig() }) - private suspend fun openConfig(path: Path): Boolean { + override suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto = withContext(Dispatchers.IO) { + resolveSetupScript(repoRoot(directory), SystemInfo.isWindows) + } + + override suspend fun openSetupScript(directory: String): Boolean { + val resolved = withContext(Dispatchers.IO) { resolveSetupScript(repoRoot(directory), SystemInfo.isWindows) } + val content = if (resolved.kind == SetupScriptKind.POWERSHELL) SetupScriptTemplate.POWERSHELL else SetupScriptTemplate.POSIX + return openConfig(Path.of(resolved.path), content) + } + + private suspend fun openConfig(path: Path, content: String = CONFIG): Boolean { val target = withContext(Dispatchers.IO) { Files.createDirectories(path.parent) - if (!Files.exists(path)) Files.writeString(path, CONFIG, StandardCharsets.UTF_8) + if (!Files.exists(path)) Files.writeString(path, content, StandardCharsets.UTF_8) path } val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false @@ -320,8 +333,11 @@ class KiloWorkspaceRpcApiImpl internal constructor( return true } + private fun repoRoot(directory: String): Path = + file(clean(directory) ?: directory)?.takeIf { it.isAbsolute } ?: Path.of(directory).normalize() + private fun localConfig(directory: String): Path { - val root = file(clean(directory) ?: directory)?.takeIf { it.isAbsolute } ?: Path.of(directory).normalize() + val root = repoRoot(directory) val dirs = LOCAL_DIRS.map { root.resolve(it) } + root val found = dirs.asSequence() .flatMap { dir -> (MODERN + LEGACY).asSequence().map { name -> dir.resolve(name) } } @@ -464,6 +480,35 @@ internal fun normalizeWorkspacePath(path: String): String? { } } +// Candidate names in the .kilo/ directory, in resolution order. Disjoint by design: a POSIX script is +// never resolved on Windows and vice versa, matching the VS Code extension. +private val SETUP_POSIX_CANDIDATES = listOf( + "setup-script" to SetupScriptKind.POSIX, + "setup-script.sh" to SetupScriptKind.POSIX, +) +private val SETUP_WINDOWS_CANDIDATES = listOf( + "setup-script.ps1" to SetupScriptKind.POWERSHELL, + "setup-script.cmd" to SetupScriptKind.CMD, + "setup-script.bat" to SetupScriptKind.CMD, +) +private val SETUP_DEFAULT_POSIX = "setup-script" to SetupScriptKind.POSIX +private val SETUP_DEFAULT_WINDOWS = "setup-script.ps1" to SetupScriptKind.POWERSHELL + +/** + * Resolves the worktree setup script in `/.kilo/` for the given platform. POSIX and Windows + * candidate lists are disjoint (a POSIX script is never resolved on Windows and vice versa); the + * first existing candidate wins, otherwise the platform default path is returned with `exists = false`. + * Pure and unit-testable without touching [SystemInfo]. + */ +internal fun resolveSetupScript(root: Path, windows: Boolean): SetupScriptTargetDto { + val dir = root.resolve(".kilo") + val candidates = if (windows) SETUP_WINDOWS_CANDIDATES else SETUP_POSIX_CANDIDATES + val found = candidates.firstOrNull { (name, _) -> Files.exists(dir.resolve(name)) } + val (name, kind) = found ?: (if (windows) SETUP_DEFAULT_WINDOWS else SETUP_DEFAULT_POSIX) + val raw = dir.resolve(name).toString() + return SetupScriptTargetDto(raw, FileUtil.getLocationRelativeToUserHome(raw, false), found != null, kind) +} + internal fun resolveProjectDirectoryHint(hint: String, bases: List): String { val clean = normalizeWorkspacePath(hint) val match = bases.firstOrNull { base -> diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt new file mode 100644 index 000000000000..d7f02cf5cf1a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/SetupScriptTemplate.kt @@ -0,0 +1,99 @@ +package ai.kilocode.backend.rpc + +/** + * Default templates for the worktree setup script (`.kilo/setup-script`), ported verbatim from the + * VS Code extension (`packages/kilo-vscode/src/agent-manager/setup-script-template.ts`) so a repo + * behaves the same regardless of which IDE created the file. + */ +internal object SetupScriptTemplate { + /** Default POSIX template for worktree setup scripts. */ + val POSIX = """#!/bin/sh +# Kilo Code Worktree Setup Script +# This script runs before the agent starts in a worktree (new sessions only). +# +# Available environment variables: +# WORKTREE_PATH - Absolute path to the worktree directory +# REPO_PATH - Absolute path to the main repository +# +# Kilo already copies root-level .env and .env.* files before this script runs. +# Use this script for dependencies, nested env files, local config, databases, +# certificates, or other project-specific setup that is not committed to git. +# +# Example tasks: +# - Copy nested env files from main repo +# - Install dependencies +# - Run database migrations +# - Set up local configuration + +set -e # Exit on error + +echo "Setting up worktree: ${'$'}WORKTREE_PATH" + +# Uncomment and modify as needed: + +# Copy a nested environment file +# if [ -f "${'$'}REPO_PATH/apps/web/.env.local" ] && [ ! -f "${'$'}WORKTREE_PATH/apps/web/.env.local" ]; then +# cp "${'$'}REPO_PATH/apps/web/.env.local" "${'$'}WORKTREE_PATH/apps/web/.env.local" +# echo "Copied apps/web/.env.local" +# fi + +# Install dependencies (Node.js) +# if [ -f "${'$'}WORKTREE_PATH/package.json" ]; then +# cd "${'$'}WORKTREE_PATH" +# npm install +# fi + +# Install dependencies (Python) +# if [ -f "${'$'}WORKTREE_PATH/requirements.txt" ]; then +# cd "${'$'}WORKTREE_PATH" +# pip install -r requirements.txt +# fi + +echo "Setup complete!" +""" + + /** Default PowerShell template for worktree setup scripts on Windows. */ + val POWERSHELL = """# Kilo Code Worktree Setup Script +# This script runs before the agent starts in a worktree (new sessions only). +# +# Available environment variables: +# ${'$'}env:WORKTREE_PATH - Absolute path to the worktree directory +# ${'$'}env:REPO_PATH - Absolute path to the main repository +# +# Kilo already copies root-level .env and .env.* files before this script runs. +# Use this script for dependencies, nested env files, local config, databases, +# certificates, or other project-specific setup that is not committed to git. +# +# Example tasks: +# - Copy nested env files from main repo +# - Install dependencies +# - Run database migrations +# - Set up local configuration + +${'$'}ErrorActionPreference = "Stop" + +Write-Host "Setting up worktree: ${'$'}env:WORKTREE_PATH" + +# Uncomment and modify as needed: + +# Copy a nested environment file +# if ((Test-Path "${'$'}env:REPO_PATH/apps/web/.env.local") -and !(Test-Path "${'$'}env:WORKTREE_PATH/apps/web/.env.local")) { +# Copy-Item "${'$'}env:REPO_PATH/apps/web/.env.local" "${'$'}env:WORKTREE_PATH/apps/web/.env.local" +# Write-Host "Copied apps/web/.env.local" +# } + +# Install dependencies (Node.js) +# if (Test-Path "${'$'}env:WORKTREE_PATH/package.json") { +# Set-Location "${'$'}env:WORKTREE_PATH" +# npm install +# } + +# Install dependencies (Python) +# if (Test-Path "${'$'}env:WORKTREE_PATH/requirements.txt") { +# Set-Location "${'$'}env:WORKTREE_PATH" +# pip install -r requirements.txt +# } + +Write-Host "Setup complete!" +""" +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt new file mode 100644 index 000000000000..fd929af29a0d --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/SetupScriptResolutionTest.kt @@ -0,0 +1,156 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.SetupScriptKind +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers [resolveSetupScript] directly (pure, no [com.intellij.openapi.util.SystemInfo]) and + * [KiloWorkspaceRpcApiImpl.setupScriptTarget] (the RPC method, which needs no [com.intellij.openapi.project.Project] + * unlike `openSetupScript`, so it -- like the rest of this file's tests -- runs without a platform + * test fixture). + */ +class SetupScriptResolutionTest { + private val dirs = mutableListOf() + + @AfterTest + fun tearDown() { + dirs.forEach { delete(it) } + dirs.clear() + } + + @Test + fun `posix prefers setup-script over setup-script sh`() { + val root = repo() + write(root, "setup-script") + write(root, "setup-script.sh") + + val target = resolveSetupScript(root, windows = false) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script").toString(), target.path) + } + + @Test + fun `posix falls back to setup-script sh when only that exists`() { + val root = repo() + write(root, "setup-script.sh") + + val target = resolveSetupScript(root, windows = false) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.sh").toString(), target.path) + } + + @Test + fun `posix default is setup-script when nothing exists`() { + val root = repo() + + val target = resolveSetupScript(root, windows = false) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script").toString(), target.path) + } + + @Test + fun `windows prefers ps1 over cmd and bat`() { + val root = repo() + write(root, "setup-script.ps1") + write(root, "setup-script.cmd") + write(root, "setup-script.bat") + + val target = resolveSetupScript(root, windows = true) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.POWERSHELL, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.ps1").toString(), target.path) + } + + @Test + fun `windows prefers cmd over bat when ps1 is absent`() { + val root = repo() + write(root, "setup-script.cmd") + write(root, "setup-script.bat") + + val target = resolveSetupScript(root, windows = true) + + assertTrue(target.exists) + assertEquals(SetupScriptKind.CMD, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.cmd").toString(), target.path) + } + + @Test + fun `windows default is setup-script ps1 when nothing exists`() { + val root = repo() + + val target = resolveSetupScript(root, windows = true) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POWERSHELL, target.kind) + assertEquals(root.resolve(".kilo").resolve("setup-script.ps1").toString(), target.path) + } + + @Test + fun `posix candidate is not resolved on windows`() { + val root = repo() + write(root, "setup-script") + write(root, "setup-script.sh") + + val target = resolveSetupScript(root, windows = true) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POWERSHELL, target.kind) + } + + @Test + fun `windows candidates are not resolved on posix`() { + val root = repo() + write(root, "setup-script.ps1") + write(root, "setup-script.cmd") + write(root, "setup-script.bat") + + val target = resolveSetupScript(root, windows = false) + + assertFalse(target.exists) + assertEquals(SetupScriptKind.POSIX, target.kind) + } + + @Test + fun `rpc method resolves the setup script for a directory`() = runBlocking { + val root = repo() + write(root, "setup-script") + + val target = KiloWorkspaceRpcApiImpl().setupScriptTarget(root.toString()) + + assertTrue(target.exists) + assertEquals(root.resolve(".kilo").resolve("setup-script").toString(), target.path) + } + + private fun repo(): Path { + val dir = Files.createTempDirectory("kilo-setup-script") + dirs.add(dir) + return dir + } + + private fun write(root: Path, name: String) { + val dir = root.resolve(".kilo") + Files.createDirectories(dir) + dir.resolve(name).writeText("# test\n") + } + + private fun delete(dir: Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt index 01054601b01a..59918e9ef15e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.telemetry.Telemetry @@ -79,3 +80,38 @@ class OpenGlobalConfigAction : ConfigAction( } } } + +class OpenSetupScriptAction : AnAction( + KiloBundle.message("action.Kilo.OpenSetupScript.text"), + KiloBundle.message("action.Kilo.OpenSetupScript.description"), + null, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + /** Worktree-row-only: hidden on the main worktree row, like Run. */ + override fun update(e: AnActionEvent) { + if (e.getData(WorktreeDataKeys.WORKTREE)?.main == true) { + e.presentation.isEnabledAndVisible = false + return + } + val dir = e.workspaceDirectory() + val service = service() + val target = dir?.let { service.setupScript[it] } + e.presentation.isEnabledAndVisible = dir != null + e.presentation.text = KiloBundle.message( + if (target?.exists == false) "action.Kilo.CreateSetupScript.text" else "action.Kilo.OpenSetupScript.text", + ) + + if (dir != null && target == null) { + service.refreshSetupScriptTarget(dir) + } + } + + override fun actionPerformed(e: AnActionEvent) { + val dir = e.workspaceDirectory() ?: return + Telemetry.send("Worktree Setup Script Opened", mapOf("surface" to "worktree_row")) + service().openSetupScript(dir) { ok -> + if (!ok) KiloNotifications.error(KiloBundle.message("action.Kilo.OpenConfig.failed")) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt new file mode 100644 index 000000000000..ebaa0346b7bf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeLocalDiffAction.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.agentManager.SidePanelKeys +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class OpenWorktreeLocalDiffAction : AnAction() { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) + val item = e.getData(WorktreeDataKeys.WORKTREE) + e.presentation.isEnabledAndVisible = panel != null && panel.canOpenLocalDiff(item) + } + + override fun actionPerformed(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return + val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return + if (panel.canOpenLocalDiff(item)) panel.openLocalDiff(item) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt new file mode 100644 index 000000000000..c08510921fba --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RunWorktreeSetupScriptAction.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.agentManager.SidePanelKeys +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class RunWorktreeSetupScriptAction : AnAction() { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) + val item = e.getData(WorktreeDataKeys.WORKTREE) + e.presentation.isEnabledAndVisible = panel != null && panel.canRunSetup(item) + } + + override fun actionPerformed(e: AnActionEvent) { + val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return + val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return + if (panel.canRunSetup(item)) panel.runSetup(item) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index cdf0fc888fae..de2ec8844fd0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -13,6 +13,8 @@ import ai.kilocode.client.agentManager.worktree.WorktreeIcons import ai.kilocode.client.agentManager.worktree.WorktreeStatusBinding import ai.kilocode.client.agentManager.worktree.WorktreeStatusService import ai.kilocode.client.agentManager.worktree.WorktreeNameCache +import ai.kilocode.client.agentManager.worktree.runWorktreeSetupScript +import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatchers import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorMatcher import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind @@ -112,7 +114,10 @@ class AgentManagerPanel( open(item, focus) }, menu = ActiveListMenu(WorktreeDataKeys.WORKTREE, group, element = { row -> - (row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) } + (row as? WorktreeRow)?.dto?.takeIf { + canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) || canOpenLocalDiff(it) || + canOpenSetupScript(it) || canRunSetup(it) + } }), reorder = ActiveListReorder( movable = { row -> row is WorktreeRow && !row.current && row.progress == null }, @@ -138,9 +143,10 @@ class AgentManagerPanel( } // A fresh worktree changes what git reports, so bypass the refresh throttle instead of // leaving the new row without its stats and PR badge until the next poll. - controller.onCreated = { + controller.onCreated = { created -> project?.service()?.refreshStats() project?.service()?.refreshPr(force = true) + autoRunSetupScript(created) } controller.onReload = { sync() } controller.onCreateFailure = { err -> notifyCreateFailed(err) } @@ -278,6 +284,56 @@ class AgentManagerPanel( openKiloDiff(target, item.path, KiloDiffComparison.BASE, item.branch.takeUnless { it == "(detached)" }) } + internal fun canOpenLocalDiff(item: WorktreeDto?): Boolean { + if (item == null || item.main || project == null) return false + return controller.progress(item.id) == null + } + + @RequiresEdt + internal fun openLocalDiff(item: WorktreeDto) { + val target = project ?: return + if (!canOpenLocalDiff(item)) return + openKiloDiff(target, item.path, KiloDiffComparison.LOCAL) + } + + /** The Open/Create setup-script action is repo-scoped, so it never targets the main worktree row. */ + internal fun canOpenSetupScript(item: WorktreeDto?): Boolean = item != null && !item.main + + internal fun canRunSetup(item: WorktreeDto?): Boolean { + if (item == null || item.main) return false + if (controller.progress(item.id) != null) return false + val service = service() + val target = service.setupScript[controller.directory] ?: run { + service.refreshSetupScriptTarget(controller.directory) + return false + } + return target.exists + } + + @RequiresEdt + internal fun runSetup(item: WorktreeDto) { + val target = project ?: return + if (!canRunSetup(item)) return + val script = service().setupScript[controller.directory] ?: return + Telemetry.send("Worktree Setup Script Run", mapOf("surface" to "worktree_row")) + runWorktreeSetupScript(target, script, item.path, controller.directory) + } + + /** + * Fires the setup script right after worktree creation, mirroring VS Code's trigger point but not + * its blocking semantics: this does not await the script or gate session creation. Silently does + * nothing when [created] is the main worktree or no script is configured, matching VS Code. + */ + @RequiresEdt + private fun autoRunSetupScript(created: WorktreeDto) { + if (created.main) return + val target = project ?: return + service().ifSetupScriptExists(controller.directory) { script -> + Telemetry.send("Worktree Setup Script Run", mapOf("surface" to "auto")) + runWorktreeSetupScript(target, script, created.path, controller.directory) + } + } + private fun showDeletePopup(item: WorktreeDto, cell: String? = null) { val opts = ActiveListDeleteOptions( message = KiloBundle.message("worktree.delete.confirm.message", item.name), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt new file mode 100644 index 000000000000..99706e029bbe --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScript.kt @@ -0,0 +1,59 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import org.jetbrains.plugins.terminal.TerminalToolWindowFactory + +/** Tags a terminal tab as the dedicated setup-script runner for a worktree, keyed by its directory. */ +private val SETUP_DIR = Key.create("kilo.worktree.setup.dir") + +/** + * Runs the worktree setup [script] in a terminal tab dedicated to [worktree], distinct from the tab + * the Terminal button opens. Reusing a separate tab means auto-run on worktree creation never types + * into (or steals focus from) a shell the user is actively working in, and env is set once at tab + * creation via [com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabBuilder.envVariables] + * so the sent command needs no shell-specific env-prefix syntax. + */ +@RequiresEdt +internal fun runWorktreeSetupScript(project: Project, script: SetupScriptTargetDto, worktree: String, repo: String) { + val tabs = TerminalToolWindowTabsManager.getInstance(project) + val existing = tabs.tabs.firstOrNull { FileUtil.pathsEqual(it.content.getUserData(SETUP_DIR), worktree) } + val tab = existing ?: tabs.createTabBuilder() + .workingDirectory(worktree) + .envVariables(mapOf("WORKTREE_PATH" to worktree, "REPO_PATH" to repo)) + .tabName(KiloBundle.message("worktree.setup.terminal.tabName", service().title(worktree))) + .requestFocus(true) + .createTab() + .also { it.content.putUserData(SETUP_DIR, worktree) } + if (existing != null) { + existing.content.manager?.setSelectedContent(existing.content, true) + } + ToolWindowManager.getInstance(project).getToolWindow(TerminalToolWindowFactory.TOOL_WINDOW_ID)?.activate(null) + tab.view.createSendTextBuilder().shouldExecute().send(setupScriptCommand(script)) +} + +/** + * Builds the shell command that invokes [script] through its interpreter. Env comes entirely from the + * tab's [com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabBuilder.envVariables], so this + * carries no env-var prefix -- only the script path needs quoting, and its syntax is known exactly + * because [script]'s kind determines which interpreter (and therefore which quoting rules) apply. + */ +internal fun setupScriptCommand(script: SetupScriptTargetDto): String = when (script.kind) { + SetupScriptKind.POSIX -> "sh ${quotePosix(script.path)}" + SetupScriptKind.POWERSHELL -> "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${quoteWindows(script.path)}" + SetupScriptKind.CMD -> "cmd.exe /d /s /c ${quoteWindows(script.path)}" +} + +/** Single-quotes [path] for POSIX shells, escaping embedded single quotes the standard `'\''` way. */ +private fun quotePosix(path: String): String = "'" + path.replace("'", "'\\''") + "'" + +/** Double-quotes [path] for PowerShell/cmd, doubling embedded double quotes. */ +private fun quoteWindows(path: String): String = "\"" + path.replace("\"", "\"\"") + "\"" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 9baffc9ffd45..3353d1787277 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -10,7 +10,9 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto +import ai.kilocode.client.util.edt import com.intellij.ide.ActivityTracker import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog @@ -51,7 +53,9 @@ class KiloWorkspaceService internal constructor( private val workspaces = ConcurrentHashMap() internal val localConfig = ConcurrentHashMap() + internal val setupScript = ConcurrentHashMap() private val pendingLocal = ConcurrentHashMap.newKeySet() + private val pendingSetupScript = ConcurrentHashMap.newKeySet() private val pendingGlobal = AtomicBoolean(false) @Volatile @@ -302,4 +306,50 @@ class KiloWorkspaceService internal constructor( } } + suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto? { + return try { + val target = call { this.setupScriptTarget(directory) } + setupScript[directory] = target + target + } catch (e: Exception) { + LOG.warn("setup script lookup failed for directory=$directory", e) + setupScript[directory] + } + } + + fun refreshSetupScriptTarget(directory: String): Job? { + if (!pendingSetupScript.add(directory)) return null + + return cs.launch { + try { + setupScriptTarget(directory) + } finally { + pendingSetupScript.remove(directory) + ActivityTracker.getInstance().inc() + } + } + } + + fun openSetupScript(directory: String, done: (Boolean) -> Unit) { + cs.launch { + val ok = try { + call { this.openSetupScript(directory) } + } catch (e: Exception) { + LOG.warn("setup script open failed for directory=$directory", e) + false + } + done(ok) + } + } + + /** + * Resolves the setup script for [directory] and invokes [found] with it on the EDT, but only + * when one is actually configured. Silent no-op otherwise, matching VS Code's behavior of doing + * nothing when a worktree has no setup script. + */ + fun ifSetupScriptExists(directory: String, found: (SetupScriptTargetDto) -> Unit): Job = cs.launch { + val target = setupScriptTarget(directory)?.takeIf { it.exists } ?: return@launch + edt { found(target) } + } + } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 333f83f887dc..5e871c4d2b37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -184,6 +184,9 @@ + + @@ -282,6 +285,12 @@ + + + + @@ -293,6 +302,10 @@ + + + + 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 e436a4a26236..57692173fd57 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -426,6 +426,7 @@ worktree.session.new.action=New session worktree.session.open.action=Open worktree.session.open.tooltip=Open worktree in new window worktree.session.terminal.tooltip=Open worktree terminal +worktree.setup.terminal.tabName=Setup: {0} worktree.session.rename.action=Rename session worktree.session.delete.action=Delete session worktree.session.list.expand=Show sessions @@ -929,6 +930,9 @@ action.Kilo.OpenGlobalConfig.text=Open: global {0} action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file +action.Kilo.OpenSetupScript.text=Open Worktree Setup +action.Kilo.CreateSetupScript.text=Create Worktree Setup +action.Kilo.OpenSetupScript.description=Open or create the worktree setup script logs.configuration.level.title=Log level logs.configuration.level.description=Controls the minimum diagnostic log level written by Kilo. Saved user values override JVM properties. logs.configuration.preview.title=Message preview @@ -973,12 +977,16 @@ action.Kilo.Session.CopyShareLink.text=Copy Share Link action.Kilo.Session.CopyShareLink.description=Copy this session's public link to the clipboard action.Kilo.Worktree.Rename.text=Rename action.Kilo.Worktree.Rename.description=Rename the selected worktree -action.Kilo.Worktree.Delete.text=Delete Worktree +action.Kilo.Worktree.Delete.text=Delete action.Kilo.Worktree.Delete.description=Delete the selected worktree -action.Kilo.Worktree.OpenPr.text=Open Pull Request in Browser +action.Kilo.Worktree.OpenPr.text=Open in Browser action.Kilo.Worktree.OpenPr.description=Open the pull request in your browser -action.Kilo.Worktree.OpenDiff.text=Open Diff Against Base Branch +action.Kilo.Worktree.OpenDiff.text=Open Diff Against Base action.Kilo.Worktree.OpenDiff.description=Open the diff against the base branch +action.Kilo.Worktree.OpenLocalDiff.text=Open Uncommitted Changes +action.Kilo.Worktree.OpenLocalDiff.description=Open the diff of uncommitted changes +action.Kilo.Worktree.RunSetupScript.text=Run Worktree Setup +action.Kilo.Worktree.RunSetupScript.description=Run the worktree setup script in a terminal action.Kilo.WorktreeSession.Rename.text=Rename action.Kilo.WorktreeSession.Rename.description=Rename the selected worktree session action.Kilo.WorktreeSession.Delete.text=Delete diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index 2e3fd3060690..3aa017fd1b11 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -373,6 +373,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("id=\"Kilo.Worktree.Delete\"")) assertTrue(xml.contains("id=\"Kilo.Worktree.OpenPr\"")) assertTrue(xml.contains("id=\"Kilo.Worktree.OpenDiff\"")) + assertTrue(xml.contains("id=\"Kilo.Worktree.OpenLocalDiff\"")) + assertTrue(xml.contains("id=\"Kilo.Worktree.RunSetupScript\"")) assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Rename\"")) assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Delete\"")) assertTrue(xml.contains("id=\"Kilo.Worktree.RowMenu\"")) @@ -386,16 +388,30 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("ref=\"Kilo.Worktree.Delete\"")) assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenPr\"")) assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenDiff\"")) + assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenLocalDiff\"")) assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Rename\"")) assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Delete\"")) assertTrue(xml.contains("ref=\"${'$'}Copy\"")) - // Row menu order: rename, (separator), open pr, open diff, (separator), delete. - val rename = xml.indexOf("ref=\"Kilo.Worktree.Rename\"") - val openPr = xml.indexOf("ref=\"Kilo.Worktree.OpenPr\"") - val openDiff = xml.indexOf("ref=\"Kilo.Worktree.OpenDiff\"") - val delete = xml.indexOf("ref=\"Kilo.Worktree.Delete\"") - assertTrue(rename in 0 until openPr && openPr < openDiff && openDiff < delete) + // Row menu order: rename, (separator), open pr, open diff, open local diff, (separator), + // open/create setup script, run setup script, (separator), delete. + val rowMenuStart = xml.indexOf("") + val rowMenuEnd = xml.indexOf("", rowMenuStart) + val rowMenu = xml.substring(rowMenuStart, rowMenuEnd) + assertTrue(rowMenu.contains("ref=\"Kilo.OpenSetupScript\"")) + assertTrue(rowMenu.contains("ref=\"Kilo.Worktree.RunSetupScript\"")) + + val rename = rowMenu.indexOf("ref=\"Kilo.Worktree.Rename\"") + val openPr = rowMenu.indexOf("ref=\"Kilo.Worktree.OpenPr\"") + val openDiff = rowMenu.indexOf("ref=\"Kilo.Worktree.OpenDiff\"") + val openLocalDiff = rowMenu.indexOf("ref=\"Kilo.Worktree.OpenLocalDiff\"") + val openSetup = rowMenu.indexOf("ref=\"Kilo.OpenSetupScript\"") + val runSetup = rowMenu.indexOf("ref=\"Kilo.Worktree.RunSetupScript\"") + val delete = rowMenu.indexOf("ref=\"Kilo.Worktree.Delete\"") + assertTrue( + rename in 0 until openPr && openPr < openDiff && openDiff < openLocalDiff && + openLocalDiff < openSetup && openSetup < runSetup && runSetup < delete, + ) } // ------ Helpers ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 9d4689d514b5..a5b1a8bdff86 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.actions +import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace @@ -9,6 +10,8 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import ai.kilocode.rpc.dto.WorktreeDto import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.AnAction @@ -108,6 +111,11 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("")) assertFalse(xml.contains("")) + + // The setup-script action lives only in the worktree row menu, not the tool-window popup. + val settingsGroupStart = xml.indexOf("") + val settingsGroupEnd = xml.indexOf("", settingsGroupStart) + assertFalse(xml.substring(settingsGroupStart, settingsGroupEnd).contains("Kilo.OpenSetupScript")) } fun `test core info action shows version and architecture`() { @@ -302,6 +310,94 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals(0, rpc.localConfigPathCalls) } + fun `test setup script action says open when target exists`() { + rpc.setupScriptExists = true + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "~/.kilo/setup-script", true) + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + assertEquals("Open Worktree Setup", event.presentation.text) + assertEquals(0, rpc.setupScriptTargetCalls.size) + } + + fun `test setup script action says create when target is missing`() { + rpc.setupScriptExists = false + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "~/.kilo/setup-script", false) + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + assertEquals("Create Worktree Setup", event.presentation.text) + assertEquals(0, rpc.setupScriptTargetCalls.size) + } + + fun `test setup script action refreshes missing target in background`() { + rpc.setupScriptExists = true + val call = CompletableDeferred() + val gate = CompletableDeferred() + rpc.beforeSetupScriptTarget = { + call.complete(Unit) + gate.await() + } + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + // No cached target yet: defaults to the "Open" wording, same as a resolved existing script. + assertEquals("Open Worktree Setup", event.presentation.text) + await(call) + assertEquals(1, rpc.setupScriptTargetCalls.size) + + gate.complete(Unit) + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "/test/.kilo/setup-script", false) + + val next = event(action, workspace = workspace("/test")) + update(action, next) + + assertEquals("Create Worktree Setup", next.presentation.text) + } + + fun `test setup script action disables without directory`() { + val action = OpenSetupScriptAction() + val event = event(action) + + update(action, event) + + assertFalse(event.presentation.isEnabledAndVisible) + assertEquals(0, rpc.setupScriptTargetCalls.size) + } + + fun `test setup script action hides on the main worktree row`() { + val action = OpenSetupScriptAction() + val event = event(action, workspace = workspace("/test"), worktree = WorktreeDto("/test", "main", "main", "/test", main = true)) + + update(action, event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test setup script action visible on a non-main worktree row`() { + rpc.setupScriptExists = true + service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "/test/.kilo/setup-script", true) + val action = OpenSetupScriptAction() + val event = event( + action, + workspace = workspace("/test"), + worktree = WorktreeDto("/test/.kilo/worktrees/feature-x", "feature-x", "feature-x", "/test/.kilo/worktrees/feature-x"), + ) + + update(action, event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + fun `test settings popup group updates recursively in background`() { val group = DefaultActionGroup() val wrapped = KiloSettingsAction.popupGroup(group) @@ -318,6 +414,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals(1, rpc.localConfigPathCalls) assertEquals(1, rpc.globalConfigPathCalls) + assertEquals(0, rpc.setupScriptTargetCalls.size) } fun `test workspace creation prewarms config targets`() { @@ -334,10 +431,10 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals(1, rpc.globalConfigPathCalls) } - private fun event(action: AnAction, workspace: Workspace? = null, place: String = ""): AnActionEvent { + private fun event(action: AnAction, workspace: Workspace? = null, place: String = "", worktree: WorktreeDto? = null): AnActionEvent { val presentation = Presentation().apply { copyFrom(action.templatePresentation) } presentation.isEnabled = false - return AnActionEvent.createFromDataContext(place, presentation, context(workspace)) + return AnActionEvent.createFromDataContext(place, presentation, context(workspace, worktree)) } private fun update(action: AnAction, event: AnActionEvent) { @@ -360,11 +457,12 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { field.set(service(), target) } - private fun context(workspace: Workspace?): DataContext { + private fun context(workspace: Workspace?, worktree: WorktreeDto? = null): DataContext { return DataContext { id -> when (id) { SessionManager.WORKSPACE_KEY.name -> workspace CommonDataKeys.PROJECT.name -> project.takeIf { workspace != null } + WorktreeDataKeys.WORKTREE.name -> worktree else -> null } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index d803d8a6ad98..c493cf83f72a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -39,8 +39,11 @@ import ai.kilocode.client.vfs.KiloPath import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.client.vfs.KiloVirtualFile import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreeDirtyListDto import ai.kilocode.rpc.dto.WorktreeDto @@ -728,6 +731,70 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(KiloBundle.message("diff.editor.branch.title.named", item.branch), file.name) } + fun `test open local diff opens the uncommitted changes editor`() { + val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") + rpc.listed += item + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + assertTrue(edt { panel.canOpenLocalDiff(item) }) + edt { panel.openLocalDiff(item) } + // No branch is passed for the local comparison, so opening resolves the branch name + // asynchronously (for the editor title) on KiloDiffEditorService's own scope before + // creating the tab, hence pumpUntil rather than this test's own coroutines.drain(). + val editors = FileEditorManager.getInstance(project) + assertTrue(coroutines.pumpUntil { edt { editors.openFiles.isNotEmpty() } }) + + val file = edt { editors.openFiles.single() as KiloVirtualFile } + assertEquals(KiloDiffEditorKind.ID, file.path.kind) + assertEquals(item.path, file.path.params["directory"]) + assertEquals("local", file.path.params["source"]) + assertEquals(KiloBundle.message("diff.editor.local.title"), file.name) + } + + fun `test open local diff is hidden on the main worktree row`() { + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + + assertFalse(edt { panel.canOpenLocalDiff(main()) }) + } + + fun `test setup script actions are hidden and disabled on the main worktree row`() { + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + + assertFalse(edt { panel.canOpenSetupScript(main()) }) + assertFalse(edt { panel.canRunSetup(main()) }) + } + + fun `test can run setup script requires an existing script for a non-main worktree row`() { + val workspaceRpc = FakeWorkspaceRpcApi() + ApplicationManager.getApplication() + .replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspaceRpc), testRootDisposable) + val item = worktree("feature-x") + rpc.listed += item + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + assertTrue(edt { panel.canOpenSetupScript(item) }) + // No cached target yet: hidden, not just disabled, and a background refresh is kicked off. + assertFalse(edt { panel.canRunSetup(item) }) + flush() + assertEquals(listOf(controller.directory), workspaceRpc.setupScriptTargetCalls.toList()) + + service().setupScript[controller.directory] = + SetupScriptTargetDto("${controller.directory}/.kilo/setup-script", "", exists = false) + assertFalse(edt { panel.canRunSetup(item) }) + + service().setupScript[controller.directory] = + SetupScriptTargetDto("${controller.directory}/.kilo/setup-script", "", exists = true) + assertTrue(edt { panel.canRunSetup(item) }) + } + fun `test worktree changes clicks use the correct branch after equal-count rows render`() { val first = worktree("first").copy(name = "Custom title") val second = worktree("second") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt new file mode 100644 index 000000000000..ee9ba378a13e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSetupScriptTest.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class WorktreeSetupScriptTest : BasePlatformTestCase() { + fun `test posix command invokes sh with a quoted path`() { + val script = SetupScriptTargetDto("/repo/.kilo/setup-script", "/repo/.kilo/setup-script", true, SetupScriptKind.POSIX) + + assertEquals("sh '/repo/.kilo/setup-script'", setupScriptCommand(script)) + } + + fun `test powershell command uses NoProfile and Bypass with a quoted path`() { + val script = SetupScriptTargetDto("C:\\repo\\.kilo\\setup-script.ps1", "C:\\repo\\.kilo\\setup-script.ps1", true, SetupScriptKind.POWERSHELL) + + assertEquals( + "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"C:\\repo\\.kilo\\setup-script.ps1\"", + setupScriptCommand(script), + ) + } + + fun `test cmd command uses d s c with a quoted path`() { + val script = SetupScriptTargetDto("C:\\repo\\.kilo\\setup-script.cmd", "C:\\repo\\.kilo\\setup-script.cmd", true, SetupScriptKind.CMD) + + assertEquals("cmd.exe /d /s /c \"C:\\repo\\.kilo\\setup-script.cmd\"", setupScriptCommand(script)) + } + + fun `test posix quoting escapes an embedded single quote`() { + val script = SetupScriptTargetDto("/repo/it's/.kilo/setup-script", "/repo/it's/.kilo/setup-script", true, SetupScriptKind.POSIX) + + assertEquals("sh '/repo/it'\\''s/.kilo/setup-script'", setupScriptCommand(script)) + } + + fun `test windows quoting doubles an embedded double quote`() { + val script = SetupScriptTargetDto("C:\\repo \"copy\"\\.kilo\\setup-script.ps1", "", true, SetupScriptKind.POWERSHELL) + + assertEquals( + "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"C:\\repo \"\"copy\"\"\\.kilo\\setup-script.ps1\"", + setupScriptCommand(script), + ) + } + + fun `test posix quoting handles a path containing spaces`() { + val script = SetupScriptTargetDto("/repo/my worktree/.kilo/setup-script", "", true, SetupScriptKind.POSIX) + + assertEquals("sh '/repo/my worktree/.kilo/setup-script'", setupScriptCommand(script)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 35b5ad9d5a49..ea06d5846a39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.pumpEdt import ai.kilocode.rpc.dto.WorkspaceFileDto import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -164,4 +165,27 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(listOf("src"), rpc.searchQueries) } + + fun `test ifSetupScriptExists invokes callback when a script is configured`() = runBlocking { + rpc.setupScriptExists = true + rpc.setupScriptPath = "/test/.kilo/setup-script" + var seen: String? = null + + service.ifSetupScriptExists("/test") { seen = it.path }.join() + pumpEdt() + + assertEquals("/test/.kilo/setup-script", seen) + assertEquals(listOf("/test"), rpc.setupScriptTargetCalls.toList()) + } + + fun `test ifSetupScriptExists is silent when no script is configured`() = runBlocking { + rpc.setupScriptExists = false + var called = false + + service.ifSetupScriptExists("/test") { called = true }.join() + pumpEdt() + + assertFalse(called) + assertEquals(listOf("/test"), rpc.setupScriptTargetCalls.toList()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 22b5e33454fb..6d2cb555ea86 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -7,6 +7,8 @@ import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptKind +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.project.ProjectId import kotlinx.coroutines.CompletableDeferred @@ -58,6 +60,13 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var beforeLocalConfigTarget: (suspend () -> Unit)? = null var beforeGlobalConfigTarget: (suspend () -> Unit)? = null var refreshConfigThrows: Exception? = null + var setupScriptPath = "/test/.kilo/setup-script" + var setupScriptDisplayPath = setupScriptPath + var setupScriptExists = false + var setupScriptKind = SetupScriptKind.POSIX + var beforeSetupScriptTarget: (suspend () -> Unit)? = null + val setupScriptTargetCalls = CopyOnWriteArrayList() + val setupScripts = CopyOnWriteArrayList() val fileCalls = CopyOnWriteArrayList>() val searchQueries = CopyOnWriteArrayList() val opened = CopyOnWriteArrayList() @@ -171,5 +180,18 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return openResult } + override suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto { + assertNotEdt("setupScriptTarget") + setupScriptTargetCalls.add(directory) + beforeSetupScriptTarget?.invoke() + return SetupScriptTargetDto(setupScriptPath, setupScriptDisplayPath, setupScriptExists, setupScriptKind) + } + + override suspend fun openSetupScript(directory: String): Boolean { + assertNotEdt("openSetupScript") + setupScripts.add(directory) + return openResult + } + data class Opened(val path: String, val line: Int?, val column: Int?, val endLine: Int? = null) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 4a34ad0b7e44..910aa2404429 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.SetupScriptTargetDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.project.ProjectId import com.intellij.platform.rpc.RemoteApiProviderService @@ -79,4 +80,10 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Open or create the global config file in the IDE. */ suspend fun openGlobalConfig(): Boolean + + /** Resolve the worktree setup script target for the repo rooted at [directory]. */ + suspend fun setupScriptTarget(directory: String): SetupScriptTargetDto + + /** Open or create the worktree setup script in the IDE. */ + suspend fun openSetupScript(directory: String): Boolean } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt new file mode 100644 index 000000000000..796467c63234 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SetupScriptTargetDto.kt @@ -0,0 +1,15 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +/** Which interpreter a resolved worktree setup script should be run through. */ +@Serializable +enum class SetupScriptKind { POSIX, POWERSHELL, CMD } + +@Serializable +data class SetupScriptTargetDto( + val path: String, + val displayPath: String, + val exists: Boolean, + val kind: SetupScriptKind = SetupScriptKind.POSIX, +) From b9d0c736efdf8561dda9306db6b09f7fcca07ddd Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 15:31:05 -0400 Subject: [PATCH 10/19] fix(jetbrains): make HoverIcon.match height stable across LAF font metrics Some button UIs size a null/empty-text button purely from its icon, skipping the font-metrics contribution a labelled sibling gets from its non-empty text. That made icon-only HoverIcon.match buttons (e.g. the worktree header's Terminal action) track the icon height instead of the shared labelled-button height whenever a platform's font metrics were taller than the icon, producing a 1px mismatch on CI (Linux) that did not reproduce locally (macOS). Measure through a temporary single-space placeholder so match mode goes through the same icon+text layout path a labelled button uses, keeping both heights level regardless of LAF or font metrics. --- .../kotlin/ai/kilocode/client/ui/HoverIcon.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index b8b337583bcf..e858370be699 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -44,7 +44,7 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { override fun getPreferredSize(): Dimension { if (!text.isNullOrEmpty()) return super.getPreferredSize() if (match) { - val side = super.getPreferredSize().height + val side = labelledHeight() return Dimension(side, side) } val icon = icon ?: return JBUI.size(MIN, MIN) @@ -52,6 +52,21 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { return Dimension(side, side) } + // Button UIs commonly special-case null/empty text and size purely from the icon, skipping the + // font-metrics contribution a labelled sibling gets from its non-empty text. That makes an + // icon-only button's height track the icon instead of the shared labelled-button height whenever + // the LAF's font metrics are taller than the icon. Measuring through a non-empty placeholder + // forces the same icon+text layout path a labelled button uses, so both stay level everywhere. + private fun labelledHeight(): Int { + val previous = text + text = " " + try { + return super.getPreferredSize().height + } finally { + text = previous + } + } + override fun getMinimumSize(): Dimension = preferredSize override fun getMaximumSize(): Dimension = preferredSize From a68cb8a03610e22895ae478304a8a1201c52778f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 18:59:44 -0400 Subject: [PATCH 11/19] fix(jetbrains): stop mode picker from cancelling running sessions Switching the chat mode picker wrote default_agent to the CLI's global config. The CLI disposes every instance it holds whenever that file changes, which cancels every running turn in every open worktree -- three unrelated sessions died mid-turn from a single mode switch, with no error shown because a server-initiated cancellation and a user Stop both report the same MessageAbortedError. The mode pick now stays client-side: it rides on PromptDto.agent per turn (as it already did) and is remembered in KiloPluginSettings so new sessions still open in the last-picked mode, matching how VS Code and the TUI already handle this. No CLI config write happens. Since the CLI can still legitimately cancel a turn on its own (a settings/provider change disposing instances), the plugin now tells those apart from a user Stop: an unrequested abort shows the reason and offers Retry instead of a silent "Stopped", raises a notification, and is captured in telemetry. The backend synthesizes a session.interrupted event naming the cause when disposal happens while a session is busy. --- .../jetbrains-mode-switch-cancels-sessions.md | 9 + .../backend/app/KiloBackendActivityManager.kt | 4 + .../backend/app/KiloBackendAppService.kt | 15 +- .../backend/app/KiloBackendChatManager.kt | 36 ++-- .../kilocode/backend/cli/KiloCliDataParser.kt | 26 --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 4 - .../backend/app/KiloBackendAppServiceTest.kt | 45 +++++ .../backend/app/KiloBackendChatManagerTest.kt | 24 +++ .../backend/cli/KiloCliDataParserTest.kt | 30 --- .../kilocode/client/app/KiloSessionService.kt | 6 - .../client/plugin/KiloPluginSettings.kt | 19 ++ .../session/controller/SessionController.kt | 127 ++++++++++-- .../resources/messages/KiloBundle.properties | 3 + .../worktree/NewWorktreeDialogTest.kt | 10 +- .../session/controller/ConfigSelectionTest.kt | 37 +++- .../session/controller/PromptLifecycleTest.kt | 6 +- .../controller/SessionCancellationTest.kt | 188 ++++++++++++++++++ .../controller/SessionControllerTestBase.kt | 10 + .../session/controller/TurnLifecycleTest.kt | 3 + .../client/testing/FakeSessionRpcApi.kt | 7 - .../kotlin/ai/kilocode/log/ChatLogSummary.kt | 7 + .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 4 - .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 28 ++- 23 files changed, 514 insertions(+), 134 deletions(-) create mode 100644 .changeset/jetbrains-mode-switch-cancels-sessions.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt diff --git a/.changeset/jetbrains-mode-switch-cancels-sessions.md b/.changeset/jetbrains-mode-switch-cancels-sessions.md new file mode 100644 index 000000000000..3a2bdf00306d --- /dev/null +++ b/.changeset/jetbrains-mode-switch-cancels-sessions.md @@ -0,0 +1,9 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix switching chat mode cancelling running tasks in every open worktree, and explain any task Kilo stops on its own + +Picking a mode in the chat prompt used to be saved as the CLI's global default, which made the CLI reload and cancel every task that was running anywhere. The mode now stays in the IDE and travels with each message, and it is still remembered for new chats. + +When Kilo does stop a task without being asked — a settings or provider change, for example — the chat now shows why, offers Retry, and raises a notification, instead of quietly reporting "Stopped". diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index 24c1752d3cc0..f98dc1c0b472 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -89,6 +89,10 @@ class KiloBackendActivityManager( // A Stop publishes MessageAbortedError. That is a deliberate user action, not a failure, so // it must not badge the session list, worktree rows, or the Agents tab attention dot. is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) } + // A cancellation the user did not ask for is a failure, and the abort that follows it is + // indistinguishable from a Stop. Badge it from the interruption instead, so the session + // list, worktree rows, and the Agents tab dot all report the lost turn. + is ChatEventDto.SessionInterrupted -> errors.add(event.sessionID) is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) // Not every failure publishes a session error — a turn whose provider ended the response in // error writes the failure onto the message and only reports it through this close reason. The diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 519efd34eb55..03d94c16f92f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -19,6 +19,7 @@ import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.ConfigPatchDto @@ -826,13 +827,13 @@ class KiloBackendAppService private constructor( } } "global.disposed" -> { - logSessionDisposalRisk("global.disposed") + reportDisposal("global.disposed") log.info("SSE global.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() } "server.instance.disposed" -> { - logSessionDisposalRisk("server.instance.disposed") + reportDisposal("server.instance.disposed") log.info("SSE server.instance.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() @@ -843,10 +844,18 @@ class KiloBackendAppService private constructor( } } - private fun logSessionDisposalRisk(event: String) { + /** + * Warn, and tell every running session that the CLI is about to cancel it. + * + * Disposing an instance cancels every runner it owns, and the CLI reports that as the same + * `MessageAbortedError` a user Stop produces. Naming the cause here is the only way the UI can + * tell the difference and explain itself instead of quietly reporting "Stopped". + */ + private fun reportDisposal(event: String) { val active = sessions.statuses.value.filterValues { it.type != "idle" } if (active.isEmpty()) return log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + chat.interrupt(active.keys, ChatEventDto.SessionInterrupted.RELOAD) } private suspend fun clear() { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 4ebd49744e29..43833a6d6f65 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -4,7 +4,6 @@ import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ChatEventDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -327,25 +326,22 @@ class KiloBackendChatManager( } } - // ------ config update ------ - - fun updateConfig(dir: String, update: ConfigUpdateDto) { - val http = requireClient() - val url = requireBase() - - val partial = KiloCliDataParser.buildConfigPartial(update) - - val request = Request.Builder() - .url("$url/global/config") - .patch(partial.toRequestBody(JSON_TYPE)) - .build() - - http.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - val msg = response.body?.string() ?: "unknown error" - log.warn("config update failed: HTTP ${response.code} — $msg") - } else { - log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}") + // ------ interruption ------ + + /** + * Tell every session in [ids] that the CLI stopped its turn for [reason]. + * + * Synthesized into the same stream the CLI events use so a session's own controller sees it in + * order with the abort it explains. The CLI cannot express this itself: it reports a server-side + * cancellation as the same `MessageAbortedError` a user Stop produces, so the UI would otherwise + * report work nobody stopped as "Stopped". + */ + fun interrupt(ids: Collection, reason: String) { + if (ids.isEmpty()) return + cs.launch { + for (id in ids) { + log.warn("${ChatLogSummary.sid(id)} kind=interrupt route=chat-events reason=$reason") + _events.emit(ChatEventDto.SessionInterrupted(id, reason)) } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 1e401e45b5cd..5f82ef59fe87 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -20,7 +20,6 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto @@ -915,31 +914,6 @@ object KiloCliDataParser { return "{${fields.joinToString(",")}}" } - /** - * Build the partial JSON body for `PATCH /global/config`. - */ - fun buildConfigPartial(update: ConfigUpdateDto): String { - val sb = StringBuilder("{") - var first = true - fun sep() { if (!first) sb.append(","); first = false } - - val model = update.model - if (model != null) { - sep(); sb.append(""""model":${escape(model)}""") - } - val agent = update.agent - if (agent != null) { - sep(); sb.append(""""default_agent":${escape(agent)}""") - } - val temp = update.temperature - if (temp != null) { - val target = agent ?: "ask" - sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""") - } - sb.append("}") - return sb.toString() - } - fun buildConfigPatch(patch: ConfigPatchDto): String { val allowed = setOf("model", "small_model", "subagent_model", "subagent_variant", "default_agent") val obj = buildJsonObject { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 49764a2e2c12..80b21f39f1ec 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -11,7 +11,6 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -286,9 +285,6 @@ class KiloSessionRpcApiImpl internal constructor( log.warn("${ChatLogSummary.sid(id)} kind=subscription route=rpc-events stop=true failed message=${cause.message}", cause) } - override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = - ready { chat.updateConfig(directory, config) } - // ------ permission / question resolution ------ override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index d8009e795870..7e6d052561f7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -9,13 +9,16 @@ import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -836,6 +839,48 @@ class KiloBackendAppServiceTest { assertNotNull(svc.config) } + /** + * Disposing an instance cancels every runner it owns, and the CLI reports that as the same + * `MessageAbortedError` a user Stop produces. Naming the cause here is the only thing that lets the + * UI explain the lost turn instead of reporting it as "Stopped" — three sessions once died to a + * config reload with no trace the user could see. + */ + @Test + fun `disposal while a session is busy names the reason for that session`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""") + withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } + + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + svc.chat.events.first { it is ChatEventDto.SessionInterrupted } + } + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + val event = assertIs(withTimeout(15_000) { received.await() }) + assertEquals("ses_abc", event.sessionID) + assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) + } + + @Test + fun `disposal with no busy session names nothing`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + svc.chat.events.first { it is ChatEventDto.SessionInterrupted } + } + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + assertNull(withTimeoutOrNull(2_000) { received.await() }) + received.cancel() + } + @Test fun `restart lifecycle transitions correctly`() = runBlocking { val svc = create() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index 3225fef1a03c..7a5a0b0c19a9 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -229,6 +229,30 @@ class KiloBackendChatManagerTest { assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n")) } + /** + * A cancellation the CLI starts on its own reaches the UI as the same `MessageAbortedError` a Stop + * produces, so the reason has to travel on its own event through the same stream. + */ + @Test + fun `interrupt emits one reason event per running session`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + // UNDISPATCHED runs the collector up to its first suspension on this thread, so the subscription + // is registered before interrupt() emits — the flow buffers rather than blocks, so a late + // subscriber would simply miss the event. + val received = async(start = CoroutineStart.UNDISPATCHED) { + chat.events.first { it is ChatEventDto.SessionInterrupted } + } + chat.interrupt(listOf("ses_abc"), ChatEventDto.SessionInterrupted.RELOAD) + + val event = withTimeout(5_000) { received.await() } + assertTrue(event is ChatEventDto.SessionInterrupted) + assertEquals("ses_abc", event.sessionID) + assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) + } + @Test fun `global message event type is extracted from payload`() = runBlocking { val port = mock.start() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 892102f64ac9..7743b48c2408 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -7,7 +7,6 @@ import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -2306,29 +2305,6 @@ class KiloCliDataParserTest { assertEquals("""{"messageID":"m\"\\1","partID":"p\"\\1"}""", result) } - // ---- buildConfigPartial ---- - - @Test - fun `buildConfigPartial - model only`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) - assertEquals("""{"model":"anthropic/claude-4"}""", result) - } - - @Test - fun `buildConfigPartial - agent and temperature`() { - val result = KiloCliDataParser.buildConfigPartial( - ConfigUpdateDto(agent = "code", temperature = 0.7) - ) - assertTrue(result.contains(""""default_agent":"code"""")) - assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) - } - - @Test - fun `buildConfigPartial - empty update`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) - assertEquals("{}", result) - } - @Test fun `buildConfigPatch - top-level model set`() { val patch = ConfigPatchDto(values = linkedMapOf("model" to "anthropic/claude")) @@ -2470,12 +2446,6 @@ class KiloCliDataParserTest { assertEquals("{\"model\":\"kilo/a\\\\b\\\"c\"}", KiloCliDataParser.buildConfigPatch(patch)) } - @Test - fun `buildConfigPartial - temperature without agent defaults to ask`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) - assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) - } - // ---- buildPermissionReplyJson ---- @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 47d723dcac4f..2fde1784546c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -8,7 +8,6 @@ import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -317,11 +316,6 @@ class KiloSessionService internal constructor( } } - /** Update config (model, agent/mode, temperature). */ - suspend fun updateConfig(dir: String, config: ConfigUpdateDto) { - call { updateConfig(dir, config) } - } - // ------ permission / question resolution ------ /** Reply to a pending permission request. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index ac54c6597fc3..6a8564a48989 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -7,6 +7,25 @@ object KiloPluginSettings { private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" + private const val AGENT_KEY = "kilo.session.agent" + + /** + * Mode the prompt picker last selected, or null when the CLI's own default should win. + * + * IDE-local on purpose. This used to be written to the CLI's global config as `default_agent`, + * which made the CLI dispose every instance it held and cancel every running turn in every + * worktree — a mode switch is a UI preference, not a server reconfiguration. The picked mode + * still travels with each prompt, so nothing here reaches the server. + */ + fun getAgent(): String? = PropertiesComponent.getInstance().getValue(AGENT_KEY)?.takeIf { it.isNotBlank() } + + fun setAgent(value: String) { + PropertiesComponent.getInstance().setValue(AGENT_KEY, value) + } + + internal fun unsetAgent() { + PropertiesComponent.getInstance().unsetValue(AGENT_KEY) + } fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a0d833949e36..50add9133779 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace @@ -32,15 +33,16 @@ import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.client.util.edtLater as edt +import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ProfileDto @@ -106,6 +108,7 @@ class SessionController( private val loaded: (Boolean) -> Unit = {}, private val openProfileAction: () -> Unit = {}, private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, + private val notify: (String, String) -> Unit = { title, body -> KiloNotifications.error(title, body) }, private val timers: UiTimerSource = UiTimers, private val log: KiloLog = LOG, ) : Disposable { @@ -192,6 +195,12 @@ class SessionController( private var prefVariantKey: String? = null private var prefVariant: String? = null private var modelTime: Double? = null + // A Stop this UI asked for, so the abort it produces reads as "Stopped" rather than a failure. + // Reset on every turn open: the flag describes one cancellation, not the session. + private var stopRequested = false + // Why the CLI cancelled the current turn, when it told us. Races the abort it explains, so the + // reason may land before or after the error and both orders have to end up in the same place. + private var cancelReason: String? = null private val snapshots = mutableMapOf() val ready: Boolean get() = model.isReady() @@ -303,6 +312,10 @@ class SessionController( private fun dispatch(data: Dispatch, send: suspend (String) -> Unit) { assertEdt() if (revertOp != null) return + // New work, so the previous cancellation is settled. Turn open clears this too, but a send that + // never reaches a turn would otherwise leave a stale Stop suppressing the next explanation. + stopRequested = false + cancelReason = null val props = data.props + if (data.kind == "command") slashProps() else emptyMap() capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf( "source" to data.source, @@ -380,6 +393,7 @@ class SessionController( return } val id = sid ?: return + stopRequested = true updateModel { (childIds + id).forEach(::purgePending) } capture("Session Stop Clicked", sessionProps(id)) cs.launch { @@ -499,6 +513,9 @@ class SessionController( SessionState.Reverting.Kind.ROLLBACK, message, ) ?: return + // Marked here rather than beside the abort below: the abort's error event can arrive before a + // hop back onto the EDT would, and an unmarked abort reads as a cancellation we did not ask for. + if (busy) stopRequested = true revertJob = cs.launch { try { if (busy) { @@ -590,7 +607,9 @@ class SessionController( val state = model.state val failed = when { // A user stop also lands an errored tail (MessageAbortedError), and it is not a failure. - err != null -> !err.aborted + // A stop the user never asked for is: `error()` promoted it to SessionState.Error, so + // follow the state the transcript is already showing rather than the error name alone. + err != null -> !err.aborted || state is SessionState.Error // A turn that completed cleanly is not retryable even when a session-level error arrives // afterwards: continuing it would ask the model to redo work it already delivered. tail.info.role == "assistant" && tail.info.time.completed != null -> false @@ -686,6 +705,7 @@ class SessionController( SessionState.Reverting.Kind.REDO, message, ) ?: return + if (busy) stopRequested = true revertJob = cs.launch { try { if (busy) { @@ -761,6 +781,15 @@ class SessionController( } } + /** + * Switch this session's mode. + * + * Stays entirely client-side. The pick lives on [SessionModel.agent] for this session and in + * [KiloPluginSettings] as the mode the next new session opens with, and it reaches the CLI only + * as [PromptDto.agent] on each turn. It must never be written to the CLI's global config: the + * CLI disposes every instance it holds when that file changes, which cancels every running turn + * in every worktree. `NewWorktreeDialog` reached the same conclusion for its own picker. + */ fun selectAgent(name: String) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" } @@ -770,13 +799,7 @@ class SessionController( prefAgent = null prefVariantKey = null prefVariant = null - cs.launch { - try { - sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) - } catch (e: Exception) { - LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - } - } + KiloPluginSettings.setAgent(name) fire(SessionControllerEvent.WorkspaceReady) { model.agent = name syncModelSelection() @@ -1152,7 +1175,7 @@ class SessionController( } ?: emptyList() if (this@SessionController.model.agent == null) { - this@SessionController.model.agent = state.agents?.default + this@SessionController.model.agent = seedAgent(state.agents) } syncModelSelection() model.refreshHeader() @@ -1617,6 +1640,8 @@ class SessionController( is ChatEventDto.TurnOpen -> { partType = null tool = null + stopRequested = false + cancelReason = null if (revertOp != null) { revertDeferred = SessionState.Busy(KiloBundle.message("session.status.considering")) return @@ -1657,7 +1682,7 @@ class SessionController( is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - if (event.error?.aborted != true) { + if (event.error?.aborted != true || unrequested(event.error)) { capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) } error(event, true) @@ -1707,6 +1732,7 @@ class SessionController( } is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) + is ChatEventDto.SessionInterrupted -> interrupted(event) } } @@ -1732,6 +1758,7 @@ class SessionController( private fun handleHidden(event: ChatEventDto): Boolean = when (event) { is ChatEventDto.Error, + is ChatEventDto.SessionInterrupted, is ChatEventDto.PermissionAsked, is ChatEventDto.PermissionReplied, is ChatEventDto.QuestionAsked, @@ -1754,6 +1781,7 @@ class SessionController( LOG.debug { ChatLogSummary.event(event) } when (event) { is ChatEventDto.Error -> error(event, false) + is ChatEventDto.SessionInterrupted -> interrupted(event) is ChatEventDto.PermissionAsked -> asked(event) is ChatEventDto.PermissionReplied -> replied(event) is ChatEventDto.QuestionAsked -> asked(event) @@ -1770,7 +1798,8 @@ class SessionController( private fun error(event: ChatEventDto.Error, reveal: Boolean) { partType = null tool = null - if (isPaidModelAuthRequired(event.error)) { + val err = event.error + if (isPaidModelAuthRequired(err)) { loginRetry = retryPrompt() if (reveal) showSession() capture("Account Overlay Shown", sessionProps(event.sessionID) + mapOf( @@ -1780,9 +1809,62 @@ class SessionController( model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) return } - if (event.error?.aborted == true) return - val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - model.setState(SessionState.Error(msg, event.error?.type)) + if (err != null && err.aborted) { + if (stopRequested) return + surfaceCancelled(event.sessionID, err.type) + return + } + val msg = err?.message ?: err?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, err?.type)) + } + + /** + * Whether an abort arrived that this UI never asked for. + * + * The CLI cannot tell us: it reports a Stop and a server-side cancellation with the same + * `MessageAbortedError`, so only the client knows whether it pressed Stop. Everything else — + * a config reload disposing instances, a session deleted elsewhere, another editor aborting the + * same session — lands here and has to be explained rather than passed off as the user's doing. + */ + private fun unrequested(err: MessageErrorDto?): Boolean = err != null && err.aborted && !stopRequested + + /** + * Report a cancellation nobody in this UI asked for. + * + * Uses [SessionState.Error] rather than an interrupted outcome so the transcript prints the reason + * and offers Retry: the turn lost its work, which is a failure however politely the CLI phrased it. + * The balloon is for the case that caused this to exist — a session cancelled in a worktree the + * user is not currently looking at, which the transcript alone can never tell them about. + */ + private fun surfaceCancelled(session: String?, kind: String) { + val reason = cancelReason + val text = cancelledMessage(reason) + LOG.warn("${ChatLogSummary.sid(session ?: sid ?: "?")} kind=cancelled requested=false reason=${reason ?: "unknown"}") + model.setState(SessionState.Error(text, kind)) + notify(KiloBundle.message("session.cancelled.title"), text) + } + + private fun cancelledMessage(reason: String?): String = when (reason) { + ChatEventDto.SessionInterrupted.RELOAD -> KiloBundle.message("session.cancelled.reload") + else -> KiloBundle.message("session.cancelled.unknown") + } + + /** + * Record why the CLI stopped this turn, and re-label an already-visible cancellation. + * + * This races the abort it explains — the CLI publishes the cancellation before it finishes + * disposing, and the disposal event that names the cause can land on either side of it. Handling + * both orders here keeps the reason out of the ordering's hands. + */ + private fun interrupted(event: ChatEventDto.SessionInterrupted) { + if (cancelReason == event.reason) return + cancelReason = event.reason + val current = model.state + // Only a cancellation already on screen needs relabelling, and only [surfaceCancelled] puts the + // abort's name on an error state — a provider failure carries its own reason and keeps it. + if (current !is SessionState.Error) return + if (current.kind != MessageErrorDto.ABORTED) return + model.setState(SessionState.Error(cancelledMessage(event.reason), current.kind)) } private fun asked(event: ChatEventDto.PermissionAsked) { @@ -2187,6 +2269,20 @@ class SessionController( open(SessionRef.Local(session)) } + /** + * Mode a session with no history of its own opens in. + * + * The last mode the picker selected wins, so switching mode still sticks across new sessions and + * IDE restarts without the CLI's global config — the write that used to provide this also tore + * down every instance the CLI held. Falls back to the CLI default when the remembered mode is + * gone (renamed, hidden, or removed from a different config). + */ + private fun seedAgent(agents: AgentsDto?): String? { + val remembered = KiloPluginSettings.getAgent() ?: return agents?.default + val offered = agents?.agents ?: return remembered + return if (offered.any { it.name == remembered }) remembered else agents.default + } + private fun syncHistoryAgent(items: List) { val before = model.prefs() val agent = items @@ -2767,6 +2863,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.SessionStatusChanged -> event.sessionID == id is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionInterrupted -> event.sessionID == id is ChatEventDto.SessionQueueChanged -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id 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 41c5ac8b65fb..7f8499752c45 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -242,6 +242,9 @@ session.outcome.incomplete.description=The provider ended this response without session.outcome.incomplete.reason=Technical finish reason: {0} session.outcome.retry=Retry session.outcome.interrupted.note=Stopped +session.cancelled.title=Kilo stopped a running task +session.cancelled.reload=Kilo reloaded its configuration and stopped this task before it finished. Nothing was lost from the conversation \u2014 use Retry to continue. +session.cancelled.unknown=This task was stopped before it finished, and not from this window. Use Retry to continue. session.login.required.title=You need to sign in to use this model session.login.required.description=Go to User Profile settings to sign in, then continue this session. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt index 0edfd4b885cd..48080bc5199a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker @@ -51,6 +52,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { val ws = FakeWorkspaceRpcApi().apply { models = workspace() } workspaces = KiloWorkspaceService(scope, ws) sessionRpc = FakeSessionRpcApi() + KiloPluginSettings.unsetAgent() } override fun tearDown() { @@ -58,6 +60,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { dialog?.let { d -> edt { Disposer.dispose(d.disposable) } } dialog = null scope.cancel() + KiloPluginSettings.unsetAgent() } finally { super.tearDown() } @@ -75,7 +78,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { } } - fun `test selecting a mode forwards it with the created prompt and writes no global config`() { + fun `test selecting a mode forwards it with the created prompt only`() { open() flushUntil { edt { model().selectionKeyForTest() != null } } @@ -86,9 +89,10 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { flushUntil { edt { prompt().isSendEnabled } } edt { prompt().send() } + // The prompt is the only place the pick travels: writing it to the CLI's global default_agent + // disposed every instance the CLI held and cancelled every running turn in every worktree. assertEquals("plan", submitted().prompt?.agent) - // Picking a mode must no longer mutate the global default_agent config. - assertTrue(sessionRpc.configs.none { it.second.agent != null }) + assertNull(KiloPluginSettings.getAgent()) } fun `test selecting a model persists it for the default agent`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt index 1a986bc6c9a7..652665aa2bcf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.ConfigDto @@ -21,7 +22,6 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertTrue(rpc.configs.isEmpty()) assertEquals("code", appRpc.selections.single().agent) assertEquals("kilo", appRpc.selections.single().providerID) assertEquals("gpt-5", appRpc.selections.single().modelID) @@ -34,7 +34,11 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } - fun `test selectAgent updates SessionModel and calls updateConfig`() { + /** + * A mode switch must stay client-side. Writing it to the CLI's global config made the CLI dispose + * every instance it held, cancelling every running turn in every worktree. + */ + fun `test selectAgent stays local and never patches CLI config`() { val m = controller() collect(m) flush() @@ -42,8 +46,7 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectAgent("plan") } flush() - assertEquals(1, rpc.configs.size) - assertEquals("plan", rpc.configs[0].second.agent) + assertEquals("plan", KiloPluginSettings.getAgent()) assertSession( """ [plan] [app: DISCONNECTED] [workspace: PENDING] @@ -53,6 +56,32 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } + fun `test remembered mode seeds a new session ahead of the CLI default`() { + edt { KiloPluginSettings.setAgent("plan") } + projectRpc.state.value = workspaceReady( + agents = listOf( + AgentDto(name = "code", displayName = "Code", mode = "code"), + AgentDto(name = "plan", displayName = "Plan", mode = "code"), + ), + default = "code", + ) + val m = controller() + collect(m) + flush() + + assertEquals("plan", m.model.agent) + } + + fun `test CLI default wins when the remembered mode no longer exists`() { + edt { KiloPluginSettings.setAgent("removed-mode") } + projectRpc.state.value = workspaceReady(default = "code") + val m = controller() + collect(m) + flush() + + assertEquals("code", m.model.agent) + } + fun `test selectModel fires WorkspaceReady event`() { projectRpc.state.value = workspaceReady() val m = controller() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 768c9e36b397..0e98ab904a72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -502,7 +502,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals("plan", m.model.agent) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies) emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_code", "ses_test", "user").copy( @@ -514,7 +514,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("code", m.model.agent) assertEquals("anthropic/claude", m.model.model) assertFalse(m.model.modelOverride) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertControllerEvents("WorkspaceReady", events) } @@ -533,7 +533,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals("plan", m.model.agent) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt new file mode 100644 index 000000000000..9f6a90c44e96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt @@ -0,0 +1,188 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto + +/** + * A cancellation nobody asked for has to explain itself. + * + * The CLI reports a Stop and a server-side cancellation with the same `MessageAbortedError`, and the UI + * used to treat both as a Stop: a muted "Stopped" line, no reason, no Retry, no telemetry. Three + * sessions once died to a config reload leaving no trace anywhere the user could see. Only this client + * knows whether it pressed Stop, so that is what separates the two here. + */ +class SessionCancellationTest : SessionControllerTestBase() { + + private val abort = MessageErrorDto(type = MessageErrorDto.ABORTED, message = "Aborted") + + private fun reloadText() = KiloBundle.message("session.cancelled.reload") + + private fun unknownText() = KiloBundle.message("session.cancelled.unknown") + + private fun cancelled(text: String) = SessionState.Error(text, MessageErrorDto.ABORTED) + + fun `test unrequested abort surfaces an error instead of Stopped`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(unknownText()), m.model.state) + } + + fun `test interruption names the reason on the error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(reloadText()), m.model.state) + } + + /** + * The CLI publishes the cancellation while it is still disposing, so the event naming the cause can + * land on either side of the abort. Both orders have to end up on the same message. + */ + fun `test interruption arriving after the abort relabels the error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + assertEquals(cancelled(unknownText()), m.model.state) + + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + + assertEquals(cancelled(reloadText()), m.model.state) + } + + /** The transcript cannot reach a worktree the user is not looking at; the balloon can. */ + fun `test unrequested abort raises a balloon naming the reason`() { + prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + emit(ChatEventDto.Error("ses_test", abort)) + + assertEquals(listOf(KiloBundle.message("session.cancelled.title") to reloadText()), notifications) + } + + fun `test unrequested abort is reported to telemetry`() { + prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + + assertTrue( + appRpc.telemetry.any { + it.event == "Session Error" && it.properties["errorClass"] == MessageErrorDto.ABORTED + }, + ) + } + + fun `test unrequested abort offers Retry`() { + val m = failedTurn() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(unknownText()), m.model.state) + edt { assertTrue("A turn the user never stopped lost work, so it must be continuable", m.canRetry()) } + } + + fun `test stopped turn offers no Retry`() { + val m = failedTurn() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertTrue(notifications.isEmpty()) + edt { assertFalse(m.canRetry()) } + } + + /** A revert aborts a busy session on purpose, so its abort is a Stop and not a lost turn. */ + fun `test revert abort counts as requested`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.revert("msg1") } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + + assertTrue(notifications.isEmpty()) + assertFalse(m.model.state is SessionState.Error) + } + + /** The flag describes one cancellation. A later turn must not inherit the earlier Stop. */ + fun `test a new turn stops inheriting the previous Stop`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + assertTrue(notifications.isEmpty()) + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + + assertEquals(cancelled(unknownText()), m.model.state) + } + + /** A reopened session cannot know who stopped its last turn, so history stays a plain "Stopped". */ + fun `test reopened session keeps an aborted tail as Stopped`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = abort), + emptyList(), + ), + ) + val m = controller("ses_test") + collect(m) + flush() + + assertTrue(m.model.state is SessionState.TurnEnded) + assertTrue(notifications.isEmpty()) + } + + /** + * A loaded session whose tail can be continued: Retry needs a user message to re-prompt and an + * assistant message parented to it. + */ + private fun failedTurn(): SessionController { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = abort), + emptyList(), + ), + ) + val m = controller("ses_test") + collect(m) + flush() + return m + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 1424560cf62f..b733c27c7d9d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.AgentDto @@ -106,12 +107,19 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected lateinit var scope: CoroutineScope protected lateinit var parent: Disposable + /** Balloons a controller raised, instead of real IDE notifications. */ + protected val notifications = mutableListOf>() + override fun setUp() { super.setUp() rpc = FakeSessionRpcApi() appRpc = FakeAppRpcApi() projectRpc = FakeWorkspaceRpcApi() timers = TestUiTimers() + notifications.clear() + // Application-level and shared across tests in a fixture, and it now seeds a new session's + // mode, so a leftover pick from another test would decide this one's starting agent. + KiloPluginSettings.unsetAgent() coroutines = TestCoroutines() scope = coroutines.scope @@ -127,6 +135,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { try { Disposer.dispose(parent) coroutines.close() + KiloPluginSettings.unsetAgent() } finally { super.tearDown() } @@ -184,6 +193,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, telemetry = { event, props -> appRpc.telemetry.add(TelemetryCaptureDto(event, props)) }, + notify = { title, body -> notifications.add(title to body) }, timers = timers, log = log ?: KiloLog.create(SessionController::class.java), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index fdd0b6062604..f6f7c30532bd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -251,10 +251,13 @@ class TurnLifecycleTest : SessionControllerTestBase() { val (m, _, _) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "MessageAbortedError", message = "aborted"))) emit(ChatEventDto.TurnClose("ses_test", "interrupted")) assertTrue(appRpc.telemetry.none { it.event == "Session Error" && it.properties["errorClass"] == "MessageAbortedError" }) + assertTrue(notifications.isEmpty()) assertSession( """ [code] [kilo/gpt-5] [interrupted] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 2c23423c469f..b9f7314525c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -4,7 +4,6 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -117,7 +116,6 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val messageDeletes = mutableListOf() var messageDeleteResult = true val unreverts = mutableListOf>() - val configs = mutableListOf>() val permissionReplies = mutableListOf>() val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() @@ -333,11 +331,6 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return eventFlow?.invoke(id, directory) ?: events } - override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) { - assertNotEdt("updateConfig") - configs.add(directory to config) - } - var replyPermissionThrows: Exception? = null override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 6c569ef89a60..1175f8727dd2 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -37,6 +37,7 @@ object ChatLogSummary { is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID + is ChatEventDto.SessionInterrupted -> event.sessionID } fun dir(dir: String): String = "dirHash=${hash(dir)}" @@ -246,6 +247,12 @@ object ChatLogSummary { "evt=todo.updated", todos(event.todos), ) + + is ChatEventDto.SessionInterrupted -> join( + sid(event.sessionID), + "evt=session.interrupted", + "reason=${event.reason}", + ) } fun eventBody(event: ChatEventDto): String = event(event).substringAfter("evt=").let { body -> diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index c96e4a26c343..125b4a424bf9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -2,7 +2,6 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -140,9 +139,6 @@ interface KiloSessionRpcApi : RemoteApi { /** Subscribe to streaming chat events for a specific session. */ suspend fun events(id: String, directory: String): Flow - /** Update config (model, agent/mode, temperature). */ - suspend fun updateConfig(directory: String, config: ConfigUpdateDto) - // ------ permission / question resolution ------ /** Reply to a pending permission request (once, always, or reject). */ diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index a48f05bc5ad5..4c288ed23aa8 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -316,6 +316,25 @@ sealed class ChatEventDto { val sessionID: String, val todos: List = emptyList(), ) : ChatEventDto() + + /** + * The CLI cancelled this session's turn on its own, for [reason]. + * + * Synthesized by the backend, never parsed from a CLI event. The CLI reports a server-side + * cancellation as `MessageAbortedError` — byte-identical to what a user Stop produces — so + * without this signal the UI silently renders "Stopped" for work nobody asked to stop. + */ + @Serializable + @SerialName("session.interrupted") + data class SessionInterrupted( + val sessionID: String, + val reason: String, + ) : ChatEventDto() { + companion object { + /** The CLI disposed its instances — a config, provider, or organization change — and cancelled every running turn. */ + const val RELOAD = "reload" + } + } } // --- Permission DTOs --- @@ -443,12 +462,3 @@ data class DiffFileDto( val before: String? = null, val after: String? = null, ) - -// --- Config Update --- - -@Serializable -data class ConfigUpdateDto( - val model: String? = null, - val agent: String? = null, - val temperature: Double? = null, -) From 9601737524b255f5bd58ccae1e45130492cf571e Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 19:33:14 -0400 Subject: [PATCH 12/19] fix(jetbrains): keep interruption badges across the disposal reload The activity badge for a cancelled turn never appeared. reportDisposal runs immediately before load(), load() calls activity.start(), and start() fully stopped first -- clearing errors, statuses, and the directory resolver. The chat event flow also replays nothing, so the restarted collector could not re-see the SessionInterrupted it had just missed. The badge branch was dead on the only path that emits the event. start() now detaches the collectors in place and keeps what they recorded; a real teardown still clears everything through stop(). The badge is recorded by a direct activity.interrupt() call ordered with the disposal that caused it, rather than racing a flow emission against the reload that swaps collectors, so the event branch is gone. Both new tests fail without their respective halves of this fix. --- .../backend/app/KiloBackendActivityManager.kt | 44 +++++++++++--- .../backend/app/KiloBackendAppService.kt | 4 ++ .../app/KiloBackendActivityManagerTest.kt | 58 +++++++++++++++++++ .../backend/app/KiloBackendAppServiceTest.kt | 30 ++++++++++ 4 files changed, 127 insertions(+), 9 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index f98dc1c0b472..7535438feddb 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -46,7 +46,7 @@ class KiloBackendActivityManager( directory: (String) -> String?, chatEvents: SharedFlow, ) { - if (status?.isActive == true || events?.isActive == true) stop() + if (status?.isActive == true || events?.isActive == true) detach() this.statuses = statuses this.directory = directory status = cs.launch { @@ -64,10 +64,7 @@ class KiloBackendActivityManager( } fun stop() { - status?.cancel() - events?.cancel() - status = null - events = null + detach() statuses = null directory = { null } synchronized(lock) { @@ -79,6 +76,37 @@ class KiloBackendActivityManager( log.info("Activity manager stopped") } + /** + * Cancels the collectors without discarding what they recorded. + * + * [start] runs on every reload, including the one a disposal triggers in the same breath as + * cancelling the running turns. Clearing state there would erase the interruption badges that + * disposal just recorded, so an in-place restart keeps them and lets fresh collectors carry on. + * A real teardown still goes through [stop]. + */ + private fun detach() { + status?.cancel() + events?.cancel() + status = null + events = null + } + + /** + * Badge [ids] as having lost a turn nobody asked to stop. + * + * Called directly instead of being driven from [ChatEventDto.SessionInterrupted]: the disposal that + * cancels those turns reloads the app immediately, the reload restarts the event collector, and the + * chat event flow replays nothing — an emission racing that restart can land in the gap where + * nothing is subscribed and be dropped. A direct call is ordered with the disposal that caused it. + */ + fun interrupt(ids: Collection) { + if (ids.isEmpty()) return + synchronized(lock) { + errors.addAll(ids) + recompute() + } + } + private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.PermissionAsked -> permissions.getOrPut(event.sessionID) { mutableSetOf() }.add(event.request.id) @@ -89,10 +117,8 @@ class KiloBackendActivityManager( // A Stop publishes MessageAbortedError. That is a deliberate user action, not a failure, so // it must not badge the session list, worktree rows, or the Agents tab attention dot. is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) } - // A cancellation the user did not ask for is a failure, and the abort that follows it is - // indistinguishable from a Stop. Badge it from the interruption instead, so the session - // list, worktree rows, and the Agents tab dot all report the lost turn. - is ChatEventDto.SessionInterrupted -> errors.add(event.sessionID) + // A cancellation nobody asked for is a failure, but the abort reporting it is + // indistinguishable from a Stop, so that badge arrives through [interrupt] instead. is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) // Not every failure publishes a session error — a turn whose provider ended the response in // error writes the failure onto the message and only reports it through this close reason. The diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 03d94c16f92f..f14abf46e97b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -855,6 +855,10 @@ class KiloBackendAppService private constructor( val active = sessions.statuses.value.filterValues { it.type != "idle" } if (active.isEmpty()) return log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + // Badged here rather than off the event below, because the reload that follows this restarts the + // activity collector and the event could be dropped in the gap. Both still matter: the badge + // marks the worktree row, the event lets the open session name its own reason. + activity.interrupt(active.keys) chat.interrupt(active.keys, ChatEventDto.SessionInterrupted.RELOAD) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 19f69b72ef37..c0989a28f784 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -249,6 +249,64 @@ class KiloBackendActivityManagerTest { assertEquals("/repo/new", snap["ses_new"]?.directory) } + @Test + fun `interrupt badges the session as failed`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + + manager.interrupt(listOf("ses_1")) + statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) + + await("ses_1", SessionActivityKindDto.ERROR) + } + + /** + * The disposal that cancels a turn reloads the app in the same breath, and that reload calls + * [KiloBackendActivityManager.start] again. Clearing on that in-place restart erased the badge the + * disposal had just recorded, leaving a lost turn resting as if it had finished cleanly. + */ + @Test + fun `interrupt badge survives the reload that follows a disposal`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + + manager.interrupt(listOf("ses_1")) + + // What load() does after a disposal: same flows, fresh collectors. + val reloaded = MutableStateFlow(mapOf("ses_1" to SessionStatusDto("idle"))) + manager.start(reloaded, { directories[it] }, events) + + await("ses_1", SessionActivityKindDto.ERROR) + } + + @Test + fun `resumed work clears an interrupt badge`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + manager.interrupt(listOf("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + events.emit(ChatEventDto.TurnOpen("ses_1")) + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + + await("ses_1", SessionActivityKindDto.RUNNING) + } + + /** A real teardown is a disconnect, not a restart, so nothing may outlive it. */ + @Test + fun `stop clears an interrupt badge`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + manager.interrupt(listOf("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + manager.stop() + + assertEquals(emptyMap(), manager.activity.value) + } + private suspend fun await(id: String, kind: SessionActivityKindDto) = withTimeout(5_000) { manager.activity.first { it[id]?.kind == kind } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 7e6d052561f7..07d918864e1e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -865,6 +866,35 @@ class KiloBackendAppServiceTest { assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) } + /** + * The reload a disposal triggers restarts the activity collector, so the badge has to be recorded in + * a way that survives it — otherwise a worktree row rests as if its cancelled turn had finished. + */ + @Test + fun `disposal badges the cancelled session after the reload settles`() = runBlocking { + // A badge needs a resolvable directory, which a status event alone does not carry. + mock.sessions = """[{"id":"ses_abc","slug":"abc","projectID":"prj_test","directory":"/test/project","title":"Work","version":"1.0.0","time":{"created":1000,"updated":1000}}]""" + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + svc.sessions.list("/test/project") + + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""") + withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } + + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + // The cancelled turn then reports idle, which is what lets the badge show: live work + // deliberately outranks a past error so a resumed row keeps spinning instead. + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"idle"}}""") + + val badged = withTimeoutOrNull(20_000) { + svc.activity.activity.first { it["ses_abc"]?.kind == SessionActivityKindDto.ERROR } + } + assertNotNull(badged, "Disposal must leave a badge on the session it cancelled; logs=${log.messages}") + assertEquals("/test/project", badged["ses_abc"]?.directory) + } + @Test fun `disposal with no busy session names nothing`() = runBlocking { val svc = create() From 024733efef372d12c9bdfb92cbec954f756cd2a5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 20:23:04 -0400 Subject: [PATCH 13/19] feat(jetbrains): add Integrations settings page with GitHub toggle Adds an Integrations page under Settings > Tools > Kilo Code, positioned after Context, with a single GitHub toggle (default on). Turning it off stops the plugin from ever spawning gh: ghStatus and branchStatus take a `github` flag that, when false, resolves git state only and never runs `gh auth status`, `gh pr view`, or `gh pr list`. Worktree stats, dirty counts, branch name, and the branch dock keep working since they only need git. Disabling cancels in-flight gh probes and PR lookups, stops the PR poll timer, and clears cached PR state so badges, worktree editor tab titles, and PR context actions drop immediately with no dangling coroutines. Re-enabling probes and reloads PRs at once instead of waiting out the timers. The gh "not installed"/"not authorized" banner gains a third action, "Turn off GitHub integration," with a tooltip pointing back to the Integrations page. The New Worktree dialog drops its PR import tab while the integration is off, since importing a PR requires gh. --- .../jetbrains-github-integration-toggle.md | 5 + ...861-jetbrains-github-integration-toggle.md | 335 ++++++++++++++++++ .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 53 ++- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 20 ++ .../client/agentManager/worktree/GhBanner.kt | 8 + .../worktree/GhStatusCoordinator.kt | 50 ++- .../worktree/GithubIntegrationListener.kt | 34 ++ .../worktree/KiloWorktreeService.kt | 4 +- .../worktree/NewWorktreeDialog.kt | 7 +- .../worktree/WorktreeStatusService.kt | 45 ++- .../client/plugin/KiloPluginSettings.kt | 11 + .../ai/kilocode/client/session/SessionUi.kt | 16 +- .../settings/KiloSettingsConfigurable.kt | 9 + .../integrations/IntegrationsConfigurable.kt | 39 ++ .../integrations/IntegrationsSettingsUi.kt | 48 +++ .../resources/kilo.jetbrains.frontend.xml | 10 +- .../resources/messages/KiloBundle.properties | 7 + .../agentManager/worktree/GhBannerTest.kt | 40 +++ .../worktree/GhStatusCoordinatorTest.kt | 80 +++++ .../worktree/NewWorktreeDialogTest.kt | 13 + .../worktree/WorktreeStatusServiceTest.kt | 75 ++++ .../client/session/SessionUiLayoutTest.kt | 49 ++- .../settings/KiloSettingsConfigurableTest.kt | 10 +- .../IntegrationsConfigurableTest.kt | 131 +++++++ .../client/testing/FakeWorktreeRpcApi.kt | 14 +- .../ai/kilocode/rpc/KiloWorktreeRpcApi.kt | 9 +- 26 files changed, 1084 insertions(+), 38 deletions(-) create mode 100644 .changeset/jetbrains-github-integration-toggle.md create mode 100644 .kilo/plans/1788119831861-jetbrains-github-integration-toggle.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt diff --git a/.changeset/jetbrains-github-integration-toggle.md b/.changeset/jetbrains-github-integration-toggle.md new file mode 100644 index 000000000000..2fbdc17701eb --- /dev/null +++ b/.changeset/jetbrains-github-integration-toggle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add an Integrations settings page with a GitHub toggle. Turning it off stops Kilo from running the GitHub CLI, hides pull request badges and pull request import, and can be done straight from the gh warning banner. diff --git a/.kilo/plans/1788119831861-jetbrains-github-integration-toggle.md b/.kilo/plans/1788119831861-jetbrains-github-integration-toggle.md new file mode 100644 index 000000000000..853f0c6e97e4 --- /dev/null +++ b/.kilo/plans/1788119831861-jetbrains-github-integration-toggle.md @@ -0,0 +1,335 @@ +# JetBrains: Integrations settings page with a GitHub integration toggle + +## Goal + +Add an **Integrations** configurable under Settings → Tools → Kilo Code, positioned directly after **Context**, containing a **GitHub** section with a single boolean setting (default **on**). + +When the setting is off: + +- The plugin never spawns `gh`. No PR polling, no `gh auth status`, no `gh pr view/list`. +- In-flight gh work is cancelled, timers are stopped, and cached PR state is cleared so nothing leaks. +- PR badges, PR titles on worktree editor tabs, PR context actions, and the "Import from Pull Request" tab disappear. +- Git-only features (worktree list, stats, dirty counts, branch name, branch dock, diff) keep working unchanged. +- The `gh` missing / unauthorized banner disappears. + +The `gh`/`git` warning banner gains a third action, **Turn off GitHub integration**, with a tooltip naming the settings location. + +## Decisions taken + +These were resolved without user input; flag them if you disagree before implementing. + +| Decision | Choice | Rationale | +|---|---|---| +| Git detection while off | Keep a git-only probe: add `github: Boolean = true` to `ghStatus`/`branchStatus`; when false the backend runs only `git --version` and never spawns `gh` | `GIT_MISSING` currently comes from `probeGh`'s `git --version` step and drives both the "Git not found" banner and `BranchDock.gitAvailable()`. Dropping it would silently break both. One `git --version` per 60s is dwarfed by the existing 30s git stats poll. | +| "Import from PR" tab | Hidden while off | It calls `importPr`, which hard-fails with "GitHub CLI (gh) is not installed/authorized". Leaving it would offer a guaranteed failure. | +| Persistence | App-level `KiloPluginSettings` key `kilo.integrations.github` (PropertiesComponent), default `true` | Matches the existing local-boolean pattern; `GhStatusCoordinator` is already an app service, so lifetimes line up. | +| Page style | Plain `SearchableConfigurable` with an immediate-write toggle, **not** `DraftReadyConfigurable` | The setting is purely local. A `KiloReadyConfigurable` would render "CLI unavailable" until the CLI is ready, which is wrong for an IDE-local preference. | +| Disable button on `GIT_MISSING` | Not shown | Git is not the GitHub integration; turning it off would not fix a missing git. | + +No `kilocode_change` markers are needed: everything is under `packages/kilo-jetbrains/`, which is Kilo-owned. + +## Ordered tasks + +### 1. Setting + change notification + +**`frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt`** + +Add, following the existing triple pattern: + +```kotlin +private const val GITHUB_KEY = "kilo.integrations.github" + +fun getGithub(): Boolean = PropertiesComponent.getInstance().getBoolean(GITHUB_KEY, true) + +fun setGithub(value: Boolean) { + PropertiesComponent.getInstance().setValue(GITHUB_KEY, value.toString()) +} + +internal fun unsetGithub() { + PropertiesComponent.getInstance().unsetValue(GITHUB_KEY) +} +``` + +**New file `frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt`** + +Placed next to `GhStatusListener` because every consumer lives in that package (mirrors `session/settings/ApprovalReasonVisibilityListener.kt`). + +```kotlin +fun interface GithubIntegrationListener { + fun changed(enabled: Boolean) + + companion object { + @JvmField + val TOPIC: Topic = Topic.create( + "Kilo github integration", + GithubIntegrationListener::class.java, + ) + } +} + +/** Single write path: persists, publishes, and records the surface that flipped it. */ +@RequiresEdt +internal fun setGithubIntegration(enabled: Boolean, surface: String) { + if (KiloPluginSettings.getGithub() == enabled) return + KiloPluginSettings.setGithub(enabled) + Telemetry.send("Github Integration Toggled", mapOf("enabled" to enabled.toString(), "surface" to surface)) + ApplicationManager.getApplication().messageBus + .syncPublisher(GithubIntegrationListener.TOPIC) + .changed(enabled) +} +``` + +`Telemetry.send` takes `Map`, so stringify the boolean. Telemetry failures are caught and logged inside `KiloTelemetryService`, so tests that click the banner link are safe. + +### 2. RPC contract + +**`shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt`** + +```kotlin +suspend fun ghStatus(directory: String, github: Boolean = true): GhAvailability +suspend fun branchStatus(directory: String, github: Boolean = true): BranchStatusDto +``` + +Document that `github = false` means "resolve git state only; never spawn `gh`". + +`prStatus` is **not** changed — the frontend simply stops calling it while the integration is off. + +### 3. Backend + +**`backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt`** + +- `probeGh(base, reason, github: Boolean)`: + - keep the existing directory-exists check and the `git --version` step that yields `GIT_MISSING`; + - when `github` is false, return `GhAvailability.OK` right after the git check — no `gh auth status`, and **no** `ghCache` read or write (avoids mixing git-only verdicts into the shared 3s cache); + - when `github` is true, behave exactly as today. +- `ghAvailable(base, github: Boolean)`: when `github` is false, delegate to `probeGh(..., github = false)` and skip the `gh --version` / `ghProbe` path entirely (a git-only probe can never return `MISSING`, so that branch is unreachable anyway — make it explicit). +- `ghStatus(directory, github)`: `withContext(Dispatchers.IO) { probeGh(dir, "rpc", github) }`. +- `branchStatus(directory, github)`: + - key the `branches` cache by `"$directory|$github"` so flipping the setting cannot serve a cross-mode entry; + - still run `git branch --show-current` and `isLinkedWorktree()`; + - call `ghAvailable(root, github)`; + - call `resolver.resolve(...)` only when `github` is true; otherwise `pr = null`. +- Leave `prStatus`, `importPr`, `invalidate()`, and `PrResolver` untouched. + +### 4. Frontend client wrapper + +**`frontend/.../agentManager/worktree/KiloWorktreeService.kt`** + +Thread the new parameter through `ghStatus(directory, github)` and `branchStatus(directory, github)`. Keep the existing "do not swallow" semantics and the doc comments explaining why. + +### 5. `GhStatusCoordinator` — stop probing gh, cancel in-flight work + +**`frontend/.../agentManager/worktree/GhStatusCoordinator.kt`** + +- Add `private var github = KiloPluginSettings.getGithub()` and `private var job: Job? = null`. +- Subscribe once in the primary constructor: `ApplicationManager.getApplication().messageBus.connect(cs).subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { edt { github(it) } })`. Precedent for a scope-bound connection: `backend/.../run/WorktreeRunManager.kt:319`. The test constructor delegates to the primary, so the subscription is live in tests. +- `probe(reason)`: store the launched coroutine in `job` and pass `github` to `ghStatus(dir, github)`. +- `detachEdt`: also `job?.cancel(); job = null` alongside the existing timer stop and `busy = false` reset. +- New `@RequiresEdt private fun github(enabled: Boolean)`: + - assign the field; + - when disabling: `job?.cancel(); job = null; busy = false; notified = false; generation++`, then `apply(target(), GhAvailability.OK)` so the banner hides immediately instead of waiting for a probe, then `schedule()`; + - when enabling: `notified = false`, then `probe("github-enabled")` for an immediate re-check. +- `apply(project, next)`: when `!github`, ignore any non-`OK`, non-`GIT_MISSING` value. This guards against an `in-flight` `prStatus` from another project resolving into `report()` after the user disabled the toggle. +- `baseDelay()`: return `SLOW` when `!github` (the loop only checks git at that point). +- `notify(...)`: unreachable for gh states while disabled; no change needed. + +### 6. `WorktreeStatusService` — stop PR polling, clear PR state + +**`frontend/.../agentManager/worktree/WorktreeStatusService.kt`** + +- Add `private var github = KiloPluginSettings.getGithub()` and `private var prJob: Job? = null`. +- Subscribe via `ApplicationManager.getApplication().messageBus.connect(cs)` to `GithubIntegrationListener.TOPIC`. +- `refreshPr(force)`: early-return when `!github`. +- `loadPr()`: assign the launched coroutine to `prJob`. +- `start()`: create `prTimer` only when `github`; still do the immediate `refreshPr(force = true)` (a no-op when disabled). +- `stop()`: also `prJob?.cancel(); prJob = null`. +- On `changed(false)`: `prTimer?.stop(); prTimer = null; prJob?.cancel(); prJob = null; prFlow.value = emptyMap(); ghFlow.value = GhAvailability.OK; lastPr = 0`. +- On `changed(true)`: when `refs > 0`, start `prTimer` and `refreshPr(force = true)`. + +Clearing `prFlow` propagates through `WorktreeStatusBinding` → `AgentManagerPanel.prs = emptyMap()` → `sync()`, which already calls `service().putPr(item.path, pull)` with a nullable `pull` (`AgentManagerPanel.kt:471`) and `putPr` removes on null (`WorktreeNameCache.kt:38-49`). So worktree editor tab titles revert from PR titles to worktree names with **no** change in `AgentManagerPanel`. Verify this while implementing; add an explicit clear only if it does not hold. + +### 7. `GhBanner` — third action with tooltip + +**`frontend/.../agentManager/worktree/GhBanner.kt`** + +In `sync(next)`, after the existing action labels, when `next` is `MISSING` or `UNAUTH`: + +```kotlin +createActionLabel(KiloBundle.message("worktree.gh.disable")) { + setGithubIntegration(false, "worktree_gh_banner") +}.toolTipText = KiloBundle.message("worktree.gh.disable.tooltip") +``` + +`EditorNotificationPanel.createActionLabel(String, Runnable)` returns `HyperlinkLabel`, so `toolTipText` is available. No explicit hide is needed: the coordinator publishes `OK`, which routes into the existing `render(OK)` path and hides the panel. + +Do not add the label for `GIT_MISSING`. + +### 8. `SessionUi` — branch dock without gh + +**`frontend/.../session/SessionUi.kt`** + +- `refreshBranch()`: pass `KiloPluginSettings.getGithub()` to `branchStatus(workspace.directory, github)`. +- On the existing app message-bus connection (the same block that subscribes `ApprovalReasonVisibilityListener` around line 808), subscribe `GithubIntegrationListener.TOPIC` and call `refreshBranch()`. `refreshBranch()` already cancels `branchJob` first, so a flip cancels the in-flight gh call and re-reads git-only state. `pr` becomes null, so the PR badge and the `OpenSessionPrAction` / `CopySessionPrRefAction` context actions go away; `availability` stays `OK`, so the dock keeps working. + +### 9. `NewWorktreeDialog` — hide the PR tab + +**`frontend/.../agentManager/worktree/NewWorktreeDialog.kt`** + +In `tabs()` (line ~175), build and `addTab(pr)` only when `KiloPluginSettings.getGithub()`. `submit()`'s `DialogTab.PR` branch then becomes unreachable; leave it. The `worktree.import.pr.*` bundle keys stay. + +### 10. Settings page + +**New `frontend/.../settings/integrations/IntegrationsSettingsUi.kt`** (`internal`) + +`internal class IntegrationsSettingsUi : SettingsPanel(), Disposable`: + +- Build a `BaseContentPanel` with one `section(KiloBundle.message("settings.integrations.github.title"), KiloBundle.message("settings.integrations.github.description"))` and a single `SettingsRow(title, description, toggle)`. +- `private val toggle = SettingsToggle(KiloPluginSettings.getGithub()) { setGithubIntegration(it, "settings") }`. +- `setContent(content)`. +- Subscribe `GithubIntegrationListener.TOPIC` on `ApplicationManager.getApplication().messageBus.connect(this)` and re-sync `toggle.isSelected` so a flip from the banner is reflected in an open settings page. +- `@RequiresEdt fun sync() { toggle.isSelected = KiloPluginSettings.getGithub() }`. +- `override fun dispose() = Unit` (the bus connection is disposed with `this`). + +**New `frontend/.../settings/integrations/IntegrationsConfigurable.kt`** (public — XML instantiates it) + +```kotlin +class IntegrationsConfigurable : SearchableConfigurable, Configurable.NoMargin, Configurable.NoScroll { + private var ui: IntegrationsSettingsUi? = null + + override fun getId(): String = ID + override fun getDisplayName(): String = KiloBundle.message("settings.integrations.displayName") + override fun createComponent(): JComponent = IntegrationsSettingsUi().also { ui = it } + override fun isModified(): Boolean = false + override fun apply() = Unit + override fun reset() { ui?.sync() } + override fun disposeUIResources() { + ui?.let { Disposer.dispose(it) } + ui = null + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.integrations" + } +} +``` + +`NoMargin` + `NoScroll` because `SettingsPanel` owns its insets and its own `JBScrollPane` (`SettingsPanel.kt:19-45`), matching `KiloReadyConfigurable`. + +### 11. Registration and navigation + +**`frontend/src/main/resources/kilo.jetbrains.frontend.xml`** + +Insert after the Context entry and renumber Advanced so Integrations lands between them: + +```xml + +``` + +Change the existing Advanced entry's `groupWeight` from `-2` to `-3`. + +**`frontend/.../settings/KiloSettingsConfigurable.kt`** + +Add an `ActionLink` for Integrations between the `context` and `advanced` links (lines 86-100), using the same shape as the surrounding links plus the `IntegrationsConfigurable` import. + +### 12. Bundle keys + +**`frontend/src/main/resources/messages/KiloBundle.properties`** + +```properties +settings.integrations.displayName=Integrations +settings.integrations.github.title=GitHub +settings.integrations.github.description=Kilo uses the GitHub CLI (gh) to resolve pull requests for worktrees. +settings.integrations.github.enabled.title=Enable GitHub Integration +settings.integrations.github.enabled.description=Run gh to show pull request badges on worktrees and to import a pull request into a worktree. When off, Kilo never runs gh. +worktree.gh.disable=Turn off GitHub integration +worktree.gh.disable.tooltip=You can turn this back on in Settings | Tools | Kilo Code | Integrations. +``` + +Keep the values free of apostrophes — `KiloBundleLocaleTest` asserts apostrophes are doubled. The 18 `KiloBundle_.properties` files can be left alone; `ResourceBundle` parent chaining falls back to the base bundle for missing keys. Adding translations is optional and follows the existing practice for `worktree.gh.*`. + +### 13. Tests + +**`frontend/src/test/.../testing/FakeWorktreeRpcApi.kt`** — record the `github` flag on `ghStatus` and `branchStatus` calls (e.g. change `ghCalls` to carry the flag, add a `branchCalls` list). Keep `assertNotEdt(...)` on every method. + +**New `frontend/src/test/.../settings/integrations/IntegrationsConfigurableTest.kt`** (`BasePlatformTestCase`) + +- `IntegrationsConfigurable.ID` equals the XML id. +- Default state renders one toggle that is selected (default on). +- Toggling writes `KiloPluginSettings.getGithub() == false` and publishes `GithubIntegrationListener.TOPIC` once. +- `isModified()` is false and `apply()` is inert (the write is immediate). +- `reset()` re-syncs the toggle after an external `setGithubIntegration` call. +- `tearDown` calls `KiloPluginSettings.unsetGithub()`. + +**`frontend/src/test/.../settings/KiloSettingsConfigurableTest.kt`** — add the Integrations ID assertion and update the link-order list at line ~118 to `[..., "Auto-Approve", "Context", "Integrations", "Advanced"]`. + +**`frontend/src/test/.../agentManager/worktree/GhStatusCoordinatorTest.kt`** — add: + +- while disabled, `ghStatus` is invoked with `github = false` and the loop uses the slow cadence; +- disabling while `UNAUTH` publishes `OK` immediately, cancels the in-flight probe, and no further gh-mode calls occur; +- re-enabling probes immediately with `github = true`; +- `report(project, UNAUTH)` while disabled publishes nothing. + +**`frontend/src/test/.../agentManager/worktree/GhBannerTest.kt`** — add: + +- `MISSING` and `UNAUTH` render a "Turn off GitHub integration" link whose `toolTipText` is the settings-location message; +- clicking it sets the setting to false and hides the panel; +- `GIT_MISSING` does not render that link. + +**`frontend/src/test/.../agentManager/worktree/WorktreeStatusServiceTest.kt`** — add: + +- attaching while disabled performs no `prStatus` call and starts no PR timer, while stats/dirty still load; +- disabling after attach clears the `pr` flow, resets `gh` to `OK`, and stops the PR timer; +- re-enabling forces one immediate PR load and restarts the timer. + +**`frontend/src/test/.../agentManager/worktree/NewWorktreeDialogTest.kt`** (or the existing dialog test) — the PR tab is absent while disabled and present by default. + +**`backend/src/test/.../rpc/KiloWorktreeRpcApiImplTest.kt`** — add a temp-repo test asserting `branchStatus(dir, github = false)` returns the current branch and worktree flag with `availability = OK` and `pr = null`, and that `ghStatus(dir, github = false)` returns `OK`. + +Any new test that flips the setting must call `KiloPluginSettings.unsetGithub()` in `tearDown`. + +### 14. Changeset + +Add `.changeset/jetbrains-github-integration-toggle.md`: + +```markdown +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add an Integrations settings page with a GitHub toggle. Turning it off stops Kilo from running the GitHub CLI, hides pull request badges and pull request import, and can be done straight from the gh warning banner. +``` + +`@kilocode/kilo-jetbrains` changesets are established practice (see `.changeset/jetbrains-worktree-run-configs.md`). + +## Risks and edge cases + +- **Cross-mode cache poisoning.** The backend's `ghCache` is a single global `Timed`, not keyed by directory or mode. Skipping the cache entirely on the git-only path is what keeps a git-only `OK` from masking a real `UNAUTH` after the user re-enables. Do not "optimize" this by caching the git-only verdict. +- **`branches` cache.** Must include the mode in the key, otherwise a disabled-mode entry (`pr = null`) can be served for up to 90s after re-enabling, and vice versa. +- **In-flight `prStatus` after disable.** `WorktreeStatusService.loadPr()` calls `GhStatusCoordinator.report(...)`. Cancelling `prJob` plus the coordinator's `!github` guard in `apply` are both required; either alone leaves a window where a stale `UNAUTH` re-shows the banner. +- **Constructor subscriptions.** Both services subscribe in their constructors via `messageBus.connect(cs)`. This is one cheap call, but keep it to that — AGENTS.md forbids heavy service-constructor work. +- **`GhAvailability` enum is unchanged.** No `DISABLED` value is introduced, so no exhaustive `when` in `GhBanner`, `baseDelay`, `BranchDock`, or `EmptySessionPanel` needs new branches. +- **Ordering coupling.** `groupWeight` in the XML, the root-page link order in `KiloSettingsConfigurable.createComponent()`, and the assertion in `KiloSettingsConfigurableTest` must all be updated together. + +## Validation + +From `packages/kilo-jetbrains/` (requires Java 21): + +``` +./gradlew typecheck +./gradlew test +``` + +Manual check via `./gradlew runIde`: + +1. Settings → Tools → Kilo Code shows **Integrations** between Context and Advanced, with GitHub enabled. +2. With `gh` logged out, the Agent Manager banner shows Authorize / Learn more / **Turn off GitHub integration**, and hovering the last one shows the settings path. +3. Clicking it hides the banner immediately, drops PR badges, reverts worktree tab titles, and removes the Pull Request tab from the new-worktree dialog. +4. Worktree stats, dirty counts, branch name, and the branch dock still work. +5. Re-enabling from the Integrations page restores badges and the banner within one probe. +6. With GitHub off, confirm via `ps`/logs that no `gh` process is spawned over several poll intervals. 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 e522f8685d42..3ce1288a7dfd 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 @@ -194,8 +194,8 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return synced.filter { Files.isDirectory(Path.of(it.path)) } } - override suspend fun ghStatus(directory: String): GhAvailability = withContext(Dispatchers.IO) { - probeGh(Path.of(directory).normalize(), "rpc") + override suspend fun ghStatus(directory: String, github: Boolean): GhAvailability = withContext(Dispatchers.IO) { + probeGh(Path.of(directory).normalize(), "rpc", github) } override suspend fun prStatus(directory: String): WorktreePrListDto = withContext(Dispatchers.IO) { @@ -229,9 +229,13 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { dto } - override suspend fun branchStatus(directory: String): BranchStatusDto = withContext(Dispatchers.IO) { + override suspend fun branchStatus(directory: String, github: Boolean): BranchStatusDto = withContext(Dispatchers.IO) { val now = System.currentTimeMillis() - branches[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } + // Keyed by directory + mode so flipping the GitHub integration setting cannot serve a + // cross-mode entry (a disabled-mode entry always has a null pr; an enabled-mode entry may + // not) for up to PR_TTL after the flip. + val key = "$directory|$github" + branches[key]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value } val root = Path.of(directory).normalize() if (!Files.isDirectory(root)) { LOG.info("branch status skipped, directory does not exist: $root") @@ -239,8 +243,8 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } val branch = runGit(root, "branch", "--show-current").stdout.trim() val worktree = isLinkedWorktree(root) - val availability = ghAvailable(root) - val lookup = if (availability == GhAvailability.OK && branch.isNotBlank()) { + val availability = ghAvailable(root, github) + val lookup = if (github && availability == GhAvailability.OK && branch.isNotBlank()) { resolver.resolve(directory, branch, baseBranch(root)) } else { PrLookup() @@ -252,7 +256,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { availability = if (availability == GhAvailability.OK) lookup.availability else availability, pr = lookup.pr, ) - branches[directory] = Timed(System.currentTimeMillis(), dto) + branches[key] = Timed(System.currentTimeMillis(), dto) dto } @@ -653,13 +657,19 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { ) } - private fun ghAvailable(root: Path): GhAvailability { + /** + * Resolves git/gh availability. When [github] is false, only git presence is checked and `gh` + * is never spawned — used while the user has turned off the GitHub integration setting. + */ + private fun ghAvailable(root: Path, github: Boolean = true): GhAvailability { if (!Files.isDirectory(root)) { LOG.info("gh availability skipped dir=$root missing=true") return GhAvailability.OK } - val status = probeGh(root, "availability") - if (status != GhAvailability.MISSING) return status + val status = probeGh(root, "availability", github) + // A git-only probe can only return GIT_MISSING or OK, so the gh-binary re-check below is + // unreachable while github is false; the guard documents that explicitly. + if (!github || status != GhAvailability.MISSING) return status val now = System.currentTimeMillis() ghProbe?.takeIf { now - it.time < GH_PROBE_TTL }?.let { return it.value } val res = runGh(root, "--version") @@ -668,7 +678,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return value } - private fun probeGh(root: Path, reason: String): GhAvailability = synchronized(ghLock) { + /** + * Resolves git/gh availability. When [github] is false, resolves git presence only and never + * spawns `gh` or touches [ghCache] (the cache mixes gh-auth verdicts with a boolean this probe + * would otherwise poison with a git-only "OK"). + */ + private fun probeGh(root: Path, reason: String, github: Boolean = true): GhAvailability = synchronized(ghLock) { // A stale/removed worktree directory makes the process spawn fail, which would be // misreported as GIT_MISSING. Treat a missing directory as "nothing to report" and // don't cache it, so the next probe on a real directory still runs. @@ -677,12 +692,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return@synchronized GhAvailability.OK } val now = System.currentTimeMillis() - ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let { - LOG.info("gh probe cache hit reason=$reason value=${it.value}") - return@synchronized it.value + if (github) { + ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let { + LOG.info("gh probe cache hit reason=$reason value=${it.value}") + return@synchronized it.value + } } val start = System.currentTimeMillis() - LOG.info("gh probe start reason=$reason dir=$root") + LOG.info("gh probe start reason=$reason dir=$root github=$github") val git = runGit(root, "--version") if (!git.ok) { // The directory can disappear between the check above and the spawn; a failed working @@ -692,10 +709,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return@synchronized GhAvailability.OK } val value = GhAvailability.GIT_MISSING - ghCache = Timed(System.currentTimeMillis(), value) + if (github) ghCache = Timed(System.currentTimeMillis(), value) LOG.info("gh probe result reason=$reason value=$value exit=${git.exit} ms=${System.currentTimeMillis() - start} stderr=${snippet(git.stderr)}") return@synchronized value } + if (!github) { + LOG.info("gh probe result reason=$reason value=OK github=false ms=${System.currentTimeMillis() - start}") + return@synchronized GhAvailability.OK + } val res = runGh(root, "auth", "status") val value = if (res.ok) GhAvailability.OK else classifyGhError(res.stderr.ifBlank { res.stdout }) ghCache = Timed(System.currentTimeMillis(), value) 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 29ef82d25702..2812bac0f219 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 @@ -63,6 +63,26 @@ class KiloWorktreeRpcApiImplTest { assertEquals("feature/x", api.branchStatus(dir.toString()).branch) } + @Test + fun `ghStatus reports git only when the github integration is off`() = runBlocking { + initRepo() + assertEquals(GhAvailability.OK, api.ghStatus(repo.toString(), github = false)) + } + + @Test + fun `branch status resolves git state without a pull request when the github integration is off`() = runBlocking { + initRepo() + val dir = repo.resolve(".kilo").resolve("worktrees").resolve("off") + git(repo, "worktree", "add", "-b", "feature/off", dir.toString()) + + val status = api.branchStatus(dir.toString(), github = false) + + assertEquals("feature/off", status.branch) + assertTrue(status.worktree) + assertEquals(GhAvailability.OK, status.availability) + assertNull(status.pr) + } + @Test fun `stats syncs away a worktree deleted from disk`() = runBlocking { initRepo() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt index bf5c3e839f9e..e0e1176bdcef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhBanner.kt @@ -65,6 +65,14 @@ internal class GhBanner( BrowserUtil.browse("https://cli.github.com/manual/gh_auth_login") } } + // Offered only for gh problems: a missing git is not the GitHub integration, and turning the + // integration off would not make worktree stats work. The coordinator publishes OK in + // response, which routes back through render() and hides this banner. + if (next == GhAvailability.MISSING || next == GhAvailability.UNAUTH) { + createActionLabel(KiloBundle.message("worktree.gh.disable")) { + setGithubIntegration(false, "worktree_gh_banner") + }.toolTipText = KiloBundle.message("worktree.gh.disable.tooltip") + } synced = next } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt index 8a0f36331b1a..132e6f559695 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.kiloRoot import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers @@ -17,6 +18,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch @Service(Service.Level.APP) @@ -43,8 +45,15 @@ class GhStatusCoordinator( private var busy = false private var failures = 0 private var generation = 0 + private var github = KiloPluginSettings.getGithub() + private var job: Job? = null private val projects = linkedMapOf() + init { + ApplicationManager.getApplication().messageBus.connect(cs) + .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> edt { github(enabled) } }) + } + fun current(): GhAvailability = value fun attach(project: Project): AutoCloseable { @@ -86,6 +95,8 @@ class GhStatusCoordinator( generation++ timer?.stop() timer = null + job?.cancel() + job = null busy = false failures = 0 LOG.info("gh probe loop stop") @@ -93,6 +104,10 @@ class GhStatusCoordinator( @RequiresEdt private fun apply(project: Project?, next: GhAvailability) { + // Ignore a stray backend result (e.g. an in-flight prStatus reporting into report()) that + // resolves after the user turned the integration off — anything but OK/GIT_MISSING implies + // gh ran, which cannot be trusted once disabled. + if (!github && next != GhAvailability.OK && next != GhAvailability.GIT_MISSING) return if (value == next) return val previous = value value = next @@ -127,12 +142,13 @@ class GhStatusCoordinator( busy = true val gen = generation val start = timers.now() - LOG.info("gh probe start reason=$reason state=$value delay=${delay()}") - cs.launch { + val mode = github + LOG.info("gh probe start reason=$reason state=$value delay=${delay()} github=$mode") + job = cs.launch { runCatching { val dir = project.kiloRoot() ?: return@runCatching null LOG.info("gh probe dir=$dir") - service().ghStatus(dir) + service().ghStatus(dir, mode) } .onSuccess { next -> if (next == null) { @@ -146,6 +162,30 @@ class GhStatusCoordinator( } } + /** + * Applies a GitHub integration setting change. Disabling cancels the in-flight probe and forces + * the published state to [GhAvailability.OK] so the banner hides at once instead of waiting for + * the next probe; the loop keeps running so a missing git is still reported. + */ + @RequiresEdt + private fun github(enabled: Boolean) { + if (github == enabled) return + github = enabled + job?.cancel() + job = null + busy = false + failures = 0 + generation++ + notified = false + LOG.info("gh probe github=$enabled state=$value refs=$refs") + if (!enabled) { + apply(target(), GhAvailability.OK) + schedule() + return + } + probe("github-enabled") + } + private fun idle(gen: Int) { edt { if (gen != generation || refs == 0) return@edt @@ -195,7 +235,9 @@ class GhStatusCoordinator( return baseDelay() } - private fun baseDelay(): Int = when (value) { + // While the GitHub integration is off the loop only checks whether git exists, so it never needs + // the OK cadence tuned for spotting a gh auth change. + private fun baseDelay(): Int = if (!github) SLOW else when (value) { GhAvailability.OK -> NORMAL GhAvailability.UNAUTH -> FAST GhAvailability.MISSING -> SLOW diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt new file mode 100644 index 000000000000..59e6206fb86d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.telemetry.Telemetry +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.messages.Topic + +fun interface GithubIntegrationListener { + fun changed(enabled: Boolean) + + companion object { + @JvmField + val TOPIC: Topic = Topic.create( + "Kilo github integration", + GithubIntegrationListener::class.java, + ) + } +} + +/** + * Single write path for the GitHub integration toggle: persists the value, records telemetry, and + * publishes [GithubIntegrationListener.TOPIC] so [GhStatusCoordinator], [WorktreeStatusService], the + * chat branch dock, and any open settings page stay in sync. + */ +@RequiresEdt +internal fun setGithubIntegration(enabled: Boolean, surface: String) { + if (KiloPluginSettings.getGithub() == enabled) return + KiloPluginSettings.setGithub(enabled) + Telemetry.send("Github Integration Toggled", mapOf("enabled" to enabled.toString(), "surface" to surface)) + ApplicationManager.getApplication().messageBus + .syncPublisher(GithubIntegrationListener.TOPIC) + .changed(enabled) +} 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 152118584434..ae5689dcc50d 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 @@ -99,7 +99,7 @@ class KiloWorktreeService internal constructor( * distinguish a healthy gh from an unhealthy backend via their own `runCatching` + backoff; * swallowing errors here would publish a false "gh is fine" and reset that backoff. */ - suspend fun ghStatus(directory: String): GhAvailability = call { ghStatus(directory) } + suspend fun ghStatus(directory: String, github: Boolean = true): GhAvailability = call { ghStatus(directory, github) } suspend fun prStatus(directory: String): WorktreePrListDto = try { call { prStatus(directory) } @@ -114,7 +114,7 @@ class KiloWorktreeService internal constructor( * would offer worktree actions against a directory whose real state is unknown. Callers decide * what an unknown status means. */ - suspend fun branchStatus(directory: String): BranchStatusDto = call { branchStatus(directory) } + suspend fun branchStatus(directory: String, github: Boolean = true): BranchStatusDto = call { branchStatus(directory, github) } /** * Long-lived move flow. Routed through [durable] (via [call]) so it survives reconnects and diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt index 05be98ba1f30..615d17c1e848 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.modeItems import ai.kilocode.client.session.ui.model.ModelPicker @@ -174,14 +175,16 @@ internal class NewWorktreeDialog( private fun tabs(): JComponent { val fresh = TabInfo(newContent()).setText(KiloBundle.message("worktree.dialog.tab.new")) - val pr = TabInfo(prContent()).setText(KiloBundle.message("worktree.dialog.tab.pr")) + // Importing a PR needs gh, which is exactly what the GitHub integration setting turns off, + // so the tab is omitted rather than offered as a guaranteed failure. + val pr = if (KiloPluginSettings.getGithub()) TabInfo(prContent()).setText(KiloBundle.message("worktree.dialog.tab.pr")) else null val local = TabInfo(branchContent()).setText(KiloBundle.message("worktree.dialog.tab.branch")) val tabs: JBTabs = JBTabsFactory.createTabs(project, disposable).apply { presentation.setSingleRow(true) presentation.setTabsPosition(JBTabsPosition.top) presentation.showBorder = false addTab(fresh).setPreferredFocusableComponent(prompt.defaultFocusedComponent) - addTab(pr).setPreferredFocusableComponent(url) + pr?.let { addTab(it).setPreferredFocusableComponent(url) } addTab(local).setPreferredFocusableComponent(pick) addListener(object : TabsListener { override fun beforeSelectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt index 21eda4a47564..d7fde644f0aa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.app.kiloRoot +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers @@ -9,10 +10,12 @@ import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -40,14 +43,21 @@ class WorktreeStatusService internal constructor( private var debounce: UiTimer? = null private var statsTimer: UiTimer? = null private var prTimer: UiTimer? = null + private var prJob: Job? = null private var refs = 0 private var lastPr = 0L + private var github = KiloPluginSettings.getGithub() val stats: StateFlow> get() = statsFlow val dirty: StateFlow> get() = dirtyFlow val pr: StateFlow> get() = prFlow val gh: StateFlow get() = ghFlow + init { + ApplicationManager.getApplication().messageBus.connect(cs) + .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> github(enabled) }) + } + fun attach(): AutoCloseable { refs++ if (refs == 1) start() @@ -64,7 +74,7 @@ class WorktreeStatusService internal constructor( } fun refreshPr(force: Boolean = false) { - if (project.isDisposed || refs == 0) return + if (project.isDisposed || refs == 0 || !github) return val now = timers.now() if (!force && now - lastPr < PR_THROTTLE) return lastPr = now @@ -75,16 +85,41 @@ class WorktreeStatusService internal constructor( refreshStats() refreshPr(force = true) statsTimer = timers.timer(STATS_POLL) { refreshStats() }.also { it.start() } - prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() } + if (github) prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() } } private fun stop() { debounce?.stop() statsTimer?.stop() prTimer?.stop() + prJob?.cancel() debounce = null statsTimer = null prTimer = null + prJob = null + } + + /** + * Applies a GitHub integration setting change. Disabling cancels the in-flight PR lookup, stops + * the poll, and clears the PR map so badges, tab titles, and PR actions drop immediately. Git + * stats and dirty counts are unaffected. + */ + private fun github(enabled: Boolean) { + if (github == enabled) return + github = enabled + if (!enabled) { + prTimer?.stop() + prTimer = null + prJob?.cancel() + prJob = null + lastPr = 0 + prFlow.value = emptyMap() + ghFlow.value = GhAvailability.OK + return + } + if (refs == 0) return + prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() } + refreshPr(force = true) } private fun loadStats() { @@ -106,10 +141,14 @@ class WorktreeStatusService internal constructor( } private fun loadPr() { - cs.launch { + prJob = cs.launch { val dir = project.kiloRoot() ?: return@launch runCatching { service().prStatus(dir) } .onSuccess { dto -> + // KiloWorktreeService.prStatus swallows the cancellation, so a lookup that was + // in flight when the user turned the integration off still lands here. Drop it + // rather than repopulating the badges we just cleared. + if (!github) return@onSuccess prFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } ghFlow.value = dto.availability service().report(project, dto.availability) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index ac54c6597fc3..ae4460c8f18f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -7,6 +7,7 @@ object KiloPluginSettings { private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" + private const val GITHUB_KEY = "kilo.integrations.github" fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) @@ -47,4 +48,14 @@ object KiloPluginSettings { internal fun unsetPermissionRulesExpanded() { PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY) } + + fun getGithub(): Boolean = PropertiesComponent.getInstance().getBoolean(GITHUB_KEY, true) + + fun setGithub(value: Boolean) { + PropertiesComponent.getInstance().setValue(GITHUB_KEY, value.toString()) + } + + internal fun unsetGithub() { + PropertiesComponent.getInstance().unsetValue(GITHUB_KEY) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 710b5ef840a5..7bbc702dd2f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -16,6 +16,7 @@ import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.migration.MigrationUiState import ai.kilocode.client.migration.ui.MigrationWizardPanel import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -47,6 +48,7 @@ import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind import ai.kilocode.client.session.ui.attachment.attachmentParams import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment +import ai.kilocode.client.agentManager.worktree.GithubIntegrationListener import ai.kilocode.client.agentManager.worktree.KiloWorktreeService import ai.kilocode.client.session.ui.header.BranchDock import ai.kilocode.client.session.ui.header.SessionHeaderPanel @@ -812,6 +814,14 @@ class SessionUi( messageBody.syncApprovalReasons(visible) } }) + // refreshBranch cancels the in-flight lookup first, so a flip both drops a running gh call + // and re-reads the branch without one. + bus.subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { + ApplicationManager.getApplication().invokeLater { + if (disposed) return@invokeLater + refreshBranch() + } + }) } private fun onSessionLoaded(show: Boolean) { @@ -1093,12 +1103,16 @@ class SessionUi( * the hosts that hide the dock (Agent Manager worktree session editors) are exactly the ones most * likely to have a PR. Skipped for readonly hosts, which offer no branch-scoped actions worth the * `gh` round-trip. + * + * With the GitHub integration off, the backend resolves the branch from git alone and returns a + * null PR, so the dock keeps working while no `gh` process is spawned. */ private fun refreshBranch() { if (readonly) return branchJob?.cancel() + val github = KiloPluginSettings.getGithub() branchJob = cs.launch { - val status = runCatching { service().branchStatus(workspace.directory) } + val status = runCatching { service().branchStatus(workspace.directory, github) } .getOrElse { if (it is CancellationException) throw it LOG.warn("branch status refresh failed dir=${workspace.directory}", it) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index ffaa33910e4c..7778b0ab4cb0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable import ai.kilocode.client.settings.autoapprove.AutoApproveConfigurable import ai.kilocode.client.settings.context.ContextConfigurable +import ai.kilocode.client.settings.integrations.IntegrationsConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable @@ -91,6 +92,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { context.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(context) + val integrations = ActionLink(KiloBundle.message("settings.integrations.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, IntegrationsConfigurable.ID) + } + integrations.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + panel.next(integrations) + val advanced = ActionLink(KiloBundle.message("settings.advanced.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt new file mode 100644 index 000000000000..4ce5fae6e331 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurable.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.settings.integrations + +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.util.Disposer +import javax.swing.JComponent + +/** + * Integrations settings page. Not a [ai.kilocode.client.settings.base.DraftReadyConfigurable]: its + * settings are local IDE preferences that must stay editable when the CLI is unavailable, and each + * toggle writes immediately, so there is nothing to apply or revert. + */ +class IntegrationsConfigurable : SearchableConfigurable, Configurable.NoMargin, Configurable.NoScroll { + private var ui: IntegrationsSettingsUi? = null + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.integrations.displayName") + + override fun createComponent(): JComponent = IntegrationsSettingsUi().also { ui = it } + + override fun isModified(): Boolean = false + + override fun apply() = Unit + + override fun reset() { + ui?.sync() + } + + override fun disposeUIResources() { + ui?.let { Disposer.dispose(it) } + ui = null + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.integrations" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt new file mode 100644 index 000000000000..e883cc3b19db --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/integrations/IntegrationsSettingsUi.kt @@ -0,0 +1,48 @@ +package ai.kilocode.client.settings.integrations + +import ai.kilocode.client.agentManager.worktree.GithubIntegrationListener +import ai.kilocode.client.agentManager.worktree.setGithubIntegration +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsPanel +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsToggle +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.concurrency.annotations.RequiresEdt + +/** + * Integrations page. Every setting here is a local IDE preference, so the page renders immediately + * and writes on toggle instead of going through [ai.kilocode.client.settings.base.BaseSettingsUi]'s + * draft/apply flow, which would gate the UI on CLI readiness it does not need. + */ +internal class IntegrationsSettingsUi : SettingsPanel(), Disposable { + private val github = SettingsToggle(KiloPluginSettings.getGithub()) { setGithubIntegration(it, "settings") } + + init { + val content = BaseContentPanel() + content.section( + KiloBundle.message("settings.integrations.github.title"), + KiloBundle.message("settings.integrations.github.description"), + ).row( + SettingsRow( + KiloBundle.message("settings.integrations.github.enabled.title"), + KiloBundle.message("settings.integrations.github.enabled.description"), + github, + ), + ) + setContent(content) + // The gh banner can turn the integration off while this page is open. + ApplicationManager.getApplication().messageBus.connect(this) + .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { sync() }) + } + + @RequiresEdt + fun sync() { + val value = KiloPluginSettings.getGithub() + if (github.isSelected != value) github.isSelected = value + } + + override fun dispose() = Unit +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 5e871c4d2b37..e04a1433e513 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -88,8 +88,16 @@ + + 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 57692173fd57..2446de0ca688 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -500,6 +500,8 @@ worktree.gh.unauth.title=GitHub CLI not authorized worktree.gh.unauth.content=Authorize gh to show pull request badges for worktrees. worktree.gh.authorize=Authorize worktree.gh.learnMore=Learn more +worktree.gh.disable=Turn off GitHub integration +worktree.gh.disable.tooltip=Kilo stops running gh. You can turn this back on in Settings | Tools | Kilo Code | Integrations. action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings @@ -525,6 +527,7 @@ settings.cli.unavailable.title=Kilo Code is not connected settings.cli.unavailable.message=Settings are available after Kilo Code connects to Core. If this does not resolve, restart the IDE or Kilo Core and try again. settings.models.displayName=Models settings.context.displayName=Context +settings.integrations.displayName=Integrations settings.advanced.displayName=Advanced settings.advanced.logging.title=Logging settings.advanced.logs.title=Diagnostic logs @@ -562,6 +565,10 @@ settings.context.watcher.remove=Remove selected patterns settings.context.watcher.empty=No ignore patterns configured. settings.context.watcher.input.title=Add ignore pattern settings.context.watcher.input.prompt=Enter a glob pattern to ignore: +settings.integrations.github.title=GitHub +settings.integrations.github.description=Kilo uses the GitHub CLI (gh) to resolve pull requests for worktrees. +settings.integrations.github.enabled.title=Enable GitHub Integration +settings.integrations.github.enabled.description=Show pull request badges on worktrees and import a pull request into a worktree. When off, Kilo never runs gh. settings.autoApprove.displayName=Auto-Approve settings.autoApprove.title=Auto-Approve settings.autoApprove.description=Define how tools are allowed to run. Most tools default to Allow. doom_loop and external_directory default to Ask. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt index 3fd8fa7fada6..a5fa7549a81d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot @@ -37,6 +38,7 @@ class GhBannerTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() coroutines.close(::pump) } finally { super.tearDown() @@ -81,6 +83,44 @@ class GhBannerTest : BasePlatformTestCase() { assertFalse(edt { banner.isVisible }) } + fun `test banner offers turning the github integration off for gh problems`() { + edt { service.report(project, GhAvailability.MISSING) } + pump() + val banner = edt { GhBanner(project, testRootDisposable) } + + val disable = edt { links(banner).single { it.text == "Turn off GitHub integration" } } + assertEquals( + "Kilo stops running gh. You can turn this back on in Settings | Tools | Kilo Code | Integrations.", + edt { disable.toolTipText }, + ) + + edt { service.report(project, GhAvailability.UNAUTH) } + pump() + assertNotNull(edt { links(banner).singleOrNull { it.text == "Turn off GitHub integration" } }) + } + + fun `test banner does not offer turning the integration off for a missing git`() { + edt { service.report(project, GhAvailability.GIT_MISSING) } + pump() + val banner = edt { GhBanner(project, testRootDisposable) } + + assertTrue(edt { links(banner).none { it.text == "Turn off GitHub integration" } }) + } + + fun `test banner disable action turns the setting off and hides the banner`() { + edt { service.report(project, GhAvailability.UNAUTH) } + pump() + val banner = edt { GhBanner(project, testRootDisposable) } + assertTrue(edt { banner.isVisible }) + + edt { links(banner).single { it.text == "Turn off GitHub integration" }.doClick() } + pump() + + assertFalse(KiloPluginSettings.getGithub()) + assertEquals(GhAvailability.OK, edt { service.current() }) + assertFalse(edt { banner.isVisible }) + } + private fun links(root: Component): List = components(root).filterIsInstance() private fun components(root: Component): List = buildList { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt index 82c00dac5fe2..f5364e670756 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot @@ -37,6 +38,7 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() coroutines.close(::pump) } finally { super.tearDown() @@ -157,11 +159,89 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { handle.close() } + fun `test coordinator probes git only while the github integration is off`() { + rpc.ghResult = GhAvailability.UNAUTH + val handle = edtWait { service.attach(project) } + drain() + assertEquals(GhAvailability.UNAUTH, service.current()) + + github(false) + drain() + // Disabling publishes OK immediately so the banner hides without waiting for a probe. + assertEquals(GhAvailability.OK, service.current()) + + val before = rpc.ghCalls.size + // SLOW cadence while disabled: the loop only checks whether git exists. + timers.advanceBy(59_999) + drain() + assertEquals(before, rpc.ghCalls.size) + + timers.advanceBy(1) + drain() + assertEquals(before + 1, rpc.ghCalls.size) + assertFalse("a disabled probe must never ask the backend to run gh", rpc.ghFlags.last()) + assertEquals(GhAvailability.OK, service.current()) + handle.close() + } + + fun `test coordinator still reports a missing git while the github integration is off`() { + rpc.ghResult = GhAvailability.GIT_MISSING + github(false) + val handle = edtWait { service.attach(project) } + drain() + + assertEquals(GhAvailability.GIT_MISSING, service.current()) + assertFalse(rpc.ghFlags.last()) + handle.close() + } + + fun `test coordinator ignores a stale gh report while the github integration is off`() { + val events = mutableListOf() + ApplicationManager.getApplication().messageBus.connect(testRootDisposable) + .subscribe(GhStatusListener.TOPIC, GhStatusListener { events += it }) + github(false) + + // A prStatus lookup that was in flight at the moment of disabling. + report(GhAvailability.UNAUTH) + + assertEquals(GhAvailability.OK, service.current()) + assertTrue(events.isEmpty()) + } + + fun `test coordinator cancels the in flight probe and reprobes when re-enabled`() { + val gate = CompletableDeferred() + rpc.beforeGhStatus = { gate.await() } + val handle = edtWait { service.attach(project) } + awaitCalls(1) + + // Disabling must not wait for the running gh call to finish. + github(false) + assertEquals(GhAvailability.OK, service.current()) + gate.complete(Unit) + drain() + + rpc.beforeGhStatus = {} + rpc.ghResult = GhAvailability.UNAUTH + val before = rpc.ghCalls.size + github(true) + drain() + + assertEquals("re-enabling probes at once instead of waiting out the timer", before + 1, rpc.ghCalls.size) + assertTrue(rpc.ghFlags.last()) + assertEquals(GhAvailability.UNAUTH, service.current()) + handle.close() + } + private fun report(value: GhAvailability) { edtWait { service.report(project, value) } pump() } + private fun github(enabled: Boolean) { + edtWait { setGithubIntegration(enabled, "test") } + pump() + } + private fun drain() = coroutines.drain() private fun awaitCalls(count: Int) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt index 0edfd4b885cd..ad6fe3ec2a7a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker @@ -55,6 +56,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() dialog?.let { d -> edt { Disposer.dispose(d.disposable) } } dialog = null scope.cancel() @@ -299,6 +301,17 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { assertFalse(edt { prTab().isVisible }) } + fun `test the pr tab is dropped while the github integration is off`() { + // Importing a PR needs gh, so the tab must not be offered as a guaranteed failure. + KiloPluginSettings.setGithub(false) + open() + + edt { + val titles = tabs().tabs.map { it.text } + assertEquals(listOf("New", "From Branch"), titles) + } + } + fun `test an empty branch list disables the branch picker`() { open(branches = emptyList()) selectBranch() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt index 6792cd51fbb9..8982de3eda49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt @@ -1,10 +1,12 @@ package ai.kilocode.client.agentManager.worktree +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState import ai.kilocode.rpc.dto.WorktreeDirtyDto @@ -40,6 +42,7 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloPluginSettings.unsetGithub() coroutines.close(::pump) } finally { super.tearDown() @@ -145,6 +148,78 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test attach skips pr polling while the github integration is off`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 4))) + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 3, GhState.OPEN, "https://pr/3"))) + KiloPluginSettings.setGithub(false) + val off = WorktreeStatusService(project, coroutines.scope, timers) + + val handle = off.attach() + drain() + assertTrue(rpc.prCalls.isEmpty()) + + // Git-backed stats and dirty counts are unaffected by the GitHub integration setting. + timers.advanceBy(300) + drain() + assertEquals(4, off.stats.value[key]?.additions) + + // No PR timer was started, so the poll interval must not produce a lookup either. + timers.advanceBy(120_000) + drain() + assertTrue(rpc.prCalls.isEmpty()) + assertTrue(off.pr.value.isEmpty()) + handle.close() + } + + fun `test disabling clears pr state and stops the poll`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.UNAUTH, listOf(WorktreePrDto(path, 1, GhState.OPEN, "https://pr/1"))) + val handle = service.attach() + drain() + assertEquals(1, service.pr.value[key]?.number) + assertEquals(GhAvailability.UNAUTH, service.gh.value) + val before = rpc.prCalls.size + + github(false) + drain() + assertTrue("badges, tab titles, and PR actions all read this map", service.pr.value.isEmpty()) + assertEquals(GhAvailability.OK, service.gh.value) + + timers.advanceBy(120_000) + drain() + assertEquals(before, rpc.prCalls.size) + handle.close() + } + + fun `test re-enabling reloads pr state and resumes the poll`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 1, GhState.OPEN, "https://pr/1"))) + val handle = service.attach() + drain() + github(false) + drain() + assertTrue(service.pr.value.isEmpty()) + + github(true) + drain() + assertEquals("re-enabling loads at once instead of waiting out the poll", 1, service.pr.value[key]?.number) + + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 2, GhState.OPEN, "https://pr/2"))) + timers.advanceBy(120_000) + drain() + assertEquals(2, service.pr.value[key]?.number) + handle.close() + } + + private fun github(enabled: Boolean) { + edtWait { setGithubIntegration(enabled, "test") } + pump() + } + private fun drain() = coroutines.drain(::pump) private fun pump() = pumpEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 2add894d1233..7768726d3528 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.diff.KiloDiffComparison import ai.kilocode.client.diff.KiloDiffEditorService import ai.kilocode.client.diff.openKiloDiff +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.ui.ChangesPanel import ai.kilocode.client.telemetry.KiloTelemetryService import ai.kilocode.client.vfs.KiloVirtualFile @@ -46,10 +47,12 @@ import ai.kilocode.rpc.dto.BranchStatusDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.GhState import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SessionRevertDto +import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.util.ui.JBUI import ai.kilocode.client.session.views.permission.PermissionView @@ -67,6 +70,17 @@ import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") class SessionUiLayoutTest : SessionUiTestBase() { + /** The worktree RPC installed by [dock], so branch/PR lookups can be asserted. */ + private lateinit var worktree: FakeWorktreeRpcApi + + override fun tearDown() { + try { + KiloPluginSettings.unsetGithub() + } finally { + super.tearDown() + } + } + fun `test root contains content overlay and blocker layers`() { val root = find(ui) @@ -347,6 +361,32 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(listOf(false, false), workspaceRpc.localDiffPatchCalls) } + fun `test dock resolves the pull request while the github integration is on`() { + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + + val dock = dock(WorktreePrDto("/test", 7, GhState.OPEN, "https://pr/7")) + + assertEquals(listOf("/test" to true), worktree.branchCalls) + assertTrue(dock.isVisible) + // Read by the PR badge and by the pull-request context-menu actions. + assertEquals(7, ui.pr?.number) + } + + fun `test dock resolves the branch without gh while the github integration is off`() { + KiloPluginSettings.setGithub(false) + workspaceRpc.localDiffs.add(DiffFileDto("src/Local.kt", 9, 7)) + + val dock = dock(WorktreePrDto("/test", 7, GhState.OPEN, "https://pr/7")) + + assertEquals("the backend must be told not to spawn gh", listOf("/test" to false), worktree.branchCalls) + // Git-backed dock content and worktree actions are unaffected by the GitHub setting. + assertTrue(dock.isVisible) + assertTrue(dock.newWorktreeEnabled()) + assertEquals(1, dock.changeCount()) + // Nothing PR-shaped survives. + assertNull(ui.pr) + } + fun `test committed-only summary does not enable empty-session worktree actions`() { workspaceRpc.branchDiffs.add(DiffFileDto("src/Committed.kt", 2, 1)) val dock = dock() @@ -1190,12 +1230,13 @@ class SessionUiLayoutTest : SessionUiTestBase() { // newUi(...)` swap in this file: SessionController.dispose() cancels the shared `scope`, which // would kill every later coroutine (including the replacement UI's own branch/local refresh) // launched on that same scope for the rest of the test. - private fun dock(): BranchDock { - val worktree = FakeWorktreeRpcApi().apply { - branchResult = BranchStatusDto(branch = "feature/topic", availability = GhAvailability.OK) + private fun dock(pr: WorktreePrDto? = null): BranchDock { + val fake = FakeWorktreeRpcApi().apply { + branchResult = BranchStatusDto(branch = "feature/topic", availability = GhAvailability.OK, pr = pr) } + worktree = fake ApplicationManager.getApplication() - .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, worktree), testRootDisposable) + .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, fake), testRootDisposable) ui = newUi(manager = object : SessionManager { override fun newSession() {} override fun showHistory(back: (() -> Unit)?) {} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt index b5e9b4404f64..12d54d35eb02 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.util.edtWait import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.settings.context.ContextConfigurable +import ai.kilocode.client.settings.integrations.IntegrationsConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable import ai.kilocode.client.settings.autoapprove.AutoApproveConfigurable @@ -37,6 +38,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { assertEquals("ai.kilocode.jetbrains.settings.context", ContextConfigurable.ID) } + fun `test child integrations id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.integrations", IntegrationsConfigurable.ID) + } + fun `test child advanced id matches xml registration`() { assertEquals("ai.kilocode.jetbrains.settings.advanced", AdvancedConfigurable.ID) } @@ -115,7 +120,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { edt { val panel = cfg.createComponent() val labels = links(panel as Container).map { it.text } - assertEquals(listOf("User Profile", "Models", "Providers", "Agent Behavior", "Auto-Approve", "Context", "Advanced"), labels) + assertEquals( + listOf("User Profile", "Models", "Providers", "Agent Behavior", "Auto-Approve", "Context", "Integrations", "Advanced"), + labels, + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt new file mode 100644 index 000000000000..b7ec5e5ecdae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/integrations/IntegrationsConfigurableTest.kt @@ -0,0 +1,131 @@ +package ai.kilocode.client.settings.integrations + +import ai.kilocode.client.agentManager.worktree.GithubIntegrationListener +import ai.kilocode.client.agentManager.worktree.setGithubIntegration +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.util.edtWait +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.OnOffButton +import java.awt.Container +import javax.swing.JComponent +import javax.swing.JLabel + +@Suppress("UnstableApiUsage") +class IntegrationsConfigurableTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + KiloPluginSettings.unsetGithub() + } finally { + super.tearDown() + } + } + + fun `test id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.integrations", IntegrationsConfigurable().id) + } + + fun `test page is searchable and opts out of platform margin and scrollpane`() { + val cfg: Configurable = IntegrationsConfigurable() + assertTrue(cfg is SearchableConfigurable) + assertTrue(cfg is Configurable.NoMargin) + assertTrue(cfg is Configurable.NoScroll) + } + + fun `test github toggle renders on by default`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + val all = text(panel as Container) + assertTrue("expected the GitHub section", all.contains("GitHub")) + assertTrue("expected the toggle row", all.contains("Enable GitHub Integration")) + assertTrue("default must be on", toggle(panel).isSelected) + cfg.disposeUIResources() + } + } + + fun `test toggling off persists the setting and publishes the change`() { + val events = mutableListOf() + ApplicationManager.getApplication().messageBus.connect(testRootDisposable) + .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { events += it }) + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + toggle(panel as Container).doClick() + assertFalse(KiloPluginSettings.getGithub()) + assertEquals(listOf(false), events) + toggle(panel).doClick() + assertTrue(KiloPluginSettings.getGithub()) + assertEquals(listOf(false, true), events) + cfg.disposeUIResources() + } + } + + fun `test page is inert because the toggle writes immediately`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + toggle(panel as Container).doClick() + assertFalse("an immediate write leaves nothing to apply", cfg.isModified) + cfg.apply() + assertFalse(KiloPluginSettings.getGithub()) + cfg.disposeUIResources() + } + } + + fun `test open page follows a change made elsewhere`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + assertTrue(toggle(panel as Container).isSelected) + // The gh banner turning the integration off while the page is open. + setGithubIntegration(false, "worktree_gh_banner") + assertFalse(toggle(panel).isSelected) + cfg.disposeUIResources() + } + } + + fun `test reset resyncs the toggle`() { + val cfg = IntegrationsConfigurable() + edt { + val panel = cfg.createComponent() + val button = toggle(panel as Container) + // Bypass the publisher so only reset() can bring the toggle back in line. + KiloPluginSettings.setGithub(false) + assertTrue(button.isSelected) + cfg.reset() + assertFalse(button.isSelected) + cfg.disposeUIResources() + } + } + + // -- helpers -- + + private fun edt(block: () -> T): T = edtWait(block) + + private fun toggle(root: Container): OnOffButton = buttons(root).single() + + private fun buttons(root: Container): List = buildList { + for (comp in root.components) { + if (comp is OnOffButton) add(comp) + if (comp is Container) addAll(buttons(comp)) + } + } + + private fun text(root: Container): String { + val acc = mutableListOf() + collect(root, acc) + return acc.joinToString("\n") + } + + private fun collect(root: Container, acc: MutableList) { + for (comp in root.components) { + if (comp is JLabel) comp.text?.let { acc.add(it) } + if (comp is JComponent) comp.toolTipText?.let { acc.add(it) } + if (comp is Container) collect(comp, acc) + } + } +} 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 34d17fcd84e0..c1ec25ac99c0 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 @@ -52,6 +52,11 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { var sessionListThrows: Exception? = null val opens = CopyOnWriteArrayList() val ghCalls = CopyOnWriteArrayList() + /** The `github` flag of each [ghStatus] call, positionally matching [ghCalls]. */ + val ghFlags = CopyOnWriteArrayList() + /** Each [branchStatus] call as directory to `github` flag. */ + val branchCalls = CopyOnWriteArrayList>() + val prCalls = CopyOnWriteArrayList() var beforeCreate: suspend () -> Unit = {} var beforeRemove: suspend () -> Unit = {} var beforeRename: suspend () -> Unit = {} @@ -97,21 +102,26 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { return dirtyResult } - override suspend fun ghStatus(directory: String): GhAvailability { + override suspend fun ghStatus(directory: String, github: Boolean): GhAvailability { assertNotEdt("ghStatus") ghCalls.add(directory) + ghFlags.add(github) beforeGhStatus() + if (!github) return if (ghResult == GhAvailability.GIT_MISSING) ghResult else GhAvailability.OK return ghResult } override suspend fun prStatus(directory: String): WorktreePrListDto { assertNotEdt("prStatus") + prCalls.add(directory) return prResult } - override suspend fun branchStatus(directory: String): BranchStatusDto { + override suspend fun branchStatus(directory: String, github: Boolean): BranchStatusDto { assertNotEdt("branchStatus") + branchCalls.add(directory to github) branchThrows?.let { throw it } + if (!github) return branchResult.copy(pr = null) return branchResult } 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 dbc7c0a01f87..3e84c967e811 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 @@ -49,14 +49,19 @@ interface KiloWorktreeRpcApi : RemoteApi { * save, so callers may refresh it independently. */ suspend fun dirty(directory: String): WorktreeDirtyListDto - suspend fun ghStatus(directory: String): GhAvailability + /** + * Resolves gh/git availability for [directory]. When [github] is false, resolves git state + * only ([GhAvailability.GIT_MISSING] or [GhAvailability.OK]) and never spawns `gh`. + */ + suspend fun ghStatus(directory: String, github: Boolean = true): GhAvailability suspend fun prStatus(directory: String): WorktreePrListDto /** * Single-directory branch status for the chat branch/PR dock: current branch, worktree flag, * gh availability, and the PR for the branch (if any). Cached with the same TTL as [prStatus]. + * When [github] is false, resolves git state only and returns a null PR without spawning `gh`. */ - suspend fun branchStatus(directory: String): BranchStatusDto + suspend fun branchStatus(directory: String, github: Boolean = true): BranchStatusDto /** * Moves the session [sessionId] running in [directory] into a fresh worktree on [branch]: From 3402f450264138eef252bf192107632ff716508e Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 20:38:36 -0400 Subject: [PATCH 14/19] feat(jetbrains): sync gh state on frame focus and tab switch gh availability and PR state were only refreshed by their poll timers (30s/5s for availability, 120s for PRs), so authorizing gh in a terminal or merging a PR in a browser could take up to two minutes to show up in the IDE. Both are now also submitted on IDE frame activation and on switching between the Chat and Agents tabs. The submits coalesce rather than queue, keeping the existing one-probe-at-a-time design intact: - GhStatusCoordinator.sync() replaces the unused forceProbe(). It drops the submit when a probe is already in flight, when nothing is attached, or within 3s of the last probe start. That floor matches the backend's gh auth status cache TTL, so a burst of events cannot outrun the answer a probe would return anyway. - WorktreeStatusService refreshes PRs unforced on activation, so the existing 30s throttle absorbs repeated focus events. Activation of a different project's window is ignored. --- .changeset/jetbrains-gh-sync-on-focus.md | 5 ++ .../kilocode/client/KiloToolWindowFactory.kt | 12 +++- .../worktree/GhStatusCoordinator.kt | 41 +++++++++++-- .../worktree/WorktreeStatusService.kt | 14 ++++- .../worktree/GhStatusCoordinatorTest.kt | 61 ++++++++++++++++++- .../worktree/WorktreeStatusServiceTest.kt | 48 +++++++++++++++ .../client/testing/TestIdeActivation.kt | 31 ++++++++++ 7 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 .changeset/jetbrains-gh-sync-on-focus.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt diff --git a/.changeset/jetbrains-gh-sync-on-focus.md b/.changeset/jetbrains-gh-sync-on-focus.md new file mode 100644 index 000000000000..9ed5564165a9 --- /dev/null +++ b/.changeset/jetbrains-gh-sync-on-focus.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refresh GitHub state as soon as you return to the IDE or switch between the Chat and Agents tabs, so authorizing gh or merging a pull request elsewhere shows up without waiting for the next poll. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 45ef35c80e1d..9e2e3a03fbda 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.session.SessionManager 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.SidePanelKeys import ai.kilocode.client.agentManager.SidePanelMode @@ -154,9 +155,14 @@ internal class KiloToolWindowSetupService( } val listener = object : ContentManagerListener { override fun selectionChanged(event: ContentManagerEvent) { - if (event.operation == ContentManagerEvent.ContentOperation.add && event.content === agentContent) { - agentManagerPanel.refresh() - } + if (event.operation != ContentManagerEvent.ContentOperation.add) return + // Either direction: switching tabs is a deliberate act that often follows an + // authorization or PR change made elsewhere. The coordinator drops the submit + // when a probe is already running or too recent, so tab churn cannot stack up + // gh calls. Chat needs nothing further — SessionUi re-reads branch/PR state + // itself once it becomes showing. + service().sync("tab-switch") + if (event.content === agentContent) agentManagerPanel.refresh() } } toolWindow.contentManager.addContentManagerListener(listener) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt index 132e6f559695..5085a7ec550e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt @@ -11,11 +11,13 @@ import ai.kilocode.client.util.edt import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.GhAvailability import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationActivationListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.wm.IdeFrame import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -35,6 +37,9 @@ class GhStatusCoordinator( private const val FAST = 5_000 private const val SLOW = 60_000 private const val MAX_BACKOFF = 120_000 + // Floor between event-driven syncs. Matches the backend's gh auth status cache TTL, so a + // burst of focus/tab events cannot outrun the answer a probe would get anyway. + private const val EVENT_THROTTLE = 3_000 } private var timers: UiTimerSource = UiTimers @@ -47,11 +52,17 @@ class GhStatusCoordinator( private var generation = 0 private var github = KiloPluginSettings.getGithub() private var job: Job? = null + private var probed = 0L private val projects = linkedMapOf() init { - ApplicationManager.getApplication().messageBus.connect(cs) - .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> edt { github(enabled) } }) + val bus = ApplicationManager.getApplication().messageBus.connect(cs) + bus.subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> edt { github(enabled) } }) + // Returning to the IDE usually follows work done elsewhere — `gh auth login` in a terminal, + // a PR merged in a browser — so re-check then instead of waiting out the poll interval. + bus.subscribe(ApplicationActivationListener.TOPIC, object : ApplicationActivationListener { + override fun applicationActivated(ideFrame: IdeFrame) = sync("frame-focus") + }) } fun current(): GhAvailability = value @@ -65,8 +76,29 @@ class GhStatusCoordinator( edt { apply(project, next) } } - fun forceProbe(reason: String = "forced") { - edt { probe(reason) } + /** + * Submits an out-of-band probe after an event that may have changed gh state (IDE frame focus, + * tool window tab switch). Coalesces rather than queues: dropped while a probe is already in + * flight or within [EVENT_THROTTLE] of the last one, so the single-probe-at-a-time loop keeps + * its shape no matter how many events arrive. + */ + fun sync(reason: String) { + edt { syncEdt(reason) } + } + + @RequiresEdt + private fun syncEdt(reason: String) { + if (refs == 0) return + if (busy) { + LOG.info("gh sync dropped reason=$reason busy=true") + return + } + val since = timers.now() - probed + if (since < EVENT_THROTTLE) { + LOG.info("gh sync dropped reason=$reason sinceMs=$since") + return + } + probe(reason) } @RequiresEdt @@ -142,6 +174,7 @@ class GhStatusCoordinator( busy = true val gen = generation val start = timers.now() + probed = start val mode = github LOG.info("gh probe start reason=$reason state=$value delay=${delay()} github=$mode") job = cs.launch { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt index d7fde644f0aa..0ec723e436a9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -10,10 +10,12 @@ import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.WorktreeDirtyDto import ai.kilocode.rpc.dto.WorktreePrDto import ai.kilocode.rpc.dto.WorktreeStatsDto +import com.intellij.openapi.application.ApplicationActivationListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFrame import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -54,8 +56,16 @@ class WorktreeStatusService internal constructor( val gh: StateFlow get() = ghFlow init { - ApplicationManager.getApplication().messageBus.connect(cs) - .subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> github(enabled) }) + val bus = ApplicationManager.getApplication().messageBus.connect(cs) + bus.subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { enabled -> github(enabled) }) + // A PR can be merged or closed while the IDE sits in the background, so re-check on + // activation. Unforced, so PR_THROTTLE collapses a burst of focus events into one lookup. + bus.subscribe(ApplicationActivationListener.TOPIC, object : ApplicationActivationListener { + override fun applicationActivated(ideFrame: IdeFrame) { + if (ideFrame.project !== project) return + refreshPr() + } + }) } fun attach(): AutoCloseable { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt index f5364e670756..6b8831dca7a2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.activateIde import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability @@ -142,14 +143,15 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { assertEquals(1, rpc.ghCalls.size) } - fun `test coordinator skips forced probes while busy instead of queueing`() { + fun `test coordinator drops submitted syncs while busy instead of queueing`() { val gate = CompletableDeferred() rpc.beforeGhStatus = { gate.await() } val handle = edtWait { service.attach(project) } awaitCalls(1) - edtWait { service.forceProbe("test") } - timers.advanceBy(0) + // Past the event throttle, so an in-flight probe is the only thing that can drop the submit. + timers.advanceBy(EVENT_THROTTLE) + edtWait { service.sync("test") } pump() assertEquals(1, rpc.ghCalls.size) @@ -159,6 +161,56 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { handle.close() } + fun `test coordinator throttles a burst of submitted syncs`() { + val handle = edtWait { service.attach(project) } + drain() + assertEquals(1, rpc.ghCalls.size) + + // Focus and tab-switch events can arrive in bursts; inside the window they collapse to none. + repeat(5) { edtWait { service.sync("burst") } } + drain() + assertEquals(1, rpc.ghCalls.size) + + timers.advanceBy(EVENT_THROTTLE) + edtWait { service.sync("later") } + drain() + + assertEquals(2, rpc.ghCalls.size) + handle.close() + } + + fun `test coordinator ignores submitted syncs while nothing is attached`() { + edtWait { service.sync("detached") } + drain() + + assertTrue(rpc.ghCalls.isEmpty()) + } + + fun `test coordinator syncs when the ide frame is activated`() { + rpc.ghResult = GhAvailability.UNAUTH + val handle = edtWait { service.attach(project) } + drain() + assertEquals(GhAvailability.UNAUTH, service.current()) + assertEquals(1, rpc.ghCalls.size) + timers.advanceBy(EVENT_THROTTLE) + + // The user authorized gh in a terminal and came back to the IDE. + rpc.ghResult = GhAvailability.OK + edtWait { activateIde(project) } + drain() + + assertEquals(2, rpc.ghCalls.size) + assertEquals(GhAvailability.OK, service.current()) + handle.close() + } + + fun `test coordinator does not probe on activation before anything attaches`() { + edtWait { activateIde(project) } + drain() + + assertTrue(rpc.ghCalls.isEmpty()) + } + fun `test coordinator probes git only while the github integration is off`() { rpc.ghResult = GhAvailability.UNAUTH val handle = edtWait { service.attach(project) } @@ -252,5 +304,8 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { private companion object { private const val ROOT = "/real/repo" + + /** Mirrors GhStatusCoordinator.EVENT_THROTTLE, the floor between event-driven syncs. */ + private const val EVENT_THROTTLE = 3_000L } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt index 8982de3eda49..976d0c1f991f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.activateIde import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState @@ -16,6 +17,7 @@ import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsDto import ai.kilocode.rpc.dto.WorktreeStatsListDto import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.ProjectManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService @@ -148,6 +150,52 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test activation reloads pr state within the throttle budget`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 1, GhState.OPEN, "https://pr/1"))) + val handle = service.attach() + drain() + assertEquals(1, service.pr.value[key]?.number) + + // A PR merged while the IDE sat in the background. + rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(path, 1, GhState.MERGED, "https://pr/1"))) + timers.advanceBy(30_000) + activateIde(project) + drain() + + assertEquals(GhState.MERGED, service.pr.value[key]?.state) + handle.close() + } + + fun `test activation collapses a burst of focus events into one lookup`() { + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + val before = rpc.prCalls.size + + repeat(5) { activateIde(project) } + drain() + + assertEquals("PR_THROTTLE must absorb repeated activation", before, rpc.prCalls.size) + handle.close() + } + + fun `test activation of another project does not reload pr state`() { + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + val handle = service.attach() + drain() + val before = rpc.prCalls.size + timers.advanceBy(30_000) + + // A different project's window gaining focus says nothing about this project's worktrees. + activateIde(ProjectManager.getInstance().defaultProject) + drain() + + assertEquals(before, rpc.prCalls.size) + handle.close() + } + fun `test attach skips pr polling while the github integration is off`() { val path = "${project.basePath}/.kilo/worktrees/feature-x" val key = normalizeWorktreePath(path) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt new file mode 100644 index 000000000000..295a84d89599 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestIdeActivation.kt @@ -0,0 +1,31 @@ +package ai.kilocode.client.testing + +import com.intellij.openapi.application.ApplicationActivationListener +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFrame +import com.intellij.openapi.wm.StatusBar +import com.intellij.ui.BalloonLayout +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Publishes IDE frame activation for [project], the way the platform does when its window gains + * focus. Headless tests have no real frame, so this carries a stub [IdeFrame] whose only meaningful + * member is the project the listeners route on. + */ +fun activateIde(project: Project) { + ApplicationManager.getApplication().messageBus + .syncPublisher(ApplicationActivationListener.TOPIC) + .applicationActivated(StubIdeFrame(project)) +} + +private class StubIdeFrame(private val project: Project) : IdeFrame { + override fun getStatusBar(): StatusBar? = null + override fun suggestChildFrameBounds(): Rectangle = Rectangle() + override fun getProject(): Project = project + override fun setFrameTitle(title: String) = Unit + override fun getComponent(): JComponent = JPanel() + override fun getBalloonLayout(): BalloonLayout? = null +} From 1edf5acd6f62cdcc07ff51237db16431745ca965 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 21:38:52 -0400 Subject: [PATCH 15/19] fix(jetbrains): address review on worktree status and hover icon Four review findings from PR #13566: - loadDirty() resolved the backend directory from project.basePath, which ProjectRoot documents as a synthetic JetBrains Client path in split/remote mode. dirty() then answered for a directory that does not exist and the empty result published as "no local changes", so uncommitted badges never appeared for remote users. It now resolves project.kiloRoot() inside the coroutine like loadStats() does. - The PR lookup guard only checked the github flag. Because KiloWorktreeService.prStatus swallows cancellation and answers with an empty OK DTO, a lookup abandoned by turning GitHub off could land after a quick re-enable, wipe fresh badges, and report a false OK over a real UNAUTH. Replaced with a generation token bumped on every launch, on disable, and on stop, mirroring GhStatusCoordinator. - HoverIcon.labelledHeight() measured by assigning this button's text. setText fires a property change and calls revalidate()/repaint(), so a size query invalidated the control and its ancestors on every layout pass. It now measures a detached probe button, rebuilt on updateUI so a LAF change re-measures. - The setup-script changeset claimed the actions appear in the settings menu; they are only registered under Kilo.Worktree.RowMenu. --- .changeset/jetbrains-worktree-setup-script.md | 2 +- .../worktree/WorktreeStatusService.kt | 21 ++++-- .../kotlin/ai/kilocode/client/ui/HoverIcon.kt | 26 +++++-- .../worktree/WorktreeStatusServiceTest.kt | 56 +++++++++++++++ .../client/testing/FakeWorktreeRpcApi.kt | 12 +++- .../ai/kilocode/client/ui/HoverIconTest.kt | 70 +++++++++++++++++++ 6 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt diff --git a/.changeset/jetbrains-worktree-setup-script.md b/.changeset/jetbrains-worktree-setup-script.md index 65a8336565e0..278b0925a2b6 100644 --- a/.changeset/jetbrains-worktree-setup-script.md +++ b/.changeset/jetbrains-worktree-setup-script.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": minor --- -Run the worktree setup script (`.kilo/setup-script`) in a terminal when a new worktree is created, and add actions to open, create, or run it from the worktree row menu and the settings menu. +Run the worktree setup script (`.kilo/setup-script`) in a terminal when a new worktree is created, and add actions to open, create, or run it from the worktree row menu. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt index 0ec723e436a9..e2d337d37862 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -49,6 +49,9 @@ class WorktreeStatusService internal constructor( private var refs = 0 private var lastPr = 0L private var github = KiloPluginSettings.getGithub() + // Bumped whenever a PR lookup starts or is abandoned, so a result that arrives after its reason + // to exist is gone cannot publish. Mirrors GhStatusCoordinator's probe generation. + private var generation = 0 val stats: StateFlow> get() = statsFlow val dirty: StateFlow> get() = dirtyFlow @@ -103,6 +106,7 @@ class WorktreeStatusService internal constructor( statsTimer?.stop() prTimer?.stop() prJob?.cancel() + generation++ debounce = null statsTimer = null prTimer = null @@ -122,6 +126,7 @@ class WorktreeStatusService internal constructor( prTimer = null prJob?.cancel() prJob = null + generation++ lastPr = 0 prFlow.value = emptyMap() ghFlow.value = GhAvailability.OK @@ -141,9 +146,12 @@ class WorktreeStatusService internal constructor( } } + // Resolves the backend root like loadStats rather than reading project.basePath, which is a + // synthetic JetBrains Client path in split/remote mode. Pointing the backend at that path makes + // dirty() answer for a directory that does not exist, which reads as "no local changes". private fun loadDirty() { - val dir = project.basePath ?: return cs.launch { + val dir = project.kiloRoot() ?: return@launch runCatching { service().dirty(dir) } .onSuccess { dto -> dirtyFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } } .onFailure { err -> LOG.warn("worktree dirty refresh failed dir=$dir", err) } @@ -151,14 +159,17 @@ class WorktreeStatusService internal constructor( } private fun loadPr() { + val gen = ++generation prJob = cs.launch { val dir = project.kiloRoot() ?: return@launch runCatching { service().prStatus(dir) } .onSuccess { dto -> - // KiloWorktreeService.prStatus swallows the cancellation, so a lookup that was - // in flight when the user turned the integration off still lands here. Drop it - // rather than repopulating the badges we just cleared. - if (!github) return@onSuccess + // KiloWorktreeService.prStatus swallows the cancellation and answers with an + // empty DTO, so a lookup cancelled by a disable still lands here — and after a + // quick re-enable the github flag is true again. Only the newest lookup may + // publish, or a stale empty result would wipe fresh badges and report a false OK + // over a real UNAUTH. + if (gen != generation) return@onSuccess prFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } ghFlow.value = dto.availability service().report(project, dto.availability) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index e858370be699..13e293e2c588 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -11,6 +11,7 @@ import javax.swing.JButton class HoverIcon(private val fill: Boolean = false) : JButton() { private var over = false + private var probe: JButton? = null /** * Makes an icon-only button a square of the height the look-and-feel would give it as a button, @@ -57,14 +58,27 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { // icon-only button's height track the icon instead of the shared labelled-button height whenever // the LAF's font metrics are taller than the icon. Measuring through a non-empty placeholder // forces the same icon+text layout path a labelled button uses, so both stay level everywhere. + // + // The placeholder goes on a detached probe rather than on this button: setText fires property + // changes and calls revalidate()/repaint(), and doing that from a size query would invalidate + // this control and its ancestors on every layout pass, re-queueing validation while the header + // is showing. The probe never joins a hierarchy, so its own invalidation reaches nothing. private fun labelledHeight(): Int { - val previous = text - text = " " - try { - return super.getPreferredSize().height - } finally { - text = previous + val button = probe ?: JButton(" ").also { + iconButton(it) + probe = it } + button.font = font + button.border = border + button.margin = margin + button.icon = icon + return button.preferredSize.height + } + + // Dropped so the probe is rebuilt under the incoming LAF instead of measuring with the old one. + override fun updateUI() { + super.updateUI() + probe = null } override fun getMinimumSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt index 976d0c1f991f..ec7061ccc9d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.activateIde +import ai.kilocode.client.testing.fakeRoot import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState @@ -20,6 +21,9 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.ProjectManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext @Suppress("UnstableApiUsage") class WorktreeStatusServiceTest : BasePlatformTestCase() { @@ -150,6 +154,53 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { handle.close() } + fun `test stats and dirty both target the resolved backend root`() { + // In split/remote mode project.basePath is a synthetic JetBrains Client path. Sending it to + // the backend makes dirty() answer for a directory that is not there, which the UI would + // render as "no local changes" instead of the real uncommitted counts. + fakeRoot(project, coroutines.scope, testRootDisposable, BACKEND_ROOT) + val remote = WorktreeStatusService(project, coroutines.scope, timers) + + val handle = remote.attach() + timers.advanceBy(300) + drain() + + assertEquals(listOf(BACKEND_ROOT), rpc.statsCalls) + assertEquals(listOf(BACKEND_ROOT), rpc.dirtyCalls) + assertFalse(rpc.dirtyCalls.contains(project.basePath)) + handle.close() + } + + fun `test a pr lookup cancelled by a disable cannot publish after a re-enable`() { + val path = "${project.basePath}/.kilo/worktrees/feature-x" + val key = normalizeWorktreePath(path) + val gate = CompletableDeferred() + // The lookup that gets abandoned. It answers empty and OK, which is what the RPC layer + // synthesizes when it swallows the cancellation. NonCancellable holds it open past the + // cancel so it lands after the replacement, the ordering that makes a stale write dangerous. + rpc.prResult = WorktreePrListDto(GhAvailability.OK) + rpc.beforePrStatus = { withContext(NonCancellable) { gate.await() } } + val handle = service.attach() + assertTrue(coroutines.pumpUntil { rpc.prCalls.isNotEmpty() }) + + github(false) + // The fresh lookup after re-enabling sees a real PR and an unauthorized gh. + rpc.beforePrStatus = {} + rpc.prResult = WorktreePrListDto(GhAvailability.UNAUTH, listOf(WorktreePrDto(path, 7, GhState.OPEN, "https://pr/7"))) + github(true) + drain() + assertEquals(7, service.pr.value[key]?.number) + assertEquals(GhAvailability.UNAUTH, service.gh.value) + + // Now let the abandoned lookup finish. It must not wipe the badge or report a false OK. + gate.complete(Unit) + drain() + + assertEquals(7, service.pr.value[key]?.number) + assertEquals(GhAvailability.UNAUTH, service.gh.value) + handle.close() + } + fun `test activation reloads pr state within the throttle budget`() { val path = "${project.basePath}/.kilo/worktrees/feature-x" val key = normalizeWorktreePath(path) @@ -271,4 +322,9 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() { private fun drain() = coroutines.drain(::pump) private fun pump() = pumpEdt() + + private companion object { + /** Stands in for a real repo path that differs from the project's synthetic client basePath. */ + private const val BACKEND_ROOT = "/real/repo" + } } 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 c1ec25ac99c0..dab397fb4bd6 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 @@ -57,10 +57,14 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { /** Each [branchStatus] call as directory to `github` flag. */ val branchCalls = CopyOnWriteArrayList>() val prCalls = CopyOnWriteArrayList() + val statsCalls = CopyOnWriteArrayList() + val dirtyCalls = CopyOnWriteArrayList() var beforeCreate: suspend () -> Unit = {} var beforeRemove: suspend () -> Unit = {} var beforeRename: suspend () -> Unit = {} var beforeGhStatus: suspend () -> Unit = {} + /** Gate for holding a [prStatus] answer open while the test changes state around it. */ + var beforePrStatus: suspend () -> Unit = {} var adoptResult: (String, String) -> RenameWorktreeResultDto = { path, name -> RenameWorktreeResultDto(worktree = WorktreeDto(path, name, name, path)) } @@ -94,11 +98,13 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { override suspend fun stats(directory: String): WorktreeStatsListDto { assertNotEdt("stats") + statsCalls.add(directory) return statsResult } override suspend fun dirty(directory: String): WorktreeDirtyListDto { assertNotEdt("dirty") + dirtyCalls.add(directory) return dirtyResult } @@ -114,7 +120,11 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { override suspend fun prStatus(directory: String): WorktreePrListDto { assertNotEdt("prStatus") prCalls.add(directory) - return prResult + // Snapshot before the gate so a call held open answers with what was configured when it + // started, letting a test stage a newer result for the calls that follow. + val answer = prResult + beforePrStatus() + return answer } override suspend fun branchStatus(directory: String, github: Boolean): BranchStatusDto { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt new file mode 100644 index 000000000000..eabf4cc770e9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt @@ -0,0 +1,70 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.util.edtWait +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JPanel +import java.awt.Component +import java.awt.Graphics + +class HoverIconTest : BasePlatformTestCase() { + + fun `test icon only button is square`() = edtWait { + val icon = HoverIcon().apply { icon = square(16) } + + val size = icon.preferredSize + + assertEquals(size.height, size.width) + } + + fun `test match sizes to the height a labelled button would get`() = edtWait { + val labelled = JButton("Label").also { iconButton(it) } + val icon = HoverIcon().apply { + this.icon = square(16) + match = true + } + + assertEquals(labelled.preferredSize.height, icon.preferredSize.height) + assertEquals(icon.preferredSize.height, icon.preferredSize.width) + } + + fun `test measuring does not mutate this button`() = edtWait { + val icon = HoverIcon().apply { + this.icon = square(16) + match = true + } + // AbstractButton.setText fires a text property change and calls revalidate()/repaint(). Doing + // that from a size query invalidates this control and its ancestors on every layout pass, so + // measurement must not touch this button's own text at all. + val changes = mutableListOf() + icon.addPropertyChangeListener("text") { changes += "${it.oldValue} -> ${it.newValue}" } + JPanel().add(icon) + + repeat(5) { icon.preferredSize } + + assertEquals("measuring must not reassign text: $changes", emptyList(), changes) + assertTrue("measuring must not leave text behind", icon.text.isNullOrEmpty()) + } + + fun `test match height survives a look and feel change`() = edtWait { + val icon = HoverIcon().apply { + this.icon = square(16) + match = true + } + val before = icon.preferredSize.height + + // updateUI drops the cached probe; the next measurement rebuilds it under the current LAF. + icon.updateUI() + + assertEquals(before, icon.preferredSize.height) + assertTrue(icon.text.isNullOrEmpty()) + } + + private fun square(px: Int): Icon = object : Icon { + override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) = Unit + override fun getIconWidth(): Int = JBUI.scale(px) + override fun getIconHeight(): Int = JBUI.scale(px) + } +} From 2ef13de42eef145455c4b8742121994dc3958cc3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 21:50:47 -0400 Subject: [PATCH 16/19] fix(jetbrains): keep HoverIcon.match height off the icon size CI failed on Linux with the icon-only terminal action measuring 18px against its labelled sibling's 17px. labelledHeight() folded this button's own icon into the measurement, so the matched height was insets + max(ownIcon, text). A labelled sibling gets insets + max(itsIcon, text), and the two icons are rarely the same size: hoverIconButton carries a 13px terminal icon while the hoverTextButton beside it carries a smaller product icon. On a LAF whose font is shorter than either icon, the icon-only button drifted a pixel taller. macOS font metrics are tall enough that text won on both sides, which is why this only showed up on Linux. The probe now measures text alone, giving the insets + font-metrics baseline every labelled sibling shares regardless of icon size on either side. The default (non-match) square path still tracks the icon. Adds a platform-independent regression test asserting that two matched buttons with 8px and 64px icons report the same height, which reproduces the Linux failure on macOS. --- .../kotlin/ai/kilocode/client/ui/HoverIcon.kt | 9 +++++-- .../ai/kilocode/client/ui/HoverIconTest.kt | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index 13e293e2c588..3ded8d0bb3a8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -57,7 +57,13 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { // font-metrics contribution a labelled sibling gets from its non-empty text. That makes an // icon-only button's height track the icon instead of the shared labelled-button height whenever // the LAF's font metrics are taller than the icon. Measuring through a non-empty placeholder - // forces the same icon+text layout path a labelled button uses, so both stay level everywhere. + // forces the text layout path a labelled button uses, so both stay level everywhere. + // + // The probe carries no icon on purpose. Height would otherwise follow whichever is taller, this + // button's icon or the text, and icon-only actions rarely carry the same icon size as the + // labelled sibling they sit next to — a 13px icon beside a 12px one drifts a pixel taller on + // LAFs whose font is shorter than either. Measuring text alone yields the insets+font-metrics + // baseline every labelled sibling shares, independent of icon size on both sides. // // The placeholder goes on a detached probe rather than on this button: setText fires property // changes and calls revalidate()/repaint(), and doing that from a size query would invalidate @@ -71,7 +77,6 @@ class HoverIcon(private val fill: Boolean = false) : JButton() { button.font = font button.border = border button.margin = margin - button.icon = icon return button.preferredSize.height } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt index eabf4cc770e9..95d623bf0edc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/HoverIconTest.kt @@ -30,6 +30,33 @@ class HoverIconTest : BasePlatformTestCase() { assertEquals(icon.preferredSize.height, icon.preferredSize.width) } + fun `test match height does not follow the icon size`() = edtWait { + // A labelled sibling rarely carries the same icon size as the icon-only action beside it, so + // folding this button's own icon into the measurement makes the two drift apart on any LAF + // whose font is shorter than either icon. The matched height must depend only on font+insets. + val small = HoverIcon().apply { + icon = square(8) + match = true + } + val large = HoverIcon().apply { + icon = square(64) + match = true + } + + assertEquals(small.preferredSize.height, large.preferredSize.height) + } + + fun `test match ignores the icon while the plain square path still tracks it`() = edtWait { + val matched = HoverIcon().apply { + icon = square(64) + match = true + } + val square = HoverIcon().apply { icon = square(64) } + + // Only `match` opts out of icon-driven sizing; the default square path must still grow. + assertTrue(square.preferredSize.height > matched.preferredSize.height) + } + fun `test measuring does not mutate this button`() = edtWait { val icon = HoverIcon().apply { this.icon = square(16) From 90ad80a8e7308fab92b3c65992c44e273ae2d72f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 22:06:25 -0400 Subject: [PATCH 17/19] test(jetbrains): remove race in setup script refresh test CI failed with "Open Worktree Setup" where the test expected "Create". The test released the gated background lookup, then immediately wrote a different value into the same cache entry: gate.complete(Unit) service().setupScript["/test"] = ...exists = false update(action, next) // expects "Create" The released coroutine caches exists = true from the fake, so if it resumed in the window between the manual write and the second update -- which straddles a pooled-thread handoff -- it overwrote exists = false and the action rendered "Open". Reproduced locally by forcing the background write into that window. The fake reads setupScriptExists after the gate, so the test now flips it to false before releasing and lets the background lookup cache the missing target itself, then waits for that write before asserting. No two writers, no window. The neighbouring local/global config tests use the same shape but write values byte-identical to what the background lookup produces, so they are benign and left alone. --- .../client/actions/KiloRecoveryActionsTest.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index a5b1a8bdff86..b9999d13693d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -27,9 +27,11 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull @Suppress("UnstableApiUsage") class KiloRecoveryActionsTest : BasePlatformTestCase() { @@ -355,8 +357,12 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { await(call) assertEquals(1, rpc.setupScriptTargetCalls.size) + // The fake reads setupScriptExists after the gate, so flipping it here makes the released + // background lookup cache "missing" itself. Writing the cache from the test instead would + // race that write and lose whenever the coroutine resumed first. + rpc.setupScriptExists = false gate.complete(Unit) - service().setupScript["/test"] = SetupScriptTargetDto("/test/.kilo/setup-script", "/test/.kilo/setup-script", false) + assertTrue("background refresh never cached the resolved target", cached("/test") { !it.exists }) val next = event(action, workspace = workspace("/test")) update(action, next) @@ -447,6 +453,14 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { withTimeout(5_000) { signal.await() } } + /** Waits for the background setup-script lookup to publish a target matching [want]. */ + private fun cached(dir: String, want: (SetupScriptTargetDto) -> Boolean): Boolean = runBlocking { + withTimeoutOrNull(5_000) { + while (service().setupScript[dir]?.let(want) != true) delay(5) + true + } == true + } + private fun service(): KiloWorkspaceService = ApplicationManager.getApplication().getService(KiloWorkspaceService::class.java) private fun app(): KiloAppService = ApplicationManager.getApplication().getService(KiloAppService::class.java) From b4366b4f39e6be942dca4375315abaf5df2bb6a8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 22:25:02 -0400 Subject: [PATCH 18/19] chore(jetbrains): drop plan file from the PR packages/kilo-jetbrains/AGENTS.md PR hygiene: .kilo/plans/** must not be included in JetBrains commits or PRs unless publishing plans was asked for. Kept on disk, untracked. --- ...861-jetbrains-github-integration-toggle.md | 335 ------------------ 1 file changed, 335 deletions(-) delete mode 100644 .kilo/plans/1788119831861-jetbrains-github-integration-toggle.md diff --git a/.kilo/plans/1788119831861-jetbrains-github-integration-toggle.md b/.kilo/plans/1788119831861-jetbrains-github-integration-toggle.md deleted file mode 100644 index 853f0c6e97e4..000000000000 --- a/.kilo/plans/1788119831861-jetbrains-github-integration-toggle.md +++ /dev/null @@ -1,335 +0,0 @@ -# JetBrains: Integrations settings page with a GitHub integration toggle - -## Goal - -Add an **Integrations** configurable under Settings → Tools → Kilo Code, positioned directly after **Context**, containing a **GitHub** section with a single boolean setting (default **on**). - -When the setting is off: - -- The plugin never spawns `gh`. No PR polling, no `gh auth status`, no `gh pr view/list`. -- In-flight gh work is cancelled, timers are stopped, and cached PR state is cleared so nothing leaks. -- PR badges, PR titles on worktree editor tabs, PR context actions, and the "Import from Pull Request" tab disappear. -- Git-only features (worktree list, stats, dirty counts, branch name, branch dock, diff) keep working unchanged. -- The `gh` missing / unauthorized banner disappears. - -The `gh`/`git` warning banner gains a third action, **Turn off GitHub integration**, with a tooltip naming the settings location. - -## Decisions taken - -These were resolved without user input; flag them if you disagree before implementing. - -| Decision | Choice | Rationale | -|---|---|---| -| Git detection while off | Keep a git-only probe: add `github: Boolean = true` to `ghStatus`/`branchStatus`; when false the backend runs only `git --version` and never spawns `gh` | `GIT_MISSING` currently comes from `probeGh`'s `git --version` step and drives both the "Git not found" banner and `BranchDock.gitAvailable()`. Dropping it would silently break both. One `git --version` per 60s is dwarfed by the existing 30s git stats poll. | -| "Import from PR" tab | Hidden while off | It calls `importPr`, which hard-fails with "GitHub CLI (gh) is not installed/authorized". Leaving it would offer a guaranteed failure. | -| Persistence | App-level `KiloPluginSettings` key `kilo.integrations.github` (PropertiesComponent), default `true` | Matches the existing local-boolean pattern; `GhStatusCoordinator` is already an app service, so lifetimes line up. | -| Page style | Plain `SearchableConfigurable` with an immediate-write toggle, **not** `DraftReadyConfigurable` | The setting is purely local. A `KiloReadyConfigurable` would render "CLI unavailable" until the CLI is ready, which is wrong for an IDE-local preference. | -| Disable button on `GIT_MISSING` | Not shown | Git is not the GitHub integration; turning it off would not fix a missing git. | - -No `kilocode_change` markers are needed: everything is under `packages/kilo-jetbrains/`, which is Kilo-owned. - -## Ordered tasks - -### 1. Setting + change notification - -**`frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt`** - -Add, following the existing triple pattern: - -```kotlin -private const val GITHUB_KEY = "kilo.integrations.github" - -fun getGithub(): Boolean = PropertiesComponent.getInstance().getBoolean(GITHUB_KEY, true) - -fun setGithub(value: Boolean) { - PropertiesComponent.getInstance().setValue(GITHUB_KEY, value.toString()) -} - -internal fun unsetGithub() { - PropertiesComponent.getInstance().unsetValue(GITHUB_KEY) -} -``` - -**New file `frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GithubIntegrationListener.kt`** - -Placed next to `GhStatusListener` because every consumer lives in that package (mirrors `session/settings/ApprovalReasonVisibilityListener.kt`). - -```kotlin -fun interface GithubIntegrationListener { - fun changed(enabled: Boolean) - - companion object { - @JvmField - val TOPIC: Topic = Topic.create( - "Kilo github integration", - GithubIntegrationListener::class.java, - ) - } -} - -/** Single write path: persists, publishes, and records the surface that flipped it. */ -@RequiresEdt -internal fun setGithubIntegration(enabled: Boolean, surface: String) { - if (KiloPluginSettings.getGithub() == enabled) return - KiloPluginSettings.setGithub(enabled) - Telemetry.send("Github Integration Toggled", mapOf("enabled" to enabled.toString(), "surface" to surface)) - ApplicationManager.getApplication().messageBus - .syncPublisher(GithubIntegrationListener.TOPIC) - .changed(enabled) -} -``` - -`Telemetry.send` takes `Map`, so stringify the boolean. Telemetry failures are caught and logged inside `KiloTelemetryService`, so tests that click the banner link are safe. - -### 2. RPC contract - -**`shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt`** - -```kotlin -suspend fun ghStatus(directory: String, github: Boolean = true): GhAvailability -suspend fun branchStatus(directory: String, github: Boolean = true): BranchStatusDto -``` - -Document that `github = false` means "resolve git state only; never spawn `gh`". - -`prStatus` is **not** changed — the frontend simply stops calling it while the integration is off. - -### 3. Backend - -**`backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt`** - -- `probeGh(base, reason, github: Boolean)`: - - keep the existing directory-exists check and the `git --version` step that yields `GIT_MISSING`; - - when `github` is false, return `GhAvailability.OK` right after the git check — no `gh auth status`, and **no** `ghCache` read or write (avoids mixing git-only verdicts into the shared 3s cache); - - when `github` is true, behave exactly as today. -- `ghAvailable(base, github: Boolean)`: when `github` is false, delegate to `probeGh(..., github = false)` and skip the `gh --version` / `ghProbe` path entirely (a git-only probe can never return `MISSING`, so that branch is unreachable anyway — make it explicit). -- `ghStatus(directory, github)`: `withContext(Dispatchers.IO) { probeGh(dir, "rpc", github) }`. -- `branchStatus(directory, github)`: - - key the `branches` cache by `"$directory|$github"` so flipping the setting cannot serve a cross-mode entry; - - still run `git branch --show-current` and `isLinkedWorktree()`; - - call `ghAvailable(root, github)`; - - call `resolver.resolve(...)` only when `github` is true; otherwise `pr = null`. -- Leave `prStatus`, `importPr`, `invalidate()`, and `PrResolver` untouched. - -### 4. Frontend client wrapper - -**`frontend/.../agentManager/worktree/KiloWorktreeService.kt`** - -Thread the new parameter through `ghStatus(directory, github)` and `branchStatus(directory, github)`. Keep the existing "do not swallow" semantics and the doc comments explaining why. - -### 5. `GhStatusCoordinator` — stop probing gh, cancel in-flight work - -**`frontend/.../agentManager/worktree/GhStatusCoordinator.kt`** - -- Add `private var github = KiloPluginSettings.getGithub()` and `private var job: Job? = null`. -- Subscribe once in the primary constructor: `ApplicationManager.getApplication().messageBus.connect(cs).subscribe(GithubIntegrationListener.TOPIC, GithubIntegrationListener { edt { github(it) } })`. Precedent for a scope-bound connection: `backend/.../run/WorktreeRunManager.kt:319`. The test constructor delegates to the primary, so the subscription is live in tests. -- `probe(reason)`: store the launched coroutine in `job` and pass `github` to `ghStatus(dir, github)`. -- `detachEdt`: also `job?.cancel(); job = null` alongside the existing timer stop and `busy = false` reset. -- New `@RequiresEdt private fun github(enabled: Boolean)`: - - assign the field; - - when disabling: `job?.cancel(); job = null; busy = false; notified = false; generation++`, then `apply(target(), GhAvailability.OK)` so the banner hides immediately instead of waiting for a probe, then `schedule()`; - - when enabling: `notified = false`, then `probe("github-enabled")` for an immediate re-check. -- `apply(project, next)`: when `!github`, ignore any non-`OK`, non-`GIT_MISSING` value. This guards against an `in-flight` `prStatus` from another project resolving into `report()` after the user disabled the toggle. -- `baseDelay()`: return `SLOW` when `!github` (the loop only checks git at that point). -- `notify(...)`: unreachable for gh states while disabled; no change needed. - -### 6. `WorktreeStatusService` — stop PR polling, clear PR state - -**`frontend/.../agentManager/worktree/WorktreeStatusService.kt`** - -- Add `private var github = KiloPluginSettings.getGithub()` and `private var prJob: Job? = null`. -- Subscribe via `ApplicationManager.getApplication().messageBus.connect(cs)` to `GithubIntegrationListener.TOPIC`. -- `refreshPr(force)`: early-return when `!github`. -- `loadPr()`: assign the launched coroutine to `prJob`. -- `start()`: create `prTimer` only when `github`; still do the immediate `refreshPr(force = true)` (a no-op when disabled). -- `stop()`: also `prJob?.cancel(); prJob = null`. -- On `changed(false)`: `prTimer?.stop(); prTimer = null; prJob?.cancel(); prJob = null; prFlow.value = emptyMap(); ghFlow.value = GhAvailability.OK; lastPr = 0`. -- On `changed(true)`: when `refs > 0`, start `prTimer` and `refreshPr(force = true)`. - -Clearing `prFlow` propagates through `WorktreeStatusBinding` → `AgentManagerPanel.prs = emptyMap()` → `sync()`, which already calls `service().putPr(item.path, pull)` with a nullable `pull` (`AgentManagerPanel.kt:471`) and `putPr` removes on null (`WorktreeNameCache.kt:38-49`). So worktree editor tab titles revert from PR titles to worktree names with **no** change in `AgentManagerPanel`. Verify this while implementing; add an explicit clear only if it does not hold. - -### 7. `GhBanner` — third action with tooltip - -**`frontend/.../agentManager/worktree/GhBanner.kt`** - -In `sync(next)`, after the existing action labels, when `next` is `MISSING` or `UNAUTH`: - -```kotlin -createActionLabel(KiloBundle.message("worktree.gh.disable")) { - setGithubIntegration(false, "worktree_gh_banner") -}.toolTipText = KiloBundle.message("worktree.gh.disable.tooltip") -``` - -`EditorNotificationPanel.createActionLabel(String, Runnable)` returns `HyperlinkLabel`, so `toolTipText` is available. No explicit hide is needed: the coordinator publishes `OK`, which routes into the existing `render(OK)` path and hides the panel. - -Do not add the label for `GIT_MISSING`. - -### 8. `SessionUi` — branch dock without gh - -**`frontend/.../session/SessionUi.kt`** - -- `refreshBranch()`: pass `KiloPluginSettings.getGithub()` to `branchStatus(workspace.directory, github)`. -- On the existing app message-bus connection (the same block that subscribes `ApprovalReasonVisibilityListener` around line 808), subscribe `GithubIntegrationListener.TOPIC` and call `refreshBranch()`. `refreshBranch()` already cancels `branchJob` first, so a flip cancels the in-flight gh call and re-reads git-only state. `pr` becomes null, so the PR badge and the `OpenSessionPrAction` / `CopySessionPrRefAction` context actions go away; `availability` stays `OK`, so the dock keeps working. - -### 9. `NewWorktreeDialog` — hide the PR tab - -**`frontend/.../agentManager/worktree/NewWorktreeDialog.kt`** - -In `tabs()` (line ~175), build and `addTab(pr)` only when `KiloPluginSettings.getGithub()`. `submit()`'s `DialogTab.PR` branch then becomes unreachable; leave it. The `worktree.import.pr.*` bundle keys stay. - -### 10. Settings page - -**New `frontend/.../settings/integrations/IntegrationsSettingsUi.kt`** (`internal`) - -`internal class IntegrationsSettingsUi : SettingsPanel(), Disposable`: - -- Build a `BaseContentPanel` with one `section(KiloBundle.message("settings.integrations.github.title"), KiloBundle.message("settings.integrations.github.description"))` and a single `SettingsRow(title, description, toggle)`. -- `private val toggle = SettingsToggle(KiloPluginSettings.getGithub()) { setGithubIntegration(it, "settings") }`. -- `setContent(content)`. -- Subscribe `GithubIntegrationListener.TOPIC` on `ApplicationManager.getApplication().messageBus.connect(this)` and re-sync `toggle.isSelected` so a flip from the banner is reflected in an open settings page. -- `@RequiresEdt fun sync() { toggle.isSelected = KiloPluginSettings.getGithub() }`. -- `override fun dispose() = Unit` (the bus connection is disposed with `this`). - -**New `frontend/.../settings/integrations/IntegrationsConfigurable.kt`** (public — XML instantiates it) - -```kotlin -class IntegrationsConfigurable : SearchableConfigurable, Configurable.NoMargin, Configurable.NoScroll { - private var ui: IntegrationsSettingsUi? = null - - override fun getId(): String = ID - override fun getDisplayName(): String = KiloBundle.message("settings.integrations.displayName") - override fun createComponent(): JComponent = IntegrationsSettingsUi().also { ui = it } - override fun isModified(): Boolean = false - override fun apply() = Unit - override fun reset() { ui?.sync() } - override fun disposeUIResources() { - ui?.let { Disposer.dispose(it) } - ui = null - } - - companion object { - const val ID = "ai.kilocode.jetbrains.settings.integrations" - } -} -``` - -`NoMargin` + `NoScroll` because `SettingsPanel` owns its insets and its own `JBScrollPane` (`SettingsPanel.kt:19-45`), matching `KiloReadyConfigurable`. - -### 11. Registration and navigation - -**`frontend/src/main/resources/kilo.jetbrains.frontend.xml`** - -Insert after the Context entry and renumber Advanced so Integrations lands between them: - -```xml - -``` - -Change the existing Advanced entry's `groupWeight` from `-2` to `-3`. - -**`frontend/.../settings/KiloSettingsConfigurable.kt`** - -Add an `ActionLink` for Integrations between the `context` and `advanced` links (lines 86-100), using the same shape as the surrounding links plus the `IntegrationsConfigurable` import. - -### 12. Bundle keys - -**`frontend/src/main/resources/messages/KiloBundle.properties`** - -```properties -settings.integrations.displayName=Integrations -settings.integrations.github.title=GitHub -settings.integrations.github.description=Kilo uses the GitHub CLI (gh) to resolve pull requests for worktrees. -settings.integrations.github.enabled.title=Enable GitHub Integration -settings.integrations.github.enabled.description=Run gh to show pull request badges on worktrees and to import a pull request into a worktree. When off, Kilo never runs gh. -worktree.gh.disable=Turn off GitHub integration -worktree.gh.disable.tooltip=You can turn this back on in Settings | Tools | Kilo Code | Integrations. -``` - -Keep the values free of apostrophes — `KiloBundleLocaleTest` asserts apostrophes are doubled. The 18 `KiloBundle_.properties` files can be left alone; `ResourceBundle` parent chaining falls back to the base bundle for missing keys. Adding translations is optional and follows the existing practice for `worktree.gh.*`. - -### 13. Tests - -**`frontend/src/test/.../testing/FakeWorktreeRpcApi.kt`** — record the `github` flag on `ghStatus` and `branchStatus` calls (e.g. change `ghCalls` to carry the flag, add a `branchCalls` list). Keep `assertNotEdt(...)` on every method. - -**New `frontend/src/test/.../settings/integrations/IntegrationsConfigurableTest.kt`** (`BasePlatformTestCase`) - -- `IntegrationsConfigurable.ID` equals the XML id. -- Default state renders one toggle that is selected (default on). -- Toggling writes `KiloPluginSettings.getGithub() == false` and publishes `GithubIntegrationListener.TOPIC` once. -- `isModified()` is false and `apply()` is inert (the write is immediate). -- `reset()` re-syncs the toggle after an external `setGithubIntegration` call. -- `tearDown` calls `KiloPluginSettings.unsetGithub()`. - -**`frontend/src/test/.../settings/KiloSettingsConfigurableTest.kt`** — add the Integrations ID assertion and update the link-order list at line ~118 to `[..., "Auto-Approve", "Context", "Integrations", "Advanced"]`. - -**`frontend/src/test/.../agentManager/worktree/GhStatusCoordinatorTest.kt`** — add: - -- while disabled, `ghStatus` is invoked with `github = false` and the loop uses the slow cadence; -- disabling while `UNAUTH` publishes `OK` immediately, cancels the in-flight probe, and no further gh-mode calls occur; -- re-enabling probes immediately with `github = true`; -- `report(project, UNAUTH)` while disabled publishes nothing. - -**`frontend/src/test/.../agentManager/worktree/GhBannerTest.kt`** — add: - -- `MISSING` and `UNAUTH` render a "Turn off GitHub integration" link whose `toolTipText` is the settings-location message; -- clicking it sets the setting to false and hides the panel; -- `GIT_MISSING` does not render that link. - -**`frontend/src/test/.../agentManager/worktree/WorktreeStatusServiceTest.kt`** — add: - -- attaching while disabled performs no `prStatus` call and starts no PR timer, while stats/dirty still load; -- disabling after attach clears the `pr` flow, resets `gh` to `OK`, and stops the PR timer; -- re-enabling forces one immediate PR load and restarts the timer. - -**`frontend/src/test/.../agentManager/worktree/NewWorktreeDialogTest.kt`** (or the existing dialog test) — the PR tab is absent while disabled and present by default. - -**`backend/src/test/.../rpc/KiloWorktreeRpcApiImplTest.kt`** — add a temp-repo test asserting `branchStatus(dir, github = false)` returns the current branch and worktree flag with `availability = OK` and `pr = null`, and that `ghStatus(dir, github = false)` returns `OK`. - -Any new test that flips the setting must call `KiloPluginSettings.unsetGithub()` in `tearDown`. - -### 14. Changeset - -Add `.changeset/jetbrains-github-integration-toggle.md`: - -```markdown ---- -"@kilocode/kilo-jetbrains": minor ---- - -Add an Integrations settings page with a GitHub toggle. Turning it off stops Kilo from running the GitHub CLI, hides pull request badges and pull request import, and can be done straight from the gh warning banner. -``` - -`@kilocode/kilo-jetbrains` changesets are established practice (see `.changeset/jetbrains-worktree-run-configs.md`). - -## Risks and edge cases - -- **Cross-mode cache poisoning.** The backend's `ghCache` is a single global `Timed`, not keyed by directory or mode. Skipping the cache entirely on the git-only path is what keeps a git-only `OK` from masking a real `UNAUTH` after the user re-enables. Do not "optimize" this by caching the git-only verdict. -- **`branches` cache.** Must include the mode in the key, otherwise a disabled-mode entry (`pr = null`) can be served for up to 90s after re-enabling, and vice versa. -- **In-flight `prStatus` after disable.** `WorktreeStatusService.loadPr()` calls `GhStatusCoordinator.report(...)`. Cancelling `prJob` plus the coordinator's `!github` guard in `apply` are both required; either alone leaves a window where a stale `UNAUTH` re-shows the banner. -- **Constructor subscriptions.** Both services subscribe in their constructors via `messageBus.connect(cs)`. This is one cheap call, but keep it to that — AGENTS.md forbids heavy service-constructor work. -- **`GhAvailability` enum is unchanged.** No `DISABLED` value is introduced, so no exhaustive `when` in `GhBanner`, `baseDelay`, `BranchDock`, or `EmptySessionPanel` needs new branches. -- **Ordering coupling.** `groupWeight` in the XML, the root-page link order in `KiloSettingsConfigurable.createComponent()`, and the assertion in `KiloSettingsConfigurableTest` must all be updated together. - -## Validation - -From `packages/kilo-jetbrains/` (requires Java 21): - -``` -./gradlew typecheck -./gradlew test -``` - -Manual check via `./gradlew runIde`: - -1. Settings → Tools → Kilo Code shows **Integrations** between Context and Advanced, with GitHub enabled. -2. With `gh` logged out, the Agent Manager banner shows Authorize / Learn more / **Turn off GitHub integration**, and hovering the last one shows the settings path. -3. Clicking it hides the banner immediately, drops PR badges, reverts worktree tab titles, and removes the Pull Request tab from the new-worktree dialog. -4. Worktree stats, dirty counts, branch name, and the branch dock still work. -5. Re-enabling from the Integrations page restores badges and the banner within one probe. -6. With GitHub off, confirm via `ps`/logs that no `gh` process is spawned over several poll intervals. From 9bf267803e1809f15996d81ca0a8345b82585cd2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 31 Aug 2026 08:37:02 -0400 Subject: [PATCH 19/19] test(jetbrains): pin disposal badge test to the reload it survives The test pushed the cancelled turn's idle status right after the disposal event. Those two land on separate collectors, so on a loaded CI runner the status map reached idle before the disposal watcher read it, the watcher concluded nothing was lost, and no badge was recorded. Wait for the reload to restart the activity collector before reporting idle: that restart proves the badge was taken from the busy status that preceded the disposal, and it makes the assertion cover surviving the restart rather than racing it. --- .../kilocode/backend/app/KiloBackendAppServiceTest.kt | 10 ++++++++++ .../test/kotlin/ai/kilocode/backend/testing/TestLog.kt | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 07d918864e1e..a59c6799abc6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -884,6 +884,16 @@ class KiloBackendAppServiceTest { withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + // Disposal badges the session and then reloads, and the reload is what restarts the activity + // collector — so the restart proves the badge was already taken, and waiting for it is what + // makes the assertion below about surviving the restart instead of racing it. Reporting idle + // before that point would race too: the status map is fed by a separate collector that can + // reach it before the disposal watcher runs, leaving the cancelled turn looking finished. + assertTrue( + log.awaitMessage(20_000, count = 2) { it == "INFO: Activity manager started" }, + "Disposal must reload the app and restart the activity collector; logs=${log.messages}", + ) + // The cancelled turn then reports idle, which is what lets the badge show: live work // deliberately outranks a past error so a resumed row keeps spinning instead. mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"idle"}}""") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt index b9efae14924a..a878b49247a6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt @@ -12,10 +12,11 @@ class TestLog : KiloLog { get() = synchronized(lock) { items.toList() } override var isDebugEnabled: Boolean = true - fun awaitMessage(timeout: Long = 5_000, predicate: (String) -> Boolean): Boolean { + /** Wait until at least [count] captured messages match [predicate]. */ + fun awaitMessage(timeout: Long = 5_000, count: Int = 1, predicate: (String) -> Boolean): Boolean { val end = System.currentTimeMillis() + timeout synchronized(lock) { - while (items.none(predicate)) { + while (items.count(predicate) < count) { val wait = end - System.currentTimeMillis() if (wait <= 0) return false lock.wait(wait)