From 51d5ae57618c8ee2e62a444e8a6759aaa505de94 Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Sat, 15 Aug 2026 01:05:44 +0200 Subject: [PATCH] feat(cli): add --color-only filter mode for git diffFilter Git reaches interactive staging (`git add -p`, `git stash -p`, `git reset -p`) through `interactive.diffFilter`: a non-interactive text transform whose stdout Git re-parses to drive its own prompts, so any interactive Hunk command hangs the flow. `hunk --color-only` (also `hunk pager --color-only`) reads a unified diff from stdin, applies Hunk's diff coloring and Pierre syntax highlighting, writes ANSI output to stdout, and exits -- no TTY, no alternate screen. The raw input lines stay the source of truth so the output remains a structurally valid diff (byte-identical after ANSI stripping, tabs included); the parsed model and highlight rows only pick colors, and any line that fails to align falls back to whole-line theme colors. Non-diff input passes through unchanged so a misconfigured filter can never corrupt a Git pipeline. Closes #575. Co-Authored-By: Claude --- .changeset/lucky-moles-color.md | 5 + src/app/startup.test.ts | 66 ++++++ src/app/startup.ts | 62 ++++-- src/core/cli.test.ts | 41 ++++ src/core/cli.ts | 10 +- src/core/types.ts | 2 + src/main.tsx | 11 + src/ui/colorOnlyFilter.test.ts | 149 ++++++++++++++ src/ui/colorOnlyFilter.ts | 354 ++++++++++++++++++++++++++++++++ 9 files changed, 680 insertions(+), 20 deletions(-) create mode 100644 .changeset/lucky-moles-color.md create mode 100644 src/ui/colorOnlyFilter.test.ts create mode 100644 src/ui/colorOnlyFilter.ts diff --git a/.changeset/lucky-moles-color.md b/.changeset/lucky-moles-color.md new file mode 100644 index 000000000..21f211440 --- /dev/null +++ b/.changeset/lucky-moles-color.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add `hunk --color-only` to color a unified diff piped on stdin without the interactive UI, for `git interactive.diffFilter` (`git add -p`) and fzf-style diff previews. diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index bdf35d560..e0696fe5f 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -287,6 +287,72 @@ describe("startup planning", () => { expect(loaded).toBe(false); }); + test("routes color-only pager stdin through the filter plan", async () => { + let loaded = false; + const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n"; + const customThemes = [{ id: "custom", base: "github-light-default", text: "#123456" }]; + + const plan = await prepareStartupPlan(["bun", "hunk", "--color-only"], { + parseCliImpl: async () => ({ kind: "pager", colorOnly: true, options: { theme: "custom" } }), + readStdinText: async () => patchText, + looksLikePatchInputImpl: () => true, + stdoutIsTTY: false, + env: { TERM: "xterm-256color" }, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => + createTestConfigResolution( + { + ...input, + options: { ...input.options, theme: "custom" }, + }, + { customThemes }, + ), + loadAppBootstrapImpl: async () => { + loaded = true; + throw new Error("unreachable"); + }, + }); + + expect(plan).toEqual({ + kind: "color-only", + text: patchText, + options: { theme: "custom", pager: true }, + customThemes, + }); + expect(loaded).toBe(false); + }); + + test("keeps non-diff stdin on the color-only plan so the filter can pass it through", async () => { + let loaded = false; + const text = "* main\n feature/demo\n"; + + const plan = await prepareStartupPlan(["bun", "hunk", "--color-only"], { + parseCliImpl: async () => ({ kind: "pager", colorOnly: true, options: {} }), + readStdinText: async () => text, + looksLikePatchInputImpl: () => false, + stdoutIsTTY: false, + env: { TERM: "xterm-256color" }, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async () => { + loaded = true; + throw new Error("unreachable"); + }, + }); + + expect(plan.kind).toBe("color-only"); + expect(loaded).toBe(false); + }); + + test("rejects color-only filter mode when stdin is an interactive terminal", async () => { + await expect( + prepareStartupPlan(["bun", "hunk", "--color-only"], { + parseCliImpl: async () => ({ kind: "pager", colorOnly: true, options: {} }), + stdinIsTTY: true, + }), + ).rejects.toThrow("reads a unified diff from standard input"); + }); + test("routes diff-like pager stdin to static output when no controlling terminal is available", async () => { let loaded = false; const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n"; diff --git a/src/app/startup.ts b/src/app/startup.ts index 4b927e1e2..373844c94 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -58,6 +58,12 @@ export type StartupPlan = options: CliInput["options"]; customThemes?: AppBootstrap["customThemes"]; } + | { + kind: "color-only"; + text: string; + options: CliInput["options"]; + customThemes?: AppBootstrap["customThemes"]; + } | { kind: "markup-render"; input: MarkupRenderCommandInput; @@ -176,37 +182,55 @@ export async function prepareStartupPlan( } if (parsedCliInput.kind === "pager") { + const wantsColorOnly = parsedCliInput.colorOnly === true; + if (wantsColorOnly && stdinIsTTY) { + // Reading an interactive stdin would hang the filter; Git always pipes the diff. + throw new HunkUserError("`hunk --color-only` reads a unified diff from standard input.", [ + "Use it as a Git diff filter: `git config interactive.diffFilter 'hunk --color-only'`.", + ]); + } + const stdinText = await readStdinText(); const pagerOptions = parsedCliInput.options; const capturedPagerHost = isCapturedPagerHost(env); - const staticPagerPlan = () => { - const staticPatchInput: CliInput = { - kind: "patch", - file: "-", - text: stdinText, - options: { - ...pagerOptions, - pager: true, - }, - }; - const configuredStatic = resolveConfiguredCliInputImpl( - resolveRuntimeCliInputImpl(staticPatchInput), + // Both non-interactive pager plans resolve the piped patch through the same config layers, + // so option defaults and custom themes apply identically. Extensions never load on these + // paths, so config themes are the whole theme set here. + const resolvePipedPatchPlan = (kind: "static-diff-pager" | "color-only") => { + const configured = resolveConfiguredCliInputImpl( + resolveRuntimeCliInputImpl({ + kind: "patch", + file: "-", + text: stdinText, + options: { + ...pagerOptions, + pager: true, + }, + }), { vcsCatalog: baseVcsCatalog, }, ); - const staticPlan = { - kind: "static-diff-pager" as const, + const plan = { + kind, text: stdinText, - options: configuredStatic.input.options, + options: configured.input.options, }; - // Extensions never load on the static pager path, so config themes are the whole set here. - return configuredStatic.customThemes.length > 0 - ? { ...staticPlan, customThemes: configuredStatic.customThemes } - : staticPlan; + return configured.customThemes.length > 0 + ? { ...plan, customThemes: configured.customThemes } + : plan; }; + const staticPagerPlan = () => resolvePipedPatchPlan("static-diff-pager"); + + // Filter mode: color whatever stdin holds without touching the interactive pager paths. + // The renderer itself passes non-diff input through unchanged, as `interactive.diffFilter` + // requires, so both diff-like and plain stdin share this one plan. + if (wantsColorOnly) { + return resolvePipedPatchPlan("color-only"); + } + // Captured hosts render Hunk's stdout in their own panel, so passed-through text keeps // the color Git already put in it. const passthroughPlan = { diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 39a70af36..9c757d987 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -287,6 +287,42 @@ describe("parseCli", () => { }); }); + test("parses bare --color-only as pager filter mode", async () => { + const parsed = await parseCli(["bun", "hunk", "--color-only"]); + + expect(parsed).toMatchObject({ + kind: "pager", + colorOnly: true, + }); + }); + + test("parses --color-only with theme options", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "--color-only", + "--theme", + "github-light-default", + ]); + + expect(parsed).toMatchObject({ + kind: "pager", + colorOnly: true, + options: { + theme: "github-light-default", + }, + }); + }); + + test("parses pager --color-only explicitly", async () => { + const parsed = await parseCli(["bun", "hunk", "pager", "--color-only"]); + + expect(parsed).toMatchObject({ + kind: "pager", + colorOnly: true, + }); + }); + test("prints the bundled skill path for hunk skill path", async () => { const parsed = await parseCli(["bun", "hunk", "skill", "path"]); @@ -1116,6 +1152,11 @@ describe("parseCli command help text", () => { expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs"); }); + test("documents the --color-only filter mode in help output", async () => { + expect(await expectHelp([])).toContain("hunk --color-only"); + expect(await expectHelp(["pager", "--help"])).toContain("--color-only"); + }); + test("renders the stash command overview and the stash show command help", async () => { const overview = await expectHelp(["stash"]); expect(overview).toContain("Usage: hunk stash show [ref] [options]"); diff --git a/src/core/cli.ts b/src/core/cli.ts index e4ccca782..4c0297cd1 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -416,6 +416,7 @@ function renderCliHelp() { " hunk stash show [ref] review a stash entry (git only)", " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", + " hunk --color-only color a unified diff piped on stdin (git interactive.diffFilter)", " hunk difftool [path] review Git difftool file pairs", " hunk session inspect or control a live Hunk session", " hunk markup render ( | -) preview experimental STML note markup", @@ -782,7 +783,10 @@ async function parsePagerCommand( tokens: string[], argv: string[], ): Promise { - const command = createCliReferenceCommand("pager"); + const command = createCliReferenceCommand("pager").option( + "--color-only", + "color the piped diff without reformatting it (for git interactive.diffFilter)", + ); let parsedOptions: Record = {}; command.action((options: Record) => { @@ -797,6 +801,7 @@ async function parsePagerCommand( return { kind: "pager", + colorOnly: parsedOptions.colorOnly ? true : undefined, options: buildCommonOptions(parsedOptions, argv), }; } @@ -1638,6 +1643,9 @@ export async function parseCli(argv: string[]): Promise { return parsePatchCommand(rest, argv); case "pager": return parsePagerCommand(rest, argv); + // Bare `hunk --color-only` is the form Git stores in `interactive.diffFilter`. + case "--color-only": + return parsePagerCommand(["--color-only", ...rest], argv); case "difftool": return parseDifftoolCommand(rest, argv); case "stash": diff --git a/src/core/types.ts b/src/core/types.ts index f19510db4..a0f9f8308 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -157,6 +157,8 @@ export interface HelpCommandInput { export interface PagerCommandInput { kind: "pager"; options: CommonOptions; + /** Filter mode: color stdin without reformatting it, for `git interactive.diffFilter`. */ + colorOnly?: boolean; } export interface DaemonServeCommandInput { diff --git a/src/main.tsx b/src/main.tsx index fe25d5cf4..42714aa03 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -95,6 +95,17 @@ async function main() { process.exit(0); } + if (startupPlan.kind === "color-only") { + const { renderColorOnlyDiff } = await import("./ui/colorOnlyFilter"); + process.stdout.write( + await renderColorOnlyDiff(startupPlan.text, startupPlan.options, { + customThemes: startupPlan.customThemes, + stderr: process.stderr, + }), + ); + process.exit(0); + } + if (startupPlan.kind !== "app") { throw new Error("Unreachable startup plan."); } diff --git a/src/ui/colorOnlyFilter.test.ts b/src/ui/colorOnlyFilter.test.ts new file mode 100644 index 000000000..eea5221eb --- /dev/null +++ b/src/ui/colorOnlyFilter.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { renderColorOnlyDiff } from "./colorOnlyFilter"; + +function stripAnsi(text: string) { + return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +const PATCH = [ + "diff --git a/example.ts b/example.ts", + "index 1234567..89abcde 100644", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1,3 +1,3 @@", + " const keep = 1;", + '-const old = "gone";', + '+const fresh = "new";', + "\\ No newline at end of file", +].join("\n"); + +describe("color-only filter", () => { + test("colors diff lines while preserving the exact input structure", async () => { + const output = await renderColorOnlyDiff(`${PATCH}\n`); + + // A diffFilter consumer re-parses stdout after stripping ANSI, so structure is sacred. + expect(stripAnsi(output)).toBe(`${PATCH}\n`); + expect(output).toContain("\x1b[38;2;"); // header / syntax foreground colors + expect(output).toContain("\x1b[48;2;"); // added / removed backgrounds + expect(output).not.toContain("\x1b[?1049h"); // never enters the alternate screen + }); + + test("applies syntax highlighting to changed lines", async () => { + const output = await renderColorOnlyDiff(`${PATCH}\n`); + const addedLine = output + .split("\n") + .find((line) => stripAnsi(line) === '+const fresh = "new";'); + + expect(addedLine).toBeDefined(); + // The sign color plus at least keyword/identifier/string spans all carry their own SGR run. + expect(addedLine!.match(/\x1b\[/g)?.length ?? 0).toBeGreaterThanOrEqual(4); + }); + + test("passes non-diff input through byte-identical", async () => { + const text = "just some output\nthat is not a diff\n"; + + expect(await renderColorOnlyDiff(text)).toBe(text); + expect(await renderColorOnlyDiff("")).toBe(""); + }); + + test("recolors ANSI-colored diff input", async () => { + const colored = PATCH.replace(/\+const fresh/g, "\x1b[32m+const fresh\x1b[0m"); + + const output = await renderColorOnlyDiff(`${colored}\n`); + + expect(stripAnsi(output)).toBe(`${PATCH}\n`); + expect(output).not.toContain("\x1b[32m"); + }); + + test("keeps tab-indented content bytes intact", async () => { + const tabbedPatch = [ + "diff --git a/makefile b/makefile", + "--- a/makefile", + "+++ b/makefile", + "@@ -1 +1 @@", + "-\tall:", + "+\tall: build", + ].join("\n"); + + const output = await renderColorOnlyDiff(`${tabbedPatch}\n`); + + // Highlight spans expand tabs, so those lines fall back to whole-line color — never rewrite. + expect(stripAnsi(output)).toBe(`${tabbedPatch}\n`); + expect(output).toContain("\tall: build"); + }); + + test("treats +++ and --- prefixed content inside hunks as content lines", async () => { + const tricky = [ + "diff --git a/notes.md b/notes.md", + "--- a/notes.md", + "+++ b/notes.md", + "@@ -1,2 +1,2 @@", + "--- old heading", + "+-+ new heading", + " +++ nested", + ].join("\n"); + + const output = await renderColorOnlyDiff(`${tricky}\n`); + + expect(stripAnsi(output)).toBe(`${tricky}\n`); + expect(output).toContain("\x1b[48;2;"); // the tricky lines still count as added/removed rows + }); + + test("colors a headerless patch that starts directly at a hunk header", async () => { + const headerless = ["@@ -1 +1 @@", "-old line", "+new line"].join("\n"); + + const output = await renderColorOnlyDiff(`${headerless}\n`); + + expect(stripAnsi(output)).toBe(`${headerless}\n`); + expect(output).toContain("\x1b[48;2;23;51;34m"); // added background band present + }); + + test("colors every file of a multi-file diff", async () => { + const multi = [ + "diff --git a/one.ts b/one.ts", + "--- a/one.ts", + "+++ b/one.ts", + "@@ -1 +1 @@", + "-const one = 1;", + "+const one = 11;", + "diff --git a/two.ts b/two.ts", + "--- a/two.ts", + "+++ b/two.ts", + "@@ -1 +1 @@", + "-const two = 2;", + "+const two = 22;", + ].join("\n"); + + const output = await renderColorOnlyDiff(`${multi}\n`); + + expect(stripAnsi(output)).toBe(`${multi}\n`); + const changed = output.split("\n").filter((line) => line.includes("\x1b[48;2;")); + expect(changed.length).toBeGreaterThanOrEqual(4); + }); + + test("falls back to whole-line colors when the diff model fails to load", async () => { + const warnings: string[] = []; + const output = await renderColorOnlyDiff( + `${PATCH}\n`, + {}, + { + stderr: { write: (text: string) => (warnings.push(text), true) }, + loadAppBootstrapImpl: async () => { + throw new Error("boom"); + }, + }, + ); + + expect(stripAnsi(output)).toBe(`${PATCH}\n`); + expect(output).toContain("\x1b[48;2;"); // added / removed backgrounds survive the fallback + expect(warnings.join("\n")).toContain("falling back"); + }); + + test("keeps a trailing carriage return on CRLF input", async () => { + const crlfPatch = PATCH.replace(/\n/g, "\r\n"); + + const output = await renderColorOnlyDiff(crlfPatch); + + expect(stripAnsi(output)).toBe(crlfPatch); + }); +}); diff --git a/src/ui/colorOnlyFilter.ts b/src/ui/colorOnlyFilter.ts new file mode 100644 index 000000000..c9e0ec22b --- /dev/null +++ b/src/ui/colorOnlyFilter.ts @@ -0,0 +1,354 @@ +/** + * Non-interactive `hunk --color-only` filter for Git's `interactive.diffFilter`. + * + * Git commands such as `git add -p` pipe a diff through `interactive.diffFilter` and re-parse + * the filter's stdout to drive their own prompts, so unlike the static pager this adapter must + * keep the unified diff structure intact: after stripping ANSI escapes, every emitted line has + * to match the input line exactly, in the same order. The raw input lines therefore stay the + * source of truth here, and Hunk's normal parse/highlight stack (`loadAppBootstrap`, Pierre + * metadata, `loadHighlightedDiff`, `buildStackRows`) only picks colors — never layout. Keep it a + * thin adapter: no second diff parser, no row chrome, no line-number gutters, no tab expansion. + * When a line fails to align with the parsed model it keeps its whole-line theme color so Git + * pipelines keep working, and non-diff input passes through unchanged. + */ +import { loadAppBootstrap } from "../core/loaders"; +import { looksLikePatchInput } from "../core/pager"; +import { stripTerminalControl } from "../core/patch/normalize"; +import type { CommonOptions, DiffFile, NamedCustomThemeConfig } from "../core/types"; +import { buildStackRows, loadHighlightedDiff, type DiffRow } from "./diff/pierre"; +import { stackCellPalette } from "./diff/rowStyle"; +import { resolveTheme, withTransparentSurfaces, type AppTheme } from "./themes"; + +const RESET = "\x1b[0m"; + +/** Convert a six-digit hex color into one ANSI truecolor code. */ +function ansiColor(kind: "fg" | "bg", hex: string | undefined) { + const normalized = hex?.replace(/^#/, ""); + if (!normalized || !/^[0-9a-f]{6}$/i.test(normalized)) { + return ""; + } + + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + return `\x1b[${kind === "fg" ? 38 : 48};2;${red};${green};${blue}m`; +} + +/** + * Wrap one literal text fragment in ANSI colors. + * + * Unlike the static pager's variant this never sanitizes: filter output is re-parsed by Git, so + * content bytes are data. Escape sequences were already stripped from the whole input up front. + */ +function colorText(text: string, fg?: string, bg?: string) { + if (!text) { + return ""; + } + + const prefix = `${ansiColor("fg", fg)}${ansiColor("bg", bg)}`; + return prefix ? `${prefix}${text}${RESET}` : text; +} + +/** Roles one input line can play in a unified diff, resolved by a small order-aware state machine. */ +type FilterLineKind = + | "file-header" + | "file-meta" + | "old-file-header" + | "new-file-header" + | "hunk-header" + | "context" + | "deletion" + | "addition" + | "no-newline" + | "plain"; + +type WalkSection = "outside" | "file-header" | "hunks"; + +const FILE_META_PREFIXES = [ + "index ", + "old mode ", + "new mode ", + "similarity index ", + "dissimilarity index ", + "rename from ", + "rename to ", + "copy from ", + "copy to ", + "new file mode ", + "deleted file mode ", + "old tree ", + "new tree ", +]; + +/** + * Classify one patch line and advance the walk state. + * + * `+++`/`---` only introduce file paths inside a `diff --git` header block; inside a hunk body + * they are ordinary content whose text starts with `+`/`-`, which is why the section matters. + */ +function classifyDiffLine(line: string, section: { value: WalkSection }): FilterLineKind { + if (line.startsWith("diff --git ")) { + section.value = "file-header"; + return "file-header"; + } + + // A hunk header opens a hunk body wherever it appears; `looksLikePatchInput` only admits + // text that carries diff markers somewhere, so a leading `@@` is never free prose. + if (line.startsWith("@@")) { + section.value = "hunks"; + return "hunk-header"; + } + + if (section.value === "file-header") { + if (line.startsWith("--- ")) { + return "old-file-header"; + } + + if (line.startsWith("+++ ")) { + return "new-file-header"; + } + + if ( + line.startsWith("Binary files ") || + line.startsWith("GIT binary patch") || + FILE_META_PREFIXES.some((prefix) => line.startsWith(prefix)) + ) { + return "file-meta"; + } + + return "plain"; + } + + if (section.value === "hunks") { + if (line.startsWith("-")) { + return "deletion"; + } + + if (line.startsWith("+")) { + return "addition"; + } + + if (line.startsWith(" ")) { + return "context"; + } + + if (line.startsWith("\\")) { + return "no-newline"; + } + + return "plain"; + } + + if (line.startsWith("--- ")) { + section.value = "file-header"; + return "old-file-header"; + } + + return "plain"; +} + +/** Content-line kinds that map one-to-one onto the model's stack cell kinds. */ +type ContentKind = "context" | "deletion" | "addition"; + +/** Per-file alignment state between raw patch lines and Pierre's stack rows. */ +interface HighlightGuide { + rows: DiffRow[]; + cursor: number; + /** False once the raw walk and the model disagree; re-enabled at the next hunk header. */ + spansEnabled: boolean; +} + +/** Preload one file's stack rows so the line walk can consume them synchronously. */ +async function buildHighlightGuide(file: DiffFile, theme: AppTheme): Promise { + const highlighted = + file.isBinary || file.isTooLarge ? null : await loadHighlightedDiff(file, theme); + return { + rows: buildStackRows(file, highlighted, theme), + cursor: 0, + spansEnabled: true, + }; +} + +/** + * Move the guide to the model hunk the next raw `@@` line opens. + * + * Stack rows left unconsumed mean the raw walk skipped lines (or classified them as plain), so + * they are dropped rather than allowed to misalign the hunk that follows. + */ +function consumeHunkHeader(guide: HighlightGuide) { + const headerIndex = guide.rows.findIndex( + (row, index) => index >= guide.cursor && row.type === "hunk-header", + ); + if (headerIndex === -1) { + guide.spansEnabled = false; + return; + } + + guide.cursor = headerIndex + 1; +} + +/** + * Take the next stack row for one raw content line, or null when the model has none left in + * this hunk. Collapsed gap rows have no raw counterpart and are skipped. The row is consumed + * only when its kind matches the raw line, so one mismatched row cannot cascade. + */ +function nextStackRow( + guide: HighlightGuide, + kind: ContentKind, +): Extract | null { + while (guide.cursor < guide.rows.length) { + const row = guide.rows[guide.cursor]!; + if (row.type === "collapsed") { + guide.cursor += 1; + continue; + } + + if (row.type !== "stack-line" || row.cell.kind !== kind) { + return null; + } + + guide.cursor += 1; + return row; + } + + return null; +} + +/** Render one context/added/removed line, using highlight spans only when their text matches. */ +function renderContentLine( + line: string, + kind: ContentKind, + guide: HighlightGuide | null, + theme: AppTheme, +) { + const sign = line[0]!; + const content = line.slice(1); + const row = guide && guide.spansEnabled ? nextStackRow(guide, kind) : null; + // Spans are trusted only when the model reconstructed this exact line; tab-expanded or + // normalized model text would rewrite content bytes, so those lines keep whole-line color. + const aligned = row !== null && row.cell.spans.map((span) => span.text).join("") === content; + const palette = stackCellPalette(kind, theme, aligned ? row.cell.moveKind : undefined); + const background = kind === "context" ? undefined : palette.contentBg; + const signPart = + kind === "context" ? colorText(sign) : colorText(sign, palette.signColor, background); + const body = aligned + ? row.cell.spans.map((span) => colorText(span.text, span.fg, span.bg ?? background)).join("") + : colorText(content, undefined, background); + + return `${signPart}${body}`; +} + +/** Colorize one classified line with whole-line theme colors. */ +function renderStructuralLine(line: string, kind: FilterLineKind, theme: AppTheme): string { + switch (kind) { + case "file-header": + return colorText(line, theme.text); + case "file-meta": + case "no-newline": + return colorText(line, theme.muted); + case "old-file-header": + return colorText(line, theme.badgeRemoved); + case "new-file-header": + return colorText(line, theme.badgeAdded); + case "hunk-header": + return colorText(line, theme.badgeNeutral, theme.panelAlt); + default: + return line; + } +} + +function fallbackMessage(error: unknown) { + if (error instanceof Error && error.message) { + return error.message; + } + + return String(error || "unknown error"); +} + +function warnFallback(deps: ColorOnlyFilterDeps, reason: string) { + deps.stderr?.write( + `hunk: --color-only highlight failed; falling back to plain diff colors (${reason}).\n`, + ); +} + +export interface ColorOnlyFilterDeps { + customThemes?: readonly NamedCustomThemeConfig[]; + stderr?: Pick; + loadAppBootstrapImpl?: typeof loadAppBootstrap; +} + +/** Colorize escape-stripped patch text line by line, guided by the parsed model when available. */ +function colorizeLines( + text: string, + theme: AppTheme, + guides: readonly (HighlightGuide | null)[] | null, +) { + const section: { value: WalkSection } = { value: "outside" }; + let fileIndex = 0; + let guide: HighlightGuide | null = guides?.[0] ?? null; + + return text + .split("\n") + .map((line) => { + // Preserve a carriage return from CRLF input after the colored segment. + const carriage = line.endsWith("\r") ? "\r" : ""; + const base = carriage ? line.slice(0, -1) : line; + const kind = classifyDiffLine(base, section); + + if (kind === "file-header") { + fileIndex += 1; + guide = guides?.[fileIndex - 1] ?? null; + } + + if (kind === "hunk-header" && guide) { + consumeHunkHeader(guide); + } + + let rendered: string; + if (kind === "context" || kind === "deletion" || kind === "addition") { + rendered = renderContentLine(base, kind, guide, theme); + } else { + rendered = renderStructuralLine(base, kind, theme); + } + + return `${rendered}${carriage}`; + }) + .join("\n"); +} + +/** Color a unified diff from stdin for `git interactive.diffFilter` without the interactive UI. */ +export async function renderColorOnlyDiff( + text: string, + options: CommonOptions = {}, + deps: ColorOnlyFilterDeps = {}, +) { + // A diffFilter must never mangle what it cannot colorize: non-diff input leaves unchanged. + if (!looksLikePatchInput(text)) { + return text; + } + + const resolvedTheme = resolveTheme(options.theme, null, deps.customThemes); + const theme = options.transparentBackground + ? withTransparentSurfaces(resolvedTheme) + : resolvedTheme; + let guides: (HighlightGuide | null)[] | null = null; + + try { + const bootstrap = await (deps.loadAppBootstrapImpl ?? loadAppBootstrap)({ + kind: "patch", + file: "-", + text, + options: { + ...options, + pager: true, + }, + }); + guides = await Promise.all( + bootstrap.changeset.files.map((file) => buildHighlightGuide(file, theme)), + ); + } catch (error) { + warnFallback(deps, fallbackMessage(error)); + } + + return colorizeLines(stripTerminalControl(text), theme, guides); +}