mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
fix(workspace-cleanup): preserve host-scoped cleanup metadata
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+48
@@ -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,
|
||||
|
||||
+13
-15
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+34
-2
@@ -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
|
||||
|
||||
+80
-6
@@ -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 (
|
||||
|
||||
+2
-53
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -72,7 +72,7 @@ export type WorkspaceSpaceAnalyzeResult =
|
||||
|
||||
export type WorkspaceSpaceWorktreeMeasurement = Pick<
|
||||
WorkspaceSpaceWorktree,
|
||||
'worktreeId' | 'status' | 'sizeBytes'
|
||||
'worktreeId' | 'executionHostId' | 'status' | 'sizeBytes'
|
||||
>
|
||||
|
||||
export type WorkspaceSpaceDirectoryScanResult = {
|
||||
|
||||
Reference in New Issue
Block a user