Spaces:
Running
Running
File size: 11,726 Bytes
b30b7c5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | // 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}»`;
}
|