@abelfubu/dv 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/ansi-html.d.ts +42 -0
  2. package/dist/ansi-html.d.ts.map +1 -0
  3. package/dist/ansi-html.js +327 -0
  4. package/dist/ansi-output.d.ts +22 -0
  5. package/dist/ansi-output.d.ts.map +1 -0
  6. package/dist/ansi-output.js +154 -0
  7. package/dist/balance-delimiters.d.ts +25 -0
  8. package/dist/balance-delimiters.d.ts.map +1 -0
  9. package/dist/balance-delimiters.js +539 -0
  10. package/dist/balance-delimiters.test.d.ts +2 -0
  11. package/dist/balance-delimiters.test.d.ts.map +1 -0
  12. package/dist/balance-delimiters.test.js +1029 -0
  13. package/dist/cli-copy-notification.test.d.ts +2 -0
  14. package/dist/cli-copy-notification.test.d.ts.map +1 -0
  15. package/dist/cli-copy-notification.test.js +80 -0
  16. package/dist/cli-scroll.test.d.ts +2 -0
  17. package/dist/cli-scroll.test.d.ts.map +1 -0
  18. package/dist/cli-scroll.test.js +283 -0
  19. package/dist/cli.d.ts +9 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +976 -0
  22. package/dist/clipboard.d.ts +16 -0
  23. package/dist/clipboard.d.ts.map +1 -0
  24. package/dist/clipboard.js +128 -0
  25. package/dist/components/diff-view.d.ts +32 -0
  26. package/dist/components/diff-view.d.ts.map +1 -0
  27. package/dist/components/diff-view.js +123 -0
  28. package/dist/components/diff-view.test.d.ts +5 -0
  29. package/dist/components/diff-view.test.d.ts.map +1 -0
  30. package/dist/components/diff-view.test.js +312 -0
  31. package/dist/components/directory-tree-view.d.ts +33 -0
  32. package/dist/components/directory-tree-view.d.ts.map +1 -0
  33. package/dist/components/directory-tree-view.js +262 -0
  34. package/dist/components/index.d.ts +4 -0
  35. package/dist/components/index.d.ts.map +1 -0
  36. package/dist/components/index.js +5 -0
  37. package/dist/components/toast.d.ts +21 -0
  38. package/dist/components/toast.d.ts.map +1 -0
  39. package/dist/components/toast.js +47 -0
  40. package/dist/diff-cursor-utils.d.ts +20 -0
  41. package/dist/diff-cursor-utils.d.ts.map +1 -0
  42. package/dist/diff-cursor-utils.js +105 -0
  43. package/dist/diff-cursor-utils.test.d.ts +2 -0
  44. package/dist/diff-cursor-utils.test.d.ts.map +1 -0
  45. package/dist/diff-cursor-utils.test.js +40 -0
  46. package/dist/diff-surface-copy.d.ts +23 -0
  47. package/dist/diff-surface-copy.d.ts.map +1 -0
  48. package/dist/diff-surface-copy.js +64 -0
  49. package/dist/diff-surface-copy.test.d.ts +5 -0
  50. package/dist/diff-surface-copy.test.d.ts.map +1 -0
  51. package/dist/diff-surface-copy.test.js +142 -0
  52. package/dist/diff-utils.d.ts +196 -0
  53. package/dist/diff-utils.d.ts.map +1 -0
  54. package/dist/diff-utils.js +682 -0
  55. package/dist/diff-utils.test.d.ts +2 -0
  56. package/dist/diff-utils.test.d.ts.map +1 -0
  57. package/dist/diff-utils.test.js +727 -0
  58. package/dist/directory-tree.d.ts +72 -0
  59. package/dist/directory-tree.d.ts.map +1 -0
  60. package/dist/directory-tree.js +161 -0
  61. package/dist/directory-tree.test.d.ts +2 -0
  62. package/dist/directory-tree.test.d.ts.map +1 -0
  63. package/dist/directory-tree.test.js +383 -0
  64. package/dist/dropdown.d.ts +26 -0
  65. package/dist/dropdown.d.ts.map +1 -0
  66. package/dist/dropdown.js +172 -0
  67. package/dist/dropdown.test.d.ts +2 -0
  68. package/dist/dropdown.test.d.ts.map +1 -0
  69. package/dist/dropdown.test.js +106 -0
  70. package/dist/filter-submodule.e2e.test.d.ts +2 -0
  71. package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
  72. package/dist/filter-submodule.e2e.test.js +109 -0
  73. package/dist/hooks/use-copy-selection.d.ts +29 -0
  74. package/dist/hooks/use-copy-selection.d.ts.map +1 -0
  75. package/dist/hooks/use-copy-selection.js +46 -0
  76. package/dist/kv-codec.d.ts +16 -0
  77. package/dist/kv-codec.d.ts.map +1 -0
  78. package/dist/kv-codec.js +36 -0
  79. package/dist/license.d.ts +14 -0
  80. package/dist/license.d.ts.map +1 -0
  81. package/dist/license.js +63 -0
  82. package/dist/logger.d.ts +9 -0
  83. package/dist/logger.d.ts.map +1 -0
  84. package/dist/logger.js +78 -0
  85. package/dist/monochrome.d.ts +34 -0
  86. package/dist/monochrome.d.ts.map +1 -0
  87. package/dist/monochrome.js +613 -0
  88. package/dist/monotone.d.ts +22 -0
  89. package/dist/monotone.d.ts.map +1 -0
  90. package/dist/monotone.js +185 -0
  91. package/dist/parsers-config.d.ts +19 -0
  92. package/dist/parsers-config.d.ts.map +1 -0
  93. package/dist/parsers-config.js +271 -0
  94. package/dist/patch-terminal-dimensions.d.ts +2 -0
  95. package/dist/patch-terminal-dimensions.d.ts.map +1 -0
  96. package/dist/patch-terminal-dimensions.js +45 -0
  97. package/dist/stdin-pager.test.d.ts +2 -0
  98. package/dist/stdin-pager.test.d.ts.map +1 -0
  99. package/dist/stdin-pager.test.js +497 -0
  100. package/dist/store.d.ts +16 -0
  101. package/dist/store.d.ts.map +1 -0
  102. package/dist/store.js +48 -0
  103. package/dist/themes/github.json +247 -0
  104. package/dist/themes.d.ts +59 -0
  105. package/dist/themes.d.ts.map +1 -0
  106. package/dist/themes.js +248 -0
  107. package/dist/tree-icons.d.ts +4 -0
  108. package/dist/tree-icons.d.ts.map +1 -0
  109. package/dist/tree-icons.js +18 -0
  110. package/dist/utils.d.ts +2 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils.js +13 -0
  113. package/dist/web-utils.d.ts +56 -0
  114. package/dist/web-utils.d.ts.map +1 -0
  115. package/dist/web-utils.js +363 -0
  116. package/package.json +37 -0
  117. package/public/jetbrains-mono-nerd.ttf +0 -0
  118. package/public/jetbrains-mono-nerd.woff2 +0 -0
package/dist/cli.js ADDED
@@ -0,0 +1,976 @@
1
+ #!/usr/bin/env bun
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "@opentuah/react/jsx-runtime";
3
+ // CLI entrypoint for the dv diff viewer.
4
+ // Provides TUI diff viewing with piped stdin support.
5
+ // Usage: git diff | dv
6
+ // Must be first import: patches process.stdout.columns/rows for Bun compiled binaries
7
+ // where they incorrectly return 0 instead of actual terminal dimensions.
8
+ import "./patch-terminal-dimensions.js";
9
+ import { addDefaultParsers, createCliRenderer, MacOSScrollAccel, RGBA, ScrollBoxRenderable, } from "@opentuah/core";
10
+ import { createPortal, createRoot, useKeyboard, useOnResize, useRenderer, useTerminalDimensions, } from "@opentuah/react";
11
+ import { exec, spawnSync } from "child_process";
12
+ import { goke, wrapJsonSchema } from "goke";
13
+ import { join } from "path";
14
+ import * as React from "react";
15
+ import stripAnsi from "strip-ansi";
16
+ import { promisify } from "util";
17
+ import { copyToClipboardWithRendererSync } from "./clipboard.js";
18
+ import { DEFAULT_SIDEBAR_WIDTH, DiffView, DirectoryTreeView, Toast } from "./components/index.js";
19
+ import { buildUnifiedLogicalLines } from "./diff-cursor-utils.js";
20
+ import { captureSelectedDiffText, getDiffRenderableLineCount } from "./diff-surface-copy.js";
21
+ import Dropdown from "./dropdown.js";
22
+ import { useCopySelection } from "./hooks/use-copy-selection.js";
23
+ import parsersConfig from "./parsers-config.js";
24
+ import { debounce } from "./utils.js";
25
+ // Register custom syntax highlighting parsers
26
+ addDefaultParsers(parsersConfig.parsers);
27
+ // buildDirectoryTree no longer needed — DirectoryTreeView manages its own tree
28
+ import packageJson from "../package.json" assert { type: "json" };
29
+ import { buildGitCommand, buildSubmoduleDiffCommand, buildUntrackedFileDiff, countChanges, detectFiletype, ensureGitRepo, filterParsedFilesByPatterns, getDirtySubmodulePaths, getFileName, getFileStatus, getFilterPatterns, getGitRepoRoot, getOldFileName, getUntrackedFilePaths, getViewMode, parseGitDiffFiles, processFiles, stripSubmoduleHeaders } from "./diff-utils.js";
30
+ import { logger } from "./logger.js";
31
+ import { persistedState, useAppStore, } from "./store.js";
32
+ import { defaultThemeName, getResolvedTheme, rgbaToHex, themeNames } from "./themes.js";
33
+ // Lazy-load watcher only when --watch is used
34
+ let watcherModule = null;
35
+ async function getWatcher() {
36
+ if (!watcherModule) {
37
+ watcherModule = await import("@parcel/watcher");
38
+ }
39
+ return watcherModule;
40
+ }
41
+ async function runScrollbackMode(diffContent, options) {
42
+ const { renderDiffToFrame } = await import("./web-utils.js");
43
+ const { frameToAnsi } = await import("./ansi-output.js");
44
+ const { getResolvedTheme } = await import("./themes.js");
45
+ const themeName = options.theme && themeNames.includes(options.theme)
46
+ ? options.theme
47
+ : persistedState.themeName ?? defaultThemeName;
48
+ const cols = options.cols || process.stdout.columns || 120;
49
+ try {
50
+ const frame = await renderDiffToFrame(diffContent, {
51
+ cols,
52
+ maxRows: 10000,
53
+ themeName,
54
+ });
55
+ const theme = getResolvedTheme(themeName);
56
+ const transparentBackground = persistedState.transparentBackground ?? false;
57
+ const ansi = frameToAnsi(frame, transparentBackground ? undefined : theme.background);
58
+ process.stdout.write(ansi + "\n");
59
+ process.exit(0);
60
+ }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ console.error("Failed to render scrollback:", message);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ class ErrorBoundary extends React.Component {
68
+ state = { hasError: false, error: null };
69
+ static getDerivedStateFromError(error) {
70
+ return { hasError: true, error };
71
+ }
72
+ componentDidCatch = (error, errorInfo) => {
73
+ logger.log("Error caught by boundary:", error);
74
+ logger.log("Component stack:", errorInfo.componentStack);
75
+ };
76
+ render() {
77
+ if (this.state.hasError && this.state.error) {
78
+ return (_jsxs("box", { style: { flexDirection: "column", padding: 2 }, children: [_jsxs("text", { fg: "red", children: ["Error: ", this.state.error.message] }), _jsx("text", { fg: "brightBlack", children: this.state.error.stack })] }));
79
+ }
80
+ return this.props.children;
81
+ }
82
+ }
83
+ const execAsync = promisify(exec);
84
+ async function filterCombinedDiffByPatterns(diffContent, options) {
85
+ if (!diffContent.trim())
86
+ return diffContent;
87
+ if (getFilterPatterns(options).length === 0)
88
+ return diffContent;
89
+ const { parsePatch, formatPatch } = await import("diff");
90
+ const parsedFiles = parseGitDiffFiles(stripSubmoduleHeaders(diffContent), parsePatch);
91
+ const filteredFiles = filterParsedFilesByPatterns(parsedFiles, options);
92
+ if (filteredFiles.length === 0)
93
+ return "";
94
+ return filteredFiles.map((file) => formatPatch(file)).join("\n");
95
+ }
96
+ const cli = goke("dv");
97
+ class ScrollAcceleration {
98
+ multiplier = 1;
99
+ macosAccel;
100
+ constructor() {
101
+ this.macosAccel = new MacOSScrollAccel({ A: 1.5, maxMultiplier: 10 });
102
+ }
103
+ tick(delta) {
104
+ return this.macosAccel.tick(delta) * this.multiplier;
105
+ }
106
+ reset() {
107
+ this.macosAccel.reset();
108
+ // this.multiplier = 1;
109
+ }
110
+ }
111
+ const SIDEBAR_GAP = 2;
112
+ const APP_HORIZONTAL_PADDING = 2;
113
+ export function App({ parsedFiles }) {
114
+ const { width: initialWidth, height: initialHeight } = useTerminalDimensions();
115
+ const [width, setWidth] = React.useState(initialWidth);
116
+ const [_terminalHeight, setTerminalHeight] = React.useState(initialHeight);
117
+ const [scrollAcceleration] = React.useState(() => new ScrollAcceleration());
118
+ const themeName = useAppStore((s) => s.themeName);
119
+ const italicsEnabled = useAppStore((s) => s.italicsEnabled);
120
+ const transparentBackground = useAppStore((s) => s.transparentBackground);
121
+ const [showDropdown, setShowDropdown] = React.useState(false);
122
+ const [showThemePicker, setShowThemePicker] = React.useState(false);
123
+ const [previewTheme, setPreviewTheme] = React.useState(null);
124
+ const [currentFileIndex, setCurrentFileIndex] = React.useState(0);
125
+ const [currentFolderPath, setCurrentFolderPath] = React.useState(undefined);
126
+ const [showSidebar, setShowSidebar] = React.useState(true);
127
+ const [focusedPane, setFocusedPane] = React.useState("sidebar");
128
+ // Cursor/selection state for the diff pane
129
+ const [cursorLine, setCursorLine] = React.useState(0);
130
+ const [selectionAnchor, setSelectionAnchor] = React.useState(null);
131
+ // Transient copy notification shown in the footer
132
+ const [copyNotification, setCopyNotification] = React.useState(null);
133
+ const copyNotificationTimeoutRef = React.useRef(null);
134
+ // Refs for scroll functionality
135
+ const scrollboxRef = React.useRef(null);
136
+ const sidebarScrollboxRef = React.useRef(null);
137
+ const sidebarRef = React.useRef(null);
138
+ const diffViewRef = React.useRef(null);
139
+ // Ref for double-tap detection (gg)
140
+ const lastKeyRef = React.useRef(null);
141
+ // Logical diff lines for the currently rendered file, used for bounds and copy
142
+ const logicalLinesRef = React.useRef([]);
143
+ const maxCursorLine = React.useCallback(() => {
144
+ const renderable = diffViewRef.current?.getDiffRenderable();
145
+ if (renderable) {
146
+ return Math.max(0, getDiffRenderableLineCount(renderable) - 1);
147
+ }
148
+ return Math.max(0, logicalLinesRef.current.length - 1);
149
+ }, []);
150
+ const resetCursor = React.useCallback(() => {
151
+ setCursorLine(0);
152
+ setSelectionAnchor(null);
153
+ }, []);
154
+ const clampCursor = React.useCallback((line) => {
155
+ return Math.max(0, Math.min(line, maxCursorLine()));
156
+ }, [maxCursorLine]);
157
+ const scrollCursorIntoView = React.useCallback(() => {
158
+ const scrollbox = scrollboxRef.current;
159
+ if (!scrollbox)
160
+ return;
161
+ const viewportTop = scrollbox.scrollTop;
162
+ const viewportHeight = Math.max(1, scrollbox.viewport.height);
163
+ const margin = 10;
164
+ if (cursorLine < viewportTop) {
165
+ scrollbox.scrollTo(Math.max(0, cursorLine - margin));
166
+ }
167
+ else if (cursorLine >= viewportTop + viewportHeight) {
168
+ scrollbox.scrollTo(Math.max(0, cursorLine - viewportHeight + 1 + margin));
169
+ }
170
+ else if (cursorLine >= viewportTop + viewportHeight - margin) {
171
+ // Nearer the bottom edge: scroll down a bit to keep a margin ahead.
172
+ scrollbox.scrollTo(Math.max(0, cursorLine - viewportHeight + 1 + margin));
173
+ }
174
+ }, [cursorLine]);
175
+ // Copy selection to clipboard on mouse release
176
+ const { onMouseUp } = useCopySelection();
177
+ useOnResize(React.useCallback((newWidth, newHeight) => {
178
+ setWidth(newWidth);
179
+ setTerminalHeight(newHeight);
180
+ }, []));
181
+ const renderer = useRenderer();
182
+ const transparentBg = RGBA.fromInts(0, 0, 0, 0);
183
+ // Copy selected rows as rendered on screen + file path as a markdown code block.
184
+ const copySelectionToClipboard = React.useCallback(() => {
185
+ const file = currentFileIndex !== undefined ? parsedFiles[currentFileIndex] : undefined;
186
+ if (!file)
187
+ return;
188
+ const diffRenderable = diffViewRef.current?.getDiffRenderable();
189
+ if (!diffRenderable) {
190
+ logger.log("Copy selection unavailable: diff renderable not ready");
191
+ setCopyNotification({ message: "Copy unavailable: diff not ready", type: "error" });
192
+ return;
193
+ }
194
+ const anchor = selectionAnchor ?? cursorLine;
195
+ const { text: code, startLineNum } = captureSelectedDiffText(diffRenderable, {
196
+ start: anchor,
197
+ end: cursorLine,
198
+ });
199
+ if (!code) {
200
+ logger.log("Copy selection: no text captured");
201
+ setCopyNotification({ message: "Copy: no text selected", type: "error" });
202
+ return;
203
+ }
204
+ const fileName = getFileName(file);
205
+ const oldFileName = getOldFileName(file);
206
+ const pathHeader = oldFileName ? `${oldFileName} → ${fileName}` : fileName;
207
+ const lang = detectFiletype(fileName) || "";
208
+ const lineAttr = startLineNum !== undefined ? `:${startLineNum}` : "";
209
+ const markdown = ["```" + lang, `// ${pathHeader}${lineAttr}`, code, "```"].join("\n");
210
+ try {
211
+ copyToClipboardWithRendererSync(markdown, renderer);
212
+ const lineCount = code.split("\n").length;
213
+ setCopyNotification({
214
+ message: `Copied ${lineCount} ${lineCount === 1 ? "line" : "lines"}`,
215
+ type: "success",
216
+ });
217
+ }
218
+ catch (error) {
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ logger.log("Copy selection failed:", message);
221
+ setCopyNotification({ message: `Copy failed: ${message}`, type: "error" });
222
+ }
223
+ }, [currentFileIndex, cursorLine, parsedFiles, renderer, selectionAnchor]);
224
+ // Auto-hide copy notification after a short delay.
225
+ React.useEffect(() => {
226
+ if (!copyNotification)
227
+ return;
228
+ if (copyNotificationTimeoutRef.current) {
229
+ clearTimeout(copyNotificationTimeoutRef.current);
230
+ }
231
+ copyNotificationTimeoutRef.current = setTimeout(() => {
232
+ setCopyNotification(null);
233
+ copyNotificationTimeoutRef.current = null;
234
+ }, 2000);
235
+ return () => {
236
+ if (copyNotificationTimeoutRef.current) {
237
+ clearTimeout(copyNotificationTimeoutRef.current);
238
+ }
239
+ };
240
+ }, [copyNotification]);
241
+ // Force a redraw when transparency is toggled so the terminal background shows through
242
+ React.useEffect(() => {
243
+ renderer.root.requestRender();
244
+ }, [transparentBackground]);
245
+ // Keep logical line model in sync with the rendered file and reset cursor on file change.
246
+ React.useEffect(() => {
247
+ if (currentFolderPath) {
248
+ logicalLinesRef.current = [];
249
+ resetCursor();
250
+ return;
251
+ }
252
+ const file = currentFileIndex !== undefined ? parsedFiles[currentFileIndex] : undefined;
253
+ logicalLinesRef.current = file?.rawDiff ? buildUnifiedLogicalLines(file.rawDiff) : [];
254
+ resetCursor();
255
+ }, [currentFileIndex, currentFolderPath, parsedFiles, resetCursor]);
256
+ // Auto-scroll the diff viewport so the cursor line stays visible.
257
+ React.useEffect(() => {
258
+ if (focusedPane !== "diff" || currentFolderPath)
259
+ return;
260
+ scrollCursorIntoView();
261
+ }, [cursorLine, focusedPane, currentFolderPath, scrollCursorIntoView]);
262
+ // Hide selection when leaving the diff pane.
263
+ React.useEffect(() => {
264
+ if (focusedPane !== "diff") {
265
+ setSelectionAnchor(null);
266
+ }
267
+ }, [focusedPane]);
268
+ useKeyboard((key) => {
269
+ if (showDropdown || showThemePicker) {
270
+ if (key.name === "escape") {
271
+ setShowDropdown(false);
272
+ setShowThemePicker(false);
273
+ setPreviewTheme(null);
274
+ }
275
+ return;
276
+ }
277
+ if (key.name === "escape" || key.name === "q") {
278
+ if (selectionAnchor !== null) {
279
+ setSelectionAnchor(null);
280
+ return;
281
+ }
282
+ renderer.destroy();
283
+ return;
284
+ }
285
+ if (key.name === "p") {
286
+ setShowDropdown(true);
287
+ return;
288
+ }
289
+ if ((key.shift && key.name === "t") || key.name === "T") {
290
+ useAppStore.setState({ transparentBackground: !transparentBackground });
291
+ return;
292
+ }
293
+ if (key.name === "t") {
294
+ setShowThemePicker(true);
295
+ return;
296
+ }
297
+ if (key.name === "b") {
298
+ setShowSidebar((prev) => !prev);
299
+ return;
300
+ }
301
+ if (key.name === "i") {
302
+ useAppStore.setState({ italicsEnabled: !italicsEnabled });
303
+ return;
304
+ }
305
+ if (key.name === "o") {
306
+ const file = currentFileIndex !== undefined ? parsedFiles[currentFileIndex] : undefined;
307
+ if (file) {
308
+ const editor = process.env.EDITOR || "vi";
309
+ const relativePath = getFileName(file).replace(/^[ab]\//, "");
310
+ const absolutePath = join(getGitRepoRoot(), relativePath);
311
+ renderer.suspend();
312
+ try {
313
+ const match = editor.match(/(?:[^\s"]+|"[^"]*")+/g);
314
+ const parts = match ? match.map((s) => s.replace(/^"|"$/g, "")) : [editor];
315
+ const editorName = parts[0].toLowerCase();
316
+ // GUI editors don't need the TTY; launching without stdio avoids permission prompts
317
+ const isGuiEditor = editorName === "code" ||
318
+ editorName === "code-insiders" ||
319
+ editorName === "cursor" ||
320
+ editorName === "zed" ||
321
+ editorName === "subl" ||
322
+ editorName === "sublime_text" ||
323
+ editorName === "fleet" ||
324
+ editorName === "goland" ||
325
+ editorName === "idea" ||
326
+ editorName === "webstorm" ||
327
+ editorName === "pycharm" ||
328
+ editorName === "rider" ||
329
+ editorName === "clion" ||
330
+ editorName === "datagrip" ||
331
+ editorName === "rubymine";
332
+ if (isGuiEditor) {
333
+ spawnSync(parts[0], parts.slice(1).concat(absolutePath));
334
+ }
335
+ else {
336
+ spawnSync(parts[0], parts.slice(1).concat(absolutePath), { stdio: "inherit" });
337
+ }
338
+ }
339
+ catch {
340
+ // Editor may exit non-zero (e.g. vim :cq)
341
+ }
342
+ renderer.resume();
343
+ }
344
+ return;
345
+ }
346
+ if (key.name === "tab") {
347
+ setSelectionAnchor(null);
348
+ setFocusedPane((prev) => (prev === "diff" ? "sidebar" : "diff"));
349
+ return;
350
+ }
351
+ // Diff-pane cursor/selection shortcuts
352
+ if (focusedPane === "diff" && !currentFolderPath) {
353
+ if (key.name === "v") {
354
+ if (selectionAnchor !== null) {
355
+ setSelectionAnchor(null);
356
+ }
357
+ else {
358
+ setSelectionAnchor(cursorLine);
359
+ }
360
+ key.preventDefault();
361
+ return;
362
+ }
363
+ if (key.name === "y") {
364
+ copySelectionToClipboard();
365
+ setSelectionAnchor(null);
366
+ key.preventDefault();
367
+ return;
368
+ }
369
+ }
370
+ if (key.name === "z" && key.ctrl) {
371
+ renderer.console.toggle();
372
+ return;
373
+ }
374
+ const scrollbox = scrollboxRef.current;
375
+ // Sidebar navigation when sidebar focused
376
+ if (showSidebar && focusedPane === "sidebar") {
377
+ // j/k: navigate rows
378
+ if (key.name === "j" || key.name === "k") {
379
+ // Ignore key repeat events to prevent double jumps
380
+ if (key.repeated)
381
+ return;
382
+ if (key.name === "j") {
383
+ sidebarRef.current?.focusNext();
384
+ }
385
+ else {
386
+ sidebarRef.current?.focusPrev();
387
+ }
388
+ key.preventDefault();
389
+ return;
390
+ }
391
+ // l/h/Enter/Space: toggle folder collapse
392
+ if (key.name === "l" || key.name === "h" || key.name === "return" || key.name === "space") {
393
+ sidebarRef.current?.toggleCollapse();
394
+ key.preventDefault();
395
+ return;
396
+ }
397
+ }
398
+ // Vim-style navigation (diff pane)
399
+ if (scrollbox) {
400
+ // When the diff pane is focused, j/k move the cursor line and auto-scroll.
401
+ if (focusedPane === "diff" && !currentFolderPath && (key.name === "j" || key.name === "k")) {
402
+ if (key.name === "j") {
403
+ setCursorLine((prev) => clampCursor(prev + 1));
404
+ }
405
+ else {
406
+ setCursorLine((prev) => clampCursor(prev - 1));
407
+ }
408
+ key.preventDefault();
409
+ return;
410
+ }
411
+ // j - scroll down one line
412
+ if (key.name === "j") {
413
+ scrollbox.scrollBy(1, "step");
414
+ key.preventDefault();
415
+ return;
416
+ }
417
+ // k - scroll up one line
418
+ if (key.name === "k") {
419
+ scrollbox.scrollBy(-1, "step");
420
+ key.preventDefault();
421
+ return;
422
+ }
423
+ // G - go to bottom
424
+ if (key.name === "g" && key.shift) {
425
+ if (focusedPane === "diff" && !currentFolderPath) {
426
+ setCursorLine(maxCursorLine());
427
+ }
428
+ else {
429
+ scrollbox.scrollBy(1, "content");
430
+ }
431
+ return;
432
+ }
433
+ // gg - go to top (double-tap within 300ms)
434
+ if (key.name === "g" && !key.shift && !key.ctrl) {
435
+ const now = Date.now();
436
+ if (lastKeyRef.current?.key === "g" && now - lastKeyRef.current.time < 300) {
437
+ if (focusedPane === "diff" && !currentFolderPath) {
438
+ setCursorLine(0);
439
+ }
440
+ else {
441
+ scrollbox.scrollTo(0);
442
+ }
443
+ lastKeyRef.current = null;
444
+ }
445
+ else {
446
+ lastKeyRef.current = { key: "g", time: now };
447
+ }
448
+ return;
449
+ }
450
+ // Ctrl+D - half page down
451
+ if (key.ctrl && key.name === "d") {
452
+ if (focusedPane === "diff" && !currentFolderPath) {
453
+ setCursorLine((prev) => clampCursor(prev + Math.floor(scrollbox.viewport.height / 2)));
454
+ }
455
+ else {
456
+ scrollbox.scrollBy(0.5, "viewport");
457
+ }
458
+ return;
459
+ }
460
+ // Ctrl+U - half page up
461
+ if (key.ctrl && key.name === "u") {
462
+ if (focusedPane === "diff" && !currentFolderPath) {
463
+ setCursorLine((prev) => clampCursor(prev - Math.floor(scrollbox.viewport.height / 2)));
464
+ }
465
+ else {
466
+ scrollbox.scrollBy(-0.5, "viewport");
467
+ }
468
+ return;
469
+ }
470
+ }
471
+ if (key.option) {
472
+ if (key.eventType === "release") {
473
+ scrollAcceleration.multiplier = 1;
474
+ }
475
+ else {
476
+ scrollAcceleration.multiplier = 10;
477
+ }
478
+ }
479
+ });
480
+ if (parsedFiles.length === 0) {
481
+ return (_jsx("box", { onMouseUp: onMouseUp, style: {
482
+ padding: 1,
483
+ backgroundColor: transparentBackground ? transparentBg : getResolvedTheme(themeName).background,
484
+ }, children: _jsx("text", { children: "No files to display" }) }));
485
+ }
486
+ // Use preview theme if hovering, otherwise use selected theme
487
+ const activeTheme = previewTheme ?? themeName;
488
+ const resolvedTheme = getResolvedTheme(activeTheme);
489
+ const bgColor = transparentBackground ? transparentBg : resolvedTheme.background;
490
+ const sidebarBgColor = transparentBackground ? transparentBg : rgbaToHex(resolvedTheme.backgroundPanel);
491
+ const textColor = rgbaToHex(resolvedTheme.text);
492
+ const mutedColor = rgbaToHex(resolvedTheme.textMuted);
493
+ const availableContentWidth = Math.max(20, width - APP_HORIZONTAL_PADDING);
494
+ const diffPaneWidth = showSidebar
495
+ ? Math.max(20, availableContentWidth - DEFAULT_SIDEBAR_WIDTH - SIDEBAR_GAP)
496
+ : availableContentWidth;
497
+ const diffSelection = React.useMemo(() => {
498
+ if (selectionAnchor === null)
499
+ return null;
500
+ return { start: selectionAnchor, end: cursorLine };
501
+ }, [selectionAnchor, cursorLine]);
502
+ const dropdownOptions = parsedFiles.map((file, idx) => {
503
+ const name = getFileName(file);
504
+ return {
505
+ title: name,
506
+ value: String(idx),
507
+ keywords: name.split("/"),
508
+ };
509
+ });
510
+ // Build tree data for directory tree view
511
+ const treeFiles = parsedFiles.map((file, idx) => {
512
+ const { additions, deletions } = countChanges(file.hunks);
513
+ return {
514
+ path: getFileName(file),
515
+ status: getFileStatus(file),
516
+ additions,
517
+ deletions,
518
+ fileIndex: idx,
519
+ };
520
+ });
521
+ // Synchronous sidebar scroll handler — keeps focused row in viewport
522
+ const handleSidebarFocusRowChange = React.useCallback((rowY) => {
523
+ const sidebarScrollbox = sidebarScrollboxRef.current;
524
+ if (!sidebarScrollbox)
525
+ return;
526
+ const viewportTop = sidebarScrollbox.scrollTop;
527
+ const viewportHeight = Math.max(1, sidebarScrollbox.viewport.height);
528
+ if (rowY < viewportTop) {
529
+ sidebarScrollbox.scrollTo(rowY);
530
+ }
531
+ else if (rowY >= viewportTop + viewportHeight) {
532
+ sidebarScrollbox.scrollTo(Math.max(0, rowY - viewportHeight + 1));
533
+ }
534
+ }, []);
535
+ const handleFileSelect = (value) => {
536
+ const index = parseInt(value, 10);
537
+ setCurrentFileIndex(index);
538
+ setCurrentFolderPath(undefined);
539
+ setShowDropdown(false);
540
+ // Reset scroll when switching files via picker
541
+ const scrollbox = scrollboxRef.current;
542
+ if (scrollbox)
543
+ scrollbox.scrollTo(0);
544
+ };
545
+ const themeOptions = themeNames.map((name) => ({
546
+ title: name,
547
+ value: name,
548
+ }));
549
+ const handleThemeSelect = (value) => {
550
+ useAppStore.setState({ themeName: value });
551
+ setShowThemePicker(false);
552
+ setPreviewTheme(null);
553
+ };
554
+ const handleThemeFocus = (value) => {
555
+ setPreviewTheme(value);
556
+ };
557
+ const renderCurrentFile = () => {
558
+ if (currentFolderPath) {
559
+ const folderFiles = parsedFiles.filter((file) => {
560
+ const name = getFileName(file);
561
+ return name.startsWith(currentFolderPath + "/");
562
+ });
563
+ if (folderFiles.length === 0) {
564
+ return (_jsx("box", { style: { padding: 1 }, children: _jsx("text", { fg: mutedColor, children: "No changes in this folder" }) }));
565
+ }
566
+ const totalAdditions = folderFiles.reduce((sum, f) => sum + countChanges(f.hunks).additions, 0);
567
+ const totalDeletions = folderFiles.reduce((sum, f) => sum + countChanges(f.hunks).deletions, 0);
568
+ return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsxs("box", { style: {
569
+ paddingBottom: 1,
570
+ paddingLeft: 1,
571
+ paddingRight: 1,
572
+ flexShrink: 0,
573
+ flexDirection: "row",
574
+ alignItems: "center",
575
+ }, children: [_jsxs("text", { fg: textColor, children: [currentFolderPath, "/"] }), _jsxs("text", { fg: "#2d8a47", children: [" +", totalAdditions] }), _jsxs("text", { fg: "#c53b53", children: [" -", totalDeletions] }), _jsxs("text", { fg: mutedColor, children: [" (", folderFiles.length, " files)"] })] }), folderFiles.map((file, idx) => {
576
+ const fileName = getFileName(file);
577
+ const oldFileName = getOldFileName(file);
578
+ const filetype = detectFiletype(fileName);
579
+ const { additions, deletions } = countChanges(file.hunks);
580
+ const viewMode = getViewMode(additions, deletions, diffPaneWidth);
581
+ return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsxs("box", { style: {
582
+ paddingTop: idx > 0 ? 1 : 0,
583
+ paddingBottom: 1,
584
+ paddingLeft: 1,
585
+ paddingRight: 1,
586
+ flexShrink: 0,
587
+ flexDirection: "row",
588
+ alignItems: "center",
589
+ }, children: [oldFileName ? (_jsxs(_Fragment, { children: [_jsx("text", { fg: mutedColor, children: oldFileName.trim() }), _jsx("text", { fg: mutedColor, children: " \u2192 " }), _jsx("text", { fg: textColor, children: fileName.trim() })] })) : (_jsx("text", { fg: textColor, children: fileName.trim() })), _jsxs("text", { fg: "#2d8a47", children: [" +", additions] }), _jsxs("text", { fg: "#c53b53", children: [" -", deletions] })] }), _jsx(DiffView, { diff: file.rawDiff || "", view: viewMode, filetype: filetype, themeName: activeTheme, italicsEnabled: italicsEnabled, transparentBackground: transparentBackground, focused: false })] }, idx));
590
+ })] }));
591
+ }
592
+ const file = parsedFiles[currentFileIndex ?? 0];
593
+ if (!file)
594
+ return null;
595
+ const fileName = getFileName(file);
596
+ const oldFileName = getOldFileName(file);
597
+ const filetype = detectFiletype(fileName);
598
+ const { additions, deletions } = countChanges(file.hunks);
599
+ const viewMode = getViewMode(additions, deletions, diffPaneWidth);
600
+ return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsxs("box", { style: {
601
+ paddingBottom: 1,
602
+ paddingLeft: 1,
603
+ paddingRight: 1,
604
+ flexShrink: 0,
605
+ flexDirection: "row",
606
+ alignItems: "center",
607
+ }, children: [oldFileName ? (_jsxs(_Fragment, { children: [_jsx("text", { fg: mutedColor, children: oldFileName.trim() }), _jsx("text", { fg: mutedColor, children: " \u2192 " }), _jsx("text", { fg: textColor, children: fileName.trim() })] })) : (_jsx("text", { fg: textColor, children: fileName.trim() })), _jsxs("text", { fg: "#2d8a47", children: [" +", additions] }), _jsxs("text", { fg: "#c53b53", children: ["-", deletions] })] }), _jsx(DiffView, { ref: diffViewRef, diff: file.rawDiff || "", view: viewMode, filetype: filetype, themeName: activeTheme, italicsEnabled: italicsEnabled, transparentBackground: transparentBackground, focused: focusedPane === "diff", cursorLine: cursorLine, selection: diffSelection })] }));
608
+ };
609
+ // Always render the same structure - scrollbox is never remounted
610
+ return (_jsxs("box", { style: {
611
+ flexDirection: "column",
612
+ height: "100%",
613
+ padding: 1,
614
+ backgroundColor: bgColor,
615
+ }, children: [copyNotification &&
616
+ createPortal(_jsx(Toast, { message: copyNotification.message, type: copyNotification.type, theme: resolvedTheme, transparentBackground: transparentBackground }), renderer.root, null), showThemePicker && (_jsx("box", { style: { flexShrink: 0, maxHeight: 15 }, children: _jsx(Dropdown, { tooltip: "Select theme", options: themeOptions, selectedValues: [themeName], onChange: handleThemeSelect, onFocus: handleThemeFocus, onEscape: () => {
617
+ setShowThemePicker(false);
618
+ setPreviewTheme(null);
619
+ }, placeholder: "Search themes...", itemsPerPage: 6, theme: resolvedTheme }) })), showDropdown && (_jsx("box", { style: { flexShrink: 0, maxHeight: 15 }, children: _jsx(Dropdown, { tooltip: "Select file", options: dropdownOptions, selectedValues: [], onChange: handleFileSelect, onEscape: () => {
620
+ setShowDropdown(false);
621
+ }, placeholder: "Search files...", itemsPerPage: 6, theme: resolvedTheme }) })), _jsxs("box", { style: {
622
+ flexDirection: "row",
623
+ flexGrow: 1,
624
+ flexShrink: 1,
625
+ }, children: [showSidebar && (_jsx("box", { style: {
626
+ width: DEFAULT_SIDEBAR_WIDTH,
627
+ flexShrink: 0,
628
+ marginRight: SIDEBAR_GAP,
629
+ backgroundColor: sidebarBgColor,
630
+ flexDirection: "column",
631
+ }, children: _jsx("scrollbox", { ref: sidebarScrollboxRef, scrollY: true, style: {
632
+ flexGrow: 1,
633
+ flexShrink: 1,
634
+ rootOptions: {
635
+ backgroundColor: sidebarBgColor,
636
+ border: false,
637
+ },
638
+ contentOptions: {
639
+ minHeight: 0,
640
+ },
641
+ scrollbarOptions: {
642
+ showArrows: false,
643
+ trackOptions: {
644
+ foregroundColor: mutedColor,
645
+ backgroundColor: sidebarBgColor,
646
+ },
647
+ },
648
+ }, children: _jsx(DirectoryTreeView, { ref: sidebarRef, files: treeFiles, themeName: activeTheme, width: DEFAULT_SIDEBAR_WIDTH, activeFileIndex: currentFileIndex, activeFolderPath: currentFolderPath, transparentBackground: transparentBackground, onFocusRowChange: handleSidebarFocusRowChange, onFileSelect: (fileIndex) => {
649
+ setCurrentFileIndex(fileIndex);
650
+ setCurrentFolderPath(undefined);
651
+ scrollboxRef.current?.scrollTo(0);
652
+ }, onFolderSelect: (folderPath) => {
653
+ setCurrentFolderPath(folderPath);
654
+ setCurrentFileIndex(undefined);
655
+ scrollboxRef.current?.scrollTo(0);
656
+ } }) }) })), _jsx("box", { style: {
657
+ flexGrow: 1,
658
+ flexShrink: 1,
659
+ }, children: _jsx("scrollbox", { ref: scrollboxRef, scrollY: true, scrollAcceleration: scrollAcceleration, style: {
660
+ flexGrow: 1,
661
+ flexShrink: 1,
662
+ rootOptions: {
663
+ backgroundColor: bgColor,
664
+ border: false,
665
+ },
666
+ contentOptions: {
667
+ minHeight: 0,
668
+ },
669
+ scrollbarOptions: {
670
+ showArrows: false,
671
+ trackOptions: {
672
+ foregroundColor: mutedColor,
673
+ backgroundColor: bgColor,
674
+ },
675
+ },
676
+ }, focused: !showDropdown && !showThemePicker, children: renderCurrentFile() }) })] }), !showDropdown && !showThemePicker && (_jsxs("box", { style: {
677
+ paddingTop: 1,
678
+ paddingLeft: 1,
679
+ paddingRight: 1,
680
+ flexShrink: 0,
681
+ flexDirection: "row",
682
+ alignItems: "center",
683
+ }, children: [_jsx("text", { fg: textColor, children: "p" }), _jsxs("text", { fg: mutedColor, children: [" files (", parsedFiles.length, ") "] }), _jsx("text", { fg: textColor, children: "t" }), _jsx("text", { fg: mutedColor, children: " theme " }), _jsx("text", { fg: textColor, children: "b" }), _jsx("text", { fg: mutedColor, children: " sidebar " }), _jsx("text", { fg: textColor, children: "i" }), _jsxs("text", { fg: mutedColor, children: [" ", italicsEnabled ? "italic" : "no-italic", " "] }), _jsx("text", { fg: textColor, children: "T" }), _jsxs("text", { fg: mutedColor, children: [" ", transparentBackground ? "transparent" : "opaque", " "] }), _jsx("text", { fg: textColor, children: "o" }), _jsx("text", { fg: mutedColor, children: " edit " }), _jsx("text", { fg: textColor, children: "tab" }), _jsx("text", { fg: mutedColor, children: " focus" }), focusedPane === "diff" && !currentFolderPath && (_jsxs(_Fragment, { children: [_jsx("text", { fg: textColor, children: " j/k" }), _jsx("text", { fg: mutedColor, children: " cursor" }), _jsx("text", { fg: textColor, children: " v" }), _jsxs("text", { fg: mutedColor, children: [" ", selectionAnchor !== null ? "stop" : "select"] }), _jsx("text", { fg: textColor, children: " y" }), _jsx("text", { fg: mutedColor, children: " copy" })] })), _jsx("box", { flexGrow: 1 })] }))] }));
684
+ }
685
+ cli
686
+ .command("[base] [head]", "Show diff for git references (defaults to unstaged changes)")
687
+ .option("--staged", "Show staged changes")
688
+ .option("--commit <ref>", "Show changes from a specific commit")
689
+ .option("--watch", "Watch for file changes and refresh diff")
690
+ .option("--context <lines>", "Number of context lines (default: 6)")
691
+ .option("--filter <pattern>", wrapJsonSchema({
692
+ type: "array",
693
+ items: { type: "string" },
694
+ description: "Filter files by glob pattern (can be used multiple times)",
695
+ }))
696
+ .option("--theme <name>", "Theme to use for rendering")
697
+ .option("--no-italics", "Disable italic text in syntax highlighting")
698
+ .option("--transparent", "Use transparent background")
699
+ .option("--cols <cols>", "Columns for scrollback output (default: terminal width)")
700
+ .option("--stdin", "Read diff from stdin (for use as a pager)")
701
+ .option("--no-stdin", "Ignore piped stdin, always read diff from git")
702
+ .option("--scrollback", "Output to terminal scrollback instead of TUI (auto-enabled when non-TTY)")
703
+ .action(async (base, head, options) => {
704
+ // Auto-detect piped stdin (e.g. `git diff | critique`)
705
+ // Explicit --stdin is for PTY pager integration (lazygit) where stdin IS a TTY
706
+ // --no-stdin forces git diff mode even when stdin is a pipe
707
+ const mightHaveStdin = (options.stdin || !process.stdin.isTTY) && !options.noStdin;
708
+ // Ensure we're inside a git repository before doing anything
709
+ if (!mightHaveStdin) {
710
+ ensureGitRepo();
711
+ }
712
+ // Apply theme and italics if specified (zustand subscription auto-persists)
713
+ if (options.theme && themeNames.includes(options.theme)) {
714
+ useAppStore.setState({ themeName: options.theme });
715
+ }
716
+ if (options.noItalics) {
717
+ useAppStore.setState({ italicsEnabled: false });
718
+ }
719
+ if (options.transparent) {
720
+ useAppStore.setState({ transparentBackground: true });
721
+ }
722
+ // Build git command once (used by all modes)
723
+ const gitCommand = buildGitCommand({
724
+ staged: options.staged,
725
+ commit: options.commit,
726
+ base,
727
+ head,
728
+ context: options.context,
729
+ filter: options.filter,
730
+ positionalFilters: options['--'],
731
+ });
732
+ // Get diff content - from stdin or git
733
+ let diffContent = "";
734
+ let isStdinMode = false;
735
+ if (mightHaveStdin) {
736
+ // Handle stdin mode (for lazygit pager integration or piped input)
737
+ // Lazygit uses --color=always by default, so strip ANSI escape codes
738
+ // before parsing the diff (parsePatch expects plain text)
739
+ diffContent = "";
740
+ for await (const chunk of process.stdin) {
741
+ diffContent += chunk;
742
+ }
743
+ diffContent = stripAnsi(diffContent);
744
+ isStdinMode = true;
745
+ }
746
+ if (!isStdinMode) {
747
+ // Stdin was not requested — read diff from git
748
+ ensureGitRepo();
749
+ const { stdout: gitDiff } = await execAsync(gitCommand, {
750
+ encoding: "utf-8",
751
+ });
752
+ diffContent = gitDiff;
753
+ }
754
+ // Detect default mode (no args): submodule diffs are handled separately
755
+ const isDefaultMode = !options.staged && !options.commit && !base && !head && !isStdinMode;
756
+ // In default mode, append diffs from dirty submodules only.
757
+ // The main git diff uses --ignore-submodules=all, so we separately
758
+ // fetch diffs for submodules that have uncommitted changes.
759
+ // This avoids showing submodule ref changes where the submodule
760
+ // itself has already committed everything.
761
+ if (isDefaultMode) {
762
+ const dirtySubmodules = getDirtySubmodulePaths();
763
+ if (dirtySubmodules.length > 0) {
764
+ const subCmd = buildSubmoduleDiffCommand(dirtySubmodules, {
765
+ context: options.context,
766
+ });
767
+ try {
768
+ const { stdout: subDiff } = await execAsync(subCmd, {
769
+ encoding: "utf-8",
770
+ });
771
+ if (subDiff.trim()) {
772
+ diffContent = diffContent + "\n" + subDiff;
773
+ }
774
+ }
775
+ catch {
776
+ // Submodule diff failed (e.g. submodule not initialized) — skip
777
+ }
778
+ }
779
+ // Append synthetic diffs for untracked files without modifying the git index.
780
+ const untrackedPaths = getUntrackedFilePaths();
781
+ for (const untrackedPath of untrackedPaths) {
782
+ const untrackedDiff = buildUntrackedFileDiff(untrackedPath);
783
+ if (untrackedDiff) {
784
+ diffContent = diffContent + "\n" + untrackedDiff;
785
+ }
786
+ }
787
+ diffContent = await filterCombinedDiffByPatterns(diffContent, {
788
+ filter: options.filter,
789
+ positionalFilters: options['--'],
790
+ });
791
+ }
792
+ // Clean submodule headers once
793
+ const cleanedDiff = stripSubmoduleHeaders(diffContent);
794
+ // Check for empty diff (except for --watch mode which may get content later)
795
+ const shouldWatch = options.watch && !base && !head && !options.commit && !isStdinMode;
796
+ if (!cleanedDiff.trim() && !shouldWatch) {
797
+ console.log("No changes to display");
798
+ process.exit(0);
799
+ }
800
+ // Explicit --stdin forces scrollback (pager mode). Auto-detected piped stdin
801
+ // only forces scrollback when stdout is also non-TTY.
802
+ if (options.scrollback || options.stdin || !process.stdout.isTTY) {
803
+ // For scrollback, prefer terminal width over --cols default (240 is for web)
804
+ const scrollbackCols = process.stdout.columns || parseInt(options.cols) || 120;
805
+ await runScrollbackMode(cleanedDiff, {
806
+ theme: options.theme,
807
+ cols: scrollbackCols,
808
+ });
809
+ return;
810
+ }
811
+ // TUI mode
812
+ try {
813
+ // When diff came from a piped stdin, process.stdin is consumed and cannot
814
+ // be used for keyboard input. Open /dev/tty (the controlling terminal) for
815
+ // keyboard events instead.
816
+ let rendererStdin;
817
+ if (isStdinMode && !options.stdin) {
818
+ try {
819
+ const fs = await import("fs");
820
+ const tty = await import("tty");
821
+ const fd = fs.openSync("/dev/tty", "r");
822
+ rendererStdin = new tty.ReadStream(fd);
823
+ }
824
+ catch {
825
+ // /dev/tty not available — fall back to scrollback
826
+ const scrollbackCols = process.stdout.columns || parseInt(options.cols) || 120;
827
+ await runScrollbackMode(cleanedDiff, {
828
+ theme: options.theme,
829
+ cols: scrollbackCols,
830
+ });
831
+ return;
832
+ }
833
+ }
834
+ // Parallelize diff module loading with renderer creation
835
+ const [diffModule, renderer] = await Promise.all([
836
+ import("diff"),
837
+ createCliRenderer({
838
+ stdin: rendererStdin,
839
+ onDestroy() {
840
+ process.exit(0);
841
+ },
842
+ exitOnCtrlC: true,
843
+ useMouse: true,
844
+ enableMouseMovement: true,
845
+ }),
846
+ ]);
847
+ const { parsePatch, formatPatch } = diffModule;
848
+ // Parse initial diff (already have it, no need to fetch again)
849
+ const initialParsedFiles = cleanedDiff.trim()
850
+ ? processFiles(parseGitDiffFiles(cleanedDiff, parsePatch), formatPatch)
851
+ : [];
852
+ function AppWithWatch() {
853
+ // Use initial parsed files, only re-fetch if watching
854
+ const [parsedFiles, setParsedFiles] = React.useState(shouldWatch ? null : initialParsedFiles);
855
+ const themeName = useAppStore((s) => s.themeName);
856
+ const watchRenderer = useRenderer();
857
+ // Copy selection to clipboard on mouse release
858
+ const { onMouseUp } = useCopySelection();
859
+ // Handle exit keys (Q, Escape) for loading and empty states
860
+ useKeyboard((key) => {
861
+ if (parsedFiles && parsedFiles.length > 0) {
862
+ return;
863
+ }
864
+ if (key.name === "escape" || key.name === "q") {
865
+ watchRenderer.destroy();
866
+ }
867
+ });
868
+ React.useEffect(() => {
869
+ // Skip initial fetch if not watching (we already have the data)
870
+ if (!shouldWatch) {
871
+ return;
872
+ }
873
+ const fetchDiff = async () => {
874
+ try {
875
+ const { stdout: gitDiff } = await execAsync(gitCommand, {
876
+ encoding: "utf-8",
877
+ });
878
+ // In default mode (watch is only enabled in default mode),
879
+ // append dirty submodule diffs
880
+ let fullDiff = gitDiff;
881
+ if (isDefaultMode) {
882
+ const dirtySubmodules = getDirtySubmodulePaths();
883
+ if (dirtySubmodules.length > 0) {
884
+ const subCmd = buildSubmoduleDiffCommand(dirtySubmodules, {
885
+ context: options.context,
886
+ });
887
+ try {
888
+ const { stdout: subDiff } = await execAsync(subCmd, {
889
+ encoding: "utf-8",
890
+ });
891
+ if (subDiff.trim()) {
892
+ fullDiff = fullDiff + "\n" + subDiff;
893
+ }
894
+ }
895
+ catch {
896
+ // Submodule diff failed — skip
897
+ }
898
+ }
899
+ // Append synthetic diffs for untracked files without modifying the git index.
900
+ const untrackedPaths = getUntrackedFilePaths();
901
+ for (const untrackedPath of untrackedPaths) {
902
+ const untrackedDiff = buildUntrackedFileDiff(untrackedPath);
903
+ if (untrackedDiff) {
904
+ fullDiff = fullDiff + "\n" + untrackedDiff;
905
+ }
906
+ }
907
+ }
908
+ if (!fullDiff.trim()) {
909
+ setParsedFiles([]);
910
+ return;
911
+ }
912
+ const files = parseGitDiffFiles(stripSubmoduleHeaders(fullDiff), parsePatch);
913
+ const filteredFiles = isDefaultMode
914
+ ? filterParsedFilesByPatterns(files, {
915
+ filter: options.filter,
916
+ positionalFilters: options['--'],
917
+ })
918
+ : files;
919
+ const processedFiles = processFiles(filteredFiles, formatPatch);
920
+ setParsedFiles(processedFiles);
921
+ }
922
+ catch (error) {
923
+ setParsedFiles([]);
924
+ }
925
+ };
926
+ // Initial fetch for watch mode
927
+ fetchDiff();
928
+ const cwd = process.cwd();
929
+ const debouncedFetch = debounce(() => {
930
+ fetchDiff();
931
+ }, 200);
932
+ let subscription;
933
+ // Lazy-load watcher module only when watching
934
+ getWatcher().then((watcher) => {
935
+ watcher
936
+ .subscribe(cwd, (err, events) => {
937
+ if (err) {
938
+ return;
939
+ }
940
+ if (events.length > 0) {
941
+ debouncedFetch();
942
+ }
943
+ })
944
+ .then((sub) => {
945
+ subscription = sub;
946
+ });
947
+ });
948
+ return () => {
949
+ if (subscription) {
950
+ subscription.unsubscribe();
951
+ }
952
+ };
953
+ }, []);
954
+ const defaultBg = getResolvedTheme(themeName).background;
955
+ if (parsedFiles === null) {
956
+ return (_jsx("box", { onMouseUp: onMouseUp, style: { padding: 1, backgroundColor: defaultBg }, children: _jsx("text", { children: "Loading..." }) }));
957
+ }
958
+ if (parsedFiles.length === 0) {
959
+ return (_jsx("box", { onMouseUp: onMouseUp, style: { padding: 1, backgroundColor: defaultBg }, children: _jsx("text", { children: "No changes to display" }) }));
960
+ }
961
+ return _jsx(App, { parsedFiles: parsedFiles });
962
+ }
963
+ createRoot(renderer).render(
964
+ // @ts-ignore - ErrorBoundary class is incompatible with @opentuah/react's ElementClass + React 19 types; works correctly at runtime
965
+ _jsx(ErrorBoundary, { children: _jsx(AppWithWatch, {}) }));
966
+ }
967
+ catch (error) {
968
+ console.error("Error getting git diff:", error);
969
+ process.exit(1);
970
+ }
971
+ });
972
+ if (import.meta.main) {
973
+ cli.help();
974
+ cli.version(packageJson.version);
975
+ cli.parse();
976
+ }