Persist editor drafts for hot exit (#4499)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-02 21:49:13 -04:00 committed by GitHub
parent def3374e76
commit c0b573abda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 614 additions and 54 deletions

View File

@ -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')

View File

@ -485,6 +485,27 @@ export function registerFilesystemHandlers(
}
)
ipcMain.handle(
'fs:pathExists',
async (_event, args: { filePath: string; connectionId?: string }): Promise<boolean> => {
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',

View File

@ -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<boolean>
listFiles: (args: {
rootPath: string
connectionId?: string

View File

@ -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<void> {
function requestEditorHotExitBackup(): Promise<void> {
return new Promise<void>((resolve, reject) => {
let claimed = false
window.dispatchEvent(
new CustomEvent<EditorSaveDirtyFilesDetail>(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, {
new CustomEvent<EditorPrepareHotExitDetail>(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, {
detail: {
claim: () => {
claimed = true
@ -194,9 +192,8 @@ function requestDirtyEditorFileSave(): Promise<void> {
})
)
// 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<void> {
async function prepareRendererForAppRestart({
startedEventName,
abortedEventName,
continueOnSaveFailure,
saveFailureLogPrefix
abortedEventName
}: AppRestartPrepOptions): Promise<void> {
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<void> => {
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<void> => {
// 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<boolean> =>
ipcRenderer.invoke('fs:pathExists', args),
listFiles: (args: {
rootPath: string
connectionId?: string

View File

@ -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<typeof vi.fn>
}
session?: {
setSync: ReturnType<typeof vi.fn>
}
}
}
@ -64,6 +70,60 @@ async function requestDirtyFileSave(): Promise<void> {
})
}
async function requestEditorHotExitBackup(): Promise<void> {
await new Promise<void>((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<AppState> {
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<AppState>
}
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()

View File

@ -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<void> => {
const detail = (event as CustomEvent<EditorPrepareHotExitDetail>).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<void> => {
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

View File

@ -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'
})

View File

@ -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
}
}
})

View File

@ -20,7 +20,10 @@ function stubReadDir(entriesByPath: Record<string, DirEntry[]>): ReturnType<type
vi.stubGlobal('window', {
api: {
fs: { readDir }
fs: {
pathExists: vi.fn(async ({ filePath }: { filePath: string }) => filePath in entriesByPath),
readDir
}
}
})

View File

@ -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,

View File

@ -13,6 +13,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
terminalLayoutsByTabId: {},
activeTabIdByWorktree: {},
openFiles: [],
editorDrafts: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
browserTabsByWorktree: {},

View File

@ -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> = {}
): 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)
})
])
})
})

View File

@ -13,6 +13,7 @@ function createSnapshot(
terminalLayoutsByTabId: {},
activeTabIdByWorktree: {},
openFiles: [],
editorDrafts: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
browserTabsByWorktree: {},

View File

@ -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(

View File

@ -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
)

View File

@ -13,6 +13,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
terminalLayoutsByTabId: true,
activeTabIdByWorktree: true,
openFiles: true,
editorDrafts: true,
activeFileIdByWorktree: true,
activeTabTypeByWorktree: true,
browserTabsByWorktree: true,

View File

@ -20,6 +20,7 @@ function createSnapshot(overrides: Partial<AppState> = {}): AppState {
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
},
activeTabIdByWorktree: { 'wt-1': 'tab-1', 'wt-2': 'tab-2' },
editorDrafts: {},
openFiles: [
{
filePath: '/tmp/demo.ts',

View File

@ -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<string, string>,
activeFileIdByWorktree: Record<string, string | null>,
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
): Pick<
@ -110,13 +113,15 @@ export function buildEditorSessionData(
const editFileIdsByWorktree: Record<string, Set<string>> = {}
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
),

View File

@ -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(

View File

@ -776,8 +776,22 @@ export async function runtimePathExists(
context: RuntimeFileOperationArgs,
absolutePath: string
): Promise<boolean> {
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()

View File

@ -3452,6 +3452,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
const openFiles: OpenFile[] = []
const editorDrafts: Record<string, string> = {}
const usedOpenFileIds = new Set<string>()
const legacyHydratedOpenFiles: LegacyHydratedEditorFile[] = []
const editorFileIdMigrationsByWorktree: Record<string, Map<string, string>> = {}
@ -3485,6 +3486,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
return {
openFiles,
editorDrafts,
activeFileId: nextActiveFileId,
activeFileIdByWorktree: filteredActiveFileIdByWorktree,
activeTabType: nextActiveTabType,

View File

@ -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)
})

View File

@ -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<RuntimeRpcResponse<unknown>> {
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()

View File

@ -1153,6 +1153,21 @@ function createFileApi(): NonNullable<Partial<PreloadApi>['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<Worktree> {
// Why: hidden-but-open worktrees must still resolve for git/file operations.
// `worktree.list` is sidebar-visible only, so path resolution uses detected rows.

View File

@ -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

View File

@ -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 = {

View File

@ -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 ────────────────────────────────────────────────────────