@danieltmn/tamnora 0.4.1 → 0.5.0
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.
- package/CHANGELOG.md +39 -0
- package/README.md +25 -0
- package/harness/packages/core/src/index.js +1 -1
- package/harness/packages/core/src/procs/detect.js +29 -0
- package/harness/packages/core/src/providers/utils.js +8 -2
- package/harness/packages/core/test/procs.test.js +17 -0
- package/harness/packages/core/test/providers.test.js +8 -1
- package/harness/packages/server/public/app.js +252 -39
- package/harness/packages/server/public/index.html +73 -15
- package/harness/packages/server/public/styles.css +148 -14
- package/harness/packages/server/src/routes/files.js +35 -1
- package/harness/packages/server/test/server.test.js +26 -0
- package/harness/scripts/static-server.mjs +94 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-10-02
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- Preview de **sitios estáticos**: si la carpeta del proyecto tiene un `index.html`
|
|
13
|
+
(o `public/index.html`) y no hay comando de dev, `detectar` en la pestaña *procs*
|
|
14
|
+
ofrece un chip "sitio estático (8080)" que lo sirve con un servidor local sin
|
|
15
|
+
dependencias (`harness/scripts/static-server.mjs`). Desde *preview* se ve embebido
|
|
16
|
+
y con *túnel* se abre la URL pública para verlo desde el celular.
|
|
17
|
+
- El **visor de archivos** agrega un botón "vista previa": para `.html`/`.svg` sirve
|
|
18
|
+
el workspace con el servidor estático en un iframe (con sus CSS, JS e imágenes
|
|
19
|
+
relativas) y para `.md` renderiza el markdown.
|
|
20
|
+
- La vista previa (pestaña *preview* y visor de archivos) suma selector de tamaño
|
|
21
|
+
**móvil/tablet/laptop/pc/adaptable** y botón **maximizar** (con `Esc` para salir).
|
|
22
|
+
- Los botones para mostrar/ocultar los paneles laterales pasan a estar dentro de
|
|
23
|
+
cada panel: *sesiones* tiene su botón de colapsar y, al ocultarse, aparece un
|
|
24
|
+
tirador en el borde para volver a mostrarlo (igual para *archivos*).
|
|
25
|
+
- `/new` con una sesión activa abre otra sesión en la **misma carpeta**; el botón
|
|
26
|
+
"+ nueva sesión" (y `/new` sin sesión) abre el selector de carpetas.
|
|
27
|
+
- **Carpeta predeterminada** configurable en Ajustes: el selector de carpetas abre
|
|
28
|
+
ahí por defecto (fallback a la casa si la ruta ya no existe).
|
|
29
|
+
- El **pie del chat** muestra el uso de contexto del último turno (`contexto 12% ·
|
|
30
|
+
53k/1.0M`) con una **barra de porcentaje** que cambia de color (verde/ámbar/rojo).
|
|
31
|
+
|
|
32
|
+
### Cambiado
|
|
33
|
+
|
|
34
|
+
- Las pestañas (chat, proveedores, ajustes) quedan **centradas** en la barra superior.
|
|
35
|
+
`eval` y `runs` salen del topbar y quedan en el menú "más" (los tres puntitos, visible
|
|
36
|
+
en desktop), para des-énfasis en la consola; el CLI y la API siguen igual. En mobile
|
|
37
|
+
el menú "más" agrupa las cinco y muestra la pestaña activa.
|
|
38
|
+
|
|
39
|
+
### Corregido
|
|
40
|
+
|
|
41
|
+
- `normalizeModel` no leía `context_window`, el campo que usa DeepSeek para informar
|
|
42
|
+
la ventana de contexto (y que sí trae `context_length` en otros proveedores). Sin
|
|
43
|
+
esto, el footer nunca mostraba el porcentaje de contexto.
|
|
44
|
+
- "Gestionar proveedores" (Ajustes y paleta) abría una vista inexistente por usar
|
|
45
|
+
`proveedores` en vez de `providers`; ahora entran al panel correcto y lo refrescan.
|
|
46
|
+
|
|
8
47
|
## [0.4.1] - 2026-10-02
|
|
9
48
|
|
|
10
49
|
### Corregido
|
package/README.md
CHANGED
|
@@ -63,6 +63,31 @@ Tamnora (override con `--home` o `TAMNORA_HOME`):
|
|
|
63
63
|
- macOS: `~/Library/Application Support/tamnora`
|
|
64
64
|
- Linux: `~/.config/tamnora`
|
|
65
65
|
|
|
66
|
+
### Consola web
|
|
67
|
+
|
|
68
|
+
`tamnora serve` sirve la consola en `http://127.0.0.1:4517`. Las pestanas
|
|
69
|
+
principales son `chat`, `proveedores` y `ajustes`; `eval` y `runs` viven en el
|
|
70
|
+
menu "mas" (los tres puntitos). En el chat:
|
|
71
|
+
|
|
72
|
+
- **Paneles laterales** de sesiones y archivos, colapsables desde cada panel (con
|
|
73
|
+
un tirador en el borde para reabrirlos).
|
|
74
|
+
- **Pie** con modo, proveedor, modelo, estado y el **uso de contexto** del ultimo
|
|
75
|
+
turno (`contexto 44% · 464k/1.0M`) con una barra que cambia de color.
|
|
76
|
+
- **`/new`** abre otra sesion en la **misma carpeta** de la sesion activa; el boton
|
|
77
|
+
"+ nueva sesion" (y `/new` sin sesion) abre el selector de carpetas. La
|
|
78
|
+
**carpeta predeterminada** se elige en Ajustes.
|
|
79
|
+
|
|
80
|
+
### Preview
|
|
81
|
+
|
|
82
|
+
La consola puede levantar un dev server (deteccion de scripts `dev`/`start`,
|
|
83
|
+
Laravel, PHP) y abrir un tunel al puerto para verlo desde el celular. Para sitios
|
|
84
|
+
**estaticos** (un `index.html` sin script de dev) ofrece un chip "sitio estatico"
|
|
85
|
+
que los sirve con un servidor sin dependencias (`harness/scripts/static-server.mjs`).
|
|
86
|
+
|
|
87
|
+
El visor de archivos tiene **vista previa**: `.html`/`.svg` se sirven con sus CSS,
|
|
88
|
+
JS e imagenes relativas, y `.md` se renderiza como markdown. Ambas vistas suman
|
|
89
|
+
selector de tamano (movil/tablet/laptop/pc/adaptable) y boton para maximizar.
|
|
90
|
+
|
|
66
91
|
### Login
|
|
67
92
|
|
|
68
93
|
```bash
|
|
@@ -80,5 +80,5 @@ export {
|
|
|
80
80
|
normalizeMcpConfig,
|
|
81
81
|
} from './mcp/registry.js';
|
|
82
82
|
export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
|
|
83
|
-
export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from './procs/detect.js';
|
|
83
|
+
export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
|
|
84
84
|
export { readPackageVersion } from './version.js';
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// package.json, composer/artisan y entrypoints PHP.
|
|
3
3
|
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_PROCESS_ALLOW = [
|
|
@@ -20,6 +21,26 @@ export const DEFAULT_PROCESS_ALLOW = [
|
|
|
20
21
|
|
|
21
22
|
const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
|
|
22
23
|
|
|
24
|
+
const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
|
|
25
|
+
const STATIC_PORT = 8080;
|
|
26
|
+
|
|
27
|
+
function findStaticRoot(dir) {
|
|
28
|
+
if (existsSync(path.join(dir, 'index.html'))) return '.';
|
|
29
|
+
if (existsSync(path.join(dir, 'public', 'index.html'))) return 'public';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Chip para servir `dir` como sitio estático con el servidor sin dependencias. */
|
|
34
|
+
export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
|
|
35
|
+
return {
|
|
36
|
+
label: label ?? `sitio estático (${port})`,
|
|
37
|
+
command: process.execPath,
|
|
38
|
+
args: [STATIC_SERVER, dir, String(port)],
|
|
39
|
+
cwd: dir,
|
|
40
|
+
port,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
function readJson(file) {
|
|
24
45
|
try {
|
|
25
46
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -65,5 +86,13 @@ export function detectProcs(dir) {
|
|
|
65
86
|
break;
|
|
66
87
|
}
|
|
67
88
|
|
|
89
|
+
if (!chips.length) {
|
|
90
|
+
const staticRoot = findStaticRoot(dir);
|
|
91
|
+
if (staticRoot) {
|
|
92
|
+
const target = staticRoot === '.' ? dir : path.join(dir, staticRoot);
|
|
93
|
+
chips.push(staticServerChip(target));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
return chips;
|
|
69
98
|
}
|
|
@@ -99,8 +99,14 @@ export function normalizeModel(model) {
|
|
|
99
99
|
(typeof modality === 'string' && modality.includes('image')) ||
|
|
100
100
|
(Array.isArray(model.input_modalities) && model.input_modalities.includes('image'));
|
|
101
101
|
const context =
|
|
102
|
-
Number(
|
|
103
|
-
|
|
102
|
+
Number(
|
|
103
|
+
model.context_length ??
|
|
104
|
+
model.context_length_tokens ??
|
|
105
|
+
model.max_context_length ??
|
|
106
|
+
model.context_window ??
|
|
107
|
+
model.context ??
|
|
108
|
+
0
|
|
109
|
+
) || null;
|
|
104
110
|
return { id, label, vision, context };
|
|
105
111
|
}
|
|
106
112
|
|
|
@@ -32,3 +32,20 @@ test('detectProcs falls back to npm and detects php entrypoints', async () => {
|
|
|
32
32
|
assert.ok(php);
|
|
33
33
|
assert.equal(php.args[0], '-S');
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
test('detectProcs offers a static server for plain HTML sites', async () => {
|
|
37
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
|
|
38
|
+
await writeFile(path.join(dir, 'index.html'), '<h1>hola</h1>');
|
|
39
|
+
const chips = detectProcs(dir);
|
|
40
|
+
const staticChip = chips.find((c) => c.port && c.args.some((a) => a.endsWith('static-server.mjs')));
|
|
41
|
+
assert.ok(staticChip);
|
|
42
|
+
assert.equal(staticChip.cwd, dir);
|
|
43
|
+
assert.ok(staticChip.args.includes(dir));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('detectProcs does not add the static chip when there is a dev script', async () => {
|
|
47
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
|
|
48
|
+
await writeFile(path.join(dir, 'index.html'), '<h1>hola</h1>');
|
|
49
|
+
await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
|
|
50
|
+
assert.ok(!detectProcs(dir).some((c) => c.args.some((a) => String(a).endsWith('static-server.mjs'))));
|
|
51
|
+
});
|
|
@@ -3,9 +3,16 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { Store } from '../src/storage/sqlite.js';
|
|
4
4
|
import { composeProviderSettings } from '../src/config/providers.js';
|
|
5
5
|
import { toOllamaMessages } from '../src/providers/ollama.js';
|
|
6
|
-
import { providerFetch } from '../src/providers/utils.js';
|
|
6
|
+
import { providerFetch, normalizeModel } from '../src/providers/utils.js';
|
|
7
7
|
import { isRetryableError } from '../src/providers/resilience.js';
|
|
8
8
|
|
|
9
|
+
test('normalizeModel reads the context window across provider shapes', () => {
|
|
10
|
+
assert.equal(normalizeModel({ id: 'a', context_window: 1048576 }).context, 1048576);
|
|
11
|
+
assert.equal(normalizeModel({ id: 'b', context_length: 128000 }).context, 128000);
|
|
12
|
+
assert.equal(normalizeModel({ id: 'c', max_context_length: 64000 }).context, 64000);
|
|
13
|
+
assert.equal(normalizeModel({ id: 'd' }).context, null);
|
|
14
|
+
});
|
|
15
|
+
|
|
9
16
|
test('Store persists providers and settings', () => {
|
|
10
17
|
const store = new Store(':memory:');
|
|
11
18
|
try {
|
|
@@ -154,6 +154,9 @@ async function streamPost(url, body, onEvent, signal) {
|
|
|
154
154
|
|
|
155
155
|
const moreTab = $('#more-tab');
|
|
156
156
|
const moreMenu = $('#more-menu');
|
|
157
|
+
const moreTabLabel = $('#more-tab-label');
|
|
158
|
+
|
|
159
|
+
const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
157
160
|
|
|
158
161
|
function hideMoreMenu() {
|
|
159
162
|
moreMenu.hidden = true;
|
|
@@ -161,12 +164,15 @@ function hideMoreMenu() {
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
function activateTab(name) {
|
|
164
|
-
document
|
|
167
|
+
document
|
|
168
|
+
.querySelectorAll('.tab[data-tab], #more-menu button[data-tab]')
|
|
169
|
+
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
170
|
+
if (moreTabLabel) moreTabLabel.textContent = TAB_LABELS[name] || name;
|
|
165
171
|
moreTab.classList.toggle('active', name === 'eval' || name === 'runs');
|
|
166
172
|
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
167
173
|
hideMoreMenu();
|
|
168
174
|
if (name === 'runs') refreshRuns();
|
|
169
|
-
if (name === '
|
|
175
|
+
if (name === 'providers') refreshProviders();
|
|
170
176
|
if (name === 'ajustes') { refreshMcp(); refreshSkills(); }
|
|
171
177
|
}
|
|
172
178
|
|
|
@@ -193,12 +199,23 @@ document.addEventListener('keydown', (event) => {
|
|
|
193
199
|
|
|
194
200
|
const settingsModal = $('#settings-modal');
|
|
195
201
|
if (settingsModal) {
|
|
196
|
-
$('#settings-toggle')?.addEventListener('click', () =>
|
|
202
|
+
$('#settings-toggle')?.addEventListener('click', () => {
|
|
203
|
+
syncDefaultCwd();
|
|
204
|
+
settingsModal.showModal();
|
|
205
|
+
});
|
|
197
206
|
$('#settings-close')?.addEventListener('click', () => settingsModal.close());
|
|
198
207
|
$('#settings-done')?.addEventListener('click', () => settingsModal.close());
|
|
199
208
|
$('#settings-providers')?.addEventListener('click', () => {
|
|
200
209
|
settingsModal.close();
|
|
201
|
-
activateTab('
|
|
210
|
+
activateTab('providers');
|
|
211
|
+
});
|
|
212
|
+
$('#default-cwd-pick')?.addEventListener('click', () => {
|
|
213
|
+
settingsModal.close();
|
|
214
|
+
openFolderPicker('default');
|
|
215
|
+
});
|
|
216
|
+
$('#default-cwd-clear')?.addEventListener('click', () => {
|
|
217
|
+
writeDefaultCwd('');
|
|
218
|
+
syncDefaultCwd();
|
|
202
219
|
});
|
|
203
220
|
settingsModal.addEventListener('click', (event) => {
|
|
204
221
|
if (event.target === settingsModal) settingsModal.close();
|
|
@@ -213,8 +230,8 @@ const PALETTE_COMMANDS = [
|
|
|
213
230
|
{ id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
|
|
214
231
|
{ id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
|
|
215
232
|
{ id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
|
|
216
|
-
{ id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => settingsModal?.showModal() },
|
|
217
|
-
{ id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('
|
|
233
|
+
{ id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => { syncDefaultCwd(); settingsModal?.showModal(); } },
|
|
234
|
+
{ id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
|
|
218
235
|
{ id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
|
|
219
236
|
{ id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
|
|
220
237
|
{ id: 'sidebar', label: 'Mostrar u ocultar sesiones', run: () => setSidebarOpen(!state.sidebarOpen) },
|
|
@@ -514,7 +531,11 @@ function setSidebarOpen(open) {
|
|
|
514
531
|
panels.sidebar.resizer.hidden = !open;
|
|
515
532
|
if (sidebarBackdrop) sidebarBackdrop.hidden = true;
|
|
516
533
|
}
|
|
517
|
-
$('#sidebar-
|
|
534
|
+
const handle = $('#sidebar-handle');
|
|
535
|
+
if (handle) {
|
|
536
|
+
handle.hidden = open;
|
|
537
|
+
handle.setAttribute('aria-pressed', String(open));
|
|
538
|
+
}
|
|
518
539
|
if (!isMobile()) {
|
|
519
540
|
try {
|
|
520
541
|
localStorage.setItem('tamnora-sidebar-open', open ? '1' : '0');
|
|
@@ -543,7 +564,8 @@ function initLayout() {
|
|
|
543
564
|
attachResizer(panels.sidebar);
|
|
544
565
|
attachResizer(panels.explorer);
|
|
545
566
|
setSidebarOpen(isMobile() ? false : readSidebarPref());
|
|
546
|
-
$('#sidebar-
|
|
567
|
+
$('#sidebar-collapse')?.addEventListener('click', () => setSidebarOpen(false));
|
|
568
|
+
$('#sidebar-handle')?.addEventListener('click', () => setSidebarOpen(true));
|
|
547
569
|
if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', () => setSidebarOpen(false));
|
|
548
570
|
mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
|
|
549
571
|
}
|
|
@@ -558,29 +580,37 @@ function formatK(n) {
|
|
|
558
580
|
}
|
|
559
581
|
|
|
560
582
|
function renderTokens() {
|
|
583
|
+
const wrap = $('#sb-context');
|
|
561
584
|
const node = $('#sb-tokens');
|
|
585
|
+
const fill = $('#sb-context-fill');
|
|
562
586
|
const record = state.panes.get(state.sessionId);
|
|
563
587
|
const tokens = record?.tokens ?? { in: 0, out: 0 };
|
|
564
588
|
const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
|
|
565
589
|
const total = tokens.in + tokens.out;
|
|
590
|
+
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
591
|
+
const used = lastTurn.prompt + lastTurn.completion || total;
|
|
592
|
+
const pct = context ? Math.min(100, Math.round((used / context) * 100)) : 0;
|
|
593
|
+
const tone = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
|
|
594
|
+
|
|
566
595
|
if (!total) {
|
|
567
|
-
|
|
568
|
-
node.
|
|
596
|
+
if (wrap) wrap.className = 'ctx muted';
|
|
597
|
+
node.textContent = 'tokens —';
|
|
569
598
|
node.title = '';
|
|
599
|
+
if (fill) fill.style.width = '0%';
|
|
570
600
|
return;
|
|
571
601
|
}
|
|
572
|
-
|
|
573
|
-
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
574
|
-
const used = lastTurn.prompt + lastTurn.completion || total;
|
|
575
|
-
node.title =
|
|
602
|
+
const parts =
|
|
576
603
|
`entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
|
|
577
604
|
(context ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
|
|
578
|
-
if (
|
|
579
|
-
node.
|
|
580
|
-
|
|
605
|
+
if (context) {
|
|
606
|
+
node.textContent = `contexto ${pct}% · ${formatK(used)}/${formatK(context)}`;
|
|
607
|
+
if (wrap) wrap.className = `ctx ${tone}`;
|
|
608
|
+
} else {
|
|
609
|
+
node.textContent = `tokens ${formatK(total)}`;
|
|
610
|
+
if (wrap) wrap.className = 'ctx muted';
|
|
581
611
|
}
|
|
582
|
-
|
|
583
|
-
|
|
612
|
+
node.title = parts;
|
|
613
|
+
if (fill) fill.style.width = `${context ? pct : 0}%`;
|
|
584
614
|
}
|
|
585
615
|
|
|
586
616
|
function addTokens(tokens) {
|
|
@@ -1511,9 +1541,16 @@ async function runCommand(name, arg) {
|
|
|
1511
1541
|
notify(`### Comandos\n${COMMANDS.map(([cmd, desc]) => `- \`${cmd}\` — ${desc}`).join('\n')}`);
|
|
1512
1542
|
return { handled: true };
|
|
1513
1543
|
case 'new':
|
|
1514
|
-
case 'nueva':
|
|
1515
|
-
|
|
1544
|
+
case 'nueva': {
|
|
1545
|
+
const cwd = state.sessionId ? state.cwd : '';
|
|
1546
|
+
if (cwd) {
|
|
1547
|
+
const created = await createSessionIn(cwd);
|
|
1548
|
+
if (created?.error) notify(`No se pudo crear la sesión: ${created.error}`, 'notice err');
|
|
1549
|
+
} else {
|
|
1550
|
+
openFolderPicker();
|
|
1551
|
+
}
|
|
1516
1552
|
return { handled: true };
|
|
1553
|
+
}
|
|
1517
1554
|
case 'init':
|
|
1518
1555
|
return { handled: false, input: INIT_PROMPT };
|
|
1519
1556
|
case 'cwd': {
|
|
@@ -1572,6 +1609,31 @@ let folderListPath = null;
|
|
|
1572
1609
|
let folderParent = null;
|
|
1573
1610
|
let folderDirs = [];
|
|
1574
1611
|
|
|
1612
|
+
const DEFAULT_CWD_KEY = 'tamnora-default-cwd';
|
|
1613
|
+
let folderPickerMode = 'session';
|
|
1614
|
+
|
|
1615
|
+
function readDefaultCwd() {
|
|
1616
|
+
try {
|
|
1617
|
+
return localStorage.getItem(DEFAULT_CWD_KEY) || '';
|
|
1618
|
+
} catch {
|
|
1619
|
+
return '';
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function writeDefaultCwd(value) {
|
|
1624
|
+
try {
|
|
1625
|
+
if (value) localStorage.setItem(DEFAULT_CWD_KEY, value);
|
|
1626
|
+
else localStorage.removeItem(DEFAULT_CWD_KEY);
|
|
1627
|
+
} catch {
|
|
1628
|
+
// storage unavailable
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function syncDefaultCwd() {
|
|
1633
|
+
const input = $('#default-cwd');
|
|
1634
|
+
if (input) input.value = readDefaultCwd();
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1575
1637
|
const FOLDER_PATH = 'M3.5 6.3A1.3 1.3 0 0 1 4.8 5h3.1l1.5 1.7h7.8a1.3 1.3 0 0 1 1.3 1.3v7.7a1.3 1.3 0 0 1-1.3 1.3H4.8a1.3 1.3 0 0 1-1.3-1.3z';
|
|
1576
1638
|
|
|
1577
1639
|
function folderIcon() {
|
|
@@ -1597,11 +1659,20 @@ function closeFolderPicker() {
|
|
|
1597
1659
|
if (folderModal.open) folderModal.close();
|
|
1598
1660
|
}
|
|
1599
1661
|
|
|
1600
|
-
function openFolderPicker() {
|
|
1662
|
+
function openFolderPicker(mode = 'session') {
|
|
1663
|
+
folderPickerMode = mode === 'default' ? 'default' : 'session';
|
|
1601
1664
|
folderGoto.value = '';
|
|
1602
1665
|
setFolderStatus('');
|
|
1603
1666
|
folderModal.showModal();
|
|
1604
|
-
|
|
1667
|
+
const def = readDefaultCwd();
|
|
1668
|
+
if (!def) return loadFolders(state.home || '');
|
|
1669
|
+
api(`/api/folders?path=${encodeURIComponent(def)}`)
|
|
1670
|
+
.then(() => loadFolders(def))
|
|
1671
|
+
.catch(() => {
|
|
1672
|
+
writeDefaultCwd('');
|
|
1673
|
+
syncDefaultCwd();
|
|
1674
|
+
loadFolders(state.home || '');
|
|
1675
|
+
});
|
|
1605
1676
|
}
|
|
1606
1677
|
|
|
1607
1678
|
function renderCrumbs(pathStr) {
|
|
@@ -1667,21 +1738,21 @@ async function loadFolders(target = '') {
|
|
|
1667
1738
|
$('#folder-up').disabled = !data.path;
|
|
1668
1739
|
$('#folder-new').disabled = !data.path;
|
|
1669
1740
|
folderUseBtn.disabled = !data.path;
|
|
1670
|
-
|
|
1741
|
+
if (folderPickerMode === 'default') {
|
|
1742
|
+
folderUseBtn.textContent = data.path ? `predeterminada: «${basename(data.path)}»` : 'usar como predeterminada';
|
|
1743
|
+
} else {
|
|
1744
|
+
folderUseBtn.textContent = data.path ? `usar «${basename(data.path)}»` : 'usar esta carpeta';
|
|
1745
|
+
}
|
|
1671
1746
|
renderFolderList(folderDirs);
|
|
1672
1747
|
const count = data.path ? `${folderDirs.length} subcarpetas` : `${folderDirs.length} unidades`;
|
|
1673
1748
|
setFolderStatus(data.path ? `${count} · clic para entrar` : 'elegí una unidad');
|
|
1674
1749
|
}
|
|
1675
1750
|
|
|
1676
|
-
async function
|
|
1677
|
-
const
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
return null;
|
|
1682
|
-
});
|
|
1683
|
-
if (!created) return;
|
|
1684
|
-
closeFolderPicker();
|
|
1751
|
+
async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
|
|
1752
|
+
const created = await api('/api/sessions', { method: 'POST', body: { title, cwd } }).catch((error) => ({
|
|
1753
|
+
error: error.message,
|
|
1754
|
+
}));
|
|
1755
|
+
if (created?.error) return created;
|
|
1685
1756
|
registerSession(created.session, []);
|
|
1686
1757
|
activateSession(created.session.id);
|
|
1687
1758
|
const pending = state.pendingInput;
|
|
@@ -1690,6 +1761,21 @@ async function useFolder() {
|
|
|
1690
1761
|
chatInput.value = pending;
|
|
1691
1762
|
sendMessage();
|
|
1692
1763
|
}
|
|
1764
|
+
return created;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
async function useFolder() {
|
|
1768
|
+
const cwd = folderListPath;
|
|
1769
|
+
if (!cwd) return setFolderStatus('Entrá a una carpeta primero (clic para navegar).', 'err');
|
|
1770
|
+
if (folderPickerMode === 'default') {
|
|
1771
|
+
writeDefaultCwd(cwd);
|
|
1772
|
+
syncDefaultCwd();
|
|
1773
|
+
closeFolderPicker();
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const created = await createSessionIn(cwd);
|
|
1777
|
+
if (created?.error) return setFolderStatus(created.error, 'err');
|
|
1778
|
+
closeFolderPicker();
|
|
1693
1779
|
}
|
|
1694
1780
|
|
|
1695
1781
|
function filterFolders() {
|
|
@@ -2380,7 +2466,6 @@ const explorerTree = $('#explorer-tree');
|
|
|
2380
2466
|
const explorerRoot = $('#explorer-root');
|
|
2381
2467
|
const explorerFilter = $('#explorer-filter');
|
|
2382
2468
|
const explorerStatus = $('#explorer-status');
|
|
2383
|
-
const explorerToggle = $('#explorer-toggle');
|
|
2384
2469
|
|
|
2385
2470
|
function setExplorerStatus(text, isError = false) {
|
|
2386
2471
|
explorerStatus.textContent = text;
|
|
@@ -2391,8 +2476,11 @@ function setExplorerOpen(open) {
|
|
|
2391
2476
|
state.explorerOpen = open;
|
|
2392
2477
|
explorer.hidden = !open;
|
|
2393
2478
|
panels.explorer.resizer.hidden = !open;
|
|
2394
|
-
|
|
2395
|
-
|
|
2479
|
+
const handle = $('#explorer-handle');
|
|
2480
|
+
if (handle) {
|
|
2481
|
+
handle.hidden = open;
|
|
2482
|
+
handle.setAttribute('aria-pressed', String(open));
|
|
2483
|
+
}
|
|
2396
2484
|
try {
|
|
2397
2485
|
localStorage.setItem('tamnora-explorer-open', open ? '1' : '0');
|
|
2398
2486
|
} catch {
|
|
@@ -2514,6 +2602,22 @@ function refreshTwisty(li, open) {
|
|
|
2514
2602
|
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
2515
2603
|
}
|
|
2516
2604
|
|
|
2605
|
+
const filePreviewState = { current: null, kind: null, loaded: false, open: false, content: '' };
|
|
2606
|
+
|
|
2607
|
+
const filePreviewTools = setupPreviewTools({
|
|
2608
|
+
container: $('#file-modal'),
|
|
2609
|
+
stage: $('#file-stage'),
|
|
2610
|
+
frame: $('#file-frame'),
|
|
2611
|
+
select: $('#file-device'),
|
|
2612
|
+
maxButton: $('#file-max'),
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
function filePreviewKind(name) {
|
|
2616
|
+
if (/\.(html?|svg)$/i.test(name)) return 'frame';
|
|
2617
|
+
if (/\.(md|markdown)$/i.test(name)) return 'markdown';
|
|
2618
|
+
return null;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2517
2621
|
async function openFilePreview(relPath, name) {
|
|
2518
2622
|
const params = new URLSearchParams({ root: state.cwd, path: relPath });
|
|
2519
2623
|
const data = await api(`/api/files/content?${params}`).catch((error) => {
|
|
@@ -2524,8 +2628,88 @@ async function openFilePreview(relPath, name) {
|
|
|
2524
2628
|
$('#file-title').textContent = name;
|
|
2525
2629
|
$('#file-meta').textContent = `${data.size} bytes`;
|
|
2526
2630
|
$('#file-content').textContent = data.content;
|
|
2527
|
-
|
|
2528
|
-
|
|
2631
|
+
|
|
2632
|
+
const toggle = $('#file-preview-toggle');
|
|
2633
|
+
const device = $('#file-device');
|
|
2634
|
+
const maxButton = $('#file-max');
|
|
2635
|
+
const modal = $('#file-modal');
|
|
2636
|
+
filePreviewState.current = { relPath, name };
|
|
2637
|
+
filePreviewState.kind = filePreviewKind(name);
|
|
2638
|
+
filePreviewState.loaded = false;
|
|
2639
|
+
filePreviewState.open = false;
|
|
2640
|
+
filePreviewState.content = data.content;
|
|
2641
|
+
|
|
2642
|
+
toggle.hidden = !filePreviewState.kind;
|
|
2643
|
+
toggle.textContent = 'vista previa';
|
|
2644
|
+
device.hidden = true;
|
|
2645
|
+
device.value = 'auto';
|
|
2646
|
+
maxButton.hidden = true;
|
|
2647
|
+
$('#file-frame').src = 'about:blank';
|
|
2648
|
+
$('#file-stage').hidden = true;
|
|
2649
|
+
$('#file-render').hidden = true;
|
|
2650
|
+
$('#file-render').innerHTML = '';
|
|
2651
|
+
$('#file-content').hidden = false;
|
|
2652
|
+
modal.classList.remove('preview-maximized');
|
|
2653
|
+
filePreviewTools.applyDevice();
|
|
2654
|
+
|
|
2655
|
+
modal.showModal();
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
$('#file-preview-toggle')?.addEventListener('click', async () => {
|
|
2659
|
+
const toggle = $('#file-preview-toggle');
|
|
2660
|
+
const content = $('#file-content');
|
|
2661
|
+
const stage = $('#file-stage');
|
|
2662
|
+
const frame = $('#file-frame');
|
|
2663
|
+
const render = $('#file-render');
|
|
2664
|
+
const device = $('#file-device');
|
|
2665
|
+
const maxButton = $('#file-max');
|
|
2666
|
+
const current = filePreviewState.current;
|
|
2667
|
+
if (!current) return;
|
|
2668
|
+
|
|
2669
|
+
filePreviewState.open = !filePreviewState.open;
|
|
2670
|
+
const off = () => {
|
|
2671
|
+
toggle.textContent = 'vista previa';
|
|
2672
|
+
content.hidden = false;
|
|
2673
|
+
stage.hidden = true;
|
|
2674
|
+
render.hidden = true;
|
|
2675
|
+
device.hidden = true;
|
|
2676
|
+
maxButton.hidden = true;
|
|
2677
|
+
$('#file-modal').classList.remove('preview-maximized');
|
|
2678
|
+
};
|
|
2679
|
+
if (!filePreviewState.open) return off();
|
|
2680
|
+
|
|
2681
|
+
if (filePreviewState.kind === 'markdown') {
|
|
2682
|
+
render.innerHTML = renderMarkdown(filePreviewState.content);
|
|
2683
|
+
content.hidden = true;
|
|
2684
|
+
stage.hidden = true;
|
|
2685
|
+
render.hidden = false;
|
|
2686
|
+
device.hidden = true;
|
|
2687
|
+
maxButton.hidden = true;
|
|
2688
|
+
toggle.textContent = 'ver código';
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
device.hidden = false;
|
|
2693
|
+
maxButton.hidden = false;
|
|
2694
|
+
if (!filePreviewState.loaded) {
|
|
2695
|
+
const data = await api('/api/files/preview', {
|
|
2696
|
+
method: 'POST',
|
|
2697
|
+
body: { root: state.cwd, path: current.relPath },
|
|
2698
|
+
}).catch((error) => ({ error: error.message }));
|
|
2699
|
+
if (data?.error) {
|
|
2700
|
+
setExplorerStatus(data.error, true);
|
|
2701
|
+
filePreviewState.open = false;
|
|
2702
|
+
return off();
|
|
2703
|
+
}
|
|
2704
|
+
frame.src = data.url;
|
|
2705
|
+
filePreviewState.loaded = true;
|
|
2706
|
+
filePreviewTools.applyDevice();
|
|
2707
|
+
}
|
|
2708
|
+
toggle.textContent = 'ver código';
|
|
2709
|
+
content.hidden = true;
|
|
2710
|
+
render.hidden = true;
|
|
2711
|
+
stage.hidden = false;
|
|
2712
|
+
});
|
|
2529
2713
|
|
|
2530
2714
|
/* --- explorer panes: estructura / preview / procs ------------------------- */
|
|
2531
2715
|
|
|
@@ -2558,6 +2742,35 @@ function setPreviewStatus(text, isError = false) {
|
|
|
2558
2742
|
previewStatusEl.classList.toggle('err', isError);
|
|
2559
2743
|
}
|
|
2560
2744
|
|
|
2745
|
+
/* Ancho por dispositivo + maximizar, compartido por preview y visor de archivos. */
|
|
2746
|
+
function setupPreviewTools({ container, stage, frame, select, maxButton }) {
|
|
2747
|
+
const applyDevice = () => {
|
|
2748
|
+
const value = select?.value || 'auto';
|
|
2749
|
+
if (stage) stage.dataset.device = value;
|
|
2750
|
+
frame.style.width = value === 'auto' ? '100%' : `${value}px`;
|
|
2751
|
+
};
|
|
2752
|
+
select?.addEventListener('change', applyDevice);
|
|
2753
|
+
const setMax = (on) => {
|
|
2754
|
+
container?.classList.toggle('preview-maximized', on);
|
|
2755
|
+
maxButton?.setAttribute('aria-pressed', String(on));
|
|
2756
|
+
if (maxButton) maxButton.title = on ? 'restaurar' : 'maximizar';
|
|
2757
|
+
};
|
|
2758
|
+
maxButton?.addEventListener('click', () => setMax(!container?.classList.contains('preview-maximized')));
|
|
2759
|
+
document.addEventListener('keydown', (event) => {
|
|
2760
|
+
if (event.key === 'Escape' && container?.classList.contains('preview-maximized')) setMax(false);
|
|
2761
|
+
});
|
|
2762
|
+
return { applyDevice, setMax };
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
const previewTools = setupPreviewTools({
|
|
2766
|
+
container: $('#xp-preview'),
|
|
2767
|
+
stage: $('#preview-stage'),
|
|
2768
|
+
frame: previewFrame,
|
|
2769
|
+
select: $('#preview-device'),
|
|
2770
|
+
maxButton: $('#preview-max'),
|
|
2771
|
+
});
|
|
2772
|
+
previewTools.applyDevice();
|
|
2773
|
+
|
|
2561
2774
|
function loadPreview(url) {
|
|
2562
2775
|
const value = String(url ?? '').trim();
|
|
2563
2776
|
if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
|
|
@@ -2664,7 +2877,7 @@ async function refreshProcLog() {
|
|
|
2664
2877
|
|
|
2665
2878
|
$('#procs-detect')?.addEventListener('click', detectProcs);
|
|
2666
2879
|
|
|
2667
|
-
|
|
2880
|
+
$('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
|
|
2668
2881
|
$('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
|
|
2669
2882
|
$('#explorer-refresh').addEventListener('click', refreshExplorerMode);
|
|
2670
2883
|
explorerFilter.addEventListener('input', applyTreeFilter);
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
</head>
|
|
34
34
|
<body>
|
|
35
35
|
<header class="topbar">
|
|
36
|
-
<div class="brand"><span class="logo">T</span>
|
|
36
|
+
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.5.0</span></div>
|
|
37
37
|
<nav class="tabs">
|
|
38
38
|
<button class="tab active" data-tab="chat">chat</button>
|
|
39
39
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -45,8 +45,12 @@
|
|
|
45
45
|
<circle cx="19" cy="12" r="1" />
|
|
46
46
|
<circle cx="5" cy="12" r="1" />
|
|
47
47
|
</svg>
|
|
48
|
+
<span id="more-tab-label">chat</span>
|
|
48
49
|
</button>
|
|
49
50
|
<div id="more-menu" class="menu-pop" hidden>
|
|
51
|
+
<button type="button" class="menu-primary" data-tab="chat">chat</button>
|
|
52
|
+
<button type="button" class="menu-primary" data-tab="providers">proveedores</button>
|
|
53
|
+
<button type="button" class="menu-primary" data-tab="ajustes">ajustes</button>
|
|
50
54
|
<button type="button" data-tab="eval">eval</button>
|
|
51
55
|
<button type="button" data-tab="runs">runs</button>
|
|
52
56
|
</div>
|
|
@@ -65,18 +69,6 @@
|
|
|
65
69
|
<path d="M14 2v4M8 10v4M16 18v4" />
|
|
66
70
|
</svg>
|
|
67
71
|
</button>
|
|
68
|
-
<button id="sidebar-toggle" type="button" class="btn icon" title="mostrar/ocultar sesiones" aria-label="mostrar u ocultar el panel de sesiones" aria-pressed="true">
|
|
69
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
70
|
-
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
71
|
-
<path d="M9 3v18" />
|
|
72
|
-
</svg>
|
|
73
|
-
</button>
|
|
74
|
-
<button id="explorer-toggle" type="button" class="btn icon" title="mostrar/ocultar archivos" aria-label="mostrar u ocultar el panel de archivos" aria-pressed="false">
|
|
75
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
76
|
-
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
77
|
-
<path d="M15 3v18" />
|
|
78
|
-
</svg>
|
|
79
|
-
</button>
|
|
80
72
|
<button id="theme-toggle" type="button" class="btn icon" title="cambiar tema" aria-label="cambiar tema">
|
|
81
73
|
<svg class="ico-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
82
74
|
<circle cx="12" cy="12" r="4" />
|
|
@@ -92,6 +84,14 @@
|
|
|
92
84
|
<main>
|
|
93
85
|
<section id="view-chat" class="view active">
|
|
94
86
|
<aside id="sidebar" class="sidebar">
|
|
87
|
+
<div class="panel-head">
|
|
88
|
+
<span class="panel-head-title">sesiones</span>
|
|
89
|
+
<button id="sidebar-collapse" type="button" class="btn icon" title="ocultar panel de sesiones" aria-label="ocultar panel de sesiones">
|
|
90
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
91
|
+
<path d="m15 18-6-6 6-6" />
|
|
92
|
+
</svg>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
95
|
<button id="new-session" class="btn block">+ nueva sesión</button>
|
|
96
96
|
<ul id="sessions" class="sessions"></ul>
|
|
97
97
|
<div class="rail-footer">
|
|
@@ -143,7 +143,10 @@
|
|
|
143
143
|
<span class="dot">·</span>
|
|
144
144
|
<span id="sb-rounds">listo</span>
|
|
145
145
|
<span class="spacer"></span>
|
|
146
|
-
<span id="sb-
|
|
146
|
+
<span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
|
|
147
|
+
<span id="sb-tokens">tokens —</span>
|
|
148
|
+
<span class="ctx-bar" aria-hidden="true"><span id="sb-context-fill" class="ctx-fill"></span></span>
|
|
149
|
+
</span>
|
|
147
150
|
</div>
|
|
148
151
|
</div>
|
|
149
152
|
|
|
@@ -187,8 +190,25 @@
|
|
|
187
190
|
<button id="preview-open" class="btn" type="button">abrir</button>
|
|
188
191
|
<button id="preview-tunnel" class="btn" type="button" title="Abrir un túnel público al puerto (para el celular)">túnel</button>
|
|
189
192
|
</div>
|
|
193
|
+
<div class="preview-tools">
|
|
194
|
+
<select id="preview-device" title="tamaño de pantalla" aria-label="tamaño de pantalla">
|
|
195
|
+
<option value="auto">adaptable</option>
|
|
196
|
+
<option value="390">móvil</option>
|
|
197
|
+
<option value="768">tablet</option>
|
|
198
|
+
<option value="1280">laptop</option>
|
|
199
|
+
<option value="1600">pc</option>
|
|
200
|
+
</select>
|
|
201
|
+
<span class="spacer"></span>
|
|
202
|
+
<button id="preview-max" class="btn icon" type="button" title="maximizar" aria-pressed="false" aria-label="maximizar la vista previa">
|
|
203
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
204
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
205
|
+
</svg>
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
190
208
|
<div id="preview-status" class="explorer-status"></div>
|
|
191
|
-
<
|
|
209
|
+
<div id="preview-stage" class="preview-stage" data-device="auto">
|
|
210
|
+
<iframe id="preview-frame" class="preview-frame" title="vista previa" referrerpolicy="no-referrer"></iframe>
|
|
211
|
+
</div>
|
|
192
212
|
</div>
|
|
193
213
|
|
|
194
214
|
<div id="xp-procs" class="xp-pane" hidden>
|
|
@@ -202,12 +222,36 @@
|
|
|
202
222
|
</div>
|
|
203
223
|
</aside>
|
|
204
224
|
|
|
225
|
+
<button id="sidebar-handle" type="button" class="panel-handle panel-handle-left" title="mostrar sesiones" aria-label="mostrar panel de sesiones" aria-pressed="false">
|
|
226
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
227
|
+
<path d="m9 18 6-6-6-6" />
|
|
228
|
+
</svg>
|
|
229
|
+
</button>
|
|
230
|
+
<button id="explorer-handle" type="button" class="panel-handle panel-handle-right" title="mostrar archivos" aria-label="mostrar panel de archivos" aria-pressed="false">
|
|
231
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
232
|
+
<path d="m15 18-6-6 6-6" />
|
|
233
|
+
</svg>
|
|
234
|
+
</button>
|
|
235
|
+
|
|
205
236
|
<dialog id="file-modal" class="modal file-modal">
|
|
206
237
|
<div class="modal-card">
|
|
207
238
|
<div class="row between">
|
|
208
239
|
<h3 id="file-title">archivo</h3>
|
|
209
240
|
<div class="file-actions">
|
|
210
241
|
<span id="file-meta" class="muted"></span>
|
|
242
|
+
<select id="file-device" class="file-device" title="tamaño de pantalla" aria-label="tamaño de pantalla" hidden>
|
|
243
|
+
<option value="auto">adaptable</option>
|
|
244
|
+
<option value="390">móvil</option>
|
|
245
|
+
<option value="768">tablet</option>
|
|
246
|
+
<option value="1280">laptop</option>
|
|
247
|
+
<option value="1600">pc</option>
|
|
248
|
+
</select>
|
|
249
|
+
<button id="file-max" type="button" class="btn icon" title="maximizar" aria-pressed="false" aria-label="maximizar la vista previa" hidden>
|
|
250
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
251
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
252
|
+
</svg>
|
|
253
|
+
</button>
|
|
254
|
+
<button id="file-preview-toggle" type="button" class="btn" hidden>vista previa</button>
|
|
211
255
|
<button id="file-close" type="button" class="btn icon" title="cerrar" aria-label="cerrar">
|
|
212
256
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
213
257
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
@@ -216,6 +260,10 @@
|
|
|
216
260
|
</div>
|
|
217
261
|
</div>
|
|
218
262
|
<pre id="file-content" class="file-preview"></pre>
|
|
263
|
+
<div id="file-render" class="file-render text" hidden></div>
|
|
264
|
+
<div id="file-stage" class="preview-stage" data-device="auto" hidden>
|
|
265
|
+
<iframe id="file-frame" class="file-frame" title="vista previa del archivo"></iframe>
|
|
266
|
+
</div>
|
|
219
267
|
</div>
|
|
220
268
|
</dialog>
|
|
221
269
|
</section>
|
|
@@ -404,6 +452,16 @@
|
|
|
404
452
|
<select id="provider" title="proveedor"></select>
|
|
405
453
|
<label for="model">Modelo</label>
|
|
406
454
|
<select id="model" title="modelo"></select>
|
|
455
|
+
<label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
|
|
456
|
+
<div class="row default-cwd-row">
|
|
457
|
+
<input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
|
|
458
|
+
<button type="button" id="default-cwd-pick" class="btn">elegir</button>
|
|
459
|
+
<button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
|
|
460
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
461
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
462
|
+
</svg>
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
407
465
|
<label class="check" title="aprobar automáticamente tools con permiso ask">
|
|
408
466
|
<input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
|
|
409
467
|
</label>
|
|
@@ -83,7 +83,10 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
83
83
|
|
|
84
84
|
/* topbar ------------------------------------------------------------------ */
|
|
85
85
|
.topbar {
|
|
86
|
-
|
|
86
|
+
position: relative;
|
|
87
|
+
z-index: 40;
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: 1fr auto 1fr;
|
|
87
90
|
align-items: center;
|
|
88
91
|
gap: 16px;
|
|
89
92
|
padding: 10px 16px;
|
|
@@ -92,6 +95,8 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
92
95
|
backdrop-filter: blur(14px);
|
|
93
96
|
}
|
|
94
97
|
.brand {
|
|
98
|
+
grid-column: 1;
|
|
99
|
+
justify-self: start;
|
|
95
100
|
font-family: var(--serif);
|
|
96
101
|
font-weight: 700;
|
|
97
102
|
font-size: 15px;
|
|
@@ -101,18 +106,23 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
101
106
|
gap: 9px;
|
|
102
107
|
}
|
|
103
108
|
.brand .logo {
|
|
104
|
-
width: 26px;
|
|
109
|
+
min-width: 26px;
|
|
105
110
|
height: 26px;
|
|
111
|
+
padding: 0 8px;
|
|
106
112
|
border: 1px solid var(--border);
|
|
107
113
|
border-radius: 8px;
|
|
108
|
-
display:
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
color: var(--text);
|
|
111
118
|
font: 700 14px var(--serif);
|
|
119
|
+
letter-spacing: -0.03em;
|
|
112
120
|
flex: 0 0 auto;
|
|
113
121
|
}
|
|
122
|
+
.brand .logo .logo-t { color: var(--text); -webkit-text-stroke: 0.3px var(--text); }
|
|
123
|
+
.brand .logo .logo-h { color: var(--accent); -webkit-text-stroke: 0.3px var(--accent); }
|
|
114
124
|
.brand .ver { color: var(--muted); font-family: var(--mono); font-weight: 400; font-size: 10px; letter-spacing: 0.04em; }
|
|
115
|
-
.tabs { display: flex; gap: 2px; }
|
|
125
|
+
.tabs { grid-column: 2; justify-self: center; display: flex; gap: 2px; }
|
|
116
126
|
.tab {
|
|
117
127
|
background: transparent;
|
|
118
128
|
color: var(--muted);
|
|
@@ -125,9 +135,13 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
125
135
|
.tab:hover { color: var(--text); }
|
|
126
136
|
.tab.active { color: var(--text); background: var(--theirs); border-color: var(--border); }
|
|
127
137
|
|
|
128
|
-
.menu { position: relative; }
|
|
129
|
-
#more-tab { display:
|
|
138
|
+
.menu { position: relative; display: block; }
|
|
139
|
+
#more-tab { display: inline-flex; align-items: center; gap: 6px; }
|
|
130
140
|
#more-tab svg { width: 18px; height: 18px; display: block; }
|
|
141
|
+
#more-tab-label { display: none; font: 600 12.5px var(--sans); }
|
|
142
|
+
@media (min-width: 821px) {
|
|
143
|
+
#more-menu button.menu-primary { display: none; }
|
|
144
|
+
}
|
|
131
145
|
|
|
132
146
|
#theme-toggle .ico-moon { display: none; }
|
|
133
147
|
html[data-theme='dark'] #theme-toggle .ico-sun { display: none; }
|
|
@@ -136,7 +150,7 @@ html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
|
|
|
136
150
|
position: absolute;
|
|
137
151
|
top: calc(100% + 4px);
|
|
138
152
|
left: 0;
|
|
139
|
-
z-index:
|
|
153
|
+
z-index: 60;
|
|
140
154
|
min-width: 130px;
|
|
141
155
|
display: flex;
|
|
142
156
|
flex-direction: column;
|
|
@@ -159,7 +173,8 @@ html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
|
|
|
159
173
|
font: inherit;
|
|
160
174
|
}
|
|
161
175
|
.menu-pop button:hover { background: var(--theirs); }
|
|
162
|
-
.
|
|
176
|
+
.menu-pop button.active { background: var(--theirs); color: var(--accent); }
|
|
177
|
+
.settings { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 8px; }
|
|
163
178
|
.settings select { max-width: 220px; }
|
|
164
179
|
.check { color: var(--muted); display: flex; align-items: center; gap: 5px; cursor: pointer; }
|
|
165
180
|
|
|
@@ -176,7 +191,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--accent); box-sha
|
|
|
176
191
|
|
|
177
192
|
/* layout ------------------------------------------------------------------ */
|
|
178
193
|
main { flex: 1; min-height: 0; min-width: 0; display: flex; }
|
|
179
|
-
.view { display: none; flex: 1; min-height: 0; min-width: 0; }
|
|
194
|
+
.view { display: none; flex: 1; min-height: 0; min-width: 0; position: relative; }
|
|
180
195
|
.view.active { display: flex; min-width: 0; }
|
|
181
196
|
|
|
182
197
|
.btn {
|
|
@@ -599,9 +614,12 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
599
614
|
}
|
|
600
615
|
.statusbar .dot { opacity: 0.5; }
|
|
601
616
|
.statusbar .spacer { flex: 1; }
|
|
617
|
+
.ctx { display: inline-flex; align-items: center; gap: 8px; }
|
|
618
|
+
.ctx-bar { width: 76px; height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; }
|
|
619
|
+
.ctx-fill { display: block; height: 100%; width: 0; border-radius: 999px; background: currentColor; transition: width 0.35s ease; }
|
|
602
620
|
|
|
603
621
|
/* providers / eval / runs ------------------------------------------------- */
|
|
604
|
-
.panel { flex: 1; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
|
|
622
|
+
.panel { flex: 1; overflow-y: auto; width: 100%; max-width: 920px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
|
|
605
623
|
.panel h2 { margin: 0; font: 700 20px var(--serif); letter-spacing: -0.02em; }
|
|
606
624
|
.field { display: flex; flex-direction: column; gap: 5px; color: var(--muted); }
|
|
607
625
|
.field.grow { flex: 1; }
|
|
@@ -766,7 +784,34 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
766
784
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
|
|
767
785
|
.xchip { border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 999px; padding: 4px 10px; font: 500 11px var(--mono); cursor: pointer; }
|
|
768
786
|
.xchip:hover { border-color: var(--accent); color: var(--accent); }
|
|
769
|
-
.preview-
|
|
787
|
+
.preview-tools { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
|
|
788
|
+
.preview-tools .spacer { flex: 1; }
|
|
789
|
+
.preview-tools select { flex: 0 0 auto; }
|
|
790
|
+
.preview-stage {
|
|
791
|
+
flex: 1;
|
|
792
|
+
min-height: 0;
|
|
793
|
+
display: flex;
|
|
794
|
+
align-items: stretch;
|
|
795
|
+
justify-content: center;
|
|
796
|
+
overflow: auto;
|
|
797
|
+
background: var(--theirs);
|
|
798
|
+
}
|
|
799
|
+
.preview-frame { border: 0; background: #fff; height: 100%; width: 100%; min-height: 0; }
|
|
800
|
+
.preview-stage[data-device]:not([data-device='auto']) .preview-frame {
|
|
801
|
+
height: 100%;
|
|
802
|
+
border: 1px solid var(--border);
|
|
803
|
+
border-radius: 8px;
|
|
804
|
+
box-shadow: 0 14px 36px rgba(40, 36, 26, 0.18);
|
|
805
|
+
}
|
|
806
|
+
#xp-preview.preview-maximized {
|
|
807
|
+
position: fixed;
|
|
808
|
+
inset: 0;
|
|
809
|
+
z-index: 70;
|
|
810
|
+
padding: 10px;
|
|
811
|
+
background: var(--panel);
|
|
812
|
+
border: 0;
|
|
813
|
+
}
|
|
814
|
+
#xp-preview.preview-maximized .preview-stage { border-radius: 10px; }
|
|
770
815
|
#procs-list { flex: 1; min-height: 0; overflow-y: auto; }
|
|
771
816
|
.procs-log { margin: 0; padding: 8px 12px; height: 150px; overflow: auto; background: var(--theirs); border-top: 1px solid var(--border); font: 500 11px var(--mono); white-space: pre-wrap; word-break: break-word; }
|
|
772
817
|
.import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
|
|
@@ -786,6 +831,72 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
786
831
|
white-space: pre;
|
|
787
832
|
}
|
|
788
833
|
|
|
834
|
+
.file-render {
|
|
835
|
+
margin: 0;
|
|
836
|
+
padding: 14px 16px;
|
|
837
|
+
max-height: 62vh;
|
|
838
|
+
overflow: auto;
|
|
839
|
+
background: var(--panel);
|
|
840
|
+
border: 1px solid var(--border);
|
|
841
|
+
border-radius: 10px;
|
|
842
|
+
line-height: 1.65;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.file-stage {
|
|
846
|
+
height: 62vh;
|
|
847
|
+
display: flex;
|
|
848
|
+
align-items: stretch;
|
|
849
|
+
justify-content: center;
|
|
850
|
+
overflow: auto;
|
|
851
|
+
background: var(--theirs);
|
|
852
|
+
border: 1px solid var(--border);
|
|
853
|
+
border-radius: 10px;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.file-frame { border: 0; background: #fff; height: 100%; width: 100%; }
|
|
857
|
+
|
|
858
|
+
dialog.file-modal.preview-maximized {
|
|
859
|
+
width: 100vw;
|
|
860
|
+
max-width: 100vw;
|
|
861
|
+
height: 100vh;
|
|
862
|
+
max-height: 100vh;
|
|
863
|
+
margin: 0;
|
|
864
|
+
border: 0;
|
|
865
|
+
border-radius: 0;
|
|
866
|
+
}
|
|
867
|
+
.file-modal.preview-maximized .modal-card { height: 100vh; }
|
|
868
|
+
.file-modal.preview-maximized .file-stage,
|
|
869
|
+
.file-modal.preview-maximized .file-render,
|
|
870
|
+
.file-modal.preview-maximized .file-preview {
|
|
871
|
+
flex: 1;
|
|
872
|
+
height: auto;
|
|
873
|
+
max-height: none;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/* panel heads + collapsed handles ----------------------------------------- */
|
|
877
|
+
.panel-head { display: flex; align-items: center; gap: 8px; padding: 2px 2px 6px; }
|
|
878
|
+
.panel-head-title { flex: 1; font: 700 13px var(--serif); letter-spacing: -0.02em; color: var(--muted); text-transform: lowercase; }
|
|
879
|
+
.panel-handle {
|
|
880
|
+
position: absolute;
|
|
881
|
+
top: 50%;
|
|
882
|
+
transform: translateY(-50%);
|
|
883
|
+
z-index: 30;
|
|
884
|
+
width: 22px;
|
|
885
|
+
height: 54px;
|
|
886
|
+
display: grid;
|
|
887
|
+
place-items: center;
|
|
888
|
+
padding: 0;
|
|
889
|
+
border: 1px solid var(--border);
|
|
890
|
+
background: var(--panel);
|
|
891
|
+
color: var(--muted);
|
|
892
|
+
cursor: pointer;
|
|
893
|
+
transition: color 0.15s ease, background 0.15s ease;
|
|
894
|
+
}
|
|
895
|
+
.panel-handle:hover { color: var(--accent); background: var(--theirs); }
|
|
896
|
+
.panel-handle svg { width: 16px; height: 16px; }
|
|
897
|
+
.panel-handle-left { left: 0; border-left: 0; border-radius: 0 10px 10px 0; }
|
|
898
|
+
.panel-handle-right { right: 0; border-right: 0; border-radius: 10px 0 0 10px; }
|
|
899
|
+
|
|
789
900
|
.drawer-backdrop {
|
|
790
901
|
position: fixed;
|
|
791
902
|
inset: 0;
|
|
@@ -794,6 +905,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
794
905
|
backdrop-filter: blur(2px);
|
|
795
906
|
}
|
|
796
907
|
|
|
908
|
+
|
|
797
909
|
@media (max-width: 900px) {
|
|
798
910
|
.explorer {
|
|
799
911
|
position: fixed;
|
|
@@ -809,6 +921,8 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
809
921
|
/* mobile / angosto -------------------------------------------------------- */
|
|
810
922
|
@media (max-width: 820px) {
|
|
811
923
|
.topbar {
|
|
924
|
+
position: relative;
|
|
925
|
+
display: flex;
|
|
812
926
|
flex-wrap: wrap;
|
|
813
927
|
gap: 8px 10px;
|
|
814
928
|
padding: 8px 12px;
|
|
@@ -817,10 +931,27 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
817
931
|
display: none;
|
|
818
932
|
}
|
|
819
933
|
.tabs {
|
|
820
|
-
|
|
934
|
+
margin-left: auto;
|
|
821
935
|
max-width: 100%;
|
|
822
936
|
}
|
|
937
|
+
.tabs > .tab[data-tab] {
|
|
938
|
+
display: none;
|
|
939
|
+
}
|
|
940
|
+
#more-tab-label {
|
|
941
|
+
display: inline;
|
|
942
|
+
}
|
|
943
|
+
.menu-pop {
|
|
944
|
+
top: calc(100% + 2px);
|
|
945
|
+
left: auto;
|
|
946
|
+
right: 0;
|
|
947
|
+
min-width: 200px;
|
|
948
|
+
padding: 6px;
|
|
949
|
+
}
|
|
950
|
+
.menu-pop button {
|
|
951
|
+
padding: 11px 12px;
|
|
952
|
+
}
|
|
823
953
|
.settings {
|
|
954
|
+
grid-column: auto;
|
|
824
955
|
margin-left: 0;
|
|
825
956
|
flex: 1 1 100%;
|
|
826
957
|
flex-wrap: wrap;
|
|
@@ -915,6 +1046,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
915
1046
|
text-transform: none;
|
|
916
1047
|
}
|
|
917
1048
|
.settings-actions { margin-top: 10px; }
|
|
1049
|
+
.default-cwd-row { align-items: center; }
|
|
1050
|
+
.default-cwd-row #default-cwd { flex: 1; min-width: 0; }
|
|
1051
|
+
.default-cwd-row .btn { flex: 0 0 auto; }
|
|
918
1052
|
|
|
919
1053
|
/* command palette --------------------------------------------------------- */
|
|
920
1054
|
.palette-modal { width: min(560px, calc(100vw - 24px)); }
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { sendJson, sendError } from '../lib/http.js';
|
|
4
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
5
|
+
import { staticServerChip } from '../lib/core.js';
|
|
5
6
|
import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
|
|
6
7
|
|
|
7
8
|
const MAX_PREVIEW = 200 * 1024;
|
|
@@ -114,4 +115,37 @@ export function registerFiles(route, ctx) {
|
|
|
114
115
|
content: buffer.toString('utf8'),
|
|
115
116
|
});
|
|
116
117
|
});
|
|
118
|
+
|
|
119
|
+
route('POST', '/api/files/preview', async ({ req, res }) => {
|
|
120
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
121
|
+
return sendError(res, 403, 'La vista previa solo está disponible en conexiones locales.');
|
|
122
|
+
}
|
|
123
|
+
if (!ctx.procsEnabled) {
|
|
124
|
+
return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
125
|
+
}
|
|
126
|
+
const body = await readJson(req);
|
|
127
|
+
let root;
|
|
128
|
+
try {
|
|
129
|
+
root = resolveExistingDir(body.root || ctx.cwd);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return sendError(res, 400, error.message);
|
|
132
|
+
}
|
|
133
|
+
const rel = String(body.path ?? '').replace(/^[/\\]+/, '');
|
|
134
|
+
const target = path.resolve(root, rel);
|
|
135
|
+
if (!isInside(root, target)) return sendError(res, 400, 'La ruta queda fuera del workspace.');
|
|
136
|
+
|
|
137
|
+
let proc = ctx.procs.list().find((p) => p.running && p.port && path.resolve(p.cwd) === root);
|
|
138
|
+
if (!proc) {
|
|
139
|
+
const chip = staticServerChip(root);
|
|
140
|
+
try {
|
|
141
|
+
proc = ctx.procs.start({ cwd: root, command: chip.command, args: chip.args, port: chip.port });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return sendError(res, 400, error.message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
sendJson(res, 200, {
|
|
147
|
+
url: `http://127.0.0.1:${proc.port}/${toPosix(path.relative(root, target))}`,
|
|
148
|
+
port: proc.port,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
117
151
|
}
|
|
@@ -531,6 +531,32 @@ test('lists and previews workspace files, confining to the root', async () => {
|
|
|
531
531
|
}
|
|
532
532
|
});
|
|
533
533
|
|
|
534
|
+
test('previews an HTML file through a static server', async () => {
|
|
535
|
+
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-html-'));
|
|
536
|
+
await writeFile(path.join(root, 'index.html'), '<h1>hola</h1>');
|
|
537
|
+
const { instance, base } = await startServer();
|
|
538
|
+
const json = { 'Content-Type': 'application/json' };
|
|
539
|
+
try {
|
|
540
|
+
const res = await fetch(`${base}/api/files/preview`, {
|
|
541
|
+
method: 'POST',
|
|
542
|
+
headers: json,
|
|
543
|
+
body: JSON.stringify({ root, path: 'index.html' }),
|
|
544
|
+
});
|
|
545
|
+
assert.equal(res.status, 200);
|
|
546
|
+
const data = await res.json();
|
|
547
|
+
assert.match(data.url, /^http:\/\/127\.0\.0\.1:\d+\/index\.html$/);
|
|
548
|
+
|
|
549
|
+
const outside = await fetch(`${base}/api/files/preview`, {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
headers: json,
|
|
552
|
+
body: JSON.stringify({ root, path: '../secret.txt' }),
|
|
553
|
+
});
|
|
554
|
+
assert.equal(outside.status, 400);
|
|
555
|
+
} finally {
|
|
556
|
+
await instance.close();
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
534
560
|
test('lists workspace folders and rejects invalid paths', async () => {
|
|
535
561
|
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-folders-'));
|
|
536
562
|
await mkdir(path.join(root, 'beta'));
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Servidor estatico minimo (sin dependencias) para previsualizar sitios hechos
|
|
2
|
+
// con HTML/CSS/JS planos desde la consola y exponerlos con un tunel.
|
|
3
|
+
//
|
|
4
|
+
// Uso: node static-server.mjs <dir> [puerto]
|
|
5
|
+
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(process.argv[2] || '.');
|
|
11
|
+
const port = Number(process.argv[3] || 8000);
|
|
12
|
+
const host = '127.0.0.1';
|
|
13
|
+
|
|
14
|
+
const TYPES = {
|
|
15
|
+
'.html': 'text/html; charset=utf-8',
|
|
16
|
+
'.htm': 'text/html; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
19
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
20
|
+
'.json': 'application/json; charset=utf-8',
|
|
21
|
+
'.map': 'application/json; charset=utf-8',
|
|
22
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.jpg': 'image/jpeg',
|
|
26
|
+
'.jpeg': 'image/jpeg',
|
|
27
|
+
'.gif': 'image/gif',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.avif': 'image/avif',
|
|
30
|
+
'.ico': 'image/x-icon',
|
|
31
|
+
'.woff': 'font/woff',
|
|
32
|
+
'.woff2': 'font/woff2',
|
|
33
|
+
'.ttf': 'font/ttf',
|
|
34
|
+
'.otf': 'font/otf',
|
|
35
|
+
'.pdf': 'application/pdf',
|
|
36
|
+
'.mp4': 'video/mp4',
|
|
37
|
+
'.webm': 'video/webm',
|
|
38
|
+
'.mp3': 'audio/mpeg',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function resolveRequest(urlPath) {
|
|
42
|
+
let decoded;
|
|
43
|
+
try {
|
|
44
|
+
decoded = decodeURIComponent(String(urlPath || '/').split('?')[0].split('#')[0]);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const abs = path.resolve(root, decoded.replace(/^\/+/, ''));
|
|
49
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return null;
|
|
50
|
+
return abs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const server = http.createServer((req, res) => {
|
|
54
|
+
let abs = resolveRequest(req.url);
|
|
55
|
+
if (abs === null) {
|
|
56
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
57
|
+
res.end('403');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) abs = path.join(abs, 'index.html');
|
|
62
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
63
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
64
|
+
res.end('404');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
69
|
+
res.end('500');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.writeHead(200, {
|
|
73
|
+
'content-type': TYPES[path.extname(abs).toLowerCase()] || 'application/octet-stream',
|
|
74
|
+
'cache-control': 'no-cache',
|
|
75
|
+
});
|
|
76
|
+
createReadStream(abs).on('error', () => res.destroy()).pipe(res);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
server.on('error', (error) => {
|
|
80
|
+
if (error.code === 'EADDRINUSE') {
|
|
81
|
+
console.error(`[static] el puerto ${port} esta ocupado; pasá otro: node static-server.mjs <dir> <puerto>`);
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`[static] ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
server.listen(port, host, () => {
|
|
89
|
+
console.log(`[static] http://${host}:${port} → ${root}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const shutdown = () => server.close(() => process.exit(0));
|
|
93
|
+
process.on('SIGTERM', shutdown);
|
|
94
|
+
process.on('SIGINT', shutdown);
|
package/package.json
CHANGED