perf(editor): watch only active, recent and at-risk workspaces (#30415)

* perf(editor): watch only active, recent and at-risk workspaces

Background workspaces no longer hold a whole-tree native file watch. The
active workspace, the 3 most recently visited ones, and any showing a
deleted/renamed tab keep theirs; every other workspace's subscription is
released (local, WSL, SSH via the relay, and runtime hosts alike).

When a dropped workspace is watched again, its tabs are verified against
disk through the existing paths: one overflow resync once the watch is in
place reloads clean tabs and refreshes git status, Files and markdown
lists; dirty tabs go through the restored-tab disk check, which shows the
changed-on-disk banner. Saves stay guarded by the host-side disk check, so
nothing is held while unwatched.

Also: watch requests are shared per directory and host; root events reach
every workspace on that root and SSH payloads carry their connection id;
late SSH and local/WSL capacity-retry installs send an overflow resync.

* fix(editor): catch up combined reviews, deletions, late installs and Files trees after a rewatch

- Overflow catch-up drops cached combined-diff bodies for the workspace and
  re-reads every loaded section of a mounted review (snapshot-backed too).
- Clean edit tabs are probed on the owning host first; a file proven gone is
  marked deleted and keeps its last content instead of reloading into an error.
- Dirty tabs are checked from the overflow itself, so the check runs after the
  watch is installed, including late capacity/SSH installs.
- Local/WSL Files trees also take the renderer-synthesized catch-up.
- Deleted/renamed-tab pins are capped at 3 beyond the active and recent set.

* fix(editor): catch-up keeps unsaved combined-diff edits and drops stale existence answers

- Cached Changes views reset only clean loaded sections; dirty sections keep
  their drafts and baselines (also for per-path invalidation).
- A mounted review re-reads loaded and in-flight sections on catch-up.
- Existence answers carry a per-tab generation; a newer probe or watch event
  for the tab supersedes an older answer.
- A tab is marked deleted only on a per-path ENOENT, never on a whole-request
  not-found or an old host's scalar fallback.

* fix(editor): re-check dirty tabs requested mid-read, prove runtime deletions, chunk existence probes

- A disk check requested while one is in flight is followed by one fresh read
  (coalesced) instead of being answered by the older reply.
- The restored-tab check probes runtime-owned files on their host; a per-path
  ENOENT marks the tab deleted. After a retry ceiling it stops, clears the
  suspension and shows the changed-on-disk banner.
- Overflow existence probes are split into host-sized chunks, in order.

* fix(editor): a reload that finds the file gone keeps its content; stale absence answers re-read

- A tab that already read its file and now gets a path-specific ENOENT on reload
  is marked deleted and keeps its last content, with or without an existence
  probe, so older paired servers are covered.
- The restored-tab check applies its request generation after the absence
  probe too, so an older answer cannot resolve a newer request.
This commit is contained in:
Neil
2026-10-20 05:58:58 -07:00
committed by GitHub
parent 03e9a03e9e
commit 3f435fba28
46 changed files with 2610 additions and 328 deletions
@@ -30,7 +30,8 @@ const WORKTREE_PATH = '/home/me/repo'
const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' }
const OVERFLOW_PAYLOAD = {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }]
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }],
connectionId: 'conn-1'
}
const RETRY_GIVE_UP_MS = 61_000
const DORMANT_FIRST_MS = 60_000
+8 -1
View File
@@ -62,7 +62,14 @@ export function registerFilesystemWatcherHandlers(): void {
`[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}`
)
}
scheduleRemoteWatcherRetry(event.sender, args.connectionId, args.worktreePath)
// Why: writes before the retry lands were never reported, and editor tabs may be stale from an earlier unwatched spell.
scheduleRemoteWatcherRetry(
event.sender,
args.connectionId,
args.worktreePath,
undefined,
true
)
return
}
return
@@ -7,6 +7,7 @@ import {
registerWatcherSenderCleanup
} from './filesystem-watcher-listener-lifecycle'
import { isWatcherRemovalInProgressError } from './watcher-removal-gate'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
export type SubscribeLocalWatcher = (worktreePath: string, sender: WebContents) => Promise<void>
@@ -42,6 +43,14 @@ export function scheduleLocalCapacityRetry(
console.error(`[filesystem-watcher] capacity retry failed for ${rootKey}:`, error)
}
})
// Why: the IPC call returned before this install, so writes in between were never reported.
const root = watcherLifecycleState.watchedRoots.get(rootKey)
if (root?.listeners.has(listener.id) && !listener.isDestroyed()) {
listener.send('fs:changed', {
worktreePath: root.rootPath,
events: [{ kind: 'overflow', absolutePath: root.rootPath }]
} satisfies FsChangedPayload)
}
})
)
})
@@ -81,6 +81,13 @@ describe('native filesystem watcher capacity recovery', () => {
releases.pop()?.()
await vi.waitFor(() => expect(subscribeViaWatcherProcessMock).toHaveBeenCalledTimes(2))
// Why: the IPC call returned before this install, so a write in between needs a resync.
await vi.waitFor(() =>
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/tmp/native-capacity-root',
events: [{ kind: 'overflow', absolutePath: '/tmp/native-capacity-root' }]
})
)
releases.forEach((release) => release())
})
+9
View File
@@ -37,3 +37,12 @@ export function normalizeWatcherEventPath(eventPath: string): string {
export function getRemoteWatcherKey(connectionId: string, worktreePath: string): string {
return JSON.stringify([connectionId, normalizeRuntimePathForComparison(worktreePath)])
}
export function getRemoteWatcherKeyConnectionId(key: string): string | undefined {
try {
const parsed: unknown = JSON.parse(key)
return Array.isArray(parsed) && typeof parsed[0] === 'string' ? parsed[0] : undefined
} catch {
return undefined
}
}
@@ -81,7 +81,8 @@ describe('remote filesystem watcher batching', () => {
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]
events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }],
connectionId: 'conn-1'
})
await closeAllWatchers()
vi.useRealTimers()
@@ -103,7 +104,8 @@ describe('remote filesystem watcher batching', () => {
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }]
events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }],
connectionId: 'conn-1'
})
await closeAllWatchers()
vi.useRealTimers()
@@ -125,7 +127,8 @@ describe('remote filesystem watcher batching', () => {
expect(sender.send).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }]
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }],
connectionId: 'conn-1'
})
await closeAllWatchers()
vi.useRealTimers()
@@ -189,7 +189,8 @@ async function doInstallRemoteWatcher(
try {
listener.send('fs:changed', {
worktreePath,
events
events,
connectionId
} satisfies FsChangedPayload)
} catch (err) {
// Why: batching moved this send out of the SSH mux's notification try/catch into a bare
@@ -80,7 +80,8 @@ describe('remote filesystem watcher re-arm', () => {
expect(retryWatchMock).toHaveBeenCalledTimes(1)
expect(senderOne.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }]
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
connectionId: 'conn-1'
})
warnSpy.mockRestore()
@@ -118,7 +118,8 @@ export async function restoreRemoteWatcherAfterFailedRemoval(
}
sender.send('fs:changed', {
worktreePath,
events: [{ kind: 'overflow', absolutePath: worktreePath }]
events: [{ kind: 'overflow', absolutePath: worktreePath }],
connectionId
} satisfies FsChangedPayload)
}
// Why: an overlapping retried removal may have re-suspended owners into this same entry.
@@ -5,6 +5,7 @@ import {
watcherLifecycleState
} from './filesystem-watcher-lifecycle-state'
import { isCurrentDesiredRemoteWatcher } from './filesystem-watcher-remote-desired'
import { getRemoteWatcherKeyConnectionId } from './filesystem-watcher-paths'
function flushRemoteWatcherResync(key: string): void {
const state = watcherLifecycleState.remoteWatcherResyncStates.get(key)
@@ -30,7 +31,8 @@ function flushRemoteWatcherResync(key: string): void {
try {
listener.send('fs:changed', {
worktreePath: state.worktreePath,
events: [{ kind: 'overflow', absolutePath: state.worktreePath }]
events: [{ kind: 'overflow', absolutePath: state.worktreePath }],
connectionId: getRemoteWatcherKeyConnectionId(key)
} satisfies FsChangedPayload)
sent = true
} catch (error) {
@@ -82,7 +82,8 @@ export function scheduleRemoteWatcherRetryCore(
)
listener.send('fs:changed', {
worktreePath,
events: [{ kind: 'overflow', absolutePath: worktreePath }]
events: [{ kind: 'overflow', absolutePath: worktreePath }],
connectionId
} satisfies FsChangedPayload)
}
// Why: overflow only refreshes once — without this the watch stays dead until the app restarts.
@@ -35,7 +35,8 @@ const WORKTREE_PATH = '/home/me/repo'
const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' }
const OVERFLOW_PAYLOAD = {
worktreePath: WORKTREE_PATH,
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }]
events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }],
connectionId: 'conn-1'
}
type MockSender = {
@@ -131,7 +132,7 @@ describe('remote filesystem watcher terminal retry resync', () => {
expect(senderTwo.send).toHaveBeenCalledTimes(1)
})
it('does not resync a fast retry for an initial setup failure', async () => {
it('resyncs once a retried initial setup installs', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const sender = createSender(1)
@@ -145,7 +146,9 @@ describe('remote filesystem watcher terminal retry resync', () => {
await vi.advanceTimersByTimeAsync(1_000)
expect(watchMock).toHaveBeenCalledTimes(2)
expect(sender.send).not.toHaveBeenCalled()
// Why: writes before the retry landed were never reported, so the renderer must re-read.
await vi.advanceTimersByTimeAsync(5_000)
expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD)
})
it('lets provider registration supersede a pending terminal retry', async () => {
+16 -8
View File
@@ -186,7 +186,8 @@ describe('registerFilesystemWatcherHandlers', () => {
await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }])
expect(sendMock).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ path: '/home/me/repo/file.txt', type: 'update' }]
events: [{ path: '/home/me/repo/file.txt', type: 'update' }],
connectionId: 'conn-1'
})
warnSpy.mockRestore()
handlers['fs:unwatchWorktree'](
@@ -282,7 +283,8 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(senderOne.send).not.toHaveBeenCalled()
expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }],
connectionId: 'conn-1'
})
staleTerminal(new Error('late stale failure'))
@@ -313,7 +315,8 @@ describe('registerFilesystemWatcherHandlers', () => {
await vi.waitFor(() =>
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }]
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
connectionId: 'conn-1'
})
)
@@ -321,7 +324,8 @@ describe('registerFilesystemWatcherHandlers', () => {
await emitRemote(reinstalledEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }],
connectionId: 'conn-1'
})
await closeAllWatchers()
@@ -370,7 +374,8 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(retryWatchMock).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }]
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
connectionId: 'conn-1'
})
warnSpy.mockRestore()
@@ -441,7 +446,8 @@ describe('registerFilesystemWatcherHandlers', () => {
for (const sender of [senderOne, senderTwo]) {
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }]
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
connectionId: 'conn-1'
})
}
@@ -570,13 +576,15 @@ describe('registerFilesystemWatcherHandlers', () => {
expect(watchMock).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }]
events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }],
connectionId: 'conn-1'
})
const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void
await emitRemote(replacementEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
expect(sender.send).toHaveBeenLastCalledWith('fs:changed', {
worktreePath: '/home/me/repo',
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]
events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }],
connectionId: 'conn-1'
})
})
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
import type { OpenFile } from '@/store/slices/editor'
import type { FsChangedPayload } from '../../../../../../shared/filesystem-entry-types'
import { useCombinedDiffSectionRevalidation } from './use-combined-diff-section-revalidation'
const review: OpenFile = {
id: 'combined',
worktreeId: 'wt',
filePath: '/repo',
relativePath: 'Changes',
language: 'plaintext',
isDirty: false,
mode: 'diff',
diffSource: 'combined-uncommitted'
}
function renderReview(): ReturnType<typeof vi.fn> {
const requestSectionReload = vi.fn()
renderHook(() =>
useCombinedDiffSectionRevalidation({
file: review,
gitStatusEntries: [],
// Why: index 3 is mid-read; that read may predate the missed write.
registry: {
loadedIndicesRef: { current: new Set([0, 2]) },
loadingIndicesRef: { current: new Set([2, 3]) }
},
requestSectionReload,
sectionIndexByKeyRef: { current: new Map() },
sectionEntries: [],
// Why: snapshot-backed reviews skip status-driven reloads, so only the catch-up can refresh them.
shouldAutoReloadFromGitStatus: false,
treeMode: 'uncommitted'
})
)
return requestSectionReload
}
function dispatch(payload: FsChangedPayload, runtimeEnvironmentId: string | null = null): void {
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: { payload, runtimeEnvironmentId }
})
)
})
}
afterEach(cleanup)
it('re-reads every loaded and in-flight section of a mounted review when its workspace catches up', () => {
const requestSectionReload = renderReview()
dispatch({ worktreePath: '/repo', events: [{ kind: 'overflow', absolutePath: '/repo' }] })
expect(requestSectionReload.mock.calls).toEqual([[0], [2], [3]])
})
it('ignores other workspaces, other hosts and ordinary path events', () => {
const requestSectionReload = renderReview()
dispatch({ worktreePath: '/other', events: [{ kind: 'overflow', absolutePath: '/other' }] })
dispatch(
{ worktreePath: '/repo', events: [{ kind: 'overflow', absolutePath: '/repo' }] },
'env-1'
)
dispatch({ worktreePath: '/repo', events: [{ kind: 'update', absolutePath: '/repo/a.ts' }] })
expect(requestSectionReload).not.toHaveBeenCalled()
})
@@ -5,6 +5,8 @@ import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
} from '../../editor-autosave'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
import { normalizeRuntimePathForComparison } from '../../../../../../shared/cross-platform-path'
import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature'
import {
getCombinedDiffFileTreeSectionKey,
@@ -26,7 +28,7 @@ export function useCombinedDiffSectionRevalidation({
}: {
file: OpenFile
gitStatusEntries: GitStatusEntry[]
registry: CombinedDiffSectionLoadRegistry
registry: Pick<CombinedDiffSectionLoadRegistry, 'loadedIndicesRef' | 'loadingIndicesRef'>
requestSectionReload: (index: number) => void
sectionIndexByKeyRef: React.RefObject<ReadonlyMap<string, number>>
// The entry set is structurally stable while individual section content/loading state changes.
@@ -35,7 +37,7 @@ export function useCombinedDiffSectionRevalidation({
shouldAutoReloadFromGitStatus: boolean
treeMode: CombinedDiffFileTreeMode
}): string {
const { loadedIndicesRef } = registry
const { loadedIndicesRef, loadingIndicesRef } = registry
const combinedGitStatusSignature = React.useMemo(() => {
if (!shouldAutoReloadFromGitStatus) {
return ''
@@ -105,5 +107,37 @@ export function useCombinedDiffSectionRevalidation({
treeMode
])
useEffect(() => {
if (treeMode !== 'all' && treeMode !== 'uncommitted') {
return
}
// Why: an overflow (or a switch-back catch-up) names no paths, and snapshot-backed reviews skip
// status-driven reloads, so re-read every loaded working-tree section of this workspace.
const handler = (event: WindowEventMap[typeof ORCA_WORKTREE_FILE_CHANGE_EVENT]): void => {
const { payload, runtimeEnvironmentId } = event.detail
if (
(runtimeEnvironmentId?.trim() || null) !== (file.runtimeEnvironmentId?.trim() || null) ||
normalizeRuntimePathForComparison(payload.worktreePath) !==
normalizeRuntimePathForComparison(file.filePath) ||
!payload.events.some((change) => change.kind === 'overflow')
) {
return
}
// Why: an in-flight read may have started before the missed write, so retry it too.
for (const index of new Set([...loadedIndicesRef.current, ...loadingIndicesRef.current])) {
requestSectionReload(index)
}
}
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handler)
return () => window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handler)
}, [
file.filePath,
file.runtimeEnvironmentId,
loadedIndicesRef,
loadingIndicesRef,
requestSectionReload,
treeMode
])
return combinedGitStatusSignature
}
@@ -139,14 +139,75 @@ export function rememberCombinedDiffView(
})
}
export function forgetCombinedDiffViewsShowingPath(relativePath: string): void {
// Why: a cached view may hold unsaved section edits; invalidation resets only clean, loaded sections so
// they reload on remount, and a dirty section's guarded save or conflict bar handles a disk change.
function invalidateCleanSections(
view: CachedCombinedDiffViewState,
isStale: (section: DiffSection) => boolean
): CachedCombinedDiffViewState {
let changed = false
const sections = view.sections.map((section) => {
if (
section.dirty ||
section.saveConflict ||
section.loading ||
section.loadOnDemand === true ||
!isStale(section)
) {
return section
}
changed = true
return {
...section,
originalContent: '',
modifiedContent: '',
loading: true,
error: undefined,
diffResult: null,
largeDiffRenderLimit: null,
contentGeneration: (section.contentGeneration ?? 0) + 1
}
})
if (!changed) {
return view
}
return {
...view,
sections,
loadedIndices: view.loadedIndices.filter((index) => sections[index] === view.sections[index])
}
}
function invalidateCachedViews(
matches: (memory: CombinedDiffViewMemory) => boolean,
isStale: (section: DiffSection) => boolean
): void {
for (const [viewKey, memory] of memoryByViewKey) {
if (memory.view?.sections.some((section) => section.path === relativePath)) {
memoryByViewKey.set(viewKey, { ...memory, view: undefined })
if (!memory.view || !matches(memory)) {
continue
}
const view = invalidateCleanSections(memory.view, isStale)
if (view !== memory.view) {
memoryByViewKey.set(viewKey, { ...memory, view })
}
}
}
export function forgetCombinedDiffViewsShowingPath(relativePath: string): void {
invalidateCachedViews(
() => true,
(section) => section.path === relativePath
)
}
/** An overflow names no paths, so every clean cached section of the workspace may be stale. */
export function forgetCombinedDiffViewBodiesForWorktree(worktreeId: string): void {
invalidateCachedViews(
(memory) => memory.worktreeId === worktreeId,
() => true
)
}
export function renameCombinedDiffViewOwner(oldWorktreeId: string, newWorktreeId: string): void {
for (const [viewKey, memory] of memoryByViewKey) {
if (memory.worktreeId === oldWorktreeId) {
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { FileContent } from './editor-panel-content-types'
const setExternalMutation = vi.fn()
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ setExternalMutation }) } }))
import {
applyEditorFileLoadFailure,
isPathSpecificMissingFileError
} from './editor-deleted-file-reload'
const FILE = '/repo/a.ts'
const tab: OpenFile = {
id: FILE,
worktreeId: 'wt',
filePath: FILE,
relativePath: 'a.ts',
language: 'typescript',
mode: 'edit',
isDirty: false,
lastKnownDiskSignature: 'sig'
}
const failure: FileContent = { content: '', isBinary: false, loadError: 'gone' }
// An older paired server has no existence probe; its read fails with the host's fs message.
const oldServerReadError = new Error(
"Error invoking runtime method 'files.read': ENOENT: no such file or directory, open '/repo/a.ts'"
)
function apply(error: unknown, previous: Record<string, FileContent>, file = tab) {
let contents = previous
applyEditorFileLoadFailure({
error,
fileId: FILE,
filePath: FILE,
tabs: [file],
failure,
setFileContents: (update) => {
contents = typeof update === 'function' ? update(contents) : update
}
})
return contents
}
describe('applyEditorFileLoadFailure', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the last content and marks the tab deleted when an old server reports the file gone on catch-up', () => {
const contents = apply(oldServerReadError, {
[FILE]: { content: 'last seen', isBinary: false, isStale: true }
})
expect(contents[FILE]).toEqual({ content: 'last seen', isBinary: false, isStale: false })
expect(setExternalMutation).toHaveBeenCalledWith(FILE, 'deleted')
})
it.each([
['a whole-request not-found', new Error('worktree not found')],
['another file', new Error("ENOENT: no such file or directory, open '/repo/b.ts'")],
['a connection failure', new Error('Remote connection dropped')]
])('shows the read error for %s', (_label, error) => {
const contents = apply(error, { [FILE]: { content: 'last seen', isBinary: false } })
expect(contents[FILE]).toBe(failure)
expect(setExternalMutation).not.toHaveBeenCalled()
})
it('never claims a tab that has not read the file yet or holds unsaved edits', () => {
expect(apply(oldServerReadError, {}, { ...tab, lastKnownDiskSignature: undefined })[FILE]).toBe(
failure
)
expect(apply(oldServerReadError, {}, { ...tab, isDirty: true })[FILE]).toBe(failure)
expect(setExternalMutation).not.toHaveBeenCalled()
})
it('matches the path regardless of separator spelling', () => {
expect(
isPathSpecificMissingFileError(
new Error("ENOENT: no such file or directory, open 'C:\\repo\\a.ts'"),
'C:/repo/a.ts'
)
).toBe(true)
})
})
@@ -0,0 +1,54 @@
import type { Dispatch, SetStateAction } from 'react'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import type { FileContent } from './editor-panel-content-types'
// Node's fs message names the syscall and the path; IPC and runtime RPC both carry it verbatim.
const MISSING_FILE_MESSAGE = /ENOENT: no such file or directory, \w+ '([^']+)'/
/** True only when the failure names this exact file as missing — never a worktree or host lookup. */
export function isPathSpecificMissingFileError(error: unknown, filePath: string): boolean {
const message = error instanceof Error ? error.message : String(error)
const missingPath = MISSING_FILE_MESSAGE.exec(message)?.[1]
return (
missingPath !== undefined &&
normalizeRuntimePathForComparison(missingPath) === normalizeRuntimePathForComparison(filePath)
)
}
/**
* Shows a failed read. A reload of a tab that already read the file (catch-up, watch event, stale reveal) found it gone:
* mark the tab deleted as a watch event would, and keep its last content instead of an empty error.
* Works without any existence probe, so hosts that predate one are covered too.
*/
export function applyEditorFileLoadFailure(args: {
error: unknown
fileId: string
filePath: string
tabs: readonly OpenFile[]
failure: FileContent
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>
}): void {
const tab = args.tabs.find((file) => file.id === args.fileId)
if (
!tab ||
tab.mode !== 'edit' ||
tab.isDirty ||
!tab.lastKnownDiskSignature ||
!isPathSpecificMissingFileError(args.error, args.filePath)
) {
args.setFileContents((prev) => ({ ...prev, [args.fileId]: args.failure }))
return
}
if (tab.externalMutation !== 'deleted' && tab.externalMutation !== 'renamed') {
useAppStore.getState().setExternalMutation(tab.id, 'deleted')
}
args.setFileContents((prev) => {
const existing = prev[args.fileId]
if (!existing || existing.loadError !== undefined) {
return { ...prev, [args.fileId]: args.failure }
}
return existing.isStale ? { ...prev, [args.fileId]: { ...existing, isStale: false } } : prev
})
}
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
readRuntimeFileContent: vi.fn(),
runtimePathsExist: vi.fn()
}))
vi.mock('@/runtime/runtime-file-read-client', () => ({
readRuntimeFileContent: mocks.readRuntimeFileContent
}))
vi.mock('@/runtime/runtime-path-existence-batch', () => ({
runtimePathsExist: mocks.runtimePathsExist
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undefined }))
vi.mock('@/lib/file-owner-runtime-target', () => {
const owner = { kind: 'environment', environmentId: 'env-1' }
return {
getRuntimeTargetForFileOwner: () => owner,
requireRuntimeTargetForFileOwner: () => owner
}
})
import { createTestStore } from '@/store/slices/store-test-helpers'
import {
attachRestoredTabConflictScan,
MAX_VERIFY_ATTEMPTS,
requestRestoredTabReverification
} from './editor-restored-tab-conflict-scan'
import { getDiskBaselineSignature } from './diff-content-signature'
const FILE = '/repo/a.ts'
let store: ReturnType<typeof createTestStore>
let detach: () => void
const tab = () => store.getState().openFiles.find((file) => file.id === FILE)!
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
store = createTestStore()
store.getState().openFile({
filePath: FILE,
relativePath: 'a.ts',
worktreeId: 'wt-1',
runtimeEnvironmentId: 'env-1',
language: 'typescript',
mode: 'edit'
})
store.getState().setEditorDraft(FILE, 'unsaved')
store.getState().markFileDirty(FILE, true)
store.getState().setLastKnownDiskSignature(FILE, getDiskBaselineSignature('v1'))
})
afterEach(() => {
detach()
vi.useRealTimers()
})
it('reads once more when a new check is requested while a read is in flight', async () => {
const replies: ((content: string) => void)[] = []
mocks.readRuntimeFileContent.mockImplementation(
() => new Promise((resolve) => replies.push((content) => resolve({ content, isBinary: false })))
)
store.getState().setPendingDiskBaselineVerification(FILE, true)
detach = attachRestoredTabConflictScan(store)
await vi.advanceTimersByTimeAsync(0)
expect(replies).toHaveLength(1)
// A second catch-up lands mid-read, after an external write.
requestRestoredTabReverification(FILE)
requestRestoredTabReverification(FILE)
replies[0]('v1')
await vi.advanceTimersByTimeAsync(0)
expect(tab().pendingDiskBaselineVerification).toBe(true)
// Coalesced: two requests mid-read still cost one more read.
expect(replies).toHaveLength(2)
replies[1]('v2 external write')
await vi.advanceTimersByTimeAsync(0)
expect(tab().externalMutation).toBe('changed')
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
})
it('marks a dirty runtime-owned tab deleted when its host says the file is gone', async () => {
mocks.readRuntimeFileContent.mockRejectedValue(new Error('read failed'))
mocks.runtimePathsExist.mockResolvedValue([{ exists: false }])
store.getState().setPendingDiskBaselineVerification(FILE, true)
detach = attachRestoredTabConflictScan(store)
await vi.advanceTimersByTimeAsync(0)
expect(mocks.runtimePathsExist).toHaveBeenCalledWith(
expect.objectContaining({ worktreePath: '/repo' }),
[FILE],
undefined,
{ codedAbsenceOnly: true }
)
expect(tab().externalMutation).toBe('deleted')
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
})
it('stops retrying and shows the conflict banner when the disk can never be checked', async () => {
mocks.readRuntimeFileContent.mockRejectedValue(new Error('host unreachable'))
mocks.runtimePathsExist.mockResolvedValue([{ error: 'unverifiable' }])
store.getState().setPendingDiskBaselineVerification(FILE, true)
detach = attachRestoredTabConflictScan(store)
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(MAX_VERIFY_ATTEMPTS)
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
expect(tab().externalMutation).toBe('changed')
})
it('does not let an absence answer from before a newer check resolve that check', async () => {
let answerProbe: (rows: unknown) => void = () => {}
mocks.readRuntimeFileContent.mockRejectedValueOnce(new Error('read failed'))
mocks.runtimePathsExist.mockImplementationOnce(
() => new Promise((resolve) => (answerProbe = resolve))
)
store.getState().setPendingDiskBaselineVerification(FILE, true)
detach = attachRestoredTabConflictScan(store)
await vi.advanceTimersByTimeAsync(0)
// The file comes back and a new catch-up asks again while the old probe is pending.
requestRestoredTabReverification(FILE)
mocks.readRuntimeFileContent.mockResolvedValueOnce({ content: 'v2 recreated', isBinary: false })
answerProbe([{ exists: false }])
await vi.advanceTimersByTimeAsync(0)
expect(tab().externalMutation).toBe('changed')
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
})
@@ -12,6 +12,7 @@ import {
import { canAutoSaveOpenFile } from './editor-autosave'
import { getDiskBaselineSignature } from './diff-content-signature'
import { markFileChangedOnDisk } from './editor-changed-on-disk-mark'
import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch'
type AppStoreApi = Pick<StoreApi<AppState>, 'getState' | 'subscribe'>
@@ -21,6 +22,23 @@ const VERIFY_SLOW_RETRY_MS = 15_000
const VERIFY_FAST_ATTEMPTS = 30
// Cap concurrent reads: many restored dirty tabs would otherwise fire N concurrent 15s RPCs competing with startup connection recovery.
const MAX_CONCURRENT_VERIFY_READS = 3
// Why: an unreachable host must not suspend autosave forever; give up with the conflict banner instead.
export const MAX_VERIFY_ATTEMPTS = VERIFY_FAST_ATTEMPTS + 10
// Why: a disk check requested while one is in flight must not be answered by the older read.
const verifyRequestGenerationByFileId = new Map<string, number>()
/** Asks for a fresh disk check of a tab; a read already in flight is followed by one more. */
export function requestRestoredTabReverification(fileId: string): void {
verifyRequestGenerationByFileId.set(
fileId,
(verifyRequestGenerationByFileId.get(fileId) ?? 0) + 1
)
}
function verifyRequestGeneration(fileId: string): number {
return verifyRequestGenerationByFileId.get(fileId) ?? 0
}
export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
// Dedupes queued + in-flight verifications; the store's pending flag is the durable "needs verification" signal.
@@ -48,10 +66,33 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
file.worktreeId,
file.runtimeEnvironmentId
)
// Why `!owner`: with no single owner, a client stat cannot prove the file gone.
if (owner?.kind !== 'local') {
// Why `!owner`: with no single owner, nothing can prove the file gone.
if (!owner) {
return false
}
if (owner.kind !== 'local') {
try {
const [row] = await runtimePathsExist(
{
target: owner,
worktreeId: file.worktreeId,
// Why: tabs keep their root implicitly as filePath minus relativePath.
worktreePath: file.filePath.slice(
0,
Math.max(0, file.filePath.length - file.relativePath.length - 1)
),
connectionId: getFileConnectionId(file)
},
[file.filePath],
undefined,
// Why: only a per-path ENOENT on the owning host proves absence.
{ codedAbsenceOnly: true }
)
return row !== undefined && 'exists' in row && !row.exists
} catch {
return false
}
}
try {
const connectionId = getFileConnectionId(file)
const access = connectionId ? undefined : editorTabFileAccess(store.getState(), file)
@@ -67,9 +108,22 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
}
}
// Why: a newer check was requested mid-read; this answer may predate it, so keep the flag and read once more.
const rereadIfSuperseded = (fileId: string, requestGeneration: number): boolean => {
if (verifyRequestGeneration(fileId) === requestGeneration) {
return false
}
queueMicrotask(() => {
inFlightFileIds.delete(fileId)
scan()
})
return true
}
const verify = async (file: OpenFile): Promise<void> => {
// ids are file paths: a stray leftover marker would skip a reopened same-path tab, so only a scheduled retry keeps it set.
let retryScheduled = false
const requestGeneration = verifyRequestGeneration(file.id)
try {
const state = store.getState()
const result = await readRuntimeFileContent({
@@ -88,6 +142,11 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
if (!liveFile) {
return
}
if (rereadIfSuperseded(file.id, requestGeneration)) {
retryScheduled = true
return
}
attemptsByFileId.delete(file.id)
// Verification resolved: lift autosave suspension regardless of outcome; wasPending flags a save that already re-baselined.
const wasPending = liveFile.pendingDiskBaselineVerification === true
store.getState().clearPendingDiskBaselineVerification(file.id)
@@ -113,6 +172,10 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
if (disposed) {
return
}
if (rereadIfSuperseded(file.id, requestGeneration)) {
retryScheduled = true
return
}
// Definitive not-found = resolved: no newer disk content for a save to clobber, so mark deleted instead of retrying forever.
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
if (!liveFile) {
@@ -130,6 +193,10 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
}
const attempts = (attemptsByFileId.get(file.id) ?? 0) + 1
attemptsByFileId.set(file.id, attempts)
if (attempts >= MAX_VERIFY_ATTEMPTS) {
giveUp(file)
return
}
retryScheduled = true
const timer = setTimeout(
() => {
@@ -147,6 +214,22 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
}
}
// Why: still unverifiable after every retry; resume nothing silently — the banner makes the user choose.
const giveUp = (file: OpenFile): void => {
attemptsByFileId.delete(file.id)
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
if (!liveFile?.pendingDiskBaselineVerification) {
return
}
store.getState().clearPendingDiskBaselineVerification(file.id)
if (liveFile.isDirty && liveFile.externalMutation !== 'changed') {
markFileChangedOnDisk(store.getState(), liveFile, {
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
origin: 'restore'
})
}
}
const pumpVerifyQueue = (): void => {
while (!disposed && activeVerifyReads < MAX_CONCURRENT_VERIFY_READS && verifyQueue.length > 0) {
const fileId = verifyQueue.shift()!
@@ -27,6 +27,7 @@ import { editorTabFileAccess, isClientLocalReadOnlyTab } from '@/lib/local-file-
import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree'
import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address'
import { stampCleanTabDiskBaseline } from './editor-clean-tab-disk-baseline'
import { applyEditorFileLoadFailure } from './editor-deleted-file-reload'
const inFlightFileReads = new Map<string, InFlightContentRead<FileContent>>()
@@ -264,15 +265,20 @@ export function useEditorPanelFileContentLoader({
// classifiers downstream must see the token, not only its rendering (#21041).
const loadErrorCode =
!hostConnecting && err instanceof RuntimeRpcCallError ? err.code : undefined
setFileContents((prev) => ({
...prev,
[id]: {
content: '',
isBinary: false,
loadError: message,
...(loadErrorCode ? { loadErrorCode } : {})
}
}))
const failure: FileContent = {
content: '',
isBinary: false,
loadError: message,
...(loadErrorCode ? { loadErrorCode } : {})
}
applyEditorFileLoadFailure({
error: err,
fileId: id,
filePath,
tabs: openFilesRef.current,
failure,
setFileContents
})
} finally {
if (outstandingFileReadsRef.current[id] === generation) {
delete outstandingFileReadsRef.current[id]
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
markWorktreeCatchUpPayload,
ORCA_WORKTREE_FILE_CHANGE_EVENT
} from '@/hooks/worktree-file-change-event'
import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types'
import { useFileExplorerWatch } from './useFileExplorerWatch'
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
getState: () => ({ openFiles: [] })
})
}))
vi.mock('./file-explorer-operation-owner', () => {
const owner = { kind: 'local', executionHostId: 'local' }
return {
getFileExplorerOperationOwner: () => owner,
getFileExplorerOperationOwnerFromState: () => owner
}
})
const refreshDir = vi.fn(async () => {})
const refreshTree = vi.fn(async () => 'refreshed' as const)
const ROOT = '/repo'
function dispatch(payload: FsChangedPayload, runtimeEnvironmentId: string | null = null): void {
window.dispatchEvent(
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { detail: { payload, runtimeEnvironmentId } })
)
}
const overflow = (): FsChangedPayload => ({
worktreePath: ROOT,
events: [{ kind: 'overflow', absolutePath: ROOT }]
})
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
Object.defineProperty(window, 'api', {
configurable: true,
value: { fs: { onFsChanged: vi.fn(() => vi.fn()) } }
})
renderHook(() =>
useFileExplorerWatch({
worktreePath: ROOT,
activeWorktreeId: 'wt',
dirCache: { [ROOT]: { children: [] } },
setDirCache: vi.fn(),
expanded: new Set(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree,
inlineInput: null,
dragSourcePath: null,
isNativeDragOver: false
})
)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('refreshes a local Files tree on the catch-up sent when its workspace is watched again', async () => {
act(() => dispatch(markWorktreeCatchUpPayload(overflow())))
await act(async () => vi.advanceTimersByTimeAsync(0))
expect(refreshTree).toHaveBeenCalledTimes(1)
})
it('leaves ordinary bus events to the IPC listener so nothing is processed twice', async () => {
act(() => dispatch(overflow()))
act(() => dispatch(markWorktreeCatchUpPayload(overflow()), 'env-1'))
await act(async () => vi.advanceTimersByTimeAsync(0))
expect(refreshTree).not.toHaveBeenCalled()
})
@@ -64,7 +64,7 @@ it('refreshes the selected host Explorer when another host publishes the same wo
})
const [target] = targets
const adapter = buildEditorExternalWatchEventHandler((worktreePath, runtimeEnvironmentId) =>
targets.find(
targets.filter(
(candidate) =>
candidate.worktreePath === worktreePath &&
candidate.runtimeEnvironmentId === runtimeEnvironmentId
@@ -7,7 +7,10 @@ import type {
} from './file-explorer-types'
import type { InlineInput } from './file-explorer-inline-input-row'
import { useAppStore } from '@/store'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
import {
isWorktreeCatchUpPayload,
ORCA_WORKTREE_FILE_CHANGE_EVENT
} from '@/hooks/worktree-file-change-event'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import {
getFileExplorerOperationOwner,
@@ -237,17 +240,28 @@ export function useFileExplorerWatch({
handleFsChanged(event.detail.payload)
}
}
// Why: IPC-bus trees otherwise miss the renderer-synthesized catch-up after a workspace is watched again.
const handleCatchUp = (event: WindowEventMap[typeof ORCA_WORKTREE_FILE_CHANGE_EVENT]): void => {
if (
event.detail.runtimeEnvironmentId === null &&
isWorktreeCatchUpPayload(event.detail.payload)
) {
handleFsChanged(event.detail.payload)
}
}
let unsubscribeListener: (() => void) | null = null
if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) {
// The app watcher owns recovery; a separate stream would strand this consumer after reconnect.
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleRuntimeChange)
} else {
unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged)
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleCatchUp)
}
return () => {
disposed = true
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleRuntimeChange)
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleCatchUp)
unsubscribeListener?.()
if (activeResyncByWatchKey.get(currentWatchKey) === scheduler.requestFullRefresh) {
activeResyncByWatchKey.delete(currentWatchKey)
@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest'
import {
selectEditorExternalWatchTargets,
type EditorExternalWatchTargetState
} from './editor-external-watch-targets'
import {
PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT,
WARM_RECURSIVE_WATCH_WORKSPACE_COUNT
} from './editor-external-watch-scope'
import { getWorktreeVisitKey } from '@/lib/worktree-visit-recency'
import { getDefaultSettings } from '../../../shared/constants'
import {
makeFolderWorkspace,
makeWorktree,
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
type Repo = EditorExternalWatchTargetState['repos'][number]
type Worktree = EditorExternalWatchTargetState['worktreesByRepo'][string][number]
type OpenFile = EditorExternalWatchTargetState['openFiles'][number]
type FolderWorkspace = EditorExternalWatchTargetState['folderWorkspaces'][number]
const repo = (id: string, connectionId: string | null = null): Repo => ({
...TEST_REPO,
id,
path: `/${id}`,
kind: 'git',
connectionId
})
const worktree = (repoId: string, id: string, path = `/${repoId}/${id}`): Worktree =>
makeWorktree({ id, repoId, path })
const tab = (worktreeId: string, extra: Partial<OpenFile> = {}): OpenFile => ({
id: `${worktreeId}-file`,
worktreeId,
filePath: `/x/${worktreeId}/a.ts`,
relativePath: 'a.ts',
language: 'typescript',
mode: 'edit',
isDirty: false,
...extra
})
function makeState(args: {
worktrees: Worktree[]
repos?: Repo[]
openFiles: OpenFile[]
activeWorktreeId?: string | null
visits?: Record<string, number>
folderWorkspaces?: FolderWorkspace[]
}): EditorExternalWatchTargetState {
const worktreesByRepo: EditorExternalWatchTargetState['worktreesByRepo'] = {}
for (const wt of args.worktrees) {
;(worktreesByRepo[wt.repoId] ??= []).push(wt)
}
return {
openFiles: args.openFiles,
worktreesByRepo,
repos: args.repos ?? [repo('r')],
activeWorktreeId: args.activeWorktreeId ?? null,
lastVisitedAtByWorktreeId: args.visits ?? {},
rightSidebarOpen: false,
rightSidebarTab: 'explorer',
rightSidebarExplorerView: 'files',
gitStatusHugeByWorktree: {},
sshConnectionStates: new Map(),
folderWorkspaces: args.folderWorkspaces ?? [],
projectGroups: [],
settings: getDefaultSettings('/tmp')
}
}
const watchedIds = (state: EditorExternalWatchTargetState): string[] =>
selectEditorExternalWatchTargets(state).targets.map((target) => target.worktreeId)
describe('background workspace watch trim', () => {
const ids = ['wt0', 'wt1', 'wt2', 'wt3', 'wt4']
const worktrees = ids.map((id) => worktree('r', id))
it('watches the active workspace and drops clean background ones', () => {
const state = makeState({
worktrees,
openFiles: [tab('wt0'), tab('wt1')],
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual(['wt0'])
expect([...selectEditorExternalWatchTargets(state).unwatchedTargetKeys]).toEqual([
'wt1::/r/wt1::local::client::literal'
])
})
it(`keeps the ${WARM_RECURSIVE_WATCH_WORKSPACE_COUNT} most recently visited workspaces and evicts older ones`, () => {
const state = makeState({
worktrees,
openFiles: ids.map((id) => tab(id)),
activeWorktreeId: 'wt0',
// Why: mixes host-qualified and legacy bare keys, as persisted sessions do.
visits: {
wt0: 50,
[getWorktreeVisitKey('wt1', 'local')]: 1,
wt2: 2,
[getWorktreeVisitKey('wt3', 'local')]: 3,
wt4: 4
}
})
expect(watchedIds(state)).toEqual(['wt0', 'wt2', 'wt3', 'wt4'])
})
it('does not let a deleted workspace hold a warm slot', () => {
const state = makeState({
worktrees,
openFiles: ids.map((id) => tab(id)),
activeWorktreeId: 'wt0',
visits: { gone: 100, 'also-gone': 99, wt1: 1, wt2: 2, wt3: 3 }
})
expect(watchedIds(state)).toEqual(['wt0', 'wt1', 'wt2', 'wt3'])
})
it('releases a background workspace even with unsaved, combined diff or rich edit tabs', () => {
// Why: every editor save is disk-checked by the host now, so no edit surface needs a live watch.
const state = makeState({
worktrees,
openFiles: [
tab('wt0'),
tab('wt1', { isDirty: true }),
tab('wt2', { id: 'combined', mode: 'diff', diffSource: 'combined-uncommitted' }),
tab('wt3', { id: 'md', language: 'markdown' }),
tab('wt4', { id: 'csv', language: 'csv' })
],
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual(['wt0'])
})
it.each(['deleted', 'renamed'] as const)(
'keeps a background workspace with a clean %s tab watched',
(externalMutation) => {
const state = makeState({
worktrees,
openFiles: [tab('wt0'), tab('wt1', { externalMutation }), tab('wt2')],
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual(['wt0', 'wt1'])
}
)
it(`keeps at most ${PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT} deleted/renamed-tab workspaces beyond the recent ones, evicting the least recent`, () => {
const pinnedIds = ['p1', 'p2', 'p3', 'p4', 'p5']
const state = makeState({
worktrees: [...worktrees, ...pinnedIds.map((id) => worktree('r', id))],
openFiles: [
tab('wt0'),
tab('wt1'),
tab('wt2'),
tab('wt3'),
...pinnedIds.map((id) => tab(id, { externalMutation: 'deleted' as const }))
],
activeWorktreeId: 'wt0',
visits: { wt1: 100, wt2: 99, wt3: 98, p1: 1, p2: 2, p3: 3, p4: 4 }
})
expect(watchedIds(state)).toEqual(['p2', 'p3', 'p4', 'wt0', 'wt1', 'wt2', 'wt3'])
// Why: an evicted pin is released like any other workspace, so switching back still catches it up.
expect([...selectEditorExternalWatchTargets(state).unwatchedTargetKeys].sort()).toEqual([
'p1::/r/p1::local::client::literal',
'p5::/r/p5::local::client::literal'
])
})
it('never watches for a virtual tab', () => {
const state = makeState({
worktrees,
openFiles: [tab('wt0', { mode: 'check-details' })],
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual([])
})
it('recomputes when the visit map changes', () => {
const state = makeState({
worktrees,
openFiles: [tab('wt0'), tab('wt1')],
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual(['wt0'])
expect(watchedIds({ ...state, lastVisitedAtByWorktreeId: { wt1: 1 } })).toEqual(['wt0', 'wt1'])
})
it('applies the same policy to SSH, runtime-owned and WSL workspaces', () => {
const sshRepo = repo('ssh-repo', 'ssh-1')
const sshWorktree = worktree('ssh-repo', 'wt-ssh', '/remote/wt-ssh')
const wslWorktree = worktree('r', 'wt-wsl', '\\\\wsl.localhost\\Ubuntu\\home\\me\\wt')
const openFiles = [
tab('wt0'),
tab('wt-ssh'),
tab('wt1', { id: 'runtime-tab', runtimeEnvironmentId: 'env-1' }),
tab('wt-wsl')
]
const state = makeState({
worktrees: [...worktrees, sshWorktree, wslWorktree],
repos: [repo('r'), sshRepo],
openFiles,
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual(['wt0'])
const recent = {
...state,
lastVisitedAtByWorktreeId: { 'wt-ssh': 3, wt1: 2, 'wt-wsl': 1 }
}
expect(selectEditorExternalWatchTargets(recent).targets).toEqual(
expect.arrayContaining([
expect.objectContaining({ worktreeId: 'wt-ssh', connectionId: 'ssh-1' }),
expect.objectContaining({ worktreeId: 'wt1', runtimeEnvironmentId: 'env-1' }),
expect.objectContaining({ worktreeId: 'wt-wsl' })
])
)
})
it('treats folder workspaces like worktrees', () => {
const folder = (id: string): FolderWorkspace =>
makeFolderWorkspace({
id,
projectGroupId: 'g',
folderPath: `/folders/${id}`,
executionHostId: 'local'
})
const state = makeState({
worktrees: [],
folderWorkspaces: [folder('a'), folder('b')],
openFiles: [tab('folder:a'), tab('folder:b', { id: 'b' })],
activeWorktreeId: 'folder:a'
})
expect(watchedIds(state)).toEqual(['folder:a'])
expect(watchedIds({ ...state, activeWorktreeId: 'folder:b' })).toEqual(['folder:b'])
expect(watchedIds({ ...state, lastVisitedAtByWorktreeId: { 'folder:b': 1 } })).toEqual([
'folder:a',
'folder:b'
])
})
})
@@ -1,10 +1,7 @@
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { basename } from '@/lib/path'
import {
canAutoSaveOpenFile,
isExternalReloadableEditorTab
} from '@/components/editor/editor-autosave'
import { canAutoSaveOpenFile } from '@/components/editor/editor-autosave'
import { indexEditorExternalWatchBatchPaths } from '@/components/editor/editor-external-watch-path-index'
import { getRecentSelfWrite } from '@/components/editor/editor-self-write-registry'
import {
@@ -29,8 +26,18 @@ import {
scheduleSelfWriteAwareEditorExternalReload,
type EditorExternalWatchNotification
} from './editor-external-watch-disk-verification'
import {
reconcileEditorTabsAfterOverflow,
supersedeOverflowProbesForBatch
} from './editor-external-watch-overflow'
import { WATCH_BATCH_INTERVAL_MS } from '../../../shared/filesystem-watch-batch-window'
function isTargetList(
found: EditorExternalWatchTarget | readonly EditorExternalWatchTarget[] | undefined
): found is readonly EditorExternalWatchTarget[] {
return Array.isArray(found)
}
// Why: macOS atomic writes split delete→create across payloads; debounce deletion so a same-path create cancels the tombstone before it paints.
// Under sustained writes the halves land one batch interval apart plus stat jitter (WSL/9P, AV scans), so allow two intervals.
export const EXTERNAL_MUTATION_DEBOUNCE_MS = WATCH_BATCH_INTERVAL_MS * 2 + 50
@@ -41,12 +48,18 @@ type PendingDeleteTimer = {
}
export function buildEditorExternalWatchEventHandler(
findTarget: (
// Why: several workspaces can share one root (two folder workspaces on a directory); each reconciles its own tabs.
findTargets: (
worktreePath: string,
runtimeEnvironmentId: string | null
) => EditorExternalWatchTarget | undefined
runtimeEnvironmentId: string | null,
connectionId: string | undefined
) => EditorExternalWatchTarget | readonly EditorExternalWatchTarget[] | undefined
): {
handleFsChanged: (payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void
handleFsChanged: (
payload: FsChangedPayload,
runtimeEnvironmentId?: string | null,
onlyTarget?: EditorExternalWatchTarget
) => void
dispose: () => void
} {
const pendingDeletes = new Map<string, PendingDeleteTimer>()
@@ -58,21 +71,30 @@ export function buildEditorExternalWatchEventHandler(
const handleFsChanged = (
payload: FsChangedPayload,
runtimeEnvironmentId: string | null = null
runtimeEnvironmentId: string | null = null,
onlyTarget?: EditorExternalWatchTarget
): void => {
const target = findTarget(payload.worktreePath, runtimeEnvironmentId)
if (!target) {
// Why: a re-watch resync is about one workspace; fanning it out would clear another's marks and reload its tabs.
const found =
onlyTarget ?? findTargets(payload.worktreePath, runtimeEnvironmentId, payload.connectionId)
const targets = isTargetList(found) ? found : found ? [found] : []
if (targets.length === 0) {
return
}
// Why: this app-level hook owns watcher subscriptions; other consumers listen here so they don't fight over watch/unwatch ownership.
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(
new CustomEvent<WorktreeFileChangeEventDetail>(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: { payload, runtimeEnvironmentId: target.runtimeEnvironmentId }
detail: { payload, runtimeEnvironmentId: targets[0].runtimeEnvironmentId }
})
)
}
for (const target of targets) {
reconcileTarget(payload, target)
}
}
const reconcileTarget = (payload: FsChangedPayload, target: EditorExternalWatchTarget): void => {
// Why: one batch index keeps local WSL alias normalization out of event×tab loops.
const openFilesAtStart = useAppStore.getState().openFiles
const batchPaths = indexEditorExternalWatchBatchPaths(payload, openFilesAtStart, {
@@ -81,6 +103,7 @@ export function buildEditorExternalWatchEventHandler(
runtimeEnvironmentId: target.runtimeEnvironmentId,
...getLocalWindowsWslAliasOption(target)
})
supersedeOverflowProbesForBatch(batchPaths, openFilesAtStart)
const createOrUpdatePaths = batchPaths.createOrUpdatePaths
for (const createdPath of createOrUpdatePaths.keys()) {
const key = pendingKey(target.worktreeId, target.runtimeEnvironmentId, createdPath)
@@ -154,10 +177,7 @@ export function buildEditorExternalWatchEventHandler(
}
if (payload.events.some((event) => event.kind === 'overflow')) {
// Why: overflow omits paths, so reload clean tabs and clear tombstones that may have been resurrected during the overrun.
for (const notification of collectOverflowEditorExternalReloadTargets(target)) {
scheduleDebouncedEditorExternalReload(notification)
}
reconcileEditorTabsAfterOverflow(target)
return
}
if (batchPaths.changes.length === 0) {
@@ -238,40 +258,6 @@ export function buildEditorExternalWatchEventHandler(
return { handleFsChanged, dispose }
}
export function collectOverflowEditorExternalReloadTargets(
target: Pick<EditorExternalWatchTarget, 'worktreeId' | 'worktreePath'> &
Partial<
Pick<
EditorExternalWatchTarget,
'connectionId' | 'runtimeEnvironmentId' | 'allowLocalWindowsWslAliases'
>
>
): EditorExternalWatchNotification[] {
const state = useAppStore.getState()
const notifications: EditorExternalWatchNotification[] = []
for (const file of state.openFiles) {
if (
file.worktreeId !== target.worktreeId ||
getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) ||
!isExternalReloadableEditorTab(file) ||
file.isDirty
) {
continue
}
if (file.externalMutation) {
state.setExternalMutation(file.id, null)
}
notifications.push({
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
relativePath: file.relativePath,
runtimeEnvironmentId: target.runtimeEnvironmentId ?? null,
...getLocalWindowsWslAliasOption(target)
})
}
return notifications
}
function hasRenameCorrelatedCreate(
payload: FsChangedPayload,
worktreeId: string,
@@ -0,0 +1,75 @@
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import type { EditorExternalWatchTarget } from './editor-external-watch-targets'
type WatchRootArgs = { worktreePath: string; connectionId: string | undefined }
type HeldWatchRoot = {
args: WatchRootArgs
consumers: number
installed: Promise<void>
failed: boolean
}
/** Local and SSH watch roots held by this renderer, counted per physical root. */
export type HeldWatchRoots = Map<string, HeldWatchRoot>
// Why: main keeps one listener per renderer and root, so two workspace ids on one directory must share one watch.
function getWatchRootKey(target: WatchRootArgs): string {
return `${target.connectionId ?? 'local'}::${normalizeRuntimePathForComparison(target.worktreePath)}`
}
/** Resolves once the root's watch request has settled, including when another consumer started it. */
export function acquireWatchRoot(
roots: HeldWatchRoots,
target: EditorExternalWatchTarget,
onError: (error: unknown) => void
): Promise<void> {
const key = getWatchRootKey(target)
const held = roots.get(key)
if (held) {
held.consumers += 1
// Why: a failed request is not a watch; the next workspace to need this root retries it.
if (held.failed) {
requestWatch(held, onError)
}
return held.installed
}
const root: HeldWatchRoot = {
args: { worktreePath: target.worktreePath, connectionId: target.connectionId },
consumers: 1,
installed: Promise.resolve(),
failed: false
}
roots.set(key, root)
requestWatch(root, onError)
return root.installed
}
function requestWatch(root: HeldWatchRoot, onError: (error: unknown) => void): void {
root.failed = false
root.installed = window.api.fs.watchWorktree(root.args).catch((error: unknown) => {
root.failed = true
onError(error)
})
}
export function releaseWatchRoot(roots: HeldWatchRoots, target: EditorExternalWatchTarget): void {
const key = getWatchRootKey(target)
const held = roots.get(key)
if (!held) {
return
}
held.consumers -= 1
if (held.consumers > 0) {
return
}
roots.delete(key)
void window.api.fs.unwatchWorktree(held.args)
}
export function releaseAllWatchRoots(roots: HeldWatchRoots): void {
for (const held of roots.values()) {
void window.api.fs.unwatchWorktree(held.args)
}
roots.clear()
}
@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const harness = vi.hoisted(() => {
const state: Record<string, unknown> = { openFiles: [] }
return {
state,
runtimePathsExist: vi.fn(),
scheduleReload: vi.fn(),
forgetCombinedBodies: vi.fn()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => harness.state } }))
vi.mock('@/runtime/runtime-path-existence-batch', () => ({
runtimePathsExist: harness.runtimePathsExist
}))
vi.mock('@/lib/file-owner-runtime-target', () => ({
getRuntimeTargetForFileOwner: () => ({ kind: 'local' })
}))
vi.mock('./editor-external-watch-disk-verification', () => ({
scheduleDebouncedEditorExternalReload: harness.scheduleReload
}))
vi.mock('@/components/editor/combined-diff/remember-view/combined-diff-view-cache', () => ({
forgetCombinedDiffViewBodiesForWorktree: harness.forgetCombinedBodies
}))
import type { OpenFile } from '@/store/slices/editor'
import {
reconcileEditorTabsAfterOverflow,
supersedeOverflowProbesForBatch
} from './editor-external-watch-overflow'
const setExternalMutation = vi.fn()
const setPendingDiskBaselineVerification = vi.fn()
function tab(id: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
return {
id,
worktreeId: 'wt-1',
filePath: `/repo/${id}`,
relativePath: id,
mode: 'edit',
isDirty: false,
...extra
}
}
function setTabs(...openFiles: Record<string, unknown>[]): void {
harness.state = { openFiles, setExternalMutation, setPendingDiskBaselineVerification }
}
const target = { worktreeId: 'wt-1', worktreePath: '/repo' }
const notification = (relativePath: string, runtimeEnvironmentId: string | null = null) => ({
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath,
runtimeEnvironmentId
})
describe('reconcileEditorTabsAfterOverflow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reloads clean tabs that still exist and clears stale tombstones', async () => {
setTabs(
tab('a.md', { externalMutation: 'deleted' }),
tab('staged.ts', { mode: 'diff', diffSource: 'staged' })
)
harness.runtimePathsExist.mockResolvedValue([{ exists: true }])
reconcileEditorTabsAfterOverflow(target)
await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(2))
expect(harness.scheduleReload).toHaveBeenCalledWith(notification('staged.ts'))
expect(harness.scheduleReload).toHaveBeenCalledWith(notification('a.md'))
expect(setExternalMutation).toHaveBeenCalledWith('a.md', null)
expect(harness.forgetCombinedBodies).toHaveBeenCalledWith('wt-1')
})
it('marks a tab deleted, keeping its content, when its file is gone on the owning host', async () => {
setTabs(tab('gone.ts'), tab('moved.ts', { externalMutation: 'renamed' }), tab('here.ts'))
harness.runtimePathsExist.mockResolvedValue([
{ exists: false },
{ exists: false },
{ exists: true }
])
reconcileEditorTabsAfterOverflow(target)
await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(1))
expect(harness.scheduleReload).toHaveBeenCalledWith(notification('here.ts'))
expect(setExternalMutation).toHaveBeenCalledWith('gone.ts', 'deleted')
// Why: a rename mark is kept as is rather than downgraded or cleared.
expect(setExternalMutation).not.toHaveBeenCalledWith('moved.ts', expect.anything())
})
it.each([
['the probe fails', () => harness.runtimePathsExist.mockRejectedValue(new Error('offline'))],
[
'the host answers with an error row',
() => harness.runtimePathsExist.mockResolvedValue([{ error: 'EACCES' }])
]
])('reloads as before when %s', async (_label, arrange) => {
arrange()
setTabs(tab('a.ts'))
reconcileEditorTabsAfterOverflow(target)
await vi.waitFor(() =>
expect(harness.scheduleReload).toHaveBeenCalledWith(notification('a.ts'))
)
expect(setExternalMutation).not.toHaveBeenCalledWith('a.ts', 'deleted')
})
it('hands dirty tabs to the disk check instead of reloading them', () => {
setTabs(tab('dirty.ts', { isDirty: true, lastKnownDiskSignature: 'sig' }))
reconcileEditorTabsAfterOverflow(target)
expect(setPendingDiskBaselineVerification).toHaveBeenCalledWith('dirty.ts', true)
expect(harness.runtimePathsExist).not.toHaveBeenCalled()
expect(harness.scheduleReload).not.toHaveBeenCalled()
})
it('only touches tabs of the matching runtime owner', async () => {
setTabs(tab('local.md'), tab('runtime.md', { runtimeEnvironmentId: 'env-1' }))
harness.runtimePathsExist.mockResolvedValue([{ exists: true }])
reconcileEditorTabsAfterOverflow({ ...target, runtimeEnvironmentId: 'env-1' })
await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(1))
expect(harness.scheduleReload).toHaveBeenCalledWith(notification('runtime.md', 'env-1'))
expect(harness.runtimePathsExist).toHaveBeenCalledWith(
expect.anything(),
['/repo/runtime.md'],
undefined,
// Why: only a per-path ENOENT may mark a tab deleted, never a whole-request "not found".
{ codedAbsenceOnly: true }
)
})
it('probes more tabs than one host batch allows in ordered chunks', async () => {
const ids = Array.from({ length: 129 }, (_, index) => `f${index}.ts`)
setTabs(...ids.map((id) => tab(id)))
harness.runtimePathsExist.mockImplementation(async (_context: unknown, paths: string[]) =>
// The last tab (the only one in the second chunk) is gone.
paths.map((path) => ({ exists: path !== '/repo/f128.ts' }))
)
reconcileEditorTabsAfterOverflow(target)
await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(128))
expect(harness.runtimePathsExist.mock.calls.map(([, paths]) => paths.length)).toEqual([128, 1])
expect(setExternalMutation).toHaveBeenCalledWith('f128.ts', 'deleted')
expect(harness.scheduleReload).not.toHaveBeenCalledWith(notification('f128.ts'))
})
it('drops an older existence answer once a newer probe for the tab has answered', async () => {
setTabs(tab('a.ts'))
let answerFirst: (rows: unknown) => void = () => {}
harness.runtimePathsExist
.mockImplementationOnce(() => new Promise((resolve) => (answerFirst = resolve)))
.mockResolvedValueOnce([{ exists: false }])
reconcileEditorTabsAfterOverflow(target)
reconcileEditorTabsAfterOverflow(target)
await vi.waitFor(() => expect(setExternalMutation).toHaveBeenCalledWith('a.ts', 'deleted'))
answerFirst([{ exists: true }])
await new Promise((resolve) => setTimeout(resolve, 0))
expect(setExternalMutation).not.toHaveBeenCalledWith('a.ts', null)
expect(harness.scheduleReload).not.toHaveBeenCalled()
})
it('drops a pending existence answer when a watch event for the tab arrives first', async () => {
const touched: OpenFile = {
id: 'a.ts',
worktreeId: 'wt-1',
filePath: '/repo/a.ts',
relativePath: 'a.ts',
language: 'typescript',
mode: 'edit',
isDirty: false
}
setTabs(touched)
let answer: (rows: unknown) => void = () => {}
harness.runtimePathsExist.mockImplementationOnce(
() => new Promise((resolve) => (answer = resolve))
)
reconcileEditorTabsAfterOverflow(target)
supersedeOverflowProbesForBatch(
{
createOrUpdatePaths: new Map(),
changes: [],
deletedOpenEditors: [{ file: touched, normalizedDeletePath: '/repo/a.ts' }],
hasCombinedDiffConsumer: false,
matchesCreateOrUpdate: () => false,
matchingOpenFiles: () => []
},
[touched]
)
answer([{ exists: true }])
await new Promise((resolve) => setTimeout(resolve, 0))
expect(harness.scheduleReload).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,202 @@
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import type { EditorExternalWatchBatchPathIndex } from '@/components/editor/editor-external-watch-path-index'
import {
canAutoSaveOpenFile,
isExternalReloadableEditorTab
} from '@/components/editor/editor-autosave'
import { forgetCombinedDiffViewBodiesForWorktree } from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache'
import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch'
import {
PATH_EXISTENCE_BATCH_MAX,
type PathExistenceResult
} from '../../../shared/path-existence-batch'
import { requestRestoredTabReverification } from '@/components/editor/editor-restored-tab-conflict-scan'
import {
getLocalWindowsWslAliasOption,
getOpenFileRuntimeOwner,
type EditorExternalWatchTarget
} from './editor-external-watch-targets'
import {
scheduleDebouncedEditorExternalReload,
type EditorExternalWatchNotification
} from './editor-external-watch-disk-verification'
// Why: an existence answer is stale once a newer probe or watch event for that tab arrives.
const probeGenerationByFileId = new Map<string, number>()
let probeGenerationSeq = 0
function claimProbeGeneration(fileId: string): number {
probeGenerationSeq += 1
probeGenerationByFileId.set(fileId, probeGenerationSeq)
return probeGenerationSeq
}
/** Drops pending existence answers for tabs this watch batch touched. */
export function supersedeOverflowProbesForBatch(
batchPaths: EditorExternalWatchBatchPathIndex,
openFiles: readonly OpenFile[]
): void {
if (probeGenerationByFileId.size === 0) {
return
}
const touched = new Set(batchPaths.deletedOpenEditors.map(({ file }) => file.id))
for (const change of batchPaths.changes) {
for (const file of batchPaths.matchingOpenFiles(change)) {
touched.add(file.id)
}
}
for (const file of openFiles) {
if (probeGenerationByFileId.has(file.id) && batchPaths.matchesCreateOrUpdate(file)) {
touched.add(file.id)
}
}
for (const fileId of touched) {
if (probeGenerationByFileId.has(fileId)) {
claimProbeGeneration(fileId)
}
}
}
type OverflowTarget = Pick<EditorExternalWatchTarget, 'worktreeId' | 'worktreePath'> &
Partial<
Pick<
EditorExternalWatchTarget,
'connectionId' | 'runtimeEnvironmentId' | 'allowLocalWindowsWslAliases'
>
>
type EditTabProbe = {
fileId: string
generation: number
filePath: string
runtimeEnvironmentId: string | null | undefined
notification: EditorExternalWatchNotification
}
/**
* An overflow (or a switch-back catch-up) names no paths, so every tab of the workspace may be stale.
* Clean tabs reload unless disk proves the file gone; dirty tabs get the restored-tab disk check.
*/
export function reconcileEditorTabsAfterOverflow(target: OverflowTarget): void {
// Why: unmounted combined-diff tabs get no per-path reload, so drop their cached bodies before a remount replays them.
forgetCombinedDiffViewBodiesForWorktree(target.worktreeId)
const state = useAppStore.getState()
const probes: EditTabProbe[] = []
for (const file of state.openFiles) {
if (
file.worktreeId !== target.worktreeId ||
getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) ||
!isExternalReloadableEditorTab(file)
) {
continue
}
if (file.isDirty) {
// Why: a dirty tab is never reloaded; the restored-tab scan shows the changed-on-disk banner if disk moved.
// Mirrors the scan's eligibility so it always resolves the flag.
if (
file.lastKnownDiskSignature &&
file.externalMutation !== 'changed' &&
canAutoSaveOpenFile(file)
) {
requestRestoredTabReverification(file.id)
state.setPendingDiskBaselineVerification(file.id, true)
}
continue
}
const notification: EditorExternalWatchNotification = {
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
relativePath: file.relativePath,
runtimeEnvironmentId: target.runtimeEnvironmentId ?? null,
...getLocalWindowsWslAliasOption(target)
}
if (file.mode !== 'edit') {
if (file.externalMutation) {
state.setExternalMutation(file.id, null)
}
scheduleDebouncedEditorExternalReload(notification)
continue
}
probes.push({
fileId: file.id,
generation: claimProbeGeneration(file.id),
filePath: file.filePath,
runtimeEnvironmentId: file.runtimeEnvironmentId,
notification
})
}
if (probes.length > 0) {
void reloadEditTabsUnlessGone(target, probes)
}
}
async function probeEditTabs(
target: OverflowTarget,
probes: readonly EditTabProbe[]
): Promise<readonly PathExistenceResult[] | null> {
const owner = getRuntimeTargetForFileOwner(
useAppStore.getState(),
target.worktreeId,
probes[0].runtimeEnvironmentId
)
if (!owner) {
return null
}
const context = {
target: owner,
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId
}
const paths = probes.map((probe) => probe.filePath)
// Why: the host rejects a batch above its limit outright, which would reload every tab.
const chunks: string[][] = []
for (let start = 0; start < paths.length; start += PATH_EXISTENCE_BATCH_MAX) {
chunks.push(paths.slice(start, start + PATH_EXISTENCE_BATCH_MAX))
}
try {
const results = await Promise.all(
chunks.map((chunk) =>
// Why: a whole-request "not found" (worktree re-pairing) must never mark every tab deleted.
runtimePathsExist(context, chunk, undefined, { codedAbsenceOnly: true })
)
)
return results.flat()
} catch {
// Why: a failed probe cannot prove absence; reload as before.
return null
}
}
// Why: reloading a file deleted while unwatched would swap the last content for a read error; mark it deleted as a watch event would.
async function reloadEditTabsUnlessGone(
target: OverflowTarget,
probes: readonly EditTabProbe[]
): Promise<void> {
const results = await probeEditTabs(target, probes)
const state = useAppStore.getState()
probes.forEach((probe, index) => {
if (probeGenerationByFileId.get(probe.fileId) !== probe.generation) {
return
}
probeGenerationByFileId.delete(probe.fileId)
const live = state.openFiles.find((file) => file.id === probe.fileId)
if (!live || live.isDirty || live.mode !== 'edit') {
return
}
const row = results?.[index]
if (row && 'exists' in row && !row.exists) {
// Why: keep a rename mark; without watch events a rename and a delete look the same.
if (live.externalMutation !== 'deleted' && live.externalMutation !== 'renamed') {
state.setExternalMutation(live.id, 'deleted')
}
return
}
if (live.externalMutation) {
state.setExternalMutation(live.id, null)
}
scheduleDebouncedEditorExternalReload(probe.notification)
})
}
@@ -0,0 +1,61 @@
import type { AppState } from '@/store'
import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency'
// Why: recently used workspaces keep their watch so switching back needs no re-read.
export const WARM_RECURSIVE_WATCH_WORKSPACE_COUNT = 3
// Why: deleted/renamed-tab pins sit outside the recency set, so bound them or they grow without limit.
export const PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT = 3
function mostRecent(
ids: Iterable<string>,
visitedAtById: ReadonlyMap<string, number>,
count: number
): string[] {
return [...ids]
.sort((left, right) => (visitedAtById.get(right) ?? 0) - (visitedAtById.get(left) ?? 0))
.slice(0, count)
}
/** Workspaces whose open tabs keep a native watch: the active one, the most recently visited, and a few showing a deleted/renamed tab. */
export function selectWatchEligibleWorkspaceIds(args: {
activeWorktreeId: string | null
lastVisitedAtByWorktreeId: Readonly<Record<string, number>> | undefined
openFiles: AppState['openFiles']
exists: (workspaceId: string) => boolean
}): ReadonlySet<string> {
const ids = new Set<string>()
if (args.activeWorktreeId) {
ids.add(args.activeWorktreeId)
}
// Why: one visit map entry per host, so take each workspace's latest.
const visitedAtById = new Map<string, number>()
for (const [key, visitedAt] of Object.entries(args.lastVisitedAtByWorktreeId ?? {})) {
const id = getWorktreeIdFromVisitKey(key)
visitedAtById.set(id, Math.max(visitedAtById.get(id) ?? 0, visitedAt))
}
const warmCandidates = [...visitedAtById.keys()].filter(
// Why: `exists` keeps deleted workspaces from holding warm slots.
(id) => id !== args.activeWorktreeId && args.exists(id)
)
for (const id of mostRecent(
warmCandidates,
visitedAtById,
WARM_RECURSIVE_WATCH_WORKSPACE_COUNT
)) {
ids.add(id)
}
// Why: a live watch clears a deleted/renamed mark the moment the file reappears; a catch-up only re-checks on switch-back.
const pinned = new Set<string>()
for (const file of args.openFiles) {
if (
(file.externalMutation === 'deleted' || file.externalMutation === 'renamed') &&
!ids.has(file.worktreeId)
) {
pinned.add(file.worktreeId)
}
}
for (const id of mostRecent(pinned, visitedAtById, PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT)) {
ids.add(id)
}
return ids
}
@@ -2,7 +2,9 @@ import type { AppState } from '@/store'
import { getIndexedWorktreesById } from '@/store/worktree-repo-index'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import {
isLocalExecutionHost,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
@@ -91,3 +93,32 @@ export function resolveEditorExternalWatchTargetRows(
: undefined
return { worktree, repo, folderWorkspace, projectGroup, connectionId }
}
/** Only a proven-local Windows owner may match WSL alias paths for its watch root. */
export function canWatchLocalWindowsWslAliases(
args: EditorExternalWatchTargetRows & {
worktreePath: string
runtimeEnvironmentId: string | null
}
): boolean {
if (
args.runtimeEnvironmentId !== null ||
args.connectionId !== null ||
!isWindowsAbsolutePathLike(args.worktreePath)
) {
return false
}
if (args.worktree) {
return (
!!args.repo &&
!args.worktree.runtimeOwnerEnvironmentId?.trim() &&
isLocalExecutionHost(args.worktree.hostId) &&
isLocalExecutionHost(args.repo.executionHostId)
)
}
return (
!!args.folderWorkspace &&
isLocalExecutionHost(args.folderWorkspace.executionHostId) &&
isLocalExecutionHost(args.projectGroup?.executionHostId)
)
}
@@ -5,16 +5,18 @@ import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-conte
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome'
import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import {
isLocalExecutionHost,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { resolveEditorExternalWatchTargetRows } from './editor-external-watch-target-rows'
import {
canWatchLocalWindowsWslAliases,
resolveEditorExternalWatchTargetRows
} from './editor-external-watch-target-rows'
import { selectWatchEligibleWorkspaceIds } from './editor-external-watch-scope'
export type EditorExternalWatchTarget = {
worktreeId: string
@@ -39,11 +41,19 @@ export type EditorExternalWatchTargetState = Pick<
| 'folderWorkspaces'
| 'projectGroups'
> &
Partial<Pick<AppState, 'activeWorkspaceExecutionHostId' | 'rightSidebarEffectiveTab'>>
Partial<
Pick<
AppState,
'activeWorkspaceExecutionHostId' | 'rightSidebarEffectiveTab' | 'lastVisitedAtByWorktreeId'
>
>
type WatchedTargetsSnapshot = {
targets: EditorExternalWatchTarget[]
targetsKey: string
/** Keys of open-tab targets the background trim left unwatched; a later add of one must re-read its tabs. */
unwatchedTargetKeys: ReadonlySet<string>
unwatchedTargetsKey: string
}
let cachedOpenFiles: AppState['openFiles'] | null = null
@@ -60,7 +70,13 @@ let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null =
let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null
let cachedFolderWorkspaces: AppState['folderWorkspaces'] | null = null
let cachedProjectGroups: AppState['projectGroups'] | null = null
let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' }
let cachedLastVisitedAtByWorktreeId: AppState['lastVisitedAtByWorktreeId'] | undefined
let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = {
targets: [],
targetsKey: '',
unwatchedTargetKeys: new Set(),
unwatchedTargetsKey: ''
}
export function getEditorExternalWatchTargetKey(target: EditorExternalWatchTarget): string {
// Why: include connectionId so a local placeholder watch is replaced by the real SSH watch once an SSH worktree's provider metadata hydrates.
@@ -127,37 +143,6 @@ function addWatchConsumer(
consumers.set(`${consumer.owner ?? ''}::${consumer.hostId ?? ''}`, consumer)
}
function canWatchLocalWindowsWslAliases(args: {
worktreePath: string
runtimeEnvironmentId: string | null
connectionId: string | null | undefined
worktree: AppState['worktreesByRepo'][string][number] | undefined
repo: AppState['repos'][number] | undefined
folderWorkspace: AppState['folderWorkspaces'][number] | undefined
projectGroup: AppState['projectGroups'][number] | undefined
}): boolean {
if (
args.runtimeEnvironmentId !== null ||
args.connectionId !== null ||
!isWindowsAbsolutePathLike(args.worktreePath)
) {
return false
}
if (args.worktree) {
return (
!!args.repo &&
!args.worktree.runtimeOwnerEnvironmentId?.trim() &&
isLocalExecutionHost(args.worktree.hostId) &&
isLocalExecutionHost(args.repo.executionHostId)
)
}
return (
!!args.folderWorkspace &&
isLocalExecutionHost(args.folderWorkspace.executionHostId) &&
isLocalExecutionHost(args.projectGroup?.executionHostId)
)
}
export function selectEditorExternalWatchTargets(
state: EditorExternalWatchTargetState
): WatchedTargetsSnapshot {
@@ -177,19 +162,31 @@ export function selectEditorExternalWatchTargets(
cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree &&
cachedSshConnectionStates === state.sshConnectionStates &&
cachedFolderWorkspaces === state.folderWorkspaces &&
cachedProjectGroups === state.projectGroups
cachedProjectGroups === state.projectGroups &&
cachedLastVisitedAtByWorktreeId === state.lastVisitedAtByWorktreeId
) {
return cachedWatchedTargetsSnapshot
}
const consumersByWorktreeId = new Map<string, Map<string, WatchConsumer>>()
const unwatchedConsumersByWorktreeId = new Map<string, Map<string, WatchConsumer>>()
const watchEligibleIds = selectWatchEligibleWorkspaceIds({
activeWorktreeId: state.activeWorktreeId,
lastVisitedAtByWorktreeId: state.lastVisitedAtByWorktreeId,
openFiles: state.openFiles,
exists: (id) => resolveEditorExternalWatchTargetRows(state, id, null) !== null
})
// Why: watcher ownership is scoped by worktree + execution host — the same workspace id can be open locally, over SSH and in a runtime, and reads/saves already route per host.
for (const file of state.openFiles) {
// Why: a virtual tab (CI details, chat visual) has no file in the workspace to watch.
if (isVirtualEditorFile(file)) {
continue
}
addWatchConsumer(consumersByWorktreeId, file.worktreeId, getOpenFileWatchConsumer(file))
// Why: background workspaces drop their native watch; switching back re-verifies their tabs against disk.
const consumers = watchEligibleIds.has(file.worktreeId)
? consumersByWorktreeId
: unwatchedConsumersByWorktreeId
addWatchConsumer(consumers, file.worktreeId, getOpenFileWatchConsumer(file))
}
const activeWorktreeId = state.activeWorktreeId
const activeWorktree = activeWorktreeId
@@ -226,34 +223,12 @@ export function selectEditorExternalWatchTargets(
)
}
const targetsByKey = new Map<string, EditorExternalWatchTarget>()
for (const [id, consumers] of consumersByWorktreeId) {
for (const { owner, hostId } of consumers.values()) {
const rows = resolveEditorExternalWatchTargetRows(state, id, hostId)
if (!rows || (rows.connectionId === undefined && rows.folderWorkspace)) {
continue
}
const { worktree, repo, folderWorkspace, projectGroup, connectionId } = rows
const target = {
worktreeId: id,
worktreePath: worktree?.path ?? folderWorkspace!.folderPath,
connectionId: connectionId ?? undefined,
runtimeEnvironmentId: owner,
...(canWatchLocalWindowsWslAliases({
worktreePath: worktree?.path ?? folderWorkspace!.folderPath,
runtimeEnvironmentId: owner,
connectionId,
worktree,
repo,
folderWorkspace,
projectGroup
})
? { allowLocalWindowsWslAliases: true as const }
: {})
}
targetsByKey.set(getEditorExternalWatchTargetKey(target), target)
}
}
const targetsByKey = resolveWatchTargets(state, consumersByWorktreeId)
const unwatchedTargetKeys = new Set(
[...resolveWatchTargets(state, unwatchedConsumersByWorktreeId).keys()].filter(
(key) => !targetsByKey.has(key)
)
)
// Why: consumers on one host (an unqualified legacy tab and its captured sibling) collapse to one watch.
const sortedEntries = Array.from(targetsByKey).sort(([left], [right]) =>
@@ -261,6 +236,7 @@ export function selectEditorExternalWatchTargets(
)
const nextTargets = sortedEntries.map(([, target]) => target)
const targetsKey = sortedEntries.map(([key]) => key).join('|')
const unwatchedTargetsKey = [...unwatchedTargetKeys].sort().join('|')
cachedOpenFiles = state.openFiles
cachedWorktreesByRepo = state.worktreesByRepo
cachedRepos = state.repos
@@ -275,11 +251,51 @@ export function selectEditorExternalWatchTargets(
cachedSshConnectionStates = state.sshConnectionStates
cachedFolderWorkspaces = state.folderWorkspaces
cachedProjectGroups = state.projectGroups
cachedLastVisitedAtByWorktreeId = state.lastVisitedAtByWorktreeId
if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) {
if (
targetsKey === cachedWatchedTargetsSnapshot.targetsKey &&
unwatchedTargetsKey === cachedWatchedTargetsSnapshot.unwatchedTargetsKey
) {
return cachedWatchedTargetsSnapshot
}
cachedWatchedTargetsSnapshot = { targets: nextTargets, targetsKey }
cachedWatchedTargetsSnapshot = {
targets: nextTargets,
targetsKey,
unwatchedTargetKeys,
unwatchedTargetsKey
}
return cachedWatchedTargetsSnapshot
}
function resolveWatchTargets(
state: EditorExternalWatchTargetState,
consumersByWorktreeId: ReadonlyMap<string, ReadonlyMap<string, WatchConsumer>>
): Map<string, EditorExternalWatchTarget> {
const targetsByKey = new Map<string, EditorExternalWatchTarget>()
for (const [id, consumers] of consumersByWorktreeId) {
for (const { owner, hostId } of consumers.values()) {
const rows = resolveEditorExternalWatchTargetRows(state, id, hostId)
if (!rows || (rows.connectionId === undefined && rows.folderWorkspace)) {
continue
}
const { worktree, folderWorkspace, connectionId } = rows
const target = {
worktreeId: id,
worktreePath: worktree?.path ?? folderWorkspace!.folderPath,
connectionId: connectionId ?? undefined,
runtimeEnvironmentId: owner,
...(canWatchLocalWindowsWslAliases({
worktreePath: worktree?.path ?? folderWorkspace!.folderPath,
runtimeEnvironmentId: owner,
...rows
})
? { allowLocalWindowsWslAliases: true as const }
: {})
}
targetsByKey.set(getEditorExternalWatchTargetKey(target), target)
}
}
return targetsByKey
}
@@ -77,6 +77,7 @@ describe('editor runtime file watch recovery', () => {
connectionId: undefined
},
expect.any(Function),
expect.any(Function),
expect.any(Function)
)
expect(onPayload).not.toHaveBeenCalled()
@@ -105,6 +106,7 @@ describe('editor runtime file watch recovery', () => {
connectionId: undefined
},
expect.any(Function),
expect.any(Function),
expect.any(Function)
)
expect(onPayload).toHaveBeenCalledWith({
@@ -8,8 +8,10 @@ import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-targe
export function subscribeEditorRuntimeFileWatch(
target: EditorExternalWatchTarget & { runtimeEnvironmentId: string },
onPayload: (payload: FsChangedPayload) => void,
onError: (error: unknown) => void
onError: (error: unknown) => void,
onFirstReady?: () => void
): () => void {
let readySeen = false
let disposed = false
let attempt = 0
let unsubscribe: (() => void) | null = null
@@ -38,6 +40,12 @@ export function subscribeEditorRuntimeFileWatch(
if (isCurrent()) {
onError(error)
}
},
() => {
if (isCurrent() && !readySeen) {
readySeen = true
onFirstReady?.()
}
}
)
.then((stop) => {
@@ -0,0 +1,270 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
type TestWatchTarget = {
worktreeId: string
worktreePath: string
connectionId: string | undefined
runtimeEnvironmentId: string | null
}
const harness = vi.hoisted(() => {
const targets: TestWatchTarget[] = []
const unwatched: TestWatchTarget[] = []
const runtimeReady: { current: (() => void) | undefined } = { current: undefined }
return {
runtimeReady,
keyOf: (target: TestWatchTarget): string =>
`${target.worktreeId}::${target.runtimeEnvironmentId ?? 'client'}`,
targets,
unwatched,
// Why: the hook resyncs through the event handler's overflow path; this records those calls.
catchUp: vi.fn(),
runtimeUnsubscribe: vi.fn()
}
})
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
getState: () => ({ openFiles: [], setPendingDiskBaselineVerification: vi.fn() })
})
}))
vi.mock('./editor-external-watch-targets', () => ({
selectEditorExternalWatchTargets: () => ({
targets: [...harness.targets],
targetsKey: harness.targets.map(harness.keyOf).join('|'),
unwatchedTargetKeys: new Set(harness.unwatched.map(harness.keyOf)),
unwatchedTargetsKey: harness.unwatched.map(harness.keyOf).join('|')
}),
getEditorExternalWatchTargetKey: harness.keyOf,
getOpenFileRuntimeOwner: () => null
}))
vi.mock('./editor-runtime-file-watch', () => ({
subscribeEditorRuntimeFileWatch: (
_target: unknown,
_onPayload: unknown,
_onError: unknown,
onFirstReady?: () => void
) => {
harness.runtimeReady.current = onFirstReady
return harness.runtimeUnsubscribe
}
}))
vi.mock('./editor-external-watch-event-reconciliation', () => ({
buildEditorExternalWatchEventHandler: () => ({
handleFsChanged: harness.catchUp,
dispose: vi.fn()
})
}))
vi.mock('./editor-external-watch-disk-verification', () => ({
verifyLatchedEditorMoveDestinations: vi.fn()
}))
import { useEditorExternalWatch } from './useEditorExternalWatch'
function resyncOf(target: TestWatchTarget): [unknown, string | null, TestWatchTarget] {
return [
{
worktreePath: target.worktreePath,
events: [{ kind: 'overflow', absolutePath: target.worktreePath }],
connectionId: target.connectionId
},
target.runtimeEnvironmentId,
target
]
}
function WatchProbe(): null {
useEditorExternalWatch()
return null
}
const active: TestWatchTarget = {
worktreeId: 'active',
worktreePath: '/active',
connectionId: undefined,
runtimeEnvironmentId: null
}
const background: TestWatchTarget = {
worktreeId: 'background',
worktreePath: '/background',
connectionId: undefined,
runtimeEnvironmentId: null
}
const runtimeBackground: TestWatchTarget = {
worktreeId: 'runtime-background',
worktreePath: '/runtime',
connectionId: undefined,
runtimeEnvironmentId: 'env-1'
}
describe('useEditorExternalWatch with background trim', () => {
let container: HTMLDivElement
let root: Root
let previousApi: unknown
let watchWorktree: ReturnType<typeof vi.fn>
let unwatchWorktree: ReturnType<typeof vi.fn>
let resolveWatch: () => void
async function render(targets: TestWatchTarget[], unwatched: TestWatchTarget[]): Promise<void> {
harness.targets.splice(0, harness.targets.length, ...targets)
harness.unwatched.splice(0, harness.unwatched.length, ...unwatched)
await act(async () => root.render(createElement(WatchProbe)))
}
beforeEach(() => {
vi.clearAllMocks()
watchWorktree = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveWatch = resolve
})
)
unwatchWorktree = vi.fn().mockResolvedValue(undefined)
previousApi = (window as unknown as { api?: unknown }).api
;(window as unknown as { api: unknown }).api = {
fs: { watchWorktree, unwatchWorktree, onFsChanged: vi.fn(() => vi.fn()) }
}
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
;(window as unknown as { api?: unknown }).api = previousApi
})
it('refreshes a dropped workspace once its watch request settles', async () => {
await render([active], [background])
await render([active, background], [])
expect(watchWorktree).toHaveBeenLastCalledWith({
worktreePath: '/background',
connectionId: undefined
})
expect(harness.catchUp).not.toHaveBeenCalled()
await act(async () => resolveWatch())
expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(background))
expect(harness.catchUp).toHaveBeenCalledTimes(1)
})
it('refreshes a dropped runtime workspace once its host reports the watch ready', async () => {
await render([active], [runtimeBackground])
await render([active, runtimeBackground], [])
expect(harness.catchUp).not.toHaveBeenCalled()
await act(async () => harness.runtimeReady.current?.())
expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(runtimeBackground))
})
it('releases and re-requests an SSH workspace watch through its connection', async () => {
const ssh: TestWatchTarget = {
worktreeId: 'ssh',
worktreePath: '/home/me/repo',
connectionId: 'conn-1',
runtimeEnvironmentId: null
}
await render([active, ssh], [])
await render([active], [ssh])
expect(unwatchWorktree).toHaveBeenCalledWith({
worktreePath: '/home/me/repo',
connectionId: 'conn-1'
})
await render([active, ssh], [])
expect(watchWorktree).toHaveBeenLastCalledWith({
worktreePath: '/home/me/repo',
connectionId: 'conn-1'
})
await act(async () => resolveWatch())
expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(ssh))
})
it('retries a failed directory watch when another workspace on it needs it', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
watchWorktree
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('provider gone'))
await render([active, background], [])
expect(watchWorktree).toHaveBeenCalledTimes(2)
await render([active, background, { ...background, worktreeId: 'sibling' }], [])
expect(watchWorktree).toHaveBeenCalledTimes(3)
expect(watchWorktree).toHaveBeenLastCalledWith({
worktreePath: '/background',
connectionId: undefined
})
} finally {
warning.mockRestore()
}
})
it('does not refresh a workspace whose tabs were just opened', async () => {
await render([active], [])
await render([active, background], [])
await act(async () => resolveWatch())
expect(harness.catchUp).not.toHaveBeenCalled()
})
it('keeps a directory watched while another workspace on it still needs it', async () => {
const sibling = { ...background, worktreeId: 'sibling' }
await render([active, background, sibling], [])
expect(watchWorktree).toHaveBeenCalledTimes(2)
await render([active, sibling], [background])
expect(unwatchWorktree).not.toHaveBeenCalled()
await render([active], [background, sibling])
expect(unwatchWorktree).toHaveBeenCalledTimes(1)
expect(unwatchWorktree).toHaveBeenCalledWith({
worktreePath: '/background',
connectionId: undefined
})
})
it('refreshes a re-watched workspace whose directory another workspace already watches', async () => {
const sibling = { ...background, worktreeId: 'sibling' }
await render([active, sibling], [background])
await render([active, sibling, background], [])
expect(watchWorktree).toHaveBeenCalledTimes(2)
await act(async () => resolveWatch())
expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(background))
})
it('only releases when a background workspace drops out', async () => {
await render([active, background], [])
watchWorktree.mockClear()
await render([active], [background])
expect(unwatchWorktree).toHaveBeenCalledTimes(1)
expect(unwatchWorktree).toHaveBeenCalledWith({
worktreePath: '/background',
connectionId: undefined
})
expect(watchWorktree).not.toHaveBeenCalled()
expect(harness.catchUp).not.toHaveBeenCalled()
})
it('leaves no watch behind after all tabs close or the app unmounts', async () => {
await render([active, background, runtimeBackground], [])
await render([], [])
expect(unwatchWorktree).toHaveBeenCalledTimes(2)
expect(harness.runtimeUnsubscribe).toHaveBeenCalledTimes(1)
await render([active], [background])
await act(async () => root.unmount())
expect(unwatchWorktree).toHaveBeenCalledTimes(3)
expect(unwatchWorktree).toHaveBeenLastCalledWith({
worktreePath: '/active',
connectionId: undefined
})
root = createRoot(container)
})
})
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type * as WatchTargets from './editor-external-watch-targets'
type TestWatchTarget = {
worktreeId: string
worktreePath: string
connectionId: string | undefined
runtimeEnvironmentId: string | null
}
const harness = vi.hoisted(() => {
const targets: TestWatchTarget[] = []
const unwatched: TestWatchTarget[] = []
const openFiles: Record<string, unknown>[] = []
return {
keyOf: (target: TestWatchTarget): string =>
`${target.worktreeId}::${target.runtimeEnvironmentId ?? 'client'}`,
targets,
unwatched,
openFiles,
setExternalMutation: vi.fn()
}
})
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
getState: () => ({
openFiles: harness.openFiles,
setExternalMutation: harness.setExternalMutation,
setPendingDiskBaselineVerification: vi.fn()
})
})
}))
vi.mock('./editor-external-watch-targets', async (importOriginal) => ({
...(await importOriginal<typeof WatchTargets>()),
selectEditorExternalWatchTargets: () => ({
targets: [...harness.targets],
targetsKey: harness.targets.map(harness.keyOf).join('|'),
unwatchedTargetKeys: new Set(harness.unwatched.map(harness.keyOf)),
unwatchedTargetsKey: harness.unwatched.map(harness.keyOf).join('|')
}),
getEditorExternalWatchTargetKey: harness.keyOf
}))
vi.mock('./editor-runtime-file-watch', () => ({
// Why: a shared runtime watch that is already ready reports it synchronously during subscribe.
subscribeEditorRuntimeFileWatch: (
_target: unknown,
_onPayload: unknown,
_onError: unknown,
onFirstReady?: () => void
) => {
onFirstReady?.()
return vi.fn()
}
}))
import { useEditorExternalWatch } from './useEditorExternalWatch'
import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from '@/components/editor/editor-autosave'
function WatchProbe(): null {
useEditorExternalWatch()
return null
}
function tab(worktreeId: string, relativePath: string, runtimeEnvironmentId?: string) {
return {
id: `/shared/${worktreeId}/${relativePath}`,
filePath: `/shared/${relativePath}`,
relativePath,
worktreeId,
language: 'typescript',
mode: 'edit',
isDirty: false,
lastKnownDiskSignature: 'sig',
...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {})
}
}
describe('useEditorExternalWatch resync through the real reconciliation path', () => {
let container: HTMLDivElement
let root: Root
let previousApi: unknown
let emitFsChanged: (payload: unknown) => void
let reloads: string[]
const onReload = (event: Event): void => {
if (event instanceof CustomEvent) {
reloads.push(`${event.detail.worktreeId}:${event.detail.relativePath}`)
}
}
async function render(targets: TestWatchTarget[], unwatched: TestWatchTarget[]): Promise<void> {
harness.targets.splice(0, harness.targets.length, ...targets)
harness.unwatched.splice(0, harness.unwatched.length, ...unwatched)
await act(async () => root.render(createElement(WatchProbe)))
}
beforeEach(() => {
vi.useFakeTimers()
reloads = []
harness.openFiles.splice(0)
harness.setExternalMutation.mockClear()
previousApi = (window as unknown as { api?: unknown }).api
;(window as unknown as { api: unknown }).api = {
fs: {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn((listener: (payload: unknown) => void) => {
emitFsChanged = listener
return vi.fn()
})
}
}
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload)
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload)
container.remove()
;(window as unknown as { api?: unknown }).api = previousApi
vi.useRealTimers()
})
it('reloads clean tabs of every workspace that shares the root', async () => {
const a = {
worktreeId: 'folder:a',
worktreePath: '/shared',
connectionId: undefined,
runtimeEnvironmentId: null
}
const b = { ...a, worktreeId: 'folder:b' }
harness.openFiles.push(tab('folder:a', 'a.ts'), tab('folder:b', 'b.ts'))
await render([a, b], [])
emitFsChanged({
worktreePath: '/shared',
events: [{ kind: 'overflow', absolutePath: '/shared' }]
})
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(reloads.sort()).toEqual(['folder:a:a.ts', 'folder:b:b.ts'])
})
it('resyncs a runtime workspace whose shared watch reports ready synchronously', async () => {
const runtimeTarget = {
worktreeId: 'wt-runtime',
worktreePath: '/shared',
connectionId: undefined,
runtimeEnvironmentId: 'env-1'
}
harness.openFiles.push(tab('wt-runtime', 'r.ts', 'env-1'))
await render([], [runtimeTarget])
await render([runtimeTarget], [])
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(reloads).toEqual(['wt-runtime:r.ts'])
})
it('resyncs only the re-added workspace, leaving another on the same root alone', async () => {
const a = {
worktreeId: 'folder:a',
worktreePath: '/shared',
connectionId: undefined,
runtimeEnvironmentId: null
}
const b = { ...a, worktreeId: 'folder:b' }
harness.openFiles.push(tab('folder:a', 'a.ts'), tab('folder:b', 'b.ts'), {
...tab('folder:b', 'gone.ts'),
externalMutation: 'deleted'
})
await render([b], [a])
await render([a, b], [])
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(reloads).toEqual(['folder:a:a.ts'])
expect(harness.setExternalMutation).not.toHaveBeenCalled()
})
it('keeps a local and an SSH workspace at the same path apart', async () => {
const local = {
worktreeId: 'local-wt',
worktreePath: '/shared',
connectionId: undefined,
runtimeEnvironmentId: null
}
const ssh = { ...local, worktreeId: 'ssh-wt', connectionId: 'ssh-1' }
harness.openFiles.push(tab('local-wt', 'x.ts'), tab('ssh-wt', 'x.ts'))
await render([local, ssh], [])
emitFsChanged({
worktreePath: '/shared',
events: [{ kind: 'update', absolutePath: '/shared/x.ts' }]
})
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(reloads).toEqual(['local-wt:x.ts'])
reloads = []
emitFsChanged({
worktreePath: '/shared',
events: [{ kind: 'update', absolutePath: '/shared/x.ts' }],
connectionId: 'ssh-1'
})
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(reloads).toEqual(['ssh-wt:x.ts'])
})
})
@@ -22,7 +22,9 @@ const subscriptionState = vi.hoisted(() => ({
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector({})
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
getState: () => ({ openFiles: [] })
})
}))
vi.mock('@/runtime/runtime-host-contact-regained', () => ({
subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => {
@@ -34,7 +36,11 @@ vi.mock('@/runtime/runtime-file-watch-client', () => ({
subscribeRuntimeFileChanges: subscriptionState.subscribeRuntimeFileChanges
}))
vi.mock('./editor-external-watch-targets', () => ({
selectEditorExternalWatchTargets: () => subscriptionState.snapshot,
selectEditorExternalWatchTargets: () => ({
unwatchedTargetKeys: new Set<string>(),
unwatchedTargetsKey: '',
...subscriptionState.snapshot
}),
getEditorExternalWatchTargetKey: (target: TestWatchTarget) =>
[
target.worktreeId,
@@ -0,0 +1,311 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
type TestWatchTarget = {
worktreeId: string
worktreePath: string
connectionId: string | undefined
runtimeEnvironmentId: string | null
}
const harness = vi.hoisted(() => {
const targets: TestWatchTarget[] = []
const unwatched: TestWatchTarget[] = []
const holder: { store: { getState: () => unknown } | null } = { store: null }
return {
keyOf: (target: TestWatchTarget): string => `${target.worktreeId}::client`,
targets,
unwatched,
holder,
readRuntimeFileContent: vi.fn()
}
})
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) => selector(harness.holder.store?.getState()),
{ getState: () => harness.holder.store?.getState() }
)
}))
vi.mock('./editor-external-watch-targets', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
selectEditorExternalWatchTargets: () => ({
targets: [...harness.targets],
targetsKey: harness.targets.map(harness.keyOf).join('|'),
unwatchedTargetKeys: new Set(harness.unwatched.map(harness.keyOf)),
unwatchedTargetsKey: harness.unwatched.map(harness.keyOf).join('|')
}),
getEditorExternalWatchTargetKey: harness.keyOf
}))
vi.mock('@/runtime/runtime-file-read-client', () => ({
readRuntimeFileContent: harness.readRuntimeFileContent
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undefined }))
import { createTestStore } from '@/store/slices/store-test-helpers'
import { useEditorExternalWatch } from './useEditorExternalWatch'
import { attachRestoredTabConflictScan } from '@/components/editor/editor-restored-tab-conflict-scan'
import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature'
import {
isAutosaveSuspendedForFile,
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT
} from '@/components/editor/editor-autosave'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from './worktree-file-change-event'
import {
claimCombinedDiffView,
clearCombinedDiffViewMemory,
readCombinedDiffViewMemory,
releaseCombinedDiffView,
rememberCombinedDiffView
} from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache'
function WatchProbe(): null {
useEditorExternalWatch()
return null
}
const active: TestWatchTarget = {
worktreeId: 'active',
worktreePath: '/active',
connectionId: undefined,
runtimeEnvironmentId: null
}
const background: TestWatchTarget = {
worktreeId: 'background',
worktreePath: '/background',
connectionId: undefined,
runtimeEnvironmentId: null
}
const CLEAN = '/background/clean.ts'
const DIRTY = '/background/dirty.ts'
describe('switching back to a workspace that was not watched', () => {
let store: ReturnType<typeof createTestStore>
let container: HTMLDivElement
let root: Root
let previousApi: unknown
let detachScan: () => void
let settleWatch: () => void
let emitIpc: (payload: unknown) => void
let pathExists: Mock<(args: { filePath: string }) => Promise<boolean>>
let reloads: string[]
let fileChangeEvents: number
const onReload = (event: Event): void => {
if (event instanceof CustomEvent) {
reloads.push(event.detail.relativePath)
}
}
const onFileChange = (): void => {
fileChangeEvents += 1
}
const file = (id: string) => store.getState().openFiles.find((f) => f.id === id)!
async function render(targets: TestWatchTarget[], unwatched: TestWatchTarget[]): Promise<void> {
harness.targets.splice(0, harness.targets.length, ...targets)
harness.unwatched.splice(0, harness.unwatched.length, ...unwatched)
await act(async () => root.render(createElement(WatchProbe)))
}
function openTab(filePath: string): void {
store.getState().openFile({
filePath,
relativePath: filePath.slice('/background/'.length),
worktreeId: 'background',
language: 'typescript',
mode: 'edit'
})
store.getState().setLastKnownDiskSignature(filePath, getDiskBaselineSignature('v1'))
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
store = createTestStore()
harness.holder.store = store
openTab(CLEAN)
openTab(DIRTY)
store.getState().setEditorDraft(DIRTY, 'unsaved edit')
store.getState().markFileDirty(DIRTY, true)
pathExists = vi.fn(async (_args: { filePath: string }) => true)
previousApi = (window as unknown as { api?: unknown }).api
;(window as unknown as { api: unknown }).api = {
fs: {
watchWorktree: vi.fn(
() =>
new Promise<void>((resolve) => {
settleWatch = resolve
})
),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn((listener: (payload: unknown) => void) => {
emitIpc = listener
return vi.fn()
}),
pathExists,
pathsExist: vi.fn(async ({ filePaths }: { filePaths: string[] }) =>
Promise.all(
filePaths.map(async (filePath) => ({ exists: await pathExists({ filePath }) }))
)
)
}
}
reloads = []
fileChangeEvents = 0
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload)
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, onFileChange)
detachScan = attachRestoredTabConflictScan(store)
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload)
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, onFileChange)
detachScan()
clearCombinedDiffViewMemory()
container.remove()
vi.useRealTimers()
;(window as unknown as { api?: unknown }).api = previousApi
})
async function switchBack(): Promise<void> {
await render([active], [background])
await render([active, background], [])
await act(async () => settleWatch())
await act(async () => vi.advanceTimersByTimeAsync(200))
}
it('reloads clean tabs, banners a dirty tab changed on disk, and refreshes git status/Files once', async () => {
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v2 agent write', isBinary: false })
await switchBack()
expect(reloads).toEqual(['clean.ts'])
expect(file(DIRTY).externalMutation).toBe('changed')
expect(file(DIRTY).isDirty).toBe(true)
expect(file(CLEAN).externalMutation).toBeFalsy()
expect(fileChangeEvents).toBe(1)
})
it('checks dirty tabs only once the watch is back, so a write in between still gets the banner', async () => {
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
await render([active], [background])
await render([active, background], [])
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(harness.readRuntimeFileContent).not.toHaveBeenCalled()
// An agent writes after the switch but before the watch is installed.
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v2 late write', isBinary: false })
await act(async () => settleWatch())
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(file(DIRTY).externalMutation).toBe('changed')
})
it('checks dirty tabs again when a late install (capacity, SSH retry) lands', async () => {
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
// The request returns while the watcher waits for capacity; the catch-up finds nothing yet.
await switchBack()
expect(file(DIRTY).externalMutation).toBeFalsy()
harness.readRuntimeFileContent.mockResolvedValue({
content: 'v2 before install',
isBinary: false
})
// Main announces the late install with the same overflow it sends after a reconnect.
await act(async () =>
emitIpc({
worktreePath: '/background',
events: [{ kind: 'overflow', absolutePath: '/background' }]
})
)
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(file(DIRTY).externalMutation).toBe('changed')
})
it('marks a clean tab deleted and keeps it when its file was deleted while unwatched', async () => {
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
pathExists.mockImplementation(async ({ filePath }: { filePath: string }) => filePath !== CLEAN)
await switchBack()
expect(file(CLEAN).externalMutation).toBe('deleted')
expect(reloads).toEqual([])
})
it('reloads clean sections of an unmounted combined-diff tab and keeps unsaved section edits', async () => {
const section = (path: string, dirty: boolean) => ({
key: path,
path,
status: 'modified',
originalContent: 'base',
modifiedContent: dirty ? 'unsaved section edit' : 'old disk',
collapsed: false,
loading: false,
dirty,
diffResult: null,
largeDiffRenderLimit: null
})
const viewKey = 'background::all-diffs::uncommitted::tab-1'
const lease = claimCombinedDiffView(viewKey, 'background')
releaseCombinedDiffView(lease)
rememberCombinedDiffView(lease, {
scrollTop: 120,
view: {
entrySignature: 'e',
gitStatusSignature: 'g',
sections: [section('clean.ts', false), section('dirty.ts', true)],
sectionHeights: {},
loadedIndices: [0, 1],
sideBySide: false
}
})
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
await switchBack()
const view = readCombinedDiffViewMemory(viewKey)?.view
expect(view?.sections[0]).toMatchObject({ loading: true, modifiedContent: '' })
expect(view?.sections[1]).toMatchObject({
dirty: true,
originalContent: 'base',
modifiedContent: 'unsaved section edit'
})
expect(view?.loadedIndices).toEqual([1])
expect(readCombinedDiffViewMemory(viewKey)?.scrollTop).toBe(120)
})
it('leaves a dirty tab alone when disk still matches what it last read', async () => {
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
await switchBack()
expect(file(DIRTY).externalMutation).toBeFalsy()
expect(file(DIRTY).pendingDiskBaselineVerification).toBeFalsy()
})
it('does not re-read tabs of a workspace that was never dropped', async () => {
await render([active], [])
await render([active, background], [])
await act(async () => settleWatch())
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(reloads).toEqual([])
expect(harness.readRuntimeFileContent).not.toHaveBeenCalled()
expect(file(DIRTY).pendingDiskBaselineVerification).toBeFalsy()
})
it('never holds autosave on a tab edited before the watch is back', async () => {
// Why: saves are disk-checked by the host, so a stale edit is refused there rather than held here.
await render([active], [background])
await render([active, background], [])
store.getState().setEditorDraft(CLEAN, 'typed before the watch settled')
store.getState().markFileDirty(CLEAN, true)
await act(async () => {})
expect(file(CLEAN).pendingDiskBaselineVerification).toBeFalsy()
expect(isAutosaveSuspendedForFile(file(CLEAN))).toBe(false)
})
})
@@ -75,6 +75,10 @@ describe('getEditorExternalWatchTargets', () => {
sshConnectionStates: args.sshConnectionStates ?? new Map(),
folderWorkspaces: [],
projectGroups: [],
// Why: these cases cover target resolution; a recent visit keeps the workspace inside the watch scope.
lastVisitedAtByWorktreeId: Object.fromEntries(
(args.openFiles ?? []).map((file) => [file.worktreeId, 1])
),
settings:
args.runtimeEnvironmentId === undefined
? null
@@ -21,7 +21,6 @@ vi.mock('@/components/editor/editor-autosave', async (importOriginal) => {
import {
buildEditorExternalWatchEventHandler as createExternalWatchEventHandler,
collectOverflowEditorExternalReloadTargets as getOverflowExternalReloadTargets,
EXTERNAL_MUTATION_DEBOUNCE_MS
} from './editor-external-watch-event-reconciliation'
import { getEditorExternalWatchTargetKey as getWatchedTargetKey } from './editor-external-watch-targets'
@@ -74,114 +73,6 @@ describe('getWatchedTargetKey', () => {
})
})
describe('getOverflowExternalReloadTargets', () => {
const setExternalMutation = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('clears tombstones and reloads clean edit tabs on overflow', () => {
vi.mocked(useAppStore.getState).mockReturnValue({
openFiles: [
{
id: 'file-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'notes.md',
mode: 'edit',
isDirty: false,
externalMutation: 'deleted'
},
{
id: 'file-2',
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'dirty.md',
mode: 'edit',
isDirty: true
},
{
id: 'file-3',
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'staged.ts',
mode: 'diff',
diffSource: 'staged',
isDirty: false
}
],
setExternalMutation
} as never)
expect(
getOverflowExternalReloadTargets({
worktreeId: 'wt-1',
worktreePath: '/repo'
})
).toEqual([
{
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'notes.md',
runtimeEnvironmentId: null
},
{
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'staged.ts',
runtimeEnvironmentId: null
}
])
expect(setExternalMutation).toHaveBeenCalledWith('file-1', null)
})
it('limits overflow reload targets to the matching runtime owner', () => {
vi.mocked(useAppStore.getState).mockReturnValue({
openFiles: [
{
id: 'local-file',
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'local.md',
mode: 'edit',
isDirty: false,
externalMutation: 'deleted',
runtimeEnvironmentId: null
},
{
id: 'runtime-file',
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'runtime.md',
mode: 'edit',
isDirty: false,
externalMutation: 'deleted',
runtimeEnvironmentId: 'env-1'
}
],
setExternalMutation
} as never)
expect(
getOverflowExternalReloadTargets({
worktreeId: 'wt-1',
worktreePath: '/repo',
runtimeEnvironmentId: 'env-1'
})
).toEqual([
{
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'runtime.md',
runtimeEnvironmentId: 'env-1'
}
])
expect(setExternalMutation).toHaveBeenCalledWith('runtime-file', null)
expect(setExternalMutation).not.toHaveBeenCalledWith('local-file', null)
})
})
describe('createExternalWatchEventHandler tombstone coalescing', () => {
const setExternalMutation = vi.fn()
const findTarget = (
@@ -8,8 +8,21 @@ import {
selectEditorExternalWatchTargets,
type EditorExternalWatchTarget
} from './editor-external-watch-targets'
import { markWorktreeCatchUpPayload } from './worktree-file-change-event'
import { buildEditorExternalWatchEventHandler } from './editor-external-watch-event-reconciliation'
import { verifyLatchedEditorMoveDestinations } from './editor-external-watch-disk-verification'
import {
acquireWatchRoot,
releaseAllWatchRoots,
releaseWatchRoot,
type HeldWatchRoots
} from './editor-external-watch-local-roots'
type FsChangedHandler = (
payload: FsChangedPayload,
runtimeEnvironmentId?: string | null,
onlyTarget?: EditorExternalWatchTarget
) => void
function warnExternalWatchFailure(target: EditorExternalWatchTarget, err: unknown): void {
console.warn('[filesystem-watch] failed to watch worktree', {
@@ -22,18 +35,18 @@ function warnExternalWatchFailure(target: EditorExternalWatchTarget, err: unknow
/** Keeps editor filesystem subscriptions alive beyond any individual editor surface. */
export function useEditorExternalWatch(): void {
const { targets, targetsKey } = useAppStore(selectEditorExternalWatchTargets)
const snapshot = useAppStore(selectEditorExternalWatchTargets)
const targetsRef = useRef<EditorExternalWatchTarget[]>([])
const latestTargetsRef = useRef<EditorExternalWatchTarget[]>(targets)
latestTargetsRef.current = targets
const unwatchedTargetKeysRef = useRef<ReadonlySet<string>>(new Set())
const remoteWatchUnsubsRef = useRef(new Map<string, () => void>())
const fsChangedHandlerRef = useRef<
((payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void) | null
>(null)
const heldWatchRootsRef = useRef<HeldWatchRoots>(new Map())
const fsChangedHandlerRef = useRef<FsChangedHandler | null>(null)
// Why: diff targets so unchanged worktrees keep their subscription; full teardown on every store change drops events in the gap.
useEffect(() => {
const nextTargets = latestTargetsRef.current
const nextTargets = snapshot.targets
const previousUnwatchedKeys = unwatchedTargetKeysRef.current
unwatchedTargetKeysRef.current = snapshot.unwatchedTargetKeys
const previousTargets = targetsRef.current
const previousKeys = new Set(previousTargets.map(getEditorExternalWatchTargetKey))
const nextKeys = new Set(nextTargets.map(getEditorExternalWatchTargetKey))
@@ -44,6 +57,37 @@ export function useEditorExternalWatch(): void {
(target) => !previousKeys.has(getEditorExternalWatchTargetKey(target))
)
// Why: publish first — a runtime stream that is already ready resyncs synchronously and must find its target.
targetsRef.current = nextTargets
// Why: acquire before release so a root shared by an added and a removed target is never dropped in between.
for (const target of added) {
// Why: writes made while it was unwatched (background trim) were never reported. Resync like a
// reconnect does — one overflow through the normal pipeline — once the watch is in place.
const resync = previousUnwatchedKeys.has(getEditorExternalWatchTargetKey(target))
? () =>
fsChangedHandlerRef.current?.(
markWorktreeCatchUpPayload({
worktreePath: target.worktreePath,
events: [{ kind: 'overflow', absolutePath: target.worktreePath }],
connectionId: target.connectionId
}),
target.runtimeEnvironmentId,
target
)
: undefined
if (target.runtimeEnvironmentId) {
// Why: the host's ready event means its watcher is installed, so nothing after the resync is lost.
subscribeRuntimeTarget(target, remoteWatchUnsubsRef.current, fsChangedHandlerRef, resync)
continue
}
const installed = acquireWatchRoot(heldWatchRootsRef.current, target, (err) => {
// Why: SSH providers can disappear while tabs still reference the worktree; report the failure without an uncaught renderer promise.
warnExternalWatchFailure(target, err)
})
if (resync) {
void installed.then(resync)
}
}
for (const target of removed) {
const key = getEditorExternalWatchTargetKey(target)
const remoteUnsubscribe = remoteWatchUnsubsRef.current.get(key)
@@ -51,41 +95,26 @@ export function useEditorExternalWatch(): void {
remoteUnsubscribe()
remoteWatchUnsubsRef.current.delete(key)
} else {
void window.api.fs.unwatchWorktree({
worktreePath: target.worktreePath,
connectionId: target.connectionId
})
releaseWatchRoot(heldWatchRootsRef.current, target)
}
}
for (const target of added) {
if (target.runtimeEnvironmentId) {
subscribeRuntimeTarget(target, remoteWatchUnsubsRef.current, fsChangedHandlerRef)
continue
}
void window.api.fs
.watchWorktree({
worktreePath: target.worktreePath,
connectionId: target.connectionId
})
.catch((err) => {
// Why: SSH providers can disappear while tabs still reference the worktree; report the failure without an uncaught renderer promise.
warnExternalWatchFailure(target, err)
})
}
targetsRef.current = nextTargets
// Why: final unmount cleanup owns teardown so target changes remain differential.
}, [targetsKey])
// Why: the selector keeps the snapshot identity until a target or unwatched key changes.
}, [snapshot])
// Why: one stable fs:changed listener prevents target-key changes from opening an event-loss gap.
useEffect(() => {
const remoteWatchUnsubs = remoteWatchUnsubsRef.current
const heldWatchRoots = heldWatchRootsRef.current
const { handleFsChanged, dispose } = buildEditorExternalWatchEventHandler(
(worktreePath, runtimeEnvironmentId) =>
targetsRef.current.find(
(worktreePath, runtimeEnvironmentId, connectionId) =>
targetsRef.current.filter(
(target) =>
normalizeRuntimePathForComparison(target.worktreePath) ===
normalizeRuntimePathForComparison(worktreePath) &&
target.runtimeEnvironmentId === runtimeEnvironmentId
target.runtimeEnvironmentId === runtimeEnvironmentId &&
// Why: a local and an SSH root can share a path; runtime streams are already per host.
(runtimeEnvironmentId !== null || target.connectionId === connectionId)
)
)
const unsubscribe = window.api.fs.onFsChanged((payload) => handleFsChanged(payload, null))
@@ -95,20 +124,13 @@ export function useEditorExternalWatch(): void {
unsubscribe()
dispose()
fsChangedHandlerRef.current = null
for (const target of targetsRef.current) {
const key = getEditorExternalWatchTargetKey(target)
const remoteUnsubscribe = remoteWatchUnsubs.get(key)
if (remoteUnsubscribe) {
remoteUnsubscribe()
} else {
void window.api.fs.unwatchWorktree({
worktreePath: target.worktreePath,
connectionId: target.connectionId
})
}
for (const remoteUnsubscribe of remoteWatchUnsubs.values()) {
remoteUnsubscribe()
}
remoteWatchUnsubs.clear()
releaseAllWatchRoots(heldWatchRoots)
targetsRef.current = []
unwatchedTargetKeysRef.current = new Set()
// Why: module-scoped reload timers survive StrictMode's synthetic cleanup; a late reload dispatch is harmless.
}
}, [])
@@ -117,9 +139,8 @@ export function useEditorExternalWatch(): void {
function subscribeRuntimeTarget(
target: EditorExternalWatchTarget,
remoteWatchUnsubs: Map<string, () => void>,
fsChangedHandlerRef: {
current: ((payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void) | null
}
fsChangedHandlerRef: { current: FsChangedHandler | null },
onFirstReady: (() => void) | undefined
): void {
const runtimeEnvironmentId = target.runtimeEnvironmentId
if (!runtimeEnvironmentId) {
@@ -131,7 +152,8 @@ function subscribeRuntimeTarget(
subscribeEditorRuntimeFileWatch(
{ ...target, runtimeEnvironmentId },
(payload) => fsChangedHandlerRef.current?.(payload, runtimeEnvironmentId),
(error) => warnExternalWatchFailure(target, error)
(error) => warnExternalWatchFailure(target, error),
onFirstReady
)
)
}
@@ -13,3 +13,15 @@ declare global {
'orca:worktree-file-change': CustomEvent<WorktreeFileChangeEventDetail>
}
}
// Why: a switch-back catch-up is synthesized in the renderer, so consumers on the raw IPC bus never see it.
const catchUpPayloads = new WeakSet<FsChangedPayload>()
export function markWorktreeCatchUpPayload(payload: FsChangedPayload): FsChangedPayload {
catchUpPayloads.add(payload)
return payload
}
export function isWorktreeCatchUpPayload(payload: FsChangedPayload): boolean {
return catchUpPayloads.has(payload)
}
@@ -15,6 +15,8 @@ type RuntimeFileWatchEvent =
type RuntimeFileWatchListener = {
onPayload: (payload: FsChangedPayload) => void
onError?: (error: Error) => void
/** Fires once the host reports its watcher installed (immediately when joining a live one). */
onReady?: () => void
}
type SharedRuntimeFileWatch = {
@@ -26,6 +28,7 @@ type SharedRuntimeFileWatch = {
remoteSubscriptionId: string | null
keepStreamUntilReady: boolean
closed: boolean
ready: boolean
}
const sharedRuntimeFileWatches = new Map<string, SharedRuntimeFileWatch>()
@@ -41,14 +44,17 @@ function getSharedRuntimeFileWatchKey(
export async function subscribeRuntimeFileChanges(
context: RuntimeFileOperationArgs,
onPayload: (payload: FsChangedPayload) => void,
onError?: (error: Error) => void
onError?: (error: Error) => void,
onReady?: () => void
): Promise<() => void> {
const target = context.target
if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) {
return window.api.fs.onFsChanged(onPayload)
const unsubscribe = window.api.fs.onFsChanged(onPayload)
onReady?.()
return unsubscribe
}
const listener: RuntimeFileWatchListener = { onPayload, onError }
const listener: RuntimeFileWatchListener = { onPayload, onError, onReady }
const key = getSharedRuntimeFileWatchKey(
target.environmentId,
context.worktreeId,
@@ -60,6 +66,9 @@ export async function subscribeRuntimeFileChanges(
sharedRuntimeFileWatches.set(key, shared)
}
shared.listeners.add(listener)
if (shared.ready) {
onReady?.()
}
try {
await shared.start
} catch (err) {
@@ -93,7 +102,8 @@ function createSharedRuntimeFileWatch(
unsubscribe: null,
remoteSubscriptionId: null,
keepStreamUntilReady: isWebRuntimeFileWatchSharedSocket(),
closed: false
closed: false,
ready: false
}
// Why: editor reloads and the Explorer can watch the same remote worktree.
// Keep one runtime WebSocket/server watcher and fan out events in renderer.
@@ -157,6 +167,11 @@ function handleSharedRuntimeFileWatchResponse(
if (!shared.keepStreamUntilReady) {
unwatchSharedRuntimeFileWatch(shared)
}
} else if (event.type === 'ready' && !shared.ready) {
shared.ready = true
for (const listener of Array.from(shared.listeners)) {
listener.onReady?.()
}
}
} else if (event.type === 'changed') {
for (const listener of Array.from(shared.listeners)) {
@@ -86,3 +86,32 @@ it('remote errors stay errors, and an out-of-scope path cannot read the local fi
])
expect(fsPathExists).not.toHaveBeenCalled()
})
it('coded-absence mode reports a file gone only from a per-path answer', async () => {
const strict = { codedAbsenceOnly: true }
status(['files.pathsExist'])
runtimeEnvironmentCall.mockResolvedValue({
id: 'batch',
ok: true,
result: [{ exists: false }, { error: 'ENOENT: no such file or directory' }]
})
expect(await runtimePathsExist(context, paths.slice(0, 2), undefined, strict)).toEqual([
{ exists: false },
{ error: 'ENOENT: no such file or directory' }
])
// A whole-request "not found" (re-pairing) must not read as every path missing.
runtimeEnvironmentCall.mockResolvedValue({
id: 'batch',
ok: false,
error: { code: 'not_found', message: 'worktree not found' }
})
await expect(runtimePathsExist(context, [paths[0]], undefined, strict)).rejects.toThrow()
})
it('coded-absence mode cannot get a per-path answer from an old host', async () => {
status([])
expect(
await runtimePathsExist(context, [paths[0]], undefined, { codedAbsenceOnly: true })
).toEqual([{ error: expect.any(String) }])
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.stat' })
)
})
@@ -11,24 +11,36 @@ import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-
import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
import { RuntimeRpcCallError } from './runtime-rpc-result'
// Machine token, not UI copy: callers treat any error row as "cannot tell".
const UNVERIFIABLE_PATH_EXISTENCE = 'unverifiable'
/**
* `codedAbsenceOnly`: report `exists: false` only from a per-path ENOENT, never from a message match
* or a whole-request "not found"; anything less certain comes back as an error row.
*/
export async function runtimePathsExist(
context: RuntimeFileOperationArgs,
paths: string[],
expectedPairingRevision?: number
expectedPairingRevision?: number,
options: { codedAbsenceOnly?: boolean } = {}
): Promise<PathExistenceResult[]> {
const unverifiable = (): PathExistenceResult[] =>
paths.map(() => ({ error: UNVERIFIABLE_PATH_EXISTENCE }))
const routes = paths.map((path) => getRemoteFileArgs(context, path))
const first = routes[0]
const expectedEnvironmentPairingRevision = first
? captureRuntimeEnvironmentRequestRevision(first.target.environmentId, expectedPairingRevision)
: undefined
const fallback = () =>
Promise.all(
paths.map((path) =>
capturePathExistence(() =>
runtimePathExists(context, path, expectedEnvironmentPairingRevision)
const fallback = async (): Promise<PathExistenceResult[]> =>
options.codedAbsenceOnly
? unverifiable()
: Promise.all(
paths.map((path) =>
capturePathExistence(() =>
runtimePathExists(context, path, expectedEnvironmentPairingRevision)
)
)
)
)
)
if (!first || routes.some((route) => !route)) {
if (routes.every((route) => !route) && window.api.fs.pathsExist) {
// Scalar routing performs the same ownership fence before local IPC.
@@ -62,6 +74,9 @@ export async function runtimePathsExist(
),
paths.length
)
if (options.codedAbsenceOnly) {
return result
}
// Preserve runtimePathExists's legacy missing-error interpretation.
return result.map((row) =>
'error' in row && isMissingRuntimePathError(row.error) ? { exists: false } : row
@@ -70,7 +85,7 @@ export async function runtimePathsExist(
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
return fallback()
}
if (isMissingRuntimePathError(error)) {
if (!options.codedAbsenceOnly && isMissingRuntimePathError(error)) {
return paths.map(() => ({ exists: false }))
}
throw error
+2
View File
@@ -27,4 +27,6 @@ export type FsChangeEvent = {
export type FsChangedPayload = {
worktreePath: string
events: FsChangeEvent[]
/** SSH connection the root was watched on; absent for local roots. Lets the renderer tell a local and an SSH root at the same path apart. */
connectionId?: string
}