'use strict'; /** * File-browser helpers for prompt file mentions. * * Pure, terminal-free helpers used by the in-prompt file browser in * `autocomplete-input.js`. Keeping them here means the listing, mention * formatting and range math can be unit-tested without a TTY. * * Everything path-related stays relative to `ROOT_DIR` and uses forward * slashes, matching the rest of the system (`list_files`, `read_file`, * `ripgrep`). File reads are constrained to the workspace via `resolveSafe`. */ import fs from 'fs'; import path from 'path'; import { ROOT_DIR } from './config.js'; import { listDir } from './dir-listing.js'; import { resolveSafe } from './sandbox.js'; /** Normalize a workspace-relative path to forward slashes, no trailing slash. */ function normalizeRel(rel) { return String(rel || '').replace(/\\/g, '/').replace(/\/+$/, ''); } /** Return the parent of a workspace-relative directory path ('' at the root). */ export function parentOf(cwd) { const rel = normalizeRel(cwd); if (!rel) return ''; const idx = rel.lastIndexOf('/'); return idx === -1 ? '' : rel.slice(0, idx); } /** Basename of a workspace-relative path. */ export function baseOf(rel) { const clean = normalizeRel(rel); const idx = clean.lastIndexOf('/'); return idx === -1 ? clean : clean.slice(idx + 1); } /** * List the entries of a workspace-relative directory for the browser. * * Directories come first (then files), each alphabetically. A synthetic * `..` entry is prepended unless we are already at the workspace root. * Respects `.gitignore` and hides dot-directories by delegating to `listDir`. * * Returns an array of `{ name, rel, type }` where `type` is one of * `'parent' | 'd' | 'f'` and `rel` is relative to `ROOT_DIR` (no trailing * slash; '' for the parent entry at depth 1). */ export function listEntries(cwd = '') { const rel = normalizeRel(cwd); const dir = rel ? path.join(ROOT_DIR, rel) : ROOT_DIR; const { entries } = listDir(dir, { seek: 0, limit: Number.MAX_SAFE_INTEGER }); const dirs = entries.filter((e) => e.type === 'd'); const files = entries.filter((e) => e.type === 'f'); const byName = (a, b) => a.path.localeCompare(b.path); dirs.sort(byName); files.sort(byName); const out = []; if (rel) out.push({ name: '..', rel: parentOf(rel), type: 'parent' }); for (const e of dirs) { const childRel = normalizeRel(e.path); out.push({ name: baseOf(childRel), rel: childRel, type: 'd' }); } for (const e of files) { const childRel = normalizeRel(e.path); out.push({ name: baseOf(childRel), rel: childRel, type: 'f' }); } return out; } /** * Read a workspace-relative file as an array of lines (split on `\n`). * * Returns `{ lines, binary }`. An empty file yields `[]`. A file containing a * NUL byte in its first 8000 bytes is reported as binary with no lines, so the * line selector can fall back to a whole-file mention without garbling output. * Read/permission errors yield `{ lines: [], binary: false, error: true }` * rather than throwing, so the browser can refresh and keep going. */ export function readLines(relPath) { let abs; try { abs = resolveSafe(relPath); } catch { return { lines: [], binary: false, error: true }; } let buf; try { buf = fs.readFileSync(abs); } catch { return { lines: [], binary: false, error: true }; } if (buf.subarray(0, 8000).includes(0)) { return { lines: [], binary: true }; } const text = buf.toString('utf8'); const lines = text.length === 0 ? [] : text.split('\n'); return { lines, binary: false }; } /** * Format a mention for insertion into the prompt. * * formatMention('a/b.js') -> '@a/b.js' * formatMention('a/b.js', 10, 10) -> '@a/b.js:10' * formatMention('a/b.js', 10, 20) -> '@a/b.js:10-20' */ export function formatMention(relPath, start, end) { const p = normalizeRel(relPath); if (start === undefined || start === null) return `@${p}`; if (end === undefined || end === null || end === start) return `@${p}:${start}`; return `@${p}:${start}-${end}`; } /** * Normalize a marked range from two 0-based line indices to the 1-based * `{ start, end }` shown to the user (start <= end). */ export function normalizeRange(anchor, cursor) { const a = Math.min(anchor, cursor); const b = Math.max(anchor, cursor); return { start: a + 1, end: b + 1 }; } /** * Top index of the visible window of `size` rows that keeps `cursor` in view. * Returns 0 when everything fits. */ export function windowStart(cursor, total, size) { if (total <= size) return 0; let start = cursor - Math.floor(size / 2); if (start < 0) start = 0; if (start + size > total) start = total - size; return start; }