@danieltmn/tamnora 0.21.1 → 0.22.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 +15 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +181 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +54 -53
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/tools.test.js +98 -2
- package/harness/packages/server/public/app.js +343 -6729
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +29 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/procs.js +30 -3
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/procs.js +2 -6
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +28 -2
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/procs-manager.test.js +43 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- package/harness/packages/server/test/server.test.js +0 -2878
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { restorePreviewMax } from './app.js';
|
|
2
|
+
import { panels, setStatusText } from './chrome.js';
|
|
3
|
+
import { $, api, button, clear, el, icon, state, storage } from './core.js';
|
|
4
|
+
import { highlight } from './markdown.js';
|
|
5
|
+
|
|
6
|
+
/* --- explorer (right panel) ----------------------------------------------- */
|
|
7
|
+
|
|
8
|
+
export const explorer = $('#explorer');
|
|
9
|
+
export const explorerTree = $('#explorer-tree');
|
|
10
|
+
export const explorerRoot = $('#explorer-root');
|
|
11
|
+
export const explorerFilter = $('#explorer-filter');
|
|
12
|
+
export const explorerStatus = $('#explorer-status');
|
|
13
|
+
|
|
14
|
+
export function setExplorerStatus(text, isError = false) {
|
|
15
|
+
setStatusText(explorerStatus, text, isError);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setExplorerOpen(open) {
|
|
19
|
+
state.explorerOpen = open;
|
|
20
|
+
explorer.hidden = !open;
|
|
21
|
+
panels.explorer.resizer.hidden = !open;
|
|
22
|
+
if (!open) restorePreviewMax?.();
|
|
23
|
+
const handle = $('#explorer-handle');
|
|
24
|
+
if (handle) {
|
|
25
|
+
handle.hidden = open;
|
|
26
|
+
handle.setAttribute('aria-pressed', String(open));
|
|
27
|
+
}
|
|
28
|
+
storage.set('tamnora-explorer-open', open ? '1' : '0');
|
|
29
|
+
if (open) refreshExplorerMode();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fileListUrl(relPath) {
|
|
33
|
+
const params = new URLSearchParams({ root: state.cwd, path: relPath });
|
|
34
|
+
return `/api/files?${params}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadTreeDir(relPath, container) {
|
|
38
|
+
clear(container);
|
|
39
|
+
const placeholder = el('li', 'tree-empty muted', 'cargando…');
|
|
40
|
+
container.appendChild(placeholder);
|
|
41
|
+
const data = await api(fileListUrl(relPath)).catch((error) => {
|
|
42
|
+
placeholder.textContent = error.message;
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
if (!data) return;
|
|
46
|
+
clear(container);
|
|
47
|
+
if (!data.entries.length) {
|
|
48
|
+
container.appendChild(el('li', 'tree-empty muted', '(vacío)'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of data.entries) container.appendChild(treeRow(entry, relPath));
|
|
52
|
+
applyTreeFilter();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function joinPath(base, name) {
|
|
56
|
+
return base ? `${base}/${name}` : name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function treeRow(entry, parentPath) {
|
|
60
|
+
const full = joinPath(parentPath, entry.name);
|
|
61
|
+
const li = el('li');
|
|
62
|
+
const row = el('div', `tree-row ${entry.type}`);
|
|
63
|
+
const twisty = el('span', 'tw');
|
|
64
|
+
const iconName = entry.type === 'dir' ? 'folder' : 'file';
|
|
65
|
+
row.append(twisty, icon(iconName, 15, 'fi'), el('span', 'tree-name', entry.name));
|
|
66
|
+
li.appendChild(row);
|
|
67
|
+
|
|
68
|
+
if (entry.type === 'dir') {
|
|
69
|
+
twisty.appendChild(icon('chevron-right', 14));
|
|
70
|
+
let children = null;
|
|
71
|
+
row.addEventListener('click', async () => {
|
|
72
|
+
const open = !li.classList.contains('open');
|
|
73
|
+
li.classList.toggle('open', open);
|
|
74
|
+
clear(twisty);
|
|
75
|
+
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
76
|
+
if (open && !children) {
|
|
77
|
+
children = el('ul', 'tree');
|
|
78
|
+
li.appendChild(children);
|
|
79
|
+
await loadTreeDir(full, children);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
row.addEventListener('click', () => openFilePreview(full, entry.name));
|
|
84
|
+
}
|
|
85
|
+
return li;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function refreshExplorer() {
|
|
89
|
+
if (!state.explorerOpen) return;
|
|
90
|
+
explorerRoot.textContent = state.cwd || '—';
|
|
91
|
+
explorerRoot.title = state.cwd || '';
|
|
92
|
+
explorerFilter.value = '';
|
|
93
|
+
if (!state.cwd) {
|
|
94
|
+
clear(explorerTree);
|
|
95
|
+
setExplorerStatus('sin carpeta de trabajo', true);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setExplorerStatus('cargando…');
|
|
99
|
+
const data = await api(fileListUrl('')).catch((error) => {
|
|
100
|
+
setExplorerStatus(error.message, true);
|
|
101
|
+
return null;
|
|
102
|
+
});
|
|
103
|
+
if (!data) return;
|
|
104
|
+
clear(explorerTree);
|
|
105
|
+
if (!data.entries.length) explorerTree.appendChild(el('li', 'tree-empty muted', '(vacío)'));
|
|
106
|
+
for (const entry of data.entries) explorerTree.appendChild(treeRow(entry, ''));
|
|
107
|
+
const dirs = data.entries.filter((e) => e.type === 'dir').length;
|
|
108
|
+
const files = data.entries.length - dirs;
|
|
109
|
+
setExplorerStatus(`${files} archivos · ${dirs} carpetas`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function matchesFilter(name, query) {
|
|
113
|
+
return name.toLowerCase().includes(query);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function applyTreeFilter() {
|
|
117
|
+
const query = explorerFilter.value.trim().toLowerCase();
|
|
118
|
+
const walk = (ul) => {
|
|
119
|
+
let visible = false;
|
|
120
|
+
for (const li of ul.children) {
|
|
121
|
+
if (!li.querySelector(':scope > .tree-row')) continue;
|
|
122
|
+
const name = li.querySelector(':scope > .tree-row .tree-name')?.textContent ?? '';
|
|
123
|
+
const childUl = li.querySelector(':scope > .tree');
|
|
124
|
+
const childVisible = childUl ? walk(childUl) : false;
|
|
125
|
+
const show = !query || matchesFilter(name, query) || childVisible;
|
|
126
|
+
li.hidden = !show;
|
|
127
|
+
if (show && query && childUl && childVisible && !li.classList.contains('open')) {
|
|
128
|
+
li.classList.add('open');
|
|
129
|
+
refreshTwisty(li, true);
|
|
130
|
+
}
|
|
131
|
+
visible = visible || show;
|
|
132
|
+
}
|
|
133
|
+
return visible;
|
|
134
|
+
};
|
|
135
|
+
walk(explorerTree);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function refreshTwisty(li, open) {
|
|
139
|
+
const twisty = li.querySelector(':scope > .tree-row .tw');
|
|
140
|
+
if (!twisty) return;
|
|
141
|
+
clear(twisty);
|
|
142
|
+
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const filePreviewState = { current: null, kind: null, lang: null, loaded: false, open: false, content: '' };
|
|
146
|
+
|
|
147
|
+
export const filePreviewTools = setupPreviewTools({
|
|
148
|
+
container: $('#file-modal'),
|
|
149
|
+
stage: $('#file-stage'),
|
|
150
|
+
frame: $('#file-frame'),
|
|
151
|
+
select: $('#file-device'),
|
|
152
|
+
maxButton: $('#file-max'),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export function filePreviewKind(name) {
|
|
156
|
+
if (/\.(html?|svg)$/i.test(name)) return 'frame';
|
|
157
|
+
if (/\.(md|markdown)$/i.test(name)) return 'markdown';
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const CODE_LANGS = {
|
|
162
|
+
js: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
|
|
163
|
+
ts: 'ts', tsx: 'ts',
|
|
164
|
+
json: 'json',
|
|
165
|
+
css: 'css',
|
|
166
|
+
html: 'html', htm: 'html',
|
|
167
|
+
xml: 'xml', svg: 'xml',
|
|
168
|
+
yml: 'yaml', yaml: 'yaml',
|
|
169
|
+
sh: 'bash', bash: 'bash',
|
|
170
|
+
py: 'python',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export function filePreviewLang(name) {
|
|
174
|
+
const ext = String(name ?? '').split('.').pop().toLowerCase();
|
|
175
|
+
return CODE_LANGS[ext] || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function renderCodeView(content, lang) {
|
|
179
|
+
const gutter = $('#file-gutter');
|
|
180
|
+
const node = $('#file-content');
|
|
181
|
+
if (!lang) {
|
|
182
|
+
node.textContent = content;
|
|
183
|
+
if (gutter) gutter.textContent = '';
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const lines = content.replace(/\r\n?/g, '\n').split('\n');
|
|
187
|
+
if (gutter) gutter.textContent = lines.map((_, i) => i + 1).join('\n');
|
|
188
|
+
node.innerHTML = highlight(content, lang);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function openFilePreview(relPath, name) {
|
|
192
|
+
const params = new URLSearchParams({ root: state.cwd, path: relPath });
|
|
193
|
+
const data = await api(`/api/files/content?${params}`).catch((error) => {
|
|
194
|
+
setExplorerStatus(error.message, true);
|
|
195
|
+
return null;
|
|
196
|
+
});
|
|
197
|
+
if (!data) return;
|
|
198
|
+
const code = $('#file-code');
|
|
199
|
+
const lang = filePreviewLang(name);
|
|
200
|
+
filePreviewState.current = { relPath, name };
|
|
201
|
+
filePreviewState.kind = filePreviewKind(name);
|
|
202
|
+
filePreviewState.lang = lang;
|
|
203
|
+
filePreviewState.loaded = false;
|
|
204
|
+
filePreviewState.open = false;
|
|
205
|
+
filePreviewState.content = data.content;
|
|
206
|
+
|
|
207
|
+
$('#file-title').textContent = name;
|
|
208
|
+
$('#file-meta').textContent = lang ? `${data.size} bytes · ${lang}` : `${data.size} bytes`;
|
|
209
|
+
renderCodeView(data.content, lang);
|
|
210
|
+
code.classList.toggle('has-lang', Boolean(lang));
|
|
211
|
+
|
|
212
|
+
const toggle = $('#file-preview-toggle');
|
|
213
|
+
const device = $('#file-device');
|
|
214
|
+
const maxButton = $('#file-max');
|
|
215
|
+
const modal = $('#file-modal');
|
|
216
|
+
|
|
217
|
+
toggle.hidden = !filePreviewState.kind;
|
|
218
|
+
toggle.textContent = 'vista previa';
|
|
219
|
+
device.hidden = true;
|
|
220
|
+
device.value = 'auto';
|
|
221
|
+
maxButton.hidden = false;
|
|
222
|
+
$('#file-frame').src = 'about:blank';
|
|
223
|
+
$('#file-stage').hidden = true;
|
|
224
|
+
$('#file-render').hidden = true;
|
|
225
|
+
$('#file-render').innerHTML = '';
|
|
226
|
+
code.hidden = false;
|
|
227
|
+
filePreviewTools.setMax(false);
|
|
228
|
+
filePreviewTools.applyDevice();
|
|
229
|
+
|
|
230
|
+
modal.showModal();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* --- explorer panes: estructura / preview / procs ------------------------- */
|
|
234
|
+
|
|
235
|
+
export const xpPanes = { files: $('#xp-files'), preview: $('#xp-preview'), procs: $('#xp-procs'), git: $('#xp-git') };
|
|
236
|
+
export let explorerMode = 'files';
|
|
237
|
+
|
|
238
|
+
export function setExplorerMode(mode) {
|
|
239
|
+
explorerMode = xpPanes[mode] ? mode : 'files';
|
|
240
|
+
for (const [name, pane] of Object.entries(xpPanes)) pane.hidden = name !== explorerMode;
|
|
241
|
+
document.querySelectorAll('.xtab').forEach((b) => b.classList.toggle('is-active', b.dataset.xp === explorerMode));
|
|
242
|
+
refreshExplorerMode();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function refreshExplorerMode() {
|
|
246
|
+
if (!state.explorerOpen) return;
|
|
247
|
+
if (explorerMode === 'files') refreshExplorer();
|
|
248
|
+
else if (explorerMode === 'procs') detectProcs();
|
|
249
|
+
else if (explorerMode === 'git') refreshGit();
|
|
250
|
+
else if (explorerMode === 'preview') syncPreviewTunnel();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* preview: iframe del dev server (local o por tunel) */
|
|
254
|
+
|
|
255
|
+
export const previewFrame = $('#preview-frame');
|
|
256
|
+
export const previewUrlInput = $('#preview-url');
|
|
257
|
+
export const previewStatusEl = $('#preview-status');
|
|
258
|
+
|
|
259
|
+
export function setPreviewStatus(text, isError = false) {
|
|
260
|
+
setStatusText(previewStatusEl, text, isError);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Ancho por dispositivo + maximizar, compartido por preview y visor de archivos. */
|
|
264
|
+
export function setupPreviewTools({ container, stage, frame, select, maxButton, teleport = false }) {
|
|
265
|
+
const applyDevice = () => {
|
|
266
|
+
const value = select?.value || 'auto';
|
|
267
|
+
if (stage) stage.dataset.device = value;
|
|
268
|
+
frame.style.width = value === 'auto' ? '100%' : `${value}px`;
|
|
269
|
+
};
|
|
270
|
+
select?.addEventListener('change', applyDevice);
|
|
271
|
+
// El panel del explorador usa `backdrop-filter`, que crea un containing block
|
|
272
|
+
// y dejaria el `position: fixed` encerrado en la barra. Al maximizar movemos
|
|
273
|
+
// el contenedor a `body` y lo devolvemos a su lugar al restaurar.
|
|
274
|
+
const home = teleport ? container?.parentNode : null;
|
|
275
|
+
const homeAfter = teleport ? container?.nextSibling : null;
|
|
276
|
+
const setMax = (on) => {
|
|
277
|
+
if (teleport && container && home) {
|
|
278
|
+
if (on) document.body.appendChild(container);
|
|
279
|
+
else if (!home.contains(container)) home.insertBefore(container, homeAfter ?? null);
|
|
280
|
+
}
|
|
281
|
+
container?.classList.toggle('preview-maximized', on);
|
|
282
|
+
maxButton?.setAttribute('aria-pressed', String(on));
|
|
283
|
+
if (maxButton) maxButton.title = on ? 'restaurar' : 'maximizar';
|
|
284
|
+
};
|
|
285
|
+
maxButton?.addEventListener('click', () => setMax(!container?.classList.contains('preview-maximized')));
|
|
286
|
+
document.addEventListener('keydown', (event) => {
|
|
287
|
+
if (event.key === 'Escape' && container?.classList.contains('preview-maximized')) setMax(false);
|
|
288
|
+
});
|
|
289
|
+
return { applyDevice, setMax };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export const previewTools = setupPreviewTools({
|
|
293
|
+
container: $('#xp-preview'),
|
|
294
|
+
stage: $('#preview-stage'),
|
|
295
|
+
frame: previewFrame,
|
|
296
|
+
select: $('#preview-device'),
|
|
297
|
+
maxButton: $('#preview-max'),
|
|
298
|
+
teleport: true,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
export const PREVIEW_URL_KEY = 'tamnora-preview-url';
|
|
302
|
+
export const PREVIEW_DEVICE_KEY = 'tamnora-preview-device';
|
|
303
|
+
|
|
304
|
+
/** True si la consola corre en la misma maquina que el server (loopback). */
|
|
305
|
+
export function isLocalHost() {
|
|
306
|
+
const host = String(location.hostname || '').toLowerCase();
|
|
307
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host.endsWith('.localhost');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Puerto si la URL apunta a loopback (127.0.0.1/localhost/::1). */
|
|
311
|
+
export function localPortOf(url) {
|
|
312
|
+
const match = String(url ?? '').match(/^https?:\/\/(?:localhost|127(?:\.\d+){3}|\[::1\]):(\d+)/i);
|
|
313
|
+
return match ? Number(match[1]) : 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function setTunnelUi(tunnel) {
|
|
317
|
+
const closeBtn = $('#preview-close');
|
|
318
|
+
if (closeBtn) closeBtn.hidden = !tunnel;
|
|
319
|
+
if (tunnel) {
|
|
320
|
+
const why = tunnel.provider === 'tailscale' ? 'privado (tailscale)' : tunnel.provider ? tunnel.provider : '';
|
|
321
|
+
setPreviewStatus(`acceso remoto activo: ${tunnel.url}${why ? ` · ${why}` : ''}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function syncPreviewTunnel({ load = false } = {}) {
|
|
326
|
+
const data = await api('/api/preview').catch(() => null);
|
|
327
|
+
const tunnel = data?.tunnel || null;
|
|
328
|
+
setTunnelUi(tunnel);
|
|
329
|
+
if (tunnel?.url && load) loadPreview(tunnel.url);
|
|
330
|
+
return tunnel;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Espera a que el server confirme el tunel si la respuesta del POST se corto. */
|
|
334
|
+
export async function pollPreviewTunnel(tries = 5, delayMs = 1200) {
|
|
335
|
+
for (let i = 0; i < tries; i++) {
|
|
336
|
+
const data = await api('/api/preview').catch(() => null);
|
|
337
|
+
if (data?.tunnel?.url) return data.tunnel;
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function loadPreview(url) {
|
|
344
|
+
const value = String(url ?? '').trim();
|
|
345
|
+
if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
|
|
346
|
+
previewUrlInput.value = value;
|
|
347
|
+
previewFrame.src = value;
|
|
348
|
+
storage.set(PREVIEW_URL_KEY, value);
|
|
349
|
+
setPreviewStatus(`cargando ${value} …`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Abre acceso remoto al puerto (tailscale privado o tunel publico). */
|
|
353
|
+
export async function openRemoteTunnel(port) {
|
|
354
|
+
const provider = $('#preview-provider')?.value || 'tailscale';
|
|
355
|
+
setPreviewStatus(`abriendo acceso remoto (${provider})…`);
|
|
356
|
+
const data = await api('/api/preview/tunnel', { method: 'POST', body: { port, provider } }).catch((e) => ({ error: e.message }));
|
|
357
|
+
if (data?.error) {
|
|
358
|
+
// La respuesta pudo cortarse (p. ej. la tablet en background): el server pudo
|
|
359
|
+
// haber abierto igual el tunel, asi que lo confirmamos antes de fallar.
|
|
360
|
+
const tunnel = await pollPreviewTunnel();
|
|
361
|
+
if (!tunnel) return setPreviewStatus(`error: ${data.error}`, true);
|
|
362
|
+
setTunnelUi(tunnel);
|
|
363
|
+
return loadPreview(tunnel.url);
|
|
364
|
+
}
|
|
365
|
+
setTunnelUi(data.tunnel);
|
|
366
|
+
loadPreview(data.tunnel.url);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* procs: deteccion y dev servers */
|
|
370
|
+
|
|
371
|
+
export const procsListEl = $('#procs-list');
|
|
372
|
+
export const procsChipsEl = $('#procs-chips');
|
|
373
|
+
export const procsCwdEl = $('#procs-cwd');
|
|
374
|
+
export const procsLogEl = $('#procs-log');
|
|
375
|
+
export let selectedProc = null;
|
|
376
|
+
|
|
377
|
+
export async function detectProcs() {
|
|
378
|
+
clear(procsChipsEl);
|
|
379
|
+
const cwd = state.cwd || '';
|
|
380
|
+
procsCwdEl.textContent = cwd ? `cwd: ${cwd}` : 'sin carpeta';
|
|
381
|
+
const data = await api(`/api/procs/detect?cwd=${encodeURIComponent(cwd)}`).catch(() => null);
|
|
382
|
+
if (!data) return;
|
|
383
|
+
for (const chip of data.chips) {
|
|
384
|
+
const node = button(chip.label, () => startProc(chip), 'xchip');
|
|
385
|
+
if (chip.source === 'project') node.title = 'definido en .tamnora/procs.json';
|
|
386
|
+
if (chip.allowed === false) {
|
|
387
|
+
node.disabled = true;
|
|
388
|
+
node.title = `"${chip.command}" no está en processes.allow`;
|
|
389
|
+
}
|
|
390
|
+
procsChipsEl.appendChild(node);
|
|
391
|
+
}
|
|
392
|
+
if (!data.chips.length) procsChipsEl.appendChild(el('span', 'muted', 'no detecté comandos de dev'));
|
|
393
|
+
else if (data.chips.some((c) => c.source === 'project')) {
|
|
394
|
+
procsChipsEl.appendChild(el('span', 'muted', '· desde .tamnora/procs.json'));
|
|
395
|
+
}
|
|
396
|
+
await refreshProcs();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function refreshProcs() {
|
|
400
|
+
const data = await api('/api/procs').catch(() => null);
|
|
401
|
+
if (!data) return;
|
|
402
|
+
clear(procsListEl);
|
|
403
|
+
if (!data.enabled) {
|
|
404
|
+
procsListEl.appendChild(el('li', 'muted', 'procesos deshabilitados (processes.enabled = false)'));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!data.procs.length) {
|
|
408
|
+
procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
|
|
409
|
+
}
|
|
410
|
+
for (const proc of data.procs) {
|
|
411
|
+
const li = el('li', proc.running ? 'active' : '');
|
|
412
|
+
const main = el('div', 'grow');
|
|
413
|
+
main.append(
|
|
414
|
+
el('div', '', `${proc.command} ${(proc.args || []).join(' ')}`),
|
|
415
|
+
el('div', 'pmeta', `${proc.status}${proc.port ? ` · puerto ${proc.port}` : ''}`)
|
|
416
|
+
);
|
|
417
|
+
li.appendChild(main);
|
|
418
|
+
const actions = el('div', 'pactions');
|
|
419
|
+
actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
|
|
420
|
+
if (proc.port) actions.appendChild(button('preview', () => openProcPreview(proc.port)));
|
|
421
|
+
if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
|
|
422
|
+
li.appendChild(actions);
|
|
423
|
+
procsListEl.appendChild(li);
|
|
424
|
+
}
|
|
425
|
+
await renderTunnelRow();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Fila del tunel activo: no es un proc (lo maneja la ruta preview), pero
|
|
429
|
+
* conviene verlo y poder cerrarlo aca. */
|
|
430
|
+
export async function renderTunnelRow() {
|
|
431
|
+
const data = await api('/api/preview').catch(() => null);
|
|
432
|
+
const tunnel = data?.tunnel;
|
|
433
|
+
if (!tunnel?.url) return;
|
|
434
|
+
const li = el('li', 'active');
|
|
435
|
+
const main = el('div', 'grow');
|
|
436
|
+
main.append(
|
|
437
|
+
el('div', '', `túnel → puerto ${tunnel.port}`),
|
|
438
|
+
el('div', 'pmeta', `${tunnel.provider || ''} · ${tunnel.url}`)
|
|
439
|
+
);
|
|
440
|
+
li.appendChild(main);
|
|
441
|
+
const actions = el('div', 'pactions');
|
|
442
|
+
actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(tunnel.url); }));
|
|
443
|
+
actions.appendChild(
|
|
444
|
+
button('detener', async () => { await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null); refreshProcs(); }, 'btn danger')
|
|
445
|
+
);
|
|
446
|
+
li.appendChild(actions);
|
|
447
|
+
procsListEl.appendChild(li);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Preview de un proc: local si la consola corre en la misma maquina, remoto
|
|
451
|
+
* (tailscale/tunel) si se accede desde otro dispositivo. */
|
|
452
|
+
export async function openProcPreview(port) {
|
|
453
|
+
setExplorerMode('preview');
|
|
454
|
+
if (isLocalHost()) return loadPreview(`http://127.0.0.1:${port}`);
|
|
455
|
+
return openRemoteTunnel(port);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function startProc(chip) {
|
|
459
|
+
const data = await api('/api/procs/start', {
|
|
460
|
+
method: 'POST',
|
|
461
|
+
body: { cwd: chip.cwd || state.cwd, command: chip.command, args: chip.args, port: chip.port },
|
|
462
|
+
}).catch((e) => ({ error: e.message }));
|
|
463
|
+
if (data?.error) {
|
|
464
|
+
procsCwdEl.classList.add('err');
|
|
465
|
+
procsCwdEl.textContent = `error: ${data.error}`;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
procsCwdEl.classList.remove('err');
|
|
469
|
+
selectedProc = data.proc.id;
|
|
470
|
+
await refreshProcs();
|
|
471
|
+
refreshProcLog();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export async function stopProc(id) {
|
|
475
|
+
await api(`/api/procs/${id}/stop`, { method: 'POST' }).catch(() => null);
|
|
476
|
+
await refreshProcs();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function refreshProcLog() {
|
|
480
|
+
if (!selectedProc) return;
|
|
481
|
+
const data = await api(`/api/procs/${selectedProc}/logs`).catch(() => null);
|
|
482
|
+
if (!data) return;
|
|
483
|
+
procsLogEl.textContent = (data.logs || []).map((l) => l.text).join('').slice(-4000) || '(sin salida)';
|
|
484
|
+
procsLogEl.scrollTop = procsLogEl.scrollHeight;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/* git: estado del árbol de trabajo y diffs */
|
|
488
|
+
|
|
489
|
+
export const gitListEl = $('#git-list');
|
|
490
|
+
export const gitBranchEl = $('#git-branch');
|
|
491
|
+
export const gitSyncEl = $('#git-sync');
|
|
492
|
+
export const gitStatusEl = $('#git-status');
|
|
493
|
+
export const gitFilterEl = $('#git-filter');
|
|
494
|
+
export const gitModalEl = $('#git-modal');
|
|
495
|
+
export const gitDiffEl = $('#git-diff');
|
|
496
|
+
export const gitTitleEl = $('#git-title');
|
|
497
|
+
export const gitMetaEl = $('#git-meta');
|
|
498
|
+
export let gitEntries = [];
|
|
499
|
+
|
|
500
|
+
export const GIT_BADGES = {
|
|
501
|
+
modified: { letter: 'M', cls: 'mod' },
|
|
502
|
+
added: { letter: 'A', cls: 'add' },
|
|
503
|
+
deleted: { letter: 'D', cls: 'del' },
|
|
504
|
+
renamed: { letter: 'R', cls: 'ren' },
|
|
505
|
+
untracked: { letter: 'U', cls: 'new' },
|
|
506
|
+
conflicted: { letter: 'C', cls: 'conf' },
|
|
507
|
+
ignored: { letter: 'I', cls: 'ign' },
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
export function setGitStatus(text, isError = false) {
|
|
511
|
+
setStatusText(gitStatusEl, text, isError);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function gitGroup(entry) {
|
|
515
|
+
const staged = entry.index !== ' ' && entry.index !== '?' && entry.index !== '!';
|
|
516
|
+
const changed =
|
|
517
|
+
entry.status === 'untracked' ||
|
|
518
|
+
(entry.worktree !== ' ' && entry.worktree !== '?' && entry.worktree !== '!');
|
|
519
|
+
return { staged, changed };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function refreshGit() {
|
|
523
|
+
clear(gitListEl);
|
|
524
|
+
gitBranchEl.textContent = '…';
|
|
525
|
+
gitSyncEl.textContent = '';
|
|
526
|
+
if (!state.cwd) return setGitStatus('sin carpeta de trabajo', true);
|
|
527
|
+
setGitStatus('cargando…');
|
|
528
|
+
const data = await api(`/api/git/status?cwd=${encodeURIComponent(state.cwd)}`).catch((error) => {
|
|
529
|
+
setGitStatus(error.message, true);
|
|
530
|
+
return null;
|
|
531
|
+
});
|
|
532
|
+
if (!data) return;
|
|
533
|
+
gitEntries = data.isRepo ? data.entries : [];
|
|
534
|
+
if (!data.isRepo) {
|
|
535
|
+
gitBranchEl.textContent = 'git';
|
|
536
|
+
gitListEl.appendChild(el('li', 'tree-empty muted', 'no es un repositorio git'));
|
|
537
|
+
setGitStatus('sin repositorio');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
gitBranchEl.textContent = data.branch ?? '(sin rama)';
|
|
541
|
+
gitSyncEl.textContent = [data.ahead ? `↑${data.ahead}` : '', data.behind ? `↓${data.behind}` : '']
|
|
542
|
+
.filter(Boolean)
|
|
543
|
+
.join(' ');
|
|
544
|
+
renderGitList();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function renderGitList() {
|
|
548
|
+
clear(gitListEl);
|
|
549
|
+
const query = gitFilterEl.value.trim().toLowerCase();
|
|
550
|
+
const entries = gitEntries.filter(
|
|
551
|
+
(entry) =>
|
|
552
|
+
!query ||
|
|
553
|
+
entry.path.toLowerCase().includes(query) ||
|
|
554
|
+
(entry.origPath ?? '').toLowerCase().includes(query)
|
|
555
|
+
);
|
|
556
|
+
const staged = entries.filter((entry) => gitGroup(entry).staged);
|
|
557
|
+
const changed = entries.filter((entry) => gitGroup(entry).changed);
|
|
558
|
+
if (!entries.length) {
|
|
559
|
+
gitListEl.appendChild(el('li', 'tree-empty muted', gitEntries.length ? 'sin coincidencias' : 'sin cambios'));
|
|
560
|
+
} else {
|
|
561
|
+
renderGitGroup('Preparados', staged, true);
|
|
562
|
+
renderGitGroup('Cambios', changed, false);
|
|
563
|
+
}
|
|
564
|
+
const files = new Set(entries.map((entry) => entry.path)).size;
|
|
565
|
+
setGitStatus(
|
|
566
|
+
entries.length ? `${files} archivo(s) · ${staged.length} preparado(s) · ${changed.length} cambio(s)` : 'sin cambios'
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function renderGitGroup(title, entries, staged) {
|
|
571
|
+
if (!entries.length) return;
|
|
572
|
+
gitListEl.appendChild(el('li', 'git-group', `${title} (${entries.length})`));
|
|
573
|
+
for (const entry of entries) gitListEl.appendChild(gitRow(entry, staged));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function gitRow(entry, staged) {
|
|
577
|
+
const li = el('li', 'git-item');
|
|
578
|
+
const row = el('div', 'git-row');
|
|
579
|
+
const badge = GIT_BADGES[entry.status] ?? GIT_BADGES.modified;
|
|
580
|
+
row.append(
|
|
581
|
+
el('span', `git-badge ${badge.cls}`, badge.letter),
|
|
582
|
+
el('span', 'git-path', entry.origPath ? `${entry.origPath} → ${entry.path}` : entry.path)
|
|
583
|
+
);
|
|
584
|
+
row.addEventListener('click', () => openGitDiff(entry, staged));
|
|
585
|
+
li.appendChild(row);
|
|
586
|
+
return li;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export async function openGitDiff(entry, staged) {
|
|
590
|
+
if (!gitModalEl) return;
|
|
591
|
+
gitTitleEl.textContent = entry.origPath ? `${entry.origPath} → ${entry.path}` : entry.path;
|
|
592
|
+
gitMetaEl.textContent =
|
|
593
|
+
entry.status === 'untracked' ? 'sin seguimiento' : staged ? 'preparado' : 'cambios';
|
|
594
|
+
gitDiffEl.classList.remove('err');
|
|
595
|
+
gitDiffEl.textContent = 'cargando…';
|
|
596
|
+
gitModalEl.showModal();
|
|
597
|
+
const params = new URLSearchParams({ cwd: state.cwd, path: entry.path });
|
|
598
|
+
if (staged) params.set('staged', '1');
|
|
599
|
+
const data = await api(`/api/git/diff?${params}`).catch((error) => ({ error: error.message }));
|
|
600
|
+
if (data?.error) {
|
|
601
|
+
gitDiffEl.classList.add('err');
|
|
602
|
+
gitDiffEl.textContent = data.error;
|
|
603
|
+
setGitStatus(data.error, true);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
renderGitDiff(gitDiffEl, data.diff);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function renderGitDiff(node, text) {
|
|
610
|
+
const content = String(text ?? '').trimEnd();
|
|
611
|
+
node.textContent = '';
|
|
612
|
+
if (!content) {
|
|
613
|
+
node.textContent = '(sin cambios)';
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
for (const line of content.split('\n')) {
|
|
617
|
+
let cls = 'diff-eq';
|
|
618
|
+
if (line.startsWith('@@')) cls = 'diff-sep';
|
|
619
|
+
else if (/^(diff |index |new file|deleted file|rename |similarity |old mode|new mode|\+\+\+|---)/.test(line)) cls = 'diff-meta';
|
|
620
|
+
else if (line.startsWith('+')) cls = 'diff-add';
|
|
621
|
+
else if (line.startsWith('-')) cls = 'diff-del';
|
|
622
|
+
node.appendChild(el('span', `diff-line ${cls}`, line));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|