elffuss-code / js /tools /code.js
KikoCis's picture
code.tree avisa al cortar (proyectos grandes)
d6605e7 verified
Raw
History Blame Contribute Delete
15.3 kB
// Herramientas de proyecto: el único mundo que toca Elffuss Code es la
// carpeta de código que el usuario abre con el picker nativo.
import * as db from '../db.js';
let projectHandle = null;
let projectName = '';
let currentFile = null; // ruta abierta en el editor
let onFileWritten = () => {}; // el IDE refresca pestañas/árbol
export function setOnFileWritten(fn) { onFileWritten = fn; }
export function setCurrentFile(path) { currentFile = path; }
export function current() { return { projectName, currentFile }; }
export function handle() { return projectHandle; }
// Lee un archivo de texto dentro de un dir handle por ruta (soporta subdirs).
async function readIn(dir, path) {
const parts = path.split('/'); const name = parts.pop();
for (const p of parts) dir = await dir.getDirectoryHandle(p);
return (await (await dir.getFileHandle(name)).getFile()).text();
}
// Integración git SIN dependencias: parsea el .git directamente (rama + último
// commit). Suficiente para orientar; commitear se le pide al agente/terminal.
export async function gitInfo() {
if (!projectHandle) return { isRepo: false };
let git;
try { git = await projectHandle.getDirectoryHandle('.git'); }
catch { return { isRepo: false }; }
const out = { isRepo: true, branch: '(detached)', lastCommit: null };
try {
const head = (await readIn(git, 'HEAD')).trim();
out.branch = head.match(/ref:\s*refs\/heads\/(.+)/)?.[1] || head.slice(0, 7);
} catch { /* sin HEAD */ }
try {
const last = (await readIn(git, 'logs/HEAD')).trim().split('\n').pop();
const m = last.match(/^\S+ \S+ (.+?) <[^>]*> (\d+)[^\t]*\t(.+)$/);
if (m) out.lastCommit = { author: m[1], when: new Date(+m[2] * 1000), msg: m[3] };
} catch { /* sin logs (repo recién creado) */ }
return out;
}
// Lista plana de archivos (para el command palette). Cacheada por proyecto.
let fileListCache = null;
export function invalidateFileList() { fileListCache = null; }
export async function fileList() {
if (fileListCache) return fileListCache;
if (!projectHandle) return [];
const files = [];
async function walk(dir, prefix, depth) {
if (depth > 8 || files.length > 4000) return;
for await (const e of dir.values()) {
if (IGNORE.has(e.name)) continue;
const p = prefix ? prefix + '/' + e.name : e.name;
if (e.kind === 'directory') await walk(e, p, depth + 1);
else files.push(p);
}
}
await walk(projectHandle, '', 0);
fileListCache = files;
return files;
}
export async function openProject(handle) {
projectHandle = handle;
projectName = handle.name || 'proyecto';
await db.set('kv', 'project', handle).catch(() => {});
return projectName;
}
// Reabrir el último proyecto (el navegador puede exigir un gesto para re-conceder).
export async function restoreProject() {
const h = await db.get('kv', 'project').catch(() => null);
if (!h) return null;
const q = h.queryPermission ? await h.queryPermission({ mode: 'readwrite' }) : 'granted';
if (q === 'granted') {
projectHandle = h;
projectName = h.name || 'proyecto';
return { name: projectName, ready: true };
}
return { name: h.name, ready: false, handle: h };
}
export async function regrant(h) {
if (await h.requestPermission({ mode: 'readwrite' }) !== 'granted')
throw new Error('permiso denegado');
return openProject(h);
}
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
const MAX_READ = 60_000;
// Normaliza rutas del modelo: quita ./ y / iniciales y segmentos '.'
const normalize = path => (path || '').split('/').filter(p => p && p !== '.');
async function dirOf(path, { create = false } = {}) {
if (!projectHandle) throw new Error('No hay proyecto abierto');
const parts = normalize(path);
const name = parts.pop();
let dir = projectHandle;
for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
return { dir, name };
}
// Buscar archivos por nombre (para sugerir cuando el modelo inventa rutas, o
// para resolver un enlace del chat que solo mencionaba el nombre del fichero).
export async function findByName(basename, limit = 5) {
const hits = [];
let visited = 0;
async function walk(dir, prefix) {
if (hits.length >= limit || visited > 500) return;
for await (const e of dir.values()) {
if (IGNORE.has(e.name)) continue;
visited++;
const p = prefix ? prefix + '/' + e.name : e.name;
if (e.kind === 'directory') await walk(e, p);
else if (e.name.toLowerCase() === basename.toLowerCase()) hits.push(p);
if (hits.length >= limit || visited > 500) return;
}
}
await walk(projectHandle, '');
return hits;
}
// Árbol de texto (para el modelo y el CONTEXTO). Ignora dependencias/binarios.
const TREE_MAX = 800;
export async function tree({ path = '', depth = 3 } = {}) {
if (!projectHandle) throw new Error('No hay proyecto abierto');
let root = projectHandle;
for (const p of normalize(path)) root = await root.getDirectoryHandle(p);
const out = [];
let count = 0, truncated = false;
async function walk(dir, prefix, d) {
if (d > depth || count >= TREE_MAX) return;
const entries = [];
for await (const e of dir.values()) entries.push(e);
entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
for (const e of entries) {
if (IGNORE.has(e.name)) continue;
if (++count > TREE_MAX) { truncated = true; return; }
out.push(prefix + (e.kind === 'directory' ? '📁 ' : '') + e.name);
if (e.kind === 'directory') { await walk(e, prefix + ' ', d + 1); if (truncated) return; }
}
}
await walk(root, '', 1);
let res = out.join('\n') || '(vacío)';
// Corte con AVISO y guía (como read/search): un «…» pelado se lee como «esto
// es todo» y el modelo trabaja sobre un árbol incompleto sin saberlo.
if (truncated)
res += `\n… árbol recortado (>${TREE_MAX} entradas). Acota con code.tree({path:"subcarpeta"}) o localiza con code.search.`;
return res;
}
const PAGE_SIZE = 100, PAGE_MAX = 500;
// Lectura completa (comportamiento de siempre) O por páginas de líneas — para
// ficheros grandes, o para plantarse justo en el número de línea que dio
// code.search y traer contexto antes/después sin volcar el fichero entero.
// offset: línea 1-based donde empezar (con limit, PAGE_SIZE por defecto)
// around: centra la página en esa línea (offset = around - limit/2)
// Sin offset/limit/around → fichero completo tal cual (no rompe nada que ya
// dependa de leerlo entero, p.ej. code.edit).
export async function read({ path, offset, limit, around } = {}) {
if (!path) throw new Error('Falta path');
let file;
try {
const { dir, name } = await dirOf(path);
file = await (await dir.getFileHandle(name)).getFile();
} catch {
// error que ENSEÑA: el modelo puede auto-corregirse en el siguiente paso
const base = normalize(path).pop() || path;
const hits = await findByName(base).catch(() => []);
throw new Error(`no existe «${path}» en este proyecto.` +
(hits.length ? ` ¿Quizá: ${hits.join(' · ')}?` : '') +
' Consulta el árbol (code.tree) o busca (code.search) antes de leer.');
}
const full = await file.text();
if (offset == null && limit == null && around == null) {
return full.length > MAX_READ ? full.slice(0, MAX_READ) + `\n… (recortado, ${file.size} bytes)` : full;
}
const lines = full.split('\n');
const total = lines.length;
const size = Math.max(1, Math.min(Math.round(limit) || PAGE_SIZE, PAGE_MAX));
const start = around != null
? Math.max(1, Math.round(around) - Math.floor(size / 2))
: Math.max(1, Math.round(offset) || 1);
const startIdx = start - 1;
const slice = lines.slice(startIdx, startIdx + size);
const endLine = startIdx + slice.length;
const numbered = slice.map((l, i) => `${start + i}${l}`).join('\n');
const more = endLine < total ? `\n… quedan líneas ${endLine + 1}-${total} — pide code.read con offset:${endLine + 1} para seguir` : '';
return `${path}: líneas ${start}-${endLine} de ${total}\n${numbered}${more}`;
}
// Aprobación de escritura: si «Auto» está apagado, el IDE pide confirmación
// antes de que la elfa toque archivos (como «Edit automatically» del plugin).
let approveWrite = async () => true;
export function setWriteApprover(fn) { approveWrite = fn; }
export async function write({ path, content = '' } = {}) {
if (!path) throw new Error('Falta path');
if (!await approveWrite(path, content)) return `Cambio en ${path} rechazado por el usuario`;
const { dir, name } = await dirOf(path, { create: true });
const fh = await dir.getFileHandle(name, { create: true });
const w = await fh.createWritable();
await w.write(content);
await w.close();
invalidateFileList();
onFileWritten(path, content);
return `Escrito ${path} (${content.split('\n').length} líneas)`;
}
// Edición PARCIAL (no hace falta reescribir el fichero entero): sustituye
// `search` por `replace`. Primero intenta coincidencia EXACTA (rápida, sin
// ambigüedad); si no la encuentra tal cual —el modelo puede recordar mal un
// espacio o un salto de línea—, cae a un parcheado difuso (diff-match-patch,
// la misma librería que usan herramientas como aider) que localiza el punto
// más parecido dentro del fichero real. Si ni así hay confianza suficiente,
// falla con un mensaje claro para que el agente reintente con más contexto
// — nunca escribe una coincidencia dudosa.
export async function edit({ path, search, replace } = {}) {
if (!path) throw new Error('Falta path');
if (search == null || replace == null) throw new Error('Faltan search y replace');
// Contenido COMPLETO, sin el tope de MAX_READ. edit reescribe el fichero
// entero, así que leer la vista recortada de read() truncaría todo lo que
// hubiese más allá de MAX_READ (bug histórico: editar un fichero >60KB lo
// dejaba en 60KB y metía el marcador «… (recortado)» dentro del código).
let current;
try {
const { dir, name } = await dirOf(path);
current = await (await (await dir.getFileHandle(name)).getFile()).text();
} catch {
await read({ path }); // relanza el error «que enseña» (sugiere code.tree/search); nunca retorna
throw new Error(`no existe «${path}»`);
}
const first = current.indexOf(search);
if (first !== -1) {
if (current.indexOf(search, first + 1) !== -1) {
throw new Error(`«search» aparece más de una vez en ${path} — añade más líneas de contexto alrededor para que sea inequívoco.`);
}
return write({ path, content: current.slice(0, first) + replace + current.slice(first + search.length) });
}
// fallback difuso, LOCAL y por líneas (sin depender de esm.sh en tiempo de
// edición — eso rompería la edición sin conexión, justo lo contrario del
// producto). El error típico del modelo al recordar «search» es la sangría o
// los espacios; localizamos el bloque normalizando espacios y, si hace falta,
// por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
// en lo hondo de un fichero grande).
const norm = s => s.replace(/[ \t]+/g, ' ').trim();
const curLines = current.split('\n');
const seaLines = search.replace(/\n+$/, '').split('\n');
const nSea = seaLines.map(norm);
const k = seaLines.length;
// 1) bloque idéntico salvo espacios → tiene que ser ÚNICO
let exactStart = -1, exactHits = 0;
for (let i = 0; i + k <= curLines.length; i++) {
let same = true;
for (let j = 0; j < k; j++) if (norm(curLines[i + j]) !== nSea[j]) { same = false; break; }
if (same) { exactHits++; if (exactStart === -1) exactStart = i; if (exactHits > 1) break; }
}
if (exactHits > 1)
throw new Error(`«search» coincide (ignorando espacios) en más de un sitio de ${path} — añade líneas de contexto para que sea inequívoco.`);
let start = exactStart;
if (start === -1) {
// 2) por parecido: la ventana con más líneas coincidentes (normalizadas),
// exigiendo ≥70% y que gane con claridad a la segunda mejor (sin ambigüedad)
let best = -1, bestScore = 0, second = 0;
for (let i = 0; i + k <= curLines.length; i++) {
let hit = 0;
for (let j = 0; j < k; j++) if (norm(curLines[i + j]) === nSea[j]) hit++;
const score = hit / k;
if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
else if (score > second) second = score;
}
if (bestScore >= 0.7 && bestScore - second >= 0.2) start = best;
}
if (start === -1)
throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
`Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
const nextLines = [...curLines.slice(0, start), ...replace.split('\n'), ...curLines.slice(start + k)];
const nextContent = nextLines.join('\n');
if (nextContent === current)
throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
return write({ path, content: nextContent });
}
// grep-lite por el proyecto (texto, con límites para no arrasar). Los topes
// existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
// (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
// como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80;
export async function search({ query, ext = '' } = {}) {
if (!query) throw new Error('Falta query');
if (!projectHandle) throw new Error('No hay proyecto abierto');
const results = [];
let checked = 0, capped = false;
const q = query.toLowerCase();
async function walk(dir, prefix) {
if (results.length >= SEARCH_MAX_HITS || checked >= SEARCH_MAX_FILES) { capped = true; return; }
for await (const e of dir.values()) {
if (IGNORE.has(e.name)) continue;
const p = prefix ? prefix + '/' + e.name : e.name;
if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
if (ext && !e.name.endsWith(ext)) continue;
const f = await e.getFile();
if (f.size > 200_000) continue;
checked++;
const lines = (await f.text()).split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(q)) {
results.push(`${p}:${i + 1}: ${lines[i].trim().slice(0, 140)}`);
if (results.length >= SEARCH_MAX_HITS) { capped = true; break; }
}
}
if (results.length >= SEARCH_MAX_HITS || checked >= SEARCH_MAX_FILES) { capped = true; return; }
}
}
await walk(projectHandle, '');
if (!results.length)
return `Sin resultados para «${query}»` + (capped ? ` (búsqueda cortada tras ${checked} ficheros; afina con ext:".js" o un término más concreto).` : '');
return results.join('\n') + (capped
? `\n… búsqueda cortada (${results.length} resultados, ${checked} ficheros revisados) — puede haber más: afina con ext o un término más concreto.`
: '');
}