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/.claude/commands/release.md +54 -0
- package/.github/workflows/pages.yml +36 -0
- package/CLAUDE.md +49 -0
- package/README.md +92 -0
- package/assets/main-dark.png +0 -0
- package/assets/main-light.png +0 -0
- package/biome.json +33 -0
- package/docs/assets/main-dark.png +0 -0
- package/docs/assets/main-light.png +0 -0
- package/docs/index.html +988 -0
- package/package.json +26 -0
- package/public/app.js +720 -0
- package/public/icons/icon-svg.svg +6 -0
- package/public/index.html +145 -0
- package/public/manifest.json +14 -0
- package/public/style.css +789 -0
- package/public/sw.js +34 -0
- package/server.js +579 -0
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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">✕</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
|