diff --git a/.gitignore b/.gitignore index bf18d3a5..bbeec4a4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # runtime output tmp .hunk/latest.json +.hunk/config.toml .pi/ diff --git a/README.md b/README.md index b7a22c4f..fe81c4a9 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,62 @@ If you want a different install location, set `HUNK_INSTALL_DIR` before running - `0` auto layout - `t` cycle themes - `a` toggle the agent panel +- `l` toggle line numbers +- `w` toggle line wrapping +- `m` toggle hunk metadata - `[` / `]` move between hunks +- `space` / `b` page forward and backward - `/` focus the file filter - `tab` cycle focus regions - `q` or `Esc` quit +## Configuration + +Hunk reads layered TOML config with this precedence: + +1. built-in defaults +2. global config: `$XDG_CONFIG_HOME/hunk/config.toml` or `~/.config/hunk/config.toml` +3. repo-local config: `.hunk/config.toml` +4. command-specific sections like `[git]`, `[diff]`, `[patch]`, `[difftool]` +5. `[pager]` when Hunk is running in pager mode +6. explicit CLI flags + +When you change persistent view settings inside Hunk, it writes them back to `.hunk/config.toml` in the current repo when possible, or to the global config file outside a repo. + +Example: + +```toml +theme = "midnight" +mode = "auto" +line_numbers = true +wrap_lines = false +hunk_headers = true +agent_notes = false + +[pager] +mode = "stack" +line_numbers = false + +[diff] +mode = "split" +``` + +CLI overrides are available when you want one-off or pager-specific behavior: + +```bash +hunk patch - --mode stack --no-line-numbers +hunk diff before.ts after.ts --theme paper --wrap +``` + +Supported persistent CLI overrides: + +- `--mode ` +- `--theme ` +- `--line-numbers` / `--no-line-numbers` +- `--wrap` / `--no-wrap` +- `--hunk-headers` / `--no-hunk-headers` +- `--agent-notes` / `--no-agent-notes` + ## Agent sidecar format Use `--agent-context ` to load a JSON sidecar and show agent rationale next to the diff. diff --git a/src/core/cli.ts b/src/core/cli.ts index 0f6d764a..a85283c6 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -1,18 +1,52 @@ import { Command } from "commander"; import type { CliInput, CommonOptions, LayoutMode } from "./types"; +/** Validate one requested layout mode from CLI input. */ +function parseLayoutMode(value: string): LayoutMode { + if (value === "auto" || value === "split" || value === "stack") { + return value; + } + + throw new Error(`Invalid layout mode: ${value}`); +} + +/** Read one paired positive/negative boolean flag directly from raw argv. */ +function resolveBooleanFlag(argv: string[], enabledFlag: string, disabledFlag: string) { + let resolved: boolean | undefined; + + for (const arg of argv) { + if (arg === enabledFlag) { + resolved = true; + continue; + } + + if (arg === disabledFlag) { + resolved = false; + } + } + + return resolved; +} + /** Normalize the flags shared by every input mode. */ -function buildCommonOptions(options: { - mode?: LayoutMode; - theme?: string; - agentContext?: string; - pager?: boolean; -}): CommonOptions { +function buildCommonOptions( + options: { + mode?: LayoutMode; + theme?: string; + agentContext?: string; + pager?: boolean; + }, + argv: string[], +): CommonOptions { return { - mode: options.mode ?? "auto", + mode: options.mode, theme: options.theme, agentContext: options.agentContext, - pager: options.pager ?? false, + pager: options.pager ? true : undefined, + lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"), + wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), + hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), + agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"), }; } @@ -22,7 +56,7 @@ export async function parseCli(argv: string[]): Promise { return { kind: "git", staged: false, - options: buildCommonOptions({}), + options: buildCommonOptions({}, argv), }; } @@ -37,25 +71,28 @@ export async function parseCli(argv: string[]): Promise { /** Attach the shared mode/theme/agent-context flags to a subcommand. */ const applyCommonOptions = (command: Command) => command - .option("--mode ", "layout mode: auto, split, stack", "auto") + .option("--mode ", "layout mode: auto, split, stack", parseLayoutMode) .option("--theme ", "named theme override") .option("--agent-context ", "JSON sidecar with agent rationale") - .option("--pager", "use pager-style chrome and controls", false); + .option("--pager", "use pager-style chrome and controls") + .option("--line-numbers", "show line numbers") + .option("--no-line-numbers", "hide line numbers") + .option("--wrap", "wrap long diff lines") + .option("--no-wrap", "truncate long diff lines to one row") + .option("--hunk-headers", "show hunk metadata rows") + .option("--no-hunk-headers", "hide hunk metadata rows") + .option("--agent-notes", "show agent notes by default") + .option("--no-agent-notes", "hide agent notes by default"); applyCommonOptions(program.command("git")) .argument("[range]", "revision or range to diff") - .option("--staged", "show staged changes instead of the working tree", false) + .option("--staged", "show staged changes instead of the working tree") .action((range: string | undefined, options: Record) => { selected = { kind: "git", range, staged: Boolean(options.staged), - options: buildCommonOptions({ - mode: options.mode as LayoutMode | undefined, - theme: options.theme as string | undefined, - agentContext: options.agentContext as string | undefined, - pager: options.pager as boolean | undefined, - }), + options: buildCommonOptions(options, argv), }; }); @@ -67,12 +104,7 @@ export async function parseCli(argv: string[]): Promise { kind: "diff", left, right, - options: buildCommonOptions({ - mode: options.mode as LayoutMode | undefined, - theme: options.theme as string | undefined, - agentContext: options.agentContext as string | undefined, - pager: options.pager as boolean | undefined, - }), + options: buildCommonOptions(options, argv), }; }); @@ -82,12 +114,7 @@ export async function parseCli(argv: string[]): Promise { selected = { kind: "patch", file, - options: buildCommonOptions({ - mode: options.mode as LayoutMode | undefined, - theme: options.theme as string | undefined, - agentContext: options.agentContext as string | undefined, - pager: options.pager as boolean | undefined, - }), + options: buildCommonOptions(options, argv), }; }); @@ -101,12 +128,7 @@ export async function parseCli(argv: string[]): Promise { left, right, path, - options: buildCommonOptions({ - mode: options.mode as LayoutMode | undefined, - theme: options.theme as string | undefined, - agentContext: options.agentContext as string | undefined, - pager: options.pager as boolean | undefined, - }), + options: buildCommonOptions(options, argv), }; }); diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 00000000..76630061 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,290 @@ +import fs from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } from "./types"; + +const CONFIG_SECTION_NAMES = ["pager", "git", "diff", "patch", "difftool"] as const; +const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { + mode: "auto", + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showAgentNotes: false, +}; + +interface ConfigResolutionOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; +} + +interface HunkConfigResolution { + input: CliInput; + globalConfigPath?: string; + repoConfigPath?: string; + persistencePath?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Accept only the layout names Hunk already supports. */ +function normalizeLayoutMode(value: unknown): LayoutMode | undefined { + return value === "auto" || value === "split" || value === "stack" ? value : undefined; +} + +/** Accept only plain booleans from config files. */ +function normalizeBoolean(value: unknown) { + return typeof value === "boolean" ? value : undefined; +} + +/** Accept only plain strings from config files. */ +function normalizeString(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Read the view preferences stored at one TOML object level. */ +function readConfigPreferences(source: Record): CommonOptions { + return { + mode: normalizeLayoutMode(source.mode), + theme: normalizeString(source.theme), + lineNumbers: normalizeBoolean(source.line_numbers), + wrapLines: normalizeBoolean(source.wrap_lines), + hunkHeaders: normalizeBoolean(source.hunk_headers), + agentNotes: normalizeBoolean(source.agent_notes), + }; +} + +/** Merge partial preference layers with right-hand overrides taking precedence. */ +function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOptions { + return { + ...base, + mode: overrides.mode ?? base.mode, + theme: overrides.theme ?? base.theme, + agentContext: overrides.agentContext ?? base.agentContext, + pager: overrides.pager ?? base.pager, + lineNumbers: overrides.lineNumbers ?? base.lineNumbers, + wrapLines: overrides.wrapLines ?? base.wrapLines, + hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, + agentNotes: overrides.agentNotes ?? base.agentNotes, + }; +} + +/** Apply one parsed config object, including command/pager sections, to the current invocation. */ +function resolveConfigLayer(source: Record, input: CliInput): CommonOptions { + let resolved = readConfigPreferences(source); + + const commandSection = source[input.kind]; + if (isRecord(commandSection)) { + resolved = mergeOptions(resolved, readConfigPreferences(commandSection)); + } + + const pagerSection = source.pager; + if (input.options.pager && isRecord(pagerSection)) { + resolved = mergeOptions(resolved, readConfigPreferences(pagerSection)); + } + + return resolved; +} + +/** Return the first parent that looks like a Git repository root. */ +function findRepoRoot(cwd = process.cwd()) { + let current = resolve(cwd); + + for (;;) { + if (fs.existsSync(join(current, ".git"))) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + return undefined; + } + + current = parent; + } +} + +/** Resolve the global XDG-style config path, if the environment provides one. */ +function globalConfigPath(env: NodeJS.ProcessEnv = process.env) { + if (env.XDG_CONFIG_HOME) { + return join(env.XDG_CONFIG_HOME, "hunk", "config.toml"); + } + + if (env.HOME) { + return join(env.HOME, ".config", "hunk", "config.toml"); + } + + return undefined; +} + +/** Parse one TOML config file into a plain object. */ +function readTomlRecord(path: string) { + if (!fs.existsSync(path)) { + return {}; + } + + const parsed = Bun.TOML.parse(fs.readFileSync(path, "utf8")); + if (!isRecord(parsed)) { + throw new Error(`Expected ${path} to contain a TOML object.`); + } + + return parsed; +} + +/** Resolve CLI input against global and repo-local config files. */ +export function resolveConfiguredCliInput( + input: CliInput, + { cwd = process.cwd(), env = process.env }: ConfigResolutionOptions = {}, +): HunkConfigResolution { + const repoRoot = findRepoRoot(cwd); + const repoConfigPath = repoRoot ? join(repoRoot, ".hunk", "config.toml") : undefined; + const userConfigPath = globalConfigPath(env); + + let resolvedOptions: CommonOptions = { + mode: DEFAULT_VIEW_PREFERENCES.mode, + theme: undefined, + agentContext: input.options.agentContext, + pager: input.options.pager ?? false, + lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers, + wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines, + hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders, + agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes, + }; + + if (userConfigPath) { + resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(readTomlRecord(userConfigPath), input)); + } + + if (repoConfigPath) { + resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(readTomlRecord(repoConfigPath), input)); + } + + resolvedOptions = mergeOptions(resolvedOptions, input.options); + resolvedOptions = { + ...resolvedOptions, + agentContext: input.options.agentContext, + pager: input.options.pager ?? false, + mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode, + lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers, + wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, + hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, + agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + }; + + return { + input: { + ...input, + options: resolvedOptions, + }, + globalConfigPath: userConfigPath, + repoConfigPath, + persistencePath: repoConfigPath ?? userConfigPath, + }; +} + +/** Return whether an array contains only TOML table objects. */ +function isRecordArray(value: unknown): value is Array> { + return Array.isArray(value) && value.every(isRecord); +} + +/** Serialize one inline TOML value, including scalar arrays. */ +function serializeTomlValue(value: unknown): string | undefined { + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (typeof value === "boolean" || typeof value === "number") { + return String(value); + } + + if (Array.isArray(value) && !isRecordArray(value)) { + const serializedItems = value.map((item) => serializeTomlValue(item)); + if (serializedItems.some((item) => item === undefined)) { + return undefined; + } + + return `[${serializedItems.join(", ")}]`; + } + + return undefined; +} + +/** Render one TOML object recursively while keeping scalar keys above child tables. */ +function serializeTomlObject(source: Record, sectionName?: string, arrayTable = false): string[] { + const lines: string[] = []; + const scalarEntries: Array<[string, string]> = []; + const tableEntries: Array<[string, Record]> = []; + const arrayTableEntries: Array<[string, Array>]> = []; + + for (const [key, value] of Object.entries(source)) { + if (value === undefined) { + continue; + } + + if (isRecord(value)) { + tableEntries.push([key, value]); + continue; + } + + if (isRecordArray(value)) { + arrayTableEntries.push([key, value]); + continue; + } + + const serialized = serializeTomlValue(value); + if (serialized !== undefined) { + scalarEntries.push([key, serialized]); + } + } + + if (sectionName) { + lines.push(`${arrayTable ? "[[" : "["}${sectionName}${arrayTable ? "]]" : "]"}`); + } + + for (const [key, value] of scalarEntries) { + lines.push(`${key} = ${value}`); + } + + for (const [key, value] of tableEntries) { + if (lines.length > 0) { + lines.push(""); + } + + lines.push(...serializeTomlObject(value, sectionName ? `${sectionName}.${key}` : key)); + } + + for (const [key, values] of arrayTableEntries) { + for (const value of values) { + if (lines.length > 0) { + lines.push(""); + } + + lines.push(...serializeTomlObject(value, sectionName ? `${sectionName}.${key}` : key, true)); + } + } + + return lines; +} + +/** Persist the current view defaults while preserving any existing profile sections. */ +export function persistViewPreferences(path: string, preferences: PersistedViewPreferences) { + const existing = readTomlRecord(path); + + existing.mode = preferences.mode; + existing.line_numbers = preferences.showLineNumbers; + existing.wrap_lines = preferences.wrapLines; + existing.hunk_headers = preferences.showHunkHeaders; + existing.agent_notes = preferences.showAgentNotes; + + if (preferences.theme) { + existing.theme = preferences.theme; + } else { + delete existing.theme; + } + + fs.mkdirSync(dirname(path), { recursive: true }); + fs.writeFileSync(path, `${serializeTomlObject(existing).join("\n").trim()}\n`); +} + +export const CONFIG_DEFAULTS = DEFAULT_VIEW_PREFERENCES; +export const CONFIG_SECTION_KEYS = CONFIG_SECTION_NAMES; diff --git a/src/core/loaders.ts b/src/core/loaders.ts index 97c91867..e657642c 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -311,7 +311,11 @@ export async function loadAppBootstrap(input: CliInput): Promise { return { input, changeset, - initialMode: input.options.mode, + initialMode: input.options.mode ?? "auto", initialTheme: input.options.theme, + initialShowLineNumbers: input.options.lineNumbers ?? true, + initialWrapLines: input.options.wrapLines ?? false, + initialShowHunkHeaders: input.options.hunkHeaders ?? true, + initialShowAgentNotes: input.options.agentNotes ?? false, }; } diff --git a/src/core/types.ts b/src/core/types.ts index 6f39871b..009da4ba 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -47,10 +47,23 @@ export interface Changeset { } export interface CommonOptions { - mode: LayoutMode; + mode?: LayoutMode; theme?: string; agentContext?: string; pager?: boolean; + lineNumbers?: boolean; + wrapLines?: boolean; + hunkHeaders?: boolean; + agentNotes?: boolean; +} + +export interface PersistedViewPreferences { + mode: LayoutMode; + theme?: string; + showLineNumbers: boolean; + wrapLines: boolean; + showHunkHeaders: boolean; + showAgentNotes: boolean; } export interface GitCommandInput { @@ -92,4 +105,8 @@ export interface AppBootstrap { changeset: Changeset; initialMode: LayoutMode; initialTheme?: string; + initialShowLineNumbers?: boolean; + initialWrapLines?: boolean; + initialShowHunkHeaders?: boolean; + initialShowAgentNotes?: boolean; } diff --git a/src/main.tsx b/src/main.tsx index 81f21f75..34328c63 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,12 +3,15 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { parseCli } from "./core/cli"; +import { persistViewPreferences, resolveConfiguredCliInput } from "./core/config"; import { loadAppBootstrap } from "./core/loaders"; import { shutdownSession } from "./core/shutdown"; import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput } from "./core/terminal"; import { App } from "./ui/App"; -const cliInput = resolveRuntimeCliInput(await parseCli(process.argv)); +const runtimeCliInput = resolveRuntimeCliInput(await parseCli(process.argv)); +const configured = resolveConfiguredCliInput(runtimeCliInput); +const cliInput = configured.input; const bootstrap = await loadAppBootstrap(cliInput); const controllingTerminal = usesPipedPatchInput(cliInput) ? openControllingTerminal() : null; @@ -36,4 +39,12 @@ function shutdown() { } // The app owns the full alternate screen session from this point on. -root.render(); +root.render( + persistViewPreferences(configured.persistencePath!, preferences) : undefined + } + />, +); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 479ed7bf..fee7179d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,7 +1,7 @@ import { MouseButton, type KeyEvent, type MouseEvent as TuiMouseEvent, type ScrollBoxRenderable } from "@opentui/core"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, startTransition, useDeferredValue, useEffect, useRef, useState } from "react"; -import type { AppBootstrap, LayoutMode } from "../core/types"; +import type { AppBootstrap, LayoutMode, PersistedViewPreferences } from "../core/types"; import { MenuBar } from "./components/chrome/MenuBar"; import { MENU_ORDER, buildMenuSpecs, menuWidth, nextMenuItemIndex, type MenuEntry, type MenuId } from "./components/chrome/menu"; import { StatusBar } from "./components/chrome/StatusBar"; @@ -26,7 +26,15 @@ function clamp(value: number, min: number, max: number) { } /** Orchestrate global app state, layout, navigation, and pane coordination. */ -export function App({ bootstrap, onQuit = () => process.exit(0) }: { bootstrap: AppBootstrap; onQuit?: () => void }) { +export function App({ + bootstrap, + onQuit = () => process.exit(0), + onPreferencesChange, +}: { + bootstrap: AppBootstrap; + onQuit?: () => void; + onPreferencesChange?: (preferences: PersistedViewPreferences) => void; +}) { const FILES_MIN_WIDTH = 22; const DIFF_MIN_WIDTH = 48; const BODY_PADDING = 2; @@ -37,12 +45,13 @@ export function App({ bootstrap, onQuit = () => process.exit(0) }: { bootstrap: const terminal = useTerminalDimensions(); const filesScrollRef = useRef(null); const diffScrollRef = useRef(null); + const didPersistPreferences = useRef(false); const [layoutMode, setLayoutMode] = useState(bootstrap.initialMode); const [themeId, setThemeId] = useState(() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id); - const [showAgentNotes, setShowAgentNotes] = useState(false); - const [showLineNumbers, setShowLineNumbers] = useState(true); - const [wrapLines, setWrapLines] = useState(false); - const [showHunkHeaders, setShowHunkHeaders] = useState(true); + const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false); + const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true); + const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false); + const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true); const [showHelp, setShowHelp] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeMenuId, setActiveMenuId] = useState(null); @@ -59,6 +68,26 @@ export function App({ bootstrap, onQuit = () => process.exit(0) }: { bootstrap: const pagerMode = Boolean(bootstrap.input.options.pager); const activeTheme = resolveTheme(themeId, renderer.themeMode); + useEffect(() => { + if (!onPreferencesChange) { + return; + } + + if (!didPersistPreferences.current) { + didPersistPreferences.current = true; + return; + } + + onPreferencesChange({ + mode: layoutMode, + theme: themeId, + showLineNumbers, + wrapLines, + showHunkHeaders, + showAgentNotes, + }); + }, [layoutMode, onPreferencesChange, showAgentNotes, showHunkHeaders, showLineNumbers, themeId, wrapLines]); + const filteredFiles = bootstrap.changeset.files.filter((file) => { if (!deferredFilter.trim()) { return true; diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index 4f4a3f36..88a7a328 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -230,6 +230,59 @@ describe("App interactions", () => { } }); + test("bootstrap preferences initialize the visible view state", async () => { + const setup = await testRender( + , + { width: 140, height: 20 }, + ); + + try { + await flush(setup); + + const frame = setup.captureCharFrame(); + expect(frame).toContain("Annotation for prefs.ts"); + expect(frame).toContain("long wrapped line"); + expect(frame).toContain("coverage"); + expect(frame).not.toContain("@@ -1,1 +1,2 @@"); + expect(frame).not.toContain("1 - export const message"); + expect(frame).toContain("- export const message"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("menu navigation can switch layouts and activate view actions", async () => { const setup = await testRender(, { width: 220, height: 24 }); @@ -396,4 +449,36 @@ describe("App interactions", () => { }); } }); + + test("view preference changes are emitted for persistence after interaction", async () => { + const onPreferencesChange = mock(() => undefined); + const setup = await testRender( + , + { width: 220, height: 24 }, + ); + + try { + await flush(setup); + expect(onPreferencesChange).not.toHaveBeenCalled(); + + await act(async () => { + await setup.mockInput.typeText("l"); + }); + await flush(setup); + + expect(onPreferencesChange).toHaveBeenCalledTimes(1); + expect(onPreferencesChange.mock.calls[0]?.[0]).toMatchObject({ + mode: "split", + theme: "midnight", + showLineNumbers: false, + wrapLines: false, + showHunkHeaders: true, + showAgentNotes: false, + }); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 252e2f43..3a2e893b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -6,7 +6,7 @@ describe("parseCli", () => { const parsed = await parseCli(["bun", "hunk"]); expect(parsed.kind).toBe("git"); - expect(parsed.options.mode).toBe("auto"); + expect(parsed.options.mode).toBeUndefined(); expect(parsed.options.theme).toBeUndefined(); }); @@ -24,6 +24,10 @@ describe("parseCli", () => { "--agent-context", "notes.json", "--pager", + "--no-line-numbers", + "--wrap", + "--no-hunk-headers", + "--agent-notes", ]); expect(parsed).toMatchObject({ @@ -35,6 +39,10 @@ describe("parseCli", () => { theme: "paper", agentContext: "notes.json", pager: true, + lineNumbers: false, + wrapLines: true, + hunkHeaders: false, + agentNotes: true, }, }); }); @@ -47,11 +55,11 @@ describe("parseCli", () => { range: "HEAD~1..HEAD", staged: true, options: { - mode: "auto", theme: "ember", - pager: false, }, }); + expect(parsed.options.mode).toBeUndefined(); + expect(parsed.options.pager).toBeUndefined(); }); test("parses patch mode from a file", async () => { @@ -61,10 +69,10 @@ describe("parseCli", () => { kind: "patch", file: "changes.patch", options: { - mode: "auto", pager: true, }, }); + expect(parsed.options.mode).toBeUndefined(); }); test("parses difftool mode with display path", async () => { @@ -77,8 +85,8 @@ describe("parseCli", () => { path: "src/example.ts", options: { mode: "stack", - pager: false, }, }); + expect(parsed.options.pager).toBeUndefined(); }); }); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 00000000..fc38c95f --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CliInput } from "../src/core/types"; +import { persistViewPreferences, resolveConfiguredCliInput } from "../src/core/config"; +import { loadAppBootstrap } from "../src/core/loaders"; + +const tempDirs: string[] = []; + +function cleanupTempDirs() { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +} + +function createTempDir(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function createRepo(dir: string) { + mkdirSync(join(dir, ".git"), { recursive: true }); +} + +function createPatchPagerInput(overrides: Partial = {}): CliInput { + return { + kind: "patch", + file: "-", + options: { + pager: true, + ...overrides, + }, + }; +} + +afterEach(() => { + cleanupTempDirs(); +}); + +describe("config resolution", () => { + test("merges global, repo, pager, command, and CLI overrides in the right order", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync( + join(home, ".config", "hunk", "config.toml"), + [ + 'theme = "graphite"', + "line_numbers = false", + "", + "[patch]", + 'mode = "split"', + "", + "[pager]", + 'mode = "stack"', + ].join("\n"), + ); + + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync( + join(repo, ".hunk", "config.toml"), + [ + 'theme = "paper"', + "wrap_lines = true", + "", + "[pager]", + "hunk_headers = false", + ].join("\n"), + ); + + const resolved = resolveConfiguredCliInput(createPatchPagerInput({ agentNotes: true }), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.repoConfigPath).toBe(join(repo, ".hunk", "config.toml")); + expect(resolved.persistencePath).toBe(join(repo, ".hunk", "config.toml")); + expect(resolved.input.options).toMatchObject({ + pager: true, + mode: "stack", + theme: "paper", + lineNumbers: false, + wrapLines: true, + hunkHeaders: false, + agentNotes: true, + }); + }); + + test("falls back to the global config path outside a repo", () => { + const home = createTempDir("hunk-config-home-"); + const cwd = createTempDir("hunk-config-cwd-"); + + const resolved = resolveConfiguredCliInput(createPatchPagerInput(), { + cwd, + env: { HOME: home }, + }); + + expect(resolved.repoConfigPath).toBeUndefined(); + expect(resolved.persistencePath).toBe(join(home, ".config", "hunk", "config.toml")); + }); + + test("persists top-level preferences without discarding profile sections", () => { + const repo = createTempDir("hunk-config-repo-"); + const configPath = join(repo, ".hunk", "config.toml"); + + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync( + configPath, + [ + 'recent_themes = ["paper", "midnight"]', + '', + '[pager]', + 'mode = "stack"', + '', + '[git]', + 'wrap_lines = true', + ].join('\n'), + ); + + persistViewPreferences(configPath, { + mode: "split", + theme: "midnight", + showLineNumbers: false, + wrapLines: true, + showHunkHeaders: false, + showAgentNotes: true, + }); + + const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record; + expect(parsed.mode).toBe("split"); + expect(parsed.theme).toBe("midnight"); + expect(parsed.line_numbers).toBe(false); + expect(parsed.wrap_lines).toBe(true); + expect(parsed.hunk_headers).toBe(false); + expect(parsed.agent_notes).toBe(true); + expect(parsed.recent_themes).toEqual(["paper", "midnight"]); + expect((parsed.pager as Record).mode).toBe("stack"); + expect((parsed.git as Record).wrap_lines).toBe(true); + }); + + test("preserves TOML array-of-table sections when persisting view preferences", () => { + const repo = createTempDir("hunk-config-repo-"); + const configPath = join(repo, ".hunk", "config.toml"); + + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync( + configPath, + [ + 'theme = "paper"', + '', + '[[bookmarks]]', + 'path = "src/a.ts"', + 'hunk = 0', + '', + '[[bookmarks]]', + 'path = "src/b.ts"', + 'hunk = 2', + ].join('\n'), + ); + + persistViewPreferences(configPath, { + mode: "split", + theme: "paper", + showLineNumbers: true, + wrapLines: false, + showHunkHeaders: true, + showAgentNotes: false, + }); + + const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record; + expect(parsed.bookmarks).toEqual([ + { path: "src/a.ts", hunk: 0 }, + { path: "src/b.ts", hunk: 2 }, + ]); + }); + + test("loadAppBootstrap exposes resolved initial preferences to the UI", async () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync( + join(home, ".config", "hunk", "config.toml"), + [ + 'theme = "paper"', + 'line_numbers = false', + 'wrap_lines = true', + 'hunk_headers = false', + 'agent_notes = true', + ].join('\n'), + ); + + const before = join(repo, "before.ts"); + const after = join(repo, "after.ts"); + writeFileSync(before, "export const alpha = 1;\n"); + writeFileSync(after, "export const alpha = 2;\nexport const beta = true;\n"); + + const resolved = resolveConfiguredCliInput( + { + kind: "diff", + left: before, + right: after, + options: {}, + }, + { cwd: repo, env: { HOME: home } }, + ); + const bootstrap = await loadAppBootstrap(resolved.input); + + expect(bootstrap.initialMode).toBe("auto"); + expect(bootstrap.initialTheme).toBe("paper"); + expect(bootstrap.initialShowLineNumbers).toBe(false); + expect(bootstrap.initialWrapLines).toBe(true); + expect(bootstrap.initialShowHunkHeaders).toBe(false); + expect(bootstrap.initialShowAgentNotes).toBe(true); + }); +});