sonorance 0.1.0-beta.4.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/LICENSE +174 -0
- package/README.md +103 -0
- package/build/icon.png +0 -0
- package/package.json +86 -0
- package/skill/SKILL.md +41 -0
- package/skill/scripts/sonorance.mjs +31 -0
- package/src/azure-monitor.mjs +221 -0
- package/src/cli.mjs +315 -0
- package/src/comment-client.mjs +75 -0
- package/src/engine-default.mjs +136 -0
- package/src/feedback.mjs +151 -0
- package/src/gitignore.mjs +27 -0
- package/src/grammar.mjs +55 -0
- package/src/identity.mjs +126 -0
- package/src/otlp.mjs +166 -0
- package/src/plugins/deliberate/contribute.mjs +28 -0
- package/src/plugins/deliberate/domain.mjs +40 -0
- package/src/plugins/deliberate/frontmatter.mjs +85 -0
- package/src/plugins/deliberate/gitignore.mjs +21 -0
- package/src/plugins/deliberate/kinds.mjs +44 -0
- package/src/plugins/deliberate/markdown.mjs +42 -0
- package/src/plugins/deliberate/paths.mjs +245 -0
- package/src/plugins/deliberate/stages.mjs +27 -0
- package/src/plugins/deliberate/vault.mjs +1043 -0
- package/src/plugins.mjs +91 -0
- package/src/release-config.mjs +2 -0
- package/src/scrubber.mjs +64 -0
- package/src/server/index.mjs +993 -0
- package/src/sources.mjs +80 -0
- package/src/telemetry-schema.mjs +187 -0
- package/src/telemetry.mjs +390 -0
- package/src/ui/active-line.mjs +42 -0
- package/src/ui/app.js +3553 -0
- package/src/ui/at-mention.mjs +67 -0
- package/src/ui/comments-plugin.mjs +107 -0
- package/src/ui/diff-plugin.mjs +73 -0
- package/src/ui/editor.mjs +210 -0
- package/src/ui/index.html +1723 -0
- package/src/ui/md.mjs +233 -0
- package/src/ui/paste-md.mjs +54 -0
- package/src/ui/shell.html +1374 -0
- package/src/ui/slash.mjs +122 -0
- package/src/vault-registry.mjs +150 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,3553 @@
|
|
|
1
|
+
import { createEditor, mdBlocks } from './editor.mjs';
|
|
2
|
+
(function(){
|
|
3
|
+
"use strict";
|
|
4
|
+
/* ==========================================================================
|
|
5
|
+
Deliberate workbench — a zero-build, dependency-free web app over the vault.
|
|
6
|
+
Talks to the local daemon (/api/*). Renderer + block editor are hand-rolled
|
|
7
|
+
so it works fully offline and stays token-driven and testable.
|
|
8
|
+
========================================================================== */
|
|
9
|
+
|
|
10
|
+
// ---- tiny helpers ---------------------------------------------------------
|
|
11
|
+
const $ = (s, r=document) => r.querySelector(s);
|
|
12
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
13
|
+
const iconEl = (paths) => `<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
14
|
+
const ICON = {
|
|
15
|
+
queue: '<path d="M4 6h16M4 12h16M4 18h10"/><circle cx="19" cy="18" r="2"/>',
|
|
16
|
+
inbox: '<path d="M4 13v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M4 13l2.4-6.7A2 2 0 0 1 8.3 5h7.4a2 2 0 0 1 1.9 1.3L20 13"/><path d="M4 13h4l1.2 2.2a1 1 0 0 0 .9.5h3.8a1 1 0 0 0 .9-.5L16 13h4"/>',
|
|
17
|
+
brief: '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/>',
|
|
18
|
+
readout: '<path d="M4 19V5M4 19h16"/><path d="M7 15l4-4 3 2 5-6"/><circle cx="7" cy="15" r=".8" fill="currentColor" stroke="none"/><circle cx="11" cy="11" r=".8" fill="currentColor" stroke="none"/><circle cx="14" cy="13" r=".8" fill="currentColor" stroke="none"/><circle cx="19" cy="7" r=".8" fill="currentColor" stroke="none"/>',
|
|
19
|
+
matchup: '<rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M12 3v18"/>',
|
|
20
|
+
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
|
21
|
+
chevron: '<path d="M9 6l6 6-6 6"/>',
|
|
22
|
+
chevrud: '<path d="M8 9l4 4 4-4"/><path d="M8 15l4-4 4 4" opacity=".45"/>',
|
|
23
|
+
caret: '<path d="M9 6l6 6-6 6"/>',
|
|
24
|
+
folder: '<path d="M3 7a2 2 0 0 1 2-2h3.6a2 2 0 0 1 1.4.6L11.4 7H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
25
|
+
newfolder: '<path d="M3 7a2 2 0 0 1 2-2h3.6a2 2 0 0 1 1.4.6L11.4 7H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 11v5M9.5 13.5h5"/>',
|
|
26
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
27
|
+
doc: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>',
|
|
28
|
+
collapseAll: '<path d="M7 20l5-5 5 5"/><path d="M7 4l5 5 5-5"/>',
|
|
29
|
+
expandAll: '<path d="M7 15l5 5 5-5"/><path d="M7 9l5-5 5 5"/>',
|
|
30
|
+
x: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
31
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
|
32
|
+
vault: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/>',
|
|
33
|
+
check: '<path d="M20 6L9 17l-5-5"/>',
|
|
34
|
+
external: '<path d="M14 4h6v6"/><path d="M20 4l-8.5 8.5"/><path d="M18 13.5V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4.5"/>',
|
|
35
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/>',
|
|
36
|
+
moon: '<path d="M20 14.5A8 8 0 1 1 9.5 4 6.2 6.2 0 0 0 20 14.5z"/>',
|
|
37
|
+
auto: '<circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 0 0 16z" fill="currentColor" stroke="none"/>',
|
|
38
|
+
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.08A1.7 1.7 0 0 0 9 19.37a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.63 15 1.7 1.7 0 0 0 3.08 14H3v-4h.08A1.7 1.7 0 0 0 4.63 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.63 1.7 1.7 0 0 0 10 3.08V3h4v.08A1.7 1.7 0 0 0 15 4.63a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.37 9 1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15z"/>',
|
|
39
|
+
comment: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>',
|
|
40
|
+
feedback: '<path d="M3 11v2a2 2 0 0 0 2 2h2l3 5h3l-2.5-5H12l8 3V6l-8 3H5a2 2 0 0 0-2 2z"/><path d="M7 9v6M20 10l2-1M20 14l2 1"/>',
|
|
41
|
+
thumbUp: '<path d="M7 10v11H3V10zM7 19h9.2a2 2 0 0 0 2-1.6l1.4-7A2 2 0 0 0 17.6 8H14l.5-3.1A2.4 2.4 0 0 0 12.1 2L7 10z"/>',
|
|
42
|
+
thumbDown: '<path d="M7 14V3H3v11zM7 5h9.2a2 2 0 0 1 2 1.6l1.4 7a2 2 0 0 1-2 2.4H14l.5 3.1a2.4 2.4 0 0 1-2.4 2.9L7 14z"/>',
|
|
43
|
+
rocket: '<path d="M14 4c2.8-2.8 5.5-2 6-1.5s1.3 3.2-1.5 6L13 14l-3-3z"/><path d="M14 4l6 6M10 11l-4 .5-3 3 5 1.5 1.5 5 3-3 .5-4"/><path d="M5 19l-2 2M7 17l-4 4"/>',
|
|
44
|
+
mic: '<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><path d="M12 18v4M8 22h8"/>',
|
|
45
|
+
send: '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/>',
|
|
46
|
+
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
|
|
47
|
+
trash: '<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"/><path d="M9 7V4h6v3"/>',
|
|
48
|
+
proto: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><path d="M6.5 6.5h.01M9 6.5h.01"/>',
|
|
49
|
+
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
|
|
50
|
+
pencil: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>',
|
|
51
|
+
gitbranch: '<circle cx="6" cy="6" r="2.4"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="8" r="2.4"/><path d="M6 8.4v7.2"/><path d="M18 10.4a6 6 0 0 1-6 6H8"/>',
|
|
52
|
+
gitfile: '<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5z"/><path d="M13.5 3v5h5"/><circle cx="9.4" cy="13.6" r="1.15"/><circle cx="9.4" cy="18" r="1.15"/><circle cx="14" cy="14.9" r="1.15"/><path d="M9.4 14.75v2.1"/><path d="M14 16.05a1.9 1.9 0 0 1-1.9 1.9H9.4"/>',
|
|
53
|
+
newfile: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4"/><path d="M14 3v5h5"/><path d="M17 14v6M14 17h6"/>',
|
|
54
|
+
revert: '<path d="M9 14L4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v1"/>',
|
|
55
|
+
bold: '<path d="M7 5h6a3.4 3.4 0 0 1 0 6.8H7z"/><path d="M7 11.8h7a3.6 3.6 0 0 1 0 7.2H7z"/>',
|
|
56
|
+
italic: '<path d="M19 4h-7M14 20H5M15 4l-6 16"/>',
|
|
57
|
+
link: '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
|
|
58
|
+
heading: '<path d="M6 4v16M18 4v16M6 12h12"/>',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---- markdown rendering (a focused, safe subset) --------------------------
|
|
62
|
+
// Emphasis grammar for the read-only render path (`inline`, used by briefs, the inbox, and
|
|
63
|
+
// previews — the editable surface is Tiptap). Factories return a fresh /g regex per call (no
|
|
64
|
+
// shared lastIndex).
|
|
65
|
+
const MD_RE = {
|
|
66
|
+
bold: () => /\*\*([^*\n]+)\*\*/g,
|
|
67
|
+
emStar: () => /(^|[^*\w])\*([^*\n]+)\*(?!\w)/g,
|
|
68
|
+
emUnd: () => /(^|[^\w])_([^_\n]+)_(?![\w])/g,
|
|
69
|
+
};
|
|
70
|
+
function sanitizeHref(h){ return /^(https?:|mailto:|\/|\.\/|#)/i.test(h) ? h : '#'; }
|
|
71
|
+
// The project-relative path of the doc currently rendered (a generic file, a case record, or a
|
|
72
|
+
// brief) — the base a Markdown image's relative `src` resolves against.
|
|
73
|
+
function currentDocRel(){ return (S.file && S.file.path) || S.recFile || ''; }
|
|
74
|
+
// Resolve a Markdown image `src` to a URL the browser can load. External/inline sources pass
|
|
75
|
+
// through untouched; a relative (or root-relative) path is resolved against the doc's folder
|
|
76
|
+
// (a leading `/` means the vault root) and served by the daemon's /api/asset endpoint, which is
|
|
77
|
+
// vault-confined and extension-gated. Keeps images working offline with no external fetches.
|
|
78
|
+
function resolveAssetSrc(src){
|
|
79
|
+
const raw = String(src || '').trim();
|
|
80
|
+
if (!raw) return '';
|
|
81
|
+
if (/^(https?:|data:|blob:)/i.test(raw)) return raw;
|
|
82
|
+
const dir = currentDocRel().split('/').slice(0, -1);
|
|
83
|
+
const parts = raw.startsWith('/') ? raw.replace(/^\/+/, '').split('/') : dir.concat(raw.split('/'));
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const seg of parts){ if (seg === '..') out.pop(); else if (seg && seg !== '.') out.push(seg); }
|
|
86
|
+
return out.length ? '/api/asset?path=' + encodeURIComponent(out.join('/')) + (PP() ? '&project=' + encodeURIComponent(S.projectSlug || S.project.id) : '') : '';
|
|
87
|
+
}
|
|
88
|
+
function inline(src){
|
|
89
|
+
let s = esc(src);
|
|
90
|
+
const codes = [];
|
|
91
|
+
s = s.replace(/`([^`]+)`/g, (_m, c) => { codes.push(c); return '\u0000' + (codes.length-1) + '\u0000'; });
|
|
92
|
+
// Images (``) MUST run before links — an image literally contains a `[alt](src)`
|
|
93
|
+
// link, so the link rule would otherwise eat it and leave a stray `!`. Relative srcs resolve
|
|
94
|
+
// against the doc's folder via the daemon (/api/asset); external/data URLs pass through.
|
|
95
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g,
|
|
96
|
+
(_m, alt, src2) => { const u = resolveAssetSrc(src2); return u ? `<img class="md-img" src="${esc(u)}" alt="${alt}" loading="lazy">` : alt; });
|
|
97
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g,
|
|
98
|
+
(_m, t, h) => `<a href="${sanitizeHref(h)}" target="_blank" rel="noopener noreferrer">${t}</a>`);
|
|
99
|
+
s = s.replace(MD_RE.bold(), '<strong>$1</strong>');
|
|
100
|
+
s = s.replace(MD_RE.emStar(), '$1<em>$2</em>');
|
|
101
|
+
s = s.replace(MD_RE.emUnd(), '$1<em>$2</em>');
|
|
102
|
+
s = s.replace(/\u0000(\d+)\u0000/g, (_m, i) => `<code>${codes[+i]}</code>`);
|
|
103
|
+
return s;
|
|
104
|
+
}
|
|
105
|
+
function splitRow(r){ return r.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(x => x.trim()); }
|
|
106
|
+
function renderTable(rows){
|
|
107
|
+
const head = splitRow(rows[0]);
|
|
108
|
+
let h = '<table class="md-tbl"><thead><tr>' + head.map(c => `<th>${inline(c)}</th>`).join('') + '</tr></thead><tbody>';
|
|
109
|
+
for (const r of rows.slice(2)){ const cells = splitRow(r); h += '<tr>' + head.map((_c, j) => `<td>${inline(cells[j] ?? '')}</td>`).join('') + '</tr>'; }
|
|
110
|
+
// Wrap so the table (and only the table) scrolls horizontally when its columns exceed
|
|
111
|
+
// the content width — the prose around it never scrolls.
|
|
112
|
+
return '<div class="md-tblwrap">' + h + '</tbody></table></div>';
|
|
113
|
+
}
|
|
114
|
+
function renderList(buf){
|
|
115
|
+
const items = [];
|
|
116
|
+
for (const line of buf){
|
|
117
|
+
const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
118
|
+
if (m) items.push({ indent: m[1].length, ordered: /\d+\./.test(m[2]), text: m[3] });
|
|
119
|
+
else if (items.length) items[items.length-1].text += ' ' + line.trim();
|
|
120
|
+
}
|
|
121
|
+
if (!items.length) return '';
|
|
122
|
+
const base = Math.min(...items.map(i => i.indent));
|
|
123
|
+
const ordered = items[0].ordered;
|
|
124
|
+
let h = `<${ordered?'ol':'ul'} class="md-list">`, i = 0;
|
|
125
|
+
while (i < items.length){
|
|
126
|
+
const it = items[i];
|
|
127
|
+
if (it.indent <= base){
|
|
128
|
+
let j = i+1; const kids = [];
|
|
129
|
+
while (j < items.length && items[j].indent > base){ kids.push(items[j]); j++; }
|
|
130
|
+
h += taskLi(it.text);
|
|
131
|
+
if (kids.length){ const ord2 = kids[0].ordered; h += `<${ord2?'ol':'ul'} class="md-list">` + kids.map(k => taskLi(k.text) + '</li>').join('') + `</${ord2?'ol':'ul'}>`; }
|
|
132
|
+
h += '</li>'; i = j;
|
|
133
|
+
} else i++;
|
|
134
|
+
}
|
|
135
|
+
return h + `</${ordered?'ol':'ul'}>`;
|
|
136
|
+
}
|
|
137
|
+
// A list item's opening `<li>` (caller closes it). A GitHub-style task item (`[ ]`/`[x]`
|
|
138
|
+
// after the bullet) becomes an interactive checkbox that toggles the source line in place;
|
|
139
|
+
// the checkbox is a contenteditable island so a click never drops the caret into it. The
|
|
140
|
+
// literal `[ ]`/`[x]` stays in the Markdown source — only the RENDERED view shows a box.
|
|
141
|
+
const TASK_RE = /^\[([ xX])\]\s+(.*)$/s;
|
|
142
|
+
function taskLi(text){
|
|
143
|
+
const m = String(text).match(TASK_RE);
|
|
144
|
+
if (!m) return `<li>${inline(text)}`;
|
|
145
|
+
const done = m[1].toLowerCase() === 'x';
|
|
146
|
+
return `<li class="md-task"><input type="checkbox" class="md-taskbox" contenteditable="false"${done?' checked':''} aria-label="Toggle task"><span class="md-tasktxt">${inline(m[2])}</span>`;
|
|
147
|
+
}
|
|
148
|
+
function renderMarkdown(md){
|
|
149
|
+
const lines = String(md||'').replace(/<!--[\s\S]*?-->/g, '').replace(/\r\n?/g, '\n').split('\n');
|
|
150
|
+
const blank = l => /^\s*$/.test(l);
|
|
151
|
+
const special = l => /^\s*(#{1,6}\s|>|```|[-*+]\s|\d+\.\s)/.test(l) || /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l);
|
|
152
|
+
let html = '', i = 0;
|
|
153
|
+
while (i < lines.length){
|
|
154
|
+
const l = lines[i];
|
|
155
|
+
if (blank(l)){ i++; continue; }
|
|
156
|
+
if (/^\s*```/.test(l)){ i++; const buf = []; while (i < lines.length && !/^\s*```/.test(lines[i])){ buf.push(lines[i]); i++; } i++; html += `<pre class="md-pre"><code>${esc(buf.join('\n'))}</code></pre>`; continue; }
|
|
157
|
+
const h = l.match(/^\s*(#{1,6})\s+(.*)$/); // tolerate leading indentation (matches `special`)
|
|
158
|
+
if (h){ const n = h[1].length; html += `<h${n} class="md-h md-h${n}">${inline(h[2].trim())}</h${n}>`; i++; continue; }
|
|
159
|
+
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l)){ html += '<hr class="md-hr">'; i++; continue; }
|
|
160
|
+
if (/^\s*>\s?/.test(l)){ const buf = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])){ buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += `<blockquote class="md-bq">${renderMarkdown(buf.join('\n'))}</blockquote>`; continue; }
|
|
161
|
+
if (l.includes('|') && i+1 < lines.length && lines[i+1].includes('-') && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i+1])){
|
|
162
|
+
const rows = []; while (i < lines.length && lines[i].includes('|') && !blank(lines[i])){ rows.push(lines[i]); i++; } html += renderTable(rows); continue;
|
|
163
|
+
}
|
|
164
|
+
if (/^\s*([-*+]|\d+\.)\s+/.test(l)){ const buf = []; while (i < lines.length && (/^\s*([-*+]|\d+\.)\s+/.test(lines[i]) || /^\s{2,}\S/.test(lines[i]))){ buf.push(lines[i]); i++; } html += renderList(buf); continue; }
|
|
165
|
+
const buf = []; while (i < lines.length && !blank(lines[i]) && !special(lines[i])){ buf.push(lines[i]); i++; }
|
|
166
|
+
// Forward-progress guard: a `special` line that no block branch above consumed would leave
|
|
167
|
+
// buf empty and `i` unmoved → an infinite loop that grows `html` until the string exceeds
|
|
168
|
+
// V8's max length (RangeError: Invalid string length), freezing the tab for ~20s. Never let
|
|
169
|
+
// `i` stall — fall back to emitting the current line as prose so the walk always advances.
|
|
170
|
+
if (!buf.length){ buf.push(l); i++; }
|
|
171
|
+
html += `<p class="md-p">${inline(buf.join('\n')).replace(/\n/g, '<br>')}</p>`;
|
|
172
|
+
}
|
|
173
|
+
return html;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- raw ↔ blocks (round-trip safe: reconstruct === original when unedited) --
|
|
177
|
+
function segment(raw){
|
|
178
|
+
const lines = String(raw).split('\n');
|
|
179
|
+
const items = []; let cur = null, inFence = false;
|
|
180
|
+
const push = (kind, line, fresh) => { if (fresh || !cur || cur.kind !== kind){ cur = { kind, lines: [] }; items.push(cur); } cur.lines.push(line); };
|
|
181
|
+
let start = 0;
|
|
182
|
+
// Leading YAML frontmatter is ONE atomic block. Its blank lines and YAML `#` comments must
|
|
183
|
+
// NOT be split into gap/heading segments — that shatters it so the reader can no longer hide
|
|
184
|
+
// it, dumping raw metadata into the reading view when frontmatter carries indented `# …`
|
|
185
|
+
// YAML comments that look like headings.
|
|
186
|
+
if (/^---\s*$/.test(lines[0] || '')){
|
|
187
|
+
let end = -1;
|
|
188
|
+
for (let k = 1; k < lines.length; k++){ if (/^---\s*$/.test(lines[k])){ end = k; break; } }
|
|
189
|
+
if (end > 0){ items.push({ kind: 'block', lines: lines.slice(0, end + 1) }); start = end + 1; }
|
|
190
|
+
}
|
|
191
|
+
for (let li = start; li < lines.length; li++){
|
|
192
|
+
const line = lines[li];
|
|
193
|
+
const fence = /^\s*```/.test(line);
|
|
194
|
+
if (inFence){ push('block', line); if (fence) inFence = false; continue; }
|
|
195
|
+
if (fence){ push('block', line); inFence = true; continue; }
|
|
196
|
+
if (/^\s*$/.test(line)){ push('gap', line); continue; }
|
|
197
|
+
// An ATX heading is its own single-line block. Body text glued directly beneath a
|
|
198
|
+
// heading (no blank line between) must become its OWN paragraph block — otherwise the
|
|
199
|
+
// whole run inherits the heading's block type and renders at heading size in the editor.
|
|
200
|
+
if (/^\s*#{1,6}\s/.test(line)){ push('block', line, true); cur = null; continue; }
|
|
201
|
+
push('block', line);
|
|
202
|
+
}
|
|
203
|
+
return items;
|
|
204
|
+
}
|
|
205
|
+
const reconstruct = (items) => items.flatMap(it => it.lines).join('\n');
|
|
206
|
+
|
|
207
|
+
function verdict(score){
|
|
208
|
+
if (score == null) return { cls:'neutral', label:'—', word:'Not yet scored' };
|
|
209
|
+
const n = Number(score).toFixed(1);
|
|
210
|
+
return score >= 7 ? { cls:'advance', label:n, word:'Advance' } : score >= 5 ? { cls:'shelve', label:n, word:'Shelve' } : { cls:'reject', label:n, word:'Reject' };
|
|
211
|
+
}
|
|
212
|
+
// The go/no-go call, promoted to a first-class band at the top of the record. Built from
|
|
213
|
+
// the case's score score (not the prose), so the decision is the first thing the reader
|
|
214
|
+
// sees — with a jump into the reasoning. Sits in .canvas above the editable #doc.
|
|
215
|
+
// The record's score reasoning, if present. Detected from the source markdown so
|
|
216
|
+
// the top-strip score pill can decide (before render) whether to be a jump button.
|
|
217
|
+
function reasoningInSource(){
|
|
218
|
+
return !!(S.rec && S.rec.exists && /^#{2,4}\s+.*(why this score|score|verdict)\b/im.test(S.rec.text));
|
|
219
|
+
}
|
|
220
|
+
function findReasoningTarget(){
|
|
221
|
+
const doc = $('#doc'); if (!doc) return null;
|
|
222
|
+
const heads = [...doc.querySelectorAll('h2,h3,h4')];
|
|
223
|
+
return heads.find(h => /why this score/i.test(h.textContent))
|
|
224
|
+
|| heads.find(h => /^\s*score\s*$/i.test(h.textContent))
|
|
225
|
+
|| heads.find(h => /score|score|verdict/i.test(h.textContent))
|
|
226
|
+
|| null;
|
|
227
|
+
}
|
|
228
|
+
function jumpToReasoning(){
|
|
229
|
+
const target = findReasoningTarget();
|
|
230
|
+
if (target) target.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block:'start' });
|
|
231
|
+
}
|
|
232
|
+
const relTime = (ts) => {
|
|
233
|
+
if (!ts) return '';
|
|
234
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
235
|
+
if (s < 60) return 'just now';
|
|
236
|
+
const m = Math.floor(s/60); if (m < 60) return m + 'm ago';
|
|
237
|
+
const h = Math.floor(m/60); if (h < 24) return h + 'h ago';
|
|
238
|
+
const d = Math.floor(h/24); return d < 7 ? d + 'd ago' : new Date(ts).toLocaleDateString();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// ---- API ------------------------------------------------------------------
|
|
242
|
+
async function api(method, path, body){
|
|
243
|
+
const o = { method, headers: { 'content-type':'application/json' } };
|
|
244
|
+
if (body) o.body = JSON.stringify(body);
|
|
245
|
+
// Scope every API call to the current project (the URL's /p/<slug>) so the server resolves
|
|
246
|
+
// the right vault per-request — the app can serve several projects at once, one per browser tab.
|
|
247
|
+
let url = path;
|
|
248
|
+
const slug = S.projectSlug || (S.project && S.project.id);
|
|
249
|
+
if (slug && path.startsWith('/api/')) url += (path.includes('?') ? '&' : '?') + 'project=' + encodeURIComponent(slug);
|
|
250
|
+
let r;
|
|
251
|
+
try { r = await fetch(url, o); } catch { return { error:'offline', netError:true }; }
|
|
252
|
+
// A stale/old `serve` process returns a non-JSON 404 ("not found") for routes it lacks —
|
|
253
|
+
// never throw on that (the old bug swallowed it silently); surface a structured error so
|
|
254
|
+
// callers can tell the user the workbench is out of date, and threads never look saved.
|
|
255
|
+
const status = typeof r.status === 'number' ? r.status : 200;
|
|
256
|
+
const ct = (r.headers && r.headers.get) ? (r.headers.get('content-type') || '') : 'application/json';
|
|
257
|
+
if (!ct.includes('json')) return { error: status === 404 ? 'endpoint-missing' : ('http-' + status), httpStatus: status };
|
|
258
|
+
let j; try { j = await r.json(); } catch { return { error:'bad-response', httpStatus: status }; }
|
|
259
|
+
if (j && typeof j === 'object' && !('error' in j) && r.ok === false) j.error = 'http-' + status;
|
|
260
|
+
return j;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- app state ------------------------------------------------------------
|
|
264
|
+
// Branch A: the app's identity + canonical command hints are app-owned and STABLE — always
|
|
265
|
+
// Sonorance, on every vault. A plugin (e.g. Deliberate) surfaces per-project via kinds/inbox, it
|
|
266
|
+
// never renames the app or its commands. These constants are the single UI source for that.
|
|
267
|
+
const BRAND = 'Sonorance';
|
|
268
|
+
const SERVE_CMD = 'sonorance serve';
|
|
269
|
+
const ADDRESS_CMD = '/sonorance address';
|
|
270
|
+
const S = { project:null, projectSlug:null, projects:[], cases:[], briefs:[], readouts:[], matchups:[], inventory:null, kinds:[], tree:[], treeBase:'', explorer:{ mode:'collapsed', ex:new Set() }, isGit:false, query:'', tabs:[], activeTab:-1, feedbackDraft:null,
|
|
271
|
+
caseId:null, rec:null, proto:null, items:[], mode:'read', brief:null, readout:null, matchup:null, file:null,
|
|
272
|
+
comments:[], es:null, cNavIdx:-1, recFile:null,
|
|
273
|
+
dirty:false, saving:false, saveTimer:null, cfgPending:{}, cfgTimer:null,
|
|
274
|
+
diffMode:false, diffBase:null, git:{ branch:'', changed:[] } };
|
|
275
|
+
|
|
276
|
+
// ---- git: the Explorer's diff mode (uncommitted-changes filter) ------------
|
|
277
|
+
// When diffMode is on, the Explorer lists ONLY files with uncommitted changes and each
|
|
278
|
+
// opens in its Diff view by default. `diffFilterSet()` is the set of changed paths to
|
|
279
|
+
// filter by (null when diff mode is off or the vault isn't a git repo).
|
|
280
|
+
function diffFilterSet(){ return (S.diffMode && S.isGit) ? new Set((S.git.changed || []).filter(c => /\.md$/i.test(c.path)).map(c => c.path)) : null; }
|
|
281
|
+
// ---- comment markers + filter (a SECOND, amber channel alongside git) ------
|
|
282
|
+
// S.commentCounts maps a project-relative file path → its open-comment count. It's a distinct
|
|
283
|
+
// channel from git: git tints the NAME + shows an A/M/D letter badge; comments show an amber
|
|
284
|
+
// dot/count in their OWN slot (and an amber dot beside a folder name) so the two never collide.
|
|
285
|
+
async function loadCommentCounts(){
|
|
286
|
+
try {
|
|
287
|
+
const r = await api('GET', '/api/comments-summary');
|
|
288
|
+
if (r && !r.error && r.counts) {
|
|
289
|
+
S.commentCounts = r.counts;
|
|
290
|
+
TEL.gauge('project_open_comments', totalCommentCount());
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch { /* best-effort; markers just stay as they were */ }
|
|
294
|
+
}
|
|
295
|
+
function fileCommentCount(path){ return (S.commentCounts && S.commentCounts[path]) || 0; }
|
|
296
|
+
// Open-comment total for a FOLDER — any commented file at any depth under it.
|
|
297
|
+
function dirCommentCount(dirPath){
|
|
298
|
+
if (!dirPath || !S.commentCounts) return 0;
|
|
299
|
+
const prefix = dirPath + '/'; let n = 0;
|
|
300
|
+
for (const [p, c] of Object.entries(S.commentCounts)){ if (p.startsWith(prefix)) n += c; }
|
|
301
|
+
return n;
|
|
302
|
+
}
|
|
303
|
+
const totalCommentCount = () => Object.values(S.commentCounts || {}).reduce((a, n) => a + n, 0);
|
|
304
|
+
// The set of files (+ their ancestor folders, via prefix) with open comments — powers the
|
|
305
|
+
// Explorer's comments-only filter, mirroring the git diff-mode filter.
|
|
306
|
+
function commentFilterSet(){ return S.commentFilter ? new Set(Object.keys(S.commentCounts || {}).filter(p => S.commentCounts[p] > 0)) : null; }
|
|
307
|
+
// An amber comment marker for a file row: a small dot with the count. Its own slot, so it sits
|
|
308
|
+
// beside (never on top of) the git badge.
|
|
309
|
+
function commentBadge(path){
|
|
310
|
+
const n = fileCommentCount(path); if (!n) return '';
|
|
311
|
+
const tip = `${n} open comment${n > 1 ? 's' : ''}`;
|
|
312
|
+
return `<span class="cmtb" title="${tip}" aria-label="${tip}">${iconEl(ICON.comment)}${n > 1 ? n : ''}</span>`;
|
|
313
|
+
}
|
|
314
|
+
// A folder that contains commented files (at any depth) gets a small amber dot beside its name —
|
|
315
|
+
// distinct from git's folder name-tint, so both channels read at once.
|
|
316
|
+
function dirCommentBadge(dirPath){
|
|
317
|
+
const n = dirCommentCount(dirPath); if (!n) return '';
|
|
318
|
+
const tip = `${n} open comment${n > 1 ? 's' : ''} in this folder`;
|
|
319
|
+
return `<span class="cmtb dot" title="${tip}" aria-label="${tip}"></span>`;
|
|
320
|
+
}
|
|
321
|
+
function gitStatusOf(path){ const c = (S.git.changed || []).find(x => x.path === path); return c ? c.status : ''; }
|
|
322
|
+
// The number of changed *documents* (markdown records) — what the Explorer can show; the
|
|
323
|
+
// raw git set also includes machine-state files (.config) the workbench never lists.
|
|
324
|
+
function changedDocCount(){ return (S.git.changed || []).filter(c => /\.md$/i.test(c.path)).length; }
|
|
325
|
+
// A compact, legible change badge for a file row (letter + tint — never color alone):
|
|
326
|
+
// A(dded/new), M(odified), D(eleted). Matches the diff-add / warn / diff-del vocabulary.
|
|
327
|
+
function gitBadge(path){
|
|
328
|
+
const st = gitStatusOf(path); if (!st) return '';
|
|
329
|
+
const isAdd = st.includes('A') || st.includes('?');
|
|
330
|
+
const isDel = st.includes('D');
|
|
331
|
+
const cls = isAdd ? 'add' : isDel ? 'del' : 'mod';
|
|
332
|
+
const letter = isAdd ? 'A' : isDel ? 'D' : 'M';
|
|
333
|
+
const tip = isAdd ? 'Added — a new file not yet committed'
|
|
334
|
+
: isDel ? 'Deleted — removed since the last commit'
|
|
335
|
+
: 'Modified — uncommitted changes since the last commit';
|
|
336
|
+
return `<span class="gitb ${cls}" role="img" title="${tip}" aria-label="${tip}">${letter}</span>`;
|
|
337
|
+
}
|
|
338
|
+
// The git-status class for a FILE row colours its name alongside the letter
|
|
339
|
+
// badge (so files read by both colour AND letter). '' when the file has no uncommitted change.
|
|
340
|
+
function fileGitClass(path){
|
|
341
|
+
const st = gitStatusOf(path); if (!st) return '';
|
|
342
|
+
if (st.includes('D')) return 'gs-del';
|
|
343
|
+
if (st.includes('A') || st.includes('?')) return 'gs-add';
|
|
344
|
+
return 'gs-mod';
|
|
345
|
+
}
|
|
346
|
+
// The aggregate change status of a FOLDER: a folder containing any changed
|
|
347
|
+
// markdown document — at any depth — is tinted by that change rather than carrying a badge, which
|
|
348
|
+
// is calmer/less distracting in a dense tree. Pure additions read `add`, pure deletions `del`, and
|
|
349
|
+
// anything modified or mixed reads `mod`. The authoritative per-file status still shows as a letter
|
|
350
|
+
// badge on the files themselves, so the folder colour is a hint, never the sole signal. Returns
|
|
351
|
+
// `{ cls, tip }` or null when the folder holds no changed documents.
|
|
352
|
+
function dirGitStatus(dirPath){
|
|
353
|
+
if (!S.isGit || !dirPath) return null;
|
|
354
|
+
const prefix = dirPath + '/';
|
|
355
|
+
const kids = (S.git.changed || []).filter(c => /\.md$/i.test(c.path) && c.path.startsWith(prefix));
|
|
356
|
+
if (!kids.length) return null;
|
|
357
|
+
let added = false, deleted = false, modified = false;
|
|
358
|
+
for (const c of kids){
|
|
359
|
+
if (c.status.includes('D')) deleted = true;
|
|
360
|
+
else if (c.status.includes('A') || c.status.includes('?')) added = true;
|
|
361
|
+
else modified = true;
|
|
362
|
+
}
|
|
363
|
+
const n = kids.length;
|
|
364
|
+
const [cls, what] = (modified || (added && deleted)) ? ['mod', 'with uncommitted changes']
|
|
365
|
+
: added ? ['add', 'added and not yet committed']
|
|
366
|
+
: ['del', 'deleted'];
|
|
367
|
+
return { cls, tip: `${n} document${n > 1 ? 's' : ''} ${what} in this folder` };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- a lightweight popover menu (tab actions, project switcher) ------------
|
|
371
|
+
function closeMenu(){ const m = $('#menu'); if (m) m.remove(); document.removeEventListener('mousedown', menuAway, true); }
|
|
372
|
+
function menuAway(e){ if (!e.target.closest('#menu')) closeMenu(); }
|
|
373
|
+
function openMenu(x, y, items, opts){
|
|
374
|
+
closeMenu();
|
|
375
|
+
const m = document.createElement('div'); m.className = 'menu'; m.id = 'menu';
|
|
376
|
+
// A row is a bare <button> unless it carries a `trail` (a secondary action, e.g. Remove a
|
|
377
|
+
// vault): then it's wrapped so the trailing button sits alongside the main one and reveals
|
|
378
|
+
// only on hover / keyboard focus — present when wanted, invisible at rest.
|
|
379
|
+
const rowMarkup = (it, i) => {
|
|
380
|
+
const main = `<button data-mi="${i}" ${it.on ? 'class="on"' : ''}>${it.icon ? iconEl(ICON[it.icon]) : ''}<span>${esc(it.label)}</span></button>`;
|
|
381
|
+
if (!it.trail) return main;
|
|
382
|
+
const t = it.trail;
|
|
383
|
+
return `<div class="menu-row">${main}<button class="menu-trail${t.danger ? ' danger' : ''}" data-mt="${i}" title="${esc(t.label)}" aria-label="${esc(t.label)}">${iconEl(ICON[t.icon])}</button></div>`;
|
|
384
|
+
};
|
|
385
|
+
m.innerHTML = items.map((it, i) => it.sep ? '<div class="sep"></div>' : rowMarkup(it, i)).join('');
|
|
386
|
+
// A menu anchored to a wide trigger (e.g. the project switcher) reads as broken when it's
|
|
387
|
+
// narrower than the control it dropped from — pin its min-width to the trigger's width so it
|
|
388
|
+
// lines up edge-to-edge, just like a native <select>.
|
|
389
|
+
if (opts && opts.minWidth) m.style.minWidth = Math.round(opts.minWidth) + 'px';
|
|
390
|
+
document.body.appendChild(m);
|
|
391
|
+
const w = m.offsetWidth || 190, h = m.offsetHeight || 40, vw = window.innerWidth, vh = window.innerHeight;
|
|
392
|
+
m.style.left = Math.min(x, vw - w - 8) + 'px';
|
|
393
|
+
m.style.top = Math.min(y, vh - h - 8) + 'px';
|
|
394
|
+
m.querySelectorAll('[data-mt]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); const it = items[+b.dataset.mt]; closeMenu(); it.trail.act && it.trail.act(); }));
|
|
395
|
+
m.querySelectorAll('[data-mi]').forEach(b => b.addEventListener('click', () => { const it = items[+b.dataset.mi]; closeMenu(); it.act && it.act(); }));
|
|
396
|
+
setTimeout(() => document.addEventListener('mousedown', menuAway, true), 0);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- theme ----------------------------------------------------------------
|
|
400
|
+
function mqDark(){ try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; } }
|
|
401
|
+
function themePref(){ try { return localStorage.getItem('dlb-theme') || ''; } catch { return ''; } }
|
|
402
|
+
function applyTheme(t){
|
|
403
|
+
const root = document.documentElement;
|
|
404
|
+
if (t) root.setAttribute('data-theme', t); else root.removeAttribute('data-theme');
|
|
405
|
+
try { localStorage.setItem('dlb-theme', t); } catch {}
|
|
406
|
+
const dark = t === 'dark' || (!t && mqDark());
|
|
407
|
+
const meta = $('meta[name=theme-color]'); if (meta) meta.setAttribute('content', dark ? '#0a0e14' : '#e6ecf1');
|
|
408
|
+
syncThemeControls();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---- sidebar --------------------------------------------------------------
|
|
412
|
+
function renderSidebar(){
|
|
413
|
+
const sb = $('#sidebar'); if (!sb) return;
|
|
414
|
+
sb.innerHTML = `
|
|
415
|
+
<div class="sb-head">
|
|
416
|
+
<button class="projsel" data-projsel aria-haspopup="menu" title="${S.project ? 'Switch project · ' + esc(S.project.name) : 'Switch project'}">
|
|
417
|
+
<span class="pname">${S.project ? esc(S.project.name) : 'No project'}</span>
|
|
418
|
+
${S.isGit && S.git.branch ? `<span class="pbranch" title="Current branch">${iconEl(ICON.gitbranch)}<span class="pbn">${esc(S.git.branch)}</span></span>` : ''}
|
|
419
|
+
${iconEl(ICON.chevrud).replace('class="ico"','class="ico cv"')}
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="search">
|
|
423
|
+
${iconEl(ICON.search)}
|
|
424
|
+
<input type="search" id="casesearch" placeholder="Search…" autocomplete="off" spellcheck="false" value="${esc(S.query)}" aria-label="Search records">
|
|
425
|
+
<button class="clr ${S.query?'show':''}" id="searchclr" aria-label="Clear search">${iconEl(ICON.x)}</button>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="expbar">
|
|
428
|
+
<span class="exptitle">${S.diffMode ? 'Changes' : 'Explorer'}</span>
|
|
429
|
+
<button class="expact" data-newmenu title="New file or folder" aria-label="New file or folder">${iconEl(ICON.plus)}</button>
|
|
430
|
+
${S.isGit ? `<button class="expact difftoggle ${S.diffMode?'on':''}" data-diffmode aria-pressed="${S.diffMode}" title="${S.diffMode ? 'Show all files' : 'Show only files with uncommitted changes'}" aria-label="${S.diffMode ? 'Show all files' : 'Show only changed files'}">${iconEl(ICON.gitbranch)}${S.diffMode && changedDocCount() ? `<span class="cbadge">${changedDocCount()}</span>` : ''}</button>` : ''}
|
|
431
|
+
<button class="expact cmttoggle ${S.commentFilter?'on':''}" data-cfilter aria-pressed="${!!S.commentFilter}" title="${S.commentFilter ? 'Show all files' : 'Show only files with open comments'}" aria-label="${S.commentFilter ? 'Show all files' : 'Show only files with comments'}">${iconEl(ICON.comment)}${!S.commentFilter && totalCommentCount() ? `<span class="cbadge amber">${totalCommentCount()}</span>` : ''}</button>
|
|
432
|
+
<button class="collapseall" data-collapseall title="${allTopCollapsed()?'Expand all folders':'Collapse all folders'}" aria-label="${allTopCollapsed()?'Expand all folders':'Collapse all folders'}">${iconEl(allTopCollapsed()?ICON.expandAll:ICON.collapseAll)}</button>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="caselist explorerlist" id="explorerlist"></div>`;
|
|
435
|
+
fillExplorer();
|
|
436
|
+
sb.querySelector('[data-projsel]').addEventListener('click', projMenu);
|
|
437
|
+
const si = sb.querySelector('#casesearch');
|
|
438
|
+
si.addEventListener('input', () => { const wasEmpty = !S.query; S.query = si.value; if (wasEmpty && S.query){ TEL.count('searches', 1); TEL.event('ui.action', { area:'explorer', action:'search-open' }); } sb.querySelector('#searchclr').classList.toggle('show', !!S.query); fillExplorer(); });
|
|
439
|
+
sb.querySelector('#searchclr').addEventListener('click', () => { S.query = ''; sb.querySelector('#searchclr').classList.remove('show'); const el = sb.querySelector('#casesearch'); el.value = ''; el.focus(); fillExplorer(); });
|
|
440
|
+
sb.querySelector('[data-collapseall]')?.addEventListener('click', () => { TEL.event('ui.action', { area: 'explorer', action: allTopCollapsed() ? 'expand-all' : 'collapse-all' }); toggleCollapseAll(); });
|
|
441
|
+
sb.querySelector('[data-newmenu]')?.addEventListener('click', (e) => { const r = e.currentTarget.getBoundingClientRect(); openNewMenu(S.treeBase || '', r.left, r.bottom + 4); });
|
|
442
|
+
sb.querySelector('[data-diffmode]')?.addEventListener('click', () => { TEL.event('ui.action', { area: 'explorer', action: 'diff' }); setDiffMode(!S.diffMode); });
|
|
443
|
+
sb.querySelector('[data-cfilter]')?.addEventListener('click', () => { TEL.event('ui.action', { area: 'explorer', action: 'comments' }); setCommentFilter(!S.commentFilter); });
|
|
444
|
+
updateTopGlobal();
|
|
445
|
+
}
|
|
446
|
+
function explorerInventory(nodes = S.tree){
|
|
447
|
+
let files = 0, folders = 0;
|
|
448
|
+
for (const node of nodes || []){
|
|
449
|
+
if (node.type === 'dir'){ folders++; const child = explorerInventory(node.children || []); files += child.files; folders += child.folders; }
|
|
450
|
+
else if (node.type === 'file') files++;
|
|
451
|
+
}
|
|
452
|
+
return { files, folders };
|
|
453
|
+
}
|
|
454
|
+
function captureProjectInventory(){
|
|
455
|
+
const tree = explorerInventory();
|
|
456
|
+
TEL.gauge('open_tabs', S.tabs.length);
|
|
457
|
+
TEL.gauge('explorer_files', tree.files);
|
|
458
|
+
TEL.gauge('explorer_folders', tree.folders);
|
|
459
|
+
TEL.gauge('project_cases', S.inventory?.cases ?? S.cases.length);
|
|
460
|
+
TEL.gauge('project_briefs', S.inventory?.briefs ?? S.briefs.length);
|
|
461
|
+
TEL.gauge('project_readouts', S.inventory?.readouts ?? S.readouts.length);
|
|
462
|
+
TEL.gauge('project_matchups', S.inventory?.matchups ?? S.matchups.length);
|
|
463
|
+
TEL.gauge('project_open_comments', totalCommentCount());
|
|
464
|
+
}
|
|
465
|
+
// The persistent top-bar utility cluster (global chrome, right of the per-record actions).
|
|
466
|
+
function updateTopGlobal(){
|
|
467
|
+
const ib = $('#inboxbtn'); if (ib) ib.classList.toggle('active', route().name === 'inbox');
|
|
468
|
+
const fb = $('#feedbackbtn'); if (fb) fb.classList.toggle('active', route().name === 'feedback');
|
|
469
|
+
const sb = $('#settingsbtn'); if (sb) sb.classList.toggle('active', route().name === 'settings');
|
|
470
|
+
}
|
|
471
|
+
function syncThemeControls(){
|
|
472
|
+
const pref = themePref();
|
|
473
|
+
document.querySelectorAll('[data-theme-value]').forEach((btn) => {
|
|
474
|
+
const selected = btn.dataset.themeValue === pref;
|
|
475
|
+
btn.classList.toggle('selected', selected);
|
|
476
|
+
btn.setAttribute('aria-checked', String(selected));
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
// Git diff mode: fetch the branch + the set of files with uncommitted changes, then filter
|
|
480
|
+
// the Explorer to only those (each opens in its Diff view). Kept fresh on toggle-on, after a
|
|
481
|
+
// discard, and when the agent edits a record (SSE). No-op when the vault isn't a git repo.
|
|
482
|
+
async function loadGit(){
|
|
483
|
+
if (!S.isGit){ S.git = { branch:'', changed:[] }; syncDocDiff(); return; }
|
|
484
|
+
let g; try { g = await api('GET', '/api/git'); } catch { return; }
|
|
485
|
+
if (!g || g.error) return;
|
|
486
|
+
S.git = { branch: g.branch || '', changed: Array.isArray(g.changed) ? g.changed : [] };
|
|
487
|
+
syncDocDiff(); // the file's change-state may have flipped (e.g. reverted)
|
|
488
|
+
}
|
|
489
|
+
async function setDiffMode(on){
|
|
490
|
+
const v = !!on && S.isGit;
|
|
491
|
+
if (v === S.diffMode) return;
|
|
492
|
+
S.diffMode = v;
|
|
493
|
+
if (v){ S.query = ''; S.commentFilter = false; await loadGit(); } // fresh status; the two filters are mutually exclusive
|
|
494
|
+
renderSidebar();
|
|
495
|
+
}
|
|
496
|
+
// The comments-only Explorer filter (mirrors the git diff-mode filter): show only files with
|
|
497
|
+
// open comments (and their ancestor folders). Refreshes counts on enable.
|
|
498
|
+
async function setCommentFilter(on){
|
|
499
|
+
const v = !!on;
|
|
500
|
+
if (v === !!S.commentFilter) return;
|
|
501
|
+
S.commentFilter = v;
|
|
502
|
+
if (v){ S.query = ''; S.diffMode = false; await loadCommentCounts(); }
|
|
503
|
+
renderSidebar();
|
|
504
|
+
}
|
|
505
|
+
// ---- Explorer auto-refresh (filesystem ⇄ tree) ----------------------------
|
|
506
|
+
// Re-pull the file tree (and git change set) and re-render the Explorer when the on-disk
|
|
507
|
+
// folder changes — a file added outside the app appears, a deleted one drops out. Driven by
|
|
508
|
+
// the server's `fs` SSE push (instant) with a slow interval as a fallback where recursive
|
|
509
|
+
// watch isn't available. Cheap and idempotent: it only re-renders when the tree or change
|
|
510
|
+
// set actually moved, and avoids a full sidebar rebuild while the search box has focus (which
|
|
511
|
+
// would drop the caret) — refilling just the list instead.
|
|
512
|
+
let treeRefreshTimer = null, treePollTimer = null, treeRefreshing = false;
|
|
513
|
+
async function refreshTree(){
|
|
514
|
+
if (!S.project || treeRefreshing) return; // reentrancy guard: never stack concurrent refreshes
|
|
515
|
+
treeRefreshing = true;
|
|
516
|
+
try {
|
|
517
|
+
let tr; try { tr = await api('GET', '/api/tree'); } catch { return; }
|
|
518
|
+
if (!tr || tr.error) return;
|
|
519
|
+
const nextTree = tr.tree || [];
|
|
520
|
+
const prevInventory = JSON.stringify(S.inventory);
|
|
521
|
+
if (tr.inventory) S.inventory = tr.inventory;
|
|
522
|
+
const prevChanged = JSON.stringify(S.git.changed || []);
|
|
523
|
+
// Keep the git change set fresh whenever the repo is git-tracked — the Explorer shows a
|
|
524
|
+
// per-file A/M/D badge in ALL views (not only diff mode), so every refresh needs current status.
|
|
525
|
+
if (S.isGit) await loadGit();
|
|
526
|
+
const treeMoved = JSON.stringify(nextTree) !== JSON.stringify(S.tree);
|
|
527
|
+
const inventoryMoved = JSON.stringify(S.inventory) !== prevInventory;
|
|
528
|
+
const gitMoved = JSON.stringify(S.git.changed || []) !== prevChanged;
|
|
529
|
+
if (typeof tr.base === 'string') S.treeBase = tr.base;
|
|
530
|
+
if (!treeMoved && !gitMoved && !inventoryMoved) return;
|
|
531
|
+
S.tree = nextTree;
|
|
532
|
+
captureProjectInventory();
|
|
533
|
+
const searching = document.activeElement && document.activeElement.id === 'casesearch';
|
|
534
|
+
// Preserve the Explorer's scroll across the refresh — renderSidebar() rebuilds the whole
|
|
535
|
+
// sidebar (a fresh #explorerlist at scrollTop 0), so a refresh (e.g. after a drag-move or an
|
|
536
|
+
// external file change) would otherwise jump the tree back to the top.
|
|
537
|
+
const prevScroll = $('#explorerlist')?.scrollTop || 0;
|
|
538
|
+
if (searching) fillExplorer(); else renderSidebar();
|
|
539
|
+
const nl = $('#explorerlist'); if (nl) nl.scrollTop = prevScroll;
|
|
540
|
+
} finally { treeRefreshing = false; }
|
|
541
|
+
}
|
|
542
|
+
function scheduleTreeRefresh(){ if (treeRefreshTimer) return; treeRefreshTimer = setTimeout(() => { treeRefreshTimer = null; refreshTree(); }, 150); }
|
|
543
|
+
// On a real on-disk change (fs events fire only for content — .git/.sonorance are filtered), refresh
|
|
544
|
+
// git status for the OPEN record so its Document⇄Diff toggle reflects reality — e.g. it DISABLES when
|
|
545
|
+
// the file is reverted (manually, by another tool, or `git checkout`). Coalesced; a no-op if nothing
|
|
546
|
+
// is open or the repo isn't git. (Kept off the idle poll so large repos aren't `git status`-ed every 4s.)
|
|
547
|
+
let gitSyncTimer = null;
|
|
548
|
+
function scheduleGitSync(){
|
|
549
|
+
if (gitSyncTimer || !S.isGit || !(S.file || S.caseId || S.brief || S.readout || S.matchup)) return;
|
|
550
|
+
gitSyncTimer = setTimeout(() => { gitSyncTimer = null; loadGit().then(() => { if (S.diffMode) renderSidebar(); }); }, 250);
|
|
551
|
+
gitSyncTimer.unref?.();
|
|
552
|
+
}
|
|
553
|
+
function startTreePolling(){
|
|
554
|
+
if (treePollTimer) return;
|
|
555
|
+
treePollTimer = setInterval(() => { if (document.visibilityState !== 'hidden'){ refreshTree(); reloadOpenDoc(); } }, 4000);
|
|
556
|
+
treePollTimer.unref?.();
|
|
557
|
+
}
|
|
558
|
+
// ---- project config: buffered periodic flush -------------------------------
|
|
559
|
+
// UI state that changes rapidly (Explorer collapse/expand, and any future per-project config)
|
|
560
|
+
// is written to disk on a timer, not on every interaction: `queueConfig` records the latest
|
|
561
|
+
// value in memory; `flushConfig` POSTs the accumulated patch every ~1.5s when dirty (and on
|
|
562
|
+
// unload / project switch). This keeps `.sonorance/config.json` writes coarse instead of one
|
|
563
|
+
// per keystroke-equivalent. Reusable for any project-scoped config, not just the Explorer.
|
|
564
|
+
function queueConfig(key, value){ S.cfgPending[key] = value; }
|
|
565
|
+
async function flushConfig(){
|
|
566
|
+
if (!Object.keys(S.cfgPending).length) return;
|
|
567
|
+
const patch = S.cfgPending; S.cfgPending = {};
|
|
568
|
+
try { await api('POST', '/api/config', { patch }); }
|
|
569
|
+
catch { for (const k in patch) if (!(k in S.cfgPending)) S.cfgPending[k] = patch[k]; } // re-queue on failure
|
|
570
|
+
}
|
|
571
|
+
function startConfigFlush(){
|
|
572
|
+
if (S.cfgTimer) return;
|
|
573
|
+
S.cfgTimer = setInterval(() => { flushConfig(); }, 1500);
|
|
574
|
+
S.cfgTimer.unref?.();
|
|
575
|
+
}
|
|
576
|
+
// Persist the current Explorer collapse/expand state (baseline mode + exception paths).
|
|
577
|
+
function queueExplorer(){ queueConfig('explorer', { mode: S.explorer.mode, ex: [...S.explorer.ex] }); }
|
|
578
|
+
// The Explorer's collapse/expand state is stored compactly as a baseline `mode`
|
|
579
|
+
// ('collapsed' | 'expanded') plus a set of EXCEPTION paths that differ from it — so a mostly-
|
|
580
|
+
// collapsed tree persists only the few expanded folders (and vice-versa). A fresh project
|
|
581
|
+
// defaults to fully collapsed. `folderOpen` is the single source of truth for a folder's state.
|
|
582
|
+
function folderOpen(path){ return S.explorer.mode === 'expanded' ? !S.explorer.ex.has(path) : S.explorer.ex.has(path); }
|
|
583
|
+
// True when the tree is fully collapsed (baseline collapsed, no expanded exceptions) — drives
|
|
584
|
+
// the collapse-all ⇄ expand-all toggle button.
|
|
585
|
+
function allTopCollapsed(){ return S.explorer.mode === 'collapsed' && S.explorer.ex.size === 0; }
|
|
586
|
+
function collapseAllFolders(){ S.explorer = { mode:'collapsed', ex:new Set() }; queueExplorer(); renderSidebar(); }
|
|
587
|
+
function expandAllFolders(){ S.explorer = { mode:'expanded', ex:new Set() }; queueExplorer(); renderSidebar(); }
|
|
588
|
+
function toggleCollapseAll(){ if (allTopCollapsed()) expandAllFolders(); else collapseAllFolders(); }
|
|
589
|
+
let explorerIntroShown = false;
|
|
590
|
+
function flourish(list, has){ if (has){ list.classList.toggle('intro', !explorerIntroShown); explorerIntroShown = true; } else list.classList.remove('intro'); }
|
|
591
|
+
// Fill the Explorer (kept focus-stable so typing in search never loses the caret — we only
|
|
592
|
+
// replace the list body, never the search input). The Explorer is a file tree only.
|
|
593
|
+
function fillExplorer(){ const list = $('#explorerlist'); if (!list) return; renderTree(list); }
|
|
594
|
+
|
|
595
|
+
// ---- Explorer: full Markdown file tree -----------------------------------------------
|
|
596
|
+
// The tree the Explorer renders. In diff mode it is S.tree AUGMENTED with DELETED files
|
|
597
|
+
// (git status 'D'): those no longer exist on disk, so the server's file-system walk misses
|
|
598
|
+
// them, but their removal is part of the change set and must still be reviewable. Each is
|
|
599
|
+
// grafted in as a leaf (creating any missing parent folders) that opens straight into its
|
|
600
|
+
// Diff (the removal). Outside diff mode the raw tree is used unchanged.
|
|
601
|
+
function treeForRender(){
|
|
602
|
+
if (!(S.diffMode && S.isGit)) return S.tree;
|
|
603
|
+
const present = new Set();
|
|
604
|
+
(function walk(ns){ for (const n of ns || []){ if (n.type === 'dir') walk(n.children); else present.add(n.path); } })(S.tree);
|
|
605
|
+
const deleted = (S.git.changed || [])
|
|
606
|
+
.filter(c => /\.md$/i.test(c.path) && c.status.includes('D') && !present.has(c.path))
|
|
607
|
+
.map(c => c.path);
|
|
608
|
+
if (!deleted.length) return S.tree;
|
|
609
|
+
const clone = cloneTreeNodes(S.tree);
|
|
610
|
+
for (const path of deleted) graftDeletedLeaf(clone, path);
|
|
611
|
+
sortTreeNodes(clone);
|
|
612
|
+
return clone;
|
|
613
|
+
}
|
|
614
|
+
function cloneTreeNodes(nodes){ return (nodes || []).map(n => n.type === 'dir' ? { ...n, children: cloneTreeNodes(n.children) } : { ...n }); }
|
|
615
|
+
// Insert a deleted-file leaf at `path`, creating intermediate folder nodes as needed. The
|
|
616
|
+
// leaf is marked `deleted` so a click opens its removal Diff (the file can't be read).
|
|
617
|
+
function graftDeletedLeaf(nodes, path){
|
|
618
|
+
const parts = path.split('/');
|
|
619
|
+
const name = parts.pop();
|
|
620
|
+
let level = nodes, prefix = '';
|
|
621
|
+
for (const seg of parts){
|
|
622
|
+
prefix = prefix ? `${prefix}/${seg}` : seg;
|
|
623
|
+
let dir = level.find(n => n.type === 'dir' && n.name === seg);
|
|
624
|
+
if (!dir){ dir = { name: seg, path: prefix, type: 'dir', children: [] }; level.push(dir); }
|
|
625
|
+
if (!dir.children) dir.children = [];
|
|
626
|
+
level = dir.children;
|
|
627
|
+
}
|
|
628
|
+
if (!level.some(n => n.type === 'file' && n.path === path))
|
|
629
|
+
level.push({ name, path, type: 'file', kind: 'file', id: null, deleted: true });
|
|
630
|
+
}
|
|
631
|
+
function sortTreeNodes(nodes){
|
|
632
|
+
nodes.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
633
|
+
for (const n of nodes) if (n.type === 'dir') sortTreeNodes(n.children || []);
|
|
634
|
+
}
|
|
635
|
+
// Renders S.tree with disclosure carets, per-depth indentation, single-child folder-path
|
|
636
|
+
// compaction, collapse/expand, and search that keeps a matching file's folders.
|
|
637
|
+
function renderTree(list){
|
|
638
|
+
const q = S.query.trim().toLowerCase();
|
|
639
|
+
const changed = diffFilterSet();
|
|
640
|
+
const commented = commentFilterSet();
|
|
641
|
+
const baseTree = treeForRender();
|
|
642
|
+
const prevScroll = list.scrollTop; // preserve scroll across the innerHTML swap (a refresh — e.g. after a move — must not jump to the top)
|
|
643
|
+
// Files must pass the diff filter, the comments filter (when on) AND the search query.
|
|
644
|
+
const pred = (n) => (!changed || changed.has(n.path)) && (!commented || commented.has(n.path)) && (!q || matchFileNode(n, q));
|
|
645
|
+
const roots = (q || changed || commented) ? pruneTree(baseTree, pred) : baseTree;
|
|
646
|
+
if (!roots.length){
|
|
647
|
+
list.classList.remove('intro');
|
|
648
|
+
const msg = commented ? 'No files with open comments.' : changed ? 'No files with uncommitted changes.' : (q ? 'No files match.' : 'No markdown files yet.');
|
|
649
|
+
list.innerHTML = `<div style="padding:8px 12px;color:var(--ink-faint);font-size:13px">${msg}</div>`;
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const html = [];
|
|
653
|
+
renderTreeNodes(roots, 0, !!(q || changed || commented), html);
|
|
654
|
+
list.innerHTML = '<div class="tree" role="tree">' + html.join('') + '</div>';
|
|
655
|
+
list.scrollTop = prevScroll; // restore the scroll position (clamped by the browser if the tree got shorter)
|
|
656
|
+
flourish(list, true);
|
|
657
|
+
// Folder disclosure: toggle collapse; the whole row toggles too.
|
|
658
|
+
list.querySelectorAll('[data-dir]').forEach(el => el.addEventListener('click', (e) => { e.preventDefault(); toggleFolder(el.dataset.dir); }));
|
|
659
|
+
wireExplorerLeaves(list);
|
|
660
|
+
wireDragDrop(list);
|
|
661
|
+
applyTrimTitles(list);
|
|
662
|
+
}
|
|
663
|
+
// ---- drag-and-drop move ---------------------------------------------------
|
|
664
|
+
// Drag a file or folder onto another folder (or the empty area = vault root) to move it there,
|
|
665
|
+
// subtree and all. The drop target is highlighted while dragging; an invalid drop (into itself,
|
|
666
|
+
// or back where it already is) simply doesn't highlight or accept.
|
|
667
|
+
let dragSrc = null; // { path, isDir } for the row being dragged
|
|
668
|
+
// The destination FOLDER for a drop landing on `el`: a folder row → that folder; a file row →
|
|
669
|
+
// its parent folder; anything else (empty list space) → the vault root ('').
|
|
670
|
+
function dropDirFor(el){
|
|
671
|
+
if (!el || !el.closest) return '';
|
|
672
|
+
const dir = el.closest('.trow.dir'); if (dir) return dir.dataset.dir;
|
|
673
|
+
const leaf = el.closest('.trow[data-open="file"]'); if (leaf){ const k = leaf.dataset.path || leaf.dataset.key || ''; const i = k.lastIndexOf('/'); return i >= 0 ? k.slice(0, i) : ''; }
|
|
674
|
+
return '';
|
|
675
|
+
}
|
|
676
|
+
function validDrop(src, destDir){
|
|
677
|
+
if (!src || destDir == null) return false;
|
|
678
|
+
const srcParent = src.path.includes('/') ? src.path.slice(0, src.path.lastIndexOf('/')) : '';
|
|
679
|
+
if (destDir === srcParent) return false; // already lives here → nothing to do
|
|
680
|
+
if (src.isDir && (destDir === src.path || destDir.startsWith(src.path + '/'))) return false; // into itself / own subtree
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
function clearDropHi(){
|
|
684
|
+
const list = $('#explorerlist'); if (!list) return;
|
|
685
|
+
list.classList.remove('droproot');
|
|
686
|
+
list.querySelectorAll('.trow.droptarget').forEach(r => r.classList.remove('droptarget'));
|
|
687
|
+
}
|
|
688
|
+
function highlightDrop(destDir){
|
|
689
|
+
const list = $('#explorerlist'); if (!list) return;
|
|
690
|
+
list.classList.toggle('droproot', destDir === '');
|
|
691
|
+
list.querySelectorAll('.trow.dir').forEach(r => r.classList.toggle('droptarget', destDir !== '' && r.dataset.dir === destDir));
|
|
692
|
+
}
|
|
693
|
+
function wireDragDrop(list){
|
|
694
|
+
list.querySelectorAll('.trow[draggable="true"]').forEach(row => {
|
|
695
|
+
const isDir = row.classList.contains('dir');
|
|
696
|
+
const path = isDir ? row.dataset.dir : row.dataset.path;
|
|
697
|
+
if (path == null) return;
|
|
698
|
+
row.addEventListener('dragstart', (e) => {
|
|
699
|
+
dragSrc = { path, isDir };
|
|
700
|
+
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', path); } catch {}
|
|
701
|
+
row.classList.add('dragging');
|
|
702
|
+
});
|
|
703
|
+
row.addEventListener('dragend', () => { dragSrc = null; clearDropHi(); list.querySelectorAll('.dragging').forEach(r => r.classList.remove('dragging')); });
|
|
704
|
+
});
|
|
705
|
+
list.addEventListener('dragover', (e) => {
|
|
706
|
+
if (!dragSrc) return;
|
|
707
|
+
const destDir = dropDirFor(e.target);
|
|
708
|
+
if (!validDrop(dragSrc, destDir)){ clearDropHi(); return; }
|
|
709
|
+
e.preventDefault(); try { e.dataTransfer.dropEffect = 'move'; } catch {}
|
|
710
|
+
highlightDrop(destDir);
|
|
711
|
+
});
|
|
712
|
+
list.addEventListener('dragleave', (e) => { if (e.target === list) clearDropHi(); });
|
|
713
|
+
list.addEventListener('drop', (e) => {
|
|
714
|
+
if (!dragSrc) return;
|
|
715
|
+
const destDir = dropDirFor(e.target);
|
|
716
|
+
const src = dragSrc; dragSrc = null; clearDropHi();
|
|
717
|
+
if (!validDrop(src, destDir)) return;
|
|
718
|
+
e.preventDefault();
|
|
719
|
+
moveEntry(src.path, destDir);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
// POST a move, then re-point any open tabs under the moved path and refresh the tree.
|
|
723
|
+
async function moveEntry(srcPath, destDir){
|
|
724
|
+
const r = await api('POST', '/api/move', { src: srcPath, dest: destDir });
|
|
725
|
+
if (!r || r.error){
|
|
726
|
+
showToast(r && r.error === 'into-self' ? "Can't move a folder into itself."
|
|
727
|
+
: r && r.error === 'exists' ? 'Something with that name already exists there.'
|
|
728
|
+
: r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
|
|
729
|
+
: 'Could not move.');
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
TEL.event('ui.action', { area:'explorer', action:'move' });
|
|
733
|
+
repointFileTabs(r.from || srcPath, r.path);
|
|
734
|
+
if (destDir) ensureFolderOpen(destDir);
|
|
735
|
+
await refreshTree();
|
|
736
|
+
}
|
|
737
|
+
// Rename a file or folder in place (same parent, new basename) — the Explorer's "Rename…". The
|
|
738
|
+
// dialog is seeded with the current name and pre-selects the editable stem (name without the
|
|
739
|
+
// extension for a file), so a quick retype keeps `.md`. Reuses the move plumbing to keep any open
|
|
740
|
+
// tab pointed at the renamed file (or files under a renamed folder) and its URL correct.
|
|
741
|
+
async function renameEntry(path, isDir){
|
|
742
|
+
if (!path) return;
|
|
743
|
+
const cur = baseName(path);
|
|
744
|
+
const dot = cur.lastIndexOf('.');
|
|
745
|
+
const selectEnd = (!isDir && dot > 0) ? dot : cur.length; // select the stem (files) / whole name (folders)
|
|
746
|
+
const name = await promptDialog({
|
|
747
|
+
title: isDir ? 'Rename folder' : 'Rename file',
|
|
748
|
+
label: isDir ? 'Folder name' : 'File name',
|
|
749
|
+
value: cur, selectEnd, confirmLabel: 'Rename',
|
|
750
|
+
});
|
|
751
|
+
if (name == null || name === cur) return;
|
|
752
|
+
const r = await api('POST', '/api/rename', { path, name });
|
|
753
|
+
if (!r || r.error){
|
|
754
|
+
showToast(r && r.error === 'exists' ? 'Something with that name already exists here.'
|
|
755
|
+
: r && r.error === 'bad name' ? 'That name isn’t allowed.'
|
|
756
|
+
: r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
|
|
757
|
+
: 'Could not rename.');
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
TEL.event('ui.action', { area:'explorer', action:'rename' });
|
|
761
|
+
repointFileTabs(r.from || path, r.path);
|
|
762
|
+
if (isDir) ensureFolderOpen(r.path); // keep the renamed folder revealed
|
|
763
|
+
await refreshTree();
|
|
764
|
+
}
|
|
765
|
+
// After a move, update open FILE tabs (and the live document) whose path was the moved item or
|
|
766
|
+
// lived under it, so the file stays open at its new location and the URL matches.
|
|
767
|
+
function repointFileTabs(fromPath, toPath){
|
|
768
|
+
const remap = (id) => id === fromPath ? toPath : (id.startsWith(fromPath + '/') ? toPath + id.slice(fromPath.length) : null);
|
|
769
|
+
let changed = false;
|
|
770
|
+
for (const t of S.tabs){ if (t.kind !== 'file') continue; const n = remap(t.id); if (n != null){ t.id = n; changed = true; } }
|
|
771
|
+
if (S.file && S.file.path){ const n = remap(S.file.path); if (n != null){ S.file.path = n; S.recFile = n; } }
|
|
772
|
+
if (!changed) return;
|
|
773
|
+
persistTabs(); renderTabs();
|
|
774
|
+
const at = S.tabs[S.activeTab];
|
|
775
|
+
if (at && at.kind === 'file'){ try { history.replaceState({}, '', PP() + tabRoute(at)); } catch {} }
|
|
776
|
+
}
|
|
777
|
+
function renderTreeNodes(nodes, depth, searching, out){
|
|
778
|
+
for (const n of nodes){
|
|
779
|
+
if (n.type === 'dir'){
|
|
780
|
+
// Fold a chain of single-child folders into "a/b/c".
|
|
781
|
+
let names = [n.name], cur = n;
|
|
782
|
+
while (cur.children && cur.children.length === 1 && cur.children[0].type === 'dir'){ cur = cur.children[0]; names.push(cur.name); }
|
|
783
|
+
const open = searching || folderOpen(cur.path);
|
|
784
|
+
const gs = dirGitStatus(cur.path); // colour the folder name by aggregate change, not a badge
|
|
785
|
+
out.push(`<div class="trow dir ${open?'open':''}${gs ? ' gs-' + gs.cls : ''}" role="treeitem" aria-expanded="${open}" draggable="true" data-dir="${esc(cur.path)}" data-full="${esc(cur.path)}"${gs ? ` data-gs-tip="${esc(gs.tip)}"` : ''} style="--depth:${depth}">
|
|
786
|
+
<span class="tw">${iconEl(ICON.caret)}</span><span class="lbl">${esc(names.join('/'))}</span>${rowActs({ newfile: cur.path, del: cur.path, dir: true })}<span class="gitslot">${dirCommentBadge(cur.path)}</span></div>`);
|
|
787
|
+
if (open) renderTreeNodes(cur.children || [], depth + 1, searching, out);
|
|
788
|
+
} else {
|
|
789
|
+
const label = fileNodeLabel(n);
|
|
790
|
+
const active = fileNodeActive(n);
|
|
791
|
+
// Tooltip: a plain file shows its path (folder context); a case/brief shows its
|
|
792
|
+
// full title/period (the record path — analysis.md — is not meaningful to a reader).
|
|
793
|
+
const tip = n.kind === 'file' ? n.path : label;
|
|
794
|
+
out.push(explorerLeaf(n.kind, n.kind === 'file' ? n.path : n.id, label, active, depth, 0, '', n.path, tip, gitBadge(n.path)));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// One clickable leaf (a document) in the file tree. `openKey` routes the click: a case/brief
|
|
799
|
+
// id, or a file path. `depth` sets indentation; `tip` is the full name/path surfaced as a
|
|
800
|
+
// hover tooltip when the label is truncated (see applyTrimTitles). `badge` is trusted inline
|
|
801
|
+
// HTML (a git-change badge), rendered right-aligned; `meta` is escaped text.
|
|
802
|
+
function explorerLeaf(kind, openKey, label, active, depth, i, meta, path, tip, badge){
|
|
803
|
+
const href = PP() + (kind === 'file' ? '/file/' + encFilePath(path || openKey) : '/' + kind + '/' + openKey);
|
|
804
|
+
// No icons (space is at a premium). A leaf leads by one caret-width (--lead) so its name
|
|
805
|
+
// lines up under sibling folder names.
|
|
806
|
+
const vars = `--depth:${depth};--lead:var(--ind-caret);`;
|
|
807
|
+
const gs = fileGitClass(path || openKey); // colour the name by git status for EVERY record (file/case/brief), not just plain files
|
|
808
|
+
const tail = (meta ? `<span class="meta">${esc(meta)}</span>` : '') + rowActs({ del: path || openKey, ntKind: kind, ntKey: openKey }) + commentBadge(path || openKey) + `<span class="gitslot">${badge || ''}</span>`;
|
|
809
|
+
const drag = kind === 'file' ? ' draggable="true"' : ''; // plain files are movable (records keep their id-keyed tab)
|
|
810
|
+
return `<a class="trow ${kind} ${gs} ${active?'active':''}" role="treeitem" style="${vars}--i:${Math.min(i||0,14)}"${drag} data-path="${esc(path || openKey)}"
|
|
811
|
+
href="${href}" data-open="${kind}" data-key="${esc(String(openKey))}" data-full="${esc(tip || label)}"><span class="lbl">${esc(label)}</span>${tail}</a>`;
|
|
812
|
+
}
|
|
813
|
+
// The hover-revealed row actions: "open in new tab" (documents),
|
|
814
|
+
// "new file here" (folders) and "delete". Buttons — not links — so a click acts, never
|
|
815
|
+
// navigates; each stops the row's own open/toggle handler. Kept out of the tab order (the
|
|
816
|
+
// context menu + header cover keyboard). "Open in new tab" is the discoverable, browser-safe
|
|
817
|
+
// alternative to a modifier-click (which anchors reserve for the browser's own new tab/window).
|
|
818
|
+
function rowActs({ newfile, del, dir, ntKind, ntKey } = {}){
|
|
819
|
+
const nt = ntKind != null ? `<button type="button" class="rowact" tabindex="-1" data-newtab-kind="${esc(ntKind)}" data-newtab-key="${esc(String(ntKey))}" title="Open in new tab" aria-label="Open in new tab">${iconEl(ICON.external)}</button>` : '';
|
|
820
|
+
const nf = newfile != null ? `<button type="button" class="rowact" tabindex="-1" data-newin="${esc(newfile)}" title="New file or folder here" aria-label="New file or folder here">${iconEl(ICON.plus)}</button>` : '';
|
|
821
|
+
const rm = del != null ? `<button type="button" class="rowact" tabindex="-1" data-del="${esc(del)}" data-isdir="${dir ? '1' : ''}" title="${dir ? 'Delete folder' : 'Delete file'}" aria-label="${dir ? 'Delete folder' : 'Delete file'}">${iconEl(ICON.trash)}</button>` : '';
|
|
822
|
+
return (nt || nf || rm) ? `<span class="rowacts">${nt}${nf}${rm}</span>` : '';
|
|
823
|
+
}
|
|
824
|
+
function wireExplorerLeaves(list){
|
|
825
|
+
list.querySelectorAll('[data-open]').forEach(a => {
|
|
826
|
+
// One predictable click: open in place (replace the active tab). "Open in new tab" is an
|
|
827
|
+
// explicit act — the row's hover button, or the right-click menu — never a modifier/middle
|
|
828
|
+
// click (in a real browser those are reserved by the anchor: new browser tab/window/download).
|
|
829
|
+
a.addEventListener('click', (e) => { e.preventDefault(); closeNav(); openExplorerItem(a.dataset.open, a.dataset.key, { newTab: false }); });
|
|
830
|
+
a.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); rowContextMenu(e, { kind: a.dataset.open, key: a.dataset.key, file: a.dataset.key, path: a.dataset.path }); });
|
|
831
|
+
});
|
|
832
|
+
// Row action buttons (hover-revealed) + folder context menus. Buttons stop propagation so
|
|
833
|
+
// they never trigger the row's open/toggle; the right-click menu is the power-user path.
|
|
834
|
+
wireRowActs(list);
|
|
835
|
+
}
|
|
836
|
+
// Wire the delete / new-file-here buttons and folder right-click menus for a rendered tree.
|
|
837
|
+
function wireRowActs(list){
|
|
838
|
+
list.querySelectorAll('[data-newtab-kind]').forEach(b => {
|
|
839
|
+
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closeNav(); openExplorerItem(b.dataset.newtabKind, b.dataset.newtabKey, { newTab: true }); });
|
|
840
|
+
});
|
|
841
|
+
list.querySelectorAll('[data-del]').forEach(b => {
|
|
842
|
+
if (b.tagName !== 'BUTTON') return;
|
|
843
|
+
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); confirmDeleteEntry(b.dataset.del, b.dataset.isdir === '1'); });
|
|
844
|
+
});
|
|
845
|
+
list.querySelectorAll('[data-newin]').forEach(b => {
|
|
846
|
+
b.addEventListener('click', (e) => {
|
|
847
|
+
e.preventDefault(); e.stopPropagation();
|
|
848
|
+
const r = b.getBoundingClientRect();
|
|
849
|
+
openNewMenu(b.dataset.newin, r.left, r.bottom + 4);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
list.querySelectorAll('.trow.dir[data-dir]').forEach(el => {
|
|
853
|
+
el.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); rowContextMenu(e, { dir: el.dataset.dir }); });
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
// The Explorer right-click menu — reliable in the browser (the same contextmenu event the tab
|
|
857
|
+
// strip uses). A folder offers New file/folder + Copy path + Delete; a document offers Open in a
|
|
858
|
+
// new tab + Copy path + Delete. "Copy relative path" yields the project-root-relative path;
|
|
859
|
+
// "Copy full path" prepends the vault's absolute directory.
|
|
860
|
+
function rowContextMenu(e, { kind, key, file, dir, path } = {}){
|
|
861
|
+
const rel = dir || path || file || key;
|
|
862
|
+
const copyItems = [
|
|
863
|
+
{ label: 'Copy relative path', icon: 'copy', act: () => copyExplorerPath(rel, false) },
|
|
864
|
+
{ label: 'Copy full path', icon: 'copy', act: () => copyExplorerPath(rel, true) },
|
|
865
|
+
];
|
|
866
|
+
const items = dir
|
|
867
|
+
? [{ label: 'New file here', icon: 'newfile', act: () => promptNewFile(dir) },
|
|
868
|
+
{ label: 'New folder here', icon: 'newfolder', act: () => promptNewFolder(dir) },
|
|
869
|
+
{ sep: true }, { label: 'Rename', icon: 'pencil', act: () => renameEntry(dir, true) }, ...copyItems, { sep: true },
|
|
870
|
+
{ label: 'Delete folder', icon: 'trash', act: () => confirmDeleteEntry(dir, true) }]
|
|
871
|
+
: [{ label: 'Open in new tab', icon: 'external', act: () => openExplorerItem(kind, key, { newTab: true }) },
|
|
872
|
+
{ sep: true }, { label: 'Rename', icon: 'pencil', act: () => renameEntry(path || file || key, false) }, ...copyItems, { sep: true },
|
|
873
|
+
{ label: 'Delete file', icon: 'trash', act: () => confirmDeleteEntry(path || file || key, false) }];
|
|
874
|
+
openMenu(e.clientX, e.clientY, items);
|
|
875
|
+
}
|
|
876
|
+
// Copy a tree entry's path. `full` prepends the vault's absolute directory (the full system path);
|
|
877
|
+
// otherwise it's the project-root-relative path. Uses the async clipboard with a legacy fallback.
|
|
878
|
+
async function copyExplorerPath(rel, full){
|
|
879
|
+
const relPath = String(rel || '');
|
|
880
|
+
const base = S.project && S.project.dir ? String(S.project.dir).replace(/[\\/]+$/, '') : '';
|
|
881
|
+
const out = full && base ? base + '/' + relPath : relPath;
|
|
882
|
+
let ok = false; try { await navigator.clipboard.writeText(out); ok = true; } catch { ok = copyFallback(out); }
|
|
883
|
+
showToast(ok ? (full ? 'Full path copied.' : 'Relative path copied.') : 'Could not copy the path.');
|
|
884
|
+
}
|
|
885
|
+
function openExplorerItem(kind, key, opts){
|
|
886
|
+
if (kind === 'case') return openCaseTab(key, opts);
|
|
887
|
+
if (kind === 'brief') return openBriefTab(key, opts);
|
|
888
|
+
if (kind === 'readout') return openReadoutTab(key, opts);
|
|
889
|
+
if (kind === 'matchup') return openMatchupTab(key, opts);
|
|
890
|
+
return openFileTab(key, opts);
|
|
891
|
+
}
|
|
892
|
+
// Flip one folder's open state and record it as an exception to (or back in line with) the
|
|
893
|
+
// baseline mode, then persist (buffered) and re-render.
|
|
894
|
+
function toggleFolder(path){
|
|
895
|
+
const wantOpen = !folderOpen(path);
|
|
896
|
+
const baselineOpen = S.explorer.mode === 'expanded';
|
|
897
|
+
if (wantOpen === baselineOpen) S.explorer.ex.delete(path); else S.explorer.ex.add(path);
|
|
898
|
+
queueExplorer(); fillExplorer();
|
|
899
|
+
}
|
|
900
|
+
// Force a folder OPEN (idempotent) — used after creating/moving so the affected folder is revealed.
|
|
901
|
+
function ensureFolderOpen(path){
|
|
902
|
+
if (!path || folderOpen(path)) return;
|
|
903
|
+
if (S.explorer.mode === 'expanded') S.explorer.ex.delete(path); else S.explorer.ex.add(path);
|
|
904
|
+
queueExplorer();
|
|
905
|
+
}
|
|
906
|
+
// Native hover tooltip with the full name/path — but ONLY when the label is actually
|
|
907
|
+
// truncated (its rendered text is wider than the row can show). Recomputed after every
|
|
908
|
+
// Explorer render and whenever the sidebar width changes, so resizing wider drops the
|
|
909
|
+
// now-redundant tooltips and resizing narrower adds them back.
|
|
910
|
+
function applyTrimTitles(list){
|
|
911
|
+
const root = list || $('#explorerlist'); if (!root) return;
|
|
912
|
+
root.querySelectorAll('.trow').forEach(row => {
|
|
913
|
+
const lbl = row.querySelector('.lbl'); if (!lbl) return;
|
|
914
|
+
const truncated = lbl.scrollWidth - lbl.clientWidth > 1;
|
|
915
|
+
// A truncated name shows its full path on hover; otherwise a git-tinted folder shows what
|
|
916
|
+
// changed (so the colour is backed by a description), and everything else has no tooltip.
|
|
917
|
+
if (truncated) row.title = row.dataset.full || lbl.textContent;
|
|
918
|
+
else if (row.dataset.gsTip) row.title = row.dataset.gsTip;
|
|
919
|
+
else row.removeAttribute('title');
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
// The display label for a file node is its filename — the sidebar is a generic file tree
|
|
923
|
+
// with no case/brief-specific chrome (a file is distinguished by its folder). Search still
|
|
924
|
+
// matches the H1 title via matchFileNode.
|
|
925
|
+
function fileNodeLabel(n){ return n.name; }
|
|
926
|
+
function fileNodeActive(n){
|
|
927
|
+
const r = route();
|
|
928
|
+
if (n.kind === 'case') return r.name === 'case' && r.id === n.id;
|
|
929
|
+
if (n.kind === 'brief') return r.name === 'brief' && r.id === n.id;
|
|
930
|
+
if (n.kind === 'readout') return r.name === 'readout' && r.id === n.id;
|
|
931
|
+
if (n.kind === 'matchup') return r.name === 'matchup' && r.id === n.id;
|
|
932
|
+
return r.name === 'file' && r.id === n.path;
|
|
933
|
+
}
|
|
934
|
+
// Prune the tree to files that satisfy `pred`, keeping their ancestor folders (used for
|
|
935
|
+
// both search and the git diff filter). A file matches search by path (filename + folder
|
|
936
|
+
// names) or H1 — so searching a folder name surfaces the files under it.
|
|
937
|
+
function pruneTree(nodes, pred){
|
|
938
|
+
const out = [];
|
|
939
|
+
for (const n of nodes){
|
|
940
|
+
if (n.type === 'dir'){
|
|
941
|
+
const kids = pruneTree(n.children || [], pred);
|
|
942
|
+
if (kids.length) out.push({ ...n, children: kids });
|
|
943
|
+
} else if (pred(n)){
|
|
944
|
+
out.push(n);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return out;
|
|
948
|
+
}
|
|
949
|
+
function matchFileNode(n, q){
|
|
950
|
+
return `${n.path || ''} ${n.h1 || ''} ${fileNodeLabel(n)}`.toLowerCase().includes(q);
|
|
951
|
+
}
|
|
952
|
+
// ---- briefs (project-scoped landscape reports) ----------------------------
|
|
953
|
+
// A compact label for a brief: the window it covers (e.g. "Apr 4 – Jul 4, 2026").
|
|
954
|
+
function briefLabel(b){
|
|
955
|
+
if (b && b.period_start && b.period_end){
|
|
956
|
+
const fmt = (ts, y) => new Date(ts).toLocaleDateString('en-US', y ? { month:'short', day:'numeric', year:'numeric', timeZone:'UTC' } : { month:'short', day:'numeric', timeZone:'UTC' });
|
|
957
|
+
return `${fmt(b.period_start, false)} – ${fmt(b.period_end, true)}`;
|
|
958
|
+
}
|
|
959
|
+
return 'Brief';
|
|
960
|
+
}
|
|
961
|
+
function readoutLabel(r){ return r ? briefLabel(r).replace(/^Brief$/, 'Readout') : 'Readout'; }
|
|
962
|
+
// ---- matchups (project-scoped single-competitor head-to-heads) ------------
|
|
963
|
+
// A compact label for a matchup: the competitor it sizes us up against (e.g. "Acme Corp").
|
|
964
|
+
function matchupLabel(m){ return (m && m.competitor) ? m.competitor : 'Matchup'; }
|
|
965
|
+
// ---- project switcher (item 8) --------------------------------------------
|
|
966
|
+
function projMenu(e){
|
|
967
|
+
const btn = e.currentTarget.getBoundingClientRect();
|
|
968
|
+
const cur = S.project && S.project.id;
|
|
969
|
+
const list = S.projects.slice();
|
|
970
|
+
if (S.project && !list.some(p => p.id === cur)) list.unshift(S.project); // always show the current one
|
|
971
|
+
// A vault whose folder is gone can't be switched to — its row IS the Remove action (it's never
|
|
972
|
+
// hidden). A live vault switches on click; every live vault EXCEPT the current one also carries a
|
|
973
|
+
// hover/focus-revealed trailing Remove (you switch away before forgetting the one you're viewing).
|
|
974
|
+
const items = list.map(p => {
|
|
975
|
+
if (p.exists === false) return { label: `${p.name} — missing`, icon: 'trash', act: () => removeVault(p) };
|
|
976
|
+
const isCur = p.id === cur;
|
|
977
|
+
const item = { label: p.name, icon: isCur ? 'check' : null, on: isCur, act: () => switchProject(p.id) };
|
|
978
|
+
if (!isCur) item.trail = { icon: 'trash', danger: true, label: `Remove ${p.name} from the vault list`, act: () => removeVault(p) };
|
|
979
|
+
return item;
|
|
980
|
+
});
|
|
981
|
+
if (items.length) items.push({ sep: true });
|
|
982
|
+
items.push({ label: 'Open folder…', icon: 'folder', act: () => openFolderPrompt() });
|
|
983
|
+
openMenu(btn.left, btn.bottom + 4, items, { minWidth: btn.width });
|
|
984
|
+
}
|
|
985
|
+
// Open a folder as a vault. A browser can't pick a
|
|
986
|
+
// server-side folder, so the daemon serves a directory browser (folderDialog) the user drills
|
|
987
|
+
// through; the picked absolute path is registered + switched to.
|
|
988
|
+
async function openFolderPrompt(){
|
|
989
|
+
const start = S.project && S.project.dir ? S.project.dir.replace(/[/\\][^/\\]*[/\\]?$/, '') : null; // start near the current vault
|
|
990
|
+
const dir = await folderDialog(start);
|
|
991
|
+
if (dir == null) return;
|
|
992
|
+
const path = String(dir).trim(); if (!path) return;
|
|
993
|
+
flushSave();
|
|
994
|
+
await flushConfig();
|
|
995
|
+
await TEL.flush();
|
|
996
|
+
const r = await api('POST', '/api/open', { dir: path });
|
|
997
|
+
if (!r || r.error){
|
|
998
|
+
showToast(r && r.error === 'not found' ? 'That folder no longer exists.'
|
|
999
|
+
: r && r.error === 'unsupported' ? `This build can’t open other folders — restart ${SERVE_CMD}.`
|
|
1000
|
+
: 'Could not open that folder.');
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
S.tabs = []; S.activeTab = -1; S.query = '';
|
|
1004
|
+
await boot(); // the daemon already switched to the opened vault
|
|
1005
|
+
}
|
|
1006
|
+
// Forget a vault from the switcher list (never deletes anything on disk — the folder, its files,
|
|
1007
|
+
// and its `.sonorance/` identity/state all stay, so re-adding it later restores the same vault).
|
|
1008
|
+
async function removeVault(p){
|
|
1009
|
+
const ok = await confirmDialog({
|
|
1010
|
+
title: 'Remove vault?',
|
|
1011
|
+
body: `Remove <b>${esc(p.name)}</b> from the vault list? This only forgets it here — it never deletes the folder or anything inside it, and you can add it back anytime.`,
|
|
1012
|
+
confirmLabel: 'Remove',
|
|
1013
|
+
});
|
|
1014
|
+
if (!ok) return;
|
|
1015
|
+
await api('POST', '/api/remove', { id: p.id });
|
|
1016
|
+
await boot();
|
|
1017
|
+
}
|
|
1018
|
+
async function switchProject(id){
|
|
1019
|
+
if (S.project && id === S.project.id) return;
|
|
1020
|
+
flushSave(); // persist any pending edit before leaving the project
|
|
1021
|
+
await flushConfig(); // persist buffered config (Explorer state) for THIS project first
|
|
1022
|
+
await TEL.flush(); // old-project events must arrive before the server changes telemetry context
|
|
1023
|
+
try { await api('POST', '/api/switch', { id }); } catch {} // mark the target last-active on the server
|
|
1024
|
+
S.tabs = []; S.activeTab = -1; S.query = '';
|
|
1025
|
+
history.pushState({}, '', '/p/' + encodeURIComponent(id) + '/'); // the URL carries the project; boot reads it
|
|
1026
|
+
await boot(); // reloads /api/state for the new project (its own tabs/state)
|
|
1027
|
+
}
|
|
1028
|
+
// ---- tabs (item 7) ---------------------------------------------------------
|
|
1029
|
+
const tabRoute = (t) => t.kind === 'inbox' ? '/inbox' : t.kind === 'settings' ? '/settings' : t.kind === 'feedback' ? '/feedback' : t.kind === 'file' ? '/file/' + encFilePath(t.id) : (t.kind === 'brief' ? '/brief/' : t.kind === 'readout' ? '/readout/' : t.kind === 'matchup' ? '/matchup/' : '/case/') + t.id;
|
|
1030
|
+
const baseName = (p) => String(p || '').split('/').pop() || 'File';
|
|
1031
|
+
function tabTitle(t){
|
|
1032
|
+
if (t.kind === 'inbox') return 'Inbox';
|
|
1033
|
+
if (t.kind === 'settings') return 'Settings';
|
|
1034
|
+
if (t.kind === 'feedback') return 'Feedback';
|
|
1035
|
+
if (t.kind === 'file') return baseName(t.id);
|
|
1036
|
+
if (t.kind === 'brief'){ const b = S.briefs.find(x => x.id === t.id); return b ? briefLabel(b) : 'Brief'; }
|
|
1037
|
+
if (t.kind === 'readout'){ const r = S.readouts.find(x => x.id === t.id); return r ? readoutLabel(r) : 'Readout'; }
|
|
1038
|
+
if (t.kind === 'matchup'){ const m = S.matchups.find(x => x.id === t.id); return m ? matchupLabel(m) : 'Matchup'; }
|
|
1039
|
+
const s = S.cases.find(x => x.id === t.id); return s ? (s.title || 'Untitled') : 'Case';
|
|
1040
|
+
}
|
|
1041
|
+
function activeSpec(){ const t = S.tabs[S.activeTab]; return t ? { kind: t.kind, id: t.id || null } : null; }
|
|
1042
|
+
function persistTabs(){ TEL.gauge('open_tabs', S.tabs.length); api('POST', '/api/tabs', { tabs: S.tabs.map(t => ({ kind: t.kind, id: t.id || null })), active: activeSpec() }).catch(() => {}); }
|
|
1043
|
+
function renderTabs(){
|
|
1044
|
+
const bar = $('#tabs'); if (!bar) return;
|
|
1045
|
+
// Only fixed utility tabs carry icons. Content tabs
|
|
1046
|
+
// (file / case / brief) are named by their title, so an icon is just clutter in a dense strip.
|
|
1047
|
+
const tabIco = (t) => t.kind === 'inbox' ? iconEl(ICON.inbox) : t.kind === 'settings' ? iconEl(ICON.settings) : t.kind === 'feedback' ? iconEl(ICON.feedback) : '';
|
|
1048
|
+
bar.innerHTML = S.tabs.map((t, i) =>
|
|
1049
|
+
`<div class="tab ${i===S.activeTab?'active':''} ${t.kind==='brief'?'brief':''} ${t.kind==='readout'?'readout':''} ${t.kind==='matchup'?'matchup':''} ${t.kind==='file'?'file':''}" data-tab="${i}" id="tab-${i}"
|
|
1050
|
+
role="tab" aria-selected="${i===S.activeTab}" aria-controls="main" tabindex="${i===S.activeTab?0:-1}"
|
|
1051
|
+
title="${esc(tabTitle(t))}">
|
|
1052
|
+
<span class="tab-ico">${tabIco(t)}</span>
|
|
1053
|
+
<span class="tab-label">${esc(tabTitle(t))}</span>
|
|
1054
|
+
<button class="tab-close" data-close="${i}" aria-label="Close ${esc(tabTitle(t))}" title="Close" tabindex="-1">${iconEl(ICON.x)}</button>
|
|
1055
|
+
</div>`).join('');
|
|
1056
|
+
bar.querySelectorAll('.tab').forEach(el => {
|
|
1057
|
+
const idx = +el.dataset.tab;
|
|
1058
|
+
el.addEventListener('mousedown', (e) => { if (e.target.closest('.tab-close')) return; if (e.button === 1){ e.preventDefault(); closeTab(idx); return; } if (e.button === 0) activateTab(idx); });
|
|
1059
|
+
el.addEventListener('contextmenu', (e) => { e.preventDefault(); tabMenu(e, idx); });
|
|
1060
|
+
el.addEventListener('keydown', (e) => onTabKeydown(e, idx));
|
|
1061
|
+
el.querySelector('.tab-close').addEventListener('click', (e) => { e.stopPropagation(); closeTab(idx); });
|
|
1062
|
+
});
|
|
1063
|
+
updateTabMark();
|
|
1064
|
+
updateTabScroll();
|
|
1065
|
+
scrollActiveTabIntoView();
|
|
1066
|
+
}
|
|
1067
|
+
// ---- tab-strip overflow scrolling (chevrons, not a system scrollbar) -------
|
|
1068
|
+
// When more tabs are open than fit, each tab keeps its min width and the strip scrolls. The two
|
|
1069
|
+
// chevrons appear only then; each dims at the scroll end it points past. The active tab is kept
|
|
1070
|
+
// in view so keyboard/Explorer navigation never lands on an off-screen tab.
|
|
1071
|
+
function updateTabScroll(){
|
|
1072
|
+
const wrap = $('#tabs'), L = $('#tabscrollL'), R = $('#tabscrollR');
|
|
1073
|
+
if (!wrap || !L || !R) return;
|
|
1074
|
+
const over = wrap.scrollWidth - wrap.clientWidth > 1;
|
|
1075
|
+
L.classList.toggle('show', over); R.classList.toggle('show', over);
|
|
1076
|
+
if (over){
|
|
1077
|
+
L.disabled = wrap.scrollLeft <= 1;
|
|
1078
|
+
R.disabled = wrap.scrollLeft + wrap.clientWidth >= wrap.scrollWidth - 1;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function scrollActiveTabIntoView(){
|
|
1082
|
+
const wrap = $('#tabs'); if (!wrap) return;
|
|
1083
|
+
if (wrap.scrollWidth - wrap.clientWidth <= 1) return; // nothing to scroll (also: no layout in headless)
|
|
1084
|
+
const el = document.getElementById('tab-' + S.activeTab);
|
|
1085
|
+
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
|
1086
|
+
}
|
|
1087
|
+
function nudgeTabs(dir){
|
|
1088
|
+
const wrap = $('#tabs'); if (!wrap) return;
|
|
1089
|
+
wrap.scrollBy({ left: dir * Math.max(120, wrap.clientWidth * 0.8), behavior: 'smooth' });
|
|
1090
|
+
}
|
|
1091
|
+
// The semi-translucent brand wordmark, centred in the tab bar. It names the injected product
|
|
1092
|
+
// (Sonorance standalone, Deliberate when hosted) and is HIDDEN on the empty "home" surface —
|
|
1093
|
+
// there the branded hero canvas carries the name instead, so brand presence is never doubled.
|
|
1094
|
+
function updateTabMark(){
|
|
1095
|
+
const m = $('#tabmark'); if (!m) return;
|
|
1096
|
+
m.textContent = BRAND;
|
|
1097
|
+
// Hidden whenever a branded hero is on screen (the empty Inbox home) — mutual exclusion, so
|
|
1098
|
+
// the product name is never shown twice at once.
|
|
1099
|
+
m.classList.toggle('hide', !!document.querySelector('#main .hero'));
|
|
1100
|
+
measureTabMark();
|
|
1101
|
+
}
|
|
1102
|
+
// Keep the centred wordmark clear of its neighbours: hide it whenever a tab / the + button
|
|
1103
|
+
// (on the left) or the page controls (on the right) come within a margin of it — so it recedes
|
|
1104
|
+
// cleanly as tabs fill rather than crowding or overlapping. Re-measured on tab changes and on
|
|
1105
|
+
// resize. Where there is no layout (headless tests), it stays shown.
|
|
1106
|
+
function measureTabMark(){
|
|
1107
|
+
const m = $('#tabmark'); if (!m || m.classList.contains('hide')) return;
|
|
1108
|
+
const bar = $('#tabbar'); if (!bar) return;
|
|
1109
|
+
m.classList.remove('crowded'); // reveal to measure its true geometry
|
|
1110
|
+
const barR = bar.getBoundingClientRect();
|
|
1111
|
+
if (!barR.width) return; // no layout (headless) → leave it shown
|
|
1112
|
+
const markR = m.getBoundingClientRect();
|
|
1113
|
+
const add = $('#tabadd');
|
|
1114
|
+
const acts = $('#topactions'), glob = $('#topglobal');
|
|
1115
|
+
const leftEdge = add ? add.getBoundingClientRect().right : barR.left;
|
|
1116
|
+
const rc = (acts && acts.getBoundingClientRect().width) ? acts : glob;
|
|
1117
|
+
const rightEdge = rc ? rc.getBoundingClientRect().left : barR.right;
|
|
1118
|
+
const PAD = 24;
|
|
1119
|
+
const crowded = (markR.left - leftEdge < PAD) || (rightEdge - markR.right < PAD);
|
|
1120
|
+
m.classList.toggle('crowded', crowded);
|
|
1121
|
+
}
|
|
1122
|
+
// Keyboard operation of the tab strip (WAI-ARIA tabs pattern): arrows/Home/End move
|
|
1123
|
+
// focus and activate, Enter/Space activate, Delete/Backspace closes the focused tab.
|
|
1124
|
+
function onTabKeydown(e, idx){
|
|
1125
|
+
const n = S.tabs.length; if (!n) return;
|
|
1126
|
+
const go = (j) => { const k = ((j % n) + n) % n; activateTab(k); focusTab(k); };
|
|
1127
|
+
switch (e.key){
|
|
1128
|
+
case 'ArrowRight': case 'ArrowDown': e.preventDefault(); go(idx + 1); break;
|
|
1129
|
+
case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); go(idx - 1); break;
|
|
1130
|
+
case 'Home': e.preventDefault(); go(0); break;
|
|
1131
|
+
case 'End': e.preventDefault(); go(n - 1); break;
|
|
1132
|
+
case 'Enter': case ' ': e.preventDefault(); activateTab(idx); break;
|
|
1133
|
+
case 'Delete': case 'Backspace': {
|
|
1134
|
+
e.preventDefault();
|
|
1135
|
+
closeTab(idx);
|
|
1136
|
+
if (S.tabs.length) focusTab(Math.min(idx, S.tabs.length - 1));
|
|
1137
|
+
else $('#tabadd')?.focus();
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Focus a tab by index after any (async) re-render settles.
|
|
1143
|
+
function focusTab(idx){ setTimeout(() => { const el = $('#tab-' + idx); if (el) el.focus(); }, 0); }
|
|
1144
|
+
function tabMenu(e, idx){
|
|
1145
|
+
openMenu(e.clientX, e.clientY, [
|
|
1146
|
+
{ label: 'Close', icon: 'x', act: () => closeTab(idx) },
|
|
1147
|
+
{ label: 'Close others', act: () => closeOthers(idx) },
|
|
1148
|
+
{ label: 'Close all', act: () => closeAll() },
|
|
1149
|
+
]);
|
|
1150
|
+
}
|
|
1151
|
+
// Open a document. Default: replace the ACTIVE tab in place — one predictable
|
|
1152
|
+
// click, no tab explosion from browsing. `newTab:true` opens (and focuses) a NEW tab. A doc that
|
|
1153
|
+
// is already open is simply focused (no duplicate). The Inbox home is never clobbered — opening a
|
|
1154
|
+
// doc while it's active makes a new tab. (A future `pinned` tab is likewise never replaced.)
|
|
1155
|
+
function openDoc(kind, id, { newTab = false } = {}){
|
|
1156
|
+
const same = S.tabs.findIndex(t => t.kind === kind && t.id === id);
|
|
1157
|
+
if (same >= 0) return activateTab(same); // already open → just focus it
|
|
1158
|
+
// Content-free open signal: a semantic record is an event {kind}; a raw file is an aggregate.
|
|
1159
|
+
if (kind === 'file') TEL.count('files_opened', 1);
|
|
1160
|
+
else if (kind === 'case' || kind === 'brief' || kind === 'readout' || kind === 'matchup') TEL.event('record.opened', { kind });
|
|
1161
|
+
const active = S.tabs[S.activeTab];
|
|
1162
|
+
const replace = !newTab && active && !['inbox', 'settings', 'feedback'].includes(active.kind) && !active.pinned;
|
|
1163
|
+
const spec = { kind, id };
|
|
1164
|
+
if (replace) S.tabs[S.activeTab] = spec;
|
|
1165
|
+
else { S.tabs.push(spec); S.activeTab = S.tabs.length - 1; }
|
|
1166
|
+
persistTabs(); navigate(docRoute(kind, id));
|
|
1167
|
+
}
|
|
1168
|
+
function docRoute(kind, id){ return kind === 'inbox' ? '/inbox' : kind === 'settings' ? '/settings' : kind === 'feedback' ? '/feedback' : kind === 'file' ? '/file/' + encFilePath(id) : '/' + kind + '/' + id; }
|
|
1169
|
+
function openCaseTab(id, opts){ return openDoc('case', id, opts || {}); }
|
|
1170
|
+
function openInboxTab(){ if (S.tabs.findIndex(t => t.kind === 'inbox') < 0){ S.tabs.push({ kind:'inbox' }); persistTabs(); } navigate('/inbox'); }
|
|
1171
|
+
function openSettingsTab(){ if (S.tabs.findIndex(t => t.kind === 'settings') < 0){ S.tabs.push({ kind:'settings' }); persistTabs(); } navigate('/settings'); }
|
|
1172
|
+
function openFeedbackTab(){ if (S.tabs.findIndex(t => t.kind === 'feedback') < 0){ S.tabs.push({ kind:'feedback' }); persistTabs(); } TEL.event('ui.action', { area:'toolbar', action:'feedback-open' }); navigate('/feedback'); }
|
|
1173
|
+
function openBriefTab(id, opts){ return openDoc('brief', id, opts || {}); }
|
|
1174
|
+
function openReadoutTab(id, opts){ return openDoc('readout', id, opts || {}); }
|
|
1175
|
+
function openMatchupTab(id, opts){ return openDoc('matchup', id, opts || {}); }
|
|
1176
|
+
// A plain markdown file (context/*.md and any other .md not backed by a case/brief),
|
|
1177
|
+
// opened from the Explorer's file-tree view. `path` is the project-relative path.
|
|
1178
|
+
function openFileTab(path, opts){ return openDoc('file', path, opts || {}); }
|
|
1179
|
+
function activateTab(idx){ const t = S.tabs[idx]; if (!t) return; S.activeTab = idx; navigate(tabRoute(t)); }
|
|
1180
|
+
function closeTab(idx){
|
|
1181
|
+
const wasActive = idx === S.activeTab;
|
|
1182
|
+
S.tabs.splice(idx, 1);
|
|
1183
|
+
if (idx < S.activeTab) S.activeTab--;
|
|
1184
|
+
if (S.activeTab >= S.tabs.length) S.activeTab = S.tabs.length - 1;
|
|
1185
|
+
persistTabs();
|
|
1186
|
+
if (!S.tabs.length){ S.activeTab = -1; return navigate('/'); }
|
|
1187
|
+
if (wasActive) navigate(tabRoute(S.tabs[S.activeTab])); else renderTabs();
|
|
1188
|
+
}
|
|
1189
|
+
function closeOthers(idx){ const keep = S.tabs[idx]; S.tabs = [keep]; S.activeTab = 0; persistTabs(); navigate(tabRoute(keep)); }
|
|
1190
|
+
function closeAll(){ S.tabs = []; S.activeTab = -1; persistTabs(); navigate('/'); }
|
|
1191
|
+
// Keep the tab bar in sync with the current route: focus the matching tab, else open one.
|
|
1192
|
+
// (Explicit opens go through openDoc; this covers intent-less route changes — deep links,
|
|
1193
|
+
// browser back/forward — where a new tab is the safe, non-destructive default.)
|
|
1194
|
+
function syncTabsToRoute(){
|
|
1195
|
+
const r = route();
|
|
1196
|
+
const spec = r.name === 'inbox' ? { kind:'inbox' } : r.name === 'settings' ? { kind:'settings' } : r.name === 'feedback' ? { kind:'feedback' } : r.name === 'case' ? { kind:'case', id:r.id } : r.name === 'brief' ? { kind:'brief', id:r.id } : r.name === 'readout' ? { kind:'readout', id:r.id } : r.name === 'matchup' ? { kind:'matchup', id:r.id } : r.name === 'file' ? { kind:'file', id:r.id } : null;
|
|
1197
|
+
if (!spec){ if (S.activeTab !== -1){ S.activeTab = -1; persistTabs(); } renderTabs(); return; }
|
|
1198
|
+
const prevActive = S.activeTab;
|
|
1199
|
+
let idx = S.tabs.findIndex(t => t.kind === spec.kind && (['inbox', 'settings', 'feedback'].includes(spec.kind) || t.id === spec.id));
|
|
1200
|
+
if (idx < 0){
|
|
1201
|
+
S.tabs.push(spec); idx = S.tabs.length - 1;
|
|
1202
|
+
S.activeTab = idx; persistTabs();
|
|
1203
|
+
} else {
|
|
1204
|
+
S.activeTab = idx;
|
|
1205
|
+
if (idx !== prevActive) persistTabs(); // remember which tab is focused, per project
|
|
1206
|
+
}
|
|
1207
|
+
renderTabs();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// ---- routing --------------------------------------------------------------
|
|
1211
|
+
// The project slug prefix for in-app URLs (`/p/<slug>`). Every route lives under it so
|
|
1212
|
+
// multiple projects can be open in separate browser tabs at once. Empty until a project loads.
|
|
1213
|
+
function PP(){ const s = S.projectSlug || (S.project && S.project.id); return s ? '/p/' + encodeURIComponent(s) : ''; }
|
|
1214
|
+
// File paths keep their slashes in the URL (`/file/a/b/c.md`) — far prettier than one
|
|
1215
|
+
// percent-encoded blob (`/file/a%2Fb%2Fc.md`) — but each SEGMENT is still encoded so a space,
|
|
1216
|
+
// `#`, `?` or `%` in a name never breaks the path. `decFilePath` is the exact inverse.
|
|
1217
|
+
const encFilePath = (path) => String(path || '').split('/').map(encodeURIComponent).join('/');
|
|
1218
|
+
const decFilePath = (seg) => String(seg || '').split('/').map(s => { try { return decodeURIComponent(s); } catch { return s; } }).join('/');
|
|
1219
|
+
function route(){
|
|
1220
|
+
let p = location.pathname;
|
|
1221
|
+
let project = null;
|
|
1222
|
+
const m = p.match(/^\/p\/([^/]+)(\/.*)?$/); // /p/<slug>/<inner> → strip the project prefix
|
|
1223
|
+
if (m){ project = decodeURIComponent(m[1]); p = m[2] || '/'; }
|
|
1224
|
+
let r;
|
|
1225
|
+
if (p.startsWith('/case/')) r = { name:'case', id: decodeURIComponent(p.slice(6)) };
|
|
1226
|
+
else if (p.startsWith('/brief/')) r = { name:'brief', id: decodeURIComponent(p.slice(7)) };
|
|
1227
|
+
else if (p.startsWith('/readout/')) r = { name:'readout', id: decodeURIComponent(p.slice(9)) };
|
|
1228
|
+
else if (p.startsWith('/matchup/')) r = { name:'matchup', id: decodeURIComponent(p.slice(9)) };
|
|
1229
|
+
else if (p.startsWith('/file/')) r = { name:'file', id: decFilePath(p.slice(6)) };
|
|
1230
|
+
else if (p === '/inbox') r = { name:'inbox' };
|
|
1231
|
+
else if (p === '/settings') r = { name:'settings' };
|
|
1232
|
+
else if (p === '/feedback') r = { name:'feedback' };
|
|
1233
|
+
else r = { name:'home' };
|
|
1234
|
+
r.project = project;
|
|
1235
|
+
return r;
|
|
1236
|
+
}
|
|
1237
|
+
// Route builders return the INNER path (`/inbox`, `/case/x`); navigate() prepends the current
|
|
1238
|
+
// project prefix so callers never have to thread it through.
|
|
1239
|
+
function navigate(path){ const full = path.startsWith('/p/') ? path : (PP() + path); if (location.pathname !== full) history.pushState({}, '', full); render(); }
|
|
1240
|
+
// Back/forward: within a project, re-render; across projects (the URL slug changed), re-boot
|
|
1241
|
+
// so the other project's tabs/tree/state load. Guards against a no-op when nothing changed.
|
|
1242
|
+
window.addEventListener('popstate', () => {
|
|
1243
|
+
const rp = route().project;
|
|
1244
|
+
if (rp && S.project && rp !== S.project.id){ S.projectSlug = rp; S.tabs = []; S.activeTab = -1; S.query = ''; boot(); }
|
|
1245
|
+
else render();
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
let lastTelemetryPage = null;
|
|
1249
|
+
async function render(){
|
|
1250
|
+
const r = route();
|
|
1251
|
+
if (r.name === 'home'){
|
|
1252
|
+
// No standalone empty page: home always resolves to the Inbox (opening its tab if needed).
|
|
1253
|
+
return openInboxTab();
|
|
1254
|
+
}
|
|
1255
|
+
const telemetryPage = ['inbox', 'settings', 'feedback'].includes(r.name) ? r.name : null;
|
|
1256
|
+
if (telemetryPage !== lastTelemetryPage){
|
|
1257
|
+
lastTelemetryPage = telemetryPage;
|
|
1258
|
+
if (telemetryPage) TEL.event('ui.page.viewed', { page:telemetryPage });
|
|
1259
|
+
}
|
|
1260
|
+
renderSidebar();
|
|
1261
|
+
syncTabsToRoute();
|
|
1262
|
+
if (r.name === 'inbox') return renderInbox();
|
|
1263
|
+
if (r.name === 'settings') return renderSettings();
|
|
1264
|
+
if (r.name === 'feedback') return renderFeedback();
|
|
1265
|
+
if (r.name === 'brief') return openBrief(r.id);
|
|
1266
|
+
if (r.name === 'readout') return openReadout(r.id);
|
|
1267
|
+
if (r.name === 'matchup') return openMatchup(r.id);
|
|
1268
|
+
if (r.name === 'file') return openFile(r.id);
|
|
1269
|
+
if (r.name === 'case') return openCase(r.id);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// ---- inbox: a recent-activity overview ------------------------------------
|
|
1273
|
+
// With no manual case states, the inbox is a calm recency view: the most recently
|
|
1274
|
+
// updated cases and the latest briefs — the two things a returning user wants to resume.
|
|
1275
|
+
const caseTs = (s) => s.last_stage_at || s.updated_at || s.created_at || 0;
|
|
1276
|
+
// A short, human status for a case row — derived from its funnel position (no manual states).
|
|
1277
|
+
// Note: 'done' means the analysis reached launch — NOT that a prototype exists. The prototype is
|
|
1278
|
+
// a recomputable companion detected by file presence (S.proto.exists), never a case state.
|
|
1279
|
+
function caseStateLabel(s){
|
|
1280
|
+
switch (s.state) {
|
|
1281
|
+
case 'done': return 'Analysis complete';
|
|
1282
|
+
case 'error': return 'A stage needs a re-run';
|
|
1283
|
+
case 'new': return 'Draft — not yet analysed';
|
|
1284
|
+
default: return 'Being generated…'; // score / shape / launch — mid-funnel
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
// The inbox is the ONE Deliberate-aware surface, and it is driven by the declarative kind
|
|
1288
|
+
// registry (S.kinds from /api/state): each kind with an `inbox` block contributes a section,
|
|
1289
|
+
// pulling recent items from its `source` collection and rendering them with a small generic
|
|
1290
|
+
// widget vocabulary (badge: 'score'|'brief', subtitle: a field | 'const:<text>'). Adding a
|
|
1291
|
+
// document type = adding a registry entry; no bespoke inbox code.
|
|
1292
|
+
function inboxTs(x){ return x.last_stage_at || x.updated_at || x.created_at || 0; }
|
|
1293
|
+
function inboxLabel(source, x){ return source === 'briefs' ? briefLabel(x) : source === 'readouts' ? readoutLabel(x) : source === 'matchups' ? matchupLabel(x) : (x.title || 'Untitled'); }
|
|
1294
|
+
function inboxSubtitle(spec, x){
|
|
1295
|
+
if (!spec) return '';
|
|
1296
|
+
if (spec.startsWith('const:')) return esc(spec.slice(6));
|
|
1297
|
+
if (spec === 'state') return esc(caseStateLabel(x));
|
|
1298
|
+
return esc(String(x[spec] ?? ''));
|
|
1299
|
+
}
|
|
1300
|
+
function inboxBadge(kind, x){
|
|
1301
|
+
if (kind === 'score'){
|
|
1302
|
+
const v = x.score != null ? verdict(x.score) : null;
|
|
1303
|
+
return v
|
|
1304
|
+
? `<span class="pill ${v.cls}" title="${v.word} · ${v.label}" aria-label="Verdict ${v.word}, score ${v.label}">${v.label}</span>`
|
|
1305
|
+
: `<span class="pill neutral" title="Not scored yet" aria-label="Not scored yet">—</span>`;
|
|
1306
|
+
}
|
|
1307
|
+
if (kind === 'brief') return `<span class="pill brief" title="Landscape brief" aria-label="Landscape brief">${iconEl(ICON.brief)}</span>`;
|
|
1308
|
+
if (kind === 'readout') return `<span class="pill readout" title="Product readout" aria-label="Product readout">${iconEl(ICON.readout)}</span>`;
|
|
1309
|
+
if (kind === 'matchup') return `<span class="pill matchup" title="Competitor matchup" aria-label="Competitor matchup">${iconEl(ICON.matchup)}</span>`;
|
|
1310
|
+
return '';
|
|
1311
|
+
}
|
|
1312
|
+
function renderSettings(){
|
|
1313
|
+
setTopActions('');
|
|
1314
|
+
const pref = themePref();
|
|
1315
|
+
const themes = [
|
|
1316
|
+
{ value:'', label:'System', icon:'auto', note:'Follow this device' },
|
|
1317
|
+
{ value:'light', label:'Light', icon:'sun', note:'Always light' },
|
|
1318
|
+
{ value:'dark', label:'Dark', icon:'moon', note:'Always dark' },
|
|
1319
|
+
];
|
|
1320
|
+
renderMain(`<div class="settings-wrap"><section class="settings-page" id="settings-page">
|
|
1321
|
+
<header class="settings-head"><h1>Settings</h1><p>Preferences apply across all projects on this device.</p></header>
|
|
1322
|
+
<section class="settings-section" aria-labelledby="appearance-title">
|
|
1323
|
+
<div class="settings-copy"><h2 id="appearance-title">Appearance</h2><p>Choose how Sonorance looks.</p></div>
|
|
1324
|
+
<div class="theme-options" role="radiogroup" aria-label="Theme">
|
|
1325
|
+
${themes.map((t) => `<button type="button" class="theme-option ${pref === t.value ? 'selected' : ''}" role="radio" aria-checked="${pref === t.value}" tabindex="${pref === t.value ? 0 : -1}" data-theme-value="${t.value}">
|
|
1326
|
+
<span class="theme-icon">${iconEl(ICON[t.icon])}</span><span><strong>${t.label}</strong><small>${t.note}</small></span>
|
|
1327
|
+
</button>`).join('')}
|
|
1328
|
+
</div>
|
|
1329
|
+
</section>
|
|
1330
|
+
<section class="settings-section" aria-labelledby="privacy-title">
|
|
1331
|
+
<div class="settings-copy"><h2 id="privacy-title">Privacy</h2><p>Privacy is foundational to Sonorance. We collect only a small amount of anonymous, content-free data—feature usage, performance, and redacted error signals—so we can understand what helps, fix issues, and keep improving the product.</p></div>
|
|
1332
|
+
<label class="settings-toggle">
|
|
1333
|
+
<span><strong>Share anonymous usage data</strong><small>Sonorance never logs your work—not even project, file, or folder names. Document contents, paths, prompts, and completions always stay private.</small></span>
|
|
1334
|
+
<input id="telemetry-setting" type="checkbox" ${S.telemetry && S.telemetry.enabled === false ? '' : 'checked'}>
|
|
1335
|
+
<i aria-hidden="true"></i>
|
|
1336
|
+
</label>
|
|
1337
|
+
</section>
|
|
1338
|
+
</section></div>`);
|
|
1339
|
+
const themeButtons = [...document.querySelectorAll('[data-theme-value]')];
|
|
1340
|
+
themeButtons.forEach((btn, index) => btn.addEventListener('click', () => {
|
|
1341
|
+
const value = btn.dataset.themeValue;
|
|
1342
|
+
applyTheme(value);
|
|
1343
|
+
themeButtons.forEach((option) => { option.tabIndex = option === btn ? 0 : -1; });
|
|
1344
|
+
TEL.event('ui.action', { area:'settings', action:'theme-change', value:value || 'system' });
|
|
1345
|
+
}));
|
|
1346
|
+
themeButtons.forEach((btn, index) => btn.addEventListener('keydown', (event) => {
|
|
1347
|
+
let next = null;
|
|
1348
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (index + 1) % themeButtons.length;
|
|
1349
|
+
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (index - 1 + themeButtons.length) % themeButtons.length;
|
|
1350
|
+
else if (event.key === 'Home') next = 0;
|
|
1351
|
+
else if (event.key === 'End') next = themeButtons.length - 1;
|
|
1352
|
+
if (next == null) return;
|
|
1353
|
+
event.preventDefault();
|
|
1354
|
+
themeButtons[next].click();
|
|
1355
|
+
themeButtons[next].focus();
|
|
1356
|
+
}));
|
|
1357
|
+
const telemetry = $('#telemetry-setting');
|
|
1358
|
+
telemetry.addEventListener('change', async () => {
|
|
1359
|
+
const requested = telemetry.checked;
|
|
1360
|
+
telemetry.disabled = true;
|
|
1361
|
+
const enabled = await setTelemetryConsent(requested);
|
|
1362
|
+
telemetry.checked = enabled;
|
|
1363
|
+
telemetry.disabled = false;
|
|
1364
|
+
if (enabled !== requested) toast('Could not update telemetry preference.');
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
function renderInbox(){
|
|
1368
|
+
setTopActions('');
|
|
1369
|
+
const hasKinds = (S.kinds || []).some(k => k.inbox);
|
|
1370
|
+
const sections = (S.kinds || []).filter(k => k.inbox).map(k => ({
|
|
1371
|
+
spec: k.inbox,
|
|
1372
|
+
items: [...(S[k.inbox.source] || [])].sort((a, b) => inboxTs(b) - inboxTs(a)).slice(0, k.inbox.limit || 5),
|
|
1373
|
+
}));
|
|
1374
|
+
const total = sections.reduce((n, s) => n + s.items.length, 0);
|
|
1375
|
+
if (!total){
|
|
1376
|
+
// First run should offer an explicit next step, not just a hint. The in-app action everywhere
|
|
1377
|
+
// is "New Markdown file"; on a Deliberate host the Case is created in the coding agent, so its
|
|
1378
|
+
// command is offered as a one-click COPY beside the button (the real path), never the only one.
|
|
1379
|
+
const newFileBtn = `<button type="button" class="tbtn" data-hero-newfile>${iconEl(ICON.newfile)}New Markdown file</button>`;
|
|
1380
|
+
const caseCopy = hasKinds
|
|
1381
|
+
? `<button type="button" class="tbtn ghost" data-hero-copy='/deliberate case "<idea>"' title="Copy the command to start a Case in your coding agent">${iconEl(ICON.copy)}Copy <code>/deliberate case</code></button>`
|
|
1382
|
+
: '';
|
|
1383
|
+
const out = renderMain(brandedEmpty(
|
|
1384
|
+
hasKinds ? 'Start a Case, or draft a note.' : 'A calm home for your Markdown.',
|
|
1385
|
+
hasKinds
|
|
1386
|
+
? 'A Case runs the frame → score → shape pipeline in your coding agent. Or draft any Markdown here.'
|
|
1387
|
+
: 'Create a note below, or open an existing file from the Explorer.',
|
|
1388
|
+
`${newFileBtn}${caseCopy}`));
|
|
1389
|
+
const nf = $('#main [data-hero-newfile]'); if (nf) nf.addEventListener('click', () => promptNewFile(S.treeBase || ''));
|
|
1390
|
+
const cc = $('#main [data-hero-copy]');
|
|
1391
|
+
if (cc) cc.addEventListener('click', async () => {
|
|
1392
|
+
try { await navigator.clipboard.writeText(cc.dataset.heroCopy); showToast('Command copied to clipboard.'); }
|
|
1393
|
+
catch { showToast('Copy failed — the command is: ' + cc.dataset.heroCopy); }
|
|
1394
|
+
});
|
|
1395
|
+
return out;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const row = (spec, x) => {
|
|
1399
|
+
const openKind = spec.source === 'briefs' ? 'brief' : spec.source === 'readouts' ? 'readout' : spec.source === 'matchups' ? 'matchup' : 'case';
|
|
1400
|
+
return `<button class="qrow" data-open-kind="${openKind}" data-open-id="${x.id}">
|
|
1401
|
+
${inboxBadge(spec.badge, x)}
|
|
1402
|
+
<span class="qmain"><span class="qt">${esc(inboxLabel(spec.source, x))}</span><span class="qr">${inboxSubtitle(spec.subtitle, x)}</span></span>
|
|
1403
|
+
<span class="qtime">${esc(relTime(inboxTs(x)))}</span>
|
|
1404
|
+
${iconEl(ICON.chevron).replace('class="ico"','class="ico go"')}
|
|
1405
|
+
</button>`;
|
|
1406
|
+
};
|
|
1407
|
+
const section = (s) => s.items.length ? `<h2 class="qsec">${esc(s.spec.section)}</h2>${s.items.map(x => row(s.spec, x)).join('')}` : '';
|
|
1408
|
+
renderMain(
|
|
1409
|
+
`<div class="qwrap"><div class="queue">
|
|
1410
|
+
<h1>Inbox</h1><p class="sub">Your most recently updated cases and briefs.</p>
|
|
1411
|
+
${sections.map(section).join('')}
|
|
1412
|
+
</div></div>`);
|
|
1413
|
+
$('#main').querySelectorAll('[data-open-kind]').forEach(b => b.addEventListener('click', () => {
|
|
1414
|
+
if (b.dataset.openKind === 'brief') openBriefTab(b.dataset.openId); else if (b.dataset.openKind === 'readout') openReadoutTab(b.dataset.openId); else if (b.dataset.openKind === 'matchup') openMatchupTab(b.dataset.openId); else openCaseTab(b.dataset.openId);
|
|
1415
|
+
}));
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// ---- case detail ----------------------------------------------------------
|
|
1419
|
+
// The case currently open (from the state list), or null.
|
|
1420
|
+
function currentCase(){ return S.cases.find(s => s.id === S.caseId) || null; }
|
|
1421
|
+
// A case is "being generated" while its analysis is mid-funnel — frame is done but the
|
|
1422
|
+
// agent is still appending stages (state is a pre-gate stage: score / shape / launch). Its
|
|
1423
|
+
// record is then read-only in the workbench so a hand-edit can't collide with the write in
|
|
1424
|
+
// flight. A brand-new draft ('new') and a complete case ('done') edit freely.
|
|
1425
|
+
const GENERATING_STATES = new Set(['score', 'shape', 'launch']);
|
|
1426
|
+
function caseGenerating(){ const c = currentCase(); return !!(c && GENERATING_STATES.has(c.state)); }
|
|
1427
|
+
// Any record surface that must not be edited in place: a brief, a plain file, or a
|
|
1428
|
+
// still-generating case. Gates the live-editor caret/source reveal + autosave.
|
|
1429
|
+
function docReadOnly(){ return !!(S.brief || S.readout || S.matchup || caseGenerating()); }
|
|
1430
|
+
async function openCase(id){
|
|
1431
|
+
flushSave(); // persist the outgoing case's pending edit before we switch
|
|
1432
|
+
S.caseId = id; S.dirty = false; S.saving = false; S.proto = null; S.comments = []; S.cNavIdx = -1; S.recFile = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null;
|
|
1433
|
+
renderSidebar();
|
|
1434
|
+
let rec;
|
|
1435
|
+
try { rec = await api('GET', '/api/record?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
|
|
1436
|
+
if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Case not found', 'This case has no record here.')); }
|
|
1437
|
+
S.rec = rec; S.isGit = !!rec.isGit; S.proto = rec.proto || null; S.recFile = rec.file || null;
|
|
1438
|
+
// Always segment the real file text so the record's frontmatter (id/state/gate/…) and
|
|
1439
|
+
// title round-trip byte-for-byte — even a not-yet-analysed case IS an analysis.md now, so
|
|
1440
|
+
// caseing an empty block would clobber that metadata on the first autosave.
|
|
1441
|
+
S.items = rec.text ? segment(rec.text) : [{ kind:'block', lines:[''] }];
|
|
1442
|
+
// One combined view: the record reads as rendered Markdown and reveals a block's
|
|
1443
|
+
// raw source in place when the caret enters it. Diff is the
|
|
1444
|
+
// only other surface (git only).
|
|
1445
|
+
S.mode = defaultRecordMode();
|
|
1446
|
+
paintCase();
|
|
1447
|
+
loadComments(rec.file);
|
|
1448
|
+
}
|
|
1449
|
+
// Load a file's persisted in-record comments (annotations the reader left, anchored to
|
|
1450
|
+
// spans of the file). Works for any file — a case record or a brief. Only OPEN ones show.
|
|
1451
|
+
async function loadComments(file){
|
|
1452
|
+
if (!file) return;
|
|
1453
|
+
const t = await api('GET', '/api/comments?file=' + encodeURIComponent(file));
|
|
1454
|
+
if (S.recFile !== file) return; // the user moved on while we fetched
|
|
1455
|
+
if (t && t.error === 'endpoint-missing'){ maybeStale(); return; } // stale server — warn
|
|
1456
|
+
if (!t || t.error) return;
|
|
1457
|
+
S.comments = t.comments || [];
|
|
1458
|
+
renderComments();
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ---- brief detail (read-only landscape report) ----------------------------
|
|
1462
|
+
// A brief is a project-scoped landscape report, not a case record: it reads like one
|
|
1463
|
+
// (rendered Markdown + a table-of-contents rail) but has no verdict, prototype, diff,
|
|
1464
|
+
// or editing — it's regenerated in the coding agent, and the file is the user's to edit.
|
|
1465
|
+
const stripFrontmatter = (text) => { const m = String(text || '').match(/^---\n[\s\S]*?\n---\n?/); return m ? text.slice(m[0].length) : text; };
|
|
1466
|
+
async function openBrief(id){
|
|
1467
|
+
flushSave(); // persist any outgoing case edit before switching
|
|
1468
|
+
S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
|
|
1469
|
+
renderSidebar();
|
|
1470
|
+
let rec;
|
|
1471
|
+
try { rec = await api('GET', '/api/brief?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
|
|
1472
|
+
const r = route(); if (r.name === 'brief' && r.id !== id) return; // the user moved on while we fetched
|
|
1473
|
+
if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Brief not found', 'This brief has no record here.')); }
|
|
1474
|
+
S.brief = { id, ...rec }; S.recFile = rec.file || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
|
|
1475
|
+
paintBrief();
|
|
1476
|
+
loadComments(rec.file);
|
|
1477
|
+
}
|
|
1478
|
+
// ---- generic markdown file (editable live-preview) ------------------------
|
|
1479
|
+
// Any `.md` opened from the Explorer's file tree. It edits like a case record — the same
|
|
1480
|
+
// live-preview surface + autosave — but keyed by PATH (saved via POST /api/file), with its
|
|
1481
|
+
// YAML frontmatter preserved as a hidden block. Standalone, EVERY document is one of these.
|
|
1482
|
+
async function openFile(path){
|
|
1483
|
+
flushSave();
|
|
1484
|
+
S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
|
|
1485
|
+
S.dirty = false; S.saving = false;
|
|
1486
|
+
renderSidebar();
|
|
1487
|
+
let rec;
|
|
1488
|
+
try { rec = await api('GET', '/api/file?path=' + encodeURIComponent(path)); } catch { rec = { error:'offline' }; }
|
|
1489
|
+
const r = route(); if (r.name === 'file' && r.id !== path) return; // moved on while fetching
|
|
1490
|
+
if (!rec || rec.error){
|
|
1491
|
+
// A deleted file (present in the git change set as removed) no longer exists on disk, so it
|
|
1492
|
+
// can't be read/edited — but its removal is still reviewable. Open it straight into a
|
|
1493
|
+
// read-only Diff instead of a dead "not found" (reached from the Explorer's diff mode).
|
|
1494
|
+
if (isDeletedChange(path)){
|
|
1495
|
+
S.file = { path, text: '', deleted: true }; S.recFile = path; S.isGit = true; S.mode = 'diff';
|
|
1496
|
+
S.items = [{ kind:'block', lines:[''] }];
|
|
1497
|
+
paintFile();
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
setTopActions(''); return renderMain(emptyState('File not found', 'This file isn’t in the folder.'));
|
|
1501
|
+
}
|
|
1502
|
+
S.file = { path, ...rec }; S.recFile = rec.path || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
|
|
1503
|
+
S.items = rec.text ? segment(rec.text) : [{ kind:'block', lines:[''] }];
|
|
1504
|
+
paintFile();
|
|
1505
|
+
loadComments(rec.path);
|
|
1506
|
+
}
|
|
1507
|
+
function fileControls(){ return `${commentNav()}`; }
|
|
1508
|
+
function paintFile(){
|
|
1509
|
+
if (S.file && S.file.deleted) S.mode = 'diff'; // a removed file is diff-only (nothing to edit)
|
|
1510
|
+
renderMain(recordBodyMarkup());
|
|
1511
|
+
setTopActions(fileControls());
|
|
1512
|
+
wireCaseControls();
|
|
1513
|
+
renderLive(); buildToc(); renderComments();
|
|
1514
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
1515
|
+
updateSaveDot();
|
|
1516
|
+
}
|
|
1517
|
+
function briefControls(){
|
|
1518
|
+
// A brief needs no tab-bar badge: it's read-only and its first paragraph already names the
|
|
1519
|
+
// period it covers, so a "Brief · <dates>" pill would just be redundant chrome. The comment
|
|
1520
|
+
// navigator lives in the canvas command bar (#docdiff), so there are no tab-bar controls.
|
|
1521
|
+
return '';
|
|
1522
|
+
}
|
|
1523
|
+
function paintBrief(){
|
|
1524
|
+
renderMain(recordBodyMarkup());
|
|
1525
|
+
setTopActions(briefControls());
|
|
1526
|
+
wireCaseControls(); // wires the comments prev/next navigator (no-ops for absent controls)
|
|
1527
|
+
const c = $('#canvas'); if (!c) return;
|
|
1528
|
+
const art = document.createElement('article'); art.className = 'doc'; art.id = 'doc';
|
|
1529
|
+
const text = S.brief && S.brief.exists ? stripFrontmatter(S.brief.text) : '';
|
|
1530
|
+
const empty = !text.trim();
|
|
1531
|
+
art.innerHTML = empty ? `<div class="banner">${iconEl(ICON.brief)}<div>This brief is empty. Regenerate it in your coding agent with <code>/deliberate brief</code>.</div></div>`
|
|
1532
|
+
: renderMarkdown(text);
|
|
1533
|
+
// A brief is read-only here (regenerated by the agent, not edited in place), so its task
|
|
1534
|
+
// checkboxes are shown for reference but disabled — there is no source model to toggle.
|
|
1535
|
+
art.querySelectorAll('.md-taskbox').forEach(b => { b.disabled = true; b.style.cursor = 'default'; });
|
|
1536
|
+
c.innerHTML = ''; c.appendChild(art);
|
|
1537
|
+
reindexHeadings(art);
|
|
1538
|
+
buildToc();
|
|
1539
|
+
if (!empty){ wireCommentAffordances(art); renderComments(); } // briefs are annotatable too
|
|
1540
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
1541
|
+
}
|
|
1542
|
+
// ---- Product Readout detail (read-only metrics + customer evidence) -------
|
|
1543
|
+
async function openReadout(id){
|
|
1544
|
+
flushSave();
|
|
1545
|
+
S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
|
|
1546
|
+
renderSidebar();
|
|
1547
|
+
let rec;
|
|
1548
|
+
try { rec = await api('GET', '/api/readout?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
|
|
1549
|
+
const r = route(); if (r.name === 'readout' && r.id !== id) return;
|
|
1550
|
+
if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Readout not found', 'This Product Readout has no record here.')); }
|
|
1551
|
+
S.readout = { id, ...rec }; S.recFile = rec.file || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
|
|
1552
|
+
paintReadout();
|
|
1553
|
+
loadComments(rec.file);
|
|
1554
|
+
}
|
|
1555
|
+
function readoutControls(){ return ''; }
|
|
1556
|
+
function paintReadout(){
|
|
1557
|
+
renderMain(recordBodyMarkup());
|
|
1558
|
+
setTopActions(readoutControls());
|
|
1559
|
+
wireCaseControls();
|
|
1560
|
+
const c = $('#canvas'); if (!c) return;
|
|
1561
|
+
const art = document.createElement('article'); art.className = 'doc'; art.id = 'doc';
|
|
1562
|
+
const text = S.readout && S.readout.exists ? stripFrontmatter(S.readout.text) : '';
|
|
1563
|
+
const empty = !text.trim();
|
|
1564
|
+
art.innerHTML = empty ? `<div class="banner">${iconEl(ICON.readout)}<div>This readout is empty. Regenerate it in your coding agent with <code>/deliberate readout</code>.</div></div>`
|
|
1565
|
+
: renderMarkdown(text);
|
|
1566
|
+
art.querySelectorAll('.md-taskbox').forEach(b => { b.disabled = true; b.style.cursor = 'default'; });
|
|
1567
|
+
c.innerHTML = ''; c.appendChild(art);
|
|
1568
|
+
reindexHeadings(art);
|
|
1569
|
+
buildToc();
|
|
1570
|
+
if (!empty){ wireCommentAffordances(art); renderComments(); }
|
|
1571
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
1572
|
+
}
|
|
1573
|
+
// ---- matchup detail (read-only single-competitor head-to-head) ------------
|
|
1574
|
+
// A matchup reads exactly like a brief — a project-scoped, read-only rendered report with a
|
|
1575
|
+
// table-of-contents rail and no editing (it's regenerated in the coding agent, refresh-in-place
|
|
1576
|
+
// per competitor). It shares the brief's reading surface + comment machinery; only its fetch
|
|
1577
|
+
// key (competitor-keyed id), empty-state copy and icon differ.
|
|
1578
|
+
async function openMatchup(id){
|
|
1579
|
+
flushSave(); // persist any outgoing case edit before switching
|
|
1580
|
+
S.caseId = null; S.brief = null; S.readout = null; S.matchup = null; S.file = null; S.mode = 'doc'; S.comments = []; S.cNavIdx = -1; S.recFile = null;
|
|
1581
|
+
renderSidebar();
|
|
1582
|
+
let rec;
|
|
1583
|
+
try { rec = await api('GET', '/api/matchup?id=' + encodeURIComponent(id)); } catch { rec = { error:'offline' }; }
|
|
1584
|
+
const r = route(); if (r.name === 'matchup' && r.id !== id) return; // the user moved on while we fetched
|
|
1585
|
+
if (!rec || rec.error){ setTopActions(''); return renderMain(emptyState('Matchup not found', 'This matchup has no record here.')); }
|
|
1586
|
+
S.matchup = { id, ...rec }; S.recFile = rec.file || null; S.isGit = !!rec.isGit; S.mode = defaultRecordMode();
|
|
1587
|
+
paintMatchup();
|
|
1588
|
+
loadComments(rec.file);
|
|
1589
|
+
}
|
|
1590
|
+
function matchupControls(){
|
|
1591
|
+
// Like a brief, a matchup is read-only and its first paragraph already names the competitor
|
|
1592
|
+
// and the "As of" date it captures, so no tab-bar badge is needed. The comment navigator lives
|
|
1593
|
+
// in the canvas command bar (#docdiff).
|
|
1594
|
+
return '';
|
|
1595
|
+
}
|
|
1596
|
+
function paintMatchup(){
|
|
1597
|
+
renderMain(recordBodyMarkup());
|
|
1598
|
+
setTopActions(matchupControls());
|
|
1599
|
+
wireCaseControls(); // wires the comments prev/next navigator (no-ops for absent controls)
|
|
1600
|
+
const c = $('#canvas'); if (!c) return;
|
|
1601
|
+
const art = document.createElement('article'); art.className = 'doc'; art.id = 'doc';
|
|
1602
|
+
const text = S.matchup && S.matchup.exists ? stripFrontmatter(S.matchup.text) : '';
|
|
1603
|
+
const empty = !text.trim();
|
|
1604
|
+
const competitor = (S.matchup && S.matchup.competitor) || '<competitor>';
|
|
1605
|
+
art.innerHTML = empty ? `<div class="banner">${iconEl(ICON.matchup)}<div>This matchup is empty. Regenerate it in your coding agent with <code>/deliberate matchup ${esc(competitor)}</code>.</div></div>`
|
|
1606
|
+
: renderMarkdown(text);
|
|
1607
|
+
// A matchup is read-only here (regenerated by the agent, not edited in place), so its task
|
|
1608
|
+
// checkboxes are shown for reference but disabled — there is no source model to toggle.
|
|
1609
|
+
art.querySelectorAll('.md-taskbox').forEach(b => { b.disabled = true; b.style.cursor = 'default'; });
|
|
1610
|
+
c.innerHTML = ''; c.appendChild(art);
|
|
1611
|
+
reindexHeadings(art);
|
|
1612
|
+
buildToc();
|
|
1613
|
+
if (!empty){ wireCommentAffordances(art); renderComments(); } // matchups are annotatable too
|
|
1614
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
1615
|
+
}
|
|
1616
|
+
// corner, in #docdiff), not the tab bar — see caseCanvasControls / renderCanvasDiff. The tab
|
|
1617
|
+
// bar keeps only the generic editor affordances: the comments navigator + save state.
|
|
1618
|
+
function caseControls(){
|
|
1619
|
+
const cnav = commentNav();
|
|
1620
|
+
if (caseGenerating())
|
|
1621
|
+
return `${cnav}<span class="rochip" title="Being generated by your agent — editing is disabled to avoid conflicts">${iconEl(ICON.queue)}Read-only</span>`;
|
|
1622
|
+
return `${cnav}<span class="savedot" id="savedot"><i></i><span id="savelbl"></span></span>`;
|
|
1623
|
+
}
|
|
1624
|
+
// The verdict pill (jump-to-reasoning when the score section exists). Shared by the canvas
|
|
1625
|
+
// corner controls.
|
|
1626
|
+
function verdictPill(v){
|
|
1627
|
+
if (!v) return '';
|
|
1628
|
+
const jump = v.cls !== 'neutral' && reasoningInSource();
|
|
1629
|
+
return jump
|
|
1630
|
+
? `<button type="button" class="pill jump ${v.cls}" data-scorejump title="${v.word} · ${v.label} — jump to score reasoning" aria-label="Verdict ${v.word}, score ${v.label} — jump to score reasoning">${v.label}</button>`
|
|
1631
|
+
: `<span class="pill ${v.cls}" title="${v.word} · score ${v.label}" aria-label="Verdict ${v.word}, score ${v.label}">${v.label}</span>`;
|
|
1632
|
+
}
|
|
1633
|
+
// The case-specific controls that float in the canvas corner (#docdiff): the verdict pill and the
|
|
1634
|
+
// View-/Build-prototype button. The prototype is a file, not a state: whenever the case's prototype
|
|
1635
|
+
// HTML exists (S.proto.exists) we show **View prototype** — regardless of verdict (a rejected case
|
|
1636
|
+
// can still have a prototype worth opening); otherwise, once the analysis is complete ('done'), we
|
|
1637
|
+
// offer **Build prototype**. Neither reads a 'prototype' case state (there is none — it's decoupled).
|
|
1638
|
+
function caseCanvasControls(){
|
|
1639
|
+
const kase = S.cases.find(s => s.id === S.caseId);
|
|
1640
|
+
const v = kase && kase.score != null ? verdict(kase.score) : null;
|
|
1641
|
+
const pill = verdictPill(v);
|
|
1642
|
+
if (caseGenerating()) return pill; // prototyping doesn't apply mid-generation
|
|
1643
|
+
const proto = S.proto && S.proto.exists
|
|
1644
|
+
? `<a class="tbtn" data-proto ${S.proto.external ? `href="${esc(S.proto.url)}" target="_blank" rel="noopener"` : 'role="button" tabindex="0"'}>${iconEl(ICON.external)}View prototype</a>` : '';
|
|
1645
|
+
const build = !proto && kase && kase.state === 'done'
|
|
1646
|
+
? `<button type="button" class="tbtn ghost" data-buildproto title="Build an interactive prototype for this case">${iconEl(ICON.proto)}Build prototype</button>` : '';
|
|
1647
|
+
return `${pill}${proto}${build}`;
|
|
1648
|
+
}
|
|
1649
|
+
// The comments navigator (prev / count / next) — shared by case and brief controls.
|
|
1650
|
+
// The comments count + prev/next now live in the rail header (renderCommentPanel), not the tab
|
|
1651
|
+
// bar — one home, and it frees tab-bar space. Kept as a no-op so the controls builders still call it.
|
|
1652
|
+
function commentNav(){ return ''; }
|
|
1653
|
+
function setTopActions(html){ const t = $('#topactions'); if (t) t.innerHTML = html || ''; }
|
|
1654
|
+
// The record canvas layout — a centred reading canvas + a sticky rail (table of contents). ONE
|
|
1655
|
+
// layout for BOTH the live document AND the rendered diff, so toggling Document ⇄ Diff only swaps
|
|
1656
|
+
// what fills #canvas (never the surrounding frame): the canvas stays the same width and position,
|
|
1657
|
+
// the corner controls stay put, and the rail persists — a seamless switch, not a separate screen.
|
|
1658
|
+
function recordBodyMarkup(){
|
|
1659
|
+
return `<div class="casebody"><div class="canvaswrap"><div class="canvas" id="canvas"></div><div class="cgutter" id="cgutter" aria-hidden="true"></div></div>
|
|
1660
|
+
<aside class="rail" id="rail"><nav class="toc" id="toc" aria-label="On this page"></nav></aside>
|
|
1661
|
+
<button class="secfab" id="secfab" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="secsheet"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Sections</button>
|
|
1662
|
+
<div class="secsheet" id="secsheet" role="menu" aria-label="Jump to section"></div></div>`;
|
|
1663
|
+
}
|
|
1664
|
+
function paintCase(){
|
|
1665
|
+
renderMain(recordBodyMarkup());
|
|
1666
|
+
setTopActions(caseControls());
|
|
1667
|
+
wireCaseControls();
|
|
1668
|
+
renderLive(); buildToc(); renderComments();
|
|
1669
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
1670
|
+
updateSaveDot();
|
|
1671
|
+
}
|
|
1672
|
+
function wireCaseControls(){
|
|
1673
|
+
renderCanvasDiff(); // verdict pill + prototype + Document⇄Diff toggle live in the canvas corner
|
|
1674
|
+
}
|
|
1675
|
+
// Discard ALL uncommitted changes in the open record's file — destructive, so it goes
|
|
1676
|
+
// through a confirmation dialog. On confirm: revert on the server, refresh git status, and
|
|
1677
|
+
// reload the record (and any open case editor) so the workbench reflects the restored file.
|
|
1678
|
+
async function confirmDiscard(){
|
|
1679
|
+
const file = recFile(); if (!file) return;
|
|
1680
|
+
const ok = await confirmDialog({
|
|
1681
|
+
title: 'Discard changes?',
|
|
1682
|
+
body: `This permanently discards all uncommitted changes in <b>${esc(baseName(file))}</b> and restores it to the last commit. This can’t be undone.`,
|
|
1683
|
+
confirmLabel: 'Discard changes', danger: true,
|
|
1684
|
+
});
|
|
1685
|
+
if (!ok) return;
|
|
1686
|
+
const r = await api('POST', '/api/discard', { path: file });
|
|
1687
|
+
if (!r || r.error){ showToast(r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.` : 'Could not discard changes.'); return; }
|
|
1688
|
+
await loadGit();
|
|
1689
|
+
showToast('Changes discarded — file restored to the last commit.');
|
|
1690
|
+
// If the discarded file dropped out of the change set while we're in diff mode, fall back
|
|
1691
|
+
// to the document view; otherwise re-render the diff. Reload the record so edits reflect.
|
|
1692
|
+
if (S.caseId) { await reloadRecord(); if (!recFileChanged() && S.mode === 'diff') setMode('doc'); else repaintRecord(); }
|
|
1693
|
+
else { if (!recFileChanged() && S.mode === 'diff') S.mode = 'doc'; if (S.brief) await openBrief(S.brief.id); else if (S.readout) await openReadout(S.readout.id); else if (S.matchup) await openMatchup(S.matchup.id); else if (S.file) await openFile(S.file.path); }
|
|
1694
|
+
renderSidebar();
|
|
1695
|
+
}
|
|
1696
|
+
// Toggle Document ⇄ Diff on the OPEN record WITHOUT re-rendering the document: the two modes share
|
|
1697
|
+
// the exact same DOM, so switching only adds/removes the change decorations (a background wash + the
|
|
1698
|
+
// inserted removed-block islands) over the already-rendered blocks. This means the unchanged content
|
|
1699
|
+
// never re-renders or re-animates — only the newly-revealed removed sections animate in, and the
|
|
1700
|
+
// green/red washes fade via the block's own background transition. A full paint is reserved for
|
|
1701
|
+
// opening a record / external edits (repaintRecord).
|
|
1702
|
+
function setMode(m){
|
|
1703
|
+
const mode = m === 'diff' ? 'diff' : 'doc';
|
|
1704
|
+
if (mode === S.mode) return;
|
|
1705
|
+
flushSave(); hideAsk();
|
|
1706
|
+
S.mode = mode;
|
|
1707
|
+
TEL.event('ui.action', { area:'editor', action:'mode-change', value:mode });
|
|
1708
|
+
renderCanvasDiff(); // reflect the toggle's pressed state + show/hide Discard
|
|
1709
|
+
const settle = (animate) => { applyDiffDecorations(animate); buildToc(); reserveRailForControls(); if (S.file || S.caseId || S.brief || S.readout || S.matchup) renderComments(); };
|
|
1710
|
+
if (mode === 'diff') loadDiffBase().then(() => settle(true)); // decorations animate IN on entry
|
|
1711
|
+
else settle(false); // leaving diff just clears the decorations
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
// The prototype link in analysis.md is a repo-relative path (./prototype/[<surface>/]index.html);
|
|
1716
|
+
// rewrite it to the daemon endpoint so it doesn't resolve against /case/<id>/. A surface slug (a
|
|
1717
|
+
// case can hold one prototype per primary surface) is carried through as the `surface` param.
|
|
1718
|
+
function protoUrl(surface){ const proj = (S.projectSlug || (S.project && S.project.id)); const q = proj ? '&project=' + encodeURIComponent(proj) : ''; const s = surface ? '&surface=' + encodeURIComponent(surface) : ''; return (!surface && S.proto && S.proto.external && S.proto.url) ? S.proto.url : ('/api/proto?id=' + encodeURIComponent(S.caseId) + q + s); }
|
|
1719
|
+
function openProto(surface){ TEL.event('ui.action', { area:'editor', action:'proto-open' }); try { window.open(protoUrl(surface), '_blank', 'noopener'); } catch {} }
|
|
1720
|
+
// No prototype yet: prototyping is an explicit, asked-for step the coding agent does —
|
|
1721
|
+
// so instead of a build button that can't work here, surface the exact `/deliberate`
|
|
1722
|
+
// command (with the case id filled in), one click to copy. Anchored under the button.
|
|
1723
|
+
function showBuildProto(anchor){
|
|
1724
|
+
const kase = currentCase();
|
|
1725
|
+
const ref = kase && kase.id != null ? String(kase.id) : '';
|
|
1726
|
+
const cmd = `/deliberate case ${ref} prototype`.replace(/\s+/g, ' ').trim();
|
|
1727
|
+
openCmdPopover(anchor, {
|
|
1728
|
+
title: 'Build the prototype',
|
|
1729
|
+
desc: 'Prototyping is an explicit step your coding agent runs. Paste this into your agent:',
|
|
1730
|
+
command: cmd,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
// A small popover that presents a runnable command with a one-click Copy — reuses the
|
|
1734
|
+
// `#menu` id so the existing closeMenu / click-away logic dismisses it.
|
|
1735
|
+
function openCmdPopover(anchor, { title, desc, command }){
|
|
1736
|
+
closeMenu();
|
|
1737
|
+
const m = document.createElement('div'); m.className = 'cmdpop'; m.id = 'menu'; m.setAttribute('role', 'dialog'); m.setAttribute('aria-label', title);
|
|
1738
|
+
m.innerHTML = `<div class="cmdpop-t">${esc(title)}</div><p class="cmdpop-d">${esc(desc)}</p>
|
|
1739
|
+
<div class="cmdpop-row"><code class="cmdpop-c">${esc(command)}</code>
|
|
1740
|
+
<button class="cmdpop-copy" type="button" aria-label="Copy command">${iconEl(ICON.copy)}<span>Copy</span></button></div>`;
|
|
1741
|
+
document.body.appendChild(m);
|
|
1742
|
+
const r = anchor.getBoundingClientRect();
|
|
1743
|
+
const w = m.offsetWidth || 340, vw = window.innerWidth;
|
|
1744
|
+
m.style.left = Math.max(8, Math.min(r.right - w, vw - w - 8)) + 'px';
|
|
1745
|
+
m.style.top = (r.bottom + 8) + 'px';
|
|
1746
|
+
const copyBtn = m.querySelector('.cmdpop-copy');
|
|
1747
|
+
copyBtn.addEventListener('click', async () => {
|
|
1748
|
+
let ok = false;
|
|
1749
|
+
try { await navigator.clipboard.writeText(command); ok = true; } catch { ok = copyFallback(command); }
|
|
1750
|
+
showToast(ok ? 'Copied — paste it into your coding agent.' : 'Select the command and copy it manually.');
|
|
1751
|
+
closeMenu();
|
|
1752
|
+
});
|
|
1753
|
+
copyBtn.focus();
|
|
1754
|
+
setTimeout(() => document.addEventListener('mousedown', menuAway, true), 0);
|
|
1755
|
+
}
|
|
1756
|
+
// Clipboard fallback for insecure contexts / older browsers (execCommand copy on a temp textarea).
|
|
1757
|
+
function copyFallback(text){
|
|
1758
|
+
try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; }
|
|
1759
|
+
catch { return false; }
|
|
1760
|
+
}
|
|
1761
|
+
function rewriteProtoLinks(el){
|
|
1762
|
+
el.querySelectorAll('a[href]').forEach(a => {
|
|
1763
|
+
const href = a.getAttribute('href') || '';
|
|
1764
|
+
const m = href.match(/(?:^|\/)prototype\/(?:([^/]+)\/)?index\.html\/?$/) || (/(?:^|\/)prototype\/?$/.test(href) ? [] : null);
|
|
1765
|
+
if (m){
|
|
1766
|
+
const surface = (m && m[1]) || null; // a named primary surface, or the default (flat) prototype
|
|
1767
|
+
const external = !surface && S.proto && S.proto.external;
|
|
1768
|
+
a.setAttribute('href', protoUrl(surface)); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener');
|
|
1769
|
+
if (!external){ a.dataset.protoLocal = '1'; a.addEventListener('click', (e) => { e.preventDefault(); openProto(surface); }); }
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
// Open a rendered Markdown link from the editor: an in-page `#anchor` scrolls to its heading,
|
|
1774
|
+
// everything else opens in a new tab.
|
|
1775
|
+
function openDocLink(a){
|
|
1776
|
+
const href = a.getAttribute('href') || '';
|
|
1777
|
+
if (!href || href === '#') return;
|
|
1778
|
+
if (href.charAt(0) === '#'){ const t = document.getElementById(href.slice(1)); if (t) t.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'start' }); return; }
|
|
1779
|
+
try { window.open(a.href, '_blank', 'noopener,noreferrer'); } catch { /* popup blocked */ }
|
|
1780
|
+
}
|
|
1781
|
+
// A block that renders to nothing but isn't blank — a standalone HTML-comment section marker
|
|
1782
|
+
// (machine state, like frontmatter). Kept in the document model, hidden from the reading view;
|
|
1783
|
+
// used by the comment line-mapping and the diff so those markers are never shown as content.
|
|
1784
|
+
const isMetaOnly = (src) => src.trim() !== '' && renderMarkdown(src).trim() === '';
|
|
1785
|
+
|
|
1786
|
+
// ---- The editor (Tiptap / ProseMirror over Markdown) ----------------------
|
|
1787
|
+
// The record's Markdown is mounted into a Tiptap editor (see editor.mjs). Save,
|
|
1788
|
+
// ToC, comments and diff all route through the same seams the rest of the app
|
|
1789
|
+
// uses (currentSource / buildToc / renderComments / applyDiffDecorations), so
|
|
1790
|
+
// this section only owns the mount + the debounced structure refresh.
|
|
1791
|
+
//
|
|
1792
|
+
// Sonorance's value was never in re-implementing a Markdown editor — that's a
|
|
1793
|
+
// commodity. So the editing surface is a proven library (ProseMirror via
|
|
1794
|
+
// Tiptap) and the effort goes into what's actually differentiated: comments,
|
|
1795
|
+
// diff, the ToC, slash commands, and the agent workflow on top.
|
|
1796
|
+
let wysiwygCtl = null;
|
|
1797
|
+
// True while the user's caret is live in the editor — used to keep the file poll from
|
|
1798
|
+
// re-rendering (and so blowing away the caret / edit session) out from under an active edit.
|
|
1799
|
+
function wysiwygFocused(){ return !!(wysiwygCtl && wysiwygCtl.hasFocus()); }
|
|
1800
|
+
|
|
1801
|
+
// Mount (or re-mount) the editor over the open record's Markdown. Read-only for briefs and for
|
|
1802
|
+
// a case the agent is mid-generating; editable otherwise. Contextual banners live OUTSIDE the
|
|
1803
|
+
// editor article (Tiptap owns its element's DOM) so they never collide with the ProseMirror view.
|
|
1804
|
+
function renderLive(){
|
|
1805
|
+
const c = $('#canvas'); if (!c) return;
|
|
1806
|
+
const ro = docReadOnly();
|
|
1807
|
+
hideAsk(); hideTableTools(); // clear any floating affordance from the previous surface
|
|
1808
|
+
if (wysiwygCtl){ try { wysiwygCtl.destroy(); } catch {} wysiwygCtl = null; }
|
|
1809
|
+
c.innerHTML = '';
|
|
1810
|
+
if (caseGenerating()){
|
|
1811
|
+
// The agent is writing this record (mid-funnel). Editing is locked so a hand-edit can't
|
|
1812
|
+
// collide with the in-flight save; comments still work (they don't touch the file).
|
|
1813
|
+
const b = document.createElement('div'); b.className = 'banner gen'; b.setAttribute('contenteditable', 'false');
|
|
1814
|
+
b.innerHTML = `${iconEl(ICON.queue)}<div><strong>Being generated by your agent.</strong> Editing is disabled here while the analysis is written, to avoid conflicts — it unlocks when the case is complete. You can still add comments.</div>`;
|
|
1815
|
+
c.appendChild(b);
|
|
1816
|
+
} else if (S.rec && !S.rec.exists){
|
|
1817
|
+
const b = document.createElement('div'); b.className = 'banner'; b.setAttribute('contenteditable', 'false');
|
|
1818
|
+
b.innerHTML = `${iconEl(ICON.queue)}<div>No analysis has run yet — start typing to draft this record, or run the funnel in your coding agent (<code>/deliberate case</code>).${
|
|
1819
|
+
S.rec.idea ? `<div style="margin-top:8px;color:var(--ink-faint)">Case: ${esc(S.rec.idea)}</div>` : ''}</div>`;
|
|
1820
|
+
c.appendChild(b);
|
|
1821
|
+
}
|
|
1822
|
+
const art = document.createElement('article'); art.className = 'doc' + (ro ? ' ro' : ''); art.id = 'doc';
|
|
1823
|
+
c.appendChild(art);
|
|
1824
|
+
wysiwygCtl = createEditor({
|
|
1825
|
+
element: art,
|
|
1826
|
+
editable: !ro,
|
|
1827
|
+
placeholder: 'Start writing…',
|
|
1828
|
+
onChange: onWysiwygChange,
|
|
1829
|
+
onCommentClick: (id) => focusComment(id),
|
|
1830
|
+
onAtComment: openAtComment,
|
|
1831
|
+
onLinkClick: (a, e) => { if (e) e.preventDefault(); openDocLink(a); },
|
|
1832
|
+
});
|
|
1833
|
+
wysiwygCtl.setMarkdown(reconstruct(S.items));
|
|
1834
|
+
reindexHeadings(art);
|
|
1835
|
+
buildToc();
|
|
1836
|
+
renderComments();
|
|
1837
|
+
wireCommentAffordances(art);
|
|
1838
|
+
if (S.mode === 'diff') applyDiffDecorations(); // re-layer change highlights after a (re)mount
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function onWysiwygChange(){
|
|
1842
|
+
if (docReadOnly()) return;
|
|
1843
|
+
S.dirty = true; updateSaveDot(); scheduleSave(); syncDocDiff();
|
|
1844
|
+
scheduleDocStructure(); // ToC/heading-ids/diff are debounced off the keystroke (INP)
|
|
1845
|
+
}
|
|
1846
|
+
// The per-keystroke handler must stay O(1) beyond Tiptap's own work, so the O(document)
|
|
1847
|
+
// structure work — re-slugging headings, rebuilding the ToC, and re-serializing the doc to
|
|
1848
|
+
// re-derive the diff — is coalesced here and only runs once typing settles. INP is a hard
|
|
1849
|
+
// priority: this keeps a burst of keystrokes from rebuilding the ToC + diffing the whole doc on
|
|
1850
|
+
// every key. A short debounce (not rAF) so sustained typing doesn't diff every frame either.
|
|
1851
|
+
let docStructTimer = null;
|
|
1852
|
+
function scheduleDocStructure(){ clearTimeout(docStructTimer); docStructTimer = setTimeout(refreshDocStructure, 150); }
|
|
1853
|
+
function refreshDocStructure(){
|
|
1854
|
+
clearTimeout(docStructTimer); docStructTimer = null;
|
|
1855
|
+
if (!wysiwygCtl) return;
|
|
1856
|
+
const art = $('#doc'); if (art) reindexHeadings(art);
|
|
1857
|
+
buildToc();
|
|
1858
|
+
if (S.mode === 'diff') applyDiffDecorations();
|
|
1859
|
+
}
|
|
1860
|
+
// Stable heading ids for the ToC / scroll-spy / thread anchoring — matched on the rendered
|
|
1861
|
+
// headings whether the surface is an editable case or a read-only brief.
|
|
1862
|
+
function reindexHeadings(art){
|
|
1863
|
+
let n = 0;
|
|
1864
|
+
art.querySelectorAll(':is(h1,h2,h3,h4,h5,h6)').forEach(h => { h.id = 'sec-' + n++; });
|
|
1865
|
+
}
|
|
1866
|
+
// The comment affordances (selection "Comment" bubble + the + gutter add-handle) — wired on
|
|
1867
|
+
// every rendered surface (editable case or read-only brief/file) so any file is annotatable.
|
|
1868
|
+
function wireCommentAffordances(art){
|
|
1869
|
+
const onUp = () => setTimeout(() => { maybeShowAsk(art); maybeShowTableTools(); }, 0); // the Comment affordance works whether reading or editing
|
|
1870
|
+
art.addEventListener('mouseup', onUp);
|
|
1871
|
+
art.addEventListener('keyup', onUp);
|
|
1872
|
+
art.addEventListener('mousemove', onDocHoverComment); // the + add-comment handle tracks the hovered block
|
|
1873
|
+
const wrap = art.closest('.canvaswrap'); if (wrap && !wrap._cleave){ wrap._cleave = true; wrap.addEventListener('mouseleave', hideAddHandle); }
|
|
1874
|
+
}
|
|
1875
|
+
// Dismiss the selection affordances (Comment button / formatting bubble) when the
|
|
1876
|
+
// selection clears. Registered once, in boot().
|
|
1877
|
+
function onDocSelectionChange(){
|
|
1878
|
+
if (askPop) return; // composing — keep it up
|
|
1879
|
+
maybeShowTableTools(); // the caret may have moved in/out of a table
|
|
1880
|
+
if (!askBtn && !bubbleBar) return;
|
|
1881
|
+
const sel = window.getSelection && window.getSelection();
|
|
1882
|
+
const q = sel && !sel.isCollapsed ? sel.toString().trim() : '';
|
|
1883
|
+
if (!q) hideSelectionAffordance(); // deselected → dismiss the bubble/button
|
|
1884
|
+
}
|
|
1885
|
+
// The current document's Markdown — the live editor's serialization (the on-disk truth).
|
|
1886
|
+
function currentSource(){
|
|
1887
|
+
return wysiwygCtl ? wysiwygCtl.getMarkdown() : reconstruct(S.items);
|
|
1888
|
+
}
|
|
1889
|
+
function flushSave(){ if (S.saveTimer){ clearTimeout(S.saveTimer); S.saveTimer = null; } if (S.dirty) saveNow(); }
|
|
1890
|
+
function prefersReduced(){ try { return window.matchMedia('(prefers-reduced-motion:reduce)').matches; } catch { return false; } }
|
|
1891
|
+
|
|
1892
|
+
// ---- table of contents + scroll-spy ---------------------------------------
|
|
1893
|
+
// Built from the rendered document's H2/H3 headings. The rail is sticky (always
|
|
1894
|
+
// visible while scrolling) and the entry for the section currently on screen is
|
|
1895
|
+
// highlighted. Falls back to one level (H2) if two won't fit the viewport.
|
|
1896
|
+
|
|
1897
|
+
// The document's heading elements, in order, excluding those inside a REMOVED-block decoration
|
|
1898
|
+
// (`.diffdel`) since they aren't part of the resulting document. Re-queried LIVE on every call:
|
|
1899
|
+
// Tiptap owns the `#doc` subtree and both strips the `id` attributes we set on its headings AND
|
|
1900
|
+
// replaces the heading nodes themselves on re-render, so neither a `getElementById` nor a captured
|
|
1901
|
+
// element reference survives. Jump/scroll-spy therefore address headings by their ordinal position
|
|
1902
|
+
// (`data-hidx`) and resolve them against this live list at click/scroll time.
|
|
1903
|
+
function liveHeadings(){
|
|
1904
|
+
const doc = $('#doc'); if (!doc) return [];
|
|
1905
|
+
return [...doc.querySelectorAll('h2,h3')].filter(h => !h.closest('.diffdel'));
|
|
1906
|
+
}
|
|
1907
|
+
function buildToc(){
|
|
1908
|
+
const toc = $('#toc'); if (!toc) return;
|
|
1909
|
+
const abs = liveHeadings();
|
|
1910
|
+
buildMobileSections(abs);
|
|
1911
|
+
if (abs.length < 2){ toc.innerHTML = ''; return; }
|
|
1912
|
+
let idxs = abs.map((_, i) => i);
|
|
1913
|
+
const avail = (window.innerHeight || 800) - 130;
|
|
1914
|
+
if (abs.length * 27 + 26 > avail){ const h2 = idxs.filter(i => abs[i].tagName === 'H2'); if (h2.length >= 2) idxs = h2; }
|
|
1915
|
+
const withC = sectionsWithComments();
|
|
1916
|
+
toc.innerHTML = `<div class="toc-h">On this page</div>` + idxs.map((ai, i) => { const h = abs[ai];
|
|
1917
|
+
return `<a href="#${h.id}" data-idx="${i}" data-hidx="${ai}" class="lvl${h.tagName === 'H3' ? 3 : 2}">${esc(h.textContent)}${withC.has(h) ? '<span class="tdot" aria-label="has comments"></span>' : ''}</a>`; }).join('');
|
|
1918
|
+
toc.querySelectorAll('a').forEach(a => a.addEventListener('click', (e) => {
|
|
1919
|
+
e.preventDefault();
|
|
1920
|
+
TEL.event('ui.action', { area: 'toc', action: 'toc-nav' });
|
|
1921
|
+
const el = liveHeadings()[+a.dataset.hidx];
|
|
1922
|
+
if (el) el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'start' });
|
|
1923
|
+
}));
|
|
1924
|
+
wireScrollspy(idxs);
|
|
1925
|
+
reserveRailForControls();
|
|
1926
|
+
}
|
|
1927
|
+
// Keep the floating corner controls (#docdiff: verdict pill + prototype + Document⇄Diff toggle)
|
|
1928
|
+
// from overlapping the table-of-contents rail. Rather than move the controls out of the corner,
|
|
1929
|
+
// reserve exactly enough space at the rail's top for the cluster to sit above the ToC — measured
|
|
1930
|
+
// from the live geometry, so it adapts to the cluster's width and to a wide prototype label that
|
|
1931
|
+
// wraps. No-op in diff view / on narrow layouts where the rail isn't shown.
|
|
1932
|
+
function reserveRailForControls(){
|
|
1933
|
+
const rail = $('#rail'), dd = $('#docdiff');
|
|
1934
|
+
if (!rail) return;
|
|
1935
|
+
if (!dd || dd.hidden){ rail.style.paddingTop = ''; return; }
|
|
1936
|
+
const r = rail.getBoundingClientRect(), d = dd.getBoundingClientRect();
|
|
1937
|
+
const overlap = d.bottom - r.top;
|
|
1938
|
+
// Pad the rail's top so its content (the ToC, or the comment hint + cards) starts BELOW the
|
|
1939
|
+
// floating command bar rather than under it. Both the hint and the cards scroll normally, so this
|
|
1940
|
+
// only needs to hold at the top of the scroll — no sticky offset to maintain.
|
|
1941
|
+
rail.style.paddingTop = overlap > 0 ? `${Math.ceil(overlap) + 12}px` : '';
|
|
1942
|
+
}
|
|
1943
|
+
// The mobile spine: mirror the full outline into the floating Sections sheet so the
|
|
1944
|
+
// record stays navigable once the desktop rail collapses (≤1080px).
|
|
1945
|
+
function buildMobileSections(heads){
|
|
1946
|
+
const fab = $('#secfab'), sheet = $('#secsheet');
|
|
1947
|
+
document.body.classList.remove('sec-open');
|
|
1948
|
+
document.body.classList.toggle('has-sections', heads.length >= 2);
|
|
1949
|
+
if (fab) fab.setAttribute('aria-expanded', 'false');
|
|
1950
|
+
if (!sheet){ return; }
|
|
1951
|
+
if (heads.length < 2){ sheet.innerHTML = ''; return; }
|
|
1952
|
+
sheet.innerHTML = heads.map((h, i) =>
|
|
1953
|
+
`<a href="#${h.id}" data-idx="${i}" data-hidx="${i}" class="lvl${h.tagName === 'H3' ? 3 : 2}" role="menuitem">${esc(h.textContent)}</a>`).join('');
|
|
1954
|
+
const close = () => { document.body.classList.remove('sec-open'); if (fab) fab.setAttribute('aria-expanded', 'false'); };
|
|
1955
|
+
if (fab) fab.onclick = (e) => { e.stopPropagation(); const open = document.body.classList.toggle('sec-open'); fab.setAttribute('aria-expanded', open ? 'true' : 'false'); };
|
|
1956
|
+
sheet.querySelectorAll('a').forEach(a => a.addEventListener('click', (e) => {
|
|
1957
|
+
e.preventDefault();
|
|
1958
|
+
const el = liveHeadings()[+a.dataset.hidx];
|
|
1959
|
+
if (el) el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'start' });
|
|
1960
|
+
close();
|
|
1961
|
+
}));
|
|
1962
|
+
if (!S._secOutsideWired){
|
|
1963
|
+
S._secOutsideWired = true;
|
|
1964
|
+
document.addEventListener('click', (e) => {
|
|
1965
|
+
if (!document.body.classList.contains('sec-open')) return;
|
|
1966
|
+
if (e.target.closest('#secsheet') || e.target.closest('#secfab')) return;
|
|
1967
|
+
close();
|
|
1968
|
+
});
|
|
1969
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
let spyHandler = null;
|
|
1973
|
+
function wireScrollspy(idxs){
|
|
1974
|
+
const main = $('#main'), toc = $('#toc'); if (!main || !toc) return;
|
|
1975
|
+
if (spyHandler){ main.removeEventListener('scroll', spyHandler); spyHandler = null; }
|
|
1976
|
+
const setActive = (j) => toc.querySelectorAll('a').forEach(a => a.classList.toggle('active', +a.dataset.idx === j));
|
|
1977
|
+
let ticking = false;
|
|
1978
|
+
const update = () => {
|
|
1979
|
+
ticking = false;
|
|
1980
|
+
const live = liveHeadings();
|
|
1981
|
+
const top = main.getBoundingClientRect().top;
|
|
1982
|
+
let active = 0;
|
|
1983
|
+
// Address headings by ordinal position against the live list (Tiptap replaces the nodes and
|
|
1984
|
+
// strips their ids). The last heading whose top has passed the reading line is the current one.
|
|
1985
|
+
for (let j = 0; j < idxs.length; j++){ const el = live[idxs[j]]; if (!el) continue;
|
|
1986
|
+
if (el.getBoundingClientRect().top - top <= 60) active = j; else break; }
|
|
1987
|
+
setActive(active);
|
|
1988
|
+
};
|
|
1989
|
+
spyHandler = () => { if (!ticking){ ticking = true; (window.requestAnimationFrame || setTimeout)(update); } };
|
|
1990
|
+
main.addEventListener('scroll', spyHandler, { passive: true });
|
|
1991
|
+
update();
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// ---- in-record comments (annotations) -------------------------------------
|
|
1995
|
+
// The reader leaves comments anchored to spans of ANY file (a case record OR a brief);
|
|
1996
|
+
// the agent works through them with `/deliberate address` and resolves each. Only OPEN
|
|
1997
|
+
// comments are shown — resolved ones disappear. Presentation lives in an OVERLAY over the
|
|
1998
|
+
// reading canvas (never inside the contenteditable #doc): an inline highlight per anchored
|
|
1999
|
+
// span, a left-gutter pin per commented block, and a click-to-open card.
|
|
2000
|
+
const openCmts = () => (S.comments || []).filter(c => c.status === 'open');
|
|
2001
|
+
// The block-level container of a node inside the document — a top-level Markdown block. On the
|
|
2002
|
+
// Tiptap surface the blocks are the direct children of `.ProseMirror`; on the read-only brief
|
|
2003
|
+
// surface (plain rendered HTML) they're the direct children of `#doc`. One helper so the comment
|
|
2004
|
+
// layer works identically on both surfaces.
|
|
2005
|
+
function blockOf(el){
|
|
2006
|
+
const doc = $('#doc'); if (!doc || !el) return null;
|
|
2007
|
+
const container = doc.querySelector('.ProseMirror') || doc;
|
|
2008
|
+
let n = el.nodeType === 3 ? el.parentElement : el;
|
|
2009
|
+
if (!n || !container.contains(n)) return null;
|
|
2010
|
+
while (n && n.parentElement && n.parentElement !== container) n = n.parentElement;
|
|
2011
|
+
return (n && n.parentElement === container) ? n : null;
|
|
2012
|
+
}
|
|
2013
|
+
// The rendered anchor of a comment: its highlighted span if found, else the heading
|
|
2014
|
+
// block that names its section (a fallback when the quote was edited away or spans blocks).
|
|
2015
|
+
function commentAnchor(c){
|
|
2016
|
+
const doc = $('#doc'); if (!doc) return null;
|
|
2017
|
+
const mark = doc.querySelector(`.cmark[data-comment="${c.id}"]`);
|
|
2018
|
+
if (mark) return { el: mark, blk: blockOf(mark) };
|
|
2019
|
+
const blk = commentBlockEl(c) || doc.querySelector(`.cmark-blk[data-comment="${c.id}"]`); // the line-anchored / block-highlighted block
|
|
2020
|
+
if (blk) return { el: blk, blk };
|
|
2021
|
+
const head = c.anchor && c.anchor.heading && c.anchor.heading.trim();
|
|
2022
|
+
if (head){
|
|
2023
|
+
const h = [...doc.querySelectorAll('h1,h2,h3,h4,h5,h6')].find(x => x.textContent.trim() === head);
|
|
2024
|
+
if (h) return { el: h, blk: blockOf(h) };
|
|
2025
|
+
}
|
|
2026
|
+
return null;
|
|
2027
|
+
}
|
|
2028
|
+
// The id of the nearest heading at or before an element — for ToC comment dots.
|
|
2029
|
+
function precedingHeading(el){
|
|
2030
|
+
const doc = $('#doc'); if (!doc || !el) return null;
|
|
2031
|
+
let head = null;
|
|
2032
|
+
for (const h of doc.querySelectorAll('h1,h2,h3,h4,h5,h6')){
|
|
2033
|
+
if (h === el || (h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) head = h; else break;
|
|
2034
|
+
}
|
|
2035
|
+
return head;
|
|
2036
|
+
}
|
|
2037
|
+
// The set of ToC heading ELEMENTS that contain at least one open comment. Element-keyed (not
|
|
2038
|
+
// id-keyed) because Tiptap strips the heading ids after render; see buildToc.
|
|
2039
|
+
function sectionsWithComments(){
|
|
2040
|
+
const set = new Set();
|
|
2041
|
+
for (const c of openCmts()){ const a = commentAnchor(c); if (!a) continue; const h = precedingHeading(a.el); if (h) set.add(h); }
|
|
2042
|
+
return set;
|
|
2043
|
+
}
|
|
2044
|
+
// The source line where S.items[i] begins, and the block index that owns a given source line —
|
|
2045
|
+
// the basis for LINE-anchored comments (a stable-ish reference that survives duplicate/removed
|
|
2046
|
+
// quote text far better than a bare content search; see commentAnchor).
|
|
2047
|
+
function blockStartLine(i){ let n = 0; for (let k = 0; k < i && k < S.items.length; k++) n += (S.items[k].lines || []).length; return n; }
|
|
2048
|
+
function blockIndexForLine(line){ let n = 0; for (let i = 0; i < S.items.length; i++){ const len = (S.items[i].lines || []).length; if (line >= n && line < n + len) return i; n += len; } return -1; }
|
|
2049
|
+
// The rendered .blk that a comment is anchored to, resolved robustly: prefer the block its saved
|
|
2050
|
+
// source LINE lands in (unambiguous even if the quote appears elsewhere or was edited away), then
|
|
2051
|
+
// its quote within that block. Legacy/brief comments (no line) fall back to a global quote search.
|
|
2052
|
+
function commentBlockEl(c){
|
|
2053
|
+
const doc = $('#doc'); if (!doc) return null;
|
|
2054
|
+
const line = c.anchor && typeof c.anchor.line === 'number' ? c.anchor.line : -1;
|
|
2055
|
+
if (line >= 0){
|
|
2056
|
+
if (S.caseId || S.file){ const bi = blockIndexForLine(line); if (bi >= 0){ const blk = doc.querySelector(`.blk[data-i="${bi}"]`); if (blk) return blk; } }
|
|
2057
|
+
else if (S.brief || S.readout || S.matchup){ const idx = briefBlockAtLine(line); if (idx >= 0){ const blk = briefBlockKids()[idx]; if (blk) return blk; } }
|
|
2058
|
+
}
|
|
2059
|
+
return null;
|
|
2060
|
+
}
|
|
2061
|
+
// Comments are shown as ALWAYS-VISIBLE amber cards — in the right rail (mode 'rail', the default,
|
|
2062
|
+
// which replaces the ToC while there are open comments) or, when the rail can't fit (narrow window
|
|
2063
|
+
// / split-screen), inline in the document flow (mode 'inline'). There is no manual switch: the mode
|
|
2064
|
+
// follows the available width. The anchored span keeps a soft amber highlight in both modes.
|
|
2065
|
+
function commentModeOn(){ return railAvailable() ? 'rail' : 'inline'; }
|
|
2066
|
+
function railAvailable(){ try { return window.innerWidth > 1080; } catch { return true; } }
|
|
2067
|
+
function renderComments(){
|
|
2068
|
+
const doc = $('#doc'); if (!doc) return;
|
|
2069
|
+
doc.querySelectorAll('.cnote-island').forEach(el => el.remove()); // clear stale inline islands (incl. the hint island) before re-marking
|
|
2070
|
+
if (wysiwygCtl) wysiwygCtl.setComments(openCmts()); // highlights via ProseMirror decorations (never mutate the PM DOM)
|
|
2071
|
+
const open = openCmts();
|
|
2072
|
+
ensureAddHandle($('#cgutter'));
|
|
2073
|
+
renderCommentPanel(open);
|
|
2074
|
+
if (commentModeOn() === 'inline' && open.length){ renderCommentHintIsland(); renderCommentIslands(open); }
|
|
2075
|
+
updateTocDots();
|
|
2076
|
+
refreshCommentNav();
|
|
2077
|
+
// Inline comment islands touch the DOM; if we're in diff mode, re-assert the green/red diff
|
|
2078
|
+
// decorations so comment rendering never wipes the diff wash (they're separate colour channels).
|
|
2079
|
+
if (S.mode === 'diff') applyDiffDecorations();
|
|
2080
|
+
}
|
|
2081
|
+
// Inline mode (narrow, rail hidden): the global hint rides at the very top of the document as a
|
|
2082
|
+
// non-editable island, so the "run /sonorance address" guidance is still shown once somewhere.
|
|
2083
|
+
function renderCommentHintIsland(){
|
|
2084
|
+
const doc = $('#doc'); if (!doc) return;
|
|
2085
|
+
const el = document.createElement('div'); el.className = 'cnote-island cnote-hint-island'; el.contentEditable = 'false';
|
|
2086
|
+
el.innerHTML = commentHintHTML();
|
|
2087
|
+
doc.insertBefore(el, doc.firstChild);
|
|
2088
|
+
wireCommentHint(el);
|
|
2089
|
+
}
|
|
2090
|
+
// One always-visible comment card, shared by the rail column and the inline islands. No quoted
|
|
2091
|
+
// text (it's redundant beside the highlighted anchor and eats space); the reader can Edit or
|
|
2092
|
+
// Delete their own comment — the AGENT resolves them, the reader deletes them.
|
|
2093
|
+
function commentCardHTML(c){
|
|
2094
|
+
return `<article class="cnote" data-comment="${esc(c.id)}" tabindex="0" role="button" aria-label="Comment — ${esc(String(c.body || '').slice(0, 60))}">
|
|
2095
|
+
<div class="cnote-b">${esc(c.body)}</div>
|
|
2096
|
+
<footer class="cnote-f"><span class="cnote-who">You</span><span class="cnote-t">${esc(relTime(c.ts))}</span><span class="cnote-sp"></span>
|
|
2097
|
+
<button type="button" class="cnote-act" data-edit="${esc(c.id)}" title="Edit comment" aria-label="Edit comment">${iconEl(ICON.pencil)}</button>
|
|
2098
|
+
<button type="button" class="cnote-act cnote-del" data-del="${esc(c.id)}" title="Delete comment" aria-label="Delete comment">${iconEl(ICON.trash)}</button></footer>
|
|
2099
|
+
</article>`;
|
|
2100
|
+
}
|
|
2101
|
+
// Open comments in document order (top-to-bottom), so both the rail list and the nav step
|
|
2102
|
+
// through them the way the reader reads.
|
|
2103
|
+
function orderComments(list){
|
|
2104
|
+
return list.map(c => ({ c, a: commentAnchor(c) })).sort((p, q) => {
|
|
2105
|
+
if (!p.a) return 1; if (!q.a) return -1;
|
|
2106
|
+
return (p.a.el.compareDocumentPosition(q.a.el) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1;
|
|
2107
|
+
}).map(x => x.c);
|
|
2108
|
+
}
|
|
2109
|
+
// The rail comment layout: a NON-sticky hint box ("run /sonorance address") that sits below the
|
|
2110
|
+
// floating command bar and above the anchor-aligned comment cards, scrolling with them. The comment
|
|
2111
|
+
// count + prev/next navigator does NOT live here — it's a control in the command bar (#docdiff, see
|
|
2112
|
+
// commentNavCmd). In 'inline' mode (narrow, rail hidden) the hint rides at the top of the document.
|
|
2113
|
+
function renderCommentPanel(open){
|
|
2114
|
+
const rail = $('#rail'); if (!rail) return;
|
|
2115
|
+
const toc = rail.querySelector('#toc');
|
|
2116
|
+
let head = rail.querySelector('.crail-head');
|
|
2117
|
+
let panel = rail.querySelector('#comments');
|
|
2118
|
+
const n = open.length;
|
|
2119
|
+
if (!n){ // no comments → plain ToC, no header/column
|
|
2120
|
+
if (head) head.remove();
|
|
2121
|
+
if (panel) panel.remove();
|
|
2122
|
+
rail.classList.remove('cm-aligned');
|
|
2123
|
+
if (toc) toc.style.display = '';
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
if (commentModeOn() === 'rail'){
|
|
2127
|
+
if (!head){ head = document.createElement('div'); head.className = 'crail-head'; rail.insertBefore(head, rail.firstChild); }
|
|
2128
|
+
head.innerHTML = commentHintHTML();
|
|
2129
|
+
wireCommentHint(head);
|
|
2130
|
+
if (toc) toc.style.display = 'none';
|
|
2131
|
+
if (!panel){ panel = document.createElement('div'); panel.id = 'comments'; panel.className = 'comments'; rail.appendChild(panel); }
|
|
2132
|
+
panel.classList.add('aligned'); rail.classList.add('cm-aligned');
|
|
2133
|
+
panel.innerHTML = orderComments(open).map(c => commentCardHTML(c)).join('');
|
|
2134
|
+
wireCommentCards(panel);
|
|
2135
|
+
positionRailComments(); // Google-Docs alignment: each card sits beside its anchor line
|
|
2136
|
+
requestAnimationFrame(positionRailComments); // re-run once layout has settled (fonts/highlights)
|
|
2137
|
+
} else {
|
|
2138
|
+
if (head) head.remove();
|
|
2139
|
+
rail.classList.remove('cm-aligned');
|
|
2140
|
+
if (panel) panel.remove();
|
|
2141
|
+
if (toc) toc.style.display = '';
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
// The single global "run /sonorance address" hint (shown once, not per comment), with a one-click
|
|
2145
|
+
// copy of the command. Shared by the rail (below the command bar) and the inline hint island.
|
|
2146
|
+
function commentHintHTML(){
|
|
2147
|
+
return `<div class="crail-hint"><span class="crail-hint-t">Run <code>${esc(ADDRESS_CMD)}</code> in your agent to resolve comments.</span>
|
|
2148
|
+
<button type="button" class="crail-copy" data-copyaddr title="Copy “${esc(ADDRESS_CMD)}”" aria-label="Copy the command">${iconEl(ICON.copy)}</button></div>`;
|
|
2149
|
+
}
|
|
2150
|
+
function wireCommentHint(root){
|
|
2151
|
+
root.querySelector('[data-copyaddr]')?.addEventListener('click', async () => {
|
|
2152
|
+
let ok = false; try { await navigator.clipboard.writeText(ADDRESS_CMD); ok = true; } catch { ok = copyFallback(ADDRESS_CMD); }
|
|
2153
|
+
showToast(ok ? 'Command copied.' : 'Could not copy.');
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
// The comment navigator for the canvas command bar (#docdiff): just the comment glyph + open count
|
|
2157
|
+
// and prev/next arrows — no "comments" word (the icon carries the meaning, and space is tight next
|
|
2158
|
+
// to the Diff toggle). Built by renderCanvasDiff so it sits beside the other canvas controls.
|
|
2159
|
+
function commentNavCmd(){
|
|
2160
|
+
const n = openCmts().length;
|
|
2161
|
+
if (!n) return '';
|
|
2162
|
+
const lbl = `${n} open comment${n > 1 ? 's' : ''}`;
|
|
2163
|
+
return `<span class="cnavcmd" role="group" aria-label="${lbl}">
|
|
2164
|
+
<button type="button" class="cnavcmd-step" data-cnav="prev" title="Previous comment" aria-label="Previous comment">‹</button>
|
|
2165
|
+
<span class="cnavcmd-count" title="${lbl}">${iconEl(ICON.comment)}${n}</span>
|
|
2166
|
+
<button type="button" class="cnavcmd-step" data-cnav="next" title="Next comment" aria-label="Next comment">›</button>
|
|
2167
|
+
</span>`;
|
|
2168
|
+
}
|
|
2169
|
+
// Position each rail comment card vertically next to the line it's anchored to (Google-Docs
|
|
2170
|
+
// margin notes), pushing a card down when it would overlap the one above. Positions are computed
|
|
2171
|
+
// in the (non-sticky, full-height) comments column's own coordinate space, so they scroll with the
|
|
2172
|
+
// document — no per-scroll recompute needed. Re-run on resize (wired once in boot).
|
|
2173
|
+
function positionRailComments(){
|
|
2174
|
+
const panel = $('#comments'); if (!panel || !panel.classList.contains('aligned')) return;
|
|
2175
|
+
const rail = $('#rail'); const head = rail && rail.querySelector('.crail-head');
|
|
2176
|
+
const prect = panel.getBoundingClientRect();
|
|
2177
|
+
const gap = 10;
|
|
2178
|
+
// Floor at the header's BOTTOM in the column's own coordinate space (the header doesn't start at
|
|
2179
|
+
// the column's top — rail padding + header margins offset it), so no card ever slides under it.
|
|
2180
|
+
let prevBottom = head ? (head.getBoundingClientRect().bottom - prect.top + 8) : 0;
|
|
2181
|
+
for (const card of panel.querySelectorAll('.cnote')){
|
|
2182
|
+
const c = (S.comments || []).find(x => x.id === card.dataset.comment);
|
|
2183
|
+
const a = c && commentAnchor(c);
|
|
2184
|
+
let top = a && a.el ? (a.el.getBoundingClientRect().top - prect.top) : prevBottom;
|
|
2185
|
+
top = Math.max(top, prevBottom);
|
|
2186
|
+
card.style.top = top + 'px';
|
|
2187
|
+
prevBottom = top + card.getBoundingClientRect().height + gap;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
// Inline mode: an amber card island right after each block that carries comments — a
|
|
2191
|
+
// contenteditable=false island (like the diff's removed-line blocks, third colour), so it never
|
|
2192
|
+
// enters the editing model. Re-inserted on every render (renderLive wipes them first).
|
|
2193
|
+
function renderCommentIslands(open){
|
|
2194
|
+
const doc = $('#doc'); if (!doc) return;
|
|
2195
|
+
const groups = new Map();
|
|
2196
|
+
for (const c of open){ const a = commentAnchor(c); if (!a || !a.blk) continue; if (!groups.has(a.blk)) groups.set(a.blk, []); groups.get(a.blk).push(c); }
|
|
2197
|
+
for (const [blk, list] of groups){
|
|
2198
|
+
const island = document.createElement('div'); island.className = 'cnote-island'; island.contentEditable = 'false';
|
|
2199
|
+
island.innerHTML = list.map(c => commentCardHTML(c)).join('');
|
|
2200
|
+
blk.insertAdjacentElement('afterend', island);
|
|
2201
|
+
wireCommentCards(island);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
// Edit / Delete on a card; click/keyboard elsewhere on the card focuses its anchor.
|
|
2205
|
+
function wireCommentCards(root){
|
|
2206
|
+
root.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); deleteCommentUI(b.dataset.del); }));
|
|
2207
|
+
root.querySelectorAll('[data-edit]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); startEditComment(b.dataset.edit); }));
|
|
2208
|
+
root.querySelectorAll('.cnote').forEach(card => {
|
|
2209
|
+
card.addEventListener('click', (e) => { if (!e.target.closest('.cnote-act') && !card.classList.contains('editing')) focusComment(card.dataset.comment); });
|
|
2210
|
+
card.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !card.classList.contains('editing') && e.target === card){ e.preventDefault(); focusComment(card.dataset.comment); } });
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
// Inline-edit a comment's body: swap the card body for a textarea + Save/Cancel; on Save,
|
|
2214
|
+
// persist (POST /api/comment-edit) and update in place. The reader edits; the agent resolves.
|
|
2215
|
+
function startEditComment(id){
|
|
2216
|
+
const c = (S.comments || []).find(x => x.id === id); if (!c) return;
|
|
2217
|
+
const card = [...document.querySelectorAll('.cnote')].find(x => x.dataset.comment === id); if (!card || card.classList.contains('editing')) return;
|
|
2218
|
+
card.classList.add('editing');
|
|
2219
|
+
const bodyEl = card.querySelector('.cnote-b');
|
|
2220
|
+
const prev = c.body;
|
|
2221
|
+
bodyEl.innerHTML = `<textarea class="cnote-edit" rows="2">${esc(prev)}</textarea>
|
|
2222
|
+
<div class="cnote-editrow"><button type="button" class="cbtn ghost" data-cedit="cancel">Cancel</button><button type="button" class="cbtn primary" data-cedit="save">Save</button></div>`;
|
|
2223
|
+
const ta = bodyEl.querySelector('textarea'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
2224
|
+
positionRailComments(); // the card just grew — reflow the rail so later cards don't overlap
|
|
2225
|
+
ta.addEventListener('input', positionRailComments); // keep them clear as the textarea auto-grows while typing
|
|
2226
|
+
const finish = () => renderComments();
|
|
2227
|
+
ta.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Escape'){ e.preventDefault(); finish(); } if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ e.preventDefault(); save(); } });
|
|
2228
|
+
bodyEl.querySelector('[data-cedit="cancel"]').addEventListener('click', (e) => { e.stopPropagation(); finish(); });
|
|
2229
|
+
bodyEl.querySelector('[data-cedit="save"]').addEventListener('click', (e) => { e.stopPropagation(); save(); });
|
|
2230
|
+
async function save(){
|
|
2231
|
+
const val = ta.value.trim(); if (!val || val === prev){ finish(); return; }
|
|
2232
|
+
c.body = val; // optimistic; re-render shows it immediately
|
|
2233
|
+
finish();
|
|
2234
|
+
const file = S.recFile;
|
|
2235
|
+
const r = await api('POST', '/api/comment-edit', { commentId: id, body: val });
|
|
2236
|
+
if (S.recFile !== file) return;
|
|
2237
|
+
if (!r || r.error){ c.body = prev; renderComments(); showToast(r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.` : 'Could not save the edit.'); }
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
// Bring a comment into focus: scroll to + flash its anchored span, and mark its card active.
|
|
2241
|
+
// Replaces the old click-to-open popover — the card is already visible, so this just links the two.
|
|
2242
|
+
function focusComment(id){
|
|
2243
|
+
const c = (S.comments || []).find(x => x.id === id); if (!c) return;
|
|
2244
|
+
document.querySelectorAll('.cmark.on, .cmark-blk.on').forEach(x => x.classList.remove('on')); // clear the previous active anchor (inline OR block)
|
|
2245
|
+
const a = commentAnchor(c);
|
|
2246
|
+
if (a && a.el){
|
|
2247
|
+
try { a.el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'center' }); } catch {}
|
|
2248
|
+
a.el.classList && a.el.classList.add('on', 'hot'); setTimeout(() => a.el.classList && a.el.classList.remove('hot'), 1400);
|
|
2249
|
+
}
|
|
2250
|
+
document.querySelectorAll('.cnote.active').forEach(x => x.classList.remove('active'));
|
|
2251
|
+
const card = [...document.querySelectorAll('.cnote')].find(x => x.dataset.comment === id);
|
|
2252
|
+
if (card){ card.classList.add('active'); try { card.scrollIntoView({ block: 'nearest' }); } catch {} }
|
|
2253
|
+
}
|
|
2254
|
+
const openCommentPop = focusComment; // legacy callers (nav, marks) now link to the visible card
|
|
2255
|
+
// The comment count in the top controls changes as comments are created/resolved — keep
|
|
2256
|
+
// the navigator (and its prev/next) in sync without disturbing the rest of the controls.
|
|
2257
|
+
// View-aware: a brief uses briefControls(), a case uses caseControls().
|
|
2258
|
+
function refreshCommentNav(){
|
|
2259
|
+
setTopActions(S.brief ? briefControls() : S.readout ? readoutControls() : S.matchup ? matchupControls() : caseControls());
|
|
2260
|
+
wireCaseControls();
|
|
2261
|
+
if (!S.brief && !S.readout && !S.matchup) updateSaveDot();
|
|
2262
|
+
}
|
|
2263
|
+
function updateTocDots(){
|
|
2264
|
+
const toc = $('#toc'); if (!toc) return;
|
|
2265
|
+
const withC = sectionsWithComments();
|
|
2266
|
+
const live = liveHeadings();
|
|
2267
|
+
toc.querySelectorAll('a').forEach(a => {
|
|
2268
|
+
const has = withC.has(live[+a.dataset.hidx]); let dot = a.querySelector('.tdot');
|
|
2269
|
+
if (has && !dot){ dot = document.createElement('span'); dot.className = 'tdot'; dot.setAttribute('aria-label', 'has comments'); a.appendChild(dot); }
|
|
2270
|
+
else if (!has && dot) dot.remove();
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
// The always-available "+" add-comment handle (Notion-style) that tracks the hovered
|
|
2274
|
+
// block — the primary discoverability affordance so annotating isn't hidden behind a
|
|
2275
|
+
// text selection. Created once; positioned by onDocHoverComment.
|
|
2276
|
+
let addHandle = null;
|
|
2277
|
+
function ensureAddHandle(gutter){
|
|
2278
|
+
if (addHandle && addHandle.isConnected) return;
|
|
2279
|
+
addHandle = document.createElement('button'); addHandle.className = 'cadd'; addHandle.type = 'button';
|
|
2280
|
+
addHandle.title = 'Comment on this block'; addHandle.setAttribute('aria-label', 'Comment on this block');
|
|
2281
|
+
addHandle.innerHTML = iconEl(ICON.comment);
|
|
2282
|
+
addHandle.addEventListener('mousedown', (e) => e.preventDefault());
|
|
2283
|
+
addHandle.addEventListener('click', (e) => { e.stopPropagation(); if (addHandle._blk) commentOnBlock(addHandle._blk); });
|
|
2284
|
+
gutter.appendChild(addHandle);
|
|
2285
|
+
}
|
|
2286
|
+
function onDocHoverComment(e){
|
|
2287
|
+
if (!addHandle) return;
|
|
2288
|
+
const sel = window.getSelection && window.getSelection();
|
|
2289
|
+
if (sel && !sel.isCollapsed){ addHandle.classList.remove('show'); return; } // a selection owns the affordance — never show both
|
|
2290
|
+
const blk = blockOf(e.target); const wrap = $('#cgutter') && $('#cgutter').parentElement;
|
|
2291
|
+
if (!blk || !wrap || blk.classList.contains('cnote-island')){ addHandle.classList.remove('show'); return; }
|
|
2292
|
+
const r = blk.getBoundingClientRect(), wrapRect = wrap.getBoundingClientRect();
|
|
2293
|
+
addHandle._blk = blk;
|
|
2294
|
+
// Sit the handle in the left gutter, vertically centred on the block. Its RIGHT edge is kept a
|
|
2295
|
+
// clear gap before the text's left edge so it never renders over the words; the canvas carries a
|
|
2296
|
+
// wider left padding (a comment gutter) so there's always room for this. Floored at the container
|
|
2297
|
+
// edge, and — if the doc is ever too tight — nudged as far left as needed to avoid overlap.
|
|
2298
|
+
const size = 26, gap = 12, edge = 2;
|
|
2299
|
+
const textLeft = r.left - wrapRect.left;
|
|
2300
|
+
let left = Math.max(edge, textLeft - gap - size);
|
|
2301
|
+
if (left + size > textLeft - 4) left = Math.max(0, textLeft - 4 - size); // hard no-overlap guard
|
|
2302
|
+
addHandle.style.top = Math.max(0, r.top - wrapRect.top + (r.height - size) / 2) + 'px';
|
|
2303
|
+
addHandle.style.left = left + 'px';
|
|
2304
|
+
addHandle.classList.add('show');
|
|
2305
|
+
}
|
|
2306
|
+
function hideAddHandle(){ if (addHandle) addHandle.classList.remove('show'); }
|
|
2307
|
+
// Comment on a whole block (from the + handle): anchor on the block's text + section.
|
|
2308
|
+
function commentOnBlock(blk){
|
|
2309
|
+
if (!blk) return;
|
|
2310
|
+
const quote = (blk.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
2311
|
+
if (!quote) return;
|
|
2312
|
+
let rect; try { rect = blk.getBoundingClientRect(); } catch { rect = { left: 40, top: 80, bottom: 96 }; }
|
|
2313
|
+
pendingAnchor = { quote, heading: headingFor(blk), rect, blk, endBlk: blk, whole: true };
|
|
2314
|
+
openCommentComposer();
|
|
2315
|
+
}
|
|
2316
|
+
// Delete the reader's own comment outright (DELETE /api/comment) — it is removed from the
|
|
2317
|
+
// record, not resolved. The agent's resolution is a separate, agent-only action.
|
|
2318
|
+
async function deleteCommentUI(id){
|
|
2319
|
+
const file = S.recFile;
|
|
2320
|
+
const r = await api('DELETE', '/api/comment', { commentId: id });
|
|
2321
|
+
if (S.recFile !== file) return;
|
|
2322
|
+
if (r && r.ok){ dropComment(id); }
|
|
2323
|
+
else if (r && r.error === 'endpoint-missing') maybeStale();
|
|
2324
|
+
else showToast(commentErrorText(r));
|
|
2325
|
+
}
|
|
2326
|
+
// Remove a comment from the view (deleted by the reader, or resolved by the agent): drop
|
|
2327
|
+
// its state, unwrap its highlight, and re-render the layer / nav. Records the id so a LATE
|
|
2328
|
+
// SSE `comment` echo (which can arrive after a fast create→delete) can't resurrect it.
|
|
2329
|
+
const droppedComments = new Set();
|
|
2330
|
+
function dropComment(id){
|
|
2331
|
+
droppedComments.add(id);
|
|
2332
|
+
S.comments = (S.comments || []).filter(c => c.id !== id);
|
|
2333
|
+
const doc = $('#doc'); const mark = doc && doc.querySelector(`.cmark[data-comment="${id}"]`);
|
|
2334
|
+
if (mark){ const parent = mark.parentNode; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); parent.normalize && parent.normalize(); }
|
|
2335
|
+
const blkMark = doc && doc.querySelector(`.cmark-blk[data-comment="${id}"]`); // whole-block anchor: clear its highlight too
|
|
2336
|
+
if (blkMark){ blkMark.classList.remove('cmark-blk', 'on', 'hot'); delete blkMark.dataset.comment; }
|
|
2337
|
+
renderComments();
|
|
2338
|
+
}
|
|
2339
|
+
// The comments navigator (prev / next in the top controls): step through open comments in
|
|
2340
|
+
// document order, scrolling to each and opening its card.
|
|
2341
|
+
function stepComment(dir){
|
|
2342
|
+
const doc = $('#doc'); if (!doc) return;
|
|
2343
|
+
const ordered = openCmts().map(c => ({ c, a: commentAnchor(c) })).filter(x => x.a)
|
|
2344
|
+
.sort((p, q) => (p.a.el.compareDocumentPosition(q.a.el) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1);
|
|
2345
|
+
if (!ordered.length) return;
|
|
2346
|
+
S.cNavIdx = ((S.cNavIdx + dir) % ordered.length + ordered.length) % ordered.length;
|
|
2347
|
+
const target = ordered[S.cNavIdx];
|
|
2348
|
+
target.a.el.scrollIntoView && target.a.el.scrollIntoView({ behavior: prefersReduced() ? 'auto' : 'smooth', block: 'center' });
|
|
2349
|
+
openCommentPop(target.c.id);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// ---- the selection Comment affordance + composer --------------------------
|
|
2353
|
+
let askBtn = null, askPop = null, pendingAnchor = null, speech = null;
|
|
2354
|
+
function hideAsk(){ hideSelectionAffordance(); closeAskPop(); }
|
|
2355
|
+
function closeAskPop(){ if (speech){ try { speech.stop(); } catch {} speech = null; } if (askPop){ askPop.remove(); askPop = null; } }
|
|
2356
|
+
// The nearest preceding heading of a node — the section a comment is anchored in.
|
|
2357
|
+
function headingFor(node){
|
|
2358
|
+
let el = node.nodeType === 3 ? node.parentElement : node;
|
|
2359
|
+
const doc = $('#doc'); if (!doc || !el) return '';
|
|
2360
|
+
const heads = [...doc.querySelectorAll('h1,h2,h3,h4,h5,h6')];
|
|
2361
|
+
let best = '';
|
|
2362
|
+
for (const h of heads){ if (h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) best = h.textContent.trim(); else break; }
|
|
2363
|
+
return best;
|
|
2364
|
+
}
|
|
2365
|
+
function maybeShowAsk(doc){
|
|
2366
|
+
if (askPop) return; // already composing
|
|
2367
|
+
const sel = window.getSelection && window.getSelection();
|
|
2368
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount){ hideSelectionAffordance(); return; }
|
|
2369
|
+
const range = sel.getRangeAt(0);
|
|
2370
|
+
const root = $('#doc') || doc; // a selection may span sections → anchor on the whole document
|
|
2371
|
+
if (!root || !root.contains(range.commonAncestorContainer)){ hideSelectionAffordance(); return; }
|
|
2372
|
+
const quote = sel.toString().replace(/\s+/g, ' ').trim();
|
|
2373
|
+
if (quote.length < 2){ hideSelectionAffordance(); return; }
|
|
2374
|
+
let rect; try { rect = range.getBoundingClientRect(); } catch { rect = { left: 40, top: 80, bottom: 96 }; }
|
|
2375
|
+
pendingAnchor = { quote, heading: headingFor(range.startContainer), rect, blk: blockOf(range.startContainer), endBlk: blockOf(range.endContainer) };
|
|
2376
|
+
hideAddHandle(); // a selection owns the affordance — never show the gutter + handle at the same time
|
|
2377
|
+
// In edit mode the selection raises the formatting bubble (which carries its own
|
|
2378
|
+
// Comment button) — so the standalone Comment button never doubles up. Read-only
|
|
2379
|
+
// surfaces (briefs, a generating case) can't be formatted, so they keep the plain
|
|
2380
|
+
// Comment button.
|
|
2381
|
+
if (wysiwygCtl && wysiwygCtl.isEditable()) showBubble(rect);
|
|
2382
|
+
else showAskButton(rect);
|
|
2383
|
+
}
|
|
2384
|
+
// Tear down whichever selection affordance is up (the read-only Comment button OR the
|
|
2385
|
+
// edit-mode bubble). Called when the selection clears or a composer opens.
|
|
2386
|
+
function hideSelectionAffordance(){
|
|
2387
|
+
if (askBtn){ askBtn.remove(); askBtn = null; }
|
|
2388
|
+
hideBubble();
|
|
2389
|
+
}
|
|
2390
|
+
function showAskButton(rect){
|
|
2391
|
+
hideBubble();
|
|
2392
|
+
if (!askBtn){
|
|
2393
|
+
askBtn = document.createElement('button'); askBtn.className = 'askbtn';
|
|
2394
|
+
askBtn.innerHTML = iconEl(ICON.comment);
|
|
2395
|
+
askBtn.title = 'Comment'; askBtn.setAttribute('aria-label', 'Comment on selection');
|
|
2396
|
+
askBtn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
2397
|
+
askBtn.addEventListener('click', openCommentComposer);
|
|
2398
|
+
document.body.appendChild(askBtn);
|
|
2399
|
+
}
|
|
2400
|
+
const vw = window.innerWidth || 1200;
|
|
2401
|
+
askBtn.style.left = Math.max(8, Math.min(rect.left, vw - 40)) + 'px';
|
|
2402
|
+
askBtn.style.top = Math.max(8, (rect.top || 0) - 36) + 'px';
|
|
2403
|
+
}
|
|
2404
|
+
// The selection formatting bubble (edit mode only): bold / italic / link / H2, then a
|
|
2405
|
+
// Comment button — the same composer the read-only Comment button opens. Buttons drive
|
|
2406
|
+
// the editor through the controller (never Tiptap directly). `mousedown` is prevented so
|
|
2407
|
+
// clicking a button doesn't collapse the selection it acts on.
|
|
2408
|
+
let bubbleBar = null;
|
|
2409
|
+
function showBubble(rect){
|
|
2410
|
+
if (askBtn){ askBtn.remove(); askBtn = null; }
|
|
2411
|
+
const ctl = wysiwygCtl; if (!ctl) return;
|
|
2412
|
+
if (!bubbleBar){
|
|
2413
|
+
bubbleBar = document.createElement('div'); bubbleBar.className = 'bubblebar'; bubbleBar.setAttribute('role', 'toolbar'); bubbleBar.setAttribute('aria-label', 'Format selection');
|
|
2414
|
+
const btn = (bb, icon, label) => `<button type="button" data-bb="${bb}" class="bbtn" title="${label}" aria-label="${label}">${iconEl(icon)}</button>`;
|
|
2415
|
+
bubbleBar.innerHTML =
|
|
2416
|
+
btn('bold', ICON.bold, 'Bold') + btn('italic', ICON.italic, 'Italic') +
|
|
2417
|
+
btn('link', ICON.link, 'Link') + btn('h2', ICON.heading, 'Heading') +
|
|
2418
|
+
`<span class="bbsep"></span>` + btn('comment', ICON.comment, 'Comment');
|
|
2419
|
+
bubbleBar.addEventListener('mousedown', (e) => e.preventDefault());
|
|
2420
|
+
bubbleBar.addEventListener('click', onBubbleClick);
|
|
2421
|
+
document.body.appendChild(bubbleBar);
|
|
2422
|
+
}
|
|
2423
|
+
paintBubbleState();
|
|
2424
|
+
const vw = window.innerWidth || 1200;
|
|
2425
|
+
const w = bubbleBar.offsetWidth || 200;
|
|
2426
|
+
bubbleBar.style.left = Math.max(8, Math.min((rect.left || 40), vw - w - 8)) + 'px';
|
|
2427
|
+
bubbleBar.style.top = Math.max(8, (rect.top || 0) - 42) + 'px';
|
|
2428
|
+
bubbleBar.classList.add('show');
|
|
2429
|
+
}
|
|
2430
|
+
function paintBubbleState(){
|
|
2431
|
+
if (!bubbleBar || !wysiwygCtl) return;
|
|
2432
|
+
const on = (bb, active) => { const b = bubbleBar.querySelector(`[data-bb="${bb}"]`); if (b) b.classList.toggle('on', !!active); };
|
|
2433
|
+
on('bold', wysiwygCtl.isActive('bold'));
|
|
2434
|
+
on('italic', wysiwygCtl.isActive('italic'));
|
|
2435
|
+
on('link', wysiwygCtl.isActive('link'));
|
|
2436
|
+
on('h2', wysiwygCtl.isActive('heading', { level: 2 }));
|
|
2437
|
+
}
|
|
2438
|
+
function onBubbleClick(e){
|
|
2439
|
+
const b = e.target.closest && e.target.closest('[data-bb]'); if (!b || !wysiwygCtl) return;
|
|
2440
|
+
const kind = b.dataset.bb;
|
|
2441
|
+
if (kind === 'comment'){ openCommentComposer(); return; }
|
|
2442
|
+
if (kind === 'bold') wysiwygCtl.toggleBold();
|
|
2443
|
+
else if (kind === 'italic') wysiwygCtl.toggleItalic();
|
|
2444
|
+
else if (kind === 'h2') wysiwygCtl.toggleHeading(2);
|
|
2445
|
+
else if (kind === 'link'){
|
|
2446
|
+
const cur = wysiwygCtl.isActive('link');
|
|
2447
|
+
const href = cur ? '' : (window.prompt && window.prompt('Link URL'));
|
|
2448
|
+
if (href === null || href === undefined) return; // cancelled
|
|
2449
|
+
wysiwygCtl.setLink(href);
|
|
2450
|
+
}
|
|
2451
|
+
paintBubbleState();
|
|
2452
|
+
}
|
|
2453
|
+
function hideBubble(){ if (bubbleBar){ bubbleBar.remove(); bubbleBar = null; } }
|
|
2454
|
+
// Open the comment composer on the block the caret sits in — the "@agent" mention's
|
|
2455
|
+
// landing (see at-mention.mjs). Reuses commentOnBlock so the anchor matches the + handle.
|
|
2456
|
+
function openAtComment(){
|
|
2457
|
+
const sel = window.getSelection && window.getSelection();
|
|
2458
|
+
const node = sel && sel.rangeCount ? (sel.anchorNode || sel.getRangeAt(0).startContainer) : null;
|
|
2459
|
+
const blk = node ? blockOf(node) : null;
|
|
2460
|
+
if (blk) commentOnBlock(blk);
|
|
2461
|
+
}
|
|
2462
|
+
// The in-table toolbar (edit mode only): add/remove rows & columns, or drop the table.
|
|
2463
|
+
// Shown whenever the caret is inside a table; hidden otherwise. Table structure editing
|
|
2464
|
+
// is a library capability — this only surfaces it.
|
|
2465
|
+
let tableTools = null;
|
|
2466
|
+
function maybeShowTableTools(){
|
|
2467
|
+
const ctl = wysiwygCtl;
|
|
2468
|
+
if (!ctl || !ctl.isEditable() || !ctl.isActive('table')){ hideTableTools(); return; }
|
|
2469
|
+
const sel = window.getSelection && window.getSelection();
|
|
2470
|
+
const node = sel && sel.rangeCount ? (sel.anchorNode || sel.getRangeAt(0).startContainer) : null;
|
|
2471
|
+
const el = node ? (node.nodeType === 3 ? node.parentElement : node) : null;
|
|
2472
|
+
const table = el && el.closest ? el.closest('#doc .ProseMirror table') : null;
|
|
2473
|
+
if (!table){ hideTableTools(); return; }
|
|
2474
|
+
showTableTools(table);
|
|
2475
|
+
}
|
|
2476
|
+
function showTableTools(table){
|
|
2477
|
+
if (!tableTools){
|
|
2478
|
+
tableTools = document.createElement('div'); tableTools.className = 'tabletools'; tableTools.setAttribute('role', 'toolbar'); tableTools.setAttribute('aria-label', 'Edit table');
|
|
2479
|
+
const b = (tt, label) => `<button type="button" data-tt="${tt}" class="ttbtn" title="${label}" aria-label="${label}">${label}</button>`;
|
|
2480
|
+
tableTools.innerHTML =
|
|
2481
|
+
b('row+', '+ Row') + b('col+', '+ Col') + b('row-', '- Row') + b('col-', '- Col') +
|
|
2482
|
+
`<span class="ttsep"></span>` +
|
|
2483
|
+
`<button type="button" data-tt="del" class="ttbtn danger" title="Delete table" aria-label="Delete table">${iconEl(ICON.trash)}</button>`;
|
|
2484
|
+
tableTools.addEventListener('mousedown', (e) => e.preventDefault());
|
|
2485
|
+
tableTools.addEventListener('click', onTableToolsClick);
|
|
2486
|
+
document.body.appendChild(tableTools);
|
|
2487
|
+
}
|
|
2488
|
+
let r; try { r = table.getBoundingClientRect(); } catch { r = { left: 40, top: 80 }; }
|
|
2489
|
+
const vw = window.innerWidth || 1200, w = tableTools.offsetWidth || 220;
|
|
2490
|
+
tableTools.style.left = Math.max(8, Math.min((r.left || 40), vw - w - 8)) + 'px';
|
|
2491
|
+
tableTools.style.top = Math.max(8, (r.top || 0) - 40) + 'px';
|
|
2492
|
+
tableTools.classList.add('show');
|
|
2493
|
+
}
|
|
2494
|
+
function onTableToolsClick(e){
|
|
2495
|
+
const b = e.target.closest && e.target.closest('[data-tt]'); if (!b || !wysiwygCtl) return;
|
|
2496
|
+
const tt = b.dataset.tt;
|
|
2497
|
+
if (tt === 'row+') wysiwygCtl.addRow();
|
|
2498
|
+
else if (tt === 'col+') wysiwygCtl.addColumn();
|
|
2499
|
+
else if (tt === 'row-') wysiwygCtl.deleteRow();
|
|
2500
|
+
else if (tt === 'col-') wysiwygCtl.deleteColumn();
|
|
2501
|
+
else if (tt === 'del'){ wysiwygCtl.deleteTable(); hideTableTools(); return; }
|
|
2502
|
+
requestAnimationFrame(maybeShowTableTools); // reposition after the structure changed
|
|
2503
|
+
}
|
|
2504
|
+
function hideTableTools(){ if (tableTools){ tableTools.remove(); tableTools = null; } }
|
|
2505
|
+
function openCommentComposer(){
|
|
2506
|
+
if (!pendingAnchor) return;
|
|
2507
|
+
hideSelectionAffordance();
|
|
2508
|
+
askPop = document.createElement('div'); askPop.className = 'askpop';
|
|
2509
|
+
const mic = supportsSpeech() ? `<button class="mic" id="askmic" title="Dictate" aria-label="Dictate">${iconEl(ICON.mic)}</button>` : '';
|
|
2510
|
+
askPop.innerHTML = `
|
|
2511
|
+
<div class="askhd">${iconEl(ICON.comment)}<span>Leave a comment for the agent to address</span></div>
|
|
2512
|
+
<div class="aq">${esc(pendingAnchor.quote)}</div>
|
|
2513
|
+
<textarea id="askta" rows="3" placeholder="A question or a change to make…"></textarea>
|
|
2514
|
+
<div class="arow">${mic}<span class="sp"></span>
|
|
2515
|
+
<button class="ghost" id="askcancel">Cancel</button>
|
|
2516
|
+
<button class="send" id="asksend">${iconEl(ICON.send)}Comment</button>
|
|
2517
|
+
</div>`;
|
|
2518
|
+
document.body.appendChild(askPop);
|
|
2519
|
+
const vw = window.innerWidth || 1200, vh = window.innerHeight || 800;
|
|
2520
|
+
const w = askPop.offsetWidth || 340, h = askPop.offsetHeight || 160;
|
|
2521
|
+
const r = pendingAnchor.rect;
|
|
2522
|
+
askPop.style.left = Math.max(8, Math.min((r.left || 40), vw - w - 8)) + 'px';
|
|
2523
|
+
askPop.style.top = Math.min((r.bottom || 96) + 8, vh - h - 8) + 'px';
|
|
2524
|
+
$('#askcancel').addEventListener('click', hideAsk);
|
|
2525
|
+
$('#asksend').addEventListener('click', submitComment);
|
|
2526
|
+
const ta = $('#askta');
|
|
2527
|
+
ta.addEventListener('keydown', (e) => {
|
|
2528
|
+
if (e.key === 'Escape'){ e.preventDefault(); hideAsk(); }
|
|
2529
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ e.preventDefault(); submitComment(); }
|
|
2530
|
+
});
|
|
2531
|
+
const mb = $('#askmic'); if (mb) mb.addEventListener('click', toggleMic);
|
|
2532
|
+
ta.focus();
|
|
2533
|
+
}
|
|
2534
|
+
async function submitComment(){
|
|
2535
|
+
const ta = $('#askta'); const body = (ta && ta.value || '').trim();
|
|
2536
|
+
if (!body || !pendingAnchor) return;
|
|
2537
|
+
const { line, endLine } = anchorSourceRange(pendingAnchor); // the source LINE RANGE the anchor spans (robust anchor)
|
|
2538
|
+
const anchor = { quote: pendingAnchor.quote.slice(0, 300), heading: pendingAnchor.heading, line, endLine };
|
|
2539
|
+
hideAsk();
|
|
2540
|
+
try { const sel = window.getSelection && window.getSelection(); sel && sel.removeAllRanges(); } catch {}
|
|
2541
|
+
await createComment(anchor.quote, anchor.heading, body, line, endLine);
|
|
2542
|
+
}
|
|
2543
|
+
// The source LINE RANGE [line, endLine] (0-based) a pending comment anchors to — the primary,
|
|
2544
|
+
// content-independent anchor (robust to duplicate headings / edited-away quotes). Derived from the
|
|
2545
|
+
// rendered block(s) the selection (or +-handle block) covers. { -1, -1 } when it can't be resolved
|
|
2546
|
+
// (e.g. a surface with no source-line model).
|
|
2547
|
+
function anchorSourceRange(pa){
|
|
2548
|
+
const start = pa && pa.blk ? sourceLineRange(pa.blk) : null;
|
|
2549
|
+
const end = pa && pa.endBlk ? sourceLineRange(pa.endBlk) : null;
|
|
2550
|
+
if (!start && !end) return { line: -1, endLine: -1 };
|
|
2551
|
+
const s = start || end, e = end || start;
|
|
2552
|
+
return { line: s.line, endLine: Math.max(s.endLine, e.endLine) };
|
|
2553
|
+
}
|
|
2554
|
+
// The 0-based source-line range a rendered block spans. Cases/files use the live S.items model
|
|
2555
|
+
// (file-absolute lines); briefs map the rendered block's position to the source it was rendered
|
|
2556
|
+
// from. null when the surface has no source-line model.
|
|
2557
|
+
function sourceLineRange(blk){
|
|
2558
|
+
if (!blk) return null;
|
|
2559
|
+
if (blk.dataset && blk.dataset.i != null && S.items && S.items.length){
|
|
2560
|
+
const i = +blk.dataset.i; const len = ((S.items[i] && S.items[i].lines) || ['']).length;
|
|
2561
|
+
const line = blockStartLine(i); return { line, endLine: line + Math.max(0, len - 1) };
|
|
2562
|
+
}
|
|
2563
|
+
if (S.brief || S.readout || S.matchup){ const map = briefBlockLineMap(); const idx = briefBlockIndex(blk); return (map && idx >= 0 && idx < map.length) ? map[idx] : null; }
|
|
2564
|
+
return null;
|
|
2565
|
+
}
|
|
2566
|
+
// The rendered block-children of a brief (in document order), excluding the diff/hint/comment
|
|
2567
|
+
// islands — a 1:1 match with briefBlockLineMap()'s source blocks.
|
|
2568
|
+
function briefBlockKids(){
|
|
2569
|
+
const doc = $('#doc'); if (!doc) return [];
|
|
2570
|
+
return [...doc.children].filter(el => el.nodeType === 1 && !el.classList.contains('diffdel') && !el.classList.contains('banner') && !el.classList.contains('cnote-island'));
|
|
2571
|
+
}
|
|
2572
|
+
function briefBlockIndex(blk){ return briefBlockKids().indexOf(blk); }
|
|
2573
|
+
// Per-rendered-block source line ranges for a brief, from the same segmentation the diff uses, so
|
|
2574
|
+
// index N of the map lines up with the Nth rendered block. Lines are relative to the rendered
|
|
2575
|
+
// (frontmatter-stripped) brief body.
|
|
2576
|
+
function briefBlockLineMap(){
|
|
2577
|
+
const doc = S.brief || S.readout || S.matchup;
|
|
2578
|
+
if (!doc) return null;
|
|
2579
|
+
const items = segment(stripFrontmatter(doc.text || ''));
|
|
2580
|
+
const map = []; let acc = 0;
|
|
2581
|
+
for (const it of items){
|
|
2582
|
+
const src = (it.lines || []).join('\n');
|
|
2583
|
+
if (it.kind === 'block' && !isMetaOnly(src)) map.push({ line: acc, endLine: acc + Math.max(0, (it.lines || []).length - 1) });
|
|
2584
|
+
acc += (it.lines || []).length;
|
|
2585
|
+
}
|
|
2586
|
+
return map;
|
|
2587
|
+
}
|
|
2588
|
+
function briefBlockAtLine(line){
|
|
2589
|
+
const map = briefBlockLineMap(); if (!map) return -1;
|
|
2590
|
+
for (let i = 0; i < map.length; i++){ if (line >= map[i].line && line <= map[i].endLine) return i; }
|
|
2591
|
+
return -1;
|
|
2592
|
+
}
|
|
2593
|
+
// Create an in-record comment on the current file: POST to the bridge, then reflect it
|
|
2594
|
+
// inline. On a stale server (404) surface the version-skew banner rather than a vanish.
|
|
2595
|
+
async function createComment(quote, heading, body, line, endLine){
|
|
2596
|
+
const file = S.recFile; if (!file) return;
|
|
2597
|
+
const r = await api('POST', '/api/comment', { file, quote, heading, body, line, endLine });
|
|
2598
|
+
if (S.recFile !== file) return;
|
|
2599
|
+
if (r && r.ok && r.commentId){
|
|
2600
|
+
// The SSE `comment` broadcast can arrive before this POST resolves, so dedup: only
|
|
2601
|
+
// add if that push hasn't already landed it (otherwise the same comment counts twice).
|
|
2602
|
+
if (!(S.comments || []).some(x => x.id === r.commentId))
|
|
2603
|
+
S.comments = [...(S.comments || []), { id: r.commentId, file: r.file, anchor: { quote, heading, line, endLine }, body, status: 'open', ts: Date.now() }];
|
|
2604
|
+
renderComments();
|
|
2605
|
+
requestAnimationFrame(() => { positionRailComments(); focusComment(r.commentId); }); // co-locate the new card once its highlight is laid out
|
|
2606
|
+
} else {
|
|
2607
|
+
if (r && r.error === 'endpoint-missing') maybeStale();
|
|
2608
|
+
else showToast(commentErrorText(r));
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
function commentErrorText(r){
|
|
2612
|
+
if (!r || r.netError) return 'Couldn’t reach the workbench — is it still running?';
|
|
2613
|
+
if (r.error === 'invalid file' || r.error === 'no file') return 'Couldn’t save — this file can’t be annotated.';
|
|
2614
|
+
if (r.error === 'no project') return 'Couldn’t save — no project is open.';
|
|
2615
|
+
if (r.error) return 'Couldn’t save — ' + r.error + '.';
|
|
2616
|
+
return 'Couldn’t save the comment.';
|
|
2617
|
+
}
|
|
2618
|
+
// Voice input via the browser Web Speech API (progressive enhancement — the mic only
|
|
2619
|
+
// appears when supported). Click to dictate into the composer; click again to stop.
|
|
2620
|
+
function supportsSpeech(){ return !!(window.SpeechRecognition || window.webkitSpeechRecognition); }
|
|
2621
|
+
function toggleMic(){
|
|
2622
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return;
|
|
2623
|
+
const btn = $('#askmic'), ta = $('#askta');
|
|
2624
|
+
if (speech){ try { speech.stop(); } catch {} speech = null; btn && btn.classList.remove('rec'); return; }
|
|
2625
|
+
speech = new SR(); speech.lang = 'en-US'; speech.interimResults = false;
|
|
2626
|
+
speech.onresult = (e) => { const tx = [...e.results].map(x => x[0].transcript).join(' ').trim(); if (ta && tx) ta.value = (ta.value ? ta.value + ' ' : '') + tx; };
|
|
2627
|
+
speech.onend = () => { btn && btn.classList.remove('rec'); speech = null; };
|
|
2628
|
+
speech.onerror = () => { btn && btn.classList.remove('rec'); speech = null; };
|
|
2629
|
+
try { speech.start(); btn && btn.classList.add('rec'); } catch { speech = null; }
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// ---- stale-server (version skew) banner -----------------------------------
|
|
2633
|
+
// Shown when the workbench is served by an out-of-date `serve` process (new UI, old
|
|
2634
|
+
// routes). Idempotent + dismissible; tells the user the one thing that fixes it.
|
|
2635
|
+
let staleShown = false;
|
|
2636
|
+
function maybeStale(){ if (staleShown) return; staleShown = true; showStaleBar(); }
|
|
2637
|
+
function showStaleBar(){
|
|
2638
|
+
if ($('#stalebar')) return;
|
|
2639
|
+
const bar = document.createElement('div');
|
|
2640
|
+
bar.id = 'stalebar'; bar.className = 'stalebar';
|
|
2641
|
+
bar.innerHTML = `<span>This editor is out of date — restart <code>${esc(SERVE_CMD)}</code> to reconnect the comment bridge.</span><button class="x" aria-label="Dismiss">${iconEl(ICON.x)}</button>`;
|
|
2642
|
+
document.body.appendChild(bar);
|
|
2643
|
+
bar.querySelector('.x').addEventListener('click', () => bar.remove());
|
|
2644
|
+
}
|
|
2645
|
+
// A brief transient message (comment save error, agent addressing) — auto-dismisses.
|
|
2646
|
+
let toastTimer = null;
|
|
2647
|
+
function showToast(text, opts){
|
|
2648
|
+
let t = $('#dlbtoast');
|
|
2649
|
+
if (!t){ t = document.createElement('div'); t.id = 'dlbtoast'; t.className = 'caddr'; document.body.appendChild(t); }
|
|
2650
|
+
t.innerHTML = (opts && opts.spin ? `<span class="spin"></span>` : '') + `<span>${esc(text)}</span>`;
|
|
2651
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.remove(); }, (opts && opts.ms) || 3600);
|
|
2652
|
+
}
|
|
2653
|
+
// A reusable, accessible confirmation dialog for destructive actions: a native <dialog>
|
|
2654
|
+
// (focus-trapped + Escape-to-cancel + backdrop-click cancels), returning a Promise<boolean>.
|
|
2655
|
+
function confirmDialog({ title, body, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false }){
|
|
2656
|
+
return new Promise((resolve) => {
|
|
2657
|
+
const dlg = document.createElement('dialog');
|
|
2658
|
+
dlg.className = 'confirm';
|
|
2659
|
+
dlg.innerHTML = `<form method="dialog" class="confirm-in">
|
|
2660
|
+
<h2>${esc(title)}</h2>
|
|
2661
|
+
<div class="confirm-b">${body}</div>
|
|
2662
|
+
<div class="confirm-actions">
|
|
2663
|
+
<button type="button" class="cbtn ghost" data-x="0">${esc(cancelLabel)}</button>
|
|
2664
|
+
<button type="submit" class="cbtn ${danger ? 'danger' : 'primary'}" data-x="1">${esc(confirmLabel)}</button>
|
|
2665
|
+
</div>
|
|
2666
|
+
</form>`;
|
|
2667
|
+
document.body.appendChild(dlg);
|
|
2668
|
+
let done = false;
|
|
2669
|
+
const finish = (v) => { if (done) return; done = true; try { dlg.close(); } catch {} dlg.remove(); resolve(v); };
|
|
2670
|
+
dlg.querySelector('[data-x="0"]').addEventListener('click', () => finish(false));
|
|
2671
|
+
dlg.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); finish(true); });
|
|
2672
|
+
dlg.addEventListener('cancel', (e) => { e.preventDefault(); finish(false); }); // Escape
|
|
2673
|
+
dlg.addEventListener('click', (e) => { if (e.target === dlg) finish(false); }); // backdrop
|
|
2674
|
+
try { dlg.showModal(); } catch { dlg.setAttribute('open', ''); } // jsdom / no <dialog>
|
|
2675
|
+
(dlg.querySelector('[data-x="1"]') || dlg).focus();
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
// A reusable single-line text-input dialog (native <dialog>, focus-trapped, Escape/backdrop
|
|
2679
|
+
// cancels) → Promise<string|null>. Used to name a new file. An optional `prefix` shows the
|
|
2680
|
+
// fixed folder the name is created under (read-only context, not part of the returned value).
|
|
2681
|
+
function promptDialog({ title, label, placeholder = '', value = '', prefix = '', confirmLabel = 'Create', selectEnd = null }){
|
|
2682
|
+
return new Promise((resolve) => {
|
|
2683
|
+
const dlg = document.createElement('dialog');
|
|
2684
|
+
dlg.className = 'confirm prompt';
|
|
2685
|
+
dlg.innerHTML = `<form method="dialog" class="confirm-in">
|
|
2686
|
+
<h2>${esc(title)}</h2>
|
|
2687
|
+
${prefix ? `<div class="prompt-path"><span class="prompt-path-cap">In</span><span class="prompt-path-val">${esc(prefix.replace(/\/$/, ''))}/</span></div>` : ''}
|
|
2688
|
+
<label class="prompt-l">${esc(label)}
|
|
2689
|
+
<span class="prompt-row"><input class="prompt-i" type="text" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" spellcheck="false"></span>
|
|
2690
|
+
</label>
|
|
2691
|
+
<div class="confirm-actions">
|
|
2692
|
+
<button type="button" class="cbtn ghost" data-x="0">Cancel</button>
|
|
2693
|
+
<button type="submit" class="cbtn primary" data-x="1">${esc(confirmLabel)}</button>
|
|
2694
|
+
</div>
|
|
2695
|
+
</form>`;
|
|
2696
|
+
document.body.appendChild(dlg);
|
|
2697
|
+
const input = dlg.querySelector('.prompt-i');
|
|
2698
|
+
let done = false;
|
|
2699
|
+
const finish = (v) => { if (done) return; done = true; try { dlg.close(); } catch {} dlg.remove(); resolve(v); };
|
|
2700
|
+
dlg.querySelector('[data-x="0"]').addEventListener('click', () => finish(null));
|
|
2701
|
+
dlg.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); const val = input.value.trim(); finish(val || null); });
|
|
2702
|
+
dlg.addEventListener('cancel', (e) => { e.preventDefault(); finish(null); }); // Escape
|
|
2703
|
+
dlg.addEventListener('click', (e) => { if (e.target === dlg) finish(null); }); // backdrop
|
|
2704
|
+
try { dlg.showModal(); } catch { dlg.setAttribute('open', ''); } // jsdom / no <dialog>
|
|
2705
|
+
// Focus the field; when renaming, pre-select the editable part (the name without its
|
|
2706
|
+
// extension) so a retype keeps `.md` — otherwise leave the caret at the end.
|
|
2707
|
+
setTimeout(() => { try { input.focus(); if (selectEnd != null) input.setSelectionRange(0, selectEnd); } catch {} }, 0);
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
// A server-side folder browser → Promise<string|null> (the chosen absolute path). A browser
|
|
2711
|
+
// can't return a real filesystem path from a native picker, and the daemon needs one — so the
|
|
2712
|
+
// daemon lists directories (/api/browse) and the user drills in:
|
|
2713
|
+
// click a folder to enter it, the breadcrumb / Backspace to go up, then "Open this folder".
|
|
2714
|
+
function folderDialog(startPath){
|
|
2715
|
+
return new Promise((resolve) => {
|
|
2716
|
+
const opener = document.activeElement; // restore focus on close (WCAG)
|
|
2717
|
+
const dlg = document.createElement('dialog');
|
|
2718
|
+
dlg.className = 'confirm folderpick';
|
|
2719
|
+
dlg.innerHTML = `<div class="confirm-in fp">
|
|
2720
|
+
<div class="fp-head">
|
|
2721
|
+
<h2>Open folder</h2>
|
|
2722
|
+
<nav class="fp-crumbs" aria-label="Folder path"></nav>
|
|
2723
|
+
</div>
|
|
2724
|
+
<div class="fp-body"><ul class="fp-list" aria-label="Sub-folders"></ul></div>
|
|
2725
|
+
<div class="fp-foot">
|
|
2726
|
+
<div class="confirm-actions">
|
|
2727
|
+
<button type="button" class="cbtn ghost" data-x="0">Cancel</button>
|
|
2728
|
+
<button type="button" class="cbtn primary" data-x="1">Open this folder</button>
|
|
2729
|
+
</div>
|
|
2730
|
+
</div>
|
|
2731
|
+
</div>`;
|
|
2732
|
+
document.body.appendChild(dlg);
|
|
2733
|
+
const crumbs = dlg.querySelector('.fp-crumbs');
|
|
2734
|
+
const list = dlg.querySelector('.fp-list');
|
|
2735
|
+
const body = dlg.querySelector('.fp-body');
|
|
2736
|
+
const openBtn = dlg.querySelector('[data-x="1"]');
|
|
2737
|
+
let cur = null, home = null, parentPath = null, sel = -1, rows = [], done = false;
|
|
2738
|
+
const finish = (v) => { if (done) return; done = true; try { dlg.close(); } catch {} dlg.remove(); try { opener && opener.focus(); } catch {} resolve(v); };
|
|
2739
|
+
const sep = () => `<span class="fp-sep">${iconEl(ICON.chevron)}</span>`;
|
|
2740
|
+
|
|
2741
|
+
function renderCrumbs(path){
|
|
2742
|
+
const segs = path.split('/').filter(Boolean);
|
|
2743
|
+
let acc = '';
|
|
2744
|
+
const parts = [`<button type="button" class="fp-crumb" data-path="${esc(home || '')}">Home</button>`];
|
|
2745
|
+
segs.forEach((s, i) => { acc += '/' + s; parts.push(sep(), `<button type="button" class="fp-crumb" data-path="${esc(acc)}"${i === segs.length - 1 ? ' aria-current="true"' : ''}>${esc(s)}</button>`); });
|
|
2746
|
+
crumbs.innerHTML = parts.join('');
|
|
2747
|
+
crumbs.querySelectorAll('.fp-crumb').forEach(b => b.addEventListener('click', () => { const p = b.dataset.path || null; if (p !== cur) load(p); }));
|
|
2748
|
+
}
|
|
2749
|
+
function renderRows(entries){
|
|
2750
|
+
rows = entries; sel = -1;
|
|
2751
|
+
if (!entries.length){ list.innerHTML = `<li class="fp-empty">No sub-folders here. Open this folder, or go up.</li>`; return; }
|
|
2752
|
+
list.innerHTML = entries.map((e, i) =>
|
|
2753
|
+
`<li><button type="button" class="fp-row" data-i="${i}" title="${esc(e.name)}">
|
|
2754
|
+
<span class="fp-fico">${iconEl(ICON.folder)}</span><span class="fp-name">${esc(e.name)}</span><span class="fp-go">${iconEl(ICON.chevron)}</span>
|
|
2755
|
+
</button></li>`).join('');
|
|
2756
|
+
list.querySelectorAll('.fp-row').forEach(r => r.addEventListener('click', () => load(entries[+r.dataset.i].path)));
|
|
2757
|
+
}
|
|
2758
|
+
async function load(path){
|
|
2759
|
+
dlg.classList.add('fp-loading');
|
|
2760
|
+
let r; try { r = await api('GET', '/api/browse' + (path ? '?path=' + encodeURIComponent(path) : '')); } catch { r = { error: 'offline' }; }
|
|
2761
|
+
dlg.classList.remove('fp-loading');
|
|
2762
|
+
if (!r || r.error){
|
|
2763
|
+
if (cur == null){ crumbs.innerHTML = ''; list.innerHTML = `<li class="fp-empty">Couldn’t read that folder.</li>`; }
|
|
2764
|
+
else showToast('Couldn’t open that folder.');
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
cur = r.path; home = r.home || home; parentPath = r.parent || null;
|
|
2768
|
+
renderCrumbs(cur); renderRows(r.entries); body.scrollTop = 0;
|
|
2769
|
+
}
|
|
2770
|
+
dlg.addEventListener('keydown', (e) => {
|
|
2771
|
+
if (e.key === 'Escape'){ e.preventDefault(); return finish(null); }
|
|
2772
|
+
if (e.key === 'Backspace'){ e.preventDefault(); if (parentPath) load(parentPath); return; }
|
|
2773
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){
|
|
2774
|
+
e.preventDefault(); if (!rows.length) return;
|
|
2775
|
+
sel = e.key === 'ArrowDown' ? Math.min(rows.length - 1, sel + 1) : Math.max(0, sel < 0 ? 0 : sel - 1);
|
|
2776
|
+
const els = list.querySelectorAll('.fp-row'); els.forEach(x => x.classList.remove('sel'));
|
|
2777
|
+
const el = els[sel]; if (el){ el.classList.add('sel'); el.scrollIntoView({ block: 'nearest' }); el.focus(); }
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (e.key === 'Enter' && sel >= 0 && rows[sel]){ e.preventDefault(); load(rows[sel].path); }
|
|
2781
|
+
});
|
|
2782
|
+
dlg.querySelector('[data-x="0"]').addEventListener('click', () => finish(null));
|
|
2783
|
+
openBtn.addEventListener('click', () => finish(cur));
|
|
2784
|
+
dlg.addEventListener('cancel', (e) => { e.preventDefault(); finish(null); }); // Escape (native)
|
|
2785
|
+
dlg.addEventListener('click', (e) => { if (e.target === dlg) finish(null); }); // backdrop
|
|
2786
|
+
try { dlg.showModal(); } catch { dlg.setAttribute('open', ''); }
|
|
2787
|
+
load(startPath || null);
|
|
2788
|
+
setTimeout(() => { try { openBtn.focus(); } catch {} }, 0);
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
// ---- Explorer file management (create / delete) ---------------------------
|
|
2792
|
+
// Create a new markdown file, optionally under `dir` (a project-relative folder, or the
|
|
2793
|
+
// content root from the header "New file"). `.md` is appended when omitted. On success the
|
|
2794
|
+
// The New file / New folder chooser — shared by the Explorer header + button and each folder
|
|
2795
|
+
// row's + action, so both offer the same two options anchored under the clicked button.
|
|
2796
|
+
function openNewMenu(dir, x, y){
|
|
2797
|
+
openMenu(x, y, [
|
|
2798
|
+
{ label: 'New file…', icon: 'newfile', act: () => promptNewFile(dir) },
|
|
2799
|
+
{ label: 'New folder…', icon: 'newfolder', act: () => promptNewFolder(dir) },
|
|
2800
|
+
]);
|
|
2801
|
+
}
|
|
2802
|
+
// tree refreshes and the new file opens (pinned) for editing.
|
|
2803
|
+
async function promptNewFile(dir){
|
|
2804
|
+
const prefix = dir ? dir.replace(/\/?$/, '/') : '';
|
|
2805
|
+
const name = await promptDialog({ title: 'New file', label: 'File name', placeholder: 'notes.md', prefix });
|
|
2806
|
+
if (name == null) return;
|
|
2807
|
+
let rel = (prefix + name).replace(/\/{2,}/g, '/').replace(/^\//, '').trim();
|
|
2808
|
+
if (!rel) return;
|
|
2809
|
+
if (!/\.md$/i.test(rel)) rel += '.md';
|
|
2810
|
+
const r = await api('POST', '/api/create', { path: rel, text: '' });
|
|
2811
|
+
if (!r || r.error){
|
|
2812
|
+
showToast(r && r.error === 'exists' ? 'A file with that name already exists.'
|
|
2813
|
+
: r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
|
|
2814
|
+
: 'Could not create the file.');
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
TEL.event('ui.action', { area:'explorer', action:'create-file' });
|
|
2818
|
+
await refreshTree();
|
|
2819
|
+
openFileTab(r.path, { newTab: true });
|
|
2820
|
+
}
|
|
2821
|
+
// Create a new folder, optionally under `dir`. A folder is shown in the Explorer only once it
|
|
2822
|
+
// holds a Markdown file (the tree shows exactly the folders on a path to a `.md`), so creating a
|
|
2823
|
+
// folder flows straight into naming its first file — otherwise the new folder would be invisible.
|
|
2824
|
+
// Cancelling the file step leaves an empty folder on disk that simply doesn't appear until it has
|
|
2825
|
+
// content.
|
|
2826
|
+
async function promptNewFolder(dir){
|
|
2827
|
+
const prefix = dir ? dir.replace(/\/?$/, '/') : '';
|
|
2828
|
+
const name = await promptDialog({ title: 'New folder', label: 'Folder name', placeholder: 'ideas', prefix });
|
|
2829
|
+
if (name == null) return;
|
|
2830
|
+
const rel = (prefix + name).replace(/\/{2,}/g, '/').replace(/^\//, '').replace(/\/$/, '').trim();
|
|
2831
|
+
if (!rel) return;
|
|
2832
|
+
const r = await api('POST', '/api/mkdir', { path: rel });
|
|
2833
|
+
if (!r || r.error){
|
|
2834
|
+
showToast(r && r.error === 'exists' ? 'A folder with that name already exists.'
|
|
2835
|
+
: r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.`
|
|
2836
|
+
: 'Could not create the folder.');
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
TEL.event('ui.action', { area:'explorer', action:'create-folder' });
|
|
2840
|
+
if (dir) ensureFolderOpen(dir); // reveal the parent
|
|
2841
|
+
ensureFolderOpen(r.path); // and pre-open the new folder
|
|
2842
|
+
await promptNewFile(r.path); // name its first file so the folder appears
|
|
2843
|
+
}
|
|
2844
|
+
// Delete a file or a folder (recursive) from the Explorer — destructive, so it confirms
|
|
2845
|
+
// first. On success: close any tabs pointing inside it, refresh the tree, and toast.
|
|
2846
|
+
async function confirmDeleteEntry(path, isDir){
|
|
2847
|
+
if (!path) return;
|
|
2848
|
+
const ok = await confirmDialog({
|
|
2849
|
+
title: isDir ? 'Delete folder?' : 'Delete file?',
|
|
2850
|
+
body: isDir
|
|
2851
|
+
? `This permanently deletes the folder <b>${esc(baseName(path))}</b> and everything inside it. This can’t be undone.`
|
|
2852
|
+
: `This permanently deletes <b>${esc(baseName(path))}</b>. This can’t be undone.`,
|
|
2853
|
+
confirmLabel: 'Delete', danger: true,
|
|
2854
|
+
});
|
|
2855
|
+
if (!ok) return;
|
|
2856
|
+
const r = await api('DELETE', '/api/file', { path });
|
|
2857
|
+
if (!r || r.error){
|
|
2858
|
+
showToast(r && r.error === 'endpoint-missing' ? `Editor is out of date — restart ${SERVE_CMD}.` : 'Could not delete.');
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
TEL.event('ui.action', { area:'explorer', action:isDir ? 'delete-folder' : 'delete-file' });
|
|
2862
|
+
dropFileTabs(id => id === path || id.startsWith(path + '/'));
|
|
2863
|
+
await refreshTree();
|
|
2864
|
+
showToast(isDir ? 'Folder deleted.' : 'File deleted.');
|
|
2865
|
+
}
|
|
2866
|
+
// Close every open FILE tab whose path matches `pred` (a deleted file / folder), fixing the
|
|
2867
|
+
// active-tab index and navigating away if the active tab was the one closed.
|
|
2868
|
+
function dropFileTabs(pred){
|
|
2869
|
+
for (let i = S.tabs.length - 1; i >= 0; i--){
|
|
2870
|
+
const t = S.tabs[i];
|
|
2871
|
+
if (t.kind === 'file' && pred(t.id)) closeTab(i);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// ---- the comment bridge over SSE (server → browser push) ------------------
|
|
2876
|
+
// Debounced refresh of the Explorer's comment markers/filter after any comment change (on any
|
|
2877
|
+
// file). The open document's own comments are handled separately (S.comments + renderComments).
|
|
2878
|
+
let cmtCountTimer = null;
|
|
2879
|
+
function scheduleCommentCountsRefresh(){
|
|
2880
|
+
if (cmtCountTimer) return;
|
|
2881
|
+
cmtCountTimer = setTimeout(async () => { cmtCountTimer = null; await loadCommentCounts(); fillExplorer(); if (S.commentFilter) renderSidebar(); }, 300);
|
|
2882
|
+
}
|
|
2883
|
+
function initEvents(){
|
|
2884
|
+
if (!window.EventSource) return;
|
|
2885
|
+
try { if (S.es) S.es.close(); } catch {}
|
|
2886
|
+
try { S.es = new EventSource('/api/events'); } catch { S.es = null; return; }
|
|
2887
|
+
S.es.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } handleBridgeEvent(m); };
|
|
2888
|
+
S.es.onerror = () => { /* the browser auto-reconnects */ };
|
|
2889
|
+
}
|
|
2890
|
+
function handleBridgeEvent(m){
|
|
2891
|
+
if (!m || !m.type) return;
|
|
2892
|
+
if (m.type === 'fs'){ scheduleTreeRefresh(); scheduleGitSync(); scheduleDocReload(); return; } // disk changed → refresh Explorer, the open file's diff-state, and its content
|
|
2893
|
+
if (m.type === 'addressing'){ showToast('Agent is working through your comments…', { spin: true, ms: 8000 }); return; }
|
|
2894
|
+
if (m.type === 'comment' || m.type === 'resolve' || m.type === 'delete') scheduleCommentCountsRefresh(); // keep Explorer markers/filter fresh for ANY file
|
|
2895
|
+
if (m.file && m.file !== S.recFile) return; // an event for a file we're not viewing
|
|
2896
|
+
if (m.type === 'resolve'){
|
|
2897
|
+
dropComment(m.commentId);
|
|
2898
|
+
if (m.revised) reloadRecord();
|
|
2899
|
+
} else if (m.type === 'delete'){
|
|
2900
|
+
dropComment(m.commentId);
|
|
2901
|
+
} else if (m.type === 'comment'){
|
|
2902
|
+
if (droppedComments.has(m.comment.id)) return; // a late echo of a just-deleted comment
|
|
2903
|
+
const at = (S.comments || []).findIndex(x => x.id === m.comment.id);
|
|
2904
|
+
if (at >= 0){ S.comments[at] = m.comment; renderComments(); } // an EDIT echo → update in place
|
|
2905
|
+
else { S.comments = [...(S.comments || []), m.comment]; renderComments(); } // a new comment
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
// The agent may edit analysis.md while addressing a comment; re-pull the record so the
|
|
2909
|
+
// live document and diff reflect it (reviewable before the user commits).
|
|
2910
|
+
async function reloadRecord(){
|
|
2911
|
+
if (wysiwygFocused()) return; // never yank a live edit out from under the caret
|
|
2912
|
+
const id = S.caseId; let rec;
|
|
2913
|
+
try { rec = await api('GET', '/api/record?id=' + encodeURIComponent(id)); } catch { return; }
|
|
2914
|
+
if (S.caseId !== id || !rec || rec.error) return;
|
|
2915
|
+
if (rec.text === reconstruct(S.items)) { S.rec = rec; return; } // no external change (e.g. our own save) → don't flicker
|
|
2916
|
+
S.rec = rec; if (rec.text) S.items = segment(rec.text);
|
|
2917
|
+
renderLive(); buildToc(); renderComments();
|
|
2918
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
2919
|
+
// The agent just wrote the file → the git change set moved; refresh it so the Explorer's
|
|
2920
|
+
// diff mode + badges stay accurate.
|
|
2921
|
+
if (S.isGit){ await loadGit(); if (S.diffMode) renderSidebar(); }
|
|
2922
|
+
}
|
|
2923
|
+
// Keep the OPEN document in sync with the file on disk when it changes OUTSIDE the app (an
|
|
2924
|
+
// editor, git checkout, the agent). Debounced, driven by the `fs` bridge event + the poll.
|
|
2925
|
+
// Never clobbers unsaved local edits (skips while dirty/saving), and skips identical content so
|
|
2926
|
+
// the app's own autosave doesn't cause a reload/flicker.
|
|
2927
|
+
let docReloadTimer = null;
|
|
2928
|
+
function scheduleDocReload(){ if (docReloadTimer) return; docReloadTimer = setTimeout(() => { docReloadTimer = null; reloadOpenDoc(); }, 200); }
|
|
2929
|
+
async function reloadOpenDoc(){
|
|
2930
|
+
if (S.dirty || S.saving || wysiwygFocused()) return; // don't overwrite unsaved / in-flight / actively-edited docs
|
|
2931
|
+
if (S.caseId) return reloadRecord();
|
|
2932
|
+
if (S.brief){ const id = S.brief.id; let rec; try { rec = await api('GET', '/api/brief?id=' + encodeURIComponent(id)); } catch { return; } if (S.brief?.id === id && rec && !rec.error && rec.text !== (S.brief.text || '')){ S.brief = { id, ...rec }; paintBrief(); } return; }
|
|
2933
|
+
if (S.readout){ const id = S.readout.id; let rec; try { rec = await api('GET', '/api/readout?id=' + encodeURIComponent(id)); } catch { return; } if (S.readout?.id === id && rec && !rec.error && rec.text !== (S.readout.text || '')){ S.readout = { id, ...rec }; paintReadout(); } return; }
|
|
2934
|
+
if (S.matchup){ const id = S.matchup.id; let rec; try { rec = await api('GET', '/api/matchup?id=' + encodeURIComponent(id)); } catch { return; } if (S.matchup?.id === id && rec && !rec.error && rec.text !== (S.matchup.text || '')){ S.matchup = { id, ...rec }; paintMatchup(); } return; }
|
|
2935
|
+
if (S.file && !S.file.deleted){
|
|
2936
|
+
const path = S.file.path; let rec;
|
|
2937
|
+
try { rec = await api('GET', '/api/file?path=' + encodeURIComponent(path)); } catch { return; }
|
|
2938
|
+
if (!rec || rec.error || !S.file || S.file.path !== path) return;
|
|
2939
|
+
S.isGit = !!rec.isGit;
|
|
2940
|
+
if (rec.text === reconstruct(S.items)) return; // unchanged (e.g. our own save)
|
|
2941
|
+
S.file = { path, ...rec }; S.recFile = rec.path || path;
|
|
2942
|
+
S.items = rec.text ? segment(rec.text) : [{ kind: 'block', lines: [''] }];
|
|
2943
|
+
renderLive(); buildToc(); renderComments();
|
|
2944
|
+
if (S.mode === 'diff') loadDiffBase().then(refreshDiffDecorations);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
// The project-relative file of the record currently open (case / brief / plain file).
|
|
2948
|
+
function recFile(){ return S.caseId ? (S.rec && S.rec.file) : S.recFile; }
|
|
2949
|
+
// True when that file has uncommitted changes (drives the Discard button).
|
|
2950
|
+
function recFileChanged(){ const f = recFile(); return !!(f && (S.git.changed || []).some(c => c.path === f)); }
|
|
2951
|
+
// ---- Diff mode = the LIVE EDITOR with change decorations ------------------
|
|
2952
|
+
// Diff mode is NOT a separate view: it is the exact same live-preview document (same layout,
|
|
2953
|
+
// typography, editing, checkboxes, comments, table-of-contents). The ONLY difference is a
|
|
2954
|
+
// decoration pass layered on top: blocks ADDED since the last commit get a green wash, MODIFIED
|
|
2955
|
+
// blocks a green wash above their removed (red) form, and removed blocks are shown inline as
|
|
2956
|
+
// read-only red decorations. `S.diffBase` caches the committed (HEAD) text for the open record;
|
|
2957
|
+
// the working side is always the live S.items, so highlights update as you type.
|
|
2958
|
+
async function loadDiffBase(){
|
|
2959
|
+
S.diffBase = null;
|
|
2960
|
+
const q = S.caseId ? ('id=' + encodeURIComponent(S.caseId)) : ('path=' + encodeURIComponent(recFile() || ''));
|
|
2961
|
+
let d; try { d = await api('GET', '/api/diff?' + q); } catch { d = null; }
|
|
2962
|
+
if (d && d.isGit && typeof d.base === 'string') S.diffBase = d.base.replace(/\r\n?/g, '\n');
|
|
2963
|
+
}
|
|
2964
|
+
// Apply (or refresh) the change decorations over the live document: serialize the Tiptap doc to
|
|
2965
|
+
// per-block Markdown, LCS-diff it against the committed base (normalized the SAME way via
|
|
2966
|
+
// mdBlocks), then hand the result to the editor's diff plugin — added/modified blocks get a
|
|
2967
|
+
// class, removed blocks render as read-only red islands before the block that follows them.
|
|
2968
|
+
// Block-level. `animate` is accepted for call-site compatibility (the plugin owns its reveal).
|
|
2969
|
+
function applyDiffDecorations(animate){
|
|
2970
|
+
const ctl = wysiwygCtl; if (!ctl) return;
|
|
2971
|
+
if (S.mode !== 'diff' || S.diffBase == null){ ctl.setDiff(null); return; }
|
|
2972
|
+
const oldBlocks = mdBlocks(S.diffBase);
|
|
2973
|
+
const newBlocks = ctl.blockSources();
|
|
2974
|
+
const ops = lcsDiff(oldBlocks, newBlocks);
|
|
2975
|
+
const nodeClasses = new Array(newBlocks.length).fill('');
|
|
2976
|
+
const deletions = []; let ni = 0, pendingDel = [];
|
|
2977
|
+
const flush = () => {
|
|
2978
|
+
if (!pendingDel.length) return;
|
|
2979
|
+
const html = pendingDel.map(src => `<div class="diffdel" role="note" aria-label="Removed since the last commit">${renderMarkdown(src) || '<p class="dempty">(removed blank line)</p>'}</div>`).join('');
|
|
2980
|
+
deletions.push({ at: ni < newBlocks.length ? ni : -1, html, key: String(deletions.length) });
|
|
2981
|
+
pendingDel = [];
|
|
2982
|
+
};
|
|
2983
|
+
for (const o of ops){
|
|
2984
|
+
if (o.op === 'del'){ pendingDel.push(o.a); continue; }
|
|
2985
|
+
const hadDel = pendingDel.length > 0;
|
|
2986
|
+
flush();
|
|
2987
|
+
if (o.op === 'add') nodeClasses[ni] = hadDel ? 'diff-mod' : 'diff-add';
|
|
2988
|
+
ni++;
|
|
2989
|
+
}
|
|
2990
|
+
flush();
|
|
2991
|
+
ctl.setDiff({ nodeClasses, deletions, count: newBlocks.length });
|
|
2992
|
+
}
|
|
2993
|
+
// Re-apply the change decorations (and rebuild the ToC so removed-heading decorations are
|
|
2994
|
+
// excluded). Used after entering diff mode and after edits.
|
|
2995
|
+
function refreshDiffDecorations(){ if (S.mode === 'diff'){ applyDiffDecorations(true); buildToc(); reserveRailForControls(); } }
|
|
2996
|
+
// Longest-common-subsequence diff of two arrays → an ordered list of {op:'eq'|'del'|'add'}.
|
|
2997
|
+
// Used for both the block-level rendered diff and the line-level change counts. Guarded against a
|
|
2998
|
+
// pathological product so a huge file never blocks the UI (it degrades to a whole-document swap).
|
|
2999
|
+
function lcsDiff(a, b){
|
|
3000
|
+
const n = a.length, m = b.length;
|
|
3001
|
+
if (n * m > 4000000){ return [...a.map(x => ({ op:'del', a:x })), ...b.map(x => ({ op:'add', b:x }))]; }
|
|
3002
|
+
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
3003
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
|
|
3004
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
3005
|
+
const out = []; let i = 0, j = 0;
|
|
3006
|
+
while (i < n && j < m){
|
|
3007
|
+
if (a[i] === b[j]){ out.push({ op:'eq', a:a[i], b:b[j] }); i++; j++; }
|
|
3008
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]){ out.push({ op:'del', a:a[i] }); i++; }
|
|
3009
|
+
else { out.push({ op:'add', b:b[j] }); j++; }
|
|
3010
|
+
}
|
|
3011
|
+
while (i < n){ out.push({ op:'del', a:a[i] }); i++; }
|
|
3012
|
+
while (j < m){ out.push({ op:'add', b:b[j] }); j++; }
|
|
3013
|
+
return out;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
// The Document/Diff toggle (git repos only) + a Discard button in Diff view when
|
|
3017
|
+
// the file actually has uncommitted changes. Shared by the case, brief and file surfaces.
|
|
3018
|
+
function viewSeg(){
|
|
3019
|
+
if (!S.isGit) return '';
|
|
3020
|
+
const deleted = !!(S.file && S.file.deleted);
|
|
3021
|
+
const discard = ((S.mode === 'diff' || deleted) && recFileChanged())
|
|
3022
|
+
? `<button type="button" class="tbtn danger" data-discard title="${deleted ? 'Restore this file from the last commit' : 'Discard all uncommitted changes in this file'}">${iconEl(ICON.revert)}${deleted ? 'Restore file' : 'Discard changes'}</button>` : '';
|
|
3023
|
+
// A deleted file has no Document surface — only its removal Diff (+ a Restore).
|
|
3024
|
+
if (deleted) return `${discard}<span class="rochip" title="This file was deleted — reviewing its removal">${iconEl(ICON.trash)}Deleted</span>`;
|
|
3025
|
+
// A single compact toggle (not a two-button bar): the source-control-over-a-file glyph flips
|
|
3026
|
+
// this file between its rendered document and its diff-against-committed. It's DISABLED when the
|
|
3027
|
+
// file has no uncommitted changes (nothing to diff — no empty "no changes" dead-end); enabled
|
|
3028
|
+
// with a gold dot when it does; pressed (ember) while the diff is showing.
|
|
3029
|
+
const inDiff = S.mode === 'diff';
|
|
3030
|
+
const changed = recFileChanged() || !!S.dirty; // git-tracked change OR unsaved local edits (instant)
|
|
3031
|
+
const label = !changed ? 'No uncommitted changes in this file'
|
|
3032
|
+
: inDiff ? 'Show the document' : 'Show changes in this file';
|
|
3033
|
+
const dot = (changed && !inDiff) ? '<span class="cdot" aria-hidden="true"></span>' : '';
|
|
3034
|
+
return `${discard}<button type="button" class="viewtoggle ${inDiff ? 'on' : ''}" data-view="${inDiff ? 'doc' : 'diff'}"
|
|
3035
|
+
aria-pressed="${inDiff}" ${changed ? '' : 'disabled'} title="${label}" aria-label="${label}">${iconEl(ICON.gitbranch)}${dot}</button>`;
|
|
3036
|
+
}
|
|
3037
|
+
// True when `path` is in the git change set as a removal (status includes 'D').
|
|
3038
|
+
function isDeletedChange(path){ return (S.git.changed || []).some(c => c.path === path && c.status.includes('D')); }
|
|
3039
|
+
// Re-paint whichever record surface is open (used by setMode + after a discard).
|
|
3040
|
+
function repaintRecord(){ if (S.caseId) paintCase(); else if (S.brief) paintBrief(); else if (S.readout) paintReadout(); else if (S.matchup) paintMatchup(); else if (S.file) paintFile(); }
|
|
3041
|
+
// The default view mode when opening a record: Diff when the Explorer is in diff mode
|
|
3042
|
+
// (reviewing changes), else the Document.
|
|
3043
|
+
function defaultRecordMode(){ return (S.diffMode && S.isGit) ? 'diff' : 'doc'; }
|
|
3044
|
+
|
|
3045
|
+
// ---- autosave -------------------------------------------------------------
|
|
3046
|
+
function scheduleSave(){ clearTimeout(S.saveTimer); S.saveTimer = setTimeout(saveNow, 600); }
|
|
3047
|
+
async function saveNow(){
|
|
3048
|
+
if (!S.dirty || docReadOnly()) return;
|
|
3049
|
+
const text = currentSource();
|
|
3050
|
+
if (S.caseId){
|
|
3051
|
+
const caseId = S.caseId; // capture: the case may switch during the async POST
|
|
3052
|
+
S.saving = true; updateSaveDot();
|
|
3053
|
+
try { await api('POST', '/api/record', { id: caseId, text }); if (S.caseId === caseId){ S.dirty = false; S.items = segment(text); if (S.rec) S.rec.exists = true; } TEL.count('autosaves', 1); }
|
|
3054
|
+
catch {}
|
|
3055
|
+
if (S.caseId === caseId){ S.saving = false; updateSaveDot(); await refreshDocDiffAfterSave(); }
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
if (S.file){
|
|
3059
|
+
const path = S.file.path; // capture: the file may switch during the async POST
|
|
3060
|
+
S.saving = true; updateSaveDot();
|
|
3061
|
+
try { await api('POST', '/api/file', { path, text }); if (S.file && S.file.path === path){ S.dirty = false; S.file.text = text; S.items = segment(text); } TEL.count('autosaves', 1); }
|
|
3062
|
+
catch {}
|
|
3063
|
+
if (S.file && S.file.path === path){ S.saving = false; updateSaveDot(); await refreshDocDiffAfterSave(); }
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
// After an autosave, the file's diff-state may have changed in EITHER direction — a clean file now
|
|
3067
|
+
// differs, or an edited file was reverted back to its committed content. Refresh git status so the
|
|
3068
|
+
// canvas Document⇄Diff toggle reflects reality (loadGit re-syncs it). Cheap; the watcher filters
|
|
3069
|
+
// .git so this never triggers a refresh loop.
|
|
3070
|
+
async function refreshDocDiffAfterSave(){
|
|
3071
|
+
if (S.isGit){ try { await loadGit(); } catch { /* poll covers it */ } }
|
|
3072
|
+
syncDocDiff();
|
|
3073
|
+
}
|
|
3074
|
+
// Best-effort synchronous flush for tab-close / navigation-away, when a debounced save
|
|
3075
|
+
// would otherwise be dropped. sendBeacon survives unload; a sync XHR is the fallback.
|
|
3076
|
+
function flushOnUnload(){
|
|
3077
|
+
TEL.beacon(); // drain any buffered content-free telemetry
|
|
3078
|
+
// Buffered project config (Explorer state, …): beacon the pending patch so it survives unload.
|
|
3079
|
+
if (Object.keys(S.cfgPending).length){
|
|
3080
|
+
try {
|
|
3081
|
+
const patch = S.cfgPending; S.cfgPending = {};
|
|
3082
|
+
const body = JSON.stringify({ patch });
|
|
3083
|
+
if (navigator.sendBeacon){ navigator.sendBeacon('/api/config', new Blob([body], { type: 'application/json' })); }
|
|
3084
|
+
else { const x = new XMLHttpRequest(); x.open('POST', '/api/config', false); x.setRequestHeader('content-type', 'application/json'); x.send(body); }
|
|
3085
|
+
} catch {}
|
|
3086
|
+
}
|
|
3087
|
+
if (S.saveTimer){ clearTimeout(S.saveTimer); S.saveTimer = null; }
|
|
3088
|
+
if (!S.dirty || docReadOnly()) return;
|
|
3089
|
+
const target = S.caseId ? { url: '/api/record', body: { id: S.caseId, text: currentSource() } }
|
|
3090
|
+
: S.file ? { url: '/api/file', body: { path: S.file.path, text: currentSource() } } : null;
|
|
3091
|
+
if (!target) return;
|
|
3092
|
+
try {
|
|
3093
|
+
const body = JSON.stringify(target.body);
|
|
3094
|
+
if (navigator.sendBeacon){ navigator.sendBeacon(target.url, new Blob([body], { type: 'application/json' })); }
|
|
3095
|
+
else { const x = new XMLHttpRequest(); x.open('POST', target.url, false); x.setRequestHeader('content-type', 'application/json'); x.send(body); }
|
|
3096
|
+
S.dirty = false;
|
|
3097
|
+
} catch {}
|
|
3098
|
+
}
|
|
3099
|
+
function updateSaveDot(){
|
|
3100
|
+
const d = $('#savedot'), l = $('#savelbl'); if (!d) return;
|
|
3101
|
+
d.classList.toggle('dirty', S.dirty && !S.saving);
|
|
3102
|
+
d.classList.toggle('saving', S.saving);
|
|
3103
|
+
if (l) l.textContent = S.saving ? 'Saving…' : S.dirty ? 'Unsaved' : '';
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// ---- shell helpers --------------------------------------------------------
|
|
3107
|
+
function renderMain(html){ const m = $('#main'); if (m) m.innerHTML = html; const dd = $('#docdiff'); if (dd){ dd.hidden = true; dd.innerHTML = ''; } updateTabMark(); }
|
|
3108
|
+
// The Document⇄Diff toggle (+ its Discard action) rendered into the reading-canvas corner
|
|
3109
|
+
// (#docdiff), not the tab bar. Shown only for a git-backed open record; hidden on the inbox and
|
|
3110
|
+
// empty states (renderMain resets it). Called by wireCaseControls, so it re-renders on every
|
|
3111
|
+
// paint and on setMode — staying present across the document AND diff views.
|
|
3112
|
+
function renderCanvasDiff(){
|
|
3113
|
+
const host = $('#docdiff'); if (!host) return;
|
|
3114
|
+
const caseCtl = S.caseId ? caseCanvasControls() : ''; // verdict pill + prototype (in the canvas corner)
|
|
3115
|
+
const seg = (S.isGit && (S.file || S.brief || S.readout || S.matchup || S.caseId)) ? viewSeg() : '';
|
|
3116
|
+
const cnav = commentNavCmd(); // comment count + prev/next, beside the Diff toggle
|
|
3117
|
+
const markup = caseCtl + cnav + seg;
|
|
3118
|
+
host.innerHTML = markup; host.hidden = !markup;
|
|
3119
|
+
if (!markup) return;
|
|
3120
|
+
host.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setMode(b.dataset.view)));
|
|
3121
|
+
host.querySelectorAll('[data-cnav]').forEach(b => b.addEventListener('click', () => stepComment(b.dataset.cnav === 'prev' ? -1 : 1)));
|
|
3122
|
+
const dc = host.querySelector('[data-discard]'); if (dc) dc.addEventListener('click', () => confirmDiscard());
|
|
3123
|
+
const sj = host.querySelector('[data-scorejump]'); if (sj) sj.addEventListener('click', jumpToReasoning);
|
|
3124
|
+
const pb = host.querySelector('[data-proto]');
|
|
3125
|
+
if (pb && !(S.proto && S.proto.external)){ pb.addEventListener('click', () => openProto()); pb.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); openProto(); } }); }
|
|
3126
|
+
const bp = host.querySelector('[data-buildproto]'); if (bp) bp.addEventListener('click', () => showBuildProto(bp));
|
|
3127
|
+
reserveRailForControls();
|
|
3128
|
+
}
|
|
3129
|
+
// Reactively reflect the current file's change-state in the canvas Document⇄Diff toggle WITHOUT a
|
|
3130
|
+
// full re-render (cheap enough to run per keystroke) — so it ENABLES the instant the file is edited
|
|
3131
|
+
// (S.dirty) or git reports it changed, instead of only after a refresh. Never disabled mid-diff.
|
|
3132
|
+
function syncDocDiff(){
|
|
3133
|
+
const changed = recFileChanged() || !!S.dirty;
|
|
3134
|
+
// Changes vanished (reverted, discarded, or committed) while the diff was showing → the diff is
|
|
3135
|
+
// now empty, so fall back to the document view. setMode repaints (which re-renders this control).
|
|
3136
|
+
if (!changed && S.mode === 'diff' && (S.file || S.caseId || S.brief || S.readout || S.matchup)){ setMode('doc'); return; }
|
|
3137
|
+
const host = $('#docdiff'); if (!host || host.hidden) return;
|
|
3138
|
+
const btn = host.querySelector('.viewtoggle'); if (!btn) return;
|
|
3139
|
+
const inDiff = S.mode === 'diff';
|
|
3140
|
+
btn.disabled = !changed && !inDiff;
|
|
3141
|
+
const label = !changed ? 'No uncommitted changes in this file' : inDiff ? 'Show the document' : 'Show changes in this file';
|
|
3142
|
+
btn.title = label; btn.setAttribute('aria-label', label);
|
|
3143
|
+
let dot = btn.querySelector('.cdot');
|
|
3144
|
+
if (changed && !inDiff){ if (!dot){ dot = document.createElement('span'); dot.className = 'cdot'; dot.setAttribute('aria-hidden', 'true'); btn.appendChild(dot); } }
|
|
3145
|
+
else if (dot){ dot.remove(); }
|
|
3146
|
+
}
|
|
3147
|
+
function emptyState(h, p){ return `<div class="canvaswrap"><div class="empty"><h2>${esc(h)}</h2><p>${p}</p></div></div>`; }
|
|
3148
|
+
// The branded empty surface — the ember mark + injected product name as a calm welcome,
|
|
3149
|
+
// shown on the empty Inbox (the app's home). `sub` is a short line; `hint` is trusted inline
|
|
3150
|
+
// HTML (it may carry a <code> command). Its counterpart, the tab-bar wordmark, is hidden while
|
|
3151
|
+
// a `.hero` is on screen so brand presence is never doubled (see updateTabMark).
|
|
3152
|
+
function brandedEmpty(sub, hint, actions){
|
|
3153
|
+
const name = esc(BRAND);
|
|
3154
|
+
return `<div class="canvaswrap"><div class="hero">
|
|
3155
|
+
<span class="hero-mark" aria-hidden="true"><i></i></span>
|
|
3156
|
+
<h1 class="hero-name">${name}</h1>
|
|
3157
|
+
${sub ? `<p class="hero-sub">${esc(sub)}</p>` : ''}
|
|
3158
|
+
${actions ? `<div class="hero-actions">${actions}</div>` : ''}
|
|
3159
|
+
${hint ? `<p class="hero-hint">${hint}</p>` : ''}
|
|
3160
|
+
</div></div>`;
|
|
3161
|
+
}
|
|
3162
|
+
function openNav(){ document.body.classList.add('nav-open'); }
|
|
3163
|
+
function closeNav(){ document.body.classList.remove('nav-open'); }
|
|
3164
|
+
|
|
3165
|
+
// ---- client telemetry (content-free; batched OFF the interaction hot path) ----------------
|
|
3166
|
+
// The browser can't reach the OTLP endpoint (no direct network, no secrets), so it accumulates
|
|
3167
|
+
// content-free events + aggregate rollups and POSTs them to /api/telemetry, where the server
|
|
3168
|
+
// re-emits them through the shared client (re-validating the schema allow-list). Enqueue is O(1);
|
|
3169
|
+
// the network flush is debounced + coalesced, and a pagehide beacon drains the tail — so telemetry
|
|
3170
|
+
// never sits on the render/interaction path (INP is a hard priority). No-op when consent is off.
|
|
3171
|
+
const TEL = (() => {
|
|
3172
|
+
let enabled = false, queue = [], timer = null;
|
|
3173
|
+
const inFlight = new Set();
|
|
3174
|
+
const counters = Object.create(null), gauges = Object.create(null), histos = Object.create(null);
|
|
3175
|
+
const clear = () => { queue = []; for (const k of Object.keys(counters)) delete counters[k]; for (const k of Object.keys(gauges)) delete gauges[k]; for (const k of Object.keys(histos)) delete histos[k]; };
|
|
3176
|
+
const pending = () => queue.length || Object.keys(counters).length || Object.keys(gauges).length || Object.keys(histos).length;
|
|
3177
|
+
const schedule = () => { if (!enabled || timer) return; timer = setTimeout(() => { timer = null; flush(); }, 10000); if (timer && timer.unref) timer.unref(); };
|
|
3178
|
+
const drain = () => { const body = { events: queue, counters: { ...counters }, gauges: { ...gauges }, histograms: { ...histos } }; clear(); return body; };
|
|
3179
|
+
async function flush(){
|
|
3180
|
+
if (enabled && pending()) {
|
|
3181
|
+
const body = drain();
|
|
3182
|
+
try {
|
|
3183
|
+
let request;
|
|
3184
|
+
request = api('POST', '/api/telemetry', body).catch(() => {}).finally(() => inFlight.delete(request));
|
|
3185
|
+
inFlight.add(request);
|
|
3186
|
+
} catch { /* best-effort */ }
|
|
3187
|
+
}
|
|
3188
|
+
if (inFlight.size) await Promise.all([...inFlight]);
|
|
3189
|
+
}
|
|
3190
|
+
function beacon(){
|
|
3191
|
+
if (!enabled || !pending()) return;
|
|
3192
|
+
const body = drain();
|
|
3193
|
+
try {
|
|
3194
|
+
if (navigator.sendBeacon) navigator.sendBeacon('/api/telemetry', new Blob([JSON.stringify(body)], { type: 'application/json' }));
|
|
3195
|
+
else api('POST', '/api/telemetry', body);
|
|
3196
|
+
} catch { /* best-effort */ }
|
|
3197
|
+
}
|
|
3198
|
+
// Best-effort interaction-latency (INP-ish) sampling — aggregated into a histogram, never one
|
|
3199
|
+
// event per interaction. Guarded: absent in jsdom/older browsers, so it simply doesn't run.
|
|
3200
|
+
const startINP = () => {
|
|
3201
|
+
if (typeof PerformanceObserver !== 'function') return;
|
|
3202
|
+
try {
|
|
3203
|
+
const po = new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (e.duration) observe('inp_ms', Math.round(e.duration)); } });
|
|
3204
|
+
po.observe({ type: 'event', buffered: true, durationThreshold: 40 });
|
|
3205
|
+
} catch { /* unsupported entry type → skip */ }
|
|
3206
|
+
};
|
|
3207
|
+
function event(name, props){ if (!enabled) return; queue.push({ name, props: props || {} }); if (queue.length >= 40) flush(); else schedule(); }
|
|
3208
|
+
function count(metric, n){ if (!enabled) return; counters[metric] = (counters[metric] || 0) + (Number(n) || 0); schedule(); }
|
|
3209
|
+
function gauge(metric, v){ if (!enabled) return; const x = Number(v); if (!Number.isFinite(x) || x < 0) return; gauges[metric] = x; schedule(); }
|
|
3210
|
+
function observe(metric, v){ if (!enabled) return; const x = Number(v); if (!Number.isFinite(x)) return; (histos[metric] || (histos[metric] = [])).push(x); schedule(); }
|
|
3211
|
+
return {
|
|
3212
|
+
setEnabled(v){ const was = enabled; enabled = !!v; if (!enabled) clear(); else if (!was) startINP(); },
|
|
3213
|
+
isEnabled(){ return enabled; },
|
|
3214
|
+
event, count, gauge, observe, flush, beacon,
|
|
3215
|
+
_buffers(){ return { queue, counters, gauges, histos, inFlight }; },
|
|
3216
|
+
};
|
|
3217
|
+
})();
|
|
3218
|
+
|
|
3219
|
+
// ---- feedback + telemetry consent (the two user-facing surfaces) --------------------------
|
|
3220
|
+
// Feedback is a first-class tab so users can switch away and return without losing their draft.
|
|
3221
|
+
// User-authored words stay verbatim; the category-specific prompt only nudges useful framing.
|
|
3222
|
+
function renderFeedback(){
|
|
3223
|
+
setTopActions('');
|
|
3224
|
+
const d = S.feedbackDraft || { category:'other', message:'', sentiment:null, contact:'' };
|
|
3225
|
+
const prompts = {
|
|
3226
|
+
other: 'Tell us what is working, what is not, or anything else you want us to know.',
|
|
3227
|
+
idea: 'What are you trying to achieve, and what gets in the way today? Describe the problem—not the solution you think should be built.',
|
|
3228
|
+
bug: 'What happened? Describe what you expected, what happened instead, and the precise steps to reproduce it.',
|
|
3229
|
+
};
|
|
3230
|
+
const types = [
|
|
3231
|
+
{ value:'other', label:'General', note:'Thoughts, questions, or praise' },
|
|
3232
|
+
{ value:'idea', label:'Feature request', note:'A problem you cannot solve today' },
|
|
3233
|
+
{ value:'bug', label:'Bug report', note:'Something is not working' },
|
|
3234
|
+
];
|
|
3235
|
+
const sentiments = [
|
|
3236
|
+
{ value:'up', label:'Positive', icon:'thumbUp' },
|
|
3237
|
+
{ value:'down', label:'Could be better', icon:'thumbDown' },
|
|
3238
|
+
{ value:'excited', label:'Excited', icon:'rocket' },
|
|
3239
|
+
];
|
|
3240
|
+
renderMain(`<div class="feedback-wrap"><form class="feedback-page" id="feedback-page" novalidate>
|
|
3241
|
+
<header class="feedback-head">
|
|
3242
|
+
<h1>Send feedback</h1><p>Tell us what would make Sonorance more useful.</p>
|
|
3243
|
+
</header>
|
|
3244
|
+
<div class="feedback-form">
|
|
3245
|
+
<fieldset class="feedback-type"><legend>What kind of feedback?</legend>
|
|
3246
|
+
<div class="feedback-type-options" role="radiogroup" aria-label="Feedback type">
|
|
3247
|
+
${types.map((type) => `<button type="button" class="feedback-type-option ${d.category === type.value ? 'selected' : ''}" role="radio" aria-checked="${d.category === type.value}" tabindex="${d.category === type.value ? 0 : -1}" data-feedback-category="${type.value}">
|
|
3248
|
+
<strong>${type.label}</strong><small>${type.note}</small>
|
|
3249
|
+
</button>`).join('')}
|
|
3250
|
+
</div>
|
|
3251
|
+
</fieldset>
|
|
3252
|
+
<label class="feedback-field" id="fbmsgl"><span>${d.category === 'bug' ? 'What went wrong?' : 'Your feedback'}</span>
|
|
3253
|
+
<textarea id="fbmsg" rows="6" placeholder="${esc(prompts[d.category] || prompts.other)}">${esc(d.message)}</textarea>
|
|
3254
|
+
</label>
|
|
3255
|
+
<label class="feedback-field compact"><span>Email <em>Optional, for a reply</em></span>
|
|
3256
|
+
<input type="email" id="fbcontact" value="${esc(d.contact)}" placeholder="you@example.com" autocomplete="email">
|
|
3257
|
+
</label>
|
|
3258
|
+
<fieldset class="feedback-sentiment"><legend>Sentiment <span>Optional</span></legend>
|
|
3259
|
+
<div class="feedback-sentiment-options" role="group" aria-label="Sentiment">
|
|
3260
|
+
${sentiments.map((item) => `<button type="button" class="feedback-sentiment-option ${d.sentiment === item.value ? 'selected' : ''}" data-sentiment="${item.value}" aria-label="${item.label}" aria-pressed="${d.sentiment === item.value}" title="${item.label}">${iconEl(ICON[item.icon])}<span>${item.label}</span></button>`).join('')}
|
|
3261
|
+
</div>
|
|
3262
|
+
</fieldset>
|
|
3263
|
+
<div class="feedback-trust">${iconEl(ICON.check)}<span>Only this form is submitted. Sonorance never reads your documents to create feedback.</span></div>
|
|
3264
|
+
<p class="fb-note" id="fbhint" role="status"></p>
|
|
3265
|
+
<div class="fb-actions"><button class="fb-send tbtn" id="fbsend" type="submit">${iconEl(ICON.send)}Send feedback</button></div>
|
|
3266
|
+
</div>
|
|
3267
|
+
</form></div>`);
|
|
3268
|
+
const wrap = $('#feedback-page');
|
|
3269
|
+
const categoryButtons = [...wrap.querySelectorAll('[data-feedback-category]')];
|
|
3270
|
+
const captureDraft = () => {
|
|
3271
|
+
S.feedbackDraft = {
|
|
3272
|
+
category:wrap.dataset.category,
|
|
3273
|
+
message:wrap.querySelector('#fbmsg').value,
|
|
3274
|
+
sentiment:wrap.dataset.sentiment || null,
|
|
3275
|
+
contact:wrap.querySelector('#fbcontact').value,
|
|
3276
|
+
};
|
|
3277
|
+
};
|
|
3278
|
+
const selectCategory = (button) => {
|
|
3279
|
+
const category = button.dataset.feedbackCategory;
|
|
3280
|
+
wrap.dataset.category = category;
|
|
3281
|
+
categoryButtons.forEach((option) => {
|
|
3282
|
+
const selected = option === button;
|
|
3283
|
+
option.classList.toggle('selected', selected);
|
|
3284
|
+
option.setAttribute('aria-checked', String(selected));
|
|
3285
|
+
option.tabIndex = selected ? 0 : -1;
|
|
3286
|
+
});
|
|
3287
|
+
wrap.querySelector('#fbmsgl span').textContent = category === 'bug' ? 'What went wrong?' : 'Your feedback';
|
|
3288
|
+
wrap.querySelector('#fbmsg').placeholder = prompts[category] || prompts.other;
|
|
3289
|
+
captureDraft();
|
|
3290
|
+
};
|
|
3291
|
+
wrap.dataset.category = d.category;
|
|
3292
|
+
wrap.dataset.sentiment = d.sentiment || '';
|
|
3293
|
+
categoryButtons.forEach((button, index) => {
|
|
3294
|
+
button.addEventListener('click', () => selectCategory(button));
|
|
3295
|
+
button.addEventListener('keydown', (event) => {
|
|
3296
|
+
let next = null;
|
|
3297
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (index + 1) % categoryButtons.length;
|
|
3298
|
+
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (index - 1 + categoryButtons.length) % categoryButtons.length;
|
|
3299
|
+
else if (event.key === 'Home') next = 0;
|
|
3300
|
+
else if (event.key === 'End') next = categoryButtons.length - 1;
|
|
3301
|
+
if (next == null) return;
|
|
3302
|
+
event.preventDefault();
|
|
3303
|
+
selectCategory(categoryButtons[next]);
|
|
3304
|
+
categoryButtons[next].focus();
|
|
3305
|
+
});
|
|
3306
|
+
});
|
|
3307
|
+
wrap.querySelectorAll('textarea,input[type="email"]').forEach((el) => el.addEventListener('input', captureDraft));
|
|
3308
|
+
wrap.querySelectorAll('[data-sentiment]').forEach((button) => button.addEventListener('click', () => {
|
|
3309
|
+
const sentiment = wrap.dataset.sentiment === button.dataset.sentiment ? '' : button.dataset.sentiment;
|
|
3310
|
+
wrap.dataset.sentiment = sentiment;
|
|
3311
|
+
wrap.querySelectorAll('[data-sentiment]').forEach((option) => {
|
|
3312
|
+
const selected = option.dataset.sentiment === sentiment;
|
|
3313
|
+
option.classList.toggle('selected', selected);
|
|
3314
|
+
option.setAttribute('aria-pressed', String(selected));
|
|
3315
|
+
});
|
|
3316
|
+
captureDraft();
|
|
3317
|
+
}));
|
|
3318
|
+
wrap.addEventListener('submit', (event) => { event.preventDefault(); captureDraft(); submitFeedbackUI(wrap, wrap.dataset.sentiment || null); });
|
|
3319
|
+
}
|
|
3320
|
+
function openFeedback(){ openFeedbackTab(); }
|
|
3321
|
+
function closeFeedback(){
|
|
3322
|
+
const idx = S.tabs.findIndex(t => t.kind === 'feedback');
|
|
3323
|
+
if (idx >= 0) closeTab(idx);
|
|
3324
|
+
}
|
|
3325
|
+
async function submitFeedbackUI(wrap, sentiment){
|
|
3326
|
+
const cat = wrap.dataset.category;
|
|
3327
|
+
const message = wrap.querySelector('#fbmsg').value.trim();
|
|
3328
|
+
const hint = wrap.querySelector('#fbhint');
|
|
3329
|
+
if (!message){ hint.textContent = 'Please add a few words before sending.'; hint.classList.add('warn'); return; }
|
|
3330
|
+
const payload = { category: cat, message, rating: sentiment, contact: wrap.querySelector('#fbcontact').value.trim() || null };
|
|
3331
|
+
const send = wrap.querySelector('#fbsend'); send.disabled = true; send.textContent = 'Sending…';
|
|
3332
|
+
const r = await api('POST', '/api/feedback', payload);
|
|
3333
|
+
if (r && r.ok){ S.feedbackDraft = null; renderFeedback(); toast('Thanks — your feedback was sent.'); }
|
|
3334
|
+
else { send.disabled = false; send.innerHTML = `${iconEl(ICON.send)}Send feedback`; hint.textContent = 'Could not send right now — it was saved locally and you can try again.'; hint.classList.add('warn'); }
|
|
3335
|
+
}
|
|
3336
|
+
// A quiet, self-dismissing confirmation (bottom-center). No dependency on any framework.
|
|
3337
|
+
function toast(msg){
|
|
3338
|
+
let t = $('#dlbtoast'); if (t) t.remove();
|
|
3339
|
+
t = document.createElement('div'); t.id = 'dlbtoast'; t.className = 'dlb-toast'; t.textContent = msg;
|
|
3340
|
+
document.body.appendChild(t);
|
|
3341
|
+
requestAnimationFrame(() => t.classList.add('show'));
|
|
3342
|
+
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2600);
|
|
3343
|
+
}
|
|
3344
|
+
// Flip the opt-out choice: persist server-side (shared with the CLI) + update the live client.
|
|
3345
|
+
async function setTelemetryConsent(enabled){
|
|
3346
|
+
const previous = !(S.telemetry && S.telemetry.enabled === false);
|
|
3347
|
+
const r = await api('POST', '/api/consent', { enabled: !!enabled });
|
|
3348
|
+
if (!r || !r.ok) return previous;
|
|
3349
|
+
const on = r.enabled !== false;
|
|
3350
|
+
S.telemetry = { enabled: on };
|
|
3351
|
+
TEL.setEnabled(on);
|
|
3352
|
+
return on;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
// ---- boot -----------------------------------------------------------------
|
|
3356
|
+
async function boot(){
|
|
3357
|
+
lastTelemetryPage = null;
|
|
3358
|
+
applyTheme(themePref());
|
|
3359
|
+
applySavedSidebarWidth();
|
|
3360
|
+
initSidebarResize();
|
|
3361
|
+
$('#scrim') && $('#scrim').addEventListener('click', closeNav);
|
|
3362
|
+
const add = $('#tabadd'); if (add && !add.__wired){ add.__wired = true; add.addEventListener('click', openInboxTab); }
|
|
3363
|
+
const tsl = $('#tabscrollL'); if (tsl && !tsl.__wired){ tsl.__wired = true; tsl.addEventListener('click', () => nudgeTabs(-1)); }
|
|
3364
|
+
const tsr = $('#tabscrollR'); if (tsr && !tsr.__wired){ tsr.__wired = true; tsr.addEventListener('click', () => nudgeTabs(1)); }
|
|
3365
|
+
const ts = $('#tabs'); if (ts && !ts.__wired){ ts.__wired = true; ts.addEventListener('scroll', updateTabScroll, { passive: true }); }
|
|
3366
|
+
const nb = $('#navbtn'); if (nb && !nb.__wired){ nb.__wired = true; nb.addEventListener('click', openNav); }
|
|
3367
|
+
const ib = $('#inboxbtn'); if (ib && !ib.__wired){ ib.__wired = true; ib.addEventListener('click', () => { closeNav(); openInboxTab(); }); }
|
|
3368
|
+
const sb = $('#settingsbtn'); if (sb && !sb.__wired){ sb.__wired = true; sb.addEventListener('click', openSettingsTab); }
|
|
3369
|
+
const fbb = $('#feedbackbtn'); if (fbb && !fbb.__wired){ fbb.__wired = true; fbb.addEventListener('click', openFeedbackTab); }
|
|
3370
|
+
if (!document.__selWired){ document.__selWired = true; document.addEventListener('selectionchange', onDocSelectionChange); }
|
|
3371
|
+
if (!window.__saveGuardWired){
|
|
3372
|
+
window.__saveGuardWired = true;
|
|
3373
|
+
window.addEventListener('pagehide', flushOnUnload);
|
|
3374
|
+
window.addEventListener('beforeunload', flushOnUnload);
|
|
3375
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushOnUnload(); });
|
|
3376
|
+
}
|
|
3377
|
+
initAtmosphere();
|
|
3378
|
+
{
|
|
3379
|
+
S.projectSlug = route().project || null; // from /p/<slug>/… — scopes the first /api/state below
|
|
3380
|
+
const st = await api('GET', '/api/state');
|
|
3381
|
+
// Version-skew guard: a stale `serve` process serves the fresh UI but old routes. It
|
|
3382
|
+
// returns /api/state without the `bridge` capability (or 404s it entirely) — warn the
|
|
3383
|
+
// user to restart, instead of silently 404-ing every ask (the "disappears" bug).
|
|
3384
|
+
if (st && (st.error === 'endpoint-missing' || (!st.error && !st.netError && st.bridge !== true))) maybeStale();
|
|
3385
|
+
if (st && !st.error){
|
|
3386
|
+
Object.assign(S, { project: st.project, projects: st.projects || [], cases: st.cases || [], briefs: st.briefs || [], readouts: st.readouts || [], matchups: st.matchups || [], kinds: st.kinds || [], isGit: !!st.isGit });
|
|
3387
|
+
S.telemetry = st.telemetry || { enabled: true }; // content-free product telemetry (opt-out); UI mirrors the server's resolved state
|
|
3388
|
+
TEL.setEnabled(S.telemetry.enabled !== false);
|
|
3389
|
+
S.projectSlug = st.project ? st.project.id : null; // canonical slug from the server (may differ from the URL)
|
|
3390
|
+
try { document.title = BRAND; } catch {}
|
|
3391
|
+
// Restore persisted tabs, dropping any that point at cases/briefs no longer present.
|
|
3392
|
+
S.tabs = (st.tabs || []).filter(t => t && (t.kind === 'inbox' || t.kind === 'settings' || t.kind === 'feedback' || t.kind === 'file'
|
|
3393
|
+
|| (t.kind === 'brief' ? S.briefs.some(b => b.id === t.id) : t.kind === 'readout' ? S.readouts.some(r => r.id === t.id) : t.kind === 'matchup' ? S.matchups.some(m => m.id === t.id) : S.cases.some(s => s.id === t.id))))
|
|
3394
|
+
.map(t => ({ kind: t.kind, id: t.id || undefined }));
|
|
3395
|
+
S.activeTab = -1;
|
|
3396
|
+
S._bootActive = st.activeTab || null; // this project's last-focused tab {kind,id}
|
|
3397
|
+
// Restore the Explorer collapse/expand state (baseline mode + exceptions). A project with
|
|
3398
|
+
// no saved state defaults to fully collapsed.
|
|
3399
|
+
S.explorer = (st.explorer && st.explorer.mode)
|
|
3400
|
+
? { mode: st.explorer.mode === 'expanded' ? 'expanded' : 'collapsed', ex: new Set(Array.isArray(st.explorer.ex) ? st.explorer.ex : []) }
|
|
3401
|
+
: { mode: 'collapsed', ex: new Set() };
|
|
3402
|
+
try {
|
|
3403
|
+
const tr = await api('GET', '/api/tree');
|
|
3404
|
+
S.tree = (tr && !tr.error && tr.tree) || [];
|
|
3405
|
+
S.treeBase = (tr && typeof tr.base === 'string') ? tr.base : '';
|
|
3406
|
+
S.inventory = (tr && tr.inventory) || { cases: S.cases.length, briefs: S.briefs.length, readouts: S.readouts.length, matchups: S.matchups.length };
|
|
3407
|
+
} catch {
|
|
3408
|
+
S.tree = []; S.treeBase = '';
|
|
3409
|
+
S.inventory = { cases: S.cases.length, briefs: S.briefs.length, readouts: S.readouts.length, matchups: S.matchups.length };
|
|
3410
|
+
}
|
|
3411
|
+
await loadGit(); // current branch + changed set (for the diff-mode toggle)
|
|
3412
|
+
await loadCommentCounts(); // per-file open-comment counts (Explorer markers + filter)
|
|
3413
|
+
} else { S.cases = []; S.briefs = []; S.readouts = []; S.matchups = []; S.inventory = null; S.kinds = []; S.tree = []; S.projects = []; S.tabs = []; S._bootActive = null; S.explorer = { mode:'collapsed', ex:new Set() }; }
|
|
3414
|
+
}
|
|
3415
|
+
initEvents();
|
|
3416
|
+
startTreePolling();
|
|
3417
|
+
startConfigFlush();
|
|
3418
|
+
renderSidebar();
|
|
3419
|
+
renderTabs();
|
|
3420
|
+
restoreBootRoute();
|
|
3421
|
+
await render();
|
|
3422
|
+
captureProjectInventory();
|
|
3423
|
+
}
|
|
3424
|
+
// On (re)boot — including a project switch — the URL may point at the previous project's
|
|
3425
|
+
// case. Respect only a deep link valid in THIS project; otherwise re-focus the project's
|
|
3426
|
+
// own last-active tab (or its first tab, or home). This keeps tabs strictly per-project.
|
|
3427
|
+
function specRoute(spec){ return spec.kind === 'inbox' ? '/inbox' : spec.kind === 'settings' ? '/settings' : spec.kind === 'feedback' ? '/feedback' : spec.kind === 'file' ? '/file/' + encFilePath(spec.id) : (spec.kind === 'brief' ? '/brief/' : spec.kind === 'readout' ? '/readout/' : spec.kind === 'matchup' ? '/matchup/' : '/case/') + spec.id; }
|
|
3428
|
+
function tabSpecValid(spec){
|
|
3429
|
+
if (!spec) return false;
|
|
3430
|
+
if (spec.kind === 'inbox') return S.tabs.some(t => t.kind === 'inbox');
|
|
3431
|
+
if (spec.kind === 'settings') return S.tabs.some(t => t.kind === 'settings');
|
|
3432
|
+
if (spec.kind === 'feedback') return S.tabs.some(t => t.kind === 'feedback');
|
|
3433
|
+
if (spec.kind === 'file') return S.tabs.some(t => t.kind === 'file' && t.id === spec.id);
|
|
3434
|
+
if (spec.kind === 'brief') return S.briefs.some(b => b.id === spec.id) && S.tabs.some(t => t.kind === 'brief' && t.id === spec.id);
|
|
3435
|
+
if (spec.kind === 'readout') return S.readouts.some(r => r.id === spec.id) && S.tabs.some(t => t.kind === 'readout' && t.id === spec.id);
|
|
3436
|
+
if (spec.kind === 'matchup') return S.matchups.some(m => m.id === spec.id) && S.tabs.some(t => t.kind === 'matchup' && t.id === spec.id);
|
|
3437
|
+
return S.cases.some(s => s.id === spec.id) && S.tabs.some(t => t.kind === 'case' && t.id === spec.id);
|
|
3438
|
+
}
|
|
3439
|
+
function restoreBootRoute(){
|
|
3440
|
+
const PFX = S.project ? '/p/' + encodeURIComponent(S.project.id) : '';
|
|
3441
|
+
const r = route();
|
|
3442
|
+
const innerValid =
|
|
3443
|
+
(r.name === 'case' && S.cases.some(s => s.id === r.id)) ||
|
|
3444
|
+
(r.name === 'brief' && S.briefs.some(b => b.id === r.id)) ||
|
|
3445
|
+
(r.name === 'readout' && S.readouts.some(x => x.id === r.id)) ||
|
|
3446
|
+
(r.name === 'matchup' && S.matchups.some(m => m.id === r.id)) ||
|
|
3447
|
+
(r.name === 'file' && S.tabs.some(t => t.kind === 'file' && t.id === r.id)) ||
|
|
3448
|
+
(r.name === 'inbox' && S.tabs.some(t => t.kind === 'inbox')) ||
|
|
3449
|
+
r.name === 'settings' ||
|
|
3450
|
+
r.name === 'feedback';
|
|
3451
|
+
let inner = null;
|
|
3452
|
+
if (innerValid) inner = specRoute({ kind: r.name, id: r.id }); // a valid deep link into THIS project
|
|
3453
|
+
else if (tabSpecValid(S._bootActive)) inner = specRoute(S._bootActive); // else the project's last-focused tab
|
|
3454
|
+
else if (S.tabs.length) inner = specRoute({ kind: S.tabs[0].kind, id: S.tabs[0].id });
|
|
3455
|
+
const dest = PFX + (inner || '/'); // always carries the /p/<slug> prefix
|
|
3456
|
+
if (location.pathname !== dest) history.replaceState({}, '', dest);
|
|
3457
|
+
}
|
|
3458
|
+
// Subtle parallax: the main scroll gently drifts the atmospheric backdrop (--py),
|
|
3459
|
+
// rAF-throttled and disabled under reduced-motion. Also drops blur while resizing.
|
|
3460
|
+
function initAtmosphere(){
|
|
3461
|
+
let reduce = false;
|
|
3462
|
+
try { reduce = window.matchMedia('(prefers-reduced-motion:reduce)').matches; } catch {}
|
|
3463
|
+
const root = document.documentElement;
|
|
3464
|
+
if (!reduce){
|
|
3465
|
+
let ticking = false, idle = null;
|
|
3466
|
+
const main = $('#main');
|
|
3467
|
+
const onScroll = () => {
|
|
3468
|
+
// Pause the glass backdrop-blur while the parallax is moving the atmosphere (it makes
|
|
3469
|
+
// re-blur cheap → smooth scroll); restore it shortly after scrolling stops.
|
|
3470
|
+
if (!root.classList.contains('scrolling')) root.classList.add('scrolling');
|
|
3471
|
+
clearTimeout(idle); idle = setTimeout(() => root.classList.remove('scrolling'), 140);
|
|
3472
|
+
if (ticking) return; ticking = true;
|
|
3473
|
+
requestAnimationFrame(() => {
|
|
3474
|
+
const y = main ? main.scrollTop : 0;
|
|
3475
|
+
root.style.setProperty('--py', String(Math.min(y * 0.46, 720)));
|
|
3476
|
+
ticking = false;
|
|
3477
|
+
});
|
|
3478
|
+
};
|
|
3479
|
+
main && main.addEventListener('scroll', onScroll, { passive:true });
|
|
3480
|
+
}
|
|
3481
|
+
let rt;
|
|
3482
|
+
window.addEventListener('resize', () => {
|
|
3483
|
+
root.classList.add('resizing');
|
|
3484
|
+
measureTabMark(); updateTabScroll();
|
|
3485
|
+
clearTimeout(rt); rt = setTimeout(() => { root.classList.remove('resizing'); applyTrimTitles(); measureTabMark(); updateTabScroll(); reserveRailForControls(); if (S.file || S.caseId || S.brief || S.readout || S.matchup) renderComments(); }, 180);
|
|
3486
|
+
}, { passive:true });
|
|
3487
|
+
}
|
|
3488
|
+
// ---- resizable sidebar -----------------------------------------------------
|
|
3489
|
+
// The left rail is drag-resizable via the #sbresize divider on its boundary. Width lives
|
|
3490
|
+
// in the `--sb` custom property (the grid's first column) and is clamped to [--sb-min,
|
|
3491
|
+
// --sb-max] and persisted. Keyboard: ←/→ nudge, Home/End jump to the bounds, double-click
|
|
3492
|
+
// resets to the default.
|
|
3493
|
+
const SB_DEFAULT = 288;
|
|
3494
|
+
function clampSb(w){
|
|
3495
|
+
const cs = getComputedStyle(document.documentElement);
|
|
3496
|
+
const min = parseInt(cs.getPropertyValue('--sb-min')) || 220;
|
|
3497
|
+
const max = parseInt(cs.getPropertyValue('--sb-max')) || 560;
|
|
3498
|
+
return Math.max(min, Math.min(max, Math.round(w)));
|
|
3499
|
+
}
|
|
3500
|
+
function curSb(){ return parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sb')) || SB_DEFAULT; }
|
|
3501
|
+
function setSidebarWidth(w, persist){
|
|
3502
|
+
const px = clampSb(w);
|
|
3503
|
+
document.documentElement.style.setProperty('--sb', px + 'px');
|
|
3504
|
+
if (persist){ try { localStorage.setItem('dlb-sb', String(px)); } catch {} }
|
|
3505
|
+
applyTrimTitles();
|
|
3506
|
+
measureTabMark();
|
|
3507
|
+
return px;
|
|
3508
|
+
}
|
|
3509
|
+
function applySavedSidebarWidth(){
|
|
3510
|
+
let w; try { w = parseInt(localStorage.getItem('dlb-sb') || '', 10); } catch {}
|
|
3511
|
+
if (w) document.documentElement.style.setProperty('--sb', clampSb(w) + 'px');
|
|
3512
|
+
}
|
|
3513
|
+
function initSidebarResize(){
|
|
3514
|
+
const h = $('#sbresize'); if (!h || h.__wired) return; h.__wired = true;
|
|
3515
|
+
let startX = 0, startW = 0, dragging = false;
|
|
3516
|
+
const move = (e) => { if (dragging) setSidebarWidth(startW + (e.clientX - startX), false); };
|
|
3517
|
+
const up = () => { if (!dragging) return; dragging = false; document.body.classList.remove('resizing-sb'); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); try { localStorage.setItem('dlb-sb', String(curSb())); } catch {} };
|
|
3518
|
+
h.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; e.preventDefault(); dragging = true; startX = e.clientX; startW = curSb(); document.body.classList.add('resizing-sb'); window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); });
|
|
3519
|
+
h.addEventListener('dblclick', () => setSidebarWidth(SB_DEFAULT, true));
|
|
3520
|
+
h.addEventListener('keydown', (e) => {
|
|
3521
|
+
const k = e.key;
|
|
3522
|
+
if (k === 'ArrowLeft'){ e.preventDefault(); setSidebarWidth(curSb() - 16, true); }
|
|
3523
|
+
else if (k === 'ArrowRight'){ e.preventDefault(); setSidebarWidth(curSb() + 16, true); }
|
|
3524
|
+
else if (k === 'Home'){ e.preventDefault(); setSidebarWidth(0, true); } // clamps to --sb-min
|
|
3525
|
+
else if (k === 'End'){ e.preventDefault(); setSidebarWidth(9999, true); } // clamps to --sb-max
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
3528
|
+
try {
|
|
3529
|
+
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').addEventListener &&
|
|
3530
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!themePref()) applyTheme(''); });
|
|
3531
|
+
} catch {}
|
|
3532
|
+
|
|
3533
|
+
// expose pure fns + actions for tests
|
|
3534
|
+
window.DLB = { renderMarkdown, inline, segment, reconstruct, verdict, boot, render, navigate, resolveAssetSrc, openDocLink,
|
|
3535
|
+
openCase, openBrief, openReadout, openMatchup, renderInbox, renderSettings, renderFeedback, applyTheme, openCaseTab, openBriefTab, openReadoutTab, openMatchupTab, openInboxTab, openSettingsTab, openFeedbackTab, closeTab, closeOthers, closeAll,
|
|
3536
|
+
switchProject, projMenu, openFolderPrompt, folderDialog, removeVault, briefLabel, readoutLabel, matchupLabel, fillExplorer, renderTree, pruneTree, matchFileNode, toggleFolder, folderOpen, collapseAllFolders, expandAllFolders, toggleCollapseAll, allTopCollapsed, queueConfig, flushConfig, queueExplorer, openFile, openFileTab, renderLive, renderComments, submitComment, createComment, maybeStale, handleBridgeEvent, loadComments,
|
|
3537
|
+
openCommentPop, focusComment, deleteCommentUI, dropComment, stepComment, startEditComment, commentOnBlock,
|
|
3538
|
+
currentSource, reloadOpenDoc, scheduleDocReload,
|
|
3539
|
+
flushSave, flushOnUnload, saveNow,
|
|
3540
|
+
applyTrimTitles, setSidebarWidth, initSidebarResize, curSb,
|
|
3541
|
+
setDiffMode, refreshTree, promptNewFile, promptNewFolder, confirmDeleteEntry, moveEntry, renameEntry, validDrop, dropDirFor, route, encFilePath, decFilePath, updateTabMark, measureTabMark, updateTabScroll, nudgeTabs, syncDocDiff,
|
|
3542
|
+
reserveRailForControls, dirGitStatus, loadDiffBase, applyDiffDecorations, refreshDiffDecorations, buildToc,
|
|
3543
|
+
maybeShowAsk: () => maybeShowAsk($('#doc')),
|
|
3544
|
+
maybeShowTableTools, openAtComment,
|
|
3545
|
+
openFeedback, closeFeedback, submitFeedbackUI, setTelemetryConsent, tel: () => TEL,
|
|
3546
|
+
wysiwyg: () => wysiwygCtl,
|
|
3547
|
+
setMode, state: () => S };
|
|
3548
|
+
|
|
3549
|
+
if (!window.__DLB_NO_AUTOBOOT){
|
|
3550
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|
3551
|
+
else boot();
|
|
3552
|
+
}
|
|
3553
|
+
})();
|