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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-uncommitted-changes-badge.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ class AgentManagerPanel(
controller.kind(item.path),
stats[key],
pull,
dirty[key],
)
},
ActiveListSelection.Preserve,
Expand Down Expand Up @@ -566,19 +567,31 @@ 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")
val body = WorktreeRowPopupBody(
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)
Expand Down Expand Up @@ -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() },
)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) },
)
}

Expand All @@ -785,6 +808,7 @@ class AgentManagerPanel(
kind == row.kind &&
stats == row.stats &&
pr == row.pr &&
dirty == row.dirty &&
current == row.current
}

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -88,33 +93,41 @@ 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
// A compact summary sits inside a row that already prints the file count and the +/- lines, so
// 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()
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ internal fun activeListRegions(item: ActiveListItem): Map<String, () -> 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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ worktree.stats.tooltip=<html>{0} files changed, +{1} -{2}.<br>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=<html>{0} uncommitted files, +{1} -{2}.<br>Click to compare with HEAD.</html>
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
Expand Down
Loading
Loading