From 64b2bfb6749b45e55bf0e457d3f44d6800cb7c80 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:38:10 -0700 Subject: [PATCH] fix(workspaces): reconcile cleanup dialog state --- .../WorkspaceCleanupDialog.tsx | 23 ++-- .../use-workspace-cleanup-facet-rows.ts | 3 +- ...se-workspace-cleanup-git-evidence.test.tsx | 111 ++++++++++++++++++ .../use-workspace-cleanup-git-evidence.ts | 43 +++++-- .../workspace-cleanup-browse-toolbar.tsx | 4 +- .../workspace-cleanup-facet-controls.test.tsx | 9 ++ .../workspace-cleanup-facet-controls.tsx | 15 +-- .../workspace-cleanup-facet-filter.test.ts | 8 +- .../workspace-cleanup-facet-labels.ts | 7 ++ .../workspace-cleanup-facet-panel-model.ts | 9 ++ .../workspace-cleanup-facet-sort.ts | 2 +- .../workspace-cleanup-facets.ts | 4 + .../workspace-cleanup-git-evidence.test.ts | 34 ++++++ .../workspace-cleanup-git-evidence.ts | 20 +++- .../workspace-cleanup-git-review-facets.tsx | 2 + .../workspace-cleanup-sort-header.test.tsx | 8 ++ .../workspace-cleanup-sort-header.tsx | 11 +- src/renderer/src/i18n/locales/en.json | 2 + 18 files changed, 278 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index e0d0b4f3ec4..b011abd1007 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -112,7 +112,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort) - const gitEvidence = useWorkspaceCleanupGitEvidence({ enabled: gitEvidenceNeeded, candidates }) + const gitEvidence = useWorkspaceCleanupGitEvidence({ + enabled: gitEvidenceNeeded, + candidates, + scannedAt: scan?.scannedAt ?? null + }) const evidencedCandidates = useMemo( () => applyWorkspaceCleanupGitEvidence(candidates, gitEvidence.evidenceByWorktreeId), [candidates, gitEvidence.evidenceByWorktreeId] @@ -253,17 +257,19 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setSelectedIds((current) => toggleSetMember(current, worktreeId)) }, []) - const selectableWorktreeIds = facetRows.selectableWorktreeIds + const selectableWorktreeIds = useMemo( + () => + removal.removalInFlight + ? [] + : facetRows.selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id)), + [deletingWorktreeIds, facetRows.selectableWorktreeIds, removal.removalInFlight] + ) const toggleSelectAll = useCallback( (selectAll: boolean) => { selectionTouchedRef.current = true - setSelectedIds( - selectAll - ? new Set(selectableWorktreeIds.filter((id) => !deletingWorktreeIds.has(id))) - : new Set() - ) + setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set()) }, - [deletingWorktreeIds, selectableWorktreeIds] + [selectableWorktreeIds] ) // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow @@ -334,6 +340,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ({ scan: vi.fn() })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { scanWorkspaceCleanup: typeof holders.scan }) => unknown) => + selector({ scanWorkspaceCleanup: holders.scan }) +})) + +type PendingScan = { + worktreeId: string + resolve: (result: WorkspaceCleanupScanResult) => void +} + +function deferredCandidate(worktreeId: string) { + return makeFacetCandidate({ + worktreeId, + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) +} + +describe('useWorkspaceCleanupGitEvidence', () => { + let pending: PendingScan[] + + beforeEach(() => { + pending = [] + holders.scan.mockReset() + holders.scan.mockImplementation(({ worktreeId }: { worktreeId: string }) => { + return new Promise((resolve) => { + pending.push({ worktreeId, resolve }) + }) + }) + }) + + it('counts queued rows and isolates a restarted pass from stale settlements', async () => { + const candidates = ['a', 'b', 'c', 'd', 'e', 'f'].map(deferredCandidate) + const view = renderHook( + ({ enabled }: { enabled: boolean }) => + useWorkspaceCleanupGitEvidence({ enabled, candidates, scannedAt: 1 }), + { initialProps: { enabled: true } } + ) + + await waitFor(() => expect(view.result.current.totalCount).toBe(6)) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + expect(holders.scan).toHaveBeenCalledTimes(4) + + view.rerender({ enabled: false }) + await waitFor(() => expect(view.result.current.pendingWorktreeIds.size).toBe(0)) + view.rerender({ enabled: true }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(8)) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + + await act(async () => { + pending[0]?.resolve({ + scannedAt: 2, + candidates: [makeFacetCandidate({ worktreeId: pending[0].worktreeId })], + errors: [] + }) + }) + expect(view.result.current.pendingWorktreeIds.size).toBe(6) + expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + + await act(async () => { + pending[4]?.resolve({ + scannedAt: 3, + candidates: [makeFacetCandidate({ worktreeId: pending[4].worktreeId })], + errors: [] + }) + }) + await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + expect(view.result.current.pendingWorktreeIds.size).toBe(5) + }) + + it('restarts evidence collection when the settled scan snapshot changes', async () => { + const candidates = [deferredCandidate('a')] + const view = renderHook( + ({ scannedAt }: { scannedAt: number }) => + useWorkspaceCleanupGitEvidence({ enabled: true, candidates, scannedAt }), + { initialProps: { scannedAt: 1 } } + ) + + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(1)) + view.rerender({ scannedAt: 2 }) + await waitFor(() => expect(holders.scan).toHaveBeenCalledTimes(2)) + + await act(async () => { + pending[0]?.resolve({ + scannedAt: 1, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + expect(view.result.current.evidenceByWorktreeId.size).toBe(0) + expect(view.result.current.pendingWorktreeIds).toEqual(new Set(['a'])) + + await act(async () => { + pending[1]?.resolve({ + scannedAt: 2, + candidates: [makeFacetCandidate({ worktreeId: 'a' })], + errors: [] + }) + }) + await waitFor(() => expect(view.result.current.evidenceByWorktreeId.size).toBe(1)) + }) +}) 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 index 0d5b61ce870..05177cea921 100644 --- 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 @@ -29,10 +29,12 @@ const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = { */ export function useWorkspaceCleanupGitEvidence({ enabled, - candidates + candidates, + scannedAt }: { enabled: boolean candidates: readonly WorkspaceCleanupCandidate[] + scannedAt: number | null }): WorkspaceCleanupGitEvidenceState { const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) const mountedRef = useMountedRef() @@ -40,8 +42,11 @@ export function useWorkspaceCleanupGitEvidence({ const evidenceRef = useRef(new Map()) const attemptedRef = useRef(new Set()) const queueRef = useRef([]) + const queuedRef = useRef(new Set()) const inFlightRef = useRef(new Set()) const totalRef = useRef(0) + const enabledRef = useRef(enabled) + const scannedAtRef = useRef(scannedAt) // 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) @@ -52,8 +57,8 @@ export function useWorkspaceCleanupGitEvidence({ } setState({ evidenceByWorktreeId: new Map(evidenceRef.current), - pendingWorktreeIds: new Set(inFlightRef.current), - checkedCount: totalRef.current - queueRef.current.length - inFlightRef.current.size, + pendingWorktreeIds: new Set([...queuedRef.current, ...inFlightRef.current]), + checkedCount: totalRef.current - queuedRef.current.size - inFlightRef.current.size, totalCount: totalRef.current }) }, [mountedRef]) @@ -68,13 +73,18 @@ export function useWorkspaceCleanupGitEvidence({ if (worktreeId === undefined) { break } + queuedRef.current.delete(worktreeId) inFlightRef.current.add(worktreeId) attemptedRef.current.add(worktreeId) void scanWorkspaceCleanup({ worktreeId }) .then((result) => { - const refreshed = result.candidates.find( - (candidate) => candidate.worktreeId === worktreeId - ) + let refreshed: WorkspaceCleanupCandidate | undefined + for (const candidate of result.candidates) { + if (candidate.worktreeId === worktreeId) { + refreshed = candidate + break + } + } if (refreshed && generation === generationRef.current) { evidenceRef.current.set(worktreeId, refreshed) } @@ -84,10 +94,10 @@ export function useWorkspaceCleanupGitEvidence({ // attemptedRef keeps it from retrying in a loop. }) .finally(() => { - inFlightRef.current.delete(worktreeId) if (generation !== generationRef.current) { return } + inFlightRef.current.delete(worktreeId) publish() pump() }) @@ -96,24 +106,37 @@ export function useWorkspaceCleanupGitEvidence({ }, [publish, scanWorkspaceCleanup]) useEffect(() => { - if (!enabled) { + const wasEnabled = enabledRef.current + const snapshotChanged = scannedAtRef.current !== scannedAt + enabledRef.current = enabled + scannedAtRef.current = scannedAt + + if ((wasEnabled && !enabled) || (enabled && snapshotChanged)) { generationRef.current += 1 queueRef.current = [] + queuedRef.current.clear() inFlightRef.current.clear() + attemptedRef.current.clear() + evidenceRef.current.clear() totalRef.current = 0 publish() + } + if (!enabled) { return } const targets = selectWorkspaceCleanupGitEvidenceTargets(candidates, { resolvedWorktreeIds: attemptedRef.current - }).filter((id) => !queueRef.current.includes(id)) + }).filter((id) => !queuedRef.current.has(id) && !inFlightRef.current.has(id)) if (targets.length === 0) { return } queueRef.current.push(...targets) + for (const target of targets) { + queuedRef.current.add(target) + } totalRef.current += targets.length pump() - }, [candidates, enabled, pump, publish]) + }, [candidates, enabled, pump, publish, scannedAt]) return state } 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 index 5cbbb126082..cc74a92fb18 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -13,6 +13,7 @@ import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header' export function WorkspaceCleanupBrowseToolbar({ browse, facetRows, + selectableCount, selectedCount, spaceScanning, gitPendingCount, @@ -22,6 +23,7 @@ export function WorkspaceCleanupBrowseToolbar({ }: { browse: WorkspaceCleanupBrowseController facetRows: WorkspaceCleanupFacetRows + selectableCount: number selectedCount: number spaceScanning: boolean gitPendingCount: number @@ -54,7 +56,7 @@ export function WorkspaceCleanupBrowseToolbar({ /> { expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' }) }) + it('labels blocker matching without exposing model tokens', () => { + renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock()) + + expect(control('Blocker match: Has any')).not.toBeNull() + expect(control('Blocker match: Has none')).not.toBeNull() + expect(container?.textContent).not.toContain('any-of') + expect(container?.textContent).not.toContain('none-of') + }) + it('toggles the unsized escape hatch for the opt-in space scan', () => { const onPatch = createPatchMock() renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch) 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 index 5853c6bd16d..d4220ac4504 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.tsx @@ -3,6 +3,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' +import { parseWorkspaceCleanupFacetNumber } from './workspace-cleanup-facet-panel-model' /** One facet group inside the filter panel, with its own match count. */ export function FacetSection({ @@ -48,11 +49,12 @@ export function FacetToggleList({ getLabel: (value: T) => string onChange: (next: T[]) => void }): React.JSX.Element { + const selectedSet = new Set(selected) return (
{values.map((value) => { - const active = selected.includes(value) + const active = selectedSet.has(value) return (
) } - -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 index 5446a967900..75b38714fd2 100644 --- 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 @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { buildWorkspaceCleanupFacetList, buildWorkspaceCleanupSizeIndex, - buildWorkspaceCleanupWorktreeIndex + buildWorkspaceCleanupWorktreeIndex, + countWorkspaceCleanupMeasuredRows } from './workspace-cleanup-facets' import { DAY, @@ -69,6 +70,11 @@ describe('facet building', () => { expect(index.has('b')).toBe(false) }) + it('counts measurements only for rows in the current fleet', () => { + const rows = [makeFacets({ sizeBytes: 10 }), makeNamedFacets('unsized')] + expect(countWorkspaceCleanupMeasuredRows(rows)).toBe(1) + }) + it('indexes worktrees across repos by id', () => { const index = buildWorkspaceCleanupWorktreeIndex({ 'repo-1': [{ id: 'w1' }], 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 index e9ddc69936d..9ea2cd09adf 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -3,6 +3,7 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupAgentState, + WorkspaceCleanupBlockerMode, WorkspaceCleanupGitState, WorkspaceCleanupIdleSignal, WorkspaceCleanupPresence, @@ -166,3 +167,9 @@ export function getWorkspaceCleanupPresenceLabel(value: WorkspaceCleanupPresence return translate('components.workspace.cleanup.browse.presence.none', 'Has none') } } + +export function getWorkspaceCleanupBlockerModeLabel(value: WorkspaceCleanupBlockerMode): string { + return value === 'any-of' + ? translate('components.workspace.cleanup.browse.blockerModeAny', 'Has any') + : translate('components.workspace.cleanup.browse.blockerModeNone', 'Has none') +} 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 index b079f7a203c..60a097a00e1 100644 --- 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 @@ -35,3 +35,12 @@ export function toMegabytes(bytes: number | null): number | null { export function fromMegabytes(megabytes: number | null): number | null { return megabytes === null ? null : Math.round(megabytes * WORKSPACE_CLEANUP_MEGABYTE) } + +export function parseWorkspaceCleanupFacetNumber(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-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts index 29073828941..50787aa6bd9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -37,7 +37,7 @@ export function sortWorkspaceCleanupFacets( sort: WorkspaceCleanupSortState ): WorkspaceCleanupFacets[] { const multiplier = sort.direction === 'asc' ? 1 : -1 - return [...facets].sort( + return facets.toSorted( (left, right) => comparePrimary(left, right, sort.field, multiplier) || compareTieBreaks(left, right) ) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index e848c947923..fb4dbadebad 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -174,6 +174,10 @@ export function buildWorkspaceCleanupSizeIndex( return index } +export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanupFacets[]): number { + return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0) +} + export function buildWorkspaceCleanupWorktreeIndex( worktreesByRepo: Readonly> ): Map { 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 index b2e63e5dfe5..bfcc1a087a4 100644 --- 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 @@ -111,4 +111,38 @@ describe('applyWorkspaceCleanupGitEvidence', () => { const candidates = [deferredCandidate('a')] expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates) }) + + it('merges only git facts into the current broad-scan row', () => { + const current = deferredCandidate('a') + current.displayName = 'current name' + current.blockers = ['dismissed'] + current.fingerprint = 'current-fingerprint' + const refreshed = makeFacetCandidate({ + worktreeId: 'a', + displayName: 'stale name', + blockers: ['dirty-files'], + fingerprint: 'focused-fingerprint', + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 5 } + }) + + const [row] = applyWorkspaceCleanupGitEvidence([current], new Map([['a', refreshed]])) + + expect(row.displayName).toBe('current name') + expect(row.fingerprint).toBe('current-fingerprint') + expect(row.blockers).toEqual(expect.arrayContaining(['dismissed', 'dirty-files'])) + expect(row.git).toEqual(refreshed.git) + }) + + it('keeps newer broad-scan git evidence', () => { + const current = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 10 } + }) + const older = makeFacetCandidate({ + worktreeId: 'a', + git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 } + }) + + expect(applyWorkspaceCleanupGitEvidence([current], new Map([['a', older]]))[0]).toBe(current) + }) }) 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 index b39c716b049..90b64cf511f 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -2,6 +2,7 @@ import type { WorkspaceCleanupBlocker, WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { applyWorkspaceCleanupPolicy } from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupFilterState, WorkspaceCleanupSortField, @@ -77,5 +78,22 @@ export function applyWorkspaceCleanupGitEvidence( if (evidenceByWorktreeId.size === 0) { return candidates } - return candidates.map((candidate) => evidenceByWorktreeId.get(candidate.worktreeId) ?? candidate) + return candidates.map((candidate) => { + const evidence = evidenceByWorktreeId.get(candidate.worktreeId) + if ( + evidence === undefined || + (candidate.git.checkedAt !== null && + candidate.git.checkedAt >= (evidence.git.checkedAt ?? Number.NEGATIVE_INFINITY)) + ) { + return candidate + } + return applyWorkspaceCleanupPolicy({ + ...candidate, + blockers: [ + ...candidate.blockers.filter((blocker) => !GIT_DERIVED_BLOCKERS.has(blocker)), + ...evidence.blockers.filter((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) + ], + git: evidence.git + }) + }) } 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 index cefd5993ece..a9c95d11f6e 100644 --- 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 @@ -21,6 +21,7 @@ import { FacetToggleList } from './workspace-cleanup-facet-controls' import { + getWorkspaceCleanupBlockerModeLabel, getWorkspaceCleanupGitStateLabel, getWorkspaceCleanupPresenceLabel, getWorkspaceCleanupReviewProviderLabel, @@ -176,6 +177,7 @@ export function WorkspaceCleanupGitReviewFacets({ label={translate('components.workspace.cleanup.browse.blockerMode', 'Blocker match')} value={filters.safety.blockerMode} options={WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES} + getLabel={getWorkspaceCleanupBlockerModeLabel} onChange={(blockerMode) => onPatch('safety', { blockerMode })} /> { expect(onToggleSelectAll).toHaveBeenCalledWith(true) }) + + it('exposes a partial selection as mixed', () => { + render({ field: 'name', direction: 'asc' }, { selectableCount: 3, selectedCount: 1 }) + + expect( + container?.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') + ).toBe('mixed') + }) }) 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 index 3f04cec09c6..2e8de45c1a8 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ArrowDown, ArrowUp, Check, ChevronsUpDown } from 'lucide-react' +import { ArrowDown, ArrowUp, Check, ChevronsUpDown, Minus } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -30,12 +30,13 @@ export function WorkspaceCleanupSortHeader({ onToggleSelectAll: (selectAll: boolean) => void }): React.JSX.Element { const allSelected = selectableCount > 0 && selectedCount >= selectableCount + const someSelected = selectableCount > 0 && selectedCount > 0 && !allSelected return (
{translate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a92bef36709..7c3f9e97b21 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15032,6 +15032,8 @@ "protected": "Protected" }, "blockerMode": "Blocker match", + "blockerModeAny": "Has any", + "blockerModeNone": "Has none", "blockers": "Blockers", "dismissed": "Ignored", "selectableOnly": "Only workspaces I can delete now",