mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix(jetbrains): share active list delete UX
This commit is contained in:
@@ -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.
|
||||
+18
-9
@@ -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('/')
|
||||
|
||||
+14
@@ -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) {
|
||||
|
||||
-9
@@ -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)
|
||||
|
||||
+32
-8
@@ -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,
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+48
-23
@@ -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
|
||||
}
|
||||
+2
-1
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+9
-4
@@ -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 {
|
||||
|
||||
+7
@@ -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}
|
||||
|
||||
+22
@@ -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)
|
||||
}
|
||||
|
||||
-3
@@ -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 }
|
||||
}
|
||||
|
||||
+3
-7
@@ -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
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user