Compact the Space window layout (#29857)

* Compact Space window layout

* Make Space summary and workspace rows single line

* Expose confirmed single-workspace delete in Space rows
This commit is contained in:
Neil
2026-10-16 16:06:15 -07:00
committed by GitHub
parent 17a2bced11
commit c4c03e89bb
16 changed files with 363 additions and 252 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow {
height: 820,
minWidth: 700,
minHeight: 520,
title: 'Orca Space',
title: 'Orca Space Beta',
show: false,
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
+1 -1
View File
@@ -2,7 +2,7 @@
<html class="native-shell">
<head>
<meta charset="UTF-8" />
<title>Orca Space</title>
<title>Orca Space Beta</title>
</head>
<body>
<div id="root"></div>
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react'
import {
filterWorkspaceSpaceRows,
getLargestWorkspaceSpaceRowSize,
isWorkspaceSpaceRowReadyToDelete,
pruneWorkspaceSpaceSelectedIds,
resolveWorkspaceSpaceInspectedWorktreeId,
@@ -172,7 +171,6 @@ export function useWorkspaceSpaceManagerProjection(args: {
(row) =>
getWorkspaceSpaceWorktreeIdentity(row) === nextTreemapZoomWorktreeId && row.status === 'ok'
) ?? null
const maxSize = getLargestWorkspaceSpaceRowSize(rows)
const selectedDeletableRows = useMemo(
() => getSelectedDeletableWorkspaceRows(rows, nextSelectedIds, isWorktreeUnavailableForDelete),
[isWorktreeUnavailableForDelete, nextSelectedIds, rows]
@@ -218,7 +216,6 @@ export function useWorkspaceSpaceManagerProjection(args: {
nextTreemapZoomWorktreeId,
inspectedWorktree,
zoomedWorktree,
maxSize,
selectedDeletableRows,
selectedDeletableIdentities,
visibleDeletableIdentities,
@@ -73,6 +73,27 @@ describe('WorkspaceSpaceBreakdownList', () => {
expect(revealInFileManager).toHaveBeenCalledWith('/workspace/src', 'local')
})
it('keeps compacted folder details available in the overflow popover', () => {
const { container } = render(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({
topLevelItems: [
item('first', 500),
item('second', 400),
item('third', 300),
item('fourth', 200),
item('fifth', 100)
]
})}
/>
)
expect(renderedRowNames(container)).toEqual(['first', 'second', 'third', 'fourth'])
fireEvent.click(screen.getByText('Show more folder details'))
expect(screen.getAllByText('fifth')).toHaveLength(1)
})
it('renders one row per counted top-level item, including the omitted aggregate', () => {
const { container } = render(
<WorkspaceSpaceBreakdownList
@@ -8,12 +8,15 @@ import { formatBytes, formatCompactCount } from './workspace-space-format'
import { getLargestWorkspaceSpaceItemSize } from './workspace-space-presentation'
import { translate } from '@/i18n/i18n'
import { getRevealInFileManagerLabel, revealInFileManager } from '@/lib/reveal-in-file-manager'
import { cn } from '@/lib/utils'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '../ui/context-menu'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { Button } from '../ui/button'
export function WorkspaceSpaceSizeBar({
value,
@@ -39,7 +42,7 @@ export function WorkspaceSpaceBreakdownList({
}): React.JSX.Element {
if (!worktree) {
return (
<div className="flex h-full min-h-72 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/15 text-sm text-muted-foreground">
<div className="flex h-full min-h-0 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/15 px-3 text-center text-sm text-muted-foreground">
<span className="flex items-center gap-2">
{isScanning ? <Loader2 className="size-4 animate-spin" /> : null}
{isScanning
@@ -74,9 +77,12 @@ export function WorkspaceSpaceBreakdownList({
sizeBytes: worktree.omittedTopLevelSizeBytes
}
: null
const breakdownItems = omittedItem
? [...worktree.topLevelItems, omittedItem]
: worktree.topLevelItems
return (
<div className="min-h-72 rounded-lg border border-border/70 bg-background/35">
<div className="border-b border-border/60 px-4 py-3">
<div className="h-full min-h-0 overflow-hidden rounded-lg border border-border/70 bg-background/35">
<div className="border-b border-border/60 px-3 py-2">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{worktree.displayName}</div>
@@ -100,7 +106,7 @@ export function WorkspaceSpaceBreakdownList({
</div>
{worktree.status !== 'ok' ? (
<div className="flex items-start gap-2 px-4 py-4 text-xs text-destructive">
<div className="flex items-start gap-2 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
{worktree.error ??
@@ -111,28 +117,29 @@ export function WorkspaceSpaceBreakdownList({
</span>
</div>
) : topLevelItemCount === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079',
'No files found.'
)}
</div>
) : worktree.topLevelItems.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{translate(
'workspaceSpace.savedScan.folderDetailsUnavailable',
'Folder details are not saved with past scans. Refresh to inspect this workspace.'
)}
</div>
) : (
<div className="max-h-72 overflow-y-auto scrollbar-sleek px-3 py-3">
<div className="space-y-2">
{worktree.topLevelItems.slice(0, 12).map((item) => (
<div className="overflow-hidden px-2 py-1.5">
<div className="space-y-0.5">
{worktree.topLevelItems.slice(0, omittedItem ? 3 : 4).map((item, index) => (
<BreakdownRow
key={`${item.path}:${item.name}`}
item={item}
maxSize={maxChildSize}
executionHostId={worktree.executionHostId ?? 'unresolved'}
hideWhenShort={index >= 2}
/>
))}
{omittedItem ? (
@@ -140,8 +147,37 @@ export function WorkspaceSpaceBreakdownList({
item={omittedItem}
maxSize={maxChildSize}
executionHostId={worktree.executionHostId ?? 'unresolved'}
hideWhenShort={worktree.topLevelItems.length >= 2}
/>
) : null}
{breakdownItems.length > 2 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
className={cn(
'w-full',
breakdownItems.length <= 4 && '[@media(min-height:651px)]:hidden'
)}
>
{translate('workspaceSpace.breakdown.viewAll', 'Show more folder details')}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<div className="max-h-[min(70vh,28rem)] space-y-0.5 overflow-y-auto scrollbar-sleek">
{breakdownItems.map((item) => (
<BreakdownRow
key={`${item.path}:${item.name}`}
item={item}
maxSize={maxChildSize}
executionHostId={worktree.executionHostId ?? 'unresolved'}
/>
))}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
)}
@@ -152,14 +188,21 @@ export function WorkspaceSpaceBreakdownList({
function BreakdownRow({
item,
maxSize,
executionHostId
executionHostId,
hideWhenShort = false
}: {
item: WorkspaceSpaceItem
maxSize: number
executionHostId: ExecutionHostId | 'unresolved'
hideWhenShort?: boolean
}): React.JSX.Element {
const row = (
<div className="space-y-1.5 rounded-md px-2 py-1.5 hover:bg-accent/50">
<div
className={cn(
'space-y-1 rounded-md px-2 py-1 hover:bg-accent/50',
hideWhenShort && '[@media(max-height:650px)]:hidden'
)}
>
<div className="flex min-w-0 items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate font-medium">{item.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
@@ -1,5 +1,6 @@
import { AlertTriangle, HardDrive, Loader2, RefreshCw, X } from 'lucide-react'
import { Button } from '../ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { formatBytes } from './workspace-space-format'
import { translate } from '@/i18n/i18n'
import { Metric, UpdatedMetric } from './workspace-space-metrics'
@@ -56,73 +57,42 @@ export function WorkspaceSpaceManagerOverview({
)
}
return (
<>
<div className="grid overflow-hidden rounded-lg border border-border/65 bg-background/35 md:grid-cols-4 md:divide-x md:divide-border/60">
<Metric
label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.09960d86bd',
'Scanned'
)}
value={analysis ? formatBytes(analysis.totalSizeBytes) : '—'}
/>
<Metric
label={translate('workspaceSpace.metric.linkedWorktrees', 'Linked worktrees')}
value={analysis ? formatBytes(analysis.reclaimableBytes) : '—'}
/>
<Metric
label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.43171f3e60',
'Workspaces'
)}
value={
analysis
? analysis.unavailableWorktreeCount > 0
? `${analysis.scannedWorktreeCount}/${analysis.worktreeCount}`
: String(analysis.scannedWorktreeCount)
: '—'
}
/>
<UpdatedMetric scannedAt={analysis?.scannedAt ?? null} isScanning={isScanning} />
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{isScanning ? (
<Loader2 className="size-4 shrink-0 animate-spin" />
) : (
<HardDrive className="size-4 shrink-0" />
)}
<span className="truncate">
{analysis
? isScanning
? translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.34174bd83d',
'{{value0}}. You can leave this page; the last result stays visible.',
{ value0: progressLabel ?? 'Scanning workspace sizes' }
)
: translate(
'workspaceSpace.summary.linkedWorktrees',
'{{value0}} is used by linked worktrees. Managed worktrees can be removed here.',
{ value0: formatBytes(analysis.reclaimableBytes) }
)
: isScanning
? translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.265d956765',
'{{value0}}. You can leave this page.',
{ value0: progressLabel ?? 'Scanning workspace sizes' }
)
: translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.e91dd2a9ae',
'Run a scan to inspect workspace sizes.'
)}
</span>
<div className="shrink-0 space-y-2.5">
<div className="flex min-w-0 items-center rounded-lg border border-border/65 bg-background/35">
<div className="grid min-w-0 flex-1 grid-cols-4 divide-x divide-border/60">
<Metric
label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.09960d86bd',
'Scanned'
)}
value={analysis ? formatBytes(analysis.totalSizeBytes) : '—'}
/>
<Metric
label={translate('workspaceSpace.metric.linkedWorktrees', 'Linked worktrees')}
shortLabel={translate('workspaceSpace.compact.linked', 'Linked')}
value={analysis ? formatBytes(analysis.reclaimableBytes) : '—'}
/>
<Metric
label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.43171f3e60',
'Workspaces'
)}
value={
analysis
? analysis.unavailableWorktreeCount > 0
? `${analysis.scannedWorktreeCount}/${analysis.worktreeCount}`
: String(analysis.scannedWorktreeCount)
: '—'
}
/>
<UpdatedMetric scannedAt={analysis?.scannedAt ?? null} isScanning={isScanning} />
</div>
<Button
variant="outline"
size="sm"
onClick={isScanning ? cancelScan : refresh}
disabled={progress?.state === 'cancelling'}
className="w-28 gap-1.5"
className="mr-1.5 w-24 shrink-0"
>
{isScanning ? (
progress?.state === 'cancelling' ? (
@@ -155,8 +125,27 @@ export function WorkspaceSpaceManagerOverview({
</Button>
</div>
{isScanning ? (
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-4 shrink-0 animate-spin" />
<span className="truncate">
{analysis
? translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.34174bd83d',
'{{value0}}. You can leave this page; the last result stays visible.',
{ value0: progressLabel ?? 'Scanning workspace sizes' }
)
: translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.265d956765',
'{{value0}}. You can leave this page.',
{ value0: progressLabel ?? 'Scanning workspace sizes' }
)}
</span>
</div>
) : null}
{scanError ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/35 bg-destructive/8 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2 rounded-md border border-destructive/35 bg-destructive/8 px-2 py-1.5 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
{scanError}
@@ -171,20 +160,46 @@ export function WorkspaceSpaceManagerOverview({
) : null}
{repoErrors.length > 0 ? (
<div className="space-y-1.5 rounded-md border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{repoErrors.map((repo) => (
<div className="rounded-md border border-border/70 bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">
{repoErrors.slice(0, 1).map((repo) => (
<div key={repo.repoId} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
<span className="min-w-0 truncate" title={`${repo.displayName}: ${repo.error}`}>
{repo.displayName}: {repo.error}
</span>
{repoErrors.length > 1 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
aria-label={translate(
'workspaceSpace.scan.moreErrors',
'{{value0}} more scan errors',
{ value0: repoErrors.length - 1 }
)}
>
+{repoErrors.length - 1}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<div className="max-h-56 space-y-2 overflow-y-auto scrollbar-sleek text-xs">
{repoErrors.map((entry) => (
<div key={entry.repoId} className="break-words">
<span className="font-medium">{entry.displayName}:</span> {entry.error}
</div>
))}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
))}
</div>
) : null}
{hasRows || isInitialScan ? (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<div className="grid min-h-0 grid-cols-[minmax(0,1.4fr)_minmax(16rem,0.6fr)] gap-2.5">
<WorkspaceSpaceTreemap
rows={sourceRows}
isScanning={isInitialScan}
@@ -198,6 +213,6 @@ export function WorkspaceSpaceManagerOverview({
<WorkspaceSpaceBreakdownList worktree={inspectedWorktree} isScanning={isInitialScan} />
</div>
) : null}
</>
</div>
)
}
@@ -22,7 +22,6 @@ export function WorkspaceSpaceManagerTable({
hasRows,
inspectedWorktree,
isInitialScan,
maxSize,
nextSelectedIds,
getDeleteStateForWorktree,
rows,
@@ -38,11 +37,11 @@ export function WorkspaceSpaceManagerTable({
allVisibleSelected
} = model
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
{hasRows || isInitialScan ? (
<div className="overflow-x-auto rounded-lg border border-border/70 bg-background/30">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[1.75rem_minmax(0,1.25fr)_minmax(9rem,0.55fr)_8rem_9.5rem] gap-3 border-b border-border/60 px-3 py-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-x-auto rounded-lg border border-border/70 bg-background/30">
<div className="flex min-h-0 min-w-[40rem] flex-1 flex-col">
<div className="grid shrink-0 grid-cols-[1.75rem_minmax(0,1.4fr)_minmax(5rem,0.55fr)_5.5rem_6rem_9.5rem] gap-2 border-b border-border/60 px-3 py-1.5 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
<div className="flex items-center">
<CheckButton
checked={visibleSelectionState}
@@ -83,6 +82,14 @@ export function WorkspaceSpaceManagerTable({
)}
<SortIndicator sortKey="repo" activeKey={sortKey} direction={sortDirection} />
</button>
<button
type="button"
onClick={() => toggleSort('activity')}
className="flex items-center justify-end gap-1 text-right"
>
{translate('workspaceSpace.table.lastActive', 'Last active')}
<SortIndicator sortKey="activity" activeKey={sortKey} direction={sortDirection} />
</button>
<button
type="button"
onClick={() => toggleSort('size')}
@@ -102,7 +109,7 @@ export function WorkspaceSpaceManagerTable({
</div>
</div>
<div className="max-h-[28rem] overflow-y-auto scrollbar-sleek">
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
{isInitialScan ? (
<div className="flex items-center justify-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
@@ -129,7 +136,6 @@ export function WorkspaceSpaceManagerTable({
<WorkspaceSpaceWorktreeRow
key={identity}
worktree={worktree}
maxSize={maxSize}
selected={nextSelectedIds.has(identity)}
inspected={
inspectedWorktree !== null &&
@@ -178,6 +184,6 @@ export function WorkspaceSpaceManagerTable({
)}
</div>
)}
</>
</div>
)
}
@@ -1,4 +1,4 @@
import { Check, Search, Trash2 } from 'lucide-react'
import { Check, Trash2 } from 'lucide-react'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
@@ -33,7 +33,7 @@ export function WorkspaceSpaceManagerToolbar({
return (
<>
{selectedDeletableIds.length > 0 ? (
<div className="sticky top-0 z-10 -mx-1 flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/70 bg-background/95 px-3 py-2 shadow-xs backdrop-blur">
<div className="flex items-center justify-between gap-2 rounded-md border border-border/70 bg-background/95 px-2 py-1 shadow-xs">
<div className="min-w-0 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{selectedDeletableIds.length}{' '}
@@ -82,9 +82,8 @@ export function WorkspaceSpaceManagerToolbar({
) : null}
{hasRows ? (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[16rem] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 flex-1">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -92,7 +91,7 @@ export function WorkspaceSpaceManagerToolbar({
'auto.components.status.bar.WorkspaceSpaceManagerPanel.6f8f6a6b04',
'Filter workspaces'
)}
className="pl-9"
className="h-8"
/>
</div>
@@ -100,7 +99,7 @@ export function WorkspaceSpaceManagerToolbar({
value={sortKey}
onValueChange={(value) => selectSortKey(value as WorkspaceSpaceSortKey)}
>
<SelectTrigger className="w-36">
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -135,7 +134,7 @@ export function WorkspaceSpaceManagerToolbar({
variant={onlyDeletable ? 'secondary' : 'outline'}
size="sm"
onClick={() => setOnlyDeletable((current) => !current)}
className="w-32"
className="w-24"
aria-label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.81aaf1de65',
'Show only deletable workspaces'
@@ -157,7 +156,7 @@ export function WorkspaceSpaceManagerToolbar({
size="sm"
onClick={toggleVisibleSelection}
disabled={visibleDeletableIds.length === 0}
className="w-32 gap-1.5"
className="w-24"
aria-label={
allVisibleSelected
? translate(
@@ -11,7 +11,7 @@ export function WorkspaceSpaceManagerView({
model: WorkspaceSpaceManagerModel
}): React.JSX.Element {
return (
<div className="space-y-5">
<div className="flex min-h-0 flex-1 flex-col gap-3">
<WorkspaceSpaceManagerOverview model={model} />
<WorkspaceSpaceManagerToolbar model={model} />
<WorkspaceSpaceManagerTable model={model} />
@@ -6,23 +6,42 @@ import {
getWorkspaceSpaceScanDateTimeLabel,
getWorkspaceSpaceScanTimeLabel
} from './workspace-space-format'
import { formatShortTimeAgo } from '@/lib/short-time-ago'
export function Metric({
label,
value,
title
title,
shortLabel,
shortValue
}: {
label: string
value: string
title?: string
shortLabel?: string
shortValue?: string
}): React.JSX.Element {
return (
<div className="min-w-0 px-4 py-3">
<div className="truncate text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{label}
<div className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 md:px-3">
<div className="min-w-0 truncate text-2xs font-medium uppercase tracking-wider text-muted-foreground">
{shortLabel ? (
<>
<span className="md:hidden">{shortLabel}</span>
<span className="hidden md:inline">{label}</span>
</>
) : (
label
)}
</div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums" title={title}>
{value}
<div className="shrink-0 text-sm font-semibold tabular-nums" title={title}>
{shortValue ? (
<>
<span className="md:hidden">{shortValue}</span>
<span className="hidden md:inline">{value}</span>
</>
) : (
value
)}
</div>
</div>
)
@@ -55,6 +74,7 @@ export function UpdatedMetric({
'Updated'
)}
title={scannedAt === null ? undefined : getWorkspaceSpaceScanDateTimeLabel(scannedAt)}
shortValue={scannedAt === null ? undefined : formatShortTimeAgo(scannedAt, now)}
value={
scannedAt === null
? isScanning
@@ -73,7 +73,7 @@ export function WorkspaceSpaceTreemap({
if (rects.length === 0) {
return (
<div className="relative flex h-72 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm text-muted-foreground">
<div className="relative flex h-[clamp(7rem,18vh,11rem)] items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm text-muted-foreground">
{zoomedWorktree ? (
<Button
variant="outline"
@@ -108,7 +108,7 @@ export function WorkspaceSpaceTreemap({
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex min-h-10 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 py-2">
<div className="flex min-h-8 items-center justify-between gap-2 border-b border-border/70 bg-background/80 px-2.5 py-1">
<div className="min-w-0 truncate text-xs font-medium text-foreground">
{zoomedWorktree
? translate('workspaceSpace.chart.folderSizes', 'Folder sizes')
@@ -143,7 +143,7 @@ export function WorkspaceSpaceTreemap({
) : null}
</div>
</div>
<div className="relative h-72 overflow-hidden">
<div className="relative h-[clamp(7rem,18vh,11rem)] overflow-hidden">
{rects.map((rect) => {
const area = rect.width * rect.height
const selected = !isZoomed && rect.id === selectedWorktreeId
@@ -68,11 +68,11 @@ afterEach(() => {
})
describe('WorkspaceSpaceWorktreeRow context menu', () => {
it('reveals a workspace even when deletion is unavailable', () => {
it('shows the existing workspace activity timestamp with its full time on hover', () => {
const lastActivityAt = Date.now() - 2 * 60 * 60 * 1000
render(
<WorkspaceSpaceWorktreeRow
worktree={worktree}
maxSize={4096}
worktree={{ ...worktree, lastActivityAt }}
selected={false}
inspected={false}
decisionDetails={decisionDetails}
@@ -84,30 +84,83 @@ describe('WorkspaceSpaceWorktreeRow context menu', () => {
/>
)
fireEvent.contextMenu(screen.getByRole('button', { name: /workspace/ }))
expect(screen.queryByText('Delete workspace')).toBeNull()
expect(screen.getByText('2h').getAttribute('title')).toBeTruthy()
})
it('reveals a workspace even when deletion is unavailable', () => {
render(
<WorkspaceSpaceWorktreeRow
worktree={worktree}
selected={false}
inspected={false}
decisionDetails={decisionDetails}
onToggleSelected={() => {}}
onInspect={() => {}}
onOpenWorkspace={() => {}}
onDelete={() => {}}
onForceDelete={() => {}}
/>
)
fireEvent.contextMenu(
screen.getByText('workspace').closest('[data-slot="context-menu-trigger"]')!
)
expect(
screen
.getByText('Delete workspace')
.closest('[role="menuitem"]')
?.getAttribute('data-disabled')
).toBe('')
expect(screen.queryByRole('button', { name: 'Delete workspace' })).toBeNull()
fireEvent.click(screen.getByText('Reveal in Finder'))
expect(revealInFileManager).toHaveBeenCalledWith('/workspace', 'local')
})
it('keeps deletion beside reveal for an eligible linked worktree', () => {
const onDelete = vi.fn()
render(
<WorkspaceSpaceWorktreeRow
worktree={{ ...worktree, isMainWorktree: false, canDelete: true }}
maxSize={4096}
selected={false}
inspected={false}
decisionDetails={decisionDetails}
onToggleSelected={() => {}}
onInspect={() => {}}
onOpenWorkspace={() => {}}
onDelete={() => {}}
onDelete={onDelete}
onForceDelete={() => {}}
/>
)
fireEvent.contextMenu(screen.getByRole('button', { name: /workspace/ }))
fireEvent.contextMenu(
screen.getByText('workspace').closest('[data-slot="context-menu-trigger"]')!
)
expect(screen.getByText('Reveal in Finder')).toBeTruthy()
expect(screen.getByText('Delete workspace')).toBeTruthy()
const menuItem = screen.getByText('Delete workspace').closest('[role="menuitem"]')
expect(menuItem?.getAttribute('data-disabled')).toBeNull()
fireEvent.click(screen.getByText('Delete workspace'))
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('offers a direct delete control without opening or selecting the row', () => {
const onDelete = vi.fn()
const onInspect = vi.fn()
render(
<WorkspaceSpaceWorktreeRow
worktree={{ ...worktree, isMainWorktree: false, canDelete: true }}
selected={false}
inspected={false}
decisionDetails={decisionDetails}
onToggleSelected={() => {}}
onInspect={onInspect}
onOpenWorkspace={() => {}}
onDelete={onDelete}
onForceDelete={() => {}}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onInspect).not.toHaveBeenCalled()
})
})
@@ -7,12 +7,15 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '../ui/context-menu'
import { getRevealInFileManagerLabel, revealInFileManager } from '@/lib/reveal-in-file-manager'
import { HoverCard, HoverCardTrigger } from '../ui/hover-card'
import { formatBytes, getWorkspaceSpaceBranchLabel } from './workspace-space-format'
import {
formatBytes,
getWorkspaceSpaceBranchLabel,
getWorkspaceSpaceScanDateTimeLabel
} from './workspace-space-format'
import { isWorkspaceSpaceRowReadyToDelete } from './workspace-space-presentation'
import { translate } from '@/i18n/i18n'
import type { WorkspaceDecisionDetails } from './workspace-space-decision-details'
@@ -23,12 +26,11 @@ import type {
import { CheckButton } from './workspace-space-selection-controls'
import { WorkspaceDecisionHoverCard } from './workspace-space-decision-hover-card'
import { WorkspaceSpaceStatusBadge } from './workspace-space-status-badge'
import { WorkspaceSpaceSizeBar } from './workspace-space-breakdown-list'
import { describeWorktreeDeleteError } from '../sidebar/worktree-delete-error-display'
import { formatShortTimeAgo } from '@/lib/short-time-ago'
export function WorkspaceSpaceWorktreeRow({
worktree,
maxSize,
selected,
inspected,
decisionDetails,
@@ -41,7 +43,6 @@ export function WorkspaceSpaceWorktreeRow({
onForceDelete
}: {
worktree: WorkspaceSpaceWorktree
maxSize: number
selected: boolean
inspected: boolean
decisionDetails: WorkspaceDecisionDetails
@@ -76,7 +77,7 @@ export function WorkspaceSpaceWorktreeRow({
onInspect()
}}
className={cn(
'grid w-full cursor-pointer grid-cols-[1.75rem_minmax(0,1.25fr)_minmax(9rem,0.55fr)_8rem_9.5rem] items-center gap-3 border-b border-border/45 px-3 py-2.5 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'grid w-full cursor-pointer grid-cols-[1.75rem_minmax(0,1.4fr)_minmax(5rem,0.55fr)_5.5rem_6rem_9.5rem] items-center gap-2 border-b border-border/45 px-3 py-1 text-left text-xs transition-colors last:border-b-0 hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
inspected && 'bg-accent/55',
isDeleting && 'cursor-wait opacity-50 grayscale hover:bg-transparent'
)}
@@ -92,32 +93,25 @@ export function WorkspaceSpaceWorktreeRow({
onClick={onToggleSelected}
/>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate font-medium">{worktree.displayName}</span>
{worktree.isRemote ? (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
) : null}
{worktree.isSparse ? (
<Badge variant="outline">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.9155381019',
'Sparse'
)}
</Badge>
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<GitBranch className="size-3 shrink-0" />
<span className="truncate">{getWorkspaceSpaceBranchLabel(worktree)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{worktree.path}
</div>
<div className="flex min-w-0 items-center gap-1.5" title={worktree.path}>
<span className="min-w-0 truncate font-medium">{worktree.displayName}</span>
{worktree.isRemote ? <Server className="size-3.5 shrink-0 text-muted-foreground" /> : null}
{worktree.isSparse ? (
<Badge variant="outline">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.9155381019',
'Sparse'
)}
</Badge>
) : null}
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
<span className="max-w-24 shrink-0 truncate text-muted-foreground">
{getWorkspaceSpaceBranchLabel(worktree)}
</span>
{deleteError ? (
<div className="mt-2 flex min-w-0 items-start gap-2 rounded-md border border-destructive/35 bg-destructive/8 px-2 py-1.5 text-[11px] text-destructive">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 flex-1 break-words" title={deleteError}>
<div className="flex min-w-0 items-center gap-1 text-destructive">
<AlertTriangle className="size-3 shrink-0" />
<span className="min-w-0 truncate" title={deleteError}>
{deleteError}
</span>
{canForceDelete ? (
@@ -126,7 +120,7 @@ export function WorkspaceSpaceWorktreeRow({
variant="destructive"
size="xs"
onClick={handleForceDelete}
className="h-6 shrink-0 gap-1 px-2"
className="h-6 shrink-0"
>
<Trash2 className="size-3" />
{translate(
@@ -139,21 +133,46 @@ export function WorkspaceSpaceWorktreeRow({
) : null}
</div>
<div className="min-w-0 text-xs">
<div className="truncate font-medium">{worktree.repoDisplayName}</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{worktree.repoPath}
</div>
<div className="min-w-0 truncate" title={worktree.repoPath}>
{worktree.repoDisplayName}
</div>
<div className="min-w-0 space-y-1.5">
<div className="text-right text-sm font-medium tabular-nums">
{worktree.status === 'ok' ? formatBytes(worktree.sizeBytes) : '—'}
</div>
<WorkspaceSpaceSizeBar value={worktree.sizeBytes} max={maxSize} />
<div
className="truncate text-right tabular-nums text-muted-foreground"
title={
worktree.lastActivityAt > 0
? getWorkspaceSpaceScanDateTimeLabel(worktree.lastActivityAt)
: undefined
}
>
{worktree.lastActivityAt > 0 ? formatShortTimeAgo(worktree.lastActivityAt) : '—'}
</div>
<div className="flex justify-end">
<div className="truncate text-right font-medium tabular-nums">
{worktree.status === 'ok' ? formatBytes(worktree.sizeBytes) : '—'}
</div>
<div className="flex items-center justify-end gap-1">
{canDelete ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('workspaceSpace.deleteOne', 'Delete {{value0}}', {
value0: worktree.displayName
})}
title={translate('workspaceSpace.deleteOne', 'Delete {{value0}}', {
value0: worktree.displayName
})}
onClick={(event) => {
event.stopPropagation()
onDelete()
}}
onKeyDown={(event) => event.stopPropagation()}
>
<Trash2 className="size-3.5" />
</Button>
) : null}
<HoverCard openDelay={250} closeDelay={120}>
<HoverCardTrigger asChild>
<span
@@ -191,18 +210,13 @@ export function WorkspaceSpaceWorktreeRow({
<FolderSearch className="size-3.5" />
{getRevealInFileManagerLabel()}
</ContextMenuItem>
{canDelete ? (
<>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onDelete}>
<Trash2 className="size-3.5" />
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.792a214457',
'Delete workspace'
)}
</ContextMenuItem>
</>
) : null}
<ContextMenuItem variant="destructive" disabled={!canDelete} onSelect={onDelete}>
<Trash2 className="size-3.5" />
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.792a214457',
'Delete workspace'
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
@@ -1,88 +1,23 @@
import { useCallback, useEffect } from 'react'
import { ArrowLeft, HardDrive, X } from 'lucide-react'
import { Badge } from '../ui/badge'
import { ArrowLeft } from 'lucide-react'
import { Button } from '../ui/button'
import { WorkspaceSpaceManagerPanel } from '../status-bar/WorkspaceSpaceManagerPanel'
import { useAppStore } from '../../store'
import { hasVisibleOverlay } from '@/lib/visible-overlay'
import { translate } from '@/i18n/i18n'
export default function WorkspaceSpacePage(): React.JSX.Element {
const closeSpacePage = useAppStore((state) => state.closeSpacePage)
const isSeparateWindow = window.location.pathname.endsWith('/space.html')
const close = useCallback((): void => {
if (isSeparateWindow) {
void window.api.workspaceSpace.closeWindow?.()
} else {
closeSpacePage()
}
}, [closeSpacePage, isSeparateWindow])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
// Why: confirmation dialogs own Escape first; page-level Escape should
// only leave the full Space surface when no modal or popover is active.
if (hasVisibleOverlay()) {
return
}
const target = event.target instanceof HTMLElement ? event.target : null
if (
target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
) {
return
}
event.preventDefault()
close()
}
// Why: tooltips can consume Escape before bubble listeners see it. Capture
// keeps the first Escape reliable while still deferring to real overlays.
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [close])
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center gap-3 border-b border-border px-5 py-3">
<Button variant="ghost" size="sm" onClick={close} className="shrink-0">
{isSeparateWindow ? <X className="size-3.5" /> : <ArrowLeft className="size-3.5" />}
{isSeparateWindow
? translate('workspaceSpace.window.close', 'Close')
: translate('auto.components.workspace.space.WorkspaceSpacePage.ecf72fdc3b', 'Back')}
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background p-3">
{!isSeparateWindow ? (
<Button variant="ghost" size="xs" onClick={closeSpacePage} className="mb-2 w-fit">
<ArrowLeft className="size-3.5" />
{translate('auto.components.workspace.space.WorkspaceSpacePage.ecf72fdc3b', 'Back')}
</Button>
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30">
<HardDrive className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-base font-semibold text-foreground">
{translate(
'auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc',
'Space'
)}
</h1>
<Badge variant="secondary">
{translate('auto.components.workspace.space.WorkspaceSpacePage.e8d6ba11ab', 'Beta')}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
{translate(
'auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb',
'See what uses space across your workspaces and what you can safely reclaim.'
)}
</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5 scrollbar-sleek">
<div className="mx-auto max-w-7xl">
<WorkspaceSpaceManagerPanel />
</div>
) : null}
<div className="mx-auto flex min-h-0 w-full max-w-[96rem] flex-1 flex-col">
<WorkspaceSpaceManagerPanel />
</div>
</div>
)
+3 -1
View File
@@ -2709,7 +2709,9 @@
},
"space": {
"WorkspaceSpacePage": {
"8d0048e1cb": "Workspace disk usage and reclaimable worktree storage."
"45f6302dbc": "Space",
"8d0048e1cb": "Workspace disk usage and reclaimable worktree storage.",
"e8d6ba11ab": "Beta"
}
}
}
+9 -3
View File
@@ -20189,6 +20189,13 @@
}
},
"workspaceSpace": {
"deleteOne": "Delete {{value0}}",
"table": {
"lastActive": "Last active"
},
"breakdown": {
"viewAll": "Show more folder details"
},
"compact": {
"linkedAndUnavailable": "{{value0}} in linked worktrees · {{value1}} unavailable",
"linkedAndScanned": "{{value0}} in linked worktrees · {{value1}} workspaces",
@@ -20205,8 +20212,8 @@
"metric": {
"linkedWorktrees": "Linked worktrees"
},
"summary": {
"linkedWorktrees": "{{value0}} is used by linked worktrees. Managed worktrees can be removed here."
"scan": {
"moreErrors": "{{value0}} more scan errors"
},
"status": {
"notManaged": "Not managed"
@@ -20217,7 +20224,6 @@
"clickToZoom": "Click a workspace to zoom"
},
"window": {
"close": "Close",
"errorTitle": "Space hit an error.",
"errorDescription": "Close and reopen Space to try again."
}