claude-code-memory-explorer 0.9.1 → 0.11.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/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/package.json +5 -3
- package/public/app.js +510 -48
- package/public/index.html +20 -2
- package/public/style.css +607 -32
- package/server.js +550 -45
package/public/app.js
CHANGED
|
@@ -9,12 +9,23 @@ let selectedFileId = null;
|
|
|
9
9
|
|
|
10
10
|
// #region UTILS
|
|
11
11
|
|
|
12
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' };
|
|
13
|
+
|
|
12
14
|
function esc(s) {
|
|
13
|
-
|
|
15
|
+
if (s == null) return '';
|
|
16
|
+
return String(s).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
// For a value landing inside a quoted JS string inside an HTML attribute —
|
|
20
|
+
// onclick="fn('${escAttrJs(x)}')". The browser HTML-decodes the attribute before
|
|
21
|
+
// the JS parser sees it, so the JS escape must happen first and be escaped in turn.
|
|
22
|
+
function escAttrJs(value) {
|
|
23
|
+
const js = String(value == null ? '' : value)
|
|
24
|
+
.replace(/\\/g, '\\\\')
|
|
25
|
+
.replace(/'/g, "\\'")
|
|
26
|
+
.replace(/\r/g, '\\r')
|
|
27
|
+
.replace(/\n/g, '\\n');
|
|
28
|
+
return esc(js);
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
// #endregion UTILS
|
|
@@ -47,7 +58,7 @@ function highlightSource(text, fileName) {
|
|
|
47
58
|
if (fm) {
|
|
48
59
|
const fmHtml = hljs.highlight(fm[1], { language: 'yaml' }).value;
|
|
49
60
|
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}`;
|
|
61
|
+
return `<span class="hl-fm-block"><span class="hl-frontmatter">---</span>\n${fmHtml}\n<span class="hl-frontmatter">---</span></span>\n${bodyHtml}`;
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
return hljs.highlight(text, { language: lang }).value;
|
|
@@ -64,7 +75,7 @@ function linkifyImports(text, _sourceId) {
|
|
|
64
75
|
const ph = (child, display) => {
|
|
65
76
|
const token = `\x00LINK${placeholders.length}\x00`;
|
|
66
77
|
placeholders.push(
|
|
67
|
-
`<a class="inline-import" href="#" onclick="selectFile('${
|
|
78
|
+
`<a class="inline-import" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(display)}</a>`,
|
|
68
79
|
);
|
|
69
80
|
return token;
|
|
70
81
|
};
|
|
@@ -273,9 +284,9 @@ function renderRecentProjects() {
|
|
|
273
284
|
recent
|
|
274
285
|
.map(
|
|
275
286
|
(p) =>
|
|
276
|
-
`<div class="recent-project-item" onclick="_selectRecentProject('${
|
|
287
|
+
`<div class="recent-project-item" onclick="_selectRecentProject('${escAttrJs(p)}')">` +
|
|
277
288
|
`<span>${esc(p)}</span>` +
|
|
278
|
-
`<button class="recent-project-remove" onclick="_removeRecentProject('${
|
|
289
|
+
`<button class="recent-project-remove" onclick="_removeRecentProject('${escAttrJs(p)}', event)" title="Remove">✕</button>` +
|
|
279
290
|
`</div>`,
|
|
280
291
|
)
|
|
281
292
|
.join('');
|
|
@@ -293,6 +304,12 @@ function toggleHelpModal() {
|
|
|
293
304
|
document.getElementById('helpModal').classList.toggle('open');
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
// biome-ignore lint/correctness/noUnusedVariables: called from topbar markup
|
|
308
|
+
function openAnalysisModal() {
|
|
309
|
+
document.getElementById('analysisModal').classList.add('open');
|
|
310
|
+
refreshAnalysis();
|
|
311
|
+
}
|
|
312
|
+
|
|
296
313
|
function bindModalKeys(inputId, modalId, submitFn) {
|
|
297
314
|
document.getElementById(inputId).addEventListener('keydown', (e) => {
|
|
298
315
|
if (e.key === 'Enter') {
|
|
@@ -310,12 +327,13 @@ function bindModalKeys(inputId, modalId, submitFn) {
|
|
|
310
327
|
|
|
311
328
|
// #region RENDER_TREE
|
|
312
329
|
|
|
313
|
-
const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'agent-memory'];
|
|
330
|
+
const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
|
|
314
331
|
const SCOPE_LABELS = {
|
|
315
332
|
policy: 'Managed Policy',
|
|
316
333
|
user: 'User',
|
|
317
334
|
project: 'Project',
|
|
318
335
|
rule: 'Rules',
|
|
336
|
+
skill: 'Skills',
|
|
319
337
|
memory: 'Auto Memory',
|
|
320
338
|
'agent-memory': 'Agent Memory',
|
|
321
339
|
};
|
|
@@ -385,9 +403,9 @@ function renderTree() {
|
|
|
385
403
|
const children = childrenOf[item.id];
|
|
386
404
|
const expanded = children && isItemExpanded(item.id);
|
|
387
405
|
const chevron = children
|
|
388
|
-
? `<span class="tree-chevron" onclick="_toggleItem(event,'${
|
|
406
|
+
? `<span class="tree-chevron" onclick="_toggleItem(event,'${escAttrJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
|
|
389
407
|
: '<span class="tree-chevron-spacer"></span>';
|
|
390
|
-
let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${
|
|
408
|
+
let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
|
|
391
409
|
h += chevron;
|
|
392
410
|
h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
|
|
393
411
|
h += `<span class="file-name">${esc(item.name)}</span>`;
|
|
@@ -408,7 +426,7 @@ function renderTree() {
|
|
|
408
426
|
const collapsed = collapsible && isGroupCollapsed(scope);
|
|
409
427
|
const chevron = collapsible ? `<span class="group-chevron">${collapsed ? '\u25B8' : '\u25BE'}</span>` : '';
|
|
410
428
|
const headerClass = collapsible ? 'tree-group-header tree-group-clickable' : 'tree-group-header';
|
|
411
|
-
const onclick = collapsible ? ` onclick="_toggleGroup('${
|
|
429
|
+
const onclick = collapsible ? ` onclick="_toggleGroup('${escAttrJs(scope)}')"` : '';
|
|
412
430
|
html += `<div class="${headerClass}" data-scope="${esc(scope)}"${onclick}>${chevron}<span class="scope-dot" style="color:var(--scope-${scope})">\u25CF</span> ${esc(label)} <span style="opacity:0.5">${items.length}</span></div>`;
|
|
413
431
|
if (collapsed) continue;
|
|
414
432
|
if (scope === 'agent-memory') {
|
|
@@ -505,7 +523,7 @@ function selectFile(id, pushState = true) {
|
|
|
505
523
|
}
|
|
506
524
|
|
|
507
525
|
function scrollToSelected() {
|
|
508
|
-
const el = document.querySelector(`.tree-item[data-id="${selectedFileId}"]`);
|
|
526
|
+
const el = document.querySelector(`.tree-item[data-id="${CSS.escape(selectedFileId ?? '')}"]`);
|
|
509
527
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
510
528
|
}
|
|
511
529
|
|
|
@@ -608,7 +626,7 @@ function renderMemoryIndexTable(entries) {
|
|
|
608
626
|
const type = typeMatch ? typeMatch[1] : 'memory';
|
|
609
627
|
const scopeColor = TYPE_COLORS[type] || 'memory';
|
|
610
628
|
const nameHtml = child
|
|
611
|
-
? `<a class="import-link" href="#" onclick="selectFile('${
|
|
629
|
+
? `<a class="import-link" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(entry.name)}</a>`
|
|
612
630
|
: `<span class="import-link unresolved" title="Not found: ${esc(entry.file)}">⚠ ${esc(entry.name)}</span>`;
|
|
613
631
|
html += `<div class="memory-index-row">`;
|
|
614
632
|
html += `<span class="scope-badge scope-${scopeColor} memory-index-type">${esc(type)}</span>`;
|
|
@@ -649,21 +667,37 @@ async function renderPreview() {
|
|
|
649
667
|
if (isMemoryIndex(source)) {
|
|
650
668
|
const broken = (source.unresolvedImports || []).length;
|
|
651
669
|
if (broken) {
|
|
652
|
-
html += `<button class="action-btn small" onclick="_cleanupOrphans('${
|
|
670
|
+
html += `<button class="action-btn small" onclick="_cleanupOrphans('${escAttrJs(source.path)}')" title="Cleanup ${broken} orphaned refs"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M12 11v6"/></svg><span style="margin-left:3px">${broken}</span></button>`;
|
|
653
671
|
}
|
|
654
672
|
}
|
|
655
|
-
html += `<button class="action-btn small" onclick="openInEditor('${
|
|
656
|
-
html += `<button class="action-btn small" onclick="_confirmDeleteFile('${
|
|
673
|
+
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>`;
|
|
674
|
+
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>`;
|
|
657
675
|
html += '</div></div>';
|
|
658
676
|
|
|
677
|
+
// Description gets its own full-width line; the rest stay as key:value chips
|
|
678
|
+
const fmDesc =
|
|
679
|
+
fileData.frontmatter && typeof fileData.frontmatter.description === 'string'
|
|
680
|
+
? fileData.frontmatter.description.replace(/\s+/g, ' ')
|
|
681
|
+
: '';
|
|
682
|
+
if (fmDesc)
|
|
683
|
+
html += `<div class="preview-desc" title="Click to expand" onclick="this.classList.toggle('expanded')">${esc(fmDesc)}</div>`;
|
|
684
|
+
|
|
659
685
|
// Badges row
|
|
660
686
|
html += '<div class="preview-badges">';
|
|
661
687
|
html += `<span class="load-badge load-${source.load}">${esc(source.load)}</span>`;
|
|
662
688
|
html += `<span class="tag-badge">${source.lines}L / ${formatBytes(source.bytes)}</span>`;
|
|
663
689
|
if (fileData.frontmatter) {
|
|
664
690
|
for (const [k, v] of Object.entries(fileData.frontmatter)) {
|
|
665
|
-
|
|
666
|
-
|
|
691
|
+
if (k === 'description' && fmDesc) continue;
|
|
692
|
+
let val;
|
|
693
|
+
if (Array.isArray(v)) val = v.join(', ');
|
|
694
|
+
else if (v && typeof v === 'object')
|
|
695
|
+
val = Object.entries(v)
|
|
696
|
+
.map(([ck, cv]) => `${ck}=${cv}`)
|
|
697
|
+
.join(', ');
|
|
698
|
+
else val = String(v).replace(/\s+/g, ' ');
|
|
699
|
+
const shown = val.length > 72 ? `${val.slice(0, 69)}…` : val;
|
|
700
|
+
html += `<span class="tag-badge" title="${esc(`${k}: ${val}`)}"><span class="tag-k">${esc(k)}</span>${esc(shown)}</span>`;
|
|
667
701
|
}
|
|
668
702
|
}
|
|
669
703
|
html += '</div>';
|
|
@@ -677,7 +711,7 @@ async function renderPreview() {
|
|
|
677
711
|
if (!showMemoryTable && (children.length || unresolved.length)) {
|
|
678
712
|
html += '<div class="preview-imports">';
|
|
679
713
|
for (const child of children) {
|
|
680
|
-
html += `<a class="import-link" href="#" onclick="selectFile('${
|
|
714
|
+
html += `<a class="import-link" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
|
|
681
715
|
}
|
|
682
716
|
for (const u of unresolved) {
|
|
683
717
|
html += `<span class="import-link unresolved" title="Not found: ${esc(u)}">⚠ ${esc(u)}</span>`;
|
|
@@ -685,14 +719,15 @@ async function renderPreview() {
|
|
|
685
719
|
html += '</div>';
|
|
686
720
|
}
|
|
687
721
|
|
|
722
|
+
// File path lives inside the header as a muted line instead of its own band
|
|
723
|
+
html += `<div class="preview-filepath" title="${esc(source.path)}">${esc(source.path)}</div>`;
|
|
688
724
|
html += '</div>';
|
|
689
725
|
|
|
690
|
-
// File path
|
|
691
|
-
html += `<div class="preview-filepath">${esc(source.path)}</div>`;
|
|
692
|
-
|
|
693
726
|
const hl = (text) => {
|
|
694
727
|
const { text: processed, placeholders } = linkifyImports(text, source.id);
|
|
695
|
-
|
|
728
|
+
// Skill names aren't filenames (e.g. "aspire") — derive the language from the real file
|
|
729
|
+
const fileName = source.path ? source.path.split(/[\\/]/).pop() : source.name;
|
|
730
|
+
return restorePlaceholders(highlightSource(processed, fileName), placeholders);
|
|
696
731
|
};
|
|
697
732
|
|
|
698
733
|
if (showMemoryTable) {
|
|
@@ -837,6 +872,433 @@ async function openInEditor(filePath) {
|
|
|
837
872
|
|
|
838
873
|
// #endregion RENDER_PREVIEW
|
|
839
874
|
|
|
875
|
+
// #region ANALYZER
|
|
876
|
+
|
|
877
|
+
let analysisPollTimer = null;
|
|
878
|
+
let lastAnalysisSt = null;
|
|
879
|
+
let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
|
|
880
|
+
let analysisFilter = 'all';
|
|
881
|
+
let analysisDismissed = new Set(); // session-only; keys are `${kind}|${title}`
|
|
882
|
+
let analysisTs = null; // reset dismissals + filter when a new result lands
|
|
883
|
+
let analysisRunIdx = 0; // which run from the per-project history is shown (0 = latest)
|
|
884
|
+
|
|
885
|
+
// Session-only identity of a finding, used by dismiss/copy/render alike
|
|
886
|
+
function findingKey(f) {
|
|
887
|
+
return `${f.kind}|${f.title}`;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function resetAnalysisView(runIdx = 0) {
|
|
891
|
+
analysisRunIdx = runIdx;
|
|
892
|
+
analysisFilter = 'all';
|
|
893
|
+
analysisDismissed = new Set();
|
|
894
|
+
}
|
|
895
|
+
let scopePanelOpen = false;
|
|
896
|
+
let scopeChecked = null; // Set of source ids; seeded with the default scope on first open
|
|
897
|
+
const scopeCollapsed = new Set();
|
|
898
|
+
const ANALYSIS_MODELS = ['sonnet', 'opus', 'fable'];
|
|
899
|
+
let analysisModel = ANALYSIS_MODELS.includes(localStorage.getItem('analysisModel'))
|
|
900
|
+
? localStorage.getItem('analysisModel')
|
|
901
|
+
: 'sonnet';
|
|
902
|
+
|
|
903
|
+
function _setAnalysisModel(m) {
|
|
904
|
+
if (!ANALYSIS_MODELS.includes(m)) return;
|
|
905
|
+
analysisModel = m;
|
|
906
|
+
try {
|
|
907
|
+
localStorage.setItem('analysisModel', m);
|
|
908
|
+
} catch {}
|
|
909
|
+
rerenderAnalysis();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function _runAnalysis(force, ids) {
|
|
913
|
+
try {
|
|
914
|
+
const body = { force: !!force, model: analysisModel };
|
|
915
|
+
if (Array.isArray(ids) && ids.length) body.ids = ids;
|
|
916
|
+
const res = await fetch('/api/memory/analyze', {
|
|
917
|
+
method: 'POST',
|
|
918
|
+
headers: { 'Content-Type': 'application/json' },
|
|
919
|
+
body: JSON.stringify(body),
|
|
920
|
+
});
|
|
921
|
+
if (!res.ok && res.status !== 409) {
|
|
922
|
+
const msg = await res
|
|
923
|
+
.json()
|
|
924
|
+
.then((e) => e.error)
|
|
925
|
+
.catch(() => `Analyze failed (${res.status})`);
|
|
926
|
+
showToast(msg, 'error');
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const data = await res.json().catch(() => ({}));
|
|
930
|
+
if (data.cached) showToast('Memory unchanged — showing cached analysis. Use Re-run to force.', 'info');
|
|
931
|
+
} catch (err) {
|
|
932
|
+
showToast(err.message, 'error');
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
refreshAnalysis();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function refreshAnalysis() {
|
|
939
|
+
const el = document.getElementById('analysisSection');
|
|
940
|
+
if (!el) return;
|
|
941
|
+
let st;
|
|
942
|
+
try {
|
|
943
|
+
st = await fetchJSON('/api/memory/analysis');
|
|
944
|
+
} catch {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (st.ts !== analysisTs) {
|
|
948
|
+
analysisTs = st.ts;
|
|
949
|
+
resetAnalysisView();
|
|
950
|
+
}
|
|
951
|
+
lastAnalysisSt = st;
|
|
952
|
+
el.innerHTML = renderAnalysis(st);
|
|
953
|
+
applyScopeIndeterminate(el);
|
|
954
|
+
clearTimeout(analysisPollTimer);
|
|
955
|
+
if (st.running) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Re-render from the cached state — used by scope/filter/dismiss handlers so a
|
|
959
|
+
// checkbox click never triggers a server round-trip.
|
|
960
|
+
function rerenderAnalysis() {
|
|
961
|
+
const el = document.getElementById('analysisSection');
|
|
962
|
+
if (!el || !lastAnalysisSt) return;
|
|
963
|
+
el.innerHTML = renderAnalysis(lastAnalysisSt);
|
|
964
|
+
applyScopeIndeterminate(el);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// `indeterminate` is a DOM property, not an attribute — set it after every innerHTML render.
|
|
968
|
+
function applyScopeIndeterminate(el) {
|
|
969
|
+
for (const box of el.querySelectorAll('input[data-ind]')) box.indeterminate = true;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// #region ANALYZER_SCOPE
|
|
973
|
+
|
|
974
|
+
const CLAUDE_MD_SCOPES = new Set(['project', 'local']);
|
|
975
|
+
|
|
976
|
+
function analysisScopeGroups() {
|
|
977
|
+
return [
|
|
978
|
+
{
|
|
979
|
+
key: 'memory',
|
|
980
|
+
label: 'Auto memory',
|
|
981
|
+
cls: 't-memory',
|
|
982
|
+
tag: 'memory',
|
|
983
|
+
items: stackData.filter((s) => s.scope === 'memory'),
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
key: 'skill',
|
|
987
|
+
label: 'Skills',
|
|
988
|
+
cls: 't-skill',
|
|
989
|
+
tag: 'skill',
|
|
990
|
+
items: stackData.filter((s) => s.scope === 'skill' && !s.parentId),
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
key: 'claudemd',
|
|
994
|
+
label: 'CLAUDE.md',
|
|
995
|
+
cls: 't-claudemd',
|
|
996
|
+
tag: 'md',
|
|
997
|
+
items: stackData.filter((s) => CLAUDE_MD_SCOPES.has(s.scope) && !s.parentId),
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
key: 'agent',
|
|
1001
|
+
label: 'Agent memory',
|
|
1002
|
+
cls: 't-agent',
|
|
1003
|
+
tag: 'agent',
|
|
1004
|
+
items: stackData.filter((s) => s.scope === 'agent-memory'),
|
|
1005
|
+
},
|
|
1006
|
+
].filter((g) => g.items.length);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function _toggleScopePanel() {
|
|
1010
|
+
scopePanelOpen = !scopePanelOpen;
|
|
1011
|
+
if (scopePanelOpen && !scopeChecked) {
|
|
1012
|
+
// default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
|
|
1013
|
+
scopeChecked = new Set(stackData.filter((s) => s.scope === 'memory').map((s) => s.id));
|
|
1014
|
+
}
|
|
1015
|
+
rerenderAnalysis();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function _toggleScopeGroup(key) {
|
|
1019
|
+
const g = analysisScopeGroups().find((x) => x.key === key);
|
|
1020
|
+
if (!g) return;
|
|
1021
|
+
const allOn = g.items.every((s) => scopeChecked.has(s.id));
|
|
1022
|
+
for (const s of g.items) {
|
|
1023
|
+
if (allOn) scopeChecked.delete(s.id);
|
|
1024
|
+
else scopeChecked.add(s.id);
|
|
1025
|
+
}
|
|
1026
|
+
rerenderAnalysis();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function _toggleScopeLeaf(id) {
|
|
1030
|
+
if (scopeChecked.has(id)) scopeChecked.delete(id);
|
|
1031
|
+
else scopeChecked.add(id);
|
|
1032
|
+
rerenderAnalysis();
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function _toggleScopeCollapse(key, e) {
|
|
1036
|
+
e.stopPropagation();
|
|
1037
|
+
if (scopeCollapsed.has(key)) scopeCollapsed.delete(key);
|
|
1038
|
+
else scopeCollapsed.add(key);
|
|
1039
|
+
rerenderAnalysis();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function _runScopedAnalysis() {
|
|
1043
|
+
if (!scopeChecked?.size) return;
|
|
1044
|
+
scopePanelOpen = false;
|
|
1045
|
+
_runAnalysis(true, [...scopeChecked]);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function renderScopePanel() {
|
|
1049
|
+
if (!scopePanelOpen || !scopeChecked) return '';
|
|
1050
|
+
let html = '<div class="scope-panel open">';
|
|
1051
|
+
html += '<div class="scope-panel-title">Select what to analyze</div>';
|
|
1052
|
+
html += '<div class="scope-tree">';
|
|
1053
|
+
for (const g of analysisScopeGroups()) {
|
|
1054
|
+
const on = g.items.filter((s) => scopeChecked.has(s.id)).length;
|
|
1055
|
+
const collapsed = scopeCollapsed.has(g.key);
|
|
1056
|
+
html += `<div class="scope-group${collapsed ? ' collapsed' : ''}">`;
|
|
1057
|
+
html += `<div class="scope-row" onclick="_toggleScopeGroup('${escAttrJs(g.key)}')">`;
|
|
1058
|
+
html += `<span class="scope-caret" onclick="_toggleScopeCollapse('${escAttrJs(g.key)}', event)">${collapsed ? '▸' : '▾'}</span>`;
|
|
1059
|
+
html += `<input type="checkbox" ${on === g.items.length ? 'checked' : ''} ${on > 0 && on < g.items.length ? 'data-ind="1"' : ''} onclick="event.preventDefault()">`;
|
|
1060
|
+
html += `<span class="scope-tag ${esc(g.cls)}">${g.tag}</span>`;
|
|
1061
|
+
html += `<span>${esc(g.label)}</span>`;
|
|
1062
|
+
html += `<span class="scope-meta">${on}/${g.items.length}</span>`;
|
|
1063
|
+
html += '</div>';
|
|
1064
|
+
html += '<div class="scope-children">';
|
|
1065
|
+
for (const s of g.items) {
|
|
1066
|
+
html += `<div class="scope-row" onclick="_toggleScopeLeaf('${escAttrJs(s.id)}')">`;
|
|
1067
|
+
html += `<input type="checkbox" ${scopeChecked.has(s.id) ? 'checked' : ''} onclick="event.preventDefault()">`;
|
|
1068
|
+
html += `<span>${esc(s.name)}</span>`;
|
|
1069
|
+
html += `<span class="scope-meta">${s.lines}L</span>`;
|
|
1070
|
+
html += '</div>';
|
|
1071
|
+
}
|
|
1072
|
+
html += '</div></div>';
|
|
1073
|
+
}
|
|
1074
|
+
html += '</div>';
|
|
1075
|
+
const n = scopeChecked.size;
|
|
1076
|
+
html += '<div class="scope-footer">';
|
|
1077
|
+
html += `<span class="scope-hint">${n} file${n === 1 ? '' : 's'} selected</span>`;
|
|
1078
|
+
html += '<div class="scope-model" title="Model for this analysis">';
|
|
1079
|
+
for (const m of ANALYSIS_MODELS)
|
|
1080
|
+
html += `<button class="scope-model-opt${m === analysisModel ? ' active' : ''}" onclick="_setAnalysisModel('${escAttrJs(m)}')">${m}</button>`;
|
|
1081
|
+
html += '</div>';
|
|
1082
|
+
html += '<button class="action-btn small" onclick="_toggleScopePanel()">Cancel</button>';
|
|
1083
|
+
html += `<button class="action-btn small primary" ${n ? '' : 'disabled'} onclick="_runScopedAnalysis()">Run analysis (${n})</button>`;
|
|
1084
|
+
html += '</div></div>';
|
|
1085
|
+
return html;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// #endregion ANALYZER_SCOPE
|
|
1089
|
+
|
|
1090
|
+
// #region ANALYZER_ACTIONS
|
|
1091
|
+
|
|
1092
|
+
function memoryDirPath() {
|
|
1093
|
+
const idx = stackData.find((s) => s.scope === 'memory' && s.name === 'MEMORY.md');
|
|
1094
|
+
return idx ? idx.path.replace(/[\\/][^\\/]+$/, '') : null;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function findingPrompt(f) {
|
|
1098
|
+
const dir = memoryDirPath();
|
|
1099
|
+
const lines = [
|
|
1100
|
+
dir
|
|
1101
|
+
? `In the Claude Code auto-memory at ${dir}, apply this fix:`
|
|
1102
|
+
: "In this project's Claude Code auto-memory, apply this fix:",
|
|
1103
|
+
'',
|
|
1104
|
+
`Finding (${f.kind}, ${f.severity}): ${f.title}`,
|
|
1105
|
+
f.detail,
|
|
1106
|
+
'',
|
|
1107
|
+
`Action: ${f.suggestion}`,
|
|
1108
|
+
];
|
|
1109
|
+
if ((f.files || []).length) lines.push(`Files: ${f.files.join(', ')}`);
|
|
1110
|
+
lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
|
|
1111
|
+
return lines.join('\n');
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function copyText(text, okMsg) {
|
|
1115
|
+
navigator.clipboard
|
|
1116
|
+
.writeText(text)
|
|
1117
|
+
.then(() => showToast(okMsg, 'info'))
|
|
1118
|
+
.catch(() => showToast('Clipboard unavailable', 'error'));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function _copyFindingPrompt(idx) {
|
|
1122
|
+
const f = analysisFindings[idx];
|
|
1123
|
+
if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function _copyAllFindings() {
|
|
1127
|
+
const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
|
|
1128
|
+
if (!live.length) return;
|
|
1129
|
+
copyText(live.map(findingPrompt).join('\n\n---\n\n'), `${live.length} prompts copied`);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function _dismissFinding(btn, idx) {
|
|
1133
|
+
const f = analysisFindings[idx];
|
|
1134
|
+
if (!f) return;
|
|
1135
|
+
const card = btn.closest('.analysis-card');
|
|
1136
|
+
card.style.maxHeight = `${card.offsetHeight}px`;
|
|
1137
|
+
requestAnimationFrame(() => card.classList.add('leaving'));
|
|
1138
|
+
setTimeout(() => {
|
|
1139
|
+
analysisDismissed.add(findingKey(f));
|
|
1140
|
+
rerenderAnalysis();
|
|
1141
|
+
}, 280);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function _restoreDismissed() {
|
|
1145
|
+
analysisDismissed = new Set();
|
|
1146
|
+
rerenderAnalysis();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function _setAnalysisFilter(sev) {
|
|
1150
|
+
analysisFilter = sev;
|
|
1151
|
+
rerenderAnalysis();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// #endregion ANALYZER_ACTIONS
|
|
1155
|
+
|
|
1156
|
+
function analysisTimeAgo(ts) {
|
|
1157
|
+
const min = Math.round((Date.now() - ts) / 60000);
|
|
1158
|
+
if (min < 1) return 'just now';
|
|
1159
|
+
if (min < 60) return `${min}m ago`;
|
|
1160
|
+
const h = Math.round(min / 60);
|
|
1161
|
+
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const COPY_ICON =
|
|
1165
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
1166
|
+
const DISMISS_ICON =
|
|
1167
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>';
|
|
1168
|
+
const FILE_ICON =
|
|
1169
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>';
|
|
1170
|
+
|
|
1171
|
+
function fileChipScope(source) {
|
|
1172
|
+
if (!source) return '';
|
|
1173
|
+
return source.scope === 'agent-memory' ? 'agent' : source.scope;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function renderAnalysisHead(st, r, ts) {
|
|
1177
|
+
const meta = [];
|
|
1178
|
+
if (ts) meta.push(analysisTimeAgo(ts));
|
|
1179
|
+
if (r?.costUsd != null) meta.push(`$${r.costUsd.toFixed(2)}`);
|
|
1180
|
+
if (r?.durationMs != null) meta.push(`${Math.round(r.durationMs / 1000)}s`);
|
|
1181
|
+
if (r?.model) meta.push(r.model);
|
|
1182
|
+
if (r?.scopeDesc) meta.push(`scope: ${r.scopeDesc}`);
|
|
1183
|
+
if (st.stale && analysisRunIdx === 0) meta.push('memory changed since');
|
|
1184
|
+
let html = '<div class="analysis-head">';
|
|
1185
|
+
html += '<span class="analysis-title">Claude analysis</span>';
|
|
1186
|
+
html += `<span class="analysis-meta">${esc(meta.join(' · '))}</span>`;
|
|
1187
|
+
if (r && (r.findings || []).length)
|
|
1188
|
+
html += '<button class="action-btn small" onclick="_copyAllFindings()">Copy all</button>';
|
|
1189
|
+
html += `<button class="action-btn small primary" onclick="_toggleScopePanel()">New analysis…</button>`;
|
|
1190
|
+
html += '</div>';
|
|
1191
|
+
html += renderRunsStrip(st);
|
|
1192
|
+
html += renderScopePanel();
|
|
1193
|
+
return html;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function _setAnalysisRun(i) {
|
|
1197
|
+
resetAnalysisView(i);
|
|
1198
|
+
rerenderAnalysis();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function _deleteAnalysisRun(ev, ts) {
|
|
1202
|
+
ev.stopPropagation();
|
|
1203
|
+
await fetch('/api/memory/analysis/delete-run', {
|
|
1204
|
+
method: 'POST',
|
|
1205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1206
|
+
body: JSON.stringify({ ts }),
|
|
1207
|
+
});
|
|
1208
|
+
resetAnalysisView();
|
|
1209
|
+
analysisTs = null; // force refreshAnalysis to re-adopt the new latest run
|
|
1210
|
+
refreshAnalysis();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Past runs for this project (newest first).
|
|
1214
|
+
function renderRunsStrip(st) {
|
|
1215
|
+
const runs = st.runs || [];
|
|
1216
|
+
if (!runs.length) return '';
|
|
1217
|
+
let html = '<div class="runs-strip"><span class="runs-label">runs</span>';
|
|
1218
|
+
runs.forEach((run, i) => {
|
|
1219
|
+
const r = run.result || {};
|
|
1220
|
+
const bits = [analysisTimeAgo(run.ts)];
|
|
1221
|
+
if (r.model) bits.push(r.model);
|
|
1222
|
+
if (r.costUsd != null) bits.push(`$${r.costUsd.toFixed(2)}`);
|
|
1223
|
+
html += `<span class="run-chip${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)})" title="Delete this run">×</button></span>`;
|
|
1224
|
+
});
|
|
1225
|
+
html += '</div>';
|
|
1226
|
+
return html;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function renderAnalysis(st) {
|
|
1230
|
+
if (st.running) {
|
|
1231
|
+
return '<div class="analysis-box"><div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…</div></div>';
|
|
1232
|
+
}
|
|
1233
|
+
const runs = st.runs || [];
|
|
1234
|
+
const shown = runs[analysisRunIdx];
|
|
1235
|
+
let html = '<div class="analysis-box">';
|
|
1236
|
+
html += renderAnalysisHead(st, shown?.result, shown?.ts);
|
|
1237
|
+
if (st.error) {
|
|
1238
|
+
html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div></div>`;
|
|
1239
|
+
return html;
|
|
1240
|
+
}
|
|
1241
|
+
if (!shown) {
|
|
1242
|
+
html += '<div class="analysis-empty">No analysis yet — pick a scope and run one.</div></div>';
|
|
1243
|
+
return html;
|
|
1244
|
+
}
|
|
1245
|
+
const r = shown.result;
|
|
1246
|
+
analysisFindings = r.findings || [];
|
|
1247
|
+
const byName = Object.fromEntries(stackData.map((s) => [s.name, s]));
|
|
1248
|
+
if (r.summary) html += `<div class="analysis-summary">${esc(r.summary)}</div>`;
|
|
1249
|
+
|
|
1250
|
+
const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
|
|
1251
|
+
const count = (sev) => live.filter((f) => f.severity === sev).length;
|
|
1252
|
+
if (analysisFindings.length) {
|
|
1253
|
+
html += '<div class="sev-strip">';
|
|
1254
|
+
for (const sev of ['all', 'high', 'med', 'low']) {
|
|
1255
|
+
const n = sev === 'all' ? live.length : count(sev);
|
|
1256
|
+
html += `<button class="sev-pill${analysisFilter === sev ? ' active' : ''}" data-sev="${esc(sev)}" onclick="_setAnalysisFilter('${escAttrJs(sev)}')">`;
|
|
1257
|
+
if (sev !== 'all') html += '<span class="dot"></span>';
|
|
1258
|
+
html += `${sev} ${n}</button>`;
|
|
1259
|
+
}
|
|
1260
|
+
if (analysisDismissed.size) {
|
|
1261
|
+
html += `<span class="dismissed-note">${analysisDismissed.size} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
|
|
1262
|
+
}
|
|
1263
|
+
html += '</div>';
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const visible = live.filter((f) => analysisFilter === 'all' || f.severity === analysisFilter);
|
|
1267
|
+
if (!analysisFindings.length) html += '<div class="analysis-empty">No issues found.</div>';
|
|
1268
|
+
else if (!visible.length) html += '<div class="analysis-empty">Nothing here — filtered out or dismissed.</div>';
|
|
1269
|
+
for (const f of visible) {
|
|
1270
|
+
const idx = analysisFindings.indexOf(f);
|
|
1271
|
+
html += `<div class="analysis-card ${esc(`sev-${f.severity}`)}">`;
|
|
1272
|
+
html += '<div class="analysis-card-head">';
|
|
1273
|
+
html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
|
|
1274
|
+
html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
|
|
1275
|
+
html += '<span class="card-actions">';
|
|
1276
|
+
html += `<button class="card-btn" onclick="_copyFindingPrompt(${idx})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
|
|
1277
|
+
html += `<button class="card-btn" onclick="_dismissFinding(this, ${idx})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
|
|
1278
|
+
html += '</span></div>';
|
|
1279
|
+
const files = f.files || [];
|
|
1280
|
+
html += '<div class="analysis-card-files">';
|
|
1281
|
+
if (!files.length) html += '<span class="no-files">no file — new memory suggested</span>';
|
|
1282
|
+
for (const fn of files) {
|
|
1283
|
+
const child = byName[fn];
|
|
1284
|
+
const scopeTag = fileChipScope(child);
|
|
1285
|
+
if (child) {
|
|
1286
|
+
html += `<a class="file-chip" href="#" onclick="closeModal('analysisModal');selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
|
|
1287
|
+
} else {
|
|
1288
|
+
html += `<span class="file-chip">${FILE_ICON}${esc(fn)}</span>`;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
html += '</div>';
|
|
1292
|
+
html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
|
|
1293
|
+
html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
|
|
1294
|
+
html += '</div>';
|
|
1295
|
+
}
|
|
1296
|
+
html += '</div>';
|
|
1297
|
+
return html;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// #endregion ANALYZER
|
|
1301
|
+
|
|
840
1302
|
// #region RENDER_BUDGET
|
|
841
1303
|
|
|
842
1304
|
function renderBudget() {
|
|
@@ -844,32 +1306,30 @@ function renderBudget() {
|
|
|
844
1306
|
|
|
845
1307
|
// Summary stat cards
|
|
846
1308
|
document.getElementById('statFiles').textContent = summaryData.totalFiles;
|
|
847
|
-
document.getElementById('
|
|
1309
|
+
document.getElementById('statChars').textContent = summaryData.totalChars.toLocaleString();
|
|
848
1310
|
document.getElementById('statBytes').textContent = formatBytes(summaryData.totalBytes);
|
|
849
1311
|
document.getElementById('statAlways').textContent = summaryData.alwaysLoaded;
|
|
850
1312
|
|
|
851
1313
|
// Budget text
|
|
852
1314
|
document.getElementById('budgetText').textContent =
|
|
853
|
-
`${summaryData.
|
|
1315
|
+
`${summaryData.totalChars.toLocaleString()} chars / ${formatBytes(summaryData.totalBytes)}`;
|
|
854
1316
|
|
|
855
|
-
// Budget segments — proportional by
|
|
1317
|
+
// Budget segments — proportional by chars per scope; the server's summary owns
|
|
1318
|
+
// the footprint filter, the client only renders its per-scope totals.
|
|
856
1319
|
const segContainer = document.getElementById('budgetSegments');
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const scopeTotals = {};
|
|
863
|
-
for (const s of stackData) {
|
|
864
|
-
scopeTotals[s.scope] = (scopeTotals[s.scope] || 0) + (s.lines || 0);
|
|
865
|
-
}
|
|
866
|
-
const totalLines = summaryData.totalLines || 1;
|
|
1320
|
+
const scopeTotals = summaryData.scopeChars || {};
|
|
1321
|
+
const skillDescChars = summaryData.skillDesc?.chars || 0;
|
|
1322
|
+
const totalChars = summaryData.totalChars || 1;
|
|
867
1323
|
let html = '';
|
|
868
1324
|
for (const scope of SCOPE_ORDER) {
|
|
869
|
-
const
|
|
870
|
-
if (!
|
|
871
|
-
const pct = (
|
|
872
|
-
html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${
|
|
1325
|
+
const chars = scopeTotals[scope];
|
|
1326
|
+
if (!chars) continue;
|
|
1327
|
+
const pct = (chars / totalChars) * 100;
|
|
1328
|
+
html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
|
|
1329
|
+
}
|
|
1330
|
+
if (skillDescChars) {
|
|
1331
|
+
const pct = (skillDescChars / totalChars) * 100;
|
|
1332
|
+
html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
|
|
873
1333
|
}
|
|
874
1334
|
segContainer.innerHTML = html;
|
|
875
1335
|
}
|
|
@@ -981,11 +1441,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
981
1441
|
.catch(() => ({}));
|
|
982
1442
|
if (!cfg.enabled) return;
|
|
983
1443
|
window.__HUB__ = cfg;
|
|
984
|
-
const fwd = (e) =>
|
|
985
|
-
window.parent?.postMessage(
|
|
986
|
-
{ type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey },
|
|
987
|
-
'*',
|
|
988
|
-
);
|
|
1444
|
+
const fwd = (e) => hubPost({ type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
989
1445
|
document.addEventListener('keydown', (e) => {
|
|
990
1446
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
991
1447
|
e.preventDefault();
|
|
@@ -1005,12 +1461,19 @@ document.addEventListener('keydown', (e) => {
|
|
|
1005
1461
|
|
|
1006
1462
|
function _hubNavigate(app, url) {
|
|
1007
1463
|
if (!window.__HUB__?.enabled) return;
|
|
1008
|
-
|
|
1464
|
+
hubPost({ type: 'hub:navigate', app, url });
|
|
1009
1465
|
}
|
|
1010
1466
|
|
|
1011
1467
|
// Hoisted out of initHubTheme so initHubProject can share it.
|
|
1012
1468
|
const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
|
|
1013
1469
|
|
|
1470
|
+
// Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
|
|
1471
|
+
// framed this app also received the forwarded keystrokes and navigation intents.
|
|
1472
|
+
function hubPost(message) {
|
|
1473
|
+
const origin = hubOrigin();
|
|
1474
|
+
if (origin) window.parent?.postMessage(message, origin);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1014
1477
|
(function initHubTheme() {
|
|
1015
1478
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
|
1016
1479
|
const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
|
|
@@ -1036,8 +1499,7 @@ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origi
|
|
|
1036
1499
|
if (t === lastTheme && ct === lastColorTheme) return;
|
|
1037
1500
|
lastTheme = t;
|
|
1038
1501
|
lastColorTheme = ct;
|
|
1039
|
-
|
|
1040
|
-
if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
|
|
1502
|
+
hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
|
|
1041
1503
|
}).observe(document.body, {
|
|
1042
1504
|
attributes: true,
|
|
1043
1505
|
attributeFilter: ['class', 'data-color-theme'],
|