critique 0.1.43 → 0.1.47
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/CHANGELOG.md +28 -0
- package/package.json +6 -5
- package/src/cli.tsx +83 -6
- package/src/components/directory-tree-view.tsx +133 -0
- package/src/components/index.ts +1 -0
- package/src/diff-utils.ts +51 -5
- package/src/directory-tree.test.tsx +332 -0
- package/src/directory-tree.ts +200 -0
- package/src/review/review-app.tsx +94 -4
- package/src/themes.ts +28 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
# 0.1.47
|
|
2
|
+
|
|
3
|
+
- Add vim-style keyboard navigation:
|
|
4
|
+
- `G` (Shift+g) - scroll to bottom
|
|
5
|
+
- `gg` (double-tap g) - scroll to top
|
|
6
|
+
- `Ctrl+D` - half page down
|
|
7
|
+
- `Ctrl+U` - half page up
|
|
8
|
+
- `review` command: change debug console toggle from `Ctrl+D` to `Ctrl+Z` (consistent with main viewer)
|
|
9
|
+
- Fix theme loading on Windows by using `fileURLToPath` for proper path conversion
|
|
10
|
+
|
|
11
|
+
# 0.1.46
|
|
12
|
+
|
|
13
|
+
- Add directory tree view at top of diff TUIs (default, review, web commands)
|
|
14
|
+
- Switch opentui packages to npm releases (from pkg.pr.new preview URLs)
|
|
15
|
+
- Add missing `marked` dependency
|
|
16
|
+
- Fix Q and Escape keys not working to exit when there are no changes to display (fixes #16)
|
|
17
|
+
|
|
18
|
+
# 0.1.45
|
|
19
|
+
|
|
20
|
+
- Support git range syntax in single argument: `critique origin/main...HEAD` or `critique main..feature`
|
|
21
|
+
|
|
22
|
+
# 0.1.44
|
|
23
|
+
|
|
24
|
+
- Fix parsing error with submodule status lines:
|
|
25
|
+
- Handle "Submodule name contains modified content" lines
|
|
26
|
+
- Handle "Submodule name contains untracked content" lines
|
|
27
|
+
- Handle "Submodule name (new commits)" and similar status lines
|
|
28
|
+
|
|
1
29
|
# 0.1.43
|
|
2
30
|
|
|
3
31
|
- Show full submodule diffs instead of just commit hashes:
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "critique",
|
|
3
3
|
"module": "src/diff.tsx",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.47",
|
|
6
6
|
"private": false,
|
|
7
7
|
"bin": "./src/cli.tsx",
|
|
8
8
|
"scripts": {
|
|
@@ -22,15 +22,16 @@
|
|
|
22
22
|
"wrangler": "^4.19.1"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agentclientprotocol/sdk": "^0.
|
|
25
|
+
"@agentclientprotocol/sdk": "^0.13.1",
|
|
26
26
|
"@clack/prompts": "1.0.0-alpha.9",
|
|
27
|
-
"@opentui/core": "
|
|
28
|
-
"@opentui/react": "
|
|
29
|
-
"@parcel/watcher": "^2.5.
|
|
27
|
+
"@opentui/core": "^0",
|
|
28
|
+
"@opentui/react": "^0",
|
|
29
|
+
"@parcel/watcher": "^2.5.6",
|
|
30
30
|
"cac": "^6.7.14",
|
|
31
31
|
"diff": "^8.0.2",
|
|
32
32
|
"ghostty-opentui": "^1.3.12",
|
|
33
33
|
"js-yaml": "^4.1.1",
|
|
34
|
+
"marked": "^17.0.1",
|
|
34
35
|
"picocolors": "^1.1.1",
|
|
35
36
|
"react": "^19.2.0",
|
|
36
37
|
"zustand": "^5.0.8"
|
package/src/cli.tsx
CHANGED
|
@@ -27,11 +27,12 @@ import { join } from "path";
|
|
|
27
27
|
import { create } from "zustand";
|
|
28
28
|
import Dropdown from "./dropdown.tsx";
|
|
29
29
|
import { debounce } from "./utils.ts";
|
|
30
|
-
import { DiffView } from "./components/
|
|
30
|
+
import { DiffView, DirectoryTreeView } from "./components/index.ts";
|
|
31
31
|
import { logger } from "./logger.ts";
|
|
32
32
|
import {
|
|
33
33
|
buildGitCommand,
|
|
34
34
|
getFileName,
|
|
35
|
+
getFileStatus,
|
|
35
36
|
countChanges,
|
|
36
37
|
getViewMode,
|
|
37
38
|
processFiles,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
IGNORED_FILES,
|
|
41
42
|
type ParsedFile,
|
|
42
43
|
} from "./diff-utils.ts";
|
|
44
|
+
import type { TreeFileInfo } from "./directory-tree.ts";
|
|
43
45
|
|
|
44
46
|
// Lazy-load watcher only when --watch is used
|
|
45
47
|
let watcherModule: typeof import("@parcel/watcher") | null = null;
|
|
@@ -1258,6 +1260,9 @@ function App({ parsedFiles }: AppProps) {
|
|
|
1258
1260
|
const scrollboxRef = React.useRef<ScrollBoxRenderable | null>(null);
|
|
1259
1261
|
const fileRefs = React.useRef<Map<number, BoxRenderable>>(new Map());
|
|
1260
1262
|
|
|
1263
|
+
// Ref for double-tap detection (gg)
|
|
1264
|
+
const lastKeyRef = React.useRef<{ key: string; time: number } | null>(null);
|
|
1265
|
+
|
|
1261
1266
|
useOnResize(
|
|
1262
1267
|
React.useCallback((newWidth: number) => {
|
|
1263
1268
|
setWidth(newWidth);
|
|
@@ -1293,7 +1298,43 @@ function App({ parsedFiles }: AppProps) {
|
|
|
1293
1298
|
|
|
1294
1299
|
if (key.name === "z" && key.ctrl) {
|
|
1295
1300
|
renderer.console.toggle();
|
|
1301
|
+
return;
|
|
1296
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
// Vim-style scroll navigation
|
|
1305
|
+
const scrollbox = scrollboxRef.current;
|
|
1306
|
+
if (scrollbox) {
|
|
1307
|
+
// G - go to bottom
|
|
1308
|
+
if (key.name === "g" && key.shift) {
|
|
1309
|
+
scrollbox.scrollBy(1, "content");
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// gg - go to top (double-tap within 300ms)
|
|
1314
|
+
if (key.name === "g" && !key.shift && !key.ctrl) {
|
|
1315
|
+
const now = Date.now();
|
|
1316
|
+
if (lastKeyRef.current?.key === "g" && now - lastKeyRef.current.time < 300) {
|
|
1317
|
+
scrollbox.scrollTo(0);
|
|
1318
|
+
lastKeyRef.current = null;
|
|
1319
|
+
} else {
|
|
1320
|
+
lastKeyRef.current = { key: "g", time: now };
|
|
1321
|
+
}
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Ctrl+D - half page down
|
|
1326
|
+
if (key.ctrl && key.name === "d") {
|
|
1327
|
+
scrollbox.scrollBy(0.5, "viewport");
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Ctrl+U - half page up
|
|
1332
|
+
if (key.ctrl && key.name === "u") {
|
|
1333
|
+
scrollbox.scrollBy(-0.5, "viewport");
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1297
1338
|
if (key.option) {
|
|
1298
1339
|
if (key.eventType === "release") {
|
|
1299
1340
|
scrollAcceleration.multiplier = 1;
|
|
@@ -1332,10 +1373,20 @@ function App({ parsedFiles }: AppProps) {
|
|
|
1332
1373
|
};
|
|
1333
1374
|
});
|
|
1334
1375
|
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1376
|
+
// Build tree data for directory tree view
|
|
1377
|
+
const treeFiles: TreeFileInfo[] = parsedFiles.map((file, idx) => {
|
|
1378
|
+
const { additions, deletions } = countChanges(file.hunks);
|
|
1379
|
+
return {
|
|
1380
|
+
path: getFileName(file),
|
|
1381
|
+
status: getFileStatus(file),
|
|
1382
|
+
additions,
|
|
1383
|
+
deletions,
|
|
1384
|
+
fileIndex: idx,
|
|
1385
|
+
};
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// Scroll to file by index
|
|
1389
|
+
const scrollToFile = (index: number) => {
|
|
1339
1390
|
const scrollbox = scrollboxRef.current;
|
|
1340
1391
|
const fileRef = fileRefs.current.get(index);
|
|
1341
1392
|
if (scrollbox && fileRef) {
|
|
@@ -1343,10 +1394,18 @@ function App({ parsedFiles }: AppProps) {
|
|
|
1343
1394
|
const targetY = fileRef.y - contentY;
|
|
1344
1395
|
scrollbox.scrollTo(Math.max(0, targetY));
|
|
1345
1396
|
}
|
|
1346
|
-
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
const handleFileSelect = (value: string) => {
|
|
1400
|
+
const index = parseInt(value, 10);
|
|
1401
|
+
scrollToFile(index);
|
|
1347
1402
|
setShowDropdown(false);
|
|
1348
1403
|
};
|
|
1349
1404
|
|
|
1405
|
+
const handleTreeFileSelect = (fileIndex: number) => {
|
|
1406
|
+
scrollToFile(fileIndex);
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1350
1409
|
const themeOptions = themeNames.map((name) => ({
|
|
1351
1410
|
title: name,
|
|
1352
1411
|
value: name,
|
|
@@ -1365,6 +1424,15 @@ function App({ parsedFiles }: AppProps) {
|
|
|
1365
1424
|
// Render all files content (used in both theme picker preview and main view)
|
|
1366
1425
|
const renderAllFiles = () => (
|
|
1367
1426
|
<box style={{ flexDirection: "column" }}>
|
|
1427
|
+
{/* Directory tree at the top */}
|
|
1428
|
+
<box style={{ marginBottom: 2 }}>
|
|
1429
|
+
<DirectoryTreeView
|
|
1430
|
+
files={treeFiles}
|
|
1431
|
+
onFileSelect={handleTreeFileSelect}
|
|
1432
|
+
themeName={activeTheme}
|
|
1433
|
+
/>
|
|
1434
|
+
</box>
|
|
1435
|
+
|
|
1368
1436
|
{parsedFiles.map((file, idx) => {
|
|
1369
1437
|
const fileName = getFileName(file);
|
|
1370
1438
|
const filetype = detectFiletype(fileName);
|
|
@@ -1612,6 +1680,15 @@ cli
|
|
|
1612
1680
|
ParsedFile[] | null
|
|
1613
1681
|
>(null);
|
|
1614
1682
|
|
|
1683
|
+
const watchRenderer = useRenderer();
|
|
1684
|
+
|
|
1685
|
+
// Handle exit keys (Q, Escape) for loading and empty states
|
|
1686
|
+
useKeyboard((key) => {
|
|
1687
|
+
if (key.name === "escape" || key.name === "q") {
|
|
1688
|
+
watchRenderer.destroy();
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1615
1692
|
React.useEffect(() => {
|
|
1616
1693
|
const fetchDiff = async () => {
|
|
1617
1694
|
try {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// DirectoryTreeView - Renders a directory tree with file status colors and change counts.
|
|
2
|
+
// Shows added files in green, modified in orange, deleted in red.
|
|
3
|
+
// Change counts (+n,-n) use green/red for the numbers, brackets are muted.
|
|
4
|
+
// Supports click-to-scroll via onFileSelect callback.
|
|
5
|
+
|
|
6
|
+
import * as React from "react"
|
|
7
|
+
import { buildDirectoryTree, type TreeFileInfo, type TreeNode } from "../directory-tree.ts"
|
|
8
|
+
import { getResolvedTheme, rgbaToHex } from "../themes.ts"
|
|
9
|
+
|
|
10
|
+
export interface DirectoryTreeViewProps {
|
|
11
|
+
/** Files to display in the tree */
|
|
12
|
+
files: TreeFileInfo[]
|
|
13
|
+
/** Callback when a file is clicked (receives fileIndex) */
|
|
14
|
+
onFileSelect?: (fileIndex: number) => void
|
|
15
|
+
/** Theme name for colors */
|
|
16
|
+
themeName: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the color for a file based on its status
|
|
21
|
+
* Uses diff colors from theme: green (added), red (deleted), default text (modified)
|
|
22
|
+
*/
|
|
23
|
+
function getStatusColor(status: "added" | "modified" | "deleted", theme: ReturnType<typeof getResolvedTheme>): string {
|
|
24
|
+
switch (status) {
|
|
25
|
+
case "added":
|
|
26
|
+
return rgbaToHex(theme.diffAdded) // green
|
|
27
|
+
case "deleted":
|
|
28
|
+
return rgbaToHex(theme.diffRemoved) // red
|
|
29
|
+
case "modified":
|
|
30
|
+
return rgbaToHex(theme.text) // default text color, same as folders
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TreeNodeLineProps {
|
|
35
|
+
node: TreeNode
|
|
36
|
+
theme: ReturnType<typeof getResolvedTheme>
|
|
37
|
+
mutedColor: string
|
|
38
|
+
textColor: string
|
|
39
|
+
onSelect?: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render a single tree node line with proper colors
|
|
44
|
+
*/
|
|
45
|
+
const TreeNodeLine: React.FC<TreeNodeLineProps> = ({
|
|
46
|
+
node,
|
|
47
|
+
theme,
|
|
48
|
+
mutedColor,
|
|
49
|
+
textColor,
|
|
50
|
+
onSelect,
|
|
51
|
+
}) => {
|
|
52
|
+
const [isHovered, setIsHovered] = React.useState(false)
|
|
53
|
+
|
|
54
|
+
if (node.isFile) {
|
|
55
|
+
// File node - colorize based on status
|
|
56
|
+
const pathColor = node.status ? getStatusColor(node.status, theme) : textColor
|
|
57
|
+
const addColor = rgbaToHex(theme.diffAdded) // green
|
|
58
|
+
const delColor = rgbaToHex(theme.diffRemoved) // red
|
|
59
|
+
const hasAdditions = (node.additions ?? 0) > 0
|
|
60
|
+
const hasDeletions = (node.deletions ?? 0) > 0
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<box
|
|
64
|
+
style={{
|
|
65
|
+
flexDirection: "row",
|
|
66
|
+
backgroundColor: isHovered ? rgbaToHex(theme.backgroundPanel) : undefined,
|
|
67
|
+
}}
|
|
68
|
+
onMouseMove={() => setIsHovered(true)}
|
|
69
|
+
onMouseOut={() => setIsHovered(false)}
|
|
70
|
+
onMouseDown={onSelect}
|
|
71
|
+
>
|
|
72
|
+
<text fg={mutedColor}>{node.prefix}{node.connector}</text>
|
|
73
|
+
<text fg={pathColor}>{node.displayPath}</text>
|
|
74
|
+
<text fg={mutedColor}> (</text>
|
|
75
|
+
{hasAdditions && <text fg={addColor}>+{node.additions}</text>}
|
|
76
|
+
{hasAdditions && hasDeletions && <text fg={mutedColor}>,</text>}
|
|
77
|
+
{hasDeletions && <text fg={delColor}>-{node.deletions}</text>}
|
|
78
|
+
<text fg={mutedColor}>)</text>
|
|
79
|
+
</box>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Directory node - use muted color for everything
|
|
84
|
+
return (
|
|
85
|
+
<box style={{ flexDirection: "row" }}>
|
|
86
|
+
<text fg={mutedColor}>{node.prefix}{node.connector}</text>
|
|
87
|
+
<text fg={textColor}>{node.displayPath}</text>
|
|
88
|
+
</box>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* DirectoryTreeView component
|
|
94
|
+
* Renders a directory tree with file status colors and click-to-scroll support
|
|
95
|
+
*/
|
|
96
|
+
export function DirectoryTreeView({
|
|
97
|
+
files,
|
|
98
|
+
onFileSelect,
|
|
99
|
+
themeName,
|
|
100
|
+
}: DirectoryTreeViewProps) {
|
|
101
|
+
const nodes = React.useMemo(() => buildDirectoryTree(files), [files])
|
|
102
|
+
const resolvedTheme = getResolvedTheme(themeName)
|
|
103
|
+
const mutedColor = rgbaToHex(resolvedTheme.textMuted)
|
|
104
|
+
const textColor = rgbaToHex(resolvedTheme.text)
|
|
105
|
+
|
|
106
|
+
if (nodes.length === 0) {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<box
|
|
112
|
+
style={{
|
|
113
|
+
alignSelf: "center",
|
|
114
|
+
flexDirection: "column",
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
{nodes.map((node, idx) => (
|
|
118
|
+
<TreeNodeLine
|
|
119
|
+
key={idx}
|
|
120
|
+
node={node}
|
|
121
|
+
theme={resolvedTheme}
|
|
122
|
+
mutedColor={mutedColor}
|
|
123
|
+
textColor={textColor}
|
|
124
|
+
onSelect={
|
|
125
|
+
node.isFile && node.fileIndex !== undefined && onFileSelect
|
|
126
|
+
? () => onFileSelect(node.fileIndex!)
|
|
127
|
+
: undefined
|
|
128
|
+
}
|
|
129
|
+
/>
|
|
130
|
+
))}
|
|
131
|
+
</box>
|
|
132
|
+
)
|
|
133
|
+
}
|
package/src/components/index.ts
CHANGED
package/src/diff-utils.ts
CHANGED
|
@@ -3,15 +3,27 @@
|
|
|
3
3
|
// and provides helpers for unified/split view mode selection.
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Strip submodule
|
|
7
|
-
* git diff --submodule=diff adds
|
|
8
|
-
*
|
|
6
|
+
* Strip submodule status lines from git diff output.
|
|
7
|
+
* git diff --submodule=diff adds various status lines that the diff parser doesn't understand:
|
|
8
|
+
* - "Submodule name hash1..hash2:" (header before submodule diff)
|
|
9
|
+
* - "Submodule name contains modified content"
|
|
10
|
+
* - "Submodule name contains untracked content"
|
|
11
|
+
* - "Submodule name (new commits)"
|
|
12
|
+
* - "Submodule name (commits not present)"
|
|
9
13
|
*/
|
|
10
14
|
export function stripSubmoduleHeaders(diffOutput: string): string {
|
|
11
|
-
// Match lines like "Submodule errore 1bf6fc8..d746b25:"
|
|
12
15
|
return diffOutput
|
|
13
16
|
.split("\n")
|
|
14
|
-
.filter((line) =>
|
|
17
|
+
.filter((line) => {
|
|
18
|
+
// Match lines like "Submodule errore 1bf6fc8..d746b25:"
|
|
19
|
+
if (line.match(/^Submodule \S+ [a-f0-9]+\.\.[a-f0-9]+:?$/)) return false;
|
|
20
|
+
// Match lines like "Submodule unframer contains modified content"
|
|
21
|
+
if (line.match(/^Submodule \S+ contains (modified|untracked) content$/))
|
|
22
|
+
return false;
|
|
23
|
+
// Match lines like "Submodule name (new commits)" or "(commits not present)"
|
|
24
|
+
if (line.match(/^Submodule \S+ \(.*\)$/)) return false;
|
|
25
|
+
return true;
|
|
26
|
+
})
|
|
15
27
|
.join("\n");
|
|
16
28
|
}
|
|
17
29
|
|
|
@@ -76,6 +88,22 @@ export function buildGitCommand(options: GitCommandOptions): string {
|
|
|
76
88
|
if (options.base && options.head) {
|
|
77
89
|
return `git diff ${options.base}...${options.head} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
78
90
|
}
|
|
91
|
+
// Detect range syntax in single base argument (e.g., "origin/main...HEAD" or "main..feature")
|
|
92
|
+
if (options.base && !options.head) {
|
|
93
|
+
// Three-dot syntax: A...B (merge-base to B, like GitHub PRs)
|
|
94
|
+
const threeDotsMatch = options.base.match(/^(.+)\.\.\.(.+)$/);
|
|
95
|
+
if (threeDotsMatch) {
|
|
96
|
+
const [, rangeBase, rangeHead] = threeDotsMatch;
|
|
97
|
+
return `git diff ${rangeBase}...${rangeHead} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Two-dot syntax: A..B (commits in B not in A)
|
|
101
|
+
const twoDotsMatch = options.base.match(/^(.+)\.\.(.+)$/);
|
|
102
|
+
if (twoDotsMatch) {
|
|
103
|
+
const [, rangeBase, rangeHead] = twoDotsMatch;
|
|
104
|
+
return `git diff ${rangeBase}..${rangeHead} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
79
107
|
// Single ref: show that commit's changes
|
|
80
108
|
if (options.base) {
|
|
81
109
|
return `git show ${options.base} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
@@ -83,6 +111,24 @@ export function buildGitCommand(options: GitCommandOptions): string {
|
|
|
83
111
|
return `git add -N . && git diff --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
|
|
84
112
|
}
|
|
85
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Get file status from parsed diff file based on /dev/null presence
|
|
116
|
+
* - added: oldFileName is /dev/null (new file)
|
|
117
|
+
* - deleted: newFileName is /dev/null (removed file)
|
|
118
|
+
* - modified: both files exist (changed file)
|
|
119
|
+
*/
|
|
120
|
+
export function getFileStatus(file: {
|
|
121
|
+
oldFileName?: string;
|
|
122
|
+
newFileName?: string;
|
|
123
|
+
}): "added" | "modified" | "deleted" {
|
|
124
|
+
const oldName = file.oldFileName;
|
|
125
|
+
const newName = file.newFileName;
|
|
126
|
+
|
|
127
|
+
if (!oldName || oldName === "/dev/null") return "added";
|
|
128
|
+
if (!newName || newName === "/dev/null") return "deleted";
|
|
129
|
+
return "modified";
|
|
130
|
+
}
|
|
131
|
+
|
|
86
132
|
/**
|
|
87
133
|
* Get filename from parsed diff file, handling /dev/null for new/deleted files
|
|
88
134
|
*/
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Tests for directory tree building and rendering
|
|
2
|
+
// Uses opentui test renderer with captureCharFrame() for visual testing
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it } from "bun:test"
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils"
|
|
6
|
+
import { buildDirectoryTree, type TreeFileInfo, type TreeNode } from "./directory-tree.ts"
|
|
7
|
+
import { DirectoryTreeView } from "./components/directory-tree-view.tsx"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Simple component to render tree nodes as text for testing
|
|
11
|
+
*/
|
|
12
|
+
function TreeRenderer({ nodes }: { nodes: TreeNode[] }) {
|
|
13
|
+
return (
|
|
14
|
+
<box style={{ flexDirection: "column" }}>
|
|
15
|
+
{nodes.map((node, idx) => {
|
|
16
|
+
// Build the line: prefix + connector + path + optional stats
|
|
17
|
+
const statsStr = node.isFile
|
|
18
|
+
? ` (+${node.additions},-${node.deletions})`
|
|
19
|
+
: ""
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<text key={idx}>
|
|
23
|
+
{node.prefix}
|
|
24
|
+
{node.connector}
|
|
25
|
+
{node.displayPath}
|
|
26
|
+
{statsStr}
|
|
27
|
+
</text>
|
|
28
|
+
)
|
|
29
|
+
})}
|
|
30
|
+
</box>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("buildDirectoryTree", () => {
|
|
35
|
+
it("should return empty array for no files", () => {
|
|
36
|
+
const result = buildDirectoryTree([])
|
|
37
|
+
expect(result).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should handle single file at root", () => {
|
|
41
|
+
const files: TreeFileInfo[] = [
|
|
42
|
+
{ path: "README.md", status: "modified", additions: 5, deletions: 2 },
|
|
43
|
+
]
|
|
44
|
+
const result = buildDirectoryTree(files)
|
|
45
|
+
expect(result).toHaveLength(1)
|
|
46
|
+
expect(result[0]!.displayPath).toBe("README.md")
|
|
47
|
+
expect(result[0]!.isFile).toBe(true)
|
|
48
|
+
expect(result[0]!.status).toBe("modified")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("should collapse single-child directories", () => {
|
|
52
|
+
const files: TreeFileInfo[] = [
|
|
53
|
+
{ path: "src/components/Button.tsx", status: "added", additions: 50, deletions: 0 },
|
|
54
|
+
]
|
|
55
|
+
const result = buildDirectoryTree(files)
|
|
56
|
+
// Should collapse src/components into one directory node
|
|
57
|
+
expect(result).toHaveLength(2)
|
|
58
|
+
expect(result[0]!.displayPath).toBe("src/components")
|
|
59
|
+
expect(result[0]!.isFile).toBe(false)
|
|
60
|
+
expect(result[1]!.displayPath).toBe("Button.tsx")
|
|
61
|
+
expect(result[1]!.isFile).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("TreeRenderer visual tests", () => {
|
|
66
|
+
let testSetup: Awaited<ReturnType<typeof testRender>>
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
if (testSetup) {
|
|
70
|
+
testSetup.renderer.destroy()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("should render single file", async () => {
|
|
75
|
+
const files: TreeFileInfo[] = [
|
|
76
|
+
{ path: "package.json", status: "modified", additions: 1, deletions: 1 },
|
|
77
|
+
]
|
|
78
|
+
const nodes = buildDirectoryTree(files)
|
|
79
|
+
|
|
80
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
81
|
+
width: 50,
|
|
82
|
+
height: 5,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await testSetup.renderOnce()
|
|
86
|
+
const frame = testSetup.captureCharFrame()
|
|
87
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
88
|
+
"└── package.json (+1,-1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
"
|
|
94
|
+
`)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("should render multiple root files", async () => {
|
|
98
|
+
const files: TreeFileInfo[] = [
|
|
99
|
+
{ path: "package.json", status: "modified", additions: 1, deletions: 1 },
|
|
100
|
+
{ path: "README.md", status: "added", additions: 20, deletions: 0 },
|
|
101
|
+
{ path: "tsconfig.json", status: "deleted", additions: 0, deletions: 15 },
|
|
102
|
+
]
|
|
103
|
+
const nodes = buildDirectoryTree(files)
|
|
104
|
+
|
|
105
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
106
|
+
width: 50,
|
|
107
|
+
height: 7,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await testSetup.renderOnce()
|
|
111
|
+
const frame = testSetup.captureCharFrame()
|
|
112
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
113
|
+
"├── package.json (+1,-1)
|
|
114
|
+
├── README.md (+20,-0)
|
|
115
|
+
└── tsconfig.json (+0,-15)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
"
|
|
121
|
+
`)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("should render nested directories with proper connectors", async () => {
|
|
125
|
+
const files: TreeFileInfo[] = [
|
|
126
|
+
{ path: "src/index.ts", status: "modified", additions: 5, deletions: 2 },
|
|
127
|
+
{ path: "src/utils.ts", status: "added", additions: 30, deletions: 0 },
|
|
128
|
+
]
|
|
129
|
+
const nodes = buildDirectoryTree(files)
|
|
130
|
+
|
|
131
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
132
|
+
width: 50,
|
|
133
|
+
height: 7,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await testSetup.renderOnce()
|
|
137
|
+
const frame = testSetup.captureCharFrame()
|
|
138
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
139
|
+
"└── src
|
|
140
|
+
├── index.ts (+5,-2)
|
|
141
|
+
└── utils.ts (+30,-0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
"
|
|
147
|
+
`)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("should collapse single-child directories", async () => {
|
|
151
|
+
const files: TreeFileInfo[] = [
|
|
152
|
+
{ path: "src/components/Button.tsx", status: "added", additions: 50, deletions: 0 },
|
|
153
|
+
{ path: "src/components/Input.tsx", status: "added", additions: 40, deletions: 0 },
|
|
154
|
+
]
|
|
155
|
+
const nodes = buildDirectoryTree(files)
|
|
156
|
+
|
|
157
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
158
|
+
width: 50,
|
|
159
|
+
height: 7,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
await testSetup.renderOnce()
|
|
163
|
+
const frame = testSetup.captureCharFrame()
|
|
164
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
165
|
+
"└── src/components
|
|
166
|
+
├── Button.tsx (+50,-0)
|
|
167
|
+
└── Input.tsx (+40,-0)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
"
|
|
173
|
+
`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("should render complex nested structure", async () => {
|
|
177
|
+
const files: TreeFileInfo[] = [
|
|
178
|
+
{ path: "package.json", status: "modified", additions: 2, deletions: 1, fileIndex: 0 },
|
|
179
|
+
{ path: "src/index.ts", status: "modified", additions: 10, deletions: 5, fileIndex: 1 },
|
|
180
|
+
{ path: "src/components/Button.tsx", status: "added", additions: 50, deletions: 0, fileIndex: 2 },
|
|
181
|
+
{ path: "src/components/Input.tsx", status: "modified", additions: 15, deletions: 8, fileIndex: 3 },
|
|
182
|
+
{ path: "src/utils/helpers.ts", status: "deleted", additions: 0, deletions: 30, fileIndex: 4 },
|
|
183
|
+
{ path: "tests/index.test.ts", status: "added", additions: 25, deletions: 0, fileIndex: 5 },
|
|
184
|
+
]
|
|
185
|
+
const nodes = buildDirectoryTree(files)
|
|
186
|
+
|
|
187
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
188
|
+
width: 60,
|
|
189
|
+
height: 15,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await testSetup.renderOnce()
|
|
193
|
+
const frame = testSetup.captureCharFrame()
|
|
194
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
195
|
+
"├── package.json (+2,-1)
|
|
196
|
+
├── src
|
|
197
|
+
│ ├── index.ts (+10,-5)
|
|
198
|
+
│ ├── components
|
|
199
|
+
│ │ ├── Button.tsx (+50,-0)
|
|
200
|
+
│ │ └── Input.tsx (+15,-8)
|
|
201
|
+
│ └── utils
|
|
202
|
+
│ └── helpers.ts (+0,-30)
|
|
203
|
+
└── tests
|
|
204
|
+
└── index.test.ts (+25,-0)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
"
|
|
211
|
+
`)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("should handle deeply nested paths with collapse", async () => {
|
|
215
|
+
const files: TreeFileInfo[] = [
|
|
216
|
+
{ path: "packages/core/src/lib/utils/helpers.ts", status: "modified", additions: 5, deletions: 3 },
|
|
217
|
+
{ path: "packages/core/src/lib/utils/format.ts", status: "added", additions: 20, deletions: 0 },
|
|
218
|
+
]
|
|
219
|
+
const nodes = buildDirectoryTree(files)
|
|
220
|
+
|
|
221
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
222
|
+
width: 60,
|
|
223
|
+
height: 7,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await testSetup.renderOnce()
|
|
227
|
+
const frame = testSetup.captureCharFrame()
|
|
228
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
229
|
+
"└── packages/core/src/lib/utils
|
|
230
|
+
├── helpers.ts (+5,-3)
|
|
231
|
+
└── format.ts (+20,-0)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
"
|
|
237
|
+
`)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("should handle sibling directories at different levels", async () => {
|
|
241
|
+
const files: TreeFileInfo[] = [
|
|
242
|
+
{ path: "src/api/routes.ts", status: "modified", additions: 10, deletions: 5 },
|
|
243
|
+
{ path: "src/api/handlers.ts", status: "added", additions: 30, deletions: 0 },
|
|
244
|
+
{ path: "src/db/models.ts", status: "modified", additions: 8, deletions: 2 },
|
|
245
|
+
{ path: "lib/utils.ts", status: "added", additions: 15, deletions: 0 },
|
|
246
|
+
]
|
|
247
|
+
const nodes = buildDirectoryTree(files)
|
|
248
|
+
|
|
249
|
+
testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
|
|
250
|
+
width: 60,
|
|
251
|
+
height: 12,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await testSetup.renderOnce()
|
|
255
|
+
const frame = testSetup.captureCharFrame()
|
|
256
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
257
|
+
"├── src
|
|
258
|
+
│ ├── api
|
|
259
|
+
│ │ ├── routes.ts (+10,-5)
|
|
260
|
+
│ │ └── handlers.ts (+30,-0)
|
|
261
|
+
│ └── db
|
|
262
|
+
│ └── models.ts (+8,-2)
|
|
263
|
+
└── lib
|
|
264
|
+
└── utils.ts (+15,-0)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
"
|
|
270
|
+
`)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe("DirectoryTreeView component", () => {
|
|
275
|
+
let testSetup: Awaited<ReturnType<typeof testRender>>
|
|
276
|
+
|
|
277
|
+
afterEach(() => {
|
|
278
|
+
if (testSetup) {
|
|
279
|
+
testSetup.renderer.destroy()
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("should render tree without border", async () => {
|
|
284
|
+
const files: TreeFileInfo[] = [
|
|
285
|
+
{ path: "src/index.ts", status: "modified", additions: 5, deletions: 2, fileIndex: 0 },
|
|
286
|
+
{ path: "src/utils.ts", status: "added", additions: 30, deletions: 0, fileIndex: 1 },
|
|
287
|
+
{ path: "README.md", status: "deleted", additions: 0, deletions: 15, fileIndex: 2 },
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
testSetup = await testRender(
|
|
291
|
+
<DirectoryTreeView files={files} themeName="github" />,
|
|
292
|
+
{ width: 60, height: 12 },
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
await testSetup.renderOnce()
|
|
296
|
+
const frame = testSetup.captureCharFrame()
|
|
297
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
298
|
+
" ├── src
|
|
299
|
+
│ ├── index.ts (+5,-2)
|
|
300
|
+
│ └── utils.ts (+30)
|
|
301
|
+
└── README.md (-15)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
"
|
|
311
|
+
`)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("should render empty when no files", async () => {
|
|
315
|
+
testSetup = await testRender(
|
|
316
|
+
<DirectoryTreeView files={[]} themeName="github" />,
|
|
317
|
+
{ width: 40, height: 5 },
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
await testSetup.renderOnce()
|
|
321
|
+
const frame = testSetup.captureCharFrame()
|
|
322
|
+
// Should render nothing (DirectoryTreeView returns null for empty)
|
|
323
|
+
expect(frame).toMatchInlineSnapshot(`
|
|
324
|
+
"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
"
|
|
330
|
+
`)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Directory tree builder for displaying file changes in a tree structure.
|
|
2
|
+
// Builds a collapsible tree from file paths with status colors and change counts.
|
|
3
|
+
// Returns structured nodes that can be rendered by DirectoryTreeView component.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File status based on git diff
|
|
7
|
+
*/
|
|
8
|
+
export type FileStatus = "added" | "modified" | "deleted"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Input file info for tree building
|
|
12
|
+
*/
|
|
13
|
+
export interface TreeFileInfo {
|
|
14
|
+
path: string
|
|
15
|
+
status: FileStatus
|
|
16
|
+
additions: number
|
|
17
|
+
deletions: number
|
|
18
|
+
/** Optional index for scroll-to functionality */
|
|
19
|
+
fileIndex?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single node in the rendered tree output
|
|
24
|
+
*/
|
|
25
|
+
export interface TreeNode {
|
|
26
|
+
/** Display path (may be collapsed, e.g., "src/components") */
|
|
27
|
+
displayPath: string
|
|
28
|
+
/** Whether this is a file (true) or directory (false) */
|
|
29
|
+
isFile: boolean
|
|
30
|
+
/** File index for scroll-to (only for files) */
|
|
31
|
+
fileIndex?: number
|
|
32
|
+
/** File status (only for files) */
|
|
33
|
+
status?: FileStatus
|
|
34
|
+
/** Number of added lines (only for files) */
|
|
35
|
+
additions?: number
|
|
36
|
+
/** Number of deleted lines (only for files) */
|
|
37
|
+
deletions?: number
|
|
38
|
+
/** Tree connector character: "├── " or "└── " */
|
|
39
|
+
connector: string
|
|
40
|
+
/** Prefix string for tree lines, e.g., "│ " */
|
|
41
|
+
prefix: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface InternalTreeNode {
|
|
45
|
+
path: string
|
|
46
|
+
title?: string
|
|
47
|
+
fileIndex?: number
|
|
48
|
+
status?: FileStatus
|
|
49
|
+
additions?: number
|
|
50
|
+
deletions?: number
|
|
51
|
+
children: InternalTreeNode[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a directory tree from file paths
|
|
56
|
+
* @param files Array of file info objects
|
|
57
|
+
* @returns Array of TreeNode objects ready for rendering
|
|
58
|
+
*/
|
|
59
|
+
export function buildDirectoryTree(files: TreeFileInfo[]): TreeNode[] {
|
|
60
|
+
if (files.length === 0) {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tree = buildInternalTree(files)
|
|
65
|
+
return flattenTree(tree)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build internal tree structure from file paths
|
|
70
|
+
*/
|
|
71
|
+
function buildInternalTree(files: TreeFileInfo[]): InternalTreeNode[] {
|
|
72
|
+
const root: InternalTreeNode[] = []
|
|
73
|
+
const nodeMap = new Map<string, InternalTreeNode>()
|
|
74
|
+
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const parts = file.path.split("/").filter((part) => part !== "")
|
|
77
|
+
let currentPath = ""
|
|
78
|
+
let currentLevel = root
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < parts.length; i++) {
|
|
81
|
+
const part = parts[i]!
|
|
82
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part
|
|
83
|
+
|
|
84
|
+
let node = nodeMap.get(currentPath)
|
|
85
|
+
if (!node) {
|
|
86
|
+
node = {
|
|
87
|
+
path: currentPath,
|
|
88
|
+
children: [],
|
|
89
|
+
}
|
|
90
|
+
nodeMap.set(currentPath, node)
|
|
91
|
+
currentLevel.push(node)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// On the final part, assign file info
|
|
95
|
+
if (i === parts.length - 1) {
|
|
96
|
+
node.title = part
|
|
97
|
+
node.fileIndex = file.fileIndex
|
|
98
|
+
node.status = file.status
|
|
99
|
+
node.additions = file.additions
|
|
100
|
+
node.deletions = file.deletions
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
currentLevel = node.children
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return root
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the base name from a path
|
|
112
|
+
*/
|
|
113
|
+
function getName(node: InternalTreeNode): string {
|
|
114
|
+
const parts = node.path.split("/")
|
|
115
|
+
return parts[parts.length - 1] || node.path
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Collapse directories that only contain a single subdirectory (no files)
|
|
120
|
+
*/
|
|
121
|
+
function collapseNode(node: InternalTreeNode): {
|
|
122
|
+
path: string
|
|
123
|
+
collapsed: boolean
|
|
124
|
+
children: InternalTreeNode[]
|
|
125
|
+
originalNode: InternalTreeNode
|
|
126
|
+
} {
|
|
127
|
+
let currentNode = node
|
|
128
|
+
let collapsedPath = getName(currentNode)
|
|
129
|
+
|
|
130
|
+
// Keep collapsing while:
|
|
131
|
+
// - Current node has exactly one child
|
|
132
|
+
// - Current node is not a file (no status/title means it's a directory)
|
|
133
|
+
// - The single child is also a directory (no status)
|
|
134
|
+
while (
|
|
135
|
+
currentNode.children.length === 1 &&
|
|
136
|
+
currentNode.status === undefined &&
|
|
137
|
+
currentNode.children[0]!.status === undefined &&
|
|
138
|
+
currentNode.children[0]!.children.length > 0
|
|
139
|
+
) {
|
|
140
|
+
currentNode = currentNode.children[0]!
|
|
141
|
+
collapsedPath = collapsedPath + "/" + getName(currentNode)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
path: collapsedPath,
|
|
146
|
+
collapsed: collapsedPath !== getName(node),
|
|
147
|
+
children: currentNode.children,
|
|
148
|
+
originalNode: currentNode,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Flatten the tree into a linear array of TreeNode objects
|
|
154
|
+
*/
|
|
155
|
+
function flattenTree(tree: InternalTreeNode[]): TreeNode[] {
|
|
156
|
+
const result: TreeNode[] = []
|
|
157
|
+
|
|
158
|
+
function processNode(
|
|
159
|
+
node: InternalTreeNode,
|
|
160
|
+
prefix: string,
|
|
161
|
+
isLast: boolean,
|
|
162
|
+
isRoot: boolean,
|
|
163
|
+
): void {
|
|
164
|
+
const collapsed = collapseNode(node)
|
|
165
|
+
const displayPath = collapsed.path
|
|
166
|
+
const connector = isLast ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 "
|
|
167
|
+
|
|
168
|
+
// Determine if this is a file (has status) or directory
|
|
169
|
+
const isFile = collapsed.originalNode.status !== undefined
|
|
170
|
+
|
|
171
|
+
result.push({
|
|
172
|
+
displayPath,
|
|
173
|
+
isFile,
|
|
174
|
+
fileIndex: collapsed.originalNode.fileIndex,
|
|
175
|
+
status: collapsed.originalNode.status,
|
|
176
|
+
additions: collapsed.originalNode.additions,
|
|
177
|
+
deletions: collapsed.originalNode.deletions,
|
|
178
|
+
connector,
|
|
179
|
+
prefix,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Process children
|
|
183
|
+
if (collapsed.children.length > 0) {
|
|
184
|
+
const childPrefix = prefix + (isLast ? " " : "\u2502 ")
|
|
185
|
+
|
|
186
|
+
collapsed.children.forEach((child, idx) => {
|
|
187
|
+
const childIsLast = idx === collapsed.children.length - 1
|
|
188
|
+
processNode(child, childPrefix, childIsLast, false)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Process root level nodes
|
|
194
|
+
tree.forEach((node, idx) => {
|
|
195
|
+
const isLast = idx === tree.length - 1
|
|
196
|
+
processNode(node, "", isLast, true)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return result
|
|
200
|
+
}
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import * as React from "react"
|
|
6
6
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
7
|
-
import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable } from "@opentui/core"
|
|
7
|
+
import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core"
|
|
8
8
|
import type { Token } from "marked"
|
|
9
9
|
import { getResolvedTheme, getSyntaxTheme, defaultThemeName, themeNames, rgbaToHex } from "../themes.ts"
|
|
10
10
|
import { detectFiletype, countChanges, getViewMode } from "../diff-utils.ts"
|
|
11
|
-
import { DiffView } from "../components/
|
|
11
|
+
import { DiffView, DirectoryTreeView } from "../components/index.ts"
|
|
12
|
+
import type { TreeFileInfo } from "../directory-tree.ts"
|
|
12
13
|
import { watchReviewYaml } from "./yaml-watcher.ts"
|
|
13
14
|
import { createSubHunk } from "./hunk-parser.ts"
|
|
14
15
|
import { parseDiagram } from "./diagram-parser.ts"
|
|
@@ -52,6 +53,10 @@ export function ReviewApp({
|
|
|
52
53
|
const [showThemePicker, setShowThemePicker] = React.useState(false)
|
|
53
54
|
const [previewTheme, setPreviewTheme] = React.useState<string | null>(null)
|
|
54
55
|
|
|
56
|
+
// Refs for vim-style scroll navigation
|
|
57
|
+
const scrollboxRef = React.useRef<ScrollBoxRenderable | null>(null)
|
|
58
|
+
const lastKeyRef = React.useRef<{ key: string; time: number } | null>(null)
|
|
59
|
+
|
|
55
60
|
// Get theme from store
|
|
56
61
|
const themeName = useAppStore((s) => s.themeName)
|
|
57
62
|
// Use preview theme if hovering, otherwise use selected theme
|
|
@@ -75,8 +80,8 @@ export function ReviewApp({
|
|
|
75
80
|
|
|
76
81
|
// Keyboard navigation
|
|
77
82
|
useKeyboard((key) => {
|
|
78
|
-
// Ctrl+
|
|
79
|
-
if (key.ctrl && key.name === "
|
|
83
|
+
// Ctrl+Z toggles debug console
|
|
84
|
+
if (key.ctrl && key.name === "z") {
|
|
80
85
|
renderer.console.toggle()
|
|
81
86
|
return
|
|
82
87
|
}
|
|
@@ -98,6 +103,40 @@ export function ReviewApp({
|
|
|
98
103
|
setShowThemePicker(true)
|
|
99
104
|
return
|
|
100
105
|
}
|
|
106
|
+
|
|
107
|
+
// Vim-style scroll navigation
|
|
108
|
+
const scrollbox = scrollboxRef.current
|
|
109
|
+
if (scrollbox) {
|
|
110
|
+
// G - go to bottom
|
|
111
|
+
if (key.name === "g" && key.shift) {
|
|
112
|
+
scrollbox.scrollBy(1, "content")
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// gg - go to top (double-tap within 300ms)
|
|
117
|
+
if (key.name === "g" && !key.shift && !key.ctrl) {
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
if (lastKeyRef.current?.key === "g" && now - lastKeyRef.current.time < 300) {
|
|
120
|
+
scrollbox.scrollTo(0)
|
|
121
|
+
lastKeyRef.current = null
|
|
122
|
+
} else {
|
|
123
|
+
lastKeyRef.current = { key: "g", time: now }
|
|
124
|
+
}
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Ctrl+D - half page down
|
|
129
|
+
if (key.ctrl && key.name === "d") {
|
|
130
|
+
scrollbox.scrollBy(0.5, "viewport")
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Ctrl+U - half page up
|
|
135
|
+
if (key.ctrl && key.name === "u") {
|
|
136
|
+
scrollbox.scrollBy(-0.5, "viewport")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
}
|
|
101
140
|
})
|
|
102
141
|
|
|
103
142
|
const themeOptions = themeNames.map((name) => ({
|
|
@@ -175,6 +214,7 @@ export function ReviewApp({
|
|
|
175
214
|
themeName={activeTheme}
|
|
176
215
|
width={width}
|
|
177
216
|
renderer={renderer}
|
|
217
|
+
scrollboxRef={scrollboxRef}
|
|
178
218
|
/>
|
|
179
219
|
)
|
|
180
220
|
}
|
|
@@ -232,6 +272,7 @@ export interface ReviewAppViewProps {
|
|
|
232
272
|
showFooter?: boolean // defaults to true, set false for web rendering
|
|
233
273
|
renderer?: any // Optional renderer for variable-width markdown
|
|
234
274
|
gap?: number // Gap between markdown descriptions and hunks (default: 2)
|
|
275
|
+
scrollboxRef?: React.RefObject<ScrollBoxRenderable | null> // For vim-style navigation
|
|
235
276
|
}
|
|
236
277
|
|
|
237
278
|
/**
|
|
@@ -247,12 +288,50 @@ export function ReviewAppView({
|
|
|
247
288
|
showFooter = true,
|
|
248
289
|
renderer,
|
|
249
290
|
gap = 2,
|
|
291
|
+
scrollboxRef,
|
|
250
292
|
}: ReviewAppViewProps) {
|
|
251
293
|
const [scrollAcceleration] = React.useState(() => new ScrollAcceleration())
|
|
252
294
|
|
|
253
295
|
// Create a map of hunk ID to hunk for quick lookup
|
|
254
296
|
const hunkMap = React.useMemo(() => new Map(hunks.map((h) => [h.id, h])), [hunks])
|
|
255
297
|
|
|
298
|
+
// Build tree data from all hunks, grouped by filename
|
|
299
|
+
// Aggregates additions/deletions per file
|
|
300
|
+
const treeFiles: TreeFileInfo[] = React.useMemo(() => {
|
|
301
|
+
const fileMap = new Map<string, { additions: number; deletions: number; firstIndex: number }>()
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < hunks.length; i++) {
|
|
304
|
+
const hunk = hunks[i]!
|
|
305
|
+
const existing = fileMap.get(hunk.filename)
|
|
306
|
+
const { additions, deletions } = countChanges([{ lines: hunk.lines }])
|
|
307
|
+
|
|
308
|
+
if (existing) {
|
|
309
|
+
existing.additions += additions
|
|
310
|
+
existing.deletions += deletions
|
|
311
|
+
} else {
|
|
312
|
+
fileMap.set(hunk.filename, { additions, deletions, firstIndex: i })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Determine status for each file (simplified: all are modified in review context)
|
|
317
|
+
// In practice, we could look at the hunk headers for more accurate status
|
|
318
|
+
const result: TreeFileInfo[] = []
|
|
319
|
+
for (const [filename, data] of fileMap) {
|
|
320
|
+
let status: "added" | "modified" | "deleted" = "modified"
|
|
321
|
+
if (data.deletions === 0 && data.additions > 0) status = "added"
|
|
322
|
+
else if (data.additions === 0 && data.deletions > 0) status = "deleted"
|
|
323
|
+
|
|
324
|
+
result.push({
|
|
325
|
+
path: filename,
|
|
326
|
+
status,
|
|
327
|
+
additions: data.additions,
|
|
328
|
+
deletions: data.deletions,
|
|
329
|
+
fileIndex: data.firstIndex,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
return result
|
|
333
|
+
}, [hunks])
|
|
334
|
+
|
|
256
335
|
const resolvedTheme = getResolvedTheme(themeName)
|
|
257
336
|
const bgColor = rgbaToHex(resolvedTheme.background)
|
|
258
337
|
|
|
@@ -325,6 +404,7 @@ export function ReviewAppView({
|
|
|
325
404
|
>
|
|
326
405
|
{/* Scrollable content - shows ALL groups */}
|
|
327
406
|
<scrollbox
|
|
407
|
+
ref={scrollboxRef}
|
|
328
408
|
scrollAcceleration={scrollAcceleration}
|
|
329
409
|
style={{
|
|
330
410
|
flexGrow: 1,
|
|
@@ -347,6 +427,16 @@ export function ReviewAppView({
|
|
|
347
427
|
focused
|
|
348
428
|
>
|
|
349
429
|
<box style={{ flexDirection: "column" }}>
|
|
430
|
+
{/* Directory tree at the top - updates as hunks arrive */}
|
|
431
|
+
{treeFiles.length > 0 && (
|
|
432
|
+
<box style={{ marginBottom: gap }}>
|
|
433
|
+
<DirectoryTreeView
|
|
434
|
+
files={treeFiles}
|
|
435
|
+
themeName={themeName}
|
|
436
|
+
/>
|
|
437
|
+
</box>
|
|
438
|
+
)}
|
|
439
|
+
|
|
350
440
|
{groups.map((group, groupIdx) => {
|
|
351
441
|
// Resolve hunks from group - supports both hunkIds and hunkId with lineRange
|
|
352
442
|
const groupHunks = resolveGroupHunks(group, hunkMap)
|
package/src/themes.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// and provides both UI colors and Tree-sitter compatible syntax styles.
|
|
4
4
|
|
|
5
5
|
import { parseColor, RGBA } from "@opentui/core";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
6
7
|
|
|
7
8
|
// Only import the default theme statically for fast startup
|
|
8
9
|
// Other themes are loaded on-demand when selected
|
|
@@ -25,6 +26,11 @@ interface ThemeJson {
|
|
|
25
26
|
export interface ResolvedTheme {
|
|
26
27
|
// UI colors
|
|
27
28
|
primary: RGBA;
|
|
29
|
+
// Status colors
|
|
30
|
+
success: RGBA;
|
|
31
|
+
error: RGBA;
|
|
32
|
+
warning: RGBA;
|
|
33
|
+
info: RGBA;
|
|
28
34
|
// Syntax colors
|
|
29
35
|
syntaxComment: RGBA;
|
|
30
36
|
syntaxKeyword: RGBA;
|
|
@@ -39,7 +45,10 @@ export interface ResolvedTheme {
|
|
|
39
45
|
text: RGBA;
|
|
40
46
|
textMuted: RGBA;
|
|
41
47
|
conceal: RGBA;
|
|
42
|
-
// Diff colors
|
|
48
|
+
// Diff colors (foreground)
|
|
49
|
+
diffAdded: RGBA;
|
|
50
|
+
diffRemoved: RGBA;
|
|
51
|
+
// Diff colors (background)
|
|
43
52
|
diffAddedBg: RGBA;
|
|
44
53
|
diffRemovedBg: RGBA;
|
|
45
54
|
diffContextBg: RGBA;
|
|
@@ -133,8 +142,9 @@ function loadTheme(name: string): ThemeJson {
|
|
|
133
142
|
try {
|
|
134
143
|
// Use dynamic import with synchronous pattern for JSON
|
|
135
144
|
// This works because JSON imports are resolved at bundle time by Bun
|
|
136
|
-
const
|
|
145
|
+
const themeUrl = new URL(`./themes/${fileName}`, import.meta.url);
|
|
137
146
|
// Read file synchronously using Node fs (works in Bun)
|
|
147
|
+
const themePath = fileURLToPath(themeUrl);
|
|
138
148
|
const fs = require("fs");
|
|
139
149
|
const content = fs.readFileSync(themePath, "utf-8");
|
|
140
150
|
const themeJson = JSON.parse(content) as ThemeJson;
|
|
@@ -177,8 +187,20 @@ function resolveTheme(
|
|
|
177
187
|
|
|
178
188
|
const text = resolveColor(t.text ?? fallbackText);
|
|
179
189
|
|
|
190
|
+
// Fallback colors for status (green, red, yellow, orange)
|
|
191
|
+
const fallbackGreen: ColorValue = "#3fb950";
|
|
192
|
+
const fallbackRed: ColorValue = "#f85149";
|
|
193
|
+
const fallbackYellow: ColorValue = "#e3b341";
|
|
194
|
+
const fallbackOrange: ColorValue = "#d29922";
|
|
195
|
+
|
|
180
196
|
return {
|
|
181
197
|
primary: resolveColor(t.primary ?? t.syntaxFunction ?? fallbackGray),
|
|
198
|
+
// Status colors
|
|
199
|
+
success: resolveColor(t.success ?? fallbackGreen),
|
|
200
|
+
error: resolveColor(t.error ?? fallbackRed),
|
|
201
|
+
warning: resolveColor(t.warning ?? fallbackYellow),
|
|
202
|
+
info: resolveColor(t.info ?? fallbackOrange),
|
|
203
|
+
// Syntax colors
|
|
182
204
|
syntaxComment: resolveColor(t.syntaxComment ?? fallbackGray),
|
|
183
205
|
syntaxKeyword: resolveColor(t.syntaxKeyword ?? fallbackGray),
|
|
184
206
|
syntaxFunction: resolveColor(t.syntaxFunction ?? fallbackGray),
|
|
@@ -191,6 +213,10 @@ function resolveTheme(
|
|
|
191
213
|
text,
|
|
192
214
|
textMuted: resolveColor(t.textMuted ?? fallbackGray),
|
|
193
215
|
conceal: resolveColor(t.conceal ?? t.textMuted ?? fallbackGray),
|
|
216
|
+
// Diff foreground colors
|
|
217
|
+
diffAdded: resolveColor(t.diffAdded ?? t.success ?? fallbackGreen),
|
|
218
|
+
diffRemoved: resolveColor(t.diffRemoved ?? t.error ?? fallbackRed),
|
|
219
|
+
// Diff background colors
|
|
194
220
|
diffAddedBg: resolveColor(t.diffAddedBg ?? "#1e3a1e"),
|
|
195
221
|
diffRemovedBg: resolveColor(t.diffRemovedBg ?? "#3a1e1e"),
|
|
196
222
|
diffContextBg: resolveColor(t.diffContextBg ?? fallbackBg),
|