@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.
- package/dist/ansi-html.d.ts +42 -0
- package/dist/ansi-html.d.ts.map +1 -0
- package/dist/ansi-html.js +327 -0
- package/dist/ansi-output.d.ts +22 -0
- package/dist/ansi-output.d.ts.map +1 -0
- package/dist/ansi-output.js +154 -0
- package/dist/balance-delimiters.d.ts +25 -0
- package/dist/balance-delimiters.d.ts.map +1 -0
- package/dist/balance-delimiters.js +539 -0
- package/dist/balance-delimiters.test.d.ts +2 -0
- package/dist/balance-delimiters.test.d.ts.map +1 -0
- package/dist/balance-delimiters.test.js +1029 -0
- package/dist/cli-copy-notification.test.d.ts +2 -0
- package/dist/cli-copy-notification.test.d.ts.map +1 -0
- package/dist/cli-copy-notification.test.js +80 -0
- package/dist/cli-scroll.test.d.ts +2 -0
- package/dist/cli-scroll.test.d.ts.map +1 -0
- package/dist/cli-scroll.test.js +283 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +976 -0
- package/dist/clipboard.d.ts +16 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +128 -0
- package/dist/components/diff-view.d.ts +32 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.js +123 -0
- package/dist/components/diff-view.test.d.ts +5 -0
- package/dist/components/diff-view.test.d.ts.map +1 -0
- package/dist/components/diff-view.test.js +312 -0
- package/dist/components/directory-tree-view.d.ts +33 -0
- package/dist/components/directory-tree-view.d.ts.map +1 -0
- package/dist/components/directory-tree-view.js +262 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +5 -0
- package/dist/components/toast.d.ts +21 -0
- package/dist/components/toast.d.ts.map +1 -0
- package/dist/components/toast.js +47 -0
- package/dist/diff-cursor-utils.d.ts +20 -0
- package/dist/diff-cursor-utils.d.ts.map +1 -0
- package/dist/diff-cursor-utils.js +105 -0
- package/dist/diff-cursor-utils.test.d.ts +2 -0
- package/dist/diff-cursor-utils.test.d.ts.map +1 -0
- package/dist/diff-cursor-utils.test.js +40 -0
- package/dist/diff-surface-copy.d.ts +23 -0
- package/dist/diff-surface-copy.d.ts.map +1 -0
- package/dist/diff-surface-copy.js +64 -0
- package/dist/diff-surface-copy.test.d.ts +5 -0
- package/dist/diff-surface-copy.test.d.ts.map +1 -0
- package/dist/diff-surface-copy.test.js +142 -0
- package/dist/diff-utils.d.ts +196 -0
- package/dist/diff-utils.d.ts.map +1 -0
- package/dist/diff-utils.js +682 -0
- package/dist/diff-utils.test.d.ts +2 -0
- package/dist/diff-utils.test.d.ts.map +1 -0
- package/dist/diff-utils.test.js +727 -0
- package/dist/directory-tree.d.ts +72 -0
- package/dist/directory-tree.d.ts.map +1 -0
- package/dist/directory-tree.js +161 -0
- package/dist/directory-tree.test.d.ts +2 -0
- package/dist/directory-tree.test.d.ts.map +1 -0
- package/dist/directory-tree.test.js +383 -0
- package/dist/dropdown.d.ts +26 -0
- package/dist/dropdown.d.ts.map +1 -0
- package/dist/dropdown.js +172 -0
- package/dist/dropdown.test.d.ts +2 -0
- package/dist/dropdown.test.d.ts.map +1 -0
- package/dist/dropdown.test.js +106 -0
- package/dist/filter-submodule.e2e.test.d.ts +2 -0
- package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
- package/dist/filter-submodule.e2e.test.js +109 -0
- package/dist/hooks/use-copy-selection.d.ts +29 -0
- package/dist/hooks/use-copy-selection.d.ts.map +1 -0
- package/dist/hooks/use-copy-selection.js +46 -0
- package/dist/kv-codec.d.ts +16 -0
- package/dist/kv-codec.d.ts.map +1 -0
- package/dist/kv-codec.js +36 -0
- package/dist/license.d.ts +14 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +63 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +78 -0
- package/dist/monochrome.d.ts +34 -0
- package/dist/monochrome.d.ts.map +1 -0
- package/dist/monochrome.js +613 -0
- package/dist/monotone.d.ts +22 -0
- package/dist/monotone.d.ts.map +1 -0
- package/dist/monotone.js +185 -0
- package/dist/parsers-config.d.ts +19 -0
- package/dist/parsers-config.d.ts.map +1 -0
- package/dist/parsers-config.js +271 -0
- package/dist/patch-terminal-dimensions.d.ts +2 -0
- package/dist/patch-terminal-dimensions.d.ts.map +1 -0
- package/dist/patch-terminal-dimensions.js +45 -0
- package/dist/stdin-pager.test.d.ts +2 -0
- package/dist/stdin-pager.test.d.ts.map +1 -0
- package/dist/stdin-pager.test.js +497 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +48 -0
- package/dist/themes/github.json +247 -0
- package/dist/themes.d.ts +59 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +248 -0
- package/dist/tree-icons.d.ts +4 -0
- package/dist/tree-icons.d.ts.map +1 -0
- package/dist/tree-icons.js +18 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +13 -0
- package/dist/web-utils.d.ts +56 -0
- package/dist/web-utils.d.ts.map +1 -0
- package/dist/web-utils.js +363 -0
- package/package.json +37 -0
- package/public/jetbrains-mono-nerd.ttf +0 -0
- 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
|
+
}
|