@tmustier/pi-files-widget 0.1.17 → 0.1.19

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
@@ -4,6 +4,19 @@ All notable changes to this extension will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.18] - 2026-04-19
8
+
9
+ ### Changed
10
+ - Show symlinks with a `↗` marker in the `/readfiles` tree.
11
+
12
+ ### Fixed
13
+ - Let `/readfiles` navigate into directory symlinks in both non-git folders and git repos instead of rendering them as inert files or empty directories.
14
+ - Guard symlink directory scanning against ancestor cycles so links like `foo -> .` or `foo -> ..` don't recurse forever.
15
+ - Treat git-tracked and untracked directory symlinks as lazily scannable directories rather than plain files.
16
+
17
+ ### Thanks
18
+ - Thanks to @xapids for reporting the original macOS symlink navigation issue ([#9](https://github.com/tmustier/pi-extensions/issues/9)).
19
+
7
20
  ## [0.1.17] - 2026-04-19
8
21
 
9
22
  ### Changed
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  In-terminal file browser and diff viewer widget for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal and without interrupting your agent.
4
4
 
5
+ Directory symlinks are shown with a `↗` marker and can be expanded like normal folders.
6
+
5
7
  <video controls autoplay loop muted playsinline>
6
8
  <source src="demo.mp4" type="video/mp4" />
7
9
  </video>
package/browser.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { lstatSync, realpathSync, statSync } from "node:fs";
4
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
6
  import { basename, join, relative, resolve, sep } from "node:path";
6
7
 
@@ -104,6 +105,51 @@ function getNodeDepth(node: FileNode, cwd: string): number {
104
105
  return rel.split(sep).length;
105
106
  }
106
107
 
108
+ function safeRealPathSync(path: string): string {
109
+ try {
110
+ return realpathSync(path);
111
+ } catch {
112
+ return resolve(path);
113
+ }
114
+ }
115
+
116
+ function getPathInfoSync(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
117
+ try {
118
+ const linkStat = lstatSync(path);
119
+ const isSymlink = linkStat.isSymbolicLink();
120
+ const targetStat = isSymlink ? statSync(path) : linkStat;
121
+ return {
122
+ isDirectory: targetStat.isDirectory(),
123
+ isSymlink,
124
+ realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
125
+ };
126
+ } catch {
127
+ return { isDirectory: false, isSymlink: false };
128
+ }
129
+ }
130
+
131
+ async function getPathInfo(path: string, isSymlink: boolean): Promise<{ isDirectory: boolean; isSymlink: boolean; realPath?: string }> {
132
+ try {
133
+ const targetStat = await stat(path);
134
+ return {
135
+ isDirectory: targetStat.isDirectory(),
136
+ isSymlink,
137
+ realPath: targetStat.isDirectory() ? await realpath(path).catch(() => resolve(path)) : undefined,
138
+ };
139
+ } catch {
140
+ return { isDirectory: false, isSymlink };
141
+ }
142
+ }
143
+
144
+ function hasAncestorRealPath(node: FileNode | undefined, realPath: string): boolean {
145
+ let current = node;
146
+ while (current) {
147
+ if (current.realPath === realPath) return true;
148
+ current = current.parent;
149
+ }
150
+ return false;
151
+ }
152
+
107
153
  function shouldSafeMode(cwd: string): boolean {
108
154
  const resolved = resolve(cwd);
109
155
  const home = resolve(homedir());
@@ -183,14 +229,19 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
183
229
  return parts.length > 0 ? ` ${parts.join(" ")}` : "";
184
230
  }
185
231
 
232
+ function withSymlinkMarker(label: string, node: FileNode, theme: Theme): string {
233
+ return node.isSymlink ? `${label}${theme.fg("dim", " ↗")}` : label;
234
+ }
235
+
186
236
  function formatNodeName(node: FileNode, theme: Theme): string {
187
- if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
237
+ if (isIgnoredStatus(node.gitStatus)) return withSymlinkMarker(theme.fg("dim", node.name), node, theme);
188
238
  if (node.isDirectory) {
189
239
  const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
190
- return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
240
+ const rendered = withSymlinkMarker(label, node, theme);
241
+ return node.loading ? `${rendered}${theme.fg("dim", " ⏳")}` : rendered;
191
242
  }
192
- if (node.gitStatus) return theme.fg("warning", node.name);
193
- return node.name;
243
+ if (node.gitStatus) return withSymlinkMarker(theme.fg("warning", node.name), node, theme);
244
+ return withSymlinkMarker(node.name, node, theme);
194
245
  }
195
246
 
196
247
  function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
@@ -227,6 +278,7 @@ export function createFileBrowser(
227
278
  name: ".",
228
279
  path: cwd,
229
280
  isDirectory: true,
281
+ realPath: safeRealPathSync(cwd),
230
282
  children: undefined,
231
283
  expanded: true,
232
284
  hasChangedChildren: false,
@@ -350,6 +402,14 @@ export function createFileBrowser(
350
402
  }
351
403
 
352
404
  function shouldAutoScan(depth: number): boolean {
405
+ // In git repos the main tree comes from git file lists, not from filesystem
406
+ // crawling. If the user expands a symlinked directory inside that tree, only
407
+ // scan one level on demand; nested directories stay lazy until explicitly
408
+ // expanded so links into large trees (iCloud/Drive/$HOME) don't trigger a
409
+ // broad recursive crawl.
410
+ if (repo) {
411
+ return false;
412
+ }
353
413
  if (browser.scanState.mode === "safe") {
354
414
  return depth <= 0;
355
415
  }
@@ -399,31 +459,74 @@ export function createFileBrowser(
399
459
  for (const entry of sorted) {
400
460
  if (ignored.has(entry.name) || entry.name.startsWith(".")) continue;
401
461
  const fullPath = join(node.path, entry.name);
462
+ const childDepth = depth + 1;
463
+
402
464
  if (entry.isDirectory()) {
403
465
  const dirNode: FileNode = {
404
466
  name: entry.name,
405
467
  path: fullPath,
406
468
  isDirectory: true,
469
+ realPath: await realpath(fullPath).catch(() => resolve(fullPath)),
470
+ parent: node,
407
471
  children: undefined,
408
- expanded: depth + 1 < 1,
472
+ expanded: childDepth < 1,
409
473
  hasChangedChildren: false,
410
474
  };
411
475
  dirs.push(dirNode);
412
476
  browser.nodeByPath.set(fullPath, dirNode);
413
- if (shouldAutoScan(depth + 1)) {
414
- enqueueScan(dirNode, depth + 1);
477
+ if (shouldAutoScan(childDepth)) {
478
+ enqueueScan(dirNode, childDepth);
415
479
  }
416
- } else {
417
- const fileNode: FileNode = {
480
+ continue;
481
+ }
482
+
483
+ if (entry.isSymbolicLink()) {
484
+ const pathInfo = await getPathInfo(fullPath, true);
485
+ if (pathInfo.isDirectory) {
486
+ const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
487
+ const dirNode: FileNode = {
488
+ name: entry.name,
489
+ path: fullPath,
490
+ isDirectory: true,
491
+ isSymlink: true,
492
+ realPath: pathInfo.realPath,
493
+ parent: node,
494
+ children: isCycle ? [] : undefined,
495
+ expanded: childDepth < 1,
496
+ hasChangedChildren: false,
497
+ };
498
+ dirs.push(dirNode);
499
+ browser.nodeByPath.set(fullPath, dirNode);
500
+ if (!isCycle && shouldAutoScan(childDepth)) {
501
+ enqueueScan(dirNode, childDepth);
502
+ }
503
+ continue;
504
+ }
505
+
506
+ const symlinkFileNode: FileNode = {
418
507
  name: entry.name,
419
508
  path: fullPath,
420
509
  isDirectory: false,
510
+ isSymlink: true,
511
+ parent: node,
421
512
  agentModified: agentModifiedFiles.has(fullPath),
422
513
  };
423
- files.push(fileNode);
424
- browser.nodeByPath.set(fullPath, fileNode);
425
- queueLineCount(fileNode);
514
+ files.push(symlinkFileNode);
515
+ browser.nodeByPath.set(fullPath, symlinkFileNode);
516
+ queueLineCount(symlinkFileNode);
517
+ continue;
426
518
  }
519
+
520
+ const fileNode: FileNode = {
521
+ name: entry.name,
522
+ path: fullPath,
523
+ isDirectory: false,
524
+ parent: node,
525
+ agentModified: agentModifiedFiles.has(fullPath),
526
+ };
527
+ files.push(fileNode);
528
+ browser.nodeByPath.set(fullPath, fileNode);
529
+ queueLineCount(fileNode);
427
530
  }
428
531
 
429
532
  node.children = [...dirs, ...files];
@@ -483,14 +586,13 @@ export function createFileBrowser(
483
586
 
484
587
  function applyGitUpdates(): void {
485
588
  for (const node of browser.nodeByPath.values()) {
486
- if (node.isDirectory) continue;
487
589
  const relPath = normalizeGitPath(relative(cwd, node.path));
488
590
  node.gitStatus = gitStatus.get(relPath);
489
591
  node.diffStats = diffStats.get(relPath);
490
592
  }
491
593
  }
492
594
 
493
- function ensureFileNode(relPath: string): FileNode | null {
595
+ function ensureNode(relPath: string): FileNode | null {
494
596
  if (!browser.root) return null;
495
597
  let normalized = relPath.trim();
496
598
  if (!normalized) return null;
@@ -517,6 +619,8 @@ export function createFileBrowser(
517
619
  name: part,
518
620
  path: dirPath,
519
621
  isDirectory: true,
622
+ realPath: safeRealPathSync(dirPath),
623
+ parent: current,
520
624
  children: [],
521
625
  expanded: depth < 1,
522
626
  hasChangedChildren: false,
@@ -536,10 +640,36 @@ export function createFileBrowser(
536
640
  const existing = browser.nodeByPath.get(filePath);
537
641
  if (existing) return existing;
538
642
 
643
+ const pathInfo = getPathInfoSync(filePath);
644
+ if (pathInfo.isDirectory) {
645
+ const isCycle = pathInfo.realPath ? hasAncestorRealPath(current, pathInfo.realPath) : false;
646
+ const dirNode: FileNode = {
647
+ name: fileName,
648
+ path: filePath,
649
+ isDirectory: true,
650
+ isSymlink: pathInfo.isSymlink,
651
+ realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
652
+ parent: current,
653
+ children: pathInfo.isSymlink && !isCycle ? undefined : [],
654
+ expanded: false,
655
+ hasChangedChildren: false,
656
+ gitStatus: gitStatus.get(normalized),
657
+ diffStats: diffStats.get(normalized),
658
+ };
659
+
660
+ current.children ??= [];
661
+ current.children.push(dirNode);
662
+ sortChildren(current);
663
+ browser.nodeByPath.set(filePath, dirNode);
664
+ return dirNode;
665
+ }
666
+
539
667
  const fileNode: FileNode = {
540
668
  name: fileName,
541
669
  path: filePath,
542
670
  isDirectory: false,
671
+ isSymlink: pathInfo.isSymlink,
672
+ parent: current,
543
673
  gitStatus: gitStatus.get(normalized),
544
674
  agentModified: agentModifiedFiles.has(filePath),
545
675
  diffStats: diffStats.get(normalized),
@@ -555,11 +685,13 @@ export function createFileBrowser(
555
685
  function addUntrackedNodes(): void {
556
686
  for (const [relPath, status] of gitStatus.entries()) {
557
687
  if (!isUntrackedStatus(status)) continue;
558
- const node = ensureFileNode(relPath);
688
+ const node = ensureNode(relPath);
559
689
  if (node) {
560
690
  node.gitStatus = status;
561
691
  node.diffStats = diffStats.get(relPath);
562
- queueLineCount(node, true);
692
+ if (!node.isDirectory) {
693
+ queueLineCount(node, true);
694
+ }
563
695
  }
564
696
  }
565
697
  }
@@ -673,7 +805,7 @@ export function createFileBrowser(
673
805
  function toggleDir(node: FileNode): void {
674
806
  if (node.isDirectory) {
675
807
  node.expanded = !node.expanded;
676
- if (!repo && node.expanded && node.children === undefined) {
808
+ if (node.expanded && node.children === undefined) {
677
809
  enqueueScan(node, getNodeDepth(node, cwd), true);
678
810
  }
679
811
  refreshLists();
package/file-tree.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { MAX_TREE_DEPTH } from "./constants";
@@ -6,11 +6,26 @@ import type { DiffStats, FileNode, FlatNode } from "./types";
6
6
 
7
7
  const collator = new Intl.Collator(undefined, { sensitivity: "base" });
8
8
 
9
- function isDirectoryPath(path: string): boolean {
9
+ function safeRealPathSync(path: string): string {
10
10
  try {
11
- return statSync(path).isDirectory();
11
+ return realpathSync(path);
12
12
  } catch {
13
- return false;
13
+ return path;
14
+ }
15
+ }
16
+
17
+ function getPathInfo(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
18
+ try {
19
+ const linkStat = lstatSync(path);
20
+ const isSymlink = linkStat.isSymbolicLink();
21
+ const targetStat = isSymlink ? statSync(path) : linkStat;
22
+ return {
23
+ isDirectory: targetStat.isDirectory(),
24
+ isSymlink,
25
+ realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
26
+ };
27
+ } catch {
28
+ return { isDirectory: false, isSymlink: false };
14
29
  }
15
30
  }
16
31
 
@@ -105,6 +120,7 @@ export function buildFileTreeFromPaths(
105
120
  name: ".",
106
121
  path: cwd,
107
122
  isDirectory: true,
123
+ realPath: safeRealPathSync(cwd),
108
124
  children: [],
109
125
  expanded: true,
110
126
  hasChangedChildren: false,
@@ -145,6 +161,8 @@ export function buildFileTreeFromPaths(
145
161
  name: part,
146
162
  path: join(cwd, relPath),
147
163
  isDirectory: true,
164
+ realPath: safeRealPathSync(join(cwd, relPath)),
165
+ parent: current,
148
166
  children: [],
149
167
  expanded: depth < 1,
150
168
  hasChangedChildren: false,
@@ -178,14 +196,18 @@ export function buildFileTreeFromPaths(
178
196
  continue;
179
197
  }
180
198
 
181
- const isDirEntry = normalized.endsWith("/") || isDirectoryPath(filePath);
199
+ const pathInfo = getPathInfo(filePath);
200
+ const isDirEntry = normalized.endsWith("/") || pathInfo.isDirectory;
182
201
  if (isDirEntry) {
183
202
  const depth = parts.length;
184
203
  const dirNode: FileNode = {
185
204
  name: fileName,
186
205
  path: filePath,
187
206
  isDirectory: true,
188
- children: [],
207
+ isSymlink: pathInfo.isSymlink,
208
+ realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
209
+ parent: current,
210
+ children: pathInfo.isSymlink ? undefined : [],
189
211
  expanded: depth < 1,
190
212
  hasChangedChildren: false,
191
213
  gitStatus: fileGitStatus,
@@ -202,6 +224,8 @@ export function buildFileTreeFromPaths(
202
224
  name: fileName,
203
225
  path: filePath,
204
226
  isDirectory: false,
227
+ isSymlink: pathInfo.isSymlink,
228
+ parent: current,
205
229
  gitStatus: fileGitStatus,
206
230
  agentModified: agentModified.has(filePath),
207
231
  diffStats: fileDiffStats,
package/index.ts CHANGED
@@ -70,7 +70,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
70
70
  },
71
71
  });
72
72
 
73
- pi.registerCommand("review", {
73
+ pi.registerCommand("readfiles-review", {
74
74
  description: "Open tuicr to review changes and send feedback to agent",
75
75
  handler: async (_args, ctx) => {
76
76
  if (!hasCommand("tuicr")) {
@@ -105,7 +105,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
105
105
  },
106
106
  });
107
107
 
108
- pi.registerCommand("diff", {
108
+ pi.registerCommand("readfiles-diff", {
109
109
  description: "Open critique to view diffs",
110
110
  handler: async (args, ctx) => {
111
111
  if (!hasCommand("bun")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
package/types.ts CHANGED
@@ -7,6 +7,9 @@ export interface FileNode {
7
7
  name: string;
8
8
  path: string;
9
9
  isDirectory: boolean;
10
+ isSymlink?: boolean;
11
+ realPath?: string;
12
+ parent?: FileNode;
10
13
  children?: FileNode[];
11
14
  expanded?: boolean;
12
15
  gitStatus?: string;