One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
432 lines
18 KiB
JavaScript
432 lines
18 KiB
JavaScript
// IDE: Monaco (el editor de VS Code) + árbol de archivos + pestañas.
|
||
import * as code from './tools/code.js';
|
||
import { fileIcon, folderIcon } from './icons.js';
|
||
import { renderMarkdown } from './md.js';
|
||
|
||
let editor = null;
|
||
let monacoRef = null;
|
||
const tabs = []; // { path, model, dirty, preview }
|
||
let active = null;
|
||
let onDirtyChange = () => {};
|
||
|
||
const LANG = {
|
||
js: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript',
|
||
jsx: 'javascript', json: 'json', html: 'html', css: 'css', md: 'markdown',
|
||
py: 'python', rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c',
|
||
cpp: 'cpp', sh: 'shell', yml: 'yaml', yaml: 'yaml', toml: 'ini',
|
||
svg: 'xml', xml: 'xml', sql: 'sql', txt: 'plaintext',
|
||
};
|
||
const langOf = p => LANG[p.split('.').pop().toLowerCase()] || 'plaintext';
|
||
const $ = id => document.getElementById(id);
|
||
|
||
export async function initEditor() {
|
||
await new Promise(res => {
|
||
const s = document.createElement('script');
|
||
s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
|
||
s.onload = res;
|
||
document.head.appendChild(s);
|
||
});
|
||
window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
|
||
await new Promise(res => window.require(['vs/editor/editor.main'], res));
|
||
monacoRef = window.monaco;
|
||
editor = monacoRef.editor.create($('editor'), {
|
||
theme: 'vs-dark',
|
||
automaticLayout: true,
|
||
fontSize: 13,
|
||
minimap: { enabled: false },
|
||
padding: { top: 8 },
|
||
});
|
||
editor.addCommand(monacoRef.KeyMod.CtrlCmd | monacoRef.KeyCode.KeyS, saveActive);
|
||
editor.onDidChangeModelContent(() => {
|
||
const tab = tabs.find(t => t.path === active);
|
||
if (tab && !tab.dirty) { tab.dirty = true; renderTabs(); }
|
||
});
|
||
// los cambios del agente se reflejan al instante en el editor
|
||
code.setOnFileWritten((path, content) => {
|
||
const tab = tabs.find(t => t.path === path);
|
||
if (tab && tab.model.getValue() !== content) {
|
||
tab.model.setValue(content);
|
||
tab.dirty = false;
|
||
renderTabs();
|
||
if (path === active) applyView();
|
||
}
|
||
refreshTree();
|
||
});
|
||
}
|
||
|
||
export function gotoLine(n) {
|
||
if (!editor || !n) return;
|
||
editor.revealLineInCenter(n);
|
||
editor.setPosition({ lineNumber: n, column: 1 });
|
||
editor.focus();
|
||
}
|
||
export function triggerEditor(actionId) {
|
||
editor?.getAction(actionId)?.run();
|
||
editor?.focus();
|
||
}
|
||
export function hasEditor() { return !!editor; }
|
||
|
||
export async function openFile(path) {
|
||
let tab = tabs.find(t => t.path === path);
|
||
if (!tab) {
|
||
let content;
|
||
try { content = await code.read({ path }); }
|
||
catch (e) {
|
||
// la ruta exacta no existe (típico: un enlace del chat solo mencionaba
|
||
// el NOMBRE, «config.py», y el fichero real vive en una subcarpeta) →
|
||
// si hay una única coincidencia por nombre en el proyecto, ábrela ella
|
||
// en vez de rendirte; con varias o ninguna, el error de siempre.
|
||
const base = path.split('/').pop();
|
||
const hits = await code.findByName?.(base, 2).catch(() => []) || [];
|
||
if (hits.length === 1 && hits[0] !== path) return openFile(hits[0]);
|
||
return alertBar('No pude abrir ' + path + ': ' + e.message);
|
||
}
|
||
tab = { path, model: monacoRef.editor.createModel(content, langOf(path)), dirty: false, preview: false };
|
||
tabs.push(tab);
|
||
}
|
||
active = path;
|
||
code.setCurrentFile(path);
|
||
editor.setModel(tab.model);
|
||
setEmptyState(false);
|
||
renderTabs();
|
||
await applyView();
|
||
}
|
||
|
||
// modo «Vista previa» de un tab .md/.html: en vez del código fuente en
|
||
// Monaco, muestra el markdown renderizado (mismo motor que el chat y la
|
||
// Mente) o el HTML en un iframe real.
|
||
const PREVIEWABLE = new Set(['markdown', 'html']);
|
||
|
||
// resuelve una ruta relativa (href/src) contra la carpeta del propio fichero
|
||
function resolveRelative(ownPath, rel) {
|
||
if (/^([a-z][\w+.-]*:)?\/\//i.test(rel) || rel.startsWith('data:') || rel.startsWith('#')) return null; // externa/absoluta: no tocar
|
||
const baseDir = ownPath.includes('/') ? ownPath.slice(0, ownPath.lastIndexOf('/')) : '';
|
||
const segs = (baseDir ? baseDir.split('/') : []).concat(rel.split('/'));
|
||
const out = [];
|
||
for (const s of segs) { if (!s || s === '.') continue; if (s === '..') out.pop(); else out.push(s); }
|
||
return out.join('/');
|
||
}
|
||
// un iframe con srcdoc no tiene base URL propia: <link href="style.css"> o
|
||
// <script src="app.js"> del proyecto real NUNCA se resuelven (por eso salía
|
||
// sin CSS) — se leen del proyecto de verdad y se incrustan antes de mostrarlo.
|
||
// El iframe de Vista previa va con sandbox="allow-scripts" A PROPÓSITO, SIN
|
||
// allow-same-origin (aislado de tu proyecto real — ver applyView). Pero eso
|
||
// hace que referenciar `localStorage`/`sessionStorage` LANCE una excepción
|
||
// síncrona — y como suele ser la primera línea de un script (guardar datos
|
||
// es de lo más común), el resto del fichero entero se queda sin ejecutar,
|
||
// sin aviso: los botones no hacen nada y parece que la app está rota. Se
|
||
// incrusta un shim en memoria (nunca toca almacenamiento real, se pierde al
|
||
// cerrar la vista previa) SOLO si de verdad hace falta — si el sandbox
|
||
// permitiera acceso real, no se toca nada.
|
||
const STORAGE_SHIM = `<script>(()=>{try{window.localStorage;return}catch(e){}` +
|
||
`const m=()=>{const d=new Map();return{getItem:k=>d.has(''+k)?d.get(''+k):null,` +
|
||
`setItem:(k,v)=>{d.set(''+k,''+v)},removeItem:k=>{d.delete(''+k)},clear:()=>{d.clear()},` +
|
||
`key:i=>[...d.keys()][i]??null,get length(){return d.size}}};` +
|
||
`for(const p of ['localStorage','sessionStorage'])` +
|
||
`try{Object.defineProperty(window,p,{value:m(),configurable:true})}catch(e){}})()<\/script>`;
|
||
|
||
async function inlineLocalAssets(html, ownPath) {
|
||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
(doc.head || doc.documentElement).insertAdjacentHTML('afterbegin', STORAGE_SHIM);
|
||
for (const link of doc.querySelectorAll('link[rel~="stylesheet"][href]')) {
|
||
const path = resolveRelative(ownPath, link.getAttribute('href'));
|
||
if (!path) continue;
|
||
try {
|
||
const style = doc.createElement('style');
|
||
style.textContent = await code.read({ path });
|
||
link.replaceWith(style);
|
||
} catch { /* no se pudo leer: se deja el link (el navegador lo ignorará sin más) */ }
|
||
}
|
||
for (const script of doc.querySelectorAll('script[src]')) {
|
||
const path = resolveRelative(ownPath, script.getAttribute('src'));
|
||
if (!path) continue;
|
||
try {
|
||
const inline = doc.createElement('script');
|
||
if (script.type) inline.type = script.type;
|
||
inline.textContent = await code.read({ path });
|
||
script.replaceWith(inline);
|
||
} catch { /* deja el script tal cual */ }
|
||
}
|
||
return '<!doctype html>\n' + doc.documentElement.outerHTML;
|
||
}
|
||
|
||
async function applyView() {
|
||
const tab = tabs.find(t => t.path === active);
|
||
const lang = tab ? langOf(tab.path) : null;
|
||
const showPreview = !!(tab && tab.preview && PREVIEWABLE.has(lang));
|
||
$('editor').style.display = showPreview ? 'none' : '';
|
||
let pane = document.getElementById('md-preview-pane');
|
||
if (showPreview) {
|
||
if (!pane) {
|
||
pane = document.createElement('div');
|
||
pane.id = 'md-preview-pane';
|
||
$('editor').parentElement.appendChild(pane);
|
||
}
|
||
pane.classList.toggle('html-preview', lang === 'html');
|
||
if (lang === 'html') {
|
||
pane.replaceChildren();
|
||
const frame = document.createElement('iframe');
|
||
frame.sandbox = 'allow-scripts'; // sin allow-same-origin: aislado de tu proyecto real
|
||
frame.srcdoc = await inlineLocalAssets(tab.model.getValue(), tab.path);
|
||
pane.appendChild(frame);
|
||
} else {
|
||
pane.innerHTML = renderMarkdown(tab.model.getValue());
|
||
}
|
||
pane.style.display = '';
|
||
} else if (pane) {
|
||
pane.style.display = 'none';
|
||
}
|
||
}
|
||
async function togglePreview(path) {
|
||
// el fichero puede no tener pestaña abierta todavía (p.ej. desde el menú
|
||
// del EXPLORADOR, no el de una pestaña ya abierta) — se abre primero.
|
||
if (active !== path || !tabs.find(t => t.path === path)) await openFile(path);
|
||
const tab = tabs.find(t => t.path === path);
|
||
if (!tab) return; // openFile no pudo (fichero borrado, etc.)
|
||
tab.preview = !tab.preview;
|
||
renderTabs();
|
||
await applyView();
|
||
}
|
||
|
||
async function saveActive() {
|
||
const tab = tabs.find(t => t.path === active);
|
||
if (!tab) return;
|
||
await code.write({ path: tab.path, content: tab.model.getValue() });
|
||
tab.dirty = false;
|
||
renderTabs();
|
||
alertBar('💾 ' + tab.path + ' guardado');
|
||
}
|
||
|
||
async function closeTab(path) {
|
||
const i = tabs.findIndex(t => t.path === path);
|
||
if (i < 0) return;
|
||
tabs[i].model.dispose();
|
||
tabs.splice(i, 1);
|
||
if (active === path) {
|
||
active = tabs[i - 1]?.path || tabs[0]?.path || null;
|
||
code.setCurrentFile(active);
|
||
if (active) { editor.setModel(tabs.find(t => t.path === active).model); setEmptyState(false); }
|
||
else { editor.setModel(monacoRef.editor.createModel('', 'plaintext')); setEmptyState(true); }
|
||
}
|
||
renderTabs();
|
||
await applyView();
|
||
}
|
||
|
||
// Estado vacío: cuando no hay ningún fichero abierto, en vez de dejar el Monaco
|
||
// en blanco con un «1», mostramos un placeholder limpio.
|
||
export function setEmptyState(on) {
|
||
let el = document.getElementById('editor-empty');
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.id = 'editor-empty';
|
||
el.innerHTML = '<img src="img/elffuss-code.svg" alt=""><p>Abre un fichero del explorador,<br>o pídele algo a la elfa.</p>';
|
||
$('editor').parentElement.appendChild(el);
|
||
}
|
||
el.style.display = on ? 'flex' : 'none';
|
||
}
|
||
|
||
function renderTabs() {
|
||
const bar = $('tabs-bar');
|
||
bar.replaceChildren();
|
||
for (const t of tabs) {
|
||
const el = document.createElement('div');
|
||
el.className = 'tab' + (t.path === active ? ' active' : '') + (t.preview ? ' tab-preview' : '');
|
||
el.dataset.path = t.path;
|
||
const ico = document.createElement('span');
|
||
ico.className = 'tab-ico';
|
||
ico.innerHTML = fileIcon(t.path.split('/').pop());
|
||
const name = document.createElement('span');
|
||
name.className = 'tab-name';
|
||
name.textContent = t.path.split('/').pop();
|
||
name.title = t.path;
|
||
// como VS Code: punto ● si hay cambios sin guardar; × al pasar el ratón
|
||
const x = document.createElement('b');
|
||
x.className = t.dirty ? 'dirty' : '';
|
||
x.textContent = t.dirty ? '●' : '×';
|
||
x.onclick = e => { e.stopPropagation(); closeTab(t.path); };
|
||
el.append(ico, name, x);
|
||
el.onclick = () => openFile(t.path);
|
||
el.oncontextmenu = e => { e.preventDefault(); openTabMenu(e.clientX, e.clientY, t.path); };
|
||
bar.appendChild(el);
|
||
}
|
||
}
|
||
|
||
// ---------- menú contextual de las pestañas (botón derecho, estilo VS Code) ----------
|
||
function openTabMenu(x, y, path) {
|
||
document.getElementById('tab-menu')?.remove();
|
||
const menu = document.createElement('div');
|
||
menu.id = 'tab-menu';
|
||
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
|
||
const i = tabs.findIndex(t => t.path === path);
|
||
const tab = tabs[i];
|
||
const items = [];
|
||
items.push(['Cerrar', () => closeTab(path)]);
|
||
items.push(['Cerrar otras', () => { for (const t of tabs.filter(t => t.path !== path)) closeTab(t.path); }]);
|
||
const toTheRight = tabs.slice(i + 1).map(t => t.path);
|
||
if (toTheRight.length) items.push(['Cerrar las de la derecha', () => { for (const p of toTheRight) closeTab(p); }]);
|
||
items.push(['Cerrar todas', () => { for (const t of [...tabs]) closeTab(t.path); }]);
|
||
items.push(['sep']);
|
||
items.push(['Copiar ruta', () => navigator.clipboard?.writeText(path).catch(() => {})]);
|
||
if (PREVIEWABLE.has(langOf(path))) {
|
||
items.push([tab?.preview ? 'Ver código fuente' : 'Vista previa', () => togglePreview(path)]);
|
||
}
|
||
for (const item of items) {
|
||
if (item[0] === 'sep') { menu.appendChild(Object.assign(document.createElement('div'), { className: 'tm-sep' })); continue; }
|
||
const [label, fn] = item;
|
||
const b = document.createElement('button');
|
||
b.className = 'tm-item';
|
||
b.textContent = label;
|
||
b.onclick = () => { menu.remove(); fn(); };
|
||
menu.appendChild(b);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
}
|
||
|
||
let alertTimer;
|
||
function alertBar(msg) {
|
||
const el = $('statusbar');
|
||
el.textContent = msg;
|
||
clearTimeout(alertTimer);
|
||
alertTimer = setTimeout(() => { el.textContent = ''; }, 4000);
|
||
}
|
||
|
||
// ---------- árbol lateral ----------
|
||
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
|
||
|
||
export async function refreshTree(handle = null) {
|
||
const rootHandle = handle || window.__elffussProject;
|
||
if (!rootHandle) return;
|
||
window.__elffussProject = rootHandle;
|
||
const cont = $('tree');
|
||
cont.replaceChildren(await renderDir(rootHandle, ''));
|
||
setupTreeMenu();
|
||
}
|
||
|
||
// ---------- menú contextual del árbol (botón derecho, estilo VS Code) ----------
|
||
let treeMenuWired = false;
|
||
function setupTreeMenu() {
|
||
if (treeMenuWired) return;
|
||
treeMenuWired = true;
|
||
const tree = $('tree');
|
||
tree.addEventListener('contextmenu', e => {
|
||
e.preventDefault();
|
||
const node = e.target.closest('[data-path]');
|
||
const path = node ? node.dataset.path : '';
|
||
const kind = node ? node.dataset.kind : 'directory'; // vacío = raíz
|
||
openTreeMenu(e.clientX, e.clientY, path, kind);
|
||
});
|
||
}
|
||
|
||
// resuelve el handle de un directorio por su ruta (segmentos desde la raíz)
|
||
async function dirByPath(path) {
|
||
let dir = code.handle();
|
||
for (const s of (path || '').split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(s);
|
||
return dir;
|
||
}
|
||
const parentOf = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
|
||
const baseOf = p => p.split('/').pop();
|
||
|
||
async function fsCreate(parentPath, name, isDir) {
|
||
const dir = await dirByPath(parentPath);
|
||
if (isDir) await dir.getDirectoryHandle(name, { create: true });
|
||
else { const fh = await dir.getFileHandle(name, { create: true }); const w = await fh.createWritable(); await w.close(); }
|
||
}
|
||
async function fsDelete(path, kind) {
|
||
const dir = await dirByPath(parentOf(path));
|
||
await dir.removeEntry(baseOf(path), { recursive: kind === 'directory' });
|
||
}
|
||
// copia recursiva (la File System API no tiene rename nativo)
|
||
async function fsCopy(srcDir, name, dstDir, newName) {
|
||
const src = await (srcDir.getDirectoryHandle(name).catch(() => null));
|
||
if (src) { // directorio
|
||
const nd = await dstDir.getDirectoryHandle(newName, { create: true });
|
||
for await (const e of src.values()) await fsCopy(src, e.name, nd, e.name);
|
||
} else { // fichero
|
||
const text = await (await (await srcDir.getFileHandle(name)).getFile()).text();
|
||
const fh = await dstDir.getFileHandle(newName, { create: true });
|
||
const w = await fh.createWritable(); await w.write(text); await w.close();
|
||
}
|
||
}
|
||
async function fsRename(path, kind, newName) {
|
||
const parent = await dirByPath(parentOf(path));
|
||
await fsCopy(parent, baseOf(path), parent, newName);
|
||
await parent.removeEntry(baseOf(path), { recursive: kind === 'directory' });
|
||
}
|
||
|
||
function openTreeMenu(x, y, path, kind) {
|
||
document.getElementById('tree-menu')?.remove();
|
||
const menu = document.createElement('div');
|
||
menu.id = 'tree-menu';
|
||
menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
|
||
// en un fichero, el "aquí dentro" es su carpeta; en carpeta/raíz, ella misma
|
||
const container = kind === 'file' ? parentOf(path) : path;
|
||
const items = [];
|
||
items.push(['Nuevo archivo…', async () => {
|
||
const name = prompt('Nombre del nuevo archivo:'); if (!name) return;
|
||
await fsCreate(container, name.trim(), false); code.invalidateFileList?.();
|
||
await refreshTree(); openFile(container ? container + '/' + name.trim() : name.trim());
|
||
}]);
|
||
items.push(['Nueva carpeta…', async () => {
|
||
const name = prompt('Nombre de la nueva carpeta:'); if (!name) return;
|
||
await fsCreate(container, name.trim(), true); code.invalidateFileList?.(); await refreshTree();
|
||
}]);
|
||
if (path) {
|
||
if (kind === 'file') items.push(['Abrir', () => openFile(path)]);
|
||
if (kind === 'file' && PREVIEWABLE.has(langOf(path))) items.push(['Vista previa', () => togglePreview(path)]);
|
||
items.push(['Renombrar…', async () => {
|
||
const name = prompt('Nuevo nombre:', baseOf(path)); if (!name || name === baseOf(path)) return;
|
||
await fsRename(path, kind, name.trim()); code.invalidateFileList?.(); await refreshTree();
|
||
}]);
|
||
items.push(['Eliminar', async () => {
|
||
if (!confirm(`¿Eliminar «${baseOf(path)}»?`)) return;
|
||
await fsDelete(path, kind); code.invalidateFileList?.(); await refreshTree();
|
||
}, 'danger']);
|
||
}
|
||
for (const [label, fn, cls] of items) {
|
||
const b = document.createElement('button');
|
||
b.className = 'tm-item' + (cls ? ' ' + cls : '');
|
||
b.textContent = label;
|
||
b.onclick = async () => { menu.remove(); try { await fn(); } catch (err) { alert('No se pudo: ' + err.message); } };
|
||
menu.appendChild(b);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
}
|
||
|
||
async function renderDir(dir, prefix) {
|
||
const ul = document.createElement('ul');
|
||
const entries = [];
|
||
for await (const e of dir.values()) if (!IGNORE.has(e.name)) 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.slice(0, 200)) {
|
||
const li = document.createElement('li');
|
||
const p = prefix ? prefix + '/' + e.name : e.name;
|
||
if (e.kind === 'directory') {
|
||
const det = document.createElement('details');
|
||
const sum = document.createElement('summary');
|
||
sum.innerHTML = folderIcon();
|
||
sum.appendChild(document.createTextNode(e.name));
|
||
sum.dataset.path = p; sum.dataset.kind = 'directory';
|
||
det.appendChild(sum);
|
||
det.addEventListener('toggle', async () => {
|
||
if (det.open && det.children.length === 1)
|
||
det.appendChild(await renderDir(e, p));
|
||
}, { once: false });
|
||
li.appendChild(det);
|
||
} else {
|
||
const a = document.createElement('span');
|
||
a.className = 'file';
|
||
a.innerHTML = fileIcon(e.name);
|
||
a.appendChild(document.createTextNode(e.name));
|
||
a.dataset.path = p; a.dataset.kind = 'file';
|
||
a.onclick = () => openFile(p);
|
||
li.appendChild(a);
|
||
}
|
||
ul.appendChild(li);
|
||
}
|
||
return ul;
|
||
}
|