@tmustier/pi-files-widget 0.1.7 → 0.1.9
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 +13 -0
- package/README.md +2 -0
- package/browser.ts +433 -57
- package/constants.ts +5 -0
- package/file-tree.ts +165 -124
- package/git.ts +38 -3
- package/index.ts +4 -3
- package/package.json +1 -1
- package/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this extension will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.9] - 2026-01-26
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Bind render requests to avoid undefined context with the latest pi-tui
|
|
9
|
+
|
|
10
|
+
## [0.1.8] - 2026-01-26
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Compute line counts asynchronously with loading indicators
|
|
14
|
+
- Build git repo trees from git file lists to avoid filesystem scans
|
|
15
|
+
- Add progressive filesystem scanning with safe mode for large folders
|
|
16
|
+
- Reduce refresh work to git metadata updates
|
|
17
|
+
|
|
5
18
|
## [0.1.7] - 2026-01-26
|
|
6
19
|
|
|
7
20
|
### Changed
|
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";
|
|
@@ -27,11 +36,23 @@ interface BrowserStats {
|
|
|
27
36
|
deletions: number;
|
|
28
37
|
}
|
|
29
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
|
+
}
|
|
48
|
+
|
|
30
49
|
interface BrowserState {
|
|
31
50
|
root: FileNode | null;
|
|
32
51
|
flatList: FlatNode[];
|
|
33
52
|
fullList: FlatNode[];
|
|
34
53
|
stats: BrowserStats;
|
|
54
|
+
nodeByPath: Map<string, FileNode>;
|
|
55
|
+
scanState: ScanState;
|
|
35
56
|
selectedIndex: number;
|
|
36
57
|
searchQuery: string;
|
|
37
58
|
searchMode: boolean;
|
|
@@ -45,50 +66,49 @@ interface ChangedFile {
|
|
|
45
66
|
ancestors: FileNode[];
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
|
|
49
|
-
const expandedPaths = new Set<string>();
|
|
69
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
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;
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
|
|
63
|
-
return expandedPaths;
|
|
81
|
+
return null;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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);
|
|
71
92
|
if (node.children) {
|
|
72
93
|
for (const child of node.children) {
|
|
73
|
-
|
|
94
|
+
stack.push(child);
|
|
74
95
|
}
|
|
75
96
|
}
|
|
76
97
|
}
|
|
77
|
-
|
|
78
|
-
if (root) traverse(root);
|
|
79
98
|
}
|
|
80
99
|
|
|
81
|
-
function
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
if (!
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const found = findNodeByPath(child, path);
|
|
88
|
-
if (found) return found;
|
|
89
|
-
}
|
|
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
|
+
}
|
|
90
106
|
|
|
91
|
-
|
|
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;
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
function collectChangedFiles(node: FileNode, ancestors: FileNode[] = []): ChangedFile[] {
|
|
@@ -166,7 +186,8 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
|
|
|
166
186
|
function formatNodeName(node: FileNode, theme: Theme): string {
|
|
167
187
|
if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
|
|
168
188
|
if (node.isDirectory) {
|
|
169
|
-
|
|
189
|
+
const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
|
|
190
|
+
return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
|
|
170
191
|
}
|
|
171
192
|
if (node.gitStatus) return theme.fg("warning", node.name);
|
|
172
193
|
return node.name;
|
|
@@ -188,22 +209,44 @@ export function createFileBrowser(
|
|
|
188
209
|
agentModifiedFiles: Set<string>,
|
|
189
210
|
theme: Theme,
|
|
190
211
|
onClose: () => void,
|
|
191
|
-
requestComment: (payload: CommentPayload, comment: string) => void
|
|
212
|
+
requestComment: (payload: CommentPayload, comment: string) => void,
|
|
213
|
+
requestRender: () => void
|
|
192
214
|
): BrowserController {
|
|
193
|
-
let gitStatus = getGitStatus(cwd);
|
|
194
|
-
let diffStats = getGitDiffStats(cwd);
|
|
195
|
-
const gitBranch = getGitBranch(cwd);
|
|
196
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) : "";
|
|
197
220
|
|
|
198
221
|
const viewer = createViewer(cwd, theme, requestComment);
|
|
199
222
|
|
|
200
|
-
const root =
|
|
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
|
+
};
|
|
201
242
|
|
|
202
243
|
const browser: BrowserState = {
|
|
203
244
|
root,
|
|
204
245
|
flatList: [],
|
|
205
246
|
fullList: [],
|
|
206
247
|
stats: getTreeStats(root),
|
|
248
|
+
nodeByPath: new Map<string, FileNode>(),
|
|
249
|
+
scanState,
|
|
207
250
|
selectedIndex: 0,
|
|
208
251
|
searchQuery: "",
|
|
209
252
|
searchMode: false,
|
|
@@ -212,26 +255,332 @@ export function createFileBrowser(
|
|
|
212
255
|
lastPollTime: Date.now(),
|
|
213
256
|
};
|
|
214
257
|
|
|
258
|
+
indexNodes(browser.root, browser.nodeByPath);
|
|
215
259
|
browser.flatList = browser.root ? flattenTree(browser.root) : [];
|
|
216
260
|
browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
|
|
217
261
|
|
|
218
|
-
|
|
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;
|
|
266
|
+
|
|
267
|
+
const scanQueue: Array<{ node: FileNode; depth: number }> = [];
|
|
268
|
+
const scanQueued = new Set<string>();
|
|
269
|
+
let scanTimer: ReturnType<typeof setTimeout> | null = null;
|
|
270
|
+
|
|
271
|
+
const normalizeGitPath = (path: string): string => path.split(sep).join("/");
|
|
272
|
+
|
|
273
|
+
function refreshLists(): void {
|
|
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;
|
|
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;
|
|
219
568
|
const previousDisplayList = getDisplayList();
|
|
220
569
|
const currentPath = previousDisplayList[browser.selectedIndex]?.node.path;
|
|
221
570
|
const viewingFile = viewer.getFile();
|
|
222
571
|
const viewingFilePath = viewingFile?.path;
|
|
223
572
|
|
|
224
|
-
|
|
573
|
+
if (repo) {
|
|
574
|
+
gitStatus = getGitStatus(cwd);
|
|
575
|
+
diffStats = getGitDiffStats(cwd);
|
|
576
|
+
applyGitUpdates();
|
|
577
|
+
addUntrackedNodes();
|
|
578
|
+
}
|
|
225
579
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
browser.root = buildFileTree(cwd, cwd, gitStatus, diffStats, ignored, agentModifiedFiles);
|
|
580
|
+
applyAgentModified();
|
|
581
|
+
updateTreeStats(browser.root);
|
|
229
582
|
browser.stats = getTreeStats(browser.root);
|
|
230
|
-
|
|
231
|
-
restoreExpandedPaths(browser.root, expandedPaths);
|
|
232
|
-
|
|
233
|
-
browser.flatList = browser.root ? flattenTree(browser.root) : [];
|
|
234
|
-
browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
|
|
583
|
+
refreshLists();
|
|
235
584
|
|
|
236
585
|
const updatedDisplayList = getDisplayList();
|
|
237
586
|
if (currentPath) {
|
|
@@ -244,7 +593,7 @@ export function createFileBrowser(
|
|
|
244
593
|
browser.selectedIndex = Math.min(browser.selectedIndex, Math.max(0, updatedDisplayList.length - 1));
|
|
245
594
|
|
|
246
595
|
if (viewingFilePath && browser.root) {
|
|
247
|
-
const newNode = findNodeByPath(browser.root, viewingFilePath);
|
|
596
|
+
const newNode = browser.nodeByPath.get(viewingFilePath) ?? findNodeByPath(browser.root, viewingFilePath);
|
|
248
597
|
if (newNode) {
|
|
249
598
|
if (newNode.lineCount === undefined && viewingFile?.lineCount !== undefined) {
|
|
250
599
|
newNode.lineCount = viewingFile.lineCount;
|
|
@@ -254,6 +603,12 @@ export function createFileBrowser(
|
|
|
254
603
|
}
|
|
255
604
|
}
|
|
256
605
|
|
|
606
|
+
if (repo) {
|
|
607
|
+
queueLineCountsForTree(browser.root);
|
|
608
|
+
} else if (browser.root) {
|
|
609
|
+
enqueueScan(browser.root, 0, true);
|
|
610
|
+
}
|
|
611
|
+
|
|
257
612
|
function getDisplayList(): FlatNode[] {
|
|
258
613
|
let list = browser.searchQuery ? browser.fullList : browser.flatList;
|
|
259
614
|
|
|
@@ -317,7 +672,10 @@ export function createFileBrowser(
|
|
|
317
672
|
function toggleDir(node: FileNode): void {
|
|
318
673
|
if (node.isDirectory) {
|
|
319
674
|
node.expanded = !node.expanded;
|
|
320
|
-
|
|
675
|
+
if (!repo && node.expanded && node.children === undefined) {
|
|
676
|
+
enqueueScan(node, getNodeDepth(node, cwd), true);
|
|
677
|
+
}
|
|
678
|
+
refreshLists();
|
|
321
679
|
}
|
|
322
680
|
}
|
|
323
681
|
|
|
@@ -338,16 +696,32 @@ export function createFileBrowser(
|
|
|
338
696
|
if (stats.additions > 0) statsDisplay += theme.fg("success", ` +${stats.additions}`);
|
|
339
697
|
if (stats.deletions > 0) statsDisplay += theme.fg("error", ` -${stats.deletions}`);
|
|
340
698
|
|
|
699
|
+
const hasActivity = browser.scanState.isScanning || lineCountPending.size > 0;
|
|
700
|
+
if (hasActivity) {
|
|
701
|
+
browser.scanState.spinnerIndex = (browser.scanState.spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
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]") : "";
|
|
709
|
+
|
|
341
710
|
const searchIndicator = browser.searchMode
|
|
342
711
|
? theme.fg("accent", ` /${browser.searchQuery}█`)
|
|
343
712
|
: "";
|
|
344
713
|
|
|
345
|
-
lines.push(
|
|
714
|
+
lines.push(
|
|
715
|
+
truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + activityIndicator + partialIndicator + searchIndicator, width)
|
|
716
|
+
);
|
|
346
717
|
lines.push(theme.fg("borderMuted", "─".repeat(width)));
|
|
347
718
|
|
|
348
719
|
const displayList = getDisplayList();
|
|
349
720
|
if (displayList.length === 0) {
|
|
350
|
-
|
|
721
|
+
const emptyLabel = browser.scanState.isScanning
|
|
722
|
+
? " (loading...)"
|
|
723
|
+
: " (no files" + (browser.searchQuery ? " matching '" + browser.searchQuery + "'" : "") + ")";
|
|
724
|
+
lines.push(theme.fg("dim", emptyLabel));
|
|
351
725
|
for (let i = 1; i < browser.browserHeight; i++) {
|
|
352
726
|
lines.push("");
|
|
353
727
|
}
|
|
@@ -422,6 +796,7 @@ export function createFileBrowser(
|
|
|
422
796
|
const displayList = getDisplayList();
|
|
423
797
|
|
|
424
798
|
if (matchesKey(data, "q") && !browser.searchMode) {
|
|
799
|
+
stopBackgroundTasks();
|
|
425
800
|
onClose();
|
|
426
801
|
return;
|
|
427
802
|
}
|
|
@@ -430,6 +805,7 @@ export function createFileBrowser(
|
|
|
430
805
|
browser.searchMode = false;
|
|
431
806
|
browser.searchQuery = "";
|
|
432
807
|
} else {
|
|
808
|
+
stopBackgroundTasks();
|
|
433
809
|
onClose();
|
|
434
810
|
}
|
|
435
811
|
return;
|
|
@@ -521,9 +897,9 @@ export function createFileBrowser(
|
|
|
521
897
|
return {
|
|
522
898
|
render(width: number): string[] {
|
|
523
899
|
const now = Date.now();
|
|
524
|
-
if (now - browser.lastPollTime > POLL_INTERVAL_MS) {
|
|
900
|
+
if (repo && now - browser.lastPollTime > POLL_INTERVAL_MS) {
|
|
525
901
|
browser.lastPollTime = now;
|
|
526
|
-
|
|
902
|
+
refreshMetadata();
|
|
527
903
|
}
|
|
528
904
|
|
|
529
905
|
if (viewer.isOpen()) {
|
package/constants.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export const MAX_TREE_DEPTH = 6;
|
|
2
2
|
export const POLL_INTERVAL_MS = 3000;
|
|
3
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;
|
|
4
9
|
|
|
5
10
|
export const DEFAULT_VIEWER_HEIGHT = 18;
|
|
6
11
|
export const DEFAULT_BROWSER_HEIGHT = 15;
|
package/file-tree.ts
CHANGED
|
@@ -1,29 +1,177 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { basename, join, relative } from "node:path";
|
|
1
|
+
import { join } from "node:path";
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { MAX_TREE_DEPTH } from "./constants";
|
|
5
4
|
import type { DiffStats, FileNode, FlatNode } from "./types";
|
|
6
5
|
|
|
7
|
-
const
|
|
6
|
+
const collator = new Intl.Collator(undefined, { sensitivity: "base" });
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return cached.count;
|
|
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);
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
}
|
|
14
74
|
|
|
15
|
-
|
|
16
|
-
|
|
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 };
|
|
17
82
|
}
|
|
18
83
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
84
|
+
traverse(root);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildFileTreeFromPaths(
|
|
88
|
+
cwd: string,
|
|
89
|
+
filePaths: string[],
|
|
90
|
+
gitStatus: Map<string, string>,
|
|
91
|
+
diffStats: Map<string, DiffStats>,
|
|
92
|
+
ignored: Set<string>,
|
|
93
|
+
agentModified: Set<string>
|
|
94
|
+
): FileNode {
|
|
95
|
+
const root: FileNode = {
|
|
96
|
+
name: ".",
|
|
97
|
+
path: cwd,
|
|
98
|
+
isDirectory: true,
|
|
99
|
+
children: [],
|
|
100
|
+
expanded: true,
|
|
101
|
+
hasChangedChildren: false,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const directoryMap = new Map<string, FileNode>();
|
|
105
|
+
directoryMap.set("", root);
|
|
106
|
+
const seenFiles = new Set<string>();
|
|
107
|
+
|
|
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;
|
|
130
|
+
}
|
|
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);
|
|
145
|
+
}
|
|
146
|
+
current = dirNode;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (skip) continue;
|
|
150
|
+
|
|
151
|
+
const fileName = parts[parts.length - 1];
|
|
152
|
+
if (shouldIgnoreSegment(fileName, ignored)) continue;
|
|
153
|
+
|
|
154
|
+
const fileRelPath = parts.join("/");
|
|
155
|
+
if (seenFiles.has(fileRelPath)) continue;
|
|
156
|
+
seenFiles.add(fileRelPath);
|
|
157
|
+
|
|
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
|
+
});
|
|
26
170
|
}
|
|
171
|
+
|
|
172
|
+
sortTree(root);
|
|
173
|
+
updateTreeStats(root);
|
|
174
|
+
return root;
|
|
27
175
|
}
|
|
28
176
|
|
|
29
177
|
export function getIgnoredNames(): Set<string> {
|
|
@@ -48,113 +196,6 @@ export function getIgnoredNames(): Set<string> {
|
|
|
48
196
|
]);
|
|
49
197
|
}
|
|
50
198
|
|
|
51
|
-
export function buildFileTree(
|
|
52
|
-
dir: string,
|
|
53
|
-
cwd: string,
|
|
54
|
-
gitStatus: Map<string, string>,
|
|
55
|
-
diffStats: Map<string, DiffStats>,
|
|
56
|
-
ignored: Set<string>,
|
|
57
|
-
agentModified: Set<string>,
|
|
58
|
-
depth = 0
|
|
59
|
-
): FileNode | null {
|
|
60
|
-
if (depth > MAX_TREE_DEPTH) return null;
|
|
61
|
-
|
|
62
|
-
const name = basename(dir) || dir;
|
|
63
|
-
if (ignored.has(name)) return null;
|
|
64
|
-
|
|
65
|
-
const node: FileNode = {
|
|
66
|
-
name: depth === 0 ? "." : name,
|
|
67
|
-
path: dir,
|
|
68
|
-
isDirectory: true,
|
|
69
|
-
children: [],
|
|
70
|
-
expanded: depth < 1,
|
|
71
|
-
hasChangedChildren: false,
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const entries = readdirSync(dir);
|
|
76
|
-
const dirs: FileNode[] = [];
|
|
77
|
-
const files: FileNode[] = [];
|
|
78
|
-
|
|
79
|
-
for (const entry of entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))) {
|
|
80
|
-
if (ignored.has(entry) || entry.startsWith(".")) continue;
|
|
81
|
-
|
|
82
|
-
const fullPath = join(dir, entry);
|
|
83
|
-
let stat;
|
|
84
|
-
try {
|
|
85
|
-
stat = statSync(fullPath);
|
|
86
|
-
} catch {
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (stat.isDirectory()) {
|
|
91
|
-
const child = buildFileTree(fullPath, cwd, gitStatus, diffStats, ignored, agentModified, depth + 1);
|
|
92
|
-
if (child) {
|
|
93
|
-
dirs.push(child);
|
|
94
|
-
// Propagate hasChangedChildren up
|
|
95
|
-
if (child.hasChangedChildren || child.gitStatus) {
|
|
96
|
-
node.hasChangedChildren = true;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
const relPath = relative(cwd, fullPath);
|
|
101
|
-
const fileGitStatus = gitStatus.get(relPath);
|
|
102
|
-
const fileDiffStats = diffStats.get(relPath);
|
|
103
|
-
|
|
104
|
-
if (fileGitStatus) {
|
|
105
|
-
node.hasChangedChildren = true;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
files.push({
|
|
109
|
-
name: entry,
|
|
110
|
-
path: fullPath,
|
|
111
|
-
isDirectory: false,
|
|
112
|
-
gitStatus: fileGitStatus,
|
|
113
|
-
agentModified: agentModified.has(fullPath),
|
|
114
|
-
lineCount: getFileLineCount(fullPath, stat.size, stat.mtimeMs),
|
|
115
|
-
diffStats: fileDiffStats,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
node.children = [...dirs, ...files];
|
|
121
|
-
|
|
122
|
-
// Calculate aggregated stats for this directory
|
|
123
|
-
let totalLines = 0;
|
|
124
|
-
let totalAdditions = 0;
|
|
125
|
-
let totalDeletions = 0;
|
|
126
|
-
let lineCountComplete = true;
|
|
127
|
-
|
|
128
|
-
for (const child of node.children) {
|
|
129
|
-
if (child.isDirectory) {
|
|
130
|
-
totalLines += child.totalLines ?? 0;
|
|
131
|
-
totalAdditions += child.totalAdditions ?? 0;
|
|
132
|
-
totalDeletions += child.totalDeletions ?? 0;
|
|
133
|
-
if (child.lineCountComplete === false) {
|
|
134
|
-
lineCountComplete = false;
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
if (child.lineCount === undefined) {
|
|
138
|
-
lineCountComplete = false;
|
|
139
|
-
} else {
|
|
140
|
-
totalLines += child.lineCount;
|
|
141
|
-
}
|
|
142
|
-
if (child.diffStats) {
|
|
143
|
-
totalAdditions += child.diffStats.additions;
|
|
144
|
-
totalDeletions += child.diffStats.deletions;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
node.totalLines = totalLines;
|
|
150
|
-
node.totalAdditions = totalAdditions;
|
|
151
|
-
node.totalDeletions = totalDeletions;
|
|
152
|
-
node.lineCountComplete = lineCountComplete;
|
|
153
|
-
} catch {}
|
|
154
|
-
|
|
155
|
-
return node;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
199
|
export function flattenTree(
|
|
159
200
|
node: FileNode,
|
|
160
201
|
depth = 0,
|
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,17 +39,18 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
39
39
|
} as any);
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
const requestRender = () => tui.requestRender();
|
|
43
|
+
const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment, requestRender);
|
|
43
44
|
|
|
44
45
|
pollInterval = setInterval(() => {
|
|
45
|
-
|
|
46
|
+
requestRender();
|
|
46
47
|
}, POLL_INTERVAL_MS);
|
|
47
48
|
|
|
48
49
|
return {
|
|
49
50
|
render: (w) => browser.render(w),
|
|
50
51
|
handleInput: (data) => {
|
|
51
52
|
browser.handleInput(data);
|
|
52
|
-
|
|
53
|
+
requestRender();
|
|
53
54
|
},
|
|
54
55
|
invalidate: () => browser.invalidate(),
|
|
55
56
|
};
|
package/package.json
CHANGED