fix(jetbrains): share active list delete UX

This commit is contained in:
kirillk
2026-07-28 15:35:42 -04:00
parent 4acb9e46a4
commit 8254e9c525
16 changed files with 435 additions and 67 deletions
@@ -0,0 +1,208 @@
# Generalize delete-confirmation popup + "deleting" UX into ActiveList; drop session timestamps
## Goal
Refactor the JetBrains Agent Manager UI so the delete-confirmation **balloon popup** and the
optimistic **"deleting…" row state** become generic capabilities of the shared `ui/list/` active
list, used by both the worktree list and the worktree session editor list without duplicating code
and without the shared code knowing anything about worktrees or sessions. Also remove the per-row
timestamp from the worktree session editor list (keep sorting and day-group section headers).
All work is in `packages/kilo-jetbrains/` (entirely Kilo-owned — **no `kilocode_change` markers**).
## Resolved decisions (from interview)
1. **Session timestamp**: remove the per-row relative-time trailing (`HistoryTime.relative`) from the
session list. Keep `HistoryTime.sorted()` ordering and the Today/Yesterday/… section headers. The
`trailing` slot is then used only for the generic "Deleting…" indicator.
2. **Confirmation popup**: replace the session list's synchronous `Messages.showYesNoDialog` with the
same balloon popup style the worktree list uses. Extract one generic popup into `ui/list/`.
Multi-select delete stays: the balloon anchors at the primary (lead) selected row and, on confirm,
runs the manager's optimistic multi-delete. Confirmation moves from the manager into the panel
(mirroring `AgentManagerPanel`).
3. **Deleting UX is generic and wired to both lists**: the active list renders a `deleting` row muted,
with a "Deleting…" trailing label, hidden badges, hidden action cells, and suppressed interaction.
The session list keeps its existing manager-tracked deleting set; the worktree list gains an
optimistic deleting state in `WorktreeController` (mark on remove, drop on success, revert on
failure) instead of vanishing instantly.
## Shared model / behavior design (`ui/list/`)
- **`ActiveListModel.kt`**
- Add `val deleting: Boolean get() = false` to `ActiveListItem`.
- **Remove** `val muted: Boolean` (its only consumer is the deleting path; `deleting` replaces it).
- In `activeListVisibleCells(item, active)`: also return `emptyList()` when `item.deleting` (so a
deleting row never shows action cells), alongside the existing `item.disabled` check.
- **`ActiveListRenderer.kt`** — centralize the deleting visuals (was per-`SessionRow`):
- `val titleFg = if (value.deleting) weak else fg` (replaces the `value.muted` check).
- Trailing text: `val end = if (value.deleting) KiloBundle.message("common.deleting") else value.trailing.orEmpty()` (import `KiloBundle`).
- Badges: skip/clear badges when `value.deleting` (guard the `syncBadges(value)` call so a
disappearing row shows no RUNNING badge).
- Cells already hidden via `activeListVisibleCells` change; no extra work.
- **`ActiveListView.kt`** — suppress interaction on deleting rows generically (was in the panel):
- In the mouse `mouseClicked` (single + double) and `mouseReleased` handlers and the keyboard
`open()`/`source()`/`primary()`/`activate()` paths, no-op when the hit/selected item's
`deleting` is true. Simplest: guard `onOpen`/`onActivate`/`onCell`/`primary` dispatch with
`if (item.deleting) return`. Selection is still allowed (so toolbars can reflect it).
- **Generic delete popup** — new file `ui/list/ActiveListDeletePopup.kt` (no `WorktreeDto`/`SessionDto`
imports):
- `internal data class ActiveListDeleteOptions(val message: String, val detail: String? = null, val gate: String? = null, val button: String = KiloBundle.message("common.delete"))`.
`gate` is an optional required-checkbox label (used for the worktree "locked" confirm).
- `internal fun activeListDeleteContent(options: ActiveListDeleteOptions, hide: () -> Unit, onConfirm: (Boolean) -> Unit): JComponent` — builds the `Stack` content (message label; optional
help-colored detail label; optional `JBCheckBox` gate; a `DialogWrapper.createJButtonForAction`
Delete button). Delete is enabled iff `gate == null || checkbox.isSelected`; on click it calls
`hide()` then `onConfirm(checkbox?.isSelected ?: false)`. This is a real
content/presentation split so gating is unit-testable without creating a live `Balloon`.
- `internal fun showActiveListDeletePopup(anchor: RelativePoint, options: ActiveListDeleteOptions, onConfirm: (Boolean) -> Unit): Balloon` — wraps `activeListDeleteContent` in the same
`JBPopupFactory` balloon config currently in `WorktreeDeletePopup.kt` (fill/border/callout/
close-on-outside, `defaultButton`, `show(anchor, below)`), passing `balloon.hide(true)` as `hide`.
- **Delete** `agentManager/worktree/WorktreeDeletePopup.kt` (its logic moves here).
- **`ActiveList.kt`** — add a convenience method so both panels avoid duplicating show+track:
- `@RequiresEdt fun confirmDelete(anchor: RelativePoint, options: ActiveListDeleteOptions, onConfirm: (Boolean) -> Unit) { trackBalloon(showActiveListDeletePopup(anchor, options, onConfirm)) }`.
## Worktree session list changes
- **`WorktreeSessionEditorPanel.kt`**
- `SessionRow` simplifies: drop the `trailing` override entirely (no timestamp), drop the
`badges`/`cells`/`muted` deleting-branches, and add `override val deleting: Boolean = deleting`.
Keep `title`, `tooltip`, `section` (still `HistoryTime.title(HistoryTime.section(item))`),
`search`, the `kind` badge (unconditional now — renderer hides it while deleting), and the delete
cell (unconditional — renderer hides it while deleting). Remove the now-unused `HistoryTime.relative`
import (keep `HistoryTime.sorted`/`section`/`title` and `LocalHistoryItem`).
- `open(row, focus)`: remove the `if (row.key in manager.deleting()) return` guard (the view now
suppresses opening deleting rows generically). Keep the `NEW` branch.
- Add a constructor seam for confirmation (default = real balloon, overridable in tests):
`confirm: (RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit = { anchor, opts, run -> list.confirmDelete(anchor, opts) { run() } }`.
- New `private fun confirmDelete(ids: List<String>, cell: String? = null)`: return if empty; build
message via `worktree.session.delete.confirm.message` / `.message.multiple` (count = `ids.size`,
single-name via `item(ids.first())?.title`); `ActiveListDeleteOptions(message, detail = worktree.session.delete.confirm.detail)`;
call `confirm(list.point(ids.first(), cell), opts) { manager.deleteSessions(ids) }`.
- Route delete through confirmation: `onCell` → `confirmDelete(listOf(key), DELETE_CELL)`;
`deleteSelected()` → `confirmDelete(selectedKeys())`. `selectedKeys()` unchanged (still excludes
`NEW` and `manager.deleting()`).
- **`WorktreeSessionEditorManager.kt`**
- Remove the `confirm` constructor param and the confirm/message block from `deleteSessions` (the
panel confirms first). `deleteSessions(ids)` keeps: filter `active`, mark `deleting`,
`onListChanged`, immediate switch-away using deleting-excluded `latest()`/`newSession()`,
per-id `list.delete` with `deleting.remove` + `notify` on failure, `forceSession`. Keep the
`notify` seam. Remove the now-unused `Messages` import.
## Worktree list changes
- **`WorktreeController.kt`**
- Add `private val deleting = linkedSetOf<String>()` and `fun isDeleting(id: String) = id in deleting`.
- `remove(dto, force, onSuccess, onFailure)`: return early if `dto.id in deleting`; on EDT
`deleting.add(dto.id)` + fire a re-render (`model.contentsChanged(dto)`; fall back to
`allContentsChanged()` if `contentsChanged(T)` is not accessible) before launching. On success:
`deleting.remove(dto.id)`, then existing `model.remove(dto)` + callbacks/telemetry. On failure:
`deleting.remove(dto.id)`, fire `model.contentsChanged(dto)` to un-mute, then existing
`onFailure` + `reload()` reconcile.
- **`AgentManagerPanel.kt`**
- `WorktreeRow`: add `override val deleting: Boolean` sourced from `controller.isDeleting(dto.id)`;
pass it in `sync()` (`WorktreeRow(item, controller.isPending(item.id), controller.isDeleting(item.id))`).
Keep the delete cell condition (`main || pending`); renderer hides it while deleting.
- Replace `showWorktreeDeletePopup(...) + list.trackBalloon(...)` in `showDeletePopup` with
`list.confirmDelete(list.point(item.id, cell), options) { force -> controller.remove(item, force, onSuccess = { restoreFocus(idx) }, onFailure = { notifyFailed(item, it, force) }) }`,
where `options = ActiveListDeleteOptions(message = worktree.delete.confirm.message(name), detail = worktree.delete.confirm.detail, gate = if (item.locked) worktree.delete.locked.confirm else null)`.
Remove the `showWorktreeDeletePopup` import.
- Add a small guard helper and use it at both delete entry points (`onCell` and the
`WorktreeDeleteProvider`): deletable iff `worktreeDeletable(item, pending) && !controller.isDeleting(item.id)`.
(Belt-and-braces; the view already suppresses interaction on deleting rows.)
## Bundle strings (`frontend/.../messages/KiloBundle.properties`)
- Add `common.deleting=Deleting…`.
- Add `worktree.session.delete.confirm.detail=This permanently removes the session.`
- Remove `worktree.session.deleting=Deleting…` (replaced by `common.deleting`).
- Remove `worktree.session.delete.confirm.title=Delete session?` (balloon has no title bar).
- Remove `worktree.delete.button=Delete` (popup uses the `common.delete` default).
- (No locale-parity test exists in the JetBrains frontend; mirroring to `KiloBundle_*.properties` is
optional and not required for correctness.)
## Ordered tasks
1. `ui/list/ActiveListModel.kt`: add `deleting`, remove `muted`, update `activeListVisibleCells`.
2. `ui/list/ActiveListRenderer.kt`: key muting/trailing/badges off `deleting`; import `KiloBundle`.
3. `ui/list/ActiveListView.kt`: no-op open/activate/primary/cell dispatch for `deleting` items.
4. Create `ui/list/ActiveListDeletePopup.kt` (`ActiveListDeleteOptions`, `activeListDeleteContent`,
`showActiveListDeletePopup`); delete `agentManager/worktree/WorktreeDeletePopup.kt`.
5. `ui/list/ActiveList.kt`: add `confirmDelete(anchor, options, onConfirm)`.
6. `KiloBundle.properties`: add/remove keys per above.
7. `WorktreeSessionEditorManager.kt`: drop `confirm` seam + confirm/message block from `deleteSessions`.
8. `WorktreeSessionEditorPanel.kt`: simplify `SessionRow` (drop timestamp + deleting branches, add
`deleting`); add panel `confirm` seam + `confirmDelete`; route `onCell`/`deleteSelected` through it;
drop the `open` deleting guard.
9. `WorktreeController.kt`: add `deleting` set + `isDeleting`; wire optimistic deleting into `remove`.
10. `AgentManagerPanel.kt`: `WorktreeRow.deleting`; build `ActiveListDeleteOptions`; use
`list.confirmDelete`; add `isDeleting` guard; drop `showWorktreeDeletePopup` import.
11. Tests (below).
12. Run validation checks.
## Tests
- **New `ui/list/ActiveListDeletePopupTest.kt`** (`BasePlatformTestCase`): drive `activeListDeleteContent`
(not a live balloon). Assert: (a) no gate → Delete button enabled, click → `onConfirm(false)` +
`hide` called; (b) gate set → Delete disabled until the `JBCheckBox` is selected, then enabled,
click → `onConfirm(true)`. Locate the button/checkbox by walking the returned component tree.
- **Optional `ui/list/ActiveListRendererTest.kt`** (or fold into the panel test): a `deleting` item
renders "Deleting…" in the trailing label, hides badges/cells, and mutes the title; a non-deleting
item renders its own trailing/badges/cells. This is the canonical home for the generic behavior now
that `SessionRow` no longer encodes it.
- **`WorktreeSessionEditorPanelTest.kt`**:
- `test session rows match history visuals`: remove the `row.trailing == HistoryTime.relative(...)`
assertion; keep title/badges/section assertions. Add that `row.trailing` is null/blank.
- `test deleting row shows deleting state`: assert `row.deleting == true` (the row no longer
overrides trailing/badges/cells/muted — those are renderer concerns, covered by the renderer test).
- `FakeManager`: remove the `confirm = { _, _, _ -> true }` arg. Construct the panel with an
auto-confirming seam: `confirm = { _, _, run -> run() }` so `deleteSelected()`/`onCell` still reach
`manager.deleteSessions(ids)`. The existing multi-select / skip-deleting assertions then hold.
- `test row click ignores deleting session`: still valid (view suppression); keep.
- **`WorktreeSessionEditorManagerTest.kt`**:
- Remove the `confirm` seam usage, the `confirms` counter, and `assertEquals(1, confirms)` in
`test deleting shown session removes it and falls back to next session`. Other delete tests
(`marks session deleting then removes on success`, `failure reverts row and notifies`) call
`deleteSessions` directly and remain valid.
- **`WorktreeControllerTest.kt`**: add coverage for the new deleting state:
- `test remove marks the row deleting until it resolves`: gate the remove RPC (existing
`FakeWorktreeRpcApi` gating pattern like `beforeCreate`; add a `beforeRemove` gate if none exists),
assert `isDeleting(id)` true while in flight and false after success + row removed.
- `test failed remove clears deleting and keeps the row`: with `removeResult` returning locked/error,
assert `isDeleting(id)` false afterwards and the row still present (extends the existing failure test).
- **`AgentManagerPanelTest.kt`**: existing tests drive `controller.remove` directly and stay green; no
balloon interaction needed. Optionally assert a `WorktreeRow` reports `deleting` while a gated remove
is in flight.
## Failure modes / risks
- **Removing `muted`**: only `SessionRow` + the renderer + one panel test reference it; grep confirms no
other consumers. Replace all with `deleting`.
- **Async confirmation vs. tests**: the session panel's balloon is injected behind a `confirm` seam so
tests confirm synchronously; production uses the real balloon. `AgentManagerPanel` keeps calling the
shared popup directly (its tests bypass via `controller.remove`), matching current structure.
- **Worktree deleting re-render**: `CollectionListModel.contentsChanged(T)` must fire a `ListDataEvent`
so `AgentManagerPanel.sync()` rebuilds rows; if that method isn't accessible use `allContentsChanged()`
(selection is preserved by key via `PreserveNoScroll`).
- **Double-delete**: `remove` early-returns for ids already in `deleting`; the view + hidden cell also
prevent re-triggering.
- **Switch-away fallback**: `WorktreeSessionEditorManager.latest()` already excludes `deleting`; unchanged.
- **EDT discipline**: all `deleting` mutations and model changes happen on EDT; RPC stays off-EDT and
marshals back via the existing `edt {}` helpers.
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew :frontend:test --tests "ai.kilocode.client.ui.list.*" --tests "ai.kilocode.client.agentManager.*" --tests "ai.kilocode.client.agentManager.worktree.*"`
- `./gradlew typecheck`
- Manual smoke (optional): session list has no per-row time but keeps Today/Yesterday headers;
deleting a session/worktree shows the balloon confirm, then a muted "Deleting…" row that disappears
on success or reverts (with notification) on failure.
## Out of scope
- Sidebar `HistoryPanel` styling and the older `HistoryListRenderer` timestamp column.
- Changing worktree/session delete telemetry or RPC contracts.
- Localizing the new `common.deleting` / session detail strings into `KiloBundle_*.properties`.
## Open questions
None blocking.
@@ -6,12 +6,12 @@ import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeIcons
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.showWorktreeDeletePopup
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListDeleteOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.vfs.KiloVfsManager
@@ -53,7 +53,7 @@ class AgentManagerPanel(
onCell = { key, id ->
if (id != DELETE_CELL) return@ActiveList
val item = item(key) ?: return@ActiveList
if (worktreeDeletable(item, controller.isPending(item.id))) showDeletePopup(item, id)
if (deletable(item)) showDeletePopup(item, id)
},
onOpen = { row, focus ->
val item = (row as? WorktreeRow)?.dto ?: return@ActiveList
@@ -70,7 +70,7 @@ class AgentManagerPanel(
bindTheme()
controller.onSelect = { key ->
// Focus the list so the freshly created worktree renders as an active selection rather
// than the muted, inactive highlight it would get while focus stays on the toolbar.
// than the inactive highlight it would get while focus stays on the toolbar.
if (list.select(key)) list.focusList()
}
controller.onCreateFailure = { err -> notifyCreateFailed(err) }
@@ -111,7 +111,12 @@ class AgentManagerPanel(
private fun showDeletePopup(item: WorktreeDto, cell: String? = null) {
val idx = list.selectedIndex().takeIf { it >= 0 } ?: controller.model.getElementIndex(item)
val balloon = showWorktreeDeletePopup(list.point(item.id, cell), item) { force ->
val opts = ActiveListDeleteOptions(
message = KiloBundle.message("worktree.delete.confirm.message", item.name),
detail = KiloBundle.message("worktree.delete.confirm.detail"),
gate = if (item.locked) KiloBundle.message("worktree.delete.locked.confirm") else null,
)
list.confirmDelete(list.point(item.id, cell), opts) { force ->
controller.remove(
item,
force,
@@ -119,7 +124,11 @@ class AgentManagerPanel(
onFailure = { result -> notifyFailed(item, result, force) },
)
}
list.trackBalloon(balloon)
}
private fun deletable(item: WorktreeDto?): Boolean {
if (!worktreeDeletable(item, item?.id?.let(controller::isPending) == true)) return false
return item?.id?.let(controller::isDeleting) != true
}
/**
@@ -177,7 +186,7 @@ class AgentManagerPanel(
list.update(
(0 until controller.model.size).map {
val item = controller.model.getElementAt(it)
WorktreeRow(item, controller.isPending(item.id))
WorktreeRow(item, controller.isPending(item.id), controller.isDeleting(item.id))
},
ActiveListSelection.PreserveNoScroll,
)
@@ -202,17 +211,17 @@ class AgentManagerPanel(
override fun canDeleteElement(dataContext: DataContext): Boolean {
val row = selectedRow()
return worktreeDeletable(row?.dto, row?.pending == true)
return deletable(row?.dto)
}
override fun deleteElement(dataContext: DataContext) {
val row = selectedRow() ?: return
if (!worktreeDeletable(row.dto, row.pending)) return
if (!deletable(row.dto)) return
showDeletePopup(row.dto)
}
}
private data class WorktreeRow(val dto: WorktreeDto, val pending: Boolean) : ActiveListItem {
private data class WorktreeRow(val dto: WorktreeDto, val pending: Boolean, override val deleting: Boolean) : ActiveListItem {
override val key: String get() = dto.id
override val title: String get() = dto.name
override val description: String get() = dto.path.trimEnd('/').substringAfterLast('/')
@@ -8,6 +8,7 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.CollectionListModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Collections
/**
* Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations
@@ -21,6 +22,7 @@ class WorktreeController(
) {
val model = CollectionListModel<WorktreeDto>()
private val pending = LinkedHashMap<String, WorktreeDto>()
private val deleting = Collections.synchronizedSet(LinkedHashSet<String>())
var onSelect: ((String) -> Unit)? = null
var onCreateFailure: ((String?) -> Unit)? = null
var onRemoveSuccess: ((WorktreeDto) -> Unit)? = null
@@ -41,6 +43,8 @@ class WorktreeController(
fun isPending(id: String): Boolean = id in pending
fun isDeleting(id: String): Boolean = id in deleting
fun reload() {
cs.launch {
val result = service.list(directory)
@@ -103,10 +107,13 @@ class WorktreeController(
onSuccess: () -> Unit = {},
onFailure: (RemoveWorktreeResultDto) -> Unit = {},
) {
if (!deleting.add(dto.id)) return
edt { refresh(dto) }
cs.launch {
val result = service.remove(directory, dto.path, dto.branch, force)
if (result.ok) {
edt {
deleting.remove(dto.id)
model.remove(dto)
onRemoveSuccess?.invoke(dto)
onSuccess()
@@ -117,6 +124,8 @@ class WorktreeController(
// Removal failed: git still tracks the worktree. Keep the row and reconcile with
// ground truth so a stale optimistic delete can't make the entry reappear later.
edt {
deleting.remove(dto.id)
refresh(dto)
telemetry(
"Worktree Delete Failed",
mapOf("branch" to dto.branch, "force" to force.toString(), "locked" to result.locked.toString()),
@@ -126,6 +135,11 @@ class WorktreeController(
reload()
}
}
private fun refresh(dto: WorktreeDto) {
val idx = model.getElementIndex(dto)
if (idx >= 0) model.setElementAt(dto, idx)
}
}
private fun edt(block: () -> Unit) {
@@ -19,7 +19,6 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -44,9 +43,6 @@ open class WorktreeSessionEditorManager(
IdeFocusManager.getInstance(project).requestFocusInProject(focus, project)
}, ModalityState.defaultModalityState())
},
private val confirm: (JComponent, String, String) -> Boolean = { parent, msg, title ->
Messages.showYesNoDialog(parent, msg, title, Messages.getWarningIcon()) == Messages.YES
},
private val notify: (String, String?) -> Unit = { title, content -> KiloNotifications.error(project, title, content) },
) : SessionHost(project, worktree, create, resolve, status, timers, request) {
private val right = JPanel(BorderLayout())
@@ -119,11 +115,6 @@ open class WorktreeSessionEditorManager(
open fun deleteSessions(ids: List<String>) {
val active = ids.filter { it != NEW && it !in deleting }.distinct()
if (active.isEmpty()) return
val msg = if (active.size == 1)
KiloBundle.message("worktree.session.delete.confirm.message", title(active[0]))
else
KiloBundle.message("worktree.session.delete.confirm.message.multiple", active.size)
if (!confirm(right, msg, KiloBundle.message("worktree.session.delete.confirm.title"))) return
val key = currentKey()
val names = active.associateWith(::title)
deleting.addAll(active)
@@ -13,6 +13,7 @@ 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.ActiveListRowHeight
import ai.kilocode.client.ui.list.ActiveListSelection
@@ -31,6 +32,7 @@ import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
@@ -47,6 +49,7 @@ class WorktreeSessionEditorPanel(
private val manager: WorktreeSessionEditorManager,
private val controller: WorktreeSessionListController,
private val worktree: ai.kilocode.client.app.Workspace,
private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null,
) : BorderLayoutPanel(), Disposable, UiDataProvider {
private val add = NewAction()
private val delete = DeleteAction()
@@ -58,7 +61,7 @@ class WorktreeSessionEditorPanel(
selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION,
),
showSearch = false,
onCell = { key, id -> if (id == DELETE_CELL) manager.deleteSessions(listOf(key)) },
onCell = { key, id -> if (id == DELETE_CELL) confirmDelete(listOf(key), DELETE_CELL) },
onOpen = { row, focus -> open(row, focus) },
onSelect = { updateActions() },
)
@@ -100,7 +103,26 @@ class WorktreeSessionEditorPanel(
@RequiresEdt
fun deleteSelected() {
manager.deleteSessions(selectedKeys())
confirmDelete(selectedKeys())
}
@RequiresEdt
private fun confirmDelete(ids: List<String>, cell: String? = null) {
val active = ids.filter { it != SessionHost.NEW && it !in manager.deleting() }.distinct()
if (active.isEmpty()) return
val msg = if (active.size == 1) {
KiloBundle.message("worktree.session.delete.confirm.message", title(active[0]))
} else {
KiloBundle.message("worktree.session.delete.confirm.message.multiple", active.size)
}
val opts = ActiveListDeleteOptions(
message = msg,
detail = KiloBundle.message("worktree.session.delete.confirm.detail"),
)
val handler = confirm ?: { anchor: RelativePoint, options: ActiveListDeleteOptions, run: () -> Unit ->
list.confirmDelete(anchor, options) { run() }
}
handler(list.point(active[0], cell), opts) { manager.deleteSessions(active) }
}
@RequiresEdt
@@ -124,7 +146,6 @@ class WorktreeSessionEditorPanel(
@RequiresEdt
private fun open(row: ActiveListItem, focus: Boolean) {
if (row.key in manager.deleting()) return
if (row.key == SessionHost.NEW) {
manager.newSession()
return
@@ -161,6 +182,11 @@ class WorktreeSessionEditorPanel(
.firstOrNull { it.id == key }
}
@RequiresEdt
private fun title(key: String): String {
return item(key)?.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("worktree.session.untitled")
}
@RequiresEdt
private fun selectedKeys(): List<String> = list.selectedKeys().filter { it != SessionHost.NEW && it !in manager.deleting() }
@@ -235,20 +261,18 @@ class WorktreeSessionEditorPanel(
private data class SessionRow(
val session: SessionDto,
val kind: SessionActivityKind?,
val deleting: Boolean = false,
override val deleting: Boolean = false,
) : ActiveListItem {
private val item = LocalHistoryItem(session)
override val key: String get() = session.id
override val title: String get() = session.title.takeIf { it.isNotBlank() }
?: KiloBundle.message("worktree.session.untitled")
override val tooltip: String get() = title
override val badges: List<ActiveListBadge> get() = if (deleting) emptyList() else listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
override val trailing: String get() = if (deleting) KiloBundle.message("worktree.session.deleting") else HistoryTime.relative(item)
override val badges: List<ActiveListBadge> get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
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 muted: Boolean get() = deleting
override val cells: List<ActiveListCell>
get() = if (deleting) emptyList() else listOf(ActiveListCell(
get() = listOf(ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.session.delete.action"),
icon = AllIcons.Actions.GC,
@@ -83,6 +83,11 @@ internal class ActiveList(
@RequiresEdt
fun trackBalloon(balloon: Balloon) = view.trackBalloon(balloon)
@RequiresEdt
fun confirmDelete(anchor: RelativePoint, opts: ActiveListDeleteOptions, confirm: (Boolean) -> Unit) {
trackBalloon(showActiveListDeletePopup(anchor, opts, confirm))
}
@RequiresEdt
fun setBusy(value: Boolean) {
search?.isEnabled = !value
@@ -1,59 +1,76 @@
package ai.kilocode.client.agentManager.worktree
package ai.kilocode.client.ui.list
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.awt.RelativePoint
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Container
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.SwingUtilities
internal fun showWorktreeDeletePopup(
anchor: RelativePoint,
item: WorktreeDto,
onConfirm: (Boolean) -> Unit,
): Balloon {
lateinit var balloon: Balloon
val confirm = JBCheckBox(KiloBundle.message("worktree.delete.locked.confirm")).apply {
isOpaque = false
data class ActiveListDeleteOptions(
val message: String,
val detail: String? = null,
val gate: String? = null,
val button: String = KiloBundle.message("common.delete"),
)
internal fun activeListDeleteContent(
opts: ActiveListDeleteOptions,
hide: () -> Unit,
confirm: (Boolean) -> Unit,
): JComponent {
val gate = opts.gate?.let {
JBCheckBox(it).apply { isOpaque = false }
}
val action = object : AbstractAction(KiloBundle.message("worktree.delete.button")) {
val action = object : AbstractAction(opts.button) {
override fun actionPerformed(e: ActionEvent) {
balloon.hide(true)
onConfirm(item.locked)
hide()
confirm(gate?.isSelected == true)
}
}.apply { putValue(DialogWrapper.DEFAULT_ACTION, true) }
val delete = DialogWrapper.createJButtonForAction(action, null).apply { isOpaque = false }
fun sync() {
action.isEnabled = !item.locked || confirm.isSelected
action.isEnabled = gate?.isSelected ?: true
}
val content = Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.lg())
next(JBLabel(KiloBundle.message("worktree.delete.confirm.message", item.name)))
next(JBLabel(KiloBundle.message("worktree.delete.confirm.detail")).apply {
foreground = UIUtil.getContextHelpForeground()
})
if (item.locked) {
next(confirm)
next(JBLabel(opts.message))
opts.detail?.takeIf { it.isNotBlank() }?.let { text ->
next(JBLabel(text).apply {
foreground = UIUtil.getContextHelpForeground()
})
}
gate?.let { next(it) }
next(BorderLayoutPanel().andTransparent().addToRight(delete))
}
confirm.addActionListener { sync() }
gate?.addActionListener { sync() }
sync()
return content
}
internal fun showActiveListDeletePopup(
anchor: RelativePoint,
opts: ActiveListDeleteOptions,
confirm: (Boolean) -> Unit,
): Balloon {
lateinit var balloon: Balloon
val content = activeListDeleteContent(opts, hide = { balloon.hide(true) }, confirm)
balloon = JBPopupFactory.getInstance()
.createBalloonBuilder(content)
.setFillColor(UIUtil.getToolTipBackground())
@@ -68,6 +85,14 @@ internal fun showWorktreeDeletePopup(
.setRequestFocus(true)
.createBalloon()
balloon.show(anchor, Balloon.Position.below)
SwingUtilities.getRootPane(content)?.defaultButton = delete
SwingUtilities.getRootPane(content)?.defaultButton = activeListDeleteButton(content)
return balloon
}
private fun activeListDeleteButton(root: Container): JButton? {
for (child in root.components) {
if (child is JButton) return child
if (child is Container) activeListDeleteButton(child)?.let { return it }
}
return null
}
@@ -64,7 +64,7 @@ internal interface ActiveListItem {
val trailing: String? get() = null
val cells: List<ActiveListCell> get() = emptyList()
val disabled: Boolean get() = false
val muted: Boolean get() = false
val deleting: Boolean get() = false
/** Extra text matched by the filter field in addition to [title]; null matches title only. */
val search: String? get() = null
}
@@ -77,6 +77,7 @@ internal fun activeListSectionTitle(items: List<ActiveListItem>, index: Int): St
internal fun activeListVisibleCells(item: ActiveListItem, active: Boolean): List<ActiveListCell> {
if (item.disabled) return emptyList()
if (item.deleting) return emptyList()
return item.cells.filter { active || it.alwaysVisible }
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.ui.list
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
@@ -84,7 +85,7 @@ internal class ActiveListRenderer(
val active = selected && (focused || list.hasFocus() || (list as? ActiveListActive)?.active() == true)
val fg = UIUtil.getListForeground(active, active || focused)
val weak = if (active) fg else UiStyle.Colors.weak()
val titleFg = if (value.muted) weak else fg
val titleFg = if (value.deleting) weak else fg
val section = activeListSectionTitle(model.items, index)
background = list.background
@@ -93,7 +94,11 @@ internal class ActiveListRenderer(
sep.caption = section
sep.setHideLine(index == 0)
top.isVisible = section != null
top.setPreferredSize(section?.let { Dimension(0, sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) })
top.setPreferredSize(section?.let {
val height = sep.preferredSize.height
.coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom)
Dimension(0, height + JBUI.scale(2))
})
title.clear()
title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, titleFg))
@@ -112,7 +117,7 @@ internal class ActiveListRenderer(
JBUI.Borders.empty()
}
desc.foreground = weak
val end = value.trailing.orEmpty()
val end = if (value.deleting) KiloBundle.message("common.deleting") else value.trailing.orEmpty()
trail.text = end
trail.isVisible = end.isNotBlank()
trail.foreground = weak
@@ -149,7 +154,7 @@ internal class ActiveListRenderer(
}
private fun syncBadges(item: ActiveListItem) {
val items = item.badges
val items = if (item.deleting) emptyList() else item.badges
while (badges.componentCount > items.size) badges.remove(badges.componentCount - 1)
while (badges.componentCount < items.size) {
badges.add(JBLabel().apply {
@@ -109,6 +109,7 @@ internal class ActiveListView(
if (e.clickCount == 1 && UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) {
val hit = hit(e, enabled = false) ?: return
if (hit.id != null) return
if (hit.item.deleting) return
val action = onOpen
if (action != null) action(hit.item, false) else onClick?.invoke(hit.item) ?: return
e.consume()
@@ -117,6 +118,7 @@ internal class ActiveListView(
if (e.clickCount != 2 || !UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) return
val hit = hit(e, enabled = false) ?: return
if (hit.id != null) return
if (hit.item.deleting) return
val action = onOpen
if (action != null) action(hit.item, true) else activate(hit.item)
e.consume()
@@ -128,6 +130,7 @@ internal class ActiveListView(
press = null
val hit = hit(e) ?: return
if (hit.item.key != down.key || hit.id != down.id) return
if (hit.item.deleting) return
onCell(hit.item.key, down.id)
e.consume()
}
@@ -368,6 +371,7 @@ internal class ActiveListView(
private fun open(focus: Boolean) {
val item = list.selectedValue ?: return
if (item.deleting) return
val action = onOpen
if (action != null) {
action(item, focus)
@@ -378,6 +382,7 @@ internal class ActiveListView(
private fun source() {
val item = list.selectedValue ?: return
if (item.deleting) return
onOpen?.invoke(item, true)
}
@@ -388,6 +393,7 @@ internal class ActiveListView(
* action is destructive (e.g. delete) does nothing on double-click.
*/
private fun activate(item: ActiveListItem) {
if (item.deleting) return
val action = onActivate
if (action != null) {
action(item)
@@ -403,6 +409,7 @@ internal class ActiveListView(
}
private fun primary(item: ActiveListItem) {
if (item.deleting) return
val cells = activeListVisibleCells(item, true)
val cell = cells.firstOrNull { it.enabled && it.primary }
if (cell != null) {
@@ -1,4 +1,5 @@
common.delete=Delete
common.deleting=Deleting…
common.open=Open
common.save=Save
session.action.cancel=Cancel
@@ -311,7 +312,6 @@ worktree.delete.confirm.message=Delete worktree "{0}"?
worktree.delete.confirm.detail=This removes the working tree and its branch.
worktree.delete.locked.confirm=Confirm deleting this locked worktree
worktree.delete.force=Force delete this worktree
worktree.delete.button=Delete
worktree.delete.failed.title=Couldn''t delete worktree "{0}"
worktree.create.failed.title=Couldn''t create worktree
worktree.session.title=Worktree Session
@@ -321,11 +321,10 @@ worktree.session.list.empty=No sessions
worktree.session.list.search.placeholder=Search sessions
worktree.session.new.action=New session
worktree.session.delete.action=Delete session
worktree.session.delete.confirm.title=Delete session?
worktree.session.delete.confirm.message=Delete session "{0}"?
worktree.session.delete.confirm.message.multiple=Delete {0} sessions?
worktree.session.delete.confirm.detail=This permanently removes the session.
worktree.session.delete.failed.title=Failed to delete session "{0}"
worktree.session.deleting=Deleting…
worktree.session.new=New session
worktree.session.untitled=Untitled session
worktree.menu.from=New Worktree from {0}
@@ -118,6 +118,27 @@ class WorktreeControllerTest : BasePlatformTestCase() {
assertEquals(listOf(item), removed)
}
fun `test remove marks the row deleting until it resolves`() {
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val gate = CompletableDeferred<Unit>()
rpc.listed += item
rpc.beforeRemove = { gate.await() }
val controller = controller()
controller.reload()
flush()
controller.remove(controller.model.getElementAt(0))
assertTrue(controller.isDeleting(item.id))
assertEquals(1, controller.model.size)
gate.complete(Unit)
flush()
assertFalse(controller.isDeleting(item.id))
assertEquals(0, controller.model.size)
}
fun `test failed remove keeps the row and invokes the failure callback`() {
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
rpc.listed += item
@@ -133,6 +154,7 @@ class WorktreeControllerTest : BasePlatformTestCase() {
// git rejected the removal, so the entry must remain instead of vanishing optimistically.
assertEquals(1, controller.model.size)
assertEquals("feature/x", controller.model.getElementAt(0).branch)
assertFalse(controller.isDeleting(item.id))
assertEquals(1, failures.size)
assertTrue(failures.first().locked)
}
@@ -44,7 +44,6 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
private val requested = mutableListOf<JComponent>()
private val notified = mutableListOf<Pair<String, String?>>()
private val ui = mutableListOf<SessionUi>()
private var confirms = 0
override fun setUp() {
super.setUp()
@@ -155,7 +154,6 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
pump()
flush()
assertEquals(1, confirms)
assertEquals(listOf(DIR to "ses_1", DIR to "ses_2"), created)
waitUntil { manager.deleting().isEmpty() }
assertEquals(listOf(first.id to DIR), rpc.deletes.toList())
@@ -230,7 +228,6 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
status = { sessions.activity() },
timers = timers,
request = { requested += it },
confirm = { _, _, _ -> confirms++; true },
notify = { title, content -> notified += title to content },
).also { it.startFocus = focus }
}
@@ -56,7 +56,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
sessions = KiloSessionService(project, coroutines.scope, rpc)
controller = WorktreeSessionListController(sessions, DIR, coroutines.scope)
manager = FakeManager()
panel = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace) }
panel = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) }
}
override fun tearDown() {
@@ -119,7 +119,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertNull(row.icon)
assertNull(row.description)
assertEquals(listOf(ActiveListBadge(SessionActivityKind.RUNNING.label(), SessionActivityKind.RUNNING.style())), row.badges)
assertEquals(HistoryTime.relative(LocalHistoryItem(session)), row.trailing)
assertNull(row.trailing)
assertEquals(HistoryTime.title(HistoryTime.section(LocalHistoryItem(session))), row.section)
}
@@ -132,10 +132,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
val row = row("ses_1")
assertEquals("Deleting…", row.trailing)
assertTrue(row.cells.isEmpty())
assertTrue(row.badges.isEmpty())
assertTrue(row.muted)
assertTrue(row.deleting)
}
fun `test pending new session groups under today`() {
@@ -341,7 +338,6 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
controller,
create = { _, _, _, _, _ -> error("unused") },
request = {},
confirm = { _, _, _ -> true },
) {
var newCount = 0
var pending = false
@@ -21,6 +21,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
val removes = CopyOnWriteArrayList<Triple<String, String, String?>>()
val removeForces = CopyOnWriteArrayList<Boolean>()
var beforeCreate: suspend () -> Unit = {}
var beforeRemove: suspend () -> Unit = {}
var createResult: (CreateWorktreeRequestDto) -> CreateWorktreeResultDto = { req ->
CreateWorktreeResultDto(WorktreeDto(req.branch, req.branch, req.branch, req.branch))
}
@@ -47,6 +48,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
assertNotEdt("remove")
removes.add(Triple(directory, path, branch))
removeForces.add(force)
beforeRemove()
return removeResult(path, branch, force)
}
}
@@ -0,0 +1,63 @@
package ai.kilocode.client.ui.list
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBCheckBox
import java.awt.Component
import java.awt.Container
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class ActiveListDeletePopupTest : BasePlatformTestCase() {
fun `test delete content confirms without gate`() {
val hides = mutableListOf<Unit>()
val confirms = mutableListOf<Boolean>()
val content = activeListDeleteContent(
ActiveListDeleteOptions(message = "Delete item?"),
hide = { hides += Unit },
confirm = { confirms += it },
)
val button = component<JButton>(content)
assertTrue(button.isEnabled)
button.doClick()
assertEquals(1, hides.size)
assertEquals(listOf(false), confirms)
}
fun `test delete content requires gate confirmation`() {
val hides = mutableListOf<Unit>()
val confirms = mutableListOf<Boolean>()
val content = activeListDeleteContent(
ActiveListDeleteOptions(message = "Delete item?", gate = "Confirm delete"),
hide = { hides += Unit },
confirm = { confirms += it },
)
val box = component<JBCheckBox>(content)
val button = component<JButton>(content)
assertFalse(button.isEnabled)
box.doClick()
assertTrue(button.isEnabled)
button.doClick()
assertEquals(1, hides.size)
assertEquals(listOf(true), confirms)
}
private inline fun <reified T : Component> component(root: Component): T {
val found = components(root).filterIsInstance<T>().firstOrNull()
assertNotNull(found)
return found!!
}
private fun components(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(item: Component) {
out += item
if (item is Container) item.components.forEach { visit(it) }
}
visit(root)
return out
}
}