From 3e45b8f1d9d0652b8353bc2119d8200e2384e635 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 23 Jul 2026 16:33:20 -0400 Subject: [PATCH] feat: make diff tab width configurable --- .changeset/configurable-tab-width.md | 5 ++ README.md | 2 + docs/opentui-component.md | 1 + nix/README.md | 1 + src/core/cli.test.ts | 10 ++++ src/core/cli.ts | 5 ++ src/core/config.test.ts | 34 ++++++++++-- src/core/config.ts | 18 +++++++ src/core/loaders.ts | 2 + src/core/tabWidth.ts | 23 ++++++++ src/core/types.ts | 2 + src/opentui/HunkDiffBody.tsx | 8 +-- src/opentui/HunkDiffView.test.tsx | 17 ++++++ src/opentui/HunkReviewStream.tsx | 2 + src/opentui/types.ts | 2 + src/ui/App.tsx | 7 ++- src/ui/components/panes/DiffPane.tsx | 10 +++- src/ui/components/panes/DiffSection.tsx | 4 ++ src/ui/diff/PierreDiffView.tsx | 8 ++- src/ui/diff/codeColumns.test.ts | 23 +++++++- src/ui/diff/codeColumns.ts | 54 ++++++++++++++----- src/ui/diff/diffSectionGeometry.test.ts | 15 ++++++ src/ui/diff/diffSectionGeometry.ts | 9 +++- src/ui/diff/diffSectionRowPlan.ts | 11 ++-- src/ui/diff/expandCollapsedRows.test.ts | 3 +- src/ui/diff/expandCollapsedRows.ts | 17 ++++-- src/ui/diff/pierre.test.ts | 32 +++++++++++ src/ui/diff/pierre.ts | 70 ++++++++++++++++++------- src/ui/staticDiffPager.test.ts | 11 ++++ src/ui/staticDiffPager.ts | 6 ++- test/pty/harness.ts | 12 +++++ test/pty/layout.test.ts | 25 +++++++++ 32 files changed, 394 insertions(+), 55 deletions(-) create mode 100644 .changeset/configurable-tab-width.md create mode 100644 src/core/tabWidth.ts diff --git a/.changeset/configurable-tab-width.md b/.changeset/configurable-tab-width.md new file mode 100644 index 00000000..40d812f8 --- /dev/null +++ b/.changeset/configurable-tab-width.md @@ -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. diff --git a/README.md b/README.md index c0e15dbb..5014239e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. diff --git a/docs/opentui-component.md b/docs/opentui-component.md index 684af069..735f38bf 100644 --- a/docs/opentui-component.md +++ b/docs/opentui-component.md @@ -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. | diff --git a/nix/README.md b/nix/README.md index 19fde858..3a19b1e0 100644 --- a/nix/README.md +++ b/nix/README.md @@ -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; }; }; } diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index fa8675bd..b691bea2 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -98,6 +98,7 @@ describe("parseCli", () => { "--agent-context", "notes.json", "--no-line-numbers", + "-x4", "--wrap", "--no-hunk-headers", "--agent-notes", @@ -115,6 +116,7 @@ describe("parseCli", () => { agentContext: "notes.json", watch: true, lineNumbers: false, + tabWidth: 4, wrapLines: true, hunkHeaders: false, agentNotes: true, @@ -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", diff --git a/src/core/cli.ts b/src/core/cli.ts index 96832bcf..35594801 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -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. */ @@ -65,6 +66,7 @@ function buildCommonOptions( pager?: boolean; watch?: boolean; transparentBackground?: boolean; + tabWidth?: number; }, argv: string[], ): CommonOptions { @@ -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"), @@ -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 ", "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") @@ -160,6 +164,7 @@ function renderCliHelp() { " --agent-context 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 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", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 63926e95..e9562a13 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -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", @@ -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")); @@ -204,6 +208,7 @@ describe("config resolution", () => { mode: "stack", theme: "github-light-default", lineNumbers: false, + tabWidth: 6, wrapLines: true, menuBar: false, hunkHeaders: false, @@ -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-"); @@ -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", @@ -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); diff --git a/src/core/config.ts b/src/core/config.ts index 25364e9e..7f36bbd5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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, @@ -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) { @@ -320,6 +334,7 @@ function readConfigPreferences(source: Record): 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), @@ -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, @@ -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, @@ -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, diff --git a/src/core/loaders.ts b/src/core/loaders.ts index ff6eb9ab..c5865393 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -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 { @@ -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, diff --git a/src/core/tabWidth.ts b/src/core/tabWidth.ts new file mode 100644 index 00000000..89ce550f --- /dev/null +++ b/src/core/tabWidth.ts @@ -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)); +} diff --git a/src/core/types.ts b/src/core/types.ts index 65491964..1ad64f35 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -91,6 +91,7 @@ export interface CommonOptions { watch?: boolean; excludeUntracked?: boolean; lineNumbers?: boolean; + tabWidth?: number; wrapLines?: boolean; hunkHeaders?: boolean; menuBar?: boolean; @@ -399,6 +400,7 @@ export interface AppBootstrap { initialThemeMode?: TerminalThemeMode; customTheme?: CustomThemeConfig; initialShowLineNumbers?: boolean; + initialTabWidth?: number; initialWrapLines?: boolean; initialShowHunkHeaders?: boolean; initialShowMenuBar?: boolean; diff --git a/src/opentui/HunkDiffBody.tsx b/src/opentui/HunkDiffBody.tsx index 30a9c35c..bbebc751 100644 --- a/src/opentui/HunkDiffBody.tsx +++ b/src/opentui/HunkDiffBody.tsx @@ -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"; @@ -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, @@ -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, diff --git a/src/opentui/HunkDiffView.test.tsx b/src/opentui/HunkDiffView.test.tsx index 915b7835..3fc0237e 100644 --- a/src/opentui/HunkDiffView.test.tsx +++ b/src/opentui/HunkDiffView.test.tsx @@ -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( + , + 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( diff --git a/src/opentui/HunkReviewStream.tsx b/src/opentui/HunkReviewStream.tsx index cc7dc5e4..b0c4833d 100644 --- a/src/opentui/HunkReviewStream.tsx +++ b/src/opentui/HunkReviewStream.tsx @@ -24,6 +24,7 @@ export function HunkReviewStream({ showFileSeparators = true, showLineNumbers = true, showHunkHeaders = true, + tabWidth, wrapLines = false, horizontalOffset = 0, highlight = true, @@ -75,6 +76,7 @@ export function HunkReviewStream({ theme={theme} showLineNumbers={showLineNumbers} showHunkHeaders={showHunkHeaders} + tabWidth={tabWidth} wrapLines={wrapLines} horizontalOffset={horizontalOffset} highlight={highlight} diff --git a/src/opentui/types.ts b/src/opentui/types.ts index 7112f01f..fdd67ced 100644 --- a/src/opentui/types.ts +++ b/src/opentui/types.ts @@ -42,6 +42,7 @@ export interface HunkDiffBodyProps { theme?: HunkDiffThemeName; showLineNumbers?: boolean; showHunkHeaders?: boolean; + tabWidth?: number; wrapLines?: boolean; horizontalOffset?: number; highlight?: boolean; @@ -71,6 +72,7 @@ export interface HunkReviewStreamProps { showFileSeparators?: boolean; showLineNumbers?: boolean; showHunkHeaders?: boolean; + tabWidth?: number; wrapLines?: boolean; horizontalOffset?: number; highlight?: boolean; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8571e377..962a4bde 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -10,6 +10,7 @@ import { saveGlobalViewPreferences, saveViewPreferencesPromptPreference, } from "../core/config"; +import { DEFAULT_TAB_WIDTH } from "../core/tabWidth"; import type { AppBootstrap, CliInput, @@ -126,6 +127,7 @@ export function App({ const DIVIDER_HIT_WIDTH = 5; const pagerMode = Boolean(bootstrap.input.options.pager); + const tabWidth = bootstrap.initialTabWidth ?? DEFAULT_TAB_WIDTH; const renderer = useRenderer(); const terminal = useTerminalDimensions(); const sidebarScrollRef = useRef(null); @@ -415,11 +417,11 @@ export function App({ Math.max( 0, filteredFiles.reduce( - (maxWidth, file) => Math.max(maxWidth, maxFileCodeLineWidth(file)), + (maxWidth, file) => Math.max(maxWidth, maxFileCodeLineWidth(file, tabWidth)), 0, ) - codeViewportWidth, ), - [codeViewportWidth, filteredFiles], + [codeViewportWidth, filteredFiles, tabWidth], ); useEffect(() => { @@ -1121,6 +1123,7 @@ export function App({ showLineNumbers={showLineNumbers} showHunkHeaders={showHunkHeaders} sourceStatusByFileId={review.sourceStatusByFileId} + tabWidth={tabWidth} wrapLines={wrapLines} wrapToggleScrollTop={wrapToggleScrollTopRef.current} layoutToggleScrollTop={layoutToggleScrollTopRef.current} diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 58226ae7..65790945 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -13,6 +13,7 @@ import { useState, type RefObject, } from "react"; +import { DEFAULT_TAB_WIDTH } from "../../../core/tabWidth"; import type { AgentAnnotation, DiffFile, @@ -196,6 +197,7 @@ export function DiffPane({ showLineNumbers, showHunkHeaders, sourceStatusByFileId = EMPTY_SOURCE_STATUS_BY_FILE_ID, + tabWidth = DEFAULT_TAB_WIDTH, wrapLines, wrapToggleScrollTop, layoutToggleScrollTop = null, @@ -243,6 +245,7 @@ export function DiffPane({ showLineNumbers: boolean; showHunkHeaders: boolean; sourceStatusByFileId?: Record; + tabWidth?: number; wrapLines: boolean; wrapToggleScrollTop: number | null; layoutToggleScrollTop?: number | null; @@ -661,6 +664,7 @@ export function DiffPane({ expandedGapsByFileId[file.id] ?? EMPTY_EXPANDED_GAP_KEYS, sourceStatusByFileId[file.id], reserveAddNoteColumn, + tabWidth, ), ), [ @@ -672,6 +676,7 @@ export function DiffPane({ showHunkHeaders, showLineNumbers, sourceStatusByFileId, + tabWidth, theme, wrapLines, ], @@ -703,6 +708,7 @@ export function DiffPane({ expandedGapsByFileId[file.id] ?? EMPTY_EXPANDED_GAP_KEYS, sourceStatusByFileId[file.id], reserveAddNoteColumn, + tabWidth, ); }), [ @@ -716,6 +722,7 @@ export function DiffPane({ showHunkHeaders, showLineNumbers, sourceStatusByFileId, + tabWidth, theme, wrapLines, ], @@ -1777,7 +1784,7 @@ export function DiffPane({ {fileRenderItems.map((item) => { @@ -1816,6 +1823,7 @@ export function DiffPane({ showLineNumbers={showLineNumbers} showHunkHeaders={showHunkHeaders} sourceStatus={sourceStatusByFileId[file.id]} + tabWidth={tabWidth} wrapLines={wrapLines} theme={theme} hoverActive={hoveredFileId === null || hoveredFileId === file.id} diff --git a/src/ui/components/panes/DiffSection.tsx b/src/ui/components/panes/DiffSection.tsx index 7dc6af2c..1541d1b3 100644 --- a/src/ui/components/panes/DiffSection.tsx +++ b/src/ui/components/panes/DiffSection.tsx @@ -27,6 +27,7 @@ interface DiffSectionProps { showLineNumbers: boolean; showHunkHeaders: boolean; sourceStatus: FileSourceStatus | undefined; + tabWidth: number; wrapLines: boolean; showHeader: boolean; showSeparator: boolean; @@ -61,6 +62,7 @@ function DiffSectionComponent({ showLineNumbers, showHunkHeaders, sourceStatus, + tabWidth, wrapLines, showHeader, showSeparator, @@ -120,6 +122,7 @@ function DiffSectionComponent({ showLineNumbers={showLineNumbers} showHunkHeaders={showHunkHeaders} sourceStatus={sourceStatus} + tabWidth={tabWidth} wrapLines={wrapLines} codeHorizontalOffset={codeHorizontalOffset} copySelectedRowRanges={copySelectedRowRanges} @@ -165,6 +168,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => { previous.showLineNumbers === next.showLineNumbers && previous.showHunkHeaders === next.showHunkHeaders && previous.sourceStatus === next.sourceStatus && + previous.tabWidth === next.tabWidth && previous.wrapLines === next.wrapLines && previous.showHeader === next.showHeader && previous.showSeparator === next.showSeparator && diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index 5903f917..ec7df7d7 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -1,5 +1,6 @@ import { useRenderer } from "@opentui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { DEFAULT_TAB_WIDTH } from "../../core/tabWidth"; import type { DiffFile, LayoutMode, UserNoteLineTarget } from "../../core/types"; import { AgentInlineNote } from "../components/panes/AgentInlineNote"; import type { VisibleAgentNote } from "../lib/agentAnnotations"; @@ -72,6 +73,7 @@ export function PierreDiffView({ showLineNumbers = true, showHunkHeaders = true, sourceStatus, + tabWidth = DEFAULT_TAB_WIDTH, wrapLines = false, theme, visibleAgentNotes = EMPTY_VISIBLE_AGENT_NOTES, @@ -97,6 +99,7 @@ export function PierreDiffView({ showLineNumbers?: boolean; showHunkHeaders?: boolean; sourceStatus?: FileSourceStatus | undefined; + tabWidth?: number; wrapLines?: boolean; theme: AppTheme; visibleAgentNotes?: VisibleAgentNote[]; @@ -197,8 +200,9 @@ export function PierreDiffView({ line, resolvedHighlightedSource?.lines[sourceLineNumber], theme, + tabWidth, ), - [resolvedHighlightedSource, theme], + [resolvedHighlightedSource, tabWidth, theme], ); const sectionRowPlan = useMemo( @@ -211,6 +215,7 @@ export function PierreDiffView({ showHunkHeaders, sourceLineSpans, sourceStatus, + tabWidth, theme, visibleAgentNotes, }), @@ -222,6 +227,7 @@ export function PierreDiffView({ showHunkHeaders, sourceLineSpans, sourceStatus, + tabWidth, theme, visibleAgentNotes, ], diff --git a/src/ui/diff/codeColumns.test.ts b/src/ui/diff/codeColumns.test.ts index 356e687d..b5194db0 100644 --- a/src/ui/diff/codeColumns.test.ts +++ b/src/ui/diff/codeColumns.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import type { DiffFile } from "../../core/types"; -import { findMaxLineNumberInRows, maxFileCodeLineWidth } from "./codeColumns"; +import { + expandDiffTabs, + findMaxLineNumberInRows, + maxFileCodeLineWidth, + measureRenderedCodeLineWidth, +} from "./codeColumns"; import type { DiffRow } from "./pierre"; /** Generate a large diff metadata fixture without checking a huge file into the repo. */ @@ -24,6 +29,22 @@ function createLargeLineFixture(lineCount: number, widestLine: string): DiffFile } describe("code column measurement", () => { + test("expands tabs to configurable terminal-cell stops", () => { + expect(expandDiffTabs("\tvalue")).toBe(" value"); + expect(expandDiffTabs("a\tb", 4)).toBe("a b"); + expect(expandDiffTabs("abc\td", 4)).toBe("abc d"); + expect(expandDiffTabs("日本\tx", 4)).toBe("日本 x"); + expect(expandDiffTabs("a\tb", 4, 2)).toBe("a b"); + expect(measureRenderedCodeLineWidth("a\tb", 8)).toBe(9); + }); + + test("caches widest lines separately for each tab width", () => { + const file = createLargeLineFixture(2, "\twide"); + + expect(maxFileCodeLineWidth(file, 2)).toBe(6); + expect(maxFileCodeLineWidth(file, 8)).toBe(12); + }); + test("measures large generated fixtures without overflowing the call stack", () => { const file = createLargeLineFixture(100_000, "the widest generated line"); diff --git a/src/ui/diff/codeColumns.ts b/src/ui/diff/codeColumns.ts index be11d786..72b32a2b 100644 --- a/src/ui/diff/codeColumns.ts +++ b/src/ui/diff/codeColumns.ts @@ -1,26 +1,50 @@ +import { DEFAULT_TAB_WIDTH, validateTabWidth } from "../../core/tabWidth"; import type { DiffFile, LayoutMode } from "../../core/types"; import { measureTextWidth } from "../lib/text"; import type { DiffRow } from "./pierre"; -export const DIFF_CODE_TAB_WIDTH = 2; export const DIFF_RAIL_PREFIX_WIDTH = 1; export const DIFF_SPLIT_SEPARATOR_WIDTH = 1; -const maxFileCodeLineWidthCache = new WeakMap(); +const maxFileCodeLineWidthCache = new WeakMap>(); -/** Expand tabs the same way the diff renderer does before measuring visible columns. */ -export function expandDiffTabs(text: string) { - return text.replaceAll("\t", " ".repeat(DIFF_CODE_TAB_WIDTH)); +/** Expand tabs to the next source-column stop using terminal-cell widths. */ +export function expandDiffTabs(text: string, tabWidth = DEFAULT_TAB_WIDTH, initialColumn = 0) { + if (!text.includes("\t")) { + return text; + } + + const resolvedTabWidth = validateTabWidth(tabWidth); + const segments = text.split("\t"); + let column = Math.max(0, initialColumn); + let expanded = ""; + + for (const [index, segment] of segments.entries()) { + expanded += segment; + column += measureTextWidth(segment); + + if (index < segments.length - 1) { + const spaces = resolvedTabWidth - (column % resolvedTabWidth); + expanded += " ".repeat(spaces); + column += spaces; + } + } + + return expanded; } /** Measure one rendered code line after tab expansion and newline trimming. */ -export function measureRenderedCodeLineWidth(line: string | undefined) { - return measureTextWidth(expandDiffTabs((line ?? "").replace(/\n$/, ""))); +export function measureRenderedCodeLineWidth( + line: string | undefined, + tabWidth = DEFAULT_TAB_WIDTH, +) { + return measureTextWidth(expandDiffTabs((line ?? "").replace(/\n$/, ""), tabWidth)); } -/** Track the widest rendered code line for one file. */ -export function maxFileCodeLineWidth(file: DiffFile) { - const cachedWidth = maxFileCodeLineWidthCache.get(file.metadata); +/** Track the widest rendered code line for one file and tab width. */ +export function maxFileCodeLineWidth(file: DiffFile, tabWidth = DEFAULT_TAB_WIDTH) { + const cachedByTabWidth = maxFileCodeLineWidthCache.get(file.metadata); + const cachedWidth = cachedByTabWidth?.get(tabWidth); if (cachedWidth !== undefined) { return cachedWidth; } @@ -31,14 +55,18 @@ export function maxFileCodeLineWidth(file: DiffFile) { let maxWidth = 0; for (const line of deletionLines) { - maxWidth = Math.max(maxWidth, measureRenderedCodeLineWidth(line)); + maxWidth = Math.max(maxWidth, measureRenderedCodeLineWidth(line, tabWidth)); } for (const line of additionLines) { - maxWidth = Math.max(maxWidth, measureRenderedCodeLineWidth(line)); + maxWidth = Math.max(maxWidth, measureRenderedCodeLineWidth(line, tabWidth)); } - maxFileCodeLineWidthCache.set(file.metadata, maxWidth); + const nextCachedByTabWidth = cachedByTabWidth ?? new Map(); + nextCachedByTabWidth.set(tabWidth, maxWidth); + if (!cachedByTabWidth) { + maxFileCodeLineWidthCache.set(file.metadata, nextCachedByTabWidth); + } return maxWidth; } diff --git a/src/ui/diff/diffSectionGeometry.test.ts b/src/ui/diff/diffSectionGeometry.test.ts index 27afd347..75c59488 100644 --- a/src/ui/diff/diffSectionGeometry.test.ts +++ b/src/ui/diff/diffSectionGeometry.test.ts @@ -51,10 +51,25 @@ describe("measureDiffSectionGeometry", () => { undefined, true, ); + const differentTabWidth = measureDiffSectionGeometry( + file, + "split", + true, + theme, + [], + 120, + true, + false, + undefined, + undefined, + false, + 8, + ); expect(second).toBe(first); expect(differentWidth).not.toBe(first); expect(differentAddNotePolicy).not.toBe(first); + expect(differentTabWidth).not.toBe(first); }); test("caches planned rows after the lazy geometry property is first read", () => { diff --git a/src/ui/diff/diffSectionGeometry.ts b/src/ui/diff/diffSectionGeometry.ts index 2d1f7c8b..278f7872 100644 --- a/src/ui/diff/diffSectionGeometry.ts +++ b/src/ui/diff/diffSectionGeometry.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TAB_WIDTH } from "../../core/tabWidth"; import type { DiffFile, LayoutMode } from "../../core/types"; import { measureAgentInlineNoteHeight } from "../components/panes/AgentInlineNote"; import type { VisibleAgentNote } from "../lib/agentAnnotations"; @@ -141,6 +142,7 @@ function createLazyPlannedRowsResolver({ layout, showHunkHeaders, sourceStatus, + tabWidth, theme, visibleAgentNotes, }: { @@ -149,6 +151,7 @@ function createLazyPlannedRowsResolver({ layout: Exclude; showHunkHeaders: boolean; sourceStatus: FileSourceStatus | undefined; + tabWidth: number; theme: AppTheme; visibleAgentNotes: VisibleAgentNote[]; }) { @@ -162,6 +165,7 @@ function createLazyPlannedRowsResolver({ layout, showHunkHeaders, sourceStatus: sourceStatus ? structuredClone(sourceStatus) : undefined, + tabWidth, theme, visibleAgentNotes: visibleAgentNotes.length === 0 @@ -263,6 +267,7 @@ export function measureDiffSectionGeometry( expandedKeys: ReadonlySet = EMPTY_EXPANDED_GAP_KEYS, sourceStatus: FileSourceStatus | undefined = undefined, reserveAddNoteColumn = false, + tabWidth = DEFAULT_TAB_WIDTH, ): DiffSectionGeometry { if (file.metadata.hunks.length === 0) { return { @@ -291,7 +296,7 @@ export function measureDiffSectionGeometry( theme.lineNumberBg, theme.lineNumberFg, ].join(":"); - const cacheKey = `${file.id}:${layout}:${showHunkHeaders ? 1 : 0}:${themeCacheKey}:${width}:${showLineNumbers ? 1 : 0}:${wrapLines ? 1 : 0}:${reserveAddNoteColumn ? 1 : 0}${expansionCacheKey(expandedKeys, sourceStatus)}${notesCacheKey(visibleAgentNotes)}`; + const cacheKey = `${file.id}:${layout}:${showHunkHeaders ? 1 : 0}:${themeCacheKey}:${width}:${showLineNumbers ? 1 : 0}:${wrapLines ? 1 : 0}:${reserveAddNoteColumn ? 1 : 0}:tabs:${tabWidth}${expansionCacheKey(expandedKeys, sourceStatus)}${notesCacheKey(visibleAgentNotes)}`; const cacheSlot = sectionGeometryCacheSlot(visibleAgentNotes); const cached = getCachedSectionGeometry(file, cacheSlot, cacheKey); if (cached) { @@ -304,6 +309,7 @@ export function measureDiffSectionGeometry( layout, showHunkHeaders, sourceStatus, + tabWidth, theme, visibleAgentNotes, }); @@ -378,6 +384,7 @@ export function measureDiffSectionGeometry( layout, showHunkHeaders, sourceStatus, + tabWidth, theme, visibleAgentNotes, }); diff --git a/src/ui/diff/diffSectionRowPlan.ts b/src/ui/diff/diffSectionRowPlan.ts index fd0ec827..e5a49b3e 100644 --- a/src/ui/diff/diffSectionRowPlan.ts +++ b/src/ui/diff/diffSectionRowPlan.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TAB_WIDTH } from "../../core/tabWidth"; import type { DiffFile, LayoutMode } from "../../core/types"; import type { VisibleAgentNote } from "../lib/agentAnnotations"; import type { AppTheme } from "../themes"; @@ -27,6 +28,7 @@ export interface BuildDiffSectionRowPlanOptions { showHunkHeaders: boolean; sourceLineSpans?: (line: string | undefined, sourceLineNumber: number) => RenderSpan[]; sourceStatus?: FileSourceStatus | undefined; + tabWidth?: number; theme: AppTheme; visibleAgentNotes?: VisibleAgentNote[]; } @@ -37,10 +39,11 @@ function buildBaseRows( layout: Exclude, highlightedDiff: HighlightedDiffCode | null | undefined, theme: AppTheme, + tabWidth: number, ) { return layout === "split" - ? buildSplitRows(file, highlightedDiff ?? null, theme) - : buildStackRows(file, highlightedDiff ?? null, theme); + ? buildSplitRows(file, highlightedDiff ?? null, theme, tabWidth) + : buildStackRows(file, highlightedDiff ?? null, theme, tabWidth); } /** Build the shared file-level diff plan consumed by rendering and geometry measurement. */ @@ -52,6 +55,7 @@ export function buildDiffSectionRowPlan({ showHunkHeaders, sourceLineSpans, sourceStatus, + tabWidth = DEFAULT_TAB_WIDTH, theme, visibleAgentNotes = EMPTY_VISIBLE_AGENT_NOTES, }: BuildDiffSectionRowPlanOptions): DiffSectionRowPlan { @@ -62,13 +66,14 @@ export function buildDiffSectionRowPlan({ }; } - const baseRows = buildBaseRows(file, layout, highlightedDiff, theme); + const baseRows = buildBaseRows(file, layout, highlightedDiff, theme, tabWidth); const expansionSide = file.metadata.type === "deleted" ? "old" : "new"; const rows = expandCollapsedRows(baseRows, { layout, expandedKeys, sourceLineSpans, sourceStatus, + tabWidth, side: expansionSide, }); diff --git a/src/ui/diff/expandCollapsedRows.test.ts b/src/ui/diff/expandCollapsedRows.test.ts index 2ef84b70..e82293ac 100644 --- a/src/ui/diff/expandCollapsedRows.test.ts +++ b/src/ui/diff/expandCollapsedRows.test.ts @@ -296,6 +296,7 @@ describe("expandCollapsedRows", () => { layout: "stack", expandedKeys: new Set([gapKey("before", 0)]), sourceStatus: { kind: "loaded", text: sourceWithTab }, + tabWidth: 4, side: "new", }); @@ -303,7 +304,7 @@ describe("expandCollapsedRows", () => { if (!inserted || inserted.type !== "stack-line") { throw new Error("expected one stack-line row"); } - expect(inserted.cell.spans[0]?.text.includes("\t")).toBe(false); + expect(inserted.cell.spans[0]?.text).toBe("a b"); }); test("uses caller-provided spans for expanded source lines", () => { diff --git a/src/ui/diff/expandCollapsedRows.ts b/src/ui/diff/expandCollapsedRows.ts index 5cd7cbaa..570a49ca 100644 --- a/src/ui/diff/expandCollapsedRows.ts +++ b/src/ui/diff/expandCollapsedRows.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TAB_WIDTH } from "../../core/tabWidth"; import { sanitizeTerminalLine, sanitizeTerminalSpans } from "../../lib/terminalText"; import { expandDiffTabs } from "./codeColumns"; import type { @@ -20,6 +21,7 @@ export interface ExpandCollapsedRowsOptions { layout: ExpansionLayout; expandedKeys: ReadonlySet; sourceStatus: FileSourceStatus | undefined; + tabWidth?: number; /** Optional syntax-aware span resolver for a zero-based source line. */ sourceLineSpans?: (line: string | undefined, sourceLineNumber: number) => RenderSpan[]; // Whose side's line indices in the source text. Defaults to "new". @@ -84,8 +86,8 @@ function sliceLines(sourceText: string) { return trimmed.length === 0 ? [] : trimmed.split("\n"); } -function spansFor(line: string | undefined): RenderSpan[] { - const text = expandDiffTabs(sanitizeTerminalLine(line ?? "")); +function spansFor(line: string | undefined, tabWidth: number): RenderSpan[] { + const text = expandDiffTabs(sanitizeTerminalLine(line ?? ""), tabWidth); return text.length > 0 ? [{ text }] : []; } @@ -154,7 +156,14 @@ export function expandCollapsedRows( rows: DiffRow[], options: ExpandCollapsedRowsOptions, ): DiffRow[] { - const { layout, expandedKeys, sourceLineSpans, sourceStatus, side = "new" } = options; + const { + layout, + expandedKeys, + sourceLineSpans, + sourceStatus, + tabWidth = DEFAULT_TAB_WIDTH, + side = "new", + } = options; if (expandedKeys.size === 0) { return rows; @@ -223,7 +232,7 @@ export function expandCollapsedRows( const text = sourceLines[sourceLineNumber]; const spans = sourceLineSpans ? sanitizeTerminalSpans(sourceLineSpans(text, sourceLineNumber)) - : spansFor(text); + : spansFor(text, tabWidth); result.push( layout === "split" diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index 644df05f..ce971417 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -147,6 +147,38 @@ describe("Pierre diff rows", () => { expect(addedWordSpan?.bg).toBe(TRANSPARENT_BACKGROUND); }); + test("expands highlighted tabs across syntax span boundaries for each configured width", async () => { + const metadata = parseDiffFromFile( + { name: "tabs.ts", contents: "let a\t= 1;\n", cacheKey: "tabs-before" }, + { name: "tabs.ts", contents: "let a\t= 2;\n", cacheKey: "tabs-after" }, + { context: 3 }, + true, + ); + const file: DiffFile = { + id: "tabs", + path: "tabs.ts", + patch: "", + language: "typescript", + stats: { additions: 1, deletions: 1 }, + metadata, + agent: null, + }; + const theme = resolveTheme("github-dark-default", null); + const highlighted = await loadHighlightedDiff(file, theme); + const addedText = (tabWidth: number) => { + const row = buildStackRows(file, highlighted, theme, tabWidth).find( + (candidate) => candidate.type === "stack-line" && candidate.cell.kind === "addition", + ); + if (!row || row.type !== "stack-line") { + throw new Error("expected one highlighted addition row"); + } + return row.cell.spans.map((span) => span.text).join(""); + }; + + expect(addedText(2)).toBe("let a = 2;"); + expect(addedText(4)).toBe("let a = 2;"); + }); + test("builds stacked rows with separate deletion and addition lines", () => { const file = createDiffFile(); const theme = resolveTheme("github-light-default", null); diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index 453ca6c7..9c747e26 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -8,8 +8,10 @@ import { type FileDiffMetadata, } from "@pierre/diffs"; import { formatHunkHeader } from "../../core/hunkHeader"; +import { DEFAULT_TAB_WIDTH } from "../../core/tabWidth"; import type { DiffFile, DiffLineMoveKind } from "../../core/types"; import { blendHex, hexColorDistance } from "../lib/color"; +import { measureTextWidth } from "../lib/text"; import { sanitizeTerminalLine } from "../../lib/terminalText"; import { TRANSPARENT_BACKGROUND, type AppTheme } from "../themes"; import { expandDiffTabs } from "./codeColumns"; @@ -132,9 +134,9 @@ export type DiffRow = isExpansionRow?: true; }; -/** Replace tabs with fixed spaces so terminal cell widths stay predictable. */ -function tabify(text: string) { - return expandDiffTabs(sanitizeTerminalLine(text)); +/** Expand source tabs before terminal rendering so downstream geometry stays predictable. */ +function tabify(text: string, tabWidth: number, initialColumn = 0) { + return expandDiffTabs(sanitizeTerminalLine(text), tabWidth, initialColumn); } const EMPTY_STYLE_VALUES = new Map(); @@ -272,14 +274,19 @@ function mergeSpan(target: RenderSpan[], next: RenderSpan) { } /** Flatten one highlighted HAST line into terminal-friendly styled text spans. */ -function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emphasisBg: string) { +function flattenHighlightedLine( + node: HastNode | undefined, + theme: AppTheme, + emphasisBg: string, + tabWidth: number, +) { if (!node) { return []; } // The highlighted HAST node is already unique to the content-addressed Shiki theme. Only // post-highlight choices belong in the inner key; syntax identity comes from the WeakMap key. - const cacheKey = `${theme.appearance}:${emphasisBg}`; + const cacheKey = `${theme.appearance}:${emphasisBg}:${tabWidth}`; const cachedByTheme = flattenedHighlightedLineCache.get(node); const cached = cachedByTheme?.get(cacheKey); if (cached) { @@ -290,6 +297,7 @@ function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emp // we skip the full recursive walk and return the already-flattened terminal spans. const spans: RenderSpan[] = []; + let codeColumn = 0; const colorVariable = theme.appearance === "light" ? "--diffs-token-light" : "--diffs-token-dark"; const visit = (current: HastNode | undefined, inherited: Pick) => { @@ -301,11 +309,13 @@ function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emp // Pierre injects a "\n" placeholder into empty line nodes so they aren't childless. // Strip it the same way cleanDiffLine does for the unhighlighted path, or the literal // newline ends up in the span text and breaks terminal row rendering. + const text = tabify(cleanLastNewline(current.value), tabWidth, codeColumn); mergeSpan(spans, { - text: tabify(cleanLastNewline(current.value)), + text, fg: inherited.fg, bg: inherited.bg, }); + codeColumn += measureTextWidth(text); return; } @@ -335,8 +345,8 @@ function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emp } /** Normalize one raw diff line before rendering. */ -function cleanDiffLine(line: string | undefined) { - return tabify(cleanLastNewline(line ?? "")); +function cleanDiffLine(line: string | undefined, tabWidth: number) { + return tabify(cleanLastNewline(line ?? ""), tabWidth); } /** Build the normalized render model for one split-view cell. */ @@ -346,6 +356,7 @@ function makeSplitCell( rawLine: string | undefined, highlightedLine: HastNode | undefined, theme: AppTheme, + tabWidth: number, moveKind?: DiffLineMoveKind, ) { if (kind === "empty") { @@ -361,13 +372,18 @@ function makeSplitCell( // produced nothing. That keeps newline stripping + tab expansion off the hot path. let spans: RenderSpan[]; if (highlightedLine === undefined) { - const fallbackText = cleanDiffLine(rawLine); + const fallbackText = cleanDiffLine(rawLine, tabWidth); spans = fallbackText.length > 0 ? [{ text: fallbackText }] : []; } else { - spans = flattenHighlightedLine(highlightedLine, theme, wordDiffHighlightBg(kind, theme)); + spans = flattenHighlightedLine( + highlightedLine, + theme, + wordDiffHighlightBg(kind, theme), + tabWidth, + ); if (spans.length === 0) { - const fallbackText = cleanDiffLine(rawLine); + const fallbackText = cleanDiffLine(rawLine, tabWidth); spans = fallbackText.length > 0 ? [{ text: fallbackText }] : []; } } @@ -389,19 +405,25 @@ function makeStackCell( rawLine: string | undefined, highlightedLine: HastNode | undefined, theme: AppTheme, + tabWidth: number, moveKind?: DiffLineMoveKind, ) { // Same lazy-fallback strategy as split cells: only normalize the raw source line when we really // need the plain-text fallback, not when highlighted spans are already ready to reuse. let spans: RenderSpan[]; if (highlightedLine === undefined) { - const fallbackText = cleanDiffLine(rawLine); + const fallbackText = cleanDiffLine(rawLine, tabWidth); spans = fallbackText.length > 0 ? [{ text: fallbackText }] : []; } else { - spans = flattenHighlightedLine(highlightedLine, theme, wordDiffHighlightBg(kind, theme)); + spans = flattenHighlightedLine( + highlightedLine, + theme, + wordDiffHighlightBg(kind, theme), + tabWidth, + ); if (spans.length === 0) { - const fallbackText = cleanDiffLine(rawLine); + const fallbackText = cleanDiffLine(rawLine, tabWidth); spans = fallbackText.length > 0 ? [{ text: fallbackText }] : []; } } @@ -649,18 +671,19 @@ export function spansForHighlightedSourceLine( rawLine: string | undefined, highlightedLine: HastNode | undefined, theme: AppTheme, + tabWidth = DEFAULT_TAB_WIDTH, ): RenderSpan[] { if (highlightedLine === undefined) { - const fallbackText = cleanDiffLine(rawLine); + const fallbackText = cleanDiffLine(rawLine, tabWidth); return fallbackText.length > 0 ? [{ text: fallbackText }] : []; } - const spans = flattenHighlightedLine(highlightedLine, theme, theme.contextContentBg); + const spans = flattenHighlightedLine(highlightedLine, theme, theme.contextContentBg, tabWidth); if (spans.length > 0) { return spans; } - const fallbackText = cleanDiffLine(rawLine); + const fallbackText = cleanDiffLine(rawLine, tabWidth); return fallbackText.length > 0 ? [{ text: fallbackText }] : []; } @@ -669,6 +692,7 @@ export function buildSplitRows( file: DiffFile, highlighted: HighlightedDiffCode | null, theme: AppTheme, + tabWidth = DEFAULT_TAB_WIDTH, ): DiffRow[] { const rows: DiffRow[] = []; const deletionLines = highlighted?.deletionLines ?? []; @@ -714,6 +738,7 @@ export function buildSplitRows( file.metadata.deletionLines[deletionLineIndex + offset], deletionLines[deletionLineIndex + offset], theme, + tabWidth, ), right: makeSplitCell( "context", @@ -721,6 +746,7 @@ export function buildSplitRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + tabWidth, ), }); } @@ -750,9 +776,10 @@ export function buildSplitRows( file.metadata.deletionLines[deletionLineIndex + offset], deletionLines[deletionLineIndex + offset], theme, + tabWidth, file.lineMoveKinds?.deletionLines[deletionLineIndex + offset], ) - : makeSplitCell("empty", undefined, undefined, undefined, theme), + : makeSplitCell("empty", undefined, undefined, undefined, theme, tabWidth), right: hasAddition ? makeSplitCell( "addition", @@ -760,9 +787,10 @@ export function buildSplitRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + tabWidth, file.lineMoveKinds?.additionLines[additionLineIndex + offset], ) - : makeSplitCell("empty", undefined, undefined, undefined, theme), + : makeSplitCell("empty", undefined, undefined, undefined, theme, tabWidth), }); } @@ -795,6 +823,7 @@ export function buildStackRows( file: DiffFile, highlighted: HighlightedDiffCode | null, theme: AppTheme, + tabWidth = DEFAULT_TAB_WIDTH, ): DiffRow[] { const rows: DiffRow[] = []; const deletionLines = highlighted?.deletionLines ?? []; @@ -841,6 +870,7 @@ export function buildStackRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + tabWidth, ), }); } @@ -865,6 +895,7 @@ export function buildStackRows( file.metadata.deletionLines[deletionLineIndex + offset], deletionLines[deletionLineIndex + offset], theme, + tabWidth, file.lineMoveKinds?.deletionLines[deletionLineIndex + offset], ), }); @@ -883,6 +914,7 @@ export function buildStackRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + tabWidth, file.lineMoveKinds?.additionLines[additionLineIndex + offset], ), }); diff --git a/src/ui/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts index c78778f6..81e1bc12 100644 --- a/src/ui/staticDiffPager.test.ts +++ b/src/ui/staticDiffPager.test.ts @@ -60,6 +60,17 @@ describe("static diff pager", () => { expect(plain).toContain("▌+ const value = 2;"); }); + test("honors configured tab stops", async () => { + const patchText = + "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-a\tb\n+a\tc\n"; + + const width4 = stripAnsi(await renderStaticDiffPager(patchText, { tabWidth: 4 })); + const width8 = stripAnsi(await renderStaticDiffPager(patchText, { tabWidth: 8 })); + + expect(width4).toContain("a c"); + expect(width8).toContain("a c"); + }); + test("honors explicit split mode in static pager output", async () => { const patchText = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"; diff --git a/src/ui/staticDiffPager.ts b/src/ui/staticDiffPager.ts index e4250756..dfadb333 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -15,6 +15,7 @@ * text so pager pipelines keep working. */ import { loadAppBootstrap } from "../core/loaders"; +import { DEFAULT_TAB_WIDTH } from "../core/tabWidth"; import type { CommonOptions, CustomThemeConfig, DiffFile } from "../core/types"; import { buildSplitRows, @@ -321,10 +322,11 @@ async function renderStaticFile( const highlighted = file.isBinary || file.isTooLarge ? null : await loadHighlightedDiff(file, theme); const layout = resolveStaticLayout(options); + const tabWidth = options.tabWidth ?? DEFAULT_TAB_WIDTH; const rows = layout === "split" - ? buildSplitRows(file, highlighted, theme) - : buildStackRows(file, highlighted, theme); + ? buildSplitRows(file, highlighted, theme, tabWidth) + : buildStackRows(file, highlighted, theme, tabWidth); const lineNumberWidth = maxLineNumberWidth(file, rows); const stats = `${colorText(`+${file.stats.additions}${file.statsTruncated ? "+" : ""}`, theme.badgeAdded)} ${colorText(`-${file.stats.deletions}`, theme.badgeRemoved)}`; const status = colorText(`${fileStatusLabel(file)}${fileModeText(file)}`, theme.muted); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index acf85f19..32b9fe62 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -240,6 +240,17 @@ export function createPtyHarness() { return { dir, before, after }; } + function createTabbedFilePair() { + const dir = makeTempDir("hunk-tuistory-tabs-"); + const before = join(dir, "before.txt"); + const after = join(dir, "after.txt"); + + writeText(before, "a\tbefore\n"); + writeText(after, "a\tafter\n"); + + return { dir, before, after }; + } + function createDeletionOnlyFilePair() { const dir = makeTempDir("hunk-tuistory-deletion-"); const before = join(dir, "before.ts"); @@ -781,6 +792,7 @@ export function createPtyHarness() { createPinnedHeaderRepoFixture, createScrollableFilePair, createSidebarJumpRepoFixture, + createTabbedFilePair, createTwoFileRepoFixture, createWatchFilePair, createWideCharacterFilePair, diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index d0d94a58..561c2e31 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -83,6 +83,31 @@ describe("PTY layout", () => { } }); + test("the CLI tab width reaches interactive app rendering", async () => { + const fixture = harness.createTabbedFilePair(); + const session = await harness.launchHunk({ + args: ["diff", fixture.before, fixture.after, "--mode", "stack", "-x8"], + cols: 100, + rows: 12, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { + timeout: 15_000, + }); + const snapshot = await harness.waitForSnapshot( + session, + (text) => /a {7}after/.test(text), + 5_000, + ); + const addedLine = snapshot.split("\n").find((line) => /a {7}after/.test(line)); + + expect(addedLine).toMatch(/a {7}after/); + } finally { + session.close(); + } + }); + test("real PTY sessions can toggle wrapped lines on and off", async () => { const fixture = harness.createLongWrapFilePair(); const session = await harness.launchHunk({