Push-refresh git status on repo metadata changes and shell command completion (#7086)
Branch switches and commits made inside terminals now surface through push signals instead of waiting for the 30s terminal-only fallback poll: - The git-common watch now also covers the primary checkout's HEAD, packed-refs, and index (a few stat calls per tick; no new native watchers). - worktrees:changed events nudge the active worktree's coalesced git status runner (visibility-gated, 3s floor). - OSC 133;D command completion dispatches a window event that nudges the same runner for the active worktree. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6dafe8c870
commit
b07ea4a1a5
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
matchingWorktreeBaseRepoIds,
|
||||
type WorktreeBaseWatchTarget
|
||||
} from './worktree-base-directory-event-filter'
|
||||
|
||||
const COMMON_DIR = join('/repos', 'project', '.git')
|
||||
|
||||
function makeGitCommonTarget(): WorktreeBaseWatchTarget {
|
||||
return {
|
||||
key: `git-common:local:${COMMON_DIR}`,
|
||||
kind: 'git-common',
|
||||
path: COMMON_DIR,
|
||||
repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]])
|
||||
}
|
||||
}
|
||||
|
||||
describe('matchingWorktreeBaseRepoIds (git-common)', () => {
|
||||
it('matches linked-worktree metadata under worktrees/', () => {
|
||||
const target = makeGitCommonTarget()
|
||||
expect(
|
||||
matchingWorktreeBaseRepoIds(target, {
|
||||
type: 'update',
|
||||
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD')
|
||||
})
|
||||
).toEqual(['repo-1'])
|
||||
expect(
|
||||
matchingWorktreeBaseRepoIds(target, {
|
||||
type: 'create',
|
||||
path: join(COMMON_DIR, 'worktrees', 'wt-b')
|
||||
})
|
||||
).toEqual(['repo-1'])
|
||||
})
|
||||
|
||||
it('matches primary-checkout branch/index metadata at the common-dir top level', () => {
|
||||
const target = makeGitCommonTarget()
|
||||
for (const file of ['HEAD', 'packed-refs', 'index']) {
|
||||
expect(
|
||||
matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) })
|
||||
).toEqual(['repo-1'])
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores non-status common-dir churn', () => {
|
||||
const target = makeGitCommonTarget()
|
||||
for (const path of [
|
||||
join(COMMON_DIR, 'config'),
|
||||
join(COMMON_DIR, 'FETCH_HEAD'),
|
||||
join(COMMON_DIR, 'COMMIT_EDITMSG'),
|
||||
join(COMMON_DIR, 'objects', 'ab', 'cdef'),
|
||||
join(COMMON_DIR, 'refs', 'heads', 'main'),
|
||||
join(COMMON_DIR, 'logs', 'HEAD'),
|
||||
// Nested HEAD outside worktrees/ must not be mistaken for the primary's.
|
||||
join(COMMON_DIR, 'modules', 'sub', 'HEAD')
|
||||
]) {
|
||||
expect(matchingWorktreeBaseRepoIds(target, { type: 'update', path })).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores paths outside the watch root', () => {
|
||||
const target = makeGitCommonTarget()
|
||||
expect(
|
||||
matchingWorktreeBaseRepoIds(target, {
|
||||
type: 'update',
|
||||
path: join('/repos', 'project', 'HEAD')
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -82,11 +82,22 @@ function matchingBaseRepoIds(
|
|||
return repoIds
|
||||
}
|
||||
|
||||
// Why: branch switches and commits in the primary checkout rewrite these
|
||||
// top-level common-dir files; matching them keeps root-checkout branch/status
|
||||
// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored.
|
||||
const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index'])
|
||||
|
||||
// Why: Git records linked worktrees under the common dir's `worktrees`
|
||||
// metadata, which is lower churn than watching checkout contents.
|
||||
function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] {
|
||||
const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath)
|
||||
if (!parts || parts[0] !== 'worktrees') {
|
||||
if (!parts) {
|
||||
return []
|
||||
}
|
||||
if (
|
||||
parts[0] !== 'worktrees' &&
|
||||
!(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0]))
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [...target.repos.keys()]
|
||||
|
|
|
|||
|
|
@ -204,6 +204,33 @@ describe('worktree base directory poller', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('reports primary-checkout HEAD changes via polling', async () => {
|
||||
const commonDir = await makeRoot()
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
const target = makeTarget('git-common', commonDir)
|
||||
const poller = await startWorktreeBaseDirectoryPoller(
|
||||
target,
|
||||
() => target.repos,
|
||||
(events) => received.push(events),
|
||||
// Force the non-darwin poll path so this test is deterministic on all CI.
|
||||
{ pollIntervalMs: POLL_MS, platform: 'linux' }
|
||||
)
|
||||
cleanups.push(() => poller.unsubscribe())
|
||||
|
||||
const headFile = join(commonDir, 'HEAD')
|
||||
await writeFile(headFile, 'ref: refs/heads/main')
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'create' && event.path === headFile)
|
||||
)
|
||||
|
||||
// A branch switch rewrites HEAD in place; the mtime diff must surface it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await writeFile(headFile, 'ref: refs/heads/feature')
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'update' && event.path === headFile)
|
||||
)
|
||||
})
|
||||
|
||||
it('emits deletes for all known worktrees when the root vanishes', async () => {
|
||||
const root = await makeRoot()
|
||||
const worktree = join(root, 'external-5')
|
||||
|
|
@ -255,6 +282,34 @@ describe('worktree base directory poller', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('covers primary-checkout metadata alongside the narrow stream', async () => {
|
||||
const commonDir = await makeRoot()
|
||||
await mkdir(join(commonDir, 'worktrees'))
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
const target = makeTarget('git-common', commonDir)
|
||||
const poller = await startWorktreeBaseDirectoryPoller(
|
||||
target,
|
||||
() => target.repos,
|
||||
(events) => received.push(events),
|
||||
{ pollIntervalMs: POLL_MS, platform: 'darwin' }
|
||||
)
|
||||
cleanups.push(() => poller.unsubscribe())
|
||||
|
||||
// The narrow stream is rooted at worktrees/, so top-level HEAD writes
|
||||
// must arrive through the companion metadata poll.
|
||||
const headFile = join(commonDir, 'HEAD')
|
||||
await writeFile(headFile, 'ref: refs/heads/main')
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'create' && event.path === headFile)
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await writeFile(headFile, 'ref: refs/heads/feature')
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'update' && event.path === headFile)
|
||||
)
|
||||
})
|
||||
|
||||
it('arms via existence polling when the worktrees dir appears later', async () => {
|
||||
const commonDir = await makeRoot()
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
|
|
|
|||
|
|
@ -6,14 +6,37 @@ import type {
|
|||
WorktreeBaseSubscription
|
||||
} from './worktree-base-directory-poller'
|
||||
|
||||
// Watches a repo's `<common>/.git/worktrees` metadata — the only subtree the
|
||||
// git-common event filter consumes.
|
||||
// macOS: a narrow native stream rooted there — a tiny, rare-churn tree —
|
||||
// gives instant detection with zero idle cost and zero wide-scope fseventsd
|
||||
// delivery. Other platforms: dir-listing poll (no fseventsd to protect, and
|
||||
// Watches a repo's `<common>/.git/worktrees` metadata plus the primary
|
||||
// checkout's shallow branch/index files — the only paths the git-common event
|
||||
// filter consumes.
|
||||
// macOS: a narrow native stream rooted at `worktrees/` — a tiny, rare-churn
|
||||
// tree — gives instant detection with zero idle cost and zero wide-scope
|
||||
// fseventsd delivery; the primary files are covered by a few stat calls per
|
||||
// tick (a native stream would have to span the whole common dir, objects
|
||||
// included). Other platforms: dir-listing poll (no fseventsd to protect, and
|
||||
// on Windows an open directory handle on `worktrees/` could interfere with
|
||||
// `git worktree prune` removing it).
|
||||
|
||||
// Why: branch switches and commits made in the primary checkout rewrite these
|
||||
// top-level files (linked-worktree equivalents live under `worktrees/`).
|
||||
// Deliberately excludes FETCH_HEAD-style churn that carries no status change.
|
||||
const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index']
|
||||
|
||||
async function snapshotPrimaryCheckoutMetadata(
|
||||
commonDirPath: string
|
||||
): Promise<Map<string, number>> {
|
||||
const mtimes = new Map<string, number>()
|
||||
for (const name of PRIMARY_CHECKOUT_METADATA_FILES) {
|
||||
const filePath = join(commonDirPath, name)
|
||||
try {
|
||||
mtimes.set(filePath, (await stat(filePath)).mtimeMs)
|
||||
} catch {
|
||||
// Missing file (e.g. no packed-refs yet) diffs into a create later.
|
||||
}
|
||||
}
|
||||
return mtimes
|
||||
}
|
||||
|
||||
async function snapshotGitCommon(commonDirPath: string): Promise<Map<string, number>> {
|
||||
const mtimes = new Map<string, number>()
|
||||
const worktreesDir = join(commonDirPath, 'worktrees')
|
||||
|
|
@ -59,15 +82,15 @@ function diffGitCommon(
|
|||
return events
|
||||
}
|
||||
|
||||
async function startGitCommonPoller(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
async function startSnapshotDiffPoller(
|
||||
takeSnapshot: () => Promise<Map<string, number>>,
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
pollIntervalMs: number,
|
||||
onFullScan?: () => void
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
let disposed = false
|
||||
let ticking = false
|
||||
let snapshot = await snapshotGitCommon(target.path)
|
||||
let snapshot = await takeSnapshot()
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (disposed || ticking) {
|
||||
|
|
@ -75,7 +98,7 @@ async function startGitCommonPoller(
|
|||
}
|
||||
ticking = true
|
||||
onFullScan?.()
|
||||
void snapshotGitCommon(target.path)
|
||||
void takeSnapshot()
|
||||
.then((next) => {
|
||||
if (disposed) {
|
||||
return
|
||||
|
|
@ -103,6 +126,19 @@ async function startGitCommonPoller(
|
|||
}
|
||||
}
|
||||
|
||||
async function snapshotGitCommonAndPrimaryMetadata(
|
||||
commonDirPath: string
|
||||
): Promise<Map<string, number>> {
|
||||
const [worktrees, primary] = await Promise.all([
|
||||
snapshotGitCommon(commonDirPath),
|
||||
snapshotPrimaryCheckoutMetadata(commonDirPath)
|
||||
])
|
||||
for (const [path, mtime] of primary) {
|
||||
worktrees.set(path, mtime)
|
||||
}
|
||||
return worktrees
|
||||
}
|
||||
|
||||
async function startGitCommonNarrowWatch(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
|
|
@ -227,7 +263,25 @@ export async function startGitCommonWatch(
|
|||
onFullScan?: () => void
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
if (platform === 'darwin') {
|
||||
return startGitCommonNarrowWatch(target, onEvents, pollIntervalMs)
|
||||
const [narrowWatch, primaryMetadataPoll] = await Promise.all([
|
||||
startGitCommonNarrowWatch(target, onEvents, pollIntervalMs),
|
||||
startSnapshotDiffPoller(
|
||||
() => snapshotPrimaryCheckoutMetadata(target.path),
|
||||
onEvents,
|
||||
pollIntervalMs,
|
||||
onFullScan
|
||||
)
|
||||
])
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
await Promise.all([narrowWatch.unsubscribe(), primaryMetadataPoll.unsubscribe()])
|
||||
}
|
||||
}
|
||||
}
|
||||
return startGitCommonPoller(target, onEvents, pollIntervalMs, onFullScan)
|
||||
return startSnapshotDiffPoller(
|
||||
() => snapshotGitCommonAndPrimaryMetadata(target.path),
|
||||
onEvents,
|
||||
pollIntervalMs,
|
||||
onFullScan
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as React from 'react'
|
||||
import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT } from '@/hooks/terminal-command-finished-event'
|
||||
|
||||
type WorktreesChangedCallback = (data: { repoId: string }) => void
|
||||
type HookParams = {
|
||||
activeRepoId: string | null
|
||||
activeWorktreeId: string | null
|
||||
enabled: boolean
|
||||
fetchStatus: () => void
|
||||
}
|
||||
|
||||
async function renderHookOnce(params: HookParams): Promise<{
|
||||
emitWorktreesChanged: (repoId: string) => void
|
||||
emitCommandFinished: (worktreeId: string) => void
|
||||
onChangedSubscribe: ReturnType<typeof vi.fn>
|
||||
windowListeners: Map<string, EventListener>
|
||||
cleanups: (() => void)[]
|
||||
}> {
|
||||
vi.resetModules()
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof React>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
const cleanup = effect()
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanups.push(cleanup)
|
||||
}
|
||||
},
|
||||
useRef: <T>(initial: T) => ({ current: initial })
|
||||
}
|
||||
})
|
||||
|
||||
let worktreesChangedCallback: WorktreesChangedCallback | null = null
|
||||
const onChangedUnsubscribe = vi.fn()
|
||||
const onChangedSubscribe = vi.fn((callback: WorktreesChangedCallback) => {
|
||||
worktreesChangedCallback = callback
|
||||
return onChangedUnsubscribe
|
||||
})
|
||||
const windowListeners = new Map<string, EventListener>()
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
worktrees: {
|
||||
onChanged: onChangedSubscribe
|
||||
}
|
||||
},
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
windowListeners.set(type, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string) => {
|
||||
windowListeners.delete(type)
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('document', {
|
||||
visibilityState: 'visible',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
|
||||
const { useGitStatusPushSignalRefresh } = await import('./git-status-push-signal-refresh')
|
||||
function PushSignalRefreshHarness(props: HookParams): null {
|
||||
useGitStatusPushSignalRefresh(props)
|
||||
return null
|
||||
}
|
||||
PushSignalRefreshHarness(params)
|
||||
|
||||
return {
|
||||
emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }),
|
||||
emitCommandFinished: (worktreeId: string) => {
|
||||
const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT)
|
||||
listener?.({ detail: { worktreeId } } as unknown as Event)
|
||||
},
|
||||
onChangedSubscribe,
|
||||
windowListeners,
|
||||
cleanups
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGitStatusPushSignalRefresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('nudges status when the active repo reports a worktrees change', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
const harness = await renderHookOnce({
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
enabled: true,
|
||||
fetchStatus
|
||||
})
|
||||
|
||||
harness.emitWorktreesChanged('repo-1')
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
harness.emitWorktreesChanged('repo-other')
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('nudges status when a terminal command finishes in the active worktree', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
const harness = await renderHookOnce({
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
enabled: true,
|
||||
fetchStatus
|
||||
})
|
||||
|
||||
harness.emitCommandFinished('wt-1')
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
harness.emitCommandFinished('wt-other')
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops nudges while the window is hidden', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
const harness = await renderHookOnce({
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
enabled: true,
|
||||
fetchStatus
|
||||
})
|
||||
|
||||
;(document as unknown as { visibilityState: string }).visibilityState = 'hidden'
|
||||
harness.emitWorktreesChanged('repo-1')
|
||||
harness.emitCommandFinished('wt-1')
|
||||
expect(fetchStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subscribes to nothing while disabled', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
const harness = await renderHookOnce({
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
enabled: false,
|
||||
fetchStatus
|
||||
})
|
||||
|
||||
expect(harness.onChangedSubscribe).not.toHaveBeenCalled()
|
||||
expect(harness.windowListeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('unsubscribes both signals on cleanup', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
const harness = await renderHookOnce({
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
enabled: true,
|
||||
fetchStatus
|
||||
})
|
||||
|
||||
expect(harness.cleanups.length).toBe(2)
|
||||
for (const cleanup of harness.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
expect(harness.windowListeners.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { isWindowVisible } from '@/lib/window-visibility-interval'
|
||||
import {
|
||||
ORCA_TERMINAL_COMMAND_FINISHED_EVENT,
|
||||
type TerminalCommandFinishedEventDetail
|
||||
} from '@/hooks/terminal-command-finished-event'
|
||||
|
||||
type UseGitStatusPushSignalRefreshParams = {
|
||||
activeRepoId: string | null
|
||||
activeWorktreeId: string | null
|
||||
enabled: boolean
|
||||
fetchStatus: () => void
|
||||
}
|
||||
|
||||
// Why: these push signals close the latency gap left by the slow terminal-only
|
||||
// fallback poll — branch switches and commits made inside shells surface at
|
||||
// the coalescer's floor instead of waiting out the fallback cadence. Bursts
|
||||
// are safe: the main-process watcher debounces and fetchStatus feeds a
|
||||
// coalesced runner with a minimum interval.
|
||||
export function useGitStatusPushSignalRefresh({
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
enabled,
|
||||
fetchStatus
|
||||
}: UseGitStatusPushSignalRefreshParams): void {
|
||||
const fetchStatusRef = useRef(fetchStatus)
|
||||
fetchStatusRef.current = fetchStatus
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !activeRepoId) {
|
||||
return
|
||||
}
|
||||
// Why: remote web surfaces have no preload bridge; the fallback poll
|
||||
// still covers them.
|
||||
const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged
|
||||
if (!subscribeToWorktreesChanged) {
|
||||
return
|
||||
}
|
||||
// Repo metadata (HEAD/index/worktrees) changed on disk. Hidden windows
|
||||
// skip the nudge; the visibility interval refreshes immediately on reveal.
|
||||
return subscribeToWorktreesChanged(({ repoId }) => {
|
||||
if (repoId !== activeRepoId || !isWindowVisible()) {
|
||||
return
|
||||
}
|
||||
fetchStatusRef.current()
|
||||
})
|
||||
}, [enabled, activeRepoId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const handleCommandFinished = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<TerminalCommandFinishedEventDetail>).detail
|
||||
if (detail?.worktreeId !== activeWorktreeId || !isWindowVisible()) {
|
||||
return
|
||||
}
|
||||
fetchStatusRef.current()
|
||||
}
|
||||
window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished)
|
||||
return () => {
|
||||
window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished)
|
||||
}
|
||||
}, [enabled, activeWorktreeId])
|
||||
}
|
||||
|
|
@ -114,6 +114,9 @@ async function usePollingOnce(
|
|||
watchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
onFsChanged: vi.fn(() => vi.fn())
|
||||
},
|
||||
worktrees: {
|
||||
onChanged: vi.fn(() => vi.fn())
|
||||
}
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
|
|
@ -362,6 +365,9 @@ describe('useGitStatusPolling', () => {
|
|||
watchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
onFsChanged: vi.fn(() => vi.fn())
|
||||
},
|
||||
worktrees: {
|
||||
onChanged: vi.fn(() => vi.fn())
|
||||
}
|
||||
},
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
|
|
@ -471,6 +477,9 @@ describe('useGitStatusPolling', () => {
|
|||
watchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
onFsChanged: vi.fn(() => vi.fn())
|
||||
},
|
||||
worktrees: {
|
||||
onChanged: vi.fn(() => vi.fn())
|
||||
}
|
||||
},
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
|
|
@ -592,6 +601,9 @@ describe('useGitStatusPolling', () => {
|
|||
watchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
onFsChanged: vi.fn(() => vi.fn())
|
||||
},
|
||||
worktrees: {
|
||||
onChanged: vi.fn(() => vi.fn())
|
||||
}
|
||||
},
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
|
|
@ -707,6 +719,9 @@ describe('useGitStatusPolling', () => {
|
|||
watchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
onFsChanged: vi.fn(() => vi.fn())
|
||||
},
|
||||
worktrees: {
|
||||
onChanged: vi.fn(() => vi.fn())
|
||||
}
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ import {
|
|||
} from '@/lib/passive-macos-app-data-access'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh'
|
||||
import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh'
|
||||
|
||||
const MIN_STATUS_REFRESH_INTERVAL_MS = 3000
|
||||
const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS
|
||||
// Why: file-watch refreshes cover content changes; terminal-only polling is
|
||||
// just a fallback for branch switches made inside shells.
|
||||
// Why: file-watch refreshes cover content changes and push signals (repo
|
||||
// metadata watch, shell command completion) cover branch switches; the
|
||||
// terminal-only poll is a last-resort backstop for shells without either.
|
||||
const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000
|
||||
|
||||
export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
|
||||
|
|
@ -182,6 +184,13 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
|
|||
worktreePath
|
||||
})
|
||||
|
||||
useGitStatusPushSignalRefresh({
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
enabled: shouldPollActiveWorktreeGitStatus,
|
||||
fetchStatus
|
||||
})
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import {
|
|||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
|
||||
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
|
|
@ -1373,6 +1374,9 @@ export function connectPanePty(
|
|||
}
|
||||
const commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandFinished: () => {
|
||||
// Why: the finished command may have moved HEAD or the index (e.g.
|
||||
// `git checkout`); nudge git UI now instead of waiting for a poll.
|
||||
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[cacheKey]
|
||||
const inferenceResult = flushPendingInterruptInference()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finished'
|
||||
|
||||
export type TerminalCommandFinishedEventDetail = {
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
// Why: the OSC 133;D handler lives in a per-pane closure; a window event lets
|
||||
// decoupled consumers (e.g. git status refresh) react to shell commands
|
||||
// finishing without reaching into terminal internals.
|
||||
export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
|
||||
detail: { worktreeId }
|
||||
})
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue