Skip to content
Open
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
3 changes: 3 additions & 0 deletions files-widget/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this extension will be documented in this file.

## [Unreleased]

### Added
- `/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.

## [0.1.18] - 2026-04-19

### Changed
Expand Down
6 changes: 5 additions & 1 deletion files-widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ The `/readfiles` browser requires these tools and will refuse to open until they

## Commands

- `/readfiles` - open the file browser
- `/readfiles` - open the file browser in the current directory
- `/readfiles <path>` - open the file browser rooted at `<path>` (absolute, relative, or `~`-prefixed)
- `/review` - open tuicr review flow
- `/diff` - open critique (bunx critique)

Expand All @@ -106,6 +107,8 @@ If missing, `/review` or `/diff` will show a clear install prompt.
- `c`: toggle changed-only view
- `]` / `[`: next/prev changed file
- `/`: search (type to filter, `Esc` to exit)
- `u`: go up one directory (re-root to parent)
- `.`: jump back to the starting directory
- `+` / `-`: increase/decrease browser height
- `q`: close

Expand All @@ -130,6 +133,7 @@ If missing, `/review` or `/diff` will show a clear install prompt.

- Untracked files show as `[UNTRACKED]` and open in normal view.
- 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.
- 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.
- Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
- Line counts load asynchronously; the header shows activity while counts are computed.
- Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
Expand Down
1 change: 1 addition & 0 deletions files-widget/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [x] Collapse/expand directories
- [x] Navigation with `j/k` and arrow keys
- [x] Enter to expand dir or open file
- [x] Browse outside the current working directory (`u` to go up, `.` to reset, `/readfiles <path>` to start elsewhere)

### Git Integration
- [x] Parse `git status --porcelain` output
Expand Down
156 changes: 109 additions & 47 deletions files-widget/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
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";
import { join, relative, resolve, sep } from "node:path";

import {
DEFAULT_BROWSER_HEIGHT,
Expand Down Expand Up @@ -98,13 +98,21 @@ function indexNodes(root: FileNode | null, map: Map<string, FileNode>): void {
}
}

function getNodeDepth(node: FileNode, cwd: string): number {
if (node.path === cwd) return 0;
const rel = relative(cwd, node.path);
function getNodeDepth(node: FileNode, root: string): number {
if (node.path === root) return 0;
const rel = relative(root, node.path);
if (!rel) return 0;
return rel.split(sep).length;
}

function formatRootPath(path: string): string {
const home = homedir();
if (!home) return path;
if (path === home) return "~";
if (path.startsWith(home + sep)) return "~" + path.slice(home.length);
return path;
}

function safeRealPathSync(path: string): string {
try {
return realpathSync(path);
Expand Down Expand Up @@ -150,8 +158,8 @@ function hasAncestorRealPath(node: FileNode | undefined, realPath: string): bool
return false;
}

function shouldSafeMode(cwd: string): boolean {
const resolved = resolve(cwd);
function shouldSafeMode(path: string): boolean {
const resolved = resolve(path);
const home = resolve(homedir());
const root = resolve(sep);
return resolved === home || resolved === root;
Expand Down Expand Up @@ -256,48 +264,39 @@ function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
}

export function createFileBrowser(
cwd: string,
initialPath: string,
agentModifiedFiles: Set<string>,
theme: Theme,
onClose: () => void,
requestComment: (payload: CommentPayload, comment: string) => void,
requestRender: () => void
requestRender: () => void,
projectCwd: string = initialPath
): BrowserController {
const ignored = getIgnoredNames();
const repo = isGitRepo(cwd);
let gitStatus = repo ? getGitStatus(cwd) : new Map<string, string>();
let diffStats = repo ? getGitDiffStats(cwd) : new Map<string, DiffStats>();
const gitBranch = repo ? getGitBranch(cwd) : "";

const viewer = createViewer(cwd, theme, requestComment);
const textInput = createTextInputBuffer();
let rootPath = resolve(initialPath);
const initialRoot = rootPath;
let repo = false;
let gitStatus = new Map<string, string>();
let diffStats = new Map<string, DiffStats>();
let gitBranch = "";

const root = repo
? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
: {
name: ".",
path: cwd,
isDirectory: true,
realPath: safeRealPathSync(cwd),
children: undefined,
expanded: true,
hasChangedChildren: false,
};
const viewer = createViewer({ getRoot: () => rootPath, projectCwd }, theme, requestComment);
const textInput = createTextInputBuffer();

const safeMode = !repo && shouldSafeMode(cwd);
const scanState: ScanState = {
mode: repo ? "none" : safeMode ? "safe" : "full",
mode: "none",
isScanning: false,
isPartial: safeMode,
isPartial: false,
pending: 0,
spinnerIndex: 0,
};

const browser: BrowserState = {
root,
root: null,
flatList: [],
fullList: [],
stats: getTreeStats(root),
stats: { totalLines: undefined, additions: 0, deletions: 0 },
nodeByPath: new Map<string, FileNode>(),
scanState,
selectedIndex: 0,
Expand All @@ -308,10 +307,6 @@ export function createFileBrowser(
lastPollTime: Date.now(),
};

indexNodes(browser.root, browser.nodeByPath);
browser.flatList = browser.root ? flattenTree(browser.root) : [];
browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];

const lineCountCache = new Map<string, { size: number; mtimeMs: number; count: number }>();
const lineCountQueue: FileNode[] = [];
const lineCountPending = new Set<string>();
Expand Down Expand Up @@ -446,7 +441,7 @@ export function createFileBrowser(
const entries = await readdir(node.path, { withFileTypes: true });
const sorted = [...entries].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));

if (node.path === cwd && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
if (node.path === rootPath && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
browser.scanState.mode = "safe";
browser.scanState.isPartial = true;
scanQueue.length = 0;
Expand Down Expand Up @@ -586,7 +581,7 @@ export function createFileBrowser(

function applyGitUpdates(): void {
for (const node of browser.nodeByPath.values()) {
const relPath = normalizeGitPath(relative(cwd, node.path));
const relPath = normalizeGitPath(relative(rootPath, node.path));
node.gitStatus = gitStatus.get(relPath);
Comment on lines +584 to 585
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize git status keys to the selected root

loadRoot populates gitStatus/diffStats from git commands run in rootPath, but applyGitUpdates looks up those maps using relative(rootPath, node.path). For subdirectory roots this mismatches Git path semantics (for example, git -C app ls-files returns src/f.txt while git -C app status --porcelain reports app/src/f.txt), so modified files lose status/diff metadata and untracked handling can synthesize invalid nested paths. That means the new /readfiles <path> flow is incorrect when <path> is inside a git repo but not at its top-level root.

Useful? React with 👍 / 👎.

node.diffStats = diffStats.get(relPath);
}
Expand All @@ -611,7 +606,7 @@ export function createFileBrowser(
const part = parts[i];
if (ignored.has(part) || part.startsWith(".")) return null;
currentRel = currentRel ? `${currentRel}/${part}` : part;
const dirPath = join(cwd, currentRel);
const dirPath = join(rootPath, currentRel);
let dirNode = browser.nodeByPath.get(dirPath);
if (!dirNode) {
const depth = i + 1;
Expand All @@ -636,7 +631,7 @@ export function createFileBrowser(
const fileName = parts[parts.length - 1];
if (ignored.has(fileName) || fileName.startsWith(".")) return null;

const filePath = join(cwd, normalized);
const filePath = join(rootPath, normalized);
const existing = browser.nodeByPath.get(filePath);
if (existing) return existing;

Expand Down Expand Up @@ -704,8 +699,8 @@ export function createFileBrowser(
const viewingFilePath = viewingFile?.path;

if (repo) {
gitStatus = getGitStatus(cwd);
diffStats = getGitDiffStats(cwd);
gitStatus = getGitStatus(rootPath);
diffStats = getGitDiffStats(rootPath);
applyGitUpdates();
addUntrackedNodes();
}
Expand Down Expand Up @@ -736,12 +731,66 @@ export function createFileBrowser(
}
}

if (repo) {
queueLineCountsForTree(browser.root);
} else if (browser.root) {
enqueueScan(browser.root, 0, true);
function loadRoot(newRoot: string): void {
rootPath = resolve(newRoot);

repo = isGitRepo(rootPath);
gitStatus = repo ? getGitStatus(rootPath) : new Map<string, string>();
diffStats = repo ? getGitDiffStats(rootPath) : new Map<string, DiffStats>();
gitBranch = repo ? getGitBranch(rootPath) : "";

const newRootNode: FileNode = repo
? buildFileTreeFromPaths(rootPath, getGitFileList(rootPath), gitStatus, diffStats, ignored, agentModifiedFiles)
: {
name: ".",
path: rootPath,
isDirectory: true,
realPath: safeRealPathSync(rootPath),
children: undefined,
expanded: true,
hasChangedChildren: false,
};

browser.root = newRootNode;

const safeMode = !repo && shouldSafeMode(rootPath);
browser.scanState.mode = repo ? "none" : safeMode ? "safe" : "full";
browser.scanState.isScanning = false;
browser.scanState.isPartial = safeMode;
browser.scanState.pending = 0;

indexNodes(browser.root, browser.nodeByPath);
refreshLists();
browser.stats = getTreeStats(browser.root);

browser.selectedIndex = 0;
browser.searchQuery = "";
browser.searchMode = false;
textInput.reset();
browser.lastPollTime = Date.now();

if (repo) {
queueLineCountsForTree(browser.root);
} else if (browser.root) {
enqueueScan(browser.root, 0, true);
}
}

function setRoot(newRoot: string): void {
if (viewer.isOpen()) {
viewer.close();
}
stopBackgroundTasks();
scanQueue.length = 0;
Comment on lines +783 to +784
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 Cancel in-flight scan batches when re-rooting

setRoot stops timers and clears queues, but any already-running processScanBatch/processLineCountBatch continues after its awaited I/O and still mutates shared browser state. If a user re-roots with u or . while scanning is active, stale results from the previous root can be applied to the new root (polluting nodeByPath and tree stats). A root-generation or cancellation token check is needed before applying batch results.

Useful? React with 👍 / 👎.

scanQueued.clear();
lineCountQueue.length = 0;
lineCountPending.clear();
loadRoot(newRoot);
requestRender();
}

loadRoot(initialRoot);

function getDisplayList(): FlatNode[] {
let list = browser.searchQuery ? browser.fullList : browser.flatList;

Expand Down Expand Up @@ -806,7 +855,7 @@ export function createFileBrowser(
if (node.isDirectory) {
node.expanded = !node.expanded;
if (node.expanded && node.children === undefined) {
enqueueScan(node, getNodeDepth(node, cwd), true);
enqueueScan(node, getNodeDepth(node, rootPath), true);
}
refreshLists();
}
Expand All @@ -818,7 +867,7 @@ export function createFileBrowser(

function renderBrowser(width: number): string[] {
const lines: string[] = [];
const pathDisplay = basename(cwd);
const pathDisplay = formatRootPath(rootPath);
const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
const stats = browser.stats;

Expand Down Expand Up @@ -902,7 +951,7 @@ export function createFileBrowser(
const changedIndicator = browser.showOnlyChanged ? theme.fg("warning", " [changed only]") : "";
const help = browser.searchMode
? theme.fg("dim", "Type to search ↑↓: nav Enter: confirm Esc: cancel")
: theme.fg("dim", "j/k: nav []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
: theme.fg("dim", "j/k: nav u: up .: home []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
lines.push(truncateToWidth(help, width));

return lines;
Expand Down Expand Up @@ -974,6 +1023,19 @@ export function createFileBrowser(
}
return;
}
if (matchesKey(data, "u")) {
const parent = resolve(rootPath, "..");
if (parent !== rootPath) {
setRoot(parent);
}
return;
}
if (matchesKey(data, ".")) {
if (rootPath !== initialRoot) {
setRoot(initialRoot);
}
return;
}
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
return;
Expand Down
Loading