feat(jetbrains): add worktree row menus

Replace inline worktree rename and delete buttons with row action menus so the lists match IntelliJ overlay menu behavior and avoid row layout jumps.
This commit is contained in:
kirillk
2026-08-06 13:51:47 -04:00
parent 4100415aca
commit d1ae907746
21 changed files with 640 additions and 54 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show worktree row actions in a hover menu instead of inline rename and delete buttons.
@@ -0,0 +1,217 @@
# Worktree list row menu (kebab) + context menu
## Goal
Replace the two inline hover cells (pencil rename / trash delete) on the **worktree list**
and the **worktree session list** with a single hover-only "kebab" (⋮) button per row. The
button opens an **action-group popup** for that row. The same action group is also installed as
the right-click **context menu** on the list. Make the button a generic, opt-in capability of
`ActiveList` driven by a typed `DataKey<T>` for the row's element (matches the existing
`HistoryDataKeys` pattern).
## Scope
- In scope: worktree list (`AgentManagerPanel`) and worktree session list
(`WorktreeSessionEditorPanel`).
- Out of scope: `HistoryPanel` (keeps its current pencil/trash + right-click menu unchanged).
- All files are under `packages/kilo-jetbrains/` (Kilo-owned; no `kilocode_change` markers).
## Key decisions (chosen defaults)
1. Generic capability lives on `ActiveList` via a self-contained `ActiveListMenu<T>` descriptor
(typed element `DataKey<T>` + `ActionGroup` + `(ActiveListItem) -> T?` resolver). `ActiveList`
stores it as `ActiveListMenu<*>?`; the generic type stays contained inside the descriptor (no
`ActiveList<T>` refactor, no unchecked casts leaking to callers).
2. Kebab glyph: `AllIcons.Actions.More`, rendered as an `ActiveListActionCell` (`iconOnly = true`)
appended at the trailing end of the existing overlay `cells` stack.
3. Kebab visibility: `list.isEnabled && hovered`, **independent of selection** (new rule, distinct
from the current `selected && hovered` path used by regular cells).
4. Clicking the kebab must **not** change list selection. Achieved by overriding
`processMouseEvent` on the `JBList` and returning before `super` for a `MOUSE_PRESSED` over the
kebab (`MouseEvent.consume()` does not stop `BasicListUI`'s selection listener; skipping `super`
does). Selecting "Rename" afterwards may select the row — that is fine; the no-select rule only
applies to opening the menu.
5. Both entry points reuse one `ActionGroup` per list:
- Kebab: `ActiveListMenu.context(anchor, item)` builds a `SimpleDataContext` with the hovered
row's element under the typed key, parented to the list's `DataManager` context, then
`JBPopupFactory.createActionGroupPopup(...)`.
- Right-click: existing `ActiveList.installPopup(group)` (`PopupHandler.installPopupMenu`);
`BasicListUI` selects the row on popup-trigger press, so the panel's data snapshot supplies
the element from the current selection.
6. Actions are XML-registered and read the typed element key (idiomatic; mirrors History; testable
via XML-id assertion). Row actions invoke the panel's existing balloon flows via a panel
`DataKey` (`SidePanelKeys.WORKTREE_PANEL` already exists for the worktree list; add one for the
session panel).
7. Menu-only actions carry no keyboard shortcut, to avoid conflicting with the worktree list's
existing `DELETE_ELEMENT_PROVIDER`/`RenameElement` handling, which stays as-is.
## Architecture / API
New `ui/list/ActiveListMenu.kt`:
```kotlin
internal class ActiveListMenu<T : Any>(
private val key: DataKey<T>,
val group: ActionGroup,
private val element: (ActiveListItem) -> T?,
val place: String = ActionPlaces.POPUP,
) {
fun context(anchor: JComponent, item: ActiveListItem): DataContext {
val builder = SimpleDataContext.builder()
.setParent(DataManager.getInstance().getDataContext(anchor))
element(item)?.let { builder.add(key, it) }
return builder.build()
}
}
```
Data flow:
```
hover row -> kebab shown (hover only) -> press kebab
-> processMouseEvent skips super (no selection change)
-> ActiveListMenu.context(list, item) => SimpleDataContext[key = element(item)] + parent
-> JBPopupFactory.createActionGroupPopup(group, ctx).show(at kebab)
-> Action.actionPerformed: e.getData(key) -> element; e.getData(panelKey) -> panel
-> Rename -> panel.beginRename(element) (existing edit balloon)
-> Delete -> panel.showDeletePopup(element) (existing confirm balloon)
right-click row -> BasicListUI selects row -> installPopup(group)
-> DataContext from list's UiDataProvider snapshot (element from selection + panelKey)
-> same actions
```
## Task list
### A. Generic `ActiveList` menu capability (`ui/list/`)
1. Add `ACTIVE_LIST_MENU_CELL = "__menu__"` + `activeListMenuCell()` factory
(`AllIcons.Actions.More`, `iconOnly = true`) in `ActiveListActions.kt`.
2. Add `ActiveListMenu.kt` (descriptor above).
3. `ActiveList` + `ActiveListView`: add optional `menu: ActiveListMenu<*>? = null` ctor param.
Treat `menu != null` like `hoverActions` for installing the mouse-motion listener and hover
repaints (so `hovered` tracks without requiring selection).
4. `ActiveListRenderer.syncCells`: when `menu != null`, append the kebab cell at the end; show it
when `list.isEnabled && hovered` regardless of `selected`. Keep the existing rule for any
item-provided `cells`.
5. `ActiveListModel.kt`: make the reserved menu id hittable in `activeListCellAt` even though it is
not part of `item.cells` (the geometry from `activeListCellBounds` already includes the rendered
kebab; add the menu id to the id set considered by `activeListCellAt` and the tooltip resolver in
`ActiveListView.getToolTipText`).
6. `ActiveListView`: in the anonymous `JBList`, override `processMouseEvent` to intercept
`MOUSE_PRESSED` over the kebab: build `menu.context(list, item)`, show
`createActionGroupPopup` anchored at the kebab rect (reuse `activeListCellBounds` /
`point(key, cell)`), track the popup via existing `trackPopup` for the active-selection paint,
and return before `super` (no selection). Because the added `MouseListener.mousePressed` never
runs, the existing release/click cell dispatch no-ops for the kebab automatically.
### B. Worktree list (`AgentManagerPanel`)
7. New `agentManager/worktree/WorktreeDataKeys.kt`:
`WORKTREE: DataKey<WorktreeDto> = DataKey.create("ai.kilocode.client.agentManager.worktree.Worktree")`.
8. `AgentManagerPanel`:
- Make `beginRename(WorktreeDto)` and `showDeletePopup(WorktreeDto)` `internal` (they already
exist as private, anchoring balloons via `list.point(id, cell)`).
- Extend `uiDataSnapshot` to also emit `sink[WorktreeDataKeys.WORKTREE] = selectedRow()?.dto`.
- Build the `ActiveListMenu` with `WorktreeDataKeys.WORKTREE`, the XML group, and
`element = { (it as? WorktreeRow)?.dto }`; pass to the `ActiveList` ctor. Call
`list.installPopup(group)` for the right-click menu.
- Remove `WorktreeRow.cells` (the rename/delete cells) and the `ACTIVE_LIST_RENAME_CELL` /
`ACTIVE_LIST_DELETE_CELL` routing in `onCell` (the `onCell` lambda becomes empty/removed).
- Keep `RenameAction` inner class + `RenameElement` shortcut and `DELETE_ELEMENT_PROVIDER`.
9. New actions `actions/RenameWorktreeAction.kt`, `actions/DeleteWorktreeAction.kt`:
`update` enabled when `e.getData(WORKTREE)` renameable/deletable and
`e.getData(SidePanelKeys.WORKTREE_PANEL) != null`; `actionPerformed` calls
`panel.beginRename(worktree)` / `panel.showDeletePopup(worktree)`. `ActionUpdateThread.EDT`.
### C. Worktree session list (`WorktreeSessionEditorPanel`)
10. New `agentManager/worktree/WorktreeSessionDataKeys.kt`:
`SESSION: DataKey<SessionDto>` and `PANEL: DataKey<WorktreeSessionEditorPanel>`.
11. `WorktreeSessionEditorPanel`:
- Make `beginRename(key)` and `confirmDelete(ids, cell)` reachable from actions via narrow
`internal` wrappers that accept a `SessionDto`/id.
- Extend `uiDataSnapshot` to emit `sink[PANEL] = this` and
`sink[SESSION] = <single selected SessionDto>` (lead selection; multi-select stays served by
the existing toolbar `delete`/`rename`).
- Build `ActiveListMenu(SESSION, group, element = { (it as? SessionRow)?.session })`; pass to
the `ActiveList`; call `list.installPopup(group)`.
- Remove `SessionRow.cells` and the `ACTIVE_LIST_RENAME_CELL`/`ACTIVE_LIST_DELETE_CELL` routing
in `onCell`. Keep toolbar `NewAction`/`RenameAction`/`DeleteAction` and keyboard shortcut.
12. New actions `actions/RenameWorktreeSessionAction.kt`, `actions/DeleteWorktreeSessionAction.kt`
reading `SESSION` + `PANEL`, calling the panel wrappers. `ActionUpdateThread.EDT`.
### D. XML + strings
13. `resources/kilo.jetbrains.frontend.xml`: register the 4 actions and two groups
`Kilo.Worktree.RowMenu` (Rename, separator, Delete) and `Kilo.WorktreeSession.RowMenu`
(Rename, Delete). No `<keyboard-shortcut>` / `use-shortcut-of` on these.
14. `resources/messages/KiloBundle.properties`: add `action.*.text` (and optional `.description`)
for the new actions/groups. Other locale files fall back to the base bundle.
### E. Tests (real Swing / `BasePlatformTestCase`; no EDT mocking)
15. `ui/list` (extend `SettingsListViewTest` or add `ActiveListMenuTest`): kebab renders only when
`menu != null`; visible on hover with **no** selection; pressing the kebab does **not** change
`selectedIndex`; `ActiveListMenu.context(...)` returns the element under the key.
16. Action tests mirroring `HistorySessionActionsTest`: assert the two group ids exist in the XML,
and `update`/`actionPerformed` behavior against a fake `DataContext` providing the element +
panel keys.
17. Update `WorktreeSessionEditorPanelTest` / any test asserting `WorktreeRow`/`SessionRow.cells`
(e.g. remove/adjust cell-id expectations). `HistoryControllerTest` stays untouched.
## Files to touch
| File | Change |
|---|---|
| `ui/list/ActiveListMenu.kt` | new descriptor |
| `ui/list/ActiveListActions.kt` | add menu cell id + factory |
| `ui/list/ActiveList.kt` | `menu` ctor param; pass through |
| `ui/list/ActiveListView.kt` | hover plumbing for `menu`; `processMouseEvent` kebab intercept + popup |
| `ui/list/ActiveListRenderer.kt` | append kebab; hover-only visibility |
| `ui/list/ActiveListModel.kt` | menu id hit-testing |
| `agentManager/worktree/WorktreeDataKeys.kt` | new `WORKTREE` key |
| `agentManager/worktree/WorktreeSessionDataKeys.kt` | new `SESSION` + `PANEL` keys |
| `agentManager/AgentManagerPanel.kt` | menu wiring; remove cells; expose rename/delete; snapshot |
| `agentManager/worktree/WorktreeSessionEditorPanel.kt` | menu wiring; remove cells; expose rename/delete; snapshot |
| `actions/RenameWorktreeAction.kt`, `actions/DeleteWorktreeAction.kt` | new |
| `actions/RenameWorktreeSessionAction.kt`, `actions/DeleteWorktreeSessionAction.kt` | new |
| `resources/kilo.jetbrains.frontend.xml` | actions + 2 groups |
| `resources/messages/KiloBundle.properties` | action strings |
| tests | as in section E |
## Removals
- `WorktreeRow.cells` and `SessionRow.cells` (rename/delete).
- `ACTIVE_LIST_RENAME_CELL` / `ACTIVE_LIST_DELETE_CELL` routing in both panels' `onCell`.
- Keep `activeListRenameCell` / `activeListDeleteCell` (still used by `HistoryPanel`).
- Keep `ActiveList.confirmDelete` / `editName` / `rename` balloons — the menu actions call them.
## Risks / edge cases
- Selection suppression relies on skipping `super.processMouseEvent`; verify keyboard nav, tooltip,
and existing single-click open still work (they use separate `MouseListener` paths).
- Kebab hit rect is read from the rendered component tree; confirm it stays correct with the
overlay `pill` and New-UI selection insets (same mechanism as current cells).
- Right-click on the session list with a multi-selection provides a single element to the row menu;
multi-delete remains available via toolbar + `$Delete`. Confirm this is acceptable.
- New XML Delete action must not double-bind `$Delete` on the worktree list (kept shortcut-less).
- `AllIcons.Actions.More` is the standard overflow glyph; confirm it reads well at row scale (icon
guidance covered by the `icon-jetbrains` skill if a custom glyph is later desired).
## Validation
- From `packages/kilo-jetbrains/`: `./gradlew typecheck` and `./gradlew test` (Java 21).
- Run inspection `Plugin DevKit | Code | Frontend and Backend API Usage` (new actions/keys).
- Manual: `./gradlew runIde` — hover a worktree row shows only the kebab; clicking it opens the
menu without changing selection; right-click shows the same menu; Rename/Delete open the existing
balloons; repeat on the worktree session list.
## Open questions (low risk; defaults chosen above)
1. Session list right-click acting on a single (lead) element vs the full multi-selection —
recommend single element for parity with the kebab; keep toolbar for multi. Confirm.
2. XML-registered actions + typed keys (recommended, matches History) vs a code-built
`DefaultActionGroup` per panel (fewer files). Recommend XML.
</content>
</invoke>
@@ -0,0 +1,23 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class DeleteWorktreeAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
val item = e.getData(WorktreeDataKeys.WORKTREE)
e.presentation.isEnabledAndVisible = panel != null && panel.canDelete(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
if (panel.canDelete(item)) panel.delete(item)
}
}
@@ -0,0 +1,22 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.worktree.WorktreeSessionDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class DeleteWorktreeSessionAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL)
val item = e.getData(WorktreeSessionDataKeys.SESSION)
e.presentation.isEnabledAndVisible = panel != null && panel.canDelete(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL) ?: return
val item = e.getData(WorktreeSessionDataKeys.SESSION) ?: return
if (panel.canDelete(item)) panel.deleteRow(item)
}
}
@@ -0,0 +1,23 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class RenameWorktreeAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
val item = e.getData(WorktreeDataKeys.WORKTREE)
e.presentation.isEnabledAndVisible = panel != null && panel.canRename(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
if (panel.canRename(item)) panel.rename(item)
}
}
@@ -0,0 +1,22 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.worktree.WorktreeSessionDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class RenameWorktreeSessionAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL)
val item = e.getData(WorktreeSessionDataKeys.SESSION)
e.presentation.isEnabledAndVisible = panel != null && panel.canRename(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL) ?: return
val item = e.getData(WorktreeSessionDataKeys.SESSION) ?: return
if (panel.canRename(item)) panel.renameRow(item)
}
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.agentManager
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.agentManager.worktree.ConfigureWorktreeDialog
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
import ai.kilocode.client.agentManager.worktree.WorktreeIcons
import ai.kilocode.client.agentManager.worktree.WorktreeStatusService
import ai.kilocode.client.agentManager.worktree.WorktreeNameCache
@@ -17,19 +18,15 @@ import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ACTIVE_LIST_DELETE_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_RENAME_CELL
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.ActiveListMenu
import ai.kilocode.client.ui.list.ActiveListMetrics
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListDeleteCell
import ai.kilocode.client.ui.list.activeListRenameCell
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
@@ -41,11 +38,13 @@ import com.intellij.ide.DeleteProvider
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.PlatformDataKeys
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.application.ApplicationManager
@@ -82,21 +81,21 @@ class AgentManagerPanel(
) : BorderLayoutPanel(), Disposable, UiDataProvider {
private val provider = WorktreeDeleteProvider()
private val edit = RenameAction()
private val group = ActionManager.getInstance().getAction("Kilo.Worktree.RowMenu") as? ActionGroup ?: DefaultActionGroup()
private val list = ActiveList(
KiloBundle.message("worktree.empty"),
cfg = ActiveListConfig(hoverActions = true),
surface = ActiveListSurface.ToolWindow,
showSearch = false,
onCell = { key, id ->
val item = item(key) ?: return@ActiveList
if (id == ACTIVE_LIST_RENAME_CELL && renameable(item)) beginRename(item, id)
if (id == ACTIVE_LIST_DELETE_CELL && deletable(item)) showDeletePopup(item, id)
},
onCell = { _, _ -> },
onOpen = { row, focus ->
val item = (row as? WorktreeRow)?.dto ?: return@ActiveList
open(item, focus)
},
onSelect = { selectedRow()?.dto?.id?.let { selected = it } },
menu = ActiveListMenu(WorktreeDataKeys.WORKTREE, group, element = { row ->
(row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) }
}),
)
private var selected: String? = null
private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -109,6 +108,7 @@ class AgentManagerPanel(
isOpaque = true
border = JBUI.Borders.empty(UiStyle.Gap.sm())
addToCenter(list)
list.installPopup(group)
sync()
bindModel()
bindTheme()
@@ -160,6 +160,10 @@ class AgentManagerPanel(
controller.remove(item, force, onFailure = { result -> notifyFailed(item, result, force) })
}
internal fun rename(item: WorktreeDto) = beginRename(item)
internal fun canRename(item: WorktreeDto?): Boolean = renameable(item)
private fun beginRename(item: WorktreeDto, cell: String? = null) {
list.rename(
item.id,
@@ -194,6 +198,10 @@ class AgentManagerPanel(
target.service<KiloVfsManager>().close(WorktreeSessionEditorKind.ID, worktreeSessionParams(item))
}
internal fun delete(item: WorktreeDto) = showDeletePopup(item)
internal fun canDelete(item: WorktreeDto?): Boolean = deletable(item)
private fun showDeletePopup(item: WorktreeDto, cell: String? = null) {
val opts = ActiveListDeleteOptions(
message = KiloBundle.message("worktree.delete.confirm.message", item.name),
@@ -396,6 +404,8 @@ class AgentManagerPanel(
}
override fun uiDataSnapshot(sink: DataSink) {
sink[SidePanelKeys.WORKTREE_PANEL] = this
selectedRow()?.dto?.let { sink[WorktreeDataKeys.WORKTREE] = it }
sink[PlatformDataKeys.DELETE_ELEMENT_PROVIDER] = provider
}
@@ -443,7 +453,7 @@ class AgentManagerPanel(
override val key: String get() = dto.id
override val title: String get() = dto.name
override val description: String get() = dto.path.trimEnd('/').substringAfterLast('/')
override val tooltip: String get() = dto.path
override val tooltip: String? get() = null
override val icon = WorktreeIcons.forRow(dto.locked, pending)
override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ")
override val badges: List<ActiveListBadge>
@@ -465,11 +475,6 @@ class AgentManagerPanel(
pr = p?.let { ActiveListBadge("#${it.number}", style(it.state)) },
)
}
override val cells: List<ActiveListCell>
get() = if (dto.main || pending) emptyList() else listOf(
activeListRenameCell(KiloBundle.message("worktree.rename.action")),
activeListDeleteCell(KiloBundle.message("worktree.delete.action")),
)
}
}
@@ -0,0 +1,8 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.actionSystem.DataKey
object WorktreeDataKeys {
val WORKTREE: DataKey<WorktreeDto> = DataKey.create("ai.kilocode.client.agentManager.worktree.Worktree")
}
@@ -0,0 +1,9 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.actionSystem.DataKey
object WorktreeSessionDataKeys {
val SESSION: DataKey<SessionDto> = DataKey.create("ai.kilocode.client.agentManager.worktree.Session")
val PANEL: DataKey<WorktreeSessionEditorPanel> = DataKey.create("ai.kilocode.client.agentManager.worktree.SessionPanel")
}
@@ -11,20 +11,16 @@ import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistorySection
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.ui.list.ACTIVE_LIST_DELETE_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_RENAME_CELL
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.ActiveListEditOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListMenu
import ai.kilocode.client.ui.list.ActiveListRowHeight
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListDeleteCell
import ai.kilocode.client.ui.list.activeListRenameCell
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.SessionDto
@@ -36,6 +32,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataSink
@@ -79,6 +76,7 @@ class WorktreeSessionEditorPanel(
private val add = NewAction()
private val rename = RenameAction()
private val delete = DeleteAction()
private val group = ActionManager.getInstance().getAction("Kilo.WorktreeSession.RowMenu") as? ActionGroup ?: DefaultActionGroup()
private val list = ActiveList(
KiloBundle.message("worktree.session.list.empty"),
cfg = ActiveListConfig(
@@ -90,11 +88,11 @@ class WorktreeSessionEditorPanel(
surface = ActiveListSurface.ToolWindow,
showSearch = false,
enter = { true },
onCell = { key, id ->
if (id == ACTIVE_LIST_RENAME_CELL) beginRename(key, ACTIVE_LIST_RENAME_CELL)
if (id == ACTIVE_LIST_DELETE_CELL) confirmDelete(listOf(key), ACTIVE_LIST_DELETE_CELL)
},
onCell = { _, _ -> },
onOpen = { row, focus -> open(row, focus) },
menu = ActiveListMenu(WorktreeSessionDataKeys.SESSION, group, element = { row ->
(row as? SessionRow)?.session?.takeIf { canRename(it) || canDelete(it) }
}),
)
private val statsView = WorktreeStatsView(::openBranchDiff)
private var started = false
@@ -111,6 +109,7 @@ class WorktreeSessionEditorPanel(
}
left.add(toolbar(), BorderLayout.NORTH)
left.add(list, BorderLayout.CENTER)
list.installPopup(group)
val splitter = OnePixelSplitter(false, 0.25f)
splitter.firstComponent = left
splitter.secondComponent = manager.component
@@ -162,6 +161,18 @@ class WorktreeSessionEditorPanel(
beginRename(key)
}
@RequiresEdt
internal fun canDelete(item: SessionDto?): Boolean = item != null && item.id != SessionHost.NEW && item.id !in manager.deleting()
@RequiresEdt
internal fun canRename(item: SessionDto?): Boolean = canDelete(item)
@RequiresEdt
internal fun deleteRow(item: SessionDto) = confirmDelete(listOf(item.id))
@RequiresEdt
internal fun renameRow(item: SessionDto) = beginRename(item.id)
@RequiresEdt
private fun confirmDelete(ids: List<String>, cell: String? = null) {
val active = ids.filter { it != SessionHost.NEW && it !in manager.deleting() }.distinct()
@@ -328,6 +339,8 @@ class WorktreeSessionEditorPanel(
}
override fun uiDataSnapshot(sink: DataSink) {
sink[WorktreeSessionDataKeys.PANEL] = this
selectedSession()?.let { sink[WorktreeSessionDataKeys.SESSION] = it }
sink[SessionManager.KEY] = manager
sink[SessionManager.WORKSPACE_KEY] = worktree
}
@@ -412,13 +425,10 @@ class WorktreeSessionEditorPanel(
override val badges: List<ActiveListBadge> get() = listOfNotNull(kind?.let(::worktreeActivityBadge))
override val section: String get() = HistoryTime.title(HistoryTime.section(item))
override val search: String get() = listOf(session.title, session.id, session.directory).joinToString(" ")
override val cells: List<ActiveListCell>
get() {
if (selectedKeys().size != 1) return emptyList()
return listOf(
activeListRenameCell(KiloBundle.message("worktree.session.rename.action")),
activeListDeleteCell(KiloBundle.message("worktree.session.delete.action")),
)
}
}
@RequiresEdt
private fun selectedSession(): SessionDto? {
return list.selectedItems().filterIsInstance<SessionRow>().firstOrNull()?.session
}
}
@@ -44,8 +44,9 @@ internal class ActiveList(
onActivate: ((ActiveListItem) -> Unit)? = null,
onClick: ((ActiveListItem) -> Unit)? = null,
onSelect: (() -> Unit)? = null,
menu: ActiveListMenu<*>? = null,
) : BorderLayoutPanel() {
private val view = ActiveListView(emptyText, cfg, surface, matcher, enter, openOnClick, onOpen, onActivate, onClick, onCell)
private val view = ActiveListView(emptyText, cfg, surface, matcher, enter, openOnClick, onOpen, onActivate, onClick, menu, onCell)
private val search: SearchTextField? = if (showSearch) SearchTextField(false) else null
private val scroll = object : JBScrollPane(view) {
override fun getBackground(): Color {
@@ -11,6 +11,7 @@ import com.intellij.icons.AllIcons
*/
internal const val ACTIVE_LIST_RENAME_CELL = "rename"
internal const val ACTIVE_LIST_DELETE_CELL = "delete"
internal const val ACTIVE_LIST_MENU_CELL = "__menu__"
internal fun activeListRenameCell(label: String = KiloBundle.message("common.rename")) = ActiveListCell(
ACTIVE_LIST_RENAME_CELL,
@@ -25,3 +26,10 @@ internal fun activeListDeleteCell(label: String = KiloBundle.message("common.del
icon = AllIcons.Actions.GC,
iconOnly = true,
)
internal fun activeListMenuCell(label: String = KiloBundle.message("common.more.actions")) = ActiveListCell(
ACTIVE_LIST_MENU_CELL,
label,
icon = AllIcons.Actions.More,
iconOnly = true,
)
@@ -0,0 +1,26 @@
package ai.kilocode.client.ui.list
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.actionSystem.impl.SimpleDataContext
import com.intellij.ide.DataManager
import javax.swing.JComponent
internal class ActiveListMenu<T : Any>(
private val key: DataKey<T>,
val group: ActionGroup,
private val element: (ActiveListItem) -> T?,
val place: String = ActionPlaces.POPUP,
) {
fun available(item: ActiveListItem): Boolean = element(item) != null
fun context(anchor: JComponent, item: ActiveListItem): DataContext {
val data = element(item)
val builder = SimpleDataContext.builder()
.setParent(DataManager.getInstance().getDataContext(anchor))
if (data != null) builder.add(key, data)
return builder.build()
}
}
@@ -85,10 +85,20 @@ internal fun activeListSectionTitle(items: List<ActiveListItem>, index: Int): St
return if (prev?.section != item.section) item.section else null
}
internal fun activeListVisibleCells(item: ActiveListItem, active: Boolean): List<ActiveListCell> {
internal fun activeListVisibleCells(
item: ActiveListItem,
active: Boolean,
menu: Boolean = false,
): List<ActiveListCell> {
if (item.disabled) return emptyList()
if (item.deleting) return emptyList()
return item.cells.filter { active || it.alwaysVisible }
val cells = item.cells.filter { active || it.alwaysVisible }
if (!menu) return cells
return cells + activeListMenuCell()
}
internal fun activeListVisibleCells(item: ActiveListItem, active: Boolean): List<ActiveListCell> {
return activeListVisibleCells(item, active, false)
}
internal fun activeListCellGap() = JBUI.scale(CELL_GAP)
@@ -131,16 +141,26 @@ internal fun activeListCellAt(
index: Int,
point: Point,
selected: Boolean,
menu: Boolean = false,
): String? {
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)
return activeListVisibleCells(item, selected, menu)
.firstOrNull { cell -> cell.enabled && cells[cell.id]?.contains(point) == true }
?.id
}
internal fun activeListCellAt(
list: JList<*>,
index: Int,
point: Point,
selected: Boolean,
): String? {
return activeListCellAt(list, index, point, selected, false)
}
private fun activeListLayout(component: Component) {
if (component !is Container) return
component.doLayout()
@@ -10,11 +10,13 @@ import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.icons.AllIcons
import com.intellij.ui.CollectionListModel
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import ai.kilocode.rpc.dto.WorktreeStatsDto
@@ -30,6 +32,38 @@ internal class ActiveListRenderer(
private val model: CollectionListModel<ActiveListItem>,
private val cfg: ActiveListConfig = ActiveListConfig.Equal,
) : JPanel(BorderLayout()), ListCellRenderer<ActiveListItem> {
constructor(
model: CollectionListModel<ActiveListItem>,
cfg: ActiveListConfig = ActiveListConfig.Equal,
menu: ActiveListMenu<*>?,
) : this(model, cfg) {
this.menu = menu
if (menu == null) return
glyph.update(activeListMenuCell())
glyph.isVisible = false
// Mirror the flush leading icon: drop the row's trailing inset and let the empty-icon spacer
// hold the column flush against the content edge, separated from the body by the row gap, so
// the dropdown's margin from the selection matches the leading icon's. The overlay glyph then
// floats over that same slot, revealed on hover.
row.border = JBUI.Borders.empty(UiStyle.Gap.md(), 0, UiStyle.Gap.md(), 0)
val tail = JPanel(BorderLayout(UiStyle.Gap.md(), 0))
UiStyle.Components.transparent(tail)
tail.add(endPane, BorderLayout.CENTER)
tail.add(spacer, BorderLayout.EAST)
row.remove(endPane)
row.add(tail, BorderLayout.EAST)
layers.addOverlay(glyph) { host, child ->
val size = child.preferredSize
Rectangle(
(host.width - size.width).coerceAtLeast(0),
((host.height - size.height) / 2).coerceAtLeast(0),
size.width.coerceAtMost(host.width),
size.height.coerceAtMost(host.height),
)
}
}
private var menu: ActiveListMenu<*>? = null
private val insets = JBUI.CurrentTheme.Popup.separatorLabelInsets()
private val sep = GroupHeaderSeparator(insets)
private val top = JPanel(BorderLayout()).apply {
@@ -67,6 +101,14 @@ internal class ActiveListRenderer(
border = JBUI.Borders.empty(UiStyle.Gap.sm())
add(cellPane, BorderLayout.CENTER)
}
// The dropdown button keeps the overlay approach: a real empty-icon [spacer] holds the trailing
// column in the row layout, and the [glyph] button floats over that slot — revealed on hover —
// so the row body is laid out beside the column and never shifts. Both are bare (no border) so
// the icon sits flush against the content edge, mirroring the flush leading icon.
private val glyph = ActiveListActionCell()
private val spacer = JBLabel(EmptyIcon.create(AllIcons.Actions.More))
// Width of the dropdown column, used to offset the action pill when a list opts into both.
private val reserve: Int by lazy { glyph.preferredSize.width }
private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
add(mark, BorderLayout.WEST)
add(textPane, BorderLayout.CENTER)
@@ -100,6 +142,8 @@ internal class ActiveListRenderer(
endPane,
cells,
cellPane,
glyph,
spacer,
)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
@@ -109,8 +153,9 @@ internal class ActiveListRenderer(
)
layers.addOverlay(pill) { host, child ->
val size = child.preferredSize
val gap = if (menu != null) reserve else 0
Rectangle(
(host.width - size.width - UiStyle.Gap.pad()).coerceAtLeast(0),
(host.width - size.width - UiStyle.Gap.pad() - gap).coerceAtLeast(0),
((host.height - size.height) / 2).coerceAtLeast(0),
size.width.coerceAtMost(host.width),
size.height.coerceAtMost(host.height),
@@ -173,9 +218,10 @@ internal class ActiveListRenderer(
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)
syncCells(value, show)
cellPane.isVisible = cells.isVisible
pill.isVisible = cells.isVisible
menu?.let { glyph.isVisible = list.isEnabled && hovered && it.available(value) }
// 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
@@ -224,8 +270,8 @@ internal class ActiveListRenderer(
}
}
private fun syncCells(item: ActiveListItem, selected: Boolean, enabled: Boolean) {
val visible = if (enabled) activeListVisibleCells(item, selected) else emptyList()
private fun syncCells(item: ActiveListItem, selected: Boolean) {
val visible = activeListVisibleCells(item, selected)
while (cells.componentCount > visible.size) cells.remove(cells.componentCount - 1)
while (cells.componentCount < visible.size) cells.add(ActiveListActionCell())
cells.isVisible = visible.isNotEmpty()
@@ -4,6 +4,7 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupListener
import com.intellij.openapi.ui.popup.LightweightWindowEvent
@@ -44,15 +45,24 @@ internal class ActiveListView(
private val onOpen: ((ActiveListItem, Boolean) -> Unit)? = null,
private val onActivate: ((ActiveListItem) -> Unit)? = null,
private val onClick: ((ActiveListItem) -> Unit)? = null,
private val menu: ActiveListMenu<*>? = null,
private val onCell: (String, String) -> Unit,
) : Stack(StackAxis.VERTICAL), Scrollable {
private val model = CollectionListModel<ActiveListItem>()
private val renderer = ActiveListRenderer(model, cfg)
private val renderer = ActiveListRenderer(model, cfg, menu)
private val hover = cfg.hoverActions || menu != null
internal val list: JBList<ActiveListItem> = object : JBList<ActiveListItem>(model), ActiveListActive {
override fun active(): Boolean = popups > 0
override fun hoveredIndex(): Int = hovered
override fun processMouseEvent(e: MouseEvent) {
if (e.id == MouseEvent.MOUSE_PRESSED && UIUtil.isActionClick(e, MouseEvent.MOUSE_PRESSED, true)) {
if (showMenu(e.point)) return
}
super.processMouseEvent(e)
}
override fun getBackground(): Color {
if (surface == ActiveListSurface.ToolWindow) return activeListToolWindowBackground()
return super.getBackground() ?: UIUtil.getListBackground(false, false)
@@ -71,7 +81,8 @@ internal class ActiveListView(
.entries
.firstOrNull { it.value.contains(event.point) }
?.key
val cell = activeListVisibleCells(item, selected).firstOrNull { it.id == id }
val cell = activeListVisibleCells(item, selected, menu?.takeIf { it.available(item) } != null)
.firstOrNull { it.id == id }
if (cell != null) return cell.label.takeIf { it.isNotBlank() }
if (!cfg.description || !cfg.tooltip) return null
val note = item.tooltip?.takeIf { it.isNotBlank() } ?: return null
@@ -162,7 +173,7 @@ internal class ActiveListView(
}
override fun mouseMoved(e: MouseEvent) {
if (!cfg.hoverActions) return
if (!hover) return
val idx = list.locationToIndex(e.point)
.takeIf { it >= 0 && list.getCellBounds(it, it)?.contains(e.point) == true }
?: -1
@@ -170,16 +181,16 @@ internal class ActiveListView(
}
override fun mouseExited(e: MouseEvent) {
if (!cfg.hoverActions) return
if (!hover) return
setHovered(-1)
}
}
list.addMouseListener(mouse)
if (cfg.hoverActions) list.addMouseMotionListener(mouse)
if (hover) 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 (hover) repaintRow(hovered)
if (!e.valueIsAdjusting) onSelect?.invoke()
}
list.addFocusListener(object : FocusAdapter() {
@@ -329,10 +340,15 @@ 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)
// Rebuilding the model fires a list-wide repaint, so skip it when the visible rows are
// structurally unchanged (e.g. a stats/name refresh that produced identical rows) and only
// reconcile selection below. Row types are data classes, so equality is by value.
if (model.items != rows) {
setHovered(-1)
model.replaceAll(rows)
}
syncCellHeight(rows)
val idx = at?.let { activeListIndex(rows, it) }?.takeIf { it >= 0 }
?: activeListIndex(rows, prefer).takeIf { it >= 0 }
@@ -477,7 +493,7 @@ internal class ActiveListView(
val item = model.getElementAt(idx)
val selected = list.isSelectedIndex(idx)
val id = if (enabled) {
activeListCellAt(list, idx, e.point, selected)
activeListCellAt(list, idx, e.point, selected, menu?.takeIf { it.available(item) } != null)
} else {
activeListCellBounds(list, idx, selected)
.entries
@@ -487,6 +503,28 @@ internal class ActiveListView(
return Hit(item, id)
}
private fun showMenu(point: Point): Boolean {
val cfg = menu ?: return false
val idx = list.locationToIndex(point)
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false
if (!bounds.contains(point)) return false
val item = model.getElementAt(idx)
if (item.disabled || item.deleting || !cfg.available(item)) return false
val rect = activeListCellBounds(list, idx, list.isSelectedIndex(idx))[ACTIVE_LIST_MENU_CELL] ?: return false
if (!rect.contains(point)) return false
val popup = JBPopupFactory.getInstance().createActionGroupPopup(
null,
cfg.group,
cfg.context(list, item),
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
true,
cfg.place,
)
trackPopup(popup)
popup.show(RelativePoint(list, Point(rect.x + rect.width / 2, rect.y + rect.height)))
return true
}
private fun trackPopupState(visible: Boolean, add: (JBPopupListener) -> Unit) {
var tracked = false
fun activate() {
@@ -235,6 +235,29 @@
class="ai.kilocode.client.actions.DeleteSessionAction"
use-shortcut-of="$Delete"/>
<action id="Kilo.Worktree.Rename"
class="ai.kilocode.client.actions.RenameWorktreeAction"/>
<action id="Kilo.Worktree.Delete"
class="ai.kilocode.client.actions.DeleteWorktreeAction"/>
<action id="Kilo.WorktreeSession.Rename"
class="ai.kilocode.client.actions.RenameWorktreeSessionAction"/>
<action id="Kilo.WorktreeSession.Delete"
class="ai.kilocode.client.actions.DeleteWorktreeSessionAction"/>
<group id="Kilo.Worktree.RowMenu">
<reference ref="Kilo.Worktree.Rename"/>
<separator/>
<reference ref="Kilo.Worktree.Delete"/>
</group>
<group id="Kilo.WorktreeSession.RowMenu">
<reference ref="Kilo.WorktreeSession.Rename"/>
<reference ref="Kilo.WorktreeSession.Delete"/>
</group>
<group id="Kilo.History.ContextMenu">
<reference ref="Kilo.Session.Open"/>
<separator/>
@@ -789,8 +789,19 @@ action.Kilo.Session.Rename.text=Rename
action.Kilo.Session.Rename.description=Rename the selected session
action.Kilo.Session.Delete.text=Delete
action.Kilo.Session.Delete.description=Delete the selected session(s)
action.Kilo.Worktree.Rename.text=Rename
action.Kilo.Worktree.Rename.description=Rename the selected worktree
action.Kilo.Worktree.Delete.text=Delete
action.Kilo.Worktree.Delete.description=Delete the selected worktree
action.Kilo.WorktreeSession.Rename.text=Rename
action.Kilo.WorktreeSession.Rename.description=Rename the selected worktree session
action.Kilo.WorktreeSession.Delete.text=Delete
action.Kilo.WorktreeSession.Delete.description=Delete the selected worktree session
action.Kilo.Worktree.RowMenu.text=Worktree Actions
action.Kilo.WorktreeSession.RowMenu.text=Worktree Session Actions
action.Kilo.History.ContextMenu.text=History Actions
action.Kilo.Session.ContextMenu.text=Session Actions
common.more.actions=More actions
# Migration wizard
migration.migrate.title=Migrate Your Settings
@@ -339,11 +339,21 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(xml.contains("id=\"Kilo.Session.Open\""))
assertTrue(xml.contains("id=\"Kilo.Session.Rename\""))
assertTrue(xml.contains("id=\"Kilo.Session.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.Rename\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.Delete\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Rename\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.RowMenu\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.RowMenu\""))
assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\""))
assertTrue(xml.contains("id=\"Kilo.Session.ContextMenu\""))
assertTrue(xml.contains("ref=\"Kilo.Session.Open\""))
assertTrue(xml.contains("ref=\"Kilo.Session.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.Session.Delete\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.Delete\""))
assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Delete\""))
assertTrue(xml.contains("ref=\"${'$'}Copy\""))
}
@@ -314,14 +314,14 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(listOf("ses_2", "ses_1"), manager.deleted)
}
fun `test multi select hides row delete cells`() {
fun `test session rows do not expose inline action cells`() {
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt { panel.selectSessions(listOf("ses_1")) }
assertEquals(listOf(RENAME_CELL, DELETE_CELL), row("ses_1").cells.map { it.id })
assertTrue(row("ses_1").cells.isEmpty())
edt { panel.selectSessions(listOf("ses_1", "ses_2")) }
@@ -494,7 +494,5 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
private companion object {
const val DIR = "/repo/.kilo/worktrees/feature-x"
const val RENAME_CELL = "rename"
const val DELETE_CELL = "delete"
}
}
@@ -12,13 +12,17 @@ import ai.kilocode.client.ui.list.ActiveListActive
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.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_MENU_CELL
import ai.kilocode.client.ui.list.activeListCellAt
import ai.kilocode.client.ui.list.activeListCellBounds
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.CollectionListModel
@@ -506,6 +510,60 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test menu button overlays reserved slot on hover only`() {
edt {
val key = DataKey.create<ActiveListItem>("test.activeList.menu")
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()
// The real spacer holds the column, so the overlay glyph stays hidden until hover.
assertTrue(renderedCells(view, 0).isEmpty())
hover(view, center(view.list.getCellBounds(0, 0)))
assertEquals(-1, view.list.selectedIndex)
assertEquals(listOf(ACTIVE_LIST_MENU_CELL), renderedCells(view, 0))
val area = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_MENU_CELL)
assertEquals(ACTIVE_LIST_MENU_CELL, activeListCellAt(view.list, 0, center(area), selected = false, menu = true))
}
}
fun `test menu button reserves dedicated east space in the layout`() {
edt {
val row = item("with", "Alpha", null)
val model = CollectionListModel<ActiveListItem>(listOf(row))
val list = JBList(model)
val plain = ActiveListRenderer(model, ActiveListConfig.Equal)
plain.getListCellRendererComponent(list, row, 0, true, true)
val key = DataKey.create<ActiveListItem>("test.activeList.menu.space")
val menu = ActiveListMenu(key, DefaultActionGroup(), element = { it })
val withMenu = ActiveListRenderer(model, ActiveListConfig.Equal, menu)
withMenu.getListCellRendererComponent(list, row, 0, true, true)
// The empty-icon spacer widens the row body instead of relying on a border inset.
assertTrue(
"menu list reserves extra trailing width for the dropdown column",
rowPanel(withMenu).preferredSize.width > rowPanel(plain).preferredSize.width,
)
}
}
fun `test menu context provides typed element`() {
edt {
val key = DataKey.create<ActiveListItem>("test.activeList.menu.context")
val row = item("with", "Alpha", null)
val menu = ActiveListMenu(key, DefaultActionGroup(), element = { item -> item.takeIf { it.key == row.key } })
val view = ActiveListView("Empty", menu = menu) { _, _ -> }
assertSame(row, key.getData(menu.context(view.list, row)))
}
}
fun `test selection alone does not reveal cells without hover`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
@@ -794,6 +852,9 @@ class SettingsListViewTest : BasePlatformTestCase() {
return actionCells(comp).filter { it.isVisible }.map { it.cellId }
}
private fun rowPanel(renderer: ActiveListRenderer): JPanel =
components(renderer).filterIsInstance<LayeredOverlayPanel>().single().content.getComponent(0) as JPanel
private fun actionPill(root: java.awt.Component): JPanel {
val cell = actionCells(root).single()
return cell.parent.parent.parent as JPanel