1- import type { GitWorktreeCleanupCandidate } from "@okcode/contracts" ;
2- import { useMutation , useQuery , useQueryClient } from "@tanstack/react-query" ;
1+ import { useMutation , useQueryClient } from "@tanstack/react-query" ;
32import { GitMergeIcon , LoaderCircleIcon , Trash2Icon } from "lucide-react" ;
4- import { useMemo } from "react" ;
3+ import { useMemo , useState } from "react" ;
54
5+ import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates" ;
6+ import { gitRemoveWorktreeMutationOptions } from "~/lib/gitReactQuery" ;
7+ import { readNativeApi } from "~/nativeApi" ;
68import { useStore } from "~/store" ;
7- import { useHandleNewThread } from "~/hooks/useHandleNewThread" ;
89import {
9- gitMergedWorktreeCleanupCandidatesQueryOptions ,
10- gitPruneWorktreesMutationOptions ,
11- gitRemoveWorktreeMutationOptions ,
12- } from "~/lib/gitReactQuery" ;
13- import { formatBranchAge , formatWorktreePathForDisplay } from "~/worktreeCleanup" ;
10+ buildWorktreeCleanupCandidateStates ,
11+ formatBranchAge ,
12+ formatWorktreePathForDisplay ,
13+ type WorktreeCleanupCandidateState ,
14+ } from "~/worktreeCleanup" ;
1415import { useWorktreeCleanupStore } from "~/worktreeCleanupStore" ;
15- import { toastManager } from "./ui/toast" ;
1616import { Badge } from "./ui/badge" ;
1717import { Button } from "./ui/button" ;
18+ import { Card , CardContent } from "./ui/card" ;
1819import {
1920 Dialog ,
2021 DialogDescription ,
@@ -24,100 +25,160 @@ import {
2425 DialogPopup ,
2526 DialogTitle ,
2627} from "./ui/dialog" ;
27- import { Separator } from "./ui/separator" ;
2828import { ScrollArea } from "./ui/scroll-area" ;
29- import { Card , CardContent } from "./ui/card" ;
30-
31- function resolveWorktreeUsageCount (
32- candidate : GitWorktreeCleanupCandidate ,
33- threadWorktreePaths : readonly ( string | null ) [ ] ,
34- ) : number {
35- return threadWorktreePaths . filter ( ( path ) => path === candidate . path ) . length ;
36- }
29+ import { Separator } from "./ui/separator" ;
30+ import { toastManager } from "./ui/toast" ;
3731
3832export function WorktreeCleanupDialog ( ) {
3933 const open = useWorktreeCleanupStore ( ( state ) => state . open ) ;
4034 const closeDialog = useWorktreeCleanupStore ( ( state ) => state . closeDialog ) ;
41- const { activeThread } = useHandleNewThread ( ) ;
4235 const threads = useStore ( ( state ) => state . threads ) ;
43- const projects = useStore ( ( state ) => state . projects ) ;
4436 const queryClient = useQueryClient ( ) ;
45- const activeProject = useMemo ( ( ) => {
46- if ( activeThread ) {
47- return (
48- projects . find ( ( project ) => project . id === activeThread . projectId ) ?? projects [ 0 ] ?? null
49- ) ;
50- }
51- return projects [ 0 ] ?? null ;
52- } , [ activeThread , projects ] ) ;
53- const cwd = activeProject ?. cwd ?? null ;
37+ const [ isDeletingAll , setIsDeletingAll ] = useState ( false ) ;
38+ const { candidates, candidatesQuery, cwd } = useCurrentWorktreeCleanupCandidates ( ) ;
5439 const threadWorktreePaths = useMemo (
5540 ( ) => threads . map ( ( thread ) => thread . worktreePath ) ,
5641 [ threads ] ,
5742 ) ;
5843
59- const candidatesQuery = useQuery ( gitMergedWorktreeCleanupCandidatesQueryOptions ( cwd ) ) ;
6044 const removeWorktreeMutation = useMutation ( gitRemoveWorktreeMutationOptions ( { queryClient } ) ) ;
61- const pruneWorktreesMutation = useMutation ( gitPruneWorktreesMutationOptions ( { queryClient } ) ) ;
62-
63- const candidates = Array . isArray ( candidatesQuery . data ) ? candidatesQuery . data : [ ] ;
64- const hasCandidates = candidates . length > 0 ;
65- const isBusy = removeWorktreeMutation . isPending || pruneWorktreesMutation . isPending ;
66-
67- const staleCandidates = useMemo (
45+ const candidateStates = useMemo (
6846 ( ) =>
69- candidates . filter (
70- ( c ) => ! c . pathExists && resolveWorktreeUsageCount ( c , threadWorktreePaths ) === 0 ,
71- ) ,
47+ buildWorktreeCleanupCandidateStates ( {
48+ candidates,
49+ threadWorktreePaths,
50+ } ) ,
7251 [ candidates , threadWorktreePaths ] ,
7352 ) ;
74- const hasStaleCandidates = staleCandidates . length > 0 ;
53+ const actionableCandidateStates = useMemo (
54+ ( ) => candidateStates . filter ( ( state ) => state . canDelete ) ,
55+ [ candidateStates ] ,
56+ ) ;
57+ const onDiskCandidateCount = actionableCandidateStates . filter (
58+ ( state ) => state . candidate . pathExists ,
59+ ) . length ;
60+ const staleCandidateCount = actionableCandidateStates . length - onDiskCandidateCount ;
61+ const hasCandidates = candidateStates . length > 0 ;
62+ const isBusy = isDeletingAll || removeWorktreeMutation . isPending ;
7563
7664 const handleClose = ( ) => {
7765 closeDialog ( ) ;
7866 } ;
7967
80- const handlePruneAllStale = async ( ) => {
81- if ( ! cwd || ! hasStaleCandidates ) return ;
68+ const handleRemoveCandidate = async ( candidateState : WorktreeCleanupCandidateState ) => {
69+ if ( ! cwd || ! candidateState . canDelete ) return ;
70+
8271 try {
83- await pruneWorktreesMutation . mutateAsync ( { cwd } ) ;
84- toastManager . add ( {
85- type : "success" ,
86- title : "Stale records pruned" ,
87- description : `Pruned ${ staleCandidates . length } stale worktree record${ staleCandidates . length === 1 ? "" : "s" } .` ,
72+ await removeWorktreeMutation . mutateAsync ( {
73+ cwd,
74+ path : candidateState . candidate . path ,
75+ force : true ,
8876 } ) ;
8977 } catch ( error ) {
9078 toastManager . add ( {
9179 type : "error" ,
92- title : "Could not prune stale records" ,
80+ title : candidateState . candidate . pathExists
81+ ? "Could not delete worktree"
82+ : "Could not remove stale record" ,
9383 description : error instanceof Error ? error . message : "Unknown error." ,
9484 } ) ;
9585 }
9686 } ;
9787
98- const handleRemoveCandidate = async ( candidate : GitWorktreeCleanupCandidate ) => {
99- if ( ! cwd ) return ;
100- const usageCount = resolveWorktreeUsageCount ( candidate , threadWorktreePaths ) ;
101- if ( usageCount > 0 ) return ;
88+ const handleDeleteAll = async ( ) => {
89+ if ( ! cwd || actionableCandidateStates . length === 0 ) return ;
90+
91+ const skippedCount = candidateStates . length - actionableCandidateStates . length ;
92+ const summaryLines = [ "Delete all available cleanup candidates?" ] ;
93+ const effects : string [ ] = [ ] ;
94+ if ( onDiskCandidateCount > 0 ) {
95+ effects . push (
96+ `delete ${ onDiskCandidateCount } worktree${ onDiskCandidateCount === 1 ? "" : "s" } on disk` ,
97+ ) ;
98+ }
99+ if ( staleCandidateCount > 0 ) {
100+ effects . push (
101+ `remove ${ staleCandidateCount } stale Git record${ staleCandidateCount === 1 ? "" : "s" } ` ,
102+ ) ;
103+ }
104+ if ( effects . length > 0 ) {
105+ summaryLines . push ( `${ effects . join ( " and " ) } .` ) ;
106+ }
107+ if ( skippedCount > 0 ) {
108+ summaryLines . push (
109+ `Skip ${ skippedCount } candidate${ skippedCount === 1 ? "" : "s" } still linked to thread${ skippedCount === 1 ? "" : "s" } .` ,
110+ ) ;
111+ }
112+ summaryLines . push ( "This cannot be undone." ) ;
113+
114+ const api = readNativeApi ( ) ;
115+ const confirmMessage = summaryLines . join ( "\n" ) ;
116+ const confirmed = api
117+ ? await api . dialogs . confirm ( confirmMessage )
118+ : window . confirm ( confirmMessage ) ;
119+ if ( ! confirmed ) {
120+ return ;
121+ }
102122
123+ setIsDeletingAll ( true ) ;
124+ let deletedOnDiskCount = 0 ;
125+ let deletedStaleCount = 0 ;
103126 try {
104- if ( candidate . pathExists ) {
127+ for ( const candidateState of actionableCandidateStates ) {
105128 await removeWorktreeMutation . mutateAsync ( {
106129 cwd,
107- path : candidate . path ,
130+ path : candidateState . candidate . path ,
108131 force : true ,
109132 } ) ;
110- } else {
111- await pruneWorktreesMutation . mutateAsync ( { cwd } ) ;
133+ if ( candidateState . candidate . pathExists ) {
134+ deletedOnDiskCount += 1 ;
135+ } else {
136+ deletedStaleCount += 1 ;
137+ }
138+ }
139+
140+ const completedParts : string [ ] = [ ] ;
141+ if ( deletedOnDiskCount > 0 ) {
142+ completedParts . push (
143+ `Deleted ${ deletedOnDiskCount } worktree${ deletedOnDiskCount === 1 ? "" : "s" } ` ,
144+ ) ;
145+ }
146+ if ( deletedStaleCount > 0 ) {
147+ completedParts . push (
148+ `removed ${ deletedStaleCount } stale Git record${ deletedStaleCount === 1 ? "" : "s" } ` ,
149+ ) ;
112150 }
151+ if ( skippedCount > 0 ) {
152+ completedParts . push (
153+ `skipped ${ skippedCount } candidate${ skippedCount === 1 ? "" : "s" } still linked to thread${ skippedCount === 1 ? "" : "s" } ` ,
154+ ) ;
155+ }
156+
157+ toastManager . add ( {
158+ type : "success" ,
159+ title : "Cleanup complete" ,
160+ description : `${ completedParts . join ( "; " ) } .` ,
161+ } ) ;
113162 } catch ( error ) {
163+ const completedParts : string [ ] = [ ] ;
164+ if ( deletedOnDiskCount > 0 ) {
165+ completedParts . push (
166+ `Deleted ${ deletedOnDiskCount } worktree${ deletedOnDiskCount === 1 ? "" : "s" } ` ,
167+ ) ;
168+ }
169+ if ( deletedStaleCount > 0 ) {
170+ completedParts . push (
171+ `removed ${ deletedStaleCount } stale Git record${ deletedStaleCount === 1 ? "" : "s" } ` ,
172+ ) ;
173+ }
174+
114175 toastManager . add ( {
115176 type : "error" ,
116- title : candidate . pathExists
117- ? "Could not delete worktree"
118- : "Could not prune worktree record" ,
119- description : error instanceof Error ? error . message : "Unknown error." ,
177+ title : "Cleanup stopped before finishing" ,
178+ description : `${ completedParts . length > 0 ? `${ completedParts . join ( "; " ) } before the failure. ` : "" } ${ error instanceof Error ? error . message : "Unknown error." } ` ,
120179 } ) ;
180+ } finally {
181+ setIsDeletingAll ( false ) ;
121182 }
122183 } ;
123184
@@ -128,7 +189,7 @@ export function WorktreeCleanupDialog() {
128189 < DialogTitle > Merged worktree cleanup</ DialogTitle >
129190 < DialogDescription >
130191 Review worktrees whose pull requests are already merged. Delete the worktree if it is
131- still on disk, or prune the stale Git record if it is already missing.
192+ still on disk, or remove the stale Git record if it is already missing.
132193 </ DialogDescription >
133194 </ DialogHeader >
134195 < DialogPanel className = "px-4 pb-4 sm:px-6" >
@@ -161,13 +222,12 @@ export function WorktreeCleanupDialog() {
161222 ) : (
162223 < ScrollArea className = "max-h-[60vh] pr-1" scrollbarGutter >
163224 < div className = "space-y-3" >
164- { candidates . map ( ( candidate , index ) => {
165- const usageCount = resolveWorktreeUsageCount ( candidate , threadWorktreePaths ) ;
225+ { candidateStates . map ( ( candidateState , index ) => {
226+ const { candidate, canDelete , usageCount } = candidateState ;
166227 const displayPath = formatWorktreePathForDisplay ( candidate . path ) ;
167- const canDelete = usageCount === 0 ;
168228 const actionLabel = candidate . pathExists
169229 ? "Delete worktree"
170- : "Prune stale records " ;
230+ : "Delete stale record " ;
171231
172232 return (
173233 < Card
@@ -233,7 +293,7 @@ export function WorktreeCleanupDialog() {
233293 variant = "destructive-outline"
234294 size = "sm"
235295 disabled = { ! canDelete || isBusy }
236- onClick = { ( ) => void handleRemoveCandidate ( candidate ) }
296+ onClick = { ( ) => void handleRemoveCandidate ( candidateState ) }
237297 >
238298 { isBusy ? (
239299 < LoaderCircleIcon className = "size-3.5 animate-spin" />
@@ -244,7 +304,9 @@ export function WorktreeCleanupDialog() {
244304 </ Button >
245305 </ div >
246306 </ div >
247- { index < candidates . length - 1 ? < Separator className = "mt-4" /> : null }
307+ { index < candidateStates . length - 1 ? (
308+ < Separator className = "mt-4" />
309+ ) : null }
248310 </ CardContent >
249311 </ Card >
250312 ) ;
@@ -256,22 +318,22 @@ export function WorktreeCleanupDialog() {
256318 < DialogFooter variant = "bare" >
257319 < div className = "flex w-full items-center justify-between gap-3" >
258320 < div className = "text-xs text-muted-foreground" >
259- { candidates . length } candidate{ candidates . length === 1 ? "" : "s" } found
321+ { candidateStates . length } candidate{ candidateStates . length === 1 ? "" : "s" } found
260322 </ div >
261323 < div className = "flex items-center gap-2" >
262- { hasStaleCandidates ? (
324+ { actionableCandidateStates . length > 0 ? (
263325 < Button
264326 variant = "destructive-outline"
265327 size = "sm"
266328 disabled = { isBusy }
267- onClick = { ( ) => void handlePruneAllStale ( ) }
329+ onClick = { ( ) => void handleDeleteAll ( ) }
268330 >
269- { pruneWorktreesMutation . isPending ? (
331+ { isDeletingAll ? (
270332 < LoaderCircleIcon className = "size-3.5 animate-spin" />
271333 ) : (
272334 < Trash2Icon className = "size-3.5" />
273335 ) }
274- Prune all stale ( { staleCandidates . length } )
336+ Delete all ( { actionableCandidateStates . length } )
275337 </ Button >
276338 ) : null }
277339 < Button variant = "outline" size = "sm" onClick = { handleClose } >
0 commit comments