From 8254e9c52562058390600f050ef4ffc2b9154937 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Jul 2026 15:35:42 -0400 Subject: [PATCH] fix(jetbrains): share active list delete UX --- ...70262-active-list-delete-popup-deleting.md | 208 ++++++++++++++++++ .../client/agentManager/AgentManagerPanel.kt | 27 ++- .../worktree/WorktreeController.kt | 14 ++ .../worktree/WorktreeSessionEditorManager.kt | 9 - .../worktree/WorktreeSessionEditorPanel.kt | 40 +++- .../ai/kilocode/client/ui/list/ActiveList.kt | 5 + .../list/ActiveListDeletePopup.kt} | 71 ++++-- .../client/ui/list/ActiveListModel.kt | 3 +- .../client/ui/list/ActiveListRenderer.kt | 13 +- .../kilocode/client/ui/list/ActiveListView.kt | 7 + .../resources/messages/KiloBundle.properties | 5 +- .../agentManager/WorktreeControllerTest.kt | 22 ++ .../WorktreeSessionEditorManagerTest.kt | 3 - .../WorktreeSessionEditorPanelTest.kt | 10 +- .../client/testing/FakeWorktreeRpcApi.kt | 2 + .../ui/list/ActiveListDeletePopupTest.kt | 63 ++++++ 16 files changed, 435 insertions(+), 67 deletions(-) create mode 100644 .kilo/plans/1785252070262-active-list-delete-popup-deleting.md rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/{agentManager/worktree/WorktreeDeletePopup.kt => ui/list/ActiveListDeletePopup.kt} (53%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopupTest.kt diff --git a/.kilo/plans/1785252070262-active-list-delete-popup-deleting.md b/.kilo/plans/1785252070262-active-list-delete-popup-deleting.md new file mode 100644 index 00000000000..40b01745256 --- /dev/null +++ b/.kilo/plans/1785252070262-active-list-delete-popup-deleting.md @@ -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, 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()` 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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 4091da2cb60..c6042351731 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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('/') diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index b4aa05cc92f..87c61936321 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -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() private val pending = LinkedHashMap() + private val deleting = Collections.synchronizedSet(LinkedHashSet()) 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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt index 69c6fa0d0fa..18b2a5821d7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt @@ -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) { 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index bca000759f8..1484826e9e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -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, 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 = 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 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 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 - get() = if (deleting) emptyList() else listOf(ActiveListCell( + get() = listOf(ActiveListCell( DELETE_CELL, KiloBundle.message("worktree.session.delete.action"), icon = AllIcons.Actions.GC, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt index e9e78275499..b21ed05c369 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeDeletePopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopup.kt similarity index 53% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeDeletePopup.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopup.kt index 097ec308026..0e02e2e84db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeDeletePopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopup.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 662ffa0cc39..c824d4a3106 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -64,7 +64,7 @@ internal interface ActiveListItem { val trailing: String? get() = null val cells: List 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, index: Int): St internal fun activeListVisibleCells(item: ActiveListItem, active: Boolean): List { if (item.disabled) return emptyList() + if (item.deleting) return emptyList() return item.cells.filter { active || it.alwaysVisible } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 7c25f82d255..eea13260f3a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index 2e07b3fb0b7..4fa3f86b76a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index a8fa9d983d7..e0a75c38e22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index efaedbd72f3..fd56dc068f7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -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() + 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) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt index 3a46b17558c..1ee4deca915 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt @@ -44,7 +44,6 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { private val requested = mutableListOf() private val notified = mutableListOf>() private val ui = mutableListOf() - 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 } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index 452586bbb58..e3ccda21379 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index a3f46fe8c63..44900372f70 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -21,6 +21,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val removes = CopyOnWriteArrayList>() val removeForces = CopyOnWriteArrayList() 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) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopupTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopupTest.kt new file mode 100644 index 00000000000..c19972af2d7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListDeletePopupTest.kt @@ -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() + val confirms = mutableListOf() + val content = activeListDeleteContent( + ActiveListDeleteOptions(message = "Delete item?"), + hide = { hides += Unit }, + confirm = { confirms += it }, + ) + + val button = component(content) + + assertTrue(button.isEnabled) + button.doClick() + assertEquals(1, hides.size) + assertEquals(listOf(false), confirms) + } + + fun `test delete content requires gate confirmation`() { + val hides = mutableListOf() + val confirms = mutableListOf() + val content = activeListDeleteContent( + ActiveListDeleteOptions(message = "Delete item?", gate = "Confirm delete"), + hide = { hides += Unit }, + confirm = { confirms += it }, + ) + val box = component(content) + val button = component(content) + + assertFalse(button.isEnabled) + box.doClick() + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(1, hides.size) + assertEquals(listOf(true), confirms) + } + + private inline fun component(root: Component): T { + val found = components(root).filterIsInstance().firstOrNull() + assertNotNull(found) + return found!! + } + + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } +}