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
5 changes: 5 additions & 0 deletions .changeset/configurable-tab-width.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Render source tabs at four-column stops by default and add `tab_width`, `-x`, and `--tab-width` overrides for projects with different conventions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ vcs = "git" # git, jj, sl
watch = false
exclude_untracked = false
line_numbers = true
tab_width = 4 # tab stops, 1-16
wrap_lines = false
menu_bar = true
agent_notes = false
Expand All @@ -140,6 +141,7 @@ transparent_background = false
`theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer.
Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases.
`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
`tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`.
`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences.
`transparent_background` can also be written as `transparentBackground`.

Expand Down
1 change: 1 addition & 0 deletions docs/opentui-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember" \| "catppuccin-latte" \| "catppuccin-frappe" \| "catppuccin-macchiato" \| "catppuccin-mocha" \| "zenburn"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `tabWidth` | `number` | `4` | Sets source-code tab stops from 1 to 16 columns. |
| `showFileSeparators` | `boolean` | `true` | Toggles separator rows between files in `HunkReviewStream`. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
Expand Down
1 change: 1 addition & 0 deletions nix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Hunk provides a Home Manager module to manage both the package and its configura
theme = "graphite";
mode = "split";
line_numbers = true;
tab_width = 4;
};
};
}
Expand Down
10 changes: 10 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe("parseCli", () => {
"--agent-context",
"notes.json",
"--no-line-numbers",
"-x4",
"--wrap",
"--no-hunk-headers",
"--agent-notes",
Expand All @@ -115,6 +116,7 @@ describe("parseCli", () => {
agentContext: "notes.json",
watch: true,
lineNumbers: false,
tabWidth: 4,
wrapLines: true,
hunkHeaders: false,
agentNotes: true,
Expand Down Expand Up @@ -1137,6 +1139,14 @@ describe("parseCli argument validation", () => {
]);
}

test("rejects invalid tab widths", async () => {
for (const value of ["0", "17", "4x"]) {
await expect(parseCli(["bun", "hunk", "diff", "--tab-width", value])).rejects.toThrow(
"Invalid tab width",
);
}
});

test("rejects an invalid layout mode and rethrows the parser error", async () => {
await expect(parseCli(["bun", "hunk", "diff", "--mode", "bogus"])).rejects.toThrow(
"Invalid layout mode: bogus",
Expand Down
5 changes: 5 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "./types";
import { resolveBundledHunkReviewSkillPath } from "./paths";
import { detectVcs } from "./vcs";
import { parseTabWidth } from "./tabWidth";
import { resolveCliVersion } from "./version";

/** Validate one requested layout mode from CLI input. */
Expand Down Expand Up @@ -65,6 +66,7 @@ function buildCommonOptions(
pager?: boolean;
watch?: boolean;
transparentBackground?: boolean;
tabWidth?: number;
},
argv: string[],
): CommonOptions {
Expand All @@ -76,6 +78,7 @@ function buildCommonOptions(
watch: options.watch ? true : undefined,
excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"),
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
tabWidth: options.tabWidth,
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
Expand All @@ -92,6 +95,7 @@ function applyCommonOptions(command: Command) {
.option("--pager", "use pager-style chrome and controls")
.option("--line-numbers", "show line numbers")
.option("--no-line-numbers", "hide line numbers")
.option("-x, --tab-width <columns>", "tab stop width: 1-16", parseTabWidth)
.option("--wrap", "wrap long diff lines")
.option("--no-wrap", "truncate long diff lines to one row")
.option("--hunk-headers", "show hunk metadata rows")
Expand Down Expand Up @@ -160,6 +164,7 @@ function renderCliHelp() {
" --agent-context <path> JSON sidecar with agent rationale",
" --pager use pager-style chrome and controls",
" --line-numbers / --no-line-numbers show or hide line numbers",
" -x, --tab-width <columns> tab stop width: 1-16 (default: 4)",
" --wrap / --no-wrap wrap or truncate long diff lines",
" --hunk-headers / --no-hunk-headers show or hide hunk metadata rows",
" --agent-notes / --no-agent-notes show or hide agent notes by default",
Expand Down
34 changes: 30 additions & 4 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ describe("config resolution", () => {
[
'theme = "github-dark-default"',
"line_numbers = false",
"tab_width = 8",
"transparentBackground = true",
"color_moved = true",
"prompt_save_view_preferences = false",
Expand All @@ -192,10 +193,13 @@ describe("config resolution", () => {
].join("\n"),
);

const resolved = resolveConfiguredCliInput(createPatchPagerInput({ agentNotes: true }), {
cwd: repo,
env: { HOME: home },
});
const resolved = resolveConfiguredCliInput(
createPatchPagerInput({ agentNotes: true, tabWidth: 6 }),
{
cwd: repo,
env: { HOME: home },
},
);

expect(resolved.repoConfigPath).toBe(join(repo, ".hunk", "config.toml"));
expect(resolved.viewPreferencesConfigPath).toBe(join(repo, ".hunk", "config.toml"));
Expand All @@ -204,6 +208,7 @@ describe("config resolution", () => {
mode: "stack",
theme: "github-light-default",
lineNumbers: false,
tabWidth: 6,
wrapLines: true,
menuBar: false,
hunkHeaders: false,
Expand All @@ -214,6 +219,25 @@ describe("config resolution", () => {
});
});

test("defaults tab width to 4 and rejects invalid configured widths", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);

const input = createPatchPagerInput();
expect(
resolveConfiguredCliInput(input, { cwd: repo, env: { HOME: home } }).input.options.tabWidth,
).toBe(4);

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
for (const invalid of ["0", "17", '"4"']) {
writeFileSync(join(home, ".config", "hunk", "config.toml"), `tab_width = ${invalid}\n`);
expect(() => resolveConfiguredCliInput(input, { cwd: repo, env: { HOME: home } })).toThrow(
/tab_width/,
);
}
});

test("merges custom theme overrides from global and repo config", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
Expand Down Expand Up @@ -666,6 +690,7 @@ describe("config resolution", () => {
[
'theme = "github-light-default"',
"line_numbers = false",
"tab_width = 8",
"wrap_lines = true",
"menu_bar = false",
"hunk_headers = false",
Expand Down Expand Up @@ -693,6 +718,7 @@ describe("config resolution", () => {
expect(bootstrap.initialMode).toBe("auto");
expect(bootstrap.initialTheme).toBe("github-light-default");
expect(bootstrap.initialShowLineNumbers).toBe(false);
expect(bootstrap.initialTabWidth).toBe(8);
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowMenuBar).toBe(false);
expect(bootstrap.initialShowHunkHeaders).toBe(false);
Expand Down
18 changes: 18 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { normalizeBuiltInThemeId } from "../ui/themes";
import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes";
import { resolveGlobalConfigPath } from "./paths";
import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice";
import { DEFAULT_TAB_WIDTH, validateTabWidth } from "./tabWidth";
import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs";
import type {
CliInput,
Expand Down Expand Up @@ -158,6 +159,19 @@ function normalizeString(value: unknown) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

/** Accept a bounded integer tab width from TOML configuration. */
function normalizeTabWidth(value: unknown) {
if (value === undefined) {
return undefined;
}

if (typeof value !== "number" || !Number.isInteger(value)) {
throw new Error("Expected tab_width to be an integer from 1 to 16.");
}

return validateTabWidth(value, "tab_width");
}

/** Accept only #rrggbb theme colors and report the failing TOML key path. */
function normalizeThemeColor(value: unknown, keyPath: string) {
if (value === undefined) {
Expand Down Expand Up @@ -320,6 +334,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
watch: normalizeBoolean(source.watch),
excludeUntracked: normalizeBoolean(source.exclude_untracked),
lineNumbers: normalizeBoolean(source.line_numbers),
tabWidth: normalizeTabWidth(source.tab_width),
wrapLines: normalizeBoolean(source.wrap_lines),
hunkHeaders: normalizeBoolean(source.hunk_headers),
menuBar: normalizeBoolean(source.menu_bar),
Expand All @@ -345,6 +360,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
watch: overrides.watch ?? base.watch,
excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked,
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
tabWidth: overrides.tabWidth ?? base.tabWidth,
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
menuBar: overrides.menuBar ?? base.menuBar,
Expand Down Expand Up @@ -511,6 +527,7 @@ export function resolveConfiguredCliInput(
watch: input.options.watch ?? false,
excludeUntracked: false,
lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers,
tabWidth: DEFAULT_TAB_WIDTH,
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
menuBar: DEFAULT_VIEW_PREFERENCES.showMenuBar,
Expand Down Expand Up @@ -547,6 +564,7 @@ export function resolveConfiguredCliInput(
vcs: resolvedOptions.vcs ?? getDefaultVcsAdapter().id,
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
tabWidth: resolvedOptions.tabWidth ?? DEFAULT_TAB_WIDTH,
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar,
Expand Down
2 changes: 2 additions & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { buildDiffFile, type BuildDiffFileOptions, type DiffFileSourceContext }
import { createFileSourceFetcher, type FileSourceSpec } from "./fileSource";
import { splitPatchIntoFileChunks, findPatchChunk } from "./patch/chunks";
import { normalizePatchText, stripTerminalControl } from "./patch/normalize";
import { DEFAULT_TAB_WIDTH } from "./tabWidth";
import { getConfiguredVcsAdapter, loadVcsReview, operationFromInput } from "./vcs";
import { computeWatchSignature } from "./watch";
import type {
Expand Down Expand Up @@ -474,6 +475,7 @@ export async function loadAppBootstrap(
initialTheme: input.options.theme,
customTheme,
initialShowLineNumbers: input.options.lineNumbers ?? true,
initialTabWidth: input.options.tabWidth ?? DEFAULT_TAB_WIDTH,
initialWrapLines: input.options.wrapLines ?? false,
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowMenuBar: input.options.menuBar ?? true,
Expand Down
23 changes: 23 additions & 0 deletions src/core/tabWidth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const DEFAULT_TAB_WIDTH = 4;
export const MIN_TAB_WIDTH = 1;
export const MAX_TAB_WIDTH = 16;

/** Validate one numeric tab width while keeping rendering allocations practical. */
export function validateTabWidth(value: number, label = "tab width") {
if (!Number.isSafeInteger(value) || value < MIN_TAB_WIDTH || value > MAX_TAB_WIDTH) {
throw new Error(
`Invalid ${label}: ${String(value)} (expected ${MIN_TAB_WIDTH}-${MAX_TAB_WIDTH})`,
);
}

return value;
}

/** Parse one CLI tab-width argument. */
export function parseTabWidth(value: string) {
if (!/^[1-9]\d*$/.test(value)) {
throw new Error(`Invalid tab width: ${value}`);
}

return validateTabWidth(Number(value));
}
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface CommonOptions {
watch?: boolean;
excludeUntracked?: boolean;
lineNumbers?: boolean;
tabWidth?: number;
wrapLines?: boolean;
hunkHeaders?: boolean;
menuBar?: boolean;
Expand Down Expand Up @@ -399,6 +400,7 @@ export interface AppBootstrap {
initialThemeMode?: TerminalThemeMode;
customTheme?: CustomThemeConfig;
initialShowLineNumbers?: boolean;
initialTabWidth?: number;
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
initialShowMenuBar?: boolean;
Expand Down
8 changes: 5 additions & 3 deletions src/opentui/HunkDiffBody.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { DEFAULT_TAB_WIDTH } from "../core/tabWidth";
import { findMaxLineNumber } from "../ui/diff/codeColumns";
import { buildSplitRows, buildStackRows } from "../ui/diff/pierre";
import { diffMessage, DiffRowView, fitText } from "../ui/diff/renderRows";
Expand All @@ -15,6 +16,7 @@ export function HunkDiffBody({
theme = "github-dark-default",
showLineNumbers = true,
showHunkHeaders = true,
tabWidth = DEFAULT_TAB_WIDTH,
wrapLines = false,
horizontalOffset = 0,
highlight = true,
Expand All @@ -31,10 +33,10 @@ export function HunkDiffBody({
() =>
internalFile
? layout === "split"
? buildSplitRows(internalFile, resolvedHighlighted, resolvedTheme)
: buildStackRows(internalFile, resolvedHighlighted, resolvedTheme)
? buildSplitRows(internalFile, resolvedHighlighted, resolvedTheme, tabWidth)
: buildStackRows(internalFile, resolvedHighlighted, resolvedTheme, tabWidth)
: [],
[internalFile, layout, resolvedHighlighted, resolvedTheme],
[internalFile, layout, resolvedHighlighted, resolvedTheme, tabWidth],
);
const lineNumberDigits = useMemo(
() => String(internalFile ? findMaxLineNumber(internalFile) : 1).length,
Expand Down
17 changes: 17 additions & 0 deletions src/opentui/HunkDiffView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ describe("OpenTUI public components", () => {
expect(frame).toContain(" 1 + export const value = 2;");
});

test("accepts a custom tab width through the public body primitive", async () => {
const metadata = parseDiffFromFile(
{ cacheKey: "tabs-before", contents: "a\tb\n", name: "tabs.txt" },
{ cacheKey: "tabs-after", contents: "a\tc\n", name: "tabs.txt" },
{ context: 3 },
true,
);
const file = createHunkDiffFile({ id: "tabs", metadata, path: "tabs.txt" });
const frame = await captureFrame(
<HunkDiffBody file={file} layout="stack" width={88} tabWidth={8} highlight={false} />,
92,
8,
);

expect(frame).toContain("a c");
});

test("renders reusable file header and multi-file review stream primitives", async () => {
const diff = createExampleDiff();
const frame = await captureFrame(
Expand Down
2 changes: 2 additions & 0 deletions src/opentui/HunkReviewStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function HunkReviewStream({
showFileSeparators = true,
showLineNumbers = true,
showHunkHeaders = true,
tabWidth,
wrapLines = false,
horizontalOffset = 0,
highlight = true,
Expand Down Expand Up @@ -75,6 +76,7 @@ export function HunkReviewStream({
theme={theme}
showLineNumbers={showLineNumbers}
showHunkHeaders={showHunkHeaders}
tabWidth={tabWidth}
wrapLines={wrapLines}
horizontalOffset={horizontalOffset}
highlight={highlight}
Expand Down
2 changes: 2 additions & 0 deletions src/opentui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface HunkDiffBodyProps {
theme?: HunkDiffThemeName;
showLineNumbers?: boolean;
showHunkHeaders?: boolean;
tabWidth?: number;
wrapLines?: boolean;
horizontalOffset?: number;
highlight?: boolean;
Expand Down Expand Up @@ -71,6 +72,7 @@ export interface HunkReviewStreamProps {
showFileSeparators?: boolean;
showLineNumbers?: boolean;
showHunkHeaders?: boolean;
tabWidth?: number;
wrapLines?: boolean;
horizontalOffset?: number;
highlight?: boolean;
Expand Down
Loading