Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions files-widget/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this extension will be documented in this file.

## [Unreleased]

## [0.1.18] - 2026-04-19

### Changed
- Show symlinks with a `↗` marker in the `/readfiles` tree.

### Fixed
- Let `/readfiles` navigate into directory symlinks in both non-git folders and git repos instead of rendering them as inert files or empty directories.
- Guard symlink directory scanning against ancestor cycles so links like `foo -> .` or `foo -> ..` don't recurse forever.
- Treat git-tracked and untracked directory symlinks as lazily scannable directories rather than plain files.

### Thanks
- Thanks to @xapids for reporting the original macOS symlink navigation issue ([#9](https://github.com/tmustier/pi-extensions/issues/9)).

## [0.1.17] - 2026-04-19

### Changed
Expand Down
2 changes: 2 additions & 0 deletions files-widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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.

Directory symlinks are shown with a `↗` marker and can be expanded like normal folders.

<video controls autoplay loop muted playsinline>
<source src="demo.mp4" type="video/mp4" />
</video>
Expand Down
168 changes: 150 additions & 18 deletions files-widget/browser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Theme } from "@mariozechner/pi-coding-agent";
import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
import { readdir, readFile, stat } from "node:fs/promises";
import { lstatSync, realpathSync, statSync } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, join, relative, resolve, sep } from "node:path";

Expand Down Expand Up @@ -104,6 +105,51 @@ function getNodeDepth(node: FileNode, cwd: string): number {
return rel.split(sep).length;
}

function safeRealPathSync(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}

function getPathInfoSync(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
try {
const linkStat = lstatSync(path);
const isSymlink = linkStat.isSymbolicLink();
const targetStat = isSymlink ? statSync(path) : linkStat;
return {
isDirectory: targetStat.isDirectory(),
isSymlink,
realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
};
} catch {
return { isDirectory: false, isSymlink: false };
}
}

async function getPathInfo(path: string, isSymlink: boolean): Promise<{ isDirectory: boolean; isSymlink: boolean; realPath?: string }> {
try {
const targetStat = await stat(path);
return {
isDirectory: targetStat.isDirectory(),
isSymlink,
realPath: targetStat.isDirectory() ? await realpath(path).catch(() => resolve(path)) : undefined,
};
} catch {
return { isDirectory: false, isSymlink };
}
}

function hasAncestorRealPath(node: FileNode | undefined, realPath: string): boolean {
let current = node;
while (current) {
if (current.realPath === realPath) return true;
current = current.parent;
}
return false;
}

function shouldSafeMode(cwd: string): boolean {
const resolved = resolve(cwd);
const home = resolve(homedir());
Expand Down Expand Up @@ -183,14 +229,19 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}

function withSymlinkMarker(label: string, node: FileNode, theme: Theme): string {
return node.isSymlink ? `${label}${theme.fg("dim", " ↗")}` : label;
}

function formatNodeName(node: FileNode, theme: Theme): string {
if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
if (isIgnoredStatus(node.gitStatus)) return withSymlinkMarker(theme.fg("dim", node.name), node, theme);
if (node.isDirectory) {
const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
const rendered = withSymlinkMarker(label, node, theme);
return node.loading ? `${rendered}${theme.fg("dim", " ⏳")}` : rendered;
}
if (node.gitStatus) return theme.fg("warning", node.name);
return node.name;
if (node.gitStatus) return withSymlinkMarker(theme.fg("warning", node.name), node, theme);
return withSymlinkMarker(node.name, node, theme);
}

function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
Expand Down Expand Up @@ -227,6 +278,7 @@ export function createFileBrowser(
name: ".",
path: cwd,
isDirectory: true,
realPath: safeRealPathSync(cwd),
children: undefined,
expanded: true,
hasChangedChildren: false,
Expand Down Expand Up @@ -350,6 +402,14 @@ export function createFileBrowser(
}

function shouldAutoScan(depth: number): boolean {
// In git repos the main tree comes from git file lists, not from filesystem
// crawling. If the user expands a symlinked directory inside that tree, only
// scan one level on demand; nested directories stay lazy until explicitly
// expanded so links into large trees (iCloud/Drive/$HOME) don't trigger a
// broad recursive crawl.
if (repo) {
return false;
}
if (browser.scanState.mode === "safe") {
return depth <= 0;
}
Expand Down Expand Up @@ -399,31 +459,74 @@ export function createFileBrowser(
for (const entry of sorted) {
if (ignored.has(entry.name) || entry.name.startsWith(".")) continue;
const fullPath = join(node.path, entry.name);
const childDepth = depth + 1;

if (entry.isDirectory()) {
const dirNode: FileNode = {
name: entry.name,
path: fullPath,
isDirectory: true,
realPath: await realpath(fullPath).catch(() => resolve(fullPath)),
parent: node,
children: undefined,
expanded: depth + 1 < 1,
expanded: childDepth < 1,
hasChangedChildren: false,
};
dirs.push(dirNode);
browser.nodeByPath.set(fullPath, dirNode);
if (shouldAutoScan(depth + 1)) {
enqueueScan(dirNode, depth + 1);
if (shouldAutoScan(childDepth)) {
enqueueScan(dirNode, childDepth);
}
} else {
const fileNode: FileNode = {
continue;
}

if (entry.isSymbolicLink()) {
const pathInfo = await getPathInfo(fullPath, true);
if (pathInfo.isDirectory) {
const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
const dirNode: FileNode = {
name: entry.name,
path: fullPath,
isDirectory: true,
isSymlink: true,
realPath: pathInfo.realPath,
parent: node,
children: isCycle ? [] : undefined,
expanded: childDepth < 1,
hasChangedChildren: false,
};
dirs.push(dirNode);
browser.nodeByPath.set(fullPath, dirNode);
if (!isCycle && shouldAutoScan(childDepth)) {
enqueueScan(dirNode, childDepth);
}
continue;
}

const symlinkFileNode: FileNode = {
name: entry.name,
path: fullPath,
isDirectory: false,
isSymlink: true,
parent: node,
agentModified: agentModifiedFiles.has(fullPath),
};
files.push(fileNode);
browser.nodeByPath.set(fullPath, fileNode);
queueLineCount(fileNode);
files.push(symlinkFileNode);
browser.nodeByPath.set(fullPath, symlinkFileNode);
queueLineCount(symlinkFileNode);
continue;
}

const fileNode: FileNode = {
name: entry.name,
path: fullPath,
isDirectory: false,
parent: node,
agentModified: agentModifiedFiles.has(fullPath),
};
files.push(fileNode);
browser.nodeByPath.set(fullPath, fileNode);
queueLineCount(fileNode);
}

node.children = [...dirs, ...files];
Expand Down Expand Up @@ -483,14 +586,13 @@ export function createFileBrowser(

function applyGitUpdates(): void {
for (const node of browser.nodeByPath.values()) {
if (node.isDirectory) continue;
const relPath = normalizeGitPath(relative(cwd, node.path));
node.gitStatus = gitStatus.get(relPath);
node.diffStats = diffStats.get(relPath);
Comment on lines 581 to 583
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve symlink-expanded paths before git status lookup

After this change, expanding a directory symlink in a git repo creates nodes under the symlink alias path (for example link/foo.ts), but status/diff lookup still keys strictly by relative(cwd, node.path). Git status is reported for the canonical repo path (for example target/foo.ts), so these expanded nodes never get gitStatus/diffStats. As a result, modified files viewed through symlink directories appear clean and can be omitted from changed-only workflows.

Useful? React with 👍 / 👎.

}
}

function ensureFileNode(relPath: string): FileNode | null {
function ensureNode(relPath: string): FileNode | null {
if (!browser.root) return null;
let normalized = relPath.trim();
if (!normalized) return null;
Expand All @@ -517,6 +619,8 @@ export function createFileBrowser(
name: part,
path: dirPath,
isDirectory: true,
realPath: safeRealPathSync(dirPath),
parent: current,
children: [],
expanded: depth < 1,
hasChangedChildren: false,
Expand All @@ -536,10 +640,36 @@ export function createFileBrowser(
const existing = browser.nodeByPath.get(filePath);
if (existing) return existing;

const pathInfo = getPathInfoSync(filePath);
if (pathInfo.isDirectory) {
const isCycle = pathInfo.realPath ? hasAncestorRealPath(current, pathInfo.realPath) : false;
const dirNode: FileNode = {
name: fileName,
path: filePath,
isDirectory: true,
isSymlink: pathInfo.isSymlink,
realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
parent: current,
children: pathInfo.isSymlink && !isCycle ? undefined : [],
expanded: false,
hasChangedChildren: false,
gitStatus: gitStatus.get(normalized),
diffStats: diffStats.get(normalized),
};

current.children ??= [];
current.children.push(dirNode);
sortChildren(current);
browser.nodeByPath.set(filePath, dirNode);
return dirNode;
}

const fileNode: FileNode = {
name: fileName,
path: filePath,
isDirectory: false,
isSymlink: pathInfo.isSymlink,
parent: current,
gitStatus: gitStatus.get(normalized),
agentModified: agentModifiedFiles.has(filePath),
diffStats: diffStats.get(normalized),
Expand All @@ -555,11 +685,13 @@ export function createFileBrowser(
function addUntrackedNodes(): void {
for (const [relPath, status] of gitStatus.entries()) {
if (!isUntrackedStatus(status)) continue;
const node = ensureFileNode(relPath);
const node = ensureNode(relPath);
if (node) {
node.gitStatus = status;
node.diffStats = diffStats.get(relPath);
queueLineCount(node, true);
if (!node.isDirectory) {
queueLineCount(node, true);
}
}
}
}
Expand Down Expand Up @@ -673,7 +805,7 @@ export function createFileBrowser(
function toggleDir(node: FileNode): void {
if (node.isDirectory) {
node.expanded = !node.expanded;
if (!repo && node.expanded && node.children === undefined) {
if (node.expanded && node.children === undefined) {
enqueueScan(node, getNodeDepth(node, cwd), true);
Comment on lines +800 to 801
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate repo symlink scans with safe-mode limits

This now triggers filesystem scans in repo mode whenever an expandable node has children === undefined. For directory symlinks that resolve to very large trees (for example links to parent/root-like paths), expansion can enqueue a large recursive scan without the safe-mode guard used for normal non-repo root scans, which can stall the UI and spike memory. The behavior is input-dependent but reproducible with large symlink targets.

Useful? React with 👍 / 👎.

}
refreshLists();
Expand Down
36 changes: 30 additions & 6 deletions files-widget/file-tree.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import { statSync } from "node:fs";
import { lstatSync, realpathSync, statSync } from "node:fs";
import { join } from "node:path";

import { MAX_TREE_DEPTH } from "./constants";
import type { DiffStats, FileNode, FlatNode } from "./types";

const collator = new Intl.Collator(undefined, { sensitivity: "base" });

function isDirectoryPath(path: string): boolean {
function safeRealPathSync(path: string): string {
try {
return statSync(path).isDirectory();
return realpathSync(path);
} catch {
return false;
return path;
}
}

function getPathInfo(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
try {
const linkStat = lstatSync(path);
const isSymlink = linkStat.isSymbolicLink();
const targetStat = isSymlink ? statSync(path) : linkStat;
return {
isDirectory: targetStat.isDirectory(),
isSymlink,
realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
};
} catch {
return { isDirectory: false, isSymlink: false };
}
}

Expand Down Expand Up @@ -105,6 +120,7 @@ export function buildFileTreeFromPaths(
name: ".",
path: cwd,
isDirectory: true,
realPath: safeRealPathSync(cwd),
children: [],
expanded: true,
hasChangedChildren: false,
Expand Down Expand Up @@ -145,6 +161,8 @@ export function buildFileTreeFromPaths(
name: part,
path: join(cwd, relPath),
isDirectory: true,
realPath: safeRealPathSync(join(cwd, relPath)),
parent: current,
children: [],
expanded: depth < 1,
hasChangedChildren: false,
Expand Down Expand Up @@ -178,14 +196,18 @@ export function buildFileTreeFromPaths(
continue;
}

const isDirEntry = normalized.endsWith("/") || isDirectoryPath(filePath);
const pathInfo = getPathInfo(filePath);
const isDirEntry = normalized.endsWith("/") || pathInfo.isDirectory;
if (isDirEntry) {
const depth = parts.length;
const dirNode: FileNode = {
name: fileName,
path: filePath,
isDirectory: true,
children: [],
isSymlink: pathInfo.isSymlink,
realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
parent: current,
children: pathInfo.isSymlink ? undefined : [],
expanded: depth < 1,
hasChangedChildren: false,
gitStatus: fileGitStatus,
Expand All @@ -202,6 +224,8 @@ export function buildFileTreeFromPaths(
name: fileName,
path: filePath,
isDirectory: false,
isSymlink: pathInfo.isSymlink,
parent: current,
gitStatus: fileGitStatus,
agentModified: agentModified.has(filePath),
diffStats: fileDiffStats,
Expand Down
2 changes: 1 addition & 1 deletion files-widget/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tmustier/pi-files-widget",
"version": "0.1.17",
"version": "0.1.18",
"description": "In-terminal file browser and viewer for Pi.",
"license": "MIT",
"author": "Thomas Mustier",
Expand Down
Loading