Keep file changes fresh after external writes (#6289)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-24 14:11:54 -07:00 committed by GitHub
parent 4ac1e960d4
commit cfb7de4f8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1019 additions and 29 deletions

View File

@ -45,9 +45,40 @@ vi.mock('@/store', () => ({
}))
import { useEditorPanelContentState } from './useEditorPanelContentState'
import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave'
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function dispatchExternalFileChange(file: OpenFile, worktreePath: string): void {
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
detail: {
worktreeId: file.worktreeId,
worktreePath,
relativePath: file.relativePath
}
})
)
})
}
type ProbeProps = {
activeFile: OpenFile
activeFile: OpenFile | null
openFiles: OpenFile[]
gitStatusByWorktree?: Record<string, GitStatusEntry[]>
}
@ -278,4 +309,212 @@ describe('useEditorPanelContentState', () => {
)
expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2)
})
it('starts a fresh file read for a forced reload instead of reusing the in-flight read', async () => {
// A reload nonce on mount makes the lazy-load read and the forced reload
// fire in the same effect flush, while the first read is still registered
// in flight. The forced reload must delete that entry and start a new read.
const activeFile = createOpenFile({ fileContentReloadNonce: 1 })
const firstRead = createDeferred<FileContent>()
const secondRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent
.mockReturnValueOnce(firstRead.promise)
.mockReturnValueOnce(secondRead.promise)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
await act(async () => {
secondRead.resolve({ content: 'fresh content', isBinary: false })
await secondRead.promise
})
await vi.waitFor(() =>
expect(latestFileContents[activeFile.id]?.content).toBe('fresh content')
)
})
it('ignores an older file read that resolves after a newer forced read', async () => {
const activeFile = createOpenFile()
const staleRead = createDeferred<FileContent>()
const freshRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent
.mockReturnValueOnce(staleRead.promise)
.mockReturnValueOnce(freshRead.promise)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1))
dispatchExternalFileChange(activeFile, '/repo')
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
await act(async () => {
freshRead.resolve({ content: 'fresh content', isBinary: false })
await freshRead.promise
})
await vi.waitFor(() =>
expect(latestFileContents[activeFile.id]?.content).toBe('fresh content')
)
// The older read resolving last must not clobber the fresh content.
await act(async () => {
staleRead.resolve({ content: 'stale content', isBinary: false })
await staleRead.promise
})
expect(latestFileContents[activeFile.id]?.content).toBe('fresh content')
})
it('keeps non-tab conflict-review file generations until the load resolves', async () => {
const activeFile = createOpenFile({
id: 'wt-1::conflict-review',
filePath: '/repo',
relativePath: 'Conflict Review',
language: 'plaintext',
mode: 'conflict-review',
conflictReview: {
source: 'live-summary',
snapshotTimestamp: 123,
entries: [{ path: 'src/conflict.ts', conflictKind: 'both_modified' }]
}
})
const conflictRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent.mockReturnValueOnce(conflictRead.promise)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<HookProbe
activeFile={activeFile}
openFiles={[activeFile]}
gitStatusByWorktree={{
'wt-1': [
{
path: 'src/conflict.ts',
status: 'modified',
area: 'unstaged',
conflictStatus: 'unresolved',
conflictKind: 'both_modified'
}
]
}}
/>
)
})
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1))
await act(async () => {
conflictRead.resolve({ content: '<<<<<<< HEAD\ncurrent\n=======\nincoming\n>>>>>>> branch', isBinary: false })
await conflictRead.promise
})
expect(latestFileContents['/repo/src/conflict.ts']?.content).toContain('incoming')
})
it('ignores an older file read after closing and reopening the same tab id', async () => {
const activeFile = createOpenFile()
const staleRead = createDeferred<FileContent>()
const freshRead = createDeferred<FileContent>()
mocks.readRuntimeFileContent
.mockReturnValueOnce(staleRead.promise)
.mockReturnValueOnce(freshRead.promise)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1))
await act(async () => {
root?.render(<HookProbe activeFile={null} openFiles={[]} />)
})
expect(latestFileContents[activeFile.id]).toBeUndefined()
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(2))
await act(async () => {
freshRead.resolve({ content: 'fresh reopen content', isBinary: false })
await freshRead.promise
})
await vi.waitFor(() =>
expect(latestFileContents[activeFile.id]?.content).toBe('fresh reopen content')
)
await act(async () => {
staleRead.resolve({ content: 'stale pre-close content', isBinary: false })
await staleRead.promise
})
expect(latestFileContents[activeFile.id]?.content).toBe('fresh reopen content')
})
it('ignores an older diff read that resolves after a newer forced diff read', async () => {
const activeFile = createOpenFile({
id: 'wt-1::diff::unstaged::file.ts',
mode: 'diff',
diffSource: 'unstaged'
})
const staleDiff = createDeferred<DiffContent>()
const freshDiff = createDeferred<DiffContent>()
mocks.getRuntimeGitDiff
.mockReturnValueOnce(staleDiff.promise)
.mockReturnValueOnce(freshDiff.promise)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
await vi.waitFor(() => expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(1))
dispatchExternalFileChange(activeFile, '/repo')
await vi.waitFor(() => expect(mocks.getRuntimeGitDiff).toHaveBeenCalledTimes(2))
await act(async () => {
freshDiff.resolve({
kind: 'text',
originalContent: 'old',
modifiedContent: 'fresh diff content',
originalIsBinary: false,
modifiedIsBinary: false
})
await freshDiff.promise
})
await vi.waitFor(() =>
expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('fresh diff content')
)
await act(async () => {
staleDiff.resolve({
kind: 'text',
originalContent: 'old',
modifiedContent: 'stale diff content',
originalIsBinary: false,
modifiedIsBinary: false
})
await staleDiff.promise
})
expect(latestDiffContents[activeFile.id]?.modifiedContent).toBe('fresh diff content')
})
})

View File

@ -78,6 +78,12 @@ export function useEditorPanelContentState({
const diffContentsRef = useRef(diffContents)
diffContentsRef.current = diffContents
const fileLoadRetryAttemptsRef = useRef<Record<string, number>>({})
// Why: per-tab read generations let a forced/external reload supersede an
// older in-flight read so a slower stale promise cannot overwrite fresh state.
const fileReadGenerationRef = useRef<Record<string, number>>({})
const diffReadGenerationRef = useRef<Record<string, number>>({})
const fileReadGenerationCounterRef = useRef(0)
const diffReadGenerationCounterRef = useRef(0)
const openFilesRef = useRef(openFiles)
openFilesRef.current = openFiles
const editorViewModeRef = useRef(editorViewMode)
@ -92,8 +98,12 @@ export function useEditorPanelContentState({
filePath: string,
id: string,
worktreeId?: string,
relativePath?: string
relativePath?: string,
options?: { force?: boolean }
): Promise<void> => {
const generation = fileReadGenerationCounterRef.current + 1
fileReadGenerationCounterRef.current = generation
fileReadGenerationRef.current[id] = generation
try {
const connectionId = getConnectionIdForFile(worktreeId ?? null, filePath) ?? undefined
const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
@ -115,6 +125,11 @@ export function useEditorPanelContentState({
}
const readScope = getRuntimeFileReadScope(readSettings, connectionId)
const key = inFlightReadKey(readScope, filePath)
if (options?.force) {
// Why: forced reloads must not attach to a currently registered read
// started before the external change landed.
inFlightFileReads.delete(key)
}
let pending = inFlightFileReads.get(key)
if (!pending) {
pending = readRuntimeFileContent({
@ -132,9 +147,15 @@ export function useEditorPanelContentState({
})
}
const result = await pending
if (fileReadGenerationRef.current[id] !== generation) {
return
}
delete fileLoadRetryAttemptsRef.current[id]
setFileContents((prev) => ({ ...prev, [id]: result }))
} catch (err) {
if (fileReadGenerationRef.current[id] !== generation) {
return
}
const message = err instanceof Error ? err.message : String(err)
setFileContents((prev) => ({
...prev,
@ -150,6 +171,9 @@ export function useEditorPanelContentState({
if (!file || (file.mode === 'edit' && !canUseChangesModeForFile(file))) {
return
}
const generation = diffReadGenerationCounterRef.current + 1
diffReadGenerationCounterRef.current = generation
diffReadGenerationRef.current[file.id] = generation
try {
const worktreePath = file.filePath.slice(
0,
@ -173,6 +197,8 @@ export function useEditorPanelContentState({
compareAgainstHead
)
if (options?.force) {
// Why: forced diff reloads must not attach to a read started before
// the external change landed.
inFlightDiffReads.delete(key)
}
let pending = inFlightDiffReads.get(key)
@ -236,8 +262,14 @@ export function useEditorPanelContentState({
})
}
const result = await pending
if (diffReadGenerationRef.current[file.id] !== generation) {
return
}
setDiffContents((prev) => ({ ...prev, [file.id]: result }))
} catch (err) {
if (diffReadGenerationRef.current[file.id] !== generation) {
return
}
setDiffContents((prev) => ({
...prev,
[file.id]: {
@ -264,7 +296,9 @@ export function useEditorPanelContentState({
delete next[file.id]
return next
})
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath)
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath, {
force: true
})
},
[loadFileContent]
)
@ -432,7 +466,9 @@ export function useEditorPanelContentState({
delete next[current.id]
return next
})
void loadFileContent(current.filePath, current.id, current.worktreeId, current.relativePath)
void loadFileContent(current.filePath, current.id, current.worktreeId, current.relativePath, {
force: true
})
}, [activeFile?.fileContentReloadNonce, activeFile?.filePath, activeFile?.id, loadFileContent])
useEditorPanelExternalContentEvents({
@ -443,7 +479,14 @@ export function useEditorPanelContentState({
setFileContents,
setDiffContents
})
usePruneClosedEditorContent(openFiles, fileLoadRetryAttemptsRef, setFileContents, setDiffContents)
usePruneClosedEditorContent(
openFiles,
fileLoadRetryAttemptsRef,
fileReadGenerationRef,
diffReadGenerationRef,
setFileContents,
setDiffContents
)
return { fileContents, diffContents, reloadFileContent }
}

View File

@ -1,4 +1,4 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import {
@ -19,7 +19,8 @@ type UseEditorPanelExternalContentEventsParams = {
filePath: string,
id: string,
worktreeId?: string,
relativePath?: string
relativePath?: string,
options?: { force?: boolean }
) => Promise<void>
openFilesRef: MutableRefObject<OpenFile[]>
editorViewModeRef: MutableRefObject<EditorViewModeByFile>
@ -43,7 +44,11 @@ export function useEditorPanelExternalContentEvents({
}
for (const file of getOpenFilesForExternalFileChange(openFilesRef.current, detail)) {
if (file.mode === 'edit' || file.mode === 'markdown-preview') {
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath)
// Why: external writes must replace any in-flight pre-change read so
// the tab shows the new on-disk content, not a stale dedupe result.
void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath, {
force: true
})
if (editorViewModeRef.current[file.id] === 'changes') {
void loadDiffContent(file, { force: true })
}
@ -114,21 +119,48 @@ function updateSavedPreviewTabs(
export function usePruneClosedEditorContent(
openFiles: OpenFile[],
fileLoadRetryAttemptsRef: MutableRefObject<Record<string, number>>,
fileReadGenerationRef: MutableRefObject<Record<string, number>>,
diffReadGenerationRef: MutableRefObject<Record<string, number>>,
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>,
setDiffContents: Dispatch<SetStateAction<Record<string, DiffContent>>>
): void {
const knownOpenFileIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const openIds = new Set(openFiles.map((f) => f.id))
for (const fileId of openIds) {
knownOpenFileIdsRef.current.add(fileId)
}
for (const fileId of Object.keys(fileLoadRetryAttemptsRef.current)) {
if (!openIds.has(fileId)) {
delete fileLoadRetryAttemptsRef.current[fileId]
}
}
// Why: conflict-review entry loads use absolute paths as content ids; only
// ids that have belonged to tabs are safe to prune as closed tabs.
for (const fileId of Object.keys(fileReadGenerationRef.current)) {
if (knownOpenFileIdsRef.current.has(fileId) && !openIds.has(fileId)) {
delete fileReadGenerationRef.current[fileId]
}
}
for (const fileId of Object.keys(diffReadGenerationRef.current)) {
if (knownOpenFileIdsRef.current.has(fileId) && !openIds.has(fileId)) {
delete diffReadGenerationRef.current[fileId]
}
}
setFileContents((prev) =>
Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key)))
)
setDiffContents((prev) =>
Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key)))
)
}, [fileLoadRetryAttemptsRef, openFiles, setDiffContents, setFileContents])
}, [
diffReadGenerationRef,
fileLoadRetryAttemptsRef,
fileReadGenerationRef,
knownOpenFileIdsRef,
openFiles,
setDiffContents,
setFileContents
])
}

View File

@ -0,0 +1,145 @@
import { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
import type {
ActiveRightSidebarTab,
FsChangedPayload,
RightSidebarExplorerView
} from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
import {
ORCA_WORKTREE_FILE_CHANGE_EVENT,
type WorktreeFileChangeEventDetail
} from '@/hooks/worktree-file-change-event'
const WATCH_REFRESH_DEBOUNCE_MS = 125
type UseGitStatusFileWatchRefreshParams = {
activeConnectionId: string | null
activeRepoSupportsGit: boolean
activeWorktreeId: string | null
enabled: boolean
fetchStatus: () => void
gitStatusHugeByWorktree: Record<string, unknown> | undefined
isConnectionReady: (connectionId: string | null | undefined) => boolean
openFiles: OpenFile[]
rightSidebarExplorerView?: RightSidebarExplorerView
rightSidebarOpen: boolean
rightSidebarTab: ActiveRightSidebarTab
worktreePath: string | null
}
export function shouldRefreshGitStatusForFileChange(
payload: FsChangedPayload,
worktreePath: string
): boolean {
if (
normalizeRuntimePathForComparison(payload.worktreePath) !==
normalizeRuntimePathForComparison(worktreePath)
) {
return false
}
return payload.events.some((event) => {
if (event.kind === 'overflow') {
return true
}
if (event.isDirectory === true) {
return false
}
return relativePathInsideRoot(worktreePath, event.absolutePath) !== null
})
}
export function useGitStatusFileWatchRefresh({
activeConnectionId,
activeRepoSupportsGit,
activeWorktreeId,
enabled,
fetchStatus,
gitStatusHugeByWorktree,
isConnectionReady,
openFiles,
rightSidebarExplorerView,
rightSidebarOpen,
rightSidebarTab,
worktreePath
}: UseGitStatusFileWatchRefreshParams): void {
const activeRuntimeEnvironmentId = useAppStore((state) =>
getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)
)
const fetchStatusRef = useRef(fetchStatus)
fetchStatusRef.current = fetchStatus
const shouldSubscribe =
enabled &&
!!activeWorktreeId &&
!!worktreePath &&
activeRepoSupportsGit &&
shouldPollActiveGitStatus({
activeWorktreeId,
worktreePath,
rightSidebarOpen,
rightSidebarTab,
rightSidebarExplorerView,
openFiles
}) &&
isConnectionReady(activeConnectionId) &&
!gitStatusHugeByWorktree?.[activeWorktreeId]
useEffect(() => {
if (!shouldSubscribe || !worktreePath) {
return
}
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const scheduleRefresh = (): void => {
if (!isWindowVisible()) {
return
}
if (refreshTimer) {
clearTimeout(refreshTimer)
}
// Why: file watchers deliver atomic writes as bursts, but git status is
// already coalesced and should only be nudged once per burst.
refreshTimer = setTimeout(() => {
refreshTimer = null
if (!isWindowVisible()) {
return
}
fetchStatusRef.current()
}, WATCH_REFRESH_DEBOUNCE_MS)
}
const handleFsChanged = (event: Event): void => {
const detail = (event as CustomEvent<WorktreeFileChangeEventDetail>).detail
if (!detail) {
return
}
if ((detail.runtimeEnvironmentId ?? null) !== (activeRuntimeEnvironmentId ?? null)) {
return
}
const { payload } = detail
if (shouldRefreshGitStatusForFileChange(payload, worktreePath)) {
scheduleRefresh()
}
}
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleFsChanged as EventListener)
return () => {
if (refreshTimer) {
clearTimeout(refreshTimer)
}
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleFsChanged as EventListener)
}
}, [
activeRuntimeEnvironmentId,
shouldSubscribe,
worktreePath
])
}

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as React from 'react'
import type { GitPushTarget, GitStatusResult } from '../../../../shared/types'
import type { FsChangedPayload, GitPushTarget, GitStatusResult } from '../../../../shared/types'
const worktree = { id: 'repo-1::/repo', repoId: 'repo-1', path: '/repo' }
const repo = { id: 'repo-1', path: '/repo', kind: 'git', connectionId: null as string | null }
@ -14,6 +14,11 @@ type PollState = {
setConflictOperation: ReturnType<typeof vi.fn>
gitConflictOperationByWorktree: Record<string, unknown>
sshConnectionStates: Map<string, { status: string }>
rightSidebarOpen?: boolean
rightSidebarTab?: string
rightSidebarExplorerView?: string
openFiles?: unknown[]
gitStatusHugeByWorktree?: Record<string, unknown>
}
type GitStatusPollingHook = (options?: { enabled?: boolean }) => void
@ -57,7 +62,10 @@ async function usePollingOnce(
options.connectionId && options.sshStatus
? [[options.connectionId, { status: options.sshStatus }]]
: []
)
),
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
openFiles: []
}
const mockedRepo = { ...repo, connectionId: options.connectionId ?? null }
const gitStatus = vi.fn().mockResolvedValue(status)
@ -99,6 +107,11 @@ async function usePollingOnce(
api: {
git: {
status: gitStatus
},
fs: {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn(),
@ -129,6 +142,7 @@ describe('useGitStatusPolling', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('uses upstream data from git status instead of spawning a separate upstream refresh', async () => {
@ -230,6 +244,381 @@ describe('useGitStatusPolling', () => {
expect(globalThis.setInterval).not.toHaveBeenCalled()
})
it('filters filesystem payloads to files inside the active worktree', async () => {
vi.resetModules()
const { shouldRefreshGitStatusForFileChange } = await import(
'./git-status-file-watch-refresh'
)
expect(
shouldRefreshGitStatusForFileChange(
{ worktreePath: '/repo', events: [{ kind: 'update', absolutePath: '/repo/src/app.ts' }] },
'/repo'
)
).toBe(true)
expect(
shouldRefreshGitStatusForFileChange(
{
worktreePath: '/repo',
events: [{ kind: 'update', absolutePath: '/repo/src', isDirectory: true }]
},
'/repo'
)
).toBe(false)
expect(
shouldRefreshGitStatusForFileChange(
{ worktreePath: '/other', events: [{ kind: 'overflow', absolutePath: '/other' }] },
'/repo'
)
).toBe(false)
expect(
shouldRefreshGitStatusForFileChange(
{ worktreePath: '/repo', events: [{ kind: 'overflow', absolutePath: '/repo' }] },
'/repo'
)
).toBe(true)
})
it('coalesces active worktree file-watch bursts into one git status refresh', async () => {
vi.resetModules()
vi.useFakeTimers()
const windowListeners = new Map<string, EventListener[]>()
const emitWorktreeFileChange = (payload: FsChangedPayload): void => {
for (const listener of windowListeners.get('orca:worktree-file-change') ?? []) {
listener({ detail: { payload, runtimeEnvironmentId: null } } as CustomEvent)
}
}
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
head: 'abc123',
branch: 'refs/heads/main'
}
const state: PollState = {
activeWorktreeId: worktree.id,
updateWorktreeGitIdentity: vi.fn(),
setGitStatus: vi.fn(),
fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined),
setUpstreamStatus: vi.fn(),
setConflictOperation: vi.fn(),
gitConflictOperationByWorktree: {},
sshConnectionStates: new Map(),
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
openFiles: []
}
const gitStatus = vi.fn().mockResolvedValue(status)
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof React>('react')
return {
...actual,
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void | (() => void)) => {
effect()
},
useMemo: (factory: () => unknown) => factory(),
useRef: <T>(initial: T) => ({ current: initial })
}
})
vi.doMock('@/store', () => ({
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
getState: () => ({ settings: null })
})
}))
vi.doMock('@/store/selectors', () => ({
useActiveWorktree: () => worktree,
useWorktreeById: () => worktree,
useAllWorktrees: () => [worktree],
useRepoById: () => repo,
useRepoMap: () => new Map([[repo.id, repo]])
}))
vi.doMock('@/lib/connection-context', () => ({ getConnectionId: () => undefined }))
vi.stubGlobal('window', {
api: {
git: { status: gitStatus },
fs: {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener])
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(
type,
(windowListeners.get(type) ?? []).filter((candidate) => candidate !== listener)
)
})
})
vi.stubGlobal('document', {
visibilityState: 'visible',
hasFocus: () => true,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling')
GitStatusPollingHarness({ runPolling })
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
expect(windowListeners.get('orca:worktree-file-change')?.length).toBe(1)
emitWorktreeFileChange({
worktreePath: '/repo',
events: [{ kind: 'update', absolutePath: '/repo/a.ts' }]
})
emitWorktreeFileChange({
worktreePath: '/repo',
events: [{ kind: 'create', absolutePath: '/repo/b.ts' }]
})
emitWorktreeFileChange({
worktreePath: '/other',
events: [{ kind: 'update', absolutePath: '/other/c.ts' }]
})
await vi.advanceTimersByTimeAsync(124)
expect(gitStatus).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2))
expect(window.api.fs.watchWorktree).not.toHaveBeenCalled()
expect(window.api.fs.unwatchWorktree).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('does not refresh git status from file-watch events while the window is hidden', async () => {
vi.resetModules()
vi.useFakeTimers()
const windowListeners = new Map<string, EventListener[]>()
const emitWorktreeFileChange = (payload: FsChangedPayload): void => {
for (const listener of windowListeners.get('orca:worktree-file-change') ?? []) {
listener({ detail: { payload, runtimeEnvironmentId: null } } as CustomEvent)
}
}
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
head: 'abc123',
branch: 'refs/heads/main'
}
const state: PollState = {
activeWorktreeId: worktree.id,
updateWorktreeGitIdentity: vi.fn(),
setGitStatus: vi.fn(),
fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined),
setUpstreamStatus: vi.fn(),
setConflictOperation: vi.fn(),
gitConflictOperationByWorktree: {},
sshConnectionStates: new Map(),
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
openFiles: []
}
const gitStatus = vi.fn().mockResolvedValue(status)
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof React>('react')
return {
...actual,
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void | (() => void)) => {
effect()
},
useMemo: (factory: () => unknown) => factory(),
useRef: <T>(initial: T) => ({ current: initial })
}
})
vi.doMock('@/store', () => ({
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
getState: () => ({ settings: null })
})
}))
vi.doMock('@/store/selectors', () => ({
useActiveWorktree: () => worktree,
useWorktreeById: () => worktree,
useAllWorktrees: () => [worktree],
useRepoById: () => repo,
useRepoMap: () => new Map([[repo.id, repo]])
}))
vi.doMock('@/lib/connection-context', () => ({ getConnectionId: () => undefined }))
vi.stubGlobal('window', {
api: {
git: { status: gitStatus },
fs: {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener])
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(
type,
(windowListeners.get(type) ?? []).filter((candidate) => candidate !== listener)
)
})
})
vi.stubGlobal('document', {
visibilityState: 'hidden',
hasFocus: () => false,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling')
GitStatusPollingHarness({ runPolling })
expect(windowListeners.get('orca:worktree-file-change')?.length).toBe(1)
emitWorktreeFileChange({
worktreePath: '/repo',
events: [{ kind: 'update', absolutePath: '/repo/a.ts' }]
})
await vi.advanceTimersByTimeAsync(200)
expect(gitStatus).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('keeps a pending file-watch refresh across harmless open-file rerenders', async () => {
vi.resetModules()
vi.useFakeTimers()
const windowListeners = new Map<string, EventListener[]>()
const emitWorktreeFileChange = (payload: FsChangedPayload): void => {
for (const listener of windowListeners.get('orca:worktree-file-change') ?? []) {
listener({ detail: { payload, runtimeEnvironmentId: null } } as CustomEvent)
}
}
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
head: 'abc123',
branch: 'refs/heads/main'
}
const state: PollState = {
activeWorktreeId: worktree.id,
updateWorktreeGitIdentity: vi.fn(),
setGitStatus: vi.fn(),
fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined),
setUpstreamStatus: vi.fn(),
setConflictOperation: vi.fn(),
gitConflictOperationByWorktree: {},
sshConnectionStates: new Map(),
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
openFiles: []
}
const gitStatus = vi.fn().mockResolvedValue(status)
const effectSlots: {
deps: unknown[] | undefined
cleanup: void | (() => void)
}[] = []
const refSlots: { current: unknown }[] = []
let effectIndex = 0
let refIndex = 0
const depsChanged = (prev: unknown[] | undefined, next: unknown[] | undefined): boolean =>
!prev ||
!next ||
prev.length !== next.length ||
prev.some((value, index) => value !== next[index])
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof React>('react')
return {
...actual,
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void | (() => void), deps?: unknown[]) => {
const index = effectIndex
effectIndex += 1
const previous = effectSlots[index]
if (!previous || depsChanged(previous.deps, deps)) {
previous?.cleanup?.()
effectSlots[index] = { deps, cleanup: effect() }
}
},
useMemo: (factory: () => unknown) => factory(),
useRef: <T>(initial: T) => {
const index = refIndex
refIndex += 1
if (!refSlots[index]) {
refSlots[index] = { current: initial }
}
return refSlots[index] as { current: T }
}
}
})
vi.doMock('@/store', () => ({
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
getState: () => ({ settings: null })
})
}))
vi.doMock('@/store/selectors', () => ({
useActiveWorktree: () => worktree,
useWorktreeById: () => worktree,
useAllWorktrees: () => [worktree],
useRepoById: () => repo,
useRepoMap: () => new Map([[repo.id, repo]])
}))
vi.doMock('@/lib/connection-context', () => ({ getConnectionId: () => undefined }))
vi.stubGlobal('window', {
api: {
git: { status: gitStatus },
fs: {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener])
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(
type,
(windowListeners.get(type) ?? []).filter((candidate) => candidate !== listener)
)
})
})
vi.stubGlobal('document', {
visibilityState: 'visible',
hasFocus: () => true,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling')
const renderPolling = (): void => {
effectIndex = 0
refIndex = 0
GitStatusPollingHarness({ runPolling })
}
renderPolling()
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
emitWorktreeFileChange({
worktreePath: '/repo',
events: [{ kind: 'update', absolutePath: '/repo/a.ts' }]
})
await vi.advanceTimersByTimeAsync(60)
state.openFiles = [{}]
renderPolling()
expect(windowListeners.get('orca:worktree-file-change')?.length).toBe(1)
expect(window.removeEventListener).not.toHaveBeenCalledWith(
'orca:worktree-file-change',
expect.any(Function)
)
const callsBeforeDebounceFires = gitStatus.mock.calls.length
await vi.advanceTimersByTimeAsync(65)
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires + 1))
vi.useRealTimers()
})
it('does not overlap slow visible git status polls and runs one trailing refresh', async () => {
vi.resetModules()
let intervalCallback: (() => void) | null = null
@ -245,7 +634,10 @@ describe('useGitStatusPolling', () => {
setUpstreamStatus: vi.fn(),
setConflictOperation: vi.fn(),
gitConflictOperationByWorktree: {},
sshConnectionStates: new Map()
sshConnectionStates: new Map(),
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
openFiles: []
}
const status: GitStatusResult = {
entries: [],
@ -285,7 +677,14 @@ describe('useGitStatusPolling', () => {
}))
vi.stubGlobal('window', {
api: { git: { status: gitStatus } },
api: {
git: { status: gitStatus },
fs: {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})

View File

@ -10,6 +10,7 @@ import { createCoalescedPollRunner } from './coalesced-poll-runner'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh'
const POLL_INTERVAL_MS = 3000
@ -165,6 +166,21 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
return installWindowVisibilityInterval({ run: fetchStatus, intervalMs: POLL_INTERVAL_MS })
}, [enabled, fetchStatus])
useGitStatusFileWatchRefresh({
activeConnectionId,
activeRepoSupportsGit,
activeWorktreeId,
enabled,
fetchStatus,
gitStatusHugeByWorktree,
isConnectionReady,
openFiles,
rightSidebarExplorerView,
rightSidebarOpen,
rightSidebarTab,
worktreePath
})
// Why: poll conflict operation for non-active worktrees that have a stale
// non-unknown operation. This is a lightweight fs-only check (no git status)
// so it won't cause performance issues even with many worktrees.

View File

@ -61,6 +61,8 @@ describe('getEditorExternalWatchTargets', () => {
rightSidebarOpen?: boolean
rightSidebarTab?: EditorExternalWatchTargetState['rightSidebarTab']
rightSidebarExplorerView?: EditorExternalWatchTargetState['rightSidebarExplorerView']
gitStatusHugeByWorktree?: EditorExternalWatchTargetState['gitStatusHugeByWorktree']
sshConnectionStates?: EditorExternalWatchTargetState['sshConnectionStates']
}): EditorExternalWatchTargetState => ({
openFiles: args.openFiles ?? [],
worktreesByRepo: { [args.repo.id]: [args.worktree] },
@ -69,6 +71,8 @@ describe('getEditorExternalWatchTargets', () => {
rightSidebarOpen: args.rightSidebarOpen ?? false,
rightSidebarTab: args.rightSidebarTab ?? 'explorer',
rightSidebarExplorerView: args.rightSidebarExplorerView ?? 'files',
gitStatusHugeByWorktree: args.gitStatusHugeByWorktree ?? {},
sshConnectionStates: args.sshConnectionStates ?? new Map(),
settings:
args.runtimeEnvironmentId === undefined
? null
@ -98,7 +102,7 @@ describe('getEditorExternalWatchTargets', () => {
])
})
it('does not watch the active worktree while the file explorer is hidden', () => {
it('does not watch the active worktree while the sidebar is hidden', () => {
const repo = makeRepo('repo-active')
const worktree = makeWorktree(repo.id, 'wt-active')
@ -150,7 +154,7 @@ describe('getEditorExternalWatchTargets', () => {
).toEqual([])
})
it('does not watch the active worktree when a different right sidebar tab is visible', () => {
it('keeps watching the active worktree when Source Control is visible', () => {
const repo = makeRepo('repo-source-control')
const worktree = makeWorktree(repo.id, 'wt-source-control')
@ -164,9 +168,77 @@ describe('getEditorExternalWatchTargets', () => {
rightSidebarTab: 'source-control'
})
).targets
).toEqual([
{
worktreeId: 'wt-source-control',
worktreePath: '/repo-source-control/worktree',
connectionId: undefined,
runtimeEnvironmentId: null
}
])
})
it('does not watch Source Control-only worktrees when git status is paused as huge', () => {
const repo = makeRepo('repo-source-control-huge')
const worktree = makeWorktree(repo.id, 'wt-source-control-huge')
expect(
getEditorExternalWatchTargets(
makeState({
repo,
worktree,
activeWorktreeId: worktree.id,
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
gitStatusHugeByWorktree: { [worktree.id]: { limit: 1000 } }
})
).targets
).toEqual([])
})
it('does not watch Source Control-only SSH worktrees while disconnected', () => {
const repo = makeRepo('repo-source-control-ssh', 'ssh-1')
const worktree = makeWorktree(repo.id, 'wt-source-control-ssh')
expect(
getEditorExternalWatchTargets(
makeState({
repo,
worktree,
activeWorktreeId: worktree.id,
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
sshConnectionStates: new Map([['ssh-1', { status: 'disconnected' } as never]])
})
).targets
).toEqual([])
})
it('watches Source Control-only SSH worktrees when connected', () => {
const repo = makeRepo('repo-source-control-ssh-connected', 'ssh-1')
const worktree = makeWorktree(repo.id, 'wt-source-control-ssh-connected')
expect(
getEditorExternalWatchTargets(
makeState({
repo,
worktree,
activeWorktreeId: worktree.id,
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
sshConnectionStates: new Map([['ssh-1', { status: 'connected' } as never]])
})
).targets
).toEqual([
{
worktreeId: 'wt-source-control-ssh-connected',
worktreePath: '/repo-source-control-ssh-connected/worktree',
connectionId: 'ssh-1',
runtimeEnvironmentId: null
}
])
})
it('rebuilds ownerless targets when an SSH connection id hydrates', () => {
const localRepo = makeRepo('repo-remote', null)
const remoteRepo = makeRepo('repo-remote', 'ssh-1')

View File

@ -22,6 +22,11 @@ import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
import { readRuntimeFileContent, subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
ORCA_WORKTREE_FILE_CHANGE_EVENT,
type WorktreeFileChangeEventDetail
} from './worktree-file-change-event'
import { isGitRepoKind } from '../../../shared/repo-kind'
// Why: atomic-write patterns (Claude Code's Edit tool, editors like vim,
// VSCode) land as a short burst of `update` events — or `delete + create` on
@ -90,6 +95,8 @@ export type EditorExternalWatchTargetState = Pick<
| 'rightSidebarOpen'
| 'rightSidebarTab'
| 'rightSidebarExplorerView'
| 'gitStatusHugeByWorktree'
| 'sshConnectionStates'
>
let cachedOpenFiles: AppState['openFiles'] | null = null
@ -100,6 +107,8 @@ let cachedRuntimeEnvironmentId: string | undefined
let cachedRightSidebarOpen: boolean | null = null
let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null
let cachedRightSidebarExplorerView: AppState['rightSidebarExplorerView'] | null = null
let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = null
let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null
let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' }
export function getWatchedTargetKey(target: WatchedTarget): string {
@ -125,7 +134,9 @@ export function getEditorExternalWatchTargets(
cachedRuntimeEnvironmentId === runtimeEnvironmentId &&
cachedRightSidebarOpen === state.rightSidebarOpen &&
cachedRightSidebarTab === state.rightSidebarTab &&
cachedRightSidebarExplorerView === state.rightSidebarExplorerView
cachedRightSidebarExplorerView === state.rightSidebarExplorerView &&
cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree &&
cachedSshConnectionStates === state.sshConnectionStates
) {
return cachedWatchedTargetsSnapshot
}
@ -145,23 +156,37 @@ export function getEditorExternalWatchTargets(
// storing the tab, so an ownerless stored tab must stay local here.
owners.add(openFileRuntimeOwner(f))
}
if (
state.activeWorktreeId &&
const activeWorktreeId = state.activeWorktreeId
const activeWorktree = activeWorktreeId
? findWorktreeById(state.worktreesByRepo, activeWorktreeId)
: undefined
const activeRepo = activeWorktree
? state.repos.find((repo) => repo.id === activeWorktree.repoId)
: undefined
const sourceControlCanConsumeWatch =
!!activeWorktreeId &&
!!activeRepo &&
isGitRepoKind(activeRepo) &&
!state.gitStatusHugeByWorktree[activeWorktreeId] &&
(!activeRepo.connectionId ||
state.sshConnectionStates.get(activeRepo.connectionId)?.status === 'connected')
const activeWorktreeNeedsSidebarWatch =
activeWorktreeId !== null &&
state.rightSidebarOpen &&
state.rightSidebarTab === 'explorer' &&
state.rightSidebarExplorerView === 'files'
) {
// Why: the right sidebar stays mounted while hidden; do not create a
// worktree-level watcher just because the user clicked a workspace.
// macOS can surface privacy prompts for those passive filesystem probes.
let owners = targetOwnersByWorktreeId.get(state.activeWorktreeId)
((state.rightSidebarTab === 'explorer' && state.rightSidebarExplorerView === 'files') ||
(state.rightSidebarTab === 'source-control' && sourceControlCanConsumeWatch))
if (activeWorktreeNeedsSidebarWatch) {
// Why: this app-level watcher owns subscriptions for Explorer and Source
// Control so downstream consumers do not fight over watch/unwatch IPC.
let owners = targetOwnersByWorktreeId.get(activeWorktreeId)
if (!owners) {
owners = new Set()
targetOwnersByWorktreeId.set(state.activeWorktreeId, owners)
targetOwnersByWorktreeId.set(activeWorktreeId, owners)
}
// Why: the Explorer is mounted for the selected worktree. Its watcher must
// follow that worktree's host owner, not the host currently focused in the UI.
owners.add(getRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId))
// Why: sidebar consumers are mounted for the selected worktree. Their
// watcher must follow that worktree's host owner, not the host currently
// focused in the UI.
owners.add(getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId))
}
const nextTargets: WatchedTarget[] = []
@ -197,6 +222,8 @@ export function getEditorExternalWatchTargets(
cachedRightSidebarOpen = state.rightSidebarOpen
cachedRightSidebarTab = state.rightSidebarTab
cachedRightSidebarExplorerView = state.rightSidebarExplorerView
cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree
cachedSshConnectionStates = state.sshConnectionStates
if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) {
return cachedWatchedTargetsSnapshot
@ -411,6 +438,15 @@ export function createExternalWatchEventHandler(
if (!target) {
return
}
// Why: this app-level hook owns worktree watcher subscriptions. Other
// consumers listen here so they do not 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 }
})
)
}
// Why: collect create/update paths first so we can cancel any pending
// same-path delete before scheduling a new one. This is what absorbs

View File

@ -0,0 +1,8 @@
import type { FsChangedPayload } from '../../../shared/types'
export const ORCA_WORKTREE_FILE_CHANGE_EVENT = 'orca:worktree-file-change'
export type WorktreeFileChangeEventDetail = {
payload: FsChangedPayload
runtimeEnvironmentId: string | null
}