@shiyuphan/follow-me 0.1.1 → 0.3.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 +119 -69
- package/dist/src/cli.js +1069 -140
- package/dist/src/comments.js +361 -0
- package/dist/src/git.js +32 -3
- package/dist/src/host.js +106 -9
- package/dist/src/install.js +22 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +282 -26
- package/dist/src/prompt.js +711 -98
- package/dist/src/server.js +573 -140
- package/dist/src/session.js +106 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
- package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
- package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
- package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
- package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
- package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
- package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
- package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
- package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
- package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
- package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
- package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
- package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
- package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
- package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
- package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
- package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
- package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
- package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
- package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
- package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
- package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
- package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
- package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
- package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
- package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
- package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
- package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
- package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
- package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
- package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
- package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
- package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
- package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
- package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
- package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
- package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
- package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
- package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
- package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
- package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
- package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
- package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
- package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
- package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
- package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
- package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
- package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
- package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
- package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
- package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
- package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
- package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
- package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
- package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
- package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
- package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
- package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
- package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
- package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
- package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
- package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
- package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
- package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
- package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
- package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
- package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
- package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
- package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
- package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
- package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
- package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
- package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
- package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
- package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
- package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
- package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
- package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
- package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
- package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
- package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
- package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
- package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
- package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
- package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
- package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
- package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
- package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
- package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
- package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
- package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
- package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
- package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
- package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
- package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
- package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
- package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
- package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
- package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
- package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
- package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
- package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
- package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
- package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
- package/dist/ui/dist/index.html +15 -0
- package/dist/ui/dist/style.css +121 -0
- package/package.json +31 -8
- package/skills/follow-me/SKILL.md +15 -61
- package/dist/src/anthropic.js +0 -101
- package/dist/ui/app.js +0 -797
- package/dist/ui/index.html +0 -40
- package/dist/ui/playback.js +0 -175
- package/dist/ui/style.css +0 -466
package/dist/ui/app.js
DELETED
|
@@ -1,797 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildScript,
|
|
3
|
-
commitAt,
|
|
4
|
-
commitStart,
|
|
5
|
-
lineKey,
|
|
6
|
-
lineStates,
|
|
7
|
-
parseSpeed,
|
|
8
|
-
pickVoice,
|
|
9
|
-
SPEEDS,
|
|
10
|
-
speechTimeout,
|
|
11
|
-
stepDelay,
|
|
12
|
-
} from "/playback.js";
|
|
13
|
-
|
|
14
|
-
const $ = (id) => document.getElementById(id);
|
|
15
|
-
|
|
16
|
-
/** "/s/<id>": this page's session on the shared server. Every session API sits under it. */
|
|
17
|
-
const sessionPrefix = /^\/s\/[^/]+/.exec(location.pathname)?.[0] ?? "";
|
|
18
|
-
|
|
19
|
-
/** Builds an element. Text always goes through textContent, never innerHTML. */
|
|
20
|
-
function el(tag, props = {}, ...children) {
|
|
21
|
-
const node = document.createElement(tag);
|
|
22
|
-
for (const [key, value] of Object.entries(props)) {
|
|
23
|
-
if (value === undefined || value === false) continue;
|
|
24
|
-
if (key === "class") node.className = value;
|
|
25
|
-
else if (key === "text") node.textContent = value;
|
|
26
|
-
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
|
|
27
|
-
else node.setAttribute(key, value === true ? "" : value);
|
|
28
|
-
}
|
|
29
|
-
for (const child of children) if (child) node.append(child);
|
|
30
|
-
return node;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let state = null;
|
|
34
|
-
let selected = 0;
|
|
35
|
-
let unitsById = new Map();
|
|
36
|
-
/** unit id -> (change index -> index of the commit that owns the line) */
|
|
37
|
-
let owners = new Map();
|
|
38
|
-
/** "commit:unit" -> change indices the reader clicked, to anchor a comment to them */
|
|
39
|
-
const picks = new Map();
|
|
40
|
-
/** Unsent textarea contents survive re-renders, keyed like the boxes. */
|
|
41
|
-
const drafts = new Map();
|
|
42
|
-
const openBoxes = new Set();
|
|
43
|
-
const editing = new Set();
|
|
44
|
-
|
|
45
|
-
async function api(method, path, body) {
|
|
46
|
-
const res = await fetch(`${sessionPrefix}${path}`, {
|
|
47
|
-
method,
|
|
48
|
-
headers: { "content-type": "application/json" },
|
|
49
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
50
|
-
});
|
|
51
|
-
const payload = await res.json().catch(() => ({}));
|
|
52
|
-
if (!res.ok) throw new Error(payload.error ?? `${method} ${path} failed with ${res.status}`);
|
|
53
|
-
return payload;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Links to every session the server hosts, so one tab can move between workstreams. */
|
|
57
|
-
async function renderSwitcher() {
|
|
58
|
-
const res = await fetch("/api/sessions").catch(() => null);
|
|
59
|
-
if (!res?.ok) return;
|
|
60
|
-
const sessions = await res.json();
|
|
61
|
-
const here = `${sessionPrefix}/`;
|
|
62
|
-
const nav = $("sessions");
|
|
63
|
-
nav.hidden = sessions.length < 2;
|
|
64
|
-
nav.replaceChildren(
|
|
65
|
-
...sessions.map((s) =>
|
|
66
|
-
el("a", {
|
|
67
|
-
href: s.url,
|
|
68
|
-
title: s.repo,
|
|
69
|
-
"aria-current": s.url === here ? "page" : undefined,
|
|
70
|
-
text: s.mode === "walk" ? `${s.branch} (walk)` : `${s.branch} v${s.version}`,
|
|
71
|
-
}),
|
|
72
|
-
),
|
|
73
|
-
el("a", { href: "/", text: "All sessions" }),
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function report(error) {
|
|
78
|
-
$("status").textContent = error ? error.message : "";
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function guarded(action) {
|
|
82
|
-
try {
|
|
83
|
-
report(null);
|
|
84
|
-
await action();
|
|
85
|
-
} catch (error) {
|
|
86
|
-
report(error);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function indexOwnership() {
|
|
91
|
-
unitsById = new Map(state.units.map((unit) => [unit.id, unit]));
|
|
92
|
-
owners = new Map();
|
|
93
|
-
for (const commit of state.commits) {
|
|
94
|
-
for (const ref of commit.refs) {
|
|
95
|
-
if (ref.select.kind !== "lines") continue;
|
|
96
|
-
const byChange = owners.get(ref.unit) ?? new Map();
|
|
97
|
-
for (const change of ref.select.changes) byChange.set(change, commit.index);
|
|
98
|
-
owners.set(ref.unit, byChange);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function reloadComments() {
|
|
104
|
-
const fresh = await api("GET", "/api/state");
|
|
105
|
-
state.comments = fresh.comments;
|
|
106
|
-
render();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function sameAnchor(a, b) {
|
|
110
|
-
if (a.kind !== b.kind) return false;
|
|
111
|
-
if (a.kind === "plan") return true;
|
|
112
|
-
if (a.commit !== b.commit) return false;
|
|
113
|
-
return a.kind !== "hunk" || a.unit === b.unit;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function commentsAt(anchor) {
|
|
117
|
-
return state.comments.filter((comment) => sameAnchor(comment.anchor, anchor));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function firstLine(message) {
|
|
121
|
-
return message.split("\n")[0] ?? "";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function describeChanges(changes) {
|
|
125
|
-
return changes === null ? "whole hunk" : `lines ${changes.join(", ")}`;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function commentView(comment) {
|
|
129
|
-
const key = `edit:${comment.id}`;
|
|
130
|
-
if (editing.has(comment.id)) {
|
|
131
|
-
const area = el("textarea", {
|
|
132
|
-
oninput: () => drafts.set(key, area.value),
|
|
133
|
-
});
|
|
134
|
-
area.value = drafts.get(key) ?? comment.body;
|
|
135
|
-
return el(
|
|
136
|
-
"div",
|
|
137
|
-
{ class: "comment" },
|
|
138
|
-
area,
|
|
139
|
-
el(
|
|
140
|
-
"div",
|
|
141
|
-
{ class: "row box" },
|
|
142
|
-
el("button", {
|
|
143
|
-
class: "primary",
|
|
144
|
-
text: "Save",
|
|
145
|
-
onclick: () =>
|
|
146
|
-
guarded(async () => {
|
|
147
|
-
await api("PUT", `/api/comments/${comment.id}`, { ...comment, body: area.value });
|
|
148
|
-
editing.delete(comment.id);
|
|
149
|
-
drafts.delete(key);
|
|
150
|
-
await reloadComments();
|
|
151
|
-
}),
|
|
152
|
-
}),
|
|
153
|
-
el("button", {
|
|
154
|
-
text: "Cancel",
|
|
155
|
-
onclick: () => {
|
|
156
|
-
editing.delete(comment.id);
|
|
157
|
-
drafts.delete(key);
|
|
158
|
-
render();
|
|
159
|
-
},
|
|
160
|
-
}),
|
|
161
|
-
),
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
const where =
|
|
165
|
-
comment.anchor.kind === "hunk" ? ` · ${describeChanges(comment.anchor.changes)}` : "";
|
|
166
|
-
return el(
|
|
167
|
-
"div",
|
|
168
|
-
{ class: "comment" },
|
|
169
|
-
el("div", { class: "meta" }, `${comment.id}${where}`),
|
|
170
|
-
el("div", { class: "text", text: comment.body }),
|
|
171
|
-
el(
|
|
172
|
-
"div",
|
|
173
|
-
{ class: "meta" },
|
|
174
|
-
el("button", {
|
|
175
|
-
class: "link",
|
|
176
|
-
text: "edit",
|
|
177
|
-
onclick: () => {
|
|
178
|
-
editing.add(comment.id);
|
|
179
|
-
render();
|
|
180
|
-
},
|
|
181
|
-
}),
|
|
182
|
-
el("button", {
|
|
183
|
-
class: "link",
|
|
184
|
-
text: "delete",
|
|
185
|
-
onclick: () =>
|
|
186
|
-
guarded(async () => {
|
|
187
|
-
await api("DELETE", `/api/comments/${comment.id}`);
|
|
188
|
-
await reloadComments();
|
|
189
|
-
}),
|
|
190
|
-
}),
|
|
191
|
-
),
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* A collapsed button that opens into a textarea. `makeAnchor` runs at save time so a
|
|
197
|
-
* hunk comment picks up whichever lines are selected then.
|
|
198
|
-
*/
|
|
199
|
-
function commentBox({ key, label, placeholder, makeAnchor, context, onSaved }) {
|
|
200
|
-
if (!openBoxes.has(key)) {
|
|
201
|
-
return el("button", {
|
|
202
|
-
text: typeof label === "function" ? label() : label,
|
|
203
|
-
onclick: () => {
|
|
204
|
-
openBoxes.add(key);
|
|
205
|
-
render();
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
const area = el("textarea", { placeholder, oninput: () => drafts.set(key, area.value) });
|
|
210
|
-
area.value = drafts.get(key) ?? "";
|
|
211
|
-
const close = () => {
|
|
212
|
-
openBoxes.delete(key);
|
|
213
|
-
drafts.delete(key);
|
|
214
|
-
};
|
|
215
|
-
return el(
|
|
216
|
-
"div",
|
|
217
|
-
{ class: "box" },
|
|
218
|
-
area,
|
|
219
|
-
el(
|
|
220
|
-
"div",
|
|
221
|
-
{ class: "row" },
|
|
222
|
-
el("button", {
|
|
223
|
-
class: "primary",
|
|
224
|
-
text: "Comment",
|
|
225
|
-
onclick: () =>
|
|
226
|
-
guarded(async () => {
|
|
227
|
-
const body = area.value.trim();
|
|
228
|
-
if (!body) return;
|
|
229
|
-
await api("POST", "/api/comments", { anchor: makeAnchor(), context, body });
|
|
230
|
-
close();
|
|
231
|
-
onSaved?.();
|
|
232
|
-
await reloadComments();
|
|
233
|
-
}),
|
|
234
|
-
}),
|
|
235
|
-
el("button", {
|
|
236
|
-
text: "Cancel",
|
|
237
|
-
onclick: () => {
|
|
238
|
-
close();
|
|
239
|
-
render();
|
|
240
|
-
},
|
|
241
|
-
}),
|
|
242
|
-
),
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function describeFileUnit(unit) {
|
|
247
|
-
const change = unit.before === null ? "added" : unit.after === null ? "deleted" : "changed";
|
|
248
|
-
return `${unit.reason} file ${change}`;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function diffLine(unit, commit, line, index, pickKey) {
|
|
252
|
-
const base = el("span", { class: "op", text: line.op });
|
|
253
|
-
const text = el("span", { text: line.text.replace(/\n$/, "") });
|
|
254
|
-
const key = lineKey(unit.id, index);
|
|
255
|
-
if (line.op === " ")
|
|
256
|
-
return el(
|
|
257
|
-
"div",
|
|
258
|
-
{ class: "dl", "data-line": key },
|
|
259
|
-
el("span", { class: "gutter" }),
|
|
260
|
-
base,
|
|
261
|
-
text,
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
const owner = owners.get(unit.id)?.get(line.change);
|
|
265
|
-
const mine = owner === commit.index;
|
|
266
|
-
const picked = picks.get(pickKey)?.has(line.change) ?? false;
|
|
267
|
-
const classes = ["dl", line.op === "+" ? "add" : "del", mine ? "mine pickable" : "other"];
|
|
268
|
-
if (picked) classes.push("picked");
|
|
269
|
-
const node = el(
|
|
270
|
-
"div",
|
|
271
|
-
{
|
|
272
|
-
class: classes.join(" "),
|
|
273
|
-
"data-line": key,
|
|
274
|
-
title: mine ? "Click to select this line for a comment" : undefined,
|
|
275
|
-
onclick: mine
|
|
276
|
-
? () => {
|
|
277
|
-
const set = picks.get(pickKey) ?? new Set();
|
|
278
|
-
if (!set.delete(line.change)) set.add(line.change);
|
|
279
|
-
picks.set(pickKey, set);
|
|
280
|
-
render();
|
|
281
|
-
}
|
|
282
|
-
: undefined,
|
|
283
|
-
},
|
|
284
|
-
el("span", { class: "gutter", text: String(line.change) }),
|
|
285
|
-
base,
|
|
286
|
-
text,
|
|
287
|
-
);
|
|
288
|
-
if (!mine && owner !== undefined)
|
|
289
|
-
node.append(el("span", { class: "label", text: `→ commit ${owner + 1}` }));
|
|
290
|
-
return node;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function unitComments(unit, commit) {
|
|
294
|
-
const anchor = { kind: "hunk", unit: unit.id, commit: commit.index, changes: null };
|
|
295
|
-
const pickKey = `${commit.index}:${unit.id}`;
|
|
296
|
-
const existing = commentsAt(anchor);
|
|
297
|
-
const picked = () => [...(picks.get(pickKey) ?? [])].sort((a, b) => a - b);
|
|
298
|
-
return el(
|
|
299
|
-
"div",
|
|
300
|
-
{ class: "comments" },
|
|
301
|
-
...existing.map(commentView),
|
|
302
|
-
el(
|
|
303
|
-
"div",
|
|
304
|
-
{ class: "controls" },
|
|
305
|
-
commentBox({
|
|
306
|
-
key: `box:${pickKey}`,
|
|
307
|
-
label: () => {
|
|
308
|
-
const n = picked().length;
|
|
309
|
-
return n > 0 ? `Comment on ${n} selected line${n === 1 ? "" : "s"}` : "Comment";
|
|
310
|
-
},
|
|
311
|
-
placeholder: "What about this change?",
|
|
312
|
-
makeAnchor: () => ({ ...anchor, changes: picked().length > 0 ? picked() : null }),
|
|
313
|
-
context: { commitMessage: commit.message, path: unit.path },
|
|
314
|
-
onSaved: () => picks.delete(pickKey),
|
|
315
|
-
}),
|
|
316
|
-
),
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function unitView(unit, commit) {
|
|
321
|
-
const pickKey = `${commit.index}:${unit.id}`;
|
|
322
|
-
const head = el("div", { class: "unit-head" }, el("span", { text: unit.id }));
|
|
323
|
-
let content;
|
|
324
|
-
if (unit.kind === "hunk") {
|
|
325
|
-
const mineCount = [...(owners.get(unit.id) ?? [])].filter(([, o]) => o === commit.index).length;
|
|
326
|
-
head.append(
|
|
327
|
-
el("span", { class: "spacer" }),
|
|
328
|
-
el("span", { text: `takes ${mineCount} of ${unit.changeCount} changes` }),
|
|
329
|
-
);
|
|
330
|
-
content = el(
|
|
331
|
-
"div",
|
|
332
|
-
{ class: "diff" },
|
|
333
|
-
...unit.body.map((line, index) => diffLine(unit, commit, line, index, pickKey)),
|
|
334
|
-
);
|
|
335
|
-
} else {
|
|
336
|
-
const summary =
|
|
337
|
-
unit.kind === "file" ? describeFileUnit(unit) : `mode change ${unit.from} → ${unit.to}`;
|
|
338
|
-
content = el("div", { class: "card", text: `${summary}: ${unit.path}` });
|
|
339
|
-
}
|
|
340
|
-
return el(
|
|
341
|
-
"div",
|
|
342
|
-
{ class: "unit", "data-unit": unit.id },
|
|
343
|
-
head,
|
|
344
|
-
content,
|
|
345
|
-
unitComments(unit, commit),
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function renderList() {
|
|
350
|
-
const list = $("commits");
|
|
351
|
-
list.replaceChildren(
|
|
352
|
-
...state.commits.map((commit, i) => {
|
|
353
|
-
const count = state.comments.filter(
|
|
354
|
-
(c) => c.anchor.kind !== "plan" && c.anchor.commit === commit.index,
|
|
355
|
-
).length;
|
|
356
|
-
return el(
|
|
357
|
-
"li",
|
|
358
|
-
{
|
|
359
|
-
class: i === selected ? "selected" : undefined,
|
|
360
|
-
onclick: () => select(i),
|
|
361
|
-
},
|
|
362
|
-
el("span", { class: "index", text: String(i + 1) }),
|
|
363
|
-
el("span", { class: "title", text: firstLine(commit.message) }),
|
|
364
|
-
count > 0 ? el("span", { class: "badge", text: String(count) }) : null,
|
|
365
|
-
);
|
|
366
|
-
}),
|
|
367
|
-
);
|
|
368
|
-
list.children[selected]?.scrollIntoView({ block: "nearest" });
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function renderPlanNote() {
|
|
372
|
-
const anchor = { kind: "plan" };
|
|
373
|
-
const walk = isWalk();
|
|
374
|
-
$("plan-note").replaceChildren(
|
|
375
|
-
el("strong", { text: walk ? "General comment" : "Plan note" }),
|
|
376
|
-
...commentsAt(anchor).map(commentView),
|
|
377
|
-
commentBox({
|
|
378
|
-
key: "box:plan",
|
|
379
|
-
label: walk ? "Add a general comment" : "Add a note on the whole plan",
|
|
380
|
-
placeholder: walk
|
|
381
|
-
? "Anything about the change as a whole"
|
|
382
|
-
: "Anything about the plan as a whole",
|
|
383
|
-
makeAnchor: () => anchor,
|
|
384
|
-
context: { commitMessage: "", path: null },
|
|
385
|
-
}),
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function renderMain() {
|
|
390
|
-
const main = $("main");
|
|
391
|
-
const scroll = main.scrollTop;
|
|
392
|
-
const commit = state.commits[selected];
|
|
393
|
-
if (!commit) {
|
|
394
|
-
main.replaceChildren(el("p", { class: "muted", text: "This plan has no commits." }));
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
const messageAnchor = { kind: "message", commit: commit.index };
|
|
398
|
-
const positionAnchor = { kind: "position", commit: commit.index };
|
|
399
|
-
const context = { commitMessage: commit.message, path: null };
|
|
400
|
-
|
|
401
|
-
const groups = new Map();
|
|
402
|
-
for (const ref of commit.refs) {
|
|
403
|
-
const unit = unitsById.get(ref.unit);
|
|
404
|
-
if (!unit) continue;
|
|
405
|
-
groups.set(unit.path, [...(groups.get(unit.path) ?? []), unit]);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
main.replaceChildren(
|
|
409
|
-
el(
|
|
410
|
-
"div",
|
|
411
|
-
{ class: "commit-head" },
|
|
412
|
-
el("h2", { text: `Commit ${selected + 1} of ${state.commits.length}` }),
|
|
413
|
-
el("div", { class: "message", text: commit.message }),
|
|
414
|
-
...commentsAt(messageAnchor).map(commentView),
|
|
415
|
-
// A walk plan's messages and order are the planner's, not the author's, so only code is
|
|
416
|
-
// up for comment there.
|
|
417
|
-
isWalk()
|
|
418
|
-
? null
|
|
419
|
-
: el(
|
|
420
|
-
"div",
|
|
421
|
-
{ class: "controls" },
|
|
422
|
-
commentBox({
|
|
423
|
-
key: `box:message:${commit.index}`,
|
|
424
|
-
label: "Comment on the message",
|
|
425
|
-
placeholder: "What should this message say?",
|
|
426
|
-
makeAnchor: () => messageAnchor,
|
|
427
|
-
context,
|
|
428
|
-
}),
|
|
429
|
-
commentBox({
|
|
430
|
-
key: `box:position:${commit.index}`,
|
|
431
|
-
label: "This belongs elsewhere",
|
|
432
|
-
placeholder: "Where should this go, and why?",
|
|
433
|
-
makeAnchor: () => positionAnchor,
|
|
434
|
-
context,
|
|
435
|
-
}),
|
|
436
|
-
),
|
|
437
|
-
...commentsAt(positionAnchor).map(commentView),
|
|
438
|
-
),
|
|
439
|
-
...[...groups].map(([path, units]) =>
|
|
440
|
-
el(
|
|
441
|
-
"section",
|
|
442
|
-
{ class: "file-group" },
|
|
443
|
-
el("h3", { text: path }),
|
|
444
|
-
...units.map((unit) => unitView(unit, commit)),
|
|
445
|
-
),
|
|
446
|
-
),
|
|
447
|
-
);
|
|
448
|
-
main.scrollTop = scroll;
|
|
449
|
-
applyPlayback();
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function render() {
|
|
453
|
-
renderList();
|
|
454
|
-
renderPlanNote();
|
|
455
|
-
renderMain();
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function select(index) {
|
|
459
|
-
const next = Math.max(0, Math.min(state.commits.length - 1, index));
|
|
460
|
-
if (player.view === "play") {
|
|
461
|
-
seek(commitStart(script, next));
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
selected = next;
|
|
465
|
-
render();
|
|
466
|
-
$("main").scrollTop = 0;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function isWalk() {
|
|
470
|
-
return state?.mode === "walk";
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const synth = "speechSynthesis" in window ? window.speechSynthesis : null;
|
|
474
|
-
/**
|
|
475
|
-
* view "play" hides lines the playback has not written yet; "browse" shows every line.
|
|
476
|
-
* position counts performed steps of the script. generation retires a loop on every seek.
|
|
477
|
-
*/
|
|
478
|
-
const player = {
|
|
479
|
-
view: "browse",
|
|
480
|
-
playing: false,
|
|
481
|
-
position: 0,
|
|
482
|
-
generation: 0,
|
|
483
|
-
speed: 1,
|
|
484
|
-
muted: false,
|
|
485
|
-
voice: null,
|
|
486
|
-
};
|
|
487
|
-
let script = [];
|
|
488
|
-
let voices = [];
|
|
489
|
-
|
|
490
|
-
function stored(key) {
|
|
491
|
-
try {
|
|
492
|
-
return localStorage.getItem(`follow-me.${key}`);
|
|
493
|
-
} catch {
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function store(key, value) {
|
|
499
|
-
try {
|
|
500
|
-
localStorage.setItem(`follow-me.${key}`, value);
|
|
501
|
-
} catch {
|
|
502
|
-
// Private windows may refuse storage; the setting then lasts for this page only.
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function silent() {
|
|
507
|
-
return player.muted || synth === null;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/** Resolves true after `ms` of playing time, or false once a seek retires this loop. */
|
|
511
|
-
function sleep(ms, generation) {
|
|
512
|
-
return new Promise((resolve) => {
|
|
513
|
-
let left = ms;
|
|
514
|
-
let last = performance.now();
|
|
515
|
-
const tick = () => {
|
|
516
|
-
if (generation !== player.generation) return resolve(false);
|
|
517
|
-
const now = performance.now();
|
|
518
|
-
if (player.playing) left -= now - last;
|
|
519
|
-
last = now;
|
|
520
|
-
if (left <= 0 && player.playing) return resolve(true);
|
|
521
|
-
setTimeout(tick, Math.max(16, Math.min(left, 100)));
|
|
522
|
-
};
|
|
523
|
-
tick();
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function speak(text) {
|
|
528
|
-
if (silent() || !text) return null;
|
|
529
|
-
const utterance = new SpeechSynthesisUtterance(text);
|
|
530
|
-
utterance.rate = player.speed;
|
|
531
|
-
const voice = voices.find((v) => v.name === player.voice);
|
|
532
|
-
if (voice) {
|
|
533
|
-
utterance.voice = voice;
|
|
534
|
-
utterance.lang = voice.lang;
|
|
535
|
-
}
|
|
536
|
-
synth.speak(utterance);
|
|
537
|
-
return utterance;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/** Some engines never fire "end", so a pause-aware timeout bounds the wait. */
|
|
541
|
-
function speakAndWait(text, generation) {
|
|
542
|
-
const utterance = speak(text);
|
|
543
|
-
if (!utterance) return Promise.resolve(true);
|
|
544
|
-
const ended = new Promise((resolve) => {
|
|
545
|
-
utterance.addEventListener("end", () => resolve(true));
|
|
546
|
-
utterance.addEventListener("error", () => resolve(true));
|
|
547
|
-
});
|
|
548
|
-
return Promise.race([ended, sleep(speechTimeout(text, player.speed), generation)]);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function setCaption(text) {
|
|
552
|
-
$("caption").textContent = text;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
function applyPlayback() {
|
|
556
|
-
const { states, current } =
|
|
557
|
-
player.view === "play"
|
|
558
|
-
? lineStates(script, player.position, selected)
|
|
559
|
-
: { states: new Map(), current: null };
|
|
560
|
-
for (const node of $("main").querySelectorAll("[data-line]")) {
|
|
561
|
-
const key = node.dataset.line;
|
|
562
|
-
const lineState = states.get(key);
|
|
563
|
-
node.classList.toggle("pending", lineState === "hidden");
|
|
564
|
-
node.classList.toggle("gone", lineState === "gone");
|
|
565
|
-
node.classList.toggle("current", key === current);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
async function perform(step, generation) {
|
|
570
|
-
player.position += 1;
|
|
571
|
-
if (step.kind === "commit") {
|
|
572
|
-
if (selected === step.commit) applyPlayback();
|
|
573
|
-
else {
|
|
574
|
-
selected = step.commit;
|
|
575
|
-
render();
|
|
576
|
-
$("main").scrollTop = 0;
|
|
577
|
-
}
|
|
578
|
-
setCaption(step.say);
|
|
579
|
-
speak(step.say);
|
|
580
|
-
} else if (step.kind === "unit") {
|
|
581
|
-
applyPlayback();
|
|
582
|
-
$("main")
|
|
583
|
-
.querySelector(`[data-unit="${step.unit}"]`)
|
|
584
|
-
?.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
585
|
-
if (step.say) {
|
|
586
|
-
setCaption(step.say);
|
|
587
|
-
await speakAndWait(step.say, generation);
|
|
588
|
-
}
|
|
589
|
-
} else {
|
|
590
|
-
applyPlayback();
|
|
591
|
-
$("main")
|
|
592
|
-
.querySelector(".dl.current")
|
|
593
|
-
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
594
|
-
}
|
|
595
|
-
return sleep(stepDelay(step, player.speed), generation);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
async function playLoop(generation) {
|
|
599
|
-
while (player.position < script.length) {
|
|
600
|
-
if (!(await sleep(0, generation))) return;
|
|
601
|
-
if (!(await perform(script[player.position], generation))) return;
|
|
602
|
-
}
|
|
603
|
-
player.playing = false;
|
|
604
|
-
setCaption(
|
|
605
|
-
isWalk()
|
|
606
|
-
? "That is the whole branch. Leave any last comments, then finish the code review."
|
|
607
|
-
: "That is the whole plan.",
|
|
608
|
-
);
|
|
609
|
-
updatePlayer();
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** Moves playback to a step and starts a fresh loop, which waits while paused. */
|
|
613
|
-
function seek(position) {
|
|
614
|
-
synth?.cancel();
|
|
615
|
-
player.position = position;
|
|
616
|
-
player.generation += 1;
|
|
617
|
-
selected = script[position]?.commit ?? commitAt(script, position);
|
|
618
|
-
render();
|
|
619
|
-
$("main").scrollTop = 0;
|
|
620
|
-
setCaption("");
|
|
621
|
-
playLoop(player.generation);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function play() {
|
|
625
|
-
if (player.view !== "play") enterPlay();
|
|
626
|
-
if (player.position >= script.length) seek(0);
|
|
627
|
-
player.playing = true;
|
|
628
|
-
synth?.resume();
|
|
629
|
-
updatePlayer();
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/** Pausing preselects the line just written, so the comment button is ready for it. */
|
|
633
|
-
function pause() {
|
|
634
|
-
player.playing = false;
|
|
635
|
-
synth?.pause();
|
|
636
|
-
updatePlayer();
|
|
637
|
-
const key = $("main").querySelector(".dl.current")?.dataset.line;
|
|
638
|
-
if (key === undefined) return;
|
|
639
|
-
const at = key.lastIndexOf(":");
|
|
640
|
-
const unitId = key.slice(0, at);
|
|
641
|
-
const change = unitsById.get(unitId)?.body?.[Number(key.slice(at + 1))]?.change;
|
|
642
|
-
const pickKey = `${selected}:${unitId}`;
|
|
643
|
-
if (change === undefined || (picks.get(pickKey)?.size ?? 0) > 0) return;
|
|
644
|
-
picks.set(pickKey, new Set([change]));
|
|
645
|
-
render();
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function enterPlay() {
|
|
649
|
-
player.view = "play";
|
|
650
|
-
$("player").hidden = false;
|
|
651
|
-
seek(commitStart(script, selected));
|
|
652
|
-
updatePlayer();
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function enterBrowse() {
|
|
656
|
-
player.view = "browse";
|
|
657
|
-
player.playing = false;
|
|
658
|
-
player.generation += 1;
|
|
659
|
-
synth?.cancel();
|
|
660
|
-
$("player").hidden = true;
|
|
661
|
-
render();
|
|
662
|
-
updatePlayer();
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function updatePlayer() {
|
|
666
|
-
$("play").textContent = player.playing ? "Pause" : "Play";
|
|
667
|
-
$("view-toggle").textContent = player.view === "play" ? "Show all" : "Play";
|
|
668
|
-
const mute = $("mute");
|
|
669
|
-
mute.disabled = synth === null;
|
|
670
|
-
mute.textContent = synth === null ? "No speech" : player.muted ? "Unmute" : "Mute";
|
|
671
|
-
mute.setAttribute("aria-pressed", String(player.muted));
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function loadVoices() {
|
|
675
|
-
if (synth === null) return;
|
|
676
|
-
voices = synth.getVoices();
|
|
677
|
-
if (!voices.some((v) => v.name === player.voice)) {
|
|
678
|
-
player.voice = pickVoice(voices, stored("voice"), navigator.language);
|
|
679
|
-
}
|
|
680
|
-
const picker = $("voice");
|
|
681
|
-
picker.replaceChildren(
|
|
682
|
-
el("option", { value: "", text: "Browser default" }),
|
|
683
|
-
...voices.map((v) => el("option", { value: v.name, text: `${v.name} (${v.lang})` })),
|
|
684
|
-
);
|
|
685
|
-
picker.value = player.voice ?? "";
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function setUpPlayer() {
|
|
689
|
-
player.speed = parseSpeed(stored("speed"));
|
|
690
|
-
player.muted = stored("muted") === "true";
|
|
691
|
-
const speed = $("speed");
|
|
692
|
-
speed.replaceChildren(...SPEEDS.map((s) => el("option", { value: String(s), text: `${s}x` })));
|
|
693
|
-
speed.value = String(player.speed);
|
|
694
|
-
speed.addEventListener("change", () => {
|
|
695
|
-
player.speed = parseSpeed(speed.value);
|
|
696
|
-
store("speed", String(player.speed));
|
|
697
|
-
});
|
|
698
|
-
const picker = $("voice");
|
|
699
|
-
picker.disabled = synth === null;
|
|
700
|
-
picker.addEventListener("change", () => {
|
|
701
|
-
player.voice = picker.value || null;
|
|
702
|
-
store("voice", picker.value);
|
|
703
|
-
});
|
|
704
|
-
synth?.addEventListener("voiceschanged", loadVoices);
|
|
705
|
-
loadVoices();
|
|
706
|
-
$("play").addEventListener("click", () => (player.playing ? pause() : play()));
|
|
707
|
-
$("prev").addEventListener("click", () => select(selected - 1));
|
|
708
|
-
$("next").addEventListener("click", () => select(selected + 1));
|
|
709
|
-
$("mute").addEventListener("click", () => {
|
|
710
|
-
player.muted = !player.muted;
|
|
711
|
-
store("muted", String(player.muted));
|
|
712
|
-
if (player.muted) synth?.cancel();
|
|
713
|
-
updatePlayer();
|
|
714
|
-
});
|
|
715
|
-
$("view-toggle").addEventListener("click", () =>
|
|
716
|
-
player.view === "play" ? enterBrowse() : play(),
|
|
717
|
-
);
|
|
718
|
-
updatePlayer();
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
function hasUnsentText() {
|
|
722
|
-
return [...drafts.values()].some((text) => text.trim() !== "");
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
async function endReview() {
|
|
726
|
-
if (
|
|
727
|
-
hasUnsentText() &&
|
|
728
|
-
!window.confirm("Some comment boxes have text you have not sent. End anyway?")
|
|
729
|
-
) {
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
await api("POST", "/api/end");
|
|
733
|
-
player.generation += 1;
|
|
734
|
-
synth?.cancel();
|
|
735
|
-
const done = $("done");
|
|
736
|
-
const saved = `reviews/v${state.version}.json`;
|
|
737
|
-
done.replaceChildren(
|
|
738
|
-
isWalk()
|
|
739
|
-
? el(
|
|
740
|
-
"p",
|
|
741
|
-
{},
|
|
742
|
-
`Code review saved to ${saved}. The author can run `,
|
|
743
|
-
el("code", { text: "follow-me feedback" }),
|
|
744
|
-
" to read it.",
|
|
745
|
-
)
|
|
746
|
-
: el(
|
|
747
|
-
"p",
|
|
748
|
-
{},
|
|
749
|
-
`Review saved to ${saved}. Run `,
|
|
750
|
-
el("code", { text: "follow-me feedback" }),
|
|
751
|
-
" or hand the session to your agent.",
|
|
752
|
-
),
|
|
753
|
-
el("p", {}, el("a", { href: "/", text: "All sessions" })),
|
|
754
|
-
);
|
|
755
|
-
done.hidden = false;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
document.addEventListener("keydown", (event) => {
|
|
759
|
-
const target = event.target;
|
|
760
|
-
if (
|
|
761
|
-
target instanceof HTMLTextAreaElement ||
|
|
762
|
-
target instanceof HTMLInputElement ||
|
|
763
|
-
target instanceof HTMLSelectElement
|
|
764
|
-
) {
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
768
|
-
// A focused button already answers space by clicking itself.
|
|
769
|
-
if (event.key === " " && !(target instanceof HTMLButtonElement)) {
|
|
770
|
-
event.preventDefault();
|
|
771
|
-
if (player.playing) pause();
|
|
772
|
-
else play();
|
|
773
|
-
}
|
|
774
|
-
if (event.key === "j" || event.key === "n") select(selected + 1);
|
|
775
|
-
if (event.key === "k" || event.key === "p") select(selected - 1);
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
$("end").addEventListener("click", () => guarded(endReview));
|
|
779
|
-
window.addEventListener("focus", () => renderSwitcher());
|
|
780
|
-
renderSwitcher();
|
|
781
|
-
|
|
782
|
-
guarded(async () => {
|
|
783
|
-
state = await api("GET", "/api/state");
|
|
784
|
-
indexOwnership();
|
|
785
|
-
script = buildScript(state.commits, state.units);
|
|
786
|
-
const walk = isWalk();
|
|
787
|
-
document.title = walk ? "follow-me code review" : "follow-me review";
|
|
788
|
-
$("summary").textContent = walk
|
|
789
|
-
? `${state.anchor.branch} · Code review`
|
|
790
|
-
: `${state.anchor.branch} · Review of plan v${state.version}`;
|
|
791
|
-
$("end").textContent = walk ? "Finish code review" : "End review";
|
|
792
|
-
setUpPlayer();
|
|
793
|
-
if (walk) {
|
|
794
|
-
enterPlay();
|
|
795
|
-
setCaption("Press Play or space to watch this branch being written.");
|
|
796
|
-
} else render();
|
|
797
|
-
});
|