| 1 | import type { PullRequestFileViewedState, PullRequestFilesViewedResult } from "@t3tools/contracts"; |
| 2 | |
| 3 | /** What the host last said about each file, by path. Absent means the host said nothing. */ |
| 4 | export type FileViewedStates = ReadonlyMap<string, PullRequestFileViewedState>; |
| 5 | |
| 6 | /** Presses the host has not confirmed yet, by path. */ |
| 7 | export type FileViewedOverlay = ReadonlyMap<string, boolean>; |
| 8 | |
| 9 | export 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 | */ |
| 21 | function 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. */ |
| 26 | export 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. */ |
| 31 | export 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 | |
| 40 | export 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 | */ |
| 61 | export 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 | */ |
| 82 | export 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. */ |
| 96 | export function toFileViewedBatch( |
| 97 | overlay: FileViewedOverlay, |
| 98 | ): ReadonlyArray<{ readonly path: string; readonly viewed: boolean }> { |
| 99 | return [...overlay].map(([path, viewed]) => ({ path, viewed })); |
| 100 | } |