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.
This commit is contained in:
Brennan Benson
2026-08-24 10:41:18 -07:00
parent 7b9529da22
commit 13f39b9e3b
8 changed files with 726 additions and 7 deletions
@@ -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<WorkspaceCleanupBrowseController['replaceFilters']>(
(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
}
}
@@ -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 (
<div
className="flex w-full flex-wrap items-center gap-1"
role="list"
aria-label={translate(
'components.workspace.cleanup.browse.appliedFilters',
'Applied filters'
)}
>
{applied.map((filter) => (
<span
key={filter.id}
role="listitem"
className="inline-flex items-center gap-1 rounded-full border border-border bg-background py-0.5 pl-2 pr-1 text-[11px] text-muted-foreground"
>
{filter.label}
<button
type="button"
aria-label={translate(
'components.workspace.cleanup.browse.removeFilter',
'Remove filter {{value0}}',
{ value0: filter.label }
)}
onClick={() => onClear(filter)}
className="flex size-4 items-center justify-center rounded-full hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)
}
function buildAppliedFilterFormatters(): Parameters<typeof listAppliedWorkspaceCleanupFilters>[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
}
}
@@ -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)}
/>
<WorkspaceCleanupSortHeader
sort={browse.sort}
@@ -2,15 +2,24 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters'
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
afterEach(cleanup)
function renderFilterBar(facetPanelOpen = false): void {
function renderFilterBar(
facetPanelOpen = false,
overrides: {
filters?: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>
hasActiveFilters?: boolean
onClearAppliedFilter?: (filter: WorkspaceCleanupAppliedFilter) => void
onClearFilters?: () => void
} = {}
): void {
render(
<WorkspaceCleanupFilterBar
facetProps={{
filters: createDefaultWorkspaceCleanupFilterState(),
filters: overrides.filters ?? createDefaultWorkspaceCleanupFilterState(),
counts: {
activity: 0,
size: 0,
@@ -31,10 +40,11 @@ function renderFilterBar(facetPanelOpen = false): void {
onFacetPanelOpenChange={vi.fn()}
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
hasActiveFilters={overrides.hasActiveFilters ?? false}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
onClearFilters={overrides.onClearFilters ?? vi.fn()}
onClearAppliedFilter={overrides.onClearAppliedFilter ?? vi.fn()}
/>
)
}
@@ -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)
@@ -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 (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/15 px-3 py-2">
<div className="relative min-w-[180px] flex-1">
@@ -101,6 +109,15 @@ export function WorkspaceCleanupFilterBar({
)}
</span>
{/* 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 ? (
<Button variant="ghost" size="sm" className="shrink-0" onClick={onClearFilters}>
<RotateCcw className="size-3.5" />
{translate('components.workspace.cleanup.browse.clearFilters', 'Clear filters')}
</Button>
) : null}
{gitEvidence.pendingCount > 0 ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
@@ -111,6 +128,8 @@ export function WorkspaceCleanupFilterBar({
)}
</span>
) : null}
<WorkspaceCleanupAppliedFilterChips applied={applied} onClear={onClearAppliedFilter} />
</div>
)
}
+47 -1
View File
@@ -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}})",
@@ -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<typeof createDefaultWorkspaceCleanupFilterState>) =>
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')
})
})
@@ -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
}