fix(workspace-cleanup): preserve host-scoped cleanup metadata

This commit is contained in:
Brennan Benson
2026-08-12 19:45:23 -07:00
parent e0be03b3c2
commit eef9ea2951
20 changed files with 631 additions and 206 deletions
@@ -159,6 +159,25 @@ describe('workspace cleanup broad scan opt-in', () => {
])
})
it('preserves a worktree runtime host on cleanup candidates', async () => {
const runtimeMeta = makeWorktreeMeta({
lastActivityAt: NOW - 40 * DAY_MS,
hostId: 'runtime:env-1'
})
const store = {
...makeStore(),
getWorktreeMeta: (worktreeId: string) =>
worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId]
} as Store
const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true })
expect(
result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')
?.executionHostId
).toBe('runtime:env-1')
})
it('keeps the legacy suggestion-only projection when the flag is absent', async () => {
const result = await scanWorkspaceCleanup(makeStore())
+3 -3
View File
@@ -1,7 +1,7 @@
import type { IGitProvider } from '../providers/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type { Repo, Worktree } from '../../shared/types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { getWorktreeExecutionHostId } from '../../shared/execution-host'
import {
applyWorkspaceCleanupPolicy,
createWorkspaceCleanupFingerprint,
@@ -59,7 +59,7 @@ export async function buildWorkspaceCleanupCandidate(args: {
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
executionHostId: getWorktreeExecutionHostId(worktree, repo),
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
@@ -104,7 +104,7 @@ export function buildWorkspaceCleanupCandidateFromError(
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
executionHostId: getWorktreeExecutionHostId(worktree, repo),
displayName: worktree.displayName,
branch: shortWorkspaceCleanupBranchName(worktree.branch),
path: worktree.path,
@@ -1,5 +1,5 @@
import { basename } from 'node:path'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../shared/execution-host'
import type { Store } from '../persistence'
import type { Repo, WorktreeMeta } from '../../shared/types'
import {
@@ -61,7 +61,7 @@ function createDisconnectedSshCandidate(
repoId: repo.id,
repoName: repo.displayName,
connectionId: repo.connectionId ?? null,
executionHostId: getRepoExecutionHostId(repo),
executionHostId: normalizeExecutionHostId(meta.hostId) ?? getRepoExecutionHostId(repo),
displayName: meta.displayName || basename(path),
branch: basename(path),
path,
+5 -4
View File
@@ -42,14 +42,14 @@ vi.mock('./project-runtime-git-options', () => ({
import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis'
function createStore(repos: Repo[]): Store {
function createStore(repos: Repo[], worktreeHostId?: `runtime:${string}`): Store {
return {
getRepos: () => repos,
getWorktreeMeta: (worktreeId: string) => {
if (worktreeId.endsWith('feature')) {
return { displayName: 'Feature Workspace', lastActivityAt: 200 }
return { displayName: 'Feature Workspace', lastActivityAt: 200, hostId: worktreeHostId }
}
return undefined
return worktreeHostId ? { hostId: worktreeHostId } : undefined
}
} as Store
}
@@ -199,7 +199,7 @@ describe('analyzeWorkspaceSpace', () => {
])
const progress: unknown[] = []
await analyzeWorkspaceSpace(createStore([repo]), {
await analyzeWorkspaceSpace(createStore([repo], 'runtime:env-1'), {
scanId: 'scan-1',
onProgress: (event) => progress.push(event)
})
@@ -226,6 +226,7 @@ describe('analyzeWorkspaceSpace', () => {
completedMeasurements: [
expect.objectContaining({
worktreeId: `repo-1::${repoPath}`,
executionHostId: 'runtime:env-1',
status: 'ok',
sizeBytes: expect.any(Number)
})
+8 -3
View File
@@ -34,7 +34,7 @@ import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
import { mergeWorktree } from './ipc/worktree-logic'
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
import { getRepoExecutionHostId } from '../shared/execution-host'
import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../shared/execution-host'
const REPO_SCAN_CONCURRENCY = 2
const WORKTREE_SCAN_CONCURRENCY = 3
@@ -310,7 +310,7 @@ function createBaseWorktreeRow(
return {
worktreeId: worktree.id,
repoId: repo.id,
executionHostId: getRepoExecutionHostId(repo),
executionHostId: getWorktreeExecutionHostId(worktree, repo),
repoDisplayName: repo.displayName,
repoPath: repo.path,
displayName: worktree.displayName,
@@ -801,7 +801,12 @@ async function scanRepo(
{
scannedWorktreeCount: progress.scannedWorktreeCount + 1,
completedMeasurements: [
{ worktreeId: row.worktreeId, status: row.status, sizeBytes: row.sizeBytes }
{
worktreeId: row.worktreeId,
executionHostId: row.executionHostId,
status: row.status,
sizeBytes: row.sizeBytes
}
]
},
options.onProgress
@@ -179,4 +179,52 @@ describe('useWorkspaceCleanupFacetRows hot paths', () => {
expect(view.result.current.rows[0]?.sizeBytes).toBe(4_096)
expect(view.result.current.measuredSizeCount).toBe(1)
})
it('includes folder repositories discovered from cleanup candidates', () => {
const candidate = makeFacetCandidate({ repoId: 'folder-1', repoName: 'Loose files' })
const view = renderHook(() =>
useWorkspaceCleanupFacetRows({
candidates: [candidate],
filters: createDefaultWorkspaceCleanupFilterState(),
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
now: 1_700_000_000_000
})
)
expect(view.result.current.options.repos).toEqual([{ id: 'folder-1', label: 'Loose files' }])
})
it('projects same-id streamed sizes only onto their owning hosts', () => {
const worktreeId = 'repo-1::/repo/alpha'
const candidates = [
makeFacetCandidate({ worktreeId, displayName: 'local', executionHostId: 'local' }),
makeFacetCandidate({
worktreeId,
displayName: 'remote',
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
]
holders.state = {
...holders.state!,
workspaceSpaceMeasurements: [
{ worktreeId, executionHostId: 'ssh:builder', status: 'ok', sizeBytes: 4_096 }
]
}
const view = renderHook(() =>
useWorkspaceCleanupFacetRows({
candidates,
filters: createDefaultWorkspaceCleanupFilterState(),
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
now: 1_700_000_000_000
})
)
expect(
view.result.current.rows.find((row) => row.displayName === 'local')?.sizeBytes
).toBeNull()
expect(view.result.current.rows.find((row) => row.displayName === 'remote')?.sizeBytes).toBe(
4_096
)
})
})
@@ -2,7 +2,6 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import type {
@@ -11,8 +10,6 @@ import type {
} from '../../../../shared/workspace-cleanup-filter-model'
import {
buildWorkspaceCleanupFacetList,
buildWorkspaceCleanupSizeIndex,
buildWorkspaceCleanupWorktreeIndex,
countWorkspaceCleanupMeasuredRows,
type WorkspaceCleanupFacets
} from './workspace-cleanup-facets'
@@ -22,6 +19,7 @@ import {
runWorkspaceCleanupQuery
} from './workspace-cleanup-query'
import {
buildWorkspaceCleanupReviewLookup,
getWorkspaceCleanupReviewInfo,
type WorkspaceCleanupReviewInfo
} from './workspace-cleanup-presentation'
@@ -29,6 +27,13 @@ import type {
WorkspaceCleanupFacetCounts,
WorkspaceCleanupFacetOptions
} from './workspace-cleanup-facet-panel-model'
import {
buildWorkspaceCleanupSizeIndex,
buildWorkspaceCleanupWorktreeIndex,
countWorkspaceCleanupCandidateIds,
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupHostIdentity
} from './workspace-cleanup-host-identity'
export type WorkspaceCleanupFacetRows = {
rows: WorkspaceCleanupFacets[]
@@ -78,19 +83,37 @@ export function useWorkspaceCleanupFacetRows({
}))
)
const { hostedReviewCache, repos, settings, worktreesByRepo } = sources
const candidateIdCounts = useMemo(
() => countWorkspaceCleanupCandidateIds(candidates),
[candidates]
)
const reviewLookup = useMemo(
() => buildWorkspaceCleanupReviewLookup({ repos, worktreesByRepo }),
[repos, worktreesByRepo]
)
const reviewInfoByWorktreeId = useMemo(() => {
const infos = new Map<string, WorkspaceCleanupReviewInfo>()
const reviewSources = { hostedReviewCache, repos, settings, worktreesByRepo }
for (const candidate of candidates) {
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, reviewSources))
const info = getWorkspaceCleanupReviewInfo(candidate, reviewSources, reviewLookup)
infos.set(
getWorkspaceCleanupHostIdentity(
getWorkspaceCleanupCandidateHostId(candidate),
candidate.worktreeId
),
info
)
if (candidateIdCounts.get(candidate.worktreeId) === 1) {
infos.set(candidate.worktreeId, info)
}
}
return infos
}, [candidates, hostedReviewCache, repos, settings, worktreesByRepo])
}, [candidateIdCounts, candidates, hostedReviewCache, reviewLookup, settings])
const completedSizeByWorktreeId = useMemo(
() => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees),
[sources.spaceWorktrees]
() => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees, candidates),
[candidates, sources.spaceWorktrees]
)
const sizeByWorktreeId = useMemo(() => {
if (sources.spaceMeasurements.length === 0) {
@@ -98,14 +121,14 @@ export function useWorkspaceCleanupFacetRows({
}
return new Map([
...completedSizeByWorktreeId,
...buildWorkspaceCleanupSizeIndex(sources.spaceMeasurements)
...buildWorkspaceCleanupSizeIndex(sources.spaceMeasurements, candidates)
])
}, [completedSizeByWorktreeId, sources.spaceMeasurements])
}, [candidates, completedSizeByWorktreeId, sources.spaceMeasurements])
const facets = useMemo(
() =>
buildWorkspaceCleanupFacetList(candidates, {
worktreeById: buildWorkspaceCleanupWorktreeIndex(sources.worktreesByRepo),
worktreeById: buildWorkspaceCleanupWorktreeIndex(sources.worktreesByRepo, sources.repos),
workspaceStatuses: sources.workspaceStatuses,
sizeBytesByWorktreeId: sizeByWorktreeId,
lastVisitedAtByWorktreeId: sources.lastVisitedAtByWorktreeId,
@@ -127,7 +150,8 @@ export function useWorkspaceCleanupFacetRows({
sources.lastVisitedAtByWorktreeId,
sources.tabsByWorktree,
sources.workspaceStatuses,
sources.worktreesByRepo
sources.worktreesByRepo,
sources.repos
]
)
@@ -174,16 +198,20 @@ export function useWorkspaceCleanupFacetRows({
const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets])
const unmeasuredSizeCount = facets.length - measuredSizeCount
const options = useMemo<WorkspaceCleanupFacetOptions>(
() => ({
const options = useMemo<WorkspaceCleanupFacetOptions>(() => {
const repos = new Map<string, string>()
for (const row of facets) {
if (!repos.has(row.repoId)) {
repos.set(row.repoId, row.repoName)
}
}
return {
workspaceStatuses: sources.workspaceStatuses.map((status) => ({
id: status.id,
label: status.label
})),
hostIds: [...new Set(facets.map((row) => row.hostId))].sort(),
repos: sources.repos
.filter((repo) => isGitRepoKind(repo))
.map((repo) => ({ id: repo.id, label: repo.displayName || repo.path })),
repos: [...repos].map(([id, label]) => ({ id, label })),
reviewProviders: [
...new Set(
facets
@@ -191,9 +219,8 @@ export function useWorkspaceCleanupFacetRows({
.filter((provider): provider is HostedReviewProvider => provider !== null)
)
].sort()
}),
[facets, sources.repos, sources.workspaceStatuses]
)
}
}, [facets, sources.workspaceStatuses])
return {
rows: result.rows,
@@ -6,7 +6,10 @@ import {
hasActiveWorkspaceCleanupFilters,
listActiveWorkspaceCleanupFacetGroups
} from './workspace-cleanup-active-facets'
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
import {
WorkspaceCleanupEmptyState,
WorkspaceCleanupSizeScanBanner
} from './workspace-cleanup-dialog-notices'
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
import type { WorkspaceSpaceScanProgress } from '../../../../shared/workspace-space-types'
@@ -37,6 +40,14 @@ export function WorkspaceCleanupBrowseToolbar({
const activeFilters = hasActiveWorkspaceCleanupFilters(browse.filters)
return (
<>
{spaceScanning || facetRows.unmeasuredSizeCount > 0 ? (
<WorkspaceCleanupSizeScanBanner
scanning={spaceScanning}
scannedCount={spaceProgress?.scannedWorktreeCount ?? 0}
totalCount={spaceProgress?.totalWorktreeCount ?? 0}
onRun={onRunSpaceScan}
/>
) : null}
<WorkspaceCleanupFilterBar
facetProps={{
filters: browse.filters,
@@ -48,14 +59,6 @@ export function WorkspaceCleanupBrowseToolbar({
activeFacetGroupCount={listActiveWorkspaceCleanupFacetGroups(browse.filters).length}
matchedCount={facetRows.matchedCount}
hasActiveFilters={activeFilters}
sizeScan={{
measuredCount: facetRows.measuredSizeCount,
unmeasuredCount: facetRows.unmeasuredSizeCount,
scanning: spaceScanning,
scannedCount: spaceProgress?.scannedWorktreeCount ?? 0,
totalCount: spaceProgress?.totalWorktreeCount ?? 0,
onRun: onRunSpaceScan
}}
gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }}
onQueryChange={(query) => browse.patchFilters('query', query)}
onClearFilters={browse.clearFilters}
@@ -75,13 +78,8 @@ export function WorkspaceCleanupBrowseToolbar({
)}
description={translate(
'components.workspace.cleanup.browse.noSizesDescription',
'Sizes come from the disk-space scan. Run it to sort and filter by size.'
'Sizes appear after disk usage has been measured.'
)}
actionLabel={translate(
'components.workspace.cleanup.browse.measureSizes',
'Measure sizes'
)}
onAction={onRunSpaceScan}
/>
) : null}
</>
@@ -19,6 +19,10 @@ import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentatio
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
import { StatusPill } from './workspace-cleanup-status-pill'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
import {
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupHostIdentity
} from './workspace-cleanup-host-identity'
const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76
@@ -133,7 +137,16 @@ export function WorkspaceCleanupConfirmRemove({
<ConfirmRemoveRow
key={candidate.worktreeId}
candidate={candidate}
reviewInfo={reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO}
reviewInfo={
reviewInfoByWorktreeId.get(
getWorkspaceCleanupHostIdentity(
getWorkspaceCleanupCandidateHostId(candidate),
candidate.worktreeId
)
) ??
reviewInfoByWorktreeId.get(candidate.worktreeId) ??
EMPTY_REVIEW_INFO
}
last={index === candidates.length - 1}
/>
)}
@@ -1,7 +1,10 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { WorkspaceCleanupInitialScanBanner } from './workspace-cleanup-dialog-notices'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
WorkspaceCleanupInitialScanBanner,
WorkspaceCleanupSizeScanBanner
} from './workspace-cleanup-dialog-notices'
afterEach(cleanup)
@@ -24,3 +27,32 @@ describe('WorkspaceCleanupInitialScanBanner', () => {
expect(screen.queryByText('Checked workspaces so far: 23')).toBeNull()
})
})
describe('WorkspaceCleanupSizeScanBanner', () => {
it('explains why measurement is useful and exposes the action', () => {
render(
<WorkspaceCleanupSizeScanBanner
scanning={false}
scannedCount={0}
totalCount={0}
onRun={vi.fn()}
/>
)
expect(screen.getByText('Measure workspace sizes')).toBeTruthy()
expect(
screen.getByText('Measure disk usage to compare, sort, and filter workspaces by size.')
).toBeTruthy()
expect(screen.getByRole('button', { name: 'Measure sizes' })).toBeTruthy()
})
it('shows determinate progress and disables duplicate runs', () => {
render(
<WorkspaceCleanupSizeScanBanner scanning scannedCount={23} totalCount={100} onRun={vi.fn()} />
)
expect(
(screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled
).toBe(true)
})
})
@@ -1,5 +1,5 @@
import React from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { AlertTriangle, HardDrive, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
@@ -40,6 +40,60 @@ export function WorkspaceCleanupInitialScanBanner({
)
}
export function WorkspaceCleanupSizeScanBanner({
scanning,
scannedCount,
totalCount,
onRun
}: {
scanning: boolean
scannedCount: number
totalCount: number
onRun: () => void
}): React.JSX.Element {
const actionLabel = scanning
? totalCount > 0
? translate(
'components.workspace.cleanup.browse.measuringSizesProgress',
'Measuring {{value0}}/{{value1}}',
{ value0: scannedCount, value1: totalCount }
)
: translate('components.workspace.cleanup.browse.measuringSizes', 'Measuring sizes')
: translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')
return (
<div className="flex flex-col items-stretch gap-3 border-b border-border bg-muted/25 px-5 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-start gap-2">
<HardDrive className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">
{translate(
'components.workspace.cleanup.browse.measureSizesTitle',
'Measure workspace sizes'
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{translate(
'components.workspace.cleanup.browse.measureSizesDescription',
'Measure disk usage to compare, sort, and filter workspaces by size.'
)}
</div>
</div>
</div>
<Button
className="shrink-0 self-start"
variant="outline"
size="sm"
disabled={scanning}
onClick={onRun}
>
{scanning ? <Loader2 className="size-3.5 animate-spin" /> : null}
{actionLabel}
</Button>
</div>
)
}
export function WorkspaceCleanupNotice({
tone = 'muted',
message
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
buildWorkspaceCleanupFacetList,
buildWorkspaceCleanupSizeIndex,
buildWorkspaceCleanupWorktreeIndex,
countWorkspaceCleanupMeasuredRows
} from './workspace-cleanup-facets'
import {
buildWorkspaceCleanupSizeIndex,
buildWorkspaceCleanupWorktreeIndex,
getWorkspaceCleanupHostIdentity
} from './workspace-cleanup-host-identity'
import {
DAY,
FACET_NOW,
@@ -83,12 +86,83 @@ describe('facet building', () => {
expect(countWorkspaceCleanupMeasuredRows(rows)).toBe(1)
})
it('indexes worktrees across repos by id', () => {
it('indexes worktrees across repos by host and id', () => {
const index = buildWorkspaceCleanupWorktreeIndex({
'repo-1': [{ id: 'w1' }],
'repo-2': [{ id: 'w2' }]
'repo-1': [{ id: 'w1', hostId: 'local' }],
'repo-2': [{ id: 'w2', hostId: 'ssh:builder' }]
})
expect([...index.keys()]).toEqual(['w1', 'w2'])
expect([...index.keys()]).toEqual([
getWorkspaceCleanupHostIdentity('local', 'w1'),
getWorkspaceCleanupHostIdentity('ssh:builder', 'w2')
])
})
it('keeps same-id worktrees and sizes isolated by host', () => {
const candidates = [
makeFacetCandidate({ executionHostId: 'local' }),
makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' })
]
const sizes = buildWorkspaceCleanupSizeIndex(
[
{
worktreeId: candidates[0]!.worktreeId,
executionHostId: 'local',
status: 'ok',
sizeBytes: 10
},
{
worktreeId: candidates[1]!.worktreeId,
executionHostId: 'ssh:builder',
status: 'ok',
sizeBytes: 20
}
],
candidates
)
expect(sizes.get(getWorkspaceCleanupHostIdentity('local', candidates[0]!.worktreeId))).toBe(10)
expect(
sizes.get(getWorkspaceCleanupHostIdentity('ssh:builder', candidates[1]!.worktreeId))
).toBe(20)
expect(sizes.has(candidates[0]!.worktreeId)).toBe(false)
})
it('does not apply a legacy size to duplicate candidate ids', () => {
const candidates = [
makeFacetCandidate({ executionHostId: 'local' }),
makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' })
]
const sizes = buildWorkspaceCleanupSizeIndex(
[{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 10 }],
candidates
)
expect(sizes.size).toBe(0)
})
it('does not apply duplicate legacy size rows to one current candidate', () => {
const candidate = makeFacetCandidate({ executionHostId: 'local' })
const sizes = buildWorkspaceCleanupSizeIndex(
[
{ worktreeId: candidate.worktreeId, status: 'ok', sizeBytes: 10 },
{ worktreeId: candidate.worktreeId, status: 'ok', sizeBytes: 20 }
],
[candidate]
)
expect(sizes.size).toBe(0)
})
it('does not assign legacy worktree metadata with multi-host repo ownership', () => {
const index = buildWorkspaceCleanupWorktreeIndex(
{ 'repo-1': [{ id: 'w1', repoId: 'repo-1' }] },
[
{ id: 'repo-1', executionHostId: 'local' },
{ id: 'repo-1', executionHostId: 'ssh:builder' }
]
)
expect(index.size).toBe(0)
})
it('counts a workspace with nothing attached as completely empty', () => {
@@ -1,11 +1,7 @@
import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import type { Worktree, WorkspaceStatusDefinition } from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
import { getWorkspaceStatus } from '../../../../shared/workspace-statuses'
import type {
WorkspaceSpaceWorktree,
WorkspaceSpaceWorktreeMeasurement
} from '../../../../shared/workspace-space-types'
import {
canSelectWorkspaceCleanupCandidate,
type WorkspaceCleanupBlocker,
@@ -20,27 +16,12 @@ import type {
WorkspaceCleanupTicketSource
} from '../../../../shared/workspace-cleanup-filter-model'
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
/** Structural subset of `Worktree` — Pick keeps it in sync when the source type moves. */
export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
Partial<
Pick<
Worktree,
| 'workspaceStatus'
| 'isArchived'
| 'isPinned'
| 'isUnread'
| 'comment'
| 'hostId'
| 'branch'
| 'createdAt'
| 'linkedWorkItem'
| 'linkedLinearIssue'
| 'linkedIssue'
| 'locked'
| 'prunable'
>
>
import {
getWorkspaceCleanupCandidateHostId,
getWorkspaceCleanupHostIdentity,
type WorkspaceCleanupWorktreeFacts
} from './workspace-cleanup-host-identity'
export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity'
export type WorkspaceCleanupFacetSources = {
worktreeById?: ReadonlyMap<string, WorkspaceCleanupWorktreeFacts>
@@ -107,8 +88,18 @@ export function buildWorkspaceCleanupFacets(
candidate: WorkspaceCleanupCandidate,
sources: WorkspaceCleanupFacetSources = {}
): WorkspaceCleanupFacets {
const worktree = sources.worktreeById?.get(candidate.worktreeId) ?? null
const review = sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
const hostIdentity = getWorkspaceCleanupHostIdentity(
getWorkspaceCleanupCandidateHostId(candidate),
candidate.worktreeId
)
const worktree =
sources.worktreeById?.get(hostIdentity) ??
sources.worktreeById?.get(candidate.worktreeId) ??
null
const review =
sources.reviewInfoByWorktreeId?.get(hostIdentity) ??
sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ??
EMPTY_REVIEW_INFO
const ticketSources = getTicketSources(worktree)
const localContextCount = getLocalContextCount(candidate)
const hasComment = (worktree?.comment ?? '').trim().length > 0
@@ -121,7 +112,7 @@ export function buildWorkspaceCleanupFacets(
displayName: candidate.displayName,
path: candidate.path,
branch,
hostId: worktree?.hostId ?? candidate.executionHostId ?? LOCAL_EXECUTION_HOST_ID,
hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate),
tier: candidate.tier,
blockers: candidate.blockers,
blockerCount: candidate.blockers.length,
@@ -132,7 +123,10 @@ export function buildWorkspaceCleanupFacets(
lastActivityAt: candidate.lastActivityAt,
createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt),
lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]),
sizeBytes: toFiniteOrNull(sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)),
sizeBytes: toFiniteOrNull(
sources.sizeBytesByWorktreeId?.get(hostIdentity) ??
sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)
),
workspaceStatus: normalizeStatus(worktree?.workspaceStatus),
workspaceStatusLabel: getWorkspaceStatusLabel(worktree, sources.workspaceStatuses),
isArchived: worktree?.isArchived ?? candidate.reasons.includes('archived'),
@@ -164,38 +158,10 @@ export function buildWorkspaceCleanupFacetList(
return candidates.map((candidate) => buildWorkspaceCleanupFacets(candidate, sources))
}
/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */
export function buildWorkspaceCleanupSizeIndex(
worktrees:
| readonly (WorkspaceSpaceWorktree | WorkspaceSpaceWorktreeMeasurement)[]
| null
| undefined
): Map<string, number> {
const index = new Map<string, number>()
for (const entry of worktrees ?? []) {
if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) {
index.set(entry.worktreeId, entry.sizeBytes)
}
}
return index
}
export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanupFacets[]): number {
return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0)
}
export function buildWorkspaceCleanupWorktreeIndex(
worktreesByRepo: Readonly<Record<string, readonly WorkspaceCleanupWorktreeFacts[]>>
): Map<string, WorkspaceCleanupWorktreeFacts> {
const index = new Map<string, WorkspaceCleanupWorktreeFacts>()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
index.set(worktree.id, worktree)
}
}
return index
}
function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number {
const context = candidate.localContext
return (
@@ -7,7 +7,7 @@ import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
afterEach(cleanup)
describe('WorkspaceCleanupFilterBar', () => {
it('shows determinate size-scan progress', () => {
it('keeps size measurement out of the browse controls', () => {
render(
<WorkspaceCleanupFilterBar
facetProps={{
@@ -31,63 +31,12 @@ describe('WorkspaceCleanupFilterBar', () => {
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
sizeScan={{
measuredCount: 23,
unmeasuredCount: 77,
scanning: true,
scannedCount: 23,
totalCount: 100,
onRun: vi.fn()
}}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
/>
)
expect(
(screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled
).toBe(true)
})
it('keeps measurement available while any row is unmeasured', () => {
render(
<WorkspaceCleanupFilterBar
facetProps={{
filters: createDefaultWorkspaceCleanupFilterState(),
counts: {
activity: 0,
size: 0,
status: 0,
agent: 0,
git: 0,
review: 0,
ticket: 0,
context: 0,
location: 0,
safety: 0
},
totalCount: 100,
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
onPatch: vi.fn()
}}
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
sizeScan={{
measuredCount: 23,
unmeasuredCount: 77,
scanning: false,
scannedCount: 23,
totalCount: 100,
onRun: vi.fn()
}}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: 'Measure sizes' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Measure sizes' })).toBeNull()
})
})
@@ -1,5 +1,5 @@
import React from 'react'
import { HardDrive, Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react'
import { Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -9,15 +9,6 @@ import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-
import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets'
import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model'
export type WorkspaceCleanupSizeScanState = {
measuredCount: number
unmeasuredCount: number
scanning: boolean
scannedCount: number
totalCount: number
onRun: () => void
}
export type WorkspaceCleanupGitEvidenceProgress = {
pendingCount: number
totalCount: number
@@ -28,7 +19,6 @@ export function WorkspaceCleanupFilterBar({
activeFacetGroupCount,
matchedCount,
hasActiveFilters,
sizeScan,
gitEvidence,
onQueryChange,
onClearFilters
@@ -37,7 +27,6 @@ export function WorkspaceCleanupFilterBar({
activeFacetGroupCount: number
matchedCount: number
hasActiveFilters: boolean
sizeScan: WorkspaceCleanupSizeScanState
gitEvidence: WorkspaceCleanupGitEvidenceProgress
onQueryChange: (query: string) => void
onClearFilters: () => void
@@ -113,25 +102,6 @@ export function WorkspaceCleanupFilterBar({
)}
</span>
) : null}
{sizeScan.scanning || sizeScan.unmeasuredCount > 0 ? (
<Button variant="outline" size="sm" disabled={sizeScan.scanning} onClick={sizeScan.onRun}>
{sizeScan.scanning ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<HardDrive className="size-3.5" />
)}
{sizeScan.scanning
? sizeScan.totalCount > 0
? translate(
'components.workspace.cleanup.browse.measuringSizesProgress',
'Measuring {{value0}}/{{value1}}',
{ value0: sizeScan.scannedCount, value1: sizeScan.totalCount }
)
: translate('components.workspace.cleanup.browse.measuringSizes', 'Measuring sizes')
: translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')}
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,136 @@
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { Repo, Worktree } from '../../../../shared/types'
import type {
WorkspaceSpaceWorktree,
WorkspaceSpaceWorktreeMeasurement
} from '../../../../shared/workspace-space-types'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
/** Structural subset of `Worktree` kept in sync with the persisted source. */
export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
Partial<
Pick<
Worktree,
| 'repoId'
| 'workspaceStatus'
| 'isArchived'
| 'isPinned'
| 'isUnread'
| 'comment'
| 'hostId'
| 'branch'
| 'createdAt'
| 'linkedWorkItem'
| 'linkedLinearIssue'
| 'linkedIssue'
| 'locked'
| 'prunable'
>
>
export function getWorkspaceCleanupCandidateHostId(
candidate: Pick<WorkspaceCleanupCandidate, 'connectionId' | 'executionHostId'>
): ExecutionHostId {
return (
normalizeExecutionHostId(candidate.executionHostId) ??
(candidate.connectionId
? toSshExecutionHostId(candidate.connectionId)
: LOCAL_EXECUTION_HOST_ID)
)
}
export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string {
return `${hostId}\0${id}`
}
function countIds<Row extends { worktreeId: string }>(
rows: readonly Row[],
include: (row: Row) => boolean = () => true
): Map<string, number> {
const counts = new Map<string, number>()
for (const row of rows) {
if (include(row)) {
counts.set(row.worktreeId, (counts.get(row.worktreeId) ?? 0) + 1)
}
}
return counts
}
export function countWorkspaceCleanupCandidateIds(
candidates: readonly Pick<WorkspaceCleanupCandidate, 'worktreeId'>[]
): Map<string, number> {
return countIds(candidates)
}
/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */
export function buildWorkspaceCleanupSizeIndex(
worktrees:
| readonly (WorkspaceSpaceWorktree | WorkspaceSpaceWorktreeMeasurement)[]
| null
| undefined,
candidates?: readonly Pick<
WorkspaceCleanupCandidate,
'connectionId' | 'executionHostId' | 'worktreeId'
>[]
): Map<string, number> {
const index = new Map<string, number>()
const candidateCounts = candidates ? countWorkspaceCleanupCandidateIds(candidates) : null
const legacyCounts = countIds(
worktrees ?? [],
(entry) => !normalizeExecutionHostId(entry.executionHostId)
)
for (const entry of worktrees ?? []) {
if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) {
const hostId = normalizeExecutionHostId(entry.executionHostId)
if (hostId) {
index.set(getWorkspaceCleanupHostIdentity(hostId, entry.worktreeId), entry.sizeBytes)
} else if (
legacyCounts.get(entry.worktreeId) === 1 &&
(!candidateCounts || candidateCounts.get(entry.worktreeId) === 1)
) {
index.set(entry.worktreeId, entry.sizeBytes)
}
}
}
return index
}
export function buildWorkspaceCleanupWorktreeIndex(
worktreesByRepo: Readonly<Record<string, readonly WorkspaceCleanupWorktreeFacts[]>>,
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] = []
): Map<string, WorkspaceCleanupWorktreeFacts> {
const index = new Map<string, WorkspaceCleanupWorktreeFacts>()
const repoHostsById = new Map<string, Set<ExecutionHostId>>()
for (const repo of repos) {
const hosts = repoHostsById.get(repo.id) ?? new Set<ExecutionHostId>()
hosts.add(getRepoExecutionHostId(repo))
repoHostsById.set(repo.id, hosts)
}
const legacyCounts = new Map<string, number>()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
if (!normalizeExecutionHostId(worktree.hostId)) {
legacyCounts.set(worktree.id, (legacyCounts.get(worktree.id) ?? 0) + 1)
}
}
}
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
const explicitHostId = normalizeExecutionHostId(worktree.hostId)
const repoHosts = worktree.repoId ? repoHostsById.get(worktree.repoId) : undefined
const derivedHostId = repoHosts?.size === 1 ? [...repoHosts][0] : null
const hostId =
explicitHostId ?? (legacyCounts.get(worktree.id) === 1 ? derivedHostId : undefined)
if (hostId) {
index.set(getWorkspaceCleanupHostIdentity(hostId, worktree.id), worktree)
}
}
}
return index
}
@@ -16,6 +16,7 @@ import {
makeReview,
makeState
} from './workspace-cleanup-presentation-fixtures'
import type { Repo } from '../../../../shared/types'
describe('workspace cleanup presentation', () => {
it('finds hosted review details from renderer state', () => {
@@ -34,6 +35,76 @@ describe('workspace cleanup presentation', () => {
})
})
it('host-qualifies same-id repo, worktree, and review cache joins', () => {
const base = makeState()
const localRepo = { ...base.repos[0]!, executionHostId: 'local' as const }
const remoteRepo: Repo = {
...localRepo,
connectionId: 'builder',
executionHostId: 'ssh:builder'
}
const worktreeId = 'repo-1::/repo/alpha'
const localWorktree = {
...base.worktreesByRepo['repo-1']![0]!,
hostId: 'local' as const,
linkedPR: 11
}
const remoteWorktree = {
...localWorktree,
hostId: 'ssh:builder' as const,
linkedPR: 22
}
const localKey = getHostedReviewCacheKey('/repo', 'alpha', {}, 'repo-1', null, 'local', true)
const remoteKey = getHostedReviewCacheKey(
'/repo',
'alpha',
{},
'repo-1',
'builder',
'ssh:builder',
true
)
const state = makeState({
repos: [localRepo, remoteRepo],
worktreesByRepo: { 'repo-1': [localWorktree, remoteWorktree] },
hostedReviewCache: {
[localKey]: { data: makeReview({ number: 11, title: 'Local review' }), fetchedAt: NOW },
[remoteKey]: { data: makeReview({ number: 22, title: 'Remote review' }), fetchedAt: NOW }
}
})
expect(
getWorkspaceCleanupReviewInfo(
makeCandidate({
worktreeId,
connectionId: 'builder',
executionHostId: 'ssh:builder'
}),
state
)
).toMatchObject({ label: 'PR #22', title: 'Remote review' })
})
it('does not guess a host for legacy same-id review joins', () => {
const base = makeState()
const duplicateRepo = {
...base.repos[0]!,
connectionId: 'builder',
executionHostId: 'ssh:builder' as const
}
const state = makeState({
repos: [base.repos[0]!, duplicateRepo],
worktreesByRepo: {
'repo-1': [
{ ...base.worktreesByRepo['repo-1']![0]!, linkedPR: 11 },
{ ...base.worktreesByRepo['repo-1']![0]!, linkedPR: 22 }
]
}
})
expect(getWorkspaceCleanupReviewInfo(makeCandidate(), state).hasReview).toBe(false)
})
it('filters by time, review, git, and context', () => {
const openReview: WorkspaceCleanupReviewInfo = {
hasReview: true,
@@ -1,10 +1,11 @@
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../../shared/types'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity'
export type WorkspaceCleanupSortKey = 'activity' | 'name' | 'repo' | 'review' | 'git'
export type WorkspaceCleanupSortDirection = 'asc' | 'desc'
@@ -39,6 +40,11 @@ export type WorkspaceCleanupRendererStateInputs = Pick<
'worktreesByRepo' | 'hostedReviewCache' | 'repos' | 'settings'
>
export type WorkspaceCleanupReviewLookup = {
reposById: ReadonlyMap<string, readonly Repo[]>
worktreesByRepoAndId: ReadonlyMap<string, readonly Worktree[]>
}
export {
filterWorkspaceCleanupCandidates,
getWorkspaceCleanupGitLabel,
@@ -49,10 +55,11 @@ export {
export function getWorkspaceCleanupReviewInfo(
candidate: WorkspaceCleanupCandidate,
state: WorkspaceCleanupRendererStateInputs
state: WorkspaceCleanupRendererStateInputs,
lookup: WorkspaceCleanupReviewLookup = buildWorkspaceCleanupReviewLookup(state)
): WorkspaceCleanupReviewInfo {
const worktree = getWorktreeMapFromState(state).get(candidate.worktreeId) ?? null
const repo = getRepoMapFromState(state).get(candidate.repoId) ?? null
const repo = findCandidateRepo(candidate, lookup)
const worktree = findCandidateWorktree(candidate, repo, lookup)
const hostedReview = getCachedHostedReview(candidate, worktree, repo, state)
if (hostedReview) {
return {
@@ -84,6 +91,27 @@ export function getWorkspaceCleanupReviewInfo(
}
}
export function buildWorkspaceCleanupReviewLookup(
state: Pick<WorkspaceCleanupRendererStateInputs, 'repos' | 'worktreesByRepo'>
): WorkspaceCleanupReviewLookup {
const reposById = new Map<string, Repo[]>()
for (const repo of state.repos) {
const matches = reposById.get(repo.id) ?? []
matches.push(repo)
reposById.set(repo.id, matches)
}
const worktreesByRepoAndId = new Map<string, Worktree[]>()
for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) {
for (const worktree of worktrees) {
const key = `${repoId}\0${worktree.id}`
const matches = worktreesByRepoAndId.get(key) ?? []
matches.push(worktree)
worktreesByRepoAndId.set(key, matches)
}
}
return { reposById, worktreesByRepoAndId }
}
function getCachedHostedReview(
candidate: WorkspaceCleanupCandidate,
worktree: Worktree | null,
@@ -98,11 +126,43 @@ function getCachedHostedReview(
getBranchDisplayName(worktree?.branch ?? candidate.branch),
state.settings,
repo.id,
repo.connectionId
repo.connectionId,
repo.executionHostId,
true
)
return state.hostedReviewCache[cacheKey]?.data ?? null
}
function findCandidateRepo(
candidate: WorkspaceCleanupCandidate,
lookup: WorkspaceCleanupReviewLookup
): Repo | null {
const matches = lookup.reposById.get(candidate.repoId) ?? []
if (!candidate.executionHostId && !candidate.connectionId) {
return matches.length === 1 ? matches[0] : null
}
const hostId = getWorkspaceCleanupCandidateHostId(candidate)
const hostMatches = matches.filter((repo) => getRepoExecutionHostId(repo) === hostId)
return hostMatches.length === 1 ? hostMatches[0] : null
}
function findCandidateWorktree(
candidate: WorkspaceCleanupCandidate,
repo: Repo | null,
lookup: WorkspaceCleanupReviewLookup
): Worktree | null {
const matches =
lookup.worktreesByRepoAndId.get(`${candidate.repoId}\0${candidate.worktreeId}`) ?? []
const hostId = getWorkspaceCleanupCandidateHostId(candidate)
const hostMatches = matches.filter((worktree) => worktree.hostId === hostId)
if (hostMatches.length === 1) {
return hostMatches[0]
}
const legacyMatches = matches.filter((worktree) => !worktree.hostId)
const repoMatches = lookup.reposById.get(candidate.repoId) ?? []
return legacyMatches.length === 1 && repo && repoMatches.length === 1 ? legacyMatches[0] : null
}
function getLinkedReviewFallback(worktree: Worktree | null): {
label: string
provider: HostedReviewProvider
+3 -1
View File
@@ -15255,8 +15255,10 @@
},
"browse": {
"noSizes": "No workspace sizes measured yet.",
"noSizesDescription": "Sizes come from the disk-space scan. Run it to sort and filter by size.",
"noSizesDescription": "Sizes appear after disk usage has been measured.",
"measureSizes": "Measure sizes",
"measureSizesTitle": "Measure workspace sizes",
"measureSizesDescription": "Measure disk usage to compare, sort, and filter workspaces by size.",
"sizeOnDisk": "Size on disk: {{value0}}",
"checkingGitRow": "Checking git status",
"searchPlaceholder": "Search name, repo, branch, path, host",
+1 -1
View File
@@ -72,7 +72,7 @@ export type WorkspaceSpaceAnalyzeResult =
export type WorkspaceSpaceWorktreeMeasurement = Pick<
WorkspaceSpaceWorktree,
'worktreeId' | 'status' | 'sizeBytes'
'worktreeId' | 'executionHostId' | 'status' | 'sizeBytes'
>
export type WorkspaceSpaceDirectoryScanResult = {