@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 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 { 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";
@@ -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
- function captureExpandedPaths(root: FileNode | null): Set<string> {
42
- const expandedPaths = new Set<string>();
69
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
43
70
 
44
- function traverse(node: FileNode): void {
45
- if (node.isDirectory && node.expanded) {
46
- expandedPaths.add(node.path);
47
- }
48
- if (node.children) {
49
- for (const child of node.children) {
50
- traverse(child);
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
- if (root) traverse(root);
56
- return expandedPaths;
81
+ return null;
57
82
  }
58
83
 
59
- function restoreExpandedPaths(root: FileNode | null, expandedPaths: Set<string>): void {
60
- function traverse(node: FileNode): void {
61
- if (node.isDirectory) {
62
- node.expanded = expandedPaths.has(node.path);
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
- traverse(child);
94
+ stack.push(child);
67
95
  }
68
96
  }
69
97
  }
70
-
71
- if (root) traverse(root);
72
98
  }
73
99
 
74
- function findNodeByPath(root: FileNode | null, path: string): FileNode | null {
75
- if (!root) return null;
76
- if (root.path === path) return root;
77
- if (!root.children) return null;
78
-
79
- for (const child of root.children) {
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
- 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;
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 computeTotalStats(root: FileNode | null): { totalLines: number; additions: number; deletions: number } {
104
- let totalLines = 0;
105
- let additions = 0;
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
- if (root) traverse(root);
124
- return { totalLines, additions, deletions };
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
- 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;
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: buildFileTree(cwd, cwd, gitStatus, diffStats, ignored, agentModifiedFiles),
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
- function refreshGitStatus(): void {
219
- const currentPath = browser.flatList[browser.selectedIndex]?.node.path;
220
- const viewingFilePath = viewer.getFile()?.path;
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
- const expandedPaths = captureExpandedPaths(browser.root);
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
- gitStatus = getGitStatus(cwd);
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 = browser.flatList.findIndex(f => f.node.path === currentPath);
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, browser.flatList.length - 1));
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
- 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();
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 = computeTotalStats(browser.root);
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
- let statsDisplay = theme.fg("dim", ` ${stats.totalLines}L`);
327
- if (stats.additions > 0 || stats.deletions > 0) {
328
- if (stats.additions > 0) statsDisplay += theme.fg("success", ` +${stats.additions}`);
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(truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + searchIndicator, width));
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
- 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));
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
- refreshGitStatus();
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 { readdirSync, readFileSync, statSync } from "node:fs";
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
- export function getFileLineCount(filePath: string): number {
8
- try {
9
- const content = readFileSync(filePath, "utf-8");
10
- return content.split("\n").length;
11
- } catch {
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
- export function getIgnoredNames(): Set<string> {
17
- return new Set([
18
- "node_modules",
19
- ".git",
20
- ".DS_Store",
21
- "__pycache__",
22
- ".pytest_cache",
23
- ".mypy_cache",
24
- ".next",
25
- ".nuxt",
26
- "dist",
27
- "build",
28
- ".venv",
29
- "venv",
30
- ".env",
31
- "coverage",
32
- ".nyc_output",
33
- ".turbo",
34
- ".cache",
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 buildFileTree(
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
- depth = 0
46
- ): FileNode | null {
47
- if (depth > MAX_TREE_DEPTH) return null;
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: depth < 1,
100
+ expanded: true,
58
101
  hasChangedChildren: false,
59
102
  };
60
103
 
61
- try {
62
- const entries = readdirSync(dir);
63
- const dirs: FileNode[] = [];
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
- const fullPath = join(dir, entry);
70
- let stat;
71
- try {
72
- stat = statSync(fullPath);
73
- } catch {
74
- continue;
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
- if (stat.isDirectory()) {
78
- const child = buildFileTree(fullPath, cwd, gitStatus, diffStats, ignored, agentModified, depth + 1);
79
- if (child) {
80
- dirs.push(child);
81
- // Propagate hasChangedChildren up
82
- if (child.hasChangedChildren || child.gitStatus) {
83
- node.hasChangedChildren = true;
84
- }
85
- }
86
- } else {
87
- const relPath = relative(cwd, fullPath);
88
- const fileGitStatus = gitStatus.get(relPath);
89
- const fileDiffStats = diffStats.get(relPath);
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
- node.children = [...dirs, ...files];
149
+ if (skip) continue;
108
150
 
109
- // Calculate aggregated stats for this directory
110
- let totalLines = 0;
111
- let totalAdditions = 0;
112
- let totalDeletions = 0;
151
+ const fileName = parts[parts.length - 1];
152
+ if (shouldIgnoreSegment(fileName, ignored)) continue;
113
153
 
114
- for (const child of node.children) {
115
- if (child.isDirectory) {
116
- totalLines += child.totalLines || 0;
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
- node.totalLines = totalLines;
129
- node.totalAdditions = totalAdditions;
130
- node.totalDeletions = totalDeletions;
131
- } catch {}
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
- return node;
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(node: FileNode, depth = 0, isRoot = true): FlatNode[] {
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.expanded && node.children) {
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
- // 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.0",
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
@@ -18,6 +18,8 @@ export interface FileNode {
18
18
  totalLines?: number;
19
19
  totalAdditions?: number;
20
20
  totalDeletions?: number;
21
+ lineCountComplete?: boolean;
22
+ loading?: boolean;
21
23
  }
22
24
 
23
25
  export interface FlatNode {
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