lampson 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.env.example +10 -1
  2. package/README.md +9 -3
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +112 -8
  5. package/cli.syn +143 -7
  6. package/edge.syn +26 -0
  7. package/hub.tpl.syn +127 -6
  8. package/lampson.ps1 +43 -2
  9. package/lampson.sh +40 -2
  10. package/lib/agents.syn +30 -9
  11. package/lib/approvals.syn +2 -2
  12. package/lib/auth.syn +274 -0
  13. package/lib/computer.syn +2000 -0
  14. package/lib/computer_judge.syn +410 -0
  15. package/lib/fs.syn +242 -0
  16. package/lib/git.syn +2 -2
  17. package/lib/judge.syn +78 -0
  18. package/lib/line.syn +2 -2
  19. package/lib/loop.syn +41 -8
  20. package/lib/lsp.syn +4 -4
  21. package/lib/mcp.syn +20 -4
  22. package/lib/md.syn +11 -11
  23. package/lib/origin.syn +67 -0
  24. package/lib/permission.syn +47 -2
  25. package/lib/prompt.syn +10 -1
  26. package/lib/provider.syn +70 -20
  27. package/lib/schedule.syn +12 -12
  28. package/lib/session.syn +5 -5
  29. package/lib/settings.syn +65 -1
  30. package/lib/skills.syn +22 -6
  31. package/lib/tools/bash.syn +1 -1
  32. package/lib/tools/common.syn +1 -1
  33. package/lib/tools/fetch.syn +600 -0
  34. package/lib/tools/grep.syn +1 -1
  35. package/lib/tools/memo.syn +3 -3
  36. package/lib/tools/proc.syn +38 -20
  37. package/lib/tools/qr.js +26 -0
  38. package/lib/tools/url.syn +220 -0
  39. package/lib/tools.syn +25 -2
  40. package/lib/trace.syn +2 -2
  41. package/lib/tree.syn +29 -13
  42. package/lib/update.syn +1 -1
  43. package/lib/workspaces.syn +202 -36
  44. package/package.json +12 -3
  45. package/public/apple-touch-icon.png +0 -0
  46. package/public/badge-96.png +0 -0
  47. package/public/css/code.css +48 -0
  48. package/public/css/hub.css +9 -0
  49. package/public/css/login.css +16 -0
  50. package/public/css/panel.css +30 -0
  51. package/public/css/sidebar.css +21 -0
  52. package/public/css/tokens.css +1 -1
  53. package/public/hub.html +11 -1
  54. package/public/icon-192.png +0 -0
  55. package/public/icon-512.png +0 -0
  56. package/public/icon-maskable-512.png +0 -0
  57. package/public/icons-src/badge.svg +12 -0
  58. package/public/icons-src/icon-maskable.svg +15 -0
  59. package/public/icons-src/icon.svg +13 -0
  60. package/public/index.html +14 -3
  61. package/public/js/agents.js +1 -1
  62. package/public/js/chat.js +1 -1
  63. package/public/js/config.js +145 -2
  64. package/public/js/core.js +5 -3
  65. package/public/js/devices.js +80 -0
  66. package/public/js/editor.js +232 -0
  67. package/public/js/login.js +78 -0
  68. package/public/js/procs.js +1 -1
  69. package/public/js/pwa.js +5 -0
  70. package/public/js/tree.js +224 -14
  71. package/public/login.html +43 -0
  72. package/public/manifest.webmanifest +16 -0
  73. package/public/offline.html +24 -0
  74. package/public/sw.js +63 -0
  75. package/public/vendor/highlight.min.js +1343 -0
  76. package/public/vendor/qrcode.js +2297 -0
  77. package/skills/computer-use/SKILL.md +178 -0
  78. package/skills/lampson/SKILL.md +13 -0
  79. package/web.syn +82 -7
@@ -0,0 +1,232 @@
1
+ // editor.js — editor de archivos del visor: un textarea transparente (caret, selección, teclado nativo) sobre un
2
+ // <pre> con el mismo texto resaltado por highlight.js (vendor/highlight.min.js = build "common" + powershell +
3
+ // dockerfile; Synsema se registra acá, traducido de la gramática TextMate oficial del repo kitecosmic/synsema,
4
+ // editors/vscode/syntaxes/synsema.tmLanguage.json). El gutter con los números va aparte y sigue el scroll.
5
+ //
6
+ // Guardar: Ctrl+S o el botón → POST /api/fs {op:"write"} (lib/fs.syn: solo archivos que existen, dentro del
7
+ // workspace). Antes de guardar se relee el archivo: si cambió en disco (el agente lo editó mientras tanto) se
8
+ // avisa y se elige sobrescribir o recargar, nunca se pisa a ciegas. Cambiar de archivo con cambios sin guardar
9
+ // no los pierde: quedan como borrador en memoria y vuelven al reabrirlo (se pierden al recargar la página, con aviso).
10
+ // Solo lectura: archivos truncados por la API (> 200k) y binarios — guardar un recorte destruiría el archivo.
11
+
12
+ const LANG_BY_EXT = {
13
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
14
+ css: 'css', scss: 'scss', less: 'less', html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml', svelte: 'xml', xsl: 'xml', plist: 'xml',
15
+ json: 'json', jsonc: 'json', md: 'markdown', markdown: 'markdown', py: 'python', rs: 'rust', go: 'go',
16
+ sh: 'bash', bash: 'bash', zsh: 'bash', env: 'bash', ps1: 'powershell', psm1: 'powershell', psd1: 'powershell',
17
+ yml: 'yaml', yaml: 'yaml', sql: 'sql', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp', cs: 'csharp',
18
+ java: 'java', kt: 'kotlin', kts: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php', lua: 'lua', r: 'r', pl: 'perl',
19
+ ini: 'ini', toml: 'ini', cfg: 'ini', conf: 'ini', properties: 'ini', diff: 'diff', patch: 'diff', graphql: 'graphql', gql: 'graphql',
20
+ wasm: 'wasm', wat: 'wasm', syn: 'synsema', fsyn: 'synsema', txt: 'plaintext', log: 'plaintext', csv: 'plaintext', lock: 'plaintext',
21
+ prisma: 'prisma', tsbuildinfo: 'json', map: 'json', webmanifest: 'json', jsonl: 'json', har: 'json', mdx: 'markdown', proto: 'protobuf',
22
+ bat: 'dos', cmd: 'dos', psql: 'sql', pgsql: 'sql', mysql: 'sql', m: 'objectivec', csproj: 'xml', xaml: 'xml', astro: 'xml', ejs: 'xml', hbs: 'xml',
23
+ // nombres de fence habituales en el chat que no son extensiones
24
+ javascript: 'javascript', typescript: 'typescript', python: 'python', rust: 'rust', shell: 'bash', powershell: 'powershell',
25
+ synsema: 'synsema', html5: 'xml', dockerfile: 'dockerfile', makefile: 'makefile', text: 'plaintext', plaintext: 'plaintext'
26
+ };
27
+ const LANG_BY_NAME = { dockerfile: 'dockerfile', makefile: 'makefile', gnumakefile: 'makefile', '.env': 'bash', '.env.example': 'bash', '.gitignore': 'plaintext', '.gitattributes': 'plaintext', '.npmrc': 'ini', '.editorconfig': 'ini', '.nvmrc': 'plaintext', '.babelrc': 'json', '.eslintrc': 'json', '.prettierrc': 'json', '.swcrc': 'json', 'nginx.conf': 'nginx', procfile: 'plaintext' };
28
+ function langOf(path) {
29
+ const base = String(path || '').split('/').pop().toLowerCase();
30
+ if (LANG_BY_NAME[base]) return LANG_BY_NAME[base];
31
+ if (base.startsWith('.env')) return 'bash'; // .env.local, .env.production…
32
+ if (base.startsWith('dockerfile')) return 'dockerfile'; // Dockerfile.dev
33
+ const i = base.lastIndexOf('.'); const ext = i >= 0 ? base.slice(i + 1) : '';
34
+ return LANG_BY_EXT[ext] || 'plaintext';
35
+ }
36
+ // código en un bloque ``` del chat (core.js md()): resaltado si el fence trae un lenguaje conocido
37
+ function hlCode(code, lang) {
38
+ if (typeof hljs === 'undefined') return esc(code);
39
+ const l = String(lang || '').toLowerCase(); const name = LANG_BY_EXT[l] || (l && hljs.getLanguage(l) ? l : '');
40
+ if (!name || name === 'plaintext' || code.length > 60000) return esc(code);
41
+ try { return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; } catch (e) { return esc(code); }
42
+ }
43
+
44
+ // ---- Synsema para highlight.js (misma partición que el tmLanguage: control, tipos, seguridad, agentes, LLM,
45
+ // humano, observabilidad, serve, módulos; builtins solo cuando se llaman; task/type/agent + nombre = definición) ----
46
+ function synsemaGrammar(hljs) {
47
+ const KEYWORDS = [
48
+ 'when', 'otherwise', 'each', 'while', 'repeat', 'match', 'is', 'try', 'recover', 'give', 'stop', 'then', 'and_then', 'return',
49
+ 'let', 'be', 'set', 'to', 'task', 'type', 'agent', 'state',
50
+ 'and', 'or', 'not', 'in', 'of', 'as', 'with',
51
+ 'spawn', 'share', 'observe', 'signal', 'wait_for',
52
+ 'analyze', 'decide', 'reason', 'generate',
53
+ 'approve', 'confirm', 'ask', 'show',
54
+ 'trace', 'log', 'measure', 'checkpoint',
55
+ 'serve', 'on', 'route', 'auth', 'errors', 'requires', 'expect', 'max_body', 'max_streams', 'stream', 'send', 'static', 'cache', 'fallback', 'cors', 'describe', 'private', 'from', 'domain', 'tls', 'redirect', 'proxy', 'host', 'paged', 'per', 'rate_limit', 'unlimited', 'none', 'mount', 'at',
56
+ 'use', 'export', 'routes', 'enum'
57
+ ];
58
+ const SECURITY = ['require', 'allow', 'deny', 'sandbox', 'intent', 'invariant', 'verify'];
59
+ const BUILTINS = ['print', 'text', 'length', 'append', 'range', 'contains', 'split', 'join', 'type_of', 'matches', 'find_all', 'capture', 'replace_re', 'fetch', 'read_file', 'write_file', 'http', 'http_get', 'http_post', 'sql', 'sql_exec', 'sql_tables', 'db_open', 'db_close', 'format_time', 'parse_time', 'date_parts', 'now', 'sleep', 'apply', 'where', 'reduce', 'collect', 'transform', 'sort_by', 'group_by', 'every', 'some', 'flatten', 'parallel_map', 'chunk', 'remember', 'recall', 'forget_memory', 'add_rule', 'check_rules', 'get_rules', 'memory_summary', 'create_progress', 'start_step', 'complete_step', 'fail_step', 'resume_point', 'progress_display', 'progress_percent', 'ok', 'created', 'not_found', 'fail', 'html', 'respond', 'render', 'page', 'heading', 'prose', 'list', 'ordered_list', 'link', 'image', 'section', 'code', 'raw', 'content'];
60
+ const NUMBER = { scope: 'number', match: /\b[0-9][0-9_]*(\.[0-9][0-9_]*)?([eE][+-]?[0-9]+)?\b/ };
61
+ const CALL = { scope: 'built_in', match: new RegExp('\\b(' + BUILTINS.join('|') + ')(?=\\s*\\()') };
62
+ // {expr} dentro de un string: termina en }, pero también ante la comilla que cierra el string o al final de la
63
+ // línea, así una llave sin cerrar no arrastra el color
64
+ const subst = (quote) => ({ scope: 'subst', begin: /\{/, end: new RegExp('\\}|(?=' + quote + ')|$'), keywords: { keyword: KEYWORDS, literal: ['true', 'false', 'nothing'] }, contains: [NUMBER, CALL] });
65
+ return {
66
+ name: 'Synsema', aliases: ['syn', 'fsyn'],
67
+ keywords: { $pattern: /[A-Za-z_][A-Za-z0-9_]*/, keyword: KEYWORDS, literal: ['true', 'false', 'nothing'] },
68
+ contains: [
69
+ hljs.COMMENT('--', '$'),
70
+ // los strings no cruzan líneas: end también en $ (hljs compila con la bandera m). Sin esto una comilla o un
71
+ // backtick sin cerrar pintaban el resto del archivo como string (visto 2026-09-05 con un ` suelto en un .syn)
72
+ { scope: 'string', begin: /"/, end: /"|$/, contains: [{ scope: 'char.escape', match: /\\./ }, subst('"')] },
73
+ { scope: 'string', begin: /`/, end: /`|$/, contains: [{ scope: 'char.escape', match: /\\./ }, subst('`')] },
74
+ { match: [/\b(task|type|agent)\b/, /\s+/, /[A-Za-z_][A-Za-z0-9_]*/], scope: { 1: 'keyword', 3: 'title.function' } },
75
+ { scope: 'keyword.security', match: new RegExp('\\b(' + SECURITY.join('|') + ')\\b') },
76
+ CALL,
77
+ NUMBER
78
+ ]
79
+ };
80
+ }
81
+ // Prisma (schema.prisma): highlight.js no lo trae. Bloques model/enum/datasource/generator, tipos escalares,
82
+ // atributos @id/@@map, env("…"), comentarios // y ///.
83
+ function prismaGrammar(hljs) {
84
+ return {
85
+ name: 'Prisma', aliases: ['prisma'],
86
+ keywords: {
87
+ keyword: ['model', 'enum', 'type', 'view', 'datasource', 'generator'],
88
+ built_in: ['String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Unsupported'],
89
+ literal: ['true', 'false', 'null']
90
+ },
91
+ contains: [
92
+ hljs.COMMENT('///', '$'), hljs.COMMENT('//', '$'),
93
+ { scope: 'string', begin: /"/, end: /"|$/, contains: [{ scope: 'char.escape', match: /\\./ }] },
94
+ { match: [/\b(model|enum|type|view|datasource|generator)\b/, /\s+/, /[A-Za-z_]\w*/], scope: { 1: 'keyword', 3: 'title.class' } },
95
+ { scope: 'attribute', match: /@@?[A-Za-z_][\w.]*/ },
96
+ { scope: 'built_in', match: /\benv(?=\()/ },
97
+ { scope: 'number', match: /\b\d+(\.\d+)?\b/ }
98
+ ]
99
+ };
100
+ }
101
+ if (typeof hljs !== 'undefined') { hljs.registerLanguage('synsema', synsemaGrammar); hljs.registerLanguage('prisma', prismaGrammar); }
102
+
103
+ // ---- estado ----
104
+ const ED = { path: '', loaded: null, lang: 'plaintext', dirty: false, readonly: false, saving: false };
105
+ const drafts = new Map(); // path → texto sin guardar (al cambiar de archivo no se pierde nada)
106
+ const eta = $('#eta'), ehl = $('#ehl'), egut = $('#egut'), edBox = $('#editor');
107
+ const HL_MAX = 120000; // más que esto se muestra sin resaltar (hljs tardaría en cada tecla)
108
+ const HL_SYNC_MAX = 30000; // hasta acá el espejo se redibuja en el mismo tick que la tecla; después con debounce corto
109
+
110
+ function edVisible() { return !edBox.hidden && $('#viewer').style.display !== 'none' && !!ED.path; }
111
+ function edDirty() { return ED.dirty; }
112
+
113
+ // abre la respuesta de GET /api/file en el editor (tree.js openFile). Un borrador guardado gana al disco.
114
+ function edOpen(d) {
115
+ edStash();
116
+ ED.path = d.path; ED.loaded = String(d.content ?? ''); ED.lang = langOf(d.path); ED.saving = false;
117
+ const binary = /\u0000/.test(ED.loaded);
118
+ ED.readonly = !!d.truncated || binary;
119
+ const draft = drafts.get(ED.path);
120
+ eta.value = !ED.readonly && draft != null && draft !== ED.loaded ? draft : ED.loaded;
121
+ ED.dirty = eta.value !== ED.loaded;
122
+ eta.readOnly = ED.readonly;
123
+ $('#vbody').hidden = true; edBox.hidden = false; $('#viewer').classList.add('edit');
124
+ edRender(); edMeta(d.truncated ? 'archivo muy grande · solo lectura' : binary ? 'binario · solo lectura' : '');
125
+ eta.scrollTop = 0; eta.scrollLeft = 0; edSync();
126
+ if (!ED.readonly) eta.focus({ preventScroll: true });
127
+ }
128
+ function edStash() { if (!ED.path) return; if (ED.dirty) drafts.set(ED.path, eta.value); else drafts.delete(ED.path); }
129
+ // cierra el editor (✕ del visor, tras confirmar si había cambios); el visor vuelve a su <pre> de texto
130
+ function edClose(discard) {
131
+ if (discard) drafts.delete(ED.path); else edStash();
132
+ ED.path = ''; ED.loaded = null; ED.dirty = false;
133
+ edBox.hidden = true; $('#vbody').hidden = false; $('#viewer').classList.remove('edit');
134
+ }
135
+ // el visor va a mostrar texto plano (logs de procesos/agentes, showText): el editor se esconde sin perder borradores
136
+ function viewerText() { if (!edBox.hidden) edClose(false); }
137
+
138
+ function edRender() {
139
+ const src = eta.value; let html;
140
+ if (src.length > HL_MAX || ED.lang === 'plaintext' || typeof hljs === 'undefined') html = esc(src);
141
+ else { try { html = hljs.highlight(src, { language: ED.lang, ignoreIllegals: true }).value; } catch (e) { html = esc(src); } }
142
+ // el espacio final: un <pre> no pinta la última línea vacía, el textarea sí; así ambos miden lo mismo
143
+ ehl.firstElementChild.innerHTML = html + '\n ';
144
+ const n = src.split('\n').length;
145
+ if (egut.childElementCount !== n) { let g = ''; for (let i = 1; i <= n; i++) g += '<span>' + i + '</span>'; egut.innerHTML = g; }
146
+ }
147
+ function edSync() { ehl.scrollTop = eta.scrollTop; ehl.scrollLeft = eta.scrollLeft; egut.style.transform = 'translateY(' + (-eta.scrollTop) + 'px)'; }
148
+ function edMeta(note, bad) {
149
+ const n = eta.value.split('\n').length;
150
+ const parts = [ED.lang === 'plaintext' ? 'texto' : ED.lang, n + (n === 1 ? ' línea' : ' líneas')];
151
+ if (ED.dirty) parts.push('<b class="dirty">● sin guardar</b>');
152
+ if (note) parts.push(`<span class="${bad ? 'err' : 'note'}">${esc(note)}</span>`);
153
+ $('#vmeta').innerHTML = parts.join(' · ');
154
+ const b = $('#vsave'); b.hidden = ED.readonly; b.disabled = !ED.dirty || ED.saving;
155
+ }
156
+
157
+ // ---- guardar ----
158
+ async function edSave(force) {
159
+ if (!edVisible() || ED.readonly || ED.saving) return;
160
+ const content = eta.value, path = ED.path;
161
+ if (!ED.dirty && !force) return;
162
+ ED.saving = true; edMeta('guardando…');
163
+ try {
164
+ if (!force) { // ¿cambió en disco desde que lo abrimos? (el agente pudo escribirlo mientras tanto)
165
+ const r0 = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d0 = await r0.json();
166
+ if (r0.ok && String(d0.content) !== ED.loaded) { ED.saving = false; edConflict(); return; }
167
+ }
168
+ const r = await api(BASE + '/api/fs', { op: 'write', path, content });
169
+ if (!r.ok) { ED.saving = false; edMeta(r.data.error || 'no se pudo guardar', true); return; }
170
+ if (ED.path === path) { ED.loaded = content; ED.dirty = eta.value !== content; } // el usuario pudo seguir tipeando
171
+ drafts.delete(path); ED.saving = false;
172
+ edMeta('guardado'); setTimeout(() => { if (ED.path === path && !ED.dirty) edMeta(); }, 1800);
173
+ if (typeof treeChanged === 'function') treeChanged();
174
+ } catch (e) { ED.saving = false; edMeta('no se pudo guardar: ' + e.message, true); }
175
+ }
176
+ function edConflict() {
177
+ $('#vmeta').innerHTML = `<span class="warn">cambió en disco mientras lo editabas</span> · <a href="#" id="edover">sobrescribir</a> · <a href="#" id="edreload">recargar (pierde tus cambios)</a>`;
178
+ $('#edover').onclick = ev => { ev.preventDefault(); edSave(true); };
179
+ $('#edreload').onclick = ev => { ev.preventDefault(); drafts.delete(ED.path); ED.dirty = false; openFile(ED.path); };
180
+ }
181
+ // una tool del agente pudo tocar el archivo abierto (chat.js): sin cambios locales se recarga solo conservando el
182
+ // scroll; con cambios locales se avisa y el conflicto se resuelve al guardar
183
+ async function edTouched(path) {
184
+ if (!edVisible()) return;
185
+ const p = path ? String(path).replace(/\\/g, '/').replace(/^\.\//, '') : '';
186
+ if (p && p !== ED.path) return;
187
+ try {
188
+ const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(ED.path)); const d = await r.json();
189
+ if (!r.ok || String(d.content) === ED.loaded) return;
190
+ if (ED.dirty) { edMeta('el agente cambió este archivo en disco · al guardar podés sobrescribir o recargar', true); return; }
191
+ const st = eta.scrollTop, sl = eta.scrollLeft;
192
+ ED.loaded = String(d.content); eta.value = ED.loaded; ED.readonly = !!d.truncated; eta.readOnly = ED.readonly;
193
+ edRender(); edMeta('actualizado: el agente lo cambió'); eta.scrollTop = st; eta.scrollLeft = sl; edSync();
194
+ setTimeout(() => { if (!ED.dirty) edMeta(); }, 2500);
195
+ } catch (e) {}
196
+ }
197
+
198
+ // ---- teclado y eventos ----
199
+ function edInsert(t) {
200
+ if (!document.execCommand || !document.execCommand('insertText', false, t)) { // execCommand conserva el undo del navegador
201
+ eta.setRangeText(t, eta.selectionStart, eta.selectionEnd, 'end'); eta.dispatchEvent(new Event('input'));
202
+ }
203
+ }
204
+ eta.addEventListener('input', () => {
205
+ ED.dirty = eta.value !== ED.loaded;
206
+ if (eta.value.length > HL_SYNC_MAX) debounce('edhl', edRender, 40); else edRender();
207
+ edMeta(); edSync();
208
+ });
209
+ eta.addEventListener('scroll', edSync);
210
+ eta.addEventListener('keydown', ev => {
211
+ if (ED.readonly) return;
212
+ if (ev.key === 'Tab' && !ev.ctrlKey && !ev.altKey) {
213
+ ev.preventDefault();
214
+ const v = eta.value, s = eta.selectionStart, e = eta.selectionEnd;
215
+ if (s === e || !v.slice(s, e).includes('\n')) { if (!ev.shiftKey) edInsert(' '); return; } // 4 espacios: la indentación de Synsema
216
+ // varias líneas seleccionadas: indentar / desindentar el bloque
217
+ const ls = v.lastIndexOf('\n', s - 1) + 1; let le = v.indexOf('\n', e - 1); if (le < 0) le = v.length;
218
+ const lines = v.slice(ls, le).split('\n');
219
+ const out = lines.map(l => ev.shiftKey ? l.replace(/^( |\t| {1,3})/, '') : ' ' + l).join('\n');
220
+ eta.setSelectionRange(ls, le); edInsert(out); eta.setSelectionRange(ls, ls + out.length);
221
+ } else if (ev.key === 'Enter' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
222
+ ev.preventDefault();
223
+ const v = eta.value, s = eta.selectionStart; const ls = v.lastIndexOf('\n', s - 1) + 1;
224
+ const ind = (v.slice(ls, s).match(/^[ \t]*/) || [''])[0];
225
+ edInsert('\n' + ind); // conserva la indentación de la línea anterior
226
+ } else if (ev.key === 'Escape') { eta.blur(); }
227
+ });
228
+ document.addEventListener('keydown', ev => {
229
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && ev.key.toLowerCase() === 's' && edVisible()) { ev.preventDefault(); edSave(); }
230
+ });
231
+ $('#vsave').onclick = () => edSave();
232
+ window.addEventListener('beforeunload', ev => { if (ED.dirty || drafts.size) { ev.preventDefault(); ev.returnValue = ''; } });
@@ -0,0 +1,78 @@
1
+ // login.js — /login (passkey de un dispositivo ya emparejado) y /pair#<código> (registrar una passkey nueva).
2
+ // Los binarios de WebAuthn viajan en base64url: el server manda challenge / user.id / ids así, y recibe la
3
+ // credencial en la forma de PublicKeyCredential.toJSON() (armada a mano: toJSON no está en todos los Safari).
4
+ const $ = s => document.querySelector(s);
5
+ const b64uToBuf = (s) => { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; };
6
+ const bufToB64u = (buf) => { const u = new Uint8Array(buf); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); };
7
+ function say(text, kind) { const m = $('#msg'); m.textContent = text || ''; m.className = 'msg' + (kind ? ' ' + kind : ''); }
8
+ async function post(path, body) { const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }); let data = {}; try { data = await r.json(); } catch (e) {} return { ok: r.ok, status: r.status, data }; }
9
+ function credJSON(c) {
10
+ const r = c.response, out = { id: c.id, rawId: bufToB64u(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults ? c.getClientExtensionResults() : {}, response: { clientDataJSON: bufToB64u(r.clientDataJSON) } };
11
+ if (c.authenticatorAttachment) out.authenticatorAttachment = c.authenticatorAttachment;
12
+ if (r.attestationObject) { out.response.attestationObject = bufToB64u(r.attestationObject); if (r.getTransports) out.response.transports = r.getTransports(); }
13
+ if (r.authenticatorData) out.response.authenticatorData = bufToB64u(r.authenticatorData);
14
+ if (r.signature) out.response.signature = bufToB64u(r.signature);
15
+ if (r.userHandle) out.response.userHandle = bufToB64u(r.userHandle);
16
+ return out;
17
+ }
18
+ function why(e) {
19
+ if (!e) return 'algo falló';
20
+ if (e.name === 'NotAllowedError') return 'cancelado o sin respuesta — volvé a intentar';
21
+ if (e.name === 'InvalidStateError') return 'este dispositivo ya tiene una passkey de lampson para este dominio — entrá desde /login';
22
+ if (e.name === 'SecurityError') return 'el navegador rechazó el dominio: las passkeys necesitan https (o localhost)';
23
+ return (e.name ? e.name + ': ' : '') + (e.message || String(e));
24
+ }
25
+ function guessName() {
26
+ const ua = navigator.userAgent;
27
+ const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : 'dispositivo';
28
+ const br = /EdgA?\//.test(ua) ? 'Edge' : /SamsungBrowser/.test(ua) ? 'Samsung' : /CriOS|Chrome\//.test(ua) ? 'Chrome' : /FxiOS|Firefox\//.test(ua) ? 'Firefox' : /Safari\//.test(ua) ? 'Safari' : '';
29
+ return br ? os + ' · ' + br : os;
30
+ }
31
+ const next = () => { const n = new URLSearchParams(location.search).get('next'); return n && n.startsWith('/') && !n.startsWith('//') ? n : '/'; };
32
+ const supported = () => window.isSecureContext && window.PublicKeyCredential && navigator.credentials;
33
+
34
+ async function doPair(code) {
35
+ const btn = $('#doPair'); btn.disabled = true; say('preparando…');
36
+ try {
37
+ const o = await post('/api/auth/register/options', { code, name: $('#devName').value.trim() || guessName() });
38
+ if (!o.ok) { say(o.data.error || ('error ' + o.status), 'err'); btn.disabled = false; return; }
39
+ const pk = o.data.publicKey;
40
+ pk.challenge = b64uToBuf(pk.challenge); pk.user.id = b64uToBuf(pk.user.id);
41
+ say('seguí las instrucciones del dispositivo…');
42
+ const cred = await navigator.credentials.create({ publicKey: pk });
43
+ const r = await post('/api/auth/register', { code, challenge_id: o.data.challenge_id, name: $('#devName').value.trim() || guessName(), credential: credJSON(cred) });
44
+ if (!r.ok) { say(r.data.error || ('error ' + r.status), 'err'); btn.disabled = false; return; }
45
+ say('listo · entrando…', 'ok');
46
+ history.replaceState(null, '', '/pair'); // el código ya no sirve: que no quede en el historial
47
+ setTimeout(() => { location.href = '/'; }, 500);
48
+ } catch (e) { say(why(e), 'err'); btn.disabled = false; }
49
+ }
50
+
51
+ async function doLogin() {
52
+ const btn = $('#doLogin'); btn.disabled = true; say('');
53
+ try {
54
+ const o = await post('/api/auth/login/options', {});
55
+ if (!o.ok) { say(o.data.error || ('error ' + o.status), 'err'); btn.disabled = false; return; }
56
+ if (!o.data.known) { say('no hay dispositivos emparejados para este dominio: emparejá este desde la PC', 'err'); btn.disabled = false; return; }
57
+ const pk = o.data.publicKey;
58
+ pk.challenge = b64uToBuf(pk.challenge); pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ type: c.type, id: b64uToBuf(c.id) }));
59
+ const cred = await navigator.credentials.get({ publicKey: pk });
60
+ const r = await post('/api/auth/login', { challenge_id: o.data.challenge_id, credential: credJSON(cred) });
61
+ if (!r.ok) { say(r.data.error || ('error ' + r.status), 'err'); btn.disabled = false; return; }
62
+ say('entrando…', 'ok'); location.href = next();
63
+ } catch (e) { say(why(e), 'err'); btn.disabled = false; }
64
+ }
65
+
66
+ (async () => {
67
+ const code = location.pathname === '/pair' ? decodeURIComponent(location.hash.slice(1)) : '';
68
+ if (code) {
69
+ $('#pair').hidden = false; $('#devName').value = guessName();
70
+ $('#doPair').onclick = () => doPair(code);
71
+ } else {
72
+ // ¿ya adentro? (esta PC o una sesión viva) → directo al hub
73
+ try { const me = await (await fetch('/api/auth/me')).json(); if (me.authenticated) { location.href = next(); return; } } catch (e) {}
74
+ $('#login').hidden = false; $('#doLogin').onclick = doLogin;
75
+ if (location.pathname === '/pair') say('este link no trae código: escaneá el QR de nuevo', 'err');
76
+ }
77
+ if (!supported()) { say('este navegador no puede usar passkeys aquí: hace falta https (el túnel) y un navegador actual', 'err'); document.querySelectorAll('button.primary').forEach(b => b.disabled = true); }
78
+ })();
@@ -44,6 +44,6 @@ async function openProc(name) {
44
44
  async function refreshProcViewer(name) {
45
45
  if (procOpen !== name || $('#viewer').style.display === 'none') { clearInterval(procTimer); return; }
46
46
  const x = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
47
- $('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
47
+ viewerText(); $('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
48
48
  if (!x.running) { clearInterval(procTimer); loadProcs(); }
49
49
  }
@@ -0,0 +1,5 @@
1
+ // pwa.js — registra el service worker (instalable en el celular y en el escritorio). Solo en contexto seguro:
2
+ // https (túnel, VPS) o localhost; por http en la LAN el navegador no lo permite y la app sigue como sitio.
3
+ if ('serviceWorker' in navigator && window.isSecureContext) {
4
+ window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {}); });
5
+ }
package/public/js/tree.js CHANGED
@@ -1,5 +1,17 @@
1
- // tree.js — árbol de archivos (panel derecho) con estado git, y el visor de archivos
1
+ // tree.js — árbol de archivos (panel derecho) con estado git, el visor de archivos y el explorador:
2
+ // clic derecho = menú contextual (nuevo archivo/carpeta, abrir, renombrar, duplicar, copiar/cortar/pegar,
3
+ // copiar ruta relativa/absoluta, insertar la ruta en el chat, eliminar), arrastrar y soltar para mover
4
+ // (Ctrl al soltar = copiar; sobre un archivo = a su carpeta), F2 / Supr / Ctrl+C·X·V sobre la fila
5
+ // seleccionada. Las operaciones van a POST /api/fs (lib/fs.syn) y nunca pisan un destino existente.
6
+ // Las carpetas abiertas se recuerdan (localStorage lampson.tree.open) para que un cambio no las cierre.
2
7
  let gitStatus = { changes: {} };
8
+ let treeRoot = ''; // ruta absoluta del workspace (para «copiar ruta absoluta»)
9
+ let treeSel = null; // {path, is_dir, row}: última fila clickeada (izquierdo o derecho)
10
+ let treeClip = null; // {path, is_dir, op: 'copy'|'cut'}
11
+ let treeDrag = null; // {path, is_dir} mientras se arrastra
12
+ const openDirs = new Set((() => { try { return JSON.parse(localStorage.getItem('lampson.tree.open') || '[]'); } catch (e) { return []; } })());
13
+ function saveOpen() { try { localStorage.setItem('lampson.tree.open', JSON.stringify([...openDirs])); } catch (e) {} }
14
+
3
15
  async function loadGit() {
4
16
  try { gitStatus = await (await fetch(BASE + '/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
5
17
  const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
@@ -17,37 +29,235 @@ function gitClass(path, isDir) {
17
29
  if (!isDir) { const c = ch[path]; return c === '??' ? 'gU' : c === 'A' ? 'gA' : c === 'D' ? 'gD' : c ? 'gM' : ''; }
18
30
  const p = path + '/'; for (const k in ch) if (k.startsWith(p)) return 'gM'; return '';
19
31
  }
32
+ function parentOf(p) { const i = p.lastIndexOf('/'); return i < 0 ? '.' : p.slice(0, i); }
33
+ function baseOf(p) { return p.slice(p.lastIndexOf('/') + 1); }
34
+ function cssEsc(s) { return (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'); }
35
+ function rowOf(path) { return $('#tree').querySelector(`.row[data-path="${cssEsc(path)}"]`); }
36
+
20
37
  async function loadTree() {
21
38
  await loadGit();
22
- const box = $('#tree'); box.innerHTML = '';
39
+ const box = $('#tree'); box.innerHTML = ''; box.tabIndex = 0;
23
40
  let t; try { t = await (await fetch(BASE + '/api/tree')).json(); } catch (e) { box.innerHTML = '<div class="row none">sin árbol</div>'; return; }
24
- const render = (entries, parent, depth) => {
41
+ treeRoot = t.root || '';
42
+ const render = (entries, parent) => {
25
43
  for (const e of entries) {
26
44
  const n = document.createElement('div'); n.className = 'node';
27
45
  const gc = gitClass(e.path, e.is_dir); const code = (gitStatus.changes || {})[e.path];
28
- const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : ''); r.title = e.path + (code ? ' · git ' + code : '');
29
- r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span>${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
46
+ const cut = treeClip && treeClip.op === 'cut' && treeClip.path === e.path;
47
+ const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : '') + (cut ? ' cut' : '');
48
+ r.title = e.path + (code ? ' · git ' + code : ''); r.dataset.path = e.path; r.dataset.dir = e.is_dir ? '1' : '';
49
+ r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span class="nm">${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
30
50
  n.appendChild(r);
51
+ wireRow(r, e);
31
52
  if (e.is_dir) {
32
- const k = document.createElement('div'); k.className = 'kids hidden'; render(e.children || [], k, depth + 1); n.appendChild(k);
33
- r.querySelector('.tw').textContent = '▸';
34
- r.onclick = () => { k.classList.toggle('hidden'); r.querySelector('.tw').textContent = k.classList.contains('hidden') ? '▸' : '▾'; };
35
- } else { r.onclick = () => openFile(e.path, r); }
53
+ const open = openDirs.has(e.path);
54
+ const k = document.createElement('div'); k.className = 'kids' + (open ? '' : ' hidden'); render(e.children || [], k); n.appendChild(k);
55
+ r.querySelector('.tw').textContent = open ? '▾' : '▸';
56
+ r.onclick = () => { select(r, e); toggleDir(r, k, e.path); };
57
+ } else { r.onclick = () => { select(r, e); openFile(e.path, r); }; }
36
58
  parent.appendChild(n);
37
59
  }
38
60
  };
39
- render(t.entries || [], box, 0);
61
+ render(t.entries || [], box);
40
62
  if (t.truncated) { const d = document.createElement('div'); d.className = 'row none'; d.textContent = '… árbol truncado'; box.appendChild(d); }
63
+ if (treeSel) { const r = rowOf(treeSel.path); if (r) { treeSel.row = r; r.classList.add('sel'); } else treeSel = null; }
64
+ const vp = $('#vpath').textContent; const ar = vp && rowOf(vp); if (ar && $('#viewer').style.display !== 'none') ar.classList.add('active');
41
65
  }
66
+ function toggleDir(r, k, path) {
67
+ k.classList.toggle('hidden'); const open = !k.classList.contains('hidden');
68
+ r.querySelector('.tw').textContent = open ? '▾' : '▸';
69
+ if (open) openDirs.add(path); else openDirs.delete(path); saveOpen();
70
+ }
71
+ function expandDir(path) { if (!path || path === '.') return; openDirs.add(path); saveOpen(); }
72
+ function select(r, e) {
73
+ document.querySelectorAll('#tree .row.sel').forEach(x => x.classList.remove('sel'));
74
+ r.classList.add('sel'); treeSel = { path: e.path, is_dir: e.is_dir, row: r };
75
+ }
76
+
77
+ // ---- arrastrar y soltar: mover (Ctrl = copiar); sobre un archivo = a la carpeta del archivo ----
78
+ function canDrop(d, dir) {
79
+ if (!d) return false;
80
+ if (parentOf(d.path) === dir) return false; // ya está ahí
81
+ if (d.is_dir && (dir === d.path || dir.startsWith(d.path + '/'))) return false; // carpeta dentro de sí misma
82
+ return true;
83
+ }
84
+ function wireRow(r, e) {
85
+ r.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); select(r, e); showMenu(ev.clientX, ev.clientY, { path: e.path, is_dir: e.is_dir, row: r }); };
86
+ r.draggable = true;
87
+ r.ondragstart = ev => { treeDrag = { path: e.path, is_dir: e.is_dir }; ev.dataTransfer.effectAllowed = 'copyMove'; ev.dataTransfer.setData('text/plain', e.path); r.classList.add('dragging'); };
88
+ r.ondragend = () => { treeDrag = null; r.classList.remove('dragging'); document.querySelectorAll('#tree .drop').forEach(x => x.classList.remove('drop')); };
89
+ // soltar sobre una carpeta = dentro de ella; sobre un archivo = en la carpeta del archivo (se ilumina esa)
90
+ const dir = e.is_dir ? e.path : parentOf(e.path);
91
+ const lit = () => (e.is_dir ? r : (rowOf(dir) || $('#tree')));
92
+ r.ondragover = ev => { if (!canDrop(treeDrag, dir)) return; ev.preventDefault(); ev.stopPropagation(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; lit().classList.add('drop'); };
93
+ r.ondragleave = () => lit().classList.remove('drop');
94
+ r.ondrop = ev => { ev.preventDefault(); ev.stopPropagation(); document.querySelectorAll('#tree .drop, #tree.drop').forEach(x => x.classList.remove('drop')); dropInto(dir, ev.ctrlKey); };
95
+ }
96
+ async function dropInto(dir, copy) {
97
+ const d = treeDrag; treeDrag = null;
98
+ if (!canDrop(d, dir)) return;
99
+ await fsOp(copy ? { op: 'copy', path: d.path, to: dir } : { op: 'move', path: d.path, to: dir }, res => { expandDir(dir); if (!copy) afterMove(d.path, res.path); });
100
+ }
101
+ {
102
+ const box = $('#tree');
103
+ box.ondragover = ev => { if (!canDrop(treeDrag, '.')) return; ev.preventDefault(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; box.classList.add('drop'); };
104
+ box.ondragleave = () => box.classList.remove('drop');
105
+ box.ondrop = ev => { ev.preventDefault(); box.classList.remove('drop'); dropInto('.', ev.ctrlKey); };
106
+ box.oncontextmenu = ev => { if (ev.target.closest('.row')) return; ev.preventDefault(); showMenu(ev.clientX, ev.clientY, null); };
107
+ box.onkeydown = ev => {
108
+ if (ev.target.tagName === 'INPUT' || !treeSel) return;
109
+ const s = treeSel; const key = ev.key.toLowerCase();
110
+ if (ev.key === 'F2') { ev.preventDefault(); renameInline(s); }
111
+ else if (ev.key === 'Delete') { ev.preventDefault(); const b = s.row.getBoundingClientRect(); showMenu(b.left + 60, b.bottom, s, 'delete'); }
112
+ else if (ev.ctrlKey && key === 'c') { ev.preventDefault(); setClip(s, 'copy'); }
113
+ else if (ev.ctrlKey && key === 'x') { ev.preventDefault(); setClip(s, 'cut'); }
114
+ else if (ev.ctrlKey && key === 'v') { ev.preventDefault(); pasteInto(s.is_dir ? s.path : parentOf(s.path)); }
115
+ else if (ev.key === 'Escape') { closeMenu(); }
116
+ };
117
+ }
118
+
119
+ // ---- operaciones (POST /api/fs) ----
120
+ async function fsOp(body, onOk) {
121
+ const r = await api(BASE + '/api/fs', body);
122
+ if (!r.ok) { add('denied', esc((r.data && r.data.error) || 'error')); loadTree(); return null; }
123
+ const res = r.data.result || {};
124
+ if (onOk) onOk(res);
125
+ await loadTree();
126
+ return res;
127
+ }
128
+ function afterMove(oldPath, newPath) {
129
+ if (treeClip && treeClip.path === oldPath) treeClip = null;
130
+ if (treeSel && treeSel.path === oldPath) treeSel.path = newPath;
131
+ const vp = $('#vpath'); const v = vp.textContent;
132
+ if (v === oldPath || v.startsWith(oldPath + '/')) vp.textContent = newPath + v.slice(oldPath.length);
133
+ }
134
+ function afterDelete(path) {
135
+ if (treeClip && treeClip.path === path) treeClip = null;
136
+ if (treeSel && (treeSel.path === path || treeSel.path.startsWith(path + '/'))) treeSel = null;
137
+ const v = $('#vpath').textContent; if (v === path || v.startsWith(path + '/')) { $('#vpath').textContent = ''; showPane('log'); }
138
+ }
139
+ function setClip(s, op) { treeClip = { path: s.path, is_dir: s.is_dir, op }; loadTree(); }
140
+ function canPaste(dir) {
141
+ if (!treeClip) return false;
142
+ if (treeClip.op === 'cut') return canDrop(treeClip, dir);
143
+ return !(treeClip.is_dir && (dir === treeClip.path || dir.startsWith(treeClip.path + '/')));
144
+ }
145
+ async function pasteInto(dir) {
146
+ if (!canPaste(dir)) return;
147
+ const c = treeClip;
148
+ await fsOp(c.op === 'cut' ? { op: 'move', path: c.path, to: dir } : { op: 'copy', path: c.path, to: dir }, res => { expandDir(dir); if (c.op === 'cut') { afterMove(c.path, res.path); treeClip = null; } });
149
+ }
150
+ function absPath(path) {
151
+ if (!treeRoot) return path;
152
+ const sep = treeRoot.includes('\\') ? '\\' : '/';
153
+ const root = treeRoot.replace(/[\\/]+$/, '');
154
+ return path === '.' ? root : root + sep + path.split('/').join(sep);
155
+ }
156
+ function copyText(t, row) {
157
+ const done = () => { if (row) { row.classList.add('flash'); setTimeout(() => row.classList.remove('flash'), 700); } };
158
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, () => copyFallback(t, done)); else copyFallback(t, done);
159
+ }
160
+ function copyFallback(t, done) { const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); done(); }
161
+ function insertInChat(path) {
162
+ const ta = $('#in'); const v = ta.value;
163
+ ta.value = v + (v && !/\s$/.test(v) ? ' ' : '') + path + ' ';
164
+ ta.focus(); ta.dispatchEvent(new Event('input', { bubbles: true }));
165
+ }
166
+
167
+ // ---- nuevo archivo / carpeta y renombrar: un input en la propia fila ----
168
+ function nameInput(r, value, onDone) {
169
+ const nm = r.querySelector('.nm'); const inp = document.createElement('input'); inp.className = 'rn'; inp.value = value; inp.spellcheck = false;
170
+ nm.replaceWith(inp); inp.focus();
171
+ const dot = value.startsWith('.') ? -1 : value.lastIndexOf('.'); inp.setSelectionRange(0, dot > 0 ? dot : value.length);
172
+ let finished = false;
173
+ const finish = ok => { if (finished) return; finished = true; onDone(ok ? inp.value.trim() : null); };
174
+ inp.onkeydown = ev => { ev.stopPropagation(); if (ev.key === 'Enter') { ev.preventDefault(); finish(true); } else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); } };
175
+ inp.onblur = () => finish(false);
176
+ inp.onclick = ev => ev.stopPropagation(); inp.oncontextmenu = ev => ev.stopPropagation();
177
+ }
178
+ function newEntry(dir, isDir) {
179
+ closeMenu();
180
+ const box = $('#tree');
181
+ let holder = box;
182
+ if (dir !== '.') {
183
+ const r = rowOf(dir); if (!r) return;
184
+ const k = r.parentNode.querySelector('.kids'); if (k.classList.contains('hidden')) toggleDir(r, k, dir);
185
+ holder = k;
186
+ }
187
+ const n = document.createElement('div'); n.className = 'node';
188
+ const r = document.createElement('div'); r.className = 'row new' + (isDir ? ' dir' : ''); r.innerHTML = `<span class="tw">${isDir ? '▸' : ''}</span><span class="nm"></span>`;
189
+ n.appendChild(r); holder.insertBefore(n, holder.firstChild);
190
+ nameInput(r, '', name => {
191
+ n.remove();
192
+ if (!name) return;
193
+ fsOp({ op: isDir ? 'mkdir' : 'create', path: dir, name }, res => { expandDir(dir); treeSel = { path: res.path, is_dir: !!res.is_dir }; if (!isDir) openFile(res.path); });
194
+ });
195
+ }
196
+ function renameInline(s) {
197
+ closeMenu();
198
+ const r = s.row || rowOf(s.path); if (!r) return;
199
+ const old = baseOf(s.path);
200
+ nameInput(r, old, name => {
201
+ if (!name || name === old) { loadTree(); return; }
202
+ fsOp({ op: 'rename', path: s.path, name }, res => afterMove(s.path, res.path));
203
+ });
204
+ }
205
+
206
+ // ---- menú contextual ----
207
+ let ctxEl = null;
208
+ function closeMenu() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
209
+ document.addEventListener('mousedown', ev => { if (ctxEl && !ctxEl.contains(ev.target)) closeMenu(); }, true);
210
+ document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closeMenu(); });
211
+ window.addEventListener('blur', closeMenu); window.addEventListener('resize', closeMenu);
212
+ $('#tree').addEventListener('scroll', closeMenu);
213
+ // e = {path, is_dir, row} o null (raíz); only = 'delete' abre solo la confirmación de borrado (tecla Supr)
214
+ function showMenu(x, y, e, only) {
215
+ closeMenu();
216
+ const isRoot = !e; const path = isRoot ? '.' : e.path; const isDir = isRoot || e.is_dir; const row = e && e.row;
217
+ const dir = isDir ? path : parentOf(path); // carpeta donde se crea o se pega
218
+ const items = [];
219
+ const del = { l: 'Eliminar', k: 'Supr', danger: true, confirm: '¿eliminar ' + baseOf(path) + (isDir ? '/' : '') + '?', f: () => fsOp({ op: 'delete', path }, () => afterDelete(path)) };
220
+ if (only === 'delete') items.push(del);
221
+ else {
222
+ if (!isDir) items.push({ l: 'Abrir', f: () => openFile(path, row) });
223
+ items.push({ l: 'Nuevo archivo…', f: () => newEntry(dir, false) }, { l: 'Nueva carpeta…', f: () => newEntry(dir, true) }, 'sep');
224
+ if (!isRoot) items.push({ l: 'Renombrar', k: 'F2', f: () => renameInline(e) }, { l: 'Duplicar', f: () => fsOp({ op: 'copy', path }) }, 'sep', { l: 'Copiar', k: 'Ctrl+C', f: () => setClip(e, 'copy') }, { l: 'Cortar', k: 'Ctrl+X', f: () => setClip(e, 'cut') });
225
+ items.push({ l: 'Pegar' + (treeClip ? ' «' + baseOf(treeClip.path) + '»' : ''), k: 'Ctrl+V', dis: !canPaste(dir), f: () => pasteInto(dir) }, 'sep');
226
+ items.push({ l: 'Copiar ruta', f: () => copyText(path, row) }, { l: 'Copiar ruta absoluta', dis: !treeRoot, f: () => copyText(absPath(path), row) }, { l: 'Insertar en el chat', f: () => insertInChat(path) });
227
+ if (!isRoot) items.push('sep', del);
228
+ items.push('sep', { l: 'Recargar', f: loadTree });
229
+ }
230
+ const m = document.createElement('div'); m.className = 'ctx';
231
+ for (const it of items) {
232
+ if (it === 'sep') { const s = document.createElement('div'); s.className = 'sep'; m.appendChild(s); continue; }
233
+ const d = document.createElement('div'); d.className = 'it' + (it.dis ? ' dis' : '') + (it.danger ? ' danger' : '');
234
+ d.innerHTML = `<span>${esc(it.l)}</span>` + (it.k ? `<span class="k">${esc(it.k)}</span>` : '');
235
+ d.onclick = ev => { ev.stopPropagation(); if (it.confirm) inlineConfirm(d, it.confirm, () => { closeMenu(); it.f(); }); else { closeMenu(); it.f(); } };
236
+ m.appendChild(d);
237
+ }
238
+ document.body.appendChild(m); ctxEl = m;
239
+ const W = window.innerWidth, H = window.innerHeight, mw = m.offsetWidth, mh = m.offsetHeight;
240
+ m.style.left = Math.max(4, Math.min(x, W - mw - 4)) + 'px'; m.style.top = Math.max(4, Math.min(y, H - mh - 4)) + 'px';
241
+ if (only === 'delete') { const first = m.querySelector('.it'); if (first) first.click(); }
242
+ }
243
+
244
+ // abre el archivo en el editor (js/editor.js): colores por tipo de archivo, Ctrl+S guarda. El <pre> #vbody queda para logs.
42
245
  async function openFile(path, row) {
43
246
  document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
44
247
  const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
45
- if (!r.ok) { add('denied', esc(d.error || 'error')); return; }
46
- $('#vpath').textContent = d.path; $('#vmeta').textContent = d.lines + ' líneas';
47
- $('#vbody').className = ''; $('#vbody').innerHTML = String(d.content).split('\n').map((l, i) => `<span class="ln">${i + 1}</span>${esc(l)}`).join('\n');
248
+ if (!r.ok) { viewerNotice(path, 'no se pudo abrir', String(d.error || 'error')); return; }
249
+ if (!row) { const ar = rowOf(d.path); if (ar) ar.classList.add('active'); }
250
+ if (d.binary) { viewerNotice(d.path, 'binario · ' + fmtSize(d.size), 'Este archivo no es texto (base de datos, imagen, fuente, ejecutable…): no se puede mostrar ni editar acá.' + (d.error ? '\n\n' + d.error : '')); return; }
251
+ $('#vpath').textContent = d.path; $('#vbody').className = '';
252
+ edOpen(d);
48
253
  showPane('viewer'); $('#stage').scrollTop = 0;
49
254
  }
255
+ // avisos del visor (binario, error al abrir): en el propio visor, no como mensaje en el chat
256
+ function viewerNotice(path, meta, text) { showText(path, meta, text, false); }
257
+ function fmtSize(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
50
258
  // el árbol se refresca solo cuando una tool pudo crear/borrar archivos, con un pequeño debounce; ↻ lo fuerza a mano
51
259
  function treeChanged() { debounce('tree', loadTree, 400); }
52
260
  $('#tree-reload').onclick = e => { e.stopPropagation(); const b = e.currentTarget; b.classList.remove('spin'); void b.offsetWidth; b.classList.add('spin'); loadTree(); };
53
- $('#vclose').onclick = () => { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; };
261
+ function closeViewer(discard) { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; edClose(discard); const x = $('#vclose'); x.classList.remove('ask'); x.textContent = '✕ cerrar'; }
262
+ // con cambios sin guardar, el ✕ pregunta antes (mismo patrón en línea que borrar en el árbol)
263
+ $('#vclose').onclick = () => { if (edVisible() && edDirty()) { inlineConfirm($('#vclose'), '¿descartar cambios?', () => closeViewer(true)); return; } closeViewer(false); };