Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/idle-watchers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Automatically pause watch refresh polling after one minute without user activity and refresh once when activity resumes.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/core/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === "-") {
Expand Down
111 changes: 98 additions & 13 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,6 +98,7 @@ export function App({
noticeText,
onQuit = () => process.exit(0),
onReloadSession,
watchInactivitySleepMs = DEFAULT_WATCH_INACTIVITY_SLEEP_MS,
}: {
bootstrap: AppBootstrap;
hostClient?: HunkSessionBrokerClient;
Expand All @@ -102,6 +108,7 @@ export function App({
nextInput: CliInput,
options?: { resetApp?: boolean; sourcePath?: string },
) => Promise<ReloadedSessionResult>;
watchInactivitySleepMs?: number;
}) {
const SIDEBAR_MIN_WIDTH = 22;
const DIFF_MIN_WIDTH = 48;
Expand Down Expand Up @@ -153,6 +160,11 @@ export function App({
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);
const [sessionNoticeText, setSessionNoticeText] = useState<string | null>(null);
const sessionNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const watchActivityRef = useRef<WatchSessionActivity>("active");
const watchLastActivityAtRef = useRef(Date.now());
const watchRefreshInFlightRef = useRef(false);
const [watchActivity, setWatchActivity] = useState<WatchSessionActivity>("active");
const [watchActivityRevision, setWatchActivityRevision] = useState(0);

const themeOptions = useMemo(
() => availableThemes(bootstrap.customTheme),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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" ||
Comment thread
tridha643 marked this conversation as resolved.
Expand Down Expand Up @@ -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 {
Expand All @@ -611,7 +693,7 @@ export function App({
}

const pollForChanges = () => {
if (cancelled || polling || refreshing) {
if (cancelled || polling) {
return;
}

Expand All @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -834,6 +911,7 @@ export function App({
moveToHunk: review.moveToHunk,
moveMenuItem,
moveThemeSelector,
onActivity: markWatchSessionActivity,
openMenu,
openThemeSelector,
pagerMode,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -894,6 +974,7 @@ export function App({

/** End the current sidebar resize interaction. */
const endSidebarResize = (event?: TuiMouseEvent) => {
markWatchSessionActivity();
if (!isResizingSidebar) {
return;
}
Expand Down Expand Up @@ -961,10 +1042,12 @@ export function App({
}}
onMouseDrag={updateSidebarResize}
onMouseDragEnd={(event) => {
markWatchSessionActivity();
endSidebarResize(event);
cancelCopySelectionRef.current?.();
}}
onMouseUp={(event) => {
markWatchSessionActivity();
endSidebarResize(event);
closeMenu();
cancelCopySelectionRef.current?.();
Expand Down Expand Up @@ -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}
Expand Down
Loading