import type { FileDiffMetadata } from "@pierre/diffs";
import { streamPatchInWorker } from "#lib/components/repo/diffRpc.ts";
/**
* A patch being parsed in the worker while it downloads. Starting one does not
* wait for anything else on the page: the file list and the patch describe the
* same commit, and fetching them one after the other costs the sum of both.
*
* Only ever for a single commit: format-patch serves one patch per commit and
* files are keyed by name, so over a longer range a file several commits touch
* keeps one commit's hunks. Longer ranges take the combined diff instead.
*/
export interface PatchStream {
/** parsed files, keyed by current name and by previous name for renames */
readonly byName: Map<string, FileDiffMetadata>;
/** called with each batch as it lands, and unsubscribed on teardown */
subscribe: (listener: (files: FileDiffMetadata[]) => void) => () => void;
/** false when the patch could not be read, so a caller can fall back */
readonly done: Promise<boolean>;
readonly lines: number;
readonly subscribed: boolean;
stop: () => void;
}
// Dedupes in-flight patch streams by URL.
const live = new Map<string, PatchStream>();
const KEEP = 2;
const MAX_RETAINED_LINES = 20_000;
// a navigation leaves its patch behind; keeping the last couple means going
// back to a commit is instant, and anything older is dropped
const trim = () => {
for (;;) {
const evictable = [...live].filter(
([, stream], index, all) => index < all.length - 1 && !stream.subscribed
);
if (!evictable.length) return;
let victim: [string, PatchStream] | undefined;
if (live.size > KEEP) victim = evictable[0];
else if (
[...live.values()].reduce((sum, stream) => sum + stream.lines, 0) > MAX_RETAINED_LINES
)
victim = evictable.reduce((a, b) => (b[1].lines > a[1].lines ? b : a));
if (!victim) return;
victim[1].stop();
live.delete(victim[0]);
}
};
export const getPatchStream = (url: string): PatchStream => {
let stream = live.get(url);
if (stream) live.delete(url);
else stream = startPatchStream(url, trim);
live.set(url, stream);
trim();
return stream;
};
export const startPatchStream = (url: string, onGrow?: () => void): PatchStream => {
const byName = new Map<string, FileDiffMetadata>();
const listeners = new Set<(files: FileDiffMetadata[]) => void>();
const controller = new AbortController();
let stopped = false;
let lines = 0;
const land = (files: FileDiffMetadata[]) => {
if (stopped) return;
for (const file of files) {
lines += file.unifiedLineCount;
byName.set(file.name, file);
if (file.prevName) byName.set(file.prevName, file);
}
for (const listener of listeners) listener(files);
onGrow?.();
};
const stream: PatchStream = {
byName,
get lines() {
return lines;
},
get subscribed() {
return listeners.size > 0;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
// resolved either way: a caller that never asks cannot leave a rejection
// unhandled, and one that does gets an answer instead of an exception
done: streamPatchInWorker(url, land, controller.signal).then(
() => true,
() => false
),
stop: () => {
stopped = true;
listeners.clear();
controller.abort();
}
};
return stream;
};