mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(jetbrains): make worktree metrics actionable
This commit is contained in:
+43
-1
@@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-12
@@ -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) {
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ internal class ActiveList(
|
||||
|
||||
@RequiresEdt
|
||||
fun setListCursor(cursor: Cursor) {
|
||||
view.list.cursor = cursor
|
||||
view.setBaseCursor(cursor)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+92
-29
@@ -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
|
||||
}
|
||||
|
||||
+16
-3
@@ -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)
|
||||
|
||||
+69
-21
@@ -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
|
||||
|
||||
+162
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user