claude-code-memory-explorer 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.
package/public/app.js ADDED
@@ -0,0 +1,720 @@
1
+ // #region STATE
2
+
3
+ let projectData = null;
4
+ let stackData = [];
5
+ let summaryData = null;
6
+ let selectedFileId = null;
7
+
8
+ // #endregion STATE
9
+
10
+ // #region UTILS
11
+
12
+ function esc(s) {
13
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+
16
+ function escJs(s) {
17
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
18
+ }
19
+
20
+ // #endregion UTILS
21
+
22
+ // #region HIGHLIGHT
23
+
24
+ const EXT_TO_LANG = {
25
+ md: 'markdown',
26
+ json: 'json',
27
+ yaml: 'yaml',
28
+ yml: 'yaml',
29
+ js: 'javascript',
30
+ ts: 'typescript',
31
+ py: 'python',
32
+ sh: 'bash',
33
+ bash: 'bash',
34
+ css: 'css',
35
+ html: 'xml',
36
+ xml: 'xml',
37
+ toml: 'ini',
38
+ };
39
+
40
+ function highlightSource(text, fileName) {
41
+ const ext = (fileName || '').split('.').pop().toLowerCase();
42
+ const lang = EXT_TO_LANG[ext];
43
+ if (typeof hljs === 'undefined' || !lang) return esc(text);
44
+ try {
45
+ if (lang === 'markdown') {
46
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
47
+ if (fm) {
48
+ const fmHtml = hljs.highlight(fm[1], { language: 'yaml' }).value;
49
+ const bodyHtml = hljs.highlight(fm[2], { language: 'markdown' }).value;
50
+ return `<span class="hl-frontmatter">---</span>\n${fmHtml}\n<span class="hl-frontmatter">---</span>\n${bodyHtml}`;
51
+ }
52
+ }
53
+ return hljs.highlight(text, { language: lang }).value;
54
+ } catch {
55
+ /* fallback */
56
+ }
57
+ return esc(text);
58
+ }
59
+
60
+ function linkifyImports(text, sourceId) {
61
+ if (!stackData.length) return { text, placeholders: [] };
62
+ const byName = Object.fromEntries(stackData.map(c => [c.name, c]));
63
+ const placeholders = [];
64
+ const ph = (child, display) => {
65
+ const token = `\x00LINK${placeholders.length}\x00`;
66
+ placeholders.push(`<a class="inline-import" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(display)}</a>`);
67
+ return token;
68
+ };
69
+ // Replace @path refs with placeholders
70
+ text = text.replace(/@([\w./-]+\.md)\b/g, (_m, ref) => {
71
+ const child = byName[ref.split('/').pop()];
72
+ return child ? `@${ph(child, ref)}` : _m;
73
+ });
74
+ // Replace markdown [text](file.md) link targets with placeholders
75
+ text = text.replace(/(\[[^\]]*\]\()((?!https?:\/\/)[^)]+\.md)(\))/g, (_m, pre, ref, post) => {
76
+ const child = byName[ref.split('/').pop()];
77
+ return child ? `${pre}${ph(child, ref)}${post}` : _m;
78
+ });
79
+ return { text, placeholders };
80
+ }
81
+
82
+ function restorePlaceholders(html, placeholders) {
83
+ for (let i = 0; i < placeholders.length; i++) {
84
+ html = html.replaceAll(`\x00LINK${i}\x00`, placeholders[i]);
85
+ }
86
+ return html;
87
+ }
88
+
89
+ // #endregion HIGHLIGHT
90
+
91
+ // #region THEME
92
+
93
+ function loadTheme() {
94
+ if (localStorage.getItem('theme') === 'light') document.body.classList.add('light');
95
+ syncHljsTheme();
96
+ }
97
+
98
+ function toggleTheme() {
99
+ document.body.classList.toggle('light');
100
+ localStorage.setItem('theme', document.body.classList.contains('light') ? 'light' : 'dark');
101
+ syncHljsTheme();
102
+ }
103
+
104
+ function syncHljsTheme() {
105
+ const isLight = document.body.classList.contains('light');
106
+ const dark = document.getElementById('hljsDark');
107
+ const light = document.getElementById('hljsLight');
108
+ if (dark) dark.disabled = isLight;
109
+ if (light) light.disabled = !isLight;
110
+ }
111
+
112
+ // #endregion THEME
113
+
114
+ // #region FETCH
115
+
116
+ async function fetchJSON(url) {
117
+ const res = await fetch(url);
118
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
119
+ return res.json();
120
+ }
121
+
122
+ // #endregion FETCH
123
+
124
+ // #region PROJECT
125
+
126
+ async function loadProject() {
127
+ projectData = await fetchJSON('/api/project');
128
+ document.getElementById('projectName').textContent = projectData.name;
129
+ document.getElementById('projectBtn').title = projectData.path;
130
+ }
131
+
132
+ function changeProject() {
133
+ const current = document.getElementById('projectBtn').title;
134
+ document.getElementById('projectPathInput').value = current;
135
+ renderRecentProjects();
136
+ document.getElementById('projectPickerModal').classList.add('open');
137
+ setTimeout(() => document.getElementById('projectPathInput').focus(), 100);
138
+ }
139
+
140
+ async function submitProjectPicker() {
141
+ const dirPath = document.getElementById('projectPathInput').value.trim();
142
+ if (!dirPath) return;
143
+ const btn = document.getElementById('projectPickerSubmit');
144
+ btn.disabled = true;
145
+ btn.textContent = 'Switching...';
146
+ try {
147
+ const res = await fetch('/api/project', {
148
+ method: 'PUT',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify({ path: dirPath }),
151
+ });
152
+ if (!res.ok) {
153
+ const err = await res.json();
154
+ showToast(err.error, 'error');
155
+ return;
156
+ }
157
+ closeModal('projectPickerModal');
158
+ addRecentProject(dirPath);
159
+ await loadProject();
160
+ await loadData();
161
+ showToast('Project switched', 'success');
162
+ } catch (err) {
163
+ showToast(err.message, 'error');
164
+ } finally {
165
+ btn.disabled = false;
166
+ btn.textContent = 'Switch';
167
+ }
168
+ }
169
+
170
+ function getRecentProjects() {
171
+ try {
172
+ return JSON.parse(localStorage.getItem('recentProjects') || '[]');
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+
178
+ function addRecentProject(p) {
179
+ const recent = getRecentProjects().filter((r) => r !== p);
180
+ recent.unshift(p);
181
+ localStorage.setItem('recentProjects', JSON.stringify(recent.slice(0, 10)));
182
+ }
183
+
184
+ function removeRecentProject(p, e) {
185
+ e.stopPropagation();
186
+ const recent = getRecentProjects().filter((r) => r !== p);
187
+ localStorage.setItem('recentProjects', JSON.stringify(recent));
188
+ renderRecentProjects();
189
+ }
190
+
191
+ function selectRecentProject(p) {
192
+ document.getElementById('projectPathInput').value = p;
193
+ }
194
+
195
+ function renderRecentProjects() {
196
+ const container = document.getElementById('recentProjectsList');
197
+ const recent = getRecentProjects();
198
+ if (!recent.length) {
199
+ container.innerHTML = '';
200
+ return;
201
+ }
202
+ container.innerHTML =
203
+ '<div class="recent-projects-label">Recent</div>' +
204
+ recent
205
+ .map(
206
+ (p) =>
207
+ `<div class="recent-project-item" onclick="selectRecentProject('${escJs(p)}')">` +
208
+ `<span>${esc(p)}</span>` +
209
+ `<button class="recent-project-remove" onclick="removeRecentProject('${escJs(p)}', event)" title="Remove">&#10005;</button>` +
210
+ `</div>`,
211
+ )
212
+ .join('');
213
+ }
214
+
215
+ // #endregion PROJECT
216
+
217
+ // #region MODAL
218
+
219
+ function closeModal(id) {
220
+ document.getElementById(id).classList.remove('open');
221
+ }
222
+
223
+ function toggleHelpModal() {
224
+ document.getElementById('helpModal').classList.toggle('open');
225
+ }
226
+
227
+ function bindModalKeys(inputId, modalId, submitFn) {
228
+ document.getElementById(inputId).addEventListener('keydown', (e) => {
229
+ if (e.key === 'Enter') {
230
+ e.preventDefault();
231
+ submitFn();
232
+ }
233
+ if (e.key === 'Escape') {
234
+ e.preventDefault();
235
+ closeModal(modalId);
236
+ }
237
+ });
238
+ }
239
+
240
+ // #endregion MODAL
241
+
242
+ // #region RENDER_TREE
243
+
244
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory'];
245
+ const SCOPE_LABELS = {
246
+ policy: 'Managed Policy',
247
+ user: 'User',
248
+ project: 'Project',
249
+ rule: 'Rules',
250
+ memory: 'Auto Memory',
251
+ };
252
+ const LOAD_ICONS = {
253
+ always: '\u25CF',
254
+ startup: '\u25D2',
255
+ conditional: '\u25CB',
256
+ ondemand: '\u25CC',
257
+ import: '@',
258
+ };
259
+ const LOAD_TITLES = {
260
+ always: 'Always loaded',
261
+ startup: 'Loaded at startup (partial)',
262
+ conditional: 'Conditional (path-scoped)',
263
+ ondemand: 'On-demand',
264
+ import: 'Imported by parent file',
265
+ };
266
+
267
+ let treeIndex = null;
268
+
269
+ function getTreeIndex() {
270
+ if (treeIndex) return treeIndex;
271
+ const groups = {};
272
+ const childrenOf = {};
273
+ for (const s of stackData) {
274
+ if (s.parentId) {
275
+ if (!childrenOf[s.parentId]) childrenOf[s.parentId] = [];
276
+ childrenOf[s.parentId].push(s);
277
+ } else {
278
+ if (!groups[s.scope]) groups[s.scope] = [];
279
+ groups[s.scope].push(s);
280
+ }
281
+ }
282
+ const navOrder = [];
283
+ function collect(item) {
284
+ navOrder.push(item);
285
+ const children = childrenOf[item.id];
286
+ if (children) for (const c of children) collect(c);
287
+ }
288
+ for (const scope of SCOPE_ORDER) {
289
+ const items = groups[scope];
290
+ if (items) for (const item of items) collect(item);
291
+ }
292
+ treeIndex = { groups, childrenOf, navOrder };
293
+ return treeIndex;
294
+ }
295
+
296
+ function invalidateTreeIndex() { treeIndex = null; }
297
+
298
+ function renderTree() {
299
+ const container = document.getElementById('treeContent');
300
+ if (!stackData.length) {
301
+ container.innerHTML =
302
+ '<div class="loading-state" style="padding:20px;font-size:11px;color:var(--text-muted)">No memory sources found</div>';
303
+ return;
304
+ }
305
+
306
+ const { groups, childrenOf } = getTreeIndex();
307
+
308
+ function renderItem(item, indent) {
309
+ const sel = selectedFileId === item.id ? ' selected' : '';
310
+ const loadIcon = LOAD_ICONS[item.load] || '';
311
+ const loadTitle = LOAD_TITLES[item.load] || item.load;
312
+ const meta = `${item.lines}L`;
313
+ const isConditional = item.load === 'conditional' || item.load === 'ondemand';
314
+ const pad = indent ? ' style="padding-left:' + (12 + indent * 16) + 'px"' : '';
315
+ let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escJs(item.id)}')"${pad}>`;
316
+ h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
317
+ h += `<span class="file-name">${esc(item.name)}</span>`;
318
+ h += `<span class="file-meta">${meta}</span>`;
319
+ h += '</div>';
320
+ const children = childrenOf[item.id];
321
+ if (children) {
322
+ for (const child of children) h += renderItem(child, (indent || 0) + 1);
323
+ }
324
+ return h;
325
+ }
326
+
327
+ let html = '';
328
+ for (const scope of SCOPE_ORDER) {
329
+ const items = groups[scope];
330
+ if (!items) continue;
331
+ const label = SCOPE_LABELS[scope] || scope;
332
+ html += `<div class="tree-group-header"><span class="scope-dot" style="color:var(--scope-${scope})">\u25CF</span> ${esc(label)} <span style="opacity:0.5">${items.length}</span></div>`;
333
+ for (const item of items) html += renderItem(item, 0);
334
+ }
335
+ container.innerHTML = html;
336
+ }
337
+
338
+ function pushFileState(id) {
339
+ const url = id ? `#${encodeURIComponent(id)}` : location.pathname;
340
+ history.pushState({ fileId: id }, '', url);
341
+ }
342
+
343
+ function selectFile(id, pushState = true) {
344
+ selectedFileId = selectedFileId === id ? null : id;
345
+ if (pushState) pushFileState(selectedFileId);
346
+ renderTree();
347
+ renderPreview();
348
+ }
349
+
350
+ function scrollToSelected() {
351
+ const el = document.querySelector(`.tree-item[data-id="${selectedFileId}"]`);
352
+ if (el) el.scrollIntoView({ block: 'nearest' });
353
+ }
354
+
355
+ function navigateTree(direction) {
356
+ const { navOrder } = getTreeIndex();
357
+ if (!navOrder.length) return;
358
+ let idx = navOrder.findIndex((s) => s.id === selectedFileId);
359
+ if (idx === -1) {
360
+ idx = direction > 0 ? 0 : navOrder.length - 1;
361
+ } else {
362
+ idx += direction;
363
+ if (idx < 0) idx = navOrder.length - 1;
364
+ if (idx >= navOrder.length) idx = 0;
365
+ }
366
+ selectedFileId = navOrder[idx].id;
367
+ pushFileState(selectedFileId);
368
+ renderTree();
369
+ renderPreview();
370
+ scrollToSelected();
371
+ }
372
+
373
+ function navigateGroup(direction) {
374
+ const { groups } = getTreeIndex();
375
+ const activeScopes = SCOPE_ORDER.filter((sc) => groups[sc]?.length);
376
+ if (!activeScopes.length) return;
377
+
378
+ const current = stackData.find((s) => s.id === selectedFileId);
379
+ const currentScope = current?.parentId
380
+ ? stackData.find((s) => s.id === current.parentId)?.scope
381
+ : current?.scope;
382
+ let scopeIdx = activeScopes.indexOf(currentScope);
383
+ if (scopeIdx === -1) {
384
+ scopeIdx = direction > 0 ? 0 : activeScopes.length - 1;
385
+ } else {
386
+ scopeIdx += direction;
387
+ if (scopeIdx < 0) scopeIdx = activeScopes.length - 1;
388
+ if (scopeIdx >= activeScopes.length) scopeIdx = 0;
389
+ }
390
+ selectedFileId = groups[activeScopes[scopeIdx]][0].id;
391
+ pushFileState(selectedFileId);
392
+ renderTree();
393
+ renderPreview();
394
+ scrollToSelected();
395
+ }
396
+
397
+ // #endregion RENDER_TREE
398
+
399
+ // #region RENDER_PREVIEW
400
+
401
+ async function renderPreview() {
402
+ const panel = document.getElementById('previewPanel');
403
+ const source = stackData.find((s) => s.id === selectedFileId);
404
+ if (!source) {
405
+ panel.innerHTML =
406
+ '<div class="preview-empty"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/><path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/></svg><span>Select a file to preview</span></div>';
407
+ return;
408
+ }
409
+
410
+ let fileData;
411
+ try {
412
+ fileData = await fetchJSON(`/api/file?path=${encodeURIComponent(source.path)}`);
413
+ } catch {
414
+ panel.innerHTML = '<div class="preview-empty"><span>Failed to load file</span></div>';
415
+ return;
416
+ }
417
+
418
+ let html = '<div class="preview-header">';
419
+ html += '<div class="preview-title">';
420
+ html += `<span class="scope-badge scope-${source.scope}">${esc(source.scope)}</span>`;
421
+ html += `<span class="file-path">${esc(source.name)}</span>`;
422
+ html += `<button class="action-btn small" onclick="openInEditor('${escJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
423
+ html += '</div>';
424
+
425
+ // Badges row
426
+ html += '<div class="preview-badges">';
427
+ html += `<span class="load-badge load-${source.load}">${esc(source.load)}</span>`;
428
+ html += `<span class="tag-badge">${source.lines}L / ${formatBytes(source.bytes)}</span>`;
429
+ if (fileData.frontmatter) {
430
+ for (const [k, v] of Object.entries(fileData.frontmatter)) {
431
+ const val = Array.isArray(v) ? v.join(', ') : v;
432
+ html += `<span class="tag-badge">${esc(k)}: ${esc(String(val))}</span>`;
433
+ }
434
+ }
435
+ html += '</div>';
436
+
437
+ // Imports — only show children (files that have this source as parent)
438
+ const children = stackData.filter(s => s.parentId === source.id);
439
+ const unresolved = source.unresolvedImports || [];
440
+ if (children.length || unresolved.length) {
441
+ html += '<div class="preview-imports">';
442
+ for (const child of children) {
443
+ html += `<a class="import-link" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
444
+ }
445
+ for (const u of unresolved) {
446
+ html += `<span class="import-link unresolved" title="Not found: ${esc(u)}">⚠ ${esc(u)}</span>`;
447
+ }
448
+ html += '</div>';
449
+ }
450
+
451
+ html += '</div>';
452
+
453
+ // File path
454
+ html += `<div class="preview-filepath">${esc(source.path)}</div>`;
455
+
456
+ // Content — with cutoff line for auto memory startup files
457
+ const content = fileData.content || '';
458
+ const hl = (text) => {
459
+ const { text: processed, placeholders } = linkifyImports(text, source.id);
460
+ return restorePlaceholders(highlightSource(processed, source.name), placeholders);
461
+ };
462
+ if (source.scope === 'memory' && source.load === 'startup' && source.maxLines) {
463
+ const lines = content.split('\n');
464
+ const cutoff = source.maxLines;
465
+ if (lines.length > cutoff) {
466
+ const before = lines.slice(0, cutoff).join('\n');
467
+ const after = lines.slice(cutoff).join('\n');
468
+ html += `<pre class="preview-code"><code>${hl(before)}</code></pre>`;
469
+ html += `<div class="cutoff-line"><span class="cutoff-label">Cutoff: ${cutoff} lines / loaded at startup</span></div>`;
470
+ html += `<pre class="preview-code preview-code-faded"><code>${hl(after)}</code></pre>`;
471
+ } else {
472
+ html += `<pre class="preview-code"><code>${hl(content)}</code></pre>`;
473
+ }
474
+ } else {
475
+ html += `<pre class="preview-code"><code>${hl(content)}</code></pre>`;
476
+ }
477
+
478
+ panel.innerHTML = html;
479
+ }
480
+
481
+ function formatBytes(b) {
482
+ if (b < 1024) return b + 'B';
483
+ return (b / 1024).toFixed(1) + 'KB';
484
+ }
485
+
486
+ async function openInEditor(filePath) {
487
+ showToast('Opening...', 'info');
488
+ try {
489
+ const res = await fetch('/api/open-in-editor', {
490
+ method: 'POST',
491
+ headers: { 'Content-Type': 'application/json' },
492
+ body: JSON.stringify({ path: filePath }),
493
+ });
494
+ if (!res.ok) {
495
+ const err = await res.json();
496
+ showToast(err.error, 'error');
497
+ } else {
498
+ showToast('Opened in editor', 'success');
499
+ }
500
+ } catch (err) {
501
+ showToast(err.message, 'error');
502
+ }
503
+ }
504
+
505
+ // #endregion RENDER_PREVIEW
506
+
507
+ // #region RENDER_BUDGET
508
+
509
+ function renderBudget() {
510
+ if (!summaryData) return;
511
+
512
+ // Summary stat cards
513
+ document.getElementById('statFiles').textContent = summaryData.totalFiles;
514
+ document.getElementById('statLines').textContent = summaryData.totalLines.toLocaleString();
515
+ document.getElementById('statBytes').textContent = formatBytes(summaryData.totalBytes);
516
+ document.getElementById('statAlways').textContent = summaryData.alwaysLoaded;
517
+
518
+ // Budget text
519
+ document.getElementById('budgetText').textContent = `${summaryData.totalLines.toLocaleString()} lines / ${formatBytes(summaryData.totalBytes)}`;
520
+
521
+ // Budget segments — proportional by lines per scope
522
+ const segContainer = document.getElementById('budgetSegments');
523
+ if (!stackData.length) { segContainer.innerHTML = ''; return; }
524
+
525
+ const scopeTotals = {};
526
+ for (const s of stackData) {
527
+ scopeTotals[s.scope] = (scopeTotals[s.scope] || 0) + (s.lines || 0);
528
+ }
529
+ const totalLines = summaryData.totalLines || 1;
530
+ let html = '';
531
+ for (const scope of SCOPE_ORDER) {
532
+ const lines = scopeTotals[scope];
533
+ if (!lines) continue;
534
+ const pct = (lines / totalLines) * 100;
535
+ html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${lines} lines (${pct.toFixed(1)}%)"></div>`;
536
+ }
537
+ segContainer.innerHTML = html;
538
+ }
539
+
540
+ // #endregion RENDER_BUDGET
541
+
542
+ // #region DATA
543
+
544
+ async function loadData() {
545
+ try {
546
+ [stackData, summaryData] = await Promise.all([
547
+ fetchJSON('/api/stack'),
548
+ fetchJSON('/api/summary'),
549
+ ]);
550
+ invalidateTreeIndex();
551
+ renderTree();
552
+ renderBudget();
553
+ if (!selectedFileId) {
554
+ const proj = stackData.find((s) => s.scope === 'project' && s.name === 'CLAUDE.md');
555
+ const user = stackData.find((s) => s.scope === 'user' && s.name === 'CLAUDE.md');
556
+ const auto = proj || user;
557
+ if (auto) selectedFileId = auto.id;
558
+ }
559
+ if (selectedFileId) { renderTree(); renderPreview(); }
560
+ } catch (err) {
561
+ showToast('Failed to load: ' + err.message, 'error');
562
+ }
563
+ }
564
+
565
+ async function refreshData() {
566
+ try {
567
+ await fetch('/api/refresh', { method: 'POST' });
568
+ await loadData();
569
+ showToast('Refreshed', 'success');
570
+ } catch (err) {
571
+ showToast(err.message, 'error');
572
+ }
573
+ }
574
+
575
+ // #endregion DATA
576
+
577
+ // #region TOAST
578
+
579
+ function showToast(msg, type) {
580
+ const container = document.getElementById('toast');
581
+ const el = document.createElement('div');
582
+ el.className = `toast ${type || ''}`;
583
+ el.textContent = msg;
584
+ container.appendChild(el);
585
+ setTimeout(() => el.remove(), 3000);
586
+ }
587
+
588
+ // #endregion TOAST
589
+
590
+ // #region KEYBOARD
591
+
592
+ document.addEventListener('keydown', (e) => {
593
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
594
+ const modal = document.querySelector('.modal-overlay.open');
595
+ if (modal) {
596
+ if (e.key === 'Escape') {
597
+ modal.classList.remove('open');
598
+ e.preventDefault();
599
+ }
600
+ return;
601
+ }
602
+ if (e.key === 't') toggleTheme();
603
+ if (e.key === 'r') refreshData();
604
+ if (e.key === '?') {
605
+ e.preventDefault();
606
+ toggleHelpModal();
607
+ }
608
+ if (e.key === 'P' && e.shiftKey) {
609
+ e.preventDefault();
610
+ changeProject();
611
+ }
612
+ if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); navigateTree(1); }
613
+ if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); navigateTree(-1); }
614
+ if (e.key === 'h' || e.key === 'ArrowLeft') { e.preventDefault(); navigateGroup(-1); }
615
+ if (e.key === 'l' || e.key === 'ArrowRight') { e.preventDefault(); navigateGroup(1); }
616
+ if (e.key === 'Enter' && selectedFileId) renderPreview();
617
+ if (e.key === 'e' && selectedFileId) {
618
+ const s = stackData.find((x) => x.id === selectedFileId);
619
+ if (s) openInEditor(s.path);
620
+ }
621
+ });
622
+
623
+ // #endregion KEYBOARD
624
+
625
+ // #region HUB_INTEGRATION
626
+
627
+ async function detectHub() {
628
+ try {
629
+ const res = await fetch('/hub-config');
630
+ if (res.ok) window.__HUB__ = true;
631
+ } catch {
632
+ /* not in hub */
633
+ }
634
+ }
635
+
636
+ // #endregion HUB_INTEGRATION
637
+
638
+ // #region RESIZE
639
+
640
+ function initResize() {
641
+ const handle = document.getElementById('resizeHandle');
642
+ const panel = document.getElementById('treePanel');
643
+ let dragging = false;
644
+
645
+ handle.addEventListener('mousedown', (e) => {
646
+ e.preventDefault();
647
+ dragging = true;
648
+ handle.classList.add('active');
649
+ document.body.style.cursor = 'col-resize';
650
+ document.body.style.userSelect = 'none';
651
+ document.addEventListener('mousemove', onMove);
652
+ document.addEventListener('mouseup', onUp);
653
+ });
654
+
655
+ function onMove(e) {
656
+ if (!dragging) return;
657
+ const layout = panel.parentElement;
658
+ const rect = layout.getBoundingClientRect();
659
+ const width = Math.max(150, Math.min(e.clientX - rect.left, rect.width * 0.5));
660
+ panel.style.width = `${width}px`;
661
+ }
662
+
663
+ function onUp() {
664
+ if (!dragging) return;
665
+ dragging = false;
666
+ handle.classList.remove('active');
667
+ document.body.style.cursor = '';
668
+ document.body.style.userSelect = '';
669
+ document.removeEventListener('mousemove', onMove);
670
+ document.removeEventListener('mouseup', onUp);
671
+ localStorage.setItem('treePanelWidth', panel.offsetWidth);
672
+ }
673
+
674
+ const saved = localStorage.getItem('treePanelWidth');
675
+ if (saved) panel.style.width = `${saved}px`;
676
+ }
677
+
678
+ // #endregion RESIZE
679
+
680
+ // #region INIT
681
+
682
+ window.addEventListener('popstate', (e) => {
683
+ const id = e.state?.fileId || decodeURIComponent(location.hash.slice(1)) || null;
684
+ selectFile(id, false);
685
+ });
686
+
687
+ document.addEventListener('DOMContentLoaded', async () => {
688
+ loadTheme();
689
+ initResize();
690
+ bindModalKeys('projectPathInput', 'projectPickerModal', submitProjectPicker);
691
+ await detectHub();
692
+ // Handle ?project= query param
693
+ const params = new URLSearchParams(location.search);
694
+ if (params.has('project')) {
695
+ const projectPath = params.get('project');
696
+ try {
697
+ const res = await fetch('/api/project', {
698
+ method: 'PUT',
699
+ headers: { 'Content-Type': 'application/json' },
700
+ body: JSON.stringify({ path: projectPath }),
701
+ });
702
+ if (!res.ok) showToast('Failed to switch project', 'error');
703
+ } catch {}
704
+ params.delete('project');
705
+ const qs = params.toString();
706
+ history.replaceState(null, '', qs ? `?${qs}` : location.pathname + location.hash);
707
+ }
708
+ await loadProject();
709
+ addRecentProject(projectData.path);
710
+ await loadData();
711
+ // Restore file selection from hash
712
+ const hash = decodeURIComponent(location.hash.slice(1));
713
+ if (hash && stackData.find(s => s.id === hash)) {
714
+ selectedFileId = hash;
715
+ renderTree();
716
+ renderPreview();
717
+ }
718
+ });
719
+
720
+ // #endregion INIT