@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 +13 -0
- package/README.md +2 -0
- package/browser.ts +150 -18
- package/file-tree.ts +30 -6
- package/index.ts +2 -2
- package/package.json +1 -1
- package/types.ts +3 -0
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 {
|
|
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
|
-
|
|
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:
|
|
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(
|
|
414
|
-
enqueueScan(dirNode,
|
|
477
|
+
if (shouldAutoScan(childDepth)) {
|
|
478
|
+
enqueueScan(dirNode, childDepth);
|
|
415
479
|
}
|
|
416
|
-
|
|
417
|
-
|
|
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(
|
|
424
|
-
browser.nodeByPath.set(fullPath,
|
|
425
|
-
queueLineCount(
|
|
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
|
|
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 =
|
|
688
|
+
const node = ensureNode(relPath);
|
|
559
689
|
if (node) {
|
|
560
690
|
node.gitStatus = status;
|
|
561
691
|
node.diffStats = diffStats.get(relPath);
|
|
562
|
-
|
|
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 (
|
|
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
|
|
9
|
+
function safeRealPathSync(path: string): string {
|
|
10
10
|
try {
|
|
11
|
-
return
|
|
11
|
+
return realpathSync(path);
|
|
12
12
|
} catch {
|
|
13
|
-
return
|
|
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
|
|
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
|
-
|
|
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