Stand-in import of elffuss/code
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
This commit is contained in:
289
web/js/workspace.js
Normal file
289
web/js/workspace.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// workspace.js — carpeta de trabajo, copia legible en disco e inventario.
|
||||
//
|
||||
// CORE compartido por Claw y Code. El módulo no sabe qué es una «app generada»
|
||||
// ni una «conversación»: cada app le pasa un MANIFIESTO de almacenes y él sabe
|
||||
// leerlos, medirlos, escribirlos a disco, reimportarlos y borrarlos.
|
||||
//
|
||||
// Por qué existe: todo vive en IndexedDB/localStorage, que el navegador puede
|
||||
// desalojar (o el usuario borrar con «limpiar datos del sitio») sin avisar. Con
|
||||
// una carpeta de trabajo, tu trabajo queda en ficheros normales que puedes ver,
|
||||
// copiar y respaldar tú.
|
||||
//
|
||||
// Lo que NUNCA sale a disco en claro: el vault (secretos cifrados). Se marca
|
||||
// `sensitive` en el manifiesto y se excluye salvo petición explícita.
|
||||
|
||||
const HANDLE_KEY = 'workspace-handle';
|
||||
const ROOT = '.elffuss';
|
||||
|
||||
let cfg = null; // { app, ns, db, stores }
|
||||
let dirHandle = null; // FileSystemDirectoryHandle de la carpeta de trabajo
|
||||
let ready = false; // permiso concedido AHORA
|
||||
let dirty = new Set();
|
||||
let listeners = [];
|
||||
let lastSaveAt = null, lastError = null;
|
||||
let autosaveCfg = { enabled: false, debounceMs: 3000 };
|
||||
let autosaveTimer = null;
|
||||
|
||||
export function init({ app, ns, db, stores }) {
|
||||
cfg = { app, ns, db, stores: stores || [] };
|
||||
}
|
||||
|
||||
export function supported() {
|
||||
return {
|
||||
pick: typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function',
|
||||
persist: !!(navigator.storage && navigator.storage.persist),
|
||||
};
|
||||
}
|
||||
|
||||
const emit = () => { const s = statusSync(); listeners.forEach(f => { try { f(s); } catch { /* */ } }); };
|
||||
export function onChange(fn) { listeners.push(fn); return () => { listeners = listeners.filter(f => f !== fn); }; }
|
||||
|
||||
function statusSync() {
|
||||
return {
|
||||
supported: supported(), folder: dirHandle ? dirHandle.name : null, ready,
|
||||
dirty: [...dirty], lastSaveAt, lastError, autosave: autosaveCfg.enabled,
|
||||
};
|
||||
}
|
||||
export function status() { return statusSync(); }
|
||||
|
||||
// ── carpeta de trabajo ───────────────────────────────────────────────────────
|
||||
async function perm(handle, mode = 'readwrite') {
|
||||
if (!handle?.queryPermission) return true; // OPFS u otros: dados
|
||||
if ((await handle.queryPermission({ mode })) === 'granted') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// En el arranque: recupera el handle SIN pedir permiso ni abrir diálogos.
|
||||
export async function restore() {
|
||||
if (!cfg) throw new Error('workspace.init() primero');
|
||||
let h = null;
|
||||
try { h = await cfg.db.get('kv', HANDLE_KEY); } catch { return null; }
|
||||
if (!h) return null;
|
||||
dirHandle = h;
|
||||
ready = await perm(h);
|
||||
emit();
|
||||
return { name: h.name, ready, reason: ready ? null : 'prompt' };
|
||||
}
|
||||
|
||||
// Elegir carpeta (requiere gesto de usuario).
|
||||
export async function pick() {
|
||||
if (!supported().pick) throw new Error('Este navegador no permite elegir una carpeta. Usa «Descargar copia» para guardar tu trabajo.');
|
||||
const h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'elffuss-workspace', startIn: 'documents' });
|
||||
dirHandle = h;
|
||||
ready = true;
|
||||
await cfg.db.set('kv', HANDLE_KEY, h);
|
||||
emit();
|
||||
return { name: h.name, ready: true };
|
||||
}
|
||||
|
||||
// Reutilizar un handle que la app ya tiene (Code: la carpeta del proyecto).
|
||||
export async function adopt(handle) {
|
||||
if (!handle) throw new Error('sin handle');
|
||||
dirHandle = handle;
|
||||
ready = await perm(handle);
|
||||
await cfg.db.set('kv', HANDLE_KEY, handle);
|
||||
emit();
|
||||
return { name: handle.name, ready };
|
||||
}
|
||||
|
||||
// Re-conceder permiso tras recargar (requiere gesto de usuario).
|
||||
export async function regrant() {
|
||||
if (!dirHandle) throw new Error('no hay carpeta elegida');
|
||||
const ok = dirHandle.requestPermission
|
||||
? (await dirHandle.requestPermission({ mode: 'readwrite' })) === 'granted'
|
||||
: true;
|
||||
ready = ok;
|
||||
emit();
|
||||
if (!ok) throw new Error('permiso denegado');
|
||||
return { name: dirHandle.name, ready: true };
|
||||
}
|
||||
|
||||
export async function forget() {
|
||||
const name = dirHandle?.name || null;
|
||||
dirHandle = null; ready = false;
|
||||
try { await cfg.db.del('kv', HANDLE_KEY); } catch { /* */ }
|
||||
emit();
|
||||
return { forgot: name, deleted: false }; // nunca borra los ficheros del disco
|
||||
}
|
||||
|
||||
export async function ensurePersistence() {
|
||||
if (!navigator.storage?.persist) return { persisted: false, asked: false };
|
||||
const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
|
||||
if (already) return { persisted: true, asked: false };
|
||||
const got = await navigator.storage.persist().catch(() => false);
|
||||
return { persisted: got, asked: true };
|
||||
}
|
||||
|
||||
// ── inventario: «ver qué hay» ────────────────────────────────────────────────
|
||||
const sizeOf = v => { try { return new Blob([typeof v === 'string' ? v : JSON.stringify(v)]).size; } catch { return 0; } };
|
||||
|
||||
async function readStore(st) {
|
||||
if (st.kind === 'ls') {
|
||||
const items = [];
|
||||
for (const k of st.keys || []) {
|
||||
const v = localStorage.getItem(k);
|
||||
if (v != null) items.push({ key: k, value: v });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (st.kind === 'idb-store') { // store entero (apps, tasks…)
|
||||
const [ks, vs] = await Promise.all([cfg.db.keys(st.store), cfg.db.all(st.store)]);
|
||||
return ks.map((k, i) => ({ key: String(k), value: vs[i] }));
|
||||
}
|
||||
// 'idb-key': una clave concreta dentro de kv (history, conversations, skills…)
|
||||
const v = await cfg.db.get(st.store || 'kv', st.key);
|
||||
if (v == null) return [];
|
||||
return Array.isArray(v) ? v.map((x, i) => ({ key: String(x.id ?? x.name ?? i), value: x })) : [{ key: st.key, value: v }];
|
||||
}
|
||||
|
||||
export async function inventory() {
|
||||
const out = [];
|
||||
for (const st of cfg.stores) {
|
||||
let items = [];
|
||||
let err = null;
|
||||
try { items = await readStore(st); } catch (e) { err = e.message; }
|
||||
out.push({
|
||||
id: st.id, label: st.label, icon: st.icon || '•', sensitive: !!st.sensitive,
|
||||
count: items.length, bytes: items.reduce((a, b) => a + sizeOf(b.value), 0),
|
||||
note: st.note || '', error: err,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function peek(id, { limit = 50 } = {}) {
|
||||
const st = cfg.stores.find(s => s.id === id);
|
||||
if (!st) throw new Error('almacén desconocido: ' + id);
|
||||
const items = await readStore(st);
|
||||
return {
|
||||
id, truncated: items.length > limit,
|
||||
items: items.slice(0, limit).map(it => ({
|
||||
key: it.key, bytes: sizeOf(it.value),
|
||||
preview: st.sensitive ? '(cifrado — no se muestra)'
|
||||
: (typeof it.value === 'string' ? it.value : JSON.stringify(it.value)).slice(0, 240),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Espacio real del origen, incluidas TODAS las caches (los modelos son GB).
|
||||
export async function storageReport() {
|
||||
const est = navigator.storage?.estimate ? await navigator.storage.estimate().catch(() => ({})) : {};
|
||||
const persisted = navigator.storage?.persisted ? await navigator.storage.persisted().catch(() => false) : false;
|
||||
const out = { usage: est.usage || 0, quota: est.quota || 0, persisted, caches: [] };
|
||||
if (typeof caches !== 'undefined') {
|
||||
for (const name of await caches.keys().catch(() => [])) {
|
||||
const c = await caches.open(name);
|
||||
const reqs = await c.keys();
|
||||
let bytes = 0;
|
||||
for (const r of reqs) {
|
||||
const res = await c.match(r);
|
||||
const len = +(res?.headers.get('content-length') || 0);
|
||||
bytes += len;
|
||||
}
|
||||
out.caches.push({ name, entries: reqs.length, bytes });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── guardar en disco ─────────────────────────────────────────────────────────
|
||||
export function touch(id) {
|
||||
dirty.add(id);
|
||||
emit();
|
||||
if (autosaveCfg.enabled) {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(() => { save({ reason: 'auto' }).catch(() => {}); }, autosaveCfg.debounceMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function autosave({ enabled, debounceMs }) {
|
||||
autosaveCfg = { enabled: !!enabled, debounceMs: debounceMs || autosaveCfg.debounceMs };
|
||||
try { localStorage.setItem(cfg.ns + '.workspace.autosave', enabled ? '1' : '0'); } catch { /* */ }
|
||||
emit();
|
||||
return autosaveCfg;
|
||||
}
|
||||
export function autosaveEnabled() {
|
||||
try { return localStorage.getItem(cfg.ns + '.workspace.autosave') === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
async function dirFor(path) { // crea .elffuss/<app>/<sub> y devuelve el handle
|
||||
let d = dirHandle;
|
||||
for (const part of path.split('/').filter(Boolean)) d = await d.getDirectoryHandle(part, { create: true });
|
||||
return d;
|
||||
}
|
||||
async function writeFile(dir, name, text) {
|
||||
const fh = await dir.getFileHandle(name, { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write(text);
|
||||
await w.close();
|
||||
}
|
||||
const safeName = s => String(s).replace(/[^\w.\-]+/g, '_').slice(0, 60) || 'item';
|
||||
|
||||
export async function save({ only = null, reason = 'manual' } = {}) {
|
||||
if (!dirHandle) throw new Error('no-folder');
|
||||
if (!(await perm(dirHandle))) { ready = false; emit(); throw new Error('needs-regrant'); }
|
||||
const targets = cfg.stores.filter(st => !st.sensitive && (only ? only.includes(st.id) : true));
|
||||
const wrote = [], errors = [];
|
||||
for (const st of targets) {
|
||||
try {
|
||||
const items = await readStore(st);
|
||||
const base = `${ROOT}/${cfg.app}/${st.id}`;
|
||||
if (st.files) { // uno por fichero (apps .html, skills .md)
|
||||
const d = await dirFor(base);
|
||||
for (const it of items) {
|
||||
const body = st.files.body ? st.files.body(it.value) : JSON.stringify(it.value, null, 1);
|
||||
await writeFile(d, safeName(st.files.name ? st.files.name(it.value, it.key) : it.key) + (st.files.ext || '.json'), body);
|
||||
}
|
||||
} else { // uno solo (historial, ajustes)
|
||||
const d = await dirFor(`${ROOT}/${cfg.app}`);
|
||||
await writeFile(d, st.id + '.json', JSON.stringify(items.map(i => i.value), null, 1));
|
||||
}
|
||||
wrote.push({ id: st.id, files: items.length });
|
||||
dirty.delete(st.id);
|
||||
} catch (e) { errors.push({ id: st.id, error: e.message }); }
|
||||
}
|
||||
lastSaveAt = Date.now();
|
||||
lastError = errors.length ? errors[0].error : null;
|
||||
// índice legible con lo que hay
|
||||
try {
|
||||
const d = await dirFor(`${ROOT}/${cfg.app}`);
|
||||
await writeFile(d, 'index.json', JSON.stringify({ app: cfg.app, at: lastSaveAt, reason, wrote }, null, 1));
|
||||
} catch { /* */ }
|
||||
emit();
|
||||
return { ok: !errors.length, at: lastSaveAt, wrote, errors };
|
||||
}
|
||||
|
||||
// ── copia descargable (Safari/Firefox o backup manual) ───────────────────────
|
||||
export async function bundle({ only = null, includeSensitive = false } = {}) {
|
||||
const data = { app: cfg.app, at: Date.now(), stores: {} };
|
||||
for (const st of cfg.stores) {
|
||||
if (st.sensitive && !includeSensitive) continue;
|
||||
if (only && !only.includes(st.id)) continue;
|
||||
try { data.stores[st.id] = (await readStore(st)).map(i => i.value); } catch { /* */ }
|
||||
}
|
||||
return new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' });
|
||||
}
|
||||
|
||||
// ── borrar ───────────────────────────────────────────────────────────────────
|
||||
export async function wipe({ only = null, confirm } = {}) {
|
||||
if (confirm !== 'BORRAR') throw new Error('confirmación requerida');
|
||||
const removed = [];
|
||||
for (const st of cfg.stores) {
|
||||
if (only && !only.includes(st.id)) continue;
|
||||
try {
|
||||
if (st.kind === 'ls') { for (const k of st.keys || []) localStorage.removeItem(k); removed.push({ id: st.id }); }
|
||||
else if (st.kind === 'idb-store') { for (const k of await cfg.db.keys(st.store)) await cfg.db.del(st.store, k); removed.push({ id: st.id }); }
|
||||
else { await cfg.db.del(st.store || 'kv', st.key); removed.push({ id: st.id }); }
|
||||
} catch (e) { removed.push({ id: st.id, error: e.message }); }
|
||||
}
|
||||
emit();
|
||||
return { removed };
|
||||
}
|
||||
|
||||
export async function clearCaches({ names } = {}) {
|
||||
if (typeof caches === 'undefined') return { deleted: [] };
|
||||
const target = names || await caches.keys();
|
||||
const deleted = [];
|
||||
for (const n of target) if (await caches.delete(n)) deleted.push(n);
|
||||
return { deleted };
|
||||
}
|
||||
Reference in New Issue
Block a user