Files
fidoca/src/file-browser.js

142 lines
4.8 KiB
JavaScript

'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;
}