Add file browser, tests, and bin entrypoint
This commit is contained in:
141
src/file-browser.js
Normal file
141
src/file-browser.js
Normal file
@@ -0,0 +1,141 @@
|
||||
'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;
|
||||
}
|
||||
Reference in New Issue
Block a user