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