@tmustier/pi-files-widget 0.1.20 → 0.2.0

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,7 +2,22 @@
2
2
 
3
3
  All notable changes to this extension will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.2.0] - 2026-07-04
6
+
7
+ ### Added
8
+ - `/readfiles` now supports browsing outside the current working directory. Press `u` to re-root to the parent, `.` to jump back to where you started, or pass an explicit starting path (`/readfiles <path>` or `/readfiles ~/somewhere`). The browser header shows the current root so you always know where you are, and comments on files outside the project use absolute paths so the agent can still find them.
9
+
10
+ ### Fixed
11
+ - Git status, diff stats, and untracked-file discovery now work when the browser root is a subdirectory of the git repository (e.g. after `u`, `.`, or `/readfiles <subdir>`, or when pi runs from a repo subdirectory). Previously repo-root-relative git paths were mixed with root-relative node keys, producing phantom tree entries, missing statuses, and unopenable nested paths.
12
+ - Re-rooting the browser while a background directory scan or line-count batch is in flight no longer lets the stale batch mutate the new root's tree, node index, or scan state.
13
+ - Use `where` instead of `which` on Windows to detect `bat`, `delta`, and `glow`, so the dependency check works when running from PowerShell or cmd.exe.
14
+
15
+ ## [0.1.21] - 2026-05-07
16
+
17
+ ### Changed
18
+ - Declare `@earendil-works` Pi development dependencies used by runtime imports.
19
+ - Update Pi extension imports and peer dependencies to the new `@earendil-works` namespace.
20
+
6
21
 
7
22
  ## [0.1.20] - 2026-04-24
8
23
 
package/README.md CHANGED
@@ -81,7 +81,8 @@ The `/readfiles` browser requires these tools and will refuse to open until they
81
81
 
82
82
  ## Commands
83
83
 
84
- - `/readfiles` - open the file browser and viewer
84
+ - `/readfiles` - open the file browser in the current directory
85
+ - `/readfiles <path>` - open the file browser rooted at `<path>` (absolute, relative, or `~`-prefixed)
85
86
 
86
87
  Diff viewing is built into the file viewer: open a changed tracked file and press `d` to toggle the git diff view.
87
88
 
@@ -94,6 +95,8 @@ Diff viewing is built into the file viewer: open a changed tracked file and pres
94
95
  - `c`: toggle changed-only view
95
96
  - `]` / `[`: next/prev changed file
96
97
  - `/`: search (type to filter, `Esc` to exit)
98
+ - `u`: go up one directory (re-root to parent)
99
+ - `.`: jump back to the starting directory
97
100
  - `+` / `-`: increase/decrease browser height
98
101
  - `q`: close
99
102
 
@@ -118,6 +121,7 @@ Diff viewing is built into the file viewer: open a changed tracked file and pres
118
121
 
119
122
  - Untracked files show as `[UNTRACKED]` and open in normal view.
120
123
  - Searching in rendered Markdown switches to raw mode first, and selecting from rendered Markdown first switches you back to raw so line-based matches and comments stay aligned with the source file.
124
+ - When you browse outside the current project directory, inline comments on those files use absolute paths so the agent can still locate them. Files inside the project continue to use project-relative paths.
121
125
  - Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
122
126
  - Line counts load asynchronously; the header shows activity while counts are computed.
123
127
  - Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
package/TODO.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - [x] Collapse/expand directories
18
18
  - [x] Navigation with `j/k` and arrow keys
19
19
  - [x] Enter to expand dir or open file
20
+ - [x] Browse outside the current working directory (`u` to go up, `.` to reset, `/readfiles <path>` to start elsewhere)
20
21
 
21
22
  ### Git Integration
22
23
  - [x] Parse `git status --porcelain` output
package/browser.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { Theme } from "@mariozechner/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { lstatSync, realpathSync, statSync } from "node:fs";
4
4
  import { readdir, readFile, realpath, stat } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { basename, join, relative, resolve, sep } from "node:path";
6
+ import { join, relative, resolve, sep } from "node:path";
7
7
 
8
8
  import {
9
9
  DEFAULT_BROWSER_HEIGHT,
@@ -98,13 +98,21 @@ function indexNodes(root: FileNode | null, map: Map<string, FileNode>): void {
98
98
  }
99
99
  }
100
100
 
101
- function getNodeDepth(node: FileNode, cwd: string): number {
102
- if (node.path === cwd) return 0;
103
- const rel = relative(cwd, node.path);
101
+ function getNodeDepth(node: FileNode, root: string): number {
102
+ if (node.path === root) return 0;
103
+ const rel = relative(root, node.path);
104
104
  if (!rel) return 0;
105
105
  return rel.split(sep).length;
106
106
  }
107
107
 
108
+ function formatRootPath(path: string): string {
109
+ const home = homedir();
110
+ if (!home) return path;
111
+ if (path === home) return "~";
112
+ if (path.startsWith(home + sep)) return "~" + path.slice(home.length);
113
+ return path;
114
+ }
115
+
108
116
  function safeRealPathSync(path: string): string {
109
117
  try {
110
118
  return realpathSync(path);
@@ -150,8 +158,8 @@ function hasAncestorRealPath(node: FileNode | undefined, realPath: string): bool
150
158
  return false;
151
159
  }
152
160
 
153
- function shouldSafeMode(cwd: string): boolean {
154
- const resolved = resolve(cwd);
161
+ function shouldSafeMode(path: string): boolean {
162
+ const resolved = resolve(path);
155
163
  const home = resolve(homedir());
156
164
  const root = resolve(sep);
157
165
  return resolved === home || resolved === root;
@@ -256,48 +264,39 @@ function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
256
264
  }
257
265
 
258
266
  export function createFileBrowser(
259
- cwd: string,
267
+ initialPath: string,
260
268
  agentModifiedFiles: Set<string>,
261
269
  theme: Theme,
262
270
  onClose: () => void,
263
271
  requestComment: (payload: CommentPayload, comment: string) => void,
264
- requestRender: () => void
272
+ requestRender: () => void,
273
+ projectCwd: string = initialPath
265
274
  ): BrowserController {
266
275
  const ignored = getIgnoredNames();
267
- const repo = isGitRepo(cwd);
268
- let gitStatus = repo ? getGitStatus(cwd) : new Map<string, string>();
269
- let diffStats = repo ? getGitDiffStats(cwd) : new Map<string, DiffStats>();
270
- const gitBranch = repo ? getGitBranch(cwd) : "";
271
276
 
272
- const viewer = createViewer(cwd, theme, requestComment);
273
- const textInput = createTextInputBuffer();
277
+ let rootPath = resolve(initialPath);
278
+ const initialRoot = rootPath;
279
+ let repo = false;
280
+ let gitStatus = new Map<string, string>();
281
+ let diffStats = new Map<string, DiffStats>();
282
+ let gitBranch = "";
274
283
 
275
- const root = repo
276
- ? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
277
- : {
278
- name: ".",
279
- path: cwd,
280
- isDirectory: true,
281
- realPath: safeRealPathSync(cwd),
282
- children: undefined,
283
- expanded: true,
284
- hasChangedChildren: false,
285
- };
284
+ const viewer = createViewer({ getRoot: () => rootPath, projectCwd }, theme, requestComment);
285
+ const textInput = createTextInputBuffer();
286
286
 
287
- const safeMode = !repo && shouldSafeMode(cwd);
288
287
  const scanState: ScanState = {
289
- mode: repo ? "none" : safeMode ? "safe" : "full",
288
+ mode: "none",
290
289
  isScanning: false,
291
- isPartial: safeMode,
290
+ isPartial: false,
292
291
  pending: 0,
293
292
  spinnerIndex: 0,
294
293
  };
295
294
 
296
295
  const browser: BrowserState = {
297
- root,
296
+ root: null,
298
297
  flatList: [],
299
298
  fullList: [],
300
- stats: getTreeStats(root),
299
+ stats: { totalLines: undefined, additions: 0, deletions: 0 },
301
300
  nodeByPath: new Map<string, FileNode>(),
302
301
  scanState,
303
302
  selectedIndex: 0,
@@ -308,10 +307,6 @@ export function createFileBrowser(
308
307
  lastPollTime: Date.now(),
309
308
  };
310
309
 
311
- indexNodes(browser.root, browser.nodeByPath);
312
- browser.flatList = browser.root ? flattenTree(browser.root) : [];
313
- browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
314
-
315
310
  const lineCountCache = new Map<string, { size: number; mtimeMs: number; count: number }>();
316
311
  const lineCountQueue: FileNode[] = [];
317
312
  const lineCountPending = new Set<string>();
@@ -320,6 +315,10 @@ export function createFileBrowser(
320
315
  const scanQueue: Array<{ node: FileNode; depth: number }> = [];
321
316
  const scanQueued = new Set<string>();
322
317
  let scanTimer: ReturnType<typeof setTimeout> | null = null;
318
+ // Incremented on every (re-)root. In-flight async scan/line-count batches
319
+ // capture the value when they start and bail after each await if it changed,
320
+ // so work belonging to an old root can never mutate state for the new one.
321
+ let rootGeneration = 0;
323
322
 
324
323
  const normalizeGitPath = (path: string): string => path.split(sep).join("/");
325
324
 
@@ -381,15 +380,19 @@ export function createFileBrowser(
381
380
  async function processLineCountBatch(): Promise<void> {
382
381
  lineCountTimer = null;
383
382
  if (!browser.root) return;
383
+ const generation = rootGeneration;
384
384
  const batch = lineCountQueue.splice(0, LINE_COUNT_BATCH_SIZE);
385
385
  if (batch.length === 0) return;
386
386
 
387
387
  await Promise.all(
388
388
  batch.map(async node => {
389
389
  await updateLineCount(node);
390
- lineCountPending.delete(node.path);
390
+ if (generation === rootGeneration) {
391
+ lineCountPending.delete(node.path);
392
+ }
391
393
  })
392
394
  );
395
+ if (generation !== rootGeneration) return;
393
396
 
394
397
  updateTreeStats(browser.root);
395
398
  browser.stats = getTreeStats(browser.root);
@@ -441,12 +444,13 @@ export function createFileBrowser(
441
444
  }
442
445
  }
443
446
 
444
- async function scanDirectory(node: FileNode, depth: number): Promise<void> {
447
+ async function scanDirectory(node: FileNode, depth: number, generation: number): Promise<void> {
445
448
  try {
446
449
  const entries = await readdir(node.path, { withFileTypes: true });
450
+ if (generation !== rootGeneration) return;
447
451
  const sorted = [...entries].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
448
452
 
449
- if (node.path === cwd && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
453
+ if (node.path === rootPath && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
450
454
  browser.scanState.mode = "safe";
451
455
  browser.scanState.isPartial = true;
452
456
  scanQueue.length = 0;
@@ -462,11 +466,13 @@ export function createFileBrowser(
462
466
  const childDepth = depth + 1;
463
467
 
464
468
  if (entry.isDirectory()) {
469
+ const dirRealPath = await realpath(fullPath).catch(() => resolve(fullPath));
470
+ if (generation !== rootGeneration) return;
465
471
  const dirNode: FileNode = {
466
472
  name: entry.name,
467
473
  path: fullPath,
468
474
  isDirectory: true,
469
- realPath: await realpath(fullPath).catch(() => resolve(fullPath)),
475
+ realPath: dirRealPath,
470
476
  parent: node,
471
477
  children: undefined,
472
478
  expanded: childDepth < 1,
@@ -482,6 +488,7 @@ export function createFileBrowser(
482
488
 
483
489
  if (entry.isSymbolicLink()) {
484
490
  const pathInfo = await getPathInfo(fullPath, true);
491
+ if (generation !== rootGeneration) return;
485
492
  if (pathInfo.isDirectory) {
486
493
  const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
487
494
  const dirNode: FileNode = {
@@ -531,16 +538,21 @@ export function createFileBrowser(
531
538
 
532
539
  node.children = [...dirs, ...files];
533
540
  } catch {
534
- node.children = [];
541
+ if (generation === rootGeneration) {
542
+ node.children = [];
543
+ }
535
544
  } finally {
536
- node.loading = false;
537
- scanQueued.delete(node.path);
545
+ if (generation === rootGeneration) {
546
+ node.loading = false;
547
+ scanQueued.delete(node.path);
548
+ }
538
549
  }
539
550
  }
540
551
 
541
552
  async function processScanBatch(): Promise<void> {
542
553
  scanTimer = null;
543
554
  if (!browser.root) return;
555
+ const generation = rootGeneration;
544
556
  const batch = scanQueue.splice(0, getScanBatchSize());
545
557
  if (batch.length === 0) {
546
558
  browser.scanState.isScanning = false;
@@ -549,7 +561,8 @@ export function createFileBrowser(
549
561
  }
550
562
 
551
563
  for (const item of batch) {
552
- await scanDirectory(item.node, item.depth);
564
+ await scanDirectory(item.node, item.depth, generation);
565
+ if (generation !== rootGeneration) return;
553
566
  }
554
567
 
555
568
  browser.scanState.pending = scanQueue.length;
@@ -586,7 +599,7 @@ export function createFileBrowser(
586
599
 
587
600
  function applyGitUpdates(): void {
588
601
  for (const node of browser.nodeByPath.values()) {
589
- const relPath = normalizeGitPath(relative(cwd, node.path));
602
+ const relPath = normalizeGitPath(relative(rootPath, node.path));
590
603
  node.gitStatus = gitStatus.get(relPath);
591
604
  node.diffStats = diffStats.get(relPath);
592
605
  }
@@ -611,7 +624,7 @@ export function createFileBrowser(
611
624
  const part = parts[i];
612
625
  if (ignored.has(part) || part.startsWith(".")) return null;
613
626
  currentRel = currentRel ? `${currentRel}/${part}` : part;
614
- const dirPath = join(cwd, currentRel);
627
+ const dirPath = join(rootPath, currentRel);
615
628
  let dirNode = browser.nodeByPath.get(dirPath);
616
629
  if (!dirNode) {
617
630
  const depth = i + 1;
@@ -636,7 +649,7 @@ export function createFileBrowser(
636
649
  const fileName = parts[parts.length - 1];
637
650
  if (ignored.has(fileName) || fileName.startsWith(".")) return null;
638
651
 
639
- const filePath = join(cwd, normalized);
652
+ const filePath = join(rootPath, normalized);
640
653
  const existing = browser.nodeByPath.get(filePath);
641
654
  if (existing) return existing;
642
655
 
@@ -704,8 +717,8 @@ export function createFileBrowser(
704
717
  const viewingFilePath = viewingFile?.path;
705
718
 
706
719
  if (repo) {
707
- gitStatus = getGitStatus(cwd);
708
- diffStats = getGitDiffStats(cwd);
720
+ gitStatus = getGitStatus(rootPath);
721
+ diffStats = getGitDiffStats(rootPath);
709
722
  applyGitUpdates();
710
723
  addUntrackedNodes();
711
724
  }
@@ -736,12 +749,67 @@ export function createFileBrowser(
736
749
  }
737
750
  }
738
751
 
739
- if (repo) {
740
- queueLineCountsForTree(browser.root);
741
- } else if (browser.root) {
742
- enqueueScan(browser.root, 0, true);
752
+ function loadRoot(newRoot: string): void {
753
+ rootGeneration += 1;
754
+ rootPath = resolve(newRoot);
755
+
756
+ repo = isGitRepo(rootPath);
757
+ gitStatus = repo ? getGitStatus(rootPath) : new Map<string, string>();
758
+ diffStats = repo ? getGitDiffStats(rootPath) : new Map<string, DiffStats>();
759
+ gitBranch = repo ? getGitBranch(rootPath) : "";
760
+
761
+ const newRootNode: FileNode = repo
762
+ ? buildFileTreeFromPaths(rootPath, getGitFileList(rootPath), gitStatus, diffStats, ignored, agentModifiedFiles)
763
+ : {
764
+ name: ".",
765
+ path: rootPath,
766
+ isDirectory: true,
767
+ realPath: safeRealPathSync(rootPath),
768
+ children: undefined,
769
+ expanded: true,
770
+ hasChangedChildren: false,
771
+ };
772
+
773
+ browser.root = newRootNode;
774
+
775
+ const safeMode = !repo && shouldSafeMode(rootPath);
776
+ browser.scanState.mode = repo ? "none" : safeMode ? "safe" : "full";
777
+ browser.scanState.isScanning = false;
778
+ browser.scanState.isPartial = safeMode;
779
+ browser.scanState.pending = 0;
780
+
781
+ indexNodes(browser.root, browser.nodeByPath);
782
+ refreshLists();
783
+ browser.stats = getTreeStats(browser.root);
784
+
785
+ browser.selectedIndex = 0;
786
+ browser.searchQuery = "";
787
+ browser.searchMode = false;
788
+ textInput.reset();
789
+ browser.lastPollTime = Date.now();
790
+
791
+ if (repo) {
792
+ queueLineCountsForTree(browser.root);
793
+ } else if (browser.root) {
794
+ enqueueScan(browser.root, 0, true);
795
+ }
743
796
  }
744
797
 
798
+ function setRoot(newRoot: string): void {
799
+ if (viewer.isOpen()) {
800
+ viewer.close();
801
+ }
802
+ stopBackgroundTasks();
803
+ scanQueue.length = 0;
804
+ scanQueued.clear();
805
+ lineCountQueue.length = 0;
806
+ lineCountPending.clear();
807
+ loadRoot(newRoot);
808
+ requestRender();
809
+ }
810
+
811
+ loadRoot(initialRoot);
812
+
745
813
  function getDisplayList(): FlatNode[] {
746
814
  let list = browser.searchQuery ? browser.fullList : browser.flatList;
747
815
 
@@ -806,7 +874,7 @@ export function createFileBrowser(
806
874
  if (node.isDirectory) {
807
875
  node.expanded = !node.expanded;
808
876
  if (node.expanded && node.children === undefined) {
809
- enqueueScan(node, getNodeDepth(node, cwd), true);
877
+ enqueueScan(node, getNodeDepth(node, rootPath), true);
810
878
  }
811
879
  refreshLists();
812
880
  }
@@ -818,7 +886,7 @@ export function createFileBrowser(
818
886
 
819
887
  function renderBrowser(width: number): string[] {
820
888
  const lines: string[] = [];
821
- const pathDisplay = basename(cwd);
889
+ const pathDisplay = formatRootPath(rootPath);
822
890
  const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
823
891
  const stats = browser.stats;
824
892
 
@@ -902,7 +970,7 @@ export function createFileBrowser(
902
970
  const changedIndicator = browser.showOnlyChanged ? theme.fg("warning", " [changed only]") : "";
903
971
  const help = browser.searchMode
904
972
  ? theme.fg("dim", "Type to search ↑↓: nav Enter: confirm Esc: cancel")
905
- : theme.fg("dim", "j/k: nav []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
973
+ : theme.fg("dim", "j/k: nav u: up .: home []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
906
974
  lines.push(truncateToWidth(help, width));
907
975
 
908
976
  return lines;
@@ -974,6 +1042,19 @@ export function createFileBrowser(
974
1042
  }
975
1043
  return;
976
1044
  }
1045
+ if (matchesKey(data, "u")) {
1046
+ const parent = resolve(rootPath, "..");
1047
+ if (parent !== rootPath) {
1048
+ setRoot(parent);
1049
+ }
1050
+ return;
1051
+ }
1052
+ if (matchesKey(data, ".")) {
1053
+ if (rootPath !== initialRoot) {
1054
+ setRoot(initialRoot);
1055
+ }
1056
+ return;
1057
+ }
977
1058
  if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
978
1059
  browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
979
1060
  return;