pi-extensions 0.1.32 → 0.1.34
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/files-widget/CHANGELOG.md +20 -0
- package/files-widget/README.md +4 -0
- package/files-widget/TODO.md +2 -2
- package/files-widget/browser.ts +150 -18
- package/files-widget/file-tree.ts +30 -6
- package/files-widget/file-viewer.ts +35 -20
- package/files-widget/index.ts +8 -4
- package/files-widget/package.json +1 -1
- package/files-widget/types.ts +3 -0
- package/files-widget/utils.ts +4 -0
- package/files-widget/viewer.ts +45 -3
- package/package.json +1 -1
|
@@ -4,8 +4,28 @@ 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
|
+
|
|
20
|
+
## [0.1.17] - 2026-04-19
|
|
21
|
+
|
|
7
22
|
### Changed
|
|
8
23
|
- Make the inline comment editor multiline with wrapped footer rendering, `Enter` for a new line, and `Ctrl+Enter`/`Ctrl+D` to send.
|
|
24
|
+
- Add an `m` toggle for rendered vs raw Markdown in the viewer, and fall back to raw mode before line-based search or selection.
|
|
25
|
+
- Show a sent/queued confirmation toast after returning an inline comment to the agent.
|
|
26
|
+
|
|
27
|
+
### Thanks
|
|
28
|
+
- Thanks to avg8888 in the Pi Discord for surfacing the comment editor and Markdown review issues fixed in this release.
|
|
9
29
|
|
|
10
30
|
## [0.1.16] - 2026-04-19
|
|
11
31
|
|
package/files-widget/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>
|
|
@@ -113,6 +115,7 @@ If missing, `/review` or `/diff` will show a clear install prompt.
|
|
|
113
115
|
- `PgUp/PgDn`: page up/down
|
|
114
116
|
- `g/G`: top/bottom
|
|
115
117
|
- `d`: toggle diff (tracked files only)
|
|
118
|
+
- `m`: toggle rendered/raw view for Markdown files
|
|
116
119
|
- `/`: search (type to search)
|
|
117
120
|
- `n` / `N`: next/prev match
|
|
118
121
|
- `v`: select mode (line selection)
|
|
@@ -126,6 +129,7 @@ If missing, `/review` or `/diff` will show a clear install prompt.
|
|
|
126
129
|
## Notes
|
|
127
130
|
|
|
128
131
|
- Untracked files show as `[UNTRACKED]` and open in normal view.
|
|
132
|
+
- 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.
|
|
129
133
|
- Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
|
|
130
134
|
- Line counts load asynchronously; the header shows activity while counts are computed.
|
|
131
135
|
- Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
|
package/files-widget/TODO.md
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
### Markdown Rendering
|
|
47
47
|
- [x] Detect `glow` availability
|
|
48
48
|
- [x] Shell out to `glow` for .md files
|
|
49
|
-
- [
|
|
49
|
+
- [x] Toggle between rendered and raw (`m`)
|
|
50
50
|
- [x] Fallback to syntax-highlighted raw
|
|
51
51
|
|
|
52
52
|
### Diff View
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
- [x] Format message with file path, line range, code snippet, comment
|
|
75
75
|
- [x] Use `pi.sendUserMessage()` with `deliverAs: "followUp"`
|
|
76
76
|
- [x] Handle case when agent is idle vs streaming
|
|
77
|
-
- [
|
|
77
|
+
- [x] Show confirmation notification
|
|
78
78
|
|
|
79
79
|
## Phase 4: tuicr Integration (Optional)
|
|
80
80
|
|
package/files-widget/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();
|
|
@@ -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,
|
|
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
|
|
|
3
3
|
import { readFileSync, statSync } from "node:fs";
|
|
4
4
|
|
|
5
5
|
import { isGitRepo } from "./git";
|
|
6
|
-
import { hasCommand, stripLeadingEmptyLines } from "./utils";
|
|
6
|
+
import { hasCommand, isMarkdownPath, stripLeadingEmptyLines } from "./utils";
|
|
7
7
|
|
|
8
8
|
const DIFF_CONTENT_PREFIXES = new Set(["+", "-", " "]);
|
|
9
9
|
|
|
@@ -152,14 +152,20 @@ function wrapDeltaLines(lines: string[], width: number): string[] {
|
|
|
152
152
|
return wrapped;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export interface LoadedFileContent {
|
|
156
|
+
lines: string[];
|
|
157
|
+
renderedMarkdown: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
155
160
|
export function loadFileContent(
|
|
156
161
|
filePath: string,
|
|
157
162
|
cwd: string,
|
|
158
163
|
diffMode: boolean,
|
|
159
164
|
hasChanges: boolean,
|
|
160
|
-
width?: number
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
width?: number,
|
|
166
|
+
renderMarkdown = true
|
|
167
|
+
): LoadedFileContent {
|
|
168
|
+
const isMarkdown = isMarkdownPath(filePath);
|
|
163
169
|
const termWidth = width || process.stdout.columns || 80;
|
|
164
170
|
|
|
165
171
|
try {
|
|
@@ -195,7 +201,7 @@ export function loadFileContent(
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
if (!diffOutput.trim()) {
|
|
198
|
-
return ["No diff available - file may be untracked or unchanged"];
|
|
204
|
+
return { lines: ["No diff available - file may be untracked or unchanged"], renderedMarkdown: false };
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
if (hasCommand("delta")) {
|
|
@@ -211,23 +217,23 @@ export function loadFileContent(
|
|
|
211
217
|
stdio: ["pipe", "pipe", "pipe"],
|
|
212
218
|
}
|
|
213
219
|
);
|
|
214
|
-
return wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth);
|
|
220
|
+
return { lines: wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth), renderedMarkdown: false };
|
|
215
221
|
} catch {
|
|
216
222
|
// Fall back to raw diff
|
|
217
223
|
}
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
return wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth);
|
|
226
|
+
return { lines: wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth), renderedMarkdown: false };
|
|
221
227
|
} catch (e: any) {
|
|
222
|
-
return [`Diff error: ${e.message}`];
|
|
228
|
+
return { lines: [`Diff error: ${e.message}`], renderedMarkdown: false };
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
|
|
226
|
-
if (isMarkdown && hasCommand("glow")) {
|
|
232
|
+
if (isMarkdown && renderMarkdown && hasCommand("glow")) {
|
|
227
233
|
try {
|
|
228
234
|
const output = execSync(`glow -s dark -w ${termWidth} "${filePath}"`, { encoding: "utf-8", timeout: 10000 });
|
|
229
235
|
if (output.trim()) {
|
|
230
|
-
return stripLeadingEmptyLines(output.split("\n"));
|
|
236
|
+
return { lines: stripLeadingEmptyLines(output.split("\n")), renderedMarkdown: true };
|
|
231
237
|
}
|
|
232
238
|
} catch {
|
|
233
239
|
// Fall through to bat
|
|
@@ -236,16 +242,22 @@ export function loadFileContent(
|
|
|
236
242
|
|
|
237
243
|
if (hasCommand("bat")) {
|
|
238
244
|
try {
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
return {
|
|
246
|
+
lines: execSync(
|
|
247
|
+
`bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
|
|
248
|
+
{ encoding: "utf-8", timeout: 10000 }
|
|
249
|
+
).split("\n"),
|
|
250
|
+
renderedMarkdown: false,
|
|
251
|
+
};
|
|
243
252
|
} catch {
|
|
244
253
|
try {
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
return {
|
|
255
|
+
lines: execSync(
|
|
256
|
+
`bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
|
|
257
|
+
{ encoding: "utf-8", timeout: 10000 }
|
|
258
|
+
).split("\n"),
|
|
259
|
+
renderedMarkdown: false,
|
|
260
|
+
};
|
|
249
261
|
} catch {
|
|
250
262
|
// Fall through to raw file read
|
|
251
263
|
}
|
|
@@ -253,8 +265,11 @@ export function loadFileContent(
|
|
|
253
265
|
}
|
|
254
266
|
|
|
255
267
|
const raw = readFileSync(filePath, "utf-8");
|
|
256
|
-
return
|
|
268
|
+
return {
|
|
269
|
+
lines: raw.split("\n").map((line, i) => `${String(i + 1).padStart(4)} │ ${line}`),
|
|
270
|
+
renderedMarkdown: false,
|
|
271
|
+
};
|
|
257
272
|
} catch (e: any) {
|
|
258
|
-
return [`Error loading file: ${e.message}`];
|
|
273
|
+
return { lines: [`Error loading file: ${e.message}`], renderedMarkdown: false };
|
|
259
274
|
}
|
|
260
275
|
}
|
package/files-widget/index.ts
CHANGED
|
@@ -41,10 +41,14 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
const requestComment = (payload: { relPath: string; lineRange: string; ext: string; selectedText: string }, comment: string) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const message = formatCommentMessage(payload, comment);
|
|
45
|
+
if (ctx.isIdle()) {
|
|
46
|
+
pi.sendUserMessage(message);
|
|
47
|
+
ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "success");
|
|
48
|
+
} else {
|
|
49
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
50
|
+
ctx.ui.notify(`Comment queued for agent for ${payload.relPath} (${payload.lineRange})`, "info");
|
|
51
|
+
}
|
|
48
52
|
};
|
|
49
53
|
|
|
50
54
|
const requestRender = () => tui.requestRender();
|
package/files-widget/types.ts
CHANGED
package/files-widget/utils.ts
CHANGED
|
@@ -17,6 +17,10 @@ export function isIgnoredStatus(status?: string): boolean {
|
|
|
17
17
|
return status === "!" || status === "!!";
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function isMarkdownPath(path: string): boolean {
|
|
21
|
+
return path.toLowerCase().endsWith(".md");
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
export function stripLeadingEmptyLines(lines: string[]): string[] {
|
|
21
25
|
let startIdx = 0;
|
|
22
26
|
while (startIdx < lines.length && !lines[startIdx].trim()) {
|
package/files-widget/viewer.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants";
|
|
12
12
|
import { loadFileContent } from "./file-viewer";
|
|
13
13
|
import type { FileNode } from "./types";
|
|
14
|
-
import { isUntrackedStatus } from "./utils";
|
|
14
|
+
import { isMarkdownPath, isUntrackedStatus } from "./utils";
|
|
15
15
|
import { createTextInputBuffer } from "./input-utils";
|
|
16
16
|
|
|
17
17
|
const COMMENT_EDITOR_MAX_VISIBLE_LINES = 4;
|
|
@@ -36,6 +36,7 @@ interface ViewerState {
|
|
|
36
36
|
rawContent: string;
|
|
37
37
|
scroll: number;
|
|
38
38
|
diffMode: boolean;
|
|
39
|
+
renderMarkdown: boolean;
|
|
39
40
|
mode: ViewerMode;
|
|
40
41
|
selectStart: number;
|
|
41
42
|
selectEnd: number;
|
|
@@ -72,6 +73,7 @@ export function createViewer(
|
|
|
72
73
|
rawContent: "",
|
|
73
74
|
scroll: 0,
|
|
74
75
|
diffMode: false,
|
|
76
|
+
renderMarkdown: true,
|
|
75
77
|
mode: "normal",
|
|
76
78
|
selectStart: 0,
|
|
77
79
|
selectEnd: 0,
|
|
@@ -84,6 +86,31 @@ export function createViewer(
|
|
|
84
86
|
height: DEFAULT_VIEWER_HEIGHT,
|
|
85
87
|
};
|
|
86
88
|
|
|
89
|
+
function isMarkdownFile(): boolean {
|
|
90
|
+
return !!state.file && isMarkdownPath(state.file.path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRenderedMarkdownMode(): boolean {
|
|
94
|
+
return isMarkdownFile() && !state.diffMode && state.renderMarkdown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function switchMarkdownToRaw(): boolean {
|
|
98
|
+
if (!isRenderedMarkdownMode()) return false;
|
|
99
|
+
state.renderMarkdown = false;
|
|
100
|
+
const width = state.lastRenderWidth || process.stdout.columns || 80;
|
|
101
|
+
reloadContent(width);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toggleMarkdownMode(): void {
|
|
106
|
+
if (!isMarkdownFile() || state.diffMode) return;
|
|
107
|
+
state.renderMarkdown = !state.renderMarkdown;
|
|
108
|
+
state.lastRenderWidth = 0;
|
|
109
|
+
resetSearch();
|
|
110
|
+
setMode("normal");
|
|
111
|
+
clampScroll();
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
function resetSearch(): void {
|
|
88
115
|
state.searchQuery = "";
|
|
89
116
|
state.searchMatches = [];
|
|
@@ -150,7 +177,9 @@ export function createViewer(
|
|
|
150
177
|
if (!state.file) return;
|
|
151
178
|
refreshRawContent();
|
|
152
179
|
const hasChanges = !!state.file.gitStatus;
|
|
153
|
-
|
|
180
|
+
const result = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width, state.renderMarkdown);
|
|
181
|
+
state.content = result.lines;
|
|
182
|
+
state.renderMarkdown = result.renderedMarkdown;
|
|
154
183
|
state.lastRenderWidth = width;
|
|
155
184
|
clampScroll();
|
|
156
185
|
if (state.searchQuery) {
|
|
@@ -239,6 +268,8 @@ export function createViewer(
|
|
|
239
268
|
header += theme.fg("dim", " [UNTRACKED]");
|
|
240
269
|
} else if (state.diffMode) {
|
|
241
270
|
header += theme.fg("warning", " [DIFF]");
|
|
271
|
+
} else if (isMarkdownFile()) {
|
|
272
|
+
header += theme.fg("accent", state.renderMarkdown ? " [RENDERED]" : " [RAW]");
|
|
242
273
|
}
|
|
243
274
|
if (state.mode === "select" || state.mode === "comment") {
|
|
244
275
|
header += theme.fg("accent", ` [SELECT ${state.selectStart + 1}-${state.selectEnd + 1}]`);
|
|
@@ -320,9 +351,10 @@ export function createViewer(
|
|
|
320
351
|
help = theme.fg("dim", "Type to search Enter: confirm Esc: cancel");
|
|
321
352
|
} else {
|
|
322
353
|
const isUntracked = state.file && isUntrackedStatus(state.file.gitStatus);
|
|
354
|
+
const markdownHelp = isMarkdownFile() && !state.diffMode ? "m: raw/render " : "";
|
|
323
355
|
help = theme.fg(
|
|
324
356
|
"dim",
|
|
325
|
-
`j/k: scroll /: search n/N: next/prev match []: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
|
|
357
|
+
`j/k: scroll /: search n/N: next/prev match ${markdownHelp}[]: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
|
|
326
358
|
);
|
|
327
359
|
}
|
|
328
360
|
lines.push(truncateToWidth(help, width));
|
|
@@ -343,6 +375,7 @@ export function createViewer(
|
|
|
343
375
|
state.file = file;
|
|
344
376
|
state.scroll = 0;
|
|
345
377
|
state.diffMode = !!file.gitStatus && !isUntrackedStatus(file.gitStatus);
|
|
378
|
+
state.renderMarkdown = isMarkdownPath(file.path);
|
|
346
379
|
setMode("normal");
|
|
347
380
|
state.content = [];
|
|
348
381
|
state.lastRenderWidth = 0;
|
|
@@ -358,6 +391,7 @@ export function createViewer(
|
|
|
358
391
|
state.file = null;
|
|
359
392
|
state.content = [];
|
|
360
393
|
state.rawContent = "";
|
|
394
|
+
state.renderMarkdown = true;
|
|
361
395
|
state.lastLoadedMtimeMs = null;
|
|
362
396
|
setMode("normal");
|
|
363
397
|
},
|
|
@@ -452,6 +486,7 @@ export function createViewer(
|
|
|
452
486
|
return { type: "none" };
|
|
453
487
|
}
|
|
454
488
|
if (matchesKey(data, "/") && state.mode !== "select") {
|
|
489
|
+
switchMarkdownToRaw();
|
|
455
490
|
setMode("search");
|
|
456
491
|
return { type: "none" };
|
|
457
492
|
}
|
|
@@ -511,7 +546,14 @@ export function createViewer(
|
|
|
511
546
|
state.scroll = 0;
|
|
512
547
|
return { type: "none" };
|
|
513
548
|
}
|
|
549
|
+
if (matchesKey(data, "m") && state.mode !== "select" && state.mode !== "comment") {
|
|
550
|
+
toggleMarkdownMode();
|
|
551
|
+
return { type: "none" };
|
|
552
|
+
}
|
|
514
553
|
if (matchesKey(data, "v") && state.mode !== "select") {
|
|
554
|
+
if (switchMarkdownToRaw()) {
|
|
555
|
+
return { type: "none" };
|
|
556
|
+
}
|
|
515
557
|
state.mode = "select";
|
|
516
558
|
state.selectStart = state.scroll;
|
|
517
559
|
state.selectEnd = state.scroll;
|