Offer back unsaved edits when a workspace is removed without a prompt (#31738)

* fix(editor): list unsaved editor files before a workspace removal closes them

Removing a workspace closed its editor tabs and threw away unsaved text
without a word, including with "Don't ask again" on.

Removal confirms now list the files whose unsaved edits the removal
discards: the tabs the purge itself would close (host twins included),
kept only when no view sharing their model or record stays open. Editable
unstaged diff tabs count; delayed rich Markdown, CSV and notebook text is
flushed first.
- Delete Workspace dialog: the list sits with the git-change warnings.
  While it is non-empty the dialog no longer starts focus on Delete, and
  "Don't ask again" no longer skips the dialog.
- The same list in Remove Project, SSH workspace forget/reconnect-and-
  delete, SSH host removal with its workspaces, nested-worktree deletion
  (rows matched by path containment under the parent) and project-group
  deletion with its contained projects.
- Removals with no dialog whose files stay on disk refuse instead, with
  "Save or close N unsaved files in <name> first": folder-workspace
  delete (card action, context menu, hovered shortcut, through one shared
  helper) and Settings > Remove Project.

Force retries with no list of their own (the failure toast's Force
Delete / Delete Anyway, Space's Force button) re-check at that moment and
refuse the same way. A folder-workspace delete that leaves a same-id
folder on another host closes no tabs, so it is not refused. Removal
confirms touched here render on the security surface.

Removals with no prompt (CLI, automations, paired clients) behave as
before; a follow-up offers their text back.

Known limitation: the list is decided by the tabs a removal closes, not
by paths; a symlink into a deleted folder is not resolved on the host.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): offer back unsaved text a removal with no prompt closed

A workspace removed with no dialog (CLI, automations, paired clients,
removal outside Orca, listing and runtime-host purges) still closed its
editor tabs and dropped their unsaved text.

Those removals now flush delayed rich Markdown, CSV and notebook text,
then show one toast, "Closed N files with unsaved edits from <workspace>",
whose Reopen action reopens each file as an unsaved tab: in its own
workspace if it is still open, else the active one, only when that
resolves to the same host. The next save is guarded against the disk
version the edits came from. Dismissing the toast drops the text and
logs it. The captured entries belong to that removal alone.

Removals confirmed in a dialog that listed their unsaved files pass
unsavedEditsConfirmed through removeWorktree, deleteFolderWorkspace,
removeProject, project-group deletion and the purge builders, so they
discard without a toast. Folder-workspace and Settings project removals
refuse while files are unsaved, so they never reach the toast.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): keep reopened drafts editable when their file is missing

Preserve recovered text through a missing-file read, retaining previous content and rename state. Cover empty drafts and keep unrelated failures on the existing error path.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): recover removed workspace edits into new files

Create collision-safe recovered files in the active workspace using the existing untitled flow. Capture delayed edits across host twins as one removal, match stale-host flushes to purge selection, honor host-qualified confirmations during announcements, and release retained toast text.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): snapshot confirmed discards and simplify removed-workspace recovery

A removal dialog now records the exact text it listed when the user
confirms. Every purge path (worktrees:changed, announcements, removal
teardown, runtime host retirement, repo removal, nested children) skips a
closed file only while its text still equals that snapshot, so edits made
after confirming are offered back instead of dropped.

- One flushAllPendingEditorBuffers() before each purge replaces per-id
  flushing and the getState threading; idle CSV cells stay open.
- Captures merge into one toast per microtask, deduped by file id; the
  per-call options, scope ref-counting and announcement wrapper are gone.
- Capture reads a shared model's live text, else any sharing tab's draft.
- Recovery writes through a guarded write that only fills the empty file
  it created, moves to the next name on conflict, and never deletes it.
- Reopen resolves the destination path from the provenance owner host.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): scope removal discards to the confirmed teardown and flush only closing panes

- No lingering allowlist: a dialog's confirm returns a snapshot that rides
  the confirmed removal (removeWorktree, removeProject, project groups, SSH
  host clear) to its own teardown capture. Every other purge path offers
  unsaved text back, even if it races a confirmed delete.
- The pre-purge flush covers only the closing files and views sharing their
  records, and flushes every pane registered for them, so split rich and
  notebook panes all commit while other workspaces' CSV cells keep editing.
- Listing announcements flush the files they will remove before the purge.
- Capture reads a model's text only while it is attached to an editor;
  otherwise it prefers the drafts.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): keep a CSV cell that can't commit as draft text when its tab closes

A pending cell over the table size limit fails commit(), so the closing
flush now falls back to the view's preserve hook, which writes the cell
through the same limit-free serialization the unmount path uses. Recovery
then captures the typed text instead of an older draft.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-21 18:52:00 -07:00
committed by GitHub
co-authored by Claude
parent ea42a203a0
commit 924970836a
50 changed files with 1281 additions and 80 deletions
@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClosedUnsavedFile } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
const mocks = vi.hoisted(() => {
function nullableActiveWorkspace(): string | null {
return 'active-wt'
}
function nullableActiveHost(): string | null {
return 'ssh:active'
}
const state = {
activeWorktreeId: nullableActiveWorkspace(),
activeWorkspaceExecutionHostId: nullableActiveHost(),
getKnownWorktreeById: vi.fn((_id: string, _host?: string) => ({ path: '/active' })),
openFile: vi.fn()
}
return {
state,
create: vi.fn(),
capture: vi.fn(),
context: vi.fn(),
assertCurrent: vi.fn(),
warning: vi.fn(
(
_title: string,
_options: {
action: { label: string; onClick: (event: { preventDefault: () => void }) => void }
onDismiss: () => void
}
) => 'notice'
),
error: vi.fn(),
dismiss: vi.fn()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('sonner', () => ({
toast: { warning: mocks.warning, error: mocks.error, dismiss: mocks.dismiss }
}))
vi.mock('@/lib/create-untitled-markdown', () => ({ createUntitledMarkdownFile: mocks.create }))
vi.mock('@/lib/editor-file-operation-owner', () => ({
captureEditorFileOperationProvenance: mocks.capture,
getEditorFileOperationContext: mocks.context,
assertEditorFileOperationCurrent: mocks.assertCurrent
}))
import { reopenClosedUnsavedFiles, showClosedUnsavedFilesToast } from './closed-unsaved-files-toast'
function closed(extra: Partial<ClosedUnsavedFile> = {}): ClosedUnsavedFile {
return {
fileId: 'a',
filePath: '/removed/a.ts',
content: 'unsaved text',
...extra
}
}
function notice() {
const options = mocks.warning.mock.calls[0]?.[1]
if (!options) {
throw new Error('Expected a toast')
}
return options
}
const click = () => notice().action.onClick({ preventDefault: vi.fn() })
describe('closed unsaved files toast', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.activeWorktreeId = 'active-wt'
mocks.state.activeWorkspaceExecutionHostId = 'ssh:active'
mocks.capture.mockReturnValue({ generation: { route: { executionHostId: 'ssh:active' } } })
mocks.context.mockReturnValue({ target: { kind: 'local' } })
mocks.create.mockImplementation(async (_path, worktreeId, _connection, _target, options) => ({
filePath: '/active/a (recovered).ts',
relativePath: 'a (recovered).ts',
worktreeId,
language: 'typescript',
isUntitled: true,
mode: 'edit',
deleteUntouchedOnClose: false,
operationProvenance: options.operationProvenance
}))
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('creates new recovered files in the active workspace, with exact text and no original binding', async () => {
expect(await reopenClosedUnsavedFiles([closed(), closed({ content: '' })])).toEqual([])
expect(mocks.create).toHaveBeenCalledTimes(2)
expect(mocks.create).toHaveBeenNthCalledWith(
1,
'/active',
'active-wt',
undefined,
{ kind: 'local' },
expect.objectContaining({
recoveredDocument: { originalPath: '/removed/a.ts', content: 'unsaved text' }
})
)
expect(mocks.create).toHaveBeenNthCalledWith(
2,
'/active',
'active-wt',
undefined,
{ kind: 'local' },
expect.objectContaining({
recoveredDocument: { originalPath: '/removed/a.ts', content: '' }
})
)
const opened = mocks.state.openFile.mock.calls[0]?.[0]
expect(opened.filePath).toBe('/active/a (recovered).ts')
expect(opened.isUntitled).toBe(true)
expect(opened).not.toHaveProperty('lastKnownDiskSignature')
expect(opened).not.toHaveProperty('externalSshTargetId')
expect(opened).not.toHaveProperty('runtimeEnvironmentId')
})
it('uses the active destination host rather than the removed document host', async () => {
mocks.context.mockReturnValue({ target: { kind: 'environment', environmentId: 'destination' } })
await reopenClosedUnsavedFiles([closed()])
expect(mocks.create.mock.calls[0]?.[3]).toEqual({
kind: 'environment',
environmentId: 'destination'
})
})
it('takes the path from the active workspace’s owning host', async () => {
await reopenClosedUnsavedFiles([closed()])
expect(mocks.state.getKnownWorktreeById).toHaveBeenCalledWith('active-wt', 'ssh:active')
})
it('refuses when the owner route is not the active workspace’s host', async () => {
mocks.capture.mockReturnValue({ generation: { route: { executionHostId: 'local' } } })
expect(await reopenClosedUnsavedFiles([closed()])).toHaveLength(1)
expect(mocks.create).not.toHaveBeenCalled()
})
it('keeps the payload for retry when no workspace is active', async () => {
mocks.state.activeWorktreeId = null
showClosedUnsavedFilesToast([closed()], 'Feature work')
click()
await vi.waitFor(() => expect(mocks.error).toHaveBeenCalledTimes(1))
expect(mocks.dismiss).not.toHaveBeenCalled()
expect(mocks.create).not.toHaveBeenCalled()
mocks.state.activeWorktreeId = 'active-wt'
click()
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledWith('notice'))
expect(mocks.create).toHaveBeenCalledTimes(1)
})
it('retries only the documents that failed to create', async () => {
mocks.create.mockRejectedValueOnce(new Error('Offline'))
showClosedUnsavedFilesToast([closed(), closed({ filePath: '/removed/b.ts' })], 'Feature work')
click()
await vi.waitFor(() => expect(mocks.error).toHaveBeenCalledTimes(1))
click()
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
expect(mocks.create).toHaveBeenCalledTimes(3)
expect(mocks.create.mock.calls[2]?.[4].recoveredDocument.originalPath).toBe('/removed/a.ts')
})
it('releases the holder after Reopen even if the library retains the callbacks', async () => {
showClosedUnsavedFilesToast([closed()], 'Feature work')
click()
click()
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
click()
notice().onDismiss()
expect(mocks.create).toHaveBeenCalledTimes(1)
expect(console.warn).not.toHaveBeenCalled()
})
it('shows one combined notice and releases text on dismissal', () => {
showClosedUnsavedFilesToast([closed(), closed({ filePath: '/removed/b.ts' })], 'Feature work')
expect(mocks.warning).toHaveBeenCalledWith(
'Closed 2 files with unsaved edits from Feature work',
expect.anything()
)
notice().onDismiss()
notice().onDismiss()
click()
expect(mocks.create).not.toHaveBeenCalled()
expect(console.warn).toHaveBeenCalledTimes(1)
expect(console.warn).toHaveBeenCalledWith(
'[editor] dropped unsaved edits from a removed workspace',
{ filePaths: ['/removed/a.ts', '/removed/b.ts'] }
)
})
})
@@ -0,0 +1,138 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import type { ClosedUnsavedFile } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import {
assertEditorFileOperationCurrent,
captureEditorFileOperationProvenance,
getEditorFileOperationContext
} from '@/lib/editor-file-operation-owner'
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
import { translate } from '@/i18n/i18n'
function closedUnsavedFilesToastTitle(count: number, workspace: string): string {
return count === 1
? translate(
'components.workspace.delete.unsavedEdits.closedToast_one',
'Closed {{count}} file with unsaved edits from {{workspace}}',
{ count, workspace }
)
: translate(
'components.workspace.delete.unsavedEdits.closedToast_other',
'Closed {{count}} files with unsaved edits from {{workspace}}',
{ count, workspace }
)
}
/** Recovery creates new files only in the active workspace, never at the removed paths. */
export async function reopenClosedUnsavedFiles(
closed: readonly ClosedUnsavedFile[]
): Promise<ClosedUnsavedFile[]> {
const failed: ClosedUnsavedFile[] = []
for (const file of closed) {
try {
const state = useAppStore.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active workspace')
}
const operationProvenance = captureEditorFileOperationProvenance(
state,
worktreeId,
undefined,
false
)
// Why the provenance owner: a same-id workspace on another host must not supply the path.
const ownerHostId = operationProvenance.generation.route.executionHostId
const activeHostId = state.activeWorkspaceExecutionHostId
const worktree = state.getKnownWorktreeById(
worktreeId,
ownerHostId ?? activeHostId ?? undefined
)
if (!worktree || (ownerHostId && activeHostId && activeHostId !== ownerHostId)) {
throw new Error('Active workspace owner is ambiguous')
}
const context = getEditorFileOperationContext(
state,
{ worktreeId, operationProvenance },
worktree.path
)
const fileInfo = await createUntitledMarkdownFile(
worktree.path,
worktreeId,
context.connectionId,
context.target,
{
recoveredDocument: { originalPath: file.filePath, content: file.content },
operationProvenance,
expectedSshConnectionGeneration: context.expectedSshConnectionGeneration,
expectedSshTargetId: context.expectedSshTargetId,
expectedExecutionHostId: context.expectedExecutionHostId,
assertOperationCurrent: () =>
assertEditorFileOperationCurrent(
useAppStore.getState(),
worktreeId,
operationProvenance
)
}
)
useAppStore.getState().openFile(fileInfo, { preview: false, focusEditor: true })
} catch (error) {
failed.push(file)
console.warn('[editor] could not reopen closed unsaved file', {
filePath: file.filePath,
error
})
}
}
if (failed.length > 0) {
toast.error(
translate(
'components.workspace.delete.unsavedEdits.reopenFailed',
"Some files couldn't be reopened. Open a workspace and try Reopen again."
)
)
}
return failed
}
/** Callbacks retained by the toast keep only a clearable holder, not the recovered text. */
export function showClosedUnsavedFilesToast(
closed: readonly ClosedUnsavedFile[],
workspaceLabel: string
): void {
const holder: { files: readonly ClosedUnsavedFile[] | null } = { files: closed }
let reopening = false
const toastId = toast.warning(closedUnsavedFilesToastTitle(closed.length, workspaceLabel), {
duration: Number.POSITIVE_INFINITY,
action: {
label: translate('components.workspace.delete.unsavedEdits.reopen', 'Reopen'),
onClick: (event) => {
event.preventDefault()
if (reopening || !holder.files) {
return
}
reopening = true
void reopenClosedUnsavedFiles(holder.files).then((failed) => {
reopening = false
// Dismiss can release the holder while creation is still in flight.
if (!holder.files) {
return
}
holder.files = failed.length > 0 ? failed : null
if (!holder.files) {
toast.dismiss(toastId)
}
})
}
},
onDismiss: () => {
const files = holder.files
holder.files = null
if (files) {
console.warn('[editor] dropped unsaved edits from a removed workspace', {
filePaths: files.map((file) => file.filePath)
})
}
}
})
}
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { act, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
import { flushClosingEditorBuffers } from '../editor-pending-flush'
import { EMPTY_EDITOR_BUFFER_VIEW_INDEX as NO_SHARING } from '@/store/slices/editor/buffers/editor-buffer-views'
import { CSV_RECORD_BYTES } from './csv-byte-index'
import type { CsvCellEditSession } from './csv-grid-interaction'
import { parseCsvTextDocument } from './csv-text-document'
import { useCsvPendingCell } from './useCsvPendingCell'
it('keeps an over-limit pending cell as draft text when its tab closes', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const document = parseCsvTextDocument('a,b\n1,2', ',')
const huge = 'x'.repeat(CSV_RECORD_BYTES + 1)
const onContentChange = vi.fn()
function Probe(): null {
const editingRef = useRef<CsvCellEditSession | null>({
position: { row: 0, column: 1 },
value: huge,
sourceRow: 1,
source: document.source,
wasDirty: false
})
// A commit refuses the cell, as the table size limit does.
useCsvPendingCell({
fileId: 'data.csv',
commit: () => false,
document,
onContentChange,
editingRef,
onSessionChange: () => {}
})
return null
}
const root = createRoot(window.document.createElement('div'))
await act(async () => root.render(<Probe />))
try {
flushClosingEditorBuffers(NO_SHARING, ['data.csv'])
expect(onContentChange).toHaveBeenCalledWith(`a,b\n1,${huge}`)
} finally {
await act(async () => root.unmount())
}
})
@@ -42,7 +42,8 @@ export function useCsvPendingCell({
draft.value !==
(latest.current.document?.rows[draft.sourceRow]?.[draft.position.column] ?? '')
)
}
},
() => preservePendingCell(editingRef, latest.current)
)
const saved = (event: Event): void => {
if (!(event instanceof CustomEvent)) {
@@ -71,23 +72,29 @@ export function useCsvPendingCell({
}, [fileId, editingRef])
return useCallback(
(node: HTMLDivElement | null): void => {
if (node) {
return
if (!node) {
preservePendingCell(editingRef, latest.current)
}
const draft = editingRef.current
const snapshot = latest.current
if (!draft || !snapshot.document || draft.source !== snapshot.document.source) {
return
}
snapshot.onContentChange?.(
editCsvTextCells(
snapshot.document,
[{ row: draft.sourceRow, column: draft.position.column, value: draft.value }],
true
)
)
editingRef.current = null
},
[editingRef]
)
}
/** Writes the cell being edited into the draft, past the preview limits commit enforces. */
function preservePendingCell(
editingRef: RefObject<CsvCellEditSession | null>,
snapshot: { document: CsvTextDocument | null; onContentChange?: (content: string) => void }
): void {
const draft = editingRef.current
if (!draft || !snapshot.document || draft.source !== snapshot.document.source) {
return
}
snapshot.onContentChange?.(
editCsvTextCells(
snapshot.document,
[{ row: draft.sourceRow, column: draft.position.column, value: draft.value }],
true
)
)
editingRef.current = null
}
@@ -1,5 +1,6 @@
import { expect, it, vi } from 'vitest'
import {
flushClosingEditorBuffers,
flushPendingEditorBuffer,
hasPendingEditorBufferChange,
registerPendingEditorFlush
@@ -43,3 +44,32 @@ it('checks pending changes across panes and retains the remaining registration o
}
expect(hasPendingEditorBufferChange(NO_SHARING, 'panes')).toBe(false)
})
it('flushes every pane of closing files and leaves other files editing', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const failingPane = vi.fn(() => {
throw new Error('serialize failed')
})
const typingPane = vi.fn()
const keepFailedInput = vi.fn()
const keepTypedInput = vi.fn()
const closingCell = vi.fn()
const survivingCell = vi.fn()
const removers = [
registerPendingEditorFlush('rich', typingPane, undefined, keepTypedInput),
registerPendingEditorFlush('rich', failingPane, undefined, keepFailedInput),
registerPendingEditorFlush('csv', closingCell, () => false),
registerPendingEditorFlush('survivor', survivingCell, () => true)
]
try {
flushClosingEditorBuffers(NO_SHARING, ['rich', 'csv'])
expect(typingPane).toHaveBeenCalledTimes(1)
expect(failingPane).toHaveBeenCalledTimes(1)
expect(keepFailedInput).toHaveBeenCalledTimes(1)
expect(keepTypedInput).not.toHaveBeenCalled()
expect(closingCell).toHaveBeenCalledTimes(1)
expect(survivingCell).not.toHaveBeenCalled()
} finally {
removers.forEach((remove) => remove())
}
})
@@ -3,15 +3,17 @@ import {
type EditorBufferViewIndex
} from '@/store/slices/editor/buffers/editor-buffer-views'
type PendingEditor = { flush: () => void; hasChange?: () => boolean }
type PendingEditor = { flush: () => void; hasChange?: () => boolean; preserve?: () => void }
const pendingEditorFlushes = new Map<string, Set<PendingEditor>>()
export function registerPendingEditorFlush(
fileId: string,
flush: () => void,
hasChange?: () => boolean
hasChange?: () => boolean,
/** Keeps pending input as draft text when the view closes and `flush` can't commit it. */
preserve?: () => void
): () => void {
const entry = { flush, hasChange }
const entry = { flush, hasChange, preserve }
const entries = pendingEditorFlushes.get(fileId) ?? new Set<PendingEditor>()
entries.add(entry)
pendingEditorFlushes.set(fileId, entries)
@@ -54,3 +56,32 @@ export function hasPendingEditorBufferChange(
entry.hasChange?.()
)
}
/**
* Before tabs close: commits pending text from every pane of these files and the views sharing
* their records. Idle panes no-op; panes of other files are left alone, so their edits continue.
*/
export function flushClosingEditorBuffers(
state: EditorBufferViewIndex,
fileIds: Iterable<string>
): void {
const entries = new Set<PendingEditor>()
for (const fileId of fileIds) {
for (const entry of pendingEntriesOf(editorBufferSiblingViewIds(state, fileId))) {
entries.add(entry)
}
}
for (const entry of entries) {
try {
entry.flush()
} catch (error) {
// Why: one pane failing to flush must not block the close, nor lose the pane's input.
console.error('[editor] flushing pending edits before tabs close failed', error)
try {
entry.preserve?.()
} catch (preserveError) {
console.error('[editor] preserving pending edits before tabs close failed', preserveError)
}
}
}
}
@@ -32,7 +32,7 @@ export function editorTabModelUri(state: AppState, file: OpenFile): string {
}
/** Whether the tab edits its Monaco model directly; rich, notebook, CSV and preview views don't. */
function isCodeViewTab(state: AppState, file: OpenFile): boolean {
export function isCodeViewTab(state: AppState, file: OpenFile): boolean {
// Why: the Changes view renders its own diff model, not the shared text model.
if (file.mode !== 'edit' || state.editorViewMode?.[file.id] === 'changes') {
return false
@@ -4,8 +4,13 @@ import { editorBufferSiblingViewIds } from '@/store/slices/editor/buffers/editor
import type { WorktreePurgeTarget } from '@/store/slices/worktree-helpers'
import { worktreePurgeClosedFileIds } from '@/store/slices/worktrees/teardown/worktree-purge-closed-files'
import { canAutoSaveOpenFile } from './editor-autosave'
import { flushPendingEditorBuffer } from './editor-pending-flush'
import { editorModelSiblingTabs, editorTabModelUri } from './editor-save-disk-precondition'
import { flushClosingEditorBuffers } from './editor-pending-flush'
import {
editorModelSiblingTabs,
editorTabModelUri,
isCodeViewTab
} from './editor-save-disk-precondition'
import { editorModelRegistry } from '@/lib/editor-model-registry'
/**
* Savable tabs among `closedIds` holding unsaved text whose every view (shared model, shared
@@ -33,14 +38,38 @@ export function lastViewUnsavedFiles(state: AppState, closedIds: ReadonlySet<str
return result
}
/** Commits text delayed views (rich Markdown, CSV, notebook) still hold for these tabs. */
/** Every view that shows `file`'s text: tabs on its model and views of its record. */
export function unsavedTextViewIds(state: AppState, file: OpenFile): string[] {
return [
...new Set([
file.id,
...editorModelSiblingTabs(state, file).map((tab) => tab.id),
...editorBufferSiblingViewIds(state, file.id)
])
]
}
/** The freshest text of `file`: its mounted code model, else a draft a sharing view holds. */
export function currentUnsavedText(state: AppState, file: OpenFile): string | undefined {
const registry = isCodeViewTab(state, file) ? editorModelRegistry.get() : null
const model = registry?.editor.getModel(registry.Uri.parse(editorTabModelUri(state, file)))
// Why attached only: a retained model can hold older text than a restored draft.
if (model?.isAttachedToEditor()) {
return model.getValue()
}
return (
unsavedTextViewIds(state, file)
.map((id) => state.editorDrafts?.[id])
.find((draft) => draft !== undefined) ?? model?.getValue()
)
}
/** Commits text every delayed view (rich Markdown, CSV, notebook pane) holds for these tabs. */
export function flushRemovalPendingEdits(state: AppState, fileIds: Iterable<string>): void {
try {
for (const fileId of fileIds) {
flushPendingEditorBuffer(state, fileId)
}
flushClosingEditorBuffers(state, fileIds)
} catch (error) {
// Why: a view's flush failing must not block the removal that asked.
// Why: a flush failing must not block the removal that asked.
console.error('[editor] flushing pending edits before a removal failed', error)
}
}
@@ -96,7 +96,10 @@ describe('nested deletion confirmation', () => {
await review()
fireEvent.click(screen.getByRole('button', { name: 'Delete all listed worktrees' }))
await vi.waitFor(() => expect(mocks.refresh).toHaveBeenCalled())
expect(mocks.remove).toHaveBeenCalledWith(target, true, { approvedNestedWorktrees: plan })
expect(mocks.remove).toHaveBeenCalledWith(target, true, {
approvedNestedWorktrees: plan,
confirmedDiscards: expect.any(Map)
})
expect(dismissToast).toHaveBeenCalledOnce()
expect(onDeleted).toHaveBeenCalledOnce()
})
@@ -14,6 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { getAllWorktreesFromState, getWorktreeOnHostFromState } from '@/store/selectors'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { RemovalUnsavedFilesNotice } from './RemovalUnsavedFilesNotice'
import { confirmUnsavedDiscard } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
@@ -81,6 +82,7 @@ export function DeleteNestedWorktreesDialog({
if (!plan || deleting) {
return
}
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
setDeleting(true)
setOpen(false)
setError(null)
@@ -96,7 +98,10 @@ export function DeleteNestedWorktreesDialog({
})
state.markWorktreesDeleting(nestedRows)
try {
const result = await state.removeWorktree(target, true, { approvedNestedWorktrees: plan })
const result = await state.removeWorktree(target, true, {
approvedNestedWorktrees: plan,
confirmedDiscards
})
if (!result.ok) {
setPlan(null)
toast.error(
@@ -234,7 +234,8 @@ describe('DeleteWorktreeDialog lineage copy', () => {
expect(runWorktreeDeletesInParallel).toHaveBeenCalledWith([workspace], {
force: false,
onForceDeleted: expect.any(Function)
onForceDeleted: expect.any(Function),
confirmedDiscards: expect.any(Map)
})
})
@@ -276,7 +277,8 @@ describe('DeleteWorktreeDialog lineage copy', () => {
expect(runWorktreeDeletesInParallel).toHaveBeenCalledWith([child, parent], {
force: true,
onForceDeleted: expect.any(Function)
onForceDeleted: expect.any(Function),
confirmedDiscards: expect.any(Map)
})
})
@@ -495,7 +497,8 @@ describe('DeleteWorktreeDialog lineage copy', () => {
expect(runWorktreeDeletesInParallel).toHaveBeenCalledWith([workspace], {
force: true,
onForceDeleted: expect.any(Function)
onForceDeleted: expect.any(Function),
confirmedDiscards: expect.any(Map)
})
const options = vi.mocked(runWorktreeDeletesInParallel).mock.calls[0]?.[1] as
| { onForceDeleted?: (target: WorktreeRemovalTarget) => void }
@@ -38,6 +38,7 @@ import { runDialogForceDelete } from './delete-worktree-dialog-force-delete'
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options'
import { RemovalUnsavedFilesNotice } from './RemovalUnsavedFilesNotice'
import { confirmUnsavedDiscard } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { useDeleteWorktreeDialogTargets } from './use-delete-worktree-dialog-targets'
import { collectRemovalUnsavedFiles } from '@/components/editor/removal-unsaved-editor-files'
@@ -252,6 +253,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
if (!currentWorktrees) {
return
}
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
// Why: force-delete is a recovery path taken after a failed first delete.
// Saving "don't ask again" from that state would conflate the recovery
// action with a broader preference. Only persist the preference on the
@@ -265,7 +267,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
currentWorktrees,
removeWorktree,
closeModal,
onDeleted
onDeleted,
confirmedDiscards
})
} else {
// Why: this modal is the destructive confirmation for the workspace
@@ -273,7 +276,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
// a redundant Force Delete toast after the user already confirmed.
const deletePromise = runWorktreeDeletesInParallel(currentWorktrees, {
force: forceOnConfirm,
onForceDeleted: handleForceDeletedFromToast
onForceDeleted: handleForceDeletedFromToast,
confirmedDiscards
})
// Why: the workspace card owns the in-progress feedback, so the
// confirmation should get out of the way as soon as deletion begins.
@@ -297,7 +301,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
worktreeIds.length,
worktreeDeleteIdentities,
worktreeId,
resolveConfirmedTargets
resolveConfirmedTargets,
unsavedTargets
]
)
@@ -309,7 +314,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
forceOnConfirm,
onForceDeleted: handleForceDeletedFromToast,
closeModal,
onDeleted
onDeleted,
confirmedDiscards: confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
})
}
@@ -18,6 +18,7 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host'
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
import type { SshWorkspaceForgetResolution } from './ssh-workspace-forget-resolution'
import { RemovalUnsavedFilesNotice } from './RemovalUnsavedFilesNotice'
import { confirmUnsavedDiscard } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
type ForgetSshWorkspaceModalData = {
worktreeId: string
@@ -86,6 +87,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
if (resolution.kind !== 'disconnected') {
return
}
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
setBusy('reconnect')
try {
await window.api.ssh.connect({ targetId: resolution.targetId })
@@ -105,7 +107,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
}
// Close before the delete toast fires so the two don't overlap.
closeModal()
void runWorktreeDeleteWithToast(removalTarget, displayName)
void runWorktreeDeleteWithToast(removalTarget, displayName, { confirmedDiscards })
if (mountedRef.current) {
setBusy(null)
}
@@ -113,11 +115,12 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
// Remove Orca's records only — never touches remote files, worktrees, or branches.
const handleForget = async (): Promise<void> => {
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
setBusy('forget')
try {
const result = await useAppStore
.getState()
.removeWorktree(removalTarget, false, { mode: 'forget-local' })
.removeWorktree(removalTarget, false, { mode: 'forget-local', confirmedDiscards })
if (!result.ok) {
toast.error(result.error)
if (mountedRef.current) {
@@ -18,6 +18,7 @@ import { getAllWorktreesFromState } from '@/store/selectors'
import { translate } from '@/i18n/i18n'
import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { RemovalUnsavedFilesNotice } from './RemovalUnsavedFilesNotice'
import { confirmUnsavedDiscard } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { projectRemovalTargets } from './removal-unsaved-files'
import { clearHostRename } from './host-rename-remove'
import type { HostRemovalTarget } from './host-rename-remove'
@@ -128,7 +129,8 @@ export function HostRemoveDialog({
// Connected → real remote removal; offline/ghost → local forget.
const { failedIds } = await clearSshHostWorkspaces(
sshResolution,
isConnected ? 'delete-remote' : 'forget-local'
isConnected ? 'delete-remote' : 'forget-local',
confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
)
// Why: don't remove the SSH target (and report success) while some of its
// workspaces failed to clear — that would strand ghost rows behind a
@@ -18,6 +18,7 @@ import {
import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal'
import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host'
import { RemovalUnsavedFilesNotice } from './RemovalUnsavedFilesNotice'
import { confirmUnsavedDiscard } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { projectRemovalTargets } from './removal-unsaved-files'
import {
selectDetectedWorktreesByRepo,
@@ -124,10 +125,14 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
const handleConfirm = useCallback(() => {
// Why: without the opener's host a bare id could remove another host's row (#13071).
if (repoId && hostId) {
void removeProject(repoId, { hostId, errorFeedback: 'toast' })
void removeProject(repoId, {
hostId,
errorFeedback: 'toast',
confirmedDiscards: confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
})
}
closeModal()
}, [closeModal, hostId, removeProject, repoId])
}, [closeModal, hostId, removeProject, repoId, unsavedTargets])
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -8,6 +8,7 @@ import {
import type { RemoveWorktreeOptions } from '@/store/slices/worktree-removal-options'
import type { RendererRemoveWorktreeResult } from '@/store/slices/renderer-remove-worktree-result'
import { showWorkspaceListChangedToast } from './stale-workspace-list-toast'
import type { ConfirmedUnsavedDiscards } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
/**
* The dialog's explicit "Force Delete" retry.
@@ -26,6 +27,7 @@ export function runDialogForceDelete(args: {
) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }>
closeModal: () => void
onDeleted: ((deleted: WorktreeRemovalTarget[]) => void) | null | undefined
confirmedDiscards: ConfirmedUnsavedDiscards
}): void {
const { worktreeId, currentWorktrees, removeWorktree, closeModal, onDeleted } = args
// Why: this branch preserves the legacy "Force Delete" button behavior
@@ -46,7 +48,8 @@ export function runDialogForceDelete(args: {
// Why (#11960): this IS the explicit Force Delete, so it may also waive
// the PTY-stop proof — unlike the confirmed delete in the branch below.
const deletePromise = removeWorktree(toWorktreeRemovalTarget(forceTarget), true, {
allowUnverifiedPtyStop: true
allowUnverifiedPtyStop: true,
confirmedDiscards: args.confirmedDiscards
})
closeModal()
deletePromise
@@ -2,6 +2,7 @@ import type { Worktree } from '../../../../shared/worktree/types'
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
import type { WorktreeDeleteIdentity } from './worktree-delete-request'
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
import type { ConfirmedUnsavedDiscards } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
/**
* "Delete all" for a lineage: the modal already confirmed every affected
@@ -19,6 +20,7 @@ export function runLineageDeleteAll(args: {
onForceDeleted: (target: WorktreeRemovalTarget) => void
closeModal: () => void
onDeleted: ((deleted: WorktreeRemovalTarget[]) => void) | null | undefined
confirmedDiscards: ConfirmedUnsavedDiscards
}): void {
if (args.deleteAllTargetCount <= 1) {
return
@@ -32,7 +34,8 @@ export function runLineageDeleteAll(args: {
}
const deletePromise = runWorktreeDeletesInParallel(currentTargets, {
force: args.forceOnConfirm,
onForceDeleted: args.onForceDeleted
onForceDeleted: args.onForceDeleted,
confirmedDiscards: args.confirmedDiscards
})
args.closeModal()
void deletePromise.then((deletedTargets) => {
@@ -38,7 +38,8 @@ export function runWorktreeDeleteWithToast(
const removeOptions = {
...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}),
...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {})
...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}),
...(options.confirmedDiscards ? { confirmedDiscards: options.confirmedDiscards } : {})
}
const showFailureToast = (
error: string,
@@ -1,3 +1,4 @@
import type { ConfirmedUnsavedDiscards } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
import { runEnclosingWorktreeRemoval } from '@/store/active-worktree-deselection-intent'
@@ -24,7 +25,8 @@ export type ClearSshHostWorkspacesResult = {
*/
async function clearSshHostWorkspacesImpl(
resolution: SshHostRemoveResolution,
mode: 'delete-remote' | 'forget-local'
mode: 'delete-remote' | 'forget-local',
confirmedDiscards: ConfirmedUnsavedDiscards | undefined
): Promise<ClearSshHostWorkspacesResult> {
const store = useAppStore.getState()
const forgetLocalOnly = mode === 'forget-local'
@@ -39,7 +41,7 @@ async function clearSshHostWorkspacesImpl(
const result = await store.removeWorktree(
{ id: worktreeId, executionHostId: hostId },
false,
forgetLocalOnly ? { mode: 'forget-local' } : undefined
forgetLocalOnly ? { mode: 'forget-local', confirmedDiscards } : { confirmedDiscards }
)
if (!result.ok) {
failedIds.push(worktreeId)
@@ -54,7 +56,7 @@ async function clearSshHostWorkspacesImpl(
// clearing Orca's records without a successful remote call.
for (const repoId of resolution.hostRepoIds) {
try {
await store.removeProject(repoId, { hostId })
await store.removeProject(repoId, { hostId, confirmedDiscards })
} catch {
failedIds.push(repoId)
}
@@ -74,9 +76,10 @@ async function clearSshHostWorkspacesImpl(
export async function clearSshHostWorkspaces(
resolution: SshHostRemoveResolution,
mode: 'delete-remote' | 'forget-local'
mode: 'delete-remote' | 'forget-local',
confirmedDiscards?: ConfirmedUnsavedDiscards
): Promise<ClearSshHostWorkspacesResult> {
return runEnclosingWorktreeRemoval({ hostId: toSshExecutionHostId(resolution.targetId) }, () =>
clearSshHostWorkspacesImpl(resolution, mode)
clearSshHostWorkspacesImpl(resolution, mode, confirmedDiscards)
)
}
@@ -1,3 +1,4 @@
import type { ConfirmedUnsavedDiscards } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import type { Worktree } from '../../../../shared/worktree/types'
import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
@@ -26,6 +27,7 @@ export type WorktreeDeleteWithToastOptions = {
onPreservedBranch?: (branch: PreservedBranchCleanup) => void
suppressPreservedBranchToast?: boolean
snapshotPruneBatchId?: string
confirmedDiscards?: ConfirmedUnsavedDiscards
}
export function toWorktreeDeleteIdentities(
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
createProjectGroup: vi.fn(),
updateProjectGroup: vi.fn(),
deleteProjectGroupWithContainedProjects: vi.fn(),
confirmUnsavedDiscard: vi.fn(),
toastError: vi.fn()
}))
@@ -24,6 +25,10 @@ vi.mock('@/store', () => ({
})
}))
vi.mock('@/store/slices/worktrees/teardown/removed-workspace-unsaved-files', () => ({
confirmUnsavedDiscard: mocks.confirmUnsavedDiscard
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
}))
@@ -132,5 +137,6 @@ describe('project group dialogs carry the owner host', () => {
removeContainedProjects: false,
hostId: 'runtime:env-1'
})
expect(mocks.confirmUnsavedDiscard).toHaveBeenCalledTimes(1)
})
})
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { projectRemovalTargets } from '../../removal-unsaved-files'
import { confirmUnsavedDiscard } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { translate } from '@/i18n/i18n'
import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
@@ -197,18 +198,25 @@ export function useProjectGroupDialogs(args: {
if (!deleteDialog) {
return
}
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, deleteUnsavedTargets)
try {
reportProjectGroupDeleteFailures(
await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, {
removeContainedProjects,
hostId: deleteDialog.hostId
hostId: deleteDialog.hostId,
confirmedDiscards
})
)
} finally {
// Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup.
setDeleteDialog(null)
}
}, [deleteProjectGroupWithContainedProjects, removeContainedProjects, deleteDialog])
}, [
deleteProjectGroupWithContainedProjects,
removeContainedProjects,
deleteDialog,
deleteUnsavedTargets
])
return {
nameDialog,
+2
View File
@@ -3355,6 +3355,8 @@
"unsavedEdits": {
"blocked_one": "Save or close {{count}} unsaved file in {{name}} first",
"blocked_other": "Save or close {{count}} unsaved files in {{name}} first",
"closedToast_one": "Closed {{count}} file with unsaved edits from {{workspace}}",
"closedToast_other": "Closed {{count}} files with unsaved edits from {{workspace}}",
"discarded_one": "Unsaved edits in {{count}} file will be discarded:",
"discarded_other": "Unsaved edits in {{count}} files will be discarded:"
}
+5 -1
View File
@@ -19785,7 +19785,11 @@
"discarded_other": "Unsaved edits in {{count}} files will be discarded:",
"moreFiles": "and {{value}} more",
"blocked_one": "Save or close {{count}} unsaved file in {{name}} first",
"blocked_other": "Save or close {{count}} unsaved files in {{name}} first"
"blocked_other": "Save or close {{count}} unsaved files in {{name}} first",
"closedToast_one": "Closed {{count}} file with unsaved edits from {{workspace}}",
"closedToast_other": "Closed {{count}} files with unsaved edits from {{workspace}}",
"reopen": "Reopen",
"reopenFailed": "Some files couldn't be reopened. Open a workspace and try Reopen again."
}
}
},
+5 -1
View File
@@ -19359,7 +19359,11 @@
"discarded_other": "Se descartarán las ediciones sin guardar de {{count}} archivos:",
"moreFiles": "y {{value}} más",
"blocked_one": "Guarda o cierra antes {{count}} archivo sin guardar en {{name}}",
"blocked_other": "Guarda o cierra antes {{count}} archivos sin guardar en {{name}}"
"blocked_other": "Guarda o cierra antes {{count}} archivos sin guardar en {{name}}",
"closedToast_one": "Se cerró {{count}} archivo con ediciones sin guardar de {{workspace}}",
"closedToast_other": "Se cerraron {{count}} archivos con ediciones sin guardar de {{workspace}}",
"reopen": "Reabrir",
"reopenFailed": "No se pudieron reabrir algunos archivos. Abre un espacio de trabajo e intenta Reabrir de nuevo."
}
}
},
+5 -1
View File
@@ -19475,7 +19475,11 @@
"discarded_other": "Les modifications non enregistrées de {{count}} fichiers seront ignorées :",
"moreFiles": "et {{value}} de plus",
"blocked_one": "Enregistrez ou fermez d'abord {{count}} fichier non enregistré dans {{name}}",
"blocked_other": "Enregistrez ou fermez d'abord {{count}} fichiers non enregistrés dans {{name}}"
"blocked_other": "Enregistrez ou fermez d'abord {{count}} fichiers non enregistrés dans {{name}}",
"closedToast_one": "{{count}} fichier avec des modifications non enregistrées de {{workspace}} a été fermé",
"closedToast_other": "{{count}} fichiers avec des modifications non enregistrées de {{workspace}} ont été fermés",
"reopen": "Rouvrir",
"reopenFailed": "Certains fichiers n’ont pas pu être rouverts. Ouvrez un espace de travail et réessayez avec Rouvrir."
}
}
},
+5 -1
View File
@@ -19406,7 +19406,11 @@
"discarded_other": "{{count}} 個のファイルの未保存の編集は破棄されます:",
"moreFiles": "ほか {{value}} 件",
"blocked_one": "先に {{name}} の未保存のファイル {{count}} 個を保存するか閉じてください",
"blocked_other": "先に {{name}} の未保存のファイル {{count}} 個を保存するか閉じてください"
"blocked_other": "先に {{name}} の未保存のファイル {{count}} 個を保存するか閉じてください",
"closedToast_one": "{{workspace}} の未保存の編集がある {{count}} 個のファイルを閉じました",
"closedToast_other": "{{workspace}} の未保存の編集がある {{count}} 個のファイルを閉じました",
"reopen": "再度開く",
"reopenFailed": "一部のファイルを再度開けませんでした。ワークスペースを開いて、もう一度「再度開く」を試してください。"
}
}
},
+5 -1
View File
@@ -19406,7 +19406,11 @@
"discarded_other": "파일 {{count}}개의 저장되지 않은 편집 내용이 버려집니다:",
"moreFiles": "외 {{value}}개",
"blocked_one": "먼저 {{name}}에서 저장되지 않은 파일 {{count}}개를 저장하거나 닫으세요",
"blocked_other": "먼저 {{name}}에서 저장되지 않은 파일 {{count}}개를 저장하거나 닫으세요"
"blocked_other": "먼저 {{name}}에서 저장되지 않은 파일 {{count}}개를 저장하거나 닫으세요",
"closedToast_one": "{{workspace}}에서 저장되지 않은 편집 내용이 있는 파일 {{count}}개를 닫았습니다",
"closedToast_other": "{{workspace}}에서 저장되지 않은 편집 내용이 있는 파일 {{count}}개를 닫았습니다",
"reopen": "다시 열기",
"reopenFailed": "일부 파일을 다시 열지 못했습니다. 워크스페이스를 열고 다시 열기를 다시 시도하세요."
}
}
},
+5 -1
View File
@@ -19371,7 +19371,11 @@
"discarded_other": "{{count}} 个文件中未保存的编辑将被放弃:",
"moreFiles": "及另外 {{value}} 个",
"blocked_one": "请先保存或关闭 {{name}} 中 {{count}} 个未保存的文件",
"blocked_other": "请先保存或关闭 {{name}} 中 {{count}} 个未保存的文件"
"blocked_other": "请先保存或关闭 {{name}} 中 {{count}} 个未保存的文件",
"closedToast_one": "已关闭 {{workspace}} 中 {{count}} 个有未保存编辑的文件",
"closedToast_other": "已关闭 {{workspace}} 中 {{count}} 个有未保存编辑的文件",
"reopen": "重新打开",
"reopenFailed": "部分文件无法重新打开。请打开工作区,然后再次尝试“重新打开”。"
}
}
},
@@ -10,6 +10,7 @@ import {
} from '@/runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
const LOCAL: RuntimeClientTarget = { kind: 'local' }
@@ -72,6 +73,82 @@ describe('createUntitledMarkdownFile', () => {
expect(pathExists).toHaveBeenCalledTimes(3)
})
it.each(['original text {{title}}', ''])(
'recovers exact text into a collision-safe new path (%j)',
async (content) => {
const pathExists = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false)
const createFile = vi
.fn()
.mockRejectedValueOnce(new Error('EEXIST'))
.mockResolvedValue(undefined)
const writeFile = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { fs: { pathExists, createFile, writeFile } } })
await expect(
createUntitledMarkdownFile('/active', 'wt', undefined, LOCAL, {
recoveredDocument: { originalPath: 'C:\\removed\\note.ts', content }
})
).resolves.toMatchObject({
filePath: '/active/note (recovered 3).ts',
relativePath: 'note (recovered 3).ts',
language: 'typescript',
isUntitled: true,
deleteUntouchedOnClose: false
})
expect(writeFile).toHaveBeenCalledTimes(1)
expect(writeFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/active/note (recovered 3).ts',
content,
diskPrecondition: { expectedSignature: getDiskBaselineSignature('') }
})
)
expect(createFile).not.toHaveBeenCalledWith(
expect.objectContaining({ filePath: 'C:\\removed\\note.ts' })
)
}
)
it('never overwrites or deletes a file another writer filled after the create', async () => {
const createFile = vi.fn().mockResolvedValue(undefined)
const writeFile = vi
.fn()
.mockResolvedValueOnce({ ok: false, diskConflict: 'changed' })
.mockResolvedValue(undefined)
const deletePath = vi.fn()
vi.stubGlobal('window', {
api: {
fs: { pathExists: vi.fn().mockResolvedValue(false), createFile, writeFile, deletePath }
}
})
await expect(
createUntitledMarkdownFile('/active', 'wt', undefined, LOCAL, {
recoveredDocument: { originalPath: '/removed/a.md', content: 'text' }
})
).resolves.toMatchObject({ filePath: '/active/a (recovered 2).md' })
expect(writeFile).toHaveBeenCalledTimes(2)
expect(deletePath).not.toHaveBeenCalled()
})
it('keeps the created path when a recovered write fails', async () => {
const deletePath = vi.fn()
vi.stubGlobal('window', {
api: {
fs: {
pathExists: vi.fn().mockResolvedValue(false),
createFile: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockRejectedValue(new Error('disk full')),
deletePath
}
}
})
await expect(
createUntitledMarkdownFile('/active', 'wt', undefined, LOCAL, {
recoveredDocument: { originalPath: '/removed/a.md', content: 'text' }
})
).rejects.toThrow('disk full')
expect(deletePath).not.toHaveBeenCalled()
})
it('throws a descriptive error when untitled names are exhausted', async () => {
const pathExists = vi.fn(async () => true)
const stat = vi.fn().mockResolvedValue({ size: 0, isDirectory: false, mtime: 1 })
@@ -14,8 +14,9 @@ import {
type MarkdownDocumentTemplate
} from './markdown-document-templates'
import { requestMarkdownTemplateSelection } from './markdown-template-picker-request'
import { joinPath } from './path'
import { basename, joinPath } from './path'
import type { EditorFileOperationProvenance } from './editor-file-operation-owner'
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
export type UntitledMarkdownFileInfo = {
filePath: string
@@ -30,6 +31,8 @@ export type UntitledMarkdownFileInfo = {
type CreateUntitledMarkdownOptions = {
template?: MarkdownDocumentTemplate
/** Recovered text goes into a new `<name> (recovered)<ext>` file, never the original path. */
recoveredDocument?: { originalPath: string; content: string }
now?: Date
operationProvenance?: EditorFileOperationProvenance
expectedSshTargetId?: string
@@ -52,8 +55,8 @@ export async function createUntitledMarkdownFile(
target: RuntimeClientTarget,
options: CreateUntitledMarkdownOptions = {}
): Promise<UntitledMarkdownFileInfo> {
const baseName = 'untitled'
const ext = '.md'
const recovered = options.recoveredDocument
const fileNameFor = recovered ? recoveredFileName(recovered.originalPath) : untitledFileName
const MAX_ATTEMPTS = 100
const context = {
target,
@@ -82,7 +85,7 @@ export async function createUntitledMarkdownFile(
// Why: existence probing must go through the same runtime/SSH-aware file
// surface as creation; the shell probe only sees the client filesystem.
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const fileName = attempt === 1 ? `${baseName}${ext}` : `${baseName}-${attempt}${ext}`
const fileName = fileNameFor(attempt)
const filePath = joinPath(worktreePath, fileName)
assertCurrent()
@@ -93,23 +96,34 @@ export async function createUntitledMarkdownFile(
try {
assertCurrent()
await createRuntimePath(context, filePath, 'file')
if (templateContent !== null) {
try {
assertCurrent()
await writeRuntimeFile(
context,
filePath,
applyMarkdownTemplatePlaceholders(templateContent, {
const content =
recovered?.content ??
(templateContent === null
? null
: applyMarkdownTemplatePlaceholders(templateContent, {
title: getMarkdownTemplateTitleForFileName(fileName),
filename: fileName,
now: options.now
})
)
}))
if (content !== null) {
let written: Awaited<ReturnType<typeof writeRuntimeFile>>
try {
assertCurrent()
// Why guarded: only the empty file this create made may be filled, never another writer's.
written = await writeRuntimeFile(context, filePath, content, undefined, {
expectedSignature: getDiskBaselineSignature('')
})
} catch (error) {
assertCurrent()
await deleteRuntimePath(context, filePath).catch(() => undefined)
// Why: recovered text must never cost a file that replaced ours; a template leftover may go.
if (!recovered) {
await deleteRuntimePath(context, filePath).catch(() => undefined)
}
throw error
}
if (!written.ok) {
continue
}
}
return {
@@ -118,7 +132,7 @@ export async function createUntitledMarkdownFile(
worktreeId,
language: detectLanguage(fileName),
isUntitled: true,
deleteUntouchedOnClose: templateContent === null ? undefined : false,
deleteUntouchedOnClose: recovered || templateContent !== null ? false : undefined,
operationProvenance: options.operationProvenance,
mode: 'edit'
}
@@ -175,6 +189,18 @@ export async function createUntitledMarkdownFileWithTemplateSelection(
})
}
function untitledFileName(attempt: number): string {
return attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md`
}
function recoveredFileName(originalPath: string): (attempt: number) => string {
const name = basename(originalPath)
const dot = name.lastIndexOf('.')
const stem = (dot > 0 ? name.slice(0, dot) : name) || 'untitled'
const ext = dot > 0 ? name.slice(dot) : ''
return (attempt) => `${stem} (recovered${attempt === 1 ? '' : ` ${attempt}`})${ext}`
}
function requireOperationAssertion(assertCurrent: (() => void) | undefined): () => void {
if (!assertCurrent) {
throw new Error("Couldn't verify which host owns this file. Reopen the file and try again.")
@@ -223,7 +223,10 @@ export function createProjectGroupMutationActions(
const removableHostIds = removable.map((row) => getRepoExecutionHostId(row))
for (const hostId of removableHostIds) {
try {
await get().removeProject(projectId, { hostId })
await get().removeProject(projectId, {
hostId,
confirmedDiscards: options.confirmedDiscards
})
} catch (err) {
console.error('Failed to remove contained project:', err)
}
@@ -27,6 +27,11 @@ import {
captureWorkspaceChatDraftKeys,
deleteWorkspaceChatDrafts
} from '../slices/worktrees/teardown/removed-worktree-chat-drafts'
import {
flushPurgedWorkspaceEdits,
noticeRemovedUnsavedFiles
} from '../slices/worktrees/teardown/removed-workspace-unsaved-files'
import { worktreePurgeClosedFileIds } from '../slices/worktrees/teardown/worktree-purge-closed-files'
export function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean {
return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId
@@ -214,6 +219,14 @@ export function createRepoRemovalActions(
}
// Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree.
const purgeTargets = worktreeIds.map((id) => ({ id, hostId: ownerHostId }))
flushPurgedWorkspaceEdits(get(), purgeTargets)
// Why before the twin close: it closes tabs one by one; the purge below shares this batch.
noticeRemovedUnsavedFiles(
get(),
worktreePurgeClosedFileIds(get(), purgeTargets),
options.confirmedDiscards
)
closeRemovedHostTwinState(get, set, twins, preCallTwins)
get().purgeWorktreeTerminalState(exclusiveWorktreeIds)
deleteWorkspaceChatDrafts(chatDraftKeys)
@@ -28,6 +28,7 @@ import type {
FolderWorkspacePathStatusRequest
} from '../../../../shared/folder-workspace-path-status'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { ConfirmedUnsavedDiscards } from '../slices/worktrees/teardown/removed-workspace-unsaved-files'
export const ERROR_TOAST_DURATION = 60_000
@@ -109,6 +110,8 @@ export type DeleteProjectGroupWithContainedProjectsOptions = {
removeContainedProjects: boolean
// hostId disambiguates which host's group row to delete when the id exists on multiple hosts.
hostId?: ExecutionHostId
/** The text a dialog listed when the user confirmed discarding it; other edits are offered back. */
confirmedDiscards?: ConfirmedUnsavedDiscards
}
export type AllHostCatalogFetchOptions = {
@@ -272,6 +275,7 @@ export type RepoSlice = {
hostId: ExecutionHostId
errorFeedback?: 'toast' | 'silent'
automaticCleanup?: boolean
confirmedDiscards?: ConfirmedUnsavedDiscards
}
) => Promise<void>
updateProject: (
@@ -4,6 +4,8 @@
* tabs, unsaved document and drafts alone.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { registerPendingEditorFlush } from '@/components/editor/editor-pending-flush'
import { confirmUnsavedDiscard } from './worktrees/teardown/removed-workspace-unsaved-files'
import {
createTestStore,
makeOpenFile,
@@ -44,6 +46,11 @@ import {
updateNativeChatComposerDraft
} from '@/components/native-chat/native-chat-composer-draft-store'
const recoveryNotice = vi.hoisted(() => vi.fn())
vi.mock('@/components/editor/closed-unsaved-files-toast', () => ({
showClosedUnsavedFilesToast: recoveryNotice
}))
const PROJECT_ID = 'same-repo'
const WORKTREE_ID = 'same-repo::/work/same'
const REMOTE_HOST = 'runtime:env-b' as const
@@ -95,6 +102,7 @@ beforeEach(() => {
layoutApplyTestTransport.reset()
layoutApplyTestTransport.answerWith({ refuse: 'host_not_owned' })
ptyKill.mockReset()
recoveryNotice.mockClear()
onRepoRemoved = null
vi.stubGlobal('window', {
api: {
@@ -225,6 +233,57 @@ function seedTwins(): ReturnType<typeof createTestStore> {
}
describe('removing a remote project keeps its local twin', () => {
it.each([false, true])(
'captures twin and exclusive delayed text in one notice (confirmed=%s)',
async (confirmed) => {
const store = seedTwins()
const exclusive = `${PROJECT_ID}::/work/exclusive`
const exclusiveFile = '/work/exclusive/note.ts'
store.setState((s) => ({
worktreesByRepo: {
[PROJECT_ID]: [
...(s.worktreesByRepo[PROJECT_ID] ?? []),
makeWorktree({ id: exclusive, repoId: PROJECT_ID, hostId: REMOTE_HOST })
]
},
openFiles: [
...s.openFiles,
makeOpenFile({ id: exclusiveFile, worktreeId: exclusive, runtimeEnvironmentId: 'env-b' })
]
}))
const flushed: string[] = []
const unregister = ['editor:remote-file', exclusiveFile].map((id) =>
registerPendingEditorFlush(id, () => {
flushed.push(id)
store.setState((s) => ({ editorDrafts: { ...s.editorDrafts, [id]: `late ${id}` } }))
})
)
try {
const confirmedDiscards = confirmed
? confirmUnsavedDiscard(store.getState, [
{ id: WORKTREE_ID, hostId: REMOTE_HOST },
{ id: exclusive, hostId: REMOTE_HOST }
])
: undefined
await store.getState().removeProject(PROJECT_ID, { hostId: REMOTE_HOST, confirmedDiscards })
await layoutApplyTestTransport.settle()
expect([...new Set(flushed)].sort()).toEqual(['editor:remote-file', exclusiveFile].sort())
expect(store.getState().editorDrafts['/work/same/local.ts']).toBe('unsaved local edit')
if (confirmed) {
await new Promise((resolve) => setTimeout(resolve, 0))
expect(recoveryNotice).not.toHaveBeenCalled()
} else {
await vi.waitFor(() => expect(recoveryNotice).toHaveBeenCalledTimes(1))
expect(
recoveryNotice.mock.calls[0]?.[0].map((f: { content: string }) => f.content).sort()
).toEqual(['late editor:remote-file', `late ${exclusiveFile}`].sort())
}
} finally {
unregister.forEach((f) => f())
}
}
)
it('keeps the local shell, tab, dirty document and draft', async () => {
const store = seedTwins()
updateNativeChatComposerDraft('tab-local:leaf-1', { text: 'local chat draft' }, 'deferred')
@@ -26,6 +26,7 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
globalThis.window = { api: {} }
import { createTestStore, seedStore, makeWorktree, makeTab, TEST_REPO } from './store-test-helpers'
import { registerPendingEditorFlush } from '@/components/editor/editor-pending-flush'
const RUNTIME_A = toRuntimeExecutionHostId('env-a')
const RUNTIME_NEVER_SAVED = toRuntimeExecutionHostId('env-serving-client')
@@ -284,6 +285,41 @@ describe('purgeStaleRuntimeHostState', () => {
expect(s.activeWorkspaceKey).toBe(worktreeWorkspaceKey(worktreeId))
})
it('flushes delayed editor text of the removed host before purging its tabs', () => {
const store = createTestStore()
const worktreeId = 'repoA::/wt-flush'
seedStore(store, {
repos: [
{
id: 'repoA',
path: '/repoA',
displayName: 'A',
badgeColor: '#000',
addedAt: 0,
executionHostId: RUNTIME_A
}
],
worktreesByRepo: {
repoA: [makeWorktree({ id: worktreeId, repoId: 'repoA', hostId: RUNTIME_A })]
}
})
store.getState().openFile({
filePath: '/repoA/wt-flush/a.md',
relativePath: 'a.md',
worktreeId,
language: 'markdown',
mode: 'edit'
})
const fileId = store.getState().openFiles[0].id
const flush = vi.fn()
const unregister = registerPendingEditorFlush(fileId, flush)
store.getState().purgeStaleRuntimeHostState(['env-a'])
expect(flush).toHaveBeenCalledTimes(1)
unregister()
})
it('purges a legacy unhosted row when its sole repo owner was removed', () => {
const store = createTestStore()
const worktreeId = 'repoA::/legacy'
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { ConfirmedUnsavedDiscards } from './worktrees/teardown/removed-workspace-unsaved-files'
import type { NestedWorktreeRemovalApproval } from '../../../../shared/worktree/nested-removal'
@@ -22,4 +23,6 @@ export type RemoveWorktreeOptions = {
sameIdSurvivingHostId?: ExecutionHostId
/** Both scan owners are in this cleanup batch, so neither is a survivor. */
ignoreWorkspaceCleanupScanSurvivors?: boolean
/** The text a dialog listed when the user confirmed discarding it; other edits are offered back. */
confirmedDiscards?: ConfirmedUnsavedDiscards
}
@@ -70,7 +70,7 @@ export function createFetchAllWorktrees(
if (refresh.status !== 'admitted') {
return
}
mergeFetchedWorktrees(set, {
mergeFetchedWorktrees(set, get, {
repoId: r.id,
hostId,
ownerWasMissingAtStart: false,
@@ -129,7 +129,7 @@ export function createFetchAllWorktrees(
if (refresh.status !== 'admitted') {
return { repoId: r.id, ok: false as const }
}
const outcome = mergeFetchedWorktrees(set, {
const outcome = mergeFetchedWorktrees(set, get, {
repoId: r.id,
hostId,
ownerWasMissingAtStart: false,
@@ -99,7 +99,7 @@ export function createFetchWorktrees(
if (options?.requireAuthoritative && !refresh.result.authoritative) {
return directCallerAuthority ? (refresh.providerResult ?? false) : false
}
const outcome = mergeFetchedWorktrees(set, {
const outcome = mergeFetchedWorktrees(set, get, {
repoId,
hostId,
ownerWasMissingAtStart,
@@ -26,6 +26,7 @@ import {
type WorktreeListingMergeOutcome
} from './detected-worktree-refresh-admission'
import { buildWorktreePurgeState } from '../teardown/worktree-purge-state'
import { flushPurgedWorkspaceEdits } from '../teardown/removed-workspace-unsaved-files'
import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity'
import { isDisplayNamePersistencePending } from '../metadata/worktree-meta-persist'
import { branchName } from '@/lib/git-utils'
@@ -137,8 +138,22 @@ export function preserveConcurrentDisplayName<T extends Worktree>(
export function mergeFetchedWorktrees(
set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[0],
get: () => AppState,
args: FencedWorktreeMergeArgs
): WorktreeListingMergeOutcome {
if (args.purgeRemovedWorktrees !== false) {
// Why before the updater: delayed views must commit text the purge may offer back.
const state = get()
flushPurgedWorkspaceEdits(
state,
getRemovedWorktreeIdsAfterAuthoritativeScan(
state,
args.repoId,
args.refresh.result,
args.hostId
).map((id) => hostWorktreeTarget({ id, hostId: args.hostId }))
)
}
// Why a holder: the updater decides the outcome, and a narrowed `let` would hide that assignment.
const decision: { outcome: WorktreeListingMergeOutcome } = { outcome: 'not-current' }
let authoritativelyRemovedIds: readonly string[] = []
@@ -124,7 +124,7 @@ function applyListing(
rows: Worktree[],
catalogVersion?: WorktreeCatalogVersion
): WorktreeListingMergeOutcome {
return mergeFetchedWorktrees(store.setState, {
return mergeFetchedWorktrees(store.setState, store.getState, {
repoId: REPO_ID,
hostId: 'local',
ownerWasMissingAtStart: false,
@@ -16,6 +16,7 @@ import type {
} from '../../../terminals/terminal-tab-recovery-ledger'
import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up'
import { selectWorktreesByRepo } from '../../../workspace-record-selectors'
import { flushPurgedWorkspaceEdits } from '../teardown/removed-workspace-unsaved-files'
export function createSetRenamingWorktreeId(
set: WorktreeSliceSet,
@@ -152,6 +153,13 @@ export function createPurgeWorktreeTerminalState(
if (purgeableWorktreeTargets.length === 0) {
return
}
// Why before the purge: delayed views must commit text the purge may offer back.
flushPurgedWorkspaceEdits(
get(),
purgeableWorktreeTargets.map((target) =>
typeof target === 'string' ? { id: target } : target
)
)
settleAttentionRemovedBy(set, get, () =>
set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets))
)
@@ -27,6 +27,7 @@ import {
retirePurgedTabLaunches
} from './purged-structured-launch-retirement'
import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch'
import { noticeRemovedUnsavedFiles } from './removed-workspace-unsaved-files'
import {
selectDetectedWorktreesByRepo,
selectFolderWorkspaces,
@@ -200,6 +201,7 @@ export function buildHostTwinPurgeState(
}
}
}
noticeRemovedUnsavedFiles(s, doomed.removedFileIds)
const omitters = createWorktreePurgeOmitters(s, new Set(), doomed)
const tabPrefixedSleeping = {
sleepingAgentSessionsByPaneKey: omitters.omitByPaneKeyTabPrefix(
@@ -19,6 +19,7 @@ import {
import { buildFullWorktreePurgeState } from './worktree-purge-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up'
import { flushRetiredHostEdits } from './removed-workspace-unsaved-files'
export function createPurgeStaleRuntimeHostState(
set: WorktreeSliceSet,
@@ -29,6 +30,8 @@ export function createPurgeStaleRuntimeHostState(
if (removed.size === 0) {
return
}
// Why before the purge: delayed views must commit text it may offer back.
flushRetiredHostEdits(get(), removed)
// Why no flag settle: the purged host's rows are dropped here, and a surviving same-id row on
// another host must keep its own flag (a marker-less Mark as unread included).
settleAttentionRemovedBy(
@@ -8,13 +8,18 @@ import { forgetAmbiguousOwnerWarnings } from '@/lib/ambiguous-owner-warning'
import { omitRecordKeys } from './record-key-omission'
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
import {
noticeRemovedUnsavedFiles,
type ConfirmedUnsavedDiscards
} from './removed-workspace-unsaved-files'
/** Clears worktree-owned renderer state after successful removal so a reused identity cannot inherit stale preferences. */
export function applyRemoveWorktreeSuccessState(
set: WorktreeSliceSet,
worktreeId: string,
tabIds: Set<string>,
executionHostId?: ExecutionHostId
executionHostId?: ExecutionHostId,
confirmedDiscards?: ConfirmedUnsavedDiscards
): void {
// Why outside `set`: it is module-scope, not store state. Dropping it also
// re-arms the once-per-workspace warning if this id is ever added back.
@@ -39,6 +44,7 @@ export function applyRemoveWorktreeSuccessState(
removedFileIds.add(file.markdownPreviewSourceFileId)
}
}
noticeRemovedUnsavedFiles(s, removedFileIds, confirmedDiscards)
const omitByFileId = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, removedFileIds)
// If the active file belonged to the removed worktree, clear it
const activeFileCleared = s.activeFileId
@@ -245,7 +245,8 @@ export function createRemoveWorktree(
hostId,
requiredExecutionHostId,
beforeRemoval,
catalogVersion: removalResult?.catalogVersion
catalogVersion: removalResult?.catalogVersion,
confirmedDiscards: options?.confirmedDiscards
})
// Why: Source Control may be unmounted during deletion, so it can't be the only stale-draft cleanup path.
clearSessionCommitDraftForWorktree(worktreeId)
@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerPendingEditorFlush } from '@/components/editor/editor-pending-flush'
import { editorModelRegistry } from '@/lib/editor-model-registry'
import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity'
import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host'
import { createHostTwinFixture } from '../../../host-twin.test-fixture'
import { resetHostTwinModuleStores } from '../../../host-twin-state-projection.test-fixture'
import { createTestStore, makeOpenFile, makeWorktree, TEST_REPO } from '../../store-test-helpers'
import { createStoreCascadesMockApi } from '../../store-cascades-test-harness'
import { mergeFetchedWorktrees } from '../listing/fetched-worktree-merge'
import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup'
import { confirmUnsavedDiscard, noticeRemovedUnsavedFiles } from './removed-workspace-unsaved-files'
const show = vi.hoisted(() => vi.fn())
vi.mock('@/components/editor/closed-unsaved-files-toast', () => ({
showClosedUnsavedFilesToast: show
}))
vi.mock('sonner', () => ({
toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn() }
}))
type Store = ReturnType<typeof createTestStore>
const RUNTIME = toRuntimeExecutionHostId('env-a')
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const shownContents = (call = 0): string[] =>
(show.mock.calls[call]?.[0] ?? []).map((file: { content: string }) => file.content).sort()
beforeEach(() => {
show.mockClear()
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal('window', { api: { pty: { kill: vi.fn() } } })
})
afterEach(() => vi.unstubAllGlobals())
/** A store whose workspaces each hold one dirty tab `<id>/file.ts` with draft `draft <id>`. */
function dirtyStore(ids: readonly string[], hostId: 'local' | typeof RUNTIME = 'local'): Store {
const store = createTestStore()
store.setState({
repos: [{ ...TEST_REPO, executionHostId: hostId }],
worktreesByRepo: {
[TEST_REPO.id]: ids.map((id) =>
makeWorktree({ id, repoId: TEST_REPO.id, hostId, displayName: id.split('/').pop() })
)
},
openFiles: ids.map((id) =>
makeOpenFile({ id: `${id}/file.ts`, worktreeId: id, isDirty: true })
),
editorDrafts: Object.fromEntries(ids.map((id) => [`${id}/file.ts`, `draft ${id}`]))
})
return store
}
const edit = (store: Store, id: string, text: string): void =>
store.setState((s) => ({ editorDrafts: { ...s.editorDrafts, [`${id}/file.ts`]: text } }))
const GONE = `${TEST_REPO.id}::/gone`
/** Every way a workspace's tabs are purged, each closing `GONE`. */
const purgePaths: [string, (store: Store) => unknown, ('local' | typeof RUNTIME)?][] = [
['worktrees:changed purge', (store) => store.getState().purgeWorktreeTerminalState([GONE])],
[
'host announcement',
(store) =>
mergeFetchedWorktrees(store.setState, store.getState, {
repoId: TEST_REPO.id,
hostId: 'local',
ownerWasMissingAtStart: false,
requestStartedWorktrees: store.getState().worktreesByRepo[TEST_REPO.id] ?? [],
refresh: {
status: 'admitted',
executionHostId: 'local',
result: { repoId: TEST_REPO.id, authoritative: true, source: 'git', worktrees: [] }
}
})
],
[
'unconfirmed removeWorktree',
async (store) => {
vi.stubGlobal('window', { api: createStoreCascadesMockApi() })
await store.getState().removeWorktree({ id: GONE, executionHostId: 'local' }, false)
}
],
[
'runtime host retirement',
(store) => store.getState().purgeStaleRuntimeHostState(['env-a']),
RUNTIME
]
]
describe('confirmed discards apply only to the confirmed removal', () => {
it('its own teardown drops unchanged text quietly', async () => {
const store = dirtyStore([GONE])
const confirmed = confirmUnsavedDiscard(store.getState, [{ id: GONE, hostId: 'local' }])
applyRemoveWorktreeSuccessState(store.setState, GONE, new Set(), 'local', confirmed)
expect(store.getState().openFiles).toEqual([])
await settle()
expect(show).not.toHaveBeenCalled()
})
it('its own teardown offers back an edit made after confirming', async () => {
const store = dirtyStore([GONE])
const confirmed = confirmUnsavedDiscard(store.getState, [{ id: GONE, hostId: 'local' }])
edit(store, GONE, 'typed after confirming')
applyRemoveWorktreeSuccessState(store.setState, GONE, new Set(), 'local', confirmed)
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual(['typed after confirming'])
})
it('reaches the teardown through removeWorktree', async () => {
vi.stubGlobal('window', { api: createStoreCascadesMockApi() })
const store = dirtyStore([GONE])
const confirmed = confirmUnsavedDiscard(store.getState, [{ id: GONE, hostId: 'local' }])
await store.getState().removeWorktree({ id: GONE, executionHostId: 'local' }, false, {
confirmedDiscards: confirmed
})
expect(store.getState().openFiles).toEqual([])
await settle()
expect(show).not.toHaveBeenCalled()
})
it.each(purgePaths)('%s still offers text a dialog confirmed', async (_, purge, host) => {
const store = dirtyStore([GONE], host)
confirmUnsavedDiscard(store.getState, [{ id: GONE, hostId: host ?? 'local' }])
await purge(store)
expect(store.getState().openFiles).toEqual([])
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual([`draft ${GONE}`])
})
it('suppresses within its batch only, whichever purge of the batch runs first', async () => {
const store = dirtyStore([GONE])
const confirmed = confirmUnsavedDiscard(store.getState, [{ id: GONE, hostId: 'local' }])
const fileIds = new Set([`${GONE}/file.ts`])
noticeRemovedUnsavedFiles(store.getState(), fileIds)
noticeRemovedUnsavedFiles(store.getState(), fileIds, confirmed)
noticeRemovedUnsavedFiles(store.getState(), fileIds)
await settle()
expect(show).not.toHaveBeenCalled()
noticeRemovedUnsavedFiles(store.getState(), fileIds)
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
})
})
describe('delayed text is committed before a purge', () => {
it.each(purgePaths)('%s flushes every pane of the closing file', async (_, purge, host) => {
const store = dirtyStore([GONE], host)
const panes = [
registerPendingEditorFlush(`${GONE}/file.ts`, () => edit(store, GONE, 'late rich text')),
registerPendingEditorFlush(`${GONE}/file.ts`, () => {})
]
try {
await purge(store)
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual(['late rich text'])
} finally {
panes.forEach((unregister) => unregister())
}
})
it('leaves a surviving workspace’s CSV cell editing', () => {
const survivor = `${TEST_REPO.id}::/survivor`
const store = dirtyStore([GONE, survivor])
const cell = vi.fn()
const unregister = registerPendingEditorFlush(`${survivor}/file.ts`, cell, () => true)
try {
store.getState().purgeWorktreeTerminalState([GONE])
expect(cell).not.toHaveBeenCalled()
} finally {
unregister()
}
})
})
describe('the closed-files notice', () => {
it('merges a bulk purge into one toast and dedupes repeated captures', async () => {
const a = `${TEST_REPO.id}::/a`
const b = `${TEST_REPO.id}::/b`
const store = dirtyStore([a, b])
noticeRemovedUnsavedFiles(store.getState(), new Set([`${a}/file.ts`]))
store.getState().purgeWorktreeTerminalState([a, b])
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual([`draft ${a}`, `draft ${b}`])
expect(show.mock.calls[0]?.[1]).toBe('a, b')
await settle()
expect(show).toHaveBeenCalledTimes(1)
})
it('emits one notice for a full target and a host-twin scope', async () => {
const f = createHostTwinFixture('local+runtime')
try {
const full = 'other::/full'
f.store.setState((s) => ({
openFiles: [
...s.openFiles,
makeOpenFile({ id: 'full.ts', worktreeId: full, isDirty: true })
],
editorDrafts: { ...s.editorDrafts, 'full.ts': 'draft full' }
}))
f.store
.getState()
.purgeWorktreeTerminalState([
full,
hostWorktreeTarget({ id: f.workspaceKey, hostId: f.remote.hostId })
])
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual(['draft full', 'unsaved runtime:env-b edit'])
expect(f.store.getState().editorDrafts[f.local.fileId]).toBe('unsaved local edit')
} finally {
resetHostTwinModuleStores(f)
}
})
it.each([
[true, 'live model text'],
[false, 'newer draft']
])('reads a shared model only while mounted (attached=%s)', async (attached, expected) => {
const store = dirtyStore([GONE])
const tab = (id: string, isDirty: boolean) =>
makeOpenFile({ id, filePath: '/w/shared.ts', worktreeId: GONE, isDirty })
store.setState({
openFiles: [tab('first', true), tab('second', true)],
editorDrafts: { second: 'newer draft' }
})
const model = { isAttachedToEditor: () => attached, getValue: () => 'live model text' }
const unregister = editorModelRegistry.register({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: capture reads only getModel(uri) and the model's text.
editor: { getModel: () => model } as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: capture only parses the model URI.
Uri: { parse: (uri: string) => uri } as never
})
try {
store.getState().purgeWorktreeTerminalState([GONE])
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual([expected])
} finally {
unregister()
}
})
it('falls back to a sharing tab’s draft when the dirty tab has none', async () => {
const store = dirtyStore([GONE])
const tab = (id: string) =>
makeOpenFile({ id, filePath: '/w/shared.ts', worktreeId: GONE, isDirty: true })
store.setState({ openFiles: [tab('first'), tab('second')], editorDrafts: { second: 'kept' } })
store.getState().purgeWorktreeTerminalState([GONE])
await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1))
expect(shownContents()).toEqual(['kept'])
})
})
@@ -0,0 +1,135 @@
import type { AppState } from '../../../types'
import type { WorktreePurgeTarget } from '../../worktree-helpers'
import {
collectRemovalUnsavedFiles,
currentUnsavedText,
flushRemovalPendingEdits,
lastViewUnsavedFiles,
unsavedTextViewIds
} from '@/components/editor/removal-unsaved-editor-files'
import { worktreePurgeClosedFileIds } from './worktree-purge-closed-files'
import { resolveEditorModelOwner } from '@/components/editor/editor-model-owner'
import { isRemovedRuntimeHostId } from '../../stale-runtime-host-rows'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '../../../workspace-record-selectors'
/** Text retained only until the removal notice is dismissed or recovered. */
export type ClosedUnsavedFile = { fileId: string; filePath: string; content: string }
/** File id -> the exact text a removal dialog listed when the user confirmed discarding it. */
export type ConfirmedUnsavedDiscards = ReadonlyMap<string, string>
let pendingNotice: {
files: Map<string, { file: ClosedUnsavedFile; workspace: string }>
discarded: Set<string>
} | null = null
/** Snapshots the listed text; only the confirmed removal's own teardown may discard it. */
export function confirmUnsavedDiscard(
getState: () => AppState,
targets: readonly WorktreePurgeTarget[]
): ConfirmedUnsavedDiscards {
const files = collectRemovalUnsavedFiles(getState, targets)
const state = getState()
const confirmed = new Map<string, string>()
for (const file of files) {
const text = currentUnsavedText(state, file)
if (text !== undefined) {
for (const id of unsavedTextViewIds(state, file)) {
confirmed.set(id, text)
}
}
}
return confirmed
}
/** Commits delayed views' text for the tabs a purge of `targets` closes. */
export function flushPurgedWorkspaceEdits(
state: AppState,
targets: readonly WorktreePurgeTarget[]
): void {
flushRemovalPendingEdits(state, worktreePurgeClosedFileIds(state, targets))
}
/** Superset flush for a host retirement: tabs owned by a removed host, or whose owner is unknown. */
export function flushRetiredHostEdits(state: AppState, removed: ReadonlySet<string>): void {
flushRemovalPendingEdits(
state,
state.openFiles.flatMap((file) => {
const owner = resolveEditorModelOwner(file, state)
const retired =
!owner.hostId ||
(owner.environmentId !== null && removed.has(owner.environmentId)) ||
isRemovedRuntimeHostId(owner.hostId, removed)
return retired ? [file.id] : []
})
)
}
function workspaceLabel(s: AppState, worktreeId: string): string {
const worktree = Object.values(selectWorktreesByRepo(s) ?? {})
.flat()
.find((candidate) => candidate.id === worktreeId)
const folder = (selectFolderWorkspaces(s) ?? []).find(
(candidate) => folderWorkspaceKey(candidate.id) === worktreeId
)
return worktree?.displayName ?? folder?.name ?? worktreeId
}
/**
* Captures the unsaved text of tabs a purge closes and offers it back. Runs inside purge updaters,
* so one toast per microtask waits for the commit and merges every purge of the batch. Text still
* equal to `confirmed` is dropped, for this batch only.
*/
export function noticeRemovedUnsavedFiles(
s: AppState,
removedFileIds: ReadonlySet<string>,
confirmed?: ConfirmedUnsavedDiscards
): void {
try {
for (const file of removedFileIds.size > 0 ? lastViewUnsavedFiles(s, removedFileIds) : []) {
const content = currentUnsavedText(s, file)
if (content === undefined) {
console.warn('[editor] removed workspace closed a dirty tab with no text to recover', {
filePath: file.filePath
})
continue
}
const notice = (pendingNotice ??= startNotice())
if (confirmed?.get(file.id) === content) {
notice.files.delete(file.id)
notice.discarded.add(file.id)
} else if (!notice.discarded.has(file.id) && !notice.files.has(file.id)) {
notice.files.set(file.id, {
file: { fileId: file.id, filePath: file.filePath, content },
workspace: workspaceLabel(s, file.worktreeId)
})
}
}
} catch (error) {
console.error('[editor] capturing unsaved edits from a removed workspace failed', error)
}
}
function startNotice(): NonNullable<typeof pendingNotice> {
queueMicrotask(showPendingNotice)
return { files: new Map(), discarded: new Set() }
}
function showPendingNotice(): void {
const entries = [...(pendingNotice?.files.values() ?? [])]
pendingNotice = null
if (entries.length === 0) {
return
}
const closed = entries.map((entry) => entry.file)
const label = [...new Set(entries.map((entry) => entry.workspace))].join(', ')
console.warn('[editor] removed workspace closed files with unsaved edits', {
workspaces: label,
filePaths: closed.map((file) => file.filePath)
})
// Why lazy: keeps toast and editor-open code out of the store's import graph.
void import('@/components/editor/closed-unsaved-files-toast').then((module) =>
module.showClosedUnsavedFilesToast(closed, label)
)
}
@@ -12,6 +12,10 @@ import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-d
import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup'
import { purgeOrphanedRuntimeSshProjects } from './orphaned-runtime-ssh-project-purge'
import { deleteWorkspaceChatDrafts } from './removed-worktree-chat-drafts'
import {
flushPurgedWorkspaceEdits,
type ConfirmedUnsavedDiscards
} from './removed-workspace-unsaved-files'
import type { WorktreeStateBeforeRemoval } from './worktree-state-before-removal'
/**
@@ -30,6 +34,7 @@ export async function tearDownRemovedWorktreeRendererState(args: {
beforeRemoval: WorktreeStateBeforeRemoval
/** The catalog the host's removal produced, when the host stamps it. */
catalogVersion?: WorktreeCatalogVersion
confirmedDiscards?: ConfirmedUnsavedDiscards
}): Promise<void> {
const { set, get, worktreeId, hostId, requiredExecutionHostId, beforeRemoval } = args
// Why first: a listing scanned before this removal must not be applied after it and bring
@@ -90,7 +95,15 @@ export async function tearDownRemovedWorktreeRendererState(args: {
// Why: dispose parked terminal watchers only on explicit deletion; identity migration/remounts must keep buffered PTY state.
disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, beforeRemoval.terminalPtyIds)
applyRemoveWorktreeSuccessState(set, worktreeId, tabIds, requiredExecutionHostId ?? hostId)
// Why: delayed views must commit text the teardown may offer back.
flushPurgedWorkspaceEdits(get(), [{ id: worktreeId }])
applyRemoveWorktreeSuccessState(
set,
worktreeId,
tabIds,
requiredExecutionHostId ?? hostId,
args.confirmedDiscards
)
// Remove the orphaned project for the destroyed SSH target so it can't surface as a dead project in the composer.
// Why after the row drops: the project purge clears the selection, which must clear with the row.
await purgeOrphanedRuntimeSshProjects(get, runtimeCleanup.destroyedSshTargetIds)
@@ -18,6 +18,7 @@ import {
retirePurgedTabLaunches
} from './purged-structured-launch-retirement'
import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch'
import { noticeRemovedUnsavedFiles } from './removed-workspace-unsaved-files'
import { buildHostTwinPurgeState, collectHostTwinPurgeScopes } from './host-twin-worktree-purge'
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
@@ -92,6 +93,7 @@ export function buildFullWorktreePurgeState(
forgetAmbiguousOwnerWarnings(worktreeIdSet)
const doomed = collectWorktreePurgeDoomedIds(s, worktreeIdSet)
noticeRemovedUnsavedFiles(s, doomed.removedFileIds)
const omitters = createWorktreePurgeOmitters(s, worktreeIdSet, doomed)
const {
omitByWorktree,