mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+69
@@ -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()
|
||||
})
|
||||
+36
-2
@@ -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
|
||||
}
|
||||
|
||||
+64
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user