mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
feat(agent-manager): reveal worktree and session row actions on hover
Show the ActiveList action bar only for the row the mouse is hovering in the Agent Manager worktree list and the worktree-editor session list, gated on that row also being selected. Other ActiveList consumers keep the existing selection-based reveal. Track hover state in ActiveListView behind a new ActiveListConfig.hoverActions flag, scope repaints to the affected row cells, and repaint the hovered row on selection changes so the rule applies immediately. The action overlay background now mirrors the row background instead of always painting a focused-selection highlight.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show Agent Manager row actions only while hovering worktree and session rows.
|
||||
+2
@@ -17,6 +17,7 @@ import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.list.ActiveList
|
||||
import ai.kilocode.client.ui.list.ActiveListBadge
|
||||
import ai.kilocode.client.ui.list.ActiveListCell
|
||||
import ai.kilocode.client.ui.list.ActiveListConfig
|
||||
import ai.kilocode.client.ui.list.ActiveListDeleteOptions
|
||||
import ai.kilocode.client.ui.list.ActiveListItem
|
||||
import ai.kilocode.client.ui.list.ActiveListSelection
|
||||
@@ -67,6 +68,7 @@ class AgentManagerPanel(
|
||||
private val edit = RenameAction()
|
||||
private val list = ActiveList(
|
||||
KiloBundle.message("worktree.empty"),
|
||||
cfg = ActiveListConfig(hoverActions = true),
|
||||
surface = ActiveListSurface.ToolWindow,
|
||||
showSearch = false,
|
||||
onCell = { key, id ->
|
||||
|
||||
+1
@@ -66,6 +66,7 @@ class WorktreeSessionEditorPanel(
|
||||
ActiveListRowHeight.EQUAL,
|
||||
description = false,
|
||||
selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION,
|
||||
hoverActions = true,
|
||||
),
|
||||
surface = ActiveListSurface.ToolWindow,
|
||||
showSearch = false,
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ internal data class ActiveListConfig(
|
||||
val descriptionIndent: Boolean = true,
|
||||
val tooltip: Boolean = true,
|
||||
val selection: Int = ListSelectionModel.SINGLE_SELECTION,
|
||||
val hoverActions: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
val Equal = ActiveListConfig(ActiveListRowHeight.EQUAL)
|
||||
|
||||
+8
-4
@@ -159,12 +159,14 @@ internal class ActiveListRenderer(
|
||||
trail.isVisible = end.isNotBlank()
|
||||
trail.foreground = weak
|
||||
|
||||
// In-place action buttons follow the selection highlight: only when the selection is
|
||||
// visible (list focused, or an owned popup is active). An unfocused list hides them.
|
||||
syncCells(value, active && list.isEnabled, list.isEnabled)
|
||||
val hovered = (list as? ActiveListActive)?.hoveredIndex() == index
|
||||
val show = if (cfg.hoverActions) list.isEnabled && selected && hovered else active && list.isEnabled
|
||||
syncCells(value, show, list.isEnabled)
|
||||
cellPane.isVisible = cells.isVisible
|
||||
pill.isVisible = cells.isVisible
|
||||
pill.background = if (active && list.isEnabled) UIUtil.getListBackground(true, true) else list.background
|
||||
// Match the row's own background so the pill never paints a focused-selection highlight
|
||||
// on a row that is not the focused selection (e.g. a hovered, unselected row).
|
||||
pill.background = if (selected && list.isEnabled) UIUtil.getListBackground(true, active) else list.background
|
||||
val height = bodyHeight
|
||||
wrap.setPreferredSize(height?.let { Dimension(0, it) })
|
||||
top.invalidate()
|
||||
@@ -223,6 +225,8 @@ internal class ActiveListRenderer(
|
||||
|
||||
internal interface ActiveListActive {
|
||||
fun active(): Boolean
|
||||
|
||||
fun hoveredIndex(): Int = -1
|
||||
}
|
||||
|
||||
internal class ActiveListActionCell : JBLabel() {
|
||||
|
||||
+42
-2
@@ -50,6 +50,8 @@ internal class ActiveListView(
|
||||
internal val list: JBList<ActiveListItem> = object : JBList<ActiveListItem>(model), ActiveListActive {
|
||||
override fun active(): Boolean = popups > 0
|
||||
|
||||
override fun hoveredIndex(): Int = hovered
|
||||
|
||||
override fun getBackground(): Color {
|
||||
if (surface == ActiveListSurface.ToolWindow) return activeListToolWindowBackground()
|
||||
return super.getBackground() ?: UIUtil.getListBackground(false, false)
|
||||
@@ -84,6 +86,7 @@ internal class ActiveListView(
|
||||
private var filter = ""
|
||||
private var press: Press? = null
|
||||
private var popups = 0
|
||||
private var hovered = -1
|
||||
internal var onSelect: (() -> Unit)? = null
|
||||
|
||||
fun setEmptyText(text: String) {
|
||||
@@ -106,7 +109,7 @@ internal class ActiveListView(
|
||||
JComponent.WHEN_FOCUSED,
|
||||
)
|
||||
}
|
||||
list.addMouseListener(object : MouseAdapter() {
|
||||
val mouse = object : MouseAdapter() {
|
||||
override fun mousePressed(e: MouseEvent) {
|
||||
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_PRESSED, true)) return
|
||||
list.requestFocusInWindow()
|
||||
@@ -150,8 +153,26 @@ internal class ActiveListView(
|
||||
onCell(hit.item.key, down.id)
|
||||
e.consume()
|
||||
}
|
||||
})
|
||||
|
||||
override fun mouseMoved(e: MouseEvent) {
|
||||
if (!cfg.hoverActions) return
|
||||
val idx = list.locationToIndex(e.point)
|
||||
.takeIf { it >= 0 && list.getCellBounds(it, it)?.contains(e.point) == true }
|
||||
?: -1
|
||||
setHovered(idx)
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
if (!cfg.hoverActions) return
|
||||
setHovered(-1)
|
||||
}
|
||||
}
|
||||
list.addMouseListener(mouse)
|
||||
if (cfg.hoverActions) 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.
|
||||
if (cfg.hoverActions) repaintRow(hovered)
|
||||
if (!e.valueIsAdjusting) onSelect?.invoke()
|
||||
}
|
||||
list.addFocusListener(object : FocusAdapter() {
|
||||
@@ -270,6 +291,7 @@ internal class ActiveListView(
|
||||
@RequiresEdt
|
||||
fun setBusy(value: Boolean) {
|
||||
checkEdt()
|
||||
if (value) setHovered(-1)
|
||||
list.setPaintBusy(value)
|
||||
if (list.isEnabled == !value) return
|
||||
list.isEnabled = !value
|
||||
@@ -326,6 +348,7 @@ internal class ActiveListView(
|
||||
@RequiresEdt
|
||||
private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null, scroll: Boolean = true) {
|
||||
checkEdt()
|
||||
setHovered(-1)
|
||||
val q = filter.trim()
|
||||
val rows = if (q.isBlank()) items else items.filter { matcher(q, it) }
|
||||
model.replaceAll(rows)
|
||||
@@ -337,6 +360,23 @@ internal class ActiveListView(
|
||||
if (idx >= 0) choose(idx, scroll) else list.clearSelection()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun setHovered(idx: Int) {
|
||||
checkEdt()
|
||||
if (hovered == idx) return
|
||||
val old = hovered
|
||||
hovered = idx
|
||||
repaintRow(old)
|
||||
repaintRow(idx)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun repaintRow(idx: Int) {
|
||||
checkEdt()
|
||||
if (idx < 0) return
|
||||
list.getCellBounds(idx, idx)?.let { list.repaint(it) }
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncCellHeight(rows: List<ActiveListItem>) {
|
||||
checkEdt()
|
||||
|
||||
+131
-2
@@ -491,6 +491,110 @@ class SettingsListViewTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun `test hover alone does not reveal cells on unselected row`() {
|
||||
edt {
|
||||
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
|
||||
val view = ActiveListView("Empty", cfg) { _, _ -> }
|
||||
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
|
||||
layout(view)
|
||||
view.list.clearSelection()
|
||||
|
||||
hover(view, center(view.list.getCellBounds(0, 0)))
|
||||
|
||||
assertTrue(renderedCells(view, 0).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test selection alone does not reveal cells without hover`() {
|
||||
edt {
|
||||
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
|
||||
val view = ActiveListView("Empty", cfg) { _, _ -> }
|
||||
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
|
||||
layout(view)
|
||||
view.list.selectedIndex = 0
|
||||
exit(view)
|
||||
|
||||
assertTrue(renderedCells(view, 0).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test hover reveals cells only on the selected hovered row`() {
|
||||
edt {
|
||||
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
|
||||
val view = ActiveListView("Empty", cfg) { _, _ -> }
|
||||
view.update(listOf(
|
||||
item("a", "Alpha", null, ActiveListCell("edit", "Edit")),
|
||||
item("b", "Beta", null, ActiveListCell("delete", "Delete")),
|
||||
))
|
||||
layout(view)
|
||||
view.list.selectedIndex = 0
|
||||
|
||||
hover(view, center(view.list.getCellBounds(0, 0)))
|
||||
assertEquals(listOf("edit"), renderedCells(view, 0))
|
||||
assertTrue(renderedCells(view, 1).isEmpty())
|
||||
|
||||
// Hovering the other, unselected row must not reveal its cells.
|
||||
hover(view, center(view.list.getCellBounds(1, 1)))
|
||||
assertTrue(renderedCells(view, 0).isEmpty())
|
||||
assertTrue(renderedCells(view, 1).isEmpty())
|
||||
|
||||
exit(view)
|
||||
assertTrue(renderedCells(view, 0).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun `test selecting the hovered row reveals cells immediately`() {
|
||||
edt {
|
||||
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
|
||||
val view = ActiveListView("Empty", cfg) { _, _ -> }
|
||||
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
|
||||
layout(view)
|
||||
view.list.clearSelection()
|
||||
hover(view, center(view.list.getCellBounds(0, 0)))
|
||||
assertTrue(renderedCells(view, 0).isEmpty())
|
||||
|
||||
view.list.selectedIndex = 0
|
||||
|
||||
assertEquals(listOf("edit"), renderedCells(view, 0))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test hovered selected unfocused row uses unfocused selection background`() {
|
||||
edt {
|
||||
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
|
||||
val row = item("with", "Alpha", null, ActiveListCell("edit", "Edit"))
|
||||
val model = CollectionListModel<ActiveListItem>(listOf(row))
|
||||
val list = object : JBList<ActiveListItem>(model), ActiveListActive {
|
||||
override fun active(): Boolean = false
|
||||
|
||||
override fun hoveredIndex(): Int = 0
|
||||
}
|
||||
val renderer = ActiveListRenderer(model, cfg)
|
||||
|
||||
renderer.getListCellRendererComponent(list, row, 0, true, false)
|
||||
|
||||
assertEquals(listOf("edit"), actionCells(renderer).filter { it.isVisible }.map { it.cellId })
|
||||
assertEquals(UIUtil.getListBackground(true, false), actionPill(renderer).background)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test always visible action cells remain visible in hover action list`() {
|
||||
edt {
|
||||
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
|
||||
val view = ActiveListView("Empty", cfg) { _, _ -> }
|
||||
view.update(listOf(item(
|
||||
"with",
|
||||
"Alpha",
|
||||
null,
|
||||
ActiveListCell("level", "Allow", alwaysVisible = true),
|
||||
ActiveListCell("edit", "Edit"),
|
||||
)))
|
||||
layout(view)
|
||||
|
||||
assertEquals(listOf("level"), renderedCells(view, 0))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test renderer reuses action cells across updates`() {
|
||||
edt {
|
||||
val first = item(
|
||||
@@ -674,6 +778,21 @@ class SettingsListViewTest : BasePlatformTestCase() {
|
||||
private fun actionCells(root: java.awt.Component): List<ActiveListActionCell> =
|
||||
components(root).filterIsInstance<ActiveListActionCell>()
|
||||
|
||||
private fun renderedCells(view: ActiveListView, idx: Int): List<String> {
|
||||
val value = view.list.model.getElementAt(idx)
|
||||
val bounds = view.list.getCellBounds(idx, idx)
|
||||
val comp = view.list.cellRenderer.getListCellRendererComponent(
|
||||
view.list,
|
||||
value,
|
||||
idx,
|
||||
view.list.isSelectedIndex(idx),
|
||||
true,
|
||||
)
|
||||
comp.setBounds(0, 0, view.list.width, bounds.height)
|
||||
layout(comp as Container)
|
||||
return actionCells(comp).filter { it.isVisible }.map { it.cellId }
|
||||
}
|
||||
|
||||
private fun actionPill(root: java.awt.Component): JPanel {
|
||||
val cell = actionCells(root).single()
|
||||
return cell.parent.parent.parent as JPanel
|
||||
@@ -704,6 +823,16 @@ class SettingsListViewTest : BasePlatformTestCase() {
|
||||
fire(view.list, mouse(view, MouseEvent.MOUSE_RELEASED, point))
|
||||
}
|
||||
|
||||
private fun hover(view: ActiveListView, point: Point) {
|
||||
val event = event(view.list, point)
|
||||
view.list.mouseMotionListeners.forEach { it.mouseMoved(event) }
|
||||
}
|
||||
|
||||
private fun exit(view: ActiveListView) {
|
||||
val event = event(view.list, Point(-1, -1), MouseEvent.MOUSE_EXITED)
|
||||
view.list.mouseListeners.forEach { it.mouseExited(event) }
|
||||
}
|
||||
|
||||
private fun mouse(view: ActiveListView, id: Int, point: Point, count: Int = 1) = MouseEvent(
|
||||
view.list,
|
||||
id,
|
||||
@@ -716,9 +845,9 @@ class SettingsListViewTest : BasePlatformTestCase() {
|
||||
MouseEvent.BUTTON1,
|
||||
)
|
||||
|
||||
private fun event(list: javax.swing.JList<*>, point: Point) = MouseEvent(
|
||||
private fun event(list: javax.swing.JList<*>, point: Point, id: Int = MouseEvent.MOUSE_MOVED) = MouseEvent(
|
||||
list,
|
||||
MouseEvent.MOUSE_MOVED,
|
||||
id,
|
||||
System.currentTimeMillis(),
|
||||
0,
|
||||
point.x,
|
||||
|
||||
Reference in New Issue
Block a user