mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user