mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user