Keep file changes fresh after external writes (#6289)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4ac1e960d4
commit
cfb7de4f8f
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue