claude-plan-review 0.3.2 → 0.5.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 +132 -4
- package/package.json +2 -1
- package/skill/SKILL.md +90 -0
- package/src/cli.js +279 -9
- package/src/hook.js +37 -9
- package/src/mcp.js +444 -0
- package/src/paths.js +17 -0
- package/src/plan-context.js +63 -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 +372 -34
- package/src/ui/app.js +639 -108
- package/src/ui/index.html +50 -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;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function hideSelPop() {
|
|
496
|
+
$("selPop").hidden = true;
|
|
497
|
+
state.pendingSel = null;
|
|
229
498
|
}
|
|
230
499
|
|
|
231
|
-
function openComposer(
|
|
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,166 @@ async function doSave() {
|
|
|
483
802
|
}
|
|
484
803
|
}
|
|
485
804
|
|
|
805
|
+
// ---------- deletion ----------
|
|
806
|
+
// Deletes happen IMMEDIATELY on click — no confirmation modal. The modal appears
|
|
807
|
+
// ONLY when a delete is blocked by a pending review (409 for single/project, or a
|
|
808
|
+
// non-empty `blocked` array for bulk): it shows the blocked info + a Force option.
|
|
809
|
+
function deleteTitleFor(pd) {
|
|
810
|
+
if (pd.type === "version") return `Couldn't delete v${pd.n}`;
|
|
811
|
+
if (pd.type === "project") return `Couldn't delete “${projNameByKey(pd.key)}”`;
|
|
812
|
+
return "Couldn't delete every selected version";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Open the blocked/Force modal (the only remaining modal interaction).
|
|
816
|
+
function openBlockedModal(pd) {
|
|
817
|
+
$("deleteTitle").textContent = deleteTitleFor(pd);
|
|
818
|
+
$("deleteDesc").textContent = "";
|
|
819
|
+
$("deleteForce").hidden = false;
|
|
820
|
+
$("deleteForce").disabled = false;
|
|
821
|
+
$("deleteModal").hidden = false;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function deleteVersionNow() {
|
|
825
|
+
if (!state.version) return;
|
|
826
|
+
state.pendingDelete = { type: "version", key: state.key, n: state.version };
|
|
827
|
+
performDelete(false);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function showDeleteBlocked(e) {
|
|
831
|
+
openBlockedModal(state.pendingDelete);
|
|
832
|
+
const revs = e.pendingReviews || [];
|
|
833
|
+
const info = $("deletePending");
|
|
834
|
+
info.hidden = false;
|
|
835
|
+
const many = revs.length !== 1;
|
|
836
|
+
info.textContent =
|
|
837
|
+
`Blocked: ${revs.length || "a"} pending review${many ? "s" : ""} still awaiting a decision. ` +
|
|
838
|
+
`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.`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// bulk-delete is always 200 with partial-success {deleted,blocked,meta}; render
|
|
842
|
+
// the blocked versions (+ their pending reviews) and offer a Force retry for
|
|
843
|
+
// just those. Single-version / project DELETE stay 409-on-block (showDeleteBlocked).
|
|
844
|
+
function showBulkBlocked(blocked, deleted) {
|
|
845
|
+
openBlockedModal(state.pendingDelete);
|
|
846
|
+
const info = $("deletePending");
|
|
847
|
+
info.hidden = false;
|
|
848
|
+
const ns = blocked.map((b) => b.n);
|
|
849
|
+
const totalPending = blocked.reduce((a, b) => a + (b.pendingReviews?.length || 0), 0);
|
|
850
|
+
const many = totalPending !== 1;
|
|
851
|
+
const delMsg = deleted.length ? `Deleted v${deleted.join(", v")}. ` : "";
|
|
852
|
+
info.textContent =
|
|
853
|
+
`${delMsg}Blocked: v${ns.join(", v")} — ${totalPending || "a"} pending review${many ? "s" : ""} ` +
|
|
854
|
+
`still awaiting a decision. Force will auto-reject ${many ? "them" : "it"} (Claude is told the plan ` +
|
|
855
|
+
`was deleted and to re-present or ask how to proceed) and delete the remaining ${ns.length} version${ns.length === 1 ? "" : "s"}.`;
|
|
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 performDelete(force) {
|
|
868
|
+
const pd = state.pendingDelete;
|
|
869
|
+
if (!pd) return;
|
|
870
|
+
const bforce = $("deleteForce");
|
|
871
|
+
bforce.disabled = true;
|
|
872
|
+
const f = force ? "true" : "";
|
|
873
|
+
try {
|
|
874
|
+
if (pd.type === "bulk") {
|
|
875
|
+
const r = await api(`/api/projects/${enc(pd.key)}/bulk-delete`, {
|
|
876
|
+
method: "POST",
|
|
877
|
+
headers: { "content-type": "application/json" },
|
|
878
|
+
body: JSON.stringify({ versions: pd.ns, force }),
|
|
879
|
+
});
|
|
880
|
+
const blocked = r?.blocked || [];
|
|
881
|
+
const deleted = r?.deleted || [];
|
|
882
|
+
if (blocked.length) {
|
|
883
|
+
// partial success: some deleted, some blocked by pending reviews
|
|
884
|
+
if (deleted.length) pd.didDelete = true; // reflect deletions even if user cancels
|
|
885
|
+
pd.ns = blocked.map((b) => b.n); // Force retry targets only the blocked ones
|
|
886
|
+
showBulkBlocked(blocked, deleted);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
await finishDelete(pd, `Deleted ${deleted.length || pd.ns.length} version${(deleted.length || pd.ns.length) === 1 ? "" : "s"}.`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (pd.type === "version") {
|
|
894
|
+
await api(`/api/projects/${enc(pd.key)}/versions/${pd.n}?force=${f}`, { method: "DELETE" });
|
|
895
|
+
await finishDelete(pd, `Deleted v${pd.n}.`);
|
|
896
|
+
} else if (pd.type === "project") {
|
|
897
|
+
await api(`/api/projects/${enc(pd.key)}?force=${f}`, { method: "DELETE" });
|
|
898
|
+
await finishDelete(pd, "Project deleted.");
|
|
899
|
+
}
|
|
900
|
+
} catch (e) {
|
|
901
|
+
if (e.status === 409) showDeleteBlocked(e); // version / project block
|
|
902
|
+
else {
|
|
903
|
+
toast("Delete failed: " + e.message);
|
|
904
|
+
bforce.disabled = false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Delete flows must explicitly reload projects/versions — the pollLoop only
|
|
910
|
+
// refreshes review status.
|
|
911
|
+
async function reloadAfterDelete() {
|
|
912
|
+
await loadProjects();
|
|
913
|
+
if (!state.projects.length) {
|
|
914
|
+
showEmpty("No plans reviewed yet. Finish a plan in plan mode and it'll show up here.");
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const key = state.projects.some((p) => p.key === state.key) ? state.key : state.projects[0].key;
|
|
918
|
+
await selectProject(key);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ---------- manage modal ----------
|
|
922
|
+
async function openManageModal() {
|
|
923
|
+
$("manageModal").hidden = false;
|
|
924
|
+
await renderManage();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function renderManage() {
|
|
928
|
+
const list = $("manageList");
|
|
929
|
+
list.innerHTML = "<div class='empty'>Loading…</div>";
|
|
930
|
+
const projects = await api("/api/projects").catch(() => []);
|
|
931
|
+
const detailed = await Promise.all(
|
|
932
|
+
projects.map(async (p) => {
|
|
933
|
+
const r = await api(`/api/projects/${enc(p.key)}`).catch(() => ({ versions: [] }));
|
|
934
|
+
return { ...p, versions: r.versions || [] };
|
|
935
|
+
}),
|
|
936
|
+
);
|
|
937
|
+
state.manageProjects = detailed;
|
|
938
|
+
list.innerHTML = detailed.length
|
|
939
|
+
? detailed.map(manageProjectHTML).join("")
|
|
940
|
+
: "<div class='empty'>No projects.</div>";
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function manageProjectHTML(p) {
|
|
944
|
+
const vers = (p.versions || [])
|
|
945
|
+
.map(
|
|
946
|
+
(v) => `<label class="mg-ver">
|
|
947
|
+
<input type="checkbox" data-key="${esc(p.key)}" data-n="${v.n}" />
|
|
948
|
+
<span class="mg-vn">v${v.n}</span>
|
|
949
|
+
<span class="mg-meta">${esc(new Date(v.createdAt).toLocaleString())}${v.docCount > 1 ? ` · ${v.docCount} docs` : ""}${v.kind === "tree" ? " · tree" : ""}</span>
|
|
950
|
+
</label>`,
|
|
951
|
+
)
|
|
952
|
+
.join("");
|
|
953
|
+
return `<div class="mg-project" data-key="${esc(p.key)}">
|
|
954
|
+
<div class="mg-phead">
|
|
955
|
+
<span class="mg-name">${esc(p.name)}${p.pending ? ` <span class="sb-dot" title="${p.pending} pending"></span>` : ""}</span>
|
|
956
|
+
<span class="mg-actions">
|
|
957
|
+
<button class="btn sm" data-mg="delsel" data-key="${esc(p.key)}">Delete selected</button>
|
|
958
|
+
<button class="btn reject sm" data-mg="delproj" data-key="${esc(p.key)}">Delete project</button>
|
|
959
|
+
</span>
|
|
960
|
+
</div>
|
|
961
|
+
<div class="mg-vers">${vers || "<span class='mg-meta'>no versions</span>"}</div>
|
|
962
|
+
</div>`;
|
|
963
|
+
}
|
|
964
|
+
|
|
486
965
|
// ---------- polling (reflect external resolution) ----------
|
|
487
966
|
function pollLoop() {
|
|
488
967
|
setInterval(async () => {
|
|
@@ -492,6 +971,7 @@ function pollLoop() {
|
|
|
492
971
|
if (r.status !== state.review.status) {
|
|
493
972
|
state.review = r;
|
|
494
973
|
refreshReview();
|
|
974
|
+
renderSidebar(); // pending dot depends on review status
|
|
495
975
|
}
|
|
496
976
|
} catch {}
|
|
497
977
|
}
|
|
@@ -520,11 +1000,9 @@ function wireUI() {
|
|
|
520
1000
|
};
|
|
521
1001
|
|
|
522
1002
|
$("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
|
-
};
|
|
1003
|
+
$("versionSel").onchange = (e) => loadVersion(Number(e.target.value));
|
|
527
1004
|
$("baseSel").onchange = () => loadDiff();
|
|
1005
|
+
$("diffDocSel").onchange = () => loadDiff();
|
|
528
1006
|
|
|
529
1007
|
document.querySelectorAll(".tab").forEach((t) => {
|
|
530
1008
|
t.onclick = () => {
|
|
@@ -533,6 +1011,7 @@ function wireUI() {
|
|
|
533
1011
|
t.classList.add("active");
|
|
534
1012
|
state.tab = t.dataset.tab;
|
|
535
1013
|
$("panel-" + state.tab).classList.add("active");
|
|
1014
|
+
hideSelPop();
|
|
536
1015
|
if (state.tab === "diff") loadDiff();
|
|
537
1016
|
};
|
|
538
1017
|
});
|
|
@@ -546,45 +1025,61 @@ function wireUI() {
|
|
|
546
1025
|
};
|
|
547
1026
|
});
|
|
548
1027
|
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1028
|
+
// view-as-tree (single-doc, display-only)
|
|
1029
|
+
$("treeToggle").onclick = () => {
|
|
1030
|
+
state.viewAsTree = !state.viewAsTree;
|
|
1031
|
+
state.treeSection = -1;
|
|
1032
|
+
updateTreeToggle();
|
|
1033
|
+
renderPreview();
|
|
1034
|
+
renderSidebar();
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// sidebar navigation — doc rows (tree) or outline sections (view-as-tree)
|
|
1038
|
+
$("docSidebar").addEventListener("click", (e) => {
|
|
1039
|
+
const row = e.target.closest("[data-doc]");
|
|
1040
|
+
if (row) {
|
|
1041
|
+
e.preventDefault();
|
|
1042
|
+
if (row.dataset.doc !== state.doc) loadDoc(row.dataset.doc);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const sec = e.target.closest("[data-sec]");
|
|
1046
|
+
if (sec) {
|
|
1047
|
+
e.preventDefault();
|
|
1048
|
+
state.treeSection = Number(sec.dataset.sec);
|
|
1049
|
+
applyTreeView();
|
|
1050
|
+
renderSidebar();
|
|
563
1051
|
}
|
|
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
1052
|
});
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1053
|
+
|
|
1054
|
+
// comment on rendered text: after a selection in the preview, offer a "Comment" button
|
|
1055
|
+
document.addEventListener("mouseup", (e) => {
|
|
1056
|
+
if ($("selPop").contains(e.target)) return; // the button handles its own click
|
|
1057
|
+
const info = currentSelection();
|
|
1058
|
+
if (info) showSelPop(info);
|
|
1059
|
+
else hideSelPop();
|
|
583
1060
|
});
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1061
|
+
window.addEventListener("scroll", hideSelPop, true);
|
|
1062
|
+
$("selComment").onclick = () => {
|
|
1063
|
+
const info = state.pendingSel;
|
|
1064
|
+
hideSelPop();
|
|
1065
|
+
window.getSelection().removeAllRanges();
|
|
1066
|
+
if (info) openComposer(info);
|
|
587
1067
|
};
|
|
1068
|
+
|
|
1069
|
+
// preview clicks: cross-doc links navigate in-app; delete buttons remove comments
|
|
1070
|
+
$("preview").addEventListener("click", (e) => {
|
|
1071
|
+
const link = e.target.closest("a[data-doc]");
|
|
1072
|
+
if (link) {
|
|
1073
|
+
e.preventDefault();
|
|
1074
|
+
const slug = link.dataset.doc;
|
|
1075
|
+
if (state.docCache.has(slug) || (state.manifest && state.manifest.docs.some((d) => d.slug === slug))) {
|
|
1076
|
+
loadDoc(slug);
|
|
1077
|
+
}
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const del = e.target.closest("[data-del]");
|
|
1081
|
+
if (del) delComment(del.dataset.del);
|
|
1082
|
+
});
|
|
588
1083
|
$("generalList").onclick = (e) => {
|
|
589
1084
|
const del = e.target.closest("[data-del]");
|
|
590
1085
|
if (del) delComment(del.dataset.del);
|
|
@@ -610,6 +1105,42 @@ function wireUI() {
|
|
|
610
1105
|
$("rejectModal").hidden = true;
|
|
611
1106
|
await resolve("reject");
|
|
612
1107
|
};
|
|
1108
|
+
|
|
1109
|
+
// deletion + manage — delete fires immediately; the modal is blocked-only
|
|
1110
|
+
$("deleteVerBtn").onclick = () => deleteVersionNow();
|
|
1111
|
+
$("deleteCancel").onclick = async () => {
|
|
1112
|
+
const pd = state.pendingDelete;
|
|
1113
|
+
$("deleteModal").hidden = true;
|
|
1114
|
+
state.pendingDelete = null;
|
|
1115
|
+
// a partial bulk delete already removed some versions — reflect that even on cancel
|
|
1116
|
+
if (pd && pd.didDelete) {
|
|
1117
|
+
await reloadAfterDelete();
|
|
1118
|
+
if (!$("manageModal").hidden) await renderManage();
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
$("deleteForce").onclick = () => performDelete(true);
|
|
1122
|
+
|
|
1123
|
+
$("manageBtn").onclick = () => openManageModal();
|
|
1124
|
+
$("manageClose").onclick = () => ($("manageModal").hidden = true);
|
|
1125
|
+
$("manageList").addEventListener("click", (e) => {
|
|
1126
|
+
const btn = e.target.closest("[data-mg]");
|
|
1127
|
+
if (!btn) return;
|
|
1128
|
+
const key = btn.dataset.key;
|
|
1129
|
+
if (btn.dataset.mg === "delproj") {
|
|
1130
|
+
state.pendingDelete = { type: "project", key, fromManage: true };
|
|
1131
|
+
performDelete(false);
|
|
1132
|
+
} else if (btn.dataset.mg === "delsel") {
|
|
1133
|
+
const ns = [...$("manageList").querySelectorAll('input[type="checkbox"]:checked')]
|
|
1134
|
+
.filter((x) => x.dataset.key === key)
|
|
1135
|
+
.map((x) => Number(x.dataset.n));
|
|
1136
|
+
if (!ns.length) {
|
|
1137
|
+
toast("Select at least one version to delete.");
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
state.pendingDelete = { type: "bulk", key, ns, fromManage: true };
|
|
1141
|
+
performDelete(false);
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
613
1144
|
}
|
|
614
1145
|
|
|
615
1146
|
init().catch((e) => {
|