claude-plan-review 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -4
- package/package.json +2 -1
- package/skill/SKILL.md +86 -0
- package/src/cli.js +192 -7
- package/src/hook.js +31 -8
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -0
- package/src/plan-parse.js +238 -0
- package/src/server.js +261 -10
- package/src/stop-gate.js +73 -0
- package/src/store.js +309 -33
- package/src/ui/app.js +649 -108
- package/src/ui/index.html +51 -15
- package/src/ui/style.css +73 -18
package/src/ui/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const $ = (id) => document.getElementById(id);
|
|
4
|
+
const enc = encodeURIComponent;
|
|
4
5
|
const esc = (s) =>
|
|
5
6
|
String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
6
7
|
|
|
@@ -9,7 +10,10 @@ async function api(path, opts) {
|
|
|
9
10
|
if (!res.ok) {
|
|
10
11
|
const body = await res.json().catch(() => ({}));
|
|
11
12
|
const err = new Error(body.error || res.statusText);
|
|
13
|
+
err.status = res.status;
|
|
14
|
+
err.body = body;
|
|
12
15
|
err.fixCommand = body.fixCommand ?? null;
|
|
16
|
+
err.pendingReviews = body.pendingReviews ?? null;
|
|
13
17
|
throw err;
|
|
14
18
|
}
|
|
15
19
|
return res.status === 204 ? null : res.json();
|
|
@@ -21,15 +25,30 @@ const state = {
|
|
|
21
25
|
baseVersion: null,
|
|
22
26
|
reviewId: null,
|
|
23
27
|
review: null,
|
|
28
|
+
projects: [],
|
|
24
29
|
versions: [],
|
|
25
|
-
data: null, // { markdown, html, meta }
|
|
26
|
-
|
|
30
|
+
data: null, // { markdown, html, meta, kind?, manifest? }
|
|
31
|
+
// --- multi-document ---
|
|
32
|
+
manifest: null, // current version's manifest, or null for legacy/flat
|
|
33
|
+
kind: "single",
|
|
34
|
+
doc: "root", // active doc slug
|
|
35
|
+
docCache: new Map(), // slug → {slug,title,parent,markdown,html}
|
|
36
|
+
manifestCache: new Map(), // version n → manifest|null (for the diff doc union)
|
|
37
|
+
allComments: [], // every comment on the version (each carries `doc`) — drives badges
|
|
38
|
+
viewAsTree: false, // single-doc display-only outline mode
|
|
39
|
+
treeSection: -1, // selected outline section index (-1 = show all)
|
|
40
|
+
_sections: null, // cached outline sections for the current preview
|
|
41
|
+
// --- deletion ---
|
|
42
|
+
pendingDelete: null, // { type:"version"|"project"|"bulk", key, n?, ns?, fromManage? }
|
|
43
|
+
manageProjects: null,
|
|
44
|
+
// ---
|
|
45
|
+
comments: [], // comments scoped to the active doc
|
|
27
46
|
tab: "preview",
|
|
28
47
|
diffMode: "split",
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
storage: {},
|
|
32
|
-
channels: [],
|
|
48
|
+
pendingSel: null,
|
|
49
|
+
pendingDoc: null, // &doc= from the initial URL, consumed once
|
|
50
|
+
storage: {},
|
|
51
|
+
channels: [],
|
|
33
52
|
};
|
|
34
53
|
|
|
35
54
|
function toast(msg) {
|
|
@@ -46,6 +65,7 @@ async function init() {
|
|
|
46
65
|
state.key = q.get("project");
|
|
47
66
|
state.reviewId = q.get("review");
|
|
48
67
|
const wantV = q.get("version");
|
|
68
|
+
state.pendingDoc = q.get("doc");
|
|
49
69
|
|
|
50
70
|
wireUI();
|
|
51
71
|
api("/api/version")
|
|
@@ -66,8 +86,11 @@ function showEmpty(msg) {
|
|
|
66
86
|
document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active"));
|
|
67
87
|
$("panel-preview").classList.add("active");
|
|
68
88
|
$("preview").innerHTML = `<div class="empty">${esc(msg)}</div>`;
|
|
89
|
+
$("docSidebar").hidden = true;
|
|
69
90
|
$("reviewActions").hidden = true;
|
|
70
91
|
$("saveActions").hidden = true;
|
|
92
|
+
$("deleteVerBtn").hidden = true;
|
|
93
|
+
$("treeToggle").hidden = true;
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
async function loadProjects() {
|
|
@@ -86,9 +109,10 @@ async function loadProjects() {
|
|
|
86
109
|
async function selectProject(key, wantV) {
|
|
87
110
|
state.key = key;
|
|
88
111
|
$("projectSel").value = key;
|
|
89
|
-
|
|
112
|
+
state.manifestCache = new Map();
|
|
113
|
+
const { versions } = await api(`/api/projects/${enc(key)}`);
|
|
90
114
|
state.versions = versions;
|
|
91
|
-
state.storage = await api(`/api/projects/${
|
|
115
|
+
state.storage = await api(`/api/projects/${enc(key)}/storage`).catch(() => ({}));
|
|
92
116
|
if (!versions.length) {
|
|
93
117
|
showEmpty("This project has no plan versions yet.");
|
|
94
118
|
return;
|
|
@@ -99,6 +123,7 @@ async function selectProject(key, wantV) {
|
|
|
99
123
|
const fmt = (v) => `v${v.n} · ${new Date(v.createdAt).toLocaleString()}${v.n === latest ? " (current)" : ""}`;
|
|
100
124
|
$("versionSel").innerHTML = versions.map((v) => `<option value="${v.n}">${esc(fmt(v))}</option>`).join("");
|
|
101
125
|
$("versionSel").value = String(state.version);
|
|
126
|
+
$("deleteVerBtn").hidden = false;
|
|
102
127
|
|
|
103
128
|
// base = the version right before current, if any
|
|
104
129
|
state.baseVersion = versions.length > 1 ? versions[versions.findIndex((v) => v.n === state.version) - 1]?.n ?? versions[0].n : null;
|
|
@@ -113,21 +138,87 @@ async function selectProject(key, wantV) {
|
|
|
113
138
|
|
|
114
139
|
async function loadVersion(n) {
|
|
115
140
|
state.version = n;
|
|
116
|
-
|
|
117
|
-
state.
|
|
141
|
+
hideSelPop();
|
|
142
|
+
state.viewAsTree = false;
|
|
143
|
+
state.treeSection = -1;
|
|
144
|
+
state.docCache = new Map();
|
|
145
|
+
state.data = await api(`/api/projects/${enc(state.key)}/versions/${n}`);
|
|
146
|
+
// Legacy/flat servers return no manifest — treat the whole plan as one root doc.
|
|
147
|
+
state.manifest = state.data.manifest || null;
|
|
148
|
+
const docs = state.manifest?.docs || [];
|
|
149
|
+
const isTree = docs.length > 1;
|
|
150
|
+
state.kind = state.data.kind || (isTree ? "tree" : "single");
|
|
118
151
|
$("diffCur").textContent = n;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
152
|
+
updateTreeToggle();
|
|
153
|
+
|
|
154
|
+
if (isTree) {
|
|
155
|
+
// sidebar badge counts come from one unscoped fetch; threads filter it per doc.
|
|
156
|
+
state.allComments = await api(`/api/projects/${enc(state.key)}/versions/${n}/comments`);
|
|
157
|
+
let slug = state.manifest.root || docs[0].slug;
|
|
158
|
+
if (state.pendingDoc && docs.some((d) => d.slug === state.pendingDoc)) slug = state.pendingDoc;
|
|
159
|
+
state.pendingDoc = null;
|
|
160
|
+
await loadDoc(slug);
|
|
161
|
+
} else {
|
|
162
|
+
state.doc = "root";
|
|
163
|
+
state.comments = await api(`/api/projects/${enc(state.key)}/versions/${n}/comments`);
|
|
164
|
+
state.allComments = state.comments;
|
|
165
|
+
renderPreview();
|
|
166
|
+
renderGeneral();
|
|
167
|
+
updateCommentCount();
|
|
168
|
+
renderSidebar();
|
|
169
|
+
}
|
|
170
|
+
|
|
123
171
|
refreshSaveUI();
|
|
124
172
|
await refreshReview();
|
|
125
173
|
if (state.tab === "diff") loadDiff();
|
|
126
174
|
}
|
|
127
175
|
|
|
176
|
+
// Load a document within a tree version (cached), scoping comments + preview to it.
|
|
177
|
+
async function loadDoc(slug) {
|
|
178
|
+
state.doc = slug;
|
|
179
|
+
hideSelPop();
|
|
180
|
+
let doc = state.docCache.get(slug);
|
|
181
|
+
if (!doc) {
|
|
182
|
+
doc = await api(`/api/projects/${enc(state.key)}/versions/${state.version}/docs/${enc(slug)}`);
|
|
183
|
+
state.docCache.set(slug, doc);
|
|
184
|
+
}
|
|
185
|
+
state.data = { ...state.data, markdown: doc.markdown, html: doc.html };
|
|
186
|
+
state.comments = (state.allComments || []).filter((c) => (c.doc || "root") === slug);
|
|
187
|
+
updateDocParam();
|
|
188
|
+
renderPreview();
|
|
189
|
+
renderGeneral();
|
|
190
|
+
updateCommentCount();
|
|
191
|
+
renderSidebar();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// keep &doc in the URL in sync without a reload (no pushState today — greenfield)
|
|
195
|
+
function updateDocParam() {
|
|
196
|
+
const q = new URLSearchParams(location.search);
|
|
197
|
+
if (state.key) q.set("project", state.key);
|
|
198
|
+
if (state.version) q.set("version", String(state.version));
|
|
199
|
+
if (state.doc && state.doc !== "root") q.set("doc", state.doc);
|
|
200
|
+
else q.delete("doc");
|
|
201
|
+
history.replaceState(null, "", location.pathname + "?" + q.toString());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// refetch comments after a create/delete, keeping the doc-scoped + badge views in sync
|
|
205
|
+
async function refreshComments() {
|
|
206
|
+
state.allComments = await api(`/api/projects/${enc(state.key)}/versions/${state.version}/comments`);
|
|
207
|
+
const isTree = state.manifest && state.manifest.docs.length > 1;
|
|
208
|
+
state.comments = isTree
|
|
209
|
+
? state.allComments.filter((c) => (c.doc || "root") === state.doc)
|
|
210
|
+
: state.allComments;
|
|
211
|
+
renderPreview();
|
|
212
|
+
renderGeneral();
|
|
213
|
+
updateCommentCount();
|
|
214
|
+
renderSidebar();
|
|
215
|
+
}
|
|
216
|
+
|
|
128
217
|
// ---------- rendering ----------
|
|
129
218
|
function renderPreview() {
|
|
130
219
|
$("preview").innerHTML = state.data.html || "<p class='empty'>(empty plan)</p>";
|
|
220
|
+
renderThreads();
|
|
221
|
+
if (state.viewAsTree) applyTreeView();
|
|
131
222
|
}
|
|
132
223
|
|
|
133
224
|
// threads are anchored under the END line of their range; covered = every line a comment spans
|
|
@@ -154,28 +245,45 @@ function locLabel(c) {
|
|
|
154
245
|
|
|
155
246
|
function commentHTML(c) {
|
|
156
247
|
const loc = locLabel(c);
|
|
248
|
+
const quote = c.quote ? `<div class="cquote">${esc(c.quote)}</div>` : "";
|
|
157
249
|
return `<div class="comment" data-id="${c.id}">
|
|
158
250
|
<div class="chead"><span><b>${esc(c.author)}</b>${loc ? ` · <span class="loc">${loc}</span>` : ""} · ${new Date(c.createdAt).toLocaleString()}</span>
|
|
159
251
|
<button class="del" data-del="${c.id}">delete</button></div>
|
|
252
|
+
${quote}
|
|
160
253
|
<div class="cbody">${esc(c.body)}</div></div>`;
|
|
161
254
|
}
|
|
162
255
|
|
|
163
|
-
|
|
164
|
-
|
|
256
|
+
// map a source line to the rendered block that spans it
|
|
257
|
+
function blockForLine(line) {
|
|
258
|
+
const blocks = $("preview").querySelectorAll(".md-block");
|
|
259
|
+
for (const b of blocks) {
|
|
260
|
+
if (Number(b.dataset.lineStart) <= line && line <= Number(b.dataset.lineEnd)) return b;
|
|
261
|
+
}
|
|
262
|
+
return blocks[blocks.length - 1] || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// render inline comment threads into the preview, anchored after their block
|
|
266
|
+
function renderThreads() {
|
|
267
|
+
const preview = $("preview");
|
|
268
|
+
preview.querySelectorAll(".threads, .composer.inline").forEach((n) => n.remove());
|
|
165
269
|
const { byAnchor, covered } = commentLayout();
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
270
|
+
|
|
271
|
+
preview.querySelectorAll(".md-block").forEach((b) => {
|
|
272
|
+
const s = Number(b.dataset.lineStart);
|
|
273
|
+
const e = Number(b.dataset.lineEnd);
|
|
274
|
+
let has = false;
|
|
275
|
+
for (let k = s; k <= e && !has; k++) if (covered.has(k)) has = true;
|
|
276
|
+
b.classList.toggle("has-comments", has);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
for (const [anchor, threads] of byAnchor) {
|
|
280
|
+
const block = blockForLine(anchor);
|
|
281
|
+
if (!block) continue;
|
|
282
|
+
const box = document.createElement("div");
|
|
283
|
+
box.className = "threads";
|
|
284
|
+
box.innerHTML = threads.map(commentHTML).join("");
|
|
285
|
+
block.after(box);
|
|
177
286
|
}
|
|
178
|
-
$("source").innerHTML = html;
|
|
179
287
|
}
|
|
180
288
|
|
|
181
289
|
function renderGeneral() {
|
|
@@ -184,67 +292,230 @@ function renderGeneral() {
|
|
|
184
292
|
}
|
|
185
293
|
|
|
186
294
|
function updateCommentCount() {
|
|
187
|
-
const n = state.comments.length;
|
|
295
|
+
const n = (state.allComments || state.comments).length;
|
|
188
296
|
$("cCount").textContent = n ? n : "";
|
|
189
297
|
}
|
|
190
298
|
|
|
299
|
+
// ---------- sidebar (doc tree / outline) ----------
|
|
300
|
+
function docCounts() {
|
|
301
|
+
const m = new Map();
|
|
302
|
+
for (const c of state.allComments || []) {
|
|
303
|
+
const d = c.doc || "root";
|
|
304
|
+
m.set(d, (m.get(d) || 0) + 1);
|
|
305
|
+
}
|
|
306
|
+
return m;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function renderSidebar() {
|
|
310
|
+
const sb = $("docSidebar");
|
|
311
|
+
const isTree = state.manifest && state.manifest.docs.length > 1;
|
|
312
|
+
if (isTree) {
|
|
313
|
+
sb.hidden = false;
|
|
314
|
+
sb.innerHTML = renderDocTree();
|
|
315
|
+
} else if (state.viewAsTree) {
|
|
316
|
+
sb.hidden = false;
|
|
317
|
+
sb.innerHTML = renderHeadingTree();
|
|
318
|
+
} else {
|
|
319
|
+
sb.hidden = true;
|
|
320
|
+
sb.innerHTML = "";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderDocTree() {
|
|
325
|
+
const docs = state.manifest.docs.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
326
|
+
const bySlug = new Map(docs.map((d) => [d.slug, d]));
|
|
327
|
+
const rootSlug = state.manifest.root || "root";
|
|
328
|
+
const counts = docCounts();
|
|
329
|
+
const pending = state.review && state.review.status === "pending";
|
|
330
|
+
|
|
331
|
+
const parentKey = (d) => {
|
|
332
|
+
const p = d.parent;
|
|
333
|
+
if (!p || p === "") return d.slug === rootSlug ? null : rootSlug;
|
|
334
|
+
return p;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const rows = [];
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
const walk = (slug, depth) => {
|
|
340
|
+
if (seen.has(slug)) return;
|
|
341
|
+
const d = bySlug.get(slug);
|
|
342
|
+
if (!d) return;
|
|
343
|
+
seen.add(slug);
|
|
344
|
+
rows.push(docRow(d, depth, counts, pending));
|
|
345
|
+
docs.filter((x) => parentKey(x) === slug).forEach((ch) => walk(ch.slug, depth + 1));
|
|
346
|
+
};
|
|
347
|
+
walk(rootSlug, 0);
|
|
348
|
+
// orphans (parent points nowhere) — render at top level so nothing is lost
|
|
349
|
+
docs.forEach((d) => {
|
|
350
|
+
if (!seen.has(d.slug)) walk(d.slug, 0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return `<div class="sb-head">Documents</div>${rows.join("")}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function docRow(d, depth, counts, pending) {
|
|
357
|
+
const n = counts.get(d.slug) || 0;
|
|
358
|
+
const active = d.slug === state.doc ? " active" : "";
|
|
359
|
+
const dot = n > 0 && pending ? `<span class="sb-dot" title="comments pending review"></span>` : "";
|
|
360
|
+
const badge = n ? `<span class="sb-badge">${n}</span>` : "";
|
|
361
|
+
return `<a class="sb-row${active}" data-doc="${esc(d.slug)}" href="#" style="padding-left:${8 + depth * 14}px">
|
|
362
|
+
<span class="sb-title">${esc(d.title)}</span>${dot}${badge}</a>`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------- view-as-tree (single-doc, display-only) ----------
|
|
366
|
+
// Split the server-rendered preview into heading sections. Blocks are HIDDEN via
|
|
367
|
+
// a class, never detached — blockForLine/commentLayout still walk them by line.
|
|
368
|
+
function computeSections() {
|
|
369
|
+
const blocks = [...$("preview").querySelectorAll(".md-block")];
|
|
370
|
+
const sections = [];
|
|
371
|
+
let cur = null;
|
|
372
|
+
for (const b of blocks) {
|
|
373
|
+
const first = b.firstElementChild;
|
|
374
|
+
const isHeading = first && /^H[1-6]$/.test(first.tagName);
|
|
375
|
+
if (isHeading) {
|
|
376
|
+
cur = { id: sections.length, level: Number(first.tagName[1]), title: first.textContent.trim() || "(untitled)", blocks: [b] };
|
|
377
|
+
sections.push(cur);
|
|
378
|
+
} else {
|
|
379
|
+
if (!cur) {
|
|
380
|
+
cur = { id: sections.length, level: 0, title: "(top)", blocks: [] };
|
|
381
|
+
sections.push(cur);
|
|
382
|
+
}
|
|
383
|
+
cur.blocks.push(b);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return sections;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function applyTreeView() {
|
|
390
|
+
const sections = computeSections();
|
|
391
|
+
state._sections = sections;
|
|
392
|
+
if (state.treeSection >= sections.length) state.treeSection = -1;
|
|
393
|
+
const blocks = [...$("preview").querySelectorAll(".md-block")];
|
|
394
|
+
|
|
395
|
+
const show = new Set();
|
|
396
|
+
if (state.treeSection === -1) {
|
|
397
|
+
blocks.forEach((b) => show.add(b));
|
|
398
|
+
} else {
|
|
399
|
+
const sel = sections[state.treeSection];
|
|
400
|
+
sel.blocks.forEach((b) => show.add(b));
|
|
401
|
+
if (sel.level !== 0) {
|
|
402
|
+
for (let j = state.treeSection + 1; j < sections.length; j++) {
|
|
403
|
+
if (sections[j].level > sel.level) sections[j].blocks.forEach((b) => show.add(b));
|
|
404
|
+
else break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
blocks.forEach((b) => b.classList.toggle("tree-hidden", !show.has(b)));
|
|
409
|
+
// hide threads whose owning block is hidden
|
|
410
|
+
$("preview").querySelectorAll(".threads, .composer.inline").forEach((t) => {
|
|
411
|
+
const prev = t.previousElementSibling;
|
|
412
|
+
const hidden = prev && prev.classList.contains("md-block") && prev.classList.contains("tree-hidden");
|
|
413
|
+
t.classList.toggle("tree-hidden", !!hidden);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function renderHeadingTree() {
|
|
418
|
+
const sections = state._sections || computeSections();
|
|
419
|
+
state._sections = sections;
|
|
420
|
+
const rows = [`<div class="sb-head">Outline</div>`];
|
|
421
|
+
rows.push(
|
|
422
|
+
`<a class="sb-row${state.treeSection === -1 ? " active" : ""}" data-sec="-1" href="#"><span class="sb-title">Show all</span></a>`,
|
|
423
|
+
);
|
|
424
|
+
sections.forEach((s, i) => {
|
|
425
|
+
if (s.level === 0 && !s.blocks.length) return;
|
|
426
|
+
const depth = s.level === 0 ? 0 : s.level - 1;
|
|
427
|
+
rows.push(
|
|
428
|
+
`<a class="sb-row${state.treeSection === i ? " active" : ""}" data-sec="${i}" href="#" style="padding-left:${8 + depth * 14}px"><span class="sb-title">${esc(s.title)}</span></a>`,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
return rows.join("");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function updateTreeToggle() {
|
|
435
|
+
const isTree = state.manifest && state.manifest.docs.length > 1;
|
|
436
|
+
const t = $("treeToggle");
|
|
437
|
+
t.hidden = isTree; // real doc trees get the sidebar automatically
|
|
438
|
+
if (isTree) state.viewAsTree = false;
|
|
439
|
+
t.classList.toggle("active", state.viewAsTree);
|
|
440
|
+
t.textContent = state.viewAsTree ? "Exit tree view" : "View as tree";
|
|
441
|
+
}
|
|
442
|
+
|
|
191
443
|
// ---------- comments ----------
|
|
192
|
-
async function addComment(line, lineEnd, body) {
|
|
444
|
+
async function addComment(line, lineEnd, body, quote) {
|
|
193
445
|
if (!body.trim()) return;
|
|
194
|
-
await api(`/api/projects/${
|
|
446
|
+
await api(`/api/projects/${enc(state.key)}/versions/${state.version}/comments`, {
|
|
195
447
|
method: "POST",
|
|
196
448
|
headers: { "content-type": "application/json" },
|
|
197
|
-
body: JSON.stringify({ line, lineEnd, body }),
|
|
449
|
+
body: JSON.stringify({ doc: state.doc, line, lineEnd, quote: quote ?? null, body }),
|
|
198
450
|
});
|
|
199
|
-
|
|
200
|
-
renderSource();
|
|
201
|
-
renderGeneral();
|
|
202
|
-
updateCommentCount();
|
|
451
|
+
await refreshComments();
|
|
203
452
|
}
|
|
204
453
|
|
|
205
454
|
async function delComment(id) {
|
|
206
|
-
await api(`/api/projects/${
|
|
455
|
+
await api(`/api/projects/${enc(state.key)}/versions/${state.version}/comments/${id}`, {
|
|
207
456
|
method: "DELETE",
|
|
208
457
|
});
|
|
209
|
-
|
|
210
|
-
renderSource();
|
|
211
|
-
renderGeneral();
|
|
212
|
-
updateCommentCount();
|
|
458
|
+
await refreshComments();
|
|
213
459
|
}
|
|
214
460
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
461
|
+
// ---------- text selection → line-anchored comment ----------
|
|
462
|
+
function currentSelection() {
|
|
463
|
+
const sel = window.getSelection();
|
|
464
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
|
465
|
+
const range = sel.getRangeAt(0);
|
|
466
|
+
const preview = $("preview");
|
|
467
|
+
if (!preview.contains(range.commonAncestorContainer)) return null;
|
|
468
|
+
const quote = sel.toString().replace(/\s+/g, " ").trim();
|
|
469
|
+
if (!quote) return null;
|
|
470
|
+
|
|
471
|
+
const blocks = [];
|
|
472
|
+
preview.querySelectorAll(".md-block").forEach((b) => {
|
|
473
|
+
if (b.classList.contains("tree-hidden")) return;
|
|
474
|
+
const ir = range.cloneRange();
|
|
475
|
+
const br = document.createRange();
|
|
476
|
+
br.selectNodeContents(b);
|
|
477
|
+
if (ir.compareBoundaryPoints(Range.START_TO_START, br) < 0) ir.setStart(br.startContainer, br.startOffset);
|
|
478
|
+
if (ir.compareBoundaryPoints(Range.END_TO_END, br) > 0) ir.setEnd(br.endContainer, br.endOffset);
|
|
479
|
+
if (!ir.collapsed && ir.toString().trim()) blocks.push(b);
|
|
222
480
|
});
|
|
481
|
+
if (!blocks.length) return null;
|
|
482
|
+
const start = Math.min(...blocks.map((b) => Number(b.dataset.lineStart)));
|
|
483
|
+
const end = Math.max(...blocks.map((b) => Number(b.dataset.lineEnd)));
|
|
484
|
+
return { start, end, quote, rect: range.getBoundingClientRect() };
|
|
223
485
|
}
|
|
224
486
|
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
487
|
+
function showSelPop(info) {
|
|
488
|
+
const pop = $("selPop");
|
|
489
|
+
pop.hidden = false;
|
|
490
|
+
pop.style.top = `${info.rect.bottom + 6}px`;
|
|
491
|
+
pop.style.left = `${info.rect.left}px`;
|
|
492
|
+
state.pendingSel = info;
|
|
229
493
|
}
|
|
230
494
|
|
|
231
|
-
function
|
|
495
|
+
function hideSelPop() {
|
|
496
|
+
$("selPop").hidden = true;
|
|
497
|
+
state.pendingSel = null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function openComposer(info) {
|
|
232
501
|
document.querySelector(".composer.inline")?.remove();
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
235
|
-
const label = start === end ? `line ${start}` : `lines ${start}–${end}`;
|
|
502
|
+
const block = blockForLine(info.end);
|
|
503
|
+
if (!block) return;
|
|
504
|
+
const label = info.start === info.end ? `line ${info.start}` : `lines ${info.start}–${info.end}`;
|
|
236
505
|
const box = document.createElement("div");
|
|
237
506
|
box.className = "composer inline";
|
|
238
|
-
box.innerHTML = `<
|
|
239
|
-
<
|
|
240
|
-
|
|
507
|
+
box.innerHTML = `${info.quote ? `<div class="cquote">${esc(info.quote)}</div>` : ""}
|
|
508
|
+
<div class="composer-row">
|
|
509
|
+
<textarea placeholder="Comment on ${label}…"></textarea>
|
|
510
|
+
<button class="btn primary">Comment</button><button class="btn cancel">Cancel</button>
|
|
511
|
+
</div>`;
|
|
512
|
+
block.after(box);
|
|
241
513
|
const ta = box.querySelector("textarea");
|
|
242
514
|
ta.focus();
|
|
243
515
|
box.querySelector(".primary").onclick = async () => {
|
|
244
|
-
await addComment(start, end, ta.value);
|
|
245
|
-
clearSelection();
|
|
516
|
+
await addComment(info.start, info.end, ta.value, info.quote);
|
|
246
517
|
};
|
|
247
|
-
box.querySelector(".cancel").onclick = () =>
|
|
518
|
+
box.querySelector(".cancel").onclick = () => box.remove();
|
|
248
519
|
}
|
|
249
520
|
|
|
250
521
|
// ---------- diff ----------
|
|
@@ -268,18 +539,60 @@ function diffLines(a, b) {
|
|
|
268
539
|
return ops;
|
|
269
540
|
}
|
|
270
541
|
|
|
542
|
+
async function manifestFor(n) {
|
|
543
|
+
if (state.manifestCache.has(n)) return state.manifestCache.get(n);
|
|
544
|
+
const v = await api(`/api/projects/${enc(state.key)}/versions/${n}`).catch(() => null);
|
|
545
|
+
const mf = v?.manifest || null;
|
|
546
|
+
state.manifestCache.set(n, mf);
|
|
547
|
+
return mf;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function versionSummary(n) {
|
|
551
|
+
return state.versions.find((v) => v.n === n);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Build the doc <select> for the diff (union of both sides' slugs). Hidden when
|
|
555
|
+
// both versions are flat (no docCount>1) so flat-vs-flat looks exactly as today.
|
|
556
|
+
async function updateDiffDocSel() {
|
|
557
|
+
const from = versionSummary(state.baseVersion);
|
|
558
|
+
const to = versionSummary(state.version);
|
|
559
|
+
const treeDiff = (from?.docCount > 1) || (to?.docCount > 1);
|
|
560
|
+
const wrap = $("diffDocWrap");
|
|
561
|
+
wrap.hidden = !treeDiff;
|
|
562
|
+
if (!treeDiff) return;
|
|
563
|
+
|
|
564
|
+
const [mf, mt] = await Promise.all([manifestFor(state.baseVersion), manifestFor(state.version)]);
|
|
565
|
+
const slugs = [];
|
|
566
|
+
const seen = new Set();
|
|
567
|
+
for (const mm of [mf, mt])
|
|
568
|
+
for (const d of mm?.docs || [])
|
|
569
|
+
if (!seen.has(d.slug)) {
|
|
570
|
+
seen.add(d.slug);
|
|
571
|
+
slugs.push({ slug: d.slug, title: d.title });
|
|
572
|
+
}
|
|
573
|
+
const prev = $("diffDocSel").value;
|
|
574
|
+
$("diffDocSel").innerHTML = slugs.map((s) => `<option value="${esc(s.slug)}">${esc(s.title)}</option>`).join("");
|
|
575
|
+
const wantRoot = state.manifest?.root || "root";
|
|
576
|
+
if (slugs.some((s) => s.slug === prev)) $("diffDocSel").value = prev;
|
|
577
|
+
else if (slugs.some((s) => s.slug === wantRoot)) $("diffDocSel").value = wantRoot;
|
|
578
|
+
else if (slugs.length) $("diffDocSel").value = slugs[0].slug;
|
|
579
|
+
}
|
|
580
|
+
|
|
271
581
|
async function loadDiff() {
|
|
272
582
|
if (!state.baseVersion) {
|
|
273
583
|
$("diff").innerHTML = "<div class='empty'>No earlier version to compare against.</div>";
|
|
274
584
|
$("diffStat").textContent = "";
|
|
585
|
+
$("diffDocWrap").hidden = true;
|
|
275
586
|
return;
|
|
276
587
|
}
|
|
277
588
|
state.baseVersion = Number($("baseSel").value);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
);
|
|
281
|
-
const
|
|
282
|
-
const
|
|
589
|
+
await updateDiffDocSel();
|
|
590
|
+
const scoped = !$("diffDocWrap").hidden;
|
|
591
|
+
const doc = scoped ? $("diffDocSel").value || "root" : null;
|
|
592
|
+
const qs = `from=${state.baseVersion}&to=${state.version}${doc ? `&doc=${enc(doc)}` : ""}`;
|
|
593
|
+
const d = await api(`/api/projects/${enc(state.key)}/diff?${qs}`);
|
|
594
|
+
const a = (d.from.markdown || "").split("\n");
|
|
595
|
+
const b = (d.to.markdown || "").split("\n");
|
|
283
596
|
const ops = diffLines(a, b);
|
|
284
597
|
const adds = ops.filter((o) => o.t === "add").length;
|
|
285
598
|
const dels = ops.filter((o) => o.t === "del").length;
|
|
@@ -381,6 +694,13 @@ async function resolve(decision) {
|
|
|
381
694
|
function projectName() {
|
|
382
695
|
return state.projects?.find((p) => p.key === state.key)?.name || "plan";
|
|
383
696
|
}
|
|
697
|
+
function projNameByKey(key) {
|
|
698
|
+
return (
|
|
699
|
+
state.manageProjects?.find((p) => p.key === key)?.name ||
|
|
700
|
+
state.projects?.find((p) => p.key === key)?.name ||
|
|
701
|
+
key
|
|
702
|
+
);
|
|
703
|
+
}
|
|
384
704
|
|
|
385
705
|
function refreshSaveUI() {
|
|
386
706
|
const box = $("saveActions");
|
|
@@ -463,7 +783,7 @@ async function doSave() {
|
|
|
463
783
|
btn.textContent = "Saving…";
|
|
464
784
|
try {
|
|
465
785
|
const r = await api(
|
|
466
|
-
`/api/projects/${
|
|
786
|
+
`/api/projects/${enc(state.key)}/versions/${state.version}/save`,
|
|
467
787
|
{
|
|
468
788
|
method: "POST",
|
|
469
789
|
headers: { "content-type": "application/json" },
|
|
@@ -475,7 +795,6 @@ async function doSave() {
|
|
|
475
795
|
refreshSaveUI();
|
|
476
796
|
toast(`Saved v${state.version} to ${channel}.`);
|
|
477
797
|
} catch (e) {
|
|
478
|
-
// surface the remediation command if the channel handed one back
|
|
479
798
|
renderChannelStatus({ ready: false, reason: e.message, fixCommand: e.fixCommand });
|
|
480
799
|
} finally {
|
|
481
800
|
btn.textContent = prev;
|
|
@@ -483,6 +802,169 @@ async function doSave() {
|
|
|
483
802
|
}
|
|
484
803
|
}
|
|
485
804
|
|
|
805
|
+
// ---------- deletion ----------
|
|
806
|
+
function showDeleteModal(title, desc) {
|
|
807
|
+
$("deleteTitle").textContent = title;
|
|
808
|
+
$("deleteDesc").textContent = desc;
|
|
809
|
+
$("deletePending").hidden = true;
|
|
810
|
+
$("deletePending").textContent = "";
|
|
811
|
+
$("deleteForce").hidden = true;
|
|
812
|
+
$("deleteConfirm").hidden = false;
|
|
813
|
+
$("deleteConfirm").disabled = false;
|
|
814
|
+
$("deleteForce").disabled = false;
|
|
815
|
+
$("deleteModal").hidden = false;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function openDeleteVersion() {
|
|
819
|
+
if (!state.version) return;
|
|
820
|
+
state.pendingDelete = { type: "version", key: state.key, n: state.version };
|
|
821
|
+
showDeleteModal(
|
|
822
|
+
`Delete v${state.version}?`,
|
|
823
|
+
`This permanently removes v${state.version} of “${projectName()}” and its comments. This can't be undone.`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function showDeleteBlocked(e) {
|
|
828
|
+
const revs = e.pendingReviews || [];
|
|
829
|
+
const info = $("deletePending");
|
|
830
|
+
info.hidden = false;
|
|
831
|
+
const many = revs.length !== 1;
|
|
832
|
+
info.textContent =
|
|
833
|
+
`Blocked: ${revs.length || "a"} pending review${many ? "s" : ""} still awaiting a decision. ` +
|
|
834
|
+
`Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan was deleted and to re-present or ask how to proceed), then delete.`;
|
|
835
|
+
$("deleteForce").hidden = false;
|
|
836
|
+
$("deleteConfirm").hidden = true;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// bulk-delete is always 200 with partial-success {deleted,blocked,meta}; render
|
|
840
|
+
// the blocked versions (+ their pending reviews) and offer a Force retry for
|
|
841
|
+
// just those. Single-version / project DELETE stay 409-on-block (showDeleteBlocked).
|
|
842
|
+
function showBulkBlocked(blocked, deleted) {
|
|
843
|
+
const info = $("deletePending");
|
|
844
|
+
info.hidden = false;
|
|
845
|
+
const ns = blocked.map((b) => b.n);
|
|
846
|
+
const totalPending = blocked.reduce((a, b) => a + (b.pendingReviews?.length || 0), 0);
|
|
847
|
+
const many = totalPending !== 1;
|
|
848
|
+
const delMsg = deleted.length ? `Deleted v${deleted.join(", v")}. ` : "";
|
|
849
|
+
info.textContent =
|
|
850
|
+
`${delMsg}Blocked: v${ns.join(", v")} — ${totalPending || "a"} pending review${many ? "s" : ""} ` +
|
|
851
|
+
`still awaiting a decision. Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan ` +
|
|
852
|
+
`was deleted and to re-present or ask how to proceed) and delete the remaining ${ns.length} version${ns.length === 1 ? "" : "s"}.`;
|
|
853
|
+
$("deleteForce").hidden = false;
|
|
854
|
+
$("deleteForce").disabled = false;
|
|
855
|
+
$("deleteConfirm").hidden = true;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function finishDelete(pd, msg) {
|
|
859
|
+
const wasManage = pd.fromManage;
|
|
860
|
+
$("deleteModal").hidden = true;
|
|
861
|
+
state.pendingDelete = null;
|
|
862
|
+
toast(msg);
|
|
863
|
+
await reloadAfterDelete();
|
|
864
|
+
if (wasManage && !$("manageModal").hidden) await renderManage();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function confirmDelete(force) {
|
|
868
|
+
const pd = state.pendingDelete;
|
|
869
|
+
if (!pd) return;
|
|
870
|
+
const bconfirm = $("deleteConfirm");
|
|
871
|
+
const bforce = $("deleteForce");
|
|
872
|
+
bconfirm.disabled = true;
|
|
873
|
+
bforce.disabled = true;
|
|
874
|
+
const f = force ? "true" : "";
|
|
875
|
+
try {
|
|
876
|
+
if (pd.type === "bulk") {
|
|
877
|
+
const r = await api(`/api/projects/${enc(pd.key)}/bulk-delete`, {
|
|
878
|
+
method: "POST",
|
|
879
|
+
headers: { "content-type": "application/json" },
|
|
880
|
+
body: JSON.stringify({ versions: pd.ns, force }),
|
|
881
|
+
});
|
|
882
|
+
const blocked = r?.blocked || [];
|
|
883
|
+
const deleted = r?.deleted || [];
|
|
884
|
+
if (blocked.length) {
|
|
885
|
+
// partial success: some deleted, some blocked by pending reviews
|
|
886
|
+
if (deleted.length) pd.didDelete = true; // reflect deletions even if user cancels
|
|
887
|
+
pd.ns = blocked.map((b) => b.n); // Force retry targets only the blocked ones
|
|
888
|
+
showBulkBlocked(blocked, deleted);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
await finishDelete(pd, `Deleted ${deleted.length || pd.ns.length} version${(deleted.length || pd.ns.length) === 1 ? "" : "s"}.`);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (pd.type === "version") {
|
|
896
|
+
await api(`/api/projects/${enc(pd.key)}/versions/${pd.n}?force=${f}`, { method: "DELETE" });
|
|
897
|
+
await finishDelete(pd, `Deleted v${pd.n}.`);
|
|
898
|
+
} else if (pd.type === "project") {
|
|
899
|
+
await api(`/api/projects/${enc(pd.key)}?force=${f}`, { method: "DELETE" });
|
|
900
|
+
await finishDelete(pd, "Project deleted.");
|
|
901
|
+
}
|
|
902
|
+
} catch (e) {
|
|
903
|
+
if (e.status === 409) showDeleteBlocked(e); // version / project block
|
|
904
|
+
else {
|
|
905
|
+
toast("Delete failed: " + e.message);
|
|
906
|
+
bconfirm.disabled = false;
|
|
907
|
+
bforce.disabled = false;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Delete flows must explicitly reload projects/versions — the pollLoop only
|
|
913
|
+
// refreshes review status.
|
|
914
|
+
async function reloadAfterDelete() {
|
|
915
|
+
await loadProjects();
|
|
916
|
+
if (!state.projects.length) {
|
|
917
|
+
showEmpty("No plans reviewed yet. Finish a plan in plan mode and it'll show up here.");
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const key = state.projects.some((p) => p.key === state.key) ? state.key : state.projects[0].key;
|
|
921
|
+
await selectProject(key);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ---------- manage modal ----------
|
|
925
|
+
async function openManageModal() {
|
|
926
|
+
$("manageModal").hidden = false;
|
|
927
|
+
await renderManage();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function renderManage() {
|
|
931
|
+
const list = $("manageList");
|
|
932
|
+
list.innerHTML = "<div class='empty'>Loading…</div>";
|
|
933
|
+
const projects = await api("/api/projects").catch(() => []);
|
|
934
|
+
const detailed = await Promise.all(
|
|
935
|
+
projects.map(async (p) => {
|
|
936
|
+
const r = await api(`/api/projects/${enc(p.key)}`).catch(() => ({ versions: [] }));
|
|
937
|
+
return { ...p, versions: r.versions || [] };
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
state.manageProjects = detailed;
|
|
941
|
+
list.innerHTML = detailed.length
|
|
942
|
+
? detailed.map(manageProjectHTML).join("")
|
|
943
|
+
: "<div class='empty'>No projects.</div>";
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function manageProjectHTML(p) {
|
|
947
|
+
const vers = (p.versions || [])
|
|
948
|
+
.map(
|
|
949
|
+
(v) => `<label class="mg-ver">
|
|
950
|
+
<input type="checkbox" data-key="${esc(p.key)}" data-n="${v.n}" />
|
|
951
|
+
<span class="mg-vn">v${v.n}</span>
|
|
952
|
+
<span class="mg-meta">${esc(new Date(v.createdAt).toLocaleString())}${v.docCount > 1 ? ` · ${v.docCount} docs` : ""}${v.kind === "tree" ? " · tree" : ""}</span>
|
|
953
|
+
</label>`,
|
|
954
|
+
)
|
|
955
|
+
.join("");
|
|
956
|
+
return `<div class="mg-project" data-key="${esc(p.key)}">
|
|
957
|
+
<div class="mg-phead">
|
|
958
|
+
<span class="mg-name">${esc(p.name)}${p.pending ? ` <span class="sb-dot" title="${p.pending} pending"></span>` : ""}</span>
|
|
959
|
+
<span class="mg-actions">
|
|
960
|
+
<button class="btn sm" data-mg="delsel" data-key="${esc(p.key)}">Delete selected</button>
|
|
961
|
+
<button class="btn reject sm" data-mg="delproj" data-key="${esc(p.key)}">Delete project</button>
|
|
962
|
+
</span>
|
|
963
|
+
</div>
|
|
964
|
+
<div class="mg-vers">${vers || "<span class='mg-meta'>no versions</span>"}</div>
|
|
965
|
+
</div>`;
|
|
966
|
+
}
|
|
967
|
+
|
|
486
968
|
// ---------- polling (reflect external resolution) ----------
|
|
487
969
|
function pollLoop() {
|
|
488
970
|
setInterval(async () => {
|
|
@@ -492,6 +974,7 @@ function pollLoop() {
|
|
|
492
974
|
if (r.status !== state.review.status) {
|
|
493
975
|
state.review = r;
|
|
494
976
|
refreshReview();
|
|
977
|
+
renderSidebar(); // pending dot depends on review status
|
|
495
978
|
}
|
|
496
979
|
} catch {}
|
|
497
980
|
}
|
|
@@ -520,11 +1003,9 @@ function wireUI() {
|
|
|
520
1003
|
};
|
|
521
1004
|
|
|
522
1005
|
$("projectSel").onchange = (e) => selectProject(e.target.value);
|
|
523
|
-
$("versionSel").onchange = (e) =>
|
|
524
|
-
state.reviewId && (state.reviewId = state.reviewId); // keep
|
|
525
|
-
loadVersion(Number(e.target.value));
|
|
526
|
-
};
|
|
1006
|
+
$("versionSel").onchange = (e) => loadVersion(Number(e.target.value));
|
|
527
1007
|
$("baseSel").onchange = () => loadDiff();
|
|
1008
|
+
$("diffDocSel").onchange = () => loadDiff();
|
|
528
1009
|
|
|
529
1010
|
document.querySelectorAll(".tab").forEach((t) => {
|
|
530
1011
|
t.onclick = () => {
|
|
@@ -533,6 +1014,7 @@ function wireUI() {
|
|
|
533
1014
|
t.classList.add("active");
|
|
534
1015
|
state.tab = t.dataset.tab;
|
|
535
1016
|
$("panel-" + state.tab).classList.add("active");
|
|
1017
|
+
hideSelPop();
|
|
536
1018
|
if (state.tab === "diff") loadDiff();
|
|
537
1019
|
};
|
|
538
1020
|
});
|
|
@@ -546,45 +1028,61 @@ function wireUI() {
|
|
|
546
1028
|
};
|
|
547
1029
|
});
|
|
548
1030
|
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1031
|
+
// view-as-tree (single-doc, display-only)
|
|
1032
|
+
$("treeToggle").onclick = () => {
|
|
1033
|
+
state.viewAsTree = !state.viewAsTree;
|
|
1034
|
+
state.treeSection = -1;
|
|
1035
|
+
updateTreeToggle();
|
|
1036
|
+
renderPreview();
|
|
1037
|
+
renderSidebar();
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// sidebar navigation — doc rows (tree) or outline sections (view-as-tree)
|
|
1041
|
+
$("docSidebar").addEventListener("click", (e) => {
|
|
1042
|
+
const row = e.target.closest("[data-doc]");
|
|
1043
|
+
if (row) {
|
|
1044
|
+
e.preventDefault();
|
|
1045
|
+
if (row.dataset.doc !== state.doc) loadDoc(row.dataset.doc);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
const sec = e.target.closest("[data-sec]");
|
|
1049
|
+
if (sec) {
|
|
1050
|
+
e.preventDefault();
|
|
1051
|
+
state.treeSection = Number(sec.dataset.sec);
|
|
1052
|
+
applyTreeView();
|
|
1053
|
+
renderSidebar();
|
|
563
1054
|
}
|
|
564
|
-
state.dragging = true;
|
|
565
|
-
source.classList.add("dragging");
|
|
566
|
-
document.querySelector(".composer.inline")?.remove();
|
|
567
|
-
paintSelection();
|
|
568
|
-
});
|
|
569
|
-
source.addEventListener("mousemove", (e) => {
|
|
570
|
-
if (!state.dragging) return;
|
|
571
|
-
const row = e.target.closest(".srow");
|
|
572
|
-
if (!row) return;
|
|
573
|
-
const n = Number(row.dataset.line);
|
|
574
|
-
state.sel.start = Math.min(state.sel.anchor, n);
|
|
575
|
-
state.sel.end = Math.max(state.sel.anchor, n);
|
|
576
|
-
paintSelection();
|
|
577
1055
|
});
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1056
|
+
|
|
1057
|
+
// comment on rendered text: after a selection in the preview, offer a "Comment" button
|
|
1058
|
+
document.addEventListener("mouseup", (e) => {
|
|
1059
|
+
if ($("selPop").contains(e.target)) return; // the button handles its own click
|
|
1060
|
+
const info = currentSelection();
|
|
1061
|
+
if (info) showSelPop(info);
|
|
1062
|
+
else hideSelPop();
|
|
583
1063
|
});
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1064
|
+
window.addEventListener("scroll", hideSelPop, true);
|
|
1065
|
+
$("selComment").onclick = () => {
|
|
1066
|
+
const info = state.pendingSel;
|
|
1067
|
+
hideSelPop();
|
|
1068
|
+
window.getSelection().removeAllRanges();
|
|
1069
|
+
if (info) openComposer(info);
|
|
587
1070
|
};
|
|
1071
|
+
|
|
1072
|
+
// preview clicks: cross-doc links navigate in-app; delete buttons remove comments
|
|
1073
|
+
$("preview").addEventListener("click", (e) => {
|
|
1074
|
+
const link = e.target.closest("a[data-doc]");
|
|
1075
|
+
if (link) {
|
|
1076
|
+
e.preventDefault();
|
|
1077
|
+
const slug = link.dataset.doc;
|
|
1078
|
+
if (state.docCache.has(slug) || (state.manifest && state.manifest.docs.some((d) => d.slug === slug))) {
|
|
1079
|
+
loadDoc(slug);
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const del = e.target.closest("[data-del]");
|
|
1084
|
+
if (del) delComment(del.dataset.del);
|
|
1085
|
+
});
|
|
588
1086
|
$("generalList").onclick = (e) => {
|
|
589
1087
|
const del = e.target.closest("[data-del]");
|
|
590
1088
|
if (del) delComment(del.dataset.del);
|
|
@@ -610,6 +1108,49 @@ function wireUI() {
|
|
|
610
1108
|
$("rejectModal").hidden = true;
|
|
611
1109
|
await resolve("reject");
|
|
612
1110
|
};
|
|
1111
|
+
|
|
1112
|
+
// deletion + manage
|
|
1113
|
+
$("deleteVerBtn").onclick = () => openDeleteVersion();
|
|
1114
|
+
$("deleteCancel").onclick = async () => {
|
|
1115
|
+
const pd = state.pendingDelete;
|
|
1116
|
+
$("deleteModal").hidden = true;
|
|
1117
|
+
state.pendingDelete = null;
|
|
1118
|
+
// a partial bulk delete already removed some versions — reflect that even on cancel
|
|
1119
|
+
if (pd && pd.didDelete) {
|
|
1120
|
+
await reloadAfterDelete();
|
|
1121
|
+
if (!$("manageModal").hidden) await renderManage();
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
$("deleteConfirm").onclick = () => confirmDelete(false);
|
|
1125
|
+
$("deleteForce").onclick = () => confirmDelete(true);
|
|
1126
|
+
|
|
1127
|
+
$("manageBtn").onclick = () => openManageModal();
|
|
1128
|
+
$("manageClose").onclick = () => ($("manageModal").hidden = true);
|
|
1129
|
+
$("manageList").addEventListener("click", (e) => {
|
|
1130
|
+
const btn = e.target.closest("[data-mg]");
|
|
1131
|
+
if (!btn) return;
|
|
1132
|
+
const key = btn.dataset.key;
|
|
1133
|
+
if (btn.dataset.mg === "delproj") {
|
|
1134
|
+
state.pendingDelete = { type: "project", key, fromManage: true };
|
|
1135
|
+
showDeleteModal(
|
|
1136
|
+
`Delete project “${projNameByKey(key)}”?`,
|
|
1137
|
+
`Permanently removes the project and all its versions, comments and reviews. This can't be undone.`,
|
|
1138
|
+
);
|
|
1139
|
+
} else if (btn.dataset.mg === "delsel") {
|
|
1140
|
+
const ns = [...$("manageList").querySelectorAll('input[type="checkbox"]:checked')]
|
|
1141
|
+
.filter((x) => x.dataset.key === key)
|
|
1142
|
+
.map((x) => Number(x.dataset.n));
|
|
1143
|
+
if (!ns.length) {
|
|
1144
|
+
toast("Select at least one version to delete.");
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
state.pendingDelete = { type: "bulk", key, ns, fromManage: true };
|
|
1148
|
+
showDeleteModal(
|
|
1149
|
+
`Delete ${ns.length} version${ns.length === 1 ? "" : "s"} of “${projNameByKey(key)}”?`,
|
|
1150
|
+
`Permanently removes v${ns.join(", v")} and their comments. This can't be undone.`,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
613
1154
|
}
|
|
614
1155
|
|
|
615
1156
|
init().catch((e) => {
|