@tmustier/pi-files-widget 0.1.21 → 0.2.1
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 +15 -1
- package/README.md +5 -1
- package/TODO.md +1 -0
- package/browser.ts +135 -54
- package/file-viewer.ts +4 -1
- package/git.ts +33 -6
- package/index.ts +44 -9
- package/package.json +3 -3
- package/utils.ts +37 -8
- package/viewer.ts +12 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this extension will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [0.2.1] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Reduce Pi startup work by checking required commands directly on `PATH` instead of spawning `which`/`where`, and load the file-browser implementation only when `/readfiles` is invoked.
|
|
9
|
+
- Update session lifecycle, key handling, notifications, and file viewer results for Pi 0.87.
|
|
10
|
+
|
|
11
|
+
## [0.2.0] - 2026-07-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `/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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
6
20
|
|
|
7
21
|
## [0.1.21] - 2026-05-07
|
|
8
22
|
|
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
|
|
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
|
@@ -3,7 +3,7 @@ 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 {
|
|
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,
|
|
102
|
-
if (node.path ===
|
|
103
|
-
const rel = relative(
|
|
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(
|
|
154
|
-
const resolved = resolve(
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
const
|
|
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
|
|
276
|
-
|
|
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:
|
|
288
|
+
mode: "none",
|
|
290
289
|
isScanning: false,
|
|
291
|
-
isPartial:
|
|
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:
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
541
|
+
if (generation === rootGeneration) {
|
|
542
|
+
node.children = [];
|
|
543
|
+
}
|
|
535
544
|
} finally {
|
|
536
|
-
|
|
537
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
708
|
-
diffStats = getGitDiffStats(
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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,
|
|
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 =
|
|
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;
|
package/file-viewer.ts
CHANGED
|
@@ -171,7 +171,10 @@ export function loadFileContent(
|
|
|
171
171
|
try {
|
|
172
172
|
try {
|
|
173
173
|
if (statSync(filePath).isDirectory()) {
|
|
174
|
-
return
|
|
174
|
+
return {
|
|
175
|
+
lines: ["Directory selected - expand it in the file tree instead of opening it."],
|
|
176
|
+
renderedMarkdown: false,
|
|
177
|
+
};
|
|
175
178
|
}
|
|
176
179
|
} catch {
|
|
177
180
|
// Ignore stat errors and fall through to normal handling
|
package/git.ts
CHANGED
|
@@ -11,6 +11,27 @@ export function isGitRepo(cwd: string): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Path of `cwd` relative to the repository top-level (e.g. "app/"), or "" when
|
|
16
|
+
* `cwd` is the top-level itself. `git status --porcelain` reports paths relative
|
|
17
|
+
* to the repository root, while `git ls-files` (and this widget's node keys) are
|
|
18
|
+
* relative to `cwd` — this prefix lets us translate between the two.
|
|
19
|
+
*/
|
|
20
|
+
function getGitPathPrefix(cwd: string): string {
|
|
21
|
+
try {
|
|
22
|
+
return execSync("git rev-parse --show-prefix", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Convert a repo-root-relative path to a cwd-relative one; null if outside cwd. */
|
|
29
|
+
function stripPathPrefix(filePath: string, prefix: string): string | null {
|
|
30
|
+
if (!prefix) return filePath;
|
|
31
|
+
if (filePath.startsWith(prefix)) return filePath.slice(prefix.length);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
export function getGitStatus(cwd: string, options: { includeIgnored?: boolean } = {}): Map<string, string> {
|
|
15
36
|
const status = new Map<string, string>();
|
|
16
37
|
try {
|
|
@@ -18,11 +39,13 @@ export function getGitStatus(cwd: string, options: { includeIgnored?: boolean }
|
|
|
18
39
|
if (options.includeIgnored !== false) {
|
|
19
40
|
flags.push("--ignored");
|
|
20
41
|
}
|
|
42
|
+
const prefix = getGitPathPrefix(cwd);
|
|
21
43
|
const output = execSync(`git status ${flags.join(" ")}`, { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
22
44
|
for (const line of output.split("\n")) {
|
|
23
45
|
if (line.length < 3) continue;
|
|
24
46
|
const statusCode = line.slice(0, 2).trim() || "?";
|
|
25
|
-
const filePath = line.slice(3);
|
|
47
|
+
const filePath = stripPathPrefix(line.slice(3), prefix);
|
|
48
|
+
if (filePath === null || !filePath) continue;
|
|
26
49
|
status.set(filePath, statusCode);
|
|
27
50
|
}
|
|
28
51
|
} catch {}
|
|
@@ -39,6 +62,7 @@ export function getGitFileList(cwd: string): string[] {
|
|
|
39
62
|
} catch {}
|
|
40
63
|
|
|
41
64
|
try {
|
|
65
|
+
const prefix = getGitPathPrefix(cwd);
|
|
42
66
|
const statusOutput = execSync("git status --porcelain -uall -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
43
67
|
const entries = statusOutput.split("\0");
|
|
44
68
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -52,8 +76,9 @@ export function getGitFileList(cwd: string): string[] {
|
|
|
52
76
|
} else if (filePath.includes(" -> ")) {
|
|
53
77
|
filePath = filePath.split(" -> ").pop() || filePath;
|
|
54
78
|
}
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
const relPath = filePath ? stripPathPrefix(filePath, prefix) : null;
|
|
80
|
+
if (relPath) {
|
|
81
|
+
files.add(relPath);
|
|
57
82
|
}
|
|
58
83
|
}
|
|
59
84
|
} catch {}
|
|
@@ -72,8 +97,10 @@ export function getGitBranch(cwd: string): string {
|
|
|
72
97
|
export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
|
|
73
98
|
const stats = new Map<string, DiffStats>();
|
|
74
99
|
try {
|
|
75
|
-
// Get diff stats for modified files
|
|
76
|
-
|
|
100
|
+
// Get diff stats for modified files. --relative keeps paths relative to cwd
|
|
101
|
+
// (and scoped to it) so they match the widget's cwd-relative node keys even
|
|
102
|
+
// when cwd is a subdirectory of the repository.
|
|
103
|
+
const output = execSync("git diff --relative --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
77
104
|
for (const line of output.split("\n")) {
|
|
78
105
|
const parts = line.split("\t");
|
|
79
106
|
if (parts.length >= 3) {
|
|
@@ -84,7 +111,7 @@ export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
|
|
|
84
111
|
}
|
|
85
112
|
}
|
|
86
113
|
// Also get stats for staged files
|
|
87
|
-
const stagedOutput = execSync("git diff --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
114
|
+
const stagedOutput = execSync("git diff --relative --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
|
|
88
115
|
for (const line of stagedOutput.split("\n")) {
|
|
89
116
|
const parts = line.split("\t");
|
|
90
117
|
if (parts.length >= 3) {
|
package/index.ts
CHANGED
|
@@ -6,13 +6,35 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import {
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
10
12
|
|
|
11
|
-
import { createFileBrowser } from "./browser";
|
|
12
13
|
import { POLL_INTERVAL_MS } from "./constants";
|
|
13
14
|
import { formatCommentMessage } from "./comment";
|
|
14
15
|
import { hasCommand } from "./utils";
|
|
15
16
|
|
|
17
|
+
function resolveInitialPath(arg: string | undefined, cwd: string): { path: string; error?: string } {
|
|
18
|
+
if (!arg) return { path: cwd };
|
|
19
|
+
let candidate = arg.trim();
|
|
20
|
+
if (!candidate) return { path: cwd };
|
|
21
|
+
const home = homedir();
|
|
22
|
+
if (candidate === "~") {
|
|
23
|
+
candidate = home;
|
|
24
|
+
} else if (candidate.startsWith("~/")) {
|
|
25
|
+
candidate = join(home, candidate.slice(2));
|
|
26
|
+
}
|
|
27
|
+
const absolute = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
|
|
28
|
+
try {
|
|
29
|
+
if (!statSync(absolute).isDirectory()) {
|
|
30
|
+
return { path: cwd, error: `${absolute} is not a directory` };
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
return { path: cwd, error: `${absolute} is not accessible` };
|
|
34
|
+
}
|
|
35
|
+
return { path: absolute };
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
export default function editorExtension(pi: ExtensionAPI): void {
|
|
17
39
|
const cwd = process.cwd();
|
|
18
40
|
const agentModifiedFiles = new Set<string>();
|
|
@@ -20,14 +42,22 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
20
42
|
const getMissingDeps = () => requiredDeps.filter((dep) => !hasCommand(dep));
|
|
21
43
|
|
|
22
44
|
pi.registerCommand("readfiles", {
|
|
23
|
-
description: "Open file browser",
|
|
24
|
-
handler: async (
|
|
45
|
+
description: "Open file browser (optional: /readfiles <path> to start outside the current directory)",
|
|
46
|
+
handler: async (args, ctx) => {
|
|
25
47
|
const missing = getMissingDeps();
|
|
26
48
|
if (missing.length > 0) {
|
|
27
49
|
ctx.ui.notify(`files-widget requires ${missing.join(", ")}. Install: brew install bat git-delta glow`, "error");
|
|
28
50
|
return;
|
|
29
51
|
}
|
|
30
52
|
|
|
53
|
+
const resolved = resolveInitialPath(args, cwd);
|
|
54
|
+
if (resolved.error) {
|
|
55
|
+
ctx.ui.notify(resolved.error, "error");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const initialPath = resolved.path;
|
|
59
|
+
const { createFileBrowser } = await import("./browser");
|
|
60
|
+
|
|
31
61
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
32
62
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
33
63
|
|
|
@@ -43,7 +73,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
43
73
|
const message = formatCommentMessage(payload, comment);
|
|
44
74
|
if (ctx.isIdle()) {
|
|
45
75
|
pi.sendUserMessage(message);
|
|
46
|
-
ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "
|
|
76
|
+
ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "info");
|
|
47
77
|
} else {
|
|
48
78
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
49
79
|
ctx.ui.notify(`Comment queued for agent for ${payload.relPath} (${payload.lineRange})`, "info");
|
|
@@ -51,7 +81,15 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
51
81
|
};
|
|
52
82
|
|
|
53
83
|
const requestRender = () => tui.requestRender();
|
|
54
|
-
const browser = createFileBrowser(
|
|
84
|
+
const browser = createFileBrowser(
|
|
85
|
+
initialPath,
|
|
86
|
+
agentModifiedFiles,
|
|
87
|
+
theme,
|
|
88
|
+
cleanup,
|
|
89
|
+
requestComment,
|
|
90
|
+
requestRender,
|
|
91
|
+
cwd
|
|
92
|
+
);
|
|
55
93
|
|
|
56
94
|
pollInterval = setInterval(() => {
|
|
57
95
|
requestRender();
|
|
@@ -87,7 +125,4 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
87
125
|
agentModifiedFiles.clear();
|
|
88
126
|
});
|
|
89
127
|
|
|
90
|
-
pi.on("session_switch", async () => {
|
|
91
|
-
agentModifiedFiles.clear();
|
|
92
|
-
});
|
|
93
128
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmustier/pi-files-widget",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "In-terminal file browser and viewer for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Mustier",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"video": "https://raw.githubusercontent.com/tmustier/pi-extensions/main/files-widget/demo.mp4"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@earendil-works/pi-coding-agent": "
|
|
32
|
-
"@earendil-works/pi-tui": "
|
|
31
|
+
"@earendil-works/pi-coding-agent": "0.87.0",
|
|
32
|
+
"@earendil-works/pi-tui": "0.87.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/utils.ts
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CommandLookupOptions {
|
|
5
|
+
platform?: NodeJS.Platform;
|
|
6
|
+
path?: string;
|
|
7
|
+
pathExt?: string;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hasCommand(cmd: string, options: CommandLookupOptions = {}): boolean {
|
|
12
|
+
const platform = options.platform ?? process.platform;
|
|
13
|
+
const pathValue = options.path ?? process.env.PATH ?? "";
|
|
14
|
+
const pathEntries = pathValue.split(platform === "win32" ? ";" : ":");
|
|
15
|
+
const searchDirectories = platform === "win32"
|
|
16
|
+
? [options.cwd ?? process.cwd(), ...pathEntries]
|
|
17
|
+
: pathEntries;
|
|
18
|
+
const extensions = platform === "win32" && !extname(cmd)
|
|
19
|
+
? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
20
|
+
: [""];
|
|
21
|
+
const accessMode = platform === "win32" ? constants.F_OK : constants.X_OK;
|
|
22
|
+
|
|
23
|
+
for (const rawDirectory of searchDirectories) {
|
|
24
|
+
const directory = platform === "win32" && rawDirectory.startsWith('"') && rawDirectory.endsWith('"')
|
|
25
|
+
? rawDirectory.slice(1, -1)
|
|
26
|
+
: rawDirectory;
|
|
27
|
+
for (const extension of extensions) {
|
|
28
|
+
try {
|
|
29
|
+
const candidate = join(directory || ".", `${cmd}${extension}`);
|
|
30
|
+
accessSync(candidate, accessMode);
|
|
31
|
+
if (statSync(candidate).isFile()) return true;
|
|
32
|
+
} catch {
|
|
33
|
+
// Continue searching PATH.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
9
36
|
}
|
|
37
|
+
|
|
38
|
+
return false;
|
|
10
39
|
}
|
|
11
40
|
|
|
12
41
|
export function isUntrackedStatus(status?: string): boolean {
|
package/viewer.ts
CHANGED
|
@@ -59,11 +59,17 @@ export interface ViewerController {
|
|
|
59
59
|
handleInput(data: string): ViewerAction;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export interface ViewerConfig {
|
|
63
|
+
getRoot: () => string;
|
|
64
|
+
projectCwd: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
export function createViewer(
|
|
63
|
-
|
|
68
|
+
config: ViewerConfig,
|
|
64
69
|
theme: Theme,
|
|
65
70
|
requestComment: (payload: CommentPayload, comment: string) => void
|
|
66
71
|
): ViewerController {
|
|
72
|
+
const { getRoot, projectCwd } = config;
|
|
67
73
|
const searchInput = createTextInputBuffer();
|
|
68
74
|
const commentInput = createTextInputBuffer({ preserveNewlines: true });
|
|
69
75
|
|
|
@@ -177,7 +183,7 @@ export function createViewer(
|
|
|
177
183
|
if (!state.file) return;
|
|
178
184
|
refreshRawContent();
|
|
179
185
|
const hasChanges = !!state.file.gitStatus;
|
|
180
|
-
const result = loadFileContent(state.file.path,
|
|
186
|
+
const result = loadFileContent(state.file.path, getRoot(), state.diffMode, hasChanges, width, state.renderMarkdown);
|
|
181
187
|
state.content = result.lines;
|
|
182
188
|
state.renderMarkdown = result.renderedMarkdown;
|
|
183
189
|
state.lastRenderWidth = width;
|
|
@@ -242,7 +248,8 @@ export function createViewer(
|
|
|
242
248
|
|
|
243
249
|
const rawLines = state.rawContent.split("\n");
|
|
244
250
|
const selectedText = rawLines.slice(state.selectStart, state.selectEnd + 1).join("\n");
|
|
245
|
-
const
|
|
251
|
+
const rel = relative(projectCwd, state.file.path);
|
|
252
|
+
const relPath = !rel || rel.startsWith("..") ? state.file.path : rel;
|
|
246
253
|
const lineRange = state.selectStart === state.selectEnd
|
|
247
254
|
? `line ${state.selectStart + 1}`
|
|
248
255
|
: `lines ${state.selectStart + 1}-${state.selectEnd + 1}`;
|
|
@@ -494,7 +501,7 @@ export function createViewer(
|
|
|
494
501
|
jumpToNextMatch(1);
|
|
495
502
|
return { type: "none" };
|
|
496
503
|
}
|
|
497
|
-
if (matchesKey(data, "
|
|
504
|
+
if (matchesKey(data, "shift+n") && state.mode !== "select" && state.searchMatches.length > 0) {
|
|
498
505
|
jumpToNextMatch(-1);
|
|
499
506
|
return { type: "none" };
|
|
500
507
|
}
|
|
@@ -546,7 +553,7 @@ export function createViewer(
|
|
|
546
553
|
state.scroll = 0;
|
|
547
554
|
return { type: "none" };
|
|
548
555
|
}
|
|
549
|
-
if (matchesKey(data, "m") && state.mode !== "select"
|
|
556
|
+
if (matchesKey(data, "m") && state.mode !== "select") {
|
|
550
557
|
toggleMarkdownMode();
|
|
551
558
|
return { type: "none" };
|
|
552
559
|
}
|