File size: 15,271 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6605e7
b30b7c5
 
 
 
 
d6605e7
b30b7c5
d6605e7
b30b7c5
 
 
 
 
d6605e7
b30b7c5
d6605e7
b30b7c5
 
 
d6605e7
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5314ddc
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
5314ddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
5314ddc
 
 
 
 
 
b30b7c5
 
5314ddc
 
 
 
 
b30b7c5
 
 
 
5314ddc
b30b7c5
 
5314ddc
b30b7c5
 
 
5314ddc
b30b7c5
 
 
 
 
5314ddc
 
 
 
 
 
 
b30b7c5
 
 
5314ddc
 
 
 
 
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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// 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.`
    : '');
}