@erdemtuna/doc-review 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -222
- package/{src → lib}/SKILL.md +5 -2
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +1700 -0
- package/lib/chrome-session.js +73 -0
- package/lib/chrome.html +53 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/controller-store.js +96 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +19 -0
- package/lib/feedback-controller.js +113 -0
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +217 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +325 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +303 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/lib/view-identity.js +112 -0
- package/package.json +50 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/chrome.css +0 -652
- package/src/chrome.html +0 -196
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/document-trust.js +0 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
export const DEFAULT_POLL_SECONDS = 12 * 60 * 60;
|
|
4
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
5
|
+
const clock = {
|
|
6
|
+
now: () => performance.now(),
|
|
7
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
8
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
9
|
+
};
|
|
10
|
+
function codedError(message, code) {
|
|
11
|
+
return Object.assign(new Error(message), { code });
|
|
12
|
+
}
|
|
13
|
+
export function createDeadline(seconds = DEFAULT_POLL_SECONDS, time = clock) {
|
|
14
|
+
const end = time.now() + seconds * 1000;
|
|
15
|
+
const remaining = () => Math.max(0, end - time.now());
|
|
16
|
+
const check = () => {
|
|
17
|
+
if (remaining() <= 0)
|
|
18
|
+
throw codedError("Polling deadline reached.", "POLL_DEADLINE");
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
seconds,
|
|
22
|
+
time,
|
|
23
|
+
remaining,
|
|
24
|
+
check,
|
|
25
|
+
async sleep(ms) {
|
|
26
|
+
check();
|
|
27
|
+
await new Promise((resolve) => time.setTimeout(resolve, Math.min(ms, remaining(), MAX_TIMER_MS)));
|
|
28
|
+
check();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function isRecoverableTransportError(err) {
|
|
33
|
+
return new Set([
|
|
34
|
+
"ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH",
|
|
35
|
+
"ENETUNREACH", "ENETDOWN", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE",
|
|
36
|
+
"SERVER_START_PENDING",
|
|
37
|
+
]).has(err?.code);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An absolute response deadline, not a socket-idle timeout: heartbeats and
|
|
41
|
+
* partially received JSON must not extend the caller's waiting budget.
|
|
42
|
+
*/
|
|
43
|
+
export function requestRaw(server, options, body, { timeoutMs = options.timeout, time = clock, signal, timeoutCode = "ETIMEDOUT", makeRequest = http.request, } = {}) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let req;
|
|
46
|
+
let res;
|
|
47
|
+
let timer;
|
|
48
|
+
let done = false;
|
|
49
|
+
let ended = false;
|
|
50
|
+
let raw = "";
|
|
51
|
+
const expires = timeoutMs == null ? null : time.now() + timeoutMs;
|
|
52
|
+
const premature = () => codedError("The server closed an incomplete response.", "ERR_STREAM_PREMATURE_CLOSE");
|
|
53
|
+
const cleanupResponse = () => {
|
|
54
|
+
res?.removeListener("data", onData);
|
|
55
|
+
res?.removeListener("end", onEnd);
|
|
56
|
+
res?.removeListener("aborted", onAborted);
|
|
57
|
+
};
|
|
58
|
+
const settle = (err, value) => {
|
|
59
|
+
if (done)
|
|
60
|
+
return;
|
|
61
|
+
done = true;
|
|
62
|
+
time.clearTimeout(timer);
|
|
63
|
+
signal?.removeEventListener("abort", onAbort);
|
|
64
|
+
cleanupResponse();
|
|
65
|
+
// Keep error handlers until close: destroying a socket may emit one last
|
|
66
|
+
// error asynchronously. Close removes the remaining owned listeners.
|
|
67
|
+
res?.destroy();
|
|
68
|
+
req?.destroy();
|
|
69
|
+
if (err)
|
|
70
|
+
reject(err);
|
|
71
|
+
else
|
|
72
|
+
resolve(value);
|
|
73
|
+
};
|
|
74
|
+
const onError = (err) => settle(err);
|
|
75
|
+
const onData = (chunk) => { raw += chunk; };
|
|
76
|
+
const onEnd = () => {
|
|
77
|
+
ended = true;
|
|
78
|
+
if (!res.complete)
|
|
79
|
+
return settle(premature());
|
|
80
|
+
settle(null, { status: res.statusCode, raw });
|
|
81
|
+
};
|
|
82
|
+
const onAborted = () => settle(premature());
|
|
83
|
+
const onResponseClose = () => {
|
|
84
|
+
if (!ended)
|
|
85
|
+
settle(premature());
|
|
86
|
+
cleanupResponse();
|
|
87
|
+
res.removeListener("error", onError);
|
|
88
|
+
res.removeListener("close", onResponseClose);
|
|
89
|
+
};
|
|
90
|
+
const onRequestClose = () => {
|
|
91
|
+
if (!done)
|
|
92
|
+
settle(premature());
|
|
93
|
+
req.removeListener("error", onError);
|
|
94
|
+
req.removeListener("close", onRequestClose);
|
|
95
|
+
req.removeListener("response", onResponse);
|
|
96
|
+
};
|
|
97
|
+
const onAbort = () => settle(codedError("Polling cancelled.", "ABORT_ERR"));
|
|
98
|
+
const armTimer = () => {
|
|
99
|
+
const left = expires - time.now();
|
|
100
|
+
if (left <= 0)
|
|
101
|
+
return settle(codedError("Polling request timed out.", timeoutCode));
|
|
102
|
+
timer = time.setTimeout(armTimer, Math.min(left, MAX_TIMER_MS));
|
|
103
|
+
};
|
|
104
|
+
const onResponse = (response) => {
|
|
105
|
+
res = response;
|
|
106
|
+
if (done) {
|
|
107
|
+
res.destroy();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
res.setEncoding("utf8");
|
|
111
|
+
res.on("data", onData);
|
|
112
|
+
res.on("end", onEnd);
|
|
113
|
+
res.on("error", onError);
|
|
114
|
+
res.on("aborted", onAborted);
|
|
115
|
+
res.on("close", onResponseClose);
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
if (signal?.aborted)
|
|
119
|
+
return onAbort();
|
|
120
|
+
const port = typeof server === "number" ? server : server.port;
|
|
121
|
+
const token = typeof server === "number" ? "" : server.token || "";
|
|
122
|
+
const { timeout: _timeout, ...requestOptions } = options;
|
|
123
|
+
req = makeRequest({
|
|
124
|
+
host: "127.0.0.1",
|
|
125
|
+
port,
|
|
126
|
+
...requestOptions,
|
|
127
|
+
headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
|
|
128
|
+
});
|
|
129
|
+
req.on("response", onResponse);
|
|
130
|
+
req.on("error", onError);
|
|
131
|
+
req.on("close", onRequestClose);
|
|
132
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
133
|
+
if (expires !== null)
|
|
134
|
+
armTimer();
|
|
135
|
+
if (done)
|
|
136
|
+
return;
|
|
137
|
+
if (body)
|
|
138
|
+
req.write(JSON.stringify(body));
|
|
139
|
+
req.end();
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
settle(err);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
export function parseServerResponse(response) {
|
|
147
|
+
if (response.status !== 200) {
|
|
148
|
+
let detail = "";
|
|
149
|
+
try {
|
|
150
|
+
detail = JSON.parse(response.raw).error || "";
|
|
151
|
+
}
|
|
152
|
+
catch { /* HTTP status is sufficient. */ }
|
|
153
|
+
throw codedError(`Doc-review server returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`, "SERVER_RESPONSE_ERROR");
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(response.raw);
|
|
157
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
158
|
+
throw new Error();
|
|
159
|
+
return parsed;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw codedError("Malformed response from the doc-review server. End the review and restart the server.", "SERVER_RESPONSE_INVALID");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export async function pollOnce(server, target, ackId, deadline) {
|
|
166
|
+
deadline.check();
|
|
167
|
+
const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
|
|
168
|
+
const response = await requestRaw(server, {
|
|
169
|
+
method: "GET",
|
|
170
|
+
path: `/api/poll?${query}`,
|
|
171
|
+
}, undefined, {
|
|
172
|
+
timeoutMs: deadline.remaining(),
|
|
173
|
+
time: deadline.time,
|
|
174
|
+
timeoutCode: "POLL_DEADLINE",
|
|
175
|
+
});
|
|
176
|
+
// Graceful server disposal ends the space heartbeats without a JSON payload.
|
|
177
|
+
// HTTP is complete, but this poll was interrupted just like a dropped socket.
|
|
178
|
+
if (response.status === 200 && /^ +$/.test(response.raw)) {
|
|
179
|
+
throw codedError("The server ended the poll before sending feedback.", "ERR_STREAM_PREMATURE_CLOSE");
|
|
180
|
+
}
|
|
181
|
+
const batch = parseServerResponse(response);
|
|
182
|
+
if (!["feedback", "closed", "timeout"].includes(batch.status) ||
|
|
183
|
+
(batch.status === "feedback" && (typeof batch.batch_id !== "string" || !batch.batch_id || !Array.isArray(batch.pages)))) {
|
|
184
|
+
throw codedError("Malformed polling response from the doc-review server.", "SERVER_RESPONSE_INVALID");
|
|
185
|
+
}
|
|
186
|
+
return batch;
|
|
187
|
+
}
|
|
188
|
+
export async function pollUntilDeadline({ target, ackId = "", deadline = createDeadline(), discover, poll = pollOnce, diagnostic = () => { }, }) {
|
|
189
|
+
let failures = 0;
|
|
190
|
+
for (;;) {
|
|
191
|
+
try {
|
|
192
|
+
deadline.check();
|
|
193
|
+
const server = await discover(deadline);
|
|
194
|
+
deadline.check();
|
|
195
|
+
// An uncertain send must repeat the supplied receipt, never a newer ID.
|
|
196
|
+
const batch = await poll(server, target, ackId, deadline);
|
|
197
|
+
deadline.check();
|
|
198
|
+
return batch;
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
|
|
202
|
+
return {
|
|
203
|
+
status: "timeout",
|
|
204
|
+
waited_seconds: deadline.seconds,
|
|
205
|
+
next_step: "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!isRecoverableTransportError(err))
|
|
209
|
+
throw err;
|
|
210
|
+
diagnostic(`Lost the connection (${err.message}); retrying.\n`);
|
|
211
|
+
try {
|
|
212
|
+
await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
|
|
213
|
+
}
|
|
214
|
+
catch (sleepError) {
|
|
215
|
+
if (sleepError.code !== "POLL_DEADLINE")
|
|
216
|
+
throw sleepError;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const GAP = 12;
|
|
2
|
+
export function visibleViewport(win = window) {
|
|
3
|
+
const viewport = win.visualViewport;
|
|
4
|
+
return viewport
|
|
5
|
+
? { left: viewport.offsetLeft, top: viewport.offsetTop, width: viewport.width, height: viewport.height }
|
|
6
|
+
: { left: 0, top: 0, width: win.innerWidth, height: win.innerHeight };
|
|
7
|
+
}
|
|
8
|
+
export function lastVisibleRect(rects) {
|
|
9
|
+
if (!Array.isArray(rects))
|
|
10
|
+
return null;
|
|
11
|
+
for (let index = rects.length - 1; index >= 0; index -= 1) {
|
|
12
|
+
const rect = rects[index];
|
|
13
|
+
if (rect && rect.width >= 0 && rect.height >= 0)
|
|
14
|
+
return rect;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
19
|
+
function frameRectToChrome(rect, frameRect) {
|
|
20
|
+
return {
|
|
21
|
+
left: frameRect.left + rect.left,
|
|
22
|
+
right: frameRect.left + rect.right,
|
|
23
|
+
top: frameRect.top + rect.top,
|
|
24
|
+
bottom: frameRect.top + rect.bottom,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function placeContextualSurface(geometry, options) {
|
|
28
|
+
const { frameRect, viewport, surfaceWidth = 340, surfaceHeight = 220, narrow = false, toolbarHeight = 48, } = options;
|
|
29
|
+
const bottomSheet = () => ({
|
|
30
|
+
kind: "sheet",
|
|
31
|
+
left: viewport.left,
|
|
32
|
+
top: viewport.top + viewport.height - Math.min(surfaceHeight, viewport.height),
|
|
33
|
+
width: viewport.width,
|
|
34
|
+
});
|
|
35
|
+
if (!geometry || geometry.relation === "unavailable" || !geometry.clip)
|
|
36
|
+
return { kind: "hidden" };
|
|
37
|
+
if (narrow)
|
|
38
|
+
return bottomSheet();
|
|
39
|
+
const clip = frameRectToChrome(geometry.clip, frameRect);
|
|
40
|
+
const minLeft = viewport.left + GAP;
|
|
41
|
+
const maxLeft = viewport.left + viewport.width - surfaceWidth - GAP;
|
|
42
|
+
const viewportMinTop = viewport.top + toolbarHeight + GAP;
|
|
43
|
+
const minTop = Math.max(viewportMinTop, clip.top + GAP);
|
|
44
|
+
const maxTop = viewport.top + viewport.height - surfaceHeight - GAP;
|
|
45
|
+
const clipBottomTop = clip.bottom - surfaceHeight - GAP;
|
|
46
|
+
const horizontal = Number.isFinite(geometry.horizontal)
|
|
47
|
+
? frameRect.left + geometry.horizontal
|
|
48
|
+
: clip.left;
|
|
49
|
+
const edgeLeft = clamp(horizontal + GAP, minLeft, maxLeft);
|
|
50
|
+
if (geometry.relation === "above") {
|
|
51
|
+
return { kind: "edge-top", left: edgeLeft, top: minTop, width: surfaceWidth };
|
|
52
|
+
}
|
|
53
|
+
if (geometry.relation === "below") {
|
|
54
|
+
return {
|
|
55
|
+
kind: "edge-bottom",
|
|
56
|
+
left: edgeLeft,
|
|
57
|
+
top: Math.max(viewportMinTop, Math.min(maxTop, clipBottomTop)),
|
|
58
|
+
width: surfaceWidth,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const target = lastVisibleRect(geometry.rects);
|
|
62
|
+
if (!target)
|
|
63
|
+
return { kind: "hidden" };
|
|
64
|
+
const rect = frameRectToChrome(target, frameRect);
|
|
65
|
+
const attachedMaxTop = Math.min(maxTop, clipBottomTop);
|
|
66
|
+
const clampTop = (top) => clamp(top, minTop, Math.max(minTop, attachedMaxTop));
|
|
67
|
+
const candidates = [
|
|
68
|
+
{ left: rect.right + GAP, top: clampTop(rect.bottom - surfaceHeight) },
|
|
69
|
+
{ left: rect.left - surfaceWidth - GAP, top: clampTop(rect.bottom - surfaceHeight) },
|
|
70
|
+
{ left: clamp(rect.left, minLeft, maxLeft), top: rect.bottom + GAP },
|
|
71
|
+
{ left: clamp(rect.left, minLeft, maxLeft), top: rect.top - surfaceHeight - GAP },
|
|
72
|
+
];
|
|
73
|
+
const fit = candidates.find((candidate) => candidate.left >= minLeft &&
|
|
74
|
+
candidate.left <= maxLeft &&
|
|
75
|
+
candidate.top >= minTop &&
|
|
76
|
+
candidate.top <= attachedMaxTop &&
|
|
77
|
+
candidate.left + surfaceWidth <= Math.min(viewport.left + viewport.width - GAP, clip.right) &&
|
|
78
|
+
candidate.top + surfaceHeight <= clip.bottom);
|
|
79
|
+
const fallback = fit || {
|
|
80
|
+
left: clamp(rect.right + GAP, minLeft, maxLeft),
|
|
81
|
+
top: clampTop(rect.top),
|
|
82
|
+
};
|
|
83
|
+
return { kind: "attached", ...fallback, width: surfaceWidth };
|
|
84
|
+
}
|
|
85
|
+
export function alignedCardPosition(rects, { frameRect, viewport, width = 300, height = 180 }) {
|
|
86
|
+
const target = lastVisibleRect(rects);
|
|
87
|
+
if (!target)
|
|
88
|
+
return null;
|
|
89
|
+
const top = frameRect.top + target.top;
|
|
90
|
+
const right = frameRect.left + target.right;
|
|
91
|
+
if (top < frameRect.top || top > frameRect.bottom || right < frameRect.left || right > frameRect.right)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
left: Math.max(viewport.left + 12, Math.min(viewport.left + viewport.width - width - 12, right + 12)),
|
|
95
|
+
top: Math.max(viewport.top + 12, Math.min(viewport.top + viewport.height - height - 12, top)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
|
+
import { decodePage, record } from "./chrome-api.js";
|
|
3
|
+
import { executionPresentation } from "./execution-client.js";
|
|
4
|
+
export function createRecoveryController(options) {
|
|
5
|
+
let disposed = false;
|
|
6
|
+
let busy = false;
|
|
7
|
+
let reloadBusy = false;
|
|
8
|
+
let menuOpen = false;
|
|
9
|
+
let restoreMenuFocus = true;
|
|
10
|
+
let failure = null;
|
|
11
|
+
const lifetime = new AbortController();
|
|
12
|
+
const matches = (a, b) => a.key === b.key && a.generation === b.generation && a.renderId === b.renderId;
|
|
13
|
+
function project() {
|
|
14
|
+
const context = options.read();
|
|
15
|
+
const view = executionPresentation(context.page, context.rendered, {
|
|
16
|
+
loading: context.loading, pendingReload: context.pendingReload, error: context.frameError,
|
|
17
|
+
});
|
|
18
|
+
const visible = !context.ended && !context.comparing;
|
|
19
|
+
const menuVisible = visible && (view.eligible || !!view.detail);
|
|
20
|
+
const error = failure && matches(failure.identity, context.identity) ? failure.message : null;
|
|
21
|
+
return {
|
|
22
|
+
menuVisible, menuOpen: menuVisible && menuOpen, restoreMenuFocus,
|
|
23
|
+
busy, canRecover: !disposed && !context.ended && !context.comparing && !busy && view.eligible && !!context.identity.key,
|
|
24
|
+
preference: view.preference, eligible: view.eligible, detail: view.detail,
|
|
25
|
+
reloadVisible: visible && context.reload.visible,
|
|
26
|
+
reloadMessage: context.reload.message, reloadError: context.reload.error,
|
|
27
|
+
canReload: !disposed && visible && context.reload.visible && !!context.identity.key && (!context.loading || !!context.frameError) && !reloadBusy,
|
|
28
|
+
reloadBusy,
|
|
29
|
+
status: visible && !context.reload.visible ? (context.frameError || error || view.status || (busy ? "Updating page recovery settings…" : "")) : "",
|
|
30
|
+
statusError: !!context.frameError || !!error,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const store = createControllerStore(project);
|
|
34
|
+
function setMenuOpen(open, { restoreFocus = true } = {}) {
|
|
35
|
+
if (disposed || (open && !project().menuVisible))
|
|
36
|
+
return;
|
|
37
|
+
if (menuOpen === open) {
|
|
38
|
+
// The iframe's interaction message can arrive after native dismissal.
|
|
39
|
+
if (!open && !restoreFocus) {
|
|
40
|
+
restoreMenuFocus = false;
|
|
41
|
+
store.publish();
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
menuOpen = open;
|
|
46
|
+
restoreMenuFocus = restoreFocus;
|
|
47
|
+
store.publish();
|
|
48
|
+
options.menuChanged(open);
|
|
49
|
+
}
|
|
50
|
+
function publish() {
|
|
51
|
+
if (!project().menuVisible)
|
|
52
|
+
setMenuOpen(false, { restoreFocus: false });
|
|
53
|
+
return store.publish();
|
|
54
|
+
}
|
|
55
|
+
async function recover(preference) {
|
|
56
|
+
if (!project().canRecover)
|
|
57
|
+
return;
|
|
58
|
+
const identity = { ...options.read().identity };
|
|
59
|
+
busy = true;
|
|
60
|
+
failure = null;
|
|
61
|
+
setMenuOpen(false);
|
|
62
|
+
publish();
|
|
63
|
+
try {
|
|
64
|
+
const response = record(await options.request(`/api/session/${options.sessionId}/execution`, {
|
|
65
|
+
method: "POST", body: JSON.stringify({ key: identity.key, preference }), signal: lifetime.signal,
|
|
66
|
+
}));
|
|
67
|
+
const page = decodePage(response.page);
|
|
68
|
+
if (!disposed && matches(identity, options.read().identity)) {
|
|
69
|
+
if (page.key !== identity.key)
|
|
70
|
+
throw new Error("The recovery response belongs to a different page.");
|
|
71
|
+
options.changed(page);
|
|
72
|
+
}
|
|
73
|
+
// The server event remains the only owner of draft-safe frame replacement.
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!disposed && matches(identity, options.read().identity)) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
failure = { identity, message: `Recovery could not be updated: ${message}. Retry from More.` };
|
|
79
|
+
options.failed(message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
busy = false;
|
|
84
|
+
publish();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function reload() {
|
|
88
|
+
if (!project().canReload)
|
|
89
|
+
return;
|
|
90
|
+
reloadBusy = true;
|
|
91
|
+
publish();
|
|
92
|
+
try {
|
|
93
|
+
await options.reload();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!disposed)
|
|
97
|
+
options.failed(error instanceof Error ? error.message : String(error));
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
reloadBusy = false;
|
|
101
|
+
publish();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
getSnapshot: store.getSnapshot, subscribe: store.subscribe, publish,
|
|
106
|
+
commands: {
|
|
107
|
+
setMenuOpen, recover, reload,
|
|
108
|
+
keepCurrent() {
|
|
109
|
+
if (disposed || !project().reloadVisible || reloadBusy)
|
|
110
|
+
return;
|
|
111
|
+
options.keepCurrent();
|
|
112
|
+
publish();
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
dispose() {
|
|
116
|
+
if (disposed)
|
|
117
|
+
return;
|
|
118
|
+
disposed = true;
|
|
119
|
+
lifetime.abort();
|
|
120
|
+
store.dispose();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { decodePage, record } from "./chrome-api.js";
|
|
2
|
+
export function createReviewController(options) {
|
|
3
|
+
let source = null;
|
|
4
|
+
let revision = 0;
|
|
5
|
+
let navigationRevision = 0;
|
|
6
|
+
let disposed = false;
|
|
7
|
+
const cleanup = new Set();
|
|
8
|
+
const invalidate = () => { revision++; };
|
|
9
|
+
const report = (error) => { if (!disposed)
|
|
10
|
+
options.failed(error); };
|
|
11
|
+
async function refresh() {
|
|
12
|
+
const started = ++revision;
|
|
13
|
+
const key = options.key();
|
|
14
|
+
const generation = options.generation();
|
|
15
|
+
options.beforeRefresh();
|
|
16
|
+
const page = await options.request(`/api/page/${key}?session=${encodeURIComponent(options.sessionId)}`);
|
|
17
|
+
if (disposed || started !== revision || key !== options.key() || generation !== options.generation())
|
|
18
|
+
return;
|
|
19
|
+
options.refreshPage(decodePage(page), true);
|
|
20
|
+
}
|
|
21
|
+
function connect() {
|
|
22
|
+
if (disposed || source)
|
|
23
|
+
return;
|
|
24
|
+
source = (options.eventSource || ((url) => new EventSource(url)))(`/events/${options.sessionId}`);
|
|
25
|
+
source.addEventListener("ended", () => { if (!disposed)
|
|
26
|
+
options.ended(); });
|
|
27
|
+
source.addEventListener("reload", () => {
|
|
28
|
+
if (disposed)
|
|
29
|
+
return;
|
|
30
|
+
invalidate();
|
|
31
|
+
void options.reload().catch(report);
|
|
32
|
+
});
|
|
33
|
+
source.addEventListener("history", () => { if (!disposed)
|
|
34
|
+
void options.history().catch(report); });
|
|
35
|
+
source.addEventListener("agent", (event) => {
|
|
36
|
+
if (disposed)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
const value = record(JSON.parse(event.data));
|
|
40
|
+
if (typeof value.state !== "string")
|
|
41
|
+
throw new Error("Invalid agent state event.");
|
|
42
|
+
options.agent(value.state);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
report(error);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
source.addEventListener("refresh", () => { if (!disposed)
|
|
49
|
+
void refresh().catch(report); });
|
|
50
|
+
// EventSource owns reconnect/backoff; reconnect errors are not failed mutations.
|
|
51
|
+
source.onerror = () => { };
|
|
52
|
+
}
|
|
53
|
+
async function navigate(body) {
|
|
54
|
+
if (disposed)
|
|
55
|
+
throw new Error("Review ended");
|
|
56
|
+
if (options.hasDrafts())
|
|
57
|
+
throw new Error("Save or cancel open comment drafts before changing pages");
|
|
58
|
+
const started = ++navigationRevision;
|
|
59
|
+
invalidate();
|
|
60
|
+
const key = options.key();
|
|
61
|
+
const generation = options.generation();
|
|
62
|
+
await options.save();
|
|
63
|
+
if (disposed || started !== navigationRevision || key !== options.key() || generation !== options.generation()) {
|
|
64
|
+
throw new Error("The page changed before navigating. Retry on the current page.");
|
|
65
|
+
}
|
|
66
|
+
if ("key" in body)
|
|
67
|
+
options.suspend();
|
|
68
|
+
const result = record(await options.request(`/api/session/${options.sessionId}/${"key" in body ? "goto" : "navigate"}`, { method: "POST", body: JSON.stringify(body) }));
|
|
69
|
+
// A source reload does not undo a navigation already accepted by the server.
|
|
70
|
+
if (disposed || started !== navigationRevision)
|
|
71
|
+
return;
|
|
72
|
+
const next = "key" in body ? body.key : result.key;
|
|
73
|
+
if (typeof next !== "string")
|
|
74
|
+
throw new Error("The server returned an invalid navigation target.");
|
|
75
|
+
await options.load(next);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
connect, refresh, navigate, invalidate,
|
|
79
|
+
own(stop) { cleanup.add(stop); return stop; },
|
|
80
|
+
dispose() {
|
|
81
|
+
if (disposed)
|
|
82
|
+
return;
|
|
83
|
+
disposed = true;
|
|
84
|
+
revision++;
|
|
85
|
+
navigationRevision++;
|
|
86
|
+
source?.close();
|
|
87
|
+
source = null;
|
|
88
|
+
for (const stop of cleanup)
|
|
89
|
+
stop();
|
|
90
|
+
cleanup.clear();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const REVIEW_MODES = Object.freeze(["view", "edit"]);
|
|
2
|
+
export function normalizeReviewMode(mode) {
|
|
3
|
+
return mode === "edit" ? "edit" : "view";
|
|
4
|
+
}
|
|
5
|
+
export function savePolicyForPage(page) {
|
|
6
|
+
return page && ((typeof page === "object" || typeof page === "function") &&
|
|
7
|
+
(("savePolicy" in page && page.savePolicy === "feedback-only") ||
|
|
8
|
+
("kind" in page && page.kind === "url") ||
|
|
9
|
+
("markdown" in page && page.markdown) || ("feedbackOnly" in page && page.feedbackOnly)))
|
|
10
|
+
? "feedback-only"
|
|
11
|
+
: "writable";
|
|
12
|
+
}
|
|
13
|
+
export function reviewConfiguration(page, mode) {
|
|
14
|
+
return {
|
|
15
|
+
mode: normalizeReviewMode(mode),
|
|
16
|
+
savePolicy: savePolicyForPage(page),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function applyBodyReviewMode(body, mode) {
|
|
20
|
+
const normalized = normalizeReviewMode(mode);
|
|
21
|
+
if (normalized === "edit") {
|
|
22
|
+
if (body.getAttribute("contenteditable") !== "true")
|
|
23
|
+
body.setAttribute("contenteditable", "true");
|
|
24
|
+
if (body.getAttribute("spellcheck") !== "false")
|
|
25
|
+
body.setAttribute("spellcheck", "false");
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (body.hasAttribute("contenteditable"))
|
|
29
|
+
body.removeAttribute("contenteditable");
|
|
30
|
+
if (body.hasAttribute("spellcheck"))
|
|
31
|
+
body.removeAttribute("spellcheck");
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
/** Keep the active mode intact when an application hydrates and rewrites body attributes. */
|
|
36
|
+
export function keepBodyInReviewMode(body, initialMode = "view") {
|
|
37
|
+
let mode = normalizeReviewMode(initialMode);
|
|
38
|
+
let applying = false;
|
|
39
|
+
const enforce = () => {
|
|
40
|
+
if (applying)
|
|
41
|
+
return;
|
|
42
|
+
applying = true;
|
|
43
|
+
applyBodyReviewMode(body, mode);
|
|
44
|
+
applying = false;
|
|
45
|
+
};
|
|
46
|
+
const view = body.ownerDocument.defaultView;
|
|
47
|
+
if (!view)
|
|
48
|
+
throw new TypeError("The review body must belong to a window.");
|
|
49
|
+
const Observer = view.MutationObserver;
|
|
50
|
+
const observer = new Observer(enforce);
|
|
51
|
+
observer.observe(body, { attributes: true, attributeFilter: ["contenteditable", "spellcheck"] });
|
|
52
|
+
enforce();
|
|
53
|
+
return {
|
|
54
|
+
get mode() {
|
|
55
|
+
return mode;
|
|
56
|
+
},
|
|
57
|
+
setMode(nextMode) {
|
|
58
|
+
mode = normalizeReviewMode(nextMode);
|
|
59
|
+
enforce();
|
|
60
|
+
return mode;
|
|
61
|
+
},
|
|
62
|
+
disconnect() {
|
|
63
|
+
observer.disconnect();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|