claude-simple 0.1.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.
Files changed (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
@@ -0,0 +1,476 @@
1
+ // files.js — Files tab with upload / download / delete + text/office preview
2
+ import { effect, delegate, esc, $ } from './lib.js';
3
+ import { currentProject, currentTab, filesRoot, filesPath, viewingFile, ctx } from './state.js';
4
+ import { api, authHeaders, authQuery } from './api.js';
5
+
6
+ // ── Helpers ───────────────────────────────────────────────────────────────────
7
+ function fileIcon(name) {
8
+ const ext = name.split('.').pop()?.toLowerCase();
9
+ return {
10
+ js: '📄', ts: '📄', jsx: '📄', tsx: '📄', py: '🐍', md: '📝', json: '⚙️',
11
+ html: '🌐', css: '🎨', sh: '⚡', env: '🔑',
12
+ docx: '📘', pptx: '📙', xlsx: '📗', pdf: '📕',
13
+ }[ext] || '📄';
14
+ }
15
+
16
+ function extOf(path) {
17
+ const base = (path || '').split('/').pop() || '';
18
+ const i = base.lastIndexOf('.');
19
+ return i >= 0 ? base.slice(i + 1).toLowerCase() : '';
20
+ }
21
+
22
+ function officeKind(path) {
23
+ const e = extOf(path);
24
+ return e === 'docx' || e === 'pptx' || e === 'xlsx' || e === 'pdf' ? e : null;
25
+ }
26
+
27
+ function sizeLabel(size) {
28
+ if (size == null) return '';
29
+ if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
30
+ if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
31
+ return `${size} B`;
32
+ }
33
+
34
+ function base64ToArrayBuffer(b64) {
35
+ const bin = atob(b64);
36
+ const bytes = new Uint8Array(bin.length);
37
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
38
+ return bytes.buffer;
39
+ }
40
+
41
+ const _scriptLoads = new Map();
42
+ function loadScript(src) {
43
+ if (_scriptLoads.has(src)) return _scriptLoads.get(src);
44
+ const p = new Promise((resolve, reject) => {
45
+ const existing = document.querySelector(`script[data-src="${src}"]`);
46
+ if (existing) {
47
+ if (existing.dataset.loaded === '1') return resolve();
48
+ existing.addEventListener('load', () => resolve());
49
+ existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
50
+ return;
51
+ }
52
+ const s = document.createElement('script');
53
+ s.src = src;
54
+ s.async = true;
55
+ s.dataset.src = src;
56
+ s.onload = () => { s.dataset.loaded = '1'; resolve(); };
57
+ s.onerror = () => reject(new Error(`Failed to load ${src}`));
58
+ document.head.appendChild(s);
59
+ });
60
+ _scriptLoads.set(src, p);
61
+ return p;
62
+ }
63
+
64
+ /** Map filename / path → highlight.js language id (or '' for plain text). */
65
+ function langFromPath(path) {
66
+ const base = (path || '').split('/').pop() || '';
67
+ const lower = base.toLowerCase();
68
+ const byName = {
69
+ dockerfile: 'dockerfile', makefile: 'makefile', gemfile: 'ruby',
70
+ rakefile: 'ruby', procfile: 'yaml', 'cmakelists.txt': 'cmake',
71
+ };
72
+ if (byName[lower]) return byName[lower];
73
+ const ext = lower.includes('.') ? lower.split('.').pop() : '';
74
+ const map = {
75
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
76
+ ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
77
+ py: 'python', pyw: 'python', pyi: 'python',
78
+ rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', kts: 'kotlin',
79
+ c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp',
80
+ cs: 'csharp', swift: 'swift', php: 'php', scala: 'scala',
81
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
82
+ ps1: 'powershell', psm1: 'powershell',
83
+ html: 'xml', htm: 'xml', xhtml: 'xml', xml: 'xml', svg: 'xml',
84
+ css: 'css', scss: 'scss', less: 'less',
85
+ json: 'json', jsonc: 'json', json5: 'json',
86
+ yml: 'yaml', yaml: 'yaml', toml: 'ini', ini: 'ini', conf: 'ini', env: 'bash',
87
+ md: 'markdown', mdx: 'markdown', markdown: 'markdown',
88
+ sql: 'sql', graphql: 'graphql', gql: 'graphql',
89
+ vue: 'xml', svelte: 'xml',
90
+ r: 'r', lua: 'lua', pl: 'perl', pm: 'perl',
91
+ dart: 'dart', ex: 'elixir', exs: 'elixir', erl: 'erlang',
92
+ zig: 'zig', nim: 'nim', clj: 'clojure', cljs: 'clojure',
93
+ diff: 'diff', patch: 'diff',
94
+ dockerfile: 'dockerfile',
95
+ };
96
+ return map[ext] || '';
97
+ }
98
+
99
+ function highlightFilePreview(root) {
100
+ if (!root || typeof hljs === 'undefined') return;
101
+ root.querySelectorAll('pre code.file-preview-code').forEach(el => {
102
+ try {
103
+ delete el.dataset.highlighted;
104
+ hljs.highlightElement(el);
105
+ } catch { /* unknown language / empty */ }
106
+ });
107
+ }
108
+
109
+ function fileChrome(path, size, kindLabel) {
110
+ const sizeStr = sizeLabel(size);
111
+ return `
112
+ <div class="flex items-center gap-2 px-4 py-1.5 border-b border-base-300 flex-shrink-0 bg-base-200">
113
+ <span class="text-xs text-base-content/40 flex-1 truncate">${esc(path)} · ${esc(sizeStr)}${kindLabel ? ` · <span class="text-primary/70">${esc(kindLabel)}</span>` : ''}</span>
114
+ <button class="btn btn-ghost btn-xs" data-download-path="${esc(path)}">↓ Download</button>
115
+ </div>`;
116
+ }
117
+
118
+ // ── Directory / text render ───────────────────────────────────────────────────
119
+ function renderDir(files) {
120
+ if (!Array.isArray(files) || !files.length) {
121
+ return '<div class="p-4 text-xs text-base-content/40">Empty directory</div>';
122
+ }
123
+ files.sort((a, b) => (b.isDir - a.isDir) || a.name.localeCompare(b.name));
124
+ return files.map(f => `
125
+ <div class="flex items-center gap-2 px-4 py-1.5 hover:bg-base-300 group"
126
+ ${f.isDir ? `data-dir-path="${esc(f.path)}"` : `data-file-path="${esc(f.path)}"`}>
127
+ <span class="cursor-pointer flex-shrink-0">${f.isDir ? '📁' : fileIcon(f.name)}</span>
128
+ <span class="text-sm flex-1 truncate cursor-pointer ${f.isDir ? 'text-primary' : ''}">${esc(f.name)}</span>
129
+ ${!f.isDir ? `
130
+ <button class="opacity-0 group-hover:opacity-100 btn btn-ghost btn-xs px-1 text-base-content/40 hover:text-base-content"
131
+ data-download-path="${esc(f.path)}" title="Download">↓</button>
132
+ <button class="opacity-0 group-hover:opacity-100 btn btn-ghost btn-xs px-1 text-error/60 hover:text-error"
133
+ data-delete-path="${esc(f.path)}" title="Delete">✕</button>` : ''}
134
+ </div>`).join('');
135
+ }
136
+
137
+ function renderTextFile(path, content, size) {
138
+ const isBinary = /[\x00-\x08\x0e-\x1f]/.test(content.slice(0, 512));
139
+ const lang = langFromPath(path);
140
+ const langClass = lang ? `language-${esc(lang)}` : '';
141
+ const body = isBinary
142
+ ? `<div class="p-4 text-xs text-base-content/40">Binary file — <button class="text-primary hover:underline" data-download-path="${esc(path)}">Download</button></div>`
143
+ : `<pre class="file-preview p-4 font-mono text-xs leading-relaxed overflow-auto flex-1 m-0"><code class="hljs file-preview-code ${langClass}">${esc(content)}</code></pre>`;
144
+ return fileChrome(path, size, isBinary ? 'binary' : (lang || 'text')) + body;
145
+ }
146
+
147
+ // ── Office previews ───────────────────────────────────────────────────────────
148
+ async function renderDocx(arrayBuffer) {
149
+ await loadScript('https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js');
150
+ if (typeof mammoth === 'undefined') throw new Error('mammoth failed to load');
151
+ const result = await mammoth.convertToHtml({ arrayBuffer });
152
+ const warn = result.messages?.length
153
+ ? `<div class="office-warn text-xs text-warning/80 px-3 py-1">${result.messages.length} conversion note(s)</div>`
154
+ : '';
155
+ return `<div class="office-preview office-docx overflow-auto flex-1 p-4 md">${warn}<div class="office-docx-body">${result.value || '<p class="text-base-content/40">Empty document</p>'}</div></div>`;
156
+ }
157
+
158
+ async function renderXlsx(arrayBuffer) {
159
+ await loadScript('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
160
+ if (typeof XLSX === 'undefined') throw new Error('SheetJS failed to load');
161
+ const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
162
+ const names = wb.SheetNames || [];
163
+ if (!names.length) {
164
+ return `<div class="p-4 text-xs text-base-content/40">Empty workbook</div>`;
165
+ }
166
+ const tabs = names.map((n, i) =>
167
+ `<button type="button" class="office-sheet-tab${i === 0 ? ' active' : ''}" data-sheet-idx="${i}">${esc(n)}</button>`
168
+ ).join('');
169
+ const sheets = names.map((n, i) => {
170
+ const sheet = wb.Sheets[n];
171
+ // sheet_to_html is fine for preview; escape not needed — SheetJS emits tags
172
+ let html;
173
+ try {
174
+ html = XLSX.utils.sheet_to_html(sheet, { id: `xlsx-sheet-${i}`, editable: false });
175
+ } catch {
176
+ html = '<p class="text-base-content/40 text-xs p-2">Unable to render this sheet</p>';
177
+ }
178
+ return `<div class="office-sheet-pane${i === 0 ? '' : ' hidden'}" data-sheet-pane="${i}">${html}</div>`;
179
+ }).join('');
180
+ return `
181
+ <div class="office-preview office-xlsx flex flex-col flex-1 min-h-0 overflow-hidden">
182
+ <div class="office-sheet-tabs flex gap-1 px-2 py-1.5 border-b border-base-300 overflow-x-auto flex-shrink-0 bg-base-200">${tabs}</div>
183
+ <div class="office-sheet-body flex-1 overflow-auto p-2">${sheets}</div>
184
+ </div>`;
185
+ }
186
+
187
+ function pptxExtractSlideTexts(xml) {
188
+ // Collect <a:t>…</a:t> runs; group roughly by paragraph breaks via </a:p>
189
+ const parts = [];
190
+ const paraRe = /<a:p[\s>][\s\S]*?<\/a:p>/g;
191
+ const textRe = /<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/g;
192
+ let m;
193
+ const paras = xml.match(paraRe) || [xml];
194
+ for (const para of paras) {
195
+ const runs = [];
196
+ textRe.lastIndex = 0;
197
+ while ((m = textRe.exec(para)) !== null) {
198
+ const t = m[1]
199
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
200
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'");
201
+ if (t) runs.push(t);
202
+ }
203
+ if (runs.length) parts.push(runs.join(''));
204
+ }
205
+ return parts;
206
+ }
207
+
208
+ async function renderPptx(arrayBuffer) {
209
+ await loadScript('https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js');
210
+ if (typeof JSZip === 'undefined') throw new Error('JSZip failed to load');
211
+ const zip = await JSZip.loadAsync(arrayBuffer);
212
+ const slideFiles = Object.keys(zip.files)
213
+ .filter(n => /^ppt\/slides\/slide\d+\.xml$/i.test(n))
214
+ .sort((a, b) => {
215
+ const na = parseInt(a.match(/slide(\d+)/i)?.[1] || '0', 10);
216
+ const nb = parseInt(b.match(/slide(\d+)/i)?.[1] || '0', 10);
217
+ return na - nb;
218
+ });
219
+ if (!slideFiles.length) {
220
+ return `<div class="p-4 text-xs text-base-content/40">No slides found in this presentation</div>`;
221
+ }
222
+ const cards = [];
223
+ for (let i = 0; i < slideFiles.length; i++) {
224
+ const xml = await zip.files[slideFiles[i]].async('string');
225
+ const lines = pptxExtractSlideTexts(xml);
226
+ const body = lines.length
227
+ ? lines.map(l => `<p>${esc(l)}</p>`).join('')
228
+ : '<p class="text-base-content/40 italic">(此页无可提取文本 / 可能以图片为主)</p>';
229
+ cards.push(`
230
+ <article class="office-slide-card">
231
+ <header class="office-slide-num">Slide ${i + 1}</header>
232
+ <div class="office-slide-body">${body}</div>
233
+ </article>`);
234
+ }
235
+ return `
236
+ <div class="office-preview office-pptx overflow-auto flex-1 p-3 space-y-3">
237
+ <p class="text-xs text-base-content/50 px-1">文本预览(版式/图片不还原)· ${slideFiles.length} slide(s)</p>
238
+ ${cards.join('')}
239
+ </div>`;
240
+ }
241
+
242
+ async function ensurePdfJs() {
243
+ await loadScript('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js');
244
+ if (typeof pdfjsLib === 'undefined') throw new Error('pdf.js failed to load');
245
+ pdfjsLib.GlobalWorkerOptions.workerSrc =
246
+ 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
247
+ return pdfjsLib;
248
+ }
249
+
250
+ async function renderPdf(arrayBuffer) {
251
+ const pdfjs = await ensurePdfJs();
252
+ const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
253
+ const total = pdf.numPages;
254
+ const maxPages = Math.min(total, 40); // cap for memory / UX
255
+ const pagesHtml = [];
256
+ for (let i = 1; i <= maxPages; i++) {
257
+ const page = await pdf.getPage(i);
258
+ const baseViewport = page.getViewport({ scale: 1 });
259
+ // Fit ~820px wide (readable on sidebar layout)
260
+ const scale = Math.min(1.5, 820 / baseViewport.width);
261
+ const viewport = page.getViewport({ scale });
262
+ const canvas = document.createElement('canvas');
263
+ canvas.width = Math.floor(viewport.width);
264
+ canvas.height = Math.floor(viewport.height);
265
+ canvas.className = 'office-pdf-page';
266
+ canvas.dataset.page = String(i);
267
+ await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
268
+ pagesHtml.push(`
269
+ <figure class="office-pdf-figure">
270
+ <figcaption class="office-pdf-cap">Page ${i} / ${total}</figcaption>
271
+ ${canvas.outerHTML}
272
+ </figure>`);
273
+ }
274
+ const more = total > maxPages
275
+ ? `<p class="text-xs text-base-content/50 px-2">Showing first ${maxPages} of ${total} pages — download for full file.</p>`
276
+ : '';
277
+ return `
278
+ <div class="office-preview office-pdf overflow-auto flex-1 p-3 space-y-3">
279
+ <p class="text-xs text-base-content/50 px-1">PDF preview · ${total} page(s)</p>
280
+ ${more}
281
+ ${pagesHtml.join('')}
282
+ </div>`;
283
+ }
284
+
285
+ async function renderOfficeFile(path, base64, size) {
286
+ const kind = officeKind(path);
287
+ const labels = { docx: 'Word', pptx: 'PowerPoint', xlsx: 'Excel', pdf: 'PDF' };
288
+ const ab = base64ToArrayBuffer(base64);
289
+ let body;
290
+ if (kind === 'docx') body = await renderDocx(ab);
291
+ else if (kind === 'xlsx') body = await renderXlsx(ab);
292
+ else if (kind === 'pptx') body = await renderPptx(ab);
293
+ else if (kind === 'pdf') body = await renderPdf(ab);
294
+ else throw new Error('Unsupported office type');
295
+ return fileChrome(path, size, labels[kind] || kind) + body;
296
+ }
297
+
298
+ function buildBreadcrumb(pathStr, isFile) {
299
+ const parts = pathStr ? pathStr.split('/') : [];
300
+ const dirs = isFile ? parts.slice(0, -1) : parts;
301
+ let h = `<span class="cursor-pointer text-primary hover:underline" data-dir-path="">root</span>`;
302
+ let cum = '';
303
+ for (const p of dirs) {
304
+ cum = cum ? `${cum}/${p}` : p;
305
+ h += ` <span class="text-base-content/40">/</span> <span class="cursor-pointer text-primary hover:underline" data-dir-path="${esc(cum)}">${esc(p)}</span>`;
306
+ }
307
+ if (isFile) h += ` <span class="text-base-content/40">/</span> <span>${esc(parts.at(-1))}</span>`;
308
+ return h;
309
+ }
310
+
311
+ // ── Upload ────────────────────────────────────────────────────────────────────
312
+ function triggerUpload() {
313
+ const proj = currentProject.peek();
314
+ const root = filesRoot.peek() || proj?.path;
315
+ if (!root) return;
316
+ const input = document.createElement('input');
317
+ input.type = 'file';
318
+ input.multiple = true;
319
+ input.addEventListener('change', async () => {
320
+ const dir = filesPath.peek();
321
+ const id = proj?.id || '_';
322
+ const files = [...input.files];
323
+ let failed = false;
324
+ for (const file of files) {
325
+ const destPath = dir ? `${dir}/${file.name}` : file.name;
326
+ const url = `/api/projects/${id}/file?root=${encodeURIComponent(root)}&path=${encodeURIComponent(destPath)}`;
327
+ try {
328
+ const res = await fetch(url, {
329
+ method: 'POST',
330
+ headers: authHeaders(),
331
+ body: file,
332
+ duplex: 'half',
333
+ });
334
+ if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `HTTP ${res.status}`); }
335
+ } catch (e) { alert(`Upload failed (${file.name}): ${e.message}`); failed = true; break; }
336
+ }
337
+ if (!failed) {
338
+ const cur = filesPath.peek();
339
+ filesPath.value = cur + '\x00';
340
+ filesPath.value = cur;
341
+ }
342
+ });
343
+ input.click();
344
+ }
345
+
346
+ function downloadFile(proj, filePath) {
347
+ const root = filesRoot.peek() || proj?.path || '';
348
+ const id = proj?.id || '_';
349
+ const qs = authQuery({
350
+ root,
351
+ path: filePath,
352
+ download: 'true',
353
+ });
354
+ const url = `/api/projects/${id}/file?${qs}`;
355
+ const a = document.createElement('a');
356
+ a.href = url;
357
+ a.download = filePath.split('/').pop();
358
+ a.click();
359
+ }
360
+
361
+ async function deleteFile(proj, filePath) {
362
+ if (!confirm(`Delete "${filePath.split('/').pop()}"?`)) return;
363
+ const root = filesRoot.peek() || proj?.path || '';
364
+ const id = proj?.id || '_';
365
+ try {
366
+ await api('DELETE', `/api/projects/${id}/file?root=${encodeURIComponent(root)}&path=${encodeURIComponent(filePath)}`);
367
+ filesPath.value = filesPath.peek() + ' ';
368
+ filesPath.value = filesPath.peek().trimEnd();
369
+ } catch (e) { alert(`Delete failed: ${e.message}`); }
370
+ }
371
+
372
+ async function showFile(area, file, data, signal) {
373
+ const size = data.size ?? (typeof data.content === 'string' ? data.content.length : 0);
374
+ const kind = officeKind(file);
375
+ if (kind && data.encoding === 'base64') {
376
+ area.innerHTML = fileChrome(file, size, kind) +
377
+ `<div class="p-4 text-xs text-base-content/40">Parsing ${kind.toUpperCase()}…</div>`;
378
+ try {
379
+ const html = await renderOfficeFile(file, data.content, size);
380
+ if (signal.aborted) return;
381
+ area.innerHTML = html;
382
+ } catch (e) {
383
+ if (signal.aborted) return;
384
+ area.innerHTML = fileChrome(file, size, kind) +
385
+ `<div class="p-4 text-xs text-error">Preview failed: ${esc(e.message)} — <button class="text-primary hover:underline" data-download-path="${esc(file)}">Download</button></div>`;
386
+ }
387
+ return;
388
+ }
389
+ if (data.encoding === 'base64') {
390
+ area.innerHTML = fileChrome(file, size, 'binary') +
391
+ `<div class="p-4 text-xs text-base-content/40">Binary file — <button class="text-primary hover:underline" data-download-path="${esc(file)}">Download</button></div>`;
392
+ return;
393
+ }
394
+ area.innerHTML = renderTextFile(file, data.content || '', size);
395
+ highlightFilePreview(area);
396
+ }
397
+
398
+ // ── Init ──────────────────────────────────────────────────────────────────────
399
+ export function initFilesTab() {
400
+ effect(() => {
401
+ const bc = $('files-breadcrumb');
402
+ if (!bc) return;
403
+ const isFile = viewingFile.value !== null;
404
+ bc.innerHTML = `
405
+ ${buildBreadcrumb(viewingFile.value ?? filesPath.value, isFile)}
406
+ <div class="flex-1"></div>
407
+ ${!isFile ? `<button id="btn-upload" class="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content">↑ Upload</button>` : ''}`;
408
+ });
409
+
410
+ effect(() => {
411
+ const tab = currentTab.value;
412
+ const proj = currentProject.value;
413
+ // Prefer explicit filesRoot; else session cwd on the selected machine
414
+ const root = (filesRoot.value || proj?.path || '').trim();
415
+ const file = viewingFile.value;
416
+ const dir = filesPath.value;
417
+ const area = $('files-area');
418
+ if (!area) return;
419
+ const ctrl = new AbortController();
420
+ if (tab !== 'files') { return () => ctrl.abort(); }
421
+ if (!root) {
422
+ area.innerHTML = '<div class="p-4 text-xs text-base-content/40">No path yet — open a session (uses that machine&apos;s cwd) or type an absolute path and press Go.</div>';
423
+ return () => ctrl.abort();
424
+ }
425
+ const id = proj?.id || '_';
426
+ area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
427
+ if (file !== null) {
428
+ api('GET', `/api/projects/${id}/file?root=${encodeURIComponent(root)}&path=${encodeURIComponent(file)}`, undefined, ctrl.signal)
429
+ .then(d => { if (!ctrl.signal.aborted) return showFile(area, file, d, ctrl.signal); })
430
+ .catch(e => {
431
+ if (!ctrl.signal.aborted && e?.name !== 'AbortError') {
432
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e.message)}</div>`;
433
+ }
434
+ });
435
+ } else {
436
+ api('GET', `/api/projects/${id}/files?root=${encodeURIComponent(root)}&path=${encodeURIComponent(dir)}`, undefined, ctrl.signal)
437
+ .then(d => {
438
+ if (ctrl.signal.aborted) return;
439
+ const list = Array.isArray(d) ? d : (Array.isArray(d?.files) ? d.files : null);
440
+ if (!list) {
441
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(d?.error || 'Invalid directory listing')}</div>`;
442
+ return;
443
+ }
444
+ area.innerHTML = renderDir(list);
445
+ })
446
+ .catch(e => {
447
+ if (!ctrl.signal.aborted && e?.name !== 'AbortError') {
448
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e.message)}</div>`;
449
+ }
450
+ });
451
+ }
452
+ return () => ctrl.abort();
453
+ });
454
+
455
+ delegate.on('click', '[data-dir-path]', (_, el) => { viewingFile.value = null; filesPath.value = el.dataset.dirPath; });
456
+ delegate.on('click', '[data-file-path]', (_, el) => { viewingFile.value = el.dataset.filePath; });
457
+ delegate.on('click', '#btn-upload', () => triggerUpload());
458
+ delegate.on('click', '[data-download-path]', (_, el) => {
459
+ const proj = currentProject.peek();
460
+ if (proj) downloadFile(proj, el.dataset.downloadPath);
461
+ });
462
+ delegate.on('click', '[data-delete-path]', (_, el) => {
463
+ const proj = currentProject.peek();
464
+ if (proj) deleteFile(proj, el.dataset.deletePath);
465
+ });
466
+ // Excel sheet tabs
467
+ delegate.on('click', '.office-sheet-tab', (_, el) => {
468
+ const root = el.closest('.office-xlsx');
469
+ if (!root) return;
470
+ const idx = el.dataset.sheetIdx;
471
+ root.querySelectorAll('.office-sheet-tab').forEach(t => t.classList.toggle('active', t === el));
472
+ root.querySelectorAll('.office-sheet-pane').forEach(p => {
473
+ p.classList.toggle('hidden', p.dataset.sheetPane !== idx);
474
+ });
475
+ });
476
+ }
package/public/git.js ADDED
@@ -0,0 +1,130 @@
1
+ // git.js — Git tab
2
+ import { signal, effect, delegate, esc, $ } from './lib.js';
3
+ import { currentProject, currentTab, gitRoot } from './state.js';
4
+ import { api } from './api.js';
5
+
6
+ const diffStaged = signal(false);
7
+
8
+ function renderGraph(raw) {
9
+ if (!raw) return '<span class="text-base-content/40">No commits</span>';
10
+ return raw.split('\n').map(line => {
11
+ const escaped = esc(line);
12
+ return escaped
13
+ .replace(/\b([0-9a-f]{7})\b/, '<span class="gh">$1</span>')
14
+ .replace(/\(([^)]*)\)/, '<span style="color:oklch(var(--wa))">($1)</span>');
15
+ }).join('\n');
16
+ }
17
+
18
+ function gitRenderer(data) {
19
+ if (!data) return '';
20
+ if (!data.isGit) return '<div class="p-4 text-sm text-base-content/50">Not a git repository</div>';
21
+ const statusColor = { M: 'text-warning', D: 'text-error' };
22
+ return `
23
+ <div class="px-4 py-2 text-xs text-base-content/50 border-b border-base-300">
24
+ Branch: <span class="text-success font-semibold">${esc(data.branch)}</span>
25
+ </div>
26
+ ${!data.files.length
27
+ ? '<div class="px-4 py-2 text-sm text-base-content/50">Working tree clean</div>'
28
+ : data.files.map(f => `
29
+ <div class="flex items-center gap-3 px-4 py-1 text-sm">
30
+ <span class="font-mono text-xs w-5 flex-shrink-0 ${statusColor[f.status] || 'text-success'}">${esc(f.status)}</span>
31
+ <span class="break-all">${esc(f.file)}</span>
32
+ </div>`).join('')
33
+ }
34
+ <div class="border-t border-base-300 p-4">
35
+ <div class="flex gap-4 text-xs mb-3">
36
+ <span id="diff-unstaged" class="cursor-pointer text-primary">Unstaged</span>
37
+ <span id="diff-staged" class="cursor-pointer text-base-content/50">Staged</span>
38
+ </div>
39
+ <div id="diff-content" class="font-mono text-xs overflow-auto max-h-[30vh] whitespace-pre"></div>
40
+ </div>
41
+ <div class="border-t border-base-300">
42
+ <div class="flex items-center justify-between px-4 py-2 text-xs text-base-content/50 cursor-pointer select-none" id="git-graph-toggle">
43
+ <span class="uppercase tracking-wider font-medium">Git Graph</span>
44
+ <span id="git-graph-chevron" class="text-[10px]">▶</span>
45
+ </div>
46
+ <div id="git-graph-panel" class="hidden px-4 pb-4">
47
+ <div id="git-graph-content" class="git-graph overflow-x-auto max-h-[35vh] overflow-y-auto">
48
+ <span class="text-base-content/40">Loading…</span>
49
+ </div>
50
+ </div>
51
+ </div>`;
52
+ }
53
+
54
+ function renderDiff(diff) {
55
+ return diff.split('\n').map(line => {
56
+ if (line.startsWith('+') && !line.startsWith('+++')) return `<span class="diff-add">${esc(line)}</span>`;
57
+ if (line.startsWith('-') && !line.startsWith('---')) return `<span class="diff-del">${esc(line)}</span>`;
58
+ if (line.startsWith('@@')) return `<span class="diff-hunk">${esc(line)}</span>`;
59
+ if (/^(diff |index |---|[+]{3})/.test(line)) return `<span class="diff-head">${esc(line)}</span>`;
60
+ return `<span>${esc(line)}</span>`;
61
+ }).join('\n');
62
+ }
63
+
64
+ export function initGitTab() {
65
+ // Git status panel
66
+ effect(() => {
67
+ const tab = currentTab.value;
68
+ const proj = currentProject.value;
69
+ const root = gitRoot.value || proj?.path;
70
+ const area = $('git-area');
71
+ if (!area) return;
72
+ const ctrl = new AbortController();
73
+ if (tab !== 'git') return () => ctrl.abort();
74
+ if (!root) {
75
+ area.innerHTML = '<div class="p-4 text-sm text-base-content/50">Enter a project root above, or open a session with a working directory.</div>';
76
+ return () => ctrl.abort();
77
+ }
78
+ const id = proj?.id || '_';
79
+ area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
80
+ api('GET', `/api/projects/${id}/git/status?root=${encodeURIComponent(root)}`, undefined, ctrl.signal)
81
+ .then(d => { if (!ctrl.signal.aborted) area.innerHTML = gitRenderer(d); })
82
+ .catch(e => { if (!ctrl.signal.aborted && e?.name !== 'AbortError')
83
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e?.message ?? String(e))}</div>`; });
84
+ return () => ctrl.abort();
85
+ });
86
+
87
+ // Diff panel
88
+ effect(() => {
89
+ const proj = currentProject.value;
90
+ const root = gitRoot.value || proj?.path;
91
+ const staged = diffStaged.value;
92
+ const target = $('diff-content');
93
+ if (!target || !root) return;
94
+ const id = proj?.id || '_';
95
+ const ctrl = new AbortController();
96
+ api('GET', `/api/projects/${id}/git/diff?root=${encodeURIComponent(root)}${staged ? '&staged=true' : ''}`, undefined, ctrl.signal)
97
+ .then(d => { if (!ctrl.signal.aborted) target.innerHTML = d?.diff ? renderDiff(d.diff) : '<span class="text-base-content/40">No changes</span>'; })
98
+ .catch(e => { if (!ctrl.signal.aborted && e?.name !== 'AbortError')
99
+ target.innerHTML = `<span class="text-error">${esc(e?.message ?? String(e))}</span>`; });
100
+ return () => ctrl.abort();
101
+ });
102
+
103
+ delegate.on('click', '#diff-unstaged, #diff-staged', (_, el) => {
104
+ const staged = el.id === 'diff-staged';
105
+ diffStaged.value = staged;
106
+ $('diff-unstaged').className = `cursor-pointer ${staged ? 'text-base-content/50' : 'text-primary'}`;
107
+ $('diff-staged').className = `cursor-pointer ${staged ? 'text-primary' : 'text-base-content/50'}`;
108
+ });
109
+
110
+ // Git graph — load on expand
111
+ delegate.on('click', '#git-graph-toggle', () => {
112
+ const panel = $('git-graph-panel');
113
+ const chevron = $('git-graph-chevron');
114
+ const open = panel.classList.toggle('hidden');
115
+ chevron.textContent = open ? '▶' : '▼';
116
+ if (!open) loadGraph();
117
+ });
118
+ }
119
+
120
+ function loadGraph() {
121
+ const proj = currentProject.peek();
122
+ const root = gitRoot.peek() || proj?.path;
123
+ const content = $('git-graph-content');
124
+ if (!root || !content) return;
125
+ const id = proj?.id || '_';
126
+ content.innerHTML = '<span class="text-base-content/40">Loading…</span>';
127
+ api('GET', `/api/projects/${id}/git/log?root=${encodeURIComponent(root)}`)
128
+ .then(d => { content.innerHTML = d.graph ? renderGraph(d.graph) : '<span class="text-base-content/40">No commits</span>'; })
129
+ .catch(e => { content.innerHTML = `<span class="text-error">${esc(e.message)}</span>`; });
130
+ }
@@ -0,0 +1,61 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <!-- FOUC / calendar flash: decide before first paint -->
6
+ <script>
7
+ (function () {
8
+ try {
9
+ var theme = localStorage.getItem('theme') || 'dark';
10
+ document.documentElement.setAttribute('data-theme', theme);
11
+ var token = localStorage.getItem('token');
12
+ var hash = location.hash || '';
13
+ var restore = !!(token && /^#\/session\//.test(hash));
14
+ document.documentElement.classList.add('boot-pending');
15
+ if (restore) document.documentElement.classList.add('boot-restore');
16
+ if (token) document.documentElement.classList.add('boot-authed');
17
+ } catch (e) {
18
+ document.documentElement.classList.add('boot-pending');
19
+ }
20
+ })();
21
+ </script>
22
+ <style>
23
+ /* Hide app tree until JS has built the correct first screen */
24
+ html.boot-pending #root {
25
+ visibility: hidden !important;
26
+ }
27
+ html.boot-ready #root {
28
+ visibility: visible !important;
29
+ }
30
+ /* Session deep-link: never show welcome calendar during boot */
31
+ html.boot-restore #welcome {
32
+ display: none !important;
33
+ visibility: hidden !important;
34
+ pointer-events: none !important;
35
+ }
36
+ html.boot-restore #project-view {
37
+ display: flex !important;
38
+ flex-direction: column !important;
39
+ flex: 1 1 0 !important;
40
+ min-height: 0 !important;
41
+ overflow: hidden !important;
42
+ }
43
+ html.boot-restore #auth-screen,
44
+ html.boot-authed #auth-screen {
45
+ display: none !important;
46
+ }
47
+ </style>
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
49
+ <title>Claude Code</title>
50
+ <link href="https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css" rel="stylesheet">
51
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
52
+ <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
53
+ <link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
54
+ <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
55
+ <link rel="stylesheet" href="style.css?v=boot-2">
56
+ </head>
57
+ <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
58
+ <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
59
+ <script type="module" src="app.js"></script>
60
+ </body>
61
+ </html>
package/public/lib.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from 'https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@latest/dist/mini-react.min.js';
2
+ // doc from https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@latest/README.mini.md