Improve Space scan and delete states (#2025)

This commit is contained in:
Neil 2026-05-15 19:35:52 -07:00 committed by GitHub
parent 5e277cefcf
commit 8ec8044d14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 208 additions and 39 deletions

View File

@ -661,6 +661,9 @@ export function ResourceUsageStatusSegment({
const setActiveView = useAppStore((s) => s.setActiveView)
const openModal = useAppStore((s) => s.openModal)
const openSpacePage = useAppStore((s) => s.openSpacePage)
const activeView = useAppStore((s) => s.activeView)
const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
const repos = useAppStore((s) => s.repos)
const allWorktrees = useAllWorktrees()
const activeRuntimeEnvironmentId = useAppStore(
@ -677,6 +680,9 @@ export function ResourceUsageStatusSegment({
const [sessionsError, setSessionsError] = useState(false)
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
const [spaceScanReady, setSpaceScanReady] = useState(false)
const previousSpaceScanningRef = useRef(workspaceSpaceScanning)
const lastSeenSpaceScanAtRef = useRef<number | null>(workspaceSpaceScannedAt)
// Why: this segment only understands the local Electron PTY/resource daemon.
// While a runtime server is active, hiding local samples avoids showing or
// killing sessions from the wrong machine.
@ -713,6 +719,28 @@ export function ResourceUsageStatusSegment({
}
})
// Why: Space scans can finish after the user backs out of the full page or
// closes this popover; the status-bar trigger becomes the handoff point.
useEffect(() => {
const scannedAt = workspaceSpaceScannedAt
const wasScanning = previousSpaceScanningRef.current
const scanCompleted =
wasScanning &&
!workspaceSpaceScanning &&
scannedAt !== null &&
scannedAt !== lastSeenSpaceScanAtRef.current
if (scanCompleted) {
lastSeenSpaceScanAtRef.current = scannedAt
setSpaceScanReady(!open && activeView !== 'space')
} else if (spaceScanReady && (open || activeView === 'space')) {
setSpaceScanReady(false)
lastSeenSpaceScanAtRef.current = scannedAt
}
previousSpaceScanningRef.current = workspaceSpaceScanning
}, [activeView, open, spaceScanReady, workspaceSpaceScannedAt, workspaceSpaceScanning])
// Poll memory + sessions when popover is open. Sessions also poll in the
// background at a slower rate so the badge count stays reasonably fresh
// without keeping the Memory IPC hot.
@ -1043,9 +1071,17 @@ export function ResourceUsageStatusSegment({
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label="Resource manager"
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label={
spaceScanReady ? 'Resource manager, Space scan ready' : 'Resource manager'
}
>
{spaceScanReady ? (
<span
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
aria-hidden="true"
/>
) : null}
<MemoryStick className="size-3 text-muted-foreground" />
{!iconOnly && (
<>
@ -1074,8 +1110,13 @@ export function ResourceUsageStatusSegment({
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Resource Manager {memBadgeLabel} · {sessions.length} session
{sessions.length === 1 ? '' : 's'}
<div className="space-y-0.5">
<div>
Resource Manager {memBadgeLabel} · {sessions.length} session
{sessions.length === 1 ? '' : 's'}
</div>
{spaceScanReady ? <div className="text-primary">Space scan ready</div> : null}
</div>
</TooltipContent>
</Tooltip>

View File

@ -50,6 +50,7 @@ import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout'
import {
filterWorkspaceSpaceRows,
getSelectedDeletableWorkspaceIds,
getVisibleDeletableWorkspaceIds,
sortWorkspaceSpaceRows,
type WorkspaceSpaceSortDirection,
type WorkspaceSpaceSortKey
@ -63,6 +64,12 @@ const TREEMAP_FILLS = [
'color-mix(in srgb, var(--chart-1) 38%, var(--card))'
]
type WorkspaceSpaceDeleteState = {
isDeleting: boolean
error: string | null
canForceDelete: boolean
}
function getTreemapFill(rect: TreemapRect, selected: boolean): string {
if (selected) {
return 'color-mix(in srgb, var(--ring) 40%, var(--card))'
@ -185,7 +192,28 @@ function SortIndicator({
return direction === 'asc' ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" />
}
function StatusBadge({ worktree }: { worktree: WorkspaceSpaceWorktree }): React.JSX.Element {
function StatusBadge({
worktree,
deleteState
}: {
worktree: WorkspaceSpaceWorktree
deleteState?: WorkspaceSpaceDeleteState
}): React.JSX.Element {
if (deleteState?.isDeleting) {
return (
<Badge variant="outline" className="gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Deleting
</Badge>
)
}
if (deleteState?.error) {
return (
<Badge variant="outline" className="border-destructive/30 text-destructive">
Failed
</Badge>
)
}
if (worktree.status !== 'ok') {
return (
<Badge variant="outline" className="border-destructive/30 text-destructive">
@ -451,23 +479,36 @@ function WorkspaceRow({
maxSize,
selected,
inspected,
deleteState,
onToggleSelected,
onInspect,
onDelete
onDelete,
onForceDelete
}: {
worktree: WorkspaceSpaceWorktree
maxSize: number
selected: boolean
inspected: boolean
deleteState?: WorkspaceSpaceDeleteState
onToggleSelected: () => void
onInspect: () => void
onDelete: () => void
onForceDelete: () => void
}): React.JSX.Element {
const canDelete = worktree.canDelete && worktree.status === 'ok'
const isDeleting = deleteState?.isDeleting ?? false
const deleteError = deleteState?.error ?? null
const canForceDelete = deleteState?.canForceDelete ?? false
const canDelete = worktree.canDelete && worktree.status === 'ok' && !isDeleting
const handleForceDelete = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault()
event.stopPropagation()
onForceDelete()
}
const row = (
<div
role="button"
tabIndex={0}
aria-busy={isDeleting}
onClick={onInspect}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
@ -478,11 +519,12 @@ function WorkspaceRow({
}}
className={cn(
'grid w-full cursor-pointer grid-cols-[1.75rem_minmax(0,1.35fr)_minmax(9rem,0.65fr)_8rem_6rem] items-center gap-3 border-b border-border/45 px-3 py-2.5 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
inspected && 'bg-accent/55'
inspected && 'bg-accent/55',
isDeleting && 'cursor-wait opacity-50 grayscale hover:bg-transparent'
)}
>
<CheckButton
checked={selected}
checked={canDelete && selected}
disabled={!canDelete}
label={`Select ${worktree.displayName}`}
onClick={onToggleSelected}
@ -503,6 +545,26 @@ function WorkspaceRow({
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{worktree.path}
</div>
{deleteError ? (
<div className="mt-2 flex min-w-0 items-start gap-2 rounded-md border border-destructive/35 bg-destructive/8 px-2 py-1.5 text-[11px] text-destructive">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 flex-1 break-words" title={deleteError}>
{deleteError}
</span>
{canForceDelete ? (
<Button
type="button"
variant="destructive"
size="xs"
onClick={handleForceDelete}
className="h-6 shrink-0 gap-1 px-2"
>
<Trash2 className="size-3" />
Force
</Button>
) : null}
</div>
) : null}
</div>
<div className="min-w-0 text-xs">
@ -520,7 +582,7 @@ function WorkspaceRow({
</div>
<div className="flex justify-end">
<StatusBadge worktree={worktree} />
<StatusBadge worktree={worktree} deleteState={deleteState} />
</div>
</div>
)
@ -550,6 +612,8 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace)
const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan)
const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees)
const removeWorktree = useAppStore((state) => state.removeWorktree)
const deleteStateByWorktreeId = useAppStore((state) => state.deleteStateByWorktreeId)
const [query, setQuery] = useState('')
const [onlyDeletable, setOnlyDeletable] = useState(false)
const [sortKey, setSortKey] = useState<WorkspaceSpaceSortKey>('size')
@ -569,6 +633,10 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
}, [cancelWorkspaceSpaceScan])
const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees])
const isWorktreeDeleting = useCallback(
(worktreeId: string): boolean => deleteStateByWorktreeId[worktreeId]?.isDeleting ?? false,
[deleteStateByWorktreeId]
)
const rows = useMemo(
() =>
@ -589,16 +657,16 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
null
const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0)
const selectedDeletableIds = useMemo(
() => getSelectedDeletableWorkspaceIds(rows, selectedIds),
[rows, selectedIds]
() => getSelectedDeletableWorkspaceIds(rows, selectedIds, isWorktreeDeleting),
[isWorktreeDeleting, rows, selectedIds]
)
const selectedDeletableIdSet = useMemo(
() => new Set(selectedDeletableIds),
[selectedDeletableIds]
)
const visibleDeletableIds = useMemo(
() => rows.filter((row) => row.canDelete && row.status === 'ok').map((row) => row.worktreeId),
[rows]
() => getVisibleDeletableWorkspaceIds(rows, isWorktreeDeleting),
[isWorktreeDeleting, rows]
)
const allVisibleSelected =
visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id))
@ -686,6 +754,32 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
})
}
const handleDeletedWorktrees = useCallback(
(deletedIds: readonly string[]): void => {
if (deletedIds.length === 0) {
return
}
removeWorkspaceSpaceWorktrees(deletedIds)
setInspectedWorktreeId((current) =>
current && deletedIds.includes(current) ? null : current
)
setTreemapZoomWorktreeId((current) =>
current && deletedIds.includes(current) ? null : current
)
setSelectedIds((current) => {
const next = new Set(current)
for (const id of deletedIds) {
next.delete(id)
}
return next
})
toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', {
description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.`
})
},
[removeWorkspaceSpaceWorktrees]
)
const deleteWorktrees = useCallback(
(worktreeIds: readonly string[]): void => {
if (worktreeIds.length === 0) {
@ -693,31 +787,33 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
}
runWorktreeBatchDelete(worktreeIds, {
forceConfirm: true,
onDeleted: (deletedIds) => {
removeWorkspaceSpaceWorktrees(deletedIds)
setInspectedWorktreeId((current) =>
current && deletedIds.includes(current) ? null : current
)
setTreemapZoomWorktreeId((current) =>
current && deletedIds.includes(current) ? null : current
)
setSelectedIds((current) => {
if (deletedIds.length === 0) {
return current
}
const next = new Set(current)
for (const id of deletedIds) {
next.delete(id)
}
return next
})
toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', {
description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.`
})
}
onDeleted: handleDeletedWorktrees
})
},
[removeWorkspaceSpaceWorktrees]
[handleDeletedWorktrees]
)
const forceDeleteWorktree = useCallback(
(worktree: WorkspaceSpaceWorktree): void => {
// Why: Space keeps normal deletes non-force so uncommitted work is not
// discarded silently; a failed row gets this explicit recovery path.
void removeWorktree(worktree.worktreeId, true)
.then((result) => {
if (!result.ok) {
toast.error('Force delete failed', {
description: result.error
})
return
}
handleDeletedWorktrees([worktree.worktreeId])
})
.catch((error: unknown) => {
toast.error('Force delete failed', {
description: error instanceof Error ? error.message : String(error)
})
})
},
[handleDeletedWorktrees, removeWorktree]
)
const deleteSelected = (): void => {
@ -975,9 +1071,11 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
maxSize={maxSize}
selected={selectedIds.has(worktree.worktreeId)}
inspected={inspectedWorktree?.worktreeId === worktree.worktreeId}
deleteState={deleteStateByWorktreeId[worktree.worktreeId]}
onToggleSelected={() => toggleSelection(worktree.worktreeId)}
onInspect={() => setInspectedWorktreeId(worktree.worktreeId)}
onDelete={() => deleteWorktrees([worktree.worktreeId])}
onForceDelete={() => forceDeleteWorktree(worktree)}
/>
))
)}

View File

@ -3,6 +3,7 @@ import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-
import {
filterWorkspaceSpaceRows,
getSelectedDeletableWorkspaceIds,
getVisibleDeletableWorkspaceIds,
sortWorkspaceSpaceRows
} from './workspace-space-presentation'
@ -76,4 +77,17 @@ describe('workspace space presentation helpers', () => {
'ok'
])
})
it('excludes rows that are already deleting from delete actions', () => {
const rows = [
row({ worktreeId: 'idle', canDelete: true, status: 'ok' }),
row({ worktreeId: 'deleting', canDelete: true, status: 'ok' })
]
const isDeleting = (worktreeId: string): boolean => worktreeId === 'deleting'
expect(getVisibleDeletableWorkspaceIds(rows, isDeleting)).toEqual(['idle'])
expect(
getSelectedDeletableWorkspaceIds(rows, new Set(['idle', 'deleting']), isDeleting)
).toEqual(['idle'])
})
})

View File

@ -70,9 +70,25 @@ export function filterWorkspaceSpaceRows(
export function getSelectedDeletableWorkspaceIds(
rows: readonly WorkspaceSpaceWorktree[],
selectedIds: ReadonlySet<string>
selectedIds: ReadonlySet<string>,
isWorktreeDeleting: (worktreeId: string) => boolean = () => false
): string[] {
return rows
.filter((row) => row.canDelete && row.status === 'ok' && selectedIds.has(row.worktreeId))
.filter(
(row) =>
row.canDelete &&
row.status === 'ok' &&
selectedIds.has(row.worktreeId) &&
!isWorktreeDeleting(row.worktreeId)
)
.map((row) => row.worktreeId)
}
export function getVisibleDeletableWorkspaceIds(
rows: readonly WorkspaceSpaceWorktree[],
isWorktreeDeleting: (worktreeId: string) => boolean = () => false
): string[] {
return rows
.filter((row) => row.canDelete && row.status === 'ok' && !isWorktreeDeleting(row.worktreeId))
.map((row) => row.worktreeId)
}