diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index 4a97ecacc4e..d0151d6eac4 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -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()) diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index 78fc4d8bb3f..0f858c12cbe 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -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, diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 70f48e0aba3..2da27eb7548 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -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, diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 22011fee364..9bc66ba8a4a 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -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) }) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index c9b9ae1e89c..a76344faada 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -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 diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx index 8393c3d8237..c4879bd9eb9 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx @@ -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 + ) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts index e27bc792f43..ca683800f48 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -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() 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( - () => ({ + const options = useMemo(() => { + const repos = new Map() + 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, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx index 9bb2f6901a8..c8c2d6d9a7b 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -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 ? ( + + ) : null} 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} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index 26d246a2f6d..dac22d4167f 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -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({ )} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx index eeb48d4f211..2c96d43e144 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx @@ -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( + + ) + + 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( + + ) + + expect( + (screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx index 1c41466e45b..02945189c23 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx @@ -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 ( +
+
+ +
+
+ {translate( + 'components.workspace.cleanup.browse.measureSizesTitle', + 'Measure workspace sizes' + )} +
+
+ {translate( + 'components.workspace.cleanup.browse.measureSizesDescription', + 'Measure disk usage to compare, sort, and filter workspaces by size.' + )} +
+
+
+ +
+ ) +} + export function WorkspaceCleanupNotice({ tone = 'muted', message diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts index 511f92f6f52..8a5dd1a0ed9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index 880cca20c52..16a1d95938d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -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 & - 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 @@ -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 { - const index = new Map() - 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> -): Map { - const index = new Map() - 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 ( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx index 7c846662bbf..769e4ec537a 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx @@ -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( { 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( - - ) - - expect(screen.getByRole('button', { name: 'Measure sizes' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Measure sizes' })).toBeNull() }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx index 74f58c7ccaa..2fd339cf408 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -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({ )} ) : null} - - {sizeScan.scanning || sizeScan.unmeasuredCount > 0 ? ( - - ) : null} ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts new file mode 100644 index 00000000000..16755526db1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-host-identity.ts @@ -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 & + Partial< + Pick< + Worktree, + | 'repoId' + | 'workspaceStatus' + | 'isArchived' + | 'isPinned' + | 'isUnread' + | 'comment' + | 'hostId' + | 'branch' + | 'createdAt' + | 'linkedWorkItem' + | 'linkedLinearIssue' + | 'linkedIssue' + | 'locked' + | 'prunable' + > + > + +export function getWorkspaceCleanupCandidateHostId( + candidate: Pick +): 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( + rows: readonly Row[], + include: (row: Row) => boolean = () => true +): Map { + const counts = new Map() + 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[] +): Map { + 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 { + const index = new Map() + 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>, + repos: readonly Pick[] = [] +): Map { + const index = new Map() + const repoHostsById = new Map>() + for (const repo of repos) { + const hosts = repoHostsById.get(repo.id) ?? new Set() + hosts.add(getRepoExecutionHostId(repo)) + repoHostsById.set(repo.id, hosts) + } + const legacyCounts = new Map() + 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 +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts index f6e1aab4488..998b93e0d9f 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts @@ -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, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index 75ab9fd7bdd..70d89c70dfb 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -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 + worktreesByRepoAndId: ReadonlyMap +} + 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 +): WorkspaceCleanupReviewLookup { + const reposById = new Map() + for (const repo of state.repos) { + const matches = reposById.get(repo.id) ?? [] + matches.push(repo) + reposById.set(repo.id, matches) + } + const worktreesByRepoAndId = new Map() + 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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index adb9bfd2170..12ceabb355e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index eb65e965403..d09a6920c41 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -72,7 +72,7 @@ export type WorkspaceSpaceAnalyzeResult = export type WorkspaceSpaceWorktreeMeasurement = Pick< WorkspaceSpaceWorktree, - 'worktreeId' | 'status' | 'sizeBytes' + 'worktreeId' | 'executionHostId' | 'status' | 'sizeBytes' > export type WorkspaceSpaceDirectoryScanResult = {