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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/save-view-preferences-on-quit.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 54 additions & 2 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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-");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
});

Expand Down
92 changes: 91 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -84,12 +97,54 @@ interface HunkConfigResolution {
customTheme?: CustomThemeConfig;
globalConfigPath?: string;
repoConfigPath?: string;
viewPreferencesConfigPath?: string;
}

function isRecord(value: unknown): value is Record<string, unknown> {
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;
Expand Down Expand Up @@ -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<ConfigResolutionOptions, "env"> & { 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,
Expand Down Expand Up @@ -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,
};
}
1 change: 1 addition & 0 deletions src/core/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export async function prepareStartupPlan(
}

bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode;
bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath;

controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;

Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,4 +366,5 @@ export interface AppBootstrap {
initialShowHunkHeaders?: boolean;
initialShowAgentNotes?: boolean;
initialCopyDecorations?: boolean;
viewPreferencesConfigPath?: string;
}
Loading