Skip to content
Merged
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
107 changes: 1 addition & 106 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface HunkConfigResolution {
input: CliInput;
globalConfigPath?: string;
repoConfigPath?: string;
persistencePath?: string;

}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -178,113 +178,8 @@ export function resolveConfiguredCliInput(
},
globalConfigPath: userConfigPath,
repoConfigPath,
persistencePath: repoConfigPath ?? userConfigPath,
};
}

/** Return whether an array contains only TOML table objects. */
function isRecordArray(value: unknown): value is Array<Record<string, unknown>> {
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<string, unknown>, sectionName?: string, arrayTable = false): string[] {
const lines: string[] = [];
const scalarEntries: Array<[string, string]> = [];
const tableEntries: Array<[string, Record<string, unknown>]> = [];
const arrayTableEntries: Array<[string, Array<Record<string, unknown>>]> = [];

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;
5 changes: 1 addition & 4 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { parseCli } from "./core/cli";
import { persistViewPreferences, resolveConfiguredCliInput } from "./core/config";
import { resolveConfiguredCliInput } from "./core/config";
import { loadAppBootstrap } from "./core/loaders";
import { looksLikePatchInput, pagePlainText } from "./core/pager";
import { shutdownSession } from "./core/shutdown";
Expand Down Expand Up @@ -70,8 +70,5 @@ root.render(
<App
bootstrap={bootstrap}
onQuit={shutdown}
onPreferencesChange={
configured.persistencePath ? (preferences) => persistViewPreferences(configured.persistencePath!, preferences) : undefined
}
/>,
);
25 changes: 1 addition & 24 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
@@ -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, PersistedViewPreferences } from "../core/types";
import type { AppBootstrap, LayoutMode } 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";
Expand Down Expand Up @@ -29,11 +29,9 @@ function clamp(value: number, min: number, max: number) {
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;
Expand All @@ -45,7 +43,6 @@ export function App({
const terminal = useTerminalDimensions();
const filesScrollRef = useRef<ScrollBoxRenderable | null>(null);
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null);
const didPersistPreferences = useRef(false);
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
const [themeId, setThemeId] = useState(() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id);
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
Expand All @@ -68,26 +65,6 @@ export function App({
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;
Expand Down
31 changes: 0 additions & 31 deletions test/app-interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -450,35 +450,4 @@ describe("App interactions", () => {
}
});

test("view preference changes are emitted for persistence after interaction", async () => {
const onPreferencesChange = mock(() => undefined);
const setup = await testRender(
<App bootstrap={createSingleFileBootstrap()} onPreferencesChange={onPreferencesChange} />,
{ 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();
});
}
});
});
81 changes: 2 additions & 79 deletions test/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, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, 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 { resolveConfiguredCliInput } from "../src/core/config";
import { loadAppBootstrap } from "../src/core/loaders";

const tempDirs: string[] = [];
Expand Down Expand Up @@ -81,7 +81,6 @@ describe("config resolution", () => {
});

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",
Expand All @@ -103,82 +102,6 @@ describe("config resolution", () => {
});

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<string, unknown>;
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<string, unknown>).mode).toBe("stack");
expect((parsed.git as Record<string, unknown>).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<string, unknown>;
expect(parsed.bookmarks).toEqual([
{ path: "src/a.ts", hunk: 0 },
{ path: "src/b.ts", hunk: 2 },
]);
});

test("command-specific config sections also apply to show mode", () => {
Expand Down
Loading