From f097d53cd2e0e7bf5e0a3fa14413322ce90bb207 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 17:12:15 -0400 Subject: [PATCH] feat(ui): prompt to save view preferences on quit --- .changeset/save-view-preferences-on-quit.md | 5 + src/core/config.test.ts | 56 ++++++- src/core/config.ts | 92 ++++++++++- src/core/startup.ts | 1 + src/core/types.ts | 1 + src/ui/App.tsx | 163 +++++++++++++++++++- src/ui/AppHost.interactions.test.tsx | 117 +++++++++++++- src/ui/AppHost.tsx | 1 + src/ui/hooks/useAppKeyboardShortcuts.ts | 38 +++++ 9 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 .changeset/save-view-preferences-on-quit.md diff --git a/.changeset/save-view-preferences-on-quit.md b/.changeset/save-view-preferences-on-quit.md new file mode 100644 index 00000000..40a02faa --- /dev/null +++ b/.changeset/save-view-preferences-on-quit.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Offer to save changed view preferences to the user config on quit, including theme, layout, line numbers, wrapping, hunk headers, agent notes, and copy decorations. diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 7715ea5a..ab1d0a9b 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CliInput } from "./types"; -import { resolveConfiguredCliInput } from "./config"; +import { resolveConfiguredCliInput, saveGlobalViewPreferences } from "./config"; import { loadAppBootstrap } from "./loaders"; const tempDirs: string[] = []; @@ -46,6 +46,56 @@ afterEach(() => { cleanupTempDirs(); }); +describe("config persistence", () => { + test("writes accepted view preferences to user config without disturbing tables", () => { + const home = createTempDir("hunk-save-config-home-"); + const configPath = join(home, ".config", "hunk", "config.toml"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync( + configPath, + [ + "# personal defaults", + 'theme = "github-dark-default"', + "wrap_lines = false", + "", + "[custom_theme]", + 'label = "Keep me"', + ].join("\n"), + ); + + const savedPath = saveGlobalViewPreferences( + { + mode: "split", + theme: "dracula", + showLineNumbers: false, + wrapLines: true, + showHunkHeaders: false, + showAgentNotes: true, + copyDecorations: true, + }, + { env: { HOME: home } }, + ); + + expect(savedPath).toBe(configPath); + expect(readFileSync(configPath, "utf8")).toBe( + [ + "# personal defaults", + 'theme = "dracula"', + "wrap_lines = true", + 'mode = "split"', + "line_numbers = false", + "hunk_headers = false", + "agent_notes = true", + "copy_decorations = true", + "", + "[custom_theme]", + 'label = "Keep me"', + "", + ].join("\n"), + ); + }); +}); + describe("config resolution", () => { test("merges global, repo, pager, command, and CLI overrides in the right order", () => { const home = createTempDir("hunk-config-home-"); @@ -87,6 +137,7 @@ describe("config resolution", () => { }); expect(resolved.repoConfigPath).toBe(join(repo, ".hunk", "config.toml")); + expect(resolved.viewPreferencesConfigPath).toBe(join(repo, ".hunk", "config.toml")); expect(resolved.input.options).toMatchObject({ pager: true, mode: "stack", @@ -271,6 +322,7 @@ describe("config resolution", () => { }); expect(resolved.repoConfigPath).toBeUndefined(); + expect(resolved.viewPreferencesConfigPath).toBe(join(home, ".config", "hunk", "config.toml")); expect(resolved.input.options.theme).toBe("github-dark-default"); }); diff --git a/src/core/config.ts b/src/core/config.ts index b3750366..ea77f346 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes"; import { normalizeBuiltInThemeId } from "../ui/themes"; import { resolveGlobalConfigPath } from "./paths"; @@ -74,6 +74,19 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { copyDecorations: false, }; +const PERSISTED_VIEW_PREFERENCE_KEYS: Array<{ + configKey: string; + value: (preferences: PersistedViewPreferences) => string | boolean | undefined; +}> = [ + { configKey: "theme", value: (preferences) => preferences.theme }, + { configKey: "mode", value: (preferences) => preferences.mode }, + { configKey: "line_numbers", value: (preferences) => preferences.showLineNumbers }, + { configKey: "wrap_lines", value: (preferences) => preferences.wrapLines }, + { configKey: "hunk_headers", value: (preferences) => preferences.showHunkHeaders }, + { configKey: "agent_notes", value: (preferences) => preferences.showAgentNotes }, + { configKey: "copy_decorations", value: (preferences) => preferences.copyDecorations }, +]; + interface ConfigResolutionOptions { cwd?: string; env?: NodeJS.ProcessEnv; @@ -84,12 +97,54 @@ interface HunkConfigResolution { customTheme?: CustomThemeConfig; globalConfigPath?: string; repoConfigPath?: string; + viewPreferencesConfigPath?: string; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Serialize one primitive TOML preference value. */ +function serializeTomlPreferenceValue(value: string | boolean) { + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + return JSON.stringify(value); +} + +/** Update one top-level TOML key while preserving sections and unrelated comments. */ +function upsertTopLevelTomlValue(source: string, key: string, value: string | boolean) { + const lines = source.length > 0 ? source.split("\n") : []; + const serialized = serializeTomlPreferenceValue(value); + const assignment = `${key} = ${serialized}`; + let firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); + if (firstTableIndex < 0) { + firstTableIndex = lines.length; + } + + const keyPattern = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`); + for (let index = 0; index < firstTableIndex; index += 1) { + if (keyPattern.test(lines[index] ?? "")) { + lines[index] = assignment; + return `${lines.join("\n").replace(/\n*$/, "")}\n`; + } + } + + let insertAt = firstTableIndex; + const hasTableSpacer = insertAt > 0 && lines[insertAt - 1] === ""; + if (hasTableSpacer) { + insertAt -= 1; + } + lines.splice( + insertAt, + 0, + assignment, + ...(hasTableSpacer || insertAt === lines.length ? [] : [""]), + ); + return `${lines.join("\n").replace(/\n*$/, "")}\n`; +} + /** Accept only the layout names Hunk already supports. */ function normalizeLayoutMode(value: unknown): LayoutMode | undefined { return value === "auto" || value === "split" || value === "stack" ? value : undefined; @@ -302,6 +357,37 @@ function readTomlRecord(path: string) { return parsed; } +/** Persist accepted in-app view preferences to the selected Hunk config file. */ +export function saveGlobalViewPreferences( + preferences: PersistedViewPreferences, + { + configPath: configuredPath, + env = process.env, + }: Pick & { configPath?: string } = {}, +) { + const configPath = configuredPath ?? resolveGlobalConfigPath(env); + if (!configPath) { + throw new Error("Could not resolve a config path because HOME/XDG_CONFIG_HOME is unset."); + } + + let source = ""; + if (fs.existsSync(configPath)) { + source = fs.readFileSync(configPath, "utf8"); + } + + let nextSource = source; + for (const key of PERSISTED_VIEW_PREFERENCE_KEYS) { + const value = key.value(preferences); + if (value !== undefined) { + nextSource = upsertTopLevelTomlValue(nextSource, key.configKey, value); + } + } + + fs.mkdirSync(dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, nextSource); + return configPath; +} + /** Resolve CLI input against global and repo-local config files. */ export function resolveConfiguredCliInput( input: CliInput, @@ -373,5 +459,9 @@ export function resolveConfiguredCliInput( customTheme: resolvedCustomTheme, globalConfigPath: userConfigPath, repoConfigPath, + // Persist in the repo config only when the repo already has one; otherwise keep personal view + // choices user-scoped so Hunk does not create project policy files from an interactive prompt. + viewPreferencesConfigPath: + repoConfigPath && fs.existsSync(repoConfigPath) ? repoConfigPath : userConfigPath, }; } diff --git a/src/core/startup.ts b/src/core/startup.ts index da046503..92e70a16 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -232,6 +232,7 @@ export async function prepareStartupPlan( } bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; + bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; diff --git a/src/core/types.ts b/src/core/types.ts index 9da14f08..e25da7fc 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -366,4 +366,5 @@ export interface AppBootstrap { initialShowHunkHeaders?: boolean; initialShowAgentNotes?: boolean; initialCopyDecorations?: boolean; + viewPreferencesConfigPath?: string; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d8717ca3..134bfc10 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,10 +5,18 @@ import { } from "@opentui/core"; 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 { saveGlobalViewPreferences } from "../core/config"; +import type { + AppBootstrap, + CliInput, + LayoutMode, + PersistedViewPreferences, + UserNoteLineTarget, +} from "../core/types"; import { canReloadInput, computeWatchSignature } from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; +import { ModalFrame } from "./components/chrome/ModalFrame"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; import { SidebarPane } from "./components/panes/SidebarPane"; @@ -139,6 +147,7 @@ export function App({ const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode); const [forceSidebarOpen, setForceSidebarOpen] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(34); @@ -174,6 +183,72 @@ export function App({ })), [activeTheme.id, themeOptions], ); + const currentViewPreferences = useMemo( + () => ({ + mode: layoutMode, + theme: themeId, + showLineNumbers, + wrapLines, + showHunkHeaders, + showAgentNotes, + copyDecorations, + }), + [ + copyDecorations, + layoutMode, + showAgentNotes, + showHunkHeaders, + showLineNumbers, + themeId, + wrapLines, + ], + ); + const initialViewPreferencesRef = useRef(currentViewPreferences); + const changedViewPreferenceLines = useMemo(() => { + const initial = initialViewPreferencesRef.current; + const changes: string[] = []; + if (currentViewPreferences.theme !== initial.theme) { + changes.push( + `Theme: ${initial.theme ?? "default"} → ${currentViewPreferences.theme ?? "default"}`, + ); + } + if (currentViewPreferences.mode !== initial.mode) { + changes.push(`Layout: ${initial.mode} → ${currentViewPreferences.mode}`); + } + if (currentViewPreferences.showLineNumbers !== initial.showLineNumbers) { + changes.push( + `Line numbers: ${initial.showLineNumbers ? "on" : "off"} → ${currentViewPreferences.showLineNumbers ? "on" : "off"}`, + ); + } + if (currentViewPreferences.wrapLines !== initial.wrapLines) { + changes.push( + `Line wrapping: ${initial.wrapLines ? "on" : "off"} → ${currentViewPreferences.wrapLines ? "on" : "off"}`, + ); + } + if (currentViewPreferences.showHunkHeaders !== initial.showHunkHeaders) { + changes.push( + `Hunk headers: ${initial.showHunkHeaders ? "shown" : "hidden"} → ${currentViewPreferences.showHunkHeaders ? "shown" : "hidden"}`, + ); + } + if (currentViewPreferences.showAgentNotes !== initial.showAgentNotes) { + changes.push( + `Agent notes: ${initial.showAgentNotes ? "shown" : "hidden"} → ${currentViewPreferences.showAgentNotes ? "shown" : "hidden"}`, + ); + } + if (currentViewPreferences.copyDecorations !== initial.copyDecorations) { + changes.push( + `Copy decorations: ${initial.copyDecorations ? "on" : "off"} → ${currentViewPreferences.copyDecorations ? "on" : "off"}`, + ); + } + return changes; + }, [currentViewPreferences]); + const hasUnsavedViewPreferences = changedViewPreferenceLines.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 review = useReviewController({ files: bootstrap.changeset.files }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; @@ -630,11 +705,43 @@ export function App({ }; }, [bootstrap.input, refreshCurrentInput, watchEnabled]); - /** Leave the app through the shared shutdown path. */ - const requestQuit = useCallback(() => { + /** 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]); + /** Leave the app through the shared shutdown path, prompting before discarding view changes. */ + const requestQuit = useCallback(() => { + if (!pagerMode && hasUnsavedViewPreferences) { + setShowHelp(false); + setSaveConfigPromptOpen(true); + return; + } + + onQuit(); + }, [hasUnsavedViewPreferences, onQuit, pagerMode]); + + const closeSaveConfigPrompt = useCallback(() => { + setSaveConfigPromptOpen(false); + }, []); + /** Close the modal keyboard help overlay. */ const closeHelp = useCallback(() => { setShowHelp(false); @@ -795,6 +902,10 @@ export function App({ openThemeSelector, pagerMode, requestQuit, + saveConfigPromptOpen, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + closeSaveConfigPrompt, scrollCodeHorizontally, saveDraftNote, scrollDiff, @@ -1053,6 +1164,52 @@ export function App({ ) : null} + {!pagerMode && saveConfigPromptOpen ? ( + + + + Save your local view changes to {viewPreferencesConfigLabel}? + + + + {changedViewPreferenceLines.map((line) => ( + + {line} + + ))} + + + { + event.stopPropagation(); + saveViewPreferencesAndQuit(); + }} + > + [Enter/s] Save and quit + + + { + event.stopPropagation(); + discardViewPreferencesAndQuit(); + }} + > + [d] Discard + + + [Esc] Cancel + + + ) : null} + {!pagerMode && themeSelectorState.open ? ( { } }); + test("quit prompts to save changed view preferences", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + const quit = mock(() => undefined); + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + let frame = await waitForFrame(setup, (nextFrame) => + nextFrame.includes("Save view preferences?"), + ); + expect(frame).toContain("Save your local view changes"); + expect(frame).toContain("Theme: github-dark-default → github-dark-dimmed"); + expect(frame).not.toContain("Line numbers:"); + expect(frame).not.toContain("Line wrapping:"); + expect(quit).toHaveBeenCalledTimes(0); + + await act(async () => { + await setup.mockInput.typeText("d"); + await setup.renderOnce(); + }); + expect(quit).toHaveBeenCalledTimes(1); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("quit prompt saves changed view preferences", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + const quit = mock(() => undefined); + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?")); + await act(async () => { + await setup.mockInput.pressEnter(); + await Bun.sleep(140); + }); + await flush(setup); + + expect(quit).toHaveBeenCalledTimes(1); + expect(readFileSync(join(configHome, "hunk", "config.toml"), "utf8")).toContain( + 'theme = "github-dark-dimmed"', + ); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("quit shortcuts route through the provided onQuit handler in regular and pager modes", async () => { const regularQuit = mock(() => undefined); const regularSetup = await testRender( diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index c415a0ba..8abb26c6 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -55,6 +55,7 @@ export function AppHost({ cwd, customTheme: configured.customTheme, }); + nextBootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); let sessionId = "local-session"; diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 6c6465d5..c2a852e1 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -62,6 +62,10 @@ export interface UseAppKeyboardShortcutsOptions { openThemeSelector: () => void; pagerMode: boolean; requestQuit: () => void; + saveConfigPromptOpen: boolean; + saveViewPreferencesAndQuit: () => void; + discardViewPreferencesAndQuit: () => void; + closeSaveConfigPrompt: () => void; scrollCodeHorizontally: (delta: number) => void; scrollDiff: (delta: number, unit: ScrollUnit) => void; saveDraftNote: () => void; @@ -103,6 +107,10 @@ export function useAppKeyboardShortcuts({ openThemeSelector, pagerMode, requestQuit, + saveConfigPromptOpen, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + closeSaveConfigPrompt, scrollCodeHorizontally, saveDraftNote, scrollDiff, @@ -126,12 +134,14 @@ export function useAppKeyboardShortcuts({ const focusAreaRef = useRef(focusArea); const pagerModeRef = useRef(pagerMode); const showHelpRef = useRef(showHelp); + const saveConfigPromptOpenRef = useRef(saveConfigPromptOpen); const themeSelectorOpenRef = useRef(themeSelectorOpen); activeMenuIdRef.current = activeMenuId; focusAreaRef.current = focusArea; pagerModeRef.current = pagerMode; showHelpRef.current = showHelp; + saveConfigPromptOpenRef.current = saveConfigPromptOpen; themeSelectorOpenRef.current = themeSelectorOpen; const resolveJumpShortcut = (key: KeyEvent): JumpShortcut | null => { @@ -260,6 +270,30 @@ export function useAppKeyboardShortcuts({ return true; }; + const handleSaveConfigPromptShortcut = (key: KeyEvent) => { + if (!saveConfigPromptOpenRef.current) { + return false; + } + + consumeKey(key); + if (key.name === "return" || key.name === "enter" || key.name === "s" || key.sequence === "s") { + saveViewPreferencesAndQuit(); + return true; + } + + if (key.name === "d" || key.sequence === "d" || key.name === "n" || key.sequence === "n") { + discardViewPreferencesAndQuit(); + return true; + } + + if (isEscapeKey(key)) { + closeSaveConfigPrompt(); + return true; + } + + return true; + }; + const handleThemeSelectorShortcut = (key: KeyEvent) => { if (!themeSelectorOpenRef.current) { return false; @@ -560,6 +594,10 @@ export function useAppKeyboardShortcuts({ return; } + if (handleSaveConfigPromptShortcut(key)) { + return; + } + if (handleHelpShortcut(key)) { return; }