mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
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:
@@ -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>
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
-16
@@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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")
|
||||
}
|
||||
+9
@@ -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")
|
||||
}
|
||||
+27
-17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+8
@@ -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,
|
||||
)
|
||||
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -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()
|
||||
|
||||
+50
-4
@@ -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()
|
||||
|
||||
+47
-9
@@ -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
|
||||
|
||||
+10
@@ -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\""))
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user