mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
+16
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+185
@@ -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}
|
||||
|
||||
+49
-4
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user