diff --git a/.changeset/idle-watchers.md b/.changeset/idle-watchers.md new file mode 100644 index 00000000..df7d59ac --- /dev/null +++ b/.changeset/idle-watchers.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Automatically pause watch refresh polling after one minute without user activity and refresh once when activity resumes. diff --git a/README.md b/README.md index a6189e45..cf1ae064 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ transparent_background = false `theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer. Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases. `exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. +Watch mode automatically pauses its refresh polling after one minute without keyboard or mouse activity. The next interaction refreshes the diff once and resumes polling. `transparent_background` can also be written as `transparentBackground`. Custom themes can inherit from any built-in theme and override only the colors you care about: diff --git a/src/core/watch.ts b/src/core/watch.ts index 04de1dfc..4a1b8cad 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -2,6 +2,12 @@ import fs from "node:fs"; import { createVcsWatchSignature, getConfiguredVcsAdapter, operationFromInput } from "./vcs"; import type { CliInput } from "./types"; +/** Activity state that controls whether a watch session schedules expensive polling. */ +export type WatchSessionActivity = "active" | "idle"; + +/** Stop watch polling after one minute without keyboard or mouse activity. */ +export const DEFAULT_WATCH_INACTIVITY_SLEEP_MS = 60_000; + /** Return whether the current input can be rebuilt from files or VCS state without rereading stdin. */ export function canReloadInput(input: CliInput) { if (input.options.agentContext === "-") { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 63f8225a..f1ede1af 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -6,7 +6,12 @@ import { import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types"; -import { canReloadInput, computeWatchSignature } from "../core/watch"; +import type { WatchSessionActivity } from "../core/watch"; +import { + canReloadInput, + computeWatchSignature, + DEFAULT_WATCH_INACTIVITY_SLEEP_MS, +} from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; import { StatusBar } from "./components/chrome/StatusBar"; @@ -93,6 +98,7 @@ export function App({ noticeText, onQuit = () => process.exit(0), onReloadSession, + watchInactivitySleepMs = DEFAULT_WATCH_INACTIVITY_SLEEP_MS, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; @@ -102,6 +108,7 @@ export function App({ nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }, ) => Promise; + watchInactivitySleepMs?: number; }) { const SIDEBAR_MIN_WIDTH = 22; const DIFF_MIN_WIDTH = 48; @@ -153,6 +160,11 @@ export function App({ const [resizeStartWidth, setResizeStartWidth] = useState(null); const [sessionNoticeText, setSessionNoticeText] = useState(null); const sessionNoticeTimeoutRef = useRef | null>(null); + const watchActivityRef = useRef("active"); + const watchLastActivityAtRef = useRef(Date.now()); + const watchRefreshInFlightRef = useRef(false); + const [watchActivity, setWatchActivity] = useState("active"); + const [watchActivityRevision, setWatchActivityRevision] = useState(0); const themeOptions = useMemo( () => availableThemes(bootstrap.customTheme), @@ -514,6 +526,12 @@ export function App({ const canRefreshCurrentInput = canReloadInput(bootstrap.input); const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput); + const watchShouldPoll = watchEnabled && watchActivity === "active"; + + const setWatchSessionActivity = useCallback((activity: WatchSessionActivity) => { + watchActivityRef.current = activity; + setWatchActivity(activity); + }, []); /** Rebuild the current diff source while preserving the active app view options. */ const refreshCurrentInput = useCallback(async () => { @@ -560,6 +578,43 @@ export function App({ }); }, [refreshCurrentInput]); + /** Refresh a watched input once while sharing one in-flight guard across watch triggers. */ + const refreshWatchedInput = useCallback( + (failureMessage: string) => { + if (watchRefreshInFlightRef.current) { + return false; + } + + watchRefreshInFlightRef.current = true; + void refreshCurrentInput() + .catch((error) => { + console.error(failureMessage, error); + }) + .finally(() => { + watchRefreshInFlightRef.current = false; + }); + + return true; + }, + [refreshCurrentInput], + ); + + /** Record keyboard or mouse use, and refresh once when an idle watch session wakes up. */ + const markWatchSessionActivity = useCallback(() => { + if (!watchEnabled) { + return; + } + + const wasIdle = watchActivityRef.current === "idle"; + watchLastActivityAtRef.current = Date.now(); + setWatchSessionActivity("active"); + setWatchActivityRevision((current) => current + 1); + + if (wasIdle) { + refreshWatchedInput("Failed to refresh idle watch session after activity."); + } + }, [refreshWatchedInput, setWatchSessionActivity, watchEnabled]); + const triggerEditSelectedFile = useCallback(() => { const basePath = bootstrap.input.kind === "vcs" || @@ -593,14 +648,41 @@ export function App({ triggerRefreshCurrentInput, ]); + useEffect(() => { + watchLastActivityAtRef.current = Date.now(); + setWatchSessionActivity("active"); + setWatchActivityRevision((current) => current + 1); + }, [setWatchSessionActivity, watchEnabled]); + useEffect(() => { if (!watchEnabled) { + setWatchSessionActivity("active"); + return; + } + + const elapsedMs = Date.now() - watchLastActivityAtRef.current; + const remainingMs = watchInactivitySleepMs - elapsedMs; + if (remainingMs <= 0) { + setWatchSessionActivity("idle"); + return; + } + + const timeout = setTimeout(() => { + setWatchSessionActivity("idle"); + }, remainingMs); + + return () => { + clearTimeout(timeout); + }; + }, [setWatchSessionActivity, watchActivityRevision, watchEnabled, watchInactivitySleepMs]); + + useEffect(() => { + if (!watchShouldPoll) { return; } let cancelled = false; let polling = false; - let refreshing = false; let lastSignature: string; try { @@ -611,7 +693,7 @@ export function App({ } const pollForChanges = () => { - if (cancelled || polling || refreshing) { + if (cancelled || polling) { return; } @@ -620,15 +702,10 @@ export function App({ try { const nextSignature = computeWatchSignature(bootstrap.input); if (nextSignature !== lastSignature) { - lastSignature = nextSignature; - refreshing = true; - void refreshCurrentInput() - .catch((error) => { - console.error("Failed to auto-reload the current diff.", error); - }) - .finally(() => { - refreshing = false; - }); + const refreshStarted = refreshWatchedInput("Failed to auto-reload the current diff."); + if (refreshStarted) { + lastSignature = nextSignature; + } } } catch (error) { console.error("Failed to poll watch mode input.", error); @@ -642,7 +719,7 @@ export function App({ cancelled = true; clearInterval(interval); }; - }, [bootstrap.input, refreshCurrentInput, watchEnabled]); + }, [bootstrap.input, refreshWatchedInput, watchShouldPoll]); /** Leave the app through the shared shutdown path. */ const requestQuit = useCallback(() => { @@ -834,6 +911,7 @@ export function App({ moveToHunk: review.moveToHunk, moveMenuItem, moveThemeSelector, + onActivity: markWatchSessionActivity, openMenu, openThemeSelector, pagerMode, @@ -862,6 +940,7 @@ export function App({ /** Start a mouse drag resize for the optional sidebar. */ const beginSidebarResize = (event: TuiMouseEvent) => { + markWatchSessionActivity(); if (event.button !== MouseButton.LEFT) { return; } @@ -875,6 +954,7 @@ export function App({ /** Update the sidebar width while a drag resize is active. */ const updateSidebarResize = (event: TuiMouseEvent) => { + markWatchSessionActivity(); if (!isResizingSidebar || resizeDragOriginX === null || resizeStartWidth === null) { return; } @@ -894,6 +974,7 @@ export function App({ /** End the current sidebar resize interaction. */ const endSidebarResize = (event?: TuiMouseEvent) => { + markWatchSessionActivity(); if (!isResizingSidebar) { return; } @@ -961,10 +1042,12 @@ export function App({ }} onMouseDrag={updateSidebarResize} onMouseDragEnd={(event) => { + markWatchSessionActivity(); endSidebarResize(event); cancelCopySelectionRef.current?.(); }} onMouseUp={(event) => { + markWatchSessionActivity(); endSidebarResize(event); closeMenu(); cancelCopySelectionRef.current?.(); @@ -1042,8 +1125,10 @@ export function App({ onCancelDraftNote={cancelDraftNote} onFocusDraftNote={focusDraftNote} onScrollCodeHorizontally={(delta) => { + markWatchSessionActivity(); scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); }} + onUserActivity={markWatchSessionActivity} onCopyFeedback={showTransientNotice} onSelectFile={jumpToFile} onToggleGap={review.toggleGap} diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 94daad81..cd7e1cef 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -19,6 +19,7 @@ import { AGENT_SKILL_COMMAND, AGENT_SKILL_PROMPT } from "./components/chrome/Age import { resolveTheme } from "./themes"; const { loadAppBootstrap } = await import("../core/loaders"); +const { App } = await import("./App"); const { AppHost } = await import("./AppHost"); const TEST_KEY_PAGE_UP = "\x1B[5~"; @@ -1724,6 +1725,205 @@ describe("App interactions", () => { } }); + test("watch mode pauses polling while idle and refreshes once when activity resumes", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-idle-")); + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "split", + watch: true, + }, + }); + + const setup = await testRender(, { + width: 220, + height: 20, + }); + + try { + await flush(setup); + await act(async () => { + await Bun.sleep(120); + await setup.renderOnce(); + }); + + writeFileSync(right, "export const answer = 42;\nexport const idleChange = true;\n"); + + let frame = setup.captureCharFrame(); + expect(frame).not.toContain("idleChange"); + + for (let attempt = 0; attempt < 10; attempt += 1) { + await flush(setup); + frame = setup.captureCharFrame(); + expect(frame).not.toContain("idleChange"); + await act(async () => { + await Bun.sleep(40); + await setup.renderOnce(); + }); + } + + await act(async () => { + await setup.mockInput.pressArrow("right"); + }); + + const refreshedFrame = await waitForFrame( + setup, + (currentFrame) => currentFrame.includes("idleChange"), + 30, + ); + expect(refreshedFrame).toContain("idleChange"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + + test("automatic watch sleep is based on user activity instead of background reloads", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-inactivity-")); + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "split", + watch: true, + }, + }); + + const setup = await testRender(, { + width: 220, + height: 20, + }); + const mountedAt = Date.now(); + + try { + await flush(setup); + writeFileSync(right, "export const answer = 42;\nexport const activeChange = true;\n"); + + const activeFrame = await waitForFrame( + setup, + (currentFrame) => currentFrame.includes("activeChange"), + 30, + ); + expect(activeFrame).toContain("activeChange"); + + const waitUntilIdleProbeMs = Math.max(0, mountedAt + 700 - Date.now()); + await act(async () => { + await Bun.sleep(waitUntilIdleProbeMs); + await setup.renderOnce(); + }); + + writeFileSync( + right, + "export const answer = 42;\nexport const activeChange = true;\nexport const idleChange = true;\n", + ); + + await act(async () => { + await Bun.sleep(350); + await setup.renderOnce(); + }); + expect(setup.captureCharFrame()).not.toContain("idleChange"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + + test("watch mode does not overlap wake-up refreshes with resumed polling", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-idle-race-")); + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "split", + watch: true, + }, + }); + + let resolveReload: (() => void) | undefined; + const onReloadSession = mock(async () => { + await new Promise((resolve) => { + resolveReload = resolve; + }); + + return { + sessionId: "local-session", + inputKind: bootstrap.input.kind, + title: bootstrap.changeset.title, + sourceLabel: bootstrap.changeset.sourceLabel, + fileCount: bootstrap.changeset.files.length, + selectedFilePath: bootstrap.changeset.files[0]?.path, + selectedHunkIndex: 0, + }; + }); + + const setup = await testRender( + undefined} + watchInactivitySleepMs={40} + />, + { + width: 220, + height: 20, + }, + ); + + try { + await flush(setup); + await act(async () => { + await Bun.sleep(120); + await setup.renderOnce(); + }); + + writeFileSync(right, "export const answer = 42;\nexport const idleChange = true;\n"); + + await act(async () => { + await setup.mockInput.pressArrow("right"); + }); + expect(onReloadSession).toHaveBeenCalledTimes(1); + + await act(async () => { + await Bun.sleep(320); + await setup.renderOnce(); + }); + expect(onReloadSession).toHaveBeenCalledTimes(1); + } finally { + resolveReload?.(); + await act(async () => { + await Bun.sleep(0); + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + test("watch mode preserves the resolved auto theme after refreshing the file diff", async () => { const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-theme-")); const left = join(dir, "before.ts"); diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index c415a0ba..b144b88c 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -3,6 +3,7 @@ import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; import { resolveRuntimeCliInput } from "../core/terminal"; import type { AppBootstrap, CliInput } from "../core/types"; +import { DEFAULT_WATCH_INACTIVITY_SLEEP_MS } from "../core/watch"; import type { UpdateNotice } from "../core/updateNotice"; import { createInitialSessionSnapshot, @@ -22,11 +23,13 @@ export function AppHost({ hostClient, onQuit = () => process.exit(0), startupNoticeResolver, + watchInactivitySleepMs = DEFAULT_WATCH_INACTIVITY_SLEEP_MS, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; onQuit?: () => void; startupNoticeResolver?: () => Promise; + watchInactivitySleepMs?: number; }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); const [appVersion, setAppVersion] = useState(0); @@ -98,6 +101,7 @@ export function AppHost({ noticeText={startupNoticeText} onQuit={onQuit} onReloadSession={reloadSession} + watchInactivitySleepMs={watchInactivitySleepMs} /> ); } diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 58226ae7..ae65530a 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -216,6 +216,7 @@ export function DiffPane({ onCopyFeedback, onCopySelectionText, onScrollCodeHorizontally = () => {}, + onUserActivity = () => {}, onSelectFile, onToggleGap = NOOP_TOGGLE_GAP, onViewportCenteredHunkChange, @@ -265,6 +266,7 @@ export function DiffPane({ onCopyFeedback?: (text: string) => void; onCopySelectionText?: (text: string) => void | boolean; onScrollCodeHorizontally?: (delta: number) => void; + onUserActivity?: () => void; onSelectFile: (fileId: string) => void; onToggleGap?: (fileId: string, gapKey: string) => void; onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void; @@ -349,6 +351,7 @@ export function DiffPane({ /** Route shifted wheel input into horizontal code-column scrolling without disturbing vertical review scroll. */ const handleMouseScroll = useCallback( (event: TuiMouseEvent) => { + onUserActivity(); const scrollBox = scrollRef.current; const direction = event.scroll?.direction; if (!direction) { @@ -401,7 +404,7 @@ export function DiffPane({ event.preventDefault(); event.stopPropagation(); }, - [clearAddNoteHoverForScroll, onScrollCodeHorizontally, scrollRef, wrapLines], + [clearAddNoteHoverForScroll, onScrollCodeHorizontally, onUserActivity, scrollRef, wrapLines], ); const allAgentNotesByFile = useMemo(() => { diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index b163124b..0241cf95 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -856,6 +856,7 @@ describe("UI components", () => { hunkIndex: number; target?: { side: "old" | "new"; line: number }; }> = []; + let activeAffordance: unknown = null; let navigateToSecondHunk: (() => void) | null = null; function AddNoteNavigationHarness() { @@ -880,6 +881,9 @@ describe("UI components", () => { selectedHunkRevealRequestId: selectedHunk, separatorWidth: 92, width: 100, + onActiveAddNoteAffordanceChange: (affordance) => { + activeAffordance = affordance; + }, onStartUserNoteAtHunk: startUserNote, })} /> @@ -911,16 +915,25 @@ describe("UI components", () => { const addNoteX = affordanceLines[addNoteY]?.indexOf("[+]") ?? -1; expect(addNoteY).toBeGreaterThanOrEqual(0); expect(addNoteX).toBeGreaterThanOrEqual(0); - - await act(async () => { - await setup.mockMouse.moveTo(addNoteX + 1, addNoteY); - await setup.renderOnce(); - }); - await act(async () => { - await setup.mockMouse.click(addNoteX + 1, addNoteY); - await setup.renderOnce(); + expect(activeAffordance).toEqual({ + fileId: "target", + hunkIndex: 1, + target: { side: "new", line: 60 }, }); + for (const x of [addNoteX + 1, addNoteX, addNoteX + 2]) { + if (calls.length > 0) { + break; + } + + await act(async () => { + await setup.mockMouse.moveTo(x, addNoteY); + await setup.mockMouse.click(x, addNoteY); + await setup.renderOnce(); + await Bun.sleep(0); + }); + } + expect(calls).toEqual([ { fileId: "target", hunkIndex: 1, target: { side: "new", line: 60 } }, ]); diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index d6f35ded..bcd985ee 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -67,6 +67,7 @@ export interface UseAppKeyboardShortcutsOptions { moveToHunk: (delta: number) => void; moveMenuItem: (delta: number) => void; moveThemeSelector: (delta: number) => void; + onActivity?: () => void; openMenu: (menuId: MenuId) => void; openThemeSelector: () => void; pagerMode: boolean; @@ -111,6 +112,7 @@ export function useAppKeyboardShortcuts({ moveToHunk, moveMenuItem, moveThemeSelector, + onActivity, openMenu, openThemeSelector, pagerMode, @@ -576,6 +578,8 @@ export function useAppKeyboardShortcuts({ }; useKeyboard((key: KeyEvent) => { + onActivity?.(); + if (handleMenuToggleShortcut(key)) { return; }