// 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}»`; }