From 924970836ad59ec009656d42674cfec70a8044fc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:52:00 -0700 Subject: [PATCH] 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 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 * 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 ", 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude --- .../editor/closed-unsaved-files-toast.test.ts | 187 +++++++++++++ .../editor/closed-unsaved-files-toast.ts | 138 ++++++++++ .../editor/csv/useCsvPendingCell.test.tsx | 45 ++++ .../editor/csv/useCsvPendingCell.ts | 39 +-- .../editor/editor-pending-flush.test.ts | 30 +++ .../components/editor/editor-pending-flush.ts | 37 ++- .../editor/editor-save-disk-precondition.ts | 2 +- .../editor/removal-unsaved-editor-files.ts | 43 ++- .../DeleteNestedWorktreesDialog.test.tsx | 5 +- .../sidebar/DeleteNestedWorktreesDialog.tsx | 7 +- .../sidebar/DeleteWorktreeDialog.test.tsx | 9 +- .../sidebar/DeleteWorktreeDialog.tsx | 14 +- .../sidebar/ForgetSshWorkspaceDialog.tsx | 7 +- .../components/sidebar/HostRemoveDialog.tsx | 4 +- .../components/sidebar/RemoveFolderDialog.tsx | 9 +- .../delete-worktree-dialog-force-delete.ts | 5 +- .../delete-worktree-lineage-delete-all.ts | 5 +- .../sidebar/run-worktree-delete-with-toast.ts | 3 +- .../sidebar/ssh-host-remove-workspaces.ts | 13 +- .../sidebar/worktree-delete-request.ts | 2 + ...-project-group-dialogs-owner-host.test.tsx | 6 + .../rows/use-project-group-dialogs.ts | 12 +- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../src/lib/create-untitled-markdown.test.ts | 77 ++++++ .../src/lib/create-untitled-markdown.ts | 56 ++-- .../project-groups/project-group-mutations.ts | 5 +- src/renderer/src/store/repos/repo-removal.ts | 13 + src/renderer/src/store/repos/repo-state.ts | 4 + .../project-removal-host-twin-state.test.ts | 59 +++++ .../purge-stale-runtime-host-state.test.ts | 36 +++ .../store/slices/worktree-removal-options.ts | 3 + .../worktrees/listing/fetch-all-worktrees.ts | 4 +- .../worktrees/listing/fetch-worktrees.ts | 2 +- .../listing/fetched-worktree-merge.ts | 15 ++ ...le-listing-purges-created-worktree.test.ts | 2 +- .../session/worktree-slice-lookups.ts | 8 + .../teardown/host-twin-worktree-purge.ts | 2 + .../purge-stale-runtime-host-state.ts | 3 + .../teardown/remove-worktree-store-cleanup.ts | 8 +- .../worktrees/teardown/remove-worktree.ts | 3 +- .../removed-workspace-unsaved-files.test.ts | 249 ++++++++++++++++++ .../removed-workspace-unsaved-files.ts | 135 ++++++++++ .../removed-worktree-renderer-teardown.ts | 15 +- .../teardown/worktree-purge-state.ts | 2 + 50 files changed, 1281 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/components/editor/closed-unsaved-files-toast.test.ts create mode 100644 src/renderer/src/components/editor/closed-unsaved-files-toast.ts create mode 100644 src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx create mode 100644 src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.ts diff --git a/src/renderer/src/components/editor/closed-unsaved-files-toast.test.ts b/src/renderer/src/components/editor/closed-unsaved-files-toast.test.ts new file mode 100644 index 00000000000..13418249e11 --- /dev/null +++ b/src/renderer/src/components/editor/closed-unsaved-files-toast.test.ts @@ -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 { + 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'] } + ) + }) +}) diff --git a/src/renderer/src/components/editor/closed-unsaved-files-toast.ts b/src/renderer/src/components/editor/closed-unsaved-files-toast.ts new file mode 100644 index 00000000000..1d3dfaaa51a --- /dev/null +++ b/src/renderer/src/components/editor/closed-unsaved-files-toast.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 { + 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) + }) + } + } + }) +} diff --git a/src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx b/src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx new file mode 100644 index 00000000000..6ed9d5e3b9b --- /dev/null +++ b/src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx @@ -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({ + 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()) + try { + flushClosingEditorBuffers(NO_SHARING, ['data.csv']) + expect(onContentChange).toHaveBeenCalledWith(`a,b\n1,${huge}`) + } finally { + await act(async () => root.unmount()) + } +}) diff --git a/src/renderer/src/components/editor/csv/useCsvPendingCell.ts b/src/renderer/src/components/editor/csv/useCsvPendingCell.ts index c59dfc47888..511644360e1 100644 --- a/src/renderer/src/components/editor/csv/useCsvPendingCell.ts +++ b/src/renderer/src/components/editor/csv/useCsvPendingCell.ts @@ -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, + 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 +} diff --git a/src/renderer/src/components/editor/editor-pending-flush.test.ts b/src/renderer/src/components/editor/editor-pending-flush.test.ts index faf69be08af..ad93c0affb9 100644 --- a/src/renderer/src/components/editor/editor-pending-flush.test.ts +++ b/src/renderer/src/components/editor/editor-pending-flush.test.ts @@ -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()) + } +}) diff --git a/src/renderer/src/components/editor/editor-pending-flush.ts b/src/renderer/src/components/editor/editor-pending-flush.ts index e76455b0bdb..52e774ebeb4 100644 --- a/src/renderer/src/components/editor/editor-pending-flush.ts +++ b/src/renderer/src/components/editor/editor-pending-flush.ts @@ -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>() 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() 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 +): void { + const entries = new Set() + 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) + } + } + } +} diff --git a/src/renderer/src/components/editor/editor-save-disk-precondition.ts b/src/renderer/src/components/editor/editor-save-disk-precondition.ts index a6b9c1627cf..2738f6aa1ce 100644 --- a/src/renderer/src/components/editor/editor-save-disk-precondition.ts +++ b/src/renderer/src/components/editor/editor-save-disk-precondition.ts @@ -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 diff --git a/src/renderer/src/components/editor/removal-unsaved-editor-files.ts b/src/renderer/src/components/editor/removal-unsaved-editor-files.ts index f4af6ec9226..eb71e182e55 100644 --- a/src/renderer/src/components/editor/removal-unsaved-editor-files.ts +++ b/src/renderer/src/components/editor/removal-unsaved-editor-files.ts @@ -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 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): 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) } } diff --git a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx index 725fe5e02e2..9d586fa4f22 100644 --- a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx index 6bda3c1e33b..b3abe5d4a16 100644 --- a/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.tsx @@ -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( diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index b43916b7309..41b69e8d99a 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -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 } diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index adb43e59f95..a1314ffac8c 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -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) }) } diff --git a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx index 061bf73ec43..d9fcf72f6b4 100644 --- a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx +++ b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx @@ -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 => { + 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) { diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index d5f82ee774b..1b30ed3d72b 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 4ac38285c03..831b48439bb 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -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) => { diff --git a/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts b/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts index 399d1789aef..785647b84df 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-dialog-force-delete.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/delete-worktree-lineage-delete-all.ts b/src/renderer/src/components/sidebar/delete-worktree-lineage-delete-all.ts index cc706080181..b17165c4162 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-lineage-delete-all.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-lineage-delete-all.ts @@ -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) => { diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 21a3f26495c..00f8ee457d2 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts b/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts index 6f7c9297514..f20c34d1575 100644 --- a/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts +++ b/src/renderer/src/components/sidebar/ssh-host-remove-workspaces.ts @@ -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 { 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 { return runEnclosingWorktreeRemoval({ hostId: toSshExecutionHostId(resolution.targetId) }, () => - clearSshHostWorkspacesImpl(resolution, mode) + clearSshHostWorkspacesImpl(resolution, mode, confirmedDiscards) ) } diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts index 23e9bf46a9a..56e0d362a1e 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx index dd2346ecfce..9472719ba58 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx @@ -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) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index 0c922682b8b..3475b5f50a8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -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, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index ab76b071088..519f189b66c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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:" } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6b3a9630668..a89798b721b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c952c2aebc4..2e2c091b7b7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6a6fbe5020e..2600dc6b0ec 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cfc6214020b..7af869b9e5b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "一部のファイルを再度開けませんでした。ワークスペースを開いて、もう一度「再度開く」を試してください。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 79f4ce15c65..a4ccccba4b1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "일부 파일을 다시 열지 못했습니다. 워크스페이스를 열고 다시 열기를 다시 시도하세요." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ed7268f421b..77586aaa69a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "部分文件无法重新打开。请打开工作区,然后再次尝试“重新打开”。" } } }, diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts index 20f6f91e900..f5f90943c24 100644 --- a/src/renderer/src/lib/create-untitled-markdown.test.ts +++ b/src/renderer/src/lib/create-untitled-markdown.test.ts @@ -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 }) diff --git a/src/renderer/src/lib/create-untitled-markdown.ts b/src/renderer/src/lib/create-untitled-markdown.ts index 7427924d7fa..be9dd5a90e9 100644 --- a/src/renderer/src/lib/create-untitled-markdown.ts +++ b/src/renderer/src/lib/create-untitled-markdown.ts @@ -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 ` (recovered)` 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 { - 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> + 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.") diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index 3d278f8406d..fca06fdde00 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -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) } diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 7cf590c744f..dc91aa28cc6 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -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) diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 923c0ee2068..f35a23c6e37 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -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 updateProject: ( diff --git a/src/renderer/src/store/slices/project-removal-host-twin-state.test.ts b/src/renderer/src/store/slices/project-removal-host-twin-state.test.ts index c5665447d4a..fe2c02c7cb4 100644 --- a/src/renderer/src/store/slices/project-removal-host-twin-state.test.ts +++ b/src/renderer/src/store/slices/project-removal-host-twin-state.test.ts @@ -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 { } 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') diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 71cde0bac72..208729f1132 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -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' diff --git a/src/renderer/src/store/slices/worktree-removal-options.ts b/src/renderer/src/store/slices/worktree-removal-options.ts index 8672a6efb05..585dc5c5236 100644 --- a/src/renderer/src/store/slices/worktree-removal-options.ts +++ b/src/renderer/src/store/slices/worktree-removal-options.ts @@ -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 } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts index b9d3d1d1168..4c3331d1b15 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts @@ -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, diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts index 1ed7c54490d..94bb4deaf14 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts @@ -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, diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index a06f7fb7730..58c62fdb61f 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -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( export function mergeFetchedWorktrees( set: Parameters>[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[] = [] diff --git a/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts b/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts index 49fc80f9956..646e8c9e338 100644 --- a/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts +++ b/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts @@ -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, diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 0c879014bcf..670d4c2f75d 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -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)) ) diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts index b0a228561ae..0bf8f820165 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -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( diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index 704c500d5a9..b2166040f45 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -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( diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 4716b885187..58654cf2df1 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -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, - 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 = (m: Record | undefined) => omitRecordKeys(m, removedFileIds) // If the active file belonged to the removed worktree, clear it const activeFileCleared = s.activeFileId diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 29c230be14c..ecff973f7c5 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -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) diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.test.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.test.ts new file mode 100644 index 00000000000..059f94f81fa --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.test.ts @@ -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 +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 `/file.ts` with draft `draft `. */ +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']) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.ts new file mode 100644 index 00000000000..1a1204c7412 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-workspace-unsaved-files.ts @@ -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 + +let pendingNotice: { + files: Map + discarded: Set +} | 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() + 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): 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, + 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 { + 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) + ) +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 5fd0835f2f6..4f603f8dc8d 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -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 { 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) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 72246195572..7c1a71e6c89 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -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,