Skip to content

Commit cd77a48

Browse files
benvinegarclaude
andauthored
Remove auto-save of view preferences to config file (#13)
Settings changed during a session (layout mode, theme, line numbers, etc.) no longer persist to the TOML config file. Config files are still read on startup but are now treated as read-only at runtime. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3017d0 commit cd77a48

5 files changed

Lines changed: 5 additions & 244 deletions

File tree

src/core/config.ts

Lines changed: 1 addition & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface HunkConfigResolution {
2020
input: CliInput;
2121
globalConfigPath?: string;
2222
repoConfigPath?: string;
23-
persistencePath?: string;
23+
2424
}
2525

2626
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -178,113 +178,8 @@ export function resolveConfiguredCliInput(
178178
},
179179
globalConfigPath: userConfigPath,
180180
repoConfigPath,
181-
persistencePath: repoConfigPath ?? userConfigPath,
182181
};
183182
}
184183

185-
/** Return whether an array contains only TOML table objects. */
186-
function isRecordArray(value: unknown): value is Array<Record<string, unknown>> {
187-
return Array.isArray(value) && value.every(isRecord);
188-
}
189-
190-
/** Serialize one inline TOML value, including scalar arrays. */
191-
function serializeTomlValue(value: unknown): string | undefined {
192-
if (typeof value === "string") {
193-
return JSON.stringify(value);
194-
}
195-
196-
if (typeof value === "boolean" || typeof value === "number") {
197-
return String(value);
198-
}
199-
200-
if (Array.isArray(value) && !isRecordArray(value)) {
201-
const serializedItems = value.map((item) => serializeTomlValue(item));
202-
if (serializedItems.some((item) => item === undefined)) {
203-
return undefined;
204-
}
205-
206-
return `[${serializedItems.join(", ")}]`;
207-
}
208-
209-
return undefined;
210-
}
211-
212-
/** Render one TOML object recursively while keeping scalar keys above child tables. */
213-
function serializeTomlObject(source: Record<string, unknown>, sectionName?: string, arrayTable = false): string[] {
214-
const lines: string[] = [];
215-
const scalarEntries: Array<[string, string]> = [];
216-
const tableEntries: Array<[string, Record<string, unknown>]> = [];
217-
const arrayTableEntries: Array<[string, Array<Record<string, unknown>>]> = [];
218-
219-
for (const [key, value] of Object.entries(source)) {
220-
if (value === undefined) {
221-
continue;
222-
}
223-
224-
if (isRecord(value)) {
225-
tableEntries.push([key, value]);
226-
continue;
227-
}
228-
229-
if (isRecordArray(value)) {
230-
arrayTableEntries.push([key, value]);
231-
continue;
232-
}
233-
234-
const serialized = serializeTomlValue(value);
235-
if (serialized !== undefined) {
236-
scalarEntries.push([key, serialized]);
237-
}
238-
}
239-
240-
if (sectionName) {
241-
lines.push(`${arrayTable ? "[[" : "["}${sectionName}${arrayTable ? "]]" : "]"}`);
242-
}
243-
244-
for (const [key, value] of scalarEntries) {
245-
lines.push(`${key} = ${value}`);
246-
}
247-
248-
for (const [key, value] of tableEntries) {
249-
if (lines.length > 0) {
250-
lines.push("");
251-
}
252-
253-
lines.push(...serializeTomlObject(value, sectionName ? `${sectionName}.${key}` : key));
254-
}
255-
256-
for (const [key, values] of arrayTableEntries) {
257-
for (const value of values) {
258-
if (lines.length > 0) {
259-
lines.push("");
260-
}
261-
262-
lines.push(...serializeTomlObject(value, sectionName ? `${sectionName}.${key}` : key, true));
263-
}
264-
}
265-
266-
return lines;
267-
}
268-
269-
/** Persist the current view defaults while preserving any existing profile sections. */
270-
export function persistViewPreferences(path: string, preferences: PersistedViewPreferences) {
271-
const existing = readTomlRecord(path);
272-
273-
existing.mode = preferences.mode;
274-
existing.line_numbers = preferences.showLineNumbers;
275-
existing.wrap_lines = preferences.wrapLines;
276-
existing.hunk_headers = preferences.showHunkHeaders;
277-
existing.agent_notes = preferences.showAgentNotes;
278-
279-
if (preferences.theme) {
280-
existing.theme = preferences.theme;
281-
} else {
282-
delete existing.theme;
283-
}
284-
285-
fs.mkdirSync(dirname(path), { recursive: true });
286-
fs.writeFileSync(path, `${serializeTomlObject(existing).join("\n").trim()}\n`);
287-
}
288-
289184
export const CONFIG_DEFAULTS = DEFAULT_VIEW_PREFERENCES;
290185
export const CONFIG_SECTION_KEYS = CONFIG_SECTION_NAMES;

src/main.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { createCliRenderer } from "@opentui/core";
44
import { createRoot } from "@opentui/react";
55
import { parseCli } from "./core/cli";
6-
import { persistViewPreferences, resolveConfiguredCliInput } from "./core/config";
6+
import { resolveConfiguredCliInput } from "./core/config";
77
import { loadAppBootstrap } from "./core/loaders";
88
import { looksLikePatchInput, pagePlainText } from "./core/pager";
99
import { shutdownSession } from "./core/shutdown";
@@ -70,8 +70,5 @@ root.render(
7070
<App
7171
bootstrap={bootstrap}
7272
onQuit={shutdown}
73-
onPreferencesChange={
74-
configured.persistencePath ? (preferences) => persistViewPreferences(configured.persistencePath!, preferences) : undefined
75-
}
7673
/>,
7774
);

src/ui/App.tsx

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { MouseButton, type KeyEvent, type MouseEvent as TuiMouseEvent, type ScrollBoxRenderable } from "@opentui/core";
22
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
33
import { Suspense, lazy, startTransition, useDeferredValue, useEffect, useRef, useState } from "react";
4-
import type { AppBootstrap, LayoutMode, PersistedViewPreferences } from "../core/types";
4+
import type { AppBootstrap, LayoutMode } from "../core/types";
55
import { MenuBar } from "./components/chrome/MenuBar";
66
import { MENU_ORDER, buildMenuSpecs, menuWidth, nextMenuItemIndex, type MenuEntry, type MenuId } from "./components/chrome/menu";
77
import { StatusBar } from "./components/chrome/StatusBar";
@@ -29,11 +29,9 @@ function clamp(value: number, min: number, max: number) {
2929
export function App({
3030
bootstrap,
3131
onQuit = () => process.exit(0),
32-
onPreferencesChange,
3332
}: {
3433
bootstrap: AppBootstrap;
3534
onQuit?: () => void;
36-
onPreferencesChange?: (preferences: PersistedViewPreferences) => void;
3735
}) {
3836
const FILES_MIN_WIDTH = 22;
3937
const DIFF_MIN_WIDTH = 48;
@@ -45,7 +43,6 @@ export function App({
4543
const terminal = useTerminalDimensions();
4644
const filesScrollRef = useRef<ScrollBoxRenderable | null>(null);
4745
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null);
48-
const didPersistPreferences = useRef(false);
4946
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
5047
const [themeId, setThemeId] = useState(() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id);
5148
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
@@ -68,26 +65,6 @@ export function App({
6865
const pagerMode = Boolean(bootstrap.input.options.pager);
6966
const activeTheme = resolveTheme(themeId, renderer.themeMode);
7067

71-
useEffect(() => {
72-
if (!onPreferencesChange) {
73-
return;
74-
}
75-
76-
if (!didPersistPreferences.current) {
77-
didPersistPreferences.current = true;
78-
return;
79-
}
80-
81-
onPreferencesChange({
82-
mode: layoutMode,
83-
theme: themeId,
84-
showLineNumbers,
85-
wrapLines,
86-
showHunkHeaders,
87-
showAgentNotes,
88-
});
89-
}, [layoutMode, onPreferencesChange, showAgentNotes, showHunkHeaders, showLineNumbers, themeId, wrapLines]);
90-
9168
const filteredFiles = bootstrap.changeset.files.filter((file) => {
9269
if (!deferredFilter.trim()) {
9370
return true;

test/app-interactions.test.tsx

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -450,35 +450,4 @@ describe("App interactions", () => {
450450
}
451451
});
452452

453-
test("view preference changes are emitted for persistence after interaction", async () => {
454-
const onPreferencesChange = mock(() => undefined);
455-
const setup = await testRender(
456-
<App bootstrap={createSingleFileBootstrap()} onPreferencesChange={onPreferencesChange} />,
457-
{ width: 220, height: 24 },
458-
);
459-
460-
try {
461-
await flush(setup);
462-
expect(onPreferencesChange).not.toHaveBeenCalled();
463-
464-
await act(async () => {
465-
await setup.mockInput.typeText("l");
466-
});
467-
await flush(setup);
468-
469-
expect(onPreferencesChange).toHaveBeenCalledTimes(1);
470-
expect(onPreferencesChange.mock.calls[0]?.[0]).toMatchObject({
471-
mode: "split",
472-
theme: "midnight",
473-
showLineNumbers: false,
474-
wrapLines: false,
475-
showHunkHeaders: true,
476-
showAgentNotes: false,
477-
});
478-
} finally {
479-
await act(async () => {
480-
setup.renderer.destroy();
481-
});
482-
}
483-
});
484453
});

test/config.test.ts

Lines changed: 2 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { CliInput } from "../src/core/types";
6-
import { persistViewPreferences, resolveConfiguredCliInput } from "../src/core/config";
6+
import { resolveConfiguredCliInput } from "../src/core/config";
77
import { loadAppBootstrap } from "../src/core/loaders";
88

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

8383
expect(resolved.repoConfigPath).toBe(join(repo, ".hunk", "config.toml"));
84-
expect(resolved.persistencePath).toBe(join(repo, ".hunk", "config.toml"));
8584
expect(resolved.input.options).toMatchObject({
8685
pager: true,
8786
mode: "stack",
@@ -103,82 +102,6 @@ describe("config resolution", () => {
103102
});
104103

105104
expect(resolved.repoConfigPath).toBeUndefined();
106-
expect(resolved.persistencePath).toBe(join(home, ".config", "hunk", "config.toml"));
107-
});
108-
109-
test("persists top-level preferences without discarding profile sections", () => {
110-
const repo = createTempDir("hunk-config-repo-");
111-
const configPath = join(repo, ".hunk", "config.toml");
112-
113-
mkdirSync(join(repo, ".hunk"), { recursive: true });
114-
writeFileSync(
115-
configPath,
116-
[
117-
'recent_themes = ["paper", "midnight"]',
118-
'',
119-
'[pager]',
120-
'mode = "stack"',
121-
'',
122-
'[git]',
123-
'wrap_lines = true',
124-
].join('\n'),
125-
);
126-
127-
persistViewPreferences(configPath, {
128-
mode: "split",
129-
theme: "midnight",
130-
showLineNumbers: false,
131-
wrapLines: true,
132-
showHunkHeaders: false,
133-
showAgentNotes: true,
134-
});
135-
136-
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
137-
expect(parsed.mode).toBe("split");
138-
expect(parsed.theme).toBe("midnight");
139-
expect(parsed.line_numbers).toBe(false);
140-
expect(parsed.wrap_lines).toBe(true);
141-
expect(parsed.hunk_headers).toBe(false);
142-
expect(parsed.agent_notes).toBe(true);
143-
expect(parsed.recent_themes).toEqual(["paper", "midnight"]);
144-
expect((parsed.pager as Record<string, unknown>).mode).toBe("stack");
145-
expect((parsed.git as Record<string, unknown>).wrap_lines).toBe(true);
146-
});
147-
148-
test("preserves TOML array-of-table sections when persisting view preferences", () => {
149-
const repo = createTempDir("hunk-config-repo-");
150-
const configPath = join(repo, ".hunk", "config.toml");
151-
152-
mkdirSync(join(repo, ".hunk"), { recursive: true });
153-
writeFileSync(
154-
configPath,
155-
[
156-
'theme = "paper"',
157-
'',
158-
'[[bookmarks]]',
159-
'path = "src/a.ts"',
160-
'hunk = 0',
161-
'',
162-
'[[bookmarks]]',
163-
'path = "src/b.ts"',
164-
'hunk = 2',
165-
].join('\n'),
166-
);
167-
168-
persistViewPreferences(configPath, {
169-
mode: "split",
170-
theme: "paper",
171-
showLineNumbers: true,
172-
wrapLines: false,
173-
showHunkHeaders: true,
174-
showAgentNotes: false,
175-
});
176-
177-
const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
178-
expect(parsed.bookmarks).toEqual([
179-
{ path: "src/a.ts", hunk: 0 },
180-
{ path: "src/b.ts", hunk: 2 },
181-
]);
182105
});
183106

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

0 commit comments

Comments
 (0)