Skip to content

Commit e974167

Browse files
authored
fix(pager): render static diffs in captured pagers (#271)
1 parent 7b7794a commit e974167

9 files changed

Lines changed: 684 additions & 132 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All notable user-visible changes to Hunk are documented in this file.
1212

1313
### Fixed
1414

15+
- Made `hunk pager` emit static highlighted diff output for captured pager contexts like LazyGit, and pass diff input through unchanged when stdout is non-interactive.
1516
- Fixed `hunk pager` parsing for Git diffs emitted with `diff.mnemonicPrefix=true` so file paths do not keep `i/`, `w/`, `c/`, `1/`, or `2/` side prefixes.
1617
- Fixed large tracked and untracked file handling so very large diffs render as skipped placeholders instead of slowing startup or overflowing the JavaScript call stack.
1718

src/core/startup.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ describe("startup planning", () => {
8989
parseCliImpl: async () => ({ kind: "pager", options: { theme: "paper" } }),
9090
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
9191
looksLikePatchInputImpl: () => true,
92+
stdoutIsTTY: true,
93+
env: { TERM: "xterm-256color" },
94+
openControllingTerminalImpl: () => ({ stdin: {} as never, close: () => {} }),
9295
resolveRuntimeCliInputImpl(input) {
9396
seenInputs.push(input);
9497
return input;
@@ -121,6 +124,101 @@ describe("startup planning", () => {
121124
expect(seenInputs).toHaveLength(3);
122125
});
123126

127+
test("passes diff-like pager stdin through when stdout is not interactive", async () => {
128+
let loaded = false;
129+
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
130+
131+
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
132+
parseCliImpl: async () => ({ kind: "pager", options: {} }),
133+
readStdinText: async () => patchText,
134+
looksLikePatchInputImpl: () => true,
135+
stdoutIsTTY: false,
136+
loadAppBootstrapImpl: async () => {
137+
loaded = true;
138+
throw new Error("unreachable");
139+
},
140+
});
141+
142+
expect(plan).toEqual({ kind: "passthrough", text: patchText });
143+
expect(loaded).toBe(false);
144+
});
145+
146+
test("passes diff-like pager stdin through for a plain dumb terminal", async () => {
147+
let loaded = false;
148+
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
149+
150+
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
151+
parseCliImpl: async () => ({ kind: "pager", options: {} }),
152+
readStdinText: async () => patchText,
153+
looksLikePatchInputImpl: () => true,
154+
stdoutIsTTY: true,
155+
env: { TERM: "dumb" },
156+
loadAppBootstrapImpl: async () => {
157+
loaded = true;
158+
throw new Error("unreachable");
159+
},
160+
});
161+
162+
expect(plan).toEqual({ kind: "passthrough", text: patchText });
163+
expect(loaded).toBe(false);
164+
});
165+
166+
test("routes diff-like pager stdin to static output when the host advertises a captured pager", async () => {
167+
let loaded = false;
168+
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
169+
170+
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
171+
parseCliImpl: async () => ({ kind: "pager", options: { theme: "paper" } }),
172+
readStdinText: async () => patchText,
173+
looksLikePatchInputImpl: () => true,
174+
stdoutIsTTY: true,
175+
env: { TERM: "dumb", LV: "-c" },
176+
resolveRuntimeCliInputImpl: (input) => input,
177+
resolveConfiguredCliInputImpl: (input) =>
178+
({
179+
input: { ...input, options: { ...input.options, lineNumbers: false, theme: "paper" } },
180+
}) as never,
181+
loadAppBootstrapImpl: async () => {
182+
loaded = true;
183+
throw new Error("unreachable");
184+
},
185+
});
186+
187+
expect(plan).toEqual({
188+
kind: "static-diff-pager",
189+
text: patchText,
190+
options: { theme: "paper", pager: true, lineNumbers: false },
191+
});
192+
expect(loaded).toBe(false);
193+
});
194+
195+
test("routes diff-like pager stdin to static output when no controlling terminal is available", async () => {
196+
let loaded = false;
197+
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
198+
199+
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
200+
parseCliImpl: async () => ({ kind: "pager", options: {} }),
201+
readStdinText: async () => patchText,
202+
looksLikePatchInputImpl: () => true,
203+
stdoutIsTTY: true,
204+
env: { TERM: "xterm-256color" },
205+
resolveRuntimeCliInputImpl: (input) => input,
206+
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
207+
openControllingTerminalImpl: () => null,
208+
loadAppBootstrapImpl: async () => {
209+
loaded = true;
210+
throw new Error("unreachable");
211+
},
212+
});
213+
214+
expect(plan).toEqual({
215+
kind: "static-diff-pager",
216+
text: patchText,
217+
options: { pager: true },
218+
});
219+
expect(loaded).toBe(false);
220+
});
221+
124222
test("rejects watch mode for stdin-backed patch inputs", async () => {
125223
const cliInput: CliInput = {
126224
kind: "patch",

src/core/startup.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,31 @@ export type StartupPlan =
2828
kind: "plain-text-pager";
2929
text: string;
3030
}
31+
| {
32+
kind: "passthrough";
33+
text: string;
34+
}
35+
| {
36+
kind: "static-diff-pager";
37+
text: string;
38+
options: CliInput["options"];
39+
}
3140
| {
3241
kind: "app";
3342
bootstrap: AppBootstrap;
3443
cliInput: CliInput;
3544
controllingTerminal: ControllingTerminal | null;
3645
};
3746

47+
function isCapturedPagerHost(env: NodeJS.ProcessEnv) {
48+
return (
49+
env.TERM === "dumb" &&
50+
(env.LV === "-c" ||
51+
Boolean(env.GIT_PAGER) ||
52+
Object.keys(env).some((key) => key.startsWith("LAZYGIT")))
53+
);
54+
}
55+
3856
export interface StartupDeps {
3957
parseCliImpl?: (argv: string[]) => Promise<ParsedCliInput>;
4058
readStdinText?: () => Promise<string>;
@@ -44,6 +62,8 @@ export interface StartupDeps {
4462
loadAppBootstrapImpl?: typeof loadAppBootstrap;
4563
usesPipedPatchInputImpl?: typeof usesPipedPatchInput;
4664
openControllingTerminalImpl?: typeof openControllingTerminal;
65+
stdoutIsTTY?: boolean;
66+
env?: NodeJS.ProcessEnv;
4767
}
4868

4969
/** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */
@@ -60,8 +80,11 @@ export async function prepareStartupPlan(
6080
const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap;
6181
const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput;
6282
const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal;
83+
const stdoutIsTTY = deps.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
84+
const env = deps.env ?? process.env;
6385

6486
let parsedCliInput = await parseCliImpl(argv);
87+
let controllingTerminal: ControllingTerminal | null = null;
6588

6689
if (parsedCliInput.kind === "help") {
6790
return {
@@ -85,6 +108,27 @@ export async function prepareStartupPlan(
85108

86109
if (parsedCliInput.kind === "pager") {
87110
const stdinText = await readStdinText();
111+
const pagerOptions = parsedCliInput.options;
112+
const staticPagerPlan = () => {
113+
const staticPatchInput: CliInput = {
114+
kind: "patch",
115+
file: "-",
116+
text: stdinText,
117+
options: {
118+
...pagerOptions,
119+
pager: true,
120+
},
121+
};
122+
const configuredStaticInput = resolveConfiguredCliInputImpl(
123+
resolveRuntimeCliInputImpl(staticPatchInput),
124+
).input;
125+
126+
return {
127+
kind: "static-diff-pager" as const,
128+
text: stdinText,
129+
options: configuredStaticInput.options,
130+
};
131+
};
88132

89133
if (!looksLikePatchInputImpl(stdinText)) {
90134
return {
@@ -93,6 +137,31 @@ export async function prepareStartupPlan(
93137
};
94138
}
95139

140+
if (!stdoutIsTTY) {
141+
return {
142+
kind: "passthrough",
143+
text: stdinText,
144+
};
145+
}
146+
147+
if (env.TERM === "dumb" && !isCapturedPagerHost(env)) {
148+
return {
149+
kind: "passthrough",
150+
text: stdinText,
151+
};
152+
}
153+
154+
// Captured pager hosts like LazyGit can provide a PTY while advertising TERM=dumb.
155+
// In that mode, emit static colored diff output instead of launching the TUI.
156+
if (isCapturedPagerHost(env)) {
157+
return staticPagerPlan();
158+
}
159+
160+
controllingTerminal = openControllingTerminalImpl();
161+
if (!controllingTerminal) {
162+
return staticPagerPlan();
163+
}
164+
96165
parsedCliInput = {
97166
kind: "patch",
98167
file: "-",
@@ -117,10 +186,15 @@ export async function prepareStartupPlan(
117186
);
118187
}
119188

120-
const bootstrap = await loadAppBootstrapImpl(cliInput);
121-
const controllingTerminal = usesPipedPatchInputImpl(cliInput)
122-
? openControllingTerminalImpl()
123-
: null;
189+
let bootstrap: AppBootstrap;
190+
try {
191+
bootstrap = await loadAppBootstrapImpl(cliInput);
192+
} catch (error) {
193+
controllingTerminal?.close();
194+
throw error;
195+
}
196+
197+
controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;
124198

125199
return {
126200
kind: "app",

src/main.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { formatCliError } from "./core/errors";
66
import { installJobControlSuspendSupport } from "./core/jobControl";
77
import { pagePlainText } from "./core/pager";
88
import { shutdownSession } from "./core/shutdown";
9+
import { renderStaticDiffPager } from "./ui/staticDiffPager";
910
import { prepareStartupPlan } from "./core/startup";
1011
import { shouldUseMouseForApp } from "./core/terminal";
1112
import { resolveStartupUpdateNotice } from "./core/updateNotice";
@@ -48,6 +49,16 @@ async function main() {
4849
process.exit(0);
4950
}
5051

52+
if (startupPlan.kind === "passthrough") {
53+
process.stdout.write(startupPlan.text);
54+
process.exit(0);
55+
}
56+
57+
if (startupPlan.kind === "static-diff-pager") {
58+
process.stdout.write(await renderStaticDiffPager(startupPlan.text, startupPlan.options));
59+
process.exit(0);
60+
}
61+
5162
if (startupPlan.kind !== "app") {
5263
throw new Error("Unreachable startup plan.");
5364
}

0 commit comments

Comments
 (0)