@tmustier/pi-files-widget 0.1.7 → 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 CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## [0.1.7] - 2026-01-26
6
14
 
7
15
  ### 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 { basename } from "node:path";
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 { buildFileTree, flattenTree, getIgnoredNames } from "./file-tree";
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
- function captureExpandedPaths(root: FileNode | null): Set<string> {
49
- const expandedPaths = new Set<string>();
69
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
50
70
 
51
- function traverse(node: FileNode): void {
52
- if (node.isDirectory && node.expanded) {
53
- expandedPaths.add(node.path);
54
- }
55
- if (node.children) {
56
- for (const child of node.children) {
57
- traverse(child);
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
- if (root) traverse(root);
63
- return expandedPaths;
81
+ return null;
64
82
  }
65
83
 
66
- function restoreExpandedPaths(root: FileNode | null, expandedPaths: Set<string>): void {
67
- function traverse(node: FileNode): void {
68
- if (node.isDirectory) {
69
- node.expanded = expandedPaths.has(node.path);
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
- traverse(child);
94
+ stack.push(child);
74
95
  }
75
96
  }
76
97
  }
77
-
78
- if (root) traverse(root);
79
98
  }
80
99
 
81
- function findNodeByPath(root: FileNode | null, path: string): FileNode | null {
82
- if (!root) return null;
83
- if (root.path === path) return root;
84
- if (!root.children) return null;
85
-
86
- for (const child of root.children) {
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
- return null;
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
- return node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
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 = buildFileTree(cwd, cwd, gitStatus, diffStats, ignored, agentModifiedFiles);
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
- function refreshGitStatus(): void {
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
- const expandedPaths = captureExpandedPaths(browser.root);
573
+ if (repo) {
574
+ gitStatus = getGitStatus(cwd);
575
+ diffStats = getGitDiffStats(cwd);
576
+ applyGitUpdates();
577
+ addUntrackedNodes();
578
+ }
225
579
 
226
- gitStatus = getGitStatus(cwd);
227
- diffStats = getGitDiffStats(cwd);
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
- browser.flatList = browser.root ? flattenTree(browser.root) : [];
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(truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + searchIndicator, width));
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
- lines.push(theme.fg("dim", " (no files" + (browser.searchQuery ? " matching '" + browser.searchQuery + "'" : "") + ")"));
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
- refreshGitStatus();
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 { readdirSync, readFileSync, statSync } from "node:fs";
2
- import { basename, join, relative } from "node:path";
1
+ import { join } from "node:path";
3
2
 
4
- import { MAX_LINE_COUNT_BYTES, MAX_TREE_DEPTH } from "./constants";
3
+ import { MAX_TREE_DEPTH } from "./constants";
5
4
  import type { DiffStats, FileNode, FlatNode } from "./types";
6
5
 
7
- const lineCountCache = new Map<string, { mtimeMs: number; size: number; count: number }>();
6
+ const collator = new Intl.Collator(undefined, { sensitivity: "base" });
8
7
 
9
- export function getFileLineCount(filePath: string, size: number, mtimeMs: number): number | undefined {
10
- const cached = lineCountCache.get(filePath);
11
- if (cached && cached.size === size && cached.mtimeMs === mtimeMs) {
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
- if (size > MAX_LINE_COUNT_BYTES) {
16
- return undefined;
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
- try {
20
- const content = readFileSync(filePath, "utf-8");
21
- const count = content.split("\n").length;
22
- lineCountCache.set(filePath, { mtimeMs, size, count });
23
- return count;
24
- } catch {
25
- return undefined;
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
- // Include ignored files with --ignored flag
18
- const output = execSync("git status --porcelain --ignored", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
package/types.ts CHANGED
@@ -19,6 +19,7 @@ export interface FileNode {
19
19
  totalAdditions?: number;
20
20
  totalDeletions?: number;
21
21
  lineCountComplete?: boolean;
22
+ loading?: boolean;
22
23
  }
23
24
 
24
25
  export interface FlatNode {