From c0b573abda1c400aab427bb308c1606a39362134 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:49:13 -0400 Subject: [PATCH] Persist editor drafts for hot exit (#4499) Co-authored-by: Orca --- src/main/ipc/filesystem.test.ts | 31 ++++ src/main/ipc/filesystem.ts | 21 +++ src/preload/api-types.ts | 1 + src/preload/index.ts | 42 ++--- .../editor/editor-autosave-controller.test.ts | 159 +++++++++++++++++- .../editor/editor-autosave-controller.ts | 55 ++++++ .../terminal-link-handlers.test.ts | 8 +- .../src/lib/create-untitled-markdown.test.ts | 42 +++-- .../lib/markdown-document-templates.test.ts | 5 +- .../src/lib/markdown-document-templates.ts | 12 +- .../workspace-session-browser-history.test.ts | 1 + .../workspace-session-editor-drafts.test.ts | 79 +++++++++ .../lib/workspace-session-liveness.test.ts | 1 + .../src/lib/workspace-session-patch.test.ts | 31 ++++ .../src/lib/workspace-session-patch.ts | 2 + .../workspace-session-relevant-fields.test.ts | 1 + .../src/lib/workspace-session.test.ts | 1 + src/renderer/src/lib/workspace-session.ts | 8 +- .../src/runtime/runtime-file-client.test.ts | 26 +++ .../src/runtime/runtime-file-client.ts | 16 +- src/renderer/src/store/slices/editor.ts | 7 +- .../slices/store-session-cascades.test.ts | 7 +- src/renderer/src/web/web-preload-api.test.ts | 82 +++++++++ src/renderer/src/web/web-preload-api.ts | 22 +++ src/shared/editor-save-events.ts | 3 + src/shared/types.ts | 2 + src/shared/workspace-session-schema.ts | 3 +- 27 files changed, 614 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/lib/workspace-session-editor-drafts.test.ts diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 372285526..238b4c090 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -338,6 +338,37 @@ describe('registerFilesystemHandlers', () => { expect(statMock).not.toHaveBeenCalledWith(modelLinkPath) }) + it('returns false from pathExists when a local authorized path is missing', async () => { + const targetPath = path.join(REPO_PATH, 'untitled-7.md') + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + + registerFilesystemHandlers(store as never) + + await expect(handlers.get('fs:pathExists')!(null, { filePath: targetPath })).resolves.toBe( + false + ) + + expect(statMock).toHaveBeenCalledWith(targetPath) + }) + + it('returns false from pathExists when an SSH provider reports a missing path', async () => { + const provider = { + stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + } + getSshFilesystemProviderMock.mockReturnValue(provider) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:pathExists')!(null, { + filePath: '/remote/repo/untitled-7.md', + connectionId: 'ssh-1' + }) + ).resolves.toBe(false) + + expect(provider.stat).toHaveBeenCalledWith('/remote/repo/untitled-7.md') + }) + it('allows deletePath when a registered worktree parent resolves to a macOS canonical alias', async () => { const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature') const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature') diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 8ff6769fb..21c0f4391 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -485,6 +485,27 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'fs:pathExists', + async (_event, args: { filePath: string; connectionId?: string }): Promise => { + try { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + await provider.stat(args.filePath) + return true + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + await stat(filePath) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + } + ) + // ─── Search ──────────────────────────────────────────── ipcMain.handle( 'fs:search', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index deb98047c..61943ce93 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1772,6 +1772,7 @@ export type PreloadApi = { filePath: string connectionId?: string }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> + pathExists: (args: { filePath: string; connectionId?: string }) => Promise listFiles: (args: { rootPath: string connectionId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 4dc35bad1..e48b686ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,8 +135,8 @@ import type { } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' import { - ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, - type EditorSaveDirtyFilesDetail + ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, + type EditorPrepareHotExitDetail } from '../shared/editor-save-events' import { ORCA_APP_RESTART_ABORTED_EVENT, @@ -173,15 +173,13 @@ let nativeFileDropListenerRegistered = false type AppRestartPrepOptions = { startedEventName: string abortedEventName: string - continueOnSaveFailure: boolean - saveFailureLogPrefix: string } -function requestDirtyEditorFileSave(): Promise { +function requestEditorHotExitBackup(): Promise { return new Promise((resolve, reject) => { let claimed = false window.dispatchEvent( - new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { + new CustomEvent(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, { detail: { claim: () => { claimed = true @@ -194,9 +192,8 @@ function requestDirtyEditorFileSave(): Promise { }) ) - // Why: restart paths can run when no editor surface is mounted. When - // nothing claims the request there are no in-memory editor buffers to - // flush, so proceed with the normal shutdown path immediately. + // Why: restart paths can run before the editor autosave controller mounts. + // With no claimant, there are no renderer-owned dirty buffers to back up. if (!claimed) { resolve() } @@ -205,20 +202,15 @@ function requestDirtyEditorFileSave(): Promise { async function prepareRendererForAppRestart({ startedEventName, - abortedEventName, - continueOnSaveFailure, - saveFailureLogPrefix + abortedEventName }: AppRestartPrepOptions): Promise { window.dispatchEvent(new Event(startedEventName)) try { - await requestDirtyEditorFileSave() + await requestEditorHotExitBackup() } catch (error) { - if (!continueOnSaveFailure) { - window.dispatchEvent(new Event(abortedEventName)) - throw error - } - console.warn(saveFailureLogPrefix, error) + window.dispatchEvent(new Event(abortedEventName)) + throw error } // Dispatch beforeunload now so terminal buffers are captured while panes are @@ -402,9 +394,7 @@ const api = { restart: async (): Promise => { await prepareRendererForAppRestart({ startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, - continueOnSaveFailure: false, - saveFailureLogPrefix: '[app-restart] Saving dirty files before restart failed:' + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT }) try { return await ipcRenderer.invoke('app:restart') @@ -2112,15 +2102,9 @@ const api = { download: () => ipcRenderer.invoke('updater:download'), dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), quitAndInstall: async (): Promise => { - // Why: update installs must proceed even when a dirty-file auto-save - // fails; otherwise a downloaded update can get stuck behind hidden editor - // state. Manual app restart uses the same prep but aborts on save failure. await prepareRendererForAppRestart({ startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, - abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, - continueOnSaveFailure: true, - saveFailureLogPrefix: - '[updater] Saving dirty files before quit failed; proceeding with install anyway:' + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT }) try { return await ipcRenderer.invoke('updater:quitAndInstall') @@ -2195,6 +2179,8 @@ const api = { connectionId?: string }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => ipcRenderer.invoke('fs:stat', args), + pathExists: (args: { filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:pathExists', args), listFiles: (args: { rootPath: string connectionId?: string diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 6edf848c5..1bb14f739 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' import type { AppState } from '@/store' -import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-save-events' +import { + ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, + ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT +} from '../../../../shared/editor-save-events' import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave' import { attachEditorAutosaveController } from './editor-autosave-controller' import { registerPendingEditorFlush } from './editor-pending-flush' @@ -28,6 +31,9 @@ type WindowStub = { runtimeEnvironments?: { call: ReturnType } + session?: { + setSync: ReturnType + } } } @@ -64,6 +70,60 @@ async function requestDirtyFileSave(): Promise { }) } +async function requestEditorHotExitBackup(): Promise { + await new Promise((resolve, reject) => { + let claimed = false + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, { + detail: { + claim: () => { + claimed = true + }, + resolve, + reject: (message: string) => reject(new Error(message)) + } + }) + ) + + if (!claimed) { + resolve() + } + }) +} + +function makeSessionReadyState(): Partial { + return { + workspaceSessionReady: true, + hydrationSucceeded: true, + activeRepoId: 'repo-1', + activeTabId: 'tab-1', + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', title: 'shell', ptyId: null, worktreeId: 'wt-1' } as never] + }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } + }, + activeTabIdByWorktree: { 'wt-1': 'tab-1' }, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {}, + browserUrlHistory: [], + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + sshConnectionStates: new Map(), + repos: [], + worktreesByRepo: {}, + lastKnownRelayPtyIdByTabId: {}, + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {} + } as Partial +} + describe('attachEditorAutosaveController', () => { beforeEach(() => { vi.useFakeTimers() @@ -267,6 +327,103 @@ describe('attachEditorAutosaveController', () => { } }) + it('backs up dirty editor drafts for hot exit without writing files', async () => { + const writeFile = vi.fn().mockResolvedValue(undefined) + const setSync = vi.fn() + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + fs: { + writeFile + }, + session: { + setSync + } + } + } satisfies WindowStub) + + const store = createEditorStore() + store.setState(makeSessionReadyState()) + store.getState().openFile({ + filePath: '/repo/file.md', + relativePath: 'file.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().setEditorDraft('/repo/file.md', '') + store.getState().markFileDirty('/repo/file.md', true) + + const cleanup = attachEditorAutosaveController(store) + try { + await requestEditorHotExitBackup() + await vi.advanceTimersByTimeAsync(1000) + + expect(writeFile).not.toHaveBeenCalled() + expect(setSync).toHaveBeenCalledTimes(1) + expect(setSync.mock.calls[0][0].openFilesByWorktree['wt-1'][0]).toEqual( + expect.objectContaining({ + filePath: '/repo/file.md', + dirtyDraftContent: '' + }) + ) + expect(store.getState().openFiles[0]?.isDirty).toBe(true) + expect(store.getState().editorDrafts['/repo/file.md']).toBe('') + } finally { + cleanup() + } + }) + + it('rejects hot exit for dirty non-edit files that cannot be restored', async () => { + const writeFile = vi.fn().mockResolvedValue(undefined) + const setSync = vi.fn() + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + fs: { + writeFile + }, + session: { + setSync + } + } + } satisfies WindowStub) + + const store = createEditorStore() + store.setState(makeSessionReadyState()) + store.getState().openFile({ + filePath: '/repo/file.md', + relativePath: 'file.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'diff', + diffSource: 'unstaged' + } as never) + store.getState().setEditorDraft('/repo/file.md', 'diff edit') + store.getState().markFileDirty('/repo/file.md', true) + + const cleanup = attachEditorAutosaveController(store) + try { + await expect(requestEditorHotExitBackup()).rejects.toThrow( + 'Some unsaved editor changes cannot be backed up before restart.' + ) + expect(setSync).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + it('skips the open-file scan for unrelated store mutations', () => { const writeFile = vi.fn().mockResolvedValue(undefined) const eventTarget = new EventTarget() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 8666562b7..a1b7ea008 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -5,6 +5,10 @@ import type { StoreApi } from 'zustand' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionId } from '@/lib/connection-context' +import { + buildWorkspaceSessionPayload, + shouldPersistWorkspaceSession +} from '@/lib/workspace-session' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -30,7 +34,9 @@ import { getDuplicateDirtySavePaths } from './editor-autosave-state-projections' import { + ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, + type EditorPrepareHotExitDetail, type EditorSaveDirtyFilesDetail } from '../../../../shared/editor-save-events' @@ -243,6 +249,50 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { } } + const handlePrepareHotExit = async (event: Event): Promise => { + const detail = (event as CustomEvent).detail + if (!detail) { + return + } + + try { + detail.claim() + + const initiallyDirtyFiles = store.getState().openFiles.filter((file) => file.isDirty) + await Promise.all(initiallyDirtyFiles.map((file) => quiesceFileSave(file.id))) + + const state = store.getState() + const dirtyFiles = state.openFiles.filter((file) => file.isDirty) + const unsupportedDirtyFiles = dirtyFiles.filter((file) => file.mode !== 'edit') + if (unsupportedDirtyFiles.length > 0) { + detail.reject('Some unsaved editor changes cannot be backed up before restart.') + return + } + + for (const file of dirtyFiles) { + if (state.editorDrafts[file.id] === undefined) { + throw new Error(`Missing editor buffer for ${file.relativePath}`) + } + } + + if (dirtyFiles.length > 0 && !shouldPersistWorkspaceSession(state)) { + detail.reject( + 'Unsaved editor changes cannot be backed up until workspace restore finishes.' + ) + return + } + + // Why: restart/update may quit before the debounced session writer fires. + // Write the full session now so dirty drafts restore as unsaved tabs. + if (shouldPersistWorkspaceSession(state)) { + window.api.session.setSync(buildWorkspaceSessionPayload(state)) + } + detail.resolve() + } catch (error) { + detail.reject(String((error as Error)?.message ?? error)) + } + } + const handleSaveAndClose = async (event: Event): Promise => { const { fileId } = (event as CustomEvent<{ fileId: string }>).detail const file = store.getState().openFiles.find((openFile) => openFile.id === fileId) @@ -342,6 +392,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { syncAutoSave() window.addEventListener(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, handleSaveDirtyFiles as EventListener) + window.addEventListener(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, handlePrepareHotExit as EventListener) window.addEventListener(ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, handleSaveAndClose as EventListener) window.addEventListener(ORCA_EDITOR_SAVE_FILE_EVENT, handleSaveFile as EventListener) window.addEventListener(ORCA_EDITOR_QUIESCE_FILE_SAVES_EVENT, handleQuiesce as EventListener) @@ -356,6 +407,10 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, handleSaveDirtyFiles as EventListener ) + window.removeEventListener( + ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, + handlePrepareHotExit as EventListener + ) window.removeEventListener( ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, handleSaveAndClose as EventListener diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 7a3816d34..c81de1061 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -29,6 +29,7 @@ const openFilePathMock = vi.fn() const openFileMock = vi.fn() const authorizeExternalPathMock = vi.fn() const statMock = vi.fn().mockResolvedValue({ isDirectory: false }) +const fsPathExistsMock = vi.fn().mockResolvedValue(true) const runtimeEnvironmentCallMock = vi.fn() const runtimeEnvironmentTransportCallMock = vi.fn() const setActiveWorktreeMock = vi.fn() @@ -111,6 +112,7 @@ beforeEach(() => { }, fs: { authorizeExternalPath: authorizeExternalPathMock, + pathExists: fsPathExistsMock, stat: statMock }, runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock } @@ -1038,13 +1040,13 @@ describe('createFilePathLinkProvider range bounds', () => { firstProvider.provideLinks(1, (provided) => resolve(provided ?? [])) }) expect(firstLinks.map((link) => link.text)).toEqual(['shared.ts']) - expect(statMock).toHaveBeenCalledWith({ + expect(fsPathExistsMock).toHaveBeenCalledWith({ filePath: '/repo/shared.ts', connectionId: 'ssh-one' }) vi.mocked(getConnectionId).mockReturnValue('ssh-two') - statMock.mockRejectedValueOnce(new Error('ENOENT')) + fsPathExistsMock.mockResolvedValueOnce(false) const secondProvider = createFilePathLinkProvider( 1, deps, @@ -1056,7 +1058,7 @@ describe('createFilePathLinkProvider range bounds', () => { }) expect(secondLinks).toEqual([]) - expect(statMock).toHaveBeenLastCalledWith({ + expect(fsPathExistsMock).toHaveBeenLastCalledWith({ filePath: '/repo/shared.ts', connectionId: 'ssh-two' }) diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts index bcb97b2c5..1c54b3e9a 100644 --- a/src/renderer/src/lib/create-untitled-markdown.test.ts +++ b/src/renderer/src/lib/create-untitled-markdown.test.ts @@ -16,7 +16,11 @@ describe('createUntitledMarkdownFile', () => { }) it('retries with the next untitled name when createFile loses the EEXIST race', async () => { - const pathExists = vi.fn() + const pathExists = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) const stat = vi.fn(async (args: { filePath: string }) => { if (args.filePath.endsWith('untitled.md')) { return { size: 0, isDirectory: false, mtime: 1 } @@ -30,8 +34,8 @@ describe('createUntitledMarkdownFile', () => { vi.stubGlobal('window', { api: { - shell: { pathExists }, - fs: { createFile, stat } + shell: { pathExists: vi.fn() }, + fs: { createFile, pathExists, stat } } }) @@ -46,7 +50,7 @@ describe('createUntitledMarkdownFile', () => { expect(createFile).toHaveBeenNthCalledWith(1, { filePath: '/repo/untitled-2.md' }) expect(createFile).toHaveBeenNthCalledWith(2, { filePath: '/repo/untitled-3.md' }) - expect(pathExists).not.toHaveBeenCalled() + expect(pathExists).toHaveBeenCalledTimes(3) }) it('throws a descriptive error when untitled names are exhausted', async () => { @@ -56,8 +60,8 @@ describe('createUntitledMarkdownFile', () => { vi.stubGlobal('window', { api: { - shell: { pathExists }, - fs: { createFile, stat } + shell: { pathExists: vi.fn() }, + fs: { createFile, pathExists, stat } } }) @@ -66,18 +70,18 @@ describe('createUntitledMarkdownFile', () => { ) expect(createFile).not.toHaveBeenCalled() - expect(pathExists).not.toHaveBeenCalled() + expect(pathExists).toHaveBeenCalledTimes(100) }) - it('passes connectionId to stat and createFile for SSH worktrees', async () => { + it('passes connectionId to pathExists and createFile for SSH worktrees', async () => { const pathExists = vi.fn(async () => false) const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file')) const createFile = vi.fn().mockResolvedValueOnce(undefined) vi.stubGlobal('window', { api: { - shell: { pathExists }, - fs: { createFile, stat } + shell: { pathExists: vi.fn() }, + fs: { createFile, pathExists, stat } } }) @@ -87,11 +91,11 @@ describe('createUntitledMarkdownFile', () => { // Why: shell.pathExists is main-process local-only; SSH worktrees must // probe through the same filesystem API that receives the connectionId. - expect(pathExists).not.toHaveBeenCalled() - expect(stat).toHaveBeenCalledWith({ + expect(pathExists).toHaveBeenCalledWith({ filePath: '/repo/untitled.md', connectionId: 'conn-1' }) + expect(stat).not.toHaveBeenCalled() expect(createFile).toHaveBeenCalledWith({ filePath: '/repo/untitled.md', connectionId: 'conn-1' @@ -110,7 +114,7 @@ describe('createUntitledMarkdownFile', () => { vi.stubGlobal('window', { api: { shell: { pathExists: vi.fn() }, - fs: { createFile, readFile, stat, writeFile } + fs: { createFile, pathExists: vi.fn().mockResolvedValue(false), readFile, stat, writeFile } } }) @@ -158,6 +162,9 @@ describe('createUntitledMarkdownFile', () => { isBinary: false }) const writeFile = vi.fn().mockResolvedValueOnce(undefined) + const pathExists = vi.fn(async ({ filePath }: { filePath: string }) => + filePath.endsWith('/.orca/templates') + ) const unsubscribe = subscribeMarkdownTemplatePicker((request) => { const template = request.templates[0] if (!template) { @@ -169,7 +176,14 @@ describe('createUntitledMarkdownFile', () => { vi.stubGlobal('window', { api: { shell: { pathExists: vi.fn() }, - fs: { createFile, readDir, readFile, stat, writeFile } + fs: { + createFile, + pathExists, + readDir, + readFile, + stat, + writeFile + } } }) diff --git a/src/renderer/src/lib/markdown-document-templates.test.ts b/src/renderer/src/lib/markdown-document-templates.test.ts index 611eb728b..c6114c273 100644 --- a/src/renderer/src/lib/markdown-document-templates.test.ts +++ b/src/renderer/src/lib/markdown-document-templates.test.ts @@ -20,7 +20,10 @@ function stubReadDir(entriesByPath: Record): ReturnType filePath in entriesByPath), + readDir + } } }) diff --git a/src/renderer/src/lib/markdown-document-templates.ts b/src/renderer/src/lib/markdown-document-templates.ts index 077410f27..01b6486b5 100644 --- a/src/renderer/src/lib/markdown-document-templates.ts +++ b/src/renderer/src/lib/markdown-document-templates.ts @@ -1,6 +1,10 @@ import type { DirEntry, GlobalSettings } from '../../../shared/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import { readRuntimeDirectory, readRuntimeFileContent } from '@/runtime/runtime-file-client' +import { + readRuntimeDirectory, + readRuntimeFileContent, + runtimePathExists +} from '@/runtime/runtime-file-client' import { basename, joinPath, normalizeRelativePath } from './path' const MARKDOWN_TEMPLATE_ROOT = '.orca/templates' @@ -95,6 +99,12 @@ export async function listMarkdownDocumentTemplates( const templates: MarkdownDocumentTemplate[] = [] const rootPath = joinPath(worktreePath, MARKDOWN_TEMPLATE_ROOT) + // Why: missing template directories are the normal case. Probe quietly first + // so Electron does not log an IPC handler error for an optional feature. + if (!(await runtimePathExists(context, rootPath))) { + return [] + } + async function visitDirectory( dirPath: string, relativeDir: string, diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index b60f128da..47f3e12b7 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -13,6 +13,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess terminalLayoutsByTabId: {}, activeTabIdByWorktree: {}, openFiles: [], + editorDrafts: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, browserTabsByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts new file mode 100644 index 000000000..6255460ee --- /dev/null +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceSessionSnapshot } from './workspace-session' +import { buildWorkspaceSessionPayload } from './workspace-session' + +function createSnapshot( + overrides: Partial = {} +): WorkspaceSessionSnapshot { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + activeTabId: 'tab-1', + tabsByWorktree: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {}, + browserUrlHistory: [], + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + sshConnectionStates: new Map(), + repos: [], + worktreesByRepo: {}, + lastKnownRelayPtyIdByTabId: {}, + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {}, + ...overrides + } +} + +describe('workspace session editor drafts', () => { + it('persists dirty editor drafts without saving clean file content', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/tmp/dirty.md', + filePath: '/tmp/dirty.md', + relativePath: 'dirty.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: true + } as never, + { + id: '/tmp/clean.md', + filePath: '/tmp/clean.md', + relativePath: 'clean.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false + } as never + ], + editorDrafts: { + '/tmp/dirty.md': '', + '/tmp/clean.md': 'clean draft should not persist' + } + }) + ) + + expect(payload.openFilesByWorktree?.['wt-1']).toEqual([ + expect.objectContaining({ + filePath: '/tmp/dirty.md', + dirtyDraftContent: '' + }), + expect.not.objectContaining({ + dirtyDraftContent: expect.any(String) + }) + ]) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index fab4d56bf..160b7ee27 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -13,6 +13,7 @@ function createSnapshot( terminalLayoutsByTabId: {}, activeTabIdByWorktree: {}, openFiles: [], + editorDrafts: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, browserTabsByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 15ce28d72..6e8db2fd8 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -22,6 +22,7 @@ function createSnapshot( 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } }, activeTabIdByWorktree: { 'wt-1': 'tab-1', 'wt-2': 'tab-2' }, + editorDrafts: {}, openFiles: [ { filePath: '/tmp/demo.ts', @@ -104,6 +105,36 @@ describe('buildWorkspaceSessionPatch', () => { }) }) + it('derives editor session keys when only editor drafts change', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + openFiles: [ + { + id: '/tmp/demo.ts', + filePath: '/tmp/demo.ts', + relativePath: 'demo.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: true + } as never + ], + editorDrafts: { '/tmp/demo.ts': 'edited' } + }), + ['editorDrafts'] + ) + + expect(Object.keys(patch).sort()).toEqual( + ['activeFileIdByWorktree', 'activeTabTypeByWorktree', 'openFilesByWorktree'].sort() + ) + expect(patch.openFilesByWorktree?.['wt-1'][0]).toEqual( + expect.objectContaining({ + filePath: '/tmp/demo.ts', + dirtyDraftContent: 'edited' + }) + ) + }) + it('sanitizes terminal tabs and prunes local buffers when tab topology changes', () => { const localWorktreeId = 'repo-1::/local/worktree' const patch = buildWorkspaceSessionPatch( diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index ade50fe4f..667a92d64 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -78,6 +78,7 @@ export function buildWorkspaceSessionPatch( if ( hasAnyChangedField(changed, [ 'openFiles', + 'editorDrafts', 'activeFileIdByWorktree', 'activeTabTypeByWorktree' ] as const) @@ -86,6 +87,7 @@ export function buildWorkspaceSessionPatch( patch, buildEditorSessionData( snapshot.openFiles, + snapshot.editorDrafts, snapshot.activeFileIdByWorktree, snapshot.activeTabTypeByWorktree ) diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 57990b9b6..c15b09c91 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -13,6 +13,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { terminalLayoutsByTabId: true, activeTabIdByWorktree: true, openFiles: true, + editorDrafts: true, activeFileIdByWorktree: true, activeTabTypeByWorktree: true, browserTabsByWorktree: true, diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index 8ce7e1ca2..cf3579ce1 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -20,6 +20,7 @@ function createSnapshot(overrides: Partial = {}): AppState { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } }, activeTabIdByWorktree: { 'wt-1': 'tab-1', 'wt-2': 'tab-2' }, + editorDrafts: {}, openFiles: [ { filePath: '/tmp/demo.ts', diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index e5a8531c1..c03a02461 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -37,6 +37,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'terminalLayoutsByTabId' | 'activeTabIdByWorktree' | 'openFiles' + | 'editorDrafts' | 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' | 'browserTabsByWorktree' @@ -70,6 +71,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'terminalLayoutsByTabId', 'activeTabIdByWorktree', 'openFiles', + 'editorDrafts', 'activeFileIdByWorktree', 'activeTabTypeByWorktree', 'browserTabsByWorktree', @@ -99,6 +101,7 @@ void _exhaustive * Only edit-mode files are saved — diffs and conflict views are transient. */ export function buildEditorSessionData( openFiles: OpenFile[], + editorDrafts: Record, activeFileIdByWorktree: Record, activeTabTypeByWorktree: Record ): Pick< @@ -110,13 +113,15 @@ export function buildEditorSessionData( const editFileIdsByWorktree: Record> = {} for (const f of editFiles) { const arr = byWorktree[f.worktreeId] ?? (byWorktree[f.worktreeId] = []) + const dirtyDraftContent = f.isDirty ? editorDrafts[f.id] : undefined arr.push({ filePath: f.filePath, relativePath: f.relativePath, worktreeId: f.worktreeId, language: f.language, isPreview: f.isPreview || undefined, - runtimeEnvironmentId: f.runtimeEnvironmentId + runtimeEnvironmentId: f.runtimeEnvironmentId, + ...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}) }) const ids = editFileIdsByWorktree[f.worktreeId] ?? (editFileIdsByWorktree[f.worktreeId] = new Set()) @@ -329,6 +334,7 @@ export function buildWorkspaceSessionPayload( activeTabIdByWorktree: snapshot.activeTabIdByWorktree, ...buildEditorSessionData( snapshot.openFiles, + snapshot.editorDrafts, snapshot.activeFileIdByWorktree, snapshot.activeTabTypeByWorktree ), diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index cfc088312..1b54157df 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -14,6 +14,7 @@ import { readRuntimeFileContent, readRuntimeFilePreview, renameRuntimePath, + runtimePathExists, searchRuntimeFiles, statRuntimePath, subscribeRuntimeFileChanges, @@ -33,6 +34,7 @@ const fsCreateFile = vi.fn() const fsRename = vi.fn() const fsDeletePath = vi.fn() const fsStat = vi.fn() +const fsPathExists = vi.fn() const fsImportExternalPaths = vi.fn() const fsStageExternalPathsForRuntimeUpload = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -51,6 +53,7 @@ beforeEach(() => { fsRename.mockReset() fsDeletePath.mockReset() fsStat.mockReset() + fsPathExists.mockReset() fsImportExternalPaths.mockReset() fsStageExternalPathsForRuntimeUpload.mockReset() runtimeEnvironmentCall.mockReset() @@ -82,6 +85,7 @@ beforeEach(() => { rename: fsRename, deletePath: fsDeletePath, stat: fsStat, + pathExists: fsPathExists, importExternalPaths: fsImportExternalPaths, stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload }, @@ -1097,6 +1101,28 @@ describe('runtime file client', () => { }) }) + it('uses quiet local path existence checks when no runtime environment is active', async () => { + fsPathExists.mockResolvedValueOnce(false) + + await expect( + runtimePathExists( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + '/repo/untitled.md' + ) + ).resolves.toBe(false) + + expect(fsPathExists).toHaveBeenCalledWith({ + filePath: '/repo/untitled.md', + connectionId: 'ssh-1' + }) + expect(fsStat).not.toHaveBeenCalled() + }) + it('does not fall back to client-local stat for remote-owned paths outside the worktree', async () => { await expect( statRuntimePath( diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index b32cc1166..49e293695 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -776,8 +776,22 @@ export async function runtimePathExists( context: RuntimeFileOperationArgs, absolutePath: string ): Promise { + const remoteArgs = getRemoteFileArgs(context, absolutePath) + if (!remoteArgs) { + assertLocalFilesystemFallbackAllowed(context) + return window.api.fs.pathExists({ + filePath: absolutePath, + connectionId: context.connectionId + }) + } + try { - await statRuntimePath(context, absolutePath) + await callRuntimeRpc( + remoteArgs.target, + 'files.stat', + { worktree: remoteArgs.worktreeId, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) return true } catch (err) { const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 4fc2e4df7..86c5d81fb 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -3452,6 +3452,7 @@ export const createEditorSlice: StateCreator = (s validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) const openFiles: OpenFile[] = [] + const editorDrafts: Record = {} const usedOpenFileIds = new Set() const legacyHydratedOpenFiles: LegacyHydratedEditorFile[] = [] const editorFileIdMigrationsByWorktree: Record> = {} @@ -3485,6 +3486,9 @@ export const createEditorSlice: StateCreator = (s worktreeId, runtimeEnvironmentId: pf.runtimeEnvironmentId }) + if (pf.dirtyDraftContent !== undefined) { + editorDrafts[id] = pf.dirtyDraftContent + } openFiles.push({ id, filePath: pf.filePath, @@ -3494,7 +3498,7 @@ export const createEditorSlice: StateCreator = (s // Re-detect on hydrate so newly-supported extensions like .ipynb // stop reopening as raw JSON/plain text after the upgrade. language: detectLanguage(pf.relativePath || pf.filePath), - isDirty: false, + isDirty: pf.dirtyDraftContent !== undefined, isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, mode: 'edit' @@ -3575,6 +3579,7 @@ export const createEditorSlice: StateCreator = (s return { openFiles, + editorDrafts, activeFileId: nextActiveFileId, activeFileIdByWorktree: filteredActiveFileIdByWorktree, activeTabType: nextActiveTabType, diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index a124162ee..b4ffc2434 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1829,7 +1829,8 @@ describe('hydrateEditorSession', () => { relativePath: 'note.md', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, language: 'markdown', - runtimeEnvironmentId: null + runtimeEnvironmentId: null, + dirtyDraftContent: '' } ] }, @@ -1845,9 +1846,11 @@ describe('hydrateEditorSession', () => { id: fileId, filePath, worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - runtimeEnvironmentId: null + runtimeEnvironmentId: null, + isDirty: true }) ]) + expect(s.editorDrafts).toEqual({ [fileId]: '' }) expect(s.activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(fileId) }) diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 2cccf9eae..40e4239b7 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -933,6 +933,88 @@ describe('web worktree preload API', () => { }) }) +describe('web file preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('returns false for runtime missing-path errors from fs.pathExists', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/workspace/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'repo', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo' + } + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + if (method === 'repo.list') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { repos: [{ id: 'repo-1' }] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'worktree.detectedList') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { repoId: 'repo-1', authoritative: true, worktrees: [worktree] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: false, + error: { code: 'ENOENT', message: 'ENOENT: no such file' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.fs.pathExists({ filePath: '/workspace/repo/untitled.md' }) + ).resolves.toBe(false) + expect(runtimeCalls).toEqual([ + { method: 'repo.list', params: undefined }, + { method: 'worktree.detectedList', params: { repo: 'repo-1' } }, + { method: 'files.stat', params: { worktree: 'wt-1', relativePath: 'untitled.md' } } + ]) + }) +}) + describe('web GitHub preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 221dd198d..143fd3374 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1153,6 +1153,21 @@ function createFileApi(): NonNullable['fs']> { relativePath: file.relativePath }) }, + pathExists: async ({ filePath }) => { + try { + const file = await resolveRuntimeFilePath(filePath) + await callRuntimeResult('files.stat', { + worktree: file.worktree.id, + relativePath: file.relativePath + }) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } + }, listFiles: async ({ rootPath, excludePaths }) => { const file = await resolveRuntimeFilePath(rootPath) const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( @@ -2503,6 +2518,13 @@ function toLegacyDetectedWorktreeResult( } } +function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return /\bENOENT\b|not found|no such file/i.test(error.message) +} + async function resolveRuntimeWorktreeByPath(worktreePath: string): Promise { // Why: hidden-but-open worktrees must still resolve for git/file operations. // `worktree.list` is sidebar-visible only, so path resolution uses detected rows. diff --git a/src/shared/editor-save-events.ts b/src/shared/editor-save-events.ts index 299cb5392..9b6570d67 100644 --- a/src/shared/editor-save-events.ts +++ b/src/shared/editor-save-events.ts @@ -1,7 +1,10 @@ export const ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT = 'orca:editor-save-dirty-files' +export const ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT = 'orca:editor-prepare-hot-exit' export type EditorSaveDirtyFilesDetail = { claim: () => void resolve: () => void reject: (message: string) => void } + +export type EditorPrepareHotExitDetail = EditorSaveDirtyFilesDetail diff --git a/src/shared/types.ts b/src/shared/types.ts index 4a8fd02b8..3496f3c9b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -656,6 +656,8 @@ export type PersistedOpenFile = { language: string isPreview?: boolean runtimeEnvironmentId?: string | null + /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */ + dirtyDraftContent?: string } export type WorkspaceSessionState = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 4c77de68c..f78c6cfee 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -135,7 +135,8 @@ const persistedOpenFileSchema = z.object({ worktreeId: z.string(), language: z.string(), isPreview: z.boolean().optional(), - runtimeEnvironmentId: z.string().nullable().optional() + runtimeEnvironmentId: z.string().nullable().optional(), + dirtyDraftContent: z.string().optional() }) // ─── Browser ────────────────────────────────────────────────────────