From 6a5c7052ca47262485a098b7e7be5bdb9100ac6c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 26 Aug 2026 10:28:37 -0400 Subject: [PATCH 1/3] refactor(ui): isolate view preference quit flow --- .changeset/clean-birds-review.md | 2 + src/ui/App.tsx | 126 +---- .../useViewPreferenceQuitController.test.tsx | 469 ++++++++++++++++++ .../hooks/useViewPreferenceQuitController.ts | 205 ++++++++ 4 files changed, 703 insertions(+), 99 deletions(-) create mode 100644 .changeset/clean-birds-review.md create mode 100644 src/ui/hooks/useViewPreferenceQuitController.test.tsx create mode 100644 src/ui/hooks/useViewPreferenceQuitController.ts diff --git a/.changeset/clean-birds-review.md b/.changeset/clean-birds-review.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/clean-birds-review.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d53a9f600..fb4e3fd46 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -15,12 +15,7 @@ import { useRef, useState, } from "react"; -import { - diffPersistedViewPreferences, - saveGlobalViewPreferences, - saveViewPreferencesPromptPreference, - type PersistedViewPreferences, -} from "../core/run/config"; +import type { PersistedViewPreferences } from "../core/run/config"; import { experimentalFeatureEnabled, resolveExperimentalDiffFiles } from "../core/run/experimental"; import { DEFAULT_TAB_WIDTH } from "../core/run/tabWidth"; import { isVcsReviewInput } from "../core/vcs"; @@ -84,6 +79,7 @@ import { type AgentNoteGeometrySnapshot, type RevealedLineResult, } from "./hooks/useTerminalReview"; +import { useViewPreferenceQuitController } from "./hooks/useViewPreferenceQuitController"; import { useWatchedInput, type WatchedInputRuntime } from "./hooks/useWatchedInput"; import { agentNoteMarkupWidth } from "./lib/agentNoteGeometry"; import { @@ -330,7 +326,6 @@ export function App({ ); const [showHelp, setShowHelp] = useState(false); const [showAgentSkill, setShowAgentSkill] = useState(false); - const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const [paneSizes, setPaneSizes] = useState>({}); @@ -443,30 +438,6 @@ export function App({ wrapLines, ], ); - const initialViewPreferencesRef = useRef(currentViewPreferences); - const changedViewPreferences = useMemo( - () => diffPersistedViewPreferences(initialViewPreferencesRef.current, currentViewPreferences), - [currentViewPreferences], - ); - // Render each change as the -/+ pair of TOML assignments the save would rewrite, - // with the key column aligned across all changed preferences. - const viewPreferenceDiffLines = useMemo(() => { - const keyWidth = changedViewPreferences.reduce( - (width, change) => Math.max(width, change.configKey.length), - 0, - ); - return changedViewPreferences.flatMap((change) => [ - { removed: true, text: `- ${change.configKey.padEnd(keyWidth)} = ${change.previousValue}` }, - { removed: false, text: `+ ${change.configKey.padEnd(keyWidth)} = ${change.nextValue}` }, - ]); - }, [changedViewPreferences]); - const hasUnsavedViewPreferences = changedViewPreferences.length > 0; - const viewPreferencesConfigLabel = useMemo(() => { - const path = bootstrap.viewPreferencesConfigPath ?? "~/.config/hunk/config.toml"; - return process.env.HOME && path.startsWith(process.env.HOME) - ? `~${path.slice(process.env.HOME.length)}` - : path; - }, [bootstrap.viewPreferencesConfigPath]); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; const selectedHunkIndex = review.selectedHunkIndex; @@ -654,6 +625,31 @@ export function App({ sessionNoticeTimeoutRef.current = null; }, 4000); }, []); + /** Close the modal keyboard help overlay. */ + const closeHelp = useCallback(() => { + setShowHelp(false); + }, []); + const { + changedViewPreferences, + saveConfigPromptOpen, + viewPreferenceDiffLines, + viewPreferencesConfigLabel, + requestQuit, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, + } = useViewPreferenceQuitController({ + currentPreferences: currentViewPreferences, + configPath: bootstrap.viewPreferencesConfigPath, + pagerMode, + promptSaveViewPreferences: bootstrap.input.options.promptSaveViewPreferences !== false, + transientViewPreferences: extensionSessionOptions.transientViewPreferences, + onQuit, + showNotice: showSessionNotice, + showError: showSessionNotice, + closeHelp, + }); const notifyExtensionMode = useCallback( (message: string, type?: ExtensionNotifyType) => extensions?.context.notify(message, type), [extensions], @@ -1816,74 +1812,6 @@ export function App({ runtime: watchRuntime, }); - /** Save current view preferences to user config and then leave the app. */ - const saveViewPreferencesAndQuit = useCallback(() => { - try { - const configPath = saveGlobalViewPreferences(currentViewPreferences, { - configPath: bootstrap.viewPreferencesConfigPath, - }); - initialViewPreferencesRef.current = currentViewPreferences; - showSessionNotice(`Saved view preferences to ${configPath}`); - setTimeout(onQuit, 120); - } catch (error) { - showSessionNotice( - error instanceof Error ? error.message : "Failed to save view preferences.", - ); - } - }, [bootstrap.viewPreferencesConfigPath, currentViewPreferences, onQuit, showSessionNotice]); - - /** Leave the app without writing view preference changes. */ - const discardViewPreferencesAndQuit = useCallback(() => { - setSaveConfigPromptOpen(false); - onQuit(); - }, [onQuit]); - - /** Persist the user's choice to stop prompting about view preference changes. */ - const neverAskToSaveViewPreferencesAndQuit = useCallback(() => { - try { - const configPath = saveViewPreferencesPromptPreference(false, { - configPath: bootstrap.viewPreferencesConfigPath, - }); - showSessionNotice(`Won't ask to save view preferences again (${configPath})`); - setTimeout(onQuit, 120); - } catch (error) { - showSessionNotice( - error instanceof Error ? error.message : "Failed to save prompt preference.", - ); - } - }, [bootstrap.viewPreferencesConfigPath, onQuit, showSessionNotice]); - - /** Leave the app through the shared shutdown path, prompting before discarding view changes. */ - const requestQuit = useCallback(() => { - if ( - !pagerMode && - !extensionSessionOptions.transientViewPreferences && - bootstrap.input.options.promptSaveViewPreferences !== false && - hasUnsavedViewPreferences - ) { - setShowHelp(false); - setSaveConfigPromptOpen(true); - return; - } - - onQuit(); - }, [ - bootstrap.input.options.promptSaveViewPreferences, - extensionSessionOptions.transientViewPreferences, - hasUnsavedViewPreferences, - onQuit, - pagerMode, - ]); - - const closeSaveConfigPrompt = useCallback(() => { - setSaveConfigPromptOpen(false); - }, []); - - /** Close the modal keyboard help overlay. */ - const closeHelp = useCallback(() => { - setShowHelp(false); - }, []); - /** Close the agent skill setup overlay. */ const closeAgentSkill = useCallback(() => { setShowAgentSkill(false); diff --git a/src/ui/hooks/useViewPreferenceQuitController.test.tsx b/src/ui/hooks/useViewPreferenceQuitController.test.tsx new file mode 100644 index 000000000..3f8f67024 --- /dev/null +++ b/src/ui/hooks/useViewPreferenceQuitController.test.tsx @@ -0,0 +1,469 @@ +import { describe, expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { act, useState } from "react"; +import type { PersistedViewPreferences } from "../../core/run/config"; +import { + useViewPreferenceQuitController, + type UseViewPreferenceQuitControllerOptions, + type ViewPreferenceQuitScheduler, +} from "./useViewPreferenceQuitController"; + +/** Build a complete preference snapshot for controller tests. */ +function createTestPreferences( + overrides: Partial = {}, +): PersistedViewPreferences { + return { + mode: "auto", + theme: "github-dark-default", + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showMenuBar: true, + showAgentNotes: false, + copyDecorations: false, + cursorLine: "row", + ...overrides, + }; +} + +type HarnessInputs = Pick< + UseViewPreferenceQuitControllerOptions, + | "currentPreferences" + | "configPath" + | "pagerMode" + | "promptSaveViewPreferences" + | "transientViewPreferences" + | "homeDirectory" + | "quitScheduler" +>; + +type TestScheduledQuit = { + callback: () => void; + delayMs: number; + cancelled: boolean; +}; + +/** Capture delayed quits so tests can advance or cancel them without sleeping. */ +function createTestQuitScheduler() { + const scheduled: TestScheduledQuit[] = []; + const schedule = mock((callback: () => void, delayMs: number) => { + const quit = { callback, delayMs, cancelled: false }; + scheduled.push(quit); + return quit; + }); + const cancel = mock((handle: unknown) => { + (handle as TestScheduledQuit).cancelled = true; + }); + const scheduler: ViewPreferenceQuitScheduler = { schedule, cancel }; + + return { + scheduler, + scheduled, + schedule, + cancel, + run: (index = 0) => { + const quit = scheduled[index]; + if (quit && !quit.cancelled) quit.callback(); + }, + }; +} + +/** Mount the controller with replaceable App-owned inputs and observable callbacks. */ +async function renderController(overrides: Partial = {}) { + let controller!: ReturnType; + let setInputs!: (update: Partial) => void; + const onQuit = mock(() => undefined); + const showNotice = mock((_message: string) => undefined); + const showError = mock((_message: string) => undefined); + const closeHelp = mock(() => undefined); + const initialInputs: HarnessInputs = { + currentPreferences: createTestPreferences(), + configPath: undefined, + pagerMode: false, + promptSaveViewPreferences: true, + transientViewPreferences: false, + homeDirectory: "/test/home", + ...overrides, + }; + + function Harness() { + const [inputs, updateInputs] = useState(initialInputs); + setInputs = (update) => updateInputs((current) => ({ ...current, ...update })); + controller = useViewPreferenceQuitController({ + ...inputs, + onQuit, + showNotice, + showError, + closeHelp, + }); + return null; + } + + const setup = await testRender(, { width: 40, height: 4 }); + await act(async () => setup.renderOnce()); + return { + setup, + controller: () => controller, + update: async (update: Partial) => { + await act(async () => setInputs(update)); + await act(async () => setup.renderOnce()); + }, + onQuit, + showNotice, + showError, + closeHelp, + }; +} + +/** Destroy a mounted controller harness. */ +async function destroyController(harness: Awaited>) { + await act(async () => harness.setup.renderer.destroy()); +} + +describe("useViewPreferenceQuitController", () => { + test("derives dirty preferences and aligned TOML rows in persistence order", async () => { + const harness = await renderController(); + + try { + expect(harness.controller().changedViewPreferences).toEqual([]); + + await harness.update({ + currentPreferences: createTestPreferences({ + theme: "github-dark-dimmed", + showLineNumbers: false, + wrapLines: true, + }), + }); + + expect(harness.controller().changedViewPreferences.map((change) => change.configKey)).toEqual( + ["theme", "line_numbers", "wrap_lines"], + ); + expect(harness.controller().viewPreferenceDiffLines).toEqual([ + { removed: true, text: '- theme = "github-dark-default"' }, + { removed: false, text: '+ theme = "github-dark-dimmed"' }, + { removed: true, text: "- line_numbers = true" }, + { removed: false, text: "+ line_numbers = false" }, + { removed: true, text: "- wrap_lines = false" }, + { removed: false, text: "+ wrap_lines = true" }, + ]); + expect(harness.controller().changedViewPreferences).toHaveLength(3); + } finally { + await destroyController(harness); + } + }); + + test("shortens config paths beneath HOME and preserves other labels", async () => { + const harness = await renderController({ + configPath: "/users/probe/.config/hunk/config.toml", + homeDirectory: "/users/probe", + }); + + try { + expect(harness.controller().viewPreferencesConfigLabel).toBe("~/.config/hunk/config.toml"); + + await harness.update({ configPath: "/etc/hunk/config.toml" }); + expect(harness.controller().viewPreferencesConfigLabel).toBe("/etc/hunk/config.toml"); + + await harness.update({ configPath: undefined }); + expect(harness.controller().viewPreferencesConfigLabel).toBe("~/.config/hunk/config.toml"); + } finally { + await destroyController(harness); + } + }); + + test("opens the prompt only for changed persistent preferences and closes help", async () => { + const harness = await renderController(); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().requestQuit()); + + expect(harness.controller().saveConfigPromptOpen).toBe(true); + expect(harness.closeHelp).toHaveBeenCalledTimes(1); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + } finally { + await destroyController(harness); + } + }); + + test("bypasses prompting when unchanged, paging, transient, or disabled by policy", async () => { + const cases: Array<{ label: string; inputs: Partial }> = [ + { label: "unchanged", inputs: {} }, + { label: "pager", inputs: { pagerMode: true } }, + { label: "transient", inputs: { transientViewPreferences: true } }, + { label: "disabled", inputs: { promptSaveViewPreferences: false } }, + ]; + + for (const { label, inputs } of cases) { + const harness = await renderController(inputs); + try { + if (label !== "unchanged") { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + } + await act(async () => harness.controller().requestQuit()); + + expect(harness.controller().saveConfigPromptOpen, label).toBe(false); + expect(harness.closeHelp, label).toHaveBeenCalledTimes(0); + expect(harness.onQuit, label).toHaveBeenCalledTimes(1); + } finally { + await destroyController(harness); + } + } + }); + + test("saves preferences, closes the prompt, advances the baseline, and delays quit", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-save-")); + const configPath = join(directory, "config.toml"); + const quit = createTestQuitScheduler(); + const harness = await renderController({ configPath, quitScheduler: quit.scheduler }); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ theme: "github-dark-dimmed" }), + }); + await act(async () => harness.controller().requestQuit()); + expect(harness.controller().saveConfigPromptOpen).toBe(true); + + await act(async () => harness.controller().saveViewPreferencesAndQuit()); + await act(async () => harness.setup.renderOnce()); + + expect(readFileSync(configPath, "utf8")).toContain('theme = "github-dark-dimmed"'); + expect(harness.controller().changedViewPreferences).toEqual([]); + expect(harness.controller().saveConfigPromptOpen).toBe(false); + expect(harness.showNotice).toHaveBeenCalledWith(`Saved view preferences to ${configPath}`); + expect(harness.showError).toHaveBeenCalledTimes(0); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + expect(quit.scheduled).toMatchObject([{ delayMs: 120, cancelled: false }]); + + quit.run(); + expect(harness.onQuit).toHaveBeenCalledTimes(1); + } finally { + rmSync(directory, { recursive: true, force: true }); + await destroyController(harness); + } + }); + + test("locks persistence and quit actions after scheduling one successful quit", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-duplicate-")); + const configPath = join(directory, "config.toml"); + const quit = createTestQuitScheduler(); + const harness = await renderController({ configPath, quitScheduler: quit.scheduler }); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().requestQuit()); + await act(async () => harness.controller().saveViewPreferencesAndQuit()); + + await act(async () => { + harness.controller().saveViewPreferencesAndQuit(); + harness.controller().neverAskToSaveViewPreferencesAndQuit(); + harness.controller().discardViewPreferencesAndQuit(); + harness.controller().requestQuit(); + }); + + expect(quit.schedule).toHaveBeenCalledTimes(1); + expect(quit.scheduled).toHaveLength(1); + expect(harness.showNotice).toHaveBeenCalledTimes(1); + expect(readFileSync(configPath, "utf8")).not.toContain( + "prompt_save_view_preferences = false", + ); + expect(harness.controller().saveConfigPromptOpen).toBe(false); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + + quit.run(); + expect(harness.onQuit).toHaveBeenCalledTimes(1); + } finally { + rmSync(directory, { recursive: true, force: true }); + await destroyController(harness); + } + }); + + test("cancels a pending delayed quit when the controller unmounts", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-unmount-")); + const configPath = join(directory, "config.toml"); + const quit = createTestQuitScheduler(); + const harness = await renderController({ configPath, quitScheduler: quit.scheduler }); + let destroyed = false; + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().saveViewPreferencesAndQuit()); + expect(quit.scheduled).toHaveLength(1); + + await destroyController(harness); + destroyed = true; + + expect(quit.cancel).toHaveBeenCalledTimes(1); + expect(quit.scheduled[0]?.cancelled).toBe(true); + quit.run(); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + if (!destroyed) await destroyController(harness); + } + }); + + test("reports save failures without advancing the baseline or quitting", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-save-error-")); + const harness = await renderController({ configPath: directory }); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().requestQuit()); + await act(async () => harness.controller().saveViewPreferencesAndQuit()); + + expect(harness.controller().changedViewPreferences).toHaveLength(1); + expect(harness.controller().saveConfigPromptOpen).toBe(true); + expect(harness.showNotice).toHaveBeenCalledTimes(0); + expect(harness.showError).toHaveBeenCalledTimes(1); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + await destroyController(harness); + } + }); + + test("discards without writing and quits immediately", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-discard-")); + const configPath = join(directory, "config.toml"); + const harness = await renderController({ configPath }); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().requestQuit()); + await act(async () => harness.controller().discardViewPreferencesAndQuit()); + + expect(existsSync(configPath)).toBe(false); + expect(harness.controller().saveConfigPromptOpen).toBe(false); + expect(harness.onQuit).toHaveBeenCalledTimes(1); + expect(harness.showNotice).toHaveBeenCalledTimes(0); + expect(harness.showError).toHaveBeenCalledTimes(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + await destroyController(harness); + } + }); + + test("never-ask writes only prompt policy, closes the prompt, and delays quit", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-never-")); + const configPath = join(directory, "config.toml"); + writeFileSync(configPath, "# keep me\n"); + const quit = createTestQuitScheduler(); + const harness = await renderController({ configPath, quitScheduler: quit.scheduler }); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ theme: "github-dark-dimmed" }), + }); + await act(async () => harness.controller().requestQuit()); + expect(harness.controller().saveConfigPromptOpen).toBe(true); + + await act(async () => harness.controller().neverAskToSaveViewPreferencesAndQuit()); + + const source = readFileSync(configPath, "utf8"); + expect(source).toContain("# keep me"); + expect(source).toContain("prompt_save_view_preferences = false"); + expect(source).not.toContain("theme ="); + expect(harness.controller().changedViewPreferences).toHaveLength(1); + expect(harness.controller().saveConfigPromptOpen).toBe(false); + expect(harness.showNotice).toHaveBeenCalledWith( + `Won't ask to save view preferences again (${configPath})`, + ); + expect(harness.showError).toHaveBeenCalledTimes(0); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + expect(quit.scheduled).toMatchObject([{ delayMs: 120, cancelled: false }]); + + quit.run(); + expect(harness.onQuit).toHaveBeenCalledTimes(1); + } finally { + rmSync(directory, { recursive: true, force: true }); + await destroyController(harness); + } + }); + + test("reports never-ask failures without quitting", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-view-controller-never-error-")); + const harness = await renderController({ configPath: directory }); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().requestQuit()); + await act(async () => harness.controller().neverAskToSaveViewPreferencesAndQuit()); + + expect(harness.controller().changedViewPreferences).toHaveLength(1); + expect(harness.controller().saveConfigPromptOpen).toBe(true); + expect(harness.showNotice).toHaveBeenCalledTimes(0); + expect(harness.showError).toHaveBeenCalledTimes(1); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + await destroyController(harness); + } + }); + + test("cancels the prompt without quitting or clearing dirty state", async () => { + const harness = await renderController(); + + try { + await harness.update({ + currentPreferences: createTestPreferences({ wrapLines: true }), + }); + await act(async () => harness.controller().requestQuit()); + await act(async () => harness.controller().closeSaveConfigPrompt()); + + expect(harness.controller().saveConfigPromptOpen).toBe(false); + expect(harness.controller().changedViewPreferences).toHaveLength(1); + expect(harness.onQuit).toHaveBeenCalledTimes(0); + } finally { + await destroyController(harness); + } + }); + + test("preserves the mounted baseline when soft reload inputs change", async () => { + const initial = createTestPreferences(); + const changed = createTestPreferences({ wrapLines: true }); + const harness = await renderController({ + currentPreferences: initial, + configPath: "/review/one/config.toml", + }); + + try { + await harness.update({ currentPreferences: changed }); + expect(harness.controller().changedViewPreferences).toMatchObject([ + { configKey: "wrap_lines", previousValue: "false", nextValue: "true" }, + ]); + + // AppHost replaces bootstrap on a soft reload, but the mounted App and its controller survive. + await harness.update({ + currentPreferences: { ...changed }, + configPath: "/review/two/config.toml", + }); + expect(harness.controller().viewPreferencesConfigLabel).toBe("/review/two/config.toml"); + expect(harness.controller().changedViewPreferences).toMatchObject([ + { configKey: "wrap_lines", previousValue: "false", nextValue: "true" }, + ]); + + await harness.update({ currentPreferences: { ...initial } }); + expect(harness.controller().changedViewPreferences).toEqual([]); + } finally { + await destroyController(harness); + } + }); +}); diff --git a/src/ui/hooks/useViewPreferenceQuitController.ts b/src/ui/hooks/useViewPreferenceQuitController.ts new file mode 100644 index 000000000..db1e24987 --- /dev/null +++ b/src/ui/hooks/useViewPreferenceQuitController.ts @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + diffPersistedViewPreferences, + saveGlobalViewPreferences, + saveViewPreferencesPromptPreference, + type PersistedViewPreferences, + type ViewPreferenceChange, +} from "../../core/run/config"; + +const POST_PERSISTENCE_QUIT_DELAY_MS = 120; +const DEFAULT_VIEW_PREFERENCES_CONFIG_LABEL = "~/.config/hunk/config.toml"; + +/** Schedule and cancel the delayed quit that lets a persistence notice render. */ +export interface ViewPreferenceQuitScheduler { + schedule: (callback: () => void, delayMs: number) => unknown; + cancel: (handle: unknown) => void; +} + +const DEFAULT_QUIT_SCHEDULER: ViewPreferenceQuitScheduler = { + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + cancel: (handle) => clearTimeout(handle as ReturnType), +}; + +/** One aligned TOML row rendered by the view-preference confirmation dialog. */ +export interface ViewPreferenceDiffLine { + removed: boolean; + text: string; +} + +/** Dirty-state projection and quit actions consumed by App's existing UI composition. */ +export interface ViewPreferenceQuitController { + changedViewPreferences: ViewPreferenceChange[]; + saveConfigPromptOpen: boolean; + viewPreferenceDiffLines: ViewPreferenceDiffLine[]; + viewPreferencesConfigLabel: string; + requestQuit: () => void; + saveViewPreferencesAndQuit: () => void; + discardViewPreferencesAndQuit: () => void; + neverAskToSaveViewPreferencesAndQuit: () => void; + closeSaveConfigPrompt: () => void; +} + +/** App-owned facts and side effects required by the view-preference quit workflow. */ +export interface UseViewPreferenceQuitControllerOptions { + currentPreferences: PersistedViewPreferences; + configPath?: string; + pagerMode: boolean; + promptSaveViewPreferences: boolean; + transientViewPreferences: boolean; + onQuit: () => void; + showNotice: (message: string) => void; + showError: (message: string) => void; + closeHelp: () => void; + homeDirectory?: string; + quitScheduler?: ViewPreferenceQuitScheduler; +} + +/** Build aligned TOML removal/addition rows in the core preference order. */ +function buildViewPreferenceDiffLines( + changes: readonly ViewPreferenceChange[], +): ViewPreferenceDiffLine[] { + const keyWidth = changes.reduce((width, change) => Math.max(width, change.configKey.length), 0); + return changes.flatMap((change) => [ + { removed: true, text: `- ${change.configKey.padEnd(keyWidth)} = ${change.previousValue}` }, + { removed: false, text: `+ ${change.configKey.padEnd(keyWidth)} = ${change.nextValue}` }, + ]); +} + +/** Own view-preference dirty state and the save-or-discard quit workflow for one mounted App. */ +export function useViewPreferenceQuitController({ + currentPreferences, + configPath, + pagerMode, + promptSaveViewPreferences, + transientViewPreferences, + onQuit, + showNotice, + showError, + closeHelp, + homeDirectory = process.env.HOME, + quitScheduler = DEFAULT_QUIT_SCHEDULER, +}: UseViewPreferenceQuitControllerOptions): ViewPreferenceQuitController { + const [savedPreferences, setSavedPreferences] = useState(currentPreferences); + const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false); + const pendingQuitTimerRef = useRef(undefined); + const quitPendingRef = useRef(false); + const changedViewPreferences = useMemo( + () => diffPersistedViewPreferences(savedPreferences, currentPreferences), + [currentPreferences, savedPreferences], + ); + const viewPreferenceDiffLines = useMemo( + () => buildViewPreferenceDiffLines(changedViewPreferences), + [changedViewPreferences], + ); + const hasUnsavedViewPreferences = changedViewPreferences.length > 0; + const viewPreferencesConfigLabel = useMemo(() => { + const path = configPath ?? DEFAULT_VIEW_PREFERENCES_CONFIG_LABEL; + return homeDirectory && path.startsWith(homeDirectory) + ? `~${path.slice(homeDirectory.length)}` + : path; + }, [configPath, homeDirectory]); + + /** Close the prompt, lock its actions, and schedule one delayed quit. */ + const scheduleQuit = useCallback(() => { + if (quitPendingRef.current) return; + + quitPendingRef.current = true; + setSaveConfigPromptOpen(false); + pendingQuitTimerRef.current = quitScheduler.schedule(() => { + pendingQuitTimerRef.current = undefined; + quitPendingRef.current = false; + onQuit(); + }, POST_PERSISTENCE_QUIT_DELAY_MS); + }, [onQuit, quitScheduler]); + + useEffect( + () => () => { + if (!quitPendingRef.current) return; + + quitScheduler.cancel(pendingQuitTimerRef.current); + pendingQuitTimerRef.current = undefined; + quitPendingRef.current = false; + }, + [quitScheduler], + ); + + /** Save current preferences, advance the dirty baseline, and leave after the notice is visible. */ + const saveViewPreferencesAndQuit = useCallback(() => { + if (quitPendingRef.current) return; + + try { + const savedPath = saveGlobalViewPreferences(currentPreferences, { configPath }); + setSavedPreferences(currentPreferences); + showNotice(`Saved view preferences to ${savedPath}`); + scheduleQuit(); + } catch (error) { + showError(error instanceof Error ? error.message : "Failed to save view preferences."); + } + }, [configPath, currentPreferences, scheduleQuit, showError, showNotice]); + + /** Leave without persisting either the current preferences or prompt policy. */ + const discardViewPreferencesAndQuit = useCallback(() => { + if (quitPendingRef.current) return; + + setSaveConfigPromptOpen(false); + onQuit(); + }, [onQuit]); + + /** Disable future prompts without persisting the changed view preferences, then leave. */ + const neverAskToSaveViewPreferencesAndQuit = useCallback(() => { + if (quitPendingRef.current) return; + + try { + const savedPath = saveViewPreferencesPromptPreference(false, { configPath }); + showNotice(`Won't ask to save view preferences again (${savedPath})`); + scheduleQuit(); + } catch (error) { + showError(error instanceof Error ? error.message : "Failed to save prompt preference."); + } + }, [configPath, scheduleQuit, showError, showNotice]); + + /** Prompt for changed persistent preferences or leave immediately when prompting is inapplicable. */ + const requestQuit = useCallback(() => { + if (quitPendingRef.current) return; + + if ( + !pagerMode && + !transientViewPreferences && + promptSaveViewPreferences && + hasUnsavedViewPreferences + ) { + closeHelp(); + setSaveConfigPromptOpen(true); + return; + } + + onQuit(); + }, [ + closeHelp, + hasUnsavedViewPreferences, + onQuit, + pagerMode, + promptSaveViewPreferences, + transientViewPreferences, + ]); + + /** Cancel the pending quit decision without changing preferences or their baseline. */ + const closeSaveConfigPrompt = useCallback(() => { + if (quitPendingRef.current) return; + + setSaveConfigPromptOpen(false); + }, []); + + return { + changedViewPreferences, + saveConfigPromptOpen, + viewPreferenceDiffLines, + viewPreferencesConfigLabel, + requestQuit, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, + }; +} From 18a78f0eed5d0655852c7d279a2e4326e0135d36 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 26 Aug 2026 17:09:56 -0400 Subject: [PATCH 2/3] refactor(ui): inject quit controller home directory --- src/ui/App.tsx | 1 + src/ui/hooks/useViewPreferenceQuitController.test.tsx | 10 +++++++++- src/ui/hooks/useViewPreferenceQuitController.ts | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index fb4e3fd46..84bf029b2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -649,6 +649,7 @@ export function App({ showNotice: showSessionNotice, showError: showSessionNotice, closeHelp, + homeDirectory: process.env.HOME, }); const notifyExtensionMode = useCallback( (message: string, type?: ExtensionNotifyType) => extensions?.context.notify(message, type), diff --git a/src/ui/hooks/useViewPreferenceQuitController.test.tsx b/src/ui/hooks/useViewPreferenceQuitController.test.tsx index 3f8f67024..827e6970d 100644 --- a/src/ui/hooks/useViewPreferenceQuitController.test.tsx +++ b/src/ui/hooks/useViewPreferenceQuitController.test.tsx @@ -155,7 +155,7 @@ describe("useViewPreferenceQuitController", () => { } }); - test("shortens config paths beneath HOME and preserves other labels", async () => { + test("shortens config paths only when an explicit HOME contains them", async () => { const harness = await renderController({ configPath: "/users/probe/.config/hunk/config.toml", homeDirectory: "/users/probe", @@ -167,6 +167,14 @@ describe("useViewPreferenceQuitController", () => { await harness.update({ configPath: "/etc/hunk/config.toml" }); expect(harness.controller().viewPreferencesConfigLabel).toBe("/etc/hunk/config.toml"); + await harness.update({ + configPath: "/users/probe/.config/hunk/config.toml", + homeDirectory: undefined, + }); + expect(harness.controller().viewPreferencesConfigLabel).toBe( + "/users/probe/.config/hunk/config.toml", + ); + await harness.update({ configPath: undefined }); expect(harness.controller().viewPreferencesConfigLabel).toBe("~/.config/hunk/config.toml"); } finally { diff --git a/src/ui/hooks/useViewPreferenceQuitController.ts b/src/ui/hooks/useViewPreferenceQuitController.ts index db1e24987..e20eb4bf2 100644 --- a/src/ui/hooks/useViewPreferenceQuitController.ts +++ b/src/ui/hooks/useViewPreferenceQuitController.ts @@ -51,7 +51,7 @@ export interface UseViewPreferenceQuitControllerOptions { showNotice: (message: string) => void; showError: (message: string) => void; closeHelp: () => void; - homeDirectory?: string; + homeDirectory: string | undefined; quitScheduler?: ViewPreferenceQuitScheduler; } @@ -77,7 +77,7 @@ export function useViewPreferenceQuitController({ showNotice, showError, closeHelp, - homeDirectory = process.env.HOME, + homeDirectory, quitScheduler = DEFAULT_QUIT_SCHEDULER, }: UseViewPreferenceQuitControllerOptions): ViewPreferenceQuitController { const [savedPreferences, setSavedPreferences] = useState(currentPreferences); From c4917363a578fd22578e9ae5d3de984d148edf4b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 26 Aug 2026 19:32:46 -0400 Subject: [PATCH 3/3] docs(ui): explain quit controller ownership --- src/ui/hooks/useViewPreferenceQuitController.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/hooks/useViewPreferenceQuitController.ts b/src/ui/hooks/useViewPreferenceQuitController.ts index e20eb4bf2..af1b64d33 100644 --- a/src/ui/hooks/useViewPreferenceQuitController.ts +++ b/src/ui/hooks/useViewPreferenceQuitController.ts @@ -1,3 +1,7 @@ +/** + * 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. + */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { diffPersistedViewPreferences,