142 lines
4.8 KiB
JavaScript
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;
|
|
}
|