1import type { PullRequestFileViewedState, PullRequestFilesViewedResult } from "@t3tools/contracts";
2
3/** What the host last said about each file, by path. Absent means the host said nothing. */
4export type FileViewedStates = ReadonlyMap<string, PullRequestFileViewedState>;
5
6/** Presses the host has not confirmed yet, by path. */
7export type FileViewedOverlay = ReadonlyMap<string, boolean>;
8
9export function toFileViewedStates(
10 result: PullRequestFilesViewedResult | null,
11): FileViewedStates | null {
12 if (result === null) return null;
13 return new Map(result.files.map((file) => [file.path, file.state]));
14}
15
16/**
17 * Whether a file counts as seen. `dismissed` is the host saying it has been pushed to since the
18 * reader cleared it, which reads as unseen: the point of the tick is that the code behind it has
19 * been looked at, and it is not the same code any more.
20 */
21function isViewedState(state: PullRequestFileViewedState | undefined): boolean {
22 return state === "viewed";
23}
24
25/** Whether the file was cleared and has since moved, which the header says out loud. */
26export function isStaleViewedState(state: PullRequestFileViewedState | undefined): boolean {
27 return state === "dismissed";
28}
29
30/** The press the reader made if it has not landed, and the host's answer otherwise. */
31export function isFileViewed(
32 path: string,
33 states: FileViewedStates | null,
34 overlay: FileViewedOverlay,
35): boolean {
36 const pressed = overlay.get(path);
37 return pressed ?? isViewedState(states?.get(path));
38}
39
40export function countViewedFiles(
41 paths: ReadonlyArray<string>,
42 states: FileViewedStates | null,
43 overlay: FileViewedOverlay,
44): number {
45 return paths.reduce(
46 (total, path) => (isFileViewed(path, states, overlay) ? total + 1 : total),
47 0,
48 );
49}
50
51/**
52 * The overlay with everything the host has caught up on removed. A press is held until a read that
53 * could have seen it comes back, rather than cleared when the write succeeds: that read is a
54 * separate round trip, and dropping the press in between flashes the checkbox back.
55 *
56 * `pending` are the paths whose press the host cannot have heard yet, which an answer already on
57 * its way must not overrule. `answered` are the paths a read has landed for since their write was
58 * acknowledged, and those go on that read alone, including where it contradicts the press: a tick
59 * held over a `dismissed` would hide a file pushed to since, with no refresh able to recover it.
60 */
61export function settleFileViewedOverlay(
62 overlay: FileViewedOverlay,
63 states: FileViewedStates | null,
64 pending: ReadonlySet<string>,
65 answered: ReadonlySet<string>,
66): FileViewedOverlay {
67 if (states === null || overlay.size === 0) return overlay;
68 const next = new Map(overlay);
69 for (const [path, pressed] of overlay) {
70 if (pending.has(path)) continue;
71 if (answered.has(path) || isViewedState(states.get(path)) === pressed) next.delete(path);
72 }
73 return next.size === overlay.size ? overlay : next;
74}
75
76/**
77 * The overlay with a failed request's presses taken back. `owned` are the paths that request still
78 * answers for, which keeps a failure from reaching past its own presses: a path pressed again
79 * since belongs to a later request or to the next flush, and putting that box back to the host's
80 * answer would take a press out from under the reader's hand.
81 */
82export function revertFileViewedOverlay(
83 overlay: FileViewedOverlay,
84 batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>,
85 owned: ReadonlySet<string>,
86): FileViewedOverlay {
87 const next = new Map(overlay);
88 for (const { path, viewed } of batch) {
89 if (!owned.has(path)) continue;
90 if (next.get(path) === viewed) next.delete(path);
91 }
92 return next.size === overlay.size ? overlay : next;
93}
94
95/** The presses in an overlay as the batch the host is told about. */
96export function toFileViewedBatch(
97 overlay: FileViewedOverlay,
98): ReadonlyArray<{ readonly path: string; readonly viewed: boolean }> {
99 return [...overlay].map(([path, viewed]) => ({ path, viewed }));
100}