@danieltmn/tamnora 0.11.1 → 0.11.3
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 +16 -0
- package/harness/packages/core/src/providers/resilience.js +3 -1
- package/harness/packages/server/public/app.js +73 -34
- package/harness/packages/server/public/index.html +5 -2
- package/harness/packages/server/public/markdown.js +57 -3
- package/harness/packages/server/public/styles.css +117 -13
- package/harness/packages/server/test/markdown.test.js +19 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.11.3] - 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(consola): sidebar, estructura y vista previa de codigo
|
|
13
|
+
- docs(website): iconos y logo en la landing (favicon + header/footer)
|
|
14
|
+
|
|
15
|
+
## [0.11.2] - 2026-10-09
|
|
16
|
+
|
|
17
|
+
### Arreglado
|
|
18
|
+
|
|
19
|
+
- **CI en Node 22**: el backoff de `withRetry` usaba un timer `unref`, que dejaba
|
|
20
|
+
que el event loop se vaciara y `node:test` cancelara los tests de
|
|
21
|
+
`resilience.test.js` ("Promise resolution is still pending..."). Se quito el
|
|
22
|
+
`unref`: ahora el reintento mantiene el loop vivo y la suite pasa en Node 22.
|
|
23
|
+
|
|
8
24
|
## [0.11.1] - 2026-10-09
|
|
9
25
|
|
|
10
26
|
### Arreglado
|
|
@@ -26,8 +26,10 @@ export function isRetryableError(err) {
|
|
|
26
26
|
|
|
27
27
|
function sleep(ms, signal) {
|
|
28
28
|
return new Promise((resolve, reject) => {
|
|
29
|
+
// Sin `unref`: en Node 22 un timer sin referencias deja que el event loop se
|
|
30
|
+
// vacíe y `node:test` cancela los tests ("Promise resolution is still
|
|
31
|
+
// pending..."). Si abortan, el listener de abajo lo limpia.
|
|
29
32
|
const timer = setTimeout(resolve, ms);
|
|
30
|
-
timer.unref?.();
|
|
31
33
|
if (!signal) return;
|
|
32
34
|
if (signal.aborted) {
|
|
33
35
|
clearTimeout(timer);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tamnora console — dependency-free client for the harness server (terminal theme).
|
|
2
2
|
|
|
3
|
-
import { renderMarkdown, speechText } from './markdown.js';
|
|
3
|
+
import { renderMarkdown, speechText, highlight } from './markdown.js';
|
|
4
4
|
import { streamSse, followRun } from './runStream.js';
|
|
5
5
|
import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
|
|
6
6
|
|
|
@@ -21,8 +21,10 @@ const state = {
|
|
|
21
21
|
panes: new Map(),
|
|
22
22
|
tabOrder: [],
|
|
23
23
|
activeTab: null,
|
|
24
|
-
collapsedGroups: new Set(),
|
|
25
24
|
queues: new Map(),
|
|
25
|
+
// Grupos de sesiones abiertos a mano. Arranca vacio y NO se persiste: el
|
|
26
|
+
// sidebar siempre empieza con todas las carpetas sin expandir.
|
|
27
|
+
expandedGroups: new Set(),
|
|
26
28
|
// Sesiones con un run vivo segun el canal global de eventos. El sidebar lo
|
|
27
29
|
// usa para el punto de actividad antes de abrir la sesion.
|
|
28
30
|
remoteActive: new Set(),
|
|
@@ -173,6 +175,7 @@ const storage = {
|
|
|
173
175
|
const moreTab = $('#more-tab');
|
|
174
176
|
const moreMenu = $('#more-menu');
|
|
175
177
|
const moreTabLabel = $('#more-tab-label');
|
|
178
|
+
const topbarSection = $('#topbar-section');
|
|
176
179
|
|
|
177
180
|
const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
178
181
|
|
|
@@ -186,6 +189,7 @@ function activateTab(name) {
|
|
|
186
189
|
.querySelectorAll('.tab[data-tab], #more-menu button[data-tab]')
|
|
187
190
|
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
188
191
|
if (moreTabLabel) moreTabLabel.textContent = TAB_LABELS[name] || name;
|
|
192
|
+
if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
|
|
189
193
|
moreTab.classList.toggle('active', name === 'eval' || name === 'runs');
|
|
190
194
|
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
191
195
|
hideMoreMenu();
|
|
@@ -663,7 +667,6 @@ function initLayout() {
|
|
|
663
667
|
const widths = storage.getJson('tamnora-panel-widths', {}) || {};
|
|
664
668
|
if (Number(widths.sidebar)) panels.sidebar.width = Math.min(panels.sidebar.max, Math.max(panels.sidebar.min, widths.sidebar));
|
|
665
669
|
if (Number(widths.explorer)) panels.explorer.width = Math.min(panels.explorer.max, Math.max(panels.explorer.min, widths.explorer));
|
|
666
|
-
state.collapsedGroups = new Set(storage.getJson('tamnora-collapsed-groups', []) || []);
|
|
667
670
|
// En movil el panel arranca cerrado (drawer); en desktop, la preferencia guardada.
|
|
668
671
|
applyPanelWidths();
|
|
669
672
|
attachResizer(panels.sidebar);
|
|
@@ -1573,10 +1576,6 @@ function basename(p) {
|
|
|
1573
1576
|
return text.split(/[\\/]/).pop() || text;
|
|
1574
1577
|
}
|
|
1575
1578
|
|
|
1576
|
-
function persistCollapsedGroups() {
|
|
1577
|
-
storage.setJson('tamnora-collapsed-groups', [...state.collapsedGroups]);
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
1579
|
// Pestañas cerradas desde el último reporte: para que el server las olvide ya,
|
|
1581
1580
|
// sin esperar el TTL de presencia.
|
|
1582
1581
|
const presenceClosed = new Set();
|
|
@@ -1611,9 +1610,10 @@ function renderSessions(sessions) {
|
|
|
1611
1610
|
for (const [key, items] of groups) {
|
|
1612
1611
|
const groupLi = el('li', 'session-group');
|
|
1613
1612
|
const head = el('div', 'group-head');
|
|
1614
|
-
const
|
|
1613
|
+
const open = state.expandedGroups.has(key);
|
|
1614
|
+
head.classList.toggle('open', open);
|
|
1615
1615
|
const twisty = el('span', 'tw');
|
|
1616
|
-
twisty.appendChild(icon(
|
|
1616
|
+
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
1617
1617
|
head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
|
|
1618
1618
|
head.title = key || 'sesiones sin carpeta';
|
|
1619
1619
|
const delGroup = el('button', 'group-del');
|
|
@@ -1627,19 +1627,23 @@ function renderSessions(sessions) {
|
|
|
1627
1627
|
});
|
|
1628
1628
|
head.appendChild(delGroup);
|
|
1629
1629
|
const list = el('ul', 'group-sessions');
|
|
1630
|
-
list.hidden =
|
|
1630
|
+
list.hidden = !open;
|
|
1631
1631
|
head.addEventListener('click', () => {
|
|
1632
|
-
const
|
|
1633
|
-
if (
|
|
1634
|
-
else state.
|
|
1635
|
-
|
|
1636
|
-
list.hidden =
|
|
1632
|
+
const nowOpen = !state.expandedGroups.has(key);
|
|
1633
|
+
if (nowOpen) state.expandedGroups.add(key);
|
|
1634
|
+
else state.expandedGroups.delete(key);
|
|
1635
|
+
head.classList.toggle('open', nowOpen);
|
|
1636
|
+
list.hidden = !nowOpen;
|
|
1637
1637
|
clear(twisty);
|
|
1638
|
-
twisty.appendChild(icon(
|
|
1638
|
+
twisty.appendChild(icon(nowOpen ? 'chevron-down' : 'chevron-right', 14));
|
|
1639
1639
|
});
|
|
1640
1640
|
for (const session of items) {
|
|
1641
1641
|
const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
|
|
1642
|
-
|
|
1642
|
+
const main = el('div', 'session-main');
|
|
1643
|
+
main.appendChild(el('span', 'session-title', session.title || '(sin título)'));
|
|
1644
|
+
const meta = [session.provider, session.model].filter(Boolean).join(' / ');
|
|
1645
|
+
if (meta) main.appendChild(el('span', 'session-sub', meta));
|
|
1646
|
+
li.appendChild(main);
|
|
1643
1647
|
// Punto de actividad: el server lo dice en el listado, y el reconciliador
|
|
1644
1648
|
// lo mantiene al dia si el run arranca despues de este render. El estado
|
|
1645
1649
|
// "esperandote a vos" tiene su propio color: un run trabajando en otra
|
|
@@ -4931,7 +4935,7 @@ function refreshTwisty(li, open) {
|
|
|
4931
4935
|
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
4932
4936
|
}
|
|
4933
4937
|
|
|
4934
|
-
const filePreviewState = { current: null, kind: null, loaded: false, open: false, content: '' };
|
|
4938
|
+
const filePreviewState = { current: null, kind: null, lang: null, loaded: false, open: false, content: '' };
|
|
4935
4939
|
|
|
4936
4940
|
const filePreviewTools = setupPreviewTools({
|
|
4937
4941
|
container: $('#file-modal'),
|
|
@@ -4947,6 +4951,36 @@ function filePreviewKind(name) {
|
|
|
4947
4951
|
return null;
|
|
4948
4952
|
}
|
|
4949
4953
|
|
|
4954
|
+
const CODE_LANGS = {
|
|
4955
|
+
js: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
|
|
4956
|
+
ts: 'ts', tsx: 'ts',
|
|
4957
|
+
json: 'json',
|
|
4958
|
+
css: 'css',
|
|
4959
|
+
html: 'html', htm: 'html',
|
|
4960
|
+
xml: 'xml', svg: 'xml',
|
|
4961
|
+
yml: 'yaml', yaml: 'yaml',
|
|
4962
|
+
sh: 'bash', bash: 'bash',
|
|
4963
|
+
py: 'python',
|
|
4964
|
+
};
|
|
4965
|
+
|
|
4966
|
+
function filePreviewLang(name) {
|
|
4967
|
+
const ext = String(name ?? '').split('.').pop().toLowerCase();
|
|
4968
|
+
return CODE_LANGS[ext] || null;
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
function renderCodeView(content, lang) {
|
|
4972
|
+
const gutter = $('#file-gutter');
|
|
4973
|
+
const node = $('#file-content');
|
|
4974
|
+
if (!lang) {
|
|
4975
|
+
node.textContent = content;
|
|
4976
|
+
if (gutter) gutter.textContent = '';
|
|
4977
|
+
return;
|
|
4978
|
+
}
|
|
4979
|
+
const lines = content.replace(/\r\n?/g, '\n').split('\n');
|
|
4980
|
+
if (gutter) gutter.textContent = lines.map((_, i) => i + 1).join('\n');
|
|
4981
|
+
node.innerHTML = highlight(content, lang);
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4950
4984
|
async function openFilePreview(relPath, name) {
|
|
4951
4985
|
const params = new URLSearchParams({ root: state.cwd, path: relPath });
|
|
4952
4986
|
const data = await api(`/api/files/content?${params}`).catch((error) => {
|
|
@@ -4954,31 +4988,36 @@ async function openFilePreview(relPath, name) {
|
|
|
4954
4988
|
return null;
|
|
4955
4989
|
});
|
|
4956
4990
|
if (!data) return;
|
|
4991
|
+
const code = $('#file-code');
|
|
4992
|
+
const lang = filePreviewLang(name);
|
|
4993
|
+
filePreviewState.current = { relPath, name };
|
|
4994
|
+
filePreviewState.kind = filePreviewKind(name);
|
|
4995
|
+
filePreviewState.lang = lang;
|
|
4996
|
+
filePreviewState.loaded = false;
|
|
4997
|
+
filePreviewState.open = false;
|
|
4998
|
+
filePreviewState.content = data.content;
|
|
4999
|
+
|
|
4957
5000
|
$('#file-title').textContent = name;
|
|
4958
|
-
$('#file-meta').textContent = `${data.size} bytes`;
|
|
4959
|
-
|
|
5001
|
+
$('#file-meta').textContent = lang ? `${data.size} bytes · ${lang}` : `${data.size} bytes`;
|
|
5002
|
+
renderCodeView(data.content, lang);
|
|
5003
|
+
code.classList.toggle('has-lang', Boolean(lang));
|
|
4960
5004
|
|
|
4961
5005
|
const toggle = $('#file-preview-toggle');
|
|
4962
5006
|
const device = $('#file-device');
|
|
4963
5007
|
const maxButton = $('#file-max');
|
|
4964
5008
|
const modal = $('#file-modal');
|
|
4965
|
-
filePreviewState.current = { relPath, name };
|
|
4966
|
-
filePreviewState.kind = filePreviewKind(name);
|
|
4967
|
-
filePreviewState.loaded = false;
|
|
4968
|
-
filePreviewState.open = false;
|
|
4969
|
-
filePreviewState.content = data.content;
|
|
4970
5009
|
|
|
4971
5010
|
toggle.hidden = !filePreviewState.kind;
|
|
4972
5011
|
toggle.textContent = 'vista previa';
|
|
4973
5012
|
device.hidden = true;
|
|
4974
5013
|
device.value = 'auto';
|
|
4975
|
-
maxButton.hidden =
|
|
5014
|
+
maxButton.hidden = false;
|
|
4976
5015
|
$('#file-frame').src = 'about:blank';
|
|
4977
5016
|
$('#file-stage').hidden = true;
|
|
4978
5017
|
$('#file-render').hidden = true;
|
|
4979
5018
|
$('#file-render').innerHTML = '';
|
|
4980
|
-
|
|
4981
|
-
|
|
5019
|
+
code.hidden = false;
|
|
5020
|
+
filePreviewTools.setMax(false);
|
|
4982
5021
|
filePreviewTools.applyDevice();
|
|
4983
5022
|
|
|
4984
5023
|
modal.showModal();
|
|
@@ -4986,7 +5025,7 @@ async function openFilePreview(relPath, name) {
|
|
|
4986
5025
|
|
|
4987
5026
|
$('#file-preview-toggle')?.addEventListener('click', async () => {
|
|
4988
5027
|
const toggle = $('#file-preview-toggle');
|
|
4989
|
-
const
|
|
5028
|
+
const code = $('#file-code');
|
|
4990
5029
|
const stage = $('#file-stage');
|
|
4991
5030
|
const frame = $('#file-frame');
|
|
4992
5031
|
const render = $('#file-render');
|
|
@@ -4998,22 +5037,22 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
|
|
|
4998
5037
|
filePreviewState.open = !filePreviewState.open;
|
|
4999
5038
|
const off = () => {
|
|
5000
5039
|
toggle.textContent = 'vista previa';
|
|
5001
|
-
|
|
5040
|
+
code.hidden = false;
|
|
5002
5041
|
stage.hidden = true;
|
|
5003
5042
|
render.hidden = true;
|
|
5004
5043
|
device.hidden = true;
|
|
5005
|
-
maxButton.hidden =
|
|
5044
|
+
maxButton.hidden = false;
|
|
5006
5045
|
$('#file-modal').classList.remove('preview-maximized');
|
|
5007
5046
|
};
|
|
5008
5047
|
if (!filePreviewState.open) return off();
|
|
5009
5048
|
|
|
5010
5049
|
if (filePreviewState.kind === 'markdown') {
|
|
5011
5050
|
render.innerHTML = renderMarkdown(filePreviewState.content);
|
|
5012
|
-
|
|
5051
|
+
code.hidden = true;
|
|
5013
5052
|
stage.hidden = true;
|
|
5014
5053
|
render.hidden = false;
|
|
5015
5054
|
device.hidden = true;
|
|
5016
|
-
maxButton.hidden =
|
|
5055
|
+
maxButton.hidden = false;
|
|
5017
5056
|
toggle.textContent = 'ver código';
|
|
5018
5057
|
return;
|
|
5019
5058
|
}
|
|
@@ -5035,7 +5074,7 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
|
|
|
5035
5074
|
filePreviewTools.applyDevice();
|
|
5036
5075
|
}
|
|
5037
5076
|
toggle.textContent = 'ver código';
|
|
5038
|
-
|
|
5077
|
+
code.hidden = true;
|
|
5039
5078
|
render.hidden = true;
|
|
5040
5079
|
stage.hidden = false;
|
|
5041
5080
|
});
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</head>
|
|
38
38
|
<body>
|
|
39
39
|
<header class="topbar">
|
|
40
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.
|
|
40
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.11.3</span></div>
|
|
41
41
|
<nav class="tabs">
|
|
42
42
|
<button class="tab active" data-tab="chat">chat</button>
|
|
43
43
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -350,7 +350,10 @@
|
|
|
350
350
|
</button>
|
|
351
351
|
</div>
|
|
352
352
|
</div>
|
|
353
|
-
<
|
|
353
|
+
<div id="file-code" class="code-view">
|
|
354
|
+
<pre id="file-gutter" class="code-gutter" aria-hidden="true"></pre>
|
|
355
|
+
<pre id="file-content" class="file-preview"></pre>
|
|
356
|
+
</div>
|
|
354
357
|
<div id="file-render" class="file-render text" hidden></div>
|
|
355
358
|
<div id="file-stage" class="preview-stage" data-device="auto" hidden>
|
|
356
359
|
<iframe id="file-frame" class="file-frame" title="vista previa del archivo"></iframe>
|
|
@@ -25,6 +25,9 @@ function normalizeLang(lang) {
|
|
|
25
25
|
if (value === 'javascript' || value === 'mjs' || value === 'cjs') return 'js';
|
|
26
26
|
if (value === 'shell' || value === 'zsh' || value === 'console') return 'bash';
|
|
27
27
|
if (value === 'yml' || value === 'yaml') return 'yaml';
|
|
28
|
+
if (value === 'htm') return 'html';
|
|
29
|
+
if (value === 'svg') return 'xml';
|
|
30
|
+
if (value === 'scss' || value === 'less') return 'css';
|
|
28
31
|
return value;
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -32,9 +35,8 @@ function wrap(cls, text) {
|
|
|
32
35
|
return `<span class="${cls}">${escapeHtml(text)}</span>`;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
const language = normalizeLang(lang);
|
|
38
|
+
/** Tokenizer generico: comentarios, strings, numeros y keywords. Best effort. */
|
|
39
|
+
function highlightCode(code, language) {
|
|
38
40
|
const keywords = new Set((KEYWORDS[language] ?? '').split(/\s+/).filter(Boolean));
|
|
39
41
|
const pattern =
|
|
40
42
|
/(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d[\d._]*\b)|([A-Za-z_$][\w$]*)/g;
|
|
@@ -55,6 +57,58 @@ export function highlight(code, lang) {
|
|
|
55
57
|
return out + escapeHtml(code.slice(last));
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/** Resaltado de HTML/XML: comentarios, tags, atributos y strings. */
|
|
61
|
+
function highlightMarkup(code) {
|
|
62
|
+
const pattern =
|
|
63
|
+
/(<!--[\s\S]*?-->)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(<\/?)([A-Za-z][\w:.-]*)|([A-Za-z_:][-A-Za-z0-9_:.]*)(?=\s*=)|(\/?>)/g;
|
|
64
|
+
let out = '';
|
|
65
|
+
let last = 0;
|
|
66
|
+
let match;
|
|
67
|
+
while ((match = pattern.exec(code))) {
|
|
68
|
+
out += escapeHtml(code.slice(last, match.index));
|
|
69
|
+
const [full, comment, str, bracket, tag, attr, close] = match;
|
|
70
|
+
if (comment) out += wrap('tok-comment', comment);
|
|
71
|
+
else if (str) out += wrap('tok-string', str);
|
|
72
|
+
else if (bracket) out += wrap('tok-punct', bracket) + (tag ? wrap('tok-tag', tag) : '');
|
|
73
|
+
else if (attr) out += wrap('tok-attr', attr);
|
|
74
|
+
else if (close) out += wrap('tok-punct', close);
|
|
75
|
+
else out += escapeHtml(full);
|
|
76
|
+
last = match.index + full.length;
|
|
77
|
+
}
|
|
78
|
+
return out + escapeHtml(code.slice(last));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resaltado de CSS: comentarios, selectores, propiedades, at-rules y valores. */
|
|
82
|
+
function highlightCss(code) {
|
|
83
|
+
const pattern =
|
|
84
|
+
/(\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(@[A-Za-z-]+)|(#[0-9a-fA-F]{3,8}\b)|(\b-?\d[\d.]*(?:px|em|rem|%|vh|vw|vmin|vmax|s|ms|fr|deg|ch|pt|ex)?\b)|([.#][A-Za-z_][\w-]*)|([-\w]+)(?=\s*:)/g;
|
|
85
|
+
let out = '';
|
|
86
|
+
let last = 0;
|
|
87
|
+
let match;
|
|
88
|
+
while ((match = pattern.exec(code))) {
|
|
89
|
+
out += escapeHtml(code.slice(last, match.index));
|
|
90
|
+
const [full, comment, str, at, color, num, selector, prop] = match;
|
|
91
|
+
if (comment) out += wrap('tok-comment', full);
|
|
92
|
+
else if (str) out += wrap('tok-string', full);
|
|
93
|
+
else if (at) out += wrap('tok-tag', full);
|
|
94
|
+
else if (color) out += wrap('tok-number', full);
|
|
95
|
+
else if (num) out += wrap('tok-number', full);
|
|
96
|
+
else if (selector) out += wrap('tok-selector', full);
|
|
97
|
+
else if (prop) out += wrap('tok-attr', full);
|
|
98
|
+
else out += escapeHtml(full);
|
|
99
|
+
last = match.index + full.length;
|
|
100
|
+
}
|
|
101
|
+
return out + escapeHtml(code.slice(last));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Minimal highlighter: best effort, always HTML-escaped. */
|
|
105
|
+
export function highlight(code, lang) {
|
|
106
|
+
const language = normalizeLang(lang);
|
|
107
|
+
if (language === 'html' || language === 'xml') return highlightMarkup(code);
|
|
108
|
+
if (language === 'css') return highlightCss(code);
|
|
109
|
+
return highlightCode(code, language);
|
|
110
|
+
}
|
|
111
|
+
|
|
58
112
|
function inline(text) {
|
|
59
113
|
let out = escapeHtml(text);
|
|
60
114
|
const codes = [];
|
|
@@ -54,6 +54,10 @@ html[data-theme='paper'] .tok-comment { color: #7a7669; }
|
|
|
54
54
|
html[data-theme='paper'] .tok-string { color: #7a4a2c; }
|
|
55
55
|
html[data-theme='paper'] .tok-number { color: #8a5a00; }
|
|
56
56
|
html[data-theme='paper'] .tok-keyword { color: #a3341f; }
|
|
57
|
+
html[data-theme='paper'] .tok-tag { color: #a3341f; }
|
|
58
|
+
html[data-theme='paper'] .tok-attr { color: #4f6b46; }
|
|
59
|
+
html[data-theme='paper'] .tok-selector { color: #8a5a00; }
|
|
60
|
+
html[data-theme='paper'] .tok-punct { color: #7a7669; }
|
|
57
61
|
html[data-theme='paper'] .btn.primary { color: #fbf6ec; }
|
|
58
62
|
|
|
59
63
|
* { box-sizing: border-box; }
|
|
@@ -118,6 +122,7 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
118
122
|
}
|
|
119
123
|
.brand .logo img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
|
120
124
|
.brand .ver { color: var(--muted); font-family: var(--mono); font-weight: 400; font-size: 10px; letter-spacing: 0.04em; }
|
|
125
|
+
.brand .topbar-section { display: none; }
|
|
121
126
|
.tabs { grid-column: 2; justify-self: center; display: flex; gap: 2px; }
|
|
122
127
|
.tab {
|
|
123
128
|
background: transparent;
|
|
@@ -245,13 +250,16 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
|
|
|
245
250
|
display: flex;
|
|
246
251
|
align-items: center;
|
|
247
252
|
gap: 7px;
|
|
248
|
-
padding:
|
|
249
|
-
border-radius:
|
|
253
|
+
padding: 7px 9px;
|
|
254
|
+
border-radius: 8px;
|
|
250
255
|
cursor: pointer;
|
|
251
|
-
color: var(--
|
|
256
|
+
color: var(--text);
|
|
257
|
+
border: 1px solid transparent;
|
|
258
|
+
transition: background 0.12s ease, border-color 0.12s ease;
|
|
252
259
|
}
|
|
253
|
-
.group-head:hover { background: var(--panel); color: var(--
|
|
254
|
-
.group-head .tw { width: 14px; height: 14px; flex: 0 0 auto; display: grid; place-items: center; }
|
|
260
|
+
.group-head:hover { background: var(--panel); border-color: var(--border); }
|
|
261
|
+
.group-head .tw { width: 14px; height: 14px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
|
|
262
|
+
.group-head .tw svg { transition: transform 0.15s ease; }
|
|
255
263
|
.group-head .fi { width: 15px; height: 15px; flex: 0 0 auto; color: var(--accent); }
|
|
256
264
|
.group-head .gname {
|
|
257
265
|
flex: 1;
|
|
@@ -259,27 +267,65 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
|
|
|
259
267
|
overflow: hidden;
|
|
260
268
|
text-overflow: ellipsis;
|
|
261
269
|
white-space: nowrap;
|
|
262
|
-
font: 600
|
|
270
|
+
font: 600 11.5px var(--sans);
|
|
263
271
|
letter-spacing: 0.02em;
|
|
264
272
|
}
|
|
265
|
-
.group-head .gcount {
|
|
266
|
-
|
|
273
|
+
.group-head .gcount {
|
|
274
|
+
flex: 0 0 auto;
|
|
275
|
+
min-width: 18px;
|
|
276
|
+
padding: 1px 6px;
|
|
277
|
+
text-align: center;
|
|
278
|
+
border-radius: 999px;
|
|
279
|
+
background: var(--theirs);
|
|
280
|
+
border: 1px solid var(--border);
|
|
281
|
+
font: 600 10px var(--mono);
|
|
282
|
+
color: var(--muted);
|
|
283
|
+
}
|
|
284
|
+
.group-sessions {
|
|
285
|
+
list-style: none;
|
|
286
|
+
margin: 3px 0 5px 9px;
|
|
287
|
+
padding: 0 0 0 11px;
|
|
288
|
+
border-left: 1px solid var(--border);
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-direction: column;
|
|
291
|
+
gap: 2px;
|
|
292
|
+
}
|
|
267
293
|
.session-item {
|
|
294
|
+
position: relative;
|
|
268
295
|
display: flex;
|
|
269
296
|
align-items: center;
|
|
270
297
|
gap: 6px;
|
|
271
|
-
padding:
|
|
272
|
-
border-radius:
|
|
298
|
+
padding: 7px 9px;
|
|
299
|
+
border-radius: 7px;
|
|
273
300
|
cursor: pointer;
|
|
274
301
|
color: var(--muted);
|
|
275
302
|
white-space: nowrap;
|
|
276
303
|
overflow: hidden;
|
|
277
304
|
border: 1px solid transparent;
|
|
305
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
278
306
|
}
|
|
279
307
|
.session-item:hover { background: var(--panel); color: var(--text); }
|
|
280
308
|
.session-item.active { background: var(--theirs); color: var(--text); border-color: var(--border); }
|
|
309
|
+
.session-item.active::before {
|
|
310
|
+
content: '';
|
|
311
|
+
position: absolute;
|
|
312
|
+
left: 0;
|
|
313
|
+
top: 7px;
|
|
314
|
+
bottom: 7px;
|
|
315
|
+
width: 2px;
|
|
316
|
+
border-radius: 2px;
|
|
317
|
+
background: var(--accent);
|
|
318
|
+
}
|
|
281
319
|
.session-item .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
|
|
282
|
-
.session-
|
|
320
|
+
.session-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
321
|
+
.session-item .session-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
322
|
+
.session-item .session-sub {
|
|
323
|
+
color: var(--muted);
|
|
324
|
+
font: 500 10.5px var(--mono);
|
|
325
|
+
overflow: hidden;
|
|
326
|
+
text-overflow: ellipsis;
|
|
327
|
+
opacity: 0.75;
|
|
328
|
+
}
|
|
283
329
|
.session-live {
|
|
284
330
|
flex: none;
|
|
285
331
|
width: 6px;
|
|
@@ -1108,6 +1154,10 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1108
1154
|
.tok-string { color: #c2a878; }
|
|
1109
1155
|
.tok-number { color: #e0a05c; }
|
|
1110
1156
|
.tok-keyword { color: #e0794f; }
|
|
1157
|
+
.tok-tag { color: #e0794f; }
|
|
1158
|
+
.tok-attr { color: #8fae7f; }
|
|
1159
|
+
.tok-selector { color: #d9a441; }
|
|
1160
|
+
.tok-punct { color: #9c9789; }
|
|
1111
1161
|
|
|
1112
1162
|
.composer-status {
|
|
1113
1163
|
display: flex;
|
|
@@ -1343,10 +1393,14 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1343
1393
|
.explorer-filter input { width: 100%; }
|
|
1344
1394
|
|
|
1345
1395
|
.tree { list-style: none; margin: 0; padding: 2px 8px 12px; overflow-y: auto; flex: 1; min-height: 0; }
|
|
1346
|
-
|
|
1396
|
+
/* El subárbol queda oculto salvo que su carpeta esté abierta (clase .open que
|
|
1397
|
+
mantiene el JS). Sin esto, expandir no tenía vuelta atrás. */
|
|
1398
|
+
.tree .tree { padding: 0 0 0 13px; overflow: visible; display: none; }
|
|
1399
|
+
.tree li.open > .tree { display: block; }
|
|
1347
1400
|
.tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 7px; cursor: pointer; white-space: nowrap; }
|
|
1348
1401
|
.tree-row:hover { background: var(--theirs); }
|
|
1349
1402
|
.tree-row .tw { width: 14px; height: 14px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
|
|
1403
|
+
.tree-row .tw svg { transition: transform 0.15s ease; }
|
|
1350
1404
|
.tree-row .fi { width: 15px; height: 15px; flex: 0 0 auto; color: var(--muted); }
|
|
1351
1405
|
.tree-row.dir .fi { color: var(--accent); }
|
|
1352
1406
|
.tree-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
@@ -1431,6 +1485,42 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1431
1485
|
white-space: pre;
|
|
1432
1486
|
}
|
|
1433
1487
|
|
|
1488
|
+
/* Visor de codigo: gutter con numeros de linea + resaltado (estilo VSCode). */
|
|
1489
|
+
.code-view {
|
|
1490
|
+
display: flex;
|
|
1491
|
+
align-items: stretch;
|
|
1492
|
+
max-height: 62vh;
|
|
1493
|
+
overflow: auto;
|
|
1494
|
+
background: var(--theirs);
|
|
1495
|
+
border: 1px solid var(--border);
|
|
1496
|
+
border-radius: 10px;
|
|
1497
|
+
}
|
|
1498
|
+
.code-view .file-preview {
|
|
1499
|
+
flex: 1;
|
|
1500
|
+
min-width: 0;
|
|
1501
|
+
max-height: none;
|
|
1502
|
+
overflow: visible;
|
|
1503
|
+
border: 0;
|
|
1504
|
+
border-radius: 0;
|
|
1505
|
+
background: transparent;
|
|
1506
|
+
}
|
|
1507
|
+
.code-gutter {
|
|
1508
|
+
position: sticky;
|
|
1509
|
+
left: 0;
|
|
1510
|
+
z-index: 1;
|
|
1511
|
+
flex: 0 0 auto;
|
|
1512
|
+
margin: 0;
|
|
1513
|
+
padding: 12px 8px 12px 10px;
|
|
1514
|
+
text-align: right;
|
|
1515
|
+
color: var(--muted);
|
|
1516
|
+
background: var(--panel);
|
|
1517
|
+
border-right: 1px solid var(--border);
|
|
1518
|
+
font: 12px/1.55 var(--mono);
|
|
1519
|
+
white-space: pre;
|
|
1520
|
+
user-select: none;
|
|
1521
|
+
}
|
|
1522
|
+
.code-view:not(.has-lang) .code-gutter { display: none; }
|
|
1523
|
+
|
|
1434
1524
|
.file-render {
|
|
1435
1525
|
margin: 0;
|
|
1436
1526
|
padding: 14px 16px;
|
|
@@ -1467,10 +1557,12 @@ dialog.file-modal.preview-maximized {
|
|
|
1467
1557
|
.file-modal.preview-maximized .modal-card { height: 100vh; }
|
|
1468
1558
|
.file-modal.preview-maximized .file-stage,
|
|
1469
1559
|
.file-modal.preview-maximized .file-render,
|
|
1560
|
+
.file-modal.preview-maximized .code-view,
|
|
1470
1561
|
.file-modal.preview-maximized .file-preview {
|
|
1471
1562
|
flex: 1;
|
|
1472
1563
|
height: auto;
|
|
1473
1564
|
max-height: none;
|
|
1565
|
+
min-height: 0;
|
|
1474
1566
|
}
|
|
1475
1567
|
|
|
1476
1568
|
/* panel heads + collapsed handles ----------------------------------------- */
|
|
@@ -1541,6 +1633,18 @@ dialog.file-modal.preview-maximized {
|
|
|
1541
1633
|
.brand .ver {
|
|
1542
1634
|
display: none;
|
|
1543
1635
|
}
|
|
1636
|
+
.brand .topbar-section {
|
|
1637
|
+
display: inline-block;
|
|
1638
|
+
padding: 2px 8px;
|
|
1639
|
+
border-radius: 999px;
|
|
1640
|
+
border: 1px solid var(--border);
|
|
1641
|
+
background: var(--accent-soft);
|
|
1642
|
+
color: var(--accent);
|
|
1643
|
+
font: 700 10px var(--sans);
|
|
1644
|
+
text-transform: uppercase;
|
|
1645
|
+
letter-spacing: 0.08em;
|
|
1646
|
+
white-space: nowrap;
|
|
1647
|
+
}
|
|
1544
1648
|
.tabs {
|
|
1545
1649
|
margin-left: auto;
|
|
1546
1650
|
max-width: 100%;
|
|
@@ -1549,7 +1653,7 @@ dialog.file-modal.preview-maximized {
|
|
|
1549
1653
|
display: none;
|
|
1550
1654
|
}
|
|
1551
1655
|
#more-tab-label {
|
|
1552
|
-
display:
|
|
1656
|
+
display: none;
|
|
1553
1657
|
}
|
|
1554
1658
|
.menu-pop {
|
|
1555
1659
|
top: calc(100% + 2px);
|
|
@@ -64,8 +64,25 @@ test('a lone pipe line without a delimiter row stays a paragraph', () => {
|
|
|
64
64
|
assert.equal(html, '<p>foo | bar</p>');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test('highlight escapes code content', () => {
|
|
68
|
-
assert.equal(highlight('a < b', 'bash'), 'a < b');
|
|
67
|
+
test('highlight escapes code content', () => {
|
|
68
|
+
assert.equal(highlight('a < b', 'bash'), 'a < b');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('highlight resalta html: tags, atributos, strings y comentarios', () => {
|
|
72
|
+
const html = highlight('<a href="/x">hola</a><!-- nota -->', 'html');
|
|
73
|
+
assert.match(html, /tok-tag">a</);
|
|
74
|
+
assert.match(html, /tok-attr">href</);
|
|
75
|
+
assert.match(html, /tok-string">"\/x"</);
|
|
76
|
+
assert.match(html, /tok-comment"><!-- nota --></);
|
|
77
|
+
assert.ok(!html.includes('<a '));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('highlight resalta css: selectores, propiedades, colores y medidas', () => {
|
|
81
|
+
const css = highlight('.btn { color: #fff; width: 10px; }', 'css');
|
|
82
|
+
assert.match(css, /tok-selector">\.btn</);
|
|
83
|
+
assert.match(css, /tok-attr">color</);
|
|
84
|
+
assert.match(css, /tok-number">#fff</);
|
|
85
|
+
assert.match(css, /tok-number">10px</);
|
|
69
86
|
});
|
|
70
87
|
|
|
71
88
|
test('speechText saca bloques de codigo y tablas', () => {
|
package/package.json
CHANGED