From 13f39b9e3b7e2e6ba2fafeb1551184511403be56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:40:32 -0700 Subject: [PATCH] feat(workspace-cleanup): name every applied filter in the bar and make it removable Replaces the one-time filter migration this PR used to carry, and the per-group apply checkboxes that were planned to follow it. Both existed to answer one question -- why is a filter I never turned on hiding my workspaces -- and neither was the cheapest honest answer. The bar already read "Showing 546 of 799", so the *effect* was always visible. What was missing was the *cause*: which filter, and that it came from a previous session. Active constraints now render as removable chips in the bar, and Clear filters is promoted out of the popover it was buried in. Why this replaces the migration: a blanket clear cannot tell a wheel mutation from a deliberate choice, and the version here was worse than that -- it neutralized all ten groups, including location.repoIds and location.pathPrefix, which a wheel cannot set. With a chip, a stray threshold is visible on open and one click removes it. No marker, no provenance guessing, nobody's deliberate filters deleted. Why this replaces the apply toggle: every group already has a neutral resting state that does not constrain -- an empty numeric field, tri-states at 'any', the two booleans permissive. "Not applied" and "empty" are the same state today, so the toggle's only unique power was parking a value you are not using. That is a modest convenience against ten checkboxes, two-way drafts, a persistence model that could not use an 'enabled' flag without an older host dropping it, and a hydration guard. Chips are per-field, not per-group: "Activity" tells a reader nothing, while "Idle 20d+" names the thing hiding their workspaces. Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a 0 maximum hides every measured non-empty workspace and does. --- .../use-workspace-cleanup-browse-state.ts | 17 +- ...workspace-cleanup-applied-filter-chips.tsx | 185 +++++++++++++ .../workspace-cleanup-browse-toolbar.tsx | 1 + .../workspace-cleanup-filter-bar.test.tsx | 53 +++- .../workspace-cleanup-filter-bar.tsx | 21 +- src/renderer/src/i18n/locales/en.json | 48 +++- .../workspace-cleanup-applied-filters.test.ts | 153 +++++++++++ .../workspace-cleanup-applied-filters.ts | 255 ++++++++++++++++++ 8 files changed, 726 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx create mode 100644 src/shared/workspace-cleanup-applied-filters.test.ts create mode 100644 src/shared/workspace-cleanup-applied-filters.ts 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 index 76f4164b694..87e15404527 100644 --- 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 @@ -16,6 +16,10 @@ export type WorkspaceCleanupBrowseController = { ) => void toggleSortField: (field: WorkspaceCleanupSortField) => void clearFilters: () => void + /** Applies a whole-state transform, for callers that clear one named constraint. */ + replaceFilters: ( + transform: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState + ) => void } /** @@ -56,6 +60,16 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll [updateBrowse] ) + // Why read the store rather than the rendered `browse`: a chip click and any other + // filter write can land in the same tick, and the rendered snapshot would drop one. + const replaceFilters = useCallback( + (transform) => { + const current = useAppStore.getState().workspaceCleanupBrowse + updateBrowse({ ...current, filters: transform(current.filters) }) + }, + [updateBrowse] + ) + const clearFilters = useCallback(() => { updateBrowse((current) => ({ ...current, @@ -68,6 +82,7 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll sort: browse.sort, patchFilters, toggleSortField, - clearFilters + clearFilters, + replaceFilters } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx new file mode 100644 index 00000000000..d879ad079c3 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx @@ -0,0 +1,185 @@ +import React from 'react' +import { X } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { toMegabytes } from './workspace-cleanup-facet-panel-model' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' +import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters' +import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' + +/** + * Names each active constraint in the filter bar, one removable chip apiece. + * + * Why in the bar and not the panel: a filter that narrows the list must be visible + * without opening anything. The reported defect was a persisted `idleMinDays` the user + * never set — the bar already showed "Showing 546 of 799", so the effect was visible + * and only the cause was hidden. + */ +export function useAppliedWorkspaceCleanupFilters( + filters: WorkspaceCleanupFilterState +): WorkspaceCleanupAppliedFilter[] { + return React.useMemo( + () => listAppliedWorkspaceCleanupFilters(filters, buildAppliedFilterFormatters()), + [filters] + ) +} + +export function WorkspaceCleanupAppliedFilterChips({ + applied, + onClear +}: { + applied: readonly WorkspaceCleanupAppliedFilter[] + onClear: (filter: WorkspaceCleanupAppliedFilter) => void +}): React.JSX.Element | null { + if (applied.length === 0) { + return null + } + return ( +
+ {applied.map((filter) => ( + + {filter.label} + + + ))} +
+ ) +} + +function buildAppliedFilterFormatters(): Parameters[1] { + const mb = (bytes: number): number => toMegabytes(bytes) ?? 0 + return { + idleDays: (days) => + translate('components.workspace.cleanup.browse.chip.idleDays', 'Idle {{value0}}d+', { + value0: days + }), + neverVisited: () => + translate('components.workspace.cleanup.browse.chip.neverVisited', 'Never visited'), + minSize: (bytes) => + translate('components.workspace.cleanup.browse.chip.minSize', 'At least {{value0}} MB', { + value0: mb(bytes) + }), + maxSize: (bytes) => + translate('components.workspace.cleanup.browse.chip.maxSize', 'At most {{value0}} MB', { + value0: mb(bytes) + }), + excludesUnsized: () => + translate('components.workspace.cleanup.browse.chip.excludesUnsized', 'Measured only'), + excludesStatusless: () => + translate('components.workspace.cleanup.browse.chip.excludesStatusless', 'Has a status'), + list: (kind, count) => + translate('components.workspace.cleanup.browse.chip.list', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: count + }), + triState: (kind, mode) => + translate('components.workspace.cleanup.browse.chip.triState', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: + mode === 'only' + ? translate('components.workspace.cleanup.browse.chip.only', 'Only') + : translate('components.workspace.cleanup.browse.chip.exclude', 'Excluded') + }), + minAhead: (count) => + translate('components.workspace.cleanup.browse.chip.minAhead', 'Ahead {{value0}}+', { + value0: count + }), + minBehind: (count) => + translate('components.workspace.cleanup.browse.chip.minBehind', 'Behind {{value0}}+', { + value0: count + }), + branchQuery: (value) => + translate('components.workspace.cleanup.browse.chip.branchQuery', 'Branch: {{value0}}', { + value0: value + }), + pathPrefix: (value) => + translate('components.workspace.cleanup.browse.chip.pathPrefix', 'Path: {{value0}}', { + value0: value + }), + presence: (kind, mode) => + translate('components.workspace.cleanup.browse.chip.presence', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: + mode === 'some' + ? translate('components.workspace.cleanup.browse.chip.has', 'Has') + : translate('components.workspace.cleanup.browse.chip.none', 'None') + }), + completelyEmpty: () => + translate('components.workspace.cleanup.browse.chip.completelyEmpty', 'Nothing to lose'), + selectableOnly: () => + translate('components.workspace.cleanup.browse.chip.selectableOnly', 'Deletable only') + } +} + +function getChipKindLabel(kind: string): string { + switch (kind) { + case 'status': + return translate('components.workspace.cleanup.browse.chip.kind.status', 'Status') + case 'agent': + return translate('components.workspace.cleanup.browse.chip.kind.agent', 'Agent') + case 'git': + return translate('components.workspace.cleanup.browse.chip.kind.git', 'Git') + case 'review': + return translate('components.workspace.cleanup.browse.chip.kind.review', 'Review') + case 'reviewState': + return translate('components.workspace.cleanup.browse.chip.kind.reviewState', 'Review state') + case 'reviewProvider': + return translate('components.workspace.cleanup.browse.chip.kind.reviewProvider', 'Provider') + case 'ticket': + return translate('components.workspace.cleanup.browse.chip.kind.ticket', 'Ticket') + case 'ticketSource': + return translate('components.workspace.cleanup.browse.chip.kind.ticketSource', 'Ticket source') + case 'context': + return translate('components.workspace.cleanup.browse.chip.kind.context', 'Context') + case 'host': + return translate('components.workspace.cleanup.browse.chip.kind.host', 'Host') + case 'repo': + return translate('components.workspace.cleanup.browse.chip.kind.repo', 'Repo') + case 'blocker': + return translate('components.workspace.cleanup.browse.chip.kind.blocker', 'Blocker') + case 'tier': + return translate('components.workspace.cleanup.browse.chip.kind.tier', 'Safety') + case 'dismissed': + return translate('components.workspace.cleanup.browse.chip.kind.dismissed', 'Ignored') + case 'archived': + return translate('components.workspace.cleanup.browse.chip.kind.archived', 'Archived') + case 'pinned': + return translate('components.workspace.cleanup.browse.chip.kind.pinned', 'Pinned') + case 'unread': + return translate('components.workspace.cleanup.browse.chip.kind.unread', 'Unread') + case 'comment': + return translate('components.workspace.cleanup.browse.chip.kind.comment', 'Comment') + case 'prunable': + return translate('components.workspace.cleanup.browse.chip.kind.prunable', 'Prunable') + case 'locked': + return translate('components.workspace.cleanup.browse.chip.kind.locked', 'Locked') + case 'retainedAgents': + return translate( + 'components.workspace.cleanup.browse.chip.kind.retainedAgents', + 'Finished agents' + ) + default: + return kind + } +} 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 c70209dd0eb..d53b3215c2c 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 @@ -71,6 +71,7 @@ export function WorkspaceCleanupBrowseToolbar({ gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }} onQueryChange={(query) => browse.patchFilters('query', query)} onClearFilters={browse.clearFilters} + onClearAppliedFilter={(filter) => browse.replaceFilters(filter.clear)} /> + hasActiveFilters?: boolean + onClearAppliedFilter?: (filter: WorkspaceCleanupAppliedFilter) => void + onClearFilters?: () => void + } = {} +): void { render( ) } @@ -46,6 +56,41 @@ describe('WorkspaceCleanupFilterBar', () => { expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull() }) + it('names an applied filter in the bar without opening the panel', () => { + // The reported defect: a persisted idleMinDays the user never set. The bar already + // read "Showing N of M", so only the cause was hidden. + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + renderFilterBar(false, { filters, hasActiveFilters: true }) + + expect(screen.getByRole('listitem').textContent).toContain('Idle 20d+') + }) + + it('clears one named filter from its chip', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + const onClearAppliedFilter = vi.fn() + renderFilterBar(false, { filters, hasActiveFilters: true, onClearAppliedFilter }) + + screen.getByRole('button', { name: 'Remove filter Idle 20d+' }).click() + + expect(onClearAppliedFilter).toHaveBeenCalledTimes(1) + expect(onClearAppliedFilter.mock.calls[0][0].id).toBe('activity.idleMinDays') + }) + + it('offers Clear filters in the bar, not only inside the panel', () => { + renderFilterBar(false, { hasActiveFilters: true }) + + expect(screen.getAllByRole('button', { name: /Clear filters/ }).length).toBeGreaterThan(0) + }) + + it('shows no chips and no Clear for a default profile', () => { + renderFilterBar() + + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /Clear filters/ })).toBeNull() + }) + it('keeps the footer visible while the facet panel scrolls', () => { renderFilterBar(true) 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 index 1fcfa2049dc..3f4ae9e9ab9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -5,6 +5,11 @@ 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 { + useAppliedWorkspaceCleanupFilters, + WorkspaceCleanupAppliedFilterChips +} from './workspace-cleanup-applied-filter-chips' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' @@ -23,7 +28,8 @@ export function WorkspaceCleanupFilterBar({ hasActiveFilters, gitEvidence, onQueryChange, - onClearFilters + onClearFilters, + onClearAppliedFilter }: { facetProps: WorkspaceCleanupFacetGroupProps facetPanelOpen: boolean @@ -34,8 +40,10 @@ export function WorkspaceCleanupFilterBar({ gitEvidence: WorkspaceCleanupGitEvidenceProgress onQueryChange: (query: string) => void onClearFilters: () => void + onClearAppliedFilter: (filter: WorkspaceCleanupAppliedFilter) => void }): React.JSX.Element { const { filters, totalCount } = facetProps + const applied = useAppliedWorkspaceCleanupFilters(filters) return (
@@ -101,6 +109,15 @@ export function WorkspaceCleanupFilterBar({ )} + {/* Why out here and not only in the popover: a filter narrowing the list must be + clearable without first discovering which panel it lives in. */} + {hasActiveFilters ? ( + + ) : null} + {gitEvidence.pendingCount > 0 ? ( @@ -111,6 +128,8 @@ export function WorkspaceCleanupFilterBar({ )} ) : null} + +
) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c117b08e172..3600bbeb99b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16234,7 +16234,53 @@ "selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.", "selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.", "selectAllCountOne": "Select 1 safety-checked workspace", - "selectAllCount": "Select all {{value0}} safety-checked workspaces" + "selectAllCount": "Select all {{value0}} safety-checked workspaces", + "appliedFilters": "Applied filters", + "removeFilter": "Remove filter {{value0}}", + "chip": { + "idleDays": "Idle {{value0}}d+", + "neverVisited": "Never visited", + "minSize": "At least {{value0}} MB", + "maxSize": "At most {{value0}} MB", + "excludesUnsized": "Measured only", + "excludesStatusless": "Has a status", + "list": "{{value0}}: {{value1}}", + "triState": "{{value0}}: {{value1}}", + "only": "Only", + "exclude": "Excluded", + "minAhead": "Ahead {{value0}}+", + "minBehind": "Behind {{value0}}+", + "branchQuery": "Branch: {{value0}}", + "pathPrefix": "Path: {{value0}}", + "presence": "{{value0}}: {{value1}}", + "has": "Has", + "none": "None", + "completelyEmpty": "Nothing to lose", + "selectableOnly": "Deletable only", + "kind": { + "status": "Status", + "agent": "Agent", + "git": "Git", + "review": "Review", + "reviewState": "Review state", + "reviewProvider": "Provider", + "ticket": "Ticket", + "ticketSource": "Ticket source", + "context": "Context", + "host": "Host", + "repo": "Repo", + "blocker": "Blocker", + "tier": "Safety", + "dismissed": "Ignored", + "archived": "Archived", + "pinned": "Pinned", + "unread": "Unread", + "comment": "Comment", + "prunable": "Prunable", + "locked": "Locked", + "retainedAgents": "Finished agents" + } + } }, "scan": { "progress": "Scanning workspaces ({{value0}}/{{value1}})", diff --git a/src/shared/workspace-cleanup-applied-filters.test.ts b/src/shared/workspace-cleanup-applied-filters.test.ts new file mode 100644 index 00000000000..9cd9fd4afb8 --- /dev/null +++ b/src/shared/workspace-cleanup-applied-filters.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from './workspace-cleanup-filter-model' +import { listAppliedWorkspaceCleanupFilters } from './workspace-cleanup-applied-filters' + +const FORMAT = { + idleDays: (d: number) => `Idle >= ${d}d`, + neverVisited: () => 'Never visited', + minSize: (b: number) => `At least ${b}B`, + maxSize: (b: number) => `At most ${b}B`, + excludesUnsized: () => 'Excludes unmeasured', + excludesStatusless: () => 'Excludes statusless', + list: (kind: string, count: number) => `${kind}:${count}`, + triState: (kind: string, mode: string) => `${kind}:${mode}`, + minAhead: (n: number) => `Ahead >= ${n}`, + minBehind: (n: number) => `Behind >= ${n}`, + branchQuery: (v: string) => `Branch ~ ${v}`, + pathPrefix: (v: string) => `Path ~ ${v}`, + presence: (kind: string, mode: string) => `${kind}:${mode}`, + completelyEmpty: () => 'Nothing to lose', + selectableOnly: () => 'Deletable only' +} + +const list = (f: ReturnType) => + listAppliedWorkspaceCleanupFilters(f, FORMAT) + +describe('listAppliedWorkspaceCleanupFilters', () => { + it('shows nothing for a default profile', () => { + expect(list(createDefaultWorkspaceCleanupFilterState())).toEqual([]) + }) + + it('names the reported stuck filter rather than its group', () => { + // The bar always read "Showing 546 of 799"; what was missing was which filter. + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + + const applied = list(filters) + + expect(applied).toHaveLength(1) + expect(applied[0].label).toBe('Idle >= 20d') + }) + + it('clears one constraint and leaves the rest of its own group alone', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + filters.activity.neverVisited = true + + const idle = list(filters).find((a) => a.id === 'activity.idleMinDays') + const next = idle!.clear(filters) + + expect(next.activity.idleMinDays).toBeNull() + expect(next.activity.neverVisited).toBe(true) + }) + + it('never mutates the filters it is handed', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.location.pathPrefix = '/repos' + + list(filters)[0].clear(filters) + + expect(filters.location.pathPrefix).toBe('/repos') + }) + + describe('numeric zero', () => { + it('hides an inert zero minimum, which matches every row', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 0 + filters.size.minBytes = 0 + filters.git.minAhead = 0 + filters.git.minBehind = 0 + + expect(list(filters)).toEqual([]) + }) + + it('shows a zero maximum, which hides every measured non-empty workspace', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.size.maxBytes = 0 + + expect(list(filters).map((a) => a.id)).toEqual(['size.maxBytes']) + }) + }) + + it('ignores whitespace-only text', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.git.branchQuery = ' ' + filters.location.pathPrefix = ' ' + + expect(list(filters)).toEqual([]) + }) + + it('ignores parameter fields that constrain nothing on their own', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleSignal = 'created' + filters.safety.blockerMode = 'any-of' + + expect(list(filters)).toEqual([]) + }) + + it('shows the permissive booleans only when switched off', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.size.includeUnsized = false + filters.status.matchStatusless = false + + expect(list(filters).map((a) => a.id).sort()).toEqual([ + 'size.includeUnsized', + 'status.matchStatusless' + ]) + }) + + it('gives every constraint a unique, stable id', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + filters.size.maxBytes = 500 + filters.status.archived = 'only' + filters.git.states = ['dirty'] + filters.git.locked = 'exclude' + filters.location.repoIds = ['repo-1'] + filters.safety.dismissed = 'only' + + const ids = list(filters).map((a) => a.id) + + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toContain('status.archived') + expect(ids).toContain('git.locked') + }) + + it('clearing every chip in turn returns the profile to no constraints', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + filters.size.maxBytes = 0 + filters.size.includeUnsized = false + filters.status.matchStatusless = false + filters.git.branchQuery = 'release' + filters.location.pathPrefix = '/repos' + filters.safety.selectableOnly = true + + let next = filters + for (const applied of list(filters)) { + next = applied.clear(next) + } + + expect(list(next)).toEqual([]) + }) + + it('leaves the search query alone, which chips do not own', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.query = 'release' + filters.activity.idleMinDays = 20 + + const next = list(filters)[0].clear(filters) + + expect(next.query).toBe('release') + }) +}) diff --git a/src/shared/workspace-cleanup-applied-filters.ts b/src/shared/workspace-cleanup-applied-filters.ts new file mode 100644 index 00000000000..449a55f088f --- /dev/null +++ b/src/shared/workspace-cleanup-applied-filters.ts @@ -0,0 +1,255 @@ +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from './workspace-cleanup-filter-model' + +/** + * One constraint the user can see and remove from the filter bar. + * + * Deliberately per-field, not per-group: "Activity" tells a reader nothing, while + * "Idle >= 20d" names the thing that is hiding their workspaces. That naming *is* the + * fix — the reported defect was never that the effect was invisible (the bar always + * read "Showing 546 of 799"), it was that the cause was. + */ +export type WorkspaceCleanupAppliedFilter = { + /** Stable across renders and unique per constraint. */ + id: string + /** Rendered on the chip. Already localized by the caller-supplied formatter. */ + label: string + /** Returns `filters` with just this constraint reset to its default. */ + clear: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState +} + +type Formatters = { + idleDays: (days: number) => string + neverVisited: () => string + minSize: (bytes: number) => string + maxSize: (bytes: number) => string + excludesUnsized: () => string + excludesStatusless: () => string + list: (kind: string, count: number) => string + triState: (kind: string, mode: 'only' | 'exclude') => string + minAhead: (count: number) => string + minBehind: (count: number) => string + branchQuery: (value: string) => string + pathPrefix: (value: string) => string + presence: (kind: string, mode: string) => string + completelyEmpty: () => string + selectableOnly: () => string +} + +/** + * Every constraint currently narrowing the fleet, in panel order. + * + * A field counts only when its matcher actually narrows. That is why minimums count + * above zero but `size.maxBytes` counts *at* zero: a `0` minimum matches every row, + * while "at most 0 MB" hides every measured non-empty workspace. + */ +export function listAppliedWorkspaceCleanupFilters( + filters: WorkspaceCleanupFilterState, + format: Formatters +): WorkspaceCleanupAppliedFilter[] { + const defaults = createDefaultWorkspaceCleanupFilterState() + const applied: WorkspaceCleanupAppliedFilter[] = [] + const add = ( + id: string, + label: string, + clear: (f: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState + ): void => { + applied.push({ id, label, clear }) + } + + const { activity, size, status, agent, git, review, ticket, context, location, safety } = filters + + if (activity.idleMinDays !== null && activity.idleMinDays > 0) { + add('activity.idleMinDays', format.idleDays(activity.idleMinDays), (f) => ({ + ...f, + activity: { ...f.activity, idleMinDays: null } + })) + } + if (activity.neverVisited) { + add('activity.neverVisited', format.neverVisited(), (f) => ({ + ...f, + activity: { ...f.activity, neverVisited: false } + })) + } + + if (size.minBytes !== null && size.minBytes > 0) { + add('size.minBytes', format.minSize(size.minBytes), (f) => ({ + ...f, + size: { ...f.size, minBytes: null } + })) + } + if (size.maxBytes !== null) { + add('size.maxBytes', format.maxSize(size.maxBytes), (f) => ({ + ...f, + size: { ...f.size, maxBytes: null } + })) + } + if (!size.includeUnsized) { + add('size.includeUnsized', format.excludesUnsized(), (f) => ({ + ...f, + size: { ...f.size, includeUnsized: true } + })) + } + + if (status.workspaceStatuses.length > 0) { + add('status.workspaceStatuses', format.list('status', status.workspaceStatuses.length), (f) => ({ + ...f, + status: { ...f.status, workspaceStatuses: [] } + })) + } + if (!status.matchStatusless) { + add('status.matchStatusless', format.excludesStatusless(), (f) => ({ + ...f, + status: { ...f.status, matchStatusless: true } + })) + } + for (const key of ['archived', 'pinned', 'unread', 'comment'] as const) { + const value = status[key] + if (value !== 'any') { + add(`status.${key}`, format.triState(key, value), (f) => ({ + ...f, + status: { ...f.status, [key]: 'any' } + })) + } + } + + if (agent.states.length > 0) { + add('agent.states', format.list('agent', agent.states.length), (f) => ({ + ...f, + agent: { ...f.agent, states: [] } + })) + } + if (agent.retainedDoneAgents !== 'any') { + add('agent.retainedDoneAgents', format.triState('retainedAgents', agent.retainedDoneAgents), (f) => ({ + ...f, + agent: { ...f.agent, retainedDoneAgents: 'any' } + })) + } + + if (git.states.length > 0) { + add('git.states', format.list('git', git.states.length), (f) => ({ + ...f, + git: { ...f.git, states: [] } + })) + } + if (git.minAhead !== null && git.minAhead > 0) { + add('git.minAhead', format.minAhead(git.minAhead), (f) => ({ + ...f, + git: { ...f.git, minAhead: null } + })) + } + if (git.minBehind !== null && git.minBehind > 0) { + add('git.minBehind', format.minBehind(git.minBehind), (f) => ({ + ...f, + git: { ...f.git, minBehind: null } + })) + } + if (git.branchQuery.trim().length > 0) { + add('git.branchQuery', format.branchQuery(git.branchQuery.trim()), (f) => ({ + ...f, + git: { ...f.git, branchQuery: '' } + })) + } + for (const key of ['prunable', 'locked'] as const) { + const value = git[key] + if (value !== 'any') { + add(`git.${key}`, format.triState(key, value), (f) => ({ + ...f, + git: { ...f.git, [key]: 'any' } + })) + } + } + + if (review.presence !== 'any') { + add('review.presence', format.presence('review', review.presence), (f) => ({ + ...f, + review: { ...f.review, presence: defaults.review.presence } + })) + } + if (review.states.length > 0) { + add('review.states', format.list('reviewState', review.states.length), (f) => ({ + ...f, + review: { ...f.review, states: [] } + })) + } + if (review.providers.length > 0) { + add('review.providers', format.list('reviewProvider', review.providers.length), (f) => ({ + ...f, + review: { ...f.review, providers: [] } + })) + } + + if (ticket.presence !== 'any') { + add('ticket.presence', format.presence('ticket', ticket.presence), (f) => ({ + ...f, + ticket: { ...f.ticket, presence: defaults.ticket.presence } + })) + } + if (ticket.sources.length > 0) { + add('ticket.sources', format.list('ticketSource', ticket.sources.length), (f) => ({ + ...f, + ticket: { ...f.ticket, sources: [] } + })) + } + + if (context.presence !== 'any') { + add('context.presence', format.presence('context', context.presence), (f) => ({ + ...f, + context: { ...f.context, presence: defaults.context.presence } + })) + } + if (context.completelyEmpty) { + add('context.completelyEmpty', format.completelyEmpty(), (f) => ({ + ...f, + context: { ...f.context, completelyEmpty: false } + })) + } + + if (location.hostIds.length > 0) { + add('location.hostIds', format.list('host', location.hostIds.length), (f) => ({ + ...f, + location: { ...f.location, hostIds: [] } + })) + } + if (location.repoIds.length > 0) { + add('location.repoIds', format.list('repo', location.repoIds.length), (f) => ({ + ...f, + location: { ...f.location, repoIds: [] } + })) + } + if (location.pathPrefix.trim().length > 0) { + add('location.pathPrefix', format.pathPrefix(location.pathPrefix.trim()), (f) => ({ + ...f, + location: { ...f.location, pathPrefix: '' } + })) + } + + if (safety.blockers.length > 0) { + add('safety.blockers', format.list('blocker', safety.blockers.length), (f) => ({ + ...f, + safety: { ...f.safety, blockers: [] } + })) + } + if (safety.tiers.length > 0) { + add('safety.tiers', format.list('tier', safety.tiers.length), (f) => ({ + ...f, + safety: { ...f.safety, tiers: [] } + })) + } + if (safety.dismissed !== 'any') { + add('safety.dismissed', format.triState('dismissed', safety.dismissed), (f) => ({ + ...f, + safety: { ...f.safety, dismissed: 'any' } + })) + } + if (safety.selectableOnly) { + add('safety.selectableOnly', format.selectableOnly(), (f) => ({ + ...f, + safety: { ...f.safety, selectableOnly: false } + })) + } + + return applied +}