mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-13 15:24:25 +00:00
487 lines
20 KiB
JavaScript
487 lines
20 KiB
JavaScript
// Keeps the editor and the extension's copy of the drawing in sync.
|
|
// Local edits are sent as small element-level ops, and the server sends each change as ops too.
|
|
// Remote (agent) edits are merged on top of whatever the user has not saved yet.
|
|
import { applyOps, diffOps, mergeOps } from "/lib/model.mjs";
|
|
|
|
// How many changes can wait for the one before them (see drainOps) before the panel gives up on
|
|
// them and fetches the whole drawing again.
|
|
const MAX_EARLY = 200;
|
|
|
|
// Browsers only send keepalive requests (the kind that still goes out once the page is gone)
|
|
// while their bodies add up to at most 64 KiB (see flushBeacon).
|
|
const KEEPALIVE_BYTES = 64 * 1024;
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
export class Sync {
|
|
constructor({ instanceId, token, editor, handlers }) {
|
|
this.instanceId = instanceId;
|
|
this.token = token;
|
|
this.editor = editor;
|
|
this.handlers = handlers;
|
|
this.clientId = `c${Math.random().toString(36).slice(2, 10)}`;
|
|
this.drawingId = null;
|
|
this.rev = 0;
|
|
this.synced = new Map();
|
|
this.inflight = false;
|
|
// Every change sent gets the next number, so the server can tell an old one that arrives late.
|
|
this.seq = 0;
|
|
// The ops of the change on its way, if any, and the drawing they are for.
|
|
this.sending = null;
|
|
this.timer = 0;
|
|
this.deferred = null;
|
|
// Changes from the server that came before the one they follow (see drainOps).
|
|
this.early = [];
|
|
// Set when a change was missed, so the whole drawing has to be fetched again.
|
|
this.behind = false;
|
|
this.resyncing = false;
|
|
// The agent's newest selection, while the editor cannot show it yet (see showSelection).
|
|
this.pendingSelection = null;
|
|
this.failures = 0;
|
|
this.retryTimer = 0;
|
|
// Why the server refused our last changes, and why the extension cannot write this drawing
|
|
// to disk (it still has the changes in memory). Both show as errors until they clear.
|
|
this.refusal = null;
|
|
this.diskError = null;
|
|
// Set when the server says this drawing was deleted, so there is nothing left to save it to.
|
|
this.gone = false;
|
|
// Drawings this panel is moving to, each waiting for the edits on the current one to be saved.
|
|
this.switching = new Set();
|
|
this.source = null;
|
|
editor.on("change", () => this.schedule());
|
|
editor.on("idle", () => this.onIdle());
|
|
window.addEventListener("pagehide", () => this.flushBeacon());
|
|
}
|
|
|
|
url(route, extra = "") {
|
|
return `/api/${route}?i=${encodeURIComponent(this.instanceId)}&t=${encodeURIComponent(this.token)}${extra}`;
|
|
}
|
|
|
|
async post(route, body) {
|
|
const res = await fetch(this.url(route), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "text/plain;charset=utf-8" },
|
|
body: JSON.stringify({ clientId: this.clientId, ...body }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const err = new Error(data.error || `Request failed (${res.status})`);
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async loadState() {
|
|
const res = await fetch(this.url("state"), { cache: "no-store" });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || `Could not load the drawing (${res.status})`);
|
|
return data;
|
|
}
|
|
|
|
setDoc(doc) {
|
|
this.drawingId = doc.id;
|
|
this.rev = doc.rev;
|
|
this.synced = new Map(doc.elements.map((e) => [e.id, e]));
|
|
this.deferred = null;
|
|
this.early = [];
|
|
this.behind = false;
|
|
this.pendingSelection = null;
|
|
this.gone = false;
|
|
}
|
|
|
|
connect() {
|
|
const source = new EventSource(this.url("events", `&c=${this.clientId}`));
|
|
this.source = source;
|
|
const on = (name, fn) => source.addEventListener(name, (ev) => {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(ev.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
fn(data);
|
|
});
|
|
source.addEventListener("open", () => {
|
|
this.handlers.connection?.(true);
|
|
// "Reconnecting" covered the status line, so bring back an error that still applies.
|
|
if (this.refusal) this.handlers.status?.("error", this.refusal);
|
|
else if (this.diskError) this.settled();
|
|
// Changes made before the server knew about this connection (since the drawing was
|
|
// loaded, or while the connection was down) never come as events.
|
|
this.resync();
|
|
});
|
|
source.addEventListener("error", () => this.handlers.connection?.(false));
|
|
on("ops", (ev) => this.onOps(ev));
|
|
on("list", ({ drawings }) => this.handlers.list?.(drawings));
|
|
on("switch", ({ drawing, switchId }) => this.switchTo(drawing, { switchId }));
|
|
on("export", (req) => this.handlers.exportRequest?.(req));
|
|
on("select", ({ drawingId, ids }) => {
|
|
if (drawingId === this.drawingId) this.showSelection(ids);
|
|
for (const t of this.switching) if (t.drawing.id === drawingId) t.selection = ids;
|
|
});
|
|
on("settings", ({ settings, origin }) => {
|
|
if (origin !== this.clientId) this.handlers.settings?.(settings);
|
|
});
|
|
on("save", ({ drawingId, error }) => {
|
|
if (drawingId === this.drawingId) this.setDiskError(error);
|
|
for (const t of this.switching) if (t.drawing.id === drawingId) t.drawing = { ...t.drawing, saveError: error };
|
|
});
|
|
}
|
|
|
|
// Fetches the whole drawing again: each time the events connect, and when a change turns out to
|
|
// be missing (see drainOps). Changes that come meanwhile wait in `early`.
|
|
async resync() {
|
|
if (this.resyncing) {
|
|
// This one may have asked before the change that was missed, so ask again after it.
|
|
this.behind = true;
|
|
return;
|
|
}
|
|
this.resyncing = true;
|
|
this.behind = false;
|
|
let state = null;
|
|
try {
|
|
state = await this.loadState();
|
|
} catch {
|
|
// The next change or reconnect tries again.
|
|
this.behind = true;
|
|
} finally {
|
|
this.resyncing = false;
|
|
}
|
|
if (!state) return;
|
|
this.handlers.list?.(state.drawings);
|
|
if (state.settings) this.handlers.settings?.(state.settings);
|
|
if (state.drawing.id !== this.drawingId) {
|
|
this.switchTo(state.drawing);
|
|
} else {
|
|
this.onRemote(state.drawing);
|
|
this.setDiskError(state.drawing.saveError);
|
|
this.drainOps({ refetch: false });
|
|
}
|
|
}
|
|
|
|
// A change from the server: the ops that turn the drawing at `baseRev` into the one at `rev`.
|
|
onOps(ev) {
|
|
for (const t of this.switching) {
|
|
if (t.drawing.id !== ev.drawingId || ev.rev <= t.drawing.rev) continue;
|
|
if (ev.baseRev === t.drawing.rev) {
|
|
t.drawing = { ...t.drawing, rev: ev.rev, elements: ev.ops ? mergeOps(t.drawing.elements, ev.ops) : t.drawing.elements };
|
|
} else {
|
|
t.behind = true;
|
|
}
|
|
}
|
|
if (ev.drawingId !== this.drawingId) return;
|
|
if (this.early.length >= MAX_EARLY) {
|
|
this.early = [];
|
|
this.behind = true;
|
|
}
|
|
this.early.push(ev);
|
|
this.drainOps();
|
|
}
|
|
|
|
// Applies the changes that follow on from the newest copy of the drawing this panel has: the one
|
|
// waiting to be shown, or else the one it last loaded or saved. While our own save is on its way,
|
|
// a change made after it can come first, and waits for its reply (our change is the one it
|
|
// follows). Any other gap means a change never came (the panel was on another drawing for a
|
|
// moment, say), so the whole drawing is fetched again. Right after that fetch (`refetch` false),
|
|
// a change that still does not follow on can never apply, so it is dropped instead, and the next
|
|
// change that does not follow on fetches the drawing again.
|
|
drainOps({ refetch = true } = {}) {
|
|
const queue = this.early;
|
|
while (queue.length) {
|
|
const ev = queue[0];
|
|
const base = this.deferred || { rev: this.rev, elements: null };
|
|
if (ev.drawingId !== this.drawingId || ev.rev <= base.rev) {
|
|
queue.shift();
|
|
continue;
|
|
}
|
|
if (ev.baseRev !== base.rev) break;
|
|
queue.shift();
|
|
const elements = base.elements || [...this.synced.values()];
|
|
this.onRemote({ id: ev.drawingId, rev: ev.rev, elements: ev.ops ? mergeOps(elements, ev.ops) : elements });
|
|
}
|
|
if (this.inflight) return;
|
|
if (queue.length) {
|
|
if (refetch) this.behind = true;
|
|
else queue.length = 0;
|
|
}
|
|
if (this.behind) this.resync();
|
|
}
|
|
|
|
onRemote(drawing) {
|
|
if (!drawing) return;
|
|
// A drawing this panel is about to show: keep the newest copy of it for when it does.
|
|
for (const t of this.switching) if (t.drawing.id === drawing.id && drawing.rev > t.drawing.rev) t.drawing = drawing;
|
|
if (drawing.id !== this.drawingId || drawing.rev <= this.rev) return;
|
|
if (this.inflight || this.editor.busy) {
|
|
if (!this.deferred || drawing.rev > this.deferred.rev) this.deferred = drawing;
|
|
return;
|
|
}
|
|
this.rebase(drawing);
|
|
}
|
|
|
|
// Replaces our base with the remote doc and re-applies unsaved local changes on top. `record`
|
|
// is false when the doc differs only because the server tidied up what we sent.
|
|
rebase(drawing, { record = true } = {}) {
|
|
const pending = diffOps(this.synced, this.editor.elements);
|
|
this.synced = new Map(drawing.elements.map((e) => [e.id, e]));
|
|
this.rev = drawing.rev;
|
|
this.deferred = null;
|
|
// No element cap here. Going over it makes the server refuse the save and say why, which is
|
|
// better than the user's newest elements quietly disappearing.
|
|
const next = pending ? applyOps(drawing.elements, pending, Infinity) : drawing.elements;
|
|
this.editor.applyRemote(next, { record });
|
|
if (pending) this.schedule();
|
|
}
|
|
|
|
// Shows another drawing. Edits on screen that are not saved yet would be lost, so when they
|
|
// cannot be saved first, the panel stays on this drawing, tells the server so, and resolves
|
|
// false, and the unsaved handler lets the user discard them. `discard` skips that check.
|
|
// A deleted drawing has nowhere left to save to, so it never holds the panel. `switchId` comes
|
|
// with a drawing the agent opened, and the server hears which way it went (see openDrawing in
|
|
// server.mjs).
|
|
async switchTo(drawing, { discard = false, switchId = null } = {}) {
|
|
// Showing the drawing that is already on screen loses nothing, so it is only an update to it.
|
|
if (drawing.id === this.drawingId) {
|
|
this.onRemote(drawing);
|
|
this.confirmSwitch(switchId);
|
|
return true;
|
|
}
|
|
// The server already treats the other drawing as shown, so while the flush below waits, it
|
|
// sends that drawing's changes, save status and selection. They are kept here until then.
|
|
const target = { drawing, selection: null, behind: false };
|
|
this.switching.add(target);
|
|
let ready;
|
|
try {
|
|
ready = discard || (await this.flush(true)) || this.gone;
|
|
} finally {
|
|
this.switching.delete(target);
|
|
}
|
|
if (!ready) {
|
|
// The server has already moved this panel to the other drawing, so move it back. Changes to
|
|
// this drawing did not come to the panel in the meantime, so it takes the copy in the reply.
|
|
this.post("drawings", { action: "open", id: this.drawingId, switchId })
|
|
.then((res) => {
|
|
if (res.drawing) this.onRemote(res.drawing);
|
|
this.drainOps();
|
|
this.sendSelection([...this.editor.selection]);
|
|
})
|
|
.catch(() => {});
|
|
this.handlers.unsaved?.(target.drawing);
|
|
return false;
|
|
}
|
|
drawing = target.drawing;
|
|
// Another switch can show this drawing first. Then this copy is only an update to it.
|
|
if (drawing.id === this.drawingId) {
|
|
this.onRemote(drawing);
|
|
this.confirmSwitch(switchId);
|
|
return true;
|
|
}
|
|
this.setDoc(drawing);
|
|
this.handlers.switched?.(drawing);
|
|
if (target.selection) this.showSelection(target.selection);
|
|
// Messages about the last drawing do not apply to this one.
|
|
this.refusal = null;
|
|
this.diskError = null;
|
|
this.setDiskError(drawing.saveError);
|
|
if (!this.diskError) this.settled();
|
|
// A change to it came while the panel waited, and one before it never did.
|
|
if (target.behind) this.resync();
|
|
this.confirmSwitch(switchId);
|
|
return true;
|
|
}
|
|
|
|
confirmSwitch(switchId) {
|
|
if (switchId) this.post("switched", { switchId }).catch(() => {});
|
|
}
|
|
|
|
// The extension has all our changes, so show "saved", unless it cannot write them to disk.
|
|
settled() {
|
|
if (this.diskError) this.handlers.status?.("error", `Not saved to disk: ${this.diskError}`);
|
|
else this.handlers.status?.("saved");
|
|
}
|
|
|
|
setDiskError(error) {
|
|
const was = this.diskError;
|
|
this.diskError = error || null;
|
|
if (this.diskError === was) return;
|
|
if (this.diskError) this.handlers.problem?.(`This drawing could not be saved to disk: ${this.diskError}. Draw will keep trying.`);
|
|
if (this.diskError || (!this.inflight && !this.dirty)) this.settled();
|
|
}
|
|
|
|
onIdle() {
|
|
// Once the handler that ended the edit or drag is done, since it may start the next one right
|
|
// away (Tab opens the next step's label). Then what waits keeps waiting.
|
|
queueMicrotask(() => this.applyDeferred());
|
|
this.schedule();
|
|
}
|
|
|
|
// Shows a selection the agent made. While the user drags or types, or while an agent change
|
|
// waits for a save to finish (it may add the elements named here), the newest one waits for
|
|
// applyDeferred.
|
|
showSelection(ids) {
|
|
this.pendingSelection = { ids, before: [...this.editor.selection] };
|
|
this.applyDeferred();
|
|
}
|
|
|
|
// Applies what the server sent while the editor could not take it: the newest remote doc, and
|
|
// then the agent's newest selection, unless the user picked something else since it came.
|
|
// Their newer choice stands then, and the server already has it. Elements that are gone do
|
|
// not count as a different choice, since removing an element also drops it from the selection.
|
|
applyDeferred() {
|
|
if (this.inflight || this.editor.busy) return;
|
|
if (this.deferred) {
|
|
const d = this.deferred;
|
|
this.deferred = null;
|
|
if (d.rev > this.rev) this.rebase(d);
|
|
}
|
|
const sel = this.pendingSelection;
|
|
if (!sel) return;
|
|
this.pendingSelection = null;
|
|
const now = this.editor.selection;
|
|
const before = sel.before.filter((id) => this.editor.byId.has(id));
|
|
if (before.length === now.size && before.every((id) => now.has(id))) this.handlers.select?.(sel.ids);
|
|
}
|
|
|
|
schedule(delay = 60) {
|
|
if (this.timer) return;
|
|
this.timer = setTimeout(() => {
|
|
this.timer = 0;
|
|
this.flush();
|
|
}, delay);
|
|
}
|
|
|
|
get dirty() {
|
|
return !!this.drawingId && !!diffOps(this.synced, this.editor.elements);
|
|
}
|
|
|
|
// Sends the edits the extension does not have yet. Resolves true once it has everything the
|
|
// editor shows, false while something is still unsaved. `force` waits for a request that is
|
|
// already out and sends even in the middle of a gesture, for callers that need the saved copy
|
|
// to match the screen.
|
|
async flush(force = false) {
|
|
if (this.inflight) {
|
|
if (!force) return false;
|
|
while (this.inflight) await sleep(30);
|
|
}
|
|
if (!this.drawingId) return true;
|
|
if (!force && this.editor.busy) return false;
|
|
const sent = this.editor.elements;
|
|
const ops = diffOps(this.synced, sent);
|
|
if (!ops) {
|
|
this.refusal = null;
|
|
this.settled();
|
|
return true;
|
|
}
|
|
this.inflight = true;
|
|
this.handlers.status?.("saving");
|
|
const drawingId = this.drawingId;
|
|
this.sending = { drawingId, ops };
|
|
let retry = false;
|
|
let refused = false;
|
|
try {
|
|
const res = await this.post("ops", { drawingId, baseRev: this.rev, seq: ++this.seq, ops });
|
|
this.failures = 0;
|
|
this.refusal = null;
|
|
if (drawingId === this.drawingId) {
|
|
// "ignored" means the server already had newer changes from this page, so these were
|
|
// not applied. They are still on screen, so rebasing sends them again.
|
|
if (!res.ignored) this.synced = new Map(sent.map((e) => [e.id, e]));
|
|
this.rev = res.rev;
|
|
this.inflight = false;
|
|
// "cleaned" means the server saved our changes in a tidier form than we sent (a size
|
|
// past the limit, say), so the screen takes that form, without an undo step of its own.
|
|
if (res.drawing) this.rebase(res.drawing, { record: !res.cleaned });
|
|
}
|
|
} catch (err) {
|
|
if (err.status === 404) {
|
|
refused = true;
|
|
this.gone = drawingId === this.drawingId;
|
|
this.handlers.status?.("error", "This drawing was deleted.");
|
|
} else if (err.status === 400 || err.status === 413) {
|
|
// Sending the same changes again would fail the same way, so the next edit tries again
|
|
// (undoing, for example, can bring the drawing back under a limit).
|
|
refused = true;
|
|
const msg = `Not saved: ${err.message}`;
|
|
this.handlers.status?.("error", msg);
|
|
if (msg !== this.refusal) this.handlers.problem?.(msg);
|
|
this.refusal = msg;
|
|
} else {
|
|
this.failures += 1;
|
|
retry = true;
|
|
this.handlers.status?.("error", this.failures <= 6 ? "Not saved yet, retrying" : "Could not save. Check that Copilot is running.");
|
|
}
|
|
} finally {
|
|
this.inflight = false;
|
|
this.sending = null;
|
|
}
|
|
// Changes that came before the reply, and waited for it.
|
|
this.drainOps();
|
|
if (retry) {
|
|
clearTimeout(this.retryTimer);
|
|
this.retryTimer = setTimeout(() => this.schedule(0), Math.min(8000, 400 * 2 ** this.failures));
|
|
return false;
|
|
}
|
|
if (drawingId !== this.drawingId) {
|
|
// The panel moved to another drawing while this was out. Edits made there since then
|
|
// could not be sent during that time, so send them now.
|
|
if (this.dirty) this.schedule();
|
|
return !this.dirty;
|
|
}
|
|
this.applyDeferred();
|
|
if (refused) return false;
|
|
if (diffOps(this.synced, this.editor.elements)) {
|
|
// Edits made while the request was out. A forced flush sends them now too.
|
|
if (force) return this.flush(true);
|
|
this.schedule();
|
|
return false;
|
|
}
|
|
this.settled();
|
|
return true;
|
|
}
|
|
|
|
// Best effort save when the iframe goes away. A change still on its way can reach the server
|
|
// before or after this one. If it comes after, the server drops it (its number is lower). If
|
|
// it comes first, this one has to undo whatever the editor no longer shows, so it also sets
|
|
// every element that change touched to what the editor shows now.
|
|
flushBeacon() {
|
|
if (!this.drawingId) return;
|
|
const elements = this.editor.elements;
|
|
const ops = diffOps(this.synced, elements) || { upserts: [], deletes: [] };
|
|
const out = this.sending?.drawingId === this.drawingId ? this.sending.ops : null;
|
|
if (out) {
|
|
const now = new Map(elements.map((e) => [e.id, e]));
|
|
const listed = new Set([...ops.upserts.map((e) => e.id), ...ops.deletes]);
|
|
for (const id of [...out.upserts.map((e) => e.id), ...out.deletes]) {
|
|
if (listed.has(id)) continue;
|
|
listed.add(id);
|
|
if (now.has(id)) ops.upserts.push(now.get(id));
|
|
else ops.deletes.push(id);
|
|
}
|
|
// Its order, or a deleted element coming back at the end, can leave the order wrong.
|
|
if (out.order || out.deletes.some((id) => now.has(id))) ops.order = elements.map((e) => e.id);
|
|
}
|
|
if (!ops.upserts.length && !ops.deletes.length && !ops.order) return;
|
|
const body = JSON.stringify({ clientId: this.clientId, drawingId: this.drawingId, seq: ++this.seq, ops });
|
|
// Only a keepalive request is sure to arrive once the page is gone, and past the keepalive limit
|
|
// the browser refuses it outright. Splitting it would not help, since the limit is for all of
|
|
// them together. Any one element fits (see MAX_PEN_POINTS), and a change is saved about 60 ms
|
|
// after it is made, so only a bulk change to a big drawing that was not saved yet (a drag of
|
|
// many shapes that is still going on, say) is bigger. That goes as an ordinary request, which
|
|
// can still reach the local server but is often cancelled along with the page.
|
|
const keepalive = new TextEncoder().encode(body).length <= KEEPALIVE_BYTES;
|
|
try {
|
|
fetch(this.url("ops"), { method: "POST", body, keepalive, headers: { "Content-Type": "text/plain;charset=utf-8" } }).catch(() => {});
|
|
} catch {
|
|
// Nothing else we can do while unloading.
|
|
}
|
|
}
|
|
|
|
sendSelection(ids) {
|
|
clearTimeout(this.selTimer);
|
|
this.selTimer = setTimeout(() => {
|
|
this.post("selection", { drawingId: this.drawingId, ids }).catch(() => {});
|
|
}, 150);
|
|
}
|
|
}
|