Persist editor drafts for hot exit (#4499)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
def3374e76
commit
c0b573abda
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
|
|||
terminalLayoutsByTabId: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -13,6 +13,7 @@ function createSnapshot(
|
|||
terminalLayoutsByTabId: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
|
|||
terminalLayoutsByTabId: true,
|
||||
activeTabIdByWorktree: true,
|
||||
openFiles: true,
|
||||
editorDrafts: true,
|
||||
activeFileIdByWorktree: true,
|
||||
activeTabTypeByWorktree: true,
|
||||
browserTabsByWorktree: true,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue