From cfb7de4f8f3e08c5c7514fa28729fcefc85d6109 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:11:54 -0700 Subject: [PATCH] Keep file changes fresh after external writes (#6289) Co-authored-by: Orca --- .../useEditorPanelContentState.test.tsx | 241 ++++++++++- .../editor/useEditorPanelContentState.ts | 51 ++- .../useEditorPanelExternalContentEvents.ts | 40 +- .../git-status-file-watch-refresh.ts | 145 +++++++ .../right-sidebar/useGitStatusPolling.test.ts | 407 +++++++++++++++++- .../right-sidebar/useGitStatusPolling.ts | 16 + .../useEditorExternalWatch-targets.test.ts | 76 +++- .../src/hooks/useEditorExternalWatch.ts | 64 ++- .../src/hooks/worktree-file-change-event.ts | 8 + 9 files changed, 1019 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts create mode 100644 src/renderer/src/hooks/worktree-file-change-event.ts diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 711c53c4f..f45a4e56c 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -45,9 +45,40 @@ vi.mock('@/store', () => ({ })) import { useEditorPanelContentState } from './useEditorPanelContentState' +import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave' + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (reason: unknown) => void +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((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 } @@ -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() + const secondRead = createDeferred() + mocks.readRuntimeFileContent + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(secondRead.promise) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + 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() + const freshRead = createDeferred() + mocks.readRuntimeFileContent + .mockReturnValueOnce(staleRead.promise) + .mockReturnValueOnce(freshRead.promise) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + 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() + mocks.readRuntimeFileContent.mockReturnValueOnce(conflictRead.promise) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render( + + ) + }) + 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() + const freshRead = createDeferred() + mocks.readRuntimeFileContent + .mockReturnValueOnce(staleRead.promise) + .mockReturnValueOnce(freshRead.promise) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1)) + + await act(async () => { + root?.render() + }) + expect(latestFileContents[activeFile.id]).toBeUndefined() + + await act(async () => { + root?.render() + }) + 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() + const freshDiff = createDeferred() + mocks.getRuntimeGitDiff + .mockReturnValueOnce(staleDiff.promise) + .mockReturnValueOnce(freshDiff.promise) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + 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') + }) }) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 7ed30e952..bd01423f9 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -78,6 +78,12 @@ export function useEditorPanelContentState({ const diffContentsRef = useRef(diffContents) diffContentsRef.current = diffContents const fileLoadRetryAttemptsRef = useRef>({}) + // 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>({}) + const diffReadGenerationRef = useRef>({}) + 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 => { + 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 } } diff --git a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts index d6bff0563..3b70c27b4 100644 --- a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts +++ b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts @@ -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 openFilesRef: MutableRefObject editorViewModeRef: MutableRefObject @@ -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>, + fileReadGenerationRef: MutableRefObject>, + diffReadGenerationRef: MutableRefObject>, setFileContents: Dispatch>>, setDiffContents: Dispatch>> ): void { + const knownOpenFileIdsRef = useRef>(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 + ]) } diff --git a/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts new file mode 100644 index 000000000..a0f8d6a5d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts @@ -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 | 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 | 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).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 + ]) +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index c534a7859..5d374e1b7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -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 gitConflictOperationByWorktree: Record sshConnectionStates: Map + rightSidebarOpen?: boolean + rightSidebarTab?: string + rightSidebarExplorerView?: string + openFiles?: unknown[] + gitStatusHugeByWorktree?: Record } 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() + 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('react') + return { + ...actual, + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useMemo: (factory: () => unknown) => factory(), + useRef: (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() + 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('react') + return { + ...actual, + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useMemo: (factory: () => unknown) => factory(), + useRef: (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() + 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('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: (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() }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 3bab1c698..9f93623ef 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -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. diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index 7d0679fba..1e5f5e45e 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -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') diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 10fa84231..2fc422444 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -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(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 diff --git a/src/renderer/src/hooks/worktree-file-change-event.ts b/src/renderer/src/hooks/worktree-file-change-event.ts new file mode 100644 index 000000000..67c234202 --- /dev/null +++ b/src/renderer/src/hooks/worktree-file-change-event.ts @@ -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 +}