diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index d42ed1059..efd8650fe 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -3,8 +3,10 @@ import { mkdtemp, mkdir, realpath, rm, stat, utimes, writeFile } from 'node:fs/p import { tmpdir } from 'node:os' import { join } from 'node:path' import { + createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller, - type WorktreeBasePollEvent + type WorktreeBasePollEvent, + type WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import type { WorktreeBaseRepoWatchConfig, @@ -13,6 +15,37 @@ import type { const POLL_MS = 25 +type VisibilityHarness = { + source: WorktreePollerWindowVisibility + hide: () => void + show: () => void +} + +function createVisibilityHarness(initiallyVisible = true): VisibilityHarness { + let visible = initiallyVisible + let listener: (() => void) | null = null + return { + source: { + isWindowVisible: () => visible, + onWindowBecameVisible: (nextListener) => { + listener = nextListener + return () => { + if (listener === nextListener) { + listener = null + } + } + } + }, + hide: () => { + visible = false + }, + show: () => { + visible = true + listener?.() + } + } +} + function makeTarget( kind: 'base' | 'git-common', path: string, @@ -171,6 +204,95 @@ describe('worktree base directory poller', () => { expect(fullScans.length).toBeGreaterThan(0) }) + it('parks base scans while hidden and losslessly detects changes on resume', async () => { + const root = await makeRoot() + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const target = makeTarget('base', root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: POLL_MS, + visibility: visibility.source, + onFullScan: () => fullScans.push(Date.now()) + } + ) + cleanups.push(() => poller.unsubscribe()) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + const worktree = join(root, 'added-while-hidden') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + + expect(received.flat()).toHaveLength(0) + expect(fullScans).toHaveLength(0) + + visibility.show() + expect(fullScans).toHaveLength(1) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + }) + + it('keeps polling without a main window', async () => { + const root = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const visibility = createWorktreePollerWindowVisibility(() => null) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, visibility } + ) + cleanups.push(() => poller.unsubscribe()) + + const worktree = join(root, 'headless-add') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + expect(visibility.isWindowVisible()).toBe(true) + }) + + it('treats a destroyed window as absent instead of parking forever', () => { + const visibility = createWorktreePollerWindowVisibility(() => ({ isDestroyed: () => true })) + expect(visibility.isWindowVisible()).toBe(true) + }) + + it('keeps polling a live window that has never been shown (E2E headless)', () => { + // ORCA_E2E_HEADLESS keeps a live BrowserWindow that is never shown; no show/restore + // signal is coming to resume a parked poller, so a never-shown window must keep polling. + const visibility = createWorktreePollerWindowVisibility(() => ({ + isDestroyed: () => false, + isVisible: () => false, + isMinimized: () => false + })) + expect(visibility.isWindowVisible()).toBe(true) + expect(visibility.isWindowVisible()).toBe(true) + }) + + it('parks only after the window has been shown at least once', () => { + let visible = true + const visibility = createWorktreePollerWindowVisibility(() => ({ + isDestroyed: () => false, + isVisible: () => visible, + isMinimized: () => false + })) + // Shown at least once: a later reveal will fire the visibility signal to resume. + expect(visibility.isWindowVisible()).toBe(true) + // Now hidden — a previously-shown window parks (its show/restore will resume it). + visible = false + expect(visibility.isWindowVisible()).toBe(false) + }) + it('reports git-common entry creates, allowlisted leaf updates, and removals via polling', async () => { const commonDir = await makeRoot() const received: WorktreeBasePollEvent[][] = [] @@ -335,6 +457,42 @@ describe('worktree base directory poller', () => { ) }) + it('detects a primary HEAD move immediately after resuming', async () => { + const commonDir = await makeRoot() + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: POLL_MS, + platform: 'linux', + visibility: visibility.source, + onFullScan: () => fullScans.push(Date.now()) + } + ) + cleanups.push(() => poller.unsubscribe()) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + await writeFile(headFile, 'ref: refs/heads/feature') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + + expect(received.flat()).toHaveLength(0) + expect(fullScans).toHaveLength(0) + + visibility.show() + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === headFile) + ) + expect(fullScans).toHaveLength(1) + }) + it('emits deletes for all known worktrees when the root vanishes', async () => { const root = await makeRoot() const worktree = join(root, 'external-5') diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 3f4fc6757..31a068146 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,6 +1,7 @@ import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget @@ -11,9 +12,53 @@ export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path export type WorktreeBaseSubscription = { unsubscribe: () => Promise } +export type WorktreePollerWindowVisibility = { + isWindowVisible: () => boolean + onWindowBecameVisible: (listener: () => void) => () => void +} + +type WorktreePollerWindow = { + isDestroyed: () => boolean + isVisible?: () => boolean + isMinimized?: () => boolean +} + +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +export function createWorktreePollerWindowVisibility( + getWindow: () => WorktreePollerWindow | null +): WorktreePollerWindowVisibility { + // Why: only park a window that has actually been shown and is now hidden. A window + // that has NEVER been shown is either headless (ORCA_E2E_HEADLESS keeps a live but + // never-shown BrowserWindow) or still starting up — no show/restore signal is coming + // to resume it, so parking it would starve worktree freshness forever. Treat + // never-shown as visible and keep polling; only start parking once we've observed the + // window visible at least once. null/destroyed (serve/headless, macOS window-recreation + // gap) stay always-visible so a torn-down window never permanently parks the poller. + let hasBeenVisible = false + return { + isWindowVisible: () => { + const window = getWindow() + if (window === null || window.isDestroyed()) { + return true + } + if (isMainWindowVisible(window)) { + hasBeenVisible = true + return true + } + return !hasBeenVisible + }, + onWindowBecameVisible: onMainWindowBecameVisible + } +} + export type WorktreeBasePollerOptions = { pollIntervalMs?: number platform?: NodeJS.Platform + visibility?: WorktreePollerWindowVisibility /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void } @@ -145,6 +190,7 @@ async function startBasePoller( getRepos: () => ReadonlyMap, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void ): Promise { let disposed = false @@ -152,6 +198,8 @@ async function startBasePoller( let tickCount = 0 let snapshot = await snapshotBase(target.path, getRepos()) let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + let timer: ReturnType | null = null + let parkedWhileHidden = false // dir → tick when first seen without a `.git` marker const pendingMarkers = new Map() for (const [dir, marker] of snapshot.markers) { @@ -201,9 +249,9 @@ async function startBasePoller( } } - const tick = async (): Promise => { + const poll = async (forceFullScan = false): Promise => { tickCount++ - if (tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { await fullScan() return } @@ -222,25 +270,62 @@ async function startBasePoller( } } - const timer = setInterval(() => { - if (disposed || ticking) { + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { return } ticking = true - void tick() - .catch(() => { - // Transient fs error: keep the previous snapshot and retry next tick. - }) - .finally(() => { - ticking = false - }) - }, pollIntervalMs) + // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not + // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. + const startedAt = Date.now() + try { + await poll(forceFullScan) + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the ordinary dir-signature gate can miss same-granule changes made + // while hidden; resume must diff a fresh full snapshot against the baseline. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) timer.unref?.() return { unsubscribe: async () => { disposed = true - clearInterval(timer) + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() } } } @@ -256,8 +341,16 @@ export async function startWorktreeBaseDirectoryPoller( ): Promise { const pollIntervalMs = options.pollIntervalMs ?? WORKTREE_BASE_POLL_INTERVAL_MS const platform = options.platform ?? process.platform + const visibility = options.visibility ?? alwaysVisible if (target.kind === 'git-common') { - return startGitCommonWatch(target, onEvents, pollIntervalMs, platform, options.onFullScan) + return startGitCommonWatch( + target, + onEvents, + pollIntervalMs, + platform, + visibility, + options.onFullScan + ) } - return startBasePoller(target, getRepos, onEvents, pollIntervalMs, options.onFullScan) + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) } diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index c560b5e51..0a5773b13 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -10,6 +10,10 @@ vi.mock('fs/promises', () => ({ })) vi.mock('./worktree-base-directory-poller', () => ({ + createWorktreePollerWindowVisibility: vi.fn(() => ({ + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} + })), startWorktreeBaseDirectoryPoller: vi.fn() })) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 23352d688..85cd501a2 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -17,7 +17,10 @@ import { buildWorktreeBaseDirectoryWatchTargets, clearWorktreeBaseDirectoryWatchTargetWarnings } from './worktree-base-directory-watch-targets' -import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import { + createWorktreePollerWindowVisibility, + startWorktreeBaseDirectoryPoller +} from './worktree-base-directory-poller' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow @@ -58,9 +61,8 @@ function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationIn for (const repoId of changes.headIdentityRepoIds ?? []) { watch.pendingHeadIdentityRepoIds.add(repoId) } - if (watch.notifyTimer) { - clearTimeout(watch.notifyTimer) - } + // clearTimeout tolerates null (no-op), so no guard needed before rescheduling. + clearTimeout(watch.notifyTimer ?? undefined) watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null if (watch.disposed || watch.mainWindow.isDestroyed()) { @@ -192,10 +194,14 @@ async function subscribeTarget( () => (activeWatches.get(target.key) ?? activeWatch)?.repos ?? target.repos, (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch - if (!currentWatch || currentWatch.disposed) { - return + if (currentWatch && !currentWatch.disposed) { + handleLocalWatchEvents(currentWatch, null, events) } - handleLocalWatchEvents(currentWatch, null, events) + }, + { + visibility: createWorktreePollerWindowVisibility( + () => (activeWatches.get(target.key) ?? activeWatch)?.mainWindow ?? null + ) } ) activeWatch = createActiveWatch(target, mainWindow, subscription) @@ -241,9 +247,7 @@ async function removeWatch(key: string): Promise { } activeWatches.delete(key) watch.disposed = true - if (watch.notifyTimer) { - clearTimeout(watch.notifyTimer) - } + clearTimeout(watch.notifyTimer ?? undefined) clearPendingRepoIds(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 8996c7b00..8992c92e4 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -2,7 +2,8 @@ import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { WorktreeBasePollEvent, - WorktreeBaseSubscription + WorktreeBaseSubscription, + WorktreePollerWindowVisibility } from './worktree-base-directory-poller' // Shared with the darwin primary-metadata poll so the platforms cannot drift @@ -228,6 +229,7 @@ export async function startGitCommonPolling( commonDirPath: string, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, includePrimary = true ): Promise { @@ -235,39 +237,81 @@ export async function startGitCommonPolling( let ticking = false let tickCount = 0 let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) + let timer: ReturnType | null = null + let parkedWhileHidden = false - const timer = setInterval(() => { - if (disposed || ticking) { + const tick = async (forceIndexRead = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { return } ticking = true + // Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would + // land each visible refresh a full scan-duration late every tick). + const startedAt = Date.now() tickCount++ - const forceIndexRead = tickCount % INDEX_BACKSTOP_TICKS === 0 + const shouldForceIndexRead = forceIndexRead || tickCount % INDEX_BACKSTOP_TICKS === 0 onFullScan?.() - void snapshotGitCommon(commonDirPath, snapshot, includePrimary, forceIndexRead) - .then((next) => { - if (disposed) { - return - } - const events = diffGitCommon(commonDirPath, snapshot, next) - snapshot = next - if (events.length > 0) { - onEvents(events) - } - }) - .catch(() => { - // Transient fs error: keep the previous snapshot and retry next tick. - }) - .finally(() => { - ticking = false - }) - }, pollIntervalMs) + try { + const next = await snapshotGitCommon( + commonDirPath, + snapshot, + includePrimary, + shouldForceIndexRead + ) + if (disposed) { + return + } + const events = diffGitCommon(commonDirPath, snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: a linked index can change without its parent dir signature moving; + // force the leaf read when diffing the retained pre-hide snapshot. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) timer.unref?.() return { unsubscribe: async () => { disposed = true - clearInterval(timer) + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() } } } diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 9cc70f1d7..42a93f260 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' @@ -8,7 +8,10 @@ import type { WatcherProcessHooks } from './parcel-watcher-process-subscription' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' -import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' import { startGitCommonWatch } from './worktree-git-common-watch' vi.mock('./parcel-watcher-process', () => ({ @@ -17,6 +20,40 @@ vi.mock('./parcel-watcher-process', () => ({ const POLL_MS = 25 +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +function createVisibilityHarness(): { + source: WorktreePollerWindowVisibility + hide: () => void + show: () => void +} { + let visible = true + let listener: (() => void) | null = null + return { + source: { + isWindowVisible: () => visible, + onWindowBecameVisible: (nextListener) => { + listener = nextListener + return () => { + if (listener === nextListener) { + listener = null + } + } + } + }, + hide: () => { + visible = false + }, + show: () => { + visible = true + listener?.() + } + } +} + type ChildSubscription = { dir: string callback: WatcherProcessCallback @@ -67,7 +104,8 @@ describe('worktree git-common narrow watch (darwin)', () => { makeTarget(commonDir), (events) => received.push(events), POLL_MS, - 'darwin' + 'darwin', + alwaysVisible ) cleanups.push(() => watch.unsubscribe()) } @@ -167,6 +205,42 @@ describe('worktree git-common narrow watch (darwin)', () => { }) }) + it('keeps the native stream live while the primary poll is parked', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source, + () => fullScans.push(Date.now()) + ) + cleanups.push(() => watch.unsubscribe()) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + await writeFile(headFile, 'ref: refs/heads/feature') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + + expect(fullScans).toHaveLength(0) + const entryPath = join(commonDir, 'worktrees', 'native-while-hidden') + childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }]) + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled() + + visibility.show() + expect(fullScans).toHaveLength(1) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: headFile }) + }) + }) + it('stops forwarding events and unsubscribes the child on dispose', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) @@ -175,7 +249,8 @@ describe('worktree git-common narrow watch (darwin)', () => { makeTarget(commonDir), (events) => received.push(events), POLL_MS, - 'darwin' + 'darwin', + alwaysVisible ) await watch.unsubscribe() expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 705963ed7..a24adf8e3 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -4,7 +4,8 @@ import { subscribeViaWatcherProcess } from './parcel-watcher-process' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, - WorktreeBaseSubscription + WorktreeBaseSubscription, + WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import { PRIMARY_CHECKOUT_METADATA_FILES, @@ -69,42 +70,78 @@ async function startSnapshotDiffPoller( takeSnapshot: () => Promise>, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void ): Promise { let disposed = false let ticking = false let snapshot = await takeSnapshot() + let timer: ReturnType | null = null + let parkedWhileHidden = false - const timer = setInterval(() => { - if (disposed || ticking) { + const tick = async (): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { return } ticking = true + // Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would + // land each visible refresh a full scan-duration late every tick). + const startedAt = Date.now() onFullScan?.() - void takeSnapshot() - .then((next) => { - if (disposed) { - return - } - const events = diffMtimeMap(snapshot, next) - snapshot = next - if (events.length > 0) { - onEvents(events) - } - }) - .catch(() => { - // Transient fs error: keep the previous snapshot and retry next tick. - }) - .finally(() => { - ticking = false - }) - }, pollIntervalMs) + try { + const next = await takeSnapshot() + if (disposed) { + return + } + const events = diffMtimeMap(snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + void tick() + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) timer.unref?.() return { unsubscribe: async () => { disposed = true - clearInterval(timer) + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() } } } @@ -246,6 +283,7 @@ export async function startGitCommonWatch( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, platform: NodeJS.Platform, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void ): Promise { if (platform === 'darwin') { @@ -255,6 +293,7 @@ export async function startGitCommonWatch( () => snapshotPrimaryCheckoutMetadata(target.path), onEvents, pollIntervalMs, + visibility, onFullScan ) ]) @@ -264,5 +303,5 @@ export async function startGitCommonWatch( } } } - return startGitCommonPolling(target.path, onEvents, pollIntervalMs, onFullScan) + return startGitCommonPolling(target.path, onEvents, pollIntervalMs, visibility, onFullScan) }