From 81b8bfb890f1861e9ab364cd66b917ff9133a00a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:05:49 -0700 Subject: [PATCH] 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 --- src/main/ipc/workspace-space-window.ts | 8 +- src/preload/api/workspace-cleanup-api.ts | 13 +- src/preload/api/workspace-space-bridge.ts | 21 ++- .../listing/open-uncommitted-entry.ts | 34 +++++ .../source-control/listing/use-row-opening.ts | 40 ++---- ...rkspace-space-decision-projection.test.tsx | 40 ++++++ .../use-workspace-space-git-refresh-action.ts | 18 +-- .../use-workspace-space-manager-panel.ts | 10 +- .../use-workspace-space-manager-projection.ts | 23 ++-- .../status-bar/workspace-space-changes.tsx | 120 ++++++++++++++++++ .../workspace-space-manager-table.tsx | 86 +++++++------ .../workspace-space-open-change.test.ts | 109 ++++++++++++++++ .../status-bar/workspace-space-open-change.ts | 89 +++++++++++++ .../workspace-space-worktree-row.test.tsx | 38 ++++++ .../workspace-space-worktree-row.tsx | 83 ++++++++---- .../SpaceWindowStatePublisher.tsx | 7 +- src/renderer/src/i18n/locales/en.json | 19 +++ src/shared/workspace-space-change.ts | 16 +++ 18 files changed, 651 insertions(+), 123 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-changes.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-open-change.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-open-change.ts create mode 100644 src/shared/workspace-space-change.ts diff --git a/src/main/ipc/workspace-space-window.ts b/src/main/ipc/workspace-space-window.ts index ae312b5107b..f604d92aaef 100644 --- a/src/main/ipc/workspace-space-window.ts +++ b/src/main/ipc/workspace-space-window.ts @@ -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 => { diff --git a/src/preload/api/workspace-cleanup-api.ts b/src/preload/api/workspace-cleanup-api.ts index f2d579f8bc1..0e45a55aa98 100644 --- a/src/preload/api/workspace-cleanup-api.ts +++ b/src/preload/api/workspace-cleanup-api.ts @@ -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 + revealWorktree?: ( + worktreeId: string, + executionHostId?: ExecutionHostId, + change?: WorkspaceSpaceChange + ) => Promise onRevealWorktree?: ( - callback: (worktreeId: string, executionHostId?: ExecutionHostId) => void + callback: ( + worktreeId: string, + executionHostId?: ExecutionHostId, + change?: WorkspaceSpaceChange + ) => void ) => () => void requestWorktreeRefresh?: () => Promise onWorktreeRefreshRequested?: (callback: () => void) => () => void diff --git a/src/preload/api/workspace-space-bridge.ts b/src/preload/api/workspace-space-bridge.ts index 3904c6ae424..d7cace43065 100644 --- a/src/preload/api/workspace-space-bridge.ts +++ b/src/preload/api/workspace-space-bridge.ts @@ -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) }, diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts b/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts new file mode 100644 index 00000000000..d95e67dc6f4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts @@ -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[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) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts index 77050fb42f6..5b324114a53 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts @@ -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, diff --git a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx index e26a5ad60dc..8b3557d386b 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx +++ b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx @@ -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 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) + }) +}) diff --git a/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts b/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts index f19c8c0489f..e44e2f3a9ca 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts @@ -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) => ({ diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts index 3d0170027e4..82ede70c179 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts @@ -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(), diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts index 377cb6a4b04..e82768ba7bf 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts @@ -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) ?? diff --git a/src/renderer/src/components/status-bar/workspace-space-changes.tsx b/src/renderer/src/components/status-bar/workspace-space-changes.tsx new file mode 100644 index 00000000000..f4420c3b949 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-changes.tsx @@ -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(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 ( + + + + + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > +
+
{worktree.displayName}
+

+ {translate( + 'workspaceSpace.changes.description', + 'Uncommitted files · Click a file to view its changes in Orca.' + )} +

+ setQuery(event.target.value)} + /> +
+
+ `${entry.area}:${entry.path}`} + announceListPosition + renderRow={(entry) => ( + + )} + /> + {rows.length === 0 ? ( +

+ {translate('workspaceSpace.changes.empty', 'No matching files.')} +

+ ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx b/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx index e69c0ae7fab..9a67c59d158 100644 --- a/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx @@ -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(null) const { analysis, decisionDetailsByWorktreeId, deleteWorktrees, forceDeleteWorktree, gitRefreshStateByWorktreeId, + gitStatusByWorktreeIdentity, hasRows, inspectedWorktree, isInitialScan, @@ -109,7 +113,7 @@ export function WorkspaceSpaceManagerTable({ -
+
{isInitialScan ? (
@@ -126,42 +130,50 @@ export function WorkspaceSpaceManagerTable({ )}
) : ( - rows.map((worktree) => { - const identity = getWorkspaceSpaceWorktreeIdentity(worktree) - const decisionDetails = decisionDetailsByWorktreeId.get(identity) - if (!decisionDetails) { - return null - } - return ( - 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)} - /> - ) - }) + { + const identity = getWorkspaceSpaceWorktreeIdentity(worktree) + const decisionDetails = decisionDetailsByWorktreeId.get(identity) + if (!decisionDetails) { + return null + } + return ( + 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)} + /> + ) + }} + /> )}
diff --git a/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts b/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts new file mode 100644 index 00000000000..827ae0dad17 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-open-change.ts b/src/renderer/src/components/status-bar/workspace-space-open-change.ts new file mode 100644 index 00000000000..289e25ea3f5 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-open-change.ts @@ -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 { + 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) + }) + } +} diff --git a/src/renderer/src/components/status-bar/workspace-space-worktree-row.test.tsx b/src/renderer/src/components/status-bar/workspace-space-worktree-row.test.tsx index 867c06e7f49..4b22843175f 100644 --- a/src/renderer/src/components/status-bar/workspace-space-worktree-row.test.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-worktree-row.test.tsx @@ -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( + {}} + 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( diff --git a/src/renderer/src/components/status-bar/workspace-space-worktree-row.tsx b/src/renderer/src/components/status-bar/workspace-space-worktree-row.tsx index 7177810eafd..a1b223c99f1 100644 --- a/src/renderer/src/components/status-bar/workspace-space-worktree-row.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-worktree-row.tsx @@ -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): 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({ )} > ) : null} - - - event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - - - 0 ? ( + - + ) : ( + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + + + + )} ) @@ -217,6 +239,23 @@ export function WorkspaceSpaceWorktreeRow({ 'Delete workspace' )} + {!canDelete ? ( +
+ {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.' + )} +
+ ) : null} ) diff --git a/src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.tsx b/src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.tsx index b8128f8f607..f77ded81b8c 100644 --- a/src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.tsx +++ b/src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.tsx @@ -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?.(() => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7746e8f10a8..689c02b3f64 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } } } diff --git a/src/shared/workspace-space-change.ts b/src/shared/workspace-space-change.ts new file mode 100644 index 00000000000..059ed75385e --- /dev/null +++ b/src/shared/workspace-space-change.ts @@ -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') + ) +}