From 29b277c14b0f4677e2a613c2ff6502875e20735b Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 31 Aug 2026 22:41:08 -0400 Subject: [PATCH] fix(jetbrains): show a changes badge for uncommitted worktree work An Agent Manager worktree row measured its changes with WorktreeStatsDto, which counts only what is committed against the base branch. A worktree whose agent has not committed yet therefore reported zero files and the badge hid, which reads as "this worktree changed nothing". That is also why the badge looked like it depended on a pull request: nothing gates it on one, but a worktree with no pull request is exactly the worktree whose work is still uncommitted. Fall back to the uncommitted counts when nothing is committed, routing the click to the Local comparison so it opens the files the badge just counted. Keep the committed number when there is one: it is what a pull request would show. The row detail popup, the only place that breaks the two sets apart, no longer requires a pull request to open either. Backend stats keep their meaning, so the "Files changed" semantics and the BASE diff editor are untouched. --- .../jetbrains-uncommitted-changes-badge.md | 5 + .../client/agentManager/AgentManagerPanel.kt | 47 +++++-- .../ai/kilocode/client/ui/ChangesPanel.kt | 39 ++++-- .../client/ui/list/ActiveListActions.kt | 2 +- .../client/ui/list/ActiveListModel.kt | 17 ++- .../client/ui/list/ActiveListRenderer.kt | 16 ++- .../resources/messages/KiloBundle.properties | 1 + .../agentManager/AgentManagerPanelTest.kt | 115 ++++++++++++++++-- .../worktree/WorktreeRowPopupBodyTest.kt | 26 ++++ .../ai/kilocode/client/ui/ChangesPanelTest.kt | 34 +++++- 10 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 .changeset/jetbrains-uncommitted-changes-badge.md diff --git a/.changeset/jetbrains-uncommitted-changes-badge.md b/.changeset/jetbrains-uncommitted-changes-badge.md new file mode 100644 index 00000000000..8a4cd0d30bd --- /dev/null +++ b/.changeset/jetbrains-uncommitted-changes-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a changes badge on Agent Manager worktree rows while the work is still uncommitted, instead of leaving the row blank until the first commit. Clicking it opens the uncommitted comparison, and the row detail popup now opens for worktrees that have no pull request yet. 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 07c67117828..a07769de0ce 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 @@ -517,6 +517,7 @@ class AgentManagerPanel( controller.kind(item.path), stats[key], pull, + dirty[key], ) }, ActiveListSelection.Preserve, @@ -566,11 +567,23 @@ class AgentManagerPanel( popup.show(key, this) { request(item) } } + /** + * The hover detail for one row, or null when the row has nothing to detail. A pull request is not the + * bar: a worktree that has no pull request yet is exactly the one whose changes are still uncommitted, + * and this popup is the only place that breaks those out. What it will not do is follow the pointer + * down a list of untouched worktrees as an empty balloon. + */ @RequiresEdt private fun request(row: WorktreeRow): SidePopupRequest? { - val target = project ?: return null - val pull = row.pr ?: return null + if (project == null || row.progress != null) return null val key = normalizeWorktreePath(row.dto.path) + val pull = row.pr + val base = stats[key] + val local = dirty[key] + val any = pull != null || + (local != null && local.files > 0) || + (base != null && (base.files > 0 || base.ahead > 0 || base.behind > 0)) + if (!any) return null return SidePopupRequest( build = { val disposable = Disposer.newDisposable("Worktree row popup") @@ -578,7 +591,7 @@ class AgentManagerPanel( openDiff = { openDiff(row.dto) }, onLocal = { openLocalDiff(row.dto) }, ) - body.update(stats[key], pull, WorktreeTitle.fallback(row.dto.path), dirty[key]) + body.update(base, pull, WorktreeTitle.fallback(row.dto.path), local) // A PR title is as long as its author made it, and the popup exists to show the whole // thing: past the width cap it scrolls sideways rather than losing the end of the line. HeaderPopupBody(body, disposable, UiStyle.Balloon.bg(), maxWidth = POPUP_WIDTH, horizontal = true) @@ -634,9 +647,9 @@ class AgentManagerPanel( this, onStats = { value -> stats = value; sync() }, onPr = { value -> prs = value; sync() }, - // Uncommitted counts only appear in the row popup, so they do not rebuild rows: sync() would - // churn every row on each poll for a number nothing on the row itself shows. - onDirty = { value -> dirty = value }, + // Rows carry the uncommitted counts too, as the summary a worktree with no commits yet shows, + // so a poll has to rebuild them. Row equality keeps a poll that found nothing new from churning. + onDirty = { value -> dirty = value; sync() }, ) } @@ -704,6 +717,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 @@ -765,16 +779,25 @@ class AgentManagerPanel( ), ) } + /** + * Committed counts against the base branch, with the uncommitted ones behind them so a worktree + * whose agent has not committed yet still says what it changed. A row that showed nothing until + * the first commit reads as "no changes here", which is the state this summary exists to deny. + */ override val metrics: ActiveListMetrics? get() { if (progress != null) return null - val s = stats?.takeIf { it.files > 0 } ?: return null + if ((stats?.files ?: 0) == 0 && (dirty?.files ?: 0) == 0) return null return ActiveListMetrics( - files = s.files, - additions = s.additions, - deletions = s.deletions, - base = s.base, + files = stats?.files ?: 0, + additions = stats?.additions ?: 0, + deletions = stats?.deletions ?: 0, + base = stats?.base.orEmpty(), onChanges = { openDiff(dto) }, + localFiles = dirty?.files ?: 0, + localAdditions = dirty?.additions ?: 0, + localDeletions = dirty?.deletions ?: 0, + onLocal = { openLocalDiff(dto) }, ) } @@ -785,6 +808,7 @@ class AgentManagerPanel( kind == row.kind && stats == row.stats && pr == row.pr && + dirty == row.dirty && current == row.current } @@ -794,6 +818,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/ui/ChangesPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/ChangesPanel.kt index b62b12544bd..e17ac31f9cd 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 @@ -69,6 +69,11 @@ internal class ChangesPanel @RequiresEdt constructor( setActions(onBase, onLocal) } + /** + * [onBase] drives the only group a compact summary has, whichever counts it ended up showing — a + * compact host that passes an uncommitted set has to hand over the action that matches it, because + * this widget cannot know which comparison the counts came from. + */ @RequiresEdt fun setActions(onBase: (() -> Unit)?, onLocal: (() -> Unit)? = null) { base.action = onBase @@ -88,10 +93,16 @@ internal class ChangesPanel @RequiresEdt constructor( 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) + // A compact summary has one group, so uncommitted work is all it can show for a worktree that has + // committed nothing yet — and hiding instead would read as "this worktree changed nothing", which + // is the opposite of what the row is being asked. The counts it drops in that case are zero, so + // they stay out of the state and an unrelated poll cannot repaint the row. + val next = when { + mode == Mode.FULL -> + State(files, additions, deletions, ahead, behind, localFiles, localAdditions, localDeletions, base) + files == 0 && localFiles > 0 -> + State(localFiles, localAdditions, localDeletions, base = base, local = true) + else -> State(files, additions, deletions, base = base) } if (state == next) return state = next @@ -99,22 +110,24 @@ internal class ChangesPanel @RequiresEdt constructor( // its tooltip only has to say what a click does. The full form is the one that can be squeezed // out of a narrow header, and it keeps the counts and the base branch. val tip = when { + next.local -> KiloBundle.message("worktree.dirty.tooltip.open") mode == Mode.COMPACT -> KiloBundle.message("worktree.stats.tooltip.open") 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, tip) + this.base.update(next.files, next.additions, next.deletions, tip) local?.update( - localFiles, localAdditions, localDeletions, - KiloBundle.message("worktree.dirty.tooltip", localFiles, localAdditions, localDeletions), + next.localFiles, next.localAdditions, next.localDeletions, + KiloBundle.message("worktree.dirty.tooltip", next.localFiles, next.localAdditions, next.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 } + this.ahead?.let { counter(it, next.ahead) } + this.behind?.let { counter(it, next.behind) } + val right = next.files > 0 || next.ahead > 0 || next.behind > 0 + val fence = next.localFiles > 0 && right + separator?.let { if (it.isVisible != fence) it.isVisible = fence } val visible = right || next.localFiles > 0 if (isVisible != visible) isVisible = visible - val tooltip = tip.takeIf { mode == Mode.COMPACT && files > 0 } + val tooltip = tip.takeIf { mode == Mode.COMPACT && next.files > 0 } if (toolTipText != tooltip) toolTipText = tooltip syncActions() revalidate() @@ -326,6 +339,8 @@ internal class ChangesPanel @RequiresEdt constructor( val localAdditions: Int = 0, val localDeletions: Int = 0, val base: String = "", + /** The counts above are uncommitted, stood in for a committed set that is empty. Compact only. */ + val local: Boolean = false, ) private companion object { 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 0e098884516..175fe350bfb 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 @@ -24,7 +24,7 @@ internal fun activeListRegions(item: ActiveListItem): Map Unit> { val act = badge.action if (!id.isNullOrBlank() && act != null) out[id] = act } - item.metrics?.onChanges?.let { out[ACTIVE_LIST_CHANGES_CELL] = it } + item.metrics?.action?.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 d56d2025b3a..f63f23a737a 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 @@ -32,13 +32,28 @@ internal data class ActiveListBadge( val icon: Icon? = null, ) +/** + * A row's changes summary: what the row has committed against [base], and what it has left uncommitted. + * A row with nothing committed shows the uncommitted counts instead of hiding, so [onLocal] is the click + * target in that case and [onChanges] the rest of the time. + */ internal data class ActiveListMetrics( val files: Int = 0, val additions: Int = 0, val deletions: Int = 0, val base: String = "", val onChanges: (() -> Unit)? = null, -) + val localFiles: Int = 0, + val localAdditions: Int = 0, + val localDeletions: Int = 0, + val onLocal: (() -> Unit)? = null, +) { + /** Whether the uncommitted counts are standing in for a committed set that is empty. */ + val local: Boolean get() = files == 0 && localFiles > 0 + + /** The one action the summary answers to, matched to whichever counts it is showing. */ + val action: (() -> Unit)? get() = if (local) onLocal else onChanges +} 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 36e57573f09..bfbc8abd92a 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 @@ -419,21 +419,29 @@ internal class ActiveListChangesCell @RequiresEdt constructor() : JPanel(BorderL @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 }) + panel.update( + data?.files ?: 0, + data?.additions ?: 0, + data?.deletions ?: 0, + localFiles = data?.localFiles ?: 0, + localAdditions = data?.localAdditions ?: 0, + localDeletions = data?.localDeletions ?: 0, + base = data?.base.orEmpty(), + ) + panel.setActions(data?.action.takeIf { isEnabled }) isVisible = panel.isVisible toolTipText = panel.toolTipText } @RequiresEdt - override fun cellEnabled(): Boolean = isVisible && isEnabled && data?.onChanges != null + override fun cellEnabled(): Boolean = isVisible && isEnabled && data?.action != null override fun cellCursor(): Int = Cursor.HAND_CURSOR @RequiresEdt override fun cellTooltip(): String? = toolTipText - override fun cellAction(): (() -> Unit)? = data?.onChanges + override fun cellAction(): (() -> Unit)? = data?.action } internal class ActiveListActionCell : JBLabel(), ActiveListHitCell { 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 6929e36192a..cd1db05cb36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -491,6 +491,7 @@ worktree.stats.tooltip={0} files changed, +{1} -{2}.
Click to open comm worktree.stats.base.tooltip={0} files changed, +{1} -{2} vs {3}. Click to open committed changes. worktree.stats.tooltip.open=Click to open diff worktree.dirty.tooltip={0} uncommitted files, +{1} -{2}.
Click to compare with HEAD. +worktree.dirty.tooltip.open=Uncommitted changes. Click to compare with HEAD. 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 ef0c42c8fbe..ffe75f76a40 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 @@ -664,18 +664,22 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals("origin/main", metrics.base) } - fun `test worktree rows show only base files and ignore local changes and commit counters`() { + fun `test worktree rows prefer base files and fall back to uncommitted ones`() { val committed = worktree("committed") val local = worktree("local") val both = worktree("both") val renamed = worktree("renamed") - rpc.listed += listOf(committed, local, both, renamed) + val counters = worktree("counters") + val clean = worktree("clean") + rpc.listed += listOf(committed, local, both, renamed, counters, clean) rpc.statsResult = WorktreeStatsListDto( listOf( 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), + WorktreeStatsDto(counters.path, ahead = 3, behind = 2), + WorktreeStatsDto(clean.path), ), ) rpc.dirtyResult = WorktreeDirtyListDto( @@ -695,25 +699,119 @@ class AgentManagerPanelTest : BasePlatformTestCase() { 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") + assertFalse(first.local) + // Nothing committed, so the uncommitted set is the only thing the row can report. + val uncommitted = row(panel, 1).metrics ?: error("expected uncommitted changes") + assertTrue(uncommitted.local) + assertEquals(0, uncommitted.files) + assertEquals(1, uncommitted.localFiles) + assertEquals(2, uncommitted.localAdditions) + val mixed = row(panel, 2).metrics ?: error("expected base changes to win") assertEquals(3, mixed.additions) assertEquals(1, mixed.files) + assertFalse(mixed.local) val move = row(panel, 3).metrics ?: error("expected file-only changes") assertEquals(1, move.files) assertEquals(0, move.additions) assertEquals(0, move.deletions) + // A compact summary has no ahead/behind counters, and a clean worktree has nothing at all. + assertNull(row(panel, 4).metrics) + assertNull(row(panel, 5).metrics) edt { val view = UIUtil.findComponentOfType(panel, ActiveListView::class.java)!! - view.list.setSize(480, 400) + view.list.setSize(480, 600) view.list.doLayout() UIUtil.dispatchAllInvocationEvents() - for (index in listOf(0, 2, 3)) { + for (index in listOf(0, 1, 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)) + for (index in listOf(4, 5)) { + assertFalse(activeListCellBounds(view.list, index, selected = false).containsKey(ACTIVE_LIST_CHANGES_CELL)) + } + } + } + + fun `test the changes badge opens whichever comparison it is showing`() { + val committed = worktree("committed") + val local = worktree("local") + rpc.listed += listOf(committed, local) + rpc.statsResult = WorktreeStatsListDto( + listOf( + WorktreeStatsDto(committed.path, additions = 5, files = 2, base = "origin/main"), + WorktreeStatsDto(local.path), + ), + ) + rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(local.path, additions = 2, 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 { row(panel, 0).metrics!!.action!!() } + waitUntil { opened().isNotEmpty() } + assertEquals("branch", opened().single().path.params["source"]) + edt { FileEditorManager.getInstance(project).openFiles.forEach { FileEditorManager.getInstance(project).closeFile(it) } } + + edt { row(panel, 1).metrics!!.action!!() } + // The local comparison passes no branch, so the tab waits on a branch-name lookup for its title. + waitUntil { opened().isNotEmpty() } + assertEquals("local", opened().single().path.params["source"]) + } + + fun `test the uncommitted badge says so and reaches its own comparison through the list`() { + val local = worktree("local") + rpc.listed += local + rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(local.path, additions = 2, deletions = 1, files = 3))) + 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 renderer = list.cellRenderer.getListCellRendererComponent(list, list.model.getElementAt(0), 0, false, true) + renderer.setSize(list.width, list.getCellBounds(0, 0).height) + components(renderer).filterIsInstance().forEach { it.doLayout() } + assertEquals( + listOf("3 files", "-1", "+2"), + components(renderer).filterIsInstance().filter { it.isVisible && !it.text.isNullOrEmpty() && it.text != row(panel, 0).description } + .map { it.text }, + ) + + val point = center(activeListCellBounds(list, 0, selected = false).getValue(ACTIVE_LIST_CHANGES_CELL)) + 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(KiloBundle.message("worktree.dirty.tooltip.open"), 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, + )) + } } + waitUntil { opened().isNotEmpty() } + + assertEquals("local", opened().single().path.params["source"]) } fun `test open diff opens the branch diff editor`() { @@ -1366,6 +1464,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) + private fun opened(): List = + edt { FileEditorManager.getInstance(project).openFiles.filterIsInstance() } + private fun pump() = pumpEdt() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeRowPopupBodyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeRowPopupBodyTest.kt index 21757de290c..8076a23e238 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeRowPopupBodyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeRowPopupBodyTest.kt @@ -166,6 +166,32 @@ class WorktreeRowPopupBodyTest : BasePlatformTestCase() { } } + fun `test a worktree with no pull request still breaks its changes out`() { + val body = body() + + edt { + body.update( + stats = WorktreeStatsDto(path, ahead = 1, behind = 2), + pull = null, + name = "feature-x", + dirty = WorktreeDirtyDto(path, additions = 6, deletions = 2, files = 4), + ) + layout(body) + } + + edt { + // No pull request chrome to show, but the counters are the reason the popup opened. + assertTrue(components(body).filterIsInstance().none { it.icon is FilledBadgeIcon }) + assertFalse(components(body).filterIsInstance().single().isVisible) + val changes = UIUtil.findComponentOfType(body, ChangesPanel::class.java)!! + assertTrue(changes.isVisible) + assertEquals( + listOf("4 files", "-2", "+6", "1", "2"), + components(changes).filterIsInstance().filter { it.isVisible }.map { it.text }, + ) + } + } + fun `test a clean worktree drops the rule with the changes row`() { val body = body() 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 ed44a5ee6de..b894fb61c9f 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 @@ -77,11 +77,43 @@ class ChangesPanelTest : BasePlatformTestCase() { val compact = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) compact.update(1, 2, 0) - compact.update(0, 0, 0, ahead = 8, localFiles = 2) + // A compact summary has no ahead/behind counters, so commits alone leave it with nothing to show. + compact.update(0, 0, 0, ahead = 8) assertFalse(compact.isVisible) assertNull(compact.toolTipText) } + fun `test compact stands uncommitted counts in for an empty committed set`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + + view.update(0, 0, 0, ahead = 8, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") + + assertTrue(view.isVisible) + assertEquals(listOf("2 files", "-3", "+9"), labels(view)) + assertEquals(KiloBundle.message("worktree.dirty.tooltip.open"), view.toolTipText) + + // One committed file outranks any amount of uncommitted work: it is the number a PR would show. + view.update(1, 4, 0, localFiles = 2, localAdditions = 9, localDeletions = 3, base = "origin/main") + assertEquals(listOf("1 file", "+4"), labels(view)) + assertEquals(KiloBundle.message("worktree.stats.tooltip.open"), view.toolTipText) + } + + fun `test compact ignores uncommitted counts it is not showing`() = edt { + val view = ChangesPanel(ChangesPanel.Mode.COMPACT, onBase = {}) + view.update(2, 1, 1) + val previous = RepaintManager.currentManager(view) + val tracker = Tracker(view) + RepaintManager.setCurrentManager(tracker) + try { + repeat(100) { view.update(2, 1, 1, localFiles = it + 1, localAdditions = it, localDeletions = it) } + assertEquals(0, tracker.invalidations) + assertEquals(0, tracker.paints) + } finally { + RepaintManager.setCurrentManager(previous) + } + assertEquals(listOf("2 files", "-1", "+1"), labels(view)) + } + fun `test ahead behind remain independent from file groups`() = edt { val view = ChangesPanel(ChangesPanel.Mode.FULL) view.update(0, 0, 0, ahead = 2)