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,