import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { DiffView, type DiffViewFile } from "@ace/ui";
import type { Change, Changes } from "@ace/channel/protocol";
import { host } from "../host";
type Props = { channel: string; chat: number; active: boolean };
/** A loaded patch or its failure, for the version of the file described by `stamp`. */
type Loaded = { stamp: string; patch?: string; signature?: string; error?: string };
const POLL = 2000;
const LIMIT = 4;
function stamp(change: Change, changes: Changes) {
return `${changes.cwd}\0${changes.base}\0${
change.from || ""
}\0${change.version}\0${change.adds},${change.dels},${change.binary}`;
}
function same(a: Changes | undefined, b: Changes) {
if (!a || a.cwd !== b.cwd || a.lane !== b.lane || a.base !== b.base || a.head !== b.head) {
return false;
}
if (a.files.length !== b.files.length) return false;
return a.files.every((change, i) =>
change.file === b.files[i]!.file && stamp(change, a) === stamp(b.files[i]!, b)
);
}
function signature(source: string) {
let hash = 0x811c9dc5;
for (let i = 0; i < source.length; i++) {
hash ^= source.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return `${source.length}:${(hash >>> 0).toString(36)}`;
}
function short(ref: string) {
return /^[0-9a-f]{40}$/.test(ref) ? ref.slice(0, 7) : ref;
}
function subscribe(listener: () => void) {
window.addEventListener("focus", listener);
window.addEventListener("blur", listener);
return () => {
window.removeEventListener("focus", listener);
window.removeEventListener("blur", listener);
};
}
function Note({ children }: { children: string }) {
return (
);
}
/** The chat's changes, polled while the tab is visible and the window focused. */
export function Diff({ channel, chat, active }: Props) {
const status = useSyncExternalStore(host.subscribe, () => host.status);
const focused = useSyncExternalStore(subscribe, () => document.hasFocus());
const [changes, setChanges] = useState();
const [error, setError] = useState();
const [loaded, setLoaded] = useState(new Map());
const [first, setFirst] = useState();
const inflight = useRef(new Set());
useEffect(() => {
if (!active || !focused || status !== "open") return;
let live = true;
let timer: ReturnType;
const poll = () =>
host.channel(channel, { op: "changes", chat }).then(
(next) => {
if (!live) return;
setChanges((current) => same(current, next) ? current : next);
setError(undefined);
},
(error: Error) => {
if (live) setError(error.message);
},
).finally(() => {
if (live) timer = setTimeout(poll, POLL);
});
void poll();
return () => {
live = false;
clearTimeout(timer);
};
}, [channel, chat, active, focused, status]);
useEffect(() => {
if (!changes || status !== "open") return;
const wanted = changes.files.filter((change) =>
!change.binary && loaded.get(change.file)?.stamp !== stamp(change, changes)
);
const index = wanted.findIndex((change) => change.file === first);
if (index > 0) wanted.unshift(...wanted.splice(index, 1));
for (const change of wanted) {
if (inflight.current.size >= LIMIT) return;
const version = stamp(change, changes);
const key = `${change.file}\0${version}`;
if (inflight.current.has(key)) continue;
inflight.current.add(key);
host.channel(channel, { op: "patch", chat, file: change.file }).then(
(patch) => ({ stamp: version, patch, signature: signature(patch) }),
(error: Error) =>
// A dropped connection isn't the file's failure; it reloads once reconnected.
host.status === "open" ? { stamp: version, error: error.message } : undefined,
).then((value) => {
inflight.current.delete(key);
setLoaded((current) => {
const next = new Map(current);
if (value) next.set(change.file, value);
return next;
});
});
}
}, [channel, chat, changes, loaded, first, status]);
const files = useMemo(
() => {
if (!changes) return [];
return changes.files.map((change): DiffViewFile => {
const value = loaded.get(change.file);
const current = value?.stamp === stamp(change, changes) ? value : undefined;
return {
file: change.file,
from: change.from,
binary: change.binary,
adds: change.adds,
dels: change.dels,
patch: current?.patch,
signature: current?.signature,
error: current?.error,
pending: !change.binary && !current,
};
});
},
[changes, loaded],
);
function open(file: string) {
setFirst(file);
// Reopening a file that failed retries it.
if (!loaded.get(file)?.error) return;
setLoaded((current) => {
const next = new Map(current);
next.delete(file);
return next;
});
}
if (!changes) return {error || "Loading changes…"};
return (
);
}