Keep workspace deletion dialogs steady while changes load (#25321)

* Keep workspace deletion warnings from shifting the dialog

* Tighten spacing in workspace deletion confirmations

* Address deletion dialog review and synchronize localization catalogs
This commit is contained in:
Neil
2026-10-04 16:49:12 -07:00
committed by GitHub
parent 8e5080c132
commit 955dce5a5a
14 changed files with 504 additions and 108 deletions
@@ -359,7 +359,9 @@ describe('DeleteWorktreeDialog lineage copy', () => {
const markup = renderToStaticMarkup(<DeleteWorktreeDialog />)
expect(markup).toContain('2 uncommitted or untracked changes')
expect(markup).toContain('Deleting this workspace permanently removes these changes from disk.')
expect(markup).toContain(
'Any uncommitted or untracked changes in Git workspaces will be permanently deleted.'
)
expect(markup).not.toContain('Also delete local branch')
})
@@ -23,6 +23,7 @@ import { DeleteWorktreeTargetPreview } from './DeleteWorktreeTargetPreview'
import { DeleteWorktreeWarningPanels } from './DeleteWorktreeWarningPanels'
import { persistDeleteWorktreeConfirmSkipPreference } from './delete-worktree-preference-toast'
import {
getDeleteWorktreeChangeCheckStates,
getDeleteWorktreeDirtyChangeCounts,
getDeleteWorktreeDirtyChangePreviews
} from './delete-worktree-dirty-change-counts'
@@ -117,15 +118,11 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
const isBatchDelete = worktreeIds.length > 1
const isFolderWorkspaceDelete = !isBatchDelete && getIsFolderWorkspaceDelete(repoMap, worktree)
const folderWorkspaceDeleteCount = useMemo(
() => countFolderWorkspaceDeletes(repoMap, worktrees),
[repoMap, worktrees]
)
const deleteCopy = getDeleteWorktreeDialogCopy({
isBatchDelete,
worktree,
worktreeCount: worktrees.length,
folderWorkspaceDeleteCount,
folderWorkspaceDeleteCount: countFolderWorkspaceDeletes(repoMap, worktrees),
isFolderWorkspaceDelete
})
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
@@ -183,6 +180,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const dirtyChanges = useMemo(() => {
const statusInput = { deleteTargets, gitStatusByWorktree, gitStatusByWorktreeIdentity, repoMap }
return {
checkStates: getDeleteWorktreeChangeCheckStates(statusInput),
counts: getDeleteWorktreeDirtyChangeCounts({ ...statusInput, deleteStateByWorktreeId }),
previews: getDeleteWorktreeDirtyChangePreviews(statusInput)
}
@@ -365,6 +363,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
)}
</DialogTitle>
<DeleteWorktreeDialogDescription
showChangeLossWarning={dirtyChanges.checkStates.size > 0}
targetClassName={deleteCopy.targetClassName}
targetLabel={deleteCopy.targetLabel}
canDeleteAllLineage={canDeleteAllLineage}
@@ -385,6 +384,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
collisionWorktrees={allWorktrees}
hostLabelById={hostLabelById}
deleteStateByWorktreeId={deleteStateByWorktreeId}
changeCheckStatesByWorktreeId={dirtyChanges.checkStates}
dirtyChangeCountsByWorktreeId={dirtyChanges.counts}
dirtyChangePreviewsByWorktreeId={dirtyChanges.previews}
/>
@@ -392,6 +392,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
{hasLineageChildren && (
<DeleteWorktreeLineageNotice
descendants={lineageDelete.descendants}
changeCheckStatesByWorktreeId={dirtyChanges.checkStates}
dirtyChangeCountsByWorktreeId={dirtyChanges.counts}
dirtyChangePreviewsByWorktreeId={dirtyChanges.previews}
/>
@@ -6,13 +6,15 @@ export function DeleteWorktreeDialogDescription({
targetLabel,
canDeleteAllLineage,
childTargetLabel,
descriptionSuffix
descriptionSuffix,
showChangeLossWarning
}: {
targetClassName: string
targetLabel: string | undefined
canDeleteAllLineage: boolean
childTargetLabel: string
descriptionSuffix: string
showChangeLossWarning?: boolean
}): React.JSX.Element {
return (
<DialogDescription className="text-xs">
@@ -28,6 +30,14 @@ export function DeleteWorktreeDialogDescription({
) : (
<> {descriptionSuffix}</>
)}
{showChangeLossWarning && (
<span className="mt-1 block">
{translate(
'components.workspace.delete.changes.permanentLoss',
'Any uncommitted or untracked changes in Git workspaces will be permanently deleted.'
)}
</span>
)}
</DialogDescription>
)
}
@@ -1,19 +1,43 @@
import type { JSX } from 'react'
import { AlertTriangle, ChevronRight } from 'lucide-react'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
import type { DeleteWorktreeDirtyChangePreview } from './delete-worktree-dirty-change-counts'
import type {
DeleteWorktreeChangeCheckState,
DeleteWorktreeDirtyChangePreview
} from './delete-worktree-dirty-change-counts'
export function DeleteWorktreeDirtyChangeHint({
changeCount,
checkState,
preview
}: {
changeCount: number | undefined
checkState?: DeleteWorktreeChangeCheckState
preview?: DeleteWorktreeDirtyChangePreview
}): JSX.Element | null {
if (changeCount === undefined) {
return null
if (!checkState) {
return null
}
const statusLabel =
checkState === 'complete'
? translate(
'components.workspace.delete.changes.clean',
'No uncommitted or untracked changes'
)
: checkState === 'unavailable'
? translate(
'components.workspace.delete.changes.unavailable',
'Changes could not be checked'
)
: translate('components.workspace.delete.changes.checking', 'Checking for changes…')
return (
<div className="mt-0.5 flex h-4 min-w-0 items-center text-muted-foreground">
<span className="truncate">{statusLabel}</span>
</div>
)
}
const label =
@@ -21,10 +45,6 @@ export function DeleteWorktreeDirtyChangeHint({
? `${changeCount} uncommitted or untracked ${changeCount === 1 ? 'change' : 'changes'}`
: 'Uncommitted or untracked changes'
const warning = translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.8e2994ce28',
'Deleting this workspace permanently removes these changes from disk.'
)
const warningLabel = (
<>
<AlertTriangle className="size-3 shrink-0" />
@@ -33,82 +53,107 @@ export function DeleteWorktreeDirtyChangeHint({
)
if (!preview?.files.length) {
const detailsLabel =
checkState === 'checking'
? translate('components.workspace.delete.changes.checkingDetails', 'Checking…')
: checkState === 'unavailable'
? translate(
'components.workspace.delete.changes.unavailableDetails',
'Details unavailable'
)
: null
return (
<div className="mt-1 min-w-0">
<div className="mt-0.5 flex h-4 min-w-0 items-center">
<div className="flex w-fit max-w-full items-center gap-1.5 text-destructive">
{warningLabel}
{detailsLabel && <span className="shrink-0 text-muted-foreground">· {detailsLabel}</span>}
</div>
<p className="mt-1 text-muted-foreground">{warning}</p>
</div>
)
}
return (
<Collapsible className="mt-1 min-w-0">
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={`${label}: ${translate('auto.components.sidebar.DeleteWorktreeDirtyChangeHint.showLoadedPaths', 'Show loaded paths')}`}
className="group flex w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-destructive hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{warningLabel}
<ChevronRight className="size-3 shrink-0 group-data-[state=open]:rotate-90" />
</button>
</CollapsibleTrigger>
<p className="mt-1 text-muted-foreground">{warning}</p>
<CollapsibleContent>
<div className="mt-1 min-w-0 rounded-sm border border-border/60 bg-background/60 px-2 py-1.5">
<p className="text-muted-foreground">
{translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.loadedPathsNotice',
'Loaded paths may be incomplete or out of date.'
)}
</p>
<div
role="region"
aria-label={translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.loadedChangedPaths',
'Loaded changed paths'
)}
tabIndex={0}
className="scrollbar-sleek mt-1 max-h-40 min-w-0 overflow-y-auto"
<div className="mt-0.5 flex h-4 min-w-0 items-center">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`${label}: ${translate('auto.components.sidebar.DeleteWorktreeDirtyChangeHint.showLoadedPaths', 'Show loaded paths')}`}
className="flex h-4 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-destructive hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<ul className="min-w-0 space-y-0.5 font-mono">
{preview.files.map((file) => (
<li key={file.path} className="flex min-w-0 items-baseline gap-2">
<span
className="w-3 shrink-0 font-semibold"
style={{ color: STATUS_COLORS[file.status] }}
aria-label={file.status}
>
{STATUS_LABELS[file.status]}
</span>
<div className="min-w-0 break-all text-foreground">
{file.path}
{file.hasUnresolvedConflict ? (
<span className="ml-2 font-sans text-destructive">
{translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.unresolvedConflict',
'Unresolved conflict'
)}
</span>
) : null}
</div>
</li>
))}
</ul>
</div>
{preview.remainingPathCount > 0 ? (
<p className="mt-1 text-muted-foreground">
{warningLabel}
<ChevronRight className="size-3 shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
aria-label={translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.loadedChangedPaths',
'Loaded changed paths'
)}
className="w-80 max-w-[calc(100vw-2rem)]"
wheelScroll
>
<div className="scrollbar-sleek max-h-[var(--radix-popover-content-available-height)] overflow-y-auto p-3 text-xs">
<p className="mb-1 font-medium">
{translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.moreLoadedPaths',
'and {{value0}} more loaded paths',
{ value0: preview.remainingPathCount }
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.loadedChangedPaths',
'Loaded changed paths'
)}
</p>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
<p className="text-muted-foreground">
{translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.loadedPathsNotice',
'Loaded paths may be incomplete or out of date.'
)}
</p>
<div
role="region"
aria-label={translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.loadedChangedPaths',
'Loaded changed paths'
)}
tabIndex={0}
className="scrollbar-sleek mt-1 max-h-40 min-w-0 overflow-y-auto"
>
<ul className="min-w-0 space-y-0.5 font-mono">
{preview.files.map((file) => (
<li key={file.path} className="flex min-w-0 items-baseline gap-2">
<span
className="w-3 shrink-0 font-semibold"
style={{ color: STATUS_COLORS[file.status] }}
aria-label={file.status}
>
{STATUS_LABELS[file.status]}
</span>
<div className="min-w-0 break-all text-foreground">
{file.path}
{file.hasUnresolvedConflict ? (
<span className="ml-2 font-sans text-destructive">
{translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.unresolvedConflict',
'Unresolved conflict'
)}
</span>
) : null}
</div>
</li>
))}
</ul>
</div>
{preview.remainingPathCount > 0 ? (
<p className="mt-1 text-muted-foreground">
{translate(
'auto.components.sidebar.DeleteWorktreeDirtyChangeHint.moreLoadedPaths',
'and {{value0}} more loaded paths',
{ value0: preview.remainingPathCount }
)}
</p>
) : null}
</div>
</PopoverContent>
</Popover>
</div>
)
}
@@ -3,19 +3,24 @@ import type { JSX } from 'react'
import type { Worktree } from '../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint'
import type { DeleteWorktreeDirtyChangePreview } from './delete-worktree-dirty-change-counts'
import type {
DeleteWorktreeChangeCheckState,
DeleteWorktreeDirtyChangePreview
} from './delete-worktree-dirty-change-counts'
import { translate } from '@/i18n/i18n'
type DeleteWorktreeLineageNoticeProps = {
descendants: readonly Worktree[]
dirtyChangeCountsByWorktreeId: ReadonlyMap<string, number>
dirtyChangePreviewsByWorktreeId: ReadonlyMap<string, DeleteWorktreeDirtyChangePreview>
changeCheckStatesByWorktreeId?: ReadonlyMap<string, DeleteWorktreeChangeCheckState>
}
export function DeleteWorktreeLineageNotice({
descendants,
dirtyChangeCountsByWorktreeId,
dirtyChangePreviewsByWorktreeId
dirtyChangePreviewsByWorktreeId,
changeCheckStatesByWorktreeId
}: DeleteWorktreeLineageNoticeProps): JSX.Element | null {
const childWorkspaceCount = descendants.length
if (childWorkspaceCount === 0) {
@@ -53,6 +58,9 @@ export function DeleteWorktreeLineageNotice({
<div className="truncate font-medium text-foreground">{child.displayName}</div>
<div className="truncate text-muted-foreground">{child.path}</div>
<DeleteWorktreeDirtyChangeHint
checkState={changeCheckStatesByWorktreeId?.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
changeCount={dirtyChangeCountsByWorktreeId.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
@@ -190,9 +190,6 @@ describe('DeleteWorktreeTargetPreview loaded paths', () => {
})
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('src/app.ts')).not.toBeInTheDocument()
expect(
screen.getByText('Deleting this workspace permanently removes these changes from disk.')
).toBeVisible()
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
@@ -222,7 +219,7 @@ describe('DeleteWorktreeTargetPreview loaded paths', () => {
expect(screen.queryByText(/No files|clean|0 changes/)).not.toBeInTheDocument()
})
it('expands each qualified batch target independently', () => {
it('opens the selected host preview and closes the previous preview', () => {
const local = makeWorktree('same', 'collide', 'local')
const runtime = makeWorktree('same', 'collide', 'runtime:runtime-7')
const localKey = getWorktreeHostIdentity(local)
@@ -251,11 +248,12 @@ describe('DeleteWorktreeTargetPreview loaded paths', () => {
const localRow = screen.getByRole('listitem', { name: /Local/ })
const runtimeRow = screen.getByRole('listitem', { name: /Build Mac/ })
fireEvent.click(within(localRow).getByRole('button'))
expect(within(localRow).getByText('local.ts')).toBeVisible()
expect(screen.getByText('local.ts')).toBeVisible()
expect(within(runtimeRow).queryByText('runtime.ts')).not.toBeInTheDocument()
expect(screen.getByLabelText('deleted')).toHaveTextContent('D')
fireEvent.click(within(runtimeRow).getByRole('button'))
expect(within(runtimeRow).getByText('runtime.ts')).toBeVisible()
expect(within(localRow).getByLabelText('deleted')).toHaveTextContent('D')
expect(within(runtimeRow).getByLabelText('renamed')).toHaveTextContent('R')
expect(screen.getByText('runtime.ts')).toBeVisible()
expect(screen.queryByText('local.ts')).not.toBeInTheDocument()
expect(screen.getByLabelText('renamed')).toHaveTextContent('R')
})
})
@@ -4,7 +4,10 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import type { Worktree } from '../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint'
import type { DeleteWorktreeDirtyChangePreview } from './delete-worktree-dirty-change-counts'
import type {
DeleteWorktreeChangeCheckState,
DeleteWorktreeDirtyChangePreview
} from './delete-worktree-dirty-change-counts'
import type { AppState } from '@/store/types'
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
import { getWorktreeDeleteErrorToShow } from './worktree-delete-error-display'
@@ -46,7 +49,8 @@ export function DeleteWorktreeTargetPreview({
hostLabelById,
deleteStateByWorktreeId,
dirtyChangeCountsByWorktreeId,
dirtyChangePreviewsByWorktreeId
dirtyChangePreviewsByWorktreeId,
changeCheckStatesByWorktreeId
}: {
isBatchDelete: boolean
worktree: Worktree | null
@@ -56,6 +60,7 @@ export function DeleteWorktreeTargetPreview({
deleteStateByWorktreeId: AppState['deleteStateByWorktreeId']
dirtyChangeCountsByWorktreeId: ReadonlyMap<string, number>
dirtyChangePreviewsByWorktreeId: ReadonlyMap<string, DeleteWorktreeDirtyChangePreview>
changeCheckStatesByWorktreeId?: ReadonlyMap<string, DeleteWorktreeChangeCheckState>
}): JSX.Element | null {
const targetIdPrefix = useId()
const collisionIds = getCollisionIds(collisionWorktrees)
@@ -94,6 +99,9 @@ export function DeleteWorktreeTargetPreview({
) : null}
<DeleteWorktreeDirtyChangeHint
key={getWorktreeHostIdentity(item)}
checkState={changeCheckStatesByWorktreeId?.get(
item.hostId ? getWorktreeHostIdentity(item) : item.id
)}
changeCount={dirtyChangeCountsByWorktreeId.get(
item.hostId ? getWorktreeHostIdentity(item) : item.id
)}
@@ -147,6 +155,9 @@ export function DeleteWorktreeTargetPreview({
) : null}
<DeleteWorktreeDirtyChangeHint
key={getWorktreeHostIdentity(worktree)}
checkState={changeCheckStatesByWorktreeId?.get(
worktree.hostId ? getWorktreeHostIdentity(worktree) : worktree.id
)}
changeCount={dirtyChangeCountsByWorktreeId.get(
worktree.hostId ? getWorktreeHostIdentity(worktree) : worktree.id
)}
@@ -4,6 +4,7 @@ import type { Repo } from '../../../../shared/repo-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import {
getDeleteWorktreeChangeCheckStates,
getDeleteWorktreeDirtyChangeCounts,
getDeleteWorktreeDirtyChangePreview,
getDeleteWorktreeDirtyChangePreviews,
@@ -176,5 +177,51 @@ describe('delete-worktree loaded change previews', () => {
0
)
expect(getDeleteWorktreeDirtyChangePreviews(input).size).toBe(0)
expect(getDeleteWorktreeChangeCheckStates(input).size).toBe(0)
})
})
describe('deletion check states', () => {
it('distinguishes pending, failed and completed reads on the target host', () => {
const pending = worktree('same', 'ssh:pending')
const failed = worktree('same', 'ssh:failed')
const clean = worktree('same', 'local')
const input = {
deleteTargets: [pending, failed, clean],
gitStatusByWorktree: { same: [{ path: 'wrong-host.ts' }] },
gitStatusByWorktreeIdentity: new Map([
[getWorktreeHostIdentity(failed), null],
[getWorktreeHostIdentity(clean), []]
]),
repoMap: new Map()
}
expect(getDeleteWorktreeChangeCheckStates(input)).toEqual(
new Map([
[getWorktreeHostIdentity(pending), 'checking'],
[getWorktreeHostIdentity(failed), 'unavailable'],
[getWorktreeHostIdentity(clean), 'complete']
])
)
expect(getDeleteWorktreeDirtyChangeCounts({ ...input, deleteStateByWorktreeId: {} }).size).toBe(
0
)
})
it('uses a hydrated read for a target without an explicit host', () => {
const target = worktree('legacy')
const entries: GitStatusEntry[] = [{ path: 'pending.ts', status: 'modified', area: 'unstaged' }]
const input = {
deleteTargets: [target],
gitStatusByWorktree: {},
gitStatusByWorktreeIdentity: new Map([[getWorktreeHostIdentity(target), entries]]),
repoMap: new Map()
}
expect(getDeleteWorktreeChangeCheckStates(input).get('legacy')).toBe('complete')
expect(
getDeleteWorktreeDirtyChangeCounts({ ...input, deleteStateByWorktreeId: {} }).get('legacy')
).toBe(1)
expect(getDeleteWorktreeDirtyChangePreviews(input).get('legacy')?.files[0]?.path).toBe(
'pending.ts'
)
})
})
@@ -6,6 +6,43 @@ import type { WorktreeDeleteState } from '../../store/slices/worktree-helpers'
import { normalizeRelativePath } from '@/lib/path'
import { buildStatusMap } from '../right-sidebar/status-display'
import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy'
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
export type DeleteWorktreeChangeCheckState = 'checking' | 'complete' | 'unavailable'
type DeleteWorktreeStatusInput<Entry> = {
deleteTargets: readonly Worktree[]
gitStatusByWorktree: Record<string, readonly Entry[] | undefined>
gitStatusByWorktreeIdentity?: ReadonlyMap<string, readonly Entry[] | null>
repoMap: ReadonlyMap<string, Repo>
}
function getStatusEntries<Entry>(
item: Worktree,
input: DeleteWorktreeStatusInput<Entry>
): readonly Entry[] | null | undefined {
return item.hostId
? input.gitStatusByWorktreeIdentity?.get(getWorktreeHostIdentity(item))
: (input.gitStatusByWorktree[item.id] ??
input.gitStatusByWorktreeIdentity?.get(getWorktreeHostIdentity(item)))
}
export function getDeleteWorktreeChangeCheckStates(
input: DeleteWorktreeStatusInput<unknown>
): Map<string, DeleteWorktreeChangeCheckState> {
const result = new Map<string, DeleteWorktreeChangeCheckState>()
for (const item of input.deleteTargets) {
if (item.isMainWorktree || isFolderWorkspaceDelete(input.repoMap, item)) {
continue
}
const entries = getStatusEntries(item, input)
result.set(
item.hostId ? getWorktreeHostIdentity(item) : item.id,
entries === null ? 'unavailable' : entries === undefined ? 'checking' : 'complete'
)
}
return result
}
export function orderDeleteWorktreeStatusHydrationTargets({
targets,
@@ -30,7 +67,6 @@ export function orderDeleteWorktreeStatusHydrationTargets({
.sort((left, right) => left.rank - right.rank || left.index - right.index)
.map(({ target }) => target)
}
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
export function getDeleteWorktreeDirtyChangeCounts({
deleteTargets,
@@ -42,7 +78,7 @@ export function getDeleteWorktreeDirtyChangeCounts({
deleteTargets: readonly Worktree[]
deleteStateByWorktreeId: Record<string, WorktreeDeleteState | undefined>
gitStatusByWorktree: Record<string, readonly unknown[] | undefined>
gitStatusByWorktreeIdentity?: ReadonlyMap<string, readonly unknown[]>
gitStatusByWorktreeIdentity?: ReadonlyMap<string, readonly unknown[] | null>
repoMap: ReadonlyMap<string, Repo>
}): Map<string, number> {
const result = new Map<string, number>()
@@ -55,11 +91,12 @@ export function getDeleteWorktreeDirtyChangeCounts({
item,
deleteStateByWorktreeId
)?.forceDeleteReason
const changeCount = (
item.hostId
? gitStatusByWorktreeIdentity?.get(getWorktreeHostIdentity(item))
: gitStatusByWorktree[item.id]
)?.length
const changeCount = getStatusEntries(item, {
deleteTargets,
gitStatusByWorktree,
gitStatusByWorktreeIdentity,
repoMap
})?.length
if ((changeCount ?? 0) > 0) {
result.set(resultKey, changeCount ?? 0)
} else if (forceDeleteReason === 'dirty') {
@@ -109,7 +146,7 @@ export function getDeleteWorktreeDirtyChangePreviews({
}: {
deleteTargets: readonly Worktree[]
gitStatusByWorktree: Record<string, readonly GitStatusEntry[] | undefined>
gitStatusByWorktreeIdentity?: ReadonlyMap<string, readonly GitStatusEntry[]>
gitStatusByWorktreeIdentity?: ReadonlyMap<string, readonly GitStatusEntry[] | null>
repoMap: ReadonlyMap<string, Repo>
}): Map<string, DeleteWorktreeDirtyChangePreview> {
const result = new Map<string, DeleteWorktreeDirtyChangePreview>()
@@ -118,9 +155,12 @@ export function getDeleteWorktreeDirtyChangePreviews({
continue
}
const resultKey = item.hostId ? getWorktreeHostIdentity(item) : item.id
const entries = item.hostId
? gitStatusByWorktreeIdentity?.get(getWorktreeHostIdentity(item))
: gitStatusByWorktree[item.id]
const entries = getStatusEntries(item, {
deleteTargets,
gitStatusByWorktree,
gitStatusByWorktreeIdentity,
repoMap
})
if (entries?.length) {
result.set(resultKey, getDeleteWorktreeDirtyChangePreview(entries))
}
@@ -11,6 +11,7 @@ import { DeleteWorktreeTargetPreview } from './DeleteWorktreeTargetPreview'
import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice'
import { useDeleteWorktreeStatusHydration } from './use-delete-worktree-status-hydration'
import {
getDeleteWorktreeChangeCheckStates,
getDeleteWorktreeDirtyChangeCounts,
getDeleteWorktreeDirtyChangePreview,
getDeleteWorktreeDirtyChangePreviews
@@ -92,6 +93,7 @@ function Preview({ worktree }: { worktree: Worktree }): JSX.Element {
collisionWorktrees={targets}
hostLabelById={new Map()}
deleteStateByWorktreeId={input.deleteStateByWorktreeId}
changeCheckStatesByWorktreeId={getDeleteWorktreeChangeCheckStates(input)}
dirtyChangeCountsByWorktreeId={getDeleteWorktreeDirtyChangeCounts(input)}
dirtyChangePreviewsByWorktreeId={getDeleteWorktreeDirtyChangePreviews(input)}
/>
@@ -102,6 +104,28 @@ afterEach(cleanup)
beforeEach(() => vi.clearAllMocks())
describe('loaded deletion disclosure and existing hydration', () => {
it('shows pending and failed detail checks alongside a known dirty warning', async () => {
let failRead: (error: Error) => void = () => {
throw new Error('Status read has not started')
}
vi.mocked(getRuntimeGitStatus).mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
failRead = reject
})
)
render(<Preview worktree={target('known-dirty')} />)
expect(screen.getByText('Uncommitted or untracked changes')).toBeVisible()
expect(screen.getByText('· Checking…')).toBeVisible()
await act(async () => {
failRead(new Error('Details unavailable'))
})
expect(screen.getByText('Uncommitted or untracked changes')).toBeVisible()
expect(screen.getByText('· Details unavailable')).toBeVisible()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.queryByText(/No uncommitted|0 changes/)).not.toBeInTheDocument()
})
it('expands and collapses a hydrated snapshot without requesting status again', async () => {
vi.mocked(getRuntimeGitStatus).mockResolvedValue({
entries: [
@@ -155,6 +179,7 @@ describe('loaded deletion disclosure and existing hydration', () => {
})
await waitFor(() => expect(getRuntimeGitStatus).toHaveBeenCalledTimes(2))
expect(screen.getByText('Uncommitted or untracked changes')).toBeVisible()
expect(screen.getByText('· Details unavailable')).toBeVisible()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.queryByText('old-host.ts')).not.toBeInTheDocument()
expect(screen.queryByText(/No files|clean|0 changes/)).not.toBeInTheDocument()
@@ -12,7 +12,7 @@ import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualif
import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy'
import { orderDeleteWorktreeStatusHydrationTargets } from './delete-worktree-dirty-change-counts'
const EMPTY_STATUS_BY_IDENTITY = new Map<string, GitStatusResult['entries']>()
const EMPTY_STATUS_BY_IDENTITY = new Map<string, GitStatusResult['entries'] | null>()
export function useDeleteWorktreeStatusHydration({
isOpen,
@@ -24,14 +24,14 @@ export function useDeleteWorktreeStatusHydration({
deleteTargets: readonly Worktree[]
visibleTargets: readonly Worktree[]
repoMap: ReadonlyMap<string, Repo>
}): ReadonlyMap<string, GitStatusResult['entries']> {
}): ReadonlyMap<string, GitStatusResult['entries'] | null> {
const repos = useAppStore((state) => state.repos)
const settings = useAppStore((state) => state.settings)
const generation = isOpen ? deleteTargets.map(getWorktreeHostIdentity).join('\n') : ''
const generationRef = useRef(generation)
const [statusByIdentity, setStatusByIdentity] = useState<Map<string, GitStatusResult['entries']>>(
() => new Map()
)
const [statusByIdentity, setStatusByIdentity] = useState<
Map<string, GitStatusResult['entries'] | null>
>(() => new Map())
const currentStatusByIdentity =
generationRef.current === generation ? statusByIdentity : EMPTY_STATUS_BY_IDENTITY
@@ -89,7 +89,9 @@ export function useDeleteWorktreeStatusHydration({
}
})
.catch(() => {
// Best effort only; deletion performs the authoritative backend check.
if (!controller.signal.aborted && generationRef.current === generation) {
setStatusByIdentity((current) => new Map(current).set(identity, null))
}
})
}
return () => {
+4 -1
View File
@@ -1885,6 +1885,9 @@
"CacheTimer": {
"07729cc155": "expired"
},
"DeleteWorktreeDirtyChangeHint": {
"8e2994ce28": "Deleting this workspace permanently removes these changes from disk."
},
"FolderWorkspaceComposerDialog": {
"chooseSourceProject": "Choose task source",
"connectFailed": "Failed to connect to project.",
@@ -2883,7 +2886,6 @@
"openCurrentConversation": "Open the current conversation to continue.",
"optionRejected": "The agent didn't accept this setting.",
"outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.",
"sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.",
"ownerUnproven": "The previous agent in this chat may still be running.",
"promptPending": "The agent is waiting for an answer to a question or approval.",
"questionChanged": "This question was already answered or has changed.",
@@ -2893,6 +2895,7 @@
"reopenChat": "Reopen the chat to check again.",
"restartFailed": "The agent couldn't restart.",
"savedByNewerOrca": "Chats were saved by a newer Orca.",
"sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.",
"settleEarlierMessage": "Wait for your earlier message to go through, or retry it.",
"startNewChat": "Start a new chat to continue.",
"terminalAgentHoldsChat": "This chat is still open in a terminal agent.",
+10
View File
@@ -18360,6 +18360,16 @@
"workspaceReadyToast": "Workspace {{name}} is ready",
"goToWorktree": "Go to worktree",
"goToWorkspace": "Go to workspace"
},
"delete": {
"changes": {
"permanentLoss": "Any uncommitted or untracked changes in Git workspaces will be permanently deleted.",
"clean": "No uncommitted or untracked changes",
"unavailable": "Changes could not be checked",
"checking": "Checking for changes…",
"checkingDetails": "Checking…",
"unavailableDetails": "Details unavailable"
}
}
},
"agentSessionContinuation": {
@@ -0,0 +1,194 @@
import { expect, test } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
test.use({ minimumSeededWorktreeCount: 1 })
for (const scenario of ['single', 'children', 'batch', 'known-dirty'] as const) {
test(`keeps deletion geometry stable while checking ${scenario} workspaces`, async ({
orcaPage: page,
electronApp
}, testInfo) => {
await waitForSessionReady(page)
if (scenario === 'batch') {
await page.emulateMedia({ reducedMotion: 'reduce' })
}
await electronApp.evaluate(({ ipcMain }, knownDirty) => {
ipcMain.removeHandler('git:status')
ipcMain.handle('git:status', async (_event, args: { worktreePath: string }) => {
const childIndex = Number(args.worktreePath.match(/child-(\d+)$/)?.[1] ?? 0)
await new Promise((resolve) => setTimeout(resolve, 1800 + (childIndex % 3) * 180))
if (
args.worktreePath.endsWith('child-2') ||
(knownDirty && args.worktreePath.endsWith('delete-layout-parent'))
) {
throw new Error('Simulated disconnected execution host')
}
return {
entries: args.worktreePath.endsWith('child-1')
? []
: [{ path: 'src/pending-work.ts', status: 'modified', area: 'unstaged' }],
conflictOperation: 'unknown'
}
})
}, scenario === 'known-dirty')
await page.evaluate((mode) => {
const store = window.__store
const state = store?.getState()
const repo = state?.repos[0]
const source = repo && state?.worktreesByRepo[repo.id]?.[0]
if (!store || !state || !repo || !source) {
throw new Error('Missing seeded workspace')
}
const parent = {
...source,
id: 'delete-layout-parent',
instanceId: 'delete-layout-parent-instance',
displayName: 'workspace-with-pending-work',
path: `${repo.path}/delete-layout-parent`,
hostId: 'local' as const,
isMainWorktree: false,
lineage: null
}
const children = Array.from(
{ length: mode === 'children' ? 31 : mode === 'batch' ? 3 : 0 },
(_, index) => ({
...parent,
id: `delete-layout-child-${index}`,
instanceId: `delete-layout-child-instance-${index}`,
displayName: `child-workspace-${index}`,
path: `${repo.path}/delete-layout-child-${index}`,
lineage:
mode === 'children'
? {
worktreeId: `delete-layout-child-${index}`,
worktreeInstanceId: `delete-layout-child-instance-${index}`,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual' as const,
capture: { source: 'manual-action' as const, confidence: 'explicit' as const },
createdAt: 1
}
: null
})
)
store.setState({
...(state.settings
? {
settings: { ...state.settings, theme: mode === 'children' ? 'dark' : 'light' }
}
: {}),
worktreesByRepo: {
...state.worktreesByRepo,
[repo.id]: [...state.worktreesByRepo[repo.id], parent, ...children]
},
gitStatusByWorktree: {},
...(mode === 'known-dirty'
? {
deleteStateByWorktreeId: {
[parent.id]: {
isDeleting: false,
error: null,
canForceDelete: true,
forceDeleteReason: 'dirty',
executionHostId: parent.hostId
}
}
}
: {})
})
state.openModal(
'delete-worktree',
mode === 'batch'
? { worktreeIds: children.map((child) => child.id), allowSkipConfirm: false }
: { worktreeId: parent.id, allowSkipConfirm: false }
)
}, scenario)
const dialog = page.getByRole('dialog', { name: /^Delete Workspace/ })
await expect(dialog).toBeVisible()
await page.waitForTimeout(250)
await expect(
dialog.getByText(scenario === 'known-dirty' ? '· Checking…' : 'Checking for changes…').first()
).toBeVisible()
await expect(dialog.getByText(/will be permanently deleted/)).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('checking.png') })
await dialog.screenshot({ path: testInfo.outputPath('checking-dialog.png') })
const frames = await page.evaluate(async () => {
const samples: {
dialog: number[]
button: number[]
targets: number[]
confirmFocused: boolean
}[] = []
const started = performance.now()
while (performance.now() - started < 2400) {
const dialog = document.querySelector('[data-slot="dialog-content"]')
const button = dialog?.querySelector('[data-slot="dialog-footer"] button:last-child')
if (!(dialog instanceof HTMLElement) || !(button instanceof HTMLElement)) {
throw new Error('Missing dialog')
}
const bounds = (element: Element): number[] => {
const rect = element.getBoundingClientRect()
return [rect.x, rect.y, rect.width, rect.height]
}
samples.push({
dialog: bounds(dialog),
button: bounds(button),
confirmFocused: document.activeElement === button,
targets: Array.from(dialog.querySelectorAll('[role="listitem"], [role="region"], div'))
.filter(
(element) =>
element.matches('[role="listitem"], [role="region"]') ||
/^child-workspace-\d+$/.test(element.textContent ?? '')
)
.flatMap(bounds)
})
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
return samples
})
await expect(
dialog
.getByText(
scenario === 'known-dirty'
? 'Uncommitted or untracked changes'
: '1 uncommitted or untracked change',
{ exact: true }
)
.first()
).toBeVisible()
if (scenario === 'known-dirty') {
await expect(dialog.getByText('· Details unavailable')).toBeVisible()
} else if (scenario !== 'single') {
await expect(dialog.getByText('No uncommitted or untracked changes')).toBeVisible()
await expect(dialog.getByText('Changes could not be checked')).toBeVisible()
}
await page.screenshot({ path: testInfo.outputPath('loaded.png') })
await dialog.screenshot({ path: testInfo.outputPath('loaded-dialog.png') })
expect(frames.length).toBeGreaterThan(20)
expect(frames[0]?.confirmFocused).toBe(true)
for (const frame of frames) {
expect(frame).toEqual(frames[0])
}
if (scenario === 'known-dirty') {
await expect(dialog.getByRole('button', { name: /Show loaded paths/ })).toHaveCount(0)
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(dialog).toBeHidden()
return
}
const beforeDetails = await dialog.boundingBox()
await dialog
.getByRole('button', { name: /Show loaded paths/ })
.first()
.click()
await expect(page.getByText('src/pending-work.ts', { exact: true })).toBeVisible()
await page.waitForTimeout(250)
await page.screenshot({ path: testInfo.outputPath('details.png') })
await dialog.screenshot({ path: testInfo.outputPath('details-dialog.png') })
expect(await dialog.boundingBox()).toEqual(beforeDetails)
await page.keyboard.press('Escape')
await expect(dialog).toBeVisible()
await expect(page.getByText('src/pending-work.ts', { exact: true })).toBeHidden()
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(dialog).toBeHidden()
})
}