@tmustier/pi-files-widget 0.1.0 → 0.1.8
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 +15 -0
- package/README.md +2 -0
- package/browser.ts +472 -87
- package/constants.ts +6 -0
- package/file-tree.ts +178 -110
- package/git.ts +38 -3
- package/index.ts +1 -1
- package/package.json +1 -1
- package/types.ts +2 -0
- package/viewer.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this extension will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.8] - 2026-01-26
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Compute line counts asynchronously with loading indicators
|
|
9
|
+
- Build git repo trees from git file lists to avoid filesystem scans
|
|
10
|
+
- Add progressive filesystem scanning with safe mode for large folders
|
|
11
|
+
- Reduce refresh work to git metadata updates
|
|
12
|
+
|
|
13
|
+
## [0.1.7] - 2026-01-26
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Cache line counts and skip large files to avoid freezes in big folders
|
|
17
|
+
- Avoid recomputing tree stats on every render
|
|
18
|
+
- Preserve line counts for open files across refreshes
|
|
19
|
+
|
|
5
20
|
## [0.1.6] - 2026-01-24
|
|
6
21
|
|
|
7
22
|
### Added
|
package/README.md
CHANGED
|
@@ -117,4 +117,6 @@ If missing, `/review` or `/diff` will show a clear install prompt.
|
|
|
117
117
|
|
|
118
118
|
- Untracked files show as `[UNTRACKED]` and open in normal view.
|
|
119
119
|
- Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
|
|
120
|
+
- Line counts load asynchronously; the header shows activity while counts are computed.
|
|
121
|
+
- Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
|
|
120
122
|
- Git status refreshes every 3 seconds while `/files` is open.
|
package/browser.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
|
-
import {
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
4
6
|
|
|
5
7
|
import {
|
|
6
8
|
DEFAULT_BROWSER_HEIGHT,
|
|
9
|
+
LINE_COUNT_BATCH_DELAY_MS,
|
|
10
|
+
LINE_COUNT_BATCH_SIZE,
|
|
7
11
|
MAX_BROWSER_HEIGHT,
|
|
12
|
+
MAX_LINE_COUNT_BYTES,
|
|
13
|
+
MAX_TREE_DEPTH,
|
|
8
14
|
MIN_PANEL_HEIGHT,
|
|
9
15
|
POLL_INTERVAL_MS,
|
|
16
|
+
SCAN_BATCH_DELAY_MS,
|
|
17
|
+
SCAN_BATCH_SIZE,
|
|
18
|
+
SAFE_MODE_ENTRY_THRESHOLD,
|
|
10
19
|
} from "./constants";
|
|
11
|
-
import { getGitBranch, getGitDiffStats, getGitStatus } from "./git";
|
|
12
|
-
import {
|
|
13
|
-
import type { FileNode, FlatNode } from "./types";
|
|
20
|
+
import { getGitBranch, getGitDiffStats, getGitFileList, getGitStatus, isGitRepo } from "./git";
|
|
21
|
+
import { buildFileTreeFromPaths, flattenTree, getIgnoredNames, sortChildren, updateTreeStats } from "./file-tree";
|
|
22
|
+
import type { DiffStats, FileNode, FlatNode } from "./types";
|
|
14
23
|
import { isIgnoredStatus, isUntrackedStatus } from "./utils";
|
|
15
24
|
import { createViewer, type CommentPayload, type ViewerAction } from "./viewer";
|
|
16
25
|
import { isPrintableChar } from "./input-utils";
|
|
@@ -21,10 +30,29 @@ export interface BrowserController {
|
|
|
21
30
|
invalidate(): void;
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
interface BrowserStats {
|
|
34
|
+
totalLines?: number;
|
|
35
|
+
additions: number;
|
|
36
|
+
deletions: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ScanMode = "full" | "safe" | "none";
|
|
40
|
+
|
|
41
|
+
interface ScanState {
|
|
42
|
+
mode: ScanMode;
|
|
43
|
+
isScanning: boolean;
|
|
44
|
+
isPartial: boolean;
|
|
45
|
+
pending: number;
|
|
46
|
+
spinnerIndex: number;
|
|
47
|
+
}
|
|
24
48
|
|
|
25
49
|
interface BrowserState {
|
|
26
50
|
root: FileNode | null;
|
|
27
51
|
flatList: FlatNode[];
|
|
52
|
+
fullList: FlatNode[];
|
|
53
|
+
stats: BrowserStats;
|
|
54
|
+
nodeByPath: Map<string, FileNode>;
|
|
55
|
+
scanState: ScanState;
|
|
28
56
|
selectedIndex: number;
|
|
29
57
|
searchQuery: string;
|
|
30
58
|
searchMode: boolean;
|
|
@@ -38,50 +66,49 @@ interface ChangedFile {
|
|
|
38
66
|
ancestors: FileNode[];
|
|
39
67
|
}
|
|
40
68
|
|
|
41
|
-
|
|
42
|
-
const expandedPaths = new Set<string>();
|
|
69
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
70
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
71
|
+
function findNodeByPath(root: FileNode | null, path: string): FileNode | null {
|
|
72
|
+
if (!root) return null;
|
|
73
|
+
if (root.path === path) return root;
|
|
74
|
+
if (!root.children) return null;
|
|
75
|
+
|
|
76
|
+
for (const child of root.children) {
|
|
77
|
+
const found = findNodeByPath(child, path);
|
|
78
|
+
if (found) return found;
|
|
53
79
|
}
|
|
54
80
|
|
|
55
|
-
|
|
56
|
-
return expandedPaths;
|
|
81
|
+
return null;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
function indexNodes(root: FileNode | null, map: Map<string, FileNode>): void {
|
|
85
|
+
map.clear();
|
|
86
|
+
if (!root) return;
|
|
87
|
+
const stack: FileNode[] = [root];
|
|
88
|
+
while (stack.length > 0) {
|
|
89
|
+
const node = stack.pop();
|
|
90
|
+
if (!node) continue;
|
|
91
|
+
map.set(node.path, node);
|
|
64
92
|
if (node.children) {
|
|
65
93
|
for (const child of node.children) {
|
|
66
|
-
|
|
94
|
+
stack.push(child);
|
|
67
95
|
}
|
|
68
96
|
}
|
|
69
97
|
}
|
|
70
|
-
|
|
71
|
-
if (root) traverse(root);
|
|
72
98
|
}
|
|
73
99
|
|
|
74
|
-
function
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const found = findNodeByPath(child, path);
|
|
81
|
-
if (found) return found;
|
|
82
|
-
}
|
|
100
|
+
function getNodeDepth(node: FileNode, cwd: string): number {
|
|
101
|
+
if (node.path === cwd) return 0;
|
|
102
|
+
const rel = relative(cwd, node.path);
|
|
103
|
+
if (!rel) return 0;
|
|
104
|
+
return rel.split(sep).length;
|
|
105
|
+
}
|
|
83
106
|
|
|
84
|
-
|
|
107
|
+
function shouldSafeMode(cwd: string): boolean {
|
|
108
|
+
const resolved = resolve(cwd);
|
|
109
|
+
const home = resolve(homedir());
|
|
110
|
+
const root = resolve(sep);
|
|
111
|
+
return resolved === home || resolved === root;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
function collectChangedFiles(node: FileNode, ancestors: FileNode[] = []): ChangedFile[] {
|
|
@@ -100,28 +127,16 @@ function collectChangedFiles(node: FileNode, ancestors: FileNode[] = []): Change
|
|
|
100
127
|
return results;
|
|
101
128
|
}
|
|
102
129
|
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let deletions = 0;
|
|
107
|
-
|
|
108
|
-
function traverse(node: FileNode): void {
|
|
109
|
-
if (!node.isDirectory) {
|
|
110
|
-
totalLines += node.lineCount || 0;
|
|
111
|
-
if (node.diffStats) {
|
|
112
|
-
additions += node.diffStats.additions;
|
|
113
|
-
deletions += node.diffStats.deletions;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (node.children) {
|
|
117
|
-
for (const child of node.children) {
|
|
118
|
-
traverse(child);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
130
|
+
function getTreeStats(root: FileNode | null): BrowserStats {
|
|
131
|
+
if (!root) {
|
|
132
|
+
return { totalLines: undefined, additions: 0, deletions: 0 };
|
|
121
133
|
}
|
|
122
134
|
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
return {
|
|
136
|
+
totalLines: root.lineCountComplete ? root.totalLines ?? 0 : undefined,
|
|
137
|
+
additions: root.totalAdditions ?? 0,
|
|
138
|
+
deletions: root.totalDeletions ?? 0,
|
|
139
|
+
};
|
|
125
140
|
}
|
|
126
141
|
|
|
127
142
|
function formatNodeStatus(node: FileNode, theme: Theme): string {
|
|
@@ -146,7 +161,7 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
|
|
|
146
161
|
if (node.totalDeletions && node.totalDeletions > 0) {
|
|
147
162
|
parts.push(theme.fg("error", `-${node.totalDeletions}`));
|
|
148
163
|
}
|
|
149
|
-
if (node.totalLines) {
|
|
164
|
+
if (node.totalLines && node.lineCountComplete !== false) {
|
|
150
165
|
parts.push(theme.fg("dim", `${node.totalLines}L`));
|
|
151
166
|
}
|
|
152
167
|
} else if (!node.isDirectory) {
|
|
@@ -157,10 +172,10 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
|
|
|
157
172
|
if (node.diffStats.deletions > 0) {
|
|
158
173
|
parts.push(theme.fg("error", `-${node.diffStats.deletions}`));
|
|
159
174
|
}
|
|
160
|
-
} else if (isUntrackedStatus(node.gitStatus) && node.lineCount) {
|
|
175
|
+
} else if (isUntrackedStatus(node.gitStatus) && node.lineCount !== undefined) {
|
|
161
176
|
parts.push(theme.fg("success", `+${node.lineCount}`));
|
|
162
177
|
}
|
|
163
|
-
if (node.lineCount) {
|
|
178
|
+
if (node.lineCount !== undefined) {
|
|
164
179
|
parts.push(theme.fg("dim", `${node.lineCount}L`));
|
|
165
180
|
}
|
|
166
181
|
}
|
|
@@ -171,7 +186,8 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
|
|
|
171
186
|
function formatNodeName(node: FileNode, theme: Theme): string {
|
|
172
187
|
if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
|
|
173
188
|
if (node.isDirectory) {
|
|
174
|
-
|
|
189
|
+
const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
|
|
190
|
+
return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
|
|
175
191
|
}
|
|
176
192
|
if (node.gitStatus) return theme.fg("warning", node.name);
|
|
177
193
|
return node.name;
|
|
@@ -193,18 +209,44 @@ export function createFileBrowser(
|
|
|
193
209
|
agentModifiedFiles: Set<string>,
|
|
194
210
|
theme: Theme,
|
|
195
211
|
onClose: () => void,
|
|
196
|
-
requestComment: (payload: CommentPayload, comment: string) => void
|
|
212
|
+
requestComment: (payload: CommentPayload, comment: string) => void,
|
|
213
|
+
requestRender: () => void
|
|
197
214
|
): BrowserController {
|
|
198
|
-
let gitStatus = getGitStatus(cwd);
|
|
199
|
-
let diffStats = getGitDiffStats(cwd);
|
|
200
|
-
const gitBranch = getGitBranch(cwd);
|
|
201
215
|
const ignored = getIgnoredNames();
|
|
216
|
+
const repo = isGitRepo(cwd);
|
|
217
|
+
let gitStatus = repo ? getGitStatus(cwd) : new Map<string, string>();
|
|
218
|
+
let diffStats = repo ? getGitDiffStats(cwd) : new Map<string, DiffStats>();
|
|
219
|
+
const gitBranch = repo ? getGitBranch(cwd) : "";
|
|
202
220
|
|
|
203
221
|
const viewer = createViewer(cwd, theme, requestComment);
|
|
204
222
|
|
|
223
|
+
const root = repo
|
|
224
|
+
? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
|
|
225
|
+
: {
|
|
226
|
+
name: ".",
|
|
227
|
+
path: cwd,
|
|
228
|
+
isDirectory: true,
|
|
229
|
+
children: undefined,
|
|
230
|
+
expanded: true,
|
|
231
|
+
hasChangedChildren: false,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const safeMode = !repo && shouldSafeMode(cwd);
|
|
235
|
+
const scanState: ScanState = {
|
|
236
|
+
mode: repo ? "none" : safeMode ? "safe" : "full",
|
|
237
|
+
isScanning: false,
|
|
238
|
+
isPartial: safeMode,
|
|
239
|
+
pending: 0,
|
|
240
|
+
spinnerIndex: 0,
|
|
241
|
+
};
|
|
242
|
+
|
|
205
243
|
const browser: BrowserState = {
|
|
206
|
-
root
|
|
244
|
+
root,
|
|
207
245
|
flatList: [],
|
|
246
|
+
fullList: [],
|
|
247
|
+
stats: getTreeStats(root),
|
|
248
|
+
nodeByPath: new Map<string, FileNode>(),
|
|
249
|
+
scanState,
|
|
208
250
|
selectedIndex: 0,
|
|
209
251
|
searchQuery: "",
|
|
210
252
|
searchMode: false,
|
|
@@ -213,41 +255,362 @@ export function createFileBrowser(
|
|
|
213
255
|
lastPollTime: Date.now(),
|
|
214
256
|
};
|
|
215
257
|
|
|
258
|
+
indexNodes(browser.root, browser.nodeByPath);
|
|
216
259
|
browser.flatList = browser.root ? flattenTree(browser.root) : [];
|
|
260
|
+
browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
|
|
217
261
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
262
|
+
const lineCountCache = new Map<string, { size: number; mtimeMs: number; count: number }>();
|
|
263
|
+
const lineCountQueue: FileNode[] = [];
|
|
264
|
+
const lineCountPending = new Set<string>();
|
|
265
|
+
let lineCountTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
266
|
|
|
222
|
-
|
|
267
|
+
const scanQueue: Array<{ node: FileNode; depth: number }> = [];
|
|
268
|
+
const scanQueued = new Set<string>();
|
|
269
|
+
let scanTimer: ReturnType<typeof setTimeout> | null = null;
|
|
223
270
|
|
|
224
|
-
|
|
225
|
-
diffStats = getGitDiffStats(cwd);
|
|
226
|
-
browser.root = buildFileTree(cwd, cwd, gitStatus, diffStats, ignored, agentModifiedFiles);
|
|
227
|
-
|
|
228
|
-
restoreExpandedPaths(browser.root, expandedPaths);
|
|
271
|
+
const normalizeGitPath = (path: string): string => path.split(sep).join("/");
|
|
229
272
|
|
|
273
|
+
function refreshLists(): void {
|
|
230
274
|
browser.flatList = browser.root ? flattenTree(browser.root) : [];
|
|
275
|
+
browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function queueLineCount(node: FileNode, force = false): void {
|
|
279
|
+
if (node.isDirectory) return;
|
|
280
|
+
if (!force && node.lineCount !== undefined) return;
|
|
281
|
+
if (lineCountPending.has(node.path)) return;
|
|
282
|
+
lineCountPending.add(node.path);
|
|
283
|
+
lineCountQueue.push(node);
|
|
284
|
+
if (!lineCountTimer) {
|
|
285
|
+
lineCountTimer = setTimeout(processLineCountBatch, LINE_COUNT_BATCH_DELAY_MS);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function queueLineCountsForTree(rootNode: FileNode | null): void {
|
|
290
|
+
if (!rootNode) return;
|
|
291
|
+
const stack: FileNode[] = [rootNode];
|
|
292
|
+
while (stack.length > 0) {
|
|
293
|
+
const node = stack.pop();
|
|
294
|
+
if (!node) continue;
|
|
295
|
+
if (node.isDirectory) {
|
|
296
|
+
if (node.children) {
|
|
297
|
+
for (const child of node.children) {
|
|
298
|
+
stack.push(child);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
queueLineCount(node);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function updateLineCount(node: FileNode): Promise<void> {
|
|
308
|
+
try {
|
|
309
|
+
const fileStat = await stat(node.path);
|
|
310
|
+
if (fileStat.size > MAX_LINE_COUNT_BYTES) {
|
|
311
|
+
node.lineCount = undefined;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const cached = lineCountCache.get(node.path);
|
|
315
|
+
if (cached && cached.size === fileStat.size && cached.mtimeMs === fileStat.mtimeMs) {
|
|
316
|
+
node.lineCount = cached.count;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const content = await readFile(node.path, "utf-8");
|
|
320
|
+
const count = content.split("\n").length;
|
|
321
|
+
node.lineCount = count;
|
|
322
|
+
lineCountCache.set(node.path, { size: fileStat.size, mtimeMs: fileStat.mtimeMs, count });
|
|
323
|
+
} catch {
|
|
324
|
+
node.lineCount = undefined;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function processLineCountBatch(): Promise<void> {
|
|
329
|
+
lineCountTimer = null;
|
|
330
|
+
if (!browser.root) return;
|
|
331
|
+
const batch = lineCountQueue.splice(0, LINE_COUNT_BATCH_SIZE);
|
|
332
|
+
if (batch.length === 0) return;
|
|
231
333
|
|
|
334
|
+
await Promise.all(
|
|
335
|
+
batch.map(async node => {
|
|
336
|
+
await updateLineCount(node);
|
|
337
|
+
lineCountPending.delete(node.path);
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
updateTreeStats(browser.root);
|
|
342
|
+
browser.stats = getTreeStats(browser.root);
|
|
343
|
+
refreshLists();
|
|
344
|
+
requestRender();
|
|
345
|
+
|
|
346
|
+
if (lineCountQueue.length > 0) {
|
|
347
|
+
lineCountTimer = setTimeout(processLineCountBatch, LINE_COUNT_BATCH_DELAY_MS);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function shouldAutoScan(depth: number): boolean {
|
|
352
|
+
if (browser.scanState.mode === "safe") {
|
|
353
|
+
return depth <= 0;
|
|
354
|
+
}
|
|
355
|
+
return depth <= MAX_TREE_DEPTH;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function getScanBatchSize(): number {
|
|
359
|
+
return browser.scanState.mode === "safe" ? 1 : SCAN_BATCH_SIZE;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getScanDelay(): number {
|
|
363
|
+
return browser.scanState.mode === "safe" ? SCAN_BATCH_DELAY_MS * 4 : SCAN_BATCH_DELAY_MS;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function enqueueScan(node: FileNode, depth: number, force = false): void {
|
|
367
|
+
if (depth > MAX_TREE_DEPTH) return;
|
|
368
|
+
if (!force && browser.scanState.mode === "safe" && depth > 0) return;
|
|
369
|
+
if (node.children !== undefined || node.loading) return;
|
|
370
|
+
if (scanQueued.has(node.path)) return;
|
|
371
|
+
|
|
372
|
+
node.loading = true;
|
|
373
|
+
scanQueued.add(node.path);
|
|
374
|
+
scanQueue.push({ node, depth });
|
|
375
|
+
browser.scanState.pending = scanQueue.length;
|
|
376
|
+
browser.scanState.isScanning = true;
|
|
377
|
+
|
|
378
|
+
if (!scanTimer) {
|
|
379
|
+
scanTimer = setTimeout(processScanBatch, getScanDelay());
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function scanDirectory(node: FileNode, depth: number): Promise<void> {
|
|
384
|
+
try {
|
|
385
|
+
const entries = await readdir(node.path, { withFileTypes: true });
|
|
386
|
+
const sorted = [...entries].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
387
|
+
|
|
388
|
+
if (node.path === cwd && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
|
|
389
|
+
browser.scanState.mode = "safe";
|
|
390
|
+
browser.scanState.isPartial = true;
|
|
391
|
+
scanQueue.length = 0;
|
|
392
|
+
scanQueued.clear();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const dirs: FileNode[] = [];
|
|
396
|
+
const files: FileNode[] = [];
|
|
397
|
+
|
|
398
|
+
for (const entry of sorted) {
|
|
399
|
+
if (ignored.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
400
|
+
const fullPath = join(node.path, entry.name);
|
|
401
|
+
if (entry.isDirectory()) {
|
|
402
|
+
const dirNode: FileNode = {
|
|
403
|
+
name: entry.name,
|
|
404
|
+
path: fullPath,
|
|
405
|
+
isDirectory: true,
|
|
406
|
+
children: undefined,
|
|
407
|
+
expanded: depth + 1 < 1,
|
|
408
|
+
hasChangedChildren: false,
|
|
409
|
+
};
|
|
410
|
+
dirs.push(dirNode);
|
|
411
|
+
browser.nodeByPath.set(fullPath, dirNode);
|
|
412
|
+
if (shouldAutoScan(depth + 1)) {
|
|
413
|
+
enqueueScan(dirNode, depth + 1);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
const fileNode: FileNode = {
|
|
417
|
+
name: entry.name,
|
|
418
|
+
path: fullPath,
|
|
419
|
+
isDirectory: false,
|
|
420
|
+
agentModified: agentModifiedFiles.has(fullPath),
|
|
421
|
+
};
|
|
422
|
+
files.push(fileNode);
|
|
423
|
+
browser.nodeByPath.set(fullPath, fileNode);
|
|
424
|
+
queueLineCount(fileNode);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
node.children = [...dirs, ...files];
|
|
429
|
+
} catch {
|
|
430
|
+
node.children = [];
|
|
431
|
+
} finally {
|
|
432
|
+
node.loading = false;
|
|
433
|
+
scanQueued.delete(node.path);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function processScanBatch(): Promise<void> {
|
|
438
|
+
scanTimer = null;
|
|
439
|
+
if (!browser.root) return;
|
|
440
|
+
const batch = scanQueue.splice(0, getScanBatchSize());
|
|
441
|
+
if (batch.length === 0) {
|
|
442
|
+
browser.scanState.isScanning = false;
|
|
443
|
+
browser.scanState.pending = 0;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (const item of batch) {
|
|
448
|
+
await scanDirectory(item.node, item.depth);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
browser.scanState.pending = scanQueue.length;
|
|
452
|
+
browser.scanState.isScanning = scanQueue.length > 0;
|
|
453
|
+
|
|
454
|
+
updateTreeStats(browser.root);
|
|
455
|
+
browser.stats = getTreeStats(browser.root);
|
|
456
|
+
refreshLists();
|
|
457
|
+
requestRender();
|
|
458
|
+
|
|
459
|
+
if (scanQueue.length > 0) {
|
|
460
|
+
scanTimer = setTimeout(processScanBatch, getScanDelay());
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function stopBackgroundTasks(): void {
|
|
465
|
+
if (lineCountTimer) {
|
|
466
|
+
clearTimeout(lineCountTimer);
|
|
467
|
+
lineCountTimer = null;
|
|
468
|
+
}
|
|
469
|
+
if (scanTimer) {
|
|
470
|
+
clearTimeout(scanTimer);
|
|
471
|
+
scanTimer = null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function applyAgentModified(): void {
|
|
476
|
+
for (const node of browser.nodeByPath.values()) {
|
|
477
|
+
if (!node.isDirectory) {
|
|
478
|
+
node.agentModified = agentModifiedFiles.has(node.path);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function applyGitUpdates(): void {
|
|
484
|
+
for (const node of browser.nodeByPath.values()) {
|
|
485
|
+
if (node.isDirectory) continue;
|
|
486
|
+
const relPath = normalizeGitPath(relative(cwd, node.path));
|
|
487
|
+
node.gitStatus = gitStatus.get(relPath);
|
|
488
|
+
node.diffStats = diffStats.get(relPath);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function ensureFileNode(relPath: string): FileNode | null {
|
|
493
|
+
if (!browser.root) return null;
|
|
494
|
+
let normalized = relPath.trim();
|
|
495
|
+
if (!normalized) return null;
|
|
496
|
+
if (normalized.startsWith("./")) {
|
|
497
|
+
normalized = normalized.slice(2);
|
|
498
|
+
}
|
|
499
|
+
normalized = normalizeGitPath(normalized);
|
|
500
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
501
|
+
if (parts.length === 0) return null;
|
|
502
|
+
if (parts.length - 1 > MAX_TREE_DEPTH) return null;
|
|
503
|
+
|
|
504
|
+
let current = browser.root;
|
|
505
|
+
let currentRel = "";
|
|
506
|
+
|
|
507
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
508
|
+
const part = parts[i];
|
|
509
|
+
if (ignored.has(part) || part.startsWith(".")) return null;
|
|
510
|
+
currentRel = currentRel ? `${currentRel}/${part}` : part;
|
|
511
|
+
const dirPath = join(cwd, currentRel);
|
|
512
|
+
let dirNode = browser.nodeByPath.get(dirPath);
|
|
513
|
+
if (!dirNode) {
|
|
514
|
+
const depth = i + 1;
|
|
515
|
+
dirNode = {
|
|
516
|
+
name: part,
|
|
517
|
+
path: dirPath,
|
|
518
|
+
isDirectory: true,
|
|
519
|
+
children: [],
|
|
520
|
+
expanded: depth < 1,
|
|
521
|
+
hasChangedChildren: false,
|
|
522
|
+
};
|
|
523
|
+
current.children ??= [];
|
|
524
|
+
current.children.push(dirNode);
|
|
525
|
+
sortChildren(current);
|
|
526
|
+
browser.nodeByPath.set(dirPath, dirNode);
|
|
527
|
+
}
|
|
528
|
+
current = dirNode;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const fileName = parts[parts.length - 1];
|
|
532
|
+
if (ignored.has(fileName) || fileName.startsWith(".")) return null;
|
|
533
|
+
|
|
534
|
+
const filePath = join(cwd, normalized);
|
|
535
|
+
const existing = browser.nodeByPath.get(filePath);
|
|
536
|
+
if (existing) return existing;
|
|
537
|
+
|
|
538
|
+
const fileNode: FileNode = {
|
|
539
|
+
name: fileName,
|
|
540
|
+
path: filePath,
|
|
541
|
+
isDirectory: false,
|
|
542
|
+
gitStatus: gitStatus.get(normalized),
|
|
543
|
+
agentModified: agentModifiedFiles.has(filePath),
|
|
544
|
+
diffStats: diffStats.get(normalized),
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
current.children ??= [];
|
|
548
|
+
current.children.push(fileNode);
|
|
549
|
+
sortChildren(current);
|
|
550
|
+
browser.nodeByPath.set(filePath, fileNode);
|
|
551
|
+
return fileNode;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function addUntrackedNodes(): void {
|
|
555
|
+
for (const [relPath, status] of gitStatus.entries()) {
|
|
556
|
+
if (!isUntrackedStatus(status)) continue;
|
|
557
|
+
const node = ensureFileNode(relPath);
|
|
558
|
+
if (node) {
|
|
559
|
+
node.gitStatus = status;
|
|
560
|
+
node.diffStats = diffStats.get(relPath);
|
|
561
|
+
queueLineCount(node, true);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function refreshMetadata(): void {
|
|
567
|
+
if (!browser.root) return;
|
|
568
|
+
const previousDisplayList = getDisplayList();
|
|
569
|
+
const currentPath = previousDisplayList[browser.selectedIndex]?.node.path;
|
|
570
|
+
const viewingFile = viewer.getFile();
|
|
571
|
+
const viewingFilePath = viewingFile?.path;
|
|
572
|
+
|
|
573
|
+
if (repo) {
|
|
574
|
+
gitStatus = getGitStatus(cwd);
|
|
575
|
+
diffStats = getGitDiffStats(cwd);
|
|
576
|
+
applyGitUpdates();
|
|
577
|
+
addUntrackedNodes();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
applyAgentModified();
|
|
581
|
+
updateTreeStats(browser.root);
|
|
582
|
+
browser.stats = getTreeStats(browser.root);
|
|
583
|
+
refreshLists();
|
|
584
|
+
|
|
585
|
+
const updatedDisplayList = getDisplayList();
|
|
232
586
|
if (currentPath) {
|
|
233
|
-
const newIdx =
|
|
587
|
+
const newIdx = updatedDisplayList.findIndex(f => f.node.path === currentPath);
|
|
234
588
|
if (newIdx !== -1) {
|
|
235
589
|
browser.selectedIndex = newIdx;
|
|
236
590
|
}
|
|
237
591
|
}
|
|
238
592
|
|
|
239
|
-
browser.selectedIndex = Math.min(browser.selectedIndex, Math.max(0,
|
|
593
|
+
browser.selectedIndex = Math.min(browser.selectedIndex, Math.max(0, updatedDisplayList.length - 1));
|
|
240
594
|
|
|
241
595
|
if (viewingFilePath && browser.root) {
|
|
242
|
-
const newNode = findNodeByPath(browser.root, viewingFilePath);
|
|
596
|
+
const newNode = browser.nodeByPath.get(viewingFilePath) ?? findNodeByPath(browser.root, viewingFilePath);
|
|
243
597
|
if (newNode) {
|
|
598
|
+
if (newNode.lineCount === undefined && viewingFile?.lineCount !== undefined) {
|
|
599
|
+
newNode.lineCount = viewingFile.lineCount;
|
|
600
|
+
}
|
|
244
601
|
viewer.updateFileRef(newNode);
|
|
245
602
|
}
|
|
246
603
|
}
|
|
247
604
|
}
|
|
248
605
|
|
|
606
|
+
if (repo) {
|
|
607
|
+
queueLineCountsForTree(browser.root);
|
|
608
|
+
} else if (browser.root) {
|
|
609
|
+
enqueueScan(browser.root, 0, true);
|
|
610
|
+
}
|
|
611
|
+
|
|
249
612
|
function getDisplayList(): FlatNode[] {
|
|
250
|
-
let list = browser.flatList;
|
|
613
|
+
let list = browser.searchQuery ? browser.fullList : browser.flatList;
|
|
251
614
|
|
|
252
615
|
if (browser.showOnlyChanged) {
|
|
253
616
|
list = list.filter(f =>
|
|
@@ -309,7 +672,10 @@ export function createFileBrowser(
|
|
|
309
672
|
function toggleDir(node: FileNode): void {
|
|
310
673
|
if (node.isDirectory) {
|
|
311
674
|
node.expanded = !node.expanded;
|
|
312
|
-
|
|
675
|
+
if (!repo && node.expanded && node.children === undefined) {
|
|
676
|
+
enqueueScan(node, getNodeDepth(node, cwd), true);
|
|
677
|
+
}
|
|
678
|
+
refreshLists();
|
|
313
679
|
}
|
|
314
680
|
}
|
|
315
681
|
|
|
@@ -321,24 +687,41 @@ export function createFileBrowser(
|
|
|
321
687
|
const lines: string[] = [];
|
|
322
688
|
const pathDisplay = basename(cwd);
|
|
323
689
|
const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
|
|
324
|
-
const stats =
|
|
690
|
+
const stats = browser.stats;
|
|
691
|
+
|
|
692
|
+
let statsDisplay = "";
|
|
693
|
+
if (stats.totalLines !== undefined) {
|
|
694
|
+
statsDisplay += theme.fg("dim", ` ${stats.totalLines}L`);
|
|
695
|
+
}
|
|
696
|
+
if (stats.additions > 0) statsDisplay += theme.fg("success", ` +${stats.additions}`);
|
|
697
|
+
if (stats.deletions > 0) statsDisplay += theme.fg("error", ` -${stats.deletions}`);
|
|
325
698
|
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
if (stats.deletions > 0) statsDisplay += theme.fg("error", ` -${stats.deletions}`);
|
|
699
|
+
const hasActivity = browser.scanState.isScanning || lineCountPending.size > 0;
|
|
700
|
+
if (hasActivity) {
|
|
701
|
+
browser.scanState.spinnerIndex = (browser.scanState.spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
330
702
|
}
|
|
703
|
+
const spinner = SPINNER_FRAMES[browser.scanState.spinnerIndex];
|
|
704
|
+
const activityParts: string[] = [];
|
|
705
|
+
if (browser.scanState.isScanning) activityParts.push(`${spinner} scanning`);
|
|
706
|
+
if (lineCountPending.size > 0) activityParts.push(`${spinner} counts`);
|
|
707
|
+
const activityIndicator = activityParts.length > 0 ? theme.fg("dim", ` ${activityParts.join(" ")}`) : "";
|
|
708
|
+
const partialIndicator = browser.scanState.isPartial ? theme.fg("warning", " [partial]") : "";
|
|
331
709
|
|
|
332
710
|
const searchIndicator = browser.searchMode
|
|
333
711
|
? theme.fg("accent", ` /${browser.searchQuery}█`)
|
|
334
712
|
: "";
|
|
335
713
|
|
|
336
|
-
lines.push(
|
|
714
|
+
lines.push(
|
|
715
|
+
truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + activityIndicator + partialIndicator + searchIndicator, width)
|
|
716
|
+
);
|
|
337
717
|
lines.push(theme.fg("borderMuted", "─".repeat(width)));
|
|
338
718
|
|
|
339
719
|
const displayList = getDisplayList();
|
|
340
720
|
if (displayList.length === 0) {
|
|
341
|
-
|
|
721
|
+
const emptyLabel = browser.scanState.isScanning
|
|
722
|
+
? " (loading...)"
|
|
723
|
+
: " (no files" + (browser.searchQuery ? " matching '" + browser.searchQuery + "'" : "") + ")";
|
|
724
|
+
lines.push(theme.fg("dim", emptyLabel));
|
|
342
725
|
for (let i = 1; i < browser.browserHeight; i++) {
|
|
343
726
|
lines.push("");
|
|
344
727
|
}
|
|
@@ -413,6 +796,7 @@ export function createFileBrowser(
|
|
|
413
796
|
const displayList = getDisplayList();
|
|
414
797
|
|
|
415
798
|
if (matchesKey(data, "q") && !browser.searchMode) {
|
|
799
|
+
stopBackgroundTasks();
|
|
416
800
|
onClose();
|
|
417
801
|
return;
|
|
418
802
|
}
|
|
@@ -421,6 +805,7 @@ export function createFileBrowser(
|
|
|
421
805
|
browser.searchMode = false;
|
|
422
806
|
browser.searchQuery = "";
|
|
423
807
|
} else {
|
|
808
|
+
stopBackgroundTasks();
|
|
424
809
|
onClose();
|
|
425
810
|
}
|
|
426
811
|
return;
|
|
@@ -512,9 +897,9 @@ export function createFileBrowser(
|
|
|
512
897
|
return {
|
|
513
898
|
render(width: number): string[] {
|
|
514
899
|
const now = Date.now();
|
|
515
|
-
if (now - browser.lastPollTime > POLL_INTERVAL_MS) {
|
|
900
|
+
if (repo && now - browser.lastPollTime > POLL_INTERVAL_MS) {
|
|
516
901
|
browser.lastPollTime = now;
|
|
517
|
-
|
|
902
|
+
refreshMetadata();
|
|
518
903
|
}
|
|
519
904
|
|
|
520
905
|
if (viewer.isOpen()) {
|
package/constants.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export const MAX_TREE_DEPTH = 6;
|
|
2
2
|
export const POLL_INTERVAL_MS = 3000;
|
|
3
|
+
export const MAX_LINE_COUNT_BYTES = 256 * 1024;
|
|
4
|
+
export const LINE_COUNT_BATCH_SIZE = 8;
|
|
5
|
+
export const LINE_COUNT_BATCH_DELAY_MS = 30;
|
|
6
|
+
export const SCAN_BATCH_SIZE = 4;
|
|
7
|
+
export const SCAN_BATCH_DELAY_MS = 25;
|
|
8
|
+
export const SAFE_MODE_ENTRY_THRESHOLD = 200;
|
|
3
9
|
|
|
4
10
|
export const DEFAULT_VIEWER_HEIGHT = 18;
|
|
5
11
|
export const DEFAULT_BROWSER_HEIGHT = 15;
|
package/file-tree.ts
CHANGED
|
@@ -1,154 +1,222 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { basename, join, relative } from "node:path";
|
|
1
|
+
import { join } from "node:path";
|
|
3
2
|
|
|
4
3
|
import { MAX_TREE_DEPTH } from "./constants";
|
|
5
4
|
import type { DiffStats, FileNode, FlatNode } from "./types";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return 0;
|
|
6
|
+
const collator = new Intl.Collator(undefined, { sensitivity: "base" });
|
|
7
|
+
|
|
8
|
+
function compareNodes(a: FileNode, b: FileNode): number {
|
|
9
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
10
|
+
return a.isDirectory ? -1 : 1;
|
|
13
11
|
}
|
|
12
|
+
return collator.compare(a.name, b.name);
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
15
|
+
function shouldIgnoreSegment(segment: string, ignored: Set<string>): boolean {
|
|
16
|
+
return ignored.has(segment) || segment.startsWith(".");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sortChildren(node: FileNode): void {
|
|
20
|
+
if (!node.children || node.children.length === 0) return;
|
|
21
|
+
node.children.sort(compareNodes);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sortTree(node: FileNode): void {
|
|
25
|
+
sortChildren(node);
|
|
26
|
+
if (node.children) {
|
|
27
|
+
for (const child of node.children) {
|
|
28
|
+
if (child.isDirectory) {
|
|
29
|
+
sortTree(child);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function updateTreeStats(root: FileNode | null): void {
|
|
36
|
+
if (!root) return;
|
|
37
|
+
|
|
38
|
+
function traverse(node: FileNode): {
|
|
39
|
+
totalLines: number;
|
|
40
|
+
totalAdditions: number;
|
|
41
|
+
totalDeletions: number;
|
|
42
|
+
lineCountComplete: boolean;
|
|
43
|
+
hasChanges: boolean;
|
|
44
|
+
} {
|
|
45
|
+
if (!node.isDirectory) {
|
|
46
|
+
const totalLines = node.lineCount ?? 0;
|
|
47
|
+
const totalAdditions = node.diffStats?.additions ?? 0;
|
|
48
|
+
const totalDeletions = node.diffStats?.deletions ?? 0;
|
|
49
|
+
const lineCountComplete = node.lineCount !== undefined;
|
|
50
|
+
const hasChanges = Boolean(node.gitStatus || node.agentModified);
|
|
51
|
+
return { totalLines, totalAdditions, totalDeletions, lineCountComplete, hasChanges };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let totalLines = 0;
|
|
55
|
+
let totalAdditions = 0;
|
|
56
|
+
let totalDeletions = 0;
|
|
57
|
+
let lineCountComplete = true;
|
|
58
|
+
let hasChanges = false;
|
|
59
|
+
|
|
60
|
+
if (node.children) {
|
|
61
|
+
for (const child of node.children) {
|
|
62
|
+
const stats = traverse(child);
|
|
63
|
+
totalLines += stats.totalLines;
|
|
64
|
+
totalAdditions += stats.totalAdditions;
|
|
65
|
+
totalDeletions += stats.totalDeletions;
|
|
66
|
+
if (!stats.lineCountComplete) {
|
|
67
|
+
lineCountComplete = false;
|
|
68
|
+
}
|
|
69
|
+
if (stats.hasChanges) {
|
|
70
|
+
hasChanges = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
node.totalLines = totalLines;
|
|
76
|
+
node.totalAdditions = totalAdditions;
|
|
77
|
+
node.totalDeletions = totalDeletions;
|
|
78
|
+
node.lineCountComplete = lineCountComplete;
|
|
79
|
+
node.hasChangedChildren = hasChanges;
|
|
80
|
+
|
|
81
|
+
return { totalLines, totalAdditions, totalDeletions, lineCountComplete, hasChanges };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
traverse(root);
|
|
36
85
|
}
|
|
37
86
|
|
|
38
|
-
export function
|
|
39
|
-
dir: string,
|
|
87
|
+
export function buildFileTreeFromPaths(
|
|
40
88
|
cwd: string,
|
|
89
|
+
filePaths: string[],
|
|
41
90
|
gitStatus: Map<string, string>,
|
|
42
91
|
diffStats: Map<string, DiffStats>,
|
|
43
92
|
ignored: Set<string>,
|
|
44
|
-
agentModified: Set<string
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const name = basename(dir) || dir;
|
|
50
|
-
if (ignored.has(name)) return null;
|
|
51
|
-
|
|
52
|
-
const node: FileNode = {
|
|
53
|
-
name: depth === 0 ? "." : name,
|
|
54
|
-
path: dir,
|
|
93
|
+
agentModified: Set<string>
|
|
94
|
+
): FileNode {
|
|
95
|
+
const root: FileNode = {
|
|
96
|
+
name: ".",
|
|
97
|
+
path: cwd,
|
|
55
98
|
isDirectory: true,
|
|
56
99
|
children: [],
|
|
57
|
-
expanded:
|
|
100
|
+
expanded: true,
|
|
58
101
|
hasChangedChildren: false,
|
|
59
102
|
};
|
|
60
103
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const files: FileNode[] = [];
|
|
65
|
-
|
|
66
|
-
for (const entry of entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))) {
|
|
67
|
-
if (ignored.has(entry) || entry.startsWith(".")) continue;
|
|
104
|
+
const directoryMap = new Map<string, FileNode>();
|
|
105
|
+
directoryMap.set("", root);
|
|
106
|
+
const seenFiles = new Set<string>();
|
|
68
107
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
108
|
+
for (const rawPath of filePaths) {
|
|
109
|
+
let normalized = rawPath.trim();
|
|
110
|
+
if (!normalized) continue;
|
|
111
|
+
if (normalized.startsWith("./")) {
|
|
112
|
+
normalized = normalized.slice(2);
|
|
113
|
+
}
|
|
114
|
+
normalized = normalized.replace(/\\/g, "/");
|
|
115
|
+
|
|
116
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
117
|
+
if (parts.length === 0) continue;
|
|
118
|
+
const dirDepth = parts.length - 1;
|
|
119
|
+
if (dirDepth > MAX_TREE_DEPTH) continue;
|
|
120
|
+
|
|
121
|
+
let current = root;
|
|
122
|
+
let relPath = "";
|
|
123
|
+
let skip = false;
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
126
|
+
const part = parts[i];
|
|
127
|
+
if (shouldIgnoreSegment(part, ignored)) {
|
|
128
|
+
skip = true;
|
|
129
|
+
break;
|
|
75
130
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (fileGitStatus) {
|
|
92
|
-
node.hasChangedChildren = true;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
files.push({
|
|
96
|
-
name: entry,
|
|
97
|
-
path: fullPath,
|
|
98
|
-
isDirectory: false,
|
|
99
|
-
gitStatus: fileGitStatus,
|
|
100
|
-
agentModified: agentModified.has(fullPath),
|
|
101
|
-
lineCount: getFileLineCount(fullPath),
|
|
102
|
-
diffStats: fileDiffStats,
|
|
103
|
-
});
|
|
131
|
+
relPath = relPath ? `${relPath}/${part}` : part;
|
|
132
|
+
let dirNode = directoryMap.get(relPath);
|
|
133
|
+
if (!dirNode) {
|
|
134
|
+
const depth = i + 1;
|
|
135
|
+
dirNode = {
|
|
136
|
+
name: part,
|
|
137
|
+
path: join(cwd, relPath),
|
|
138
|
+
isDirectory: true,
|
|
139
|
+
children: [],
|
|
140
|
+
expanded: depth < 1,
|
|
141
|
+
hasChangedChildren: false,
|
|
142
|
+
};
|
|
143
|
+
directoryMap.set(relPath, dirNode);
|
|
144
|
+
current.children?.push(dirNode);
|
|
104
145
|
}
|
|
146
|
+
current = dirNode;
|
|
105
147
|
}
|
|
106
148
|
|
|
107
|
-
|
|
149
|
+
if (skip) continue;
|
|
108
150
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let totalAdditions = 0;
|
|
112
|
-
let totalDeletions = 0;
|
|
151
|
+
const fileName = parts[parts.length - 1];
|
|
152
|
+
if (shouldIgnoreSegment(fileName, ignored)) continue;
|
|
113
153
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
totalAdditions += child.totalAdditions || 0;
|
|
118
|
-
totalDeletions += child.totalDeletions || 0;
|
|
119
|
-
} else {
|
|
120
|
-
totalLines += child.lineCount || 0;
|
|
121
|
-
if (child.diffStats) {
|
|
122
|
-
totalAdditions += child.diffStats.additions;
|
|
123
|
-
totalDeletions += child.diffStats.deletions;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
154
|
+
const fileRelPath = parts.join("/");
|
|
155
|
+
if (seenFiles.has(fileRelPath)) continue;
|
|
156
|
+
seenFiles.add(fileRelPath);
|
|
127
157
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
const filePath = join(cwd, fileRelPath);
|
|
159
|
+
const fileGitStatus = gitStatus.get(fileRelPath);
|
|
160
|
+
const fileDiffStats = diffStats.get(fileRelPath);
|
|
161
|
+
|
|
162
|
+
current.children?.push({
|
|
163
|
+
name: fileName,
|
|
164
|
+
path: filePath,
|
|
165
|
+
isDirectory: false,
|
|
166
|
+
gitStatus: fileGitStatus,
|
|
167
|
+
agentModified: agentModified.has(filePath),
|
|
168
|
+
diffStats: fileDiffStats,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
sortTree(root);
|
|
173
|
+
updateTreeStats(root);
|
|
174
|
+
return root;
|
|
175
|
+
}
|
|
132
176
|
|
|
133
|
-
|
|
177
|
+
export function getIgnoredNames(): Set<string> {
|
|
178
|
+
return new Set([
|
|
179
|
+
"node_modules",
|
|
180
|
+
".git",
|
|
181
|
+
".DS_Store",
|
|
182
|
+
"__pycache__",
|
|
183
|
+
".pytest_cache",
|
|
184
|
+
".mypy_cache",
|
|
185
|
+
".next",
|
|
186
|
+
".nuxt",
|
|
187
|
+
"dist",
|
|
188
|
+
"build",
|
|
189
|
+
".venv",
|
|
190
|
+
"venv",
|
|
191
|
+
".env",
|
|
192
|
+
"coverage",
|
|
193
|
+
".nyc_output",
|
|
194
|
+
".turbo",
|
|
195
|
+
".cache",
|
|
196
|
+
]);
|
|
134
197
|
}
|
|
135
198
|
|
|
136
|
-
export function flattenTree(
|
|
199
|
+
export function flattenTree(
|
|
200
|
+
node: FileNode,
|
|
201
|
+
depth = 0,
|
|
202
|
+
isRoot = true,
|
|
203
|
+
includeCollapsed = false
|
|
204
|
+
): FlatNode[] {
|
|
137
205
|
const result: FlatNode[] = [];
|
|
138
206
|
|
|
139
207
|
// Skip the root "." node itself, just process its children
|
|
140
208
|
if (isRoot && node.name === ".") {
|
|
141
209
|
for (const child of node.children || []) {
|
|
142
|
-
result.push(...flattenTree(child, 0, false));
|
|
210
|
+
result.push(...flattenTree(child, 0, false, includeCollapsed));
|
|
143
211
|
}
|
|
144
212
|
return result;
|
|
145
213
|
}
|
|
146
214
|
|
|
147
215
|
result.push({ node, depth });
|
|
148
216
|
|
|
149
|
-
if (node.isDirectory && node.
|
|
217
|
+
if (node.isDirectory && node.children && (includeCollapsed || node.expanded)) {
|
|
150
218
|
for (const child of node.children) {
|
|
151
|
-
result.push(...flattenTree(child, depth + 1, false));
|
|
219
|
+
result.push(...flattenTree(child, depth + 1, false, includeCollapsed));
|
|
152
220
|
}
|
|
153
221
|
}
|
|
154
222
|
|
package/git.ts
CHANGED
|
@@ -11,11 +11,14 @@ export function isGitRepo(cwd: string): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function getGitStatus(cwd: string): Map<string, string> {
|
|
14
|
+
export function getGitStatus(cwd: string, options: { includeIgnored?: boolean } = {}): Map<string, string> {
|
|
15
15
|
const status = new Map<string, string>();
|
|
16
16
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const flags = ["--porcelain"];
|
|
18
|
+
if (options.includeIgnored !== false) {
|
|
19
|
+
flags.push("--ignored");
|
|
20
|
+
}
|
|
21
|
+
const output = execSync(`git status ${flags.join(" ")}`, { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
19
22
|
for (const line of output.split("\n")) {
|
|
20
23
|
if (line.length < 3) continue;
|
|
21
24
|
const statusCode = line.slice(0, 2).trim() || "?";
|
|
@@ -26,6 +29,38 @@ export function getGitStatus(cwd: string): Map<string, string> {
|
|
|
26
29
|
return status;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
export function getGitFileList(cwd: string): string[] {
|
|
33
|
+
const files = new Set<string>();
|
|
34
|
+
try {
|
|
35
|
+
const tracked = execSync("git ls-files -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
36
|
+
for (const entry of tracked.split("\0")) {
|
|
37
|
+
if (entry) files.add(entry);
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const statusOutput = execSync("git status --porcelain -uall -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
43
|
+
const entries = statusOutput.split("\0");
|
|
44
|
+
for (let i = 0; i < entries.length; i++) {
|
|
45
|
+
const entry = entries[i];
|
|
46
|
+
if (!entry) continue;
|
|
47
|
+
const statusCode = entry.slice(0, 2).trim();
|
|
48
|
+
let filePath = entry.slice(3);
|
|
49
|
+
if ((statusCode.startsWith("R") || statusCode.startsWith("C")) && entries[i + 1]) {
|
|
50
|
+
i += 1;
|
|
51
|
+
filePath = entries[i];
|
|
52
|
+
} else if (filePath.includes(" -> ")) {
|
|
53
|
+
filePath = filePath.split(" -> ").pop() || filePath;
|
|
54
|
+
}
|
|
55
|
+
if (filePath) {
|
|
56
|
+
files.add(filePath);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
|
|
61
|
+
return Array.from(files);
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
export function getGitBranch(cwd: string): string {
|
|
30
65
|
try {
|
|
31
66
|
return execSync("git branch --show-current", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
|
package/index.ts
CHANGED
|
@@ -39,7 +39,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
39
39
|
} as any);
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment);
|
|
42
|
+
const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment, tui.requestRender);
|
|
43
43
|
|
|
44
44
|
pollInterval = setInterval(() => {
|
|
45
45
|
tui.requestRender();
|
package/package.json
CHANGED
package/types.ts
CHANGED
package/viewer.ts
CHANGED
|
@@ -178,11 +178,11 @@ export function createViewer(
|
|
|
178
178
|
if (state.file.diffStats.deletions > 0) {
|
|
179
179
|
header += theme.fg("error", ` -${state.file.diffStats.deletions}`);
|
|
180
180
|
}
|
|
181
|
-
} else if (isUntracked && state.file.lineCount) {
|
|
181
|
+
} else if (isUntracked && state.file.lineCount !== undefined) {
|
|
182
182
|
header += theme.fg("success", ` +${state.file.lineCount}`);
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
if (state.file.lineCount) {
|
|
185
|
+
if (state.file.lineCount !== undefined) {
|
|
186
186
|
header += theme.fg("dim", ` ${state.file.lineCount}L`);
|
|
187
187
|
}
|
|
188
188
|
|
|
@@ -245,8 +245,10 @@ export function createViewer(
|
|
|
245
245
|
|
|
246
246
|
try {
|
|
247
247
|
state.rawContent = readFileSync(file.path, "utf-8");
|
|
248
|
+
file.lineCount = state.rawContent.split("\n").length;
|
|
248
249
|
} catch {
|
|
249
250
|
state.rawContent = "";
|
|
251
|
+
file.lineCount = undefined;
|
|
250
252
|
}
|
|
251
253
|
},
|
|
252
254
|
|