elffuss-code / js /tools /code.js
KikoCis's picture
Elffuss Code — browser IDE with a local AI agent (WebGPU)
b30b7c5 verified
Raw
History Blame Contribute Delete
11.7 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.
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;
async function walk(dir, prefix, d) {
if (d > depth || count > 350) 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 > 350) { out.push(prefix + '…'); return; }
out.push(prefix + (e.kind === 'directory' ? '📁 ' : '') + e.name);
if (e.kind === 'directory') await walk(e, prefix + ' ', d + 1);
}
}
await walk(root, '', 1);
return out.join('\n') || '(vacío)';
}
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');
const current = await read({ 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: el texto no aparece tal cual, prueba con la misma
// tolerancia con la que un desarrollador reconocería "es este trozo"
const { diff_match_patch } = await import('https://esm.sh/diff-match-patch@1.0.5');
const dmp = new diff_match_patch();
const patches = dmp.patch_make(search, replace);
const [next, results] = dmp.patch_apply(patches, current);
if (!results.length || !results.every(Boolean)) {
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.`);
}
return write({ path, content: next });
}
// grep-lite por el proyecto (texto, con límites para no arrasar).
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;
const q = query.toLowerCase();
async function walk(dir, prefix) {
if (results.length > 80 || checked > 400) 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); 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');
lines.forEach((l, i) => {
if (results.length <= 80 && l.toLowerCase().includes(q))
results.push(`${p}:${i + 1}: ${l.trim().slice(0, 140)}`);
});
if (results.length > 80 || checked > 400) return;
}
}
await walk(projectHandle, '');
return results.length ? results.join('\n') : `Sin resultados para «${query}»`;
}