diff --git a/files-widget/CHANGELOG.md b/files-widget/CHANGELOG.md index 4dbd852..b2ad222 100644 --- a/files-widget/CHANGELOG.md +++ b/files-widget/CHANGELOG.md @@ -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 ` 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 diff --git a/files-widget/README.md b/files-widget/README.md index b082bec..9dee81f 100644 --- a/files-widget/README.md +++ b/files-widget/README.md @@ -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 ` - open the file browser rooted at `` (absolute, relative, or `~`-prefixed) - `/review` - open tuicr review flow - `/diff` - open critique (bunx critique) @@ -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 @@ -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. diff --git a/files-widget/TODO.md b/files-widget/TODO.md index 1c778e0..cf2ed08 100644 --- a/files-widget/TODO.md +++ b/files-widget/TODO.md @@ -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 ` to start elsewhere) ### Git Integration - [x] Parse `git status --porcelain` output diff --git a/files-widget/browser.ts b/files-widget/browser.ts index 676f6b7..db89d4e 100644 --- a/files-widget/browser.ts +++ b/files-widget/browser.ts @@ -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, @@ -98,13 +98,21 @@ function indexNodes(root: FileNode | null, map: Map): 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); @@ -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; @@ -256,48 +264,39 @@ function collapseAllExcept(node: FileNode, keep: Set): void { } export function createFileBrowser( - cwd: string, + initialPath: string, agentModifiedFiles: Set, 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(); - let diffStats = repo ? getGitDiffStats(cwd) : new Map(); - 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(); + let diffStats = new Map(); + 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(), scanState, selectedIndex: 0, @@ -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(); const lineCountQueue: FileNode[] = []; const lineCountPending = new Set(); @@ -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; @@ -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); node.diffStats = diffStats.get(relPath); } @@ -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; @@ -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; @@ -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(); } @@ -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(); + diffStats = repo ? getGitDiffStats(rootPath) : new Map(); + 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; + scanQueued.clear(); + lineCountQueue.length = 0; + lineCountPending.clear(); + loadRoot(newRoot); + requestRender(); } + loadRoot(initialRoot); + function getDisplayList(): FlatNode[] { let list = browser.searchQuery ? browser.fullList : browser.flatList; @@ -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(); } @@ -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; @@ -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; @@ -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; diff --git a/files-widget/index.ts b/files-widget/index.ts index 09a27ca..f129ed8 100644 --- a/files-widget/index.ts +++ b/files-widget/index.ts @@ -7,13 +7,36 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { execSync, spawnSync } from "node:child_process"; -import { join } from "node:path"; +import { statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; import { createFileBrowser } from "./browser"; import { POLL_INTERVAL_MS } from "./constants"; import { formatCommentMessage } from "./comment"; import { hasCommand } from "./utils"; +function resolveInitialPath(arg: string | undefined, cwd: string): { path: string; error?: string } { + if (!arg) return { path: cwd }; + let candidate = arg.trim(); + if (!candidate) return { path: cwd }; + const home = homedir(); + if (candidate === "~") { + candidate = home; + } else if (candidate.startsWith("~/")) { + candidate = join(home, candidate.slice(2)); + } + const absolute = isAbsolute(candidate) ? candidate : resolve(cwd, candidate); + try { + if (!statSync(absolute).isDirectory()) { + return { path: cwd, error: `${absolute} is not a directory` }; + } + } catch { + return { path: cwd, error: `${absolute} is not accessible` }; + } + return { path: absolute }; +} + export default function editorExtension(pi: ExtensionAPI): void { const cwd = process.cwd(); const agentModifiedFiles = new Set(); @@ -21,14 +44,21 @@ export default function editorExtension(pi: ExtensionAPI): void { const getMissingDeps = () => requiredDeps.filter((dep) => !hasCommand(dep)); pi.registerCommand("readfiles", { - description: "Open file browser", - handler: async (_args, ctx) => { + description: "Open file browser (optional: /readfiles to start outside the current directory)", + handler: async (args, ctx) => { const missing = getMissingDeps(); if (missing.length > 0) { ctx.ui.notify(`files-widget requires ${missing.join(", ")}. Install: brew install bat git-delta glow`, "error"); return; } + const resolved = resolveInitialPath(args, cwd); + if (resolved.error) { + ctx.ui.notify(resolved.error, "error"); + return; + } + const initialPath = resolved.path; + await ctx.ui.custom((tui, theme, _kb, done) => { let pollInterval: ReturnType | null = null; @@ -52,7 +82,15 @@ export default function editorExtension(pi: ExtensionAPI): void { }; const requestRender = () => tui.requestRender(); - const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment, requestRender); + const browser = createFileBrowser( + initialPath, + agentModifiedFiles, + theme, + cleanup, + requestComment, + requestRender, + cwd + ); pollInterval = setInterval(() => { requestRender(); diff --git a/files-widget/viewer.ts b/files-widget/viewer.ts index c76a864..5f7ac4b 100644 --- a/files-widget/viewer.ts +++ b/files-widget/viewer.ts @@ -59,11 +59,17 @@ export interface ViewerController { handleInput(data: string): ViewerAction; } +export interface ViewerConfig { + getRoot: () => string; + projectCwd: string; +} + export function createViewer( - cwd: string, + config: ViewerConfig, theme: Theme, requestComment: (payload: CommentPayload, comment: string) => void ): ViewerController { + const { getRoot, projectCwd } = config; const searchInput = createTextInputBuffer(); const commentInput = createTextInputBuffer({ preserveNewlines: true }); @@ -177,7 +183,7 @@ export function createViewer( if (!state.file) return; refreshRawContent(); const hasChanges = !!state.file.gitStatus; - const result = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width, state.renderMarkdown); + const result = loadFileContent(state.file.path, getRoot(), state.diffMode, hasChanges, width, state.renderMarkdown); state.content = result.lines; state.renderMarkdown = result.renderedMarkdown; state.lastRenderWidth = width; @@ -242,7 +248,8 @@ export function createViewer( const rawLines = state.rawContent.split("\n"); const selectedText = rawLines.slice(state.selectStart, state.selectEnd + 1).join("\n"); - const relPath = relative(cwd, state.file.path); + const rel = relative(projectCwd, state.file.path); + const relPath = !rel || rel.startsWith("..") ? state.file.path : rel; const lineRange = state.selectStart === state.selectEnd ? `line ${state.selectStart + 1}` : `lines ${state.selectStart + 1}-${state.selectEnd + 1}`;