fix(space): speed up selection and support reviewed workspace cleanup (#30989)

* fix(space): speed up workspace rows and enable reviewed cleanup

* fix(space): resolve change inspection by workspace owner

* fix(space): register changed-file and cleanup labels
This commit is contained in:
Neil
2026-10-19 21:05:49 -07:00
committed by GitHub
parent 4b6fd9283a
commit 81b8bfb890
18 changed files with 651 additions and 123 deletions
+6 -2
View File
@@ -9,6 +9,7 @@ import {
import { safelyRevealWindow } from '../window/focus-existing-window'
import { isBackgroundLaunch } from '../window/foreground-activation-policy'
import { normalizeExecutionHostId } from '../../shared/execution-host'
import { isWorkspaceSpaceChange } from '../../shared/workspace-space-change'
import type { Store } from '../persistence'
export function registerWorkspaceSpaceWindowHandlers(store: Store): void {
@@ -46,13 +47,16 @@ export function registerWorkspaceSpaceWindowHandlers(store: Store): void {
})
ipcMain.handle(
'workspaceSpace:revealWorktree',
(event, worktreeId: unknown, hostId: unknown): void => {
(event, worktreeId: unknown, hostId: unknown, change: unknown): void => {
if (!isWorkspaceSpaceWindowRenderer(event.sender) || typeof worktreeId !== 'string') {
return
}
if (hostId !== undefined && typeof hostId !== 'string') {
return
}
if (change !== undefined && !isWorkspaceSpaceChange(change)) {
return
}
const executionHostId = normalizeExecutionHostId(hostId)
if (hostId !== undefined && executionHostId === null) {
return
@@ -67,7 +71,7 @@ export function registerWorkspaceSpaceWindowHandlers(store: Store): void {
safelyRevealWindow(window)
}
}
main.send('workspaceSpace:revealWorktree', worktreeId, executionHostId ?? undefined)
main.send('workspaceSpace:revealWorktree', worktreeId, executionHostId ?? undefined, change)
}
)
ipcMain.handle('workspaceSpace:requestWorktreeRefresh', (event): void => {
+11 -2
View File
@@ -12,6 +12,7 @@ import type {
WorkspaceSpaceScanProgress
} from '../../shared/workspace-space-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { WorkspaceSpaceChange } from '../../shared/workspace-space-change'
export type WorkspaceCleanupApi = {
scan: (
@@ -41,9 +42,17 @@ export type WorkspaceSpaceApi = {
onWindowStateRequested?: (callback: () => void) => () => void
onWindowClosed?: (callback: () => void) => () => void
onWindowState?: (callback: (state: unknown) => void) => () => void
revealWorktree?: (worktreeId: string, executionHostId?: ExecutionHostId) => Promise<void>
revealWorktree?: (
worktreeId: string,
executionHostId?: ExecutionHostId,
change?: WorkspaceSpaceChange
) => Promise<void>
onRevealWorktree?: (
callback: (worktreeId: string, executionHostId?: ExecutionHostId) => void
callback: (
worktreeId: string,
executionHostId?: ExecutionHostId,
change?: WorkspaceSpaceChange
) => void
) => () => void
requestWorktreeRefresh?: () => Promise<void>
onWorktreeRefreshRequested?: (callback: () => void) => () => void
+16 -5
View File
@@ -4,6 +4,7 @@ import type {
WorkspaceSpaceScanProgress
} from '../../shared/workspace-space-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { WorkspaceSpaceChange } from '../../shared/workspace-space-change'
import type { PreloadApi } from '../api-types'
export const workspaceSpaceApi = {
@@ -15,8 +16,11 @@ export const workspaceSpaceApi = {
publishWindowState: (state: unknown) =>
ipcRenderer.invoke('workspaceSpace:publishWindowState', state),
requestWindowState: () => ipcRenderer.invoke('workspaceSpace:requestWindowState'),
revealWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) =>
ipcRenderer.invoke('workspaceSpace:revealWorktree', worktreeId, executionHostId),
revealWorktree: (
worktreeId: string,
executionHostId?: ExecutionHostId,
change?: WorkspaceSpaceChange
) => ipcRenderer.invoke('workspaceSpace:revealWorktree', worktreeId, executionHostId, change),
requestWorktreeRefresh: () => ipcRenderer.invoke('workspaceSpace:requestWorktreeRefresh'),
publishWindowAnalysis: (analysis: WorkspaceSpaceAnalysis) =>
ipcRenderer.invoke('workspaceSpace:publishWindowAnalysis', analysis),
@@ -41,12 +45,19 @@ export const workspaceSpaceApi = {
ipcRenderer.on('workspaceSpace:windowState', listener)
return () => ipcRenderer.removeListener('workspaceSpace:windowState', listener)
},
onRevealWorktree: (callback: (worktreeId: string, executionHostId?: ExecutionHostId) => void) => {
onRevealWorktree: (
callback: (
worktreeId: string,
executionHostId?: ExecutionHostId,
change?: WorkspaceSpaceChange
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
worktreeId: string,
executionHostId?: ExecutionHostId
): void => callback(worktreeId, executionHostId)
executionHostId?: ExecutionHostId,
change?: WorkspaceSpaceChange
): void => callback(worktreeId, executionHostId, change)
ipcRenderer.on('workspaceSpace:revealWorktree', listener)
return () => ipcRenderer.removeListener('workspaceSpace:revealWorktree', listener)
},
@@ -0,0 +1,34 @@
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { AppState } from '@/store/types'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
export function openUncommittedEntry(
state: Pick<
AppState,
'trackConflictPath' | 'openConflictFile' | 'openFile' | 'setEditorViewMode' | 'openDiff'
>,
worktreeId: string,
worktreePath: string,
entry: GitStatusEntry,
options: Parameters<AppState['openDiff']>[5] = {}
): void {
const language = detectLanguage(entry.path)
if (entry.conflictKind && entry.conflictStatus) {
if (entry.conflictStatus === 'unresolved') {
state.trackConflictPath(worktreeId, entry.path, entry.conflictKind)
}
state.openConflictFile(worktreeId, worktreePath, entry, language, options)
return
}
const filePath = joinPath(worktreePath, entry.path)
if (language === 'markdown' && entry.area === 'unstaged') {
state.openFile(
{ filePath, relativePath: entry.path, worktreeId, language, mode: 'edit' },
options
)
state.setEditorViewMode(filePath, 'changes')
return
}
state.openDiff(worktreeId, filePath, entry.path, language, entry.area === 'staged', options)
}
@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { openUncommittedEntry } from './open-uncommitted-entry'
import { useAppStore } from '@/store'
import type {
GitBranchChangeEntry,
@@ -89,37 +89,13 @@ export function useSourceControlRowOpening({
}
const targetGroupId = resolveSplitTargetGroupId(event)
const openAsPreview = shouldOpenSourceControlRowAsPreview(event, targetGroupId)
if (entry.conflictKind && entry.conflictStatus) {
if (entry.conflictStatus === 'unresolved') {
trackConflictPath(activeWorktreeId, entry.path, entry.conflictKind)
}
openConflictFile(activeWorktreeId, worktreePath, entry, detectLanguage(entry.path), {
targetGroupId,
preview: openAsPreview
})
return
}
const language = detectLanguage(entry.path)
const filePath = joinPath(worktreePath, entry.path)
// Why: unstaged markdown diffs open as an edit tab in Changes view (one tab per file); staged diffs still get a separate diff tab since that isn't what the editor edits.
if (language === 'markdown' && entry.area === 'unstaged') {
openFile(
{
filePath,
relativePath: entry.path,
worktreeId: activeWorktreeId,
language,
mode: 'edit'
},
{ targetGroupId, preview: openAsPreview }
)
setEditorViewMode(filePath, 'changes')
return
}
openDiff(activeWorktreeId, filePath, entry.path, language, entry.area === 'staged', {
targetGroupId,
preview: openAsPreview
})
openUncommittedEntry(
{ trackConflictPath, openConflictFile, openFile, setEditorViewMode, openDiff },
activeWorktreeId,
worktreePath,
entry,
{ targetGroupId, preview: openAsPreview }
)
},
[
activeWorktreeId,
@@ -14,6 +14,8 @@ import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-
import { resetAgentStatusEpochClockForTests } from '@/lib/agent-status-epoch-clock'
import { useWorkspaceSpaceManagerBindings } from './use-workspace-space-manager-bindings'
import { useWorkspaceSpaceDecisionProjection } from './use-workspace-space-decision-projection'
import { useWorkspaceSpaceManagerProjection } from './use-workspace-space-manager-projection'
import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection'
let store: ReturnType<typeof createTestStore>
vi.mock('../../store', () => ({
@@ -302,3 +304,41 @@ describe('mounted Space decision projection', () => {
)
})
})
describe('Space git refresh scheduling', () => {
it('keeps the queue running across results, errors, and inspection changes', async () => {
const workspace = row('w0')
seed([workspace])
const refreshWorkspaceGitStatus = vi.fn(async () => {})
const view = renderHook(() => {
const bindings = useWorkspaceSpaceManagerBindings()
const decision = useWorkspaceSpaceDecisionProjection(bindings)
const projection = useWorkspaceSpaceManagerProjection({
bindings,
decision,
refreshWorkspaceGitStatus
})
return { bindings, projection }
})
await act(async () => {})
expect(refreshWorkspaceGitStatus).toHaveBeenCalledTimes(1)
await act(async () => {
view.result.current.bindings.setGitStatusByWorktreeIdentity(
new Map([[getWorkspaceSpaceWorktreeIdentity(workspace), []]])
)
})
await act(async () => {
view.result.current.bindings.setGitRefreshStateByWorktreeId({
[getWorkspaceSpaceWorktreeIdentity(workspace)]: { isRefreshing: false, error: 'Offline' }
})
view.result.current.bindings.setInspectedWorktreeId(
getWorkspaceSpaceWorktreeIdentity(workspace)
)
})
expect(refreshWorkspaceGitStatus).toHaveBeenCalledTimes(1)
await act(async () => {
view.result.current.bindings.setQuery('w0')
})
expect(refreshWorkspaceGitStatus).toHaveBeenCalledTimes(2)
})
})
@@ -1,5 +1,4 @@
import { useCallback } from 'react'
import type { GitStatusResult } from '../../../../shared/git-status-types'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection'
@@ -54,12 +53,15 @@ export function useWorkspaceSpaceGitRefreshAction(bindings: WorkspaceSpaceManage
return (
owner && ownerTarget
? getRuntimeGitStatus({
target: ownerTarget,
worktreeId: worktree.worktreeId,
worktreePath: worktree.path,
connectionId: owner.connectionId ?? undefined
})
? getRuntimeGitStatus(
{
target: ownerTarget,
worktreeId: worktree.worktreeId,
worktreePath: worktree.path,
connectionId: owner.connectionId ?? undefined
},
{ includeLineStats: false, admissionTier: 'background' }
)
: Promise.reject(new Error('Workspace owner is no longer available'))
)
.then((status) => {
@@ -67,7 +69,7 @@ export function useWorkspaceSpaceGitRefreshAction(bindings: WorkspaceSpaceManage
return
}
const nextStatus = new Map(gitStatusByWorktreeIdentityRef.current)
nextStatus.set(identity, (status as GitStatusResult).entries)
nextStatus.set(identity, status.entries)
gitStatusByWorktreeIdentityRef.current = nextStatus
setGitStatusByWorktreeIdentity(nextStatus)
setGitRefreshStateByWorktreeId((current) => ({
@@ -155,7 +155,10 @@ export function useWorkspaceSpaceManagerPanel() {
)
const deleteWorktrees = useCallback(
(targets: readonly WorkspaceSpaceWorktree[]): void => {
(
targets: readonly WorkspaceSpaceWorktree[],
{ forceOnConfirm = false }: { forceOnConfirm?: boolean } = {}
): void => {
if (targets.length === 0) {
return
}
@@ -179,7 +182,7 @@ export function useWorkspaceSpaceManagerPanel() {
}
runWorktreeBatchDelete(identities, {
forceConfirm: true,
forceOnConfirm: false,
forceOnConfirm,
onDeleted: handleDeletedWorktrees
})
},
@@ -188,8 +191,7 @@ export function useWorkspaceSpaceManagerPanel() {
const forceDeleteWorktree = useCallback(
(worktree: WorkspaceSpaceWorktree): void => {
// Why: Space keeps normal deletes non-force so uncommitted work is not
// discarded silently; a failed row gets this explicit recovery path.
// Bulk deletion stays non-force; failed rows retain an explicit recovery path.
// The failure toast is keyed by the store row's host, which a host-less saved Space row lacks.
const storeRow = getWorktreeOnHostFromState(
useAppStore.getState(),
@@ -145,13 +145,22 @@ export function useWorkspaceSpaceManagerProjection(args: {
setGitStatusByWorktreeIdentity
])
useEffect(() => {
const visibleWorktreeIdentities = new Set(rows.map(getWorkspaceSpaceWorktreeIdentity))
const candidates = getWorkspaceSpaceGitStatusRefreshCandidates(sourceRows, {
// Status results must not restart the queue that produced them.
const gitStatusCandidates = useMemo(() => {
const visibleWorktreeIdentities = new Set(
filterWorkspaceSpaceRows(sourceRows, query, onlyDeletable).map(
getWorkspaceSpaceWorktreeIdentity
)
)
return getWorkspaceSpaceGitStatusRefreshCandidates(sourceRows, {
activeWorktreeId,
activeExecutionHostId: activeWorkspaceExecutionHostId,
visibleWorktreeIdentities
})
}, [sourceRows, query, onlyDeletable, activeWorktreeId, activeWorkspaceExecutionHostId])
useEffect(() => {
const candidates = gitStatusCandidates
if (candidates.length === 0) {
return
}
@@ -174,13 +183,7 @@ export function useWorkspaceSpaceManagerProjection(args: {
return () => {
cancelled = true
}
}, [
activeWorktreeId,
activeWorkspaceExecutionHostId,
refreshWorkspaceGitStatus,
rows,
sourceRows
])
}, [refreshWorkspaceGitStatus, gitStatusCandidates])
const inspectedWorktree =
rows.find((row) => getWorkspaceSpaceWorktreeIdentity(row) === nextInspectedWorktreeId) ??
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import { FileTypeIcon } from '../FileTypeIcon'
import { VirtualizedList } from '../virtualized-list'
import { Badge } from '../ui/badge'
import { Input } from '../ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
import { DiffLineCounts } from '../right-sidebar/source-control/listing/diff-line-counts'
import { openWorkspaceSpaceChange } from './workspace-space-open-change'
import { translate } from '@/i18n/i18n'
export function WorkspaceSpaceChanges({
worktree,
entries,
canOpen
}: {
worktree: WorkspaceSpaceWorktree
entries: readonly GitStatusEntry[]
canOpen: boolean
}): React.JSX.Element {
const [query, setQuery] = useState('')
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const normalizedQuery = query.trim().toLowerCase()
const rows = useMemo(
() => entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)),
[entries, normalizedQuery]
)
const fileCount = useMemo(() => new Set(entries.map((entry) => entry.path)).size, [entries])
return (
<Popover>
<PopoverTrigger asChild>
<Badge variant="outline" asChild>
<button
type="button"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{translate('workspaceSpace.changes.count', 'Changed files ({{count}})', {
count: fileCount
})}
<ChevronRight />
</button>
</Badge>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[min(32rem,calc(100vw-2rem))]"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<div className="space-y-2 border-b border-border p-3">
<div className="truncate text-sm font-semibold">{worktree.displayName}</div>
<p className="text-xs text-muted-foreground">
{translate(
'workspaceSpace.changes.description',
'Uncommitted files · Click a file to view its changes in Orca.'
)}
</p>
<Input
aria-label={translate('workspaceSpace.changes.search', 'Filter changed files')}
placeholder={translate('workspaceSpace.changes.search', 'Filter changed files')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div ref={setScrollElement} className="max-h-72 overflow-y-auto scrollbar-sleek">
<VirtualizedList
rows={rows}
scrollElement={scrollElement}
estimateRowHeightPx={36}
getRowKey={(entry) => `${entry.area}:${entry.path}`}
announceListPosition
renderRow={(entry) => (
<button
key={`${entry.area}:${entry.path}`}
type="button"
disabled={!canOpen}
onClick={() => openWorkspaceSpaceChange(worktree, entry)}
className="flex min-h-9 w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:opacity-50"
>
<FileTypeIcon
filePath={entry.path}
className="size-3.5 shrink-0"
style={{ color: STATUS_COLORS[entry.status] }}
/>
<span
className="min-w-0 flex-1 truncate"
title={entry.oldPath ? `${entry.oldPath} → ${entry.path}` : entry.path}
>
{entry.path}
</span>
<DiffLineCounts added={entry.added} removed={entry.removed} />
<span className="shrink-0 text-muted-foreground">
{entry.conflictStatus === 'unresolved'
? translate('workspaceSpace.changes.conflict', 'Conflict')
: entry.area === 'staged'
? translate('workspaceSpace.changes.staged', 'Staged')
: entry.area === 'untracked'
? translate('workspaceSpace.changes.untracked', 'Untracked')
: translate('workspaceSpace.changes.unstaged', 'Unstaged')}
</span>
<span style={{ color: STATUS_COLORS[entry.status] }}>
{STATUS_LABELS[entry.status]}
</span>
</button>
)}
/>
{rows.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{translate('workspaceSpace.changes.empty', 'No matching files.')}
</p>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { VirtualizedList } from '../virtualized-list'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { translate } from '@/i18n/i18n'
import { CheckButton, SortIndicator } from './workspace-space-selection-controls'
@@ -13,12 +15,14 @@ export function WorkspaceSpaceManagerTable({
}: {
model: WorkspaceSpaceManagerModel
}): React.JSX.Element {
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const {
analysis,
decisionDetailsByWorktreeId,
deleteWorktrees,
forceDeleteWorktree,
gitRefreshStateByWorktreeId,
gitStatusByWorktreeIdentity,
hasRows,
inspectedWorktree,
isInitialScan,
@@ -109,7 +113,7 @@ export function WorkspaceSpaceManagerTable({
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div ref={setScrollElement} 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" />
@@ -126,42 +130,50 @@ export function WorkspaceSpaceManagerTable({
)}
</div>
) : (
rows.map((worktree) => {
const identity = getWorkspaceSpaceWorktreeIdentity(worktree)
const decisionDetails = decisionDetailsByWorktreeId.get(identity)
if (!decisionDetails) {
return null
}
return (
<WorkspaceSpaceWorktreeRow
key={identity}
worktree={worktree}
selected={nextSelectedIds.has(identity)}
inspected={
inspectedWorktree !== null &&
getWorkspaceSpaceWorktreeIdentity(inspectedWorktree) === identity
}
decisionDetails={decisionDetails}
gitRefreshState={gitRefreshStateByWorktreeId[identity]}
deleteState={getDeleteStateForWorktree(worktree)}
onToggleSelected={() => toggleSelection(worktree)}
onInspect={() => setInspectedWorktreeId(identity)}
onOpenWorkspace={() =>
window.location.pathname.endsWith('/space.html')
? void window.api.workspaceSpace.revealWorktree?.(
worktree.worktreeId,
worktree.executionHostId
)
: activateAndRevealWorktree(worktree.worktreeId, {
navigationIntent: 'user-open',
executionHostId: worktree.executionHostId
})
}
onDelete={() => deleteWorktrees([worktree])}
onForceDelete={() => forceDeleteWorktree(worktree)}
/>
)
})
<VirtualizedList
rows={rows}
getRowKey={getWorkspaceSpaceWorktreeIdentity}
scrollElement={scrollElement}
estimateRowHeightPx={33}
announceListPosition
renderRow={(worktree) => {
const identity = getWorkspaceSpaceWorktreeIdentity(worktree)
const decisionDetails = decisionDetailsByWorktreeId.get(identity)
if (!decisionDetails) {
return null
}
return (
<WorkspaceSpaceWorktreeRow
key={identity}
worktree={worktree}
selected={nextSelectedIds.has(identity)}
inspected={
inspectedWorktree !== null &&
getWorkspaceSpaceWorktreeIdentity(inspectedWorktree) === identity
}
decisionDetails={decisionDetails}
changedFiles={gitStatusByWorktreeIdentity.get(identity)}
gitRefreshState={gitRefreshStateByWorktreeId[identity]}
deleteState={getDeleteStateForWorktree(worktree)}
onToggleSelected={() => toggleSelection(worktree)}
onInspect={() => setInspectedWorktreeId(identity)}
onOpenWorkspace={() =>
window.location.pathname.endsWith('/space.html')
? void window.api.workspaceSpace.revealWorktree?.(
worktree.worktreeId,
worktree.executionHostId
)
: activateAndRevealWorktree(worktree.worktreeId, {
navigationIntent: 'user-open',
executionHostId: worktree.executionHostId
})
}
onDelete={() => deleteWorktrees([worktree], { forceOnConfirm: true })}
onForceDelete={() => forceDeleteWorktree(worktree)}
/>
)
}}
/>
)}
</div>
</div>
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
const mocks = vi.hoisted(() => ({
state: {
repos: [
{ id: 'repo', connectionId: null, executionHostId: 'local' },
{ id: 'repo', connectionId: 'host', executionHostId: 'ssh:host' },
{ id: 'repo', connectionId: null, executionHostId: 'runtime:server' }
],
settings: { activeRuntimeEnvironmentId: 'other-server' }
},
worktree: { id: 'same-id', repoId: 'repo', path: '/server/workspace' },
getWorktree: vi.fn(),
activate: vi.fn(),
gitStatus: vi.fn(),
openEntry: vi.fn(),
error: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/store/selectors', () => ({ getWorktreeOnHostFromState: mocks.getWorktree }))
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activate }))
vi.mock('@/runtime/runtime-git-status-client', () => ({ getRuntimeGitStatus: mocks.gitStatus }))
vi.mock('../right-sidebar/source-control/listing/open-uncommitted-entry', () => ({
openUncommittedEntry: mocks.openEntry
}))
vi.mock('sonner', () => ({ toast: { error: mocks.error } }))
import { revealWorkspaceSpaceChange } from './workspace-space-open-change'
const entry: GitStatusEntry = { path: 'src/app.ts', area: 'staged', status: 'modified' }
beforeEach(() => {
vi.clearAllMocks()
mocks.getWorktree.mockReturnValue(mocks.worktree)
mocks.gitStatus.mockResolvedValue({ entries: [entry] })
mocks.activate.mockReturnValue({ primaryTabId: null })
})
describe('Space change inspection', () => {
it.each([
['local', { kind: 'local' }, null],
['ssh:host', { kind: 'local' }, null],
['runtime:server', { kind: 'environment', environmentId: 'server' }, 'server']
] as const)(
'opens the diff on the selected host %s',
async (hostId, target, runtimeEnvironmentId) => {
await revealWorkspaceSpaceChange('same-id', hostId, entry)
expect(mocks.getWorktree).toHaveBeenCalledWith(mocks.state, 'same-id', hostId)
expect(mocks.gitStatus).toHaveBeenCalledWith(
{
target,
worktreeId: 'same-id',
worktreePath: '/server/workspace',
connectionId: hostId === 'ssh:host' ? 'host' : undefined
},
{ includeLineStats: false }
)
expect(mocks.activate).toHaveBeenCalledWith('same-id', {
navigationIntent: 'user-open',
providesInitialSurface: true,
executionHostId: hostId
})
expect(mocks.openEntry).toHaveBeenCalledWith(
mocks.state,
'same-id',
'/server/workspace',
entry,
{ preview: true, runtimeEnvironmentId }
)
}
)
it('uses the workspace owner when a legacy reveal omits the host', async () => {
mocks.getWorktree.mockReturnValue({ ...mocks.worktree, hostId: 'runtime:server' })
await revealWorkspaceSpaceChange('same-id', undefined, entry)
expect(mocks.gitStatus).toHaveBeenCalledWith(
expect.objectContaining({ target: { kind: 'environment', environmentId: 'server' } }),
{ includeLineStats: false }
)
expect(mocks.activate).toHaveBeenCalledWith(
'same-id',
expect.objectContaining({ executionHostId: 'runtime:server' })
)
})
it('uses current conflict metadata from the execution host', async () => {
const conflict = { ...entry, conflictKind: 'both_deleted', conflictStatus: 'unresolved' }
mocks.gitStatus.mockResolvedValue({ entries: [conflict] })
await revealWorkspaceSpaceChange('same-id', 'local', entry)
expect(mocks.openEntry.mock.calls[0][3]).toBe(conflict)
})
it('reports a stale change without navigating or opening a file', async () => {
mocks.gitStatus.mockResolvedValue({ entries: [{ ...entry, area: 'unstaged' }] })
await revealWorkspaceSpaceChange('same-id', 'runtime:server', entry)
expect(mocks.activate).not.toHaveBeenCalled()
expect(mocks.openEntry).not.toHaveBeenCalled()
expect(mocks.error).toHaveBeenCalledOnce()
})
it('does not fall back to a different host when its owner is unavailable', async () => {
mocks.getWorktree.mockReturnValue(undefined)
await revealWorkspaceSpaceChange('same-id', 'ssh:gone', entry)
expect(mocks.gitStatus).not.toHaveBeenCalled()
expect(mocks.openEntry).not.toHaveBeenCalled()
expect(mocks.error).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,89 @@
import { toast } from 'sonner'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import type { WorkspaceSpaceChange } from '../../../../shared/workspace-space-change'
import { useAppStore } from '@/store'
import { getWorktreeOnHostFromState } from '@/store/selectors'
import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
import { openUncommittedEntry } from '../right-sidebar/source-control/listing/open-uncommitted-entry'
import { translate } from '@/i18n/i18n'
export function openWorkspaceSpaceChange(
worktree: WorkspaceSpaceWorktree,
entry: GitStatusEntry
): void {
const change = { path: entry.path, area: entry.area }
if (window.location.pathname.endsWith('/space.html')) {
void window.api.workspaceSpace.revealWorktree?.(
worktree.worktreeId,
worktree.executionHostId,
change
)
return
}
void revealWorkspaceSpaceChange(worktree.worktreeId, worktree.executionHostId, change)
}
export async function revealWorkspaceSpaceChange(
worktreeId: string,
executionHostId: ExecutionHostId | undefined,
change: WorkspaceSpaceChange
): Promise<void> {
try {
const state = useAppStore.getState()
const worktree = getWorktreeOnHostFromState(state, worktreeId, executionHostId)
const ownerHostId = executionHostId ?? worktree?.hostId ?? LOCAL_EXECUTION_HOST_ID
const repo =
worktree &&
state.repos.find((repo) => repoMatchesHostIdentity(repo, worktree.repoId, ownerHostId))
const target = ipcOrRuntimeTargetForHostId(ownerHostId)
if (!worktree || !repo || !target) {
throw new Error(
translate(
'workspaceSpace.changes.unavailable',
'The workspace owner is no longer available.'
)
)
}
const status = await getRuntimeGitStatus(
{
target,
worktreeId,
worktreePath: worktree.path,
connectionId: repo.connectionId ?? undefined
},
{ includeLineStats: false }
)
const entry = status.entries.find(
(entry) => entry.path === change.path && entry.area === change.area
)
if (!entry) {
throw new Error(
translate(
'workspaceSpace.changes.stale',
'This file is no longer in the uncommitted changes. Refresh Space to update the list.'
)
)
}
const activated = activateAndRevealWorktree(worktreeId, {
navigationIntent: 'user-open',
providesInitialSurface: true,
executionHostId: ownerHostId
})
if (activated === false) {
return
}
openUncommittedEntry(useAppStore.getState(), worktreeId, worktree.path, entry, {
preview: true,
runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null
})
} catch (error) {
toast.error(translate('workspaceSpace.changes.openFailed', 'Could not open changes'), {
description: error instanceof Error ? error.message : String(error)
})
}
}
@@ -68,6 +68,44 @@ afterEach(() => {
})
describe('WorkspaceSpaceWorktreeRow context menu', () => {
it.each([
{ changedFileCount: null },
{ changedFileCount: 3 },
{ dirtyEditorBufferCount: 1 },
{ liveTerminalCount: 1 },
{ issueLabel: '#123' },
{ isActive: true }
])('allows an explicit deletion while keeping bulk selection protected: %j', (readiness) => {
const onDelete = vi.fn()
render(
<WorkspaceSpaceWorktreeRow
worktree={{ ...worktree, isMainWorktree: false, canDelete: true }}
selected={false}
inspected={false}
decisionDetails={{ ...decisionDetails, ...readiness }}
onToggleSelected={() => {}}
onInspect={() => {}}
onOpenWorkspace={() => {}}
onDelete={onDelete}
onForceDelete={() => {}}
/>
)
expect(
screen.getByRole('checkbox', { name: 'Select workspace' }).hasAttribute('disabled')
).toBe(true)
fireEvent.contextMenu(
screen.getByText('workspace').closest('[data-slot="context-menu-trigger"]')!
)
expect(
screen
.getByText('Delete workspace')
.closest('[role="menuitem"]')
?.getAttribute('data-disabled')
).toBeNull()
fireEvent.click(screen.getByText('Delete workspace'))
expect(onDelete).toHaveBeenCalledOnce()
})
it('shows the existing workspace activity timestamp with its full time on hover', () => {
const lastActivityAt = Date.now() - 2 * 60 * 60 * 1000
render(
@@ -1,5 +1,6 @@
import { AlertTriangle, FolderSearch, GitBranch, Server, Trash2 } from 'lucide-react'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import { cn } from '@/lib/utils'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
@@ -28,12 +29,14 @@ import { WorkspaceDecisionHoverCard } from './workspace-space-decision-hover-car
import { WorkspaceSpaceStatusBadge } from './workspace-space-status-badge'
import { describeWorktreeDeleteError } from '../sidebar/worktree-delete-error-display'
import { formatShortTimeAgo } from '@/lib/short-time-ago'
import { WorkspaceSpaceChanges } from './workspace-space-changes'
export function WorkspaceSpaceWorktreeRow({
worktree,
selected,
inspected,
decisionDetails,
changedFiles,
gitRefreshState,
deleteState,
onToggleSelected,
@@ -46,6 +49,7 @@ export function WorkspaceSpaceWorktreeRow({
selected: boolean
inspected: boolean
decisionDetails: WorkspaceDecisionDetails
changedFiles?: readonly GitStatusEntry[]
gitRefreshState?: WorkspaceGitRefreshState
deleteState?: WorkspaceSpaceDeleteState
onToggleSelected: () => void
@@ -57,7 +61,13 @@ export function WorkspaceSpaceWorktreeRow({
const isDeleting = deleteState?.isDeleting ?? false
const deleteError = deleteState?.error ? describeWorktreeDeleteError(deleteState.error) : null
const canForceDelete = deleteState?.canForceDelete ?? false
const canDelete = isWorkspaceSpaceRowReadyToDelete(worktree, decisionDetails) && !isDeleting
const canSelect = isWorkspaceSpaceRowReadyToDelete(worktree, decisionDetails) && !isDeleting
const canDelete =
worktree.canDelete &&
!worktree.isMainWorktree &&
worktree.status === 'ok' &&
decisionDetails.canOpenWorkspace &&
!isDeleting
const handleForceDelete = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault()
event.stopPropagation()
@@ -68,8 +78,12 @@ export function WorkspaceSpaceWorktreeRow({
role="button"
tabIndex={0}
aria-busy={isDeleting}
data-current={inspected ? 'true' : undefined}
onClick={onInspect}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return
}
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
@@ -83,8 +97,8 @@ export function WorkspaceSpaceWorktreeRow({
)}
>
<CheckButton
checked={canDelete && selected}
disabled={!canDelete}
checked={canSelect && selected}
disabled={!canSelect}
label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.0d1c78d749',
'Select {{value0}}',
@@ -173,27 +187,35 @@ export function WorkspaceSpaceWorktreeRow({
<Trash2 className="size-3.5" />
</Button>
) : null}
<HoverCard openDelay={250} closeDelay={120}>
<HoverCardTrigger asChild>
<span
className="inline-flex"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<WorkspaceSpaceStatusBadge
worktree={worktree}
decisionDetails={decisionDetails}
deleteState={deleteState}
/>
</span>
</HoverCardTrigger>
<WorkspaceDecisionHoverCard
{!isDeleting && !deleteState?.error && changedFiles && changedFiles.length > 0 ? (
<WorkspaceSpaceChanges
worktree={worktree}
details={decisionDetails}
gitRefreshState={gitRefreshState}
onOpenWorkspace={onOpenWorkspace}
entries={changedFiles}
canOpen={decisionDetails.canOpenWorkspace}
/>
</HoverCard>
) : (
<HoverCard openDelay={250} closeDelay={120}>
<HoverCardTrigger asChild>
<span
className="inline-flex"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<WorkspaceSpaceStatusBadge
worktree={worktree}
decisionDetails={decisionDetails}
deleteState={deleteState}
/>
</span>
</HoverCardTrigger>
<WorkspaceDecisionHoverCard
worktree={worktree}
details={decisionDetails}
gitRefreshState={gitRefreshState}
onOpenWorkspace={onOpenWorkspace}
/>
</HoverCard>
)}
</div>
</div>
)
@@ -217,6 +239,23 @@ export function WorkspaceSpaceWorktreeRow({
'Delete workspace'
)}
</ContextMenuItem>
{!canDelete ? (
<div className="max-w-64 px-2 py-1.5 text-xs text-muted-foreground">
{isDeleting
? translate('workspaceSpace.delete.pending', 'Deletion is in progress.')
: worktree.isMainWorktree
? translate('workspaceSpace.delete.main', 'The main worktree is protected.')
: !decisionDetails.canOpenWorkspace
? translate(
'workspaceSpace.delete.unmanaged',
'This workspace is no longer managed by Orca.'
)
: translate(
'workspaceSpace.delete.unavailable',
'This workspace is protected or unavailable.'
)}
</div>
) : null}
</ContextMenuContent>
</ContextMenu>
)
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { pickSpaceWindowState, spaceWindowStateChanged } from './space-window-state'
import { revealWorkspaceSpaceChange } from '../status-bar/workspace-space-open-change'
export function SpaceWindowStatePublisher(): null {
useEffect(() => {
@@ -41,7 +42,11 @@ export function SpaceWindowStatePublisher(): null {
timer = null
}
})
const offReveal = api.onRevealWorktree?.((worktreeId, executionHostId) => {
const offReveal = api.onRevealWorktree?.((worktreeId, executionHostId, change) => {
if (change) {
void revealWorkspaceSpaceChange(worktreeId, executionHostId, change)
return
}
activateAndRevealWorktree(worktreeId, { navigationIntent: 'user-open', executionHostId })
})
const offRefresh = api.onWorktreeRefreshRequested?.(() => {
+19
View File
@@ -20522,6 +20522,25 @@
"window": {
"errorTitle": "Space hit an error.",
"errorDescription": "Close and reopen Space to try again."
},
"changes": {
"count": "Changed files ({{count}})",
"description": "Uncommitted files · Click a file to view its changes in Orca.",
"search": "Filter changed files",
"conflict": "Conflict",
"staged": "Staged",
"untracked": "Untracked",
"unstaged": "Unstaged",
"empty": "No matching files.",
"unavailable": "The workspace owner is no longer available.",
"stale": "This file is no longer in the uncommitted changes. Refresh Space to update the list.",
"openFailed": "Could not open changes"
},
"delete": {
"pending": "Deletion is in progress.",
"main": "The main worktree is protected.",
"unmanaged": "This workspace is no longer managed by Orca.",
"unavailable": "This workspace is protected or unavailable."
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { GitStagingArea } from './git-status-types'
export type WorkspaceSpaceChange = { path: string; area: GitStagingArea }
export function isWorkspaceSpaceChange(value: unknown): value is WorkspaceSpaceChange {
return (
value !== null &&
typeof value === 'object' &&
'path' in value &&
typeof value.path === 'string' &&
value.path.length > 0 &&
value.path.length <= 32_768 &&
'area' in value &&
(value.area === 'staged' || value.area === 'unstaged' || value.area === 'untracked')
)
}