From b6dcee651b85e9721e5fdb7d4db0db7c090c9149 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 7 Aug 2026 12:22:11 -0400 Subject: [PATCH] feat(jetbrains): make worktree metrics actionable --- .../client/agentManager/AgentManagerPanel.kt | 44 ++++- .../worktree/WorktreeStatsView.kt | 77 +++++++-- .../ai/kilocode/client/ui/list/ActiveList.kt | 2 +- .../client/ui/list/ActiveListActions.kt | 4 + .../client/ui/list/ActiveListModel.kt | 121 +++++++++---- .../client/ui/list/ActiveListRenderer.kt | 19 +- .../kilocode/client/ui/list/ActiveListView.kt | 90 +++++++--- .../settings/base/SettingsListViewTest.kt | 162 ++++++++++++++++++ 8 files changed, 452 insertions(+), 67 deletions(-) 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 cee363d8ce8..7488a715eb2 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 @@ -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().open( + KiloDiffEditorKind.ID, + diffParams("branch", path, null, KiloBundle.message("diff.editor.branch.title")), + ) } } 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 c1b31dc7e92..459485d1317 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 @@ -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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt index 7d6a0c67ff2..46becf2dbc9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt @@ -123,7 +123,7 @@ internal class ActiveList( @RequiresEdt fun setListCursor(cursor: Cursor) { - view.list.cursor = cursor + view.setBaseCursor(cursor) } @RequiresEdt 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 8d30460107b..c0c8f529e11 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,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, 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 9ec1a9f8ffe..1d6deb71d2a 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 @@ -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 { + val model = list.model + if (index < 0 || index >= model.size) return emptyList() + @Suppress("UNCHECKED_CAST") + val renderer = list.cellRenderer as? ListCellRenderer ?: 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() + 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 { - val model = list.model - if (index < 0 || index >= model.size) return emptyMap() - @Suppress("UNCHECKED_CAST") - val renderer = list.cellRenderer as? ListCellRenderer ?: 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() - 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 { - val out = mutableListOf() +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 } 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 f1e6f97b45c..15a19e92913 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 @@ -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) 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 75662b490d5..59f678aecee 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 @@ -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("
") { 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 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 2a4049327dd..24394be4ac1 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 @@ -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() + val onCellCalls = mutableListOf() + 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() + 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("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() + 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.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() + 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