Skip to content

Commit 41111cd

Browse files
committed
animated-worktree-deletion (squashed)
1 parent e1407bc commit 41111cd

13 files changed

Lines changed: 471 additions & 100 deletions

src/main/index.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import { Store } from './store'
88
import { registerStateTransport } from './transport-electron'
99
import { PRPoller } from './pr-poller'
1010
import { WorktreesFSM } from './worktrees-fsm'
11+
import { WorktreeDeletionFSM } from './worktree-deletion-fsm'
1112
import { PanesFSM, stripTransientTabFields } from './panes-fsm'
1213
import { ActivityDeriver } from './activity-deriver'
1314
import type { TerminalTab, WorkspacePane } from '../shared/state/terminals'
14-
import { listWorktrees, listBranches, continueWorktree, removeWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, readWorktreeFile, runWorktreeScript, type MergeStrategy } from './worktree'
15+
import { listWorktrees, listBranches, continueWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, readWorktreeFile, type MergeStrategy } from './worktree'
1516
import { getPRStatus, testToken, starRepo, unstarRepo, isRepoStarred } from './github'
1617
import { AVAILABLE_EDITORS, DEFAULT_EDITOR_ID, openInEditor } from './editor'
1718
import { setSecret, hasSecret, deleteSecret } from './secrets'
@@ -191,6 +192,11 @@ const worktreesFSM = new WorktreesFSM(store, {
191192
}
192193
})
193194

195+
const worktreeDeletionFSM = new WorktreeDeletionFSM(store, {
196+
getGlobalTeardownCmd: () => config.worktreeTeardownCommand || '',
197+
worktreesFSM
198+
})
199+
194200
const activityDeriver = new ActivityDeriver(store)
195201

196202
/** Install Claude Code hooks into any worktree that's missing them, but only
@@ -343,7 +349,9 @@ function registerIpcHandlers(): void {
343349
removeMeta?: { prNumber?: number; prState?: PRState }
344350
) => {
345351
if (!repoRoot) throw new Error('No repo root provided')
346-
// Drop any locally-merged flag for the branch at this path
352+
// Drop any locally-merged flag for the branch at this path. We still
353+
// need the worktree record for its branch name *before* kicking off
354+
// the async deletion.
347355
const trees = await listWorktrees(repoRoot)
348356
const wt = trees.find((t) => t.path === path)
349357
if (wt && config.locallyMerged && wt.branch && config.locallyMerged[wt.branch]) {
@@ -358,18 +366,22 @@ function registerIpcHandlers(): void {
358366
prNumber: removeMeta?.prNumber,
359367
prState: removeMeta?.prState
360368
})
361-
const teardownRepoCfg = loadRepoConfig(repoRoot)
362-
const teardownCmd = teardownRepoCfg.teardownCommand || config.worktreeTeardownCommand || ''
363-
if (teardownCmd) {
364-
await runWorktreeScript('teardown', teardownCmd, {
365-
worktreePath: path,
366-
branch: wt?.branch || '',
367-
repoRoot
368-
})
369-
}
370-
const result = await removeWorktree(repoRoot, path, force)
371-
void worktreesFSM.refreshList()
372-
return result
369+
// Fire-and-forget: the WorktreeDeletionFSM runs the teardown script
370+
// and git worktree remove in the background, streaming progress
371+
// through the store. Returns immediately so the renderer can animate
372+
// the deletion card instead of freezing on the row.
373+
worktreeDeletionFSM.enqueue({
374+
repoRoot,
375+
path,
376+
branch: wt?.branch || '',
377+
force
378+
})
379+
return { queued: true }
380+
})
381+
382+
ipcMain.handle('worktree:dismissPendingDeletion', (_, path: string) => {
383+
worktreeDeletionFSM.dismiss(path)
384+
return true
373385
})
374386

375387
ipcMain.handle('worktree:dir', async (_, repoRoot: string) => {

src/main/worktree-deletion-fsm.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { removeWorktree, runWorktreeScript } from './worktree'
2+
import { loadRepoConfig } from './repo-config'
3+
import { log } from './debug'
4+
import type { Store } from './store'
5+
import type { WorktreesFSM } from './worktrees-fsm'
6+
import type { PendingDeletion } from '../shared/state/worktrees'
7+
8+
interface WorktreeDeletionFSMOptions {
9+
getGlobalTeardownCmd: () => string
10+
worktreesFSM: WorktreesFSM
11+
}
12+
13+
/** Owns the pending-deletion state machine. Each enqueue runs independently
14+
* (parallel deletions are fine — they touch disjoint paths), streams
15+
* teardown script output into the store, and refreshes the worktree list
16+
* on completion. Lives entirely in main so deletions keep running if the
17+
* user navigates away; the renderer just reads state. */
18+
export class WorktreeDeletionFSM {
19+
private store: Store
20+
private opts: WorktreeDeletionFSMOptions
21+
22+
constructor(store: Store, opts: WorktreeDeletionFSMOptions) {
23+
this.store = store
24+
this.opts = opts
25+
}
26+
27+
/** Kick off a deletion. Returns immediately after seeding the pending
28+
* entry; the actual work runs in the background. */
29+
enqueue(params: {
30+
repoRoot: string
31+
path: string
32+
branch: string
33+
force?: boolean
34+
}): void {
35+
void this.run(params)
36+
}
37+
38+
dismiss(path: string): void {
39+
this.store.dispatch({ type: 'worktrees/pendingDeletionRemoved', payload: path })
40+
}
41+
42+
private async run(params: {
43+
repoRoot: string
44+
path: string
45+
branch: string
46+
force?: boolean
47+
}): Promise<void> {
48+
const { repoRoot, path, branch, force } = params
49+
const repoCfg = loadRepoConfig(repoRoot)
50+
const teardownCmd = repoCfg.teardownCommand || this.opts.getGlobalTeardownCmd() || ''
51+
const hasTeardown = Boolean(teardownCmd.trim())
52+
53+
const initial: PendingDeletion = {
54+
path,
55+
repoRoot,
56+
branch,
57+
phase: hasTeardown ? 'running-teardown' : 'removing-worktree',
58+
teardownLog: hasTeardown ? '' : undefined
59+
}
60+
this.store.dispatch({ type: 'worktrees/pendingDeletionStarted', payload: initial })
61+
62+
try {
63+
if (hasTeardown) {
64+
let buffered = ''
65+
const result = await runWorktreeScript(
66+
'teardown',
67+
teardownCmd,
68+
{ worktreePath: path, branch, repoRoot },
69+
(_stream, chunk) => {
70+
buffered += chunk
71+
this.store.dispatch({
72+
type: 'worktrees/pendingDeletionUpdated',
73+
payload: { path, patch: { teardownLog: buffered } }
74+
})
75+
}
76+
)
77+
this.store.dispatch({
78+
type: 'worktrees/pendingDeletionUpdated',
79+
payload: { path, patch: { teardownExitCode: result.exitCode } }
80+
})
81+
// Teardown failure is non-fatal — we still want to remove the
82+
// worktree, matching the previous synchronous behavior.
83+
}
84+
85+
this.store.dispatch({
86+
type: 'worktrees/pendingDeletionUpdated',
87+
payload: { path, patch: { phase: 'removing-worktree' } }
88+
})
89+
await removeWorktree(repoRoot, path, force)
90+
91+
// Clear the pending entry and refresh the list so the sidebar row
92+
// disappears in one render.
93+
this.store.dispatch({ type: 'worktrees/pendingDeletionRemoved', payload: path })
94+
await this.opts.worktreesFSM.refreshList()
95+
} catch (err) {
96+
const message = err instanceof Error ? err.message : String(err)
97+
log('worktree-deletion-fsm', `deletion failed for ${path}: ${message}`)
98+
this.store.dispatch({
99+
type: 'worktrees/pendingDeletionUpdated',
100+
payload: { path, patch: { phase: 'failed', error: message } }
101+
})
102+
}
103+
}
104+
}

src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ contextBridge.exposeInMainWorld('api', {
3535
force?: boolean,
3636
removeMeta?: { prNumber?: number; prState?: 'open' | 'draft' | 'merged' | 'closed' }
3737
) => ipcRenderer.invoke('worktree:remove', repoRoot, path, force, removeMeta),
38+
dismissPendingDeletion: (path: string) =>
39+
ipcRenderer.invoke('worktree:dismissPendingDeletion', path),
3840
getWorktreeDir: (repoRoot: string) => ipcRenderer.invoke('worktree:dir', repoRoot),
3941
// Repos (multi-repo session state)
4042
listRepos: () => ipcRenderer.invoke('repo:list'),

src/renderer/App.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Sidebar } from './components/Sidebar'
1010
import { ResizeHandle } from './components/ResizeHandle'
1111
import { NewWorktreeScreen } from './components/NewWorktreeScreen'
1212
import { CreatingWorktreeScreen } from './components/CreatingWorktreeScreen'
13+
import { DeletingWorktreeScreen } from './components/DeletingWorktreeScreen'
1314
import { QuestCard } from './components/QuestCard'
1415
import { WorkspaceView } from './components/WorkspaceView'
1516
import { ChangedFilesPanel } from './components/ChangedFilesPanel'
@@ -37,6 +38,12 @@ export default function App(): JSX.Element {
3738
const wtState = useWorktrees()
3839
const worktrees = wtState.list
3940
const pendingWorktrees = wtState.pending
41+
const pendingDeletions = wtState.pendingDeletions ?? []
42+
const pendingDeletionByPath = useMemo(() => {
43+
const m: Record<string, (typeof pendingDeletions)[number]> = {}
44+
for (const d of pendingDeletions) m[d.path] = d
45+
return m
46+
}, [pendingDeletions])
4047
const worktreeRepoByPath = useMemo(() => {
4148
const m: Record<string, string> = {}
4249
for (const w of worktrees) m[w.path] = w.repoRoot
@@ -317,6 +324,21 @@ const setQuestStep = useCallback((next: QuestStep) => {
317324
fetchPRStatusIfStale(activeWorktreeId)
318325
}, [activeWorktreeId, fetchPRStatusIfStale])
319326

327+
// If the active id points at something that no longer exists — a
328+
// finished deletion, a dismissed pending creation, a stale focus after
329+
// a refresh — route focus to a neighbor so the center pane doesn't
330+
// collapse into an empty region.
331+
useEffect(() => {
332+
if (!activeWorktreeId) return
333+
if (isPendingId(activeWorktreeId)) {
334+
if (pendingWorktrees.some((p) => p.id === activeWorktreeId)) return
335+
} else {
336+
if (worktrees.some((w) => w.path === activeWorktreeId)) return
337+
if (pendingDeletions.some((d) => d.path === activeWorktreeId)) return
338+
}
339+
setActiveWorktreeId(worktrees[0]?.path ?? null)
340+
}, [activeWorktreeId, worktrees, pendingWorktrees, pendingDeletions])
341+
320342
const handleAcceptHooks = useCallback(() => {
321343
void window.api.acceptHooks()
322344
}, [])
@@ -347,7 +369,8 @@ const setQuestStep = useCallback((next: QuestStep) => {
347369
handleContinuePendingWorktree,
348370
handleContinueWorktree,
349371
handleDeleteWorktree,
350-
handleBulkDeleteWorktrees
372+
handleBulkDeleteWorktrees,
373+
handleDismissPendingDeletion
351374
} = useWorktreeHandlers({
352375
worktrees,
353376
pendingWorktrees,
@@ -584,6 +607,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
584607
<Sidebar
585608
worktrees={worktrees}
586609
pendingWorktrees={pendingWorktrees}
610+
pendingDeletions={pendingDeletions}
587611
activeWorktreeId={activeWorktreeId}
588612
statuses={worktreeStatuses}
589613
pendingTools={worktreePendingTools}
@@ -632,7 +656,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
632656
{worktrees.map((wt) => {
633657
const paneList = panes[wt.path]
634658
if (!paneList || paneList.length === 0) return null
635-
const isVisible = !showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && wt.path === activeWorktreeId
659+
const isVisible = !showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && wt.path === activeWorktreeId && !pendingDeletionByPath[wt.path]
636660
return (
637661
<div
638662
key={wt.path}
@@ -730,6 +754,12 @@ const setQuestStep = useCallback((next: QuestStep) => {
730754
/>
731755
)
732756
})()}
757+
{!showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && activeWorktreeId && pendingDeletionByPath[activeWorktreeId] && (
758+
<DeletingWorktreeScreen
759+
deletion={pendingDeletionByPath[activeWorktreeId]}
760+
onDismiss={handleDismissPendingDeletion}
761+
/>
762+
)}
733763
<QuestCard
734764
step={questStep}
735765
onDismiss={() => setQuestStep('done')}

src/renderer/components/CreatingWorktreeScreen.tsx

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useState } from 'react'
22
import { AlertCircle, ChevronDown, ChevronRight, Terminal as TerminalIcon } from 'lucide-react'
33
import type { PendingWorktree } from '../types'
4+
import { PendingLoader, ScriptLogViewer } from './PendingScreenParts'
45

56
interface CreatingWorktreeScreenProps {
67
pending: PendingWorktree
@@ -9,52 +10,6 @@ interface CreatingWorktreeScreenProps {
910
onContinue: (id: string) => void
1011
}
1112

12-
function Loader(): JSX.Element {
13-
return (
14-
<div className="claude-loader" aria-label="Creating worktree">
15-
<div className="claude-loader-halo" />
16-
<div className="claude-loader-pulser">
17-
<div className="claude-loader-rotator">
18-
<svg viewBox="0 0 56 56" width="56" height="56">
19-
<defs>
20-
<linearGradient id="creatingWtGrad" x1="0%" y1="0%" x2="100%" y2="100%">
21-
<stop offset="0%" stopColor="#f59e0b" />
22-
<stop offset="55%" stopColor="#ef4444" />
23-
<stop offset="100%" stopColor="#a855f7" />
24-
</linearGradient>
25-
</defs>
26-
<g fill="url(#creatingWtGrad)" transform="translate(28 28)">
27-
{[0, 45, 90, 135].map((deg) => (
28-
<path
29-
key={deg}
30-
d="M 0 -24 Q 3 0 0 24 Q -3 0 0 -24 Z"
31-
transform={`rotate(${deg})`}
32-
/>
33-
))}
34-
</g>
35-
</svg>
36-
</div>
37-
</div>
38-
</div>
39-
)
40-
}
41-
42-
function SetupLogViewer({ log }: { log: string }): JSX.Element {
43-
const ref = useRef<HTMLPreElement>(null)
44-
useEffect(() => {
45-
const el = ref.current
46-
if (el) el.scrollTop = el.scrollHeight
47-
}, [log])
48-
return (
49-
<pre
50-
ref={ref}
51-
className="text-[11px] leading-snug text-muted bg-app border border-border rounded p-3 whitespace-pre-wrap break-words max-h-72 overflow-auto font-mono"
52-
>
53-
{log || <span className="text-faint">Waiting for output…</span>}
54-
</pre>
55-
)
56-
}
57-
5813
export function CreatingWorktreeScreen({
5914
pending,
6015
onRetry,
@@ -115,7 +70,7 @@ export function CreatingWorktreeScreen({
11570
created successfully, but the setup command didn't exit cleanly. You
11671
can continue into the worktree or dismiss this screen.
11772
</p>
118-
<SetupLogViewer log={pending.setupLog || ''} />
73+
<ScriptLogViewer log={pending.setupLog || ''} />
11974
<div className="flex gap-2 justify-end mt-4">
12075
<button
12176
onClick={() => onDismiss(pending.id)}
@@ -139,7 +94,7 @@ export function CreatingWorktreeScreen({
13994

14095
return (
14196
<div className="flex-1 min-w-0 flex flex-col items-center justify-center gap-4 p-8">
142-
<Loader />
97+
<PendingLoader label="Creating worktree" />
14398
<div className="text-sm text-muted text-center">
14499
{inSetup ? (
145100
<>
@@ -161,7 +116,7 @@ export function CreatingWorktreeScreen({
161116
<TerminalIcon size={12} />
162117
{logsOpen ? 'Hide setup logs' : 'Show setup logs'}
163118
</button>
164-
{logsOpen && <SetupLogViewer log={pending.setupLog || ''} />}
119+
{logsOpen && <ScriptLogViewer log={pending.setupLog || ''} />}
165120
</div>
166121
)}
167122
</div>

0 commit comments

Comments
 (0)