claude-code-memory-explorer 1.2.0 → 1.3.1
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/package.json +1 -1
- package/public/app.js +598 -41
- package/public/index.html +3 -1
- package/public/style.css +466 -0
- package/server.js +201 -55
package/public/app.js
CHANGED
|
@@ -5,6 +5,10 @@ let stackData = [];
|
|
|
5
5
|
let summaryData = null;
|
|
6
6
|
let selectedFileId = null;
|
|
7
7
|
let healthViewOpen = false; // opt-in view; the Analyze button toggles it, selecting a file exits it
|
|
8
|
+
let homeData = null;
|
|
9
|
+
let homeOpen = false; // the user scope opens on the home cards; a card or the health view drills into the tree
|
|
10
|
+
let homeFocus = null; // scopes a home card drilled into; the tree shows only these until cleared
|
|
11
|
+
let previewPath = null; // a file outside the stack (settings, output style) shown read-only in the preview
|
|
8
12
|
|
|
9
13
|
// #endregion STATE
|
|
10
14
|
|
|
@@ -202,10 +206,23 @@ async function fetchJSON(url) {
|
|
|
202
206
|
|
|
203
207
|
// #region PROJECT
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
// The user scope rides in the recents list as this entry, so the boot restore brings it back.
|
|
210
|
+
const USER_ROW = '::user::';
|
|
211
|
+
|
|
212
|
+
function projectKey(info) {
|
|
213
|
+
return info?.user ? USER_ROW : info?.path;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function loadProject(info) {
|
|
217
|
+
const wasUser = !!projectData?.user;
|
|
218
|
+
projectData = info || (await fetchJSON('/api/project'));
|
|
207
219
|
document.getElementById('projectName').textContent = projectData.name;
|
|
208
|
-
document.getElementById('projectBtn').title = projectData.
|
|
220
|
+
document.getElementById('projectBtn').title = projectData.user
|
|
221
|
+
? `User scope · ${projectData.configDir}`
|
|
222
|
+
: projectData.path;
|
|
223
|
+
document.body.classList.toggle('user-scope', !!projectData.user);
|
|
224
|
+
if (projectData.user !== wasUser) homeOpen = !!projectData.user;
|
|
225
|
+
homeFocus = null;
|
|
209
226
|
}
|
|
210
227
|
|
|
211
228
|
// Shared by the project picker, the boot restore, and the hub project shim. Throws with the
|
|
@@ -216,12 +233,13 @@ async function putProject(dirPath) {
|
|
|
216
233
|
const res = await fetch('/api/project', {
|
|
217
234
|
method: 'PUT',
|
|
218
235
|
headers: { 'Content-Type': 'application/json' },
|
|
219
|
-
body: JSON.stringify({ path: dirPath }),
|
|
236
|
+
body: JSON.stringify(dirPath === USER_ROW ? { user: true } : { path: dirPath }),
|
|
220
237
|
});
|
|
221
238
|
if (!res.ok) {
|
|
222
239
|
const err = await res.json().catch(() => ({}));
|
|
223
240
|
throw new Error(err.error || `${res.status}`);
|
|
224
241
|
}
|
|
242
|
+
return res.json();
|
|
225
243
|
}
|
|
226
244
|
|
|
227
245
|
function changeProject() {
|
|
@@ -238,11 +256,15 @@ async function submitProjectPicker() {
|
|
|
238
256
|
btn.disabled = true;
|
|
239
257
|
btn.textContent = 'Switching...';
|
|
240
258
|
try {
|
|
241
|
-
await putProject(dirPath);
|
|
259
|
+
const info = await putProject(dirPath);
|
|
260
|
+
// A later hub push of the project this app left must still apply, not be deduped.
|
|
261
|
+
lastAppliedProject = dirPath;
|
|
242
262
|
closeModal('projectPickerModal');
|
|
243
263
|
addRecentProject(dirPath);
|
|
244
|
-
|
|
245
|
-
|
|
264
|
+
// loadData reads projectData to decide whether the home cards need /api/home.
|
|
265
|
+
await loadProject(info);
|
|
266
|
+
await loadData();
|
|
267
|
+
showToast(dirPath === USER_ROW ? 'Switched to user scope' : 'Project switched', 'success');
|
|
246
268
|
} catch (err) {
|
|
247
269
|
showToast(err.message, 'error');
|
|
248
270
|
} finally {
|
|
@@ -291,6 +313,17 @@ function getRecentProjects() {
|
|
|
291
313
|
}
|
|
292
314
|
}
|
|
293
315
|
|
|
316
|
+
// A fresh app (no recents) starts on the user home, so the hub's replayed project does not apply either.
|
|
317
|
+
function userScopeIsHead() {
|
|
318
|
+
const head = getRecentProjects()[0];
|
|
319
|
+
return !head || head === USER_ROW;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function isUserScopeRemembered() {
|
|
323
|
+
await projectInfoPromise;
|
|
324
|
+
return userScopeIsHead();
|
|
325
|
+
}
|
|
326
|
+
|
|
294
327
|
function addRecentProject(p) {
|
|
295
328
|
const recent = getRecentProjects().filter((r) => r !== p);
|
|
296
329
|
recent.unshift(p);
|
|
@@ -305,7 +338,7 @@ function setPickerMode(add) {
|
|
|
305
338
|
document.getElementById('projectPickerFilter').hidden = add;
|
|
306
339
|
document.getElementById('projectPickerAddForm').hidden = !add;
|
|
307
340
|
const input = document.getElementById(add ? 'projectPathInput' : 'projectPickerInput');
|
|
308
|
-
if (add) input.value =
|
|
341
|
+
if (add) input.value = projectData?.path || '';
|
|
309
342
|
// The overlay fades in, so a focus() on a field that is still hidden is dropped.
|
|
310
343
|
setTimeout(() => input.focus(), 100);
|
|
311
344
|
}
|
|
@@ -319,19 +352,29 @@ function splitProjectPath(p) {
|
|
|
319
352
|
function renderProjectPicker() {
|
|
320
353
|
const list = document.getElementById('projectPickerList');
|
|
321
354
|
const q = document.getElementById('projectPickerInput').value.trim().toLowerCase();
|
|
322
|
-
const current =
|
|
323
|
-
const recent = getRecentProjects();
|
|
324
|
-
|
|
355
|
+
const current = projectKey(projectData);
|
|
356
|
+
const recent = getRecentProjects().filter((p) => p !== USER_ROW);
|
|
357
|
+
const userMatches = !q || 'user (no project)'.includes(q);
|
|
358
|
+
pickerRows = [
|
|
359
|
+
...(userMatches ? [USER_ROW] : []),
|
|
360
|
+
...(q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent),
|
|
361
|
+
];
|
|
325
362
|
if (!pickerRows.length) {
|
|
326
363
|
pickerIdx = -1;
|
|
327
|
-
|
|
328
|
-
list.innerHTML = `<div class="picker-empty">${msg}</div>`;
|
|
364
|
+
list.innerHTML = '<div class="picker-empty">No projects match</div>';
|
|
329
365
|
return;
|
|
330
366
|
}
|
|
331
367
|
list.innerHTML = pickerRows
|
|
332
368
|
.map((p, i) => {
|
|
333
|
-
const { name, parent } = splitProjectPath(p);
|
|
334
369
|
const cur = p === current;
|
|
370
|
+
if (p === USER_ROW) {
|
|
371
|
+
return `<div class="picker-row picker-user${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="Sources every session loads, whatever the project">
|
|
372
|
+
<span class="picker-name">User (no project)</span>
|
|
373
|
+
<span class="picker-parent">${esc(claudeConfigDir)}</span>
|
|
374
|
+
${cur ? '<span class="picker-tag">current</span>' : ''}
|
|
375
|
+
</div>`;
|
|
376
|
+
}
|
|
377
|
+
const { name, parent } = splitProjectPath(p);
|
|
335
378
|
return `<div class="picker-row${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="${esc(p)}">
|
|
336
379
|
<span class="picker-name">${esc(name)}</span>
|
|
337
380
|
<span class="picker-parent">${esc(parent)}</span>
|
|
@@ -363,7 +406,7 @@ function _pickProject(idx) {
|
|
|
363
406
|
function _removeRecentProject(idx, e) {
|
|
364
407
|
e.stopPropagation();
|
|
365
408
|
const target = pickerRows[idx];
|
|
366
|
-
if (!target) return;
|
|
409
|
+
if (!target || target === USER_ROW) return;
|
|
367
410
|
localStorage.setItem(recentsKey(), JSON.stringify(getRecentProjects().filter((p) => p !== target)));
|
|
368
411
|
renderProjectPicker();
|
|
369
412
|
}
|
|
@@ -533,7 +576,8 @@ function toggleHelpModal() {
|
|
|
533
576
|
}
|
|
534
577
|
|
|
535
578
|
function openHealthView() {
|
|
536
|
-
healthViewOpen = !healthViewOpen;
|
|
579
|
+
healthViewOpen = homeOpen || !healthViewOpen;
|
|
580
|
+
leaveHome();
|
|
537
581
|
document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
|
|
538
582
|
renderPreview();
|
|
539
583
|
if (healthViewOpen) refreshAnalysis();
|
|
@@ -543,13 +587,14 @@ function openHealthView() {
|
|
|
543
587
|
|
|
544
588
|
// #region RENDER_TREE
|
|
545
589
|
|
|
546
|
-
const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
|
|
590
|
+
const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent', 'agent-memory'];
|
|
547
591
|
const SCOPE_LABELS = {
|
|
548
592
|
policy: 'Managed Policy',
|
|
549
593
|
user: 'User',
|
|
550
594
|
project: 'Project',
|
|
551
595
|
rule: 'Rules',
|
|
552
596
|
skill: 'Skills',
|
|
597
|
+
agent: 'Agents',
|
|
553
598
|
memory: 'Auto Memory',
|
|
554
599
|
'agent-memory': 'Agent Memory',
|
|
555
600
|
};
|
|
@@ -646,9 +691,10 @@ function renderTree() {
|
|
|
646
691
|
}
|
|
647
692
|
|
|
648
693
|
let html = '';
|
|
694
|
+
if (projectData?.user) html += treeHomeLink();
|
|
649
695
|
for (const scope of SCOPE_ORDER) {
|
|
650
696
|
const items = groups[scope];
|
|
651
|
-
if (!items) continue;
|
|
697
|
+
if (!items || !inFocus(scope)) continue;
|
|
652
698
|
const label = SCOPE_LABELS[scope] || scope;
|
|
653
699
|
const collapsible = scope === 'agent-memory';
|
|
654
700
|
const collapsed = collapsible && isGroupCollapsed(scope);
|
|
@@ -737,13 +783,16 @@ function pushFileState(id) {
|
|
|
737
783
|
}
|
|
738
784
|
|
|
739
785
|
function selectFile(id, pushState = true) {
|
|
786
|
+
leaveHome();
|
|
740
787
|
if (healthViewOpen) {
|
|
741
788
|
healthViewOpen = false;
|
|
742
789
|
document.getElementById('analyzeBtn')?.classList.remove('active');
|
|
743
790
|
}
|
|
744
791
|
selectedFileId = selectedFileId === id ? null : id;
|
|
792
|
+
previewPath = null;
|
|
745
793
|
if (selectedFileId) {
|
|
746
794
|
const item = stackData.find((s) => s.id === selectedFileId);
|
|
795
|
+
if (item && !inFocus(getItemScope(item))) homeFocus = null;
|
|
747
796
|
if (item) {
|
|
748
797
|
expandGroupForItem(item);
|
|
749
798
|
expandItemAncestors(item);
|
|
@@ -783,7 +832,7 @@ function getVisibleOrder() {
|
|
|
783
832
|
}
|
|
784
833
|
};
|
|
785
834
|
for (const scope of SCOPE_ORDER) {
|
|
786
|
-
if (isGroupCollapsed(scope)) continue;
|
|
835
|
+
if (isGroupCollapsed(scope) || !inFocus(scope)) continue;
|
|
787
836
|
const items = groups[scope];
|
|
788
837
|
if (items) for (const item of items) walk(item);
|
|
789
838
|
}
|
|
@@ -882,7 +931,7 @@ async function renderPreview() {
|
|
|
882
931
|
if (lastAnalysisSt) rerenderAnalysis();
|
|
883
932
|
return;
|
|
884
933
|
}
|
|
885
|
-
const source = stackData.find((s) => s.id === selectedFileId);
|
|
934
|
+
const source = stackData.find((s) => s.id === selectedFileId) || previewPath;
|
|
886
935
|
if (!source) {
|
|
887
936
|
panel.innerHTML =
|
|
888
937
|
'<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>';
|
|
@@ -893,9 +942,11 @@ async function renderPreview() {
|
|
|
893
942
|
try {
|
|
894
943
|
fileData = await fetchJSON(`/api/file?path=${encodeURIComponent(source.path)}`);
|
|
895
944
|
} catch {
|
|
896
|
-
|
|
945
|
+
const msg = source.outside ? `${source.path} does not exist` : 'Failed to load file';
|
|
946
|
+
panel.innerHTML = `<div class="preview-empty"><span>${esc(msg)}</span></div>`;
|
|
897
947
|
return;
|
|
898
948
|
}
|
|
949
|
+
if (source.outside) Object.assign(source, { lines: fileData.lines, bytes: fileData.bytes });
|
|
899
950
|
|
|
900
951
|
let html = '<div class="preview-header">';
|
|
901
952
|
html += '<div class="preview-title">';
|
|
@@ -909,7 +960,8 @@ async function renderPreview() {
|
|
|
909
960
|
}
|
|
910
961
|
}
|
|
911
962
|
html += `<button class="action-btn small" onclick="openInEditor('${escAttrJs(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>`;
|
|
912
|
-
|
|
963
|
+
if (!source.outside)
|
|
964
|
+
html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
|
|
913
965
|
html += '</div></div>';
|
|
914
966
|
|
|
915
967
|
// Description gets its own full-width line; the rest stay as key:value chips
|
|
@@ -1114,6 +1166,7 @@ async function openInEditor(filePath) {
|
|
|
1114
1166
|
|
|
1115
1167
|
// #region ANALYZER
|
|
1116
1168
|
|
|
1169
|
+
const SEV_ORDER = { high: 0, med: 1, low: 2, clean: 3 };
|
|
1117
1170
|
let analysisPollTimer = null;
|
|
1118
1171
|
let lastAnalysisSt = null;
|
|
1119
1172
|
let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
|
|
@@ -1270,6 +1323,7 @@ async function refreshAnalysis() {
|
|
|
1270
1323
|
renderTree();
|
|
1271
1324
|
if (isNew && !healthViewOpen && selectedFileId) renderPreview();
|
|
1272
1325
|
rerenderAnalysis(st);
|
|
1326
|
+
if (homeOpen) renderHome();
|
|
1273
1327
|
}
|
|
1274
1328
|
lastPollKey = pollKey;
|
|
1275
1329
|
clearTimeout(analysisPollTimer);
|
|
@@ -1346,8 +1400,11 @@ function analysisScopeGroups() {
|
|
|
1346
1400
|
function _toggleScopePanel() {
|
|
1347
1401
|
scopePanelOpen = !scopePanelOpen;
|
|
1348
1402
|
if (scopePanelOpen && !scopeChecked) {
|
|
1349
|
-
// default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
|
|
1350
|
-
|
|
1403
|
+
// default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in);
|
|
1404
|
+
// the user scope has no auto memory, so it starts on the user CLAUDE.md
|
|
1405
|
+
const memory = stackData.filter((s) => s.scope === 'memory');
|
|
1406
|
+
const fallback = stackData.filter((s) => s.scope === 'user' && !s.parentId);
|
|
1407
|
+
scopeChecked = new Set((memory.length ? memory : fallback).map((s) => s.id));
|
|
1351
1408
|
}
|
|
1352
1409
|
rerenderAnalysis();
|
|
1353
1410
|
}
|
|
@@ -1743,14 +1800,13 @@ function renderMemoryMap(shown) {
|
|
|
1743
1800
|
}
|
|
1744
1801
|
}
|
|
1745
1802
|
}
|
|
1746
|
-
const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
|
|
1747
1803
|
const cellSev = (s) => {
|
|
1748
1804
|
const c = perFile.get(s.id);
|
|
1749
1805
|
return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
|
|
1750
1806
|
};
|
|
1751
1807
|
let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
|
|
1752
1808
|
for (const s of [...audited].sort(
|
|
1753
|
-
(a, b) =>
|
|
1809
|
+
(a, b) => SEV_ORDER[cellSev(a)] - SEV_ORDER[cellSev(b)] || a.name.localeCompare(b.name),
|
|
1754
1810
|
)) {
|
|
1755
1811
|
const c = perFile.get(s.id);
|
|
1756
1812
|
const sev = cellSev(s);
|
|
@@ -1815,10 +1871,9 @@ function _toggleFixSelect(idx) {
|
|
|
1815
1871
|
}
|
|
1816
1872
|
|
|
1817
1873
|
function _copyFixPlan() {
|
|
1818
|
-
const order = { high: 0, med: 1, low: 2 };
|
|
1819
1874
|
const picked = analysisFindings
|
|
1820
1875
|
.filter((f) => fixSelected.has(findingKey(f)))
|
|
1821
|
-
.sort((a, b) =>
|
|
1876
|
+
.sort((a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity]);
|
|
1822
1877
|
if (!picked.length) return;
|
|
1823
1878
|
const plan = [
|
|
1824
1879
|
`Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
|
|
@@ -2002,6 +2057,11 @@ function renderAnalysis(st) {
|
|
|
2002
2057
|
|
|
2003
2058
|
// #region RENDER_BUDGET
|
|
2004
2059
|
|
|
2060
|
+
// Listed descriptions: [scope, summary key, label]. Each item's cost is its description, not its file.
|
|
2061
|
+
const DESC_SEGMENTS = [
|
|
2062
|
+
['skill', 'skillDesc', 'Skill descriptions'],
|
|
2063
|
+
['agent', 'agentDesc', 'Agent descriptions'],
|
|
2064
|
+
];
|
|
2005
2065
|
let footprintHighlight = false;
|
|
2006
2066
|
let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
|
|
2007
2067
|
let footprintIds = new Set(); // source ids the server counted into the footprint
|
|
@@ -2023,7 +2083,7 @@ function _toggleFootprint(scope = null, ev) {
|
|
|
2023
2083
|
}
|
|
2024
2084
|
|
|
2025
2085
|
function footprintHit(item) {
|
|
2026
|
-
if (
|
|
2086
|
+
if (DESC_SEGMENTS.some(([scope]) => scope === footprintScope)) return item.scope === footprintScope && !item.parentId;
|
|
2027
2087
|
if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
|
|
2028
2088
|
return footprintIds.has(item.id);
|
|
2029
2089
|
}
|
|
@@ -2053,7 +2113,6 @@ function renderBudget() {
|
|
|
2053
2113
|
// the footprint filter, the client only renders its per-scope totals.
|
|
2054
2114
|
const segContainer = document.getElementById('budgetSegments');
|
|
2055
2115
|
const scopeTotals = summaryData.scopeChars || {};
|
|
2056
|
-
const skillDescChars = summaryData.skillDesc?.chars || 0;
|
|
2057
2116
|
const totalChars = summaryData.totalChars || 1;
|
|
2058
2117
|
let html = '';
|
|
2059
2118
|
for (const scope of SCOPE_ORDER) {
|
|
@@ -2062,9 +2121,11 @@ function renderBudget() {
|
|
|
2062
2121
|
const pct = (chars / totalChars) * 100;
|
|
2063
2122
|
html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
|
|
2064
2123
|
}
|
|
2065
|
-
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2124
|
+
for (const [scope, desc, label] of DESC_SEGMENTS) {
|
|
2125
|
+
const chars = summaryData[desc]?.chars || 0;
|
|
2126
|
+
if (!chars) continue;
|
|
2127
|
+
const pct = (chars / totalChars) * 100;
|
|
2128
|
+
html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${esc(label)} (${esc(summaryData[desc].count)}): ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
|
|
2068
2129
|
}
|
|
2069
2130
|
segContainer.innerHTML = html;
|
|
2070
2131
|
applySegmentState();
|
|
@@ -2072,11 +2133,470 @@ function renderBudget() {
|
|
|
2072
2133
|
|
|
2073
2134
|
// #endregion RENDER_BUDGET
|
|
2074
2135
|
|
|
2136
|
+
// #region HOME
|
|
2137
|
+
|
|
2138
|
+
const svgIcon = (body) => `<svg class="home-ic" viewBox="0 0 24 24">${body}</svg>`;
|
|
2139
|
+
const HOME_ICON = svgIcon('<path d="M3 10l9-7 9 7v10a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M9 22V12h6v10"/>');
|
|
2140
|
+
const LOCK_ICON = svgIcon('<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>');
|
|
2141
|
+
const SPARKLE_ICON = svgIcon(
|
|
2142
|
+
'<path d="M12 3l1.9 5.7L19.6 10l-5.7 1.9L12 17.6l-1.9-5.7L4.4 10l5.7-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
|
2143
|
+
);
|
|
2144
|
+
|
|
2145
|
+
function applyLayout() {
|
|
2146
|
+
const on = homeOpen && !!projectData?.user;
|
|
2147
|
+
document.body.classList.toggle('home-mode', on);
|
|
2148
|
+
if (on) renderHome();
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function leaveHome() {
|
|
2152
|
+
if (!homeOpen) return;
|
|
2153
|
+
homeOpen = false;
|
|
2154
|
+
applyLayout();
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function showHome() {
|
|
2158
|
+
homeOpen = true;
|
|
2159
|
+
homeFocus = null;
|
|
2160
|
+
previewPath = null;
|
|
2161
|
+
healthViewOpen = false;
|
|
2162
|
+
applyLayout();
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function inFocus(scope) {
|
|
2166
|
+
return !homeFocus || homeFocus.includes(scope);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function treeHomeLink() {
|
|
2170
|
+
const home = `<span class="tree-home-crumb" onclick="showHome()">${HOME_ICON}Home</span>`;
|
|
2171
|
+
if (!homeFocus) return `<div class="tree-home-link">${home}</div>`;
|
|
2172
|
+
const { groups } = getTreeIndex();
|
|
2173
|
+
const label = homeFocus
|
|
2174
|
+
.filter((s) => groups[s])
|
|
2175
|
+
.map((s) => SCOPE_LABELS[s] || s)
|
|
2176
|
+
.join(' + ');
|
|
2177
|
+
return `<div class="tree-home-link">${home}<span class="tree-home-sep">›</span><span class="tree-home-focus">${esc(label)}</span><span class="tree-home-all" onclick="_clearHomeFocus()" title="Show every group">show all</span></div>`;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function _clearHomeFocus() {
|
|
2181
|
+
homeFocus = null;
|
|
2182
|
+
renderTree();
|
|
2183
|
+
scrollToSelected();
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// scopes is a comma list; without it the clicked file's own group is the focus.
|
|
2187
|
+
function _openFromHome(id, scopes) {
|
|
2188
|
+
const item = stackData.find((s) => s.id === id);
|
|
2189
|
+
homeFocus = scopes ? scopes.split(',') : item ? [getItemScope(item)] : null;
|
|
2190
|
+
for (const s of homeFocus || []) setGroupCollapsed(s, false);
|
|
2191
|
+
selectedFileId = null;
|
|
2192
|
+
selectFile(id);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function _openPath(path, scope) {
|
|
2196
|
+
leaveHome();
|
|
2197
|
+
healthViewOpen = false;
|
|
2198
|
+
selectedFileId = null;
|
|
2199
|
+
previewPath = { outside: true, id: null, scope, load: 'read-only', path, name: splitProjectPath(path).name };
|
|
2200
|
+
renderTree();
|
|
2201
|
+
renderPreview();
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function fmtChars(n) {
|
|
2205
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function rootOf(item, byId) {
|
|
2209
|
+
let cur = item;
|
|
2210
|
+
while (cur?.parentId && byId.has(cur.parentId)) cur = byId.get(cur.parentId);
|
|
2211
|
+
return cur;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function hardImportsOf(rootId) {
|
|
2215
|
+
const kids = (getTreeIndex().childrenOf[rootId] || []).filter((s) => s.load === 'import');
|
|
2216
|
+
return kids.flatMap((k) => [k, ...hardImportsOf(k.id)]);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function withImportsChars(item) {
|
|
2220
|
+
return [item, ...hardImportsOf(item.id)].reduce((n, s) => n + (s.chars || 0), 0);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
const HOME_NONE = '<li><span class="home-empty">None</span></li>';
|
|
2224
|
+
|
|
2225
|
+
function moreRow(n) {
|
|
2226
|
+
return `<li class="home-more"><span class="home-nm">+ ${n} more</span></li>`;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function homeBudget() {
|
|
2230
|
+
const parts = { policy: 0, user: 0, rule: 0 };
|
|
2231
|
+
const byId = new Map(stackData.map((s) => [s.id, s]));
|
|
2232
|
+
for (const s of stackData) {
|
|
2233
|
+
if (!footprintIds.has(s.id)) continue;
|
|
2234
|
+
const root = rootOf(s, byId);
|
|
2235
|
+
if (root.scope === 'policy') parts.policy += s.chars || 0;
|
|
2236
|
+
else if (root.id === 'user-claude-md') parts.user += s.chars || 0;
|
|
2237
|
+
else if (root.scope === 'rule' && root.load === 'always') parts.rule += s.chars || 0;
|
|
2238
|
+
}
|
|
2239
|
+
parts.policy += homeData?.managed?.claudeMdChars || 0;
|
|
2240
|
+
const style = homeData?.outputStyle?.chars ?? null;
|
|
2241
|
+
return [
|
|
2242
|
+
{ key: 'policy', label: 'Policy', chars: parts.policy },
|
|
2243
|
+
{ key: 'user', label: 'CLAUDE.md + imports', chars: parts.user },
|
|
2244
|
+
{ key: 'rule', label: 'Always-on rules', chars: parts.rule },
|
|
2245
|
+
{ key: 'style', label: 'Output style', chars: style || 0, unknown: style == null },
|
|
2246
|
+
{ key: 'skill', label: 'Skill descriptions', chars: summaryData?.skillDesc?.chars || 0 },
|
|
2247
|
+
{ key: 'agent', label: 'Agent descriptions', chars: summaryData?.agentDesc?.chars || 0 },
|
|
2248
|
+
];
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function homeCardClick({ open, focus, file, fileScope, health }) {
|
|
2252
|
+
if (open) return ` onclick="_openFromHome('${escAttrJs(open)}', '${escAttrJs(focus || '')}')"`;
|
|
2253
|
+
if (file) return ` onclick="_openPath('${escAttrJs(file)}', '${escAttrJs(fileScope)}')"`;
|
|
2254
|
+
if (health) return ' onclick="openHealthView()"';
|
|
2255
|
+
return '';
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function homeCard({ color, title, when, always, wide, full, body, ...click }) {
|
|
2259
|
+
const onclick = homeCardClick(click);
|
|
2260
|
+
return `<div class="home-card${full ? ' full' : wide ? ' wide' : ''}" style="--c:${color}"${onclick}${onclick ? ' tabindex="0" role="button"' : ''}>
|
|
2261
|
+
<div class="home-ch"><span class="home-title">${title}</span>${when ? `<span class="home-when${always ? ' always' : ''}">${esc(when)}</span>` : ''}</div>
|
|
2262
|
+
${body}
|
|
2263
|
+
</div>`;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function homeRow(s, right, extra = '', label = s.name) {
|
|
2267
|
+
return `<li onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-nm">${esc(label)}</span>${extra}<span class="home-r">${right}</span></li>`;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function homeMiniRow(s, chars, max, color) {
|
|
2271
|
+
const pct = max ? Math.max(4, Math.round((chars / max) * 100)) : 0;
|
|
2272
|
+
return homeRow(
|
|
2273
|
+
s,
|
|
2274
|
+
esc(chars.toLocaleString()),
|
|
2275
|
+
`<span class="home-mini" style="--c:${color}"><span style="width:${pct}%"></span></span>`,
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function homeTopRows(items, color, limit = 3) {
|
|
2280
|
+
if (!items.length) return HOME_NONE;
|
|
2281
|
+
const sorted = [...items].sort((a, b) => b.descChars - a.descChars);
|
|
2282
|
+
const max = sorted[0].descChars;
|
|
2283
|
+
let html = sorted
|
|
2284
|
+
.slice(0, limit)
|
|
2285
|
+
.map((s) => homeMiniRow(s, s.descChars, max, color))
|
|
2286
|
+
.join('');
|
|
2287
|
+
if (sorted.length > limit) html += moreRow(sorted.length - limit);
|
|
2288
|
+
return html;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function homeStats(pairs) {
|
|
2292
|
+
return `<div class="home-stats">${pairs.map(([v, l]) => `<div><b>${esc(v)}</b><span>${esc(l)}</span></div>`).join('')}</div>`;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function renderHomePolicy() {
|
|
2296
|
+
const policy = stackData.find((s) => s.scope === 'policy' && !s.parentId);
|
|
2297
|
+
const managed = homeData?.managed || {};
|
|
2298
|
+
const managedPath = homeData?.settingsPaths?.managed || '';
|
|
2299
|
+
const none = '<span class="home-pill mut">none</span>';
|
|
2300
|
+
const mdRow = policy
|
|
2301
|
+
? homeRow(policy, esc(`${fmtChars(withImportsChars(policy))} chars`), '', 'CLAUDE.md')
|
|
2302
|
+
: `<li><span class="home-nm">CLAUDE.md</span><span class="home-r">${none}</span></li>`;
|
|
2303
|
+
const settingsName = splitProjectPath(managedPath).name || 'managed-settings.json';
|
|
2304
|
+
const settingsRow = managed.keys
|
|
2305
|
+
? `<li onclick="event.stopPropagation();_openPath('${escAttrJs(managedPath)}', 'policy')" title="${esc(managedPath)}"><span class="home-nm">${esc(settingsName)}${managed.source === 'server' ? ' <span class="home-d">· from claude.ai</span>' : ''}</span><span class="home-r">${esc(managed.keys.join(', ') || 'empty')}</span></li>`
|
|
2306
|
+
: `<li title="${esc(managedPath)}"><span class="home-nm">managed settings</span><span class="home-r">${none}</span></li>`;
|
|
2307
|
+
const claudeMdRow =
|
|
2308
|
+
managed.claudeMdChars == null
|
|
2309
|
+
? ''
|
|
2310
|
+
: `<li><span class="home-nm">claudeMd in settings</span><span class="home-r">${esc(`${fmtChars(managed.claudeMdChars)} chars`)}</span></li>`;
|
|
2311
|
+
return homeCard({
|
|
2312
|
+
color: 'var(--scope-policy)',
|
|
2313
|
+
title: `${LOCK_ICON}Managed policy`,
|
|
2314
|
+
when: 'always',
|
|
2315
|
+
always: true,
|
|
2316
|
+
open: policy?.id,
|
|
2317
|
+
file: managed.keys ? managedPath : null,
|
|
2318
|
+
fileScope: 'policy',
|
|
2319
|
+
body: `<ul class="home-rows">${mdRow}${settingsRow}${claudeMdRow}</ul>`,
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function renderHomeUserMd() {
|
|
2324
|
+
const md = stackData.find((s) => s.id === 'user-claude-md');
|
|
2325
|
+
const card = { color: 'var(--scope-user)', title: 'User CLAUDE.md', when: 'always', always: true };
|
|
2326
|
+
if (!md) {
|
|
2327
|
+
return homeCard({
|
|
2328
|
+
...card,
|
|
2329
|
+
body: `<div class="home-path">${esc(summaryData?.userClaudeMd || '')}</div><div class="home-empty">File does not exist</div>`,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
const imports = hardImportsOf(md.id);
|
|
2333
|
+
const tree = imports
|
|
2334
|
+
.map((s, i) => {
|
|
2335
|
+
const branch = i === imports.length - 1 ? '└─' : '├─';
|
|
2336
|
+
return `<div onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-d">${branch}</span> @${esc(s.name)} <span class="home-d">${esc(fmtChars(s.chars || 0))}</span></div>`;
|
|
2337
|
+
})
|
|
2338
|
+
.join('');
|
|
2339
|
+
return homeCard({
|
|
2340
|
+
...card,
|
|
2341
|
+
open: md.id,
|
|
2342
|
+
body: `<div class="home-path" title="${esc(md.path)}">${esc(md.path)}</div>${homeStats([
|
|
2343
|
+
[md.lines, 'lines'],
|
|
2344
|
+
[fmtChars(withImportsChars(md)), 'chars'],
|
|
2345
|
+
[imports.length, 'imports'],
|
|
2346
|
+
])}${imports.length ? `<div class="home-tree">${tree}</div>` : ''}`,
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function renderHomeStyle() {
|
|
2351
|
+
const st = homeData?.outputStyle;
|
|
2352
|
+
if (!st) return '';
|
|
2353
|
+
const isDefault = st.from === 'default';
|
|
2354
|
+
const isManaged = st.from === 'managed';
|
|
2355
|
+
const fromLabel = isDefault ? 'not set · default' : `set in ${isManaged ? 'managed' : 'user'} settings`;
|
|
2356
|
+
const keep = st.keepCodingInstructions == null ? 'n/a' : String(st.keepCodingInstructions);
|
|
2357
|
+
const setIn = isManaged ? homeData.settingsPaths.managed : homeData.settingsPaths.user;
|
|
2358
|
+
const setInScope = isManaged ? 'policy' : 'user';
|
|
2359
|
+
const fromRow = isDefault
|
|
2360
|
+
? `<li><span class="home-nm">${esc(fromLabel)}</span></li>`
|
|
2361
|
+
: `<li onclick="event.stopPropagation();_openPath('${escAttrJs(setIn)}', '${escAttrJs(setInScope)}')"><span class="home-nm" title="${esc(setIn)}">${esc(fromLabel)}</span></li>`;
|
|
2362
|
+
return homeCard({
|
|
2363
|
+
color: 'var(--scope-style)',
|
|
2364
|
+
title: 'Output style',
|
|
2365
|
+
when: 'always',
|
|
2366
|
+
always: true,
|
|
2367
|
+
file: st.path || (isDefault ? null : setIn),
|
|
2368
|
+
fileScope: st.path ? 'user' : setInScope,
|
|
2369
|
+
body: `${homeStats([[st.name, !st.path ? 'built-in · text not on disk' : `${fmtChars(st.chars || 0)} chars`]])}
|
|
2370
|
+
<ul class="home-rows">
|
|
2371
|
+
${fromRow}
|
|
2372
|
+
<li><span class="home-nm">keep-coding-instructions</span><span class="home-r">${esc(keep)}</span></li>
|
|
2373
|
+
</ul>`,
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function renderHomeRules() {
|
|
2378
|
+
const rules = stackData.filter((s) => s.scope === 'rule' && !s.parentId);
|
|
2379
|
+
const always = rules.filter((s) => s.load === 'always');
|
|
2380
|
+
const cond = rules.filter((s) => s.load !== 'always');
|
|
2381
|
+
const alwaysChars = always.reduce((n, s) => n + withImportsChars(s), 0);
|
|
2382
|
+
const globs = (s) => {
|
|
2383
|
+
const p = s.frontmatter?.paths;
|
|
2384
|
+
return Array.isArray(p) ? p.join(', ') : String(p || '');
|
|
2385
|
+
};
|
|
2386
|
+
const list = (items, right) => (items.length ? items.map((s) => homeRow(s, right(s))).join('') : HOME_NONE);
|
|
2387
|
+
return homeCard({
|
|
2388
|
+
color: 'var(--scope-rule)',
|
|
2389
|
+
title: 'User rules',
|
|
2390
|
+
when: `${claudeConfigDir}/rules`,
|
|
2391
|
+
wide: true,
|
|
2392
|
+
open: rules[0]?.id,
|
|
2393
|
+
body: `<div class="home-cols">
|
|
2394
|
+
<div><div class="home-colh">Always loaded · ${always.length}${always.length ? ` · ${esc(fmtChars(alwaysChars))}` : ''}</div>
|
|
2395
|
+
<ul class="home-rows">${list(always, (s) => esc(fmtChars(withImportsChars(s))))}</ul></div>
|
|
2396
|
+
<div><div class="home-colh">When a file matches · ${cond.length}</div>
|
|
2397
|
+
<ul class="home-rows">${list(cond, (s) => esc(globs(s)))}</ul></div>
|
|
2398
|
+
</div>`,
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function renderHomeAgentMemory() {
|
|
2403
|
+
const idx = stackData.filter((s) => s.scope === 'agent-memory' && s.name === 'MEMORY.md' && !s.parentId);
|
|
2404
|
+
const rows = idx
|
|
2405
|
+
.map((s) => {
|
|
2406
|
+
const max = s.maxLines || 200;
|
|
2407
|
+
const pct = Math.min(100, Math.round((s.lines / max) * 100));
|
|
2408
|
+
const warn = pct >= 90 || s.bytes > (s.maxBytes || 25 * 1024);
|
|
2409
|
+
const bar = `<span class="home-mini" style="--c:var(--scope-agent-memory)"><span class="${warn ? 'warn' : ''}" style="width:${pct}%"></span></span>`;
|
|
2410
|
+
return homeRow(s, esc(`${s.lines}/${max}`), bar, s.agentName);
|
|
2411
|
+
})
|
|
2412
|
+
.join('');
|
|
2413
|
+
return homeCard({
|
|
2414
|
+
color: 'var(--scope-agent-memory)',
|
|
2415
|
+
title: 'Subagent memory',
|
|
2416
|
+
when: 'on agent start',
|
|
2417
|
+
open: idx[0]?.id,
|
|
2418
|
+
body: `<div class="home-path">${esc(claudeConfigDir)}/agent-memory/</div>${
|
|
2419
|
+
rows ? `<ul class="home-rows">${rows}</ul>` : '<div class="home-empty">No subagent keeps user memory</div>'
|
|
2420
|
+
}`,
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
function renderHomeListed() {
|
|
2425
|
+
const skills = stackData.filter((s) => s.scope === 'skill' && s.descChars != null);
|
|
2426
|
+
const agents = stackData.filter((s) => s.scope === 'agent' && s.descChars != null);
|
|
2427
|
+
const first = skills[0] || agents[0];
|
|
2428
|
+
return homeCard({
|
|
2429
|
+
color: 'var(--scope-skill)',
|
|
2430
|
+
title: 'User skills and agents',
|
|
2431
|
+
when: 'descriptions only',
|
|
2432
|
+
always: true,
|
|
2433
|
+
wide: true,
|
|
2434
|
+
open: first?.id,
|
|
2435
|
+
focus: 'skill,agent',
|
|
2436
|
+
body: `<div class="home-cols">
|
|
2437
|
+
<div><div class="home-colh">Skills · ${skills.length}${skills.length ? ` · ${esc(fmtChars(summaryData?.skillDesc?.chars || 0))}` : ''}</div>
|
|
2438
|
+
<ul class="home-rows">${homeTopRows(skills, 'var(--scope-skill)')}</ul></div>
|
|
2439
|
+
<div><div class="home-colh">Agents · ${agents.length}${agents.length ? ` · ${esc(fmtChars(summaryData?.agentDesc?.chars || 0))}` : ''}</div>
|
|
2440
|
+
<ul class="home-rows">${homeTopRows(agents, 'var(--scope-agent)')}</ul></div>
|
|
2441
|
+
</div>${
|
|
2442
|
+
hub.can('project.plugins')
|
|
2443
|
+
? '<ul class="home-rows home-plugins"><li onclick="event.stopPropagation();_openPlugins()"><span class="home-nm">Plugins list their skills and agents too. Open Marketplace</span></li></ul>'
|
|
2444
|
+
: ''
|
|
2445
|
+
}`,
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function _openPlugins() {
|
|
2450
|
+
hub.invoke('project.plugins');
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function renderHomeSettings() {
|
|
2454
|
+
const set = homeData?.settings;
|
|
2455
|
+
if (!set) return '';
|
|
2456
|
+
const show = (v) => {
|
|
2457
|
+
if (v == null) return '—';
|
|
2458
|
+
if (Array.isArray(v)) return v.length ? v.join(', ') : '[]';
|
|
2459
|
+
return String(v);
|
|
2460
|
+
};
|
|
2461
|
+
const rows = [
|
|
2462
|
+
['autoMemoryEnabled', set.autoMemoryEnabled],
|
|
2463
|
+
['autoMemoryDirectory', set.autoMemoryDirectory],
|
|
2464
|
+
['claudeMdExcludes', set.claudeMdExcludes],
|
|
2465
|
+
['cleanupPeriodDays', set.cleanupPeriodDays],
|
|
2466
|
+
['DISABLE_AUTO_MEMORY', set.CLAUDE_CODE_DISABLE_AUTO_MEMORY],
|
|
2467
|
+
]
|
|
2468
|
+
.map(
|
|
2469
|
+
([k, s]) =>
|
|
2470
|
+
`<span class="home-k">${esc(k)}</span><span>${esc(show(s?.value))}<span class="home-from">${esc(s?.from || '')}</span></span>`,
|
|
2471
|
+
)
|
|
2472
|
+
.join('');
|
|
2473
|
+
return homeCard({
|
|
2474
|
+
color: 'var(--text-muted)',
|
|
2475
|
+
title: 'Memory settings',
|
|
2476
|
+
when: 'effective',
|
|
2477
|
+
file: homeData.settingsPaths.user,
|
|
2478
|
+
fileScope: 'user',
|
|
2479
|
+
body: `<div class="home-kv">${rows}</div>`,
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
const SEVERITY_PILL = { high: 'err', med: 'warn', low: 'mut' };
|
|
2484
|
+
|
|
2485
|
+
function renderHomeHealth() {
|
|
2486
|
+
const st = lastAnalysisSt;
|
|
2487
|
+
const run = st ? mergedRunView(st) : null;
|
|
2488
|
+
const latest = allAnalysisRuns(st)[0]?.run;
|
|
2489
|
+
const pending = (st?.pending || []).length;
|
|
2490
|
+
let rows;
|
|
2491
|
+
if (!run) {
|
|
2492
|
+
rows =
|
|
2493
|
+
'<li><span class="home-empty">No run yet. Analyze checks the user scope for conflicts and stale facts.</span></li>';
|
|
2494
|
+
} else {
|
|
2495
|
+
const findings = (run.result?.findings || [])
|
|
2496
|
+
.filter((f) => !isDismissed(f))
|
|
2497
|
+
.sort((a, b) => (SEV_ORDER[a.severity] ?? 3) - (SEV_ORDER[b.severity] ?? 3));
|
|
2498
|
+
rows = findings.length
|
|
2499
|
+
? findings
|
|
2500
|
+
.slice(0, 4)
|
|
2501
|
+
.map(
|
|
2502
|
+
(f) =>
|
|
2503
|
+
`<li><span class="home-pill ${SEVERITY_PILL[f.severity] || 'mut'}">${esc(f.kind || f.severity)}</span><span class="home-nm">${esc(f.title)}</span></li>`,
|
|
2504
|
+
)
|
|
2505
|
+
.join('')
|
|
2506
|
+
: '<li><span class="home-pill ok">ok</span><span class="home-nm">No open findings</span></li>';
|
|
2507
|
+
if (findings.length > 4) rows += moreRow(findings.length - 4);
|
|
2508
|
+
}
|
|
2509
|
+
const when = pending ? 'running…' : latest ? `last run ${analysisTimeAgo(latest.ts)}` : 'never run';
|
|
2510
|
+
return homeCard({
|
|
2511
|
+
color: 'var(--accent)',
|
|
2512
|
+
title: `${SPARKLE_ICON}Health · user scope`,
|
|
2513
|
+
when,
|
|
2514
|
+
full: true,
|
|
2515
|
+
health: true,
|
|
2516
|
+
body: `<ul class="home-rows">${rows}</ul>`,
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function renderHomeRecents() {
|
|
2521
|
+
const recent = getRecentProjects()
|
|
2522
|
+
.filter((p) => p !== USER_ROW)
|
|
2523
|
+
.slice(0, 6);
|
|
2524
|
+
const chips = recent
|
|
2525
|
+
.map((p) => {
|
|
2526
|
+
const { name } = splitProjectPath(p);
|
|
2527
|
+
return `<span class="home-chip" title="${esc(p)}" onclick="_homeSwitch('${escAttrJs(p)}')"><span class="home-dot"></span>${esc(name)}</span>`;
|
|
2528
|
+
})
|
|
2529
|
+
.join('');
|
|
2530
|
+
return `<div class="home-recents"><h2>Open a project</h2><div class="home-chips">${chips}<span class="home-chip home-chip-add" onclick="_homeAddPath()">+ Add path</span></div></div>`;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function _homeSwitch(p) {
|
|
2534
|
+
document.getElementById('projectPathInput').value = p;
|
|
2535
|
+
submitProjectPicker();
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function _goUserHome() {
|
|
2539
|
+
if (projectData?.user) showHome();
|
|
2540
|
+
else _homeSwitch(USER_ROW);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function _homeAddPath() {
|
|
2544
|
+
changeProject();
|
|
2545
|
+
setPickerMode(true);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function renderHome() {
|
|
2549
|
+
const el = document.getElementById('homeView');
|
|
2550
|
+
if (!el || !summaryData) return;
|
|
2551
|
+
const parts = homeBudget();
|
|
2552
|
+
const known = parts.reduce((n, p) => n + p.chars, 0);
|
|
2553
|
+
const total = known || 1;
|
|
2554
|
+
const bar = parts
|
|
2555
|
+
.filter((p) => p.chars)
|
|
2556
|
+
.map(
|
|
2557
|
+
(p) =>
|
|
2558
|
+
`<span style="width:${(p.chars / total) * 100}%;background:var(--scope-${p.key})" title="${esc(p.label)}: ${p.chars.toLocaleString()} chars"></span>`,
|
|
2559
|
+
)
|
|
2560
|
+
.join('');
|
|
2561
|
+
const legend = parts
|
|
2562
|
+
.map((p) =>
|
|
2563
|
+
p.unknown
|
|
2564
|
+
? `<span class="home-unk"><i style="background:var(--scope-${p.key})"></i>${esc(p.label)}: built-in, not counted</span>`
|
|
2565
|
+
: `<span${p.chars ? '' : ' class="home-zero"'}><i style="background:var(--scope-${p.key})"></i>${esc(p.label)} <b>${esc(fmtChars(p.chars))}</b></span>`,
|
|
2566
|
+
)
|
|
2567
|
+
.join('');
|
|
2568
|
+
el.innerHTML = `<div class="home">
|
|
2569
|
+
<h1>Your memory</h1>
|
|
2570
|
+
<div class="home-budget">
|
|
2571
|
+
<div class="home-budget-head"><span class="t">Loaded into every session</span><span class="n">${esc(known.toLocaleString())} <small>chars · from files on disk</small></span></div>
|
|
2572
|
+
<div class="home-bar">${bar}</div>
|
|
2573
|
+
<div class="home-legend">${legend}</div>
|
|
2574
|
+
</div>
|
|
2575
|
+
<div class="home-grid">
|
|
2576
|
+
${renderHomePolicy()}
|
|
2577
|
+
${renderHomeUserMd()}
|
|
2578
|
+
${renderHomeStyle()}
|
|
2579
|
+
${renderHomeListed()}
|
|
2580
|
+
${renderHomeSettings()}
|
|
2581
|
+
${renderHomeRules()}
|
|
2582
|
+
${renderHomeAgentMemory()}
|
|
2583
|
+
${renderHomeHealth()}
|
|
2584
|
+
</div>
|
|
2585
|
+
${renderHomeRecents()}
|
|
2586
|
+
</div>`;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// #endregion HOME
|
|
2590
|
+
|
|
2075
2591
|
// #region DATA
|
|
2076
2592
|
|
|
2077
2593
|
async function loadData() {
|
|
2078
2594
|
try {
|
|
2079
|
-
[stackData, summaryData] = await Promise.all([
|
|
2595
|
+
[stackData, summaryData, homeData] = await Promise.all([
|
|
2596
|
+
fetchJSON('/api/stack'),
|
|
2597
|
+
fetchJSON('/api/summary'),
|
|
2598
|
+
projectData?.user ? fetchJSON('/api/home') : null,
|
|
2599
|
+
]);
|
|
2080
2600
|
invalidateTreeIndex();
|
|
2081
2601
|
renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
|
|
2082
2602
|
renderTree();
|
|
@@ -2089,6 +2609,7 @@ async function loadData() {
|
|
|
2089
2609
|
}
|
|
2090
2610
|
if (selectedFileId) renderTree();
|
|
2091
2611
|
renderPreview();
|
|
2612
|
+
applyLayout();
|
|
2092
2613
|
} catch (err) {
|
|
2093
2614
|
showToast(`Failed to load: ${err.message}`, 'error');
|
|
2094
2615
|
}
|
|
@@ -2136,6 +2657,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
2136
2657
|
openHealthView();
|
|
2137
2658
|
return;
|
|
2138
2659
|
}
|
|
2660
|
+
if (e.key === 'Escape' && projectData?.user && !homeOpen) {
|
|
2661
|
+
e.preventDefault();
|
|
2662
|
+
showHome();
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2139
2665
|
if (e.key === 't') toggleTheme();
|
|
2140
2666
|
if (e.key === 'r') refreshData();
|
|
2141
2667
|
if (e.key === '?') {
|
|
@@ -2146,6 +2672,14 @@ document.addEventListener('keydown', (e) => {
|
|
|
2146
2672
|
e.preventDefault();
|
|
2147
2673
|
changeProject();
|
|
2148
2674
|
}
|
|
2675
|
+
if (homeOpen) {
|
|
2676
|
+
const card = e.target.closest?.('.home-card[onclick]');
|
|
2677
|
+
if (card && (e.key === 'Enter' || e.key === ' ')) {
|
|
2678
|
+
e.preventDefault();
|
|
2679
|
+
card.click();
|
|
2680
|
+
}
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2149
2683
|
// Skip when modifiers are held (e.g. hub's Ctrl+Alt+Arrow app switching).
|
|
2150
2684
|
const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
2151
2685
|
if (plain && (e.key === 'j' || e.key === 'ArrowDown')) {
|
|
@@ -2165,8 +2699,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
2165
2699
|
expandOrDescend();
|
|
2166
2700
|
}
|
|
2167
2701
|
if (e.key === 'Enter' && selectedFileId) renderPreview();
|
|
2168
|
-
if (e.key === 'e' && selectedFileId) {
|
|
2169
|
-
const s = stackData.find((x) => x.id === selectedFileId);
|
|
2702
|
+
if (e.key === 'e' && (selectedFileId || previewPath)) {
|
|
2703
|
+
const s = stackData.find((x) => x.id === selectedFileId) || previewPath;
|
|
2170
2704
|
if (s) openInEditor(s.path);
|
|
2171
2705
|
}
|
|
2172
2706
|
});
|
|
@@ -2207,6 +2741,7 @@ hub.onThemes((themes) => {
|
|
|
2207
2741
|
// Set synchronously when the hub pushes a project, so the DOMContentLoaded restore below can't
|
|
2208
2742
|
// let a stale localStorage recent win the race against the hub's choice.
|
|
2209
2743
|
let hubProjectPath = null;
|
|
2744
|
+
let hubProjectReplayed = false;
|
|
2210
2745
|
// Shared with the DOMContentLoaded restore so a boot restore and a hub push never apply the same project twice.
|
|
2211
2746
|
let lastAppliedProject = null;
|
|
2212
2747
|
|
|
@@ -2217,22 +2752,35 @@ let lastAppliedProject = null;
|
|
|
2217
2752
|
if (lastAppliedProject === dirPath) return;
|
|
2218
2753
|
lastAppliedProject = dirPath;
|
|
2219
2754
|
try {
|
|
2220
|
-
await putProject(dirPath);
|
|
2755
|
+
const info = await putProject(dirPath);
|
|
2221
2756
|
// The hub does not persist its own scope, so a hub-pushed project must become the recent
|
|
2222
2757
|
// head here: after a hard refresh the boot path PUTs getRecentProjects()[0], which would
|
|
2223
2758
|
// otherwise clobber the server's hub-scoped project with the previous one.
|
|
2224
2759
|
addRecentProject(dirPath);
|
|
2225
|
-
await
|
|
2760
|
+
await loadProject(info);
|
|
2761
|
+
await loadData();
|
|
2226
2762
|
} catch (err) {
|
|
2227
2763
|
lastAppliedProject = null;
|
|
2228
2764
|
throw err;
|
|
2229
2765
|
}
|
|
2230
2766
|
}
|
|
2231
2767
|
|
|
2232
|
-
hub
|
|
2768
|
+
// The hub replays project.changed after its welcome and before the first hub:active; a live
|
|
2769
|
+
// change comes after it. Only a live change takes the app off a remembered user scope.
|
|
2770
|
+
let hubActiveSeen = false;
|
|
2771
|
+
hub.onActive(() => {
|
|
2772
|
+
hubActiveSeen = true;
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
hub.subscribe('project.changed', async (p) => {
|
|
2233
2776
|
const dirPath = typeof p?.project === 'string' && p.project ? p.project : null;
|
|
2234
2777
|
if (!dirPath) return;
|
|
2235
2778
|
hubProjectPath = dirPath;
|
|
2779
|
+
hubProjectReplayed = !hubActiveSeen;
|
|
2780
|
+
if (hubProjectReplayed && (await isUserScopeRemembered())) {
|
|
2781
|
+
if (hubProjectPath === dirPath) hubProjectPath = null;
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2236
2784
|
applyProject(dirPath).catch((err) => console.warn('project.changed failed:', err.message));
|
|
2237
2785
|
});
|
|
2238
2786
|
|
|
@@ -2245,6 +2793,11 @@ let lastAppliedProject = null;
|
|
|
2245
2793
|
|
|
2246
2794
|
// #endregion HUB_INTEGRATION
|
|
2247
2795
|
|
|
2796
|
+
// The home's Marketplace link depends on hub.can, which only knows the hub's actions after welcome.
|
|
2797
|
+
hub.onStatus(() => {
|
|
2798
|
+
if (homeOpen) renderHome();
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2248
2801
|
// #region RESIZE
|
|
2249
2802
|
|
|
2250
2803
|
function initResize() {
|
|
@@ -2304,7 +2857,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
2304
2857
|
// A hub-pushed project outranks the localStorage recent: ClaudeHub.connect() is fire-and-forget at script
|
|
2305
2858
|
// eval, so project.changed can land before or during this block.
|
|
2306
2859
|
await projectInfoPromise;
|
|
2307
|
-
if (!desiredProject)
|
|
2860
|
+
if (!desiredProject) {
|
|
2861
|
+
const hubPick = hubProjectReplayed && userScopeIsHead() ? null : hubProjectPath;
|
|
2862
|
+
desiredProject = hubPick || getRecentProjects()[0] || USER_ROW;
|
|
2863
|
+
}
|
|
2308
2864
|
if (desiredProject) {
|
|
2309
2865
|
// Set before the PUT: the hub's push can land while it is in flight.
|
|
2310
2866
|
lastAppliedProject = desiredProject;
|
|
@@ -2335,12 +2891,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
2335
2891
|
else showToast('Failed to connect to server', 'error');
|
|
2336
2892
|
}
|
|
2337
2893
|
}
|
|
2338
|
-
if (projectData) addRecentProject(projectData
|
|
2894
|
+
if (projectData) addRecentProject(projectKey(projectData));
|
|
2339
2895
|
await loadData();
|
|
2340
2896
|
// Restore file selection from hash
|
|
2341
2897
|
const hash = decodeURIComponent(location.hash.slice(1));
|
|
2342
2898
|
const hashItem = hash && stackData.find((s) => s.id === hash);
|
|
2343
2899
|
if (hashItem) {
|
|
2900
|
+
leaveHome();
|
|
2344
2901
|
selectedFileId = hash;
|
|
2345
2902
|
expandGroupForItem(hashItem);
|
|
2346
2903
|
expandItemAncestors(hashItem);
|