From 58ba85a77ab4411099ddb61990ce81873ecc5fc2 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 23:29:37 -0400 Subject: [PATCH 1/2] fix(ui): save log view preferences --- .changeset/quiet-logs-save.md | 5 ++ .../hunk/src/app/historyBootstrap.test.ts | 16 ++++- packages/hunk/src/app/historyBootstrap.ts | 12 ++++ packages/hunk/src/core/run/config.ts | 17 +++++ packages/hunk/src/ui/App.tsx | 60 ++++------------- .../hunk/src/ui/AppHost.interactions.test.tsx | 2 +- .../chrome/ViewPreferenceQuitDialog.tsx | 65 +++++++++++++++++++ .../src/ui/history/runStaticHistory.test.ts | 3 + packages/hunk/src/ui/history/types.ts | 5 ++ .../src/ui/hooks/useAppKeyboardShortcuts.ts | 28 ++------ .../hooks/useViewPreferenceQuitController.ts | 8 +-- .../src/ui/lib/viewPreferenceQuitKeys.test.ts | 51 +++++++++++++++ .../hunk/src/ui/lib/viewPreferenceQuitKeys.ts | 35 ++++++++++ packages/hunk/src/ui/log/LogApp.tsx | 56 +++++++++++++++- packages/hunk/src/ui/log/controller.test.ts | 3 + .../src/ui/session/HunkSessionHost.test.tsx | 53 +++++++++++++++ .../hunk/src/ui/session/HunkSessionHost.tsx | 7 ++ test/pty/log-integration.test.ts | 35 +++++++++- 18 files changed, 384 insertions(+), 77 deletions(-) create mode 100644 .changeset/quiet-logs-save.md create mode 100644 packages/hunk/src/ui/components/chrome/ViewPreferenceQuitDialog.tsx create mode 100644 packages/hunk/src/ui/lib/viewPreferenceQuitKeys.test.ts create mode 100644 packages/hunk/src/ui/lib/viewPreferenceQuitKeys.ts diff --git a/.changeset/quiet-logs-save.md b/.changeset/quiet-logs-save.md new file mode 100644 index 000000000..4d03b727e --- /dev/null +++ b/.changeset/quiet-logs-save.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Prompt to save theme changes when quitting an interactive `hunk log` session, matching other review commands. diff --git a/packages/hunk/src/app/historyBootstrap.test.ts b/packages/hunk/src/app/historyBootstrap.test.ts index e25487906..3d6c13adc 100644 --- a/packages/hunk/src/app/historyBootstrap.test.ts +++ b/packages/hunk/src/app/historyBootstrap.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { HistoryCommandInput } from "../core/run/commandInputs"; @@ -21,6 +21,12 @@ describe("history bootstrap cursor ownership", () => { test("cancels refresh before opening and closes each active provider cursor once", async () => { const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-")); const configHome = mkdtempSync(join(tmpdir(), "hunk-history-config-")); + const configPath = join(configHome, "hunk", "config.toml"); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + configPath, + 'theme = "github-dark-dimmed"\nline_numbers = false\nprompt_save_view_preferences = false\n', + ); const closeCounts: number[] = []; let opens = 0; const makeSource = (): VcsHistorySource => { @@ -62,6 +68,14 @@ describe("history bootstrap cursor ownership", () => { env: { ...process.env, XDG_CONFIG_HOME: configHome }, baseVcsCatalog: catalog, }); + expect(bootstrap.input.theme).toBe("github-dark-dimmed"); + expect(bootstrap.initialViewPreferences).toMatchObject({ + theme: "github-dark-dimmed", + showLineNumbers: false, + }); + expect(bootstrap.viewPreferencesConfigPath).toBe(configPath); + expect(bootstrap.promptSaveViewPreferences).toBe(false); + const cancelled = new AbortController(); cancelled.abort(); await expect(bootstrap.reopenSource(cancelled.signal)).rejects.toThrow(); diff --git a/packages/hunk/src/app/historyBootstrap.ts b/packages/hunk/src/app/historyBootstrap.ts index 08bb25b7e..f06f11066 100644 --- a/packages/hunk/src/app/historyBootstrap.ts +++ b/packages/hunk/src/app/historyBootstrap.ts @@ -1,4 +1,8 @@ import type { HistoryCommandInput } from "../core/run/commandInputs"; +import { + persistedViewPreferencesFromOptions, + type PersistedViewPreferences, +} from "../core/run/config"; import { collectSessionCustomThemes } from "../core/theme/customThemes"; import type { ExtensionVcsHistoryCommit, @@ -34,6 +38,10 @@ export interface HistoryBootstrap { extensionSession: ExtensionSession; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; + /** Launch baseline retained so the owning history surface can persist theme changes on quit. */ + initialViewPreferences: PersistedViewPreferences; + viewPreferencesConfigPath?: string; + promptSaveViewPreferences: boolean; planReview( commit: ExtensionVcsHistoryCommit, options?: ExtensionVcsHistoryReviewOptions, @@ -136,6 +144,10 @@ export async function loadHistoryBootstrap({ repoRoot, extensionSession, customThemes: sessionThemes.themes, + initialViewPreferences: persistedViewPreferencesFromOptions(resolved.configured.input.options), + viewPreferencesConfigPath: resolved.configured.viewPreferencesConfigPath, + promptSaveViewPreferences: + resolved.configured.input.options.promptSaveViewPreferences !== false, notices: [ ...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map( (notice) => sanitizeTerminalLine(notice.message), diff --git a/packages/hunk/src/core/run/config.ts b/packages/hunk/src/core/run/config.ts index 4ed7f695f..940d26f87 100644 --- a/packages/hunk/src/core/run/config.ts +++ b/packages/hunk/src/core/run/config.ts @@ -99,6 +99,23 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { cursorLine: "row", }; +/** Project resolved launch options into the complete preference shape used by persistence. */ +export function persistedViewPreferencesFromOptions( + options: CommonOptions, +): PersistedViewPreferences { + return { + mode: options.mode ?? DEFAULT_VIEW_PREFERENCES.mode, + ...(options.theme === undefined ? {} : { theme: options.theme }), + showLineNumbers: options.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers, + wrapLines: options.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, + showHunkHeaders: options.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, + showMenuBar: options.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, + showAgentNotes: options.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + copyDecorations: options.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, + cursorLine: options.cursorLine ?? DEFAULT_VIEW_PREFERENCES.cursorLine, + }; +} + const VIEW_PREFERENCES_PROMPT_CONFIG_KEY = "prompt_save_view_preferences"; const PERSISTED_VIEW_PREFERENCE_KEYS: Array<{ configKey: string; diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 1f3ad5030..2aebb0ce1 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -42,6 +42,7 @@ import type { ReloadedSessionResult, ReloadSessionOptions } from "../session/typ import { MenuBar } from "./components/chrome/MenuBar"; import { ConfirmDialog, confirmDialogHeight } from "./components/chrome/ConfirmDialog"; import { ExtensionDialog } from "./components/chrome/ExtensionDialog"; +import { ViewPreferenceQuitDialog } from "./components/chrome/ViewPreferenceQuitDialog"; import { ExtensionToast } from "./components/chrome/ExtensionToast"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; @@ -357,17 +358,7 @@ export function App({ const closeHelp = useCallback(() => { setShowHelp(false); }, []); - const { - changedViewPreferences, - saveConfigPromptOpen, - viewPreferenceDiffLines, - viewPreferencesConfigLabel, - requestQuit, - saveViewPreferencesAndQuit, - discardViewPreferencesAndQuit, - neverAskToSaveViewPreferencesAndQuit, - closeSaveConfigPrompt, - } = useViewPreferenceQuitController({ + const viewPreferenceQuit = useViewPreferenceQuitController({ currentPreferences: currentViewPreferences, configPath: bootstrap.viewPreferencesConfigPath, pagerMode, @@ -380,6 +371,14 @@ export function App({ closeHelp, homeDirectory: process.env.HOME, }); + const { + saveConfigPromptOpen, + requestQuit, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, + } = viewPreferenceQuit; const notifyExtensionMode = useCallback( (message: string, type?: ExtensionNotifyType) => extensions?.context.notify(message, type), [extensions], @@ -1527,45 +1526,12 @@ export function App({ ) : null} {saveConfigPromptOpen ? ( - - - - You changed {changedViewPreferences.length} view{" "} - {changedViewPreferences.length === 1 ? "setting" : "settings"} during this review. - - - - - Save {changedViewPreferences.length === 1 ? "it" : "them"} to your config before - quitting? - - - - - {viewPreferencesConfigLabel} - - {viewPreferenceDiffLines.map((line) => ( - - - {line.text} - - - ))} - + /> ) : null} {extensionTrustPromptOpen && extensionTrustPromptRoot ? ( diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index c37bb0b68..b00106ed5 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -3786,7 +3786,7 @@ describe("App interactions", () => { let frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?"), ); - expect(frame).toContain("You changed 1 view setting during this review."); + expect(frame).toContain("You changed 1 view setting during this session."); expect(frame).toContain("q discard"); expect(frame).toContain("n never ask"); expect(frame).toContain('- theme = "github-dark-default"'); diff --git a/packages/hunk/src/ui/components/chrome/ViewPreferenceQuitDialog.tsx b/packages/hunk/src/ui/components/chrome/ViewPreferenceQuitDialog.tsx new file mode 100644 index 000000000..d00d4d210 --- /dev/null +++ b/packages/hunk/src/ui/components/chrome/ViewPreferenceQuitDialog.tsx @@ -0,0 +1,65 @@ +import type { ViewPreferenceQuitController } from "../../hooks/useViewPreferenceQuitController"; +import type { AppTheme } from "../../themes"; +import { ConfirmDialog, confirmDialogHeight } from "./ConfirmDialog"; + +/** Render the shared save-or-discard prompt for changed view preferences. */ +export function ViewPreferenceQuitDialog({ + controller, + terminalHeight, + terminalWidth, + theme, +}: { + controller: ViewPreferenceQuitController; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; +}) { + const { + changedViewPreferences, + viewPreferenceDiffLines, + viewPreferencesConfigLabel, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, + } = controller; + + return ( + + + + You changed {changedViewPreferences.length} view{" "} + {changedViewPreferences.length === 1 ? "setting" : "settings"} during this session. + + + + + Save {changedViewPreferences.length === 1 ? "it" : "them"} to your config before quitting? + + + + + {viewPreferencesConfigLabel} + + {viewPreferenceDiffLines.map((line) => ( + + {line.text} + + ))} + + ); +} diff --git a/packages/hunk/src/ui/history/runStaticHistory.test.ts b/packages/hunk/src/ui/history/runStaticHistory.test.ts index 8d9379b82..3e9095202 100644 --- a/packages/hunk/src/ui/history/runStaticHistory.test.ts +++ b/packages/hunk/src/ui/history/runStaticHistory.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import type { HistoryCommit } from "../../core/history/types"; +import { persistedViewPreferencesFromOptions } from "../../core/run/config"; import { createTestExtensionSession } from "../../../../../test/helpers/extension-session"; import type { HistoryRuntime } from "./types"; import { runStaticHistory } from "./runStaticHistory"; @@ -32,6 +33,8 @@ function runtime(commits: HistoryCommit[], maxCount?: number, closeFailure?: Err repoRoot: "/repo", notices: [], customThemes: [], + initialViewPreferences: persistedViewPreferencesFromOptions({}), + promptSaveViewPreferences: true, async planReview(commit) { return { kind: "revision-show", revisionId: commit.revisionId }; }, diff --git a/packages/hunk/src/ui/history/types.ts b/packages/hunk/src/ui/history/types.ts index 169cf8c94..43e84810a 100644 --- a/packages/hunk/src/ui/history/types.ts +++ b/packages/hunk/src/ui/history/types.ts @@ -1,4 +1,5 @@ import type { HistoryCommandInput } from "../../core/run/commandInputs"; +import type { PersistedViewPreferences } from "../../core/run/config"; import type { VcsHistorySource } from "../../core/vcs/types"; import type { ExtensionSession } from "../../extensions/session"; import type { @@ -19,6 +20,10 @@ export interface HistoryRuntime { repoRoot: string; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; + /** Resolved launch preferences retained while history owns the session-wide quit flow. */ + initialViewPreferences: PersistedViewPreferences; + viewPreferencesConfigPath?: string; + promptSaveViewPreferences: boolean; /** Command-owned extension authority borrowed by embedded reviews. */ extensionSession: ExtensionSession; planReview( diff --git a/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts index 23327df90..3333e1a40 100644 --- a/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -17,6 +17,7 @@ import type { ExtensionDialogRequest } from "../lib/extensionDialogs"; import { toExtensionKeyEvent } from "../lib/extensionKeyEvent"; import { isEscapeKey, isSaveDraftNoteKey } from "../lib/keyboard"; import { routeKeyOwnership, type KeyOwner } from "../lib/keyRouting"; +import { handleViewPreferenceQuitPromptKey } from "../lib/viewPreferenceQuitKeys"; type FocusArea = "files" | "filter" | "note"; @@ -260,27 +261,12 @@ export function useAppKeyboardShortcuts({ return "notMine"; } - if (key.name === "return" || key.name === "enter" || key.name === "s" || key.sequence === "s") { - saveViewPreferencesAndQuit(); - return "mine"; - } - - // "q" again quits and discards, so a double-tap of the quit key always exits. - if (key.name === "q" || key.sequence === "q") { - discardViewPreferencesAndQuit(); - return "mine"; - } - - if (key.name === "n" || key.sequence === "n") { - neverAskToSaveViewPreferencesAndQuit(); - return "mine"; - } - - if (isEscapeKey(key)) { - closeSaveConfigPrompt(); - return "mine"; - } - + handleViewPreferenceQuitPromptKey(key, { + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, + }); return "mine"; }; diff --git a/packages/hunk/src/ui/hooks/useViewPreferenceQuitController.ts b/packages/hunk/src/ui/hooks/useViewPreferenceQuitController.ts index af1b64d33..d77037ece 100644 --- a/packages/hunk/src/ui/hooks/useViewPreferenceQuitController.ts +++ b/packages/hunk/src/ui/hooks/useViewPreferenceQuitController.ts @@ -1,6 +1,6 @@ /** * Coordinates view-preference dirty state, persistence choices, prompt state, and safe delayed quits. - * App continues to render the dialog and own its keyboard and UI composition. + * Each interactive surface renders the shared dialog and owns its keyboard routing. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -31,7 +31,7 @@ export interface ViewPreferenceDiffLine { text: string; } -/** Dirty-state projection and quit actions consumed by App's existing UI composition. */ +/** Dirty-state projection and quit actions consumed by an interactive surface. */ export interface ViewPreferenceQuitController { changedViewPreferences: ViewPreferenceChange[]; saveConfigPromptOpen: boolean; @@ -44,7 +44,7 @@ export interface ViewPreferenceQuitController { closeSaveConfigPrompt: () => void; } -/** App-owned facts and side effects required by the view-preference quit workflow. */ +/** Surface-owned facts and side effects required by the view-preference quit workflow. */ export interface UseViewPreferenceQuitControllerOptions { currentPreferences: PersistedViewPreferences; configPath?: string; @@ -70,7 +70,7 @@ function buildViewPreferenceDiffLines( ]); } -/** Own view-preference dirty state and the save-or-discard quit workflow for one mounted App. */ +/** Own view-preference dirty state and the save-or-discard quit workflow for one surface. */ export function useViewPreferenceQuitController({ currentPreferences, configPath, diff --git a/packages/hunk/src/ui/lib/viewPreferenceQuitKeys.test.ts b/packages/hunk/src/ui/lib/viewPreferenceQuitKeys.test.ts new file mode 100644 index 000000000..1de3a0880 --- /dev/null +++ b/packages/hunk/src/ui/lib/viewPreferenceQuitKeys.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { KeyEvent } from "@opentui/core"; +import { handleViewPreferenceQuitPromptKey } from "./viewPreferenceQuitKeys"; + +/** Build one minimal OpenTUI key event for prompt-dispatch tests. */ +function key(name: string, sequence = ""): KeyEvent { + return { name, sequence } as KeyEvent; +} + +/** Build observable prompt actions without coupling tests to persistence. */ +function actions() { + return { + saveViewPreferencesAndQuit: mock(() => undefined), + discardViewPreferencesAndQuit: mock(() => undefined), + neverAskToSaveViewPreferencesAndQuit: mock(() => undefined), + closeSaveConfigPrompt: mock(() => undefined), + }; +} + +describe("view preference quit prompt keys", () => { + test.each([ + [key("return"), "saveViewPreferencesAndQuit"], + [key("enter"), "saveViewPreferencesAndQuit"], + [key("s"), "saveViewPreferencesAndQuit"], + [key("unknown", "s"), "saveViewPreferencesAndQuit"], + [key("q"), "discardViewPreferencesAndQuit"], + [key("unknown", "q"), "discardViewPreferencesAndQuit"], + [key("n"), "neverAskToSaveViewPreferencesAndQuit"], + [key("unknown", "n"), "neverAskToSaveViewPreferencesAndQuit"], + [key("escape"), "closeSaveConfigPrompt"], + ] as const)("dispatches %o to %s", (input, expectedAction) => { + const promptActions = actions(); + + handleViewPreferenceQuitPromptKey(input, promptActions); + + expect(promptActions[expectedAction]).toHaveBeenCalledTimes(1); + expect( + Object.values(promptActions).reduce((count, action) => count + action.mock.calls.length, 0), + ).toBe(1); + }); + + test("does nothing for an unrecognized key", () => { + const promptActions = actions(); + + handleViewPreferenceQuitPromptKey(key("x", "x"), promptActions); + + expect(Object.values(promptActions).every((action) => action.mock.calls.length === 0)).toBe( + true, + ); + }); +}); diff --git a/packages/hunk/src/ui/lib/viewPreferenceQuitKeys.ts b/packages/hunk/src/ui/lib/viewPreferenceQuitKeys.ts new file mode 100644 index 000000000..1fc6d7ed6 --- /dev/null +++ b/packages/hunk/src/ui/lib/viewPreferenceQuitKeys.ts @@ -0,0 +1,35 @@ +import type { KeyEvent } from "@opentui/core"; +import type { ViewPreferenceQuitController } from "../hooks/useViewPreferenceQuitController"; +import { isEscapeKey } from "./keyboard"; + +type ViewPreferenceQuitPromptActions = Pick< + ViewPreferenceQuitController, + | "saveViewPreferencesAndQuit" + | "discardViewPreferencesAndQuit" + | "neverAskToSaveViewPreferencesAndQuit" + | "closeSaveConfigPrompt" +>; + +/** Dispatch one key owned by the save-view-preferences prompt. */ +export function handleViewPreferenceQuitPromptKey( + key: KeyEvent, + actions: ViewPreferenceQuitPromptActions, +): void { + if (key.name === "return" || key.name === "enter" || key.name === "s" || key.sequence === "s") { + actions.saveViewPreferencesAndQuit(); + return; + } + + // A repeated quit key discards, so double-tapping q always exits. + if (key.name === "q" || key.sequence === "q") { + actions.discardViewPreferencesAndQuit(); + return; + } + + if (key.name === "n" || key.sequence === "n") { + actions.neverAskToSaveViewPreferencesAndQuit(); + return; + } + + if (isEscapeKey(key)) actions.closeSaveConfigPrompt(); +} diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx index 6e38d29a3..4a14fda4f 100644 --- a/packages/hunk/src/ui/log/LogApp.tsx +++ b/packages/hunk/src/ui/log/LogApp.tsx @@ -1,17 +1,21 @@ import type { KeyEvent, MouseEvent as TuiMouseEvent } from "@opentui/core"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; import { basename } from "node:path"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { ExtensionVcsHistoryCommit } from "../../extension-api/types"; import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { resolveExtensionSessionOptions } from "../../extensions/apply"; import { HelpDialog } from "../components/chrome/HelpDialog"; import { MenuBar } from "../components/chrome/MenuBar"; import { MenuDropdown } from "../components/chrome/MenuDropdown"; import type { AppMenus, MenuEntry } from "../components/chrome/menu"; import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; +import { ViewPreferenceQuitDialog } from "../components/chrome/ViewPreferenceQuitDialog"; import { useMenuController } from "../hooks/useMenuController"; import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { useViewPreferenceQuitController } from "../hooks/useViewPreferenceQuitController"; import { fitText, measureTextWidth } from "../lib/text"; +import { handleViewPreferenceQuitPromptKey } from "../lib/viewPreferenceQuitKeys"; import type { HistoryRuntime } from "../history/types"; import type { LogController } from "./controller"; import { LOG_HELP_SECTIONS } from "./logHelp"; @@ -43,6 +47,7 @@ function HistoryGraphLine({ text, colors }: { text: string; colors: readonly str export type LogAppOutcome = | { kind: "quit"; exitCode?: number } + | { kind: "cancel-open-review" } | { kind: "open-review"; commit: ExtensionVcsHistoryCommit; @@ -76,6 +81,7 @@ export function LogApp({ // open two child reviews. Quit remains available while the host settles pending work. const reviewPending = useRef(false); const reviewQuitEnabled = useRef(false); + const pendingExitCode = useRef(undefined); const themeController = useThemeSelectorController({ customThemes: runtime.customThemes, initialTheme: snapshot.themeId, @@ -96,6 +102,28 @@ export function LogApp({ const selectedRow = snapshot.rows[snapshot.selected]; const responsiveLayout = resolveLogResponsiveLayout(terminal.width, terminal.height); const viewportBodyHeight = responsiveLayout.bodyHeight; + const currentViewPreferences = useMemo( + () => ({ ...runtime.initialViewPreferences, theme: themeController.themeId }), + [runtime.initialViewPreferences, themeController.themeId], + ); + const viewPreferenceQuit = useViewPreferenceQuitController({ + currentPreferences: currentViewPreferences, + configPath: runtime.viewPreferencesConfigPath, + pagerMode: false, + promptSaveViewPreferences: runtime.promptSaveViewPreferences, + transientViewPreferences: resolveExtensionSessionOptions( + runtime.extensionSession.current.registry, + ).transientViewPreferences, + onQuit: () => { + const exitCode = pendingExitCode.current; + pendingExitCode.current = undefined; + void onOutcome({ kind: "quit", ...(exitCode === undefined ? {} : { exitCode }) }); + }, + showNotice: setTransientNotice, + showError: setTransientNotice, + closeHelp: () => setShowHelp(false), + homeDirectory: process.env.HOME, + }); const copySelected = (row = controller.getSelectedRow()) => { const currentRow = row; @@ -144,6 +172,17 @@ export function LogApp({ }, [transientNotice]); const clearTransientNotice = () => setTransientNotice(""); + /** Stop pending review preparation before beginning the history-owned quit decision. */ + const requestLogQuit = (exitCode?: number) => { + pendingExitCode.current = exitCode; + if (reviewPending.current) { + reviewPending.current = false; + reviewQuitEnabled.current = false; + setOpeningCommit(null); + void onOutcome({ kind: "cancel-open-review" }); + } + viewPreferenceQuit.requestQuit(); + }; const executeCommand = (id: LogCommandId, exitCode?: number) => { clearTransientNotice(); if (!isLogCommandEnabled(id, controller.getSnapshot())) return; @@ -158,7 +197,7 @@ export function LogApp({ void controller.refresh(); break; case "quit": - onOutcome({ kind: "quit", ...(exitCode === undefined ? {} : { exitCode }) }); + requestLogQuit(exitCode); break; case "theme": themeController.openThemeSelector(); @@ -318,6 +357,11 @@ export function LogApp({ }; const name = key.name; const sequence = key.sequence ?? ""; + if (viewPreferenceQuit.saveConfigPromptOpen) { + handleViewPreferenceQuitPromptKey(key, viewPreferenceQuit); + consume(); + return; + } if (reviewPending.current) { if (!reviewQuitEnabled.current) { consume(); @@ -693,6 +737,14 @@ export function LogApp({ onClose={() => setShowHelp(false)} /> ) : null} + {viewPreferenceQuit.saveConfigPromptOpen ? ( + + ) : null} ); } diff --git a/packages/hunk/src/ui/log/controller.test.ts b/packages/hunk/src/ui/log/controller.test.ts index 3454a335a..bd6ade31f 100644 --- a/packages/hunk/src/ui/log/controller.test.ts +++ b/packages/hunk/src/ui/log/controller.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createTestExtensionSession } from "../../../../../test/helpers/extension-session"; +import { persistedViewPreferencesFromOptions } from "../../core/run/config"; import type { HistoryRuntime } from "../history/types"; import { LogController } from "./controller"; @@ -43,6 +44,8 @@ function createRuntime(subjects = ["first", "second", "third"]) { repoRoot: "/repo", notices: [], customThemes: [], + initialViewPreferences: persistedViewPreferencesFromOptions({}), + promptSaveViewPreferences: true, async planReview(commit) { return { kind: "revision-show", revisionId: commit.revisionId }; }, diff --git a/packages/hunk/src/ui/session/HunkSessionHost.test.tsx b/packages/hunk/src/ui/session/HunkSessionHost.test.tsx index 031b66656..d1b5c5de2 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.test.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createTestVcsAppBootstrap } from "../../../../../test/helpers/app-bootstrap"; import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { createTestExtensionSession } from "../../../../../test/helpers/extension-session"; +import { persistedViewPreferencesFromOptions } from "../../core/run/config"; import { createEmptyExtensionLoadResult } from "../../extensions/types"; import type { HistoryRuntime } from "../history/types"; import { LogController } from "../log/controller"; @@ -52,6 +53,8 @@ async function createHistoryRoute() { repoRoot: "/repo", notices: [], customThemes: [], + initialViewPreferences: persistedViewPreferencesFromOptions({}), + promptSaveViewPreferences: true, async planReview() { return { kind: "revision-show", revisionId: "revision-a" }; }, @@ -350,6 +353,56 @@ test("waits for non-cooperative provider planning before menu quit", async () => } }); +test("keeps a dirty quit prompt mounted while cancelling review preparation", async () => { + const history = await createHistoryRoute(); + let resolvePlanning!: (value: { kind: "revision-show"; revisionId: string }) => void; + const planning = new Promise<{ kind: "revision-show"; revisionId: string }>((resolve) => { + resolvePlanning = resolve; + }); + history.runtime.planReview = mock(() => planning); + const prepareReview = mock(async () => { + throw new Error("cancelled planning reached preparation"); + }); + const quit = mock(() => undefined); + const setup = await testRender( + , + { width: 100, height: 20 }, + ); + try { + await setup.renderOnce(); + await act(async () => setup.mockInput.typeText("t")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("Theme selector"); + await act(async () => setup.mockInput.pressArrow("down")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("› github-dark-dimmed"); + await act(async () => setup.mockInput.pressEnter()); + await setup.renderOnce(); + await act(async () => setup.mockInput.pressEnter()); + await Bun.sleep(10); + await selectHistoryMenuQuit(setup); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("Save view preferences?"); + + resolvePlanning({ kind: "revision-show", revisionId: "revision-a" }); + await settle(setup); + expect(prepareReview).not.toHaveBeenCalled(); + expect(setup.captureCharFrame()).toContain("Save view preferences?"); + expect(quit).not.toHaveBeenCalled(); + + await act(async () => setup.mockInput.pressKey("escape")); + expect(setup.captureCharFrame()).toContain("History row"); + } finally { + setup.renderer.destroy(); + await history.controller.close(); + } +}); + test("waits for non-cooperative provider planning after an external signal", async () => { const history = await createHistoryRoute(); let resolvePlanning!: (value: { kind: "revision-show"; revisionId: string }) => void; diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index 1814885ec..ad2819bb7 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -142,6 +142,13 @@ export function HunkSessionHost({ historyRoute: HistorySurfaceRoute, outcome: LogAppOutcome, ) => { + if (outcome.kind === "cancel-open-review") { + preparationGenerationRef.current += 1; + preparationControllerRef.current?.abort( + new Error("Hunk review preparation was cancelled before a history quit decision."), + ); + return; + } if (outcome.kind === "quit") { requestQuit(outcome.exitCode); return; diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 53834281d..62441d0bf 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPtyHarness, rightmostColumnOf } from "./harness"; @@ -218,6 +218,39 @@ describe("interactive hunk log", () => { } }); + test("prompts to save a changed theme when the history session quits", async () => { + const cwd = createHistoryRepo(); + const configHome = mkdtempSync(join(tmpdir(), "hunk-log-view-preferences-")); + tempDirs.push(configHome); + const session = await harness.launchHunk({ + args: ["log", "--color", "never", "--no-extensions"], + cwd, + cols: 100, + rows: 20, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + await session.press("t"); + await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await session.press("down"); + await session.press("enter"); + await session.press("q"); + const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); + expect(prompt).toContain('- theme = "github-dark-default"'); + expect(prompt).toContain('+ theme = "github-dark-dimmed"'); + + await session.press("s"); + const configPath = join(configHome, "hunk", "config.toml"); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && !existsSync(configPath)) await Bun.sleep(50); + expect(readFileSync(configPath, "utf8")).toContain('theme = "github-dark-dimmed"'); + } finally { + session.close(); + } + }); + test("adapts GitHub-style grouped rows and right-aligned ids on resize", async () => { const cwd = createHistoryRepo(); const displayId = Bun.spawnSync(["git", "rev-parse", "--short=8", "HEAD"], { From 9dd65223e6e8b26962c9d20f223c333ac7f0920e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 23:39:48 -0400 Subject: [PATCH 2/2] fix(ui): settle log quit races --- packages/hunk/src/ui/log/LogApp.tsx | 44 +++++++++--- .../src/ui/session/HunkSessionHost.test.tsx | 67 +++++++++++++++++-- .../hunk/src/ui/session/HunkSessionHost.tsx | 15 +++++ 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx index 4a14fda4f..4645c19d4 100644 --- a/packages/hunk/src/ui/log/LogApp.tsx +++ b/packages/hunk/src/ui/log/LogApp.tsx @@ -13,7 +13,10 @@ import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; import { ViewPreferenceQuitDialog } from "../components/chrome/ViewPreferenceQuitDialog"; import { useMenuController } from "../hooks/useMenuController"; import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; -import { useViewPreferenceQuitController } from "../hooks/useViewPreferenceQuitController"; +import { + useViewPreferenceQuitController, + type ViewPreferenceQuitScheduler, +} from "../hooks/useViewPreferenceQuitController"; import { fitText, measureTextWidth } from "../lib/text"; import { handleViewPreferenceQuitPromptKey } from "../lib/viewPreferenceQuitKeys"; import type { HistoryRuntime } from "../history/types"; @@ -62,11 +65,13 @@ export function LogApp({ runtime, onOutcome, useColor, + quitScheduler, }: { controller: LogController; runtime: HistoryRuntime; onOutcome: (outcome: LogAppOutcome) => void | Promise; useColor: boolean; + quitScheduler?: ViewPreferenceQuitScheduler; }) { const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); const terminal = useTerminalDimensions(); @@ -81,6 +86,7 @@ export function LogApp({ // open two child reviews. Quit remains available while the host settles pending work. const reviewPending = useRef(false); const reviewQuitEnabled = useRef(false); + const quitRequestCaptured = useRef(false); const pendingExitCode = useRef(undefined); const themeController = useThemeSelectorController({ customThemes: runtime.customThemes, @@ -117,12 +123,14 @@ export function LogApp({ onQuit: () => { const exitCode = pendingExitCode.current; pendingExitCode.current = undefined; + quitRequestCaptured.current = false; void onOutcome({ kind: "quit", ...(exitCode === undefined ? {} : { exitCode }) }); }, showNotice: setTransientNotice, showError: setTransientNotice, closeHelp: () => setShowHelp(false), homeDirectory: process.env.HOME, + quitScheduler, }); const copySelected = (row = controller.getSelectedRow()) => { @@ -174,15 +182,35 @@ export function LogApp({ const clearTransientNotice = () => setTransientNotice(""); /** Stop pending review preparation before beginning the history-owned quit decision. */ const requestLogQuit = (exitCode?: number) => { - pendingExitCode.current = exitCode; - if (reviewPending.current) { - reviewPending.current = false; + if (!quitRequestCaptured.current) { + quitRequestCaptured.current = true; + pendingExitCode.current = exitCode; + } + if (reviewPending.current && reviewQuitEnabled.current) { reviewQuitEnabled.current = false; - setOpeningCommit(null); - void onOutcome({ kind: "cancel-open-review" }); + void Promise.resolve(onOutcome({ kind: "cancel-open-review" })).then( + () => { + reviewPending.current = false; + setOpeningCommit(null); + }, + (error) => { + reviewPending.current = false; + setOpeningCommit(null); + controller.setNotice(error instanceof Error ? error.message : String(error)); + }, + ); } viewPreferenceQuit.requestQuit(); }; + const closeLogSaveConfigPrompt = () => { + quitRequestCaptured.current = false; + pendingExitCode.current = undefined; + viewPreferenceQuit.closeSaveConfigPrompt(); + }; + const logViewPreferenceQuit = { + ...viewPreferenceQuit, + closeSaveConfigPrompt: closeLogSaveConfigPrompt, + }; const executeCommand = (id: LogCommandId, exitCode?: number) => { clearTransientNotice(); if (!isLogCommandEnabled(id, controller.getSnapshot())) return; @@ -358,7 +386,7 @@ export function LogApp({ const name = key.name; const sequence = key.sequence ?? ""; if (viewPreferenceQuit.saveConfigPromptOpen) { - handleViewPreferenceQuitPromptKey(key, viewPreferenceQuit); + handleViewPreferenceQuitPromptKey(key, logViewPreferenceQuit); consume(); return; } @@ -739,7 +767,7 @@ export function LogApp({ ) : null} {viewPreferenceQuit.saveConfigPromptOpen ? ( } }); -test("keeps a dirty quit prompt mounted while cancelling review preparation", async () => { +test("blocks reopening until dirty-quit cancellation settles", async () => { const history = await createHistoryRoute(); let resolvePlanning!: (value: { kind: "revision-show"; revisionId: string }) => void; const planning = new Promise<{ kind: "revision-show"; revisionId: string }>((resolve) => { @@ -389,17 +392,73 @@ test("keeps a dirty quit prompt mounted while cancelling review preparation", as await setup.renderOnce(); expect(setup.captureCharFrame()).toContain("Save view preferences?"); + await act(async () => setup.mockInput.pressKey("escape")); + await setup.renderOnce(); + await act(async () => setup.mockInput.pressEnter()); + expect(setup.captureCharFrame()).not.toContain("Save view preferences?"); + resolvePlanning({ kind: "revision-show", revisionId: "revision-a" }); await settle(setup); + await act(async () => Bun.sleep(20)); + await setup.renderOnce(); expect(prepareReview).not.toHaveBeenCalled(); - expect(setup.captureCharFrame()).toContain("Save view preferences?"); + expect(setup.captureCharFrame()).toContain("History row"); + expect(setup.captureCharFrame()).not.toContain("Preparing review"); expect(quit).not.toHaveBeenCalled(); + } finally { + setup.renderer.destroy(); + await history.controller.close(); + } +}); - await act(async () => setup.mockInput.pressKey("escape")); - expect(setup.captureCharFrame()).toContain("History row"); +test("preserves the original exit status while a saved-preferences quit is delayed", async () => { + const history = await createHistoryRoute(); + const configHome = mkdtempSync(join(tmpdir(), "hunk-log-delayed-quit-")); + history.runtime.viewPreferencesConfigPath = join(configHome, "config.toml"); + const quit = mock(() => undefined); + let scheduledQuit: (() => void) | undefined; + const setup = await testRender( + , + { width: 100, height: 20 }, + ); + try { + await setup.renderOnce(); + await act(async () => setup.mockInput.typeText("t")); + await setup.renderOnce(); + await act(async () => setup.mockInput.pressArrow("down")); + await setup.renderOnce(); + await act(async () => setup.mockInput.pressEnter()); + await setup.renderOnce(); + await act(async () => setup.mockInput.typeText("q")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("Save view preferences?"); + + await act(async () => setup.mockInput.typeText("s")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("Saved view preferences"); + await act(async () => setup.mockInput.pressKey("c", { ctrl: true })); + expect(scheduledQuit).toBeDefined(); + await act(async () => scheduledQuit?.()); + + expect(quit).toHaveBeenCalledTimes(1); + expect(quit).toHaveBeenCalledWith(undefined); } finally { setup.renderer.destroy(); await history.controller.close(); + rmSync(configHome, { recursive: true, force: true }); } }); diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index ad2819bb7..ea7c7384c 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -14,6 +14,7 @@ import type { ExtensionSession } from "../../extensions/session"; import type { ExtensionLoadResult } from "../../extensions/types"; import { AppHost } from "../AppHost"; import type { HistoryRuntime } from "../history/types"; +import type { ViewPreferenceQuitScheduler } from "../hooks/useViewPreferenceQuitController"; import { interactiveLogUsesColor } from "../log/colorPolicy"; import { LogApp, type LogAppOutcome } from "../log/LogApp"; import type { LogController } from "../log/controller"; @@ -46,6 +47,7 @@ type ActiveSurfaceRoute = HistorySurfaceRoute | ActiveReviewSurfaceRoute; export interface HunkSessionHostDeps { prepareReview?: typeof prepareEmbeddedHistoryReview; createReviewRuntime?: typeof createReviewSessionRuntime; + viewPreferenceQuitScheduler?: ViewPreferenceQuitScheduler; } /** @@ -86,6 +88,7 @@ export function HunkSessionHost({ const preparingRef = useRef(false); const preparationControllerRef = useRef(null); const preparationGenerationRef = useRef(0); + const preparationSettlementRef = useRef | null>(null); const nextInstanceRef = useRef(initialRoute.kind === "review" ? initialRoute.instanceId + 1 : 1); const quitRequestedRef = useRef(false); const shutdownPendingRef = useRef(false); @@ -143,10 +146,12 @@ export function HunkSessionHost({ outcome: LogAppOutcome, ) => { if (outcome.kind === "cancel-open-review") { + const settlement = preparationSettlementRef.current; preparationGenerationRef.current += 1; preparationControllerRef.current?.abort( new Error("Hunk review preparation was cancelled before a history quit decision."), ); + await settlement; return; } if (outcome.kind === "quit") { @@ -163,6 +168,11 @@ export function HunkSessionHost({ return; } preparingRef.current = true; + let settlePreparation!: () => void; + const preparationSettlement = new Promise((resolve) => { + settlePreparation = resolve; + }); + preparationSettlementRef.current = preparationSettlement; const generation = ++preparationGenerationRef.current; const preparationController = new AbortController(); preparationControllerRef.current = preparationController; @@ -239,6 +249,10 @@ export function HunkSessionHost({ preparationControllerRef.current = null; } preparingRef.current = false; + if (preparationSettlementRef.current === preparationSettlement) { + preparationSettlementRef.current = null; + } + settlePreparation(); if (shutdownPendingRef.current && routeRef.current.kind === "history") { completeQuit(); } @@ -298,6 +312,7 @@ export function HunkSessionHost({ runtime={route.runtime} useColor={interactiveLogUsesColor(route.runtime.input.color, process.env)} onOutcome={(outcome) => handleHistoryOutcome(route, outcome)} + quitScheduler={deps.viewPreferenceQuitScheduler} /> ); }