diff --git a/.changeset/fresh-worktrees-move.md b/.changeset/fresh-worktrees-move.md new file mode 100644 index 00000000000..5b300736f12 --- /dev/null +++ b/.changeset/fresh-worktrees-move.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show worktree row actions in a hover menu instead of inline rename and delete buttons. diff --git a/.kilo/plans/1785976558143-worktree-list-row-menu.md b/.kilo/plans/1785976558143-worktree-list-row-menu.md new file mode 100644 index 00000000000..0a82dbd6874 --- /dev/null +++ b/.kilo/plans/1785976558143-worktree-list-row-menu.md @@ -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` 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` descriptor + (typed element `DataKey` + `ActionGroup` + `(ActiveListItem) -> T?` resolver). `ActiveList` + stores it as `ActiveListMenu<*>?`; the generic type stays contained inside the descriptor (no + `ActiveList` 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( + private val key: DataKey, + 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 = 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` and `PANEL: DataKey`. +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] = ` (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 `` / `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. + + diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteWorktreeAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteWorktreeAction.kt new file mode 100644 index 00000000000..a8da22c8e11 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteWorktreeAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteWorktreeSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteWorktreeSessionAction.kt new file mode 100644 index 00000000000..984238eceff --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteWorktreeSessionAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameWorktreeAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameWorktreeAction.kt new file mode 100644 index 00000000000..5798d729d9a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameWorktreeAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameWorktreeSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameWorktreeSessionAction.kt new file mode 100644 index 00000000000..721ad44ba85 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameWorktreeSessionAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 71c1de91e43..cee363d8ce8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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().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 @@ -465,11 +475,6 @@ class AgentManagerPanel( pr = p?.let { ActiveListBadge("#${it.number}", style(it.state)) }, ) } - override val cells: List - get() = if (dto.main || pending) emptyList() else listOf( - activeListRenameCell(KiloBundle.message("worktree.rename.action")), - activeListDeleteCell(KiloBundle.message("worktree.delete.action")), - ) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeDataKeys.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeDataKeys.kt new file mode 100644 index 00000000000..9ea60b14508 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeDataKeys.kt @@ -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 = DataKey.create("ai.kilocode.client.agentManager.worktree.Worktree") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionDataKeys.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionDataKeys.kt new file mode 100644 index 00000000000..31cb63222c1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionDataKeys.kt @@ -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 = DataKey.create("ai.kilocode.client.agentManager.worktree.Session") + val PANEL: DataKey = DataKey.create("ai.kilocode.client.agentManager.worktree.SessionPanel") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index 3d0b84ed33f..8d98334b4aa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -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, 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 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 - 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().firstOrNull()?.session } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt index f6456bedad8..7d6a0c67ff2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt @@ -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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt index 327ef8a0a4c..8d30460107b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListActions.kt @@ -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, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListMenu.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListMenu.kt new file mode 100644 index 00000000000..95575bcb4e4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListMenu.kt @@ -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( + private val key: DataKey, + 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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 1e75833692c..9ec1a9f8ffe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -85,10 +85,20 @@ internal fun activeListSectionTitle(items: List, index: Int): St return if (prev?.section != item.section) item.section else null } -internal fun activeListVisibleCells(item: ActiveListItem, active: Boolean): List { +internal fun activeListVisibleCells( + item: ActiveListItem, + active: Boolean, + menu: Boolean = false, +): List { 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 { + 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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index b86c39474d7..aa55ce565e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -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, private val cfg: ActiveListConfig = ActiveListConfig.Equal, ) : JPanel(BorderLayout()), ListCellRenderer { + constructor( + model: CollectionListModel, + 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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index 84ddb3e5941..75662b490d5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -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() - private val renderer = ActiveListRenderer(model, cfg) + private val renderer = ActiveListRenderer(model, cfg, menu) + private val hover = cfg.hoverActions || menu != null internal val list: JBList = object : JBList(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() { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index e00971e3bed..78d6b1a8ea9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -235,6 +235,29 @@ class="ai.kilocode.client.actions.DeleteSessionAction" use-shortcut-of="$Delete"/> + + + + + + + + + + + + + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 4b246fd0720..afa26e6f45d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index a66db51e57e..4694ac6cf71 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -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\"")) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index 9cd51ba3e9d..0dc6c720bf8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -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" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 53afb653015..2a4049327dd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -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("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(listOf(row)) + val list = JBList(model) + + val plain = ActiveListRenderer(model, ActiveListConfig.Equal) + plain.getListCellRendererComponent(list, row, 0, true, true) + + val key = DataKey.create("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("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().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