diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3275e33987a..f9aec238d5b 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -264,7 +264,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts -inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx new file mode 100644 index 00000000000..f4b9e0517d1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -0,0 +1,372 @@ +// @vitest-environment happy-dom +import type { ReactNode, Ref } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { createTestStore, seedStore } from '../../store/slices/store-test-helpers' +import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse' +import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness' +import WorkspaceCleanupDialog from './WorkspaceCleanupDialog' + +const holders = vi.hoisted(() => ({ + store: null as unknown as { (selector: (s: unknown) => unknown): unknown } & { + getState: () => Record + setState: (partial: unknown) => void + subscribe: (listener: (state: unknown, previous: unknown) => void) => () => void + }, + infoToasts: [] as string[], + rig: null as null | { + resolvers: ((result: { scannedAt: number; candidates: never[]; errors: never[] }) => void)[] + } +})) + +vi.mock('@/store', () => { + const useAppStore = (selector: (s: unknown) => unknown): unknown => holders.store(selector) + useAppStore.getState = () => holders.store.getState() + useAppStore.setState = (partial: unknown) => holders.store.setState(partial) + useAppStore.subscribe = (listener: (state: unknown, previous: unknown) => void) => + holders.store.subscribe(listener) + return { useAppStore } +}) + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: (message: unknown) => holders.infoToasts.push(String(message)) + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children, viewportRef }: { children: ReactNode; viewportRef?: Ref }) => ( +
}>{children}
+ ), + ScrollBar: () => null +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: () => null +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: () => null, + DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
+})) + +const CACHED_AT = NOW - 2 * 60 * 60 * 1000 + +type ScanRig = { + scan: ReturnType + progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] + resolvers: ((result: WorkspaceCleanupScanResult) => void)[] +} + +function installApi(cachedScan: WorkspaceCleanupScanResult | null): ScanRig { + const rig: ScanRig = { scan: vi.fn(), progressCallbacks: [], resolvers: [] } + rig.scan.mockImplementation( + ( + args?: { worktreeId?: string }, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => { + if (args?.worktreeId) { + return Promise.resolve({ + scannedAt: NOW, + candidates: [makeCandidate({ worktreeId: args.worktreeId })], + errors: [] + }) + } + if (onProgress) { + rig.progressCallbacks.push(onProgress) + } + return new Promise((resolve) => { + rig.resolvers.push(resolve) + }) + } + ) + ;(window as unknown as { api: unknown }).api = { + workspaceCleanup: { + scan: rig.scan, + getCachedScan: vi.fn().mockResolvedValue(cachedScan), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ hasKillableProcesses: false }) + }, + workspaceSpace: { + getCachedAnalysis: vi.fn().mockResolvedValue(null), + analyze: vi.fn(), + cancel: vi.fn().mockResolvedValue(false), + onProgress: vi.fn().mockReturnValue(() => undefined) + }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + holders.rig = rig as unknown as typeof holders.rig + return rig +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +async function renderDialog(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openDialog(): Promise { + await act(async () => { + holders.store.setState({ activeModal: 'workspace-cleanup' }) + }) + await flush() +} + +function rowNames(): string[] { + return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map( + (node) => node.textContent ?? '' + ) +} + +function rowCheckbox(name: string): HTMLElement | null { + return container?.querySelector(`[aria-label="Select ${name}"]`) ?? null +} + +function cachedFleet(): WorkspaceCleanupScanResult { + return { + scannedAt: CACHED_AT, + candidates: [ + makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }), + makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' }) + ], + errors: [] + } +} + +function progressTick( + rig: ScanRig, + progress: Partial & Pick +): void { + rig.progressCallbacks.at(-1)?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: progress.candidates.length, + totalWorktreeCount: 3, + errors: [], + candidateMode: 'append', + ...progress + }) +} + +describe('WorkspaceCleanupDialog stale-while-revalidate', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + // Why Date-only fake: relative "Updated Xh ago" labels need a pinned clock, + // but flush() depends on real setTimeout. + vi.useFakeTimers({ toFake: ['Date'], now: NOW }) + const store = createTestStore() + seedStore(store, {}) + holders.store = store as unknown as typeof holders.store + holders.infoToasts = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + if (root) { + act(() => root?.unmount()) + } + // Why: broad-scan dedupe lives in slice module state; an unresolved scan + // left by one case would silently join into the next case's scan call. + for (const resolve of holders.rig?.resolvers ?? []) { + resolve({ scannedAt: NOW, candidates: [], errors: [] }) + } + holders.rig = null + await new Promise((resolve) => setTimeout(resolve, 0)) + container?.remove() + root = null + container = null + resetWorkspaceCleanupBrowsePersistTimer() + vi.useRealTimers() + }) + + it('renders the cached snapshot immediately and kicks one background rescan', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + // Full list from cache while the rescan is still streaming (unresolved). + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta'])) + expect(rig.scan).toHaveBeenCalledTimes(1) + expect(container?.textContent).toContain('Updated 2h ago') + expect(container?.textContent).toContain('Refreshing') + }) + + it('reconciles streamed rows into the cached list without clearing it', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + progressTick(rig, { + candidates: [ + makeCandidate({ + worktreeId: 'repo1::/tmp/alpha', + displayName: 'alpha', + fingerprint: 'fingerprint-alpha-2' + }), + makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }) + ], + scannedWorktreeCount: 2 + }) + await flush() + + // beta was not re-reported yet; it must still be on screen mid-refresh. + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])) + }) + + it('keeps search and selection interactive while rows stream in', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + + const search = container?.querySelector('[aria-label="Search workspaces"]') + expect(search?.disabled).toBe(false) + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + await act(async () => { + setter?.call(search, 'beta') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })], + scannedWorktreeCount: 1 + }) + await flush() + + // Filter applied mid-stream: only beta visible even as new rows arrive. + expect(rowNames()).toEqual(['beta']) + + await act(async () => { + setter?.call(search, '') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma'])) + + // Rows stay selectable during the stream. + const checkbox = rowCheckbox('alpha') + expect(checkbox).not.toBeNull() + await act(async () => { + checkbox?.click() + }) + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + }) + + it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + expect(rig.scan).toHaveBeenCalledTimes(1) + + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })], + scannedWorktreeCount: 1 + }) + await flush() + + // Close at 40% and reopen: the same scan keeps streaming into the store. + await act(async () => { + holders.store.setState({ activeModal: null }) + }) + await openDialog() + expect(rig.scan).toHaveBeenCalledTimes(1) + + progressTick(rig, { + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })], + scannedWorktreeCount: 2 + }) + await flush() + await act(async () => { + rig.resolvers.at(-1)?.({ + scannedAt: NOW, + candidates: [ + ...cachedFleet().candidates, + makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }), + makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' }) + ], + errors: [] + }) + }) + await flush() + + expect(rig.scan).toHaveBeenCalledTimes(1) + expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta'])) + }) + + it('keeps selections across a refresh and surfaces vanished selections', async () => { + const rig = installApi(cachedFleet()) + await renderDialog() + await openDialog() + await act(async () => { + rig.resolvers.at(-1)?.(cachedFleet()) + }) + await flush() + + // Both rows are default-selected suggestions after the first settle. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + container?.querySelector('[aria-label="Refresh"]')?.click() + }) + await flush() + expect(rig.scan).toHaveBeenCalledTimes(2) + // Selection survives while the refresh streams. + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + + await act(async () => { + rig.resolvers.at(-1)?.({ + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' })], + errors: [] + }) + }) + await flush() + + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + expect(rowNames()).not.toContain('beta') + expect(holders.infoToasts).toContain('1 selected workspace no longer exists.') + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 0c870ee4485..f45eaf8df49 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -1,205 +1,54 @@ -/* eslint-disable max-lines -- Why: the cleanup dialog keeps scan status, - filters, row actions, localized review copy, and force-aware confirmation - in one modal flow. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - AlertTriangle, - Info, - Loader2, - RefreshCcw, - Search, - SlidersHorizontal, - Trash2, - X -} from 'lucide-react' +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Input } from '@/components/ui/input' -import { Progress } from '@/components/ui/progress' -import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { isGitRepoKind } from '../../../../shared/repo-kind' +import { translate } from '@/i18n/i18n' import { canQueueWorkspaceCleanupCandidate, - type WorkspaceCleanupCandidate, - type WorkspaceCleanupScanError, - type WorkspaceCleanupScanProgress + type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupBrowseToolbar } from './workspace-cleanup-browse-toolbar' +import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove' +import { WorkspaceCleanupDialogHeader } from './workspace-cleanup-dialog-header' import { - filterWorkspaceCleanupCandidates, - getWorkspaceCleanupReviewInfo, - sortWorkspaceCleanupCandidates, - type WorkspaceCleanupContextFilter, - type WorkspaceCleanupFilters, - type WorkspaceCleanupGitFilter, - type WorkspaceCleanupReviewFilter, - type WorkspaceCleanupReviewInfo, - type WorkspaceCleanupSortDirection, - type WorkspaceCleanupSortKey, - type WorkspaceCleanupTimeFilter -} from './workspace-cleanup-presentation' + WorkspaceCleanupInitialScanBanner, + WorkspaceCleanupNotice, + WorkspaceCleanupSkeletonRows +} from './workspace-cleanup-dialog-notices' +import { WorkspaceCleanupRowList } from './workspace-cleanup-row-list' +import { formatWorkspaceCleanupScanNotice } from './workspace-cleanup-scan-notice' import { - startWorkspaceCleanupBackgroundRemoval, - type WorkspaceCleanupRemovalProgress -} from './workspace-cleanup-background-removal' -import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' -import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' -import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' -import { - getCandidateStatus, - getContextPillLabel, - getDirtyGitLabel, - getReviewPillTone, - shouldShowGitMetadataChip -} from './workspace-cleanup-candidate-row-data' -import { StatusPill } from './workspace-cleanup-status-pill' -import { - resolveWorkspaceCleanupActiveView, - type WorkspaceCleanupView, - type WorkspaceCleanupViewCounts -} from './workspace-cleanup-view-selection' -import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' -import { translate } from '@/i18n/i18n' - -const DEFAULT_FILTERS: WorkspaceCleanupFilters = { - query: '', - time: 'all', - review: 'all', - git: 'all', - context: 'all' -} - -const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { - hasReview: false, - label: null, - state: null, - provider: null, - title: null -} - -function formatRelativeTime(timestamp: number): string { - if (!timestamp) { - return 'Never' - } - const deltaMs = Date.now() - timestamp - if (deltaMs < 60_000) { - return 'Just now' - } - const minutes = Math.floor(deltaMs / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 48) { - return `${hours}h ago` - } - return `${Math.floor(hours / 24)}d ago` -} - -function isDisconnectedRemoteScanError(message: string): boolean { - return ( - message === 'SSH provider is unavailable.' || - message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' - ) -} - -function formatScanNoticeMessage( - errors: WorkspaceCleanupScanError[], - repoNameById: Map -): string | null { - const visibleErrors = errors.filter( - (error) => !isDisconnectedRemoteScanError(error.message ?? '') - ) - if (visibleErrors.length === 0) { - return null - } - if (visibleErrors.length === 1) { - const error = visibleErrors[0] - const repoName = formatScanErrorRepoName(error, repoNameById) - return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some inactive workspaces may be missing. Refresh to try again.` - } - const repoNames = visibleErrors - .slice(0, 3) - .map((error) => formatScanErrorRepoName(error, repoNameById)) - .join(', ') - const moreCount = visibleErrors.length - 3 - const suffix = moreCount > 0 ? `, +${moreCount} more` : '' - return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some inactive workspaces may be missing. Refresh to try again.` -} - -function formatScanErrorRepoName( - error: Partial, - repoNameById: Map -): string { - const repoName = error.repoName?.trim() - if (repoName) { - return repoName - } - const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' - return fallback || 'a repository' -} - -function formatScanErrorReason(message: string | undefined): string { - if (!message) { - return 'Git could not list worktrees' - } - if (message === 'Could not scan workspace cleanup for this repository.') { - return 'Git could not list worktrees' - } - return message.replace(/\.$/, '') -} + applyWorkspaceCleanupGitEvidence, + needsWorkspaceCleanupGitEvidence +} from './workspace-cleanup-git-evidence' +import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' +import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' +import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' +import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal' +import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' +import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle' +/** + * One flat list of every workspace Orca knows about. Presets are named filter + * states rather than tabs, so nothing is hidden behind an Orca-decided bucket. + */ export default function WorkspaceCleanupDialog(): React.JSX.Element { const activeModal = useAppStore((s) => s.activeModal) - const openModal = useAppStore((s) => s.openModal) const closeModal = useAppStore((s) => s.closeModal) const scan = useAppStore((s) => s.workspaceCleanupScan) const scanProgress = useAppStore((s) => s.workspaceCleanupProgress) const loading = useAppStore((s) => s.workspaceCleanupLoading) const error = useAppStore((s) => s.workspaceCleanupError) const repos = useAppStore((s) => s.repos) - const reviewStateInputs = useAppStore( - useShallow((s) => ({ - worktreesByRepo: s.worktreesByRepo, - hostedReviewCache: s.hostedReviewCache, - repos: s.repos, - settings: s.settings - })) - ) - const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed) const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) - const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals) - const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) - const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) - const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace) + const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) const deletionPhaseByWorktreeId = useAppStore( useShallow((s) => { const phases: Record = {} @@ -217,406 +66,16 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) const open = activeModal === 'workspace-cleanup' - const openRef = useRef(open) + const browse = useWorkspaceCleanupBrowseState() const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) - const [activeView, setActiveView] = useState('ready') - const [confirming, setConfirming] = useState(false) - const [confirmCandidates, setConfirmCandidates] = useState([]) - const [removalProgress, setRemovalProgress] = useState( - null - ) - // Why: `removalProgress` only arrives once the batch reports, so rendering - // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. - const [removalInFlight, setRemovalInFlight] = useState(false) - const [rowFailures, setRowFailures] = useState>({}) - const [repoSelection, setRepoSelection] = useState>(() => new Set()) - const [filters, setFilters] = useState(DEFAULT_FILTERS) - const [sortKey, setSortKey] = useState('activity') - const [sortDirection, setSortDirection] = useState('asc') const selectedDefaultsScanAtRef = useRef(null) - const autoScanAttemptedForOpenRef = useRef(false) - const latestReadyToastScanAtRef = useRef(null) - const wasOpenRef = useRef(false) - const removalInFlightRef = useRef(false) - // Why: the dialog stays mounted across cleanup runs, so late settlements from - // an earlier batch must not mutate a newer batch's row/selection state. - const removalBatchIdRef = useRef(0) + // Why: a refresh completing mid-open must not clobber a selection the user + // already made (or was given) for this open — defaults apply at most once. + const defaultsAppliedForOpenRef = useRef(false) + const selectionTouchedRef = useRef(false) const mountedRef = useMountedRef() - const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) - const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) - - useEffect(() => { - openRef.current = open - }, [open]) - - const startWorkspaceCleanupScan = useCallback( - (options: { notifyWhenReady?: boolean } = {}) => { - setRowFailures({}) - void scanWorkspaceCleanup() - .then((result) => { - if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { - return - } - if (latestReadyToastScanAtRef.current === result.scannedAt) { - return - } - latestReadyToastScanAtRef.current = result.scannedAt - const suggestedCount = result.candidates.filter( - (candidate) => candidate.selectedByDefault - ).length - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', - 'Inactive workspace scan ready' - ), - { - description: formatWorkspaceCleanupReadyToastDescription( - result.candidates.length, - suggestedCount - ), - action: { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', - 'Review' - ), - onClick: () => openModal('workspace-cleanup') - } - } - ) - }) - .catch((err: unknown) => { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', - 'Workspace cleanup scan failed' - ), - { - description: err instanceof Error ? err.message : String(err) - } - ) - } - }) - }, - [mountedRef, openModal, scanWorkspaceCleanup] - ) - - useEffect(() => { - if (!open) { - wasOpenRef.current = false - autoScanAttemptedForOpenRef.current = false - return - } - if (!wasOpenRef.current) { - wasOpenRef.current = true - autoScanAttemptedForOpenRef.current = false - if (!removalInFlightRef.current) { - setActiveView('ready') - setConfirming(false) - setRowFailures({}) - setFilters(DEFAULT_FILTERS) - setSortKey('activity') - setSortDirection('asc') - setSelectedIds(new Set()) - } - } - // Why: reopening mid-batch keeps the deletion progress view; a broad scan - // started here would be discarded by the removal's scan invalidation, so - // skip it while a removal batch is running (matches the reset guard above). - if (!loading && !autoScanAttemptedForOpenRef.current && !removalInFlightRef.current) { - autoScanAttemptedForOpenRef.current = true - startWorkspaceCleanupScan({ notifyWhenReady: true }) - } - }, [loading, open, startWorkspaceCleanupScan]) - - useEffect(() => { - if (!open) { - return - } - setRepoSelection(new Set(eligibleRepoIds)) - }, [eligibleRepoIds, open]) - - const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) - const reviewInfoByWorktreeId = useMemo(() => { - const infos = new Map() - for (const candidate of candidates) { - infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, reviewStateInputs)) - } - return infos - }, [candidates, reviewStateInputs]) - const effectiveRepoSelection = useMemo>(() => { - if (repoSelection.size > 0 || eligibleRepoIds.length === 0) { - return repoSelection - } - return new Set(eligibleRepoIds) - }, [eligibleRepoIds, repoSelection]) - const selectedScanErrors = useMemo( - () => (scan?.errors ?? []).filter((error) => effectiveRepoSelection.has(error.repoId)), - [effectiveRepoSelection, scan?.errors] - ) - 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]) - // Why: the Resource Manager button counts from the renderer's activity record only, - // so it routinely disagrees with the scanned list. Reconcile it rather than leaving - // the user to wonder which number is real. - const estimatedInactiveCount = useMemo(() => { - if (!open) { - return null - } - return countEstimatedInactiveWorkspaces( - Object.values(reviewStateInputs.worktreesByRepo).flat(), - new Map(reviewStateInputs.repos.map((repo) => [repo.id, repo])), - Date.now() - ) - }, [open, reviewStateInputs]) - // Why: the reconciliation only holds for a complete scan; a failed or partial - // one would blame the difference on git-history checks instead of the error. - const estimateMismatchNotice = - !loading && - !error && - selectedScanErrors.length === 0 && - scan && - estimatedInactiveCount !== null && - estimatedInactiveCount !== candidates.length && - filteredCandidates.length === candidates.length - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.f637f63882', - "Resource Manager counts {{value0}}; this list found {{value1}}. That counter reads Orca's activity record alone, while this scan also checks each workspace's git history and skips disconnected remotes.", - { value0: estimatedInactiveCount, value1: candidates.length } - ) - : null - - useEffect(() => { - if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { - return - } - selectedDefaultsScanAtRef.current = scan.scannedAt - if (removalInFlightRef.current) { - return - } - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) - setConfirming(false) - setRowFailures({}) - }, [deletingWorktreeIds, loading, scan]) - - const visibleCandidates = useMemo(() => { - const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) - return sortWorkspaceCleanupCandidates(rows, 'activity', 'asc', reviewInfoByWorktreeId) - }, [filteredCandidates, reviewInfoByWorktreeId]) - const hiddenCandidates = useMemo( - () => - sortWorkspaceCleanupCandidates( - filteredCandidates.filter((candidate) => candidate.blockers.includes('dismissed')), - 'activity', - 'asc', - reviewInfoByWorktreeId - ), - [filteredCandidates, reviewInfoByWorktreeId] - ) - const groups = useMemo( - () => ({ - ready: visibleCandidates.filter((candidate) => candidate.tier === 'ready'), - review: visibleCandidates.filter((candidate) => candidate.tier === 'review'), - protected: visibleCandidates.filter((candidate) => candidate.tier === 'protected') - }), - [visibleCandidates] - ) - const cleanupViewCounts = useMemo( - () => ({ - ready: groups.ready.length, - review: groups.review.length, - protected: groups.protected.length, - hidden: hiddenCandidates.length - }), - [groups.protected.length, groups.ready.length, groups.review.length, hiddenCandidates.length] - ) - const resolvedActiveView = resolveWorkspaceCleanupActiveView({ - requestedView: activeView, - counts: cleanupViewCounts, - open, - loading, - hasScan: scan != null - }) - const repoNameById = useMemo( - () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])), - [repos] - ) - const scanNoticeMessage = useMemo( - () => formatScanNoticeMessage(selectedScanErrors, repoNameById), - [repoNameById, selectedScanErrors] - ) - const hasAnyCandidates = candidates.length > 0 - const initialLoading = loading && !hasAnyCandidates - const activeBaseRows = - resolvedActiveView === 'hidden' ? hiddenCandidates : groups[resolvedActiveView] - const activeRows = useMemo( - () => - sortWorkspaceCleanupCandidates( - filterWorkspaceCleanupCandidates( - activeBaseRows, - filters, - reviewInfoByWorktreeId, - scan?.scannedAt ?? Date.now() - ), - sortKey, - sortDirection, - reviewInfoByWorktreeId - ), - [activeBaseRows, filters, reviewInfoByWorktreeId, scan?.scannedAt, sortDirection, sortKey] - ) - const activeRowIds = useMemo( - () => new Set(activeRows.map((candidate) => candidate.worktreeId)), - [activeRows] - ) - const activeFilters = hasActiveWorkspaceCleanupFilters(filters) - const selectedCandidates = useMemo(() => { - const byId = new Map(activeRows.map((candidate) => [candidate.worktreeId, candidate])) - return [...selectedIds] - .map((id) => byId.get(id)) - .filter( - (candidate): candidate is WorkspaceCleanupCandidate => - candidate != null && - canQueueWorkspaceCleanupCandidate(candidate) && - !deletingWorktreeIds.has(candidate.worktreeId) - ) - }, [activeRows, deletingWorktreeIds, selectedIds]) - useEffect(() => { - if (!open || confirming) { - return - } - // Why: destructive selection must stay scoped to the rows the user can - // currently review after tier/filter changes. - setSelectedIds((current) => { - const next = new Set( - [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) - ) - return next.size === current.size ? current : next - }) - }, [activeRowIds, confirming, deletingWorktreeIds, open]) - - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen) { - closeModal() - } - }, - [closeModal] - ) - - const refresh = useCallback(() => { - startWorkspaceCleanupScan({ notifyWhenReady: true }) - }, [startWorkspaceCleanupScan]) - - const ignoreCandidate = useCallback( - (candidate: WorkspaceCleanupCandidate) => { - void dismissCandidates([candidate]) - .then(() => { - if (mountedRef.current) { - setSelectedIds((current) => { - const next = new Set(current) - next.delete(candidate.worktreeId) - return next - }) - } - }) - .catch((err: unknown) => { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', - 'Could not ignore cleanup suggestion' - ), - { - description: err instanceof Error ? err.message : String(err) - } - ) - } - }) - }, - [dismissCandidates, mountedRef] - ) - - const toggleExpandedRow = useCallback((worktreeId: string) => { - setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) - }, []) - - const toggleSelectedRow = useCallback((worktreeId: string) => { - setSelectedIds((current) => toggleSetMember(current, worktreeId)) - }, []) - - const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { - const nextCandidates = filterWorkspaceCleanupRemovalCandidates( - candidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (nextCandidates.length === 0) { - return - } - setConfirmCandidates(nextCandidates) - setConfirming(true) - }, []) - - // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow - // instances from re-rendering on scan stream-in and selection changes. - const handleRemoveRow = useCallback( - (candidate: WorkspaceCleanupCandidate) => { - if (loading || removalInFlightRef.current) { - return - } - setSelectedIds(new Set([candidate.worktreeId])) - openConfirmRemove([candidate]) - }, - [loading, openConfirmRemove] - ) - - const handleViewCandidate = useCallback( - (candidate: WorkspaceCleanupCandidate) => { - markCandidateViewed(candidate) - closeModal() - activateAndRevealWorktree(candidate.worktreeId) - }, - [closeModal, markCandidateViewed] - ) - - const cancelConfirmRemove = useCallback(() => { - if (removalProgress) { - closeModal() - return - } - setConfirming(false) - setConfirmCandidates([]) - }, [closeModal, removalProgress]) - - // Why: the header X reads as "leave this screen", not "abandon the dialog". The batch - // keeps running in the background either way, and the list shows each row's progress. - // Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here - // removalProgress stays set until the batch settles, so re-entry is still blocked. - const backToWorkspaceCleanupList = useCallback(() => { - setConfirming(false) - setConfirmCandidates([]) - }, []) - - const clearQueuedDeleteState = useCallback( - (worktreeId: string) => { - const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] - // Why: candidates that fail before removal starts would otherwise stay - // marked "Queued for deletion" in the sidebar; rows already in the - // 'deleting' phase or failed with an error keep their own state. - if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { - clearWorktreeDeleteState(worktreeId) - } - }, - [clearWorktreeDeleteState] - ) const deselectRemovedIds = useCallback((removedIds: readonly string[]) => { if (removedIds.length === 0) { @@ -630,398 +89,297 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { return next }) }, []) + const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal }) + // Why: the controller object is rebuilt every render; effects must depend on + // its stable members or they re-run on every keystroke. + const { removalInFlightRef, resetForReopen, resetRowFailures } = removal - const confirmRemove = useCallback(() => { - if (confirmCandidates.length === 0 || removalInFlightRef.current) { - return + const onFreshOpen = useCallback(() => { + defaultsAppliedForOpenRef.current = false + selectionTouchedRef.current = false + // Why: filters, sort, and the active preset live in the persisted browse + // slice now, so reopening keeps the user's view; only volatile row state + // resets — and not while a batch is still settling. + if (!removalInFlightRef.current) { + resetForReopen() + setSelectedIds(new Set()) } - const removableCandidates = filterWorkspaceCleanupRemovalCandidates( - confirmCandidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (removableCandidates.length === 0) { - setConfirming(false) - setConfirmCandidates([]) - return - } - removalInFlightRef.current = true - setRemovalInFlight(true) - removalBatchIdRef.current += 1 - const removalBatchId = removalBatchIdRef.current - // Why: a hung late settlement retains these callbacks for the renderer's - // lifetime; capture only ids so it cannot pin the candidate objects. - const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) - setRowFailures({}) - markWorktreesQueuedForDeletion(removableWorktreeIds) - startWorkspaceCleanupBackgroundRemoval({ - candidates: removableCandidates, - removeCandidates, - onProgress: (progress) => { - if (mountedRef.current) { - setRemovalProgress(progress) - } - }, - onRowFailed: (failure) => { - clearQueuedDeleteState(failure.worktreeId) - }, - onResult: (result) => { - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - clearQueuedDeleteState(failure.worktreeId) - } - if (mountedRef.current) { - setRowFailures(nextFailures) - deselectRemovedIds(result.removedIds) - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - }, - onLateResult: (result) => { - for (const failure of result.failures) { - // Why: a late failure can come from a hung preflight whose row never - // reached 'deleting'; clear its queued overlay like every other path. - clearQueuedDeleteState(failure.worktreeId) - } - if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { - return - } - setRowFailures((current) => { - const next = { ...current } - for (const id of result.removedIds) { - delete next[id] - } - for (const failure of result.failures) { - next[failure.worktreeId] = failure.message - } - return next - }) - deselectRemovedIds(result.removedIds) - }, - onError: () => { - for (const worktreeId of removableWorktreeIds) { - clearWorktreeDeleteState(worktreeId) - } - if (mountedRef.current) { - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - } - }) - }, [ - clearQueuedDeleteState, - clearWorktreeDeleteState, - confirmCandidates, - deselectRemovedIds, - markWorktreesQueuedForDeletion, - mountedRef, - removeCandidates - ]) + }, [removalInFlightRef, resetForReopen]) + const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({ + open, + loading, + removalInFlightRef, + resetRowFailures, + onFreshOpen + }) + + const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) + const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) + const gitEvidence = useWorkspaceCleanupGitEvidence({ enabled: gitEvidenceNeeded, candidates }) + const evidencedCandidates = useMemo( + () => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId), + [candidates, gitEvidence.evidenceByWorktreeId] + ) + // Why: a live clock would re-run every facet on each render; the scan time is + // the honest "as of" moment for every relative threshold in the list. + const facetRows = useWorkspaceCleanupFacetRows({ + candidates: evidencedCandidates, + filters: browse.filters, + sort: browse.sort, + now: scan?.scannedAt ?? Date.now() + }) + const rows = useWorkspaceCleanupRowOrder({ + rows: facetRows.rows, + streaming: loading, + sort: browse.sort + }) + const rowIds = useMemo(() => new Set(rows.map((row) => row.worktreeId)), [rows]) + const candidateIds = useMemo( + () => new Set(candidates.map((candidate) => candidate.worktreeId)), + [candidates] + ) + + const scanNoticeMessage = useMemo( + () => + formatWorkspaceCleanupScanNotice( + scan?.errors ?? [], + new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])) + ), + [repos, scan?.errors] + ) + const initialLoading = loading && candidates.length === 0 + const runSpaceScan = useCallback(() => { + void refreshWorkspaceSpace().catch(() => undefined) + }, [refreshWorkspaceSpace]) + + const selectedCandidates = useMemo(() => { + const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate])) + return [...selectedIds] + .map((id) => byId.get(id)) + .filter( + (candidate): candidate is WorkspaceCleanupCandidate => + candidate != null && + canQueueWorkspaceCleanupCandidate(candidate) && + !deletingWorktreeIds.has(candidate.worktreeId) + ) + }, [deletingWorktreeIds, rows, selectedIds]) const selectedCount = selectedCandidates.length + useEffect(() => { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { + return + } + selectedDefaultsScanAtRef.current = scan.scannedAt + if (removalInFlightRef.current) { + return + } + if (defaultsAppliedForOpenRef.current || selectionTouchedRef.current) { + return + } + defaultsAppliedForOpenRef.current = true + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) + }, [deletingWorktreeIds, loading, removalInFlightRef, scan]) + + const pruneSelectionToVisibleRows = useEffectEvent(() => { + setSelectedIds((current) => { + const next = new Set( + [...current].filter((id) => rowIds.has(id) && !deletingWorktreeIds.has(id)) + ) + return next.size === current.size ? current : next + }) + }) + + useEffect(() => { + if (!open || removal.confirming) { + return + } + // Why: destructive selection must stay scoped to the rows the user can + // currently review after a preset or filter change — and only then; a + // streaming refresh re-classifying a row must not silently deselect it. + pruneSelectionToVisibleRows() + }, [browse.filters, open, removal.confirming]) + + const pruneVanishedSelections = useEffectEvent(() => { + const kept = [...selectedIds].filter( + (id) => candidateIds.has(id) && !deletingWorktreeIds.has(id) + ) + if (kept.length === selectedIds.size) { + return + } + const vanishedCount = [...selectedIds].filter( + (id) => !candidateIds.has(id) && !deletingWorktreeIds.has(id) + ).length + setSelectedIds(new Set(kept)) + if (vanishedCount > 0) { + toast.info(formatVanishedSelectionNotice(vanishedCount)) + } + }) + + useEffect(() => { + if (loading) { + return + } + // Why: a settled refresh reconciles by worktreeId — selections survive it, + // and only rows that truly no longer exist drop (with a visible reason). + pruneVanishedSelections() + }, [candidateIds, deletingWorktreeIds, loading]) + + const ignoreCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + void dismissCandidates([candidate]) + .then(() => { + if (mountedRef.current) { + deselectRemovedIds([candidate.worktreeId]) + } + }) + .catch((err: unknown) => { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', + 'Could not ignore cleanup suggestion' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + } + }) + }, + [deselectRemovedIds, dismissCandidates, mountedRef] + ) + + const toggleExpandedRow = useCallback((worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + }, []) + + const toggleSelectedRow = useCallback((worktreeId: string) => { + selectionTouchedRef.current = true + setSelectedIds((current) => toggleSetMember(current, worktreeId)) + }, []) + + const selectableWorktreeIds = facetRows.selectableWorktreeIds + const toggleSelectAll = useCallback( + (selectAll: boolean) => { + selectionTouchedRef.current = true + setSelectedIds( + selectAll + ? new Set(selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id))) + : new Set() + ) + }, + [deletingWorktreeIds, selectableWorktreeIds] + ) + + // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow + // instances from re-rendering on scan stream-in and selection changes. + const openConfirmRemove = removal.openConfirmRemove + const handleRemoveRow = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + // Why: rows stay actionable while a rescan streams; the confirm-time + // preflight is the safety gate, not the scan state. + if (removalInFlightRef.current) { + return + } + selectionTouchedRef.current = true + setSelectedIds(new Set([candidate.worktreeId])) + openConfirmRemove([candidate]) + }, + [openConfirmRemove, removalInFlightRef] + ) + + const handleViewCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + markCandidateViewed(candidate) + closeModal() + activateAndRevealWorktree(candidate.worktreeId) + }, + [closeModal, markCandidateViewed] + ) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + closeModal() + } + }, + [closeModal] + ) + return ( - {!confirming ? ( + {!removal.confirming ? ( <> - -
-
- - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844', - 'Delete Inactive Workspaces' - )} - -
-
- - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4', - 'Refresh' - )} - - - -
-
-
+ openConfirmRemove(selectedCandidates)} + onRefresh={() => startWorkspaceCleanupScan({ notifyWhenReady: true })} + onClose={closeModal} + /> - {initialLoading ? ( -
- -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', - 'Checking inactive workspaces' - )} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108', - 'Scanning inactive workspaces. You can close this and come back.' - )} -
-
- {formatWorkspaceCleanupProgress(scanProgress)} -
-
-
- ) : hasAnyCandidates ? ( -
-
-
- {selectedCount}{' '} - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1', - 'selected' - )} -
-
-
- {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} - -
-
+ {initialLoading ? : null} + {error ? : null} + {!error && scanNoticeMessage ? ( + ) : null} - {loading && scan && hasAnyCandidates ? ( -
-
- - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9a3be9f2df', - 'Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.' - )} - - - {formatWorkspaceCleanupProgress(scanProgress)} - -
-
- ) : null} + - {error ? ( -
- {error} + +
+ {initialLoading ? : null} +
- ) : scanNoticeMessage ? ( -
- - {scanNoticeMessage} -
- ) : null} - - {estimateMismatchNotice ? ( -
- - {estimateMismatchNotice} -
- ) : null} - -
- -
- {filteredCandidates.length > 0 ? ( - 0 - } - sortKey={sortKey} - sortDirection={sortDirection} - onFiltersChange={setFilters} - onSortKeyChange={setSortKey} - onSortDirectionChange={setSortDirection} - onRestoreIgnored={() => void resetDismissals()} - /> - ) : null} - -
- {initialLoading ? : null} - {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? ( - - ) : null} - {!loading && scan && candidates.length === 0 && scanNoticeMessage ? ( - - ) : null} - {!loading && - scan && - candidates.length > 0 && - filteredCandidates.length === 0 ? ( - setRepoSelection(new Set(eligibleRepoIds))} - /> - ) : null} - {!loading && - scan && - filteredCandidates.length > 0 && - visibleCandidates.length === 0 ? ( - setActiveView('hidden')} - /> - ) : null} - {!loading && - scan && - activeRows.length === 0 && - activeBaseRows.length > 0 && - activeFilters ? ( - setFilters(DEFAULT_FILTERS)} - /> - ) : null} - {!loading && - scan && - activeRows.length === 0 && - visibleCandidates.length > 0 && - !activeFilters ? ( - - ) : null} - ( - 1 && index === activeRows.length - 1} - expanded={expandedRowIds.has(candidate.worktreeId)} - lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - deletionPhase={deletionPhaseByWorktreeId[candidate.worktreeId]} - removing={ - loading || - removalInFlight || - deletingWorktreeIds.has(candidate.worktreeId) - } - selected={ - selectedIds.has(candidate.worktreeId) && - !loading && - !deletingWorktreeIds.has(candidate.worktreeId) - } - failure={rowFailures[candidate.worktreeId]} - onToggleExpanded={toggleExpandedRow} - onToggleSelected={toggleSelectedRow} - onView={handleViewCandidate} - onIgnore={ignoreCandidate} - onRemove={handleRemoveRow} - /> - )} - /> -
-
-
-
+
) : ( - )} @@ -1029,526 +387,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) } -function WorkspaceCleanupFilterToolbar({ - filters, - showRestoreIgnored, - sortKey, - sortDirection, - onFiltersChange, - onSortKeyChange, - onSortDirectionChange, - onRestoreIgnored -}: { - filters: WorkspaceCleanupFilters - showRestoreIgnored: boolean - sortKey: WorkspaceCleanupSortKey - sortDirection: WorkspaceCleanupSortDirection - onFiltersChange: (filters: WorkspaceCleanupFilters) => void - onSortKeyChange: (sortKey: WorkspaceCleanupSortKey) => void - onSortDirectionChange: (direction: WorkspaceCleanupSortDirection) => void - onRestoreIgnored: () => void -}): React.JSX.Element { - const updateFilter = ( - key: K, - value: WorkspaceCleanupFilters[K] - ): void => { - onFiltersChange({ ...filters, [key]: value }) - } - const hasHiddenControls = hasActiveWorkspaceCleanupPanelControls(filters, sortKey, sortDirection) - const resetPanelControls = (): void => { - onFiltersChange({ - ...filters, - time: 'all', - review: 'all', - git: 'all', - context: 'all' - }) - onSortKeyChange('activity') - onSortDirectionChange('asc') - } - - return ( -
-
- - updateFilter('query', event.target.value)} - placeholder={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.searchPlaceholder', - 'Search workspaces' - )} - className="h-8 pl-8 text-xs" - /> -
- - - - - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.efb3843e75', - 'Filter and sort workspaces' - )} - - - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.93b7381d50', - 'Filters' - )} - - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ageFilter', - 'Age' - )} - value={filters.time} - options={[ - ['all', 'Any age'], - ['30d', '30d+'], - ['90d', '90d+'], - ['archived', 'Archived'] - ]} - onChange={(value) => updateFilter('time', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.reviewFilter', - 'Review' - )} - value={filters.review} - options={[ - ['all', 'Any review'], - ['no-review', 'No PR/MR'], - ['has-review', 'Has PR/MR'], - ['open-review', 'Open'], - ['closed-review', 'Closed'] - ]} - onChange={(value) => updateFilter('review', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.gitFilter', - 'Git' - )} - value={filters.git} - options={[ - ['all', 'Any git'], - ['clean', 'Clean'], - ['dirty', 'Dirty'], - ['unpushed', 'Unpushed'], - ['unknown', 'Unknown'] - ]} - onChange={(value) => updateFilter('git', value)} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.contextFilter', - 'Context' - )} - value={filters.context} - options={[ - ['all', 'Any context'], - ['has-context', 'Has context'], - ['no-context', 'No context'] - ]} - onChange={(value) => updateFilter('context', value)} - /> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a615e24679', - 'Sort' - )} - - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.sortBy', - 'Sort by' - )} - value={sortKey} - options={[ - ['activity', 'Activity'], - ['name', 'Name'], - ['repo', 'Repo'], - ['review', 'Review'], - ['git', 'Git'] - ]} - onChange={onSortKeyChange} - /> - - label={translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.sortDirection', - 'Direction' - )} - value={sortDirection} - options={[ - ['asc', 'Ascending'], - ['desc', 'Descending'] - ]} - onChange={onSortDirectionChange} - /> - {showRestoreIgnored ? ( - <> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.aaee139eab', - 'Restore ignored suggestions' - )} - - - ) : null} - {hasHiddenControls ? ( - <> - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e94b1f8bb4', - 'Clear filters' - )} - - - ) : null} - - -
- ) -} - -function WorkspaceCleanupMenuSub({ - label, - value, - options, - onChange -}: { - label: string - value: T - options: readonly (readonly [T, string])[] - onChange: (value: T) => void -}): React.JSX.Element { - const valueLabel = options.find(([optionValue]) => optionValue === value)?.[1] ?? value - return ( - - - - {label} - - {valueLabel} - - - - - onChange(next as T)}> - {options.map(([optionValue, optionLabel]) => ( - event.preventDefault()} - > - {optionLabel} - - ))} - - - - ) -} - -function CleanupViewNav({ - activeView, - counts, - onViewChange -}: { - activeView: WorkspaceCleanupView - counts: WorkspaceCleanupViewCounts - onViewChange: (view: WorkspaceCleanupView) => void -}): React.JSX.Element { - const items: { view: WorkspaceCleanupView; label: string }[] = [ - { - view: 'ready', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4b93a235d8', - 'Suggested' +function formatVanishedSelectionNotice(count: number): string { + return count === 1 + ? translate( + 'components.workspace.cleanup.browse.selectionVanishedOne', + '1 selected workspace no longer exists.' ) - }, - { - view: 'review', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.d1094dd529', - 'Needs review' + : translate( + 'components.workspace.cleanup.browse.selectionVanished', + '{{value0}} selected workspaces no longer exist.', + { value0: count } ) - }, - { - view: 'protected', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ) - }, - { - view: 'hidden', - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ) - } - ] - - return ( - - ) -} - -function ConfirmRemove({ - candidates, - reviewInfoByWorktreeId, - progress, - onBack, - onCancel, - onConfirm -}: { - candidates: WorkspaceCleanupCandidate[] - reviewInfoByWorktreeId: ReadonlyMap - progress: WorkspaceCleanupRemovalProgress | null - onBack: () => void - onCancel: () => void - onConfirm: () => void -}): React.JSX.Element { - const count = candidates.length - const deleting = progress !== null - const progressValue = progress - ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) - : 0 - return ( - <> - -
-
-
- {deleting ? ( - - ) : ( - - )} -
-
- - {deleting - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', - 'Deleting workspaces: {{value0}}', - { value0: count } - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', - 'Delete workspaces: {{value0}}?', - { value0: count } - )} - - - {deleting - ? translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', - 'You can close this and come back while deletion continues.' - ) - : translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', - "This permanently deletes their local files. You can't undo this." - )} - -
-
- -
-
-
- {progress ? ( -
-
- - - {formatWorkspaceCleanupRemovalProgress(progress)} - -
- -
- ) : null} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', - 'Selected for deletion: {{value0}}', - { value0: count } - )} -
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.592fbab446', - 'Sorted by oldest activity' - )} -
-
- - {candidates.map((candidate, index) => ( - - ))} - -
- - - {!deleting ? ( - - ) : null} - - - ) -} - -function ConfirmRemoveRow({ - candidate, - reviewInfo, - last -}: { - candidate: WorkspaceCleanupCandidate - reviewInfo: WorkspaceCleanupReviewInfo - last: boolean -}): React.JSX.Element { - const dirtyLabel = getDirtyGitLabel(candidate) - const branchDiffersFromName = candidate.branch !== candidate.displayName - const contextPillLabel = getContextPillLabel(candidate) - const showGitMetadataChip = shouldShowGitMetadataChip(candidate) - const status = getCandidateStatus(candidate) - return ( -
-
- {candidate.displayName} - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', - 'Last active' - )}{' '} - {formatRelativeTime(candidate.lastActivityAt)} - - {status.label} - {reviewInfo.label ? ( - {reviewInfo.label} - ) : null} - {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel && showGitMetadataChip ? ( - {dirtyLabel} - ) : null} -
-
- {candidate.repoName} - {branchDiffersFromName ? ( - <> - - {candidate.branch} - - ) : null} -
-
- {candidate.path} -
-
- ) -} - -function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilters): boolean { - return ( - filters.query.trim() !== '' || - filters.time !== 'all' || - filters.review !== 'all' || - filters.git !== 'all' || - filters.context !== 'all' - ) -} - -function hasActiveWorkspaceCleanupPanelControls( - filters: WorkspaceCleanupFilters, - sortKey: WorkspaceCleanupSortKey, - sortDirection: WorkspaceCleanupSortDirection -): boolean { - return ( - filters.time !== 'all' || - filters.review !== 'all' || - filters.git !== 'all' || - filters.context !== 'all' || - sortKey !== 'activity' || - sortDirection !== 'asc' - ) } function getDefaultSelectedWorkspaceCleanupIds( @@ -1564,90 +413,6 @@ function getDefaultSelectedWorkspaceCleanupIds( ) } -function formatWorkspaceCleanupReadyToastDescription( - inactiveCount: number, - suggestedCount: number -): string { - if (inactiveCount === 0) { - return 'No inactive workspaces found.' - } - const inactiveNoun = inactiveCount === 1 ? 'workspace' : 'workspaces' - const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions' - return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` -} - -function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { - const deletedText = translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', - '{{value0}}/{{value1}} deleted', - { - value0: progress.removedCount, - value1: progress.totalCount - } - ) - if (progress.failedCount === 0) { - return deletedText - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', - '{{value0}}, {{value1}} failed', - { - value0: deletedText, - value1: progress.failedCount - } - ) -} - -function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { - if (!progress || progress.scannedWorktreeCount === 0) { - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', - 'Finding inactive workspaces...' - ) - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', - 'Checked workspaces so far: {{value0}}', - { - value0: progress.scannedWorktreeCount - } - ) -} - -function SkeletonRows(): React.JSX.Element { - return ( -
- {[0, 1, 2].map((index) => ( -
- ))} -
- ) -} - -function EmptyState({ - title, - actionLabel, - onAction -}: { - title: string - actionLabel?: string - onAction?: () => void -}): React.JSX.Element { - return ( -
- {title} - {actionLabel && onAction ? ( - - ) : null} -
- ) -} - function toggleSetMember(current: Set, value: string): Set { const next = new Set(current) if (next.has(value)) { diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts new file mode 100644 index 00000000000..b1a63af57b3 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import type { WorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' +import { + createDefaultWorkspaceCleanupFilterState, + DEFAULT_WORKSPACE_CLEANUP_SORT, + type WorkspaceCleanupFilterState, + type WorkspaceCleanupSortField, + type WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + applyWorkspaceCleanupPreset, + listWorkspaceCleanupPresets, + matchWorkspaceCleanupPresetId +} from '../../../../shared/workspace-cleanup-preset-state' +import type { WorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-presets' + +export type WorkspaceCleanupBrowseController = { + browse: WorkspaceCleanupBrowseState + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState + presets: WorkspaceCleanupPreset[] + /** Preset the current state still matches; null once the user edits away from one. */ + matchedPresetId: string | null + setFilters: (next: WorkspaceCleanupFilterState) => void + patchFilters: ( + key: K, + value: Partial | WorkspaceCleanupFilterState[K] + ) => void + setSort: (next: WorkspaceCleanupSortState) => void + toggleSortField: (field: WorkspaceCleanupSortField) => void + applyPreset: (preset: WorkspaceCleanupPreset) => void + clearFilters: () => void +} + +/** + * Single seam over the persisted browse slice so the dialog never owns filter, + * sort, or preset state — reopening the dialog must not discard the user's view. + */ +export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseController { + const browse = useAppStore((s) => s.workspaceCleanupBrowse) + const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState) + + const presets = useMemo( + () => listWorkspaceCleanupPresets(browse.customPresets), + [browse.customPresets] + ) + const matchedPresetId = useMemo( + () => + matchWorkspaceCleanupPresetId( + { filters: browse.filters, sort: browse.sort }, + browse.customPresets + ), + [browse.customPresets, browse.filters, browse.sort] + ) + + const setFilters = useCallback( + (next: WorkspaceCleanupFilterState) => { + updateBrowse({ ...browse, filters: next }) + }, + [browse, updateBrowse] + ) + + const patchFilters = useCallback( + (key, value) => { + const current = browse.filters[key] + const next = + typeof current === 'object' && current !== null + ? { ...current, ...(value as object) } + : value + // Cast: a computed key over a union widens the spread result past + // WorkspaceCleanupFilterState even though `key` is constrained to it. + const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState + updateBrowse({ ...browse, filters }) + }, + [browse, updateBrowse] + ) + + const setSort = useCallback( + (next: WorkspaceCleanupSortState) => { + updateBrowse({ ...browse, sort: next }) + }, + [browse, updateBrowse] + ) + + // Why: re-picking the active column flips direction, which is the table + // convention users expect from a sortable header. + const toggleSortField = useCallback( + (field: WorkspaceCleanupSortField) => { + const direction = + browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc' + updateBrowse({ ...browse, sort: { field, direction } }) + }, + [browse, updateBrowse] + ) + + const applyPreset = useCallback( + (preset: WorkspaceCleanupPreset) => { + const applied = applyWorkspaceCleanupPreset(preset) + updateBrowse({ ...browse, activePresetId: preset.id, ...applied }) + }, + [browse, updateBrowse] + ) + + const clearFilters = useCallback(() => { + updateBrowse({ + ...browse, + activePresetId: null, + filters: createDefaultWorkspaceCleanupFilterState(), + sort: { ...DEFAULT_WORKSPACE_CLEANUP_SORT } + }) + }, [browse, updateBrowse]) + + return { + browse, + filters: browse.filters, + sort: browse.sort, + presets, + matchedPresetId, + setFilters, + patchFilters, + setSort, + toggleSortField, + applyPreset, + clearFilters + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts new file mode 100644 index 00000000000..19f289f0154 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -0,0 +1,147 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + buildWorkspaceCleanupFacetList, + buildWorkspaceCleanupSizeIndex, + buildWorkspaceCleanupWorktreeIndex, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + countWorkspaceCleanupFacetMatches, + runWorkspaceCleanupQuery +} from './workspace-cleanup-query' +import { + getWorkspaceCleanupReviewInfo, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import type { + WorkspaceCleanupFacetCounts, + WorkspaceCleanupFacetOptions +} from './workspace-cleanup-facet-panel-model' + +export type WorkspaceCleanupFacetRows = { + rows: WorkspaceCleanupFacets[] + selectableWorktreeIds: string[] + matchedCount: number + totalCount: number + facetCounts: WorkspaceCleanupFacetCounts + options: WorkspaceCleanupFacetOptions + reviewInfoByWorktreeId: ReadonlyMap + sizeByWorktreeId: ReadonlyMap + /** 0 means the workspace-space scan has never produced a usable size. */ + measuredSizeCount: number +} + +/** + * Joins the scan rows with everything the flat list filters and sorts on: + * renderer-only signals (visits, live agents, review cache, dismissals) plus + * sizes from the EXISTING workspace-space scan — no second scanner. + */ +export function useWorkspaceCleanupFacetRows({ + candidates, + filters, + sort, + now +}: { + candidates: readonly WorkspaceCleanupCandidate[] + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState + now: number +}): WorkspaceCleanupFacetRows { + const sources = useAppStore( + useShallow((s) => ({ + worktreesByRepo: s.worktreesByRepo, + hostedReviewCache: s.hostedReviewCache, + repos: s.repos, + settings: s.settings, + // Statuses are a top-level UI-slice field, not part of GlobalSettings. + workspaceStatuses: s.workspaceStatuses, + lastVisitedAtByWorktreeId: s.lastVisitedAtByWorktreeId, + agentStatusByPaneKey: s.agentStatusByPaneKey, + tabsByWorktree: s.tabsByWorktree, + dismissals: s.workspaceCleanupDismissals, + spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null + })) + ) + + const reviewInfoByWorktreeId = useMemo(() => { + const infos = new Map() + for (const candidate of candidates) { + infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, sources)) + } + return infos + }, [candidates, sources]) + + const sizeByWorktreeId = useMemo( + () => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees), + [sources.spaceWorktrees] + ) + + const facets = useMemo( + () => + buildWorkspaceCleanupFacetList(candidates, { + worktreeById: buildWorkspaceCleanupWorktreeIndex(sources.worktreesByRepo), + sizeBytesByWorktreeId: sizeByWorktreeId, + lastVisitedAtByWorktreeId: sources.lastVisitedAtByWorktreeId, + liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId( + sources.agentStatusByPaneKey, + sources.tabsByWorktree, + now + ), + reviewInfoByWorktreeId, + dismissedWorktreeIds: new Set(Object.keys(sources.dismissals)) + }), + [candidates, now, reviewInfoByWorktreeId, sizeByWorktreeId, sources] + ) + + const result = useMemo( + () => runWorkspaceCleanupQuery(facets, { filters, sort }, now), + [facets, filters, now, sort] + ) + const facetCounts = useMemo( + () => countWorkspaceCleanupFacetMatches(facets, filters, now), + [facets, filters, now] + ) + + const options = useMemo( + () => ({ + workspaceStatuses: sources.workspaceStatuses.map((status) => ({ + id: status.id, + label: status.label + })), + hostIds: [...new Set(facets.map((row) => row.hostId))].sort(), + repos: sources.repos + .filter((repo) => isGitRepoKind(repo)) + .map((repo) => ({ id: repo.id, label: repo.displayName || repo.path })), + reviewProviders: [ + ...new Set( + facets + .map((row) => row.review.provider) + .filter((provider): provider is HostedReviewProvider => provider !== null) + ) + ].sort() + }), + [facets, sources.repos, sources.workspaceStatuses] + ) + + return { + rows: result.rows, + selectableWorktreeIds: result.selectableWorktreeIds, + matchedCount: result.matchedCount, + totalCount: result.totalCount, + facetCounts, + options, + reviewInfoByWorktreeId, + sizeByWorktreeId, + measuredSizeCount: sizeByWorktreeId.size + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..0d5b61ce870 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + selectWorkspaceCleanupGitEvidenceTargets, + WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY +} from './workspace-cleanup-git-evidence' + +export type WorkspaceCleanupGitEvidenceState = { + /** Focused re-scan results, keyed by worktree id. */ + evidenceByWorktreeId: ReadonlyMap + pendingWorktreeIds: ReadonlySet + checkedCount: number + totalCount: number +} + +const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { + evidenceByWorktreeId: new Map(), + pendingWorktreeIds: new Set(), + checkedCount: 0, + totalCount: 0 +} + +/** + * Fills in git evidence the broad scan deferred, one focused re-scan per row. + * Only runs while a git-dependent filter or sort is active — the initial list + * render never waits on it, so an unfiltered browse stays instant. + */ +export function useWorkspaceCleanupGitEvidence({ + enabled, + candidates +}: { + enabled: boolean + candidates: readonly WorkspaceCleanupCandidate[] +}): WorkspaceCleanupGitEvidenceState { + const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) + const mountedRef = useMountedRef() + const [state, setState] = useState(EMPTY_EVIDENCE) + const evidenceRef = useRef(new Map()) + const attemptedRef = useRef(new Set()) + const queueRef = useRef([]) + const inFlightRef = useRef(new Set()) + const totalRef = useRef(0) + // Why: results that land after the user cleared the git filter belong to a + // stale pass and must not resurrect its pending banner. + const generationRef = useRef(0) + + const publish = useCallback(() => { + if (!mountedRef.current) { + return + } + setState({ + evidenceByWorktreeId: new Map(evidenceRef.current), + pendingWorktreeIds: new Set(inFlightRef.current), + checkedCount: totalRef.current - queueRef.current.length - inFlightRef.current.size, + totalCount: totalRef.current + }) + }, [mountedRef]) + + const pump = useCallback(() => { + const generation = generationRef.current + while ( + inFlightRef.current.size < WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY && + queueRef.current.length > 0 + ) { + const worktreeId = queueRef.current.shift() + if (worktreeId === undefined) { + break + } + inFlightRef.current.add(worktreeId) + attemptedRef.current.add(worktreeId) + void scanWorkspaceCleanup({ worktreeId }) + .then((result) => { + const refreshed = result.candidates.find( + (candidate) => candidate.worktreeId === worktreeId + ) + if (refreshed && generation === generationRef.current) { + evidenceRef.current.set(worktreeId, refreshed) + } + }) + .catch(() => { + // Why: a failed focused scan leaves the row honestly "Not checked"; + // attemptedRef keeps it from retrying in a loop. + }) + .finally(() => { + inFlightRef.current.delete(worktreeId) + if (generation !== generationRef.current) { + return + } + publish() + pump() + }) + } + publish() + }, [publish, scanWorkspaceCleanup]) + + useEffect(() => { + if (!enabled) { + generationRef.current += 1 + queueRef.current = [] + inFlightRef.current.clear() + totalRef.current = 0 + publish() + return + } + const targets = selectWorkspaceCleanupGitEvidenceTargets(candidates, { + resolvedWorktreeIds: attemptedRef.current + }).filter((id) => !queueRef.current.includes(id)) + if (targets.length === 0) { + return + } + queueRef.current.push(...targets) + totalRef.current += targets.length + pump() + }, [candidates, enabled, pump, publish]) + + return state +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts new file mode 100644 index 00000000000..6e016fc9af2 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -0,0 +1,219 @@ +import { useCallback, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +export type WorkspaceCleanupRemovalController = { + confirming: boolean + confirmCandidates: WorkspaceCleanupCandidate[] + removalProgress: WorkspaceCleanupRemovalProgress | null + removalInFlight: boolean + /** Synchronous guard; state alone lags a second confirm click. */ + removalInFlightRef: { current: boolean } + rowFailures: Record + resetRowFailures: () => void + resetForReopen: () => void + openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void + confirmRemove: () => void + cancelConfirmRemove: () => void + backToList: () => void +} + +/** + * Owns the destructive path: confirm gate, background batch, late settlement, + * and the queued-delete overlay each row shows in the sidebar. + */ +export function useWorkspaceCleanupRemoval({ + onDeselect, + closeModal +}: { + onDeselect: (removedIds: readonly string[]) => void + closeModal: () => void +}): WorkspaceCleanupRemovalController { + const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) + const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const mountedRef = useMountedRef() + + const [confirming, setConfirming] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) + // Why: `removalProgress` only arrives once the batch reports, so rendering + // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. + const [removalInFlight, setRemovalInFlight] = useState(false) + const [rowFailures, setRowFailures] = useState>({}) + const removalInFlightRef = useRef(false) + // Why: the dialog stays mounted across cleanup runs, so late settlements from + // an earlier batch must not mutate a newer batch's row state. + const removalBatchIdRef = useRef(0) + + const resetRowFailures = useCallback(() => setRowFailures({}), []) + + const resetForReopen = useCallback(() => { + if (removalInFlightRef.current) { + return + } + setConfirming(false) + setRowFailures({}) + }, []) + + const clearQueuedDeleteState = useCallback( + (worktreeId: string) => { + const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + // Why: candidates that fail before removal starts would otherwise stay + // marked "Queued for deletion" in the sidebar; rows already in the + // 'deleting' phase or failed with an error keep their own state. + if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { + clearWorktreeDeleteState(worktreeId) + } + }, + [clearWorktreeDeleteState] + ) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { + return + } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return + } + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + // Why: the header X reads as "leave this screen", not "abandon the dialog". The + // batch keeps running either way and the list shows each row's progress. + // Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here + // removalProgress stays set until the batch settles, so re-entry stays blocked. + const backToList = useCallback(() => { + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const settle = useCallback(() => { + setRemovalProgress(null) + setRemovalInFlight(false) + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRemovalInFlight(true) + removalBatchIdRef.current += 1 + const removalBatchId = removalBatchIdRef.current + // Why: a hung late settlement retains these callbacks for the renderer's + // lifetime; capture only ids so it cannot pin the candidate objects. + const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) + setRowFailures({}) + markWorktreesQueuedForDeletion(removableWorktreeIds) + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + clearQueuedDeleteState(failure.worktreeId) + } + if (mountedRef.current) { + setRowFailures(nextFailures) + onDeselect(result.removedIds) + settle() + } + removalInFlightRef.current = false + }, + onLateResult: (result) => { + for (const failure of result.failures) { + // Why: a late failure can come from a hung preflight whose row never + // reached 'deleting'; clear its queued overlay like every other path. + clearQueuedDeleteState(failure.worktreeId) + } + if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { + return + } + setRowFailures((current) => { + const next = { ...current } + for (const id of result.removedIds) { + delete next[id] + } + for (const failure of result.failures) { + next[failure.worktreeId] = failure.message + } + return next + }) + onDeselect(result.removedIds) + }, + onError: () => { + for (const worktreeId of removableWorktreeIds) { + clearWorktreeDeleteState(worktreeId) + } + if (mountedRef.current) { + settle() + } + removalInFlightRef.current = false + } + }) + }, [ + clearQueuedDeleteState, + clearWorktreeDeleteState, + confirmCandidates, + markWorktreesQueuedForDeletion, + mountedRef, + onDeselect, + removeCandidates, + settle + ]) + + return { + confirming, + confirmCandidates, + removalProgress, + removalInFlight, + removalInFlightRef, + rowFailures, + resetRowFailures, + resetForReopen, + openConfirmRemove, + confirmRemove, + cancelConfirmRemove, + backToList + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts new file mode 100644 index 00000000000..8776d8d000e --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + arrangeWorkspaceCleanupRowsByFrozenOrder, + createWorkspaceCleanupFrozenRowOrder +} from './use-workspace-cleanup-row-order' + +type Row = { worktreeId: string } + +const row = (worktreeId: string): Row => ({ worktreeId }) +const ids = (rows: readonly Row[]): string[] => rows.map((entry) => entry.worktreeId) + +describe('workspace cleanup streaming row order', () => { + it('keeps existing rows in their frozen slots when streamed values would resort them', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc') + + // The live sort now says c < a < b (values changed mid-stream). + const arranged = arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a'), row('b')], order) + + expect(ids(arranged)).toEqual(['a', 'b', 'c']) + }) + + it('appends unseen rows after the frozen ones and keeps them stable on later ticks', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b')], 'name:asc') + + const firstTick = arrangeWorkspaceCleanupRowsByFrozenOrder( + [row('a'), row('d'), row('b'), row('c')], + order + ) + expect(ids(firstTick)).toEqual(['a', 'b', 'd', 'c']) + + // A later tick sorting d and c differently must not swap them anymore. + const secondTick = arrangeWorkspaceCleanupRowsByFrozenOrder( + [row('c'), row('d'), row('b'), row('a')], + order + ) + expect(ids(secondTick)).toEqual(['a', 'b', 'd', 'c']) + }) + + it('holds slots for rows a filter temporarily hides', () => { + const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc') + + expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a')], order))).toEqual([ + 'a', + 'c' + ]) + // Un-hiding b restores its original slot between a and c. + expect( + ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('b'), row('a')], order)) + ).toEqual(['a', 'b', 'c']) + }) + + it('grows the frozen order from empty during a cold-start stream', () => { + const order = createWorkspaceCleanupFrozenRowOrder([], 'name:asc') + + expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('b'), row('d')], order))).toEqual([ + 'b', + 'd' + ]) + // New rows slot after the already-presented ones even when they sort earlier. + expect( + ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('a'), row('b'), row('c'), row('d')], order)) + ).toEqual(['b', 'd', 'a', 'c']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts new file mode 100644 index 00000000000..a19e2e74f63 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.ts @@ -0,0 +1,78 @@ +import { useMemo, useRef } from 'react' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' + +type OrderedRow = { worktreeId: string } + +export type WorkspaceCleanupFrozenRowOrder = { + sortSignature: string + positions: Map +} + +export function createWorkspaceCleanupFrozenRowOrder( + rows: readonly OrderedRow[], + sortSignature: string +): WorkspaceCleanupFrozenRowOrder { + return { + sortSignature, + positions: new Map(rows.map((row, index) => [row.worktreeId, index])) + } +} + +/** + * Rows already on screen keep their slot while a refresh streams updated + * values in; rows the order has not seen yet append after them (in the live + * sort among themselves) and claim the next slots, so later ticks cannot + * reshuffle them either. + */ +export function arrangeWorkspaceCleanupRowsByFrozenOrder( + sortedRows: readonly Row[], + order: WorkspaceCleanupFrozenRowOrder +): Row[] { + const known: Row[] = [] + const fresh: Row[] = [] + for (const row of sortedRows) { + if (order.positions.has(row.worktreeId)) { + known.push(row) + } else { + fresh.push(row) + } + } + known.sort( + (left, right) => + (order.positions.get(left.worktreeId) ?? 0) - (order.positions.get(right.worktreeId) ?? 0) + ) + for (const row of fresh) { + order.positions.set(row.worktreeId, order.positions.size) + } + return [...known, ...fresh] +} + +/** + * Streaming-order stability for the flat list: while a scan streams, existing + * rows must not reshuffle on every progress tick. The user's sort applies to + * the snapshot immediately; streamed value changes land in place; one calm + * re-sort happens when the scan settles — or at once if the user explicitly + * changes the sort mid-scan. + */ +export function useWorkspaceCleanupRowOrder({ + rows, + streaming, + sort +}: { + rows: readonly Row[] + streaming: boolean + sort: WorkspaceCleanupSortState +}): readonly Row[] { + const frozenOrderRef = useRef(null) + return useMemo(() => { + const sortSignature = `${sort.field}:${sort.direction}` + const frozen = frozenOrderRef.current + if (!streaming || frozen === null || frozen.sortSignature !== sortSignature) { + // Why: settled renders (and an explicit mid-scan sort change) present the + // live sort and freeze it as the order the next stream must hold steady. + frozenOrderRef.current = createWorkspaceCleanupFrozenRowOrder(rows, sortSignature) + return rows + } + return arrangeWorkspaceCleanupRowsByFrozenOrder(rows, frozen) + }, [rows, sort.direction, sort.field, streaming]) +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts new file mode 100644 index 00000000000..d21ead7eeea --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { formatWorkspaceCleanupReadyToast } from './workspace-cleanup-scan-notice' + +export type WorkspaceCleanupScanLifecycle = { + startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void +} + +/** + * Owns the dialog's scan cadence: seed the list from the persisted snapshot, + * kick one background rescan per open, and adopt — never duplicate — a scan + * already streaming into the store when the dialog reopens mid-flight. + */ +export function useWorkspaceCleanupScanLifecycle({ + open, + loading, + removalInFlightRef, + resetRowFailures, + onFreshOpen +}: { + open: boolean + loading: boolean + removalInFlightRef: { current: boolean } + resetRowFailures: () => void + onFreshOpen: () => void +}): WorkspaceCleanupScanLifecycle { + const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) + const hydrateCleanupFromCache = useAppStore((s) => s.hydrateWorkspaceCleanupFromCache) + const hydrateSpaceFromCache = useAppStore((s) => s.hydrateWorkspaceSpaceFromCache) + const openModal = useAppStore((s) => s.openModal) + const mountedRef = useMountedRef() + const openRef = useRef(open) + const wasOpenRef = useRef(false) + const autoScanAttemptedForOpenRef = useRef(false) + const latestReadyToastScanAtRef = useRef(null) + + useEffect(() => { + openRef.current = open + }, [open]) + + const startWorkspaceCleanupScan = useCallback( + (options: { notifyWhenReady?: boolean } = {}) => { + resetRowFailures() + void scanWorkspaceCleanup() + .then((result) => { + if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { + return + } + if (latestReadyToastScanAtRef.current === result.scannedAt) { + return + } + latestReadyToastScanAtRef.current = result.scannedAt + const suggestedCount = result.candidates.filter( + (candidate) => candidate.selectedByDefault + ).length + toast.success( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', + 'Inactive workspace scan ready' + ), + { + description: formatWorkspaceCleanupReadyToast( + result.candidates.length, + suggestedCount + ), + action: { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', + 'Review' + ), + onClick: () => openModal('workspace-cleanup') + } + } + ) + }) + .catch((err: unknown) => { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', + 'Workspace cleanup scan failed' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + } + }) + }, + [mountedRef, openModal, resetRowFailures, scanWorkspaceCleanup] + ) + + const hydrateThenScan = useCallback(() => { + // Why: the cached snapshot must seed the list before the rescan starts so + // progress reconciles into full rows instead of a cold rebuild. + void Promise.allSettled([hydrateCleanupFromCache(), hydrateSpaceFromCache()]).then(() => { + startWorkspaceCleanupScan({ notifyWhenReady: true }) + }) + }, [hydrateCleanupFromCache, hydrateSpaceFromCache, startWorkspaceCleanupScan]) + + useEffect(() => { + if (!open) { + wasOpenRef.current = false + autoScanAttemptedForOpenRef.current = false + return + } + if (!wasOpenRef.current) { + wasOpenRef.current = true + autoScanAttemptedForOpenRef.current = false + onFreshOpen() + } + // Why: reopening mid-batch keeps the deletion progress view; a broad scan + // started here would be discarded by the removal's scan invalidation, so + // skip it while a removal batch is running. + if (autoScanAttemptedForOpenRef.current || removalInFlightRef.current) { + return + } + autoScanAttemptedForOpenRef.current = true + if (loading) { + // Why: reopening mid-scan adopts the stream already feeding the store; + // starting another broad scan here would duplicate it once it settles. + return + } + hydrateThenScan() + }, [hydrateThenScan, loading, onFreshOpen, open, removalInFlightRef]) + + return { startWorkspaceCleanupScan } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts new file mode 100644 index 00000000000..1c45ddbc7ad --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { + hasActiveWorkspaceCleanupFilters, + listActiveWorkspaceCleanupFacetGroups +} from './workspace-cleanup-active-facets' + +describe('listActiveWorkspaceCleanupFacetGroups', () => { + it('reports nothing for the default state', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) + expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(false) + }) + + it('names each edited group', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 45 + filters.git.states = ['dirty'] + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(['activity', 'git']) + }) + + it('treats multi-select order as meaningless', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.review.states = ['merged', 'closed'] + const reordered = createDefaultWorkspaceCleanupFilterState() + reordered.review.states = ['closed', 'merged'] + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual( + listActiveWorkspaceCleanupFacetGroups(reordered) + ) + }) + + it('counts a text query as an active filter without naming a group', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.query = ' alpha ' + expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) + expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts new file mode 100644 index 00000000000..ece1c9b508d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.ts @@ -0,0 +1,58 @@ +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupFacetGroupKey = keyof Omit + +const FACET_GROUP_KEYS: readonly WorkspaceCleanupFacetGroupKey[] = [ + 'activity', + 'size', + 'status', + 'agent', + 'git', + 'review', + 'ticket', + 'context', + 'location', + 'safety' +] + +/** + * Facet groups the user has moved off the default state. Drives the "N filters + * on" badge, so it compares against defaults rather than against the active + * preset — a preset is itself just a named filter state. + */ +export function listActiveWorkspaceCleanupFacetGroups( + filters: WorkspaceCleanupFilterState +): WorkspaceCleanupFacetGroupKey[] { + const defaults = createDefaultWorkspaceCleanupFilterState() + return FACET_GROUP_KEYS.filter((key) => !isGroupEqual(filters[key], defaults[key])) +} + +export function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilterState): boolean { + return ( + filters.query.trim().length > 0 || listActiveWorkspaceCleanupFacetGroups(filters).length > 0 + ) +} + +/** Selection order is not meaning, so array members compare as sets. */ +function isGroupEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)) +} + +function normalize(value: unknown): unknown { + if (Array.isArray(value)) { + return [...value].map(String).sort() + } + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b) + ) + return entries.map(([key, entry]) => [ + key, + typeof entry === 'string' ? entry.trim() : normalize(entry) + ]) + } + return value +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx new file mode 100644 index 00000000000..be7817f479c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state' +import type { WorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' +import { + hasActiveWorkspaceCleanupFilters, + listActiveWorkspaceCleanupFacetGroups +} from './workspace-cleanup-active-facets' +import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' +import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar' +import { WorkspaceCleanupPresetChips } from './workspace-cleanup-preset-chips' +import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' + +export function WorkspaceCleanupBrowseToolbar({ + browse, + facetRows, + selectedCount, + spaceScanning, + gitPendingCount, + gitCheckedTotal, + onRunSpaceScan, + onToggleSelectAll +}: { + browse: WorkspaceCleanupBrowseController + facetRows: WorkspaceCleanupFacetRows + selectedCount: number + spaceScanning: boolean + gitPendingCount: number + gitCheckedTotal: number + onRunSpaceScan: () => void + onToggleSelectAll: (selectAll: boolean) => void +}): React.JSX.Element { + const activeFilters = hasActiveWorkspaceCleanupFilters(browse.filters) + return ( + <> + + browse.patchFilters('query', query)} + onClearFilters={browse.clearFilters} + /> + + {browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx index 68569c7aba9..95dae5a3d22 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx @@ -1,6 +1,8 @@ import React from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +/** Any row shape the flat list renders — raw candidates or enriched facet rows. */ +type WorkspaceCleanupListRow = { worktreeId: string } // Why: below this count plain rows keep the pre-virtualization DOM (natural // flow, no absolute positioning), so the common few-worktrees case is @@ -19,7 +21,7 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8 * no scroll-margin bookkeeping is needed. Lists shorter than * WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly. */ -export function WorkspaceCleanupCandidateList({ +export function WorkspaceCleanupCandidateList({ rows, renderRow, // Why: a state-held element, not a ref — the ScrollArea viewport is not @@ -27,8 +29,8 @@ export function WorkspaceCleanupCandidateList({ // virtualizer unobserved until some unrelated re-render. scrollElement }: { - rows: readonly WorkspaceCleanupCandidate[] - renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode + rows: readonly Row[] + renderRow: (row: Row, index: number) => React.ReactNode scrollElement: HTMLDivElement | null }): React.JSX.Element { const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 2953f46fa5a..dbbb830b6ef 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -8,6 +8,7 @@ import { FileWarning, GitBranch, GitPullRequest, + HardDrive, Loader2, Search, SquareTerminal, @@ -48,9 +49,13 @@ type CandidateRowProps = { deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean failure?: string + /** A focused git re-scan is in flight, so "Not checked" is provisional. */ + gitEvidencePending?: boolean last: boolean lastActivityLabel: string removing?: boolean + /** Joined from the workspace-space scan; null when that scan has not run. */ + sizeLabel?: string | null reviewInfo: WorkspaceCleanupReviewInfo selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void @@ -106,9 +111,11 @@ export const CandidateRow = React.memo(function CandidateRow({ deletionPhase, expanded, failure, + gitEvidencePending = false, last, lastActivityLabel, removing = false, + sizeLabel = null, reviewInfo, selected, onIgnore, @@ -193,7 +200,28 @@ export const CandidateRow = React.memo(function CandidateRow({ )} ${lastActivityLabel}`} value={formatCompactActivityLabel(lastActivityLabel)} /> - {dirtyLabel && showGitMetadataChip ? ( + {sizeLabel ? ( + + ) : null} + {gitEvidencePending ? ( + + + ) : dirtyLabel && showGitMetadataChip ? ( ) : showGitMetadataChip ? ( + progress: WorkspaceCleanupRemovalProgress | null + onBack: () => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + const count = candidates.length + const deleting = progress !== null + const progressValue = progress + ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) + : 0 + return ( + <> + +
+
+
+ {deleting ? ( + + ) : ( + + )} +
+
+ + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} + + + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', + 'You can close this and come back while deletion continues.' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', + "This permanently deletes their local files. You can't undo this." + )} + +
+
+ +
+
+
+ {progress ? ( +
+
+ + + {formatWorkspaceCleanupRemovalProgress(progress)} + +
+ +
+ ) : null} +
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } + )} +
+
+ + {candidates.map((candidate, index) => ( + + ))} + +
+ + + {!deleting ? ( + + ) : null} + + + ) +} + +function ConfirmRemoveRow({ + candidate, + reviewInfo, + last +}: { + candidate: WorkspaceCleanupCandidate + reviewInfo: WorkspaceCleanupReviewInfo + last: boolean +}): React.JSX.Element { + const dirtyLabel = getDirtyGitLabel(candidate) + const branchDiffersFromName = candidate.branch !== candidate.displayName + const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) + const status = getCandidateStatus(candidate) + return ( +
+
+ {candidate.displayName} + + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc', + 'Last active' + )}{' '} + {formatWorkspaceCleanupRelativeTime(candidate.lastActivityAt)} + + {status.label} + {reviewInfo.label ? ( + {reviewInfo.label} + ) : null} + {contextPillLabel ? {contextPillLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null} +
+
+ {candidate.repoName} + {branchDiffersFromName ? ( + <> + + {candidate.branch} + + ) : null} +
+
+ {candidate.path} +
+
+ ) +} + +function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { + const deletedText = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', + '{{value0}}/{{value1}} deleted', + { value0: progress.removedCount, value1: progress.totalCount } + ) + if (progress.failedCount === 0) { + return deletedText + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', + '{{value0}}, {{value1}} failed', + { value0: deletedText, value1: progress.failedCount } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx new file mode 100644 index 00000000000..9bdda93c68c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-header.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { RefreshCcw, Trash2, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { WorkspaceCleanupFreshness } from './workspace-cleanup-freshness' + +export function WorkspaceCleanupDialogHeader({ + selectedCount, + deleteDisabled, + loading, + scannedAt, + scanProgress, + onDeleteSelected, + onRefresh, + onClose +}: { + selectedCount: number + deleteDisabled: boolean + loading: boolean + scannedAt: number | null + scanProgress: WorkspaceCleanupScanProgress | null + onDeleteSelected: () => void + onRefresh: () => void + onClose: () => void +}): React.JSX.Element { + const refreshLabel = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4', + 'Refresh' + ) + return ( + +
+ + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844', + 'Delete Inactive Workspaces' + )} + +
+ + + {selectedCount}{' '} + {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1', + 'selected' + )} + + + + + + + + {refreshLabel} + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx new file mode 100644 index 00000000000..4a7b0c70946 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { AlertTriangle, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { formatWorkspaceCleanupScanProgress } from './workspace-cleanup-scan-notice' + +export function WorkspaceCleanupInitialScanBanner({ + progress +}: { + progress: WorkspaceCleanupScanProgress | null +}): React.JSX.Element { + return ( +
+ +
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', + 'Checking inactive workspaces' + )} +
+
+ {translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108', + 'Scanning inactive workspaces. You can close this and come back.' + )} +
+
+ {formatWorkspaceCleanupScanProgress(progress)} +
+
+
+ ) +} + +export function WorkspaceCleanupNotice({ + tone = 'muted', + message +}: { + tone?: 'muted' | 'destructive' + message: string +}): React.JSX.Element { + if (tone === 'destructive') { + return ( +
+ {message} +
+ ) + } + return ( +
+ + {message} +
+ ) +} + +export function WorkspaceCleanupEmptyState({ + title, + description, + actionLabel, + onAction +}: { + title: string + description?: string + actionLabel?: string + onAction?: () => void +}): React.JSX.Element { + return ( +
+ {title} + {description ? {description} : null} + {actionLabel && onAction ? ( + + ) : null} +
+ ) +} + +export function WorkspaceCleanupSkeletonRows(): React.JSX.Element { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx new file mode 100644 index 00000000000..a54b130c6ca --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' +import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' +import type { + WorkspaceCleanupFacetCounts, + WorkspaceCleanupFacetGroupProps, + WorkspaceCleanupFacetOptions +} from './workspace-cleanup-facet-panel-model' + +/** `Mock` erases T's type parameter, so keep the generic call signature and add the spy API. */ +type PatchMock = WorkspaceCleanupFacetGroupProps['onPatch'] & MockInstance + +function createPatchMock(): PatchMock { + return vi.fn() +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +const COUNTS: WorkspaceCleanupFacetCounts = { + activity: 3, + size: 4, + status: 8, + agent: 8, + git: 2, + review: 5, + ticket: 8, + context: 6, + location: 8, + safety: 7 +} + +const OPTIONS: WorkspaceCleanupFacetOptions = { + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostIds: ['local', 'ssh:box'], + repos: [ + { id: 'repo-1', label: 'Alpha' }, + { id: 'repo-2', label: 'Beta' } + ], + reviewProviders: ['github', 'gitlab'] +} + +function render(node: ReactNode): void { + act(() => root?.render(node)) +} + +function control(label: string): HTMLElement | null { + return container?.querySelector(`[aria-label="${label}"]`) ?? null +} + +/** React tracks the controlled value, so a bare `input.value =` is swallowed. */ +function typeInto(input: HTMLInputElement | null, value: string): void { + if (!input) { + return + } + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function renderFacets(filters: WorkspaceCleanupFilterState, onPatch: PatchMock): void { + const props = { filters, counts: COUNTS, totalCount: 8, options: OPTIONS, onPatch } + render( + <> + + + + ) +} + +describe('workspace cleanup facet controls', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('shows a per-facet match count for every group', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + const counts = [...(container?.querySelectorAll('[data-facet-count]') ?? [])].map( + (node) => node.textContent + ) + expect(counts).toHaveLength(10) + expect(counts).toContain('2/8') + expect(counts).toContain('3/8') + }) + + it('takes a user-chosen idle threshold in days rather than a fixed enum', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + const input = control('Idle for at least') as HTMLInputElement | null + expect(input?.type).toBe('number') + act(() => typeInto(input, '17')) + expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 }) + }) + + it('multi-selects git states', () => { + const onPatch = createPatchMock() + const filters = createDefaultWorkspaceCleanupFilterState() + filters.git.states = ['dirty'] + renderFacets(filters, onPatch) + + expect(control('Uncommitted changes')?.getAttribute('aria-checked')).toBe('true') + act(() => control('Unpushed commits')?.click()) + expect(onPatch).toHaveBeenCalledWith('git', { states: ['dirty', 'unpushed'] }) + }) + + it('offers the hosts and providers discovered in the fleet, not a hardcoded list', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + expect(control('GitLab')).not.toBeNull() + expect(container?.textContent).toContain('Alpha') + expect(container?.textContent).toContain('In progress') + }) + + it('cycles a tri-state safety facet', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + act(() => container?.querySelector('[aria-label="Ignored: Only"]')?.click()) + expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' }) + }) + + it('toggles the unsized escape hatch for the opt-in space scan', () => { + const onPatch = createPatchMock() + renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) + + const checkbox = control('Include unmeasured workspaces') + expect(checkbox?.getAttribute('data-state')).toBe('checked') + act(() => checkbox?.click()) + expect(onPatch).toHaveBeenCalledWith('size', { includeUnsized: false }) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx new file mode 100644 index 00000000000..5853c6bd16d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx @@ -0,0 +1,230 @@ +import React from 'react' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' + +/** One facet group inside the filter panel, with its own match count. */ +export function FacetSection({ + title, + matchCount, + totalCount, + children +}: { + title: string + matchCount: number + totalCount: number + children: React.ReactNode +}): React.JSX.Element { + return ( +
+
+

+ {title} +

+ + {matchCount}/{totalCount} + +
+
{children}
+
+ ) +} + +/** Multi-select: empty selection leaves the facet unconstrained. */ +export function FacetToggleList({ + label, + values, + selected, + getLabel, + onChange +}: { + label?: string + values: readonly T[] + selected: readonly T[] + getLabel: (value: T) => string + onChange: (next: T[]) => void +}): React.JSX.Element { + return ( + +
+ {values.map((value) => { + const active = selected.includes(value) + return ( + + ) + })} +
+
+ ) +} + +/** Single-choice segmented control for tri-state / presence / enum facets. */ +export function FacetChoice({ + label, + value, + options, + getLabel, + onChange +}: { + label: string + value: T + options: readonly T[] + getLabel?: (value: T) => string + onChange: (next: T) => void +}): React.JSX.Element { + return ( + +
+ {options.map((option) => ( + + ))} +
+
+ ) +} + +/** Free-form numeric threshold — deliberately not a fixed 30/90 enum. */ +export function FacetNumberField({ + label, + value, + placeholder, + suffix, + onChange +}: { + label: string + value: number | null + placeholder?: string + suffix?: string + onChange: (next: number | null) => void +}): React.JSX.Element { + return ( + +
+ onChange(parseFacetNumber(event.target.value))} + className="h-7 w-24 text-xs" + /> + {suffix ? {suffix} : null} +
+
+ ) +} + +export function FacetTextField({ + label, + value, + placeholder, + onChange +}: { + label: string + value: string + placeholder?: string + onChange: (next: string) => void +}): React.JSX.Element { + return ( + + onChange(event.target.value)} + className="h-7 text-xs" + /> + + ) +} + +export function FacetCheckbox({ + label, + checked, + onChange +}: { + label: string + checked: boolean + onChange: (next: boolean) => void +}): React.JSX.Element { + const id = `workspace-cleanup-facet-${label.replace(/\W+/g, '-').toLowerCase()}` + return ( +
+ onChange(next === true)} + /> + +
+ ) +} + +function FacetField({ + label, + children +}: { + label?: string + children: React.ReactNode +}): React.JSX.Element { + if (!label) { + return <>{children} + } + return ( +
+
{label}
+ {children} +
+ ) +} + +export function parseFacetNumber(raw: string): number | null { + const trimmed = raw.trim() + if (trimmed === '') { + return null + } + const parsed = Number(trimmed) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts new file mode 100644 index 00000000000..f643c338bcf --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkspaceCleanupFacetList, + buildWorkspaceCleanupSizeIndex, + buildWorkspaceCleanupWorktreeIndex +} from './workspace-cleanup-facets' +import { + DAY, + FACET_NOW, + makeFacetCandidate, + makeFacets, + makeNamedFacets +} from './workspace-cleanup-facet.test.fixture' +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from '../../../../shared/workspace-cleanup-filter-model' +import { + countWorkspaceCleanupFacetMatches, + filterWorkspaceCleanupFacets, + matchesWorkspaceCleanupFilters, + queryWorkspaceCleanupCandidates +} from './workspace-cleanup-query' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' + +function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState { + const state = createDefaultWorkspaceCleanupFilterState() + patch(state) + return state +} + +const ANY = filters((state) => { + state.safety.dismissed = 'any' +}) + +describe('facet building', () => { + it('strips the ref prefix and falls back to local host', () => { + const facets = makeFacets({ worktree: { branch: 'refs/heads/feature/x' } }) + expect(facets.branch).toBe('feature/x') + expect(facets.hostId).toBe('local') + }) + + it('treats a missing size entry as unsized rather than zero', () => { + expect(makeFacets().sizeBytes).toBeNull() + expect(makeFacets({ sizeBytes: 0 }).sizeBytes).toBe(0) + }) + + it('treats a missing visit stamp as never opened', () => { + expect(makeFacets().lastVisitedAt).toBeNull() + expect(makeFacets({ lastVisitedAt: FACET_NOW }).lastVisitedAt).toBe(FACET_NOW) + }) + + it('only trusts sizes from an ok space scan', () => { + const scanned = [ + { worktreeId: 'a', status: 'ok', sizeBytes: 10 }, + { worktreeId: 'b', status: 'permission-denied', sizeBytes: 99 } + ] as WorkspaceSpaceWorktree[] + const index = buildWorkspaceCleanupSizeIndex(scanned) + expect(index.get('a')).toBe(10) + expect(index.has('b')).toBe(false) + }) + + it('indexes worktrees across repos by id', () => { + const index = buildWorkspaceCleanupWorktreeIndex({ + 'repo-1': [{ id: 'w1' }], + 'repo-2': [{ id: 'w2' }] + }) + expect([...index.keys()]).toEqual(['w1', 'w2']) + }) + + it('counts a workspace with nothing attached as completely empty', () => { + expect(makeFacets().isCompletelyEmpty).toBe(true) + expect(makeFacets({ worktree: { comment: 'keep' } }).isCompletelyEmpty).toBe(false) + expect(makeFacets({ review: {} }).isCompletelyEmpty).toBe(false) + expect(makeFacets({ worktree: { linkedIssue: 12 } }).isCompletelyEmpty).toBe(false) + }) +}) + +describe('query search', () => { + it('matches branch, host, status, provider, and blockers', () => { + const facets = makeFacets({ + candidate: { blockers: ['live-agent'] }, + worktree: { + branch: 'refs/heads/fix/login', + hostId: 'ssh:builder', + workspaceStatus: 'in-review' + }, + review: { provider: 'gitlab', label: 'MR #7' } + }) + for (const term of ['fix/login', 'ssh:builder', 'in-review', 'gitlab', 'live-agent']) { + expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: term }, FACET_NOW)).toBe(true) + } + expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: 'nope' }, FACET_NOW)).toBe(false) + }) +}) + +describe('activity filter', () => { + it('uses the user-chosen day threshold against the chosen signal', () => { + const recentlyVisited = makeFacets({ lastVisitedAt: FACET_NOW - 5 * DAY }) + const staleVisit = makeFacets({ lastVisitedAt: FACET_NOW - 45 * DAY }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.idleSignal = 'last-visited' + s.activity.idleMinDays = 30 + }) + expect(matchesWorkspaceCleanupFilters(recentlyVisited, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(staleVisit, state, FACET_NOW)).toBe(true) + }) + + it('reads a missing signal as maximally idle', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.idleMinDays = 365 + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) + }) + + it('separates background activity from a real visit', () => { + const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.activity.neverVisited = true + }) + expect(matchesWorkspaceCleanupFilters(busyButUnopened, state, FACET_NOW)).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ lastVisitedAt: FACET_NOW }), state, FACET_NOW) + ).toBe(false) + }) +}) + +describe('size filter', () => { + it('keeps unsized rows unless the user opts out', () => { + const unsized = makeFacets() + expect(matchesWorkspaceCleanupFilters(unsized, ANY, FACET_NOW)).toBe(true) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.includeUnsized = false + }) + expect(matchesWorkspaceCleanupFilters(unsized, state, FACET_NOW)).toBe(false) + }) + + it('applies min and max byte bounds', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.minBytes = 100 + s.size.maxBytes = 200 + }) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 150 }), state, FACET_NOW)).toBe( + true + ) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 99 }), state, FACET_NOW)).toBe( + false + ) + expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 201 }), state, FACET_NOW)).toBe( + false + ) + }) +}) + +describe('status filter', () => { + it('matches open workspace-status strings and statusless rows separately', () => { + const inReview = makeFacets({ worktree: { workspaceStatus: 'in-review' } }) + const statusless = makeFacets() + const state = filters((s) => { + s.safety.dismissed = 'any' + s.status.workspaceStatuses = ['in-review'] + s.status.matchStatusless = false + }) + expect(matchesWorkspaceCleanupFilters(inReview, state, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(false) + state.status.matchStatusless = true + expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(true) + }) + + it('applies tri-state flags', () => { + const archived = makeFacets({ worktree: { isArchived: true } }) + const only = filters((s) => { + s.safety.dismissed = 'any' + s.status.archived = 'only' + }) + const exclude = filters((s) => { + s.safety.dismissed = 'any' + s.status.archived = 'exclude' + }) + expect(matchesWorkspaceCleanupFilters(archived, only, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(archived, exclude, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true) + }) +}) + +describe('agent, git, review, ticket, context, and location filters', () => { + it('filters by live agent rollup and retained done agents', () => { + const state = filters((s) => { + s.safety.dismissed = 'any' + s.agent.states = ['permission'] + }) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'permission' }), state, FACET_NOW) + ).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'working' }), state, FACET_NOW) + ).toBe(false) + expect(makeFacets().agentState).toBe('idle') + + const retained = filters((s) => { + s.safety.dismissed = 'any' + s.agent.retainedDoneAgents = 'only' + }) + const withRetained = makeFacets({ + candidate: { + localContext: { + ...makeFacetCandidate().localContext, + retainedDoneAgentCount: 2 + } + } + }) + expect(matchesWorkspaceCleanupFilters(withRetained, retained, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), retained, FACET_NOW)).toBe(false) + }) + + it('filters by git state, ahead counts, branch text, prunable and locked', () => { + const unpushed = makeFacets({ + candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } } + }) + expect(unpushed.gitState).toBe('unpushed') + const state = filters((s) => { + s.safety.dismissed = 'any' + s.git.states = ['unpushed'] + s.git.minAhead = 2 + }) + expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(true) + state.git.minAhead = 4 + expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(false) + + const branchState = filters((s) => { + s.safety.dismissed = 'any' + s.git.branchQuery = 'ALPH' + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), branchState, FACET_NOW)).toBe(true) + + const prunable = filters((s) => { + s.safety.dismissed = 'any' + s.git.prunable = 'only' + }) + expect( + matchesWorkspaceCleanupFilters( + makeFacets({ worktree: { prunable: true } }), + prunable, + FACET_NOW + ) + ).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), prunable, FACET_NOW)).toBe(false) + }) + + it('keeps review filtering provider-general and treats draft as a state', () => { + const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.review.presence = 'some' + s.review.states = ['draft'] + s.review.providers = ['gitlab', 'bitbucket'] + }) + expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(true) + state.review.providers = ['github'] + expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(false) + + const none = filters((s) => { + s.safety.dismissed = 'any' + s.review.presence = 'none' + }) + expect(matchesWorkspaceCleanupFilters(makeFacets(), none, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(draftMr, none, FACET_NOW)).toBe(false) + }) + + it('filters by ticket source', () => { + const linear = makeFacets({ worktree: { linkedLinearIssue: 'STA-1' } }) + expect(linear.ticketSources).toEqual(['linear']) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.ticket.presence = 'some' + s.ticket.sources = ['linear'] + }) + expect(matchesWorkspaceCleanupFilters(linear, state, FACET_NOW)).toBe(true) + expect( + matchesWorkspaceCleanupFilters(makeFacets({ worktree: { linkedIssue: 9 } }), state, FACET_NOW) + ).toBe(false) + }) + + it('filters by local context and completely-empty', () => { + const busy = makeFacets({ + candidate: { + localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 } + } + }) + const hasContext = filters((s) => { + s.safety.dismissed = 'any' + s.context.presence = 'some' + }) + expect(matchesWorkspaceCleanupFilters(busy, hasContext, FACET_NOW)).toBe(true) + const empty = filters((s) => { + s.safety.dismissed = 'any' + s.context.completelyEmpty = true + }) + expect(matchesWorkspaceCleanupFilters(busy, empty, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), empty, FACET_NOW)).toBe(true) + }) + + it('filters by execution host, repo, and path prefix', () => { + const remote = makeFacets({ worktree: { hostId: 'ssh:builder' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.location.hostIds = ['ssh:builder'] + s.location.repoIds = ['repo-1'] + s.location.pathPrefix = '/repo/' + }) + expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(true) + state.location.pathPrefix = '/other/' + expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false) + }) +}) + +describe('safety filter', () => { + it('supports any-of and none-of blocker selection', () => { + const live = makeFacets({ + candidate: { blockers: ['live-agent', 'pinned'], tier: 'protected' } + }) + const anyOf = filters((s) => { + s.safety.dismissed = 'any' + s.safety.blockers = ['live-agent'] + s.safety.blockerMode = 'any-of' + }) + const noneOf = filters((s) => { + s.safety.dismissed = 'any' + s.safety.blockers = ['live-agent'] + s.safety.blockerMode = 'none-of' + }) + expect(matchesWorkspaceCleanupFilters(live, anyOf, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(live, noneOf, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), noneOf, FACET_NOW)).toBe(true) + }) + + it('excludes dismissed rows by default and surfaces them on demand', () => { + const dismissed = makeFacets({ dismissed: true }) + expect( + matchesWorkspaceCleanupFilters( + dismissed, + createDefaultWorkspaceCleanupFilterState(), + FACET_NOW + ) + ).toBe(false) + const only = filters((s) => { + s.safety.dismissed = 'only' + }) + expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true) + expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false) + }) + + it('restricts to rows the policy would actually let the user delete', () => { + const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } }) + const state = filters((s) => { + s.safety.dismissed = 'any' + s.safety.selectableOnly = true + }) + expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false) + expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) + }) +}) + +describe('query pipeline', () => { + it('reports counts and selectable ids', () => { + const rows = [ + makeNamedFacets('alpha'), + makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } }) + ] + expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2) + const result = queryWorkspaceCleanupCandidates( + buildWorkspaceCleanupFacetList([makeFacetCandidate()]).map((f) => f.candidate), + { filters: ANY, sort: { field: 'name', direction: 'asc' } }, + {}, + FACET_NOW + ) + expect(result.totalCount).toBe(1) + expect(result.matchedCount).toBe(1) + expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha']) + }) + + it('counts per-facet matches independently of the other groups', () => { + const rows = [makeNamedFacets('alpha', { sizeBytes: 10 }), makeNamedFacets('beta')] + const state = filters((s) => { + s.safety.dismissed = 'any' + s.size.includeUnsized = false + s.git.states = ['dirty'] + }) + const counts = countWorkspaceCleanupFacetMatches(rows, state, FACET_NOW) + expect(counts.size).toBe(1) + expect(counts.git).toBe(0) + expect(counts.safety).toBe(2) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts new file mode 100644 index 00000000000..e9ddc69936d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -0,0 +1,168 @@ +import { translate } from '@/i18n/i18n' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupAgentState, + WorkspaceCleanupGitState, + WorkspaceCleanupIdleSignal, + WorkspaceCleanupPresence, + WorkspaceCleanupReviewState, + WorkspaceCleanupSortField, + WorkspaceCleanupTicketSource, + WorkspaceCleanupTriState +} from '../../../../shared/workspace-cleanup-filter-model' + +export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortField): string { + switch (field) { + case 'last-activity': + return translate('components.workspace.cleanup.browse.sort.lastActivity', 'Last activity') + case 'last-visited': + return translate('components.workspace.cleanup.browse.sort.lastVisited', 'Last opened') + case 'created': + return translate('components.workspace.cleanup.browse.sort.created', 'Created') + case 'size': + return translate('components.workspace.cleanup.browse.sort.size', 'Size') + case 'name': + return translate('components.workspace.cleanup.browse.sort.name', 'Workspace') + case 'repo': + return translate('components.workspace.cleanup.browse.sort.repo', 'Repository') + case 'path': + return translate('components.workspace.cleanup.browse.sort.path', 'Path') + case 'host': + return translate('components.workspace.cleanup.browse.sort.host', 'Host') + case 'workspace-status': + return translate('components.workspace.cleanup.browse.sort.workspaceStatus', 'Status') + case 'agent': + return translate('components.workspace.cleanup.browse.sort.agent', 'Agent') + case 'git': + return translate('components.workspace.cleanup.browse.sort.git', 'Git') + case 'ahead': + return translate('components.workspace.cleanup.browse.sort.ahead', 'Ahead') + case 'behind': + return translate('components.workspace.cleanup.browse.sort.behind', 'Behind') + case 'branch': + return translate('components.workspace.cleanup.browse.sort.branch', 'Branch') + case 'review': + return translate('components.workspace.cleanup.browse.sort.review', 'Review') + case 'ticket': + return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket') + case 'local-context': + return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context') + case 'tier': + return translate('components.workspace.cleanup.browse.sort.tier', 'Safety') + case 'blocker-count': + return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers') + } +} + +export function getWorkspaceCleanupGitStateLabel(state: WorkspaceCleanupGitState): string { + switch (state) { + case 'clean': + return translate('components.workspace.cleanup.browse.git.clean', 'Clean') + case 'dirty': + return translate('components.workspace.cleanup.browse.git.dirty', 'Uncommitted changes') + case 'unpushed': + return translate('components.workspace.cleanup.browse.git.unpushed', 'Unpushed commits') + case 'unknown': + return translate('components.workspace.cleanup.browse.git.unknown', 'Not checked') + } +} + +export function getWorkspaceCleanupAgentStateLabel(state: WorkspaceCleanupAgentState): string { + switch (state) { + case 'working': + return translate('components.workspace.cleanup.browse.agent.working', 'Working') + case 'permission': + return translate('components.workspace.cleanup.browse.agent.permission', 'Waiting on you') + case 'idle': + return translate('components.workspace.cleanup.browse.agent.idle', 'Idle') + } +} + +export function getWorkspaceCleanupReviewStateLabel(state: WorkspaceCleanupReviewState): string { + switch (state) { + case 'open': + return translate('components.workspace.cleanup.browse.review.open', 'Open') + case 'draft': + return translate('components.workspace.cleanup.browse.review.draft', 'Draft') + case 'merged': + return translate('components.workspace.cleanup.browse.review.merged', 'Merged') + case 'closed': + return translate('components.workspace.cleanup.browse.review.closed', 'Closed') + case 'unknown': + return translate('components.workspace.cleanup.browse.review.unknown', 'Unknown') + } +} + +/** Provider-general: GitLab MRs and Azure DevOps PRs are first-class here. */ +export function getWorkspaceCleanupReviewProviderLabel(provider: HostedReviewProvider): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'bitbucket': + return 'Bitbucket' + case 'azure-devops': + return 'Azure DevOps' + case 'gitea': + return 'Gitea' + case 'unsupported': + return translate('components.workspace.cleanup.browse.review.otherProvider', 'Other') + } +} + +export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTicketSource): string { + switch (source) { + case 'work-item': + return translate('components.workspace.cleanup.browse.ticket.workItem', 'Work item') + case 'linear': + return translate('components.workspace.cleanup.browse.ticket.linear', 'Linear') + case 'issue': + return translate('components.workspace.cleanup.browse.ticket.issue', 'Issue') + } +} + +export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string { + switch (tier) { + case 'ready': + return translate('components.workspace.cleanup.browse.tier.ready', 'Ready') + case 'review': + return translate('components.workspace.cleanup.browse.tier.review', 'Needs review') + case 'protected': + return translate('components.workspace.cleanup.browse.tier.protected', 'Protected') + } +} + +export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string { + switch (signal) { + case 'last-visited': + return translate('components.workspace.cleanup.browse.idleSignal.lastVisited', 'Not opened') + case 'last-activity': + return translate('components.workspace.cleanup.browse.idleSignal.lastActivity', 'No activity') + case 'created': + return translate('components.workspace.cleanup.browse.idleSignal.created', 'Created before') + } +} + +export function getWorkspaceCleanupTriStateLabel(value: WorkspaceCleanupTriState): string { + switch (value) { + case 'any': + return translate('components.workspace.cleanup.browse.triState.any', 'Any') + case 'only': + return translate('components.workspace.cleanup.browse.triState.only', 'Only') + case 'exclude': + return translate('components.workspace.cleanup.browse.triState.exclude', 'Exclude') + } +} + +export function getWorkspaceCleanupPresenceLabel(value: WorkspaceCleanupPresence): string { + switch (value) { + case 'any': + return translate('components.workspace.cleanup.browse.presence.any', 'Any') + case 'some': + return translate('components.workspace.cleanup.browse.presence.some', 'Has one') + case 'none': + return translate('components.workspace.cleanup.browse.presence.none', 'Has none') + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts new file mode 100644 index 00000000000..fa96014a86f --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts @@ -0,0 +1,211 @@ +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import type { + WorkspaceCleanupActivityFilter, + WorkspaceCleanupAgentFilter, + WorkspaceCleanupContextFilterState, + WorkspaceCleanupGitFilterState, + WorkspaceCleanupLocationFilter, + WorkspaceCleanupPresence, + WorkspaceCleanupReviewFilterState, + WorkspaceCleanupSafetyFilter, + WorkspaceCleanupSizeFilter, + WorkspaceCleanupStatusFilter, + WorkspaceCleanupTicketFilter, + WorkspaceCleanupTriState +} from '../../../../shared/workspace-cleanup-filter-model' + +export const WORKSPACE_CLEANUP_DAY_MS = 24 * 60 * 60 * 1000 + +export function matchesWorkspaceCleanupTriState( + filter: WorkspaceCleanupTriState, + value: boolean +): boolean { + return filter === 'any' || (filter === 'only' ? value : !value) +} + +export function matchesWorkspaceCleanupPresence( + filter: WorkspaceCleanupPresence, + value: boolean +): boolean { + return filter === 'any' || (filter === 'some' ? value : !value) +} + +export function matchesWorkspaceCleanupActivity( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupActivityFilter, + now: number +): boolean { + if (filter.neverVisited && facets.lastVisitedAt !== null) { + return false + } + if (filter.idleMinDays === null) { + return true + } + const signalAt = getIdleSignalAt(facets, filter) + // Why: a missing signal means Orca has no evidence of recent use, so it + // reads as maximally idle rather than being filtered out. + return signalAt === null || now - signalAt >= filter.idleMinDays * WORKSPACE_CLEANUP_DAY_MS +} + +export function matchesWorkspaceCleanupSize( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupSizeFilter +): boolean { + if (facets.sizeBytes === null) { + return filter.includeUnsized + } + if (filter.minBytes !== null && facets.sizeBytes < filter.minBytes) { + return false + } + return filter.maxBytes === null || facets.sizeBytes <= filter.maxBytes +} + +export function matchesWorkspaceCleanupStatus( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupStatusFilter +): boolean { + if (filter.workspaceStatuses.length > 0 && !matchesWorkspaceStatusList(facets, filter)) { + return false + } + return ( + matchesWorkspaceCleanupTriState(filter.archived, facets.isArchived) && + matchesWorkspaceCleanupTriState(filter.pinned, facets.isPinned) && + matchesWorkspaceCleanupTriState(filter.unread, facets.isUnread) && + matchesWorkspaceCleanupTriState(filter.comment, facets.hasComment) + ) +} + +export function matchesWorkspaceCleanupAgent( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupAgentFilter +): boolean { + if (filter.states.length > 0 && !filter.states.includes(facets.agentState)) { + return false + } + return matchesWorkspaceCleanupTriState( + filter.retainedDoneAgents, + facets.retainedDoneAgentCount > 0 + ) +} + +export function matchesWorkspaceCleanupGit( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupGitFilterState +): boolean { + if (filter.states.length > 0 && !filter.states.includes(facets.gitState)) { + return false + } + if (filter.minAhead !== null && (facets.upstreamAhead ?? 0) < filter.minAhead) { + return false + } + if (filter.minBehind !== null && (facets.upstreamBehind ?? 0) < filter.minBehind) { + return false + } + const branchQuery = filter.branchQuery.trim().toLowerCase() + if (branchQuery && !facets.branch.toLowerCase().includes(branchQuery)) { + return false + } + return ( + matchesWorkspaceCleanupTriState(filter.prunable, facets.isPrunable) && + matchesWorkspaceCleanupTriState(filter.locked, facets.isLocked) + ) +} + +export function matchesWorkspaceCleanupReview( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupReviewFilterState +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.review.hasReview)) { + return false + } + if (filter.states.length > 0) { + if (facets.reviewState === null || !filter.states.includes(facets.reviewState)) { + return false + } + } + if (filter.providers.length === 0) { + return true + } + return facets.review.provider !== null && filter.providers.includes(facets.review.provider) +} + +export function matchesWorkspaceCleanupTicket( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupTicketFilter +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.ticketSources.length > 0)) { + return false + } + return ( + filter.sources.length === 0 || + filter.sources.some((source) => facets.ticketSources.includes(source)) + ) +} + +export function matchesWorkspaceCleanupContext( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupContextFilterState +): boolean { + if (!matchesWorkspaceCleanupPresence(filter.presence, facets.hasLocalContext)) { + return false + } + return !filter.completelyEmpty || facets.isCompletelyEmpty +} + +export function matchesWorkspaceCleanupLocation( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupLocationFilter +): boolean { + if (filter.hostIds.length > 0 && !filter.hostIds.includes(facets.hostId)) { + return false + } + if (filter.repoIds.length > 0 && !filter.repoIds.includes(facets.repoId)) { + return false + } + const prefix = filter.pathPrefix.trim() + return prefix.length === 0 || facets.path.startsWith(prefix) +} + +export function matchesWorkspaceCleanupSafety( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupSafetyFilter +): boolean { + if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) { + return false + } + if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) { + return false + } + if (filter.selectableOnly && !facets.isSelectable) { + return false + } + if (filter.blockers.length === 0) { + return true + } + const hit = filter.blockers.some((blocker) => facets.blockers.includes(blocker)) + return filter.blockerMode === 'any-of' ? hit : !hit +} + +function matchesWorkspaceStatusList( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupStatusFilter +): boolean { + if (facets.workspaceStatus === null) { + return filter.matchStatusless + } + return filter.workspaceStatuses.includes(facets.workspaceStatus) +} + +function getIdleSignalAt( + facets: WorkspaceCleanupFacets, + filter: WorkspaceCleanupActivityFilter +): number | null { + switch (filter.idleSignal) { + case 'last-visited': + return facets.lastVisitedAt + case 'last-activity': + return facets.lastActivityAt + case 'created': + return facets.createdAt + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts new file mode 100644 index 00000000000..b079f7a203c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-panel-model.ts @@ -0,0 +1,37 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupFacetCounts = Record< + keyof Omit, + number +> + +/** Facet values discovered from the loaded fleet, not a hardcoded enum. */ +export type WorkspaceCleanupFacetOptions = { + workspaceStatuses: readonly { id: string; label: string }[] + hostIds: readonly ExecutionHostId[] + repos: readonly { id: string; label: string }[] + reviewProviders: readonly HostedReviewProvider[] +} + +export type WorkspaceCleanupFacetGroupProps = { + filters: WorkspaceCleanupFilterState + counts: WorkspaceCleanupFacetCounts + totalCount: number + options: WorkspaceCleanupFacetOptions + onPatch: ( + key: K, + value: Partial | WorkspaceCleanupFilterState[K] + ) => void +} + +export const WORKSPACE_CLEANUP_MEGABYTE = 1024 * 1024 + +export function toMegabytes(bytes: number | null): number | null { + return bytes === null ? null : Math.round(bytes / WORKSPACE_CLEANUP_MEGABYTE) +} + +export function fromMegabytes(megabytes: number | null): number | null { + return megabytes === null ? null : Math.round(megabytes * WORKSPACE_CLEANUP_MEGABYTE) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts new file mode 100644 index 00000000000..4da525a1352 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { + isWorkspaceCleanupAbsentLastSortField, + sortWorkspaceCleanupFacets +} from './workspace-cleanup-facet-sort' +import type { WorkspaceCleanupSortField } from '../../../../shared/workspace-cleanup-filter-model' + +function names( + rows: ReturnType[], + field: WorkspaceCleanupSortField, + direction: 'asc' | 'desc' +): string[] { + return sortWorkspaceCleanupFacets(rows, { field, direction }).map((row) => row.displayName) +} + +describe('absent values', () => { + it('sinks unsized workspaces to the bottom in both directions', () => { + const rows = [ + makeNamedFacets('unsized'), + makeNamedFacets('small', { sizeBytes: 10 }), + makeNamedFacets('big', { sizeBytes: 900 }) + ] + expect(names(rows, 'size', 'desc')).toEqual(['big', 'small', 'unsized']) + expect(names(rows, 'size', 'asc')).toEqual(['small', 'big', 'unsized']) + }) + + it('sinks never-visited workspaces to the bottom in both directions', () => { + const rows = [ + makeNamedFacets('never'), + makeNamedFacets('old', { lastVisitedAt: FACET_NOW - 50 * DAY }), + makeNamedFacets('recent', { lastVisitedAt: FACET_NOW }) + ] + expect(names(rows, 'last-visited', 'asc')).toEqual(['old', 'recent', 'never']) + expect(names(rows, 'last-visited', 'desc')).toEqual(['recent', 'old', 'never']) + }) + + it('sinks review-less and ticket-less workspaces to the bottom', () => { + const rows = [ + makeNamedFacets('none'), + makeNamedFacets('merged', { review: { state: 'merged' } }), + makeNamedFacets('open', { review: { state: 'open' } }) + ] + expect(names(rows, 'review', 'desc')).toEqual(['open', 'merged', 'none']) + + const ticketRows = [ + makeNamedFacets('untracked'), + makeNamedFacets('linear', { worktree: { linkedLinearIssue: 'STA-1' } }) + ] + expect(names(ticketRows, 'ticket', 'asc')).toEqual(['linear', 'untracked']) + }) + + it('marks exactly the optional-value fields as absent-last', () => { + expect(isWorkspaceCleanupAbsentLastSortField('size')).toBe(true) + expect(isWorkspaceCleanupAbsentLastSortField('name')).toBe(false) + }) +}) + +describe('ranked fields', () => { + it('orders git states from safest to riskiest', () => { + const rows = [ + makeNamedFacets('unpushed', { + candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } } + }), + makeNamedFacets('clean'), + makeNamedFacets('dirty', { + candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } } + }), + makeNamedFacets('unknown', { + candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } } + }) + ] + expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed']) + }) + + it('orders tiers ready to protected and agents idle to permission', () => { + const tierRows = [ + makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }), + makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }), + makeNamedFacets('reviewRow', { candidate: { tier: 'review' } }) + ] + expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow']) + + const agentRows = [ + makeNamedFacets('permission', { agentStatus: 'permission' }), + makeNamedFacets('idle'), + makeNamedFacets('working', { agentStatus: 'working' }) + ] + expect(names(agentRows, 'agent', 'desc')).toEqual(['permission', 'working', 'idle']) + }) + + it('breaks equal blocker counts by the worst blocker', () => { + const rows = [ + makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }), + makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } }) + ] + expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest']) + }) +}) + +describe('tie-breaks', () => { + it('falls back to activity, repo, name, then worktree id', () => { + const rows = [ + makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }), + makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + ] + // Same size on both sides, so only the tie-break chain can order them. + expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta']) + }) + + it('keeps tie-broken rows in the same order when direction flips', () => { + const rows = [ + makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }), + makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + ] + expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc')) + }) + + it('does not mutate the input array', () => { + const rows = [makeNamedFacets('b'), makeNamedFacets('a')] + const before = rows.map((row) => row.displayName) + sortWorkspaceCleanupFacets(rows, { field: 'name', direction: 'asc' }) + expect(rows.map((row) => row.displayName)).toEqual(before) + }) +}) + +describe('text fields', () => { + it('sorts by name, repo, path, branch, and host', () => { + const rows = [makeNamedFacets('charlie'), makeNamedFacets('alpha'), makeNamedFacets('bravo')] + expect(names(rows, 'name', 'asc')).toEqual(['alpha', 'bravo', 'charlie']) + expect(names(rows, 'path', 'desc')).toEqual(['charlie', 'bravo', 'alpha']) + expect(names(rows, 'branch', 'asc')).toEqual(['alpha', 'bravo', 'charlie']) + + const hostRows = [ + makeNamedFacets('remote', { worktree: { hostId: 'ssh:builder' } }), + makeNamedFacets('here', { worktree: { hostId: 'local' } }) + ] + expect(names(hostRows, 'host', 'asc')).toEqual(['here', 'remote']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts new file mode 100644 index 00000000000..29073828941 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -0,0 +1,190 @@ +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import { + getWorkspaceCleanupAgentRank, + getWorkspaceCleanupBlockerSeverity, + getWorkspaceCleanupGitRank, + getWorkspaceCleanupReviewRank, + getWorkspaceCleanupTierRank +} from '../../../../shared/workspace-cleanup-facet-rankings' +import type { + WorkspaceCleanupSortDirectionState, + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +/** + * Fields whose value can be absent. Absent rows always sink to the bottom in + * BOTH directions — flipping direction should reorder the rows that carry the + * signal, not promote the ones that carry nothing. + */ +const ABSENT_LAST_FIELDS: ReadonlySet = new Set([ + 'size', + 'last-visited', + 'created', + 'ahead', + 'behind', + 'workspace-status', + 'review', + 'ticket' +]) + +export function isWorkspaceCleanupAbsentLastSortField(field: WorkspaceCleanupSortField): boolean { + return ABSENT_LAST_FIELDS.has(field) +} + +export function sortWorkspaceCleanupFacets( + facets: readonly WorkspaceCleanupFacets[], + sort: WorkspaceCleanupSortState +): WorkspaceCleanupFacets[] { + const multiplier = sort.direction === 'asc' ? 1 : -1 + return [...facets].sort( + (left, right) => + comparePrimary(left, right, sort.field, multiplier) || compareTieBreaks(left, right) + ) +} + +export function compareWorkspaceCleanupFacets( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField, + direction: WorkspaceCleanupSortDirectionState +): number { + const multiplier = direction === 'asc' ? 1 : -1 + return comparePrimary(left, right, field, multiplier) || compareTieBreaks(left, right) +} + +function comparePrimary( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField, + multiplier: number +): number { + const absence = compareAbsence(left, right, field) + if (absence !== 0) { + return absence + } + return compareNatural(left, right, field) * multiplier +} + +/** Non-zero only when exactly one side is absent for an absent-last field. */ +function compareAbsence( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField +): number { + if (!ABSENT_LAST_FIELDS.has(field)) { + return 0 + } + const leftAbsent = isAbsent(left, field) + const rightAbsent = isAbsent(right, field) + if (leftAbsent === rightAbsent) { + return 0 + } + return leftAbsent ? 1 : -1 +} + +function isAbsent(facets: WorkspaceCleanupFacets, field: WorkspaceCleanupSortField): boolean { + if (field === 'size') { + return facets.sizeBytes === null + } + if (field === 'last-visited') { + return facets.lastVisitedAt === null + } + if (field === 'created') { + return facets.createdAt === null + } + if (field === 'ahead') { + return facets.upstreamAhead === null + } + if (field === 'behind') { + return facets.upstreamBehind === null + } + if (field === 'workspace-status') { + return facets.workspaceStatus === null + } + if (field === 'review') { + return !facets.review.hasReview + } + if (field === 'ticket') { + return facets.ticketSources.length === 0 + } + return false +} + +function compareNatural( + left: WorkspaceCleanupFacets, + right: WorkspaceCleanupFacets, + field: WorkspaceCleanupSortField +): number { + switch (field) { + case 'last-activity': + return left.lastActivityAt - right.lastActivityAt + case 'last-visited': + return (left.lastVisitedAt ?? 0) - (right.lastVisitedAt ?? 0) + case 'created': + return (left.createdAt ?? 0) - (right.createdAt ?? 0) + case 'size': + return (left.sizeBytes ?? 0) - (right.sizeBytes ?? 0) + case 'name': + return left.displayName.localeCompare(right.displayName) + case 'repo': + return left.repoName.localeCompare(right.repoName) + case 'path': + return left.path.localeCompare(right.path) + case 'host': + return left.hostId.localeCompare(right.hostId) + case 'workspace-status': + return (left.workspaceStatus ?? '').localeCompare(right.workspaceStatus ?? '') + case 'agent': + return ( + getWorkspaceCleanupAgentRank(left.agentState) - + getWorkspaceCleanupAgentRank(right.agentState) + ) + case 'git': + return getWorkspaceCleanupGitRank(left.gitState) - getWorkspaceCleanupGitRank(right.gitState) + case 'ahead': + return (left.upstreamAhead ?? 0) - (right.upstreamAhead ?? 0) + case 'behind': + return (left.upstreamBehind ?? 0) - (right.upstreamBehind ?? 0) + case 'branch': + return left.branch.localeCompare(right.branch) + case 'review': + return ( + getWorkspaceCleanupReviewRank(left.reviewState) - + getWorkspaceCleanupReviewRank(right.reviewState) || + (left.review.label ?? '').localeCompare(right.review.label ?? '') + ) + case 'ticket': + return ( + left.ticketSources.length - right.ticketSources.length || + (left.ticketSources[0] ?? '').localeCompare(right.ticketSources[0] ?? '') + ) + case 'local-context': + return left.localContextCount - right.localContextCount + case 'tier': + return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier) + case 'blocker-count': + return ( + left.blockerCount - right.blockerCount || + getMaxBlockerSeverity(left) - getMaxBlockerSeverity(right) + ) + } +} + +/** Direction-independent so a flipped sort never reshuffles equal rows. */ +function compareTieBreaks(left: WorkspaceCleanupFacets, right: WorkspaceCleanupFacets): number { + return ( + left.lastActivityAt - right.lastActivityAt || + left.repoName.localeCompare(right.repoName) || + left.displayName.localeCompare(right.displayName) || + left.worktreeId.localeCompare(right.worktreeId) + ) +} + +function getMaxBlockerSeverity(facets: WorkspaceCleanupFacets): number { + let max = 0 + for (const blocker of facets.blockers) { + max = Math.max(max, getWorkspaceCleanupBlockerSeverity(blocker)) + } + return max +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts new file mode 100644 index 00000000000..f2b0ef07a7c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet.test.fixture.ts @@ -0,0 +1,124 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupFacets, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets, + type WorkspaceCleanupWorktreeFacts +} from './workspace-cleanup-facets' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +export const FACET_NOW = 1_700_000_000_000 +export const DAY = 24 * 60 * 60 * 1000 + +export function makeFacetCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: 'repo-1::/repo/alpha', + repoId: 'repo-1', + repoName: 'Repo', + connectionId: null, + displayName: 'alpha', + branch: 'refs/heads/alpha', + path: '/repo/alpha', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: FACET_NOW - 40 * DAY, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: FACET_NOW }, + fingerprint: 'fingerprint', + ...overrides + } +} + +export function makeWorktreeFacts( + overrides: Partial = {} +): WorkspaceCleanupWorktreeFacts { + return { + id: 'repo-1::/repo/alpha', + branch: 'refs/heads/alpha', + comment: '', + isArchived: false, + isPinned: false, + isUnread: false, + linkedIssue: null, + linkedLinearIssue: null, + ...overrides + } +} + +export function makeReviewInfo( + overrides: Partial = {} +): WorkspaceCleanupReviewInfo { + return { + hasReview: true, + label: 'PR #42', + state: 'open', + provider: 'github', + title: 'Alpha cleanup', + ...overrides + } +} + +export type FacetFixtureInput = { + candidate?: Partial + worktree?: Partial | null + sizeBytes?: number + lastVisitedAt?: number + agentStatus?: LiveAgentWorktreeStatus + review?: Partial + dismissed?: boolean +} + +/** Builds through the real facet builder so tests exercise the production join. */ +export function makeFacets(input: FacetFixtureInput = {}): WorkspaceCleanupFacets { + const candidate = makeFacetCandidate(input.candidate) + const id = candidate.worktreeId + const sources: WorkspaceCleanupFacetSources = { + worktreeById: + input.worktree === null + ? new Map() + : new Map([[id, makeWorktreeFacts({ id, ...input.worktree })]]), + sizeBytesByWorktreeId: + input.sizeBytes === undefined ? new Map() : new Map([[id, input.sizeBytes]]), + lastVisitedAtByWorktreeId: + input.lastVisitedAt === undefined ? {} : { [id]: input.lastVisitedAt }, + liveAgentStatusByWorktreeId: + input.agentStatus === undefined ? new Map() : new Map([[id, input.agentStatus]]), + reviewInfoByWorktreeId: + input.review === undefined ? new Map() : new Map([[id, makeReviewInfo(input.review)]]), + dismissedWorktreeIds: new Set(input.dismissed ? [id] : []) + } + return buildWorkspaceCleanupFacets(candidate, sources) +} + +/** Distinct worktree/display identity so tie-break ordering is observable. */ +export function makeNamedFacets( + name: string, + input: FacetFixtureInput = {} +): WorkspaceCleanupFacets { + return makeFacets({ + ...input, + candidate: { + worktreeId: `repo-1::/repo/${name}`, + displayName: name, + path: `/repo/${name}`, + branch: `refs/heads/${name}`, + ...input.candidate + }, + worktree: + input.worktree === null + ? null + : { id: `repo-1::/repo/${name}`, branch: `refs/heads/${name}`, ...input.worktree } + }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts new file mode 100644 index 00000000000..4e77d0cb3d1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -0,0 +1,247 @@ +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { Worktree } from '../../../../shared/types' +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { + canSelectWorkspaceCleanupCandidate, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupTier +} from '../../../../shared/workspace-cleanup' +import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort' +import type { + WorkspaceCleanupAgentState, + WorkspaceCleanupGitState, + WorkspaceCleanupReviewState, + WorkspaceCleanupTicketSource +} from '../../../../shared/workspace-cleanup-filter-model' +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +/** Structural subset of `Worktree` — Pick keeps it in sync when the source type moves. */ +export type WorkspaceCleanupWorktreeFacts = Pick & + Partial< + Pick< + Worktree, + | 'workspaceStatus' + | 'isArchived' + | 'isPinned' + | 'isUnread' + | 'comment' + | 'hostId' + | 'branch' + | 'createdAt' + | 'linkedWorkItem' + | 'linkedLinearIssue' + | 'linkedIssue' + | 'locked' + | 'prunable' + > + > + +export type WorkspaceCleanupFacetSources = { + worktreeById?: ReadonlyMap + /** Absent entry = the space scan never ran for that worktree. */ + sizeBytesByWorktreeId?: ReadonlyMap + lastVisitedAtByWorktreeId?: Readonly> + liveAgentStatusByWorktreeId?: ReadonlyMap + reviewInfoByWorktreeId?: ReadonlyMap + dismissedWorktreeIds?: ReadonlySet +} + +export type WorkspaceCleanupFacets = { + candidate: WorkspaceCleanupCandidate + worktreeId: string + repoId: string + repoName: string + displayName: string + path: string + branch: string + hostId: ExecutionHostId + tier: WorkspaceCleanupTier + blockers: readonly WorkspaceCleanupBlocker[] + blockerCount: number + isDismissed: boolean + isSelectable: boolean + /** Background signal: ambient PTY/agent churn bumps this without a human. */ + lastActivityAt: number + createdAt: number | null + /** Honest "user opened it" signal; null when Orca never recorded a visit. */ + lastVisitedAt: number | null + sizeBytes: number | null + workspaceStatus: string | null + isArchived: boolean + isPinned: boolean + isUnread: boolean + hasComment: boolean + agentState: WorkspaceCleanupAgentState + retainedDoneAgentCount: number + gitState: WorkspaceCleanupGitState + upstreamAhead: number | null + upstreamBehind: number | null + isPrunable: boolean + isLocked: boolean + review: WorkspaceCleanupReviewInfo + reviewState: WorkspaceCleanupReviewState | null + ticketSources: readonly WorkspaceCleanupTicketSource[] + localContextCount: number + hasLocalContext: boolean + isCompletelyEmpty: boolean + searchText: string +} + +const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { + hasReview: false, + label: null, + state: null, + provider: null, + title: null +} + +export function buildWorkspaceCleanupFacets( + candidate: WorkspaceCleanupCandidate, + sources: WorkspaceCleanupFacetSources = {} +): WorkspaceCleanupFacets { + const worktree = sources.worktreeById?.get(candidate.worktreeId) ?? null + const review = sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO + const ticketSources = getTicketSources(worktree) + const localContextCount = getLocalContextCount(candidate) + const hasComment = (worktree?.comment ?? '').trim().length > 0 + const branch = getBranchDisplayName(worktree?.branch ?? candidate.branch) + const facets: Omit = { + candidate, + worktreeId: candidate.worktreeId, + repoId: candidate.repoId, + repoName: candidate.repoName, + displayName: candidate.displayName, + path: candidate.path, + branch, + hostId: worktree?.hostId ?? LOCAL_EXECUTION_HOST_ID, + tier: candidate.tier, + blockers: candidate.blockers, + blockerCount: candidate.blockers.length, + isDismissed: + (sources.dismissedWorktreeIds?.has(candidate.worktreeId) ?? false) || + candidate.blockers.includes('dismissed'), + isSelectable: canSelectWorkspaceCleanupCandidate(candidate), + lastActivityAt: candidate.lastActivityAt, + createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), + lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]), + sizeBytes: toFiniteOrNull(sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)), + workspaceStatus: normalizeStatus(worktree?.workspaceStatus), + isArchived: worktree?.isArchived ?? candidate.reasons.includes('archived'), + isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'), + isUnread: worktree?.isUnread ?? false, + hasComment, + agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle', + retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount, + gitState: getWorkspaceCleanupGitState(candidate), + upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead), + upstreamBehind: toFiniteOrNull(candidate.git.upstreamBehind), + isPrunable: worktree?.prunable ?? false, + isLocked: worktree?.locked ?? false, + review, + reviewState: review.hasReview ? (review.state ?? 'unknown') : null, + ticketSources, + localContextCount, + hasLocalContext: localContextCount > 0, + isCompletelyEmpty: + localContextCount === 0 && !review.hasReview && ticketSources.length === 0 && !hasComment + } + return { ...facets, searchText: buildSearchText(facets) } +} + +export function buildWorkspaceCleanupFacetList( + candidates: readonly WorkspaceCleanupCandidate[], + sources: WorkspaceCleanupFacetSources = {} +): WorkspaceCleanupFacets[] { + return candidates.map((candidate) => buildWorkspaceCleanupFacets(candidate, sources)) +} + +/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */ +export function buildWorkspaceCleanupSizeIndex( + worktrees: readonly WorkspaceSpaceWorktree[] | null | undefined +): Map { + const index = new Map() + for (const entry of worktrees ?? []) { + if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) { + index.set(entry.worktreeId, entry.sizeBytes) + } + } + return index +} + +export function buildWorkspaceCleanupWorktreeIndex( + worktreesByRepo: Readonly> +): Map { + const index = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + index.set(worktree.id, worktree) + } + } + return index +} + +function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number { + const context = candidate.localContext + return ( + context.terminalTabCount + + context.cleanEditorTabCount + + context.browserTabCount + + context.diffCommentCount + + context.retainedDoneAgentCount + ) +} + +function getTicketSources( + worktree: WorkspaceCleanupWorktreeFacts | null +): WorkspaceCleanupTicketSource[] { + if (!worktree) { + return [] + } + const sources: WorkspaceCleanupTicketSource[] = [] + if (worktree.linkedWorkItem != null) { + sources.push('work-item') + } + if ((worktree.linkedLinearIssue ?? '').length > 0) { + sources.push('linear') + } + if (worktree.linkedIssue != null) { + sources.push('issue') + } + return sources +} + +function buildSearchText(facets: Omit): string { + return [ + facets.displayName, + facets.repoName, + facets.branch, + facets.path, + facets.hostId, + facets.workspaceStatus, + facets.review.label, + facets.review.title, + facets.review.provider, + facets.gitState, + facets.tier, + ...facets.ticketSources, + ...facets.blockers + ] + .filter((value): value is string => Boolean(value)) + .join(' ') + .toLowerCase() +} + +function normalizeStatus(status: string | undefined): string | null { + const trimmed = (status ?? '').trim() + return trimmed.length > 0 ? trimmed : null +} + +function toFiniteOrNull(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function getBranchDisplayName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') || 'HEAD' +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx new file mode 100644 index 00000000000..f01a877074d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { HardDrive, Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { translate } from '@/i18n/i18n' +import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' +import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' +import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' + +export type WorkspaceCleanupSizeScanState = { + measuredCount: number + scanning: boolean + onRun: () => void +} + +export type WorkspaceCleanupGitEvidenceProgress = { + pendingCount: number + totalCount: number +} + +export function WorkspaceCleanupFilterBar({ + facetProps, + activeFacetGroupCount, + matchedCount, + hasActiveFilters, + sizeScan, + gitEvidence, + onQueryChange, + onClearFilters +}: { + facetProps: WorkspaceCleanupFacetGroupProps + activeFacetGroupCount: number + matchedCount: number + hasActiveFilters: boolean + sizeScan: WorkspaceCleanupSizeScanState + gitEvidence: WorkspaceCleanupGitEvidenceProgress + onQueryChange: (query: string) => void + onClearFilters: () => void +}): React.JSX.Element { + const { filters, totalCount } = facetProps + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={translate( + 'components.workspace.cleanup.browse.searchPlaceholder', + 'Search name, repo, branch, path, host' + )} + aria-label={translate( + 'components.workspace.cleanup.browse.searchLabel', + 'Search workspaces' + )} + className="h-8 pl-8 text-xs" + /> +
+ + + + + + + + + + +
+ + {translate( + 'components.workspace.cleanup.browse.showingCount', + 'Showing {{value0}} of {{value1}}', + { value0: matchedCount, value1: totalCount } + )} + + +
+
+
+ + + {translate( + 'components.workspace.cleanup.browse.showingCount', + 'Showing {{value0}} of {{value1}}', + { value0: matchedCount, value1: totalCount } + )} + + + {gitEvidence.pendingCount > 0 ? ( + + + {translate( + 'components.workspace.cleanup.browse.checkingGit', + 'Checking git status: {{value0}} left', + { value0: gitEvidence.pendingCount } + )} + + ) : null} + + {sizeScan.measuredCount === 0 ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts index ff597290bfd..e839de0c288 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts @@ -1,4 +1,5 @@ import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupGitState } from '../../../../shared/workspace-cleanup-filter-model' import type { WorkspaceCleanupContextFilter, WorkspaceCleanupFilters, @@ -94,20 +95,35 @@ export function sortWorkspaceCleanupCandidates( }) } +const GIT_STATE_LABELS: Record = { + unpushed: 'Unpushed', + unknown: 'Unknown', + clean: 'Clean', + dirty: 'Dirty' +} + export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string { + return GIT_STATE_LABELS[getWorkspaceCleanupGitState(candidate)] +} + +/** Unpushed outranks unknown: a lost commit is worse than an unreadable status. */ +export function getWorkspaceCleanupGitState( + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupGitState { if (hasUnpushedCommits(candidate)) { - return 'Unpushed' + return 'unpushed' } if (isGitStatusUnknown(candidate)) { - return 'Unknown' + return 'unknown' } if (candidate.git.clean === true) { - return 'Clean' + return 'clean' } - if (candidate.git.clean === false) { - return 'Dirty' - } - return 'Unknown' + return candidate.git.clean === false ? 'dirty' : 'unknown' +} + +export function hasWorkspaceCleanupUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean { + return hasUnpushedCommits(candidate) } function compareWorkspaceCleanupCandidates( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx new file mode 100644 index 00000000000..6cc8229ace5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { applyWorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-preset-state' +import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { runWorkspaceCleanupQuery } from './workspace-cleanup-query' +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +// One row per bucket the retired tab partition used to hide behind a tab. +const FLEET: WorkspaceCleanupFacets[] = [ + makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }), + makeNamedFacets('needs-review', { candidate: { tier: 'review' } }), + makeNamedFacets('protected-one', { + candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false } + }), + makeNamedFacets('ignored-one', { dismissed: true }) +] + +function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { + act(() => + root?.render( + ( + + )} + /> + ) + ) +} + +function renderedNames(): string[] { + // Buttons share `text-sm font-medium`, so scope to the row's name span. + return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map( + (node) => node.textContent ?? '' + ) +} + +function query(presetId: string) { + const preset = WORKSPACE_CLEANUP_BUILT_IN_PRESETS.find((entry) => entry.id === presetId) + if (!preset) { + throw new Error(`missing preset ${presetId}`) + } + return runWorkspaceCleanupQuery(FLEET, applyWorkspaceCleanupPreset(preset), FACET_NOW) +} + +describe('workspace cleanup flat list', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('renders every tier in one list rather than per-tab groups', () => { + const result = query('all') + + renderRows(result.rows) + + expect(result.totalCount).toBe(FLEET.length) + expect(result.matchedCount).toBe(FLEET.length) + expect(renderedNames()).toEqual( + expect.arrayContaining(['ready-one', 'needs-review', 'protected-one', 'ignored-one']) + ) + }) + + it('switches the visible rows when a preset is applied', () => { + const suggested = query('suggested') + renderRows(suggested.rows) + expect(renderedNames()).toEqual(['ready-one']) + + const ignored = query('ignored') + renderRows(ignored.rows) + expect(renderedNames()).toEqual(['ignored-one']) + + const protectedRows = query('protected') + renderRows(protectedRows.rows) + expect(renderedNames()).toEqual(['protected-one']) + }) + + it('reports only the rows the user could actually queue for deletion', () => { + expect(query('all').selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one') + }) + + it('shows a measured size on the row and nothing when the space scan never ran', () => { + renderRows(query('all').rows) + + expect(container?.textContent).toContain('4.00 MB') + const sizedRows = [...(container?.querySelectorAll('[aria-label^="Size on disk"]') ?? [])] + expect(sizedRows).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx new file mode 100644 index 00000000000..7a78c9fd597 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-freshness.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Loader2 } from 'lucide-react' +import { useNow } from '@/components/dashboard/useNow' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' + +/** + * Fixed-slot freshness line: "Updated 2h ago" for the data on screen plus a + * non-blocking "Refreshing" affordance while a background rescan streams. + */ +export function WorkspaceCleanupFreshness({ + scannedAt, + refreshing, + progress +}: { + scannedAt: number | null + refreshing: boolean + progress: WorkspaceCleanupScanProgress | null +}): React.JSX.Element | null { + const now = useNow(30_000) + if (scannedAt === null && !refreshing) { + return null + } + return ( + + {scannedAt !== null ? ( + + {translate('components.workspace.cleanup.browse.updatedAgo', 'Updated {{value0}}', { + value0: formatWorkspaceCleanupRelativeTime(scannedAt, now).toLowerCase() + })} + + ) : null} + {refreshing ? ( + + + ) : null} + + ) +} + +function formatWorkspaceCleanupRefreshingLabel( + progress: WorkspaceCleanupScanProgress | null +): string { + if (!progress || progress.totalWorktreeCount === 0) { + return translate('components.workspace.cleanup.browse.refreshing', 'Refreshing…') + } + return translate( + 'components.workspace.cleanup.browse.refreshingProgress', + 'Refreshing {{value0}}/{{value1}}', + { value0: progress.scannedWorktreeCount, value1: progress.totalWorktreeCount } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts new file mode 100644 index 00000000000..b2e63e5dfe5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture' +import { + applyWorkspaceCleanupGitEvidence, + hasWorkspaceCleanupGitEvidence, + needsWorkspaceCleanupGitEvidence, + selectWorkspaceCleanupGitEvidenceTargets +} from './workspace-cleanup-git-evidence' + +const CLEAN_SORT = { field: 'name', direction: 'asc' } as const + +function deferredCandidate(worktreeId: string) { + return makeFacetCandidate({ + worktreeId, + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) +} + +describe('needsWorkspaceCleanupGitEvidence', () => { + it('stays off for a filter and sort that never read git', () => { + expect( + needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), CLEAN_SORT) + ).toBe(false) + }) + + it.each([ + [ + 'git state', + (f: ReturnType) => { + f.git.states = ['dirty'] + } + ], + [ + 'ahead threshold', + (f: ReturnType) => { + f.git.minAhead = 1 + } + ], + [ + 'behind threshold', + (f: ReturnType) => { + f.git.minBehind = 1 + } + ], + [ + 'git-derived blocker', + (f: ReturnType) => { + f.safety.blockers = ['unpushed-commits'] + } + ] + ])('turns on for a %s filter', (_label, mutate) => { + const filters = createDefaultWorkspaceCleanupFilterState() + mutate(filters) + expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(true) + }) + + it.each(['git', 'ahead', 'behind'] as const)('turns on for the %s sort', (field) => { + expect( + needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), { + field, + direction: 'asc' + }) + ).toBe(true) + }) + + it('ignores a blocker filter that does not depend on git', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.safety.blockers = ['pinned'] + expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(false) + }) +}) + +describe('selectWorkspaceCleanupGitEvidenceTargets', () => { + it('only targets rows the host deferred', () => { + const targets = selectWorkspaceCleanupGitEvidenceTargets([ + deferredCandidate('a'), + makeFacetCandidate({ worktreeId: 'b' }) + ]) + expect(targets).toEqual(['a']) + expect(hasWorkspaceCleanupGitEvidence(makeFacetCandidate({ worktreeId: 'b' }))).toBe(true) + }) + + it('skips rows already attempted and caps the batch', () => { + const candidates = ['a', 'b', 'c'].map(deferredCandidate) + expect( + selectWorkspaceCleanupGitEvidenceTargets(candidates, { + resolvedWorktreeIds: new Set(['a']) + }) + ).toEqual(['b', 'c']) + expect(selectWorkspaceCleanupGitEvidenceTargets(candidates, { maxTargets: 2 })).toEqual([ + 'a', + 'b' + ]) + }) +}) + +describe('applyWorkspaceCleanupGitEvidence', () => { + it('replaces the deferred row with the focused re-scan result', () => { + const deferred = deferredCandidate('a') + const refreshed = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } + }) + const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]])) + expect(row.git.clean).toBe(false) + expect(row.git.checkedAt).toBe(5) + }) + + it('returns the input untouched when nothing was re-scanned', () => { + const candidates = [deferredCandidate('a')] + expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts new file mode 100644 index 00000000000..b39c716b049 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -0,0 +1,81 @@ +import type { + WorkspaceCleanupBlocker, + WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +/** + * The host defers git evidence for rows with no inactivity reason, so their + * `git.checkedAt` is null and the facet layer reads them as `unknown`. A + * git-dependent filter or sort would then quietly mislabel most of the list, + * so the dialog re-scans those rows on demand before trusting the answer. + */ +export function hasWorkspaceCleanupGitEvidence(candidate: WorkspaceCleanupCandidate): boolean { + return candidate.git.checkedAt !== null +} + +const GIT_SORT_FIELDS: ReadonlySet = new Set(['git', 'ahead', 'behind']) + +const GIT_DERIVED_BLOCKERS: ReadonlySet = new Set([ + 'dirty-files', + 'unpushed-commits', + 'git-status-error', + 'unknown-base' +]) + +/** Bounds how many git processes one filter toggle can spawn across a huge fleet. */ +export const WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS = 500 +export const WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY = 4 + +export function needsWorkspaceCleanupGitEvidence( + filters: WorkspaceCleanupFilterState, + sort: WorkspaceCleanupSortState +): boolean { + return ( + filters.git.states.length > 0 || + filters.git.minAhead !== null || + filters.git.minBehind !== null || + filters.safety.blockers.some((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) || + GIT_SORT_FIELDS.has(sort.field) + ) +} + +/** + * Ids still missing git evidence, capped and in list order so the rows the user + * is looking at resolve first. + */ +export function selectWorkspaceCleanupGitEvidenceTargets( + candidates: readonly WorkspaceCleanupCandidate[], + options: { + resolvedWorktreeIds?: ReadonlySet + maxTargets?: number + } = {} +): string[] { + const resolved = options.resolvedWorktreeIds ?? new Set() + const max = options.maxTargets ?? WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS + const targets: string[] = [] + for (const candidate of candidates) { + if (targets.length >= max) { + break + } + if (!hasWorkspaceCleanupGitEvidence(candidate) && !resolved.has(candidate.worktreeId)) { + targets.push(candidate.worktreeId) + } + } + return targets +} + +/** Focused re-scan results win over the deferred broad-scan row. */ +export function applyWorkspaceCleanupGitEvidence( + candidates: readonly WorkspaceCleanupCandidate[], + evidenceByWorktreeId: ReadonlyMap +): readonly WorkspaceCleanupCandidate[] { + if (evidenceByWorktreeId.size === 0) { + return candidates + } + return candidates.map((candidate) => evidenceByWorktreeId.get(candidate.worktreeId) ?? candidate) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx new file mode 100644 index 00000000000..cefd5993ece --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx @@ -0,0 +1,206 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import { + WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES, + WORKSPACE_CLEANUP_BLOCKER_VALUES, + WORKSPACE_CLEANUP_GIT_STATE_VALUES, + WORKSPACE_CLEANUP_PRESENCE_VALUES, + WORKSPACE_CLEANUP_REVIEW_STATE_VALUES, + WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, + WORKSPACE_CLEANUP_TIER_VALUES, + WORKSPACE_CLEANUP_TRI_STATE_VALUES +} from '../../../../shared/workspace-cleanup-facet-rankings' +import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels' +import { + FacetCheckbox, + FacetChoice, + FacetNumberField, + FacetSection, + FacetTextField, + FacetToggleList +} from './workspace-cleanup-facet-controls' +import { + getWorkspaceCleanupGitStateLabel, + getWorkspaceCleanupPresenceLabel, + getWorkspaceCleanupReviewProviderLabel, + getWorkspaceCleanupReviewStateLabel, + getWorkspaceCleanupTicketSourceLabel, + getWorkspaceCleanupTierLabel, + getWorkspaceCleanupTriStateLabel +} from './workspace-cleanup-facet-labels' +import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' + +/** Git, review/PR, tickets, location/host, and safety blockers. */ +export function WorkspaceCleanupGitReviewFacets({ + filters, + counts, + totalCount, + options, + onPatch +}: WorkspaceCleanupFacetGroupProps): React.JSX.Element { + return ( + <> + + onPatch('git', { states })} + /> + onPatch('git', { minAhead })} + /> + onPatch('git', { minBehind })} + /> + onPatch('git', { branchQuery })} + /> + onPatch('git', { prunable })} + /> + onPatch('git', { locked })} + /> + + + + onPatch('review', { presence })} + /> + onPatch('review', { states })} + /> + {options.reviewProviders.length > 0 ? ( + onPatch('review', { providers })} + /> + ) : null} + + + + onPatch('ticket', { presence })} + /> + onPatch('ticket', { sources })} + /> + + + + onPatch('location', { hostIds })} + /> + {options.repos.length > 1 ? ( + repo.id)} + selected={filters.location.repoIds} + getLabel={(id) => options.repos.find((repo) => repo.id === id)?.label ?? id} + onChange={(repoIds) => onPatch('location', { repoIds })} + /> + ) : null} + onPatch('location', { pathPrefix })} + /> + + + + onPatch('safety', { tiers })} + /> + onPatch('safety', { blockerMode })} + /> + onPatch('safety', { blockers })} + /> + onPatch('safety', { dismissed })} + /> + onPatch('safety', { selectableOnly })} + /> + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx new file mode 100644 index 00000000000..e532f5d6d7d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-lifecycle-facets.tsx @@ -0,0 +1,194 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import { + WORKSPACE_CLEANUP_AGENT_STATE_VALUES, + WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES, + WORKSPACE_CLEANUP_PRESENCE_VALUES, + WORKSPACE_CLEANUP_TRI_STATE_VALUES +} from '../../../../shared/workspace-cleanup-facet-rankings' +import { + FacetCheckbox, + FacetChoice, + FacetNumberField, + FacetSection, + FacetToggleList +} from './workspace-cleanup-facet-controls' +import { + getWorkspaceCleanupAgentStateLabel, + getWorkspaceCleanupIdleSignalLabel, + getWorkspaceCleanupPresenceLabel, + getWorkspaceCleanupTriStateLabel +} from './workspace-cleanup-facet-labels' +import { + fromMegabytes, + toMegabytes, + type WorkspaceCleanupFacetGroupProps +} from './workspace-cleanup-facet-panel-model' + +/** Activity, size, workspace status, agent state, and local context. */ +export function WorkspaceCleanupLifecycleFacets({ + filters, + counts, + totalCount, + options, + onPatch +}: WorkspaceCleanupFacetGroupProps): React.JSX.Element { + return ( + <> + + onPatch('activity', { idleSignal })} + /> + onPatch('activity', { idleMinDays })} + /> + onPatch('activity', { neverVisited })} + /> + + + + onPatch('size', { minBytes: fromMegabytes(megabytes) })} + /> + onPatch('size', { maxBytes: fromMegabytes(megabytes) })} + /> + onPatch('size', { includeUnsized })} + /> + + + + {options.workspaceStatuses.length > 0 ? ( + status.id)} + selected={filters.status.workspaceStatuses} + getLabel={(id) => + options.workspaceStatuses.find((status) => status.id === id)?.label ?? id + } + onChange={(workspaceStatuses) => onPatch('status', { workspaceStatuses })} + /> + ) : null} + onPatch('status', { matchStatusless })} + /> + onPatch('status', { archived })} + /> + onPatch('status', { pinned })} + /> + onPatch('status', { unread })} + /> + onPatch('status', { comment })} + /> + + + + onPatch('agent', { states })} + /> + onPatch('agent', { retainedDoneAgents })} + /> + + + + onPatch('context', { presence })} + /> + onPatch('context', { completelyEmpty })} + /> + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-preset-chips.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-preset-chips.test.tsx new file mode 100644 index 00000000000..0761a3dd265 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-preset-chips.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets' +import { WorkspaceCleanupPresetChips } from './workspace-cleanup-preset-chips' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function render(node: ReactNode): void { + act(() => root?.render(node)) +} + +function chip(presetId: string): HTMLButtonElement | null { + return container?.querySelector(`[data-preset-id="${presetId}"]`) ?? null +} + +describe('WorkspaceCleanupPresetChips', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('renders every preset as a chip instead of a fixed tab set', () => { + render( + + ) + + expect(container?.querySelectorAll('[data-preset-id]')).toHaveLength( + WORKSPACE_CLEANUP_BUILT_IN_PRESETS.length + ) + expect(chip('ignored')).not.toBeNull() + expect(chip('all')).not.toBeNull() + }) + + it('marks the matched preset active and the edited one modified', () => { + render( + + ) + + expect(chip('needs-review')?.getAttribute('data-preset-state')).toBe('modified') + expect(chip('needs-review')?.getAttribute('aria-pressed')).toBe('false') + expect(chip('suggested')?.getAttribute('data-preset-state')).toBe('idle') + }) + + it('applies the clicked preset', () => { + const onApplyPreset = vi.fn() + render( + + ) + + act(() => chip('largest')?.click()) + + expect(onApplyPreset).toHaveBeenCalledWith( + WORKSPACE_CLEANUP_BUILT_IN_PRESETS.find((preset) => preset.id === 'largest') + ) + }) + + it('only offers clear filters once something is filtered', () => { + const onClearFilters = vi.fn() + render( + + ) + expect(container?.textContent).not.toContain('Clear filters') + + render( + + ) + const clearButton = [...(container?.querySelectorAll('button') ?? [])].find((button) => + button.textContent?.includes('Clear filters') + ) + act(() => clearButton?.click()) + expect(onClearFilters).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-preset-chips.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-preset-chips.tsx new file mode 100644 index 00000000000..2876239a84a --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-preset-chips.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { RotateCcw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-presets' + +export function WorkspaceCleanupPresetChips({ + presets, + matchedPresetId, + requestedPresetId, + hasActiveFilters, + onApplyPreset, + onClearFilters +}: { + presets: readonly WorkspaceCleanupPreset[] + /** Preset the live filter state still equals, or null once it was edited. */ + matchedPresetId: string | null + /** Preset the user last picked, even after edits, so the chip can read "modified". */ + requestedPresetId: string | null + hasActiveFilters: boolean + onApplyPreset: (preset: WorkspaceCleanupPreset) => void + onClearFilters: () => void +}): React.JSX.Element { + return ( +
+ +
+ {presets.map((preset) => { + const active = matchedPresetId === preset.id + const modified = !active && requestedPresetId === preset.id + return ( + + + + + + {preset.descriptionKey + ? translate(preset.descriptionKey, preset.description) + : preset.description || preset.label} + + + ) + })} +
+ +
+ {hasActiveFilters ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presets.test.ts new file mode 100644 index 00000000000..cd188752e1d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presets.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { + createDefaultWorkspaceCleanupBrowseState, + normalizeWorkspaceCleanupBrowseState, + WORKSPACE_CLEANUP_BROWSE_STATE_VERSION +} from '../../../../shared/workspace-cleanup-browse-state' +import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture' +import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { + applyWorkspaceCleanupPreset, + createWorkspaceCleanupCustomPreset, + findWorkspaceCleanupPreset, + isWorkspaceCleanupFilterStateEqual, + listWorkspaceCleanupPresets, + matchWorkspaceCleanupPresetId +} from '../../../../shared/workspace-cleanup-preset-state' +import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets' +import { runWorkspaceCleanupQuery } from './workspace-cleanup-query' + +function applyPreset(presetId: string, rows: ReturnType[]): string[] { + const preset = findWorkspaceCleanupPreset(presetId) + expect(preset).not.toBeNull() + return runWorkspaceCleanupQuery(rows, applyWorkspaceCleanupPreset(preset!), FACET_NOW).rows.map( + (row) => row.displayName + ) +} + +const CORPUS = [ + makeNamedFacets('ready'), + makeNamedFacets('needsReview', { + candidate: { + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + } + }), + makeNamedFacets('locked', { candidate: { tier: 'protected', blockers: ['live-agent'] } }), + makeNamedFacets('ignored', { dismissed: true }), + makeNamedFacets('mergedClean', { review: { state: 'merged', provider: 'gitlab' } }), + makeNamedFacets('visited', { lastVisitedAt: FACET_NOW - 5 * DAY }), + makeNamedFacets('huge', { sizeBytes: 5_000, lastVisitedAt: FACET_NOW - 200 * DAY }) +] + +describe('built-in presets', () => { + it('exposes unique ids and i18n keys', () => { + const ids = WORKSPACE_CLEANUP_BUILT_IN_PRESETS.map((preset) => preset.id) + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toEqual([ + 'suggested', + 'needs-review', + 'protected', + 'ignored', + 'merged-review-clean', + 'never-opened', + 'largest', + 'stale-visits', + 'all' + ]) + for (const preset of WORKSPACE_CLEANUP_BUILT_IN_PRESETS) { + expect(preset.source).toBe('built-in') + expect(preset.labelKey).toBe(`components.workspace.cleanup.presets.${preset.id}.label`) + } + }) + + it('suggested keeps only selectable ready rows', () => { + // Equal lastActivityAt across the corpus, so the name tie-break decides order. + expect(applyPreset('suggested', CORPUS)).toEqual(['huge', 'mergedClean', 'ready', 'visited']) + }) + + it('needs review and protected split the non-selectable rows', () => { + expect(applyPreset('needs-review', CORPUS)).toEqual(['needsReview']) + expect(applyPreset('protected', CORPUS)).toEqual(['locked']) + }) + + it('ignored shows only dismissed rows', () => { + expect(applyPreset('ignored', CORPUS)).toEqual(['ignored']) + }) + + it('merged review & clean requires both a landed review and a clean tree', () => { + expect(applyPreset('merged-review-clean', CORPUS)).toEqual(['mergedClean']) + }) + + it('never opened ignores background activity', () => { + expect(applyPreset('never-opened', CORPUS)).not.toContain('visited') + expect(applyPreset('never-opened', CORPUS)).toContain('ready') + }) + + it('largest drops unsized rows and sorts descending', () => { + expect(applyPreset('largest', CORPUS)).toEqual(['huge']) + const sized = [ + makeNamedFacets('small', { sizeBytes: 1 }), + makeNamedFacets('big', { sizeBytes: 100 }) + ] + expect(applyPreset('largest', sized)).toEqual(['big', 'small']) + }) + + it('stale visits uses the visit signal, not background activity', () => { + expect(applyPreset('stale-visits', CORPUS)).toContain('huge') + expect(applyPreset('stale-visits', CORPUS)).not.toContain('visited') + }) + + it('all includes dismissed rows', () => { + expect(applyPreset('all', CORPUS)).toContain('ignored') + expect(applyPreset('all', CORPUS)).toHaveLength(CORPUS.length) + }) +}) + +describe('preset application', () => { + it('deep clones so editing applied state never mutates the preset', () => { + const preset = findWorkspaceCleanupPreset('suggested')! + const applied = applyWorkspaceCleanupPreset(preset) + applied.filters.safety.tiers.push('protected') + applied.sort.direction = 'desc' + expect(preset.filters.safety.tiers).toEqual(['ready']) + expect(preset.sort.direction).toBe('asc') + }) + + it('round-trips through preset matching', () => { + for (const preset of WORKSPACE_CLEANUP_BUILT_IN_PRESETS) { + expect(matchWorkspaceCleanupPresetId(applyWorkspaceCleanupPreset(preset))).toBe(preset.id) + } + }) + + it('reports no preset once the user edits away from one', () => { + const applied = applyWorkspaceCleanupPreset(findWorkspaceCleanupPreset('suggested')!) + applied.filters.git.branchQuery = 'feature' + expect(matchWorkspaceCleanupPresetId(applied)).toBeNull() + }) + + it('compares multi-selects as sets, not ordered lists', () => { + const left = createDefaultWorkspaceCleanupFilterState() + const right = createDefaultWorkspaceCleanupFilterState() + left.safety.blockers = ['pinned', 'live-agent'] + right.safety.blockers = ['live-agent', 'pinned'] + expect(isWorkspaceCleanupFilterStateEqual(left, right)).toBe(true) + right.safety.blockers = ['pinned'] + expect(isWorkspaceCleanupFilterStateEqual(left, right)).toBe(false) + }) +}) + +describe('custom presets', () => { + it('appends customs after built-ins and lets a custom id shadow a built-in', () => { + const custom = createWorkspaceCleanupCustomPreset({ + id: 'mine', + label: 'Mine', + state: applyWorkspaceCleanupPreset(findWorkspaceCleanupPreset('all')!), + createdAt: FACET_NOW + }) + const listed = listWorkspaceCleanupPresets([custom]) + expect(listed.at(-1)?.id).toBe('mine') + expect(listed.at(-1)?.source).toBe('custom') + + const shadow = { ...custom, id: 'suggested', label: 'My suggested' } + const shadowed = listWorkspaceCleanupPresets([shadow]) + expect(shadowed.filter((preset) => preset.id === 'suggested')).toHaveLength(1) + expect(findWorkspaceCleanupPreset('suggested', [shadow])?.label).toBe('My suggested') + }) +}) + +describe('persisted browse state', () => { + it('defaults to the suggested preset', () => { + const state = createDefaultWorkspaceCleanupBrowseState() + expect(state.version).toBe(WORKSPACE_CLEANUP_BROWSE_STATE_VERSION) + expect(state.activePresetId).toBe('suggested') + expect(state.customPresets).toEqual([]) + }) + + it('degrades unknown, missing, and corrupt values instead of throwing', () => { + expect(normalizeWorkspaceCleanupBrowseState(null)).toEqual( + createDefaultWorkspaceCleanupBrowseState() + ) + const normalized = normalizeWorkspaceCleanupBrowseState({ + activePresetId: 42, + sort: { field: 'from-the-future', direction: 'sideways' }, + filters: { + query: 7, + activity: { idleSignal: 'nope', idleMinDays: -5 }, + safety: { blockers: ['live-agent', 'live-agent', 'not-a-blocker'], dismissed: 'maybe' }, + unknownGroup: { anything: true } + }, + customPresets: 'nope' + }) + expect(normalized.activePresetId).toBeNull() + expect(normalized.sort).toEqual({ field: 'last-activity', direction: 'asc' }) + expect(normalized.filters.query).toBe('') + expect(normalized.filters.activity.idleSignal).toBe('last-visited') + expect(normalized.filters.activity.idleMinDays).toBe(0) + expect(normalized.filters.safety.blockers).toEqual(['live-agent']) + expect(normalized.filters.safety.dismissed).toBe('exclude') + expect(normalized.customPresets).toEqual([]) + expect(normalized).not.toHaveProperty('unknownGroup') + }) + + it('round-trips a saved state through JSON', () => { + const state = createDefaultWorkspaceCleanupBrowseState() + state.filters.activity.idleMinDays = 45 + state.filters.status.workspaceStatuses = ['in-review'] + state.customPresets = [ + createWorkspaceCleanupCustomPreset({ + id: 'mine', + label: 'Mine', + state: { filters: state.filters, sort: state.sort }, + createdAt: FACET_NOW + }) + ] + expect(normalizeWorkspaceCleanupBrowseState(JSON.parse(JSON.stringify(state)))).toEqual(state) + }) + + it('drops duplicate custom preset ids and entries without an id', () => { + const normalized = normalizeWorkspaceCleanupBrowseState({ + customPresets: [{ id: 'a', label: 'A' }, { id: 'a', label: 'B' }, { label: 'no id' }] + }) + expect(normalized.customPresets.map((preset) => preset.label)).toEqual(['A']) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts new file mode 100644 index 00000000000..0fa43b8a4ea --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts @@ -0,0 +1,130 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + buildWorkspaceCleanupFacetList, + type WorkspaceCleanupFacetSources, + type WorkspaceCleanupFacets +} from './workspace-cleanup-facets' +import { + matchesWorkspaceCleanupActivity, + matchesWorkspaceCleanupAgent, + matchesWorkspaceCleanupContext, + matchesWorkspaceCleanupGit, + matchesWorkspaceCleanupLocation, + matchesWorkspaceCleanupReview, + matchesWorkspaceCleanupSafety, + matchesWorkspaceCleanupSize, + matchesWorkspaceCleanupStatus, + matchesWorkspaceCleanupTicket +} from './workspace-cleanup-facet-matchers' +import { sortWorkspaceCleanupFacets } from './workspace-cleanup-facet-sort' +import type { + WorkspaceCleanupFilterState, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' + +export type WorkspaceCleanupQuery = { + filters: WorkspaceCleanupFilterState + sort: WorkspaceCleanupSortState +} + +export type WorkspaceCleanupQueryResult = { + /** Sorted rows that passed every facet. */ + rows: WorkspaceCleanupFacets[] + /** Rows the user could actually queue for deletion, for the select-all affordance. */ + selectableWorktreeIds: string[] + totalCount: number + matchedCount: number +} + +export function matchesWorkspaceCleanupFilters( + facets: WorkspaceCleanupFacets, + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): boolean { + const query = filters.query.trim().toLowerCase() + if (query && !facets.searchText.includes(query)) { + return false + } + return ( + matchesWorkspaceCleanupSafety(facets, filters.safety) && + matchesWorkspaceCleanupActivity(facets, filters.activity, now) && + matchesWorkspaceCleanupSize(facets, filters.size) && + matchesWorkspaceCleanupStatus(facets, filters.status) && + matchesWorkspaceCleanupAgent(facets, filters.agent) && + matchesWorkspaceCleanupGit(facets, filters.git) && + matchesWorkspaceCleanupReview(facets, filters.review) && + matchesWorkspaceCleanupTicket(facets, filters.ticket) && + matchesWorkspaceCleanupContext(facets, filters.context) && + matchesWorkspaceCleanupLocation(facets, filters.location) + ) +} + +export function filterWorkspaceCleanupFacets( + facets: readonly WorkspaceCleanupFacets[], + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): WorkspaceCleanupFacets[] { + return facets.filter((entry) => matchesWorkspaceCleanupFilters(entry, filters, now)) +} + +export function runWorkspaceCleanupQuery( + facets: readonly WorkspaceCleanupFacets[], + query: WorkspaceCleanupQuery, + now: number = Date.now() +): WorkspaceCleanupQueryResult { + const matched = filterWorkspaceCleanupFacets(facets, query.filters, now) + const rows = sortWorkspaceCleanupFacets(matched, query.sort) + return { + rows, + selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId), + totalCount: facets.length, + matchedCount: rows.length + } +} + +/** One-shot convenience for callers holding raw scan candidates. */ +export function queryWorkspaceCleanupCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + query: WorkspaceCleanupQuery, + sources: WorkspaceCleanupFacetSources = {}, + now: number = Date.now() +): WorkspaceCleanupQueryResult { + return runWorkspaceCleanupQuery(buildWorkspaceCleanupFacetList(candidates, sources), query, now) +} + +/** + * Per-facet-group match counts for badge/count rendering. Each entry counts the + * rows that would survive if only that group were applied, so a UI can show how + * much each facet is costing without re-running the whole pipeline per group. + */ +export function countWorkspaceCleanupFacetMatches( + facets: readonly WorkspaceCleanupFacets[], + filters: WorkspaceCleanupFilterState, + now: number = Date.now() +): Record, number> { + return { + activity: count(facets, (row) => matchesWorkspaceCleanupActivity(row, filters.activity, now)), + size: count(facets, (row) => matchesWorkspaceCleanupSize(row, filters.size)), + status: count(facets, (row) => matchesWorkspaceCleanupStatus(row, filters.status)), + agent: count(facets, (row) => matchesWorkspaceCleanupAgent(row, filters.agent)), + git: count(facets, (row) => matchesWorkspaceCleanupGit(row, filters.git)), + review: count(facets, (row) => matchesWorkspaceCleanupReview(row, filters.review)), + ticket: count(facets, (row) => matchesWorkspaceCleanupTicket(row, filters.ticket)), + context: count(facets, (row) => matchesWorkspaceCleanupContext(row, filters.context)), + location: count(facets, (row) => matchesWorkspaceCleanupLocation(row, filters.location)), + safety: count(facets, (row) => matchesWorkspaceCleanupSafety(row, filters.safety)) + } +} + +function count( + facets: readonly WorkspaceCleanupFacets[], + predicate: (facets: WorkspaceCleanupFacets) => boolean +): number { + let total = 0 + for (const entry of facets) { + if (predicate(entry)) { + total += 1 + } + } + return total +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts new file mode 100644 index 00000000000..d2886c5e955 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-relative-time.ts @@ -0,0 +1,19 @@ +/** Compact "how long ago" label for cleanup rows; 0 means Orca never recorded it. */ +export function formatWorkspaceCleanupRelativeTime(timestamp: number, now = Date.now()): string { + if (!timestamp) { + return 'Never' + } + const deltaMs = now - timestamp + if (deltaMs < 60_000) { + return 'Just now' + } + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) { + return `${minutes}m ago` + } + const hours = Math.floor(minutes / 60) + if (hours < 48) { + return `${hours}h ago` + } + return `${Math.floor(hours / 24)}d ago` +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx new file mode 100644 index 00000000000..63e882df4d6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { formatBytes } from '../status-bar/workspace-space-format' +import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' +import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' +import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' +import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' + +export type WorkspaceCleanupRowListState = { + rows: readonly WorkspaceCleanupFacets[] + /** Rows the scan produced before filtering; separates "empty fleet" from "no matches". */ + scannedCount: number + hasScanned: boolean + loading: boolean + deletionPhaseByWorktreeId: Record + deletingWorktreeIds: ReadonlySet + expandedRowIds: ReadonlySet + selectedIds: ReadonlySet + gitPendingWorktreeIds: ReadonlySet + rowFailures: Record + removalInFlight: boolean + scrollElement: HTMLDivElement | null + onClearFilters: () => void + onToggleExpanded: (worktreeId: string) => void + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void +} + +export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element { + const { rows, loading, hasScanned, scannedCount } = props + const settled = !loading && hasScanned + return ( + <> + {settled && scannedCount === 0 ? ( + + ) : null} + {hasScanned && scannedCount > 0 && rows.length === 0 ? ( + + ) : null} + ( + 1 && index === rows.length - 1} + expanded={props.expandedRowIds.has(row.worktreeId)} + lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt)} + sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)} + gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)} + deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]} + // Why: a background rescan must not lock rows; only an actual + // removal batch or this row's own deletion disables it. + removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)} + selected={ + props.selectedIds.has(row.worktreeId) && + !props.deletingWorktreeIds.has(row.worktreeId) + } + failure={props.rowFailures[row.worktreeId]} + onToggleExpanded={props.onToggleExpanded} + onToggleSelected={props.onToggleSelected} + onView={props.onView} + onIgnore={props.onIgnore} + onRemove={props.onRemove} + /> + )} + /> + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts new file mode 100644 index 00000000000..869fb3d5de2 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts @@ -0,0 +1,83 @@ +import { translate } from '@/i18n/i18n' +import type { + WorkspaceCleanupScanError, + WorkspaceCleanupScanProgress +} from '../../../../shared/workspace-cleanup' + +function isDisconnectedRemoteScanError(message: string): boolean { + return ( + message === 'SSH provider is unavailable.' || + message === 'Remote workspaces are not connected. Reconnect and refresh to check them.' + ) +} + +export function formatWorkspaceCleanupScanNotice( + errors: readonly WorkspaceCleanupScanError[], + repoNameById: ReadonlyMap +): string | null { + const visibleErrors = errors.filter( + (error) => !isDisconnectedRemoteScanError(error.message ?? '') + ) + if (visibleErrors.length === 0) { + return null + } + if (visibleErrors.length === 1) { + const error = visibleErrors[0] + const repoName = formatScanErrorRepoName(error, repoNameById) + return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some workspaces may be missing. Refresh to try again.` + } + const repoNames = visibleErrors + .slice(0, 3) + .map((error) => formatScanErrorRepoName(error, repoNameById)) + .join(', ') + const moreCount = visibleErrors.length - 3 + const suffix = moreCount > 0 ? `, +${moreCount} more` : '' + return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some workspaces may be missing. Refresh to try again.` +} + +export function formatWorkspaceCleanupScanProgress( + progress: WorkspaceCleanupScanProgress | null +): string { + if (!progress || progress.scannedWorktreeCount === 0) { + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', + 'Finding inactive workspaces...' + ) + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', + 'Checked workspaces so far: {{value0}}', + { value0: progress.scannedWorktreeCount } + ) +} + +export function formatWorkspaceCleanupReadyToast( + inactiveCount: number, + suggestedCount: number +): string { + if (inactiveCount === 0) { + return 'No workspaces found.' + } + const workspaceNoun = inactiveCount === 1 ? 'workspace' : 'workspaces' + const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions' + return `${inactiveCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` +} + +function formatScanErrorRepoName( + error: Partial, + repoNameById: ReadonlyMap +): string { + const repoName = error.repoName?.trim() + if (repoName) { + return repoName + } + const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : '' + return fallback || 'a repository' +} + +function formatScanErrorReason(message: string | undefined): string { + if (!message || message === 'Could not scan workspace cleanup for this repository.') { + return 'Git could not list worktrees' + } + return message.replace(/\.$/, '') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx new file mode 100644 index 00000000000..ed97cc39912 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' +import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function render(sort: WorkspaceCleanupSortState, handlers: Record = {}): void { + act(() => + root?.render( + + ) + ) +} + +function header(field: string): HTMLButtonElement | null { + return container?.querySelector(`[data-sort-field="${field}"]`) ?? null +} + +describe('WorkspaceCleanupSortHeader', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('marks the active sort column', () => { + render({ field: 'size', direction: 'desc' }) + + expect(header('size')?.getAttribute('aria-pressed')).toBe('true') + expect(header('name')?.getAttribute('aria-pressed')).toBe('false') + }) + + it('routes a column click through the sort toggle', () => { + const onToggleSortField = vi.fn() + render({ field: 'last-activity', direction: 'asc' }, { onToggleSortField }) + + act(() => header('repo')?.click()) + + expect(onToggleSortField).toHaveBeenCalledWith('repo') + }) + + it('select-all reads the query result rather than the rendered page', () => { + const onToggleSelectAll = vi.fn() + render({ field: 'name', direction: 'asc' }, { onToggleSelectAll }) + + const checkbox = container?.querySelector('[role="checkbox"]') + expect(checkbox?.getAttribute('aria-checked')).toBe('false') + act(() => checkbox?.click()) + + expect(onToggleSelectAll).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx new file mode 100644 index 00000000000..5bf8f6e3896 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx @@ -0,0 +1,151 @@ +import React from 'react' +import { ArrowDown, ArrowUp, Check, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { WORKSPACE_CLEANUP_SORT_FIELD_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings' +import type { + WorkspaceCleanupSortField, + WorkspaceCleanupSortState +} from '../../../../shared/workspace-cleanup-filter-model' +import { getWorkspaceCleanupSortFieldLabel } from './workspace-cleanup-facet-labels' + +/** Columns the flat list actually renders; the rest stay in the overflow menu. */ +const PRIMARY_SORT_FIELDS: readonly WorkspaceCleanupSortField[] = [ + 'name', + 'repo', + 'git', + 'review', + 'size', + 'last-activity' +] + +export function WorkspaceCleanupSortHeader({ + sort, + selectableCount, + selectedCount, + onToggleSortField, + onSetSort, + onToggleSelectAll +}: { + sort: WorkspaceCleanupSortState + selectableCount: number + selectedCount: number + onToggleSortField: (field: WorkspaceCleanupSortField) => void + onSetSort: (next: WorkspaceCleanupSortState) => void + onToggleSelectAll: (selectAll: boolean) => void +}): React.JSX.Element { + const allSelected = selectableCount > 0 && selectedCount >= selectableCount + return ( +
+ +
+ {PRIMARY_SORT_FIELDS.map((field) => ( + + ))} +
+ + + + + + + {translate('components.workspace.cleanup.browse.sortBy', 'Sort by')} + + {WORKSPACE_CLEANUP_SORT_FIELD_VALUES.map((field) => ( + + onSetSort({ + field, + direction: + sort.field === field && sort.direction === 'asc' ? 'desc' : sort.direction + }) + } + > + {getWorkspaceCleanupSortFieldLabel(field)} + {sort.field === field ? : null} + + ))} + + +
+ ) +} + +function SortHeaderButton({ + field, + sort, + onToggleSortField +}: { + field: WorkspaceCleanupSortField + sort: WorkspaceCleanupSortState + onToggleSortField: (field: WorkspaceCleanupSortField) => void +}): React.JSX.Element { + const active = sort.field === field + const label = getWorkspaceCleanupSortFieldLabel(field) + return ( + + ) +} + +function SortDirectionIcon({ direction }: { direction: 'asc' | 'desc' }): React.JSX.Element { + return direction === 'asc' ? ( +