From 9f4d077077c704f855f9082ff780d06170c7c23d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 18 May 2026 14:03:14 -0700 Subject: [PATCH] Polish inactive workspace deletion copy (#2264) Co-authored-by: Orca --- .../status-bar/ResourceUsageStatusSegment.tsx | 2 +- .../WorkspaceCleanupDialog.tsx | 186 ++++++++++++++---- 2 files changed, 154 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 17242f30e..853ba4ae8 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -1388,7 +1388,7 @@ export function ResourceUsageStatusSegment({ className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60" > - delete inactive workspaces ({oldWorkspaceCount}) + Delete inactive workspaces ({oldWorkspaceCount}) >({}) + const [repoSelection, setRepoSelection] = useState>(() => new Set()) + const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) + const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) useEffect(() => { if (open) { @@ -197,7 +202,29 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { } }, [open, scanWorkspaceCleanup]) + useEffect(() => { + if (!open) { + return + } + setRepoSelection(new Set(eligibleRepoIds)) + }, [eligibleRepoIds, open]) + const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) + const effectiveRepoSelection = useMemo>(() => { + if (repoSelection.size > 0 || eligibleRepoIds.length === 0) { + return repoSelection + } + return new Set(eligibleRepoIds) + }, [eligibleRepoIds, repoSelection]) + const filteredCandidates = useMemo(() => { + if ( + effectiveRepoSelection.size === 0 || + effectiveRepoSelection.size === eligibleRepoIds.length + ) { + return candidates + } + return candidates.filter((candidate) => effectiveRepoSelection.has(candidate.repoId)) + }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) useEffect(() => { if (!open || !scan) { @@ -214,15 +241,15 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }, [open, scan, scan?.scannedAt, candidates]) const visibleCandidates = useMemo(() => { - const rows = candidates.filter((candidate) => !candidate.blockers.includes('dismissed')) + const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) return [...rows].sort(compareCleanupCandidates) - }, [candidates]) + }, [filteredCandidates]) const hiddenCandidates = useMemo( () => - candidates + filteredCandidates .filter((candidate) => candidate.blockers.includes('dismissed')) .sort(compareCleanupCandidates), - [candidates] + [filteredCandidates] ) const groups = useMemo( () => ({ @@ -233,31 +260,32 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [visibleCandidates] ) const selectedCandidates = useMemo(() => { - const byId = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) + const byId = new Map(filteredCandidates.map((candidate) => [candidate.worktreeId, candidate])) return [...selectedIds] .map((id) => byId.get(id)) .filter( (candidate): candidate is WorkspaceCleanupCandidate => candidate != null && canQueueWorkspaceCleanupCandidate(candidate) ) - }, [candidates, selectedIds]) + }, [filteredCandidates, selectedIds]) - const hiddenByKeepCount = candidates.filter((candidate) => + const hiddenByKeepCount = filteredCandidates.filter((candidate) => candidate.blockers.includes('dismissed') ).length const repoNameById = useMemo( () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])), [repos] ) + const selectedScanErrors = useMemo( + () => (scan?.errors ?? []).filter((error) => effectiveRepoSelection.has(error.repoId)), + [effectiveRepoSelection, scan?.errors] + ) const scanNoticeMessage = useMemo( - () => formatScanNoticeMessage(scan?.errors ?? [], repoNameById), - [repoNameById, scan?.errors] + () => formatScanNoticeMessage(selectedScanErrors, repoNameById), + [repoNameById, selectedScanErrors] ) const readyCount = groups.ready.length - const oldCandidateCount = useMemo( - () => candidates.filter(isOldWorkspaceCandidate).length, - [candidates] - ) + const hasAnyCandidates = candidates.length > 0 const initialLoading = loading && !scan const activeRows = activeView === 'hidden' ? hiddenCandidates : groups[activeView] const activeQueueableRows = useMemo( @@ -452,7 +480,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { Checking inactive workspaces - ) : oldCandidateCount > 0 || hiddenByKeepCount > 0 ? ( + ) : hasAnyCandidates ? (
@@ -466,6 +494,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) : null}
+ {eligibleRepos.length > 1 ? ( +
+ setRepoSelection(new Set(next))} + onSelectAll={() => setRepoSelection(new Set(eligibleRepoIds))} + triggerClassName="h-8 w-full rounded-md border border-border/60 bg-background px-2 text-xs font-medium shadow-xs hover:bg-accent/60" + /> +
+ ) : null}
@@ -501,7 +540,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }} onViewChange={setActiveView} /> -
+
{activeView !== 'hidden' && activeQueueableRows.length > 0 ? ( @@ -556,7 +595,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { {!loading && scan && candidates.length === 0 && scanNoticeMessage ? ( ) : null} - {!loading && scan && candidates.length > 0 && visibleCandidates.length === 0 ? ( + {!loading && + scan && + candidates.length > 0 && + filteredCandidates.length === 0 ? ( + setRepoSelection(new Set(eligibleRepoIds))} + /> + ) : null} + {!loading && + scan && + filteredCandidates.length > 0 && + visibleCandidates.length === 0 ? ( ) : ( setConfirming(false)} onConfirm={() => void confirmRemove()} @@ -895,32 +947,51 @@ function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | nu } function ConfirmRemove({ - count, + candidates, removing, onCancel, onConfirm }: { - count: number + candidates: WorkspaceCleanupCandidate[] removing: boolean onCancel: () => void onConfirm: () => void }): React.JSX.Element { + const count = candidates.length + const noun = count === 1 ? 'workspace' : 'workspaces' return ( <> - - Remove {count} workspace{count === 1 ? '' : 's'}? - - - Removing a workspace deletes its working tree folder, local Orca metadata, terminal - history, browser workspace state, and the local branch when the existing git deletion path - decides that branch is no longer used. - +
+
+ +
+
+ + Delete {count} {noun}? + + + This permanently deletes their local files. You can't undo this. + +
+
-
- Cleanup rechecks each selected workspace before deletion. Suggested rows use the normal - git-safe removal path; not suggested rows may need forced worktree removal and are reported - if they cannot be removed. +
+
+
+ {count} {noun} to delete +
+
Sorted by oldest activity
+
+ + {candidates.map((candidate, index) => ( + + ))} +
) } +function ConfirmRemoveRow({ + candidate, + last +}: { + candidate: WorkspaceCleanupCandidate + last: boolean +}): React.JSX.Element { + const dirtyLabel = getDirtyGitLabel(candidate) + const branchDiffersFromName = candidate.branch !== candidate.displayName + return ( +
+
+ {candidate.displayName} + + Last active {formatRelativeTime(candidate.lastActivityAt)} + + {dirtyLabel ? {dirtyLabel} : null} +
+
+ {candidate.repoName} + {branchDiffersFromName ? ( + <> + + {candidate.branch} + + ) : null} +
+
+ {candidate.path} +
+
+ ) +} + +function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return `${candidate.git.upstreamAhead} unpushed commit${ + candidate.git.upstreamAhead === 1 ? '' : 's' + }` + } + if (candidate.git.clean === false) { + return 'Uncommitted changes' + } + if (candidate.git.clean == null) { + return 'Git status unknown' + } + return null +} + function SkeletonRows(): React.JSX.Element { return (