feat(jetbrains): make worktree metrics actionable

This commit is contained in:
kirillk
2026-08-07 12:22:11 -04:00
parent 6a69c7fc48
commit b6dcee651b
8 changed files with 452 additions and 67 deletions
@@ -15,6 +15,9 @@ import ai.kilocode.client.agentManager.worktree.normalizeWorktreePath
import ai.kilocode.client.agentManager.worktree.style
import ai.kilocode.client.agentManager.worktree.worktreeActivityBadge
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.diff.KiloDiffEditorKind
import ai.kilocode.client.diff.diffParams
import ai.kilocode.client.diff.ensureDiffEditorKind
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.UiStyle
@@ -34,6 +37,7 @@ import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.ide.DeleteProvider
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
@@ -442,7 +446,13 @@ class AgentManagerPanel(
}
}
private data class WorktreeRow(
/**
* 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].
*/
private inner class WorktreeRow(
val dto: WorktreeDto,
val pending: Boolean,
override val deleting: Boolean,
@@ -473,8 +483,40 @@ class AgentManagerPanel(
ahead = s?.ahead ?: 0,
behind = s?.behind ?: 0,
pr = p?.let { ActiveListBadge("#${it.number}", style(it.state)) },
onChanges = s?.let { { openBranchDiff(dto.path) } },
onPr = p?.url?.let { url -> { BrowserUtil.browse(url) } },
)
}
override fun equals(other: Any?): Boolean {
val row = other as? WorktreeRow ?: return false
return dto == row.dto &&
pending == row.pending &&
deleting == row.deleting &&
kind == row.kind &&
stats == row.stats &&
pr == row.pr
}
override fun hashCode(): Int {
var result = dto.hashCode()
result = 31 * result + pending.hashCode()
result = 31 * result + deleting.hashCode()
result = 31 * result + (kind?.hashCode() ?: 0)
result = 31 * result + (stats?.hashCode() ?: 0)
result = 31 * result + (pr?.hashCode() ?: 0)
return result
}
}
@RequiresEdt
private fun openBranchDiff(path: String) {
val target = project ?: return
ensureDiffEditorKind()
target.service<KiloVfsManager>().open(
KiloDiffEditorKind.ID,
diffParams("branch", path, null, KiloBundle.message("diff.editor.branch.title")),
)
}
}
@@ -4,7 +4,10 @@ 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_PR_CELL
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListHitCell
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
@@ -17,6 +20,7 @@ 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.Cursor
import java.awt.Dimension
import java.awt.event.MouseAdapter
@@ -25,7 +29,7 @@ import javax.swing.Icon
import javax.swing.JPanel
internal class WorktreeStatsView(
private val openDiff: (() -> Unit)? = null,
openDiff: (() -> Unit)? = null,
fill: Boolean = true,
) : JPanel(null) {
companion object {
@@ -38,9 +42,14 @@ internal class WorktreeStatsView(
private val diff = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT, fill = fill)
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.
private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) }
private val prHit = HitRegion(ACTIVE_LIST_PR_CELL).apply { add(pr, BorderLayout.CENTER) }
// Change badge and PR link stack vertically, each pinned to the trailing edge.
private val changeLine = change.align(HAlign.RIGHT, VAlign.CENTER)
private val prLine = pr.align(HAlign.RIGHT, VAlign.CENTER)
private val changeLine = changeHit.align(HAlign.RIGHT, VAlign.CENTER)
private val prLine = prHit.align(HAlign.RIGHT, VAlign.CENTER)
private val row = Stack.vertical(UiStyle.Gap.sm()).next(changeLine).next(prLine)
private var url: String? = null
private var stats: WorktreeStatsDto? = null
@@ -48,19 +57,30 @@ internal class WorktreeStatsView(
init {
add(row)
diff.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR).takeIf { openDiff != null } ?: Cursor.getDefaultCursor()
changeHit.act = openDiff
prHit.act = { url?.let(BrowserUtil::browse) }
diff.toolTipText = KiloBundle.message("worktree.stats.diff.tooltip", 0, 0)
diff.addMouseListener(object : MouseAdapter() {
changeHit.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
openDiff?.invoke()
changeHit.act?.invoke()
}
})
pr.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
pr.addMouseListener(object : MouseAdapter() {
prHit.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
url?.let(BrowserUtil::browse)
prHit.act?.invoke()
}
})
applyCursors()
}
/**
* 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.
*/
fun setActions(onChanges: (() -> Unit)?, onPr: (() -> Unit)?) {
changeHit.act = onChanges
prHit.act = onPr
applyCursors()
}
fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?) {
@@ -87,18 +107,33 @@ internal class WorktreeStatsView(
ahead.isVisible = s.ahead > 0
diff.update(s.additions, s.deletions)
diff.isVisible = s.additions > 0 || s.deletions > 0
diff.toolTipText = KiloBundle.message("worktree.stats.diff.tooltip", s.additions, s.deletions)
val diffTip = KiloBundle.message("worktree.stats.diff.tooltip", s.additions, s.deletions)
diff.toolTipText = diffTip
changeHit.tip = diffTip
url = link
pr.icon = badge?.let { FilledBadgeIcon(it.text, it.style) }
pr.toolTipText = tip
prHit.tip = tip
pr.isVisible = badge != null
changeLine.isVisible = behind.isVisible || ahead.isVisible || diff.isVisible
val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible
changeLine.isVisible = changesVisible
changeHit.isVisible = changesVisible
prLine.isVisible = pr.isVisible
isVisible = changeLine.isVisible || prLine.isVisible
prHit.isVisible = pr.isVisible
isVisible = changesVisible || pr.isVisible
applyCursors()
revalidate()
repaint()
}
private fun applyCursors() {
changeHit.cursor = actionCursor(changeHit.act != null)
prHit.cursor = actionCursor(prHit.act != null)
}
private fun actionCursor(active: Boolean) =
if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
override fun getPreferredSize(): Dimension {
val ins = insets
val size = row.preferredSize
@@ -118,6 +153,24 @@ internal class WorktreeStatsView(
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.empty()
}
/** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */
private 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
}
}
internal fun style(state: GhState): UiStyle.Badge.Style = when (state) {
@@ -123,7 +123,7 @@ internal class ActiveList(
@RequiresEdt
fun setListCursor(cursor: Cursor) {
view.list.cursor = cursor
view.setBaseCursor(cursor)
}
@RequiresEdt
@@ -13,6 +13,10 @@ 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]). */
internal const val ACTIVE_LIST_CHANGES_CELL = "__changes__"
internal const val ACTIVE_LIST_PR_CELL = "__pr__"
internal fun activeListRenameCell(label: String = KiloBundle.message("common.rename")) = ActiveListCell(
ACTIVE_LIST_RENAME_CELL,
label,
@@ -4,6 +4,7 @@ import ai.kilocode.client.ui.UiStyle
import com.intellij.util.ui.JBUI
import java.awt.Component
import java.awt.Container
import java.awt.Cursor
import java.awt.Point
import java.awt.Rectangle
import javax.swing.Icon
@@ -22,6 +23,10 @@ internal data class ActiveListMetrics(
val ahead: Int = 0,
val behind: Int = 0,
val pr: ActiveListBadge? = 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,
)
internal enum class ActiveListRowHeight { EQUAL, PREFERRED }
@@ -49,8 +54,32 @@ internal data class ActiveListCell(
val icon: Icon? = null,
val iconOnly: Boolean = false,
val primary: Boolean = false,
/** Hover tooltip; falls back to [label] when null. */
val tooltip: String? = null,
/** Cursor shown while hovering the button; defaults to the action (hand) cursor. */
val cursor: Int = Cursor.HAND_CURSOR,
/**
* Click handler; when set it replaces the list-level onCell callback for this cell. Defaults to
* null so existing cells keep routing through onCell and stay value-equal across row rebuilds.
* A per-cell handler must be a stable reference when the owning list relies on row equality to
* skip rebuilds.
*/
val action: (() -> Unit)? = null,
)
/**
* 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.
*/
internal interface ActiveListHitCell {
val cellId: String
fun cellEnabled(): Boolean
fun cellCursor(): Int
fun cellTooltip(): String?
fun cellAction(): (() -> Unit)?
}
/**
* 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 bold title with an inline [note], a
@@ -103,36 +132,63 @@ internal fun activeListVisibleCells(item: ActiveListItem, active: Boolean): List
internal fun activeListCellGap() = JBUI.scale(CELL_GAP)
/** A hit-tested region of a rendered row, in list coordinates, with its interaction metadata. */
internal class ActiveListHit(
val id: String,
val bounds: Rectangle,
val enabled: Boolean,
val cursor: Int,
val tooltip: String?,
val action: (() -> Unit)?,
)
/**
* Clickable action-cell rectangles for a row, in list coordinates.
*
* The rectangles are read back from the actual rendered component tree instead of being
* re-derived by hand. This keeps the click targets identical to what the [ActiveListRenderer]
* draws — including the action-cell overlay layer and the horizontal insets the platform's
* [com.intellij.ui.popup.list.SelectablePanel] adds in the New UI, which a hand-computed layout
* would miss.
* Hit-test regions for a row, in list coordinates, read back from the actual rendered component
* tree instead of being re-derived by hand. This keeps the click/cursor/tooltip targets identical
* to what the [ActiveListRenderer] draws — including the action-cell overlay layer and the
* horizontal insets the platform's [com.intellij.ui.popup.list.SelectablePanel] adds in the New
* UI, which a hand-computed layout would miss.
*/
internal fun activeListHits(
list: JList<*>,
index: Int,
selected: Boolean,
): List<ActiveListHit> {
val model = list.model
if (index < 0 || index >= model.size) return emptyList()
@Suppress("UNCHECKED_CAST")
val renderer = list.cellRenderer as? ListCellRenderer<Any?> ?: return emptyList()
val cell = list.getCellBounds(index, index) ?: return emptyList()
// Render as focused so the region geometry is available for hit-testing even when the list is
// not the focus owner. Painting still hides the cells on an unfocused list; this only resolves
// hit targets and keeps them stable regardless of focus.
val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, true)
comp.setBounds(0, 0, cell.width, cell.height)
activeListLayout(comp)
val out = mutableListOf<ActiveListHit>()
forEachHitCell(comp) { hit ->
val target = hit as Component
val origin = SwingUtilities.convertPoint(target, 0, 0, comp)
out += ActiveListHit(
hit.cellId,
Rectangle(cell.x + origin.x, cell.y + origin.y, target.width, target.height),
hit.cellEnabled(),
hit.cellCursor(),
hit.cellTooltip(),
hit.cellAction(),
)
}
return out
}
/** Clickable action-cell rectangles for a row, in list coordinates. */
internal fun activeListCellBounds(
list: JList<*>,
index: Int,
selected: Boolean,
): Map<String, Rectangle> {
val model = list.model
if (index < 0 || index >= model.size) return emptyMap()
@Suppress("UNCHECKED_CAST")
val renderer = list.cellRenderer as? ListCellRenderer<Any?> ?: return emptyMap()
val cell = list.getCellBounds(index, index) ?: return emptyMap()
// Render as focused so the action-cell geometry is available for hit-testing even when the
// list is not the focus owner. Painting still hides the cells on an unfocused list; this only
// resolves click targets and keeps them stable regardless of focus.
val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, true)
comp.setBounds(0, 0, cell.width, cell.height)
activeListLayout(comp)
val out = linkedMapOf<String, Rectangle>()
for (action in activeListActionCells(comp)) {
val origin = SwingUtilities.convertPoint(action, 0, 0, comp)
out[action.cellId] = Rectangle(cell.x + origin.x, cell.y + origin.y, action.width, action.height)
}
for (hit in activeListHits(list, index, selected)) out[hit.id] = hit.bounds
return out
}
@@ -146,10 +202,16 @@ internal fun activeListCellAt(
val model = list.model
if (index < 0 || index >= model.size) return null
val item = model.getElementAt(index) as? ActiveListItem ?: return null
val cells = activeListCellBounds(list, index, selected)
return activeListVisibleCells(item, selected, menu)
.firstOrNull { cell -> cell.enabled && cells[cell.id]?.contains(point) == true }
val hits = activeListHits(list, index, selected)
if (hits.isEmpty()) return null
val bounds = hits.associate { it.id to it.bounds }
val fromCell = activeListVisibleCells(item, selected, menu)
.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
}
internal fun activeListCellAt(
@@ -167,12 +229,13 @@ private fun activeListLayout(component: Component) {
for (child in component.components) activeListLayout(child)
}
private fun activeListActionCells(component: Component): List<ActiveListActionCell> {
val out = mutableListOf<ActiveListActionCell>()
private fun forEachHitCell(component: Component, action: (ActiveListHitCell) -> Unit) {
fun visit(c: Component) {
if (c is ActiveListActionCell && c.isVisible) out += c
// 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 Container) c.components.forEach(::visit)
}
visit(component)
return out
}
@@ -21,6 +21,7 @@ import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import ai.kilocode.rpc.dto.WorktreeStatsDto
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JList
@@ -208,6 +209,7 @@ internal class ActiveListRenderer(
desc.foreground = weak
val data = if (value.deleting) null else value.metrics
metrics.update(data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, data?.pr)
metrics.setActions(data?.onChanges, data?.onPr)
val end = if (value.deleting) KiloBundle.message("common.deleting") else value.trailing.orEmpty()
trail.text = end
trail.isVisible = end.isNotBlank() && data == null
@@ -285,20 +287,31 @@ internal interface ActiveListActive {
fun hoveredIndex(): Int = -1
}
internal class ActiveListActionCell : JBLabel() {
var cellId: String = ""
internal class ActiveListActionCell : JBLabel(), ActiveListHitCell {
private var cell: ActiveListCell? = null
override var cellId: String = ""
private set
fun update(cell: ActiveListCell) {
this.cell = cell
cellId = cell.id
text = if (cell.iconOnly) "" else cell.label
icon = cell.icon
toolTipText = cell.label.takeIf { it.isNotBlank() }
toolTipText = (cell.tooltip ?: cell.label).takeIf { it.isNotBlank() }
horizontalAlignment = SwingConstants.CENTER
isEnabled = cell.enabled
if (!cell.iconOnly) UiStyle.Components.actionLabel(this, isEnabled)
}
override fun cellEnabled(): Boolean = cell?.enabled ?: false
override fun cellCursor(): Int = cell?.cursor ?: Cursor.HAND_CURSOR
override fun cellTooltip(): String? = cell?.let { it.tooltip ?: it.label }?.takeIf { it.isNotBlank() }
override fun cellAction(): (() -> Unit)? = cell?.action
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
if (text.isNotBlank()) UiStyle.Components.actionLabel(this, enabled)
@@ -19,6 +19,7 @@ import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.xml.util.XmlStringUtil
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Point
import java.awt.Rectangle
@@ -77,13 +78,10 @@ internal class ActiveListView(
if (!bounds.contains(event.point)) return null
val item = model.getElementAt(idx)
val selected = isSelectedIndex(idx)
val id = activeListCellBounds(this, idx, selected)
.entries
.firstOrNull { it.value.contains(event.point) }
?.key
val cell = activeListVisibleCells(item, selected, menu?.takeIf { it.available(item) } != null)
.firstOrNull { it.id == id }
if (cell != null) return cell.label.takeIf { it.isNotBlank() }
// A button (action cell, menu glyph, or metrics badge) under the pointer owns the
// tooltip; only fall back to the row description when the pointer is over the body.
val hit = activeListHits(this, idx, selected).firstOrNull { it.bounds.contains(event.point) }
if (hit != null) return hit.tooltip?.takeIf { it.isNotBlank() }
if (!cfg.description || !cfg.tooltip) return null
val note = item.tooltip?.takeIf { it.isNotBlank() } ?: return null
val text = note.lines().joinToString("<br>") { XmlStringUtil.escapeString(it) }
@@ -100,6 +98,8 @@ internal class ActiveListView(
private var popups = 0
private var hovered = -1
private var heightKey: ActiveListHeightKey? = null
// Cursor for the row body; buttons override it on hover via [cursorAt].
private var baseCursor: Cursor = Cursor.getDefaultCursor()
internal var onSelect: (() -> Unit)? = null
fun setEmptyText(text: String) {
@@ -168,25 +168,27 @@ internal class ActiveListView(
val hit = hit(e) ?: return
if (hit.item.key != down.key || hit.id != down.id) return
if (hit.item.deleting) return
onCell(hit.item.key, down.id)
fire(hit.item, down.id)
e.consume()
}
override fun mouseMoved(e: MouseEvent) {
if (!hover) return
val idx = list.locationToIndex(e.point)
.takeIf { it >= 0 && list.getCellBounds(it, it)?.contains(e.point) == true }
?: -1
setHovered(idx)
if (hover) {
val idx = list.locationToIndex(e.point)
.takeIf { it >= 0 && list.getCellBounds(it, it)?.contains(e.point) == true }
?: -1
setHovered(idx)
}
syncCursor(cursorAt(e.point))
}
override fun mouseExited(e: MouseEvent) {
if (!hover) return
setHovered(-1)
if (hover) setHovered(-1)
syncCursor(baseCursor)
}
}
list.addMouseListener(mouse)
if (hover) list.addMouseMotionListener(mouse)
list.addMouseMotionListener(mouse)
list.addListSelectionListener { e: ListSelectionEvent ->
// Selection gates the hover-revealed action bar, so repaint the hovered row as soon as
// its selection flips instead of waiting for the next mouse move.
@@ -462,12 +464,12 @@ internal class ActiveListView(
return
}
item.doubleClick?.let { id ->
onCell(item.key, id)
fire(item, id)
return
}
activeListVisibleCells(item, true)
.firstOrNull { it.enabled && it.primary }
?.let { onCell(item.key, it.id) }
?.let { fire(item, it.id) }
}
private fun primary(item: ActiveListItem) {
@@ -475,17 +477,63 @@ internal class ActiveListView(
val cells = activeListVisibleCells(item, true)
val cell = cells.firstOrNull { it.enabled && it.primary }
if (cell != null) {
onCell(item.key, cell.id)
fire(item, cell.id)
return
}
item.doubleClick?.let { id ->
onCell(item.key, id)
fire(item, id)
return
}
cells.firstOrNull { it.enabled }?.let { onCell(item.key, it.id) }
cells.firstOrNull { it.enabled }?.let { fire(item, it.id) }
?: 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.
*/
private fun fire(item: ActiveListItem, id: String) {
when (id) {
ACTIVE_LIST_CHANGES_CELL -> {
item.metrics?.onChanges?.invoke()
return
}
ACTIVE_LIST_PR_CELL -> {
item.metrics?.onPr?.invoke()
return
}
}
val action = item.cells.firstOrNull { it.id == id }?.action
if (action != null) action() else onCell(item.key, id)
}
@RequiresEdt
fun setBaseCursor(cursor: Cursor) {
checkEdt()
baseCursor = cursor
syncCursor(cursor)
}
private fun syncCursor(cursor: Cursor) {
if (list.cursor.type != cursor.type) list.cursor = cursor
}
/**
* Cursor for [point]: the hovered button's cursor (defaulting to the hand cursor) when the
* pointer is over an enabled button, otherwise the row body's [baseCursor].
*/
private fun cursorAt(point: Point): Cursor {
val idx = list.locationToIndex(point)
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return baseCursor
if (!bounds.contains(point)) return baseCursor
val item = model.getElementAt(idx)
if (menu == null && item.cells.isEmpty() && item.metrics == null) return baseCursor
val hit = activeListHits(list, idx, list.isSelectedIndex(idx))
.firstOrNull { it.enabled && it.bounds.contains(point) }
?: return baseCursor
return Cursor.getPredefinedCursor(hit.cursor)
}
private fun hit(e: MouseEvent, enabled: Boolean = true): Hit? {
val idx = list.locationToIndex(e.point)
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null
@@ -2,6 +2,7 @@ package ai.kilocode.client.settings.base
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.FilledBadgeIcon
import ai.kilocode.client.ui.LayeredOverlayPanel
@@ -13,11 +14,14 @@ import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListMenu
import ai.kilocode.client.ui.list.ActiveListMetrics
import ai.kilocode.client.ui.list.ActiveListRenderer
import ai.kilocode.client.ui.list.ActiveListRowHeight
import ai.kilocode.client.ui.list.ActiveListSelection
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_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
@@ -34,6 +38,7 @@ import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Container
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Point
import java.awt.event.InputEvent
@@ -809,6 +814,157 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test per-cell action handler replaces onCell callback`() {
edt {
val actionCalls = mutableListOf<String>()
val onCellCalls = mutableListOf<String>()
val view = ActiveListView("Empty") { key, id -> onCellCalls += "$key:$id" }
val row = item("with", "Alpha", null, ActiveListCell("edit", "Edit", action = { actionCalls += "edit" }))
view.update(listOf(row))
view.list.size = Dimension(320, 80)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val area = activeListCellBounds(view.list, 0, selected = true).getValue("edit")
click(view, center(area))
assertEquals(listOf("edit"), actionCalls)
assertTrue(onCellCalls.isEmpty())
}
}
fun `test cell without action still routes through onCell`() {
edt {
val onCellCalls = mutableListOf<String>()
val view = ActiveListView("Empty") { key, id -> onCellCalls += "$key:$id" }
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
view.list.size = Dimension(320, 80)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val area = activeListCellBounds(view.list, 0, selected = true).getValue("edit")
click(view, center(area))
assertEquals(listOf("with:edit"), onCellCalls)
}
}
fun `test hovering a button shows the action cursor and the body keeps the base cursor`() {
edt {
val view = ActiveListView("Empty") { _, _ -> }
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit", alwaysVisible = true))))
layout(view)
val area = activeListCellBounds(view.list, 0, selected = true).getValue("edit")
hover(view, center(area))
assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type)
val bounds = view.list.getCellBounds(0, 0)
hover(view, Point(bounds.x + 2, bounds.y + bounds.height / 2))
assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type)
}
}
fun `test cell cursor kind is honored on hover`() {
edt {
val view = ActiveListView("Empty") { _, _ -> }
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit", alwaysVisible = true, cursor = Cursor.TEXT_CURSOR))))
layout(view)
val area = activeListCellBounds(view.list, 0, selected = true).getValue("edit")
hover(view, center(area))
assertEquals(Cursor.TEXT_CURSOR, view.list.cursor.type)
}
}
fun `test more menu shows the action cursor on hover`() {
edt {
val key = DataKey.create<ActiveListItem>("test.activeList.menu.cursor")
val menu = ActiveListMenu(key, DefaultActionGroup(), element = { it })
val view = ActiveListView("Empty", menu = menu) { _, _ -> }
view.update(listOf(item("with", "Alpha", null)))
layout(view)
view.list.clearSelection()
// Reveal the glyph so its slot resolves, then read and hover it.
hover(view, center(view.list.getCellBounds(0, 0)))
val area = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_MENU_CELL)
hover(view, center(area))
assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type)
}
}
fun `test cell tooltip overrides label`() {
edt {
val view = ActiveListView("Empty") { _, _ -> }
view.update(listOf(item("with", "Alpha", "Desc", ActiveListCell("edit", "Edit", alwaysVisible = true, tooltip = "Custom tip"))))
layout(view)
val area = activeListCellBounds(view.list, 0, selected = true).getValue("edit")
assertEquals("Custom tip", view.list.getToolTipText(event(view.list, center(area))))
}
}
fun `test changes badge is hit tested with cursor tooltip and action`() {
edt {
val calls = mutableListOf<String>()
val onCellCalls = mutableListOf<String>()
val view = ActiveListView("Empty") { key, id -> onCellCalls += "$key:$id" }
view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(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.diff.tooltip", 3, 2), 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("changes"), calls)
assertTrue(onCellCalls.isEmpty())
}
}
fun `test pr badge is hit tested and invokes its action`() {
edt {
val calls = mutableListOf<String>()
val view = ActiveListView("Empty") { _, _ -> }
view.update(listOf(metricsItem("wt", "Alpha", ActiveListMetrics(pr = ActiveListBadge("#12"), onPr = { calls += "pr" }))))
view.list.size = Dimension(360, 80)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_PR_CELL)
hover(view, center(area))
assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type)
click(view, center(area))
assertEquals(listOf("pr"), calls)
}
}
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.list.size = Dimension(360, 80)
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL)
// The badge still renders, but with no handler it is not an actionable cell.
assertNull(activeListCellAt(view.list, 0, center(area), selected = true))
hover(view, center(area))
assertEquals(Cursor.DEFAULT_CURSOR, view.list.cursor.type)
}
}
private fun item(id: String, name: String, note: String?, vararg cells: ActiveListCell) = object : ActiveListItem {
override val key = id
override val title = name
@@ -816,6 +972,12 @@ class SettingsListViewTest : BasePlatformTestCase() {
override val cells = cells.toList()
}
private fun metricsItem(id: String, name: String, data: ActiveListMetrics) = object : ActiveListItem {
override val key = id
override val title = name
override val metrics = data
}
private fun sectionItem(id: String, name: String, group: String) = object : ActiveListItem {
override val key = id
override val title = name