johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// The lines of a file a diff leaves out, before, between and after its
// hunks, and its rows with some of them put back as a reader asks for
// more, read from the file as it is after the change (dev.knotless.getFile),
// highlighted with the file's ranges where the diff's do not reach.
import type { DiffRowView } from '#lib/lexicons/dev/knotless/defs.ts';
/** How many lines one step shows of what a diff leaves out. */
export const STEP = 20;
/**
* The lines left out before the hunk whose header is row `at` of a diff,
* or after its last hunk, `at` then the number of rows: from `from` to
* `to` by their new numbers, `to` null after the last hunk until the
* file is read. `shift` turns a new number into the old.
*/
export type Gap = { at: number; from: number; to: number | null; shift: number };
/** How much of a gap is shown: `top` lines from its start, `bottom` from its end. */
export type Open = { top: number; bottom: number };
/** A file as it is after the change: its lines, and the ranges its highlighting paints. */
export type Read = { lines: string[]; ranges?: string };
/**
* A row as drawn: one of the diff's, a line put `back`, or, with `gap`,
* the place of the lines still left out there, `left` of them, or null
* after the last hunk while the file is unread. The diff's hunk header
* shows that place; after the last hunk, a row of its own.
*/
export type Shown = {
key: string;
row: DiffRowView;
back?: true;
gap?: Gap;
left?: number | null;
};
const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
/** The gaps between the hunks of `rows`, or none for rows with a header unread. */
export function gaps(rows: DiffRowView[]): Gap[] {
const out: Gap[] = [];
let next = 1;
let shift = 0;
for (let at = 0; at < rows.length; at++) {
if (rows[at].kind !== 'h') continue;
const m = HEADER.exec(rows[at].text);
if (!m) return [];
const [a, b, c, d] = [m[1], m[2] ?? '1', m[3], m[4] ?? '1'].map(Number);
// A side with no lines names the line before where it would be.
const old = b ? a : a + 1;
const start = d ? c : c + 1;
if (start > next) out.push({ at, from: next, to: start - 1, shift: old - start });
next = start + d;
shift = old + b - next;
}
if (rows.some((r) => r.kind === 'h')) out.push({ at: rows.length, from: next, to: null, shift });
return out;
}
/** Whether everything left out is shown, as far as can be told with `file` read or not. */
export function whole(all: Gap[], file: Read | null, open: Map<number, Open>): boolean {
return all.every((g) => {
const to = g.to ?? file?.lines.length;
if (to === undefined) return false;
const o = open.get(g.at);
return to < g.from || (!!o && o.top + o.bottom > to - g.from);
});
}
/** The ranges `ranges` encodes, by name, each `[start, end]` from the start of the text. */
function decode(ranges: string | undefined): Map<string, [number, number][]> {
const out = new Map<string, [number, number][]>();
for (const group of (ranges ?? '').split(';')) {
if (!group) continue;
const [name, list] = group.split(':');
const x = list.split(',');
const got = out.get(name) ?? [];
const before = got.length;
let o = 0;
for (let i = 0; i + 1 < x.length; i += 2) {
o += +x[i];
got.push([o, o + +x[i + 1]]);
o += +x[i + 1];
}
if (before) got.sort((a, b) => a[0] - b[0]);
out.set(name, got);
}
return out;
}
/** Each of `by`'s ranges, encoded as the pages read them. */
function encode(by: Map<string, [number, number][]>): string {
const groups: string[] = [];
for (const [name, list] of by) {
if (!list.length) continue;
const parts: number[] = [];
let end = 0;
for (const [s, e] of list) {
parts.push(s - end, e - s);
end = e;
}
groups.push(`${name}:${parts.join(',')}`);
}
return groups.join(';');
}
/** A run of text from the diff's (`d`) or the file's (`f`), `from` to `to` there, drawn from `at`. */
type Run = { src: 'd' | 'f'; from: number; to: number; at: number };
/** The ranges `runs` take with them from the diff's and the file's, where they are drawn. */
function carried(runs: Run[], diff: string | undefined, file: string | undefined): string {
const from = { d: decode(diff), f: decode(file) };
const names = new Set([...from.d.keys(), ...from.f.keys()]);
const out = new Map<string, [number, number][]>();
for (const name of names) {
const got: [number, number][] = [];
for (const run of runs) {
const list = from[run.src].get(name);
if (!list) continue;
// The first range ending past the run's start.
let lo = 0;
let hi = list.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (list[mid][1] <= run.from) lo = mid + 1;
else hi = mid;
}
for (let i = lo; i < list.length && list[i][0] < run.to; i++) {
const s = Math.max(list[i][0], run.from) - run.from + run.at;
const e = Math.min(list[i][1], run.to) - run.from + run.at;
if (e > s) got.push([s, e]);
}
}
out.set(name, got);
}
return encode(out);
}
/**
* `rows` as drawn with the lines `open` asks for put back from `file`,
* once read, and the ranges that paint them: the diff's `ranges` over its
* own lines, the file's over those put back.
*/
export function expand(
rows: DiffRowView[],
ranges: string | undefined,
all: Gap[],
file: Read | null,
open: Map<number, Open>
): { shown: Shown[]; ranges: string | undefined } {
const shown: Shown[] = [];
const runs: Run[] = [];
let at = 0;
/** Adds a line of text from `src`, `from` there, to what is drawn. */
const take = (src: Run['src'], from: number, length: number) => {
const run = runs.at(-1);
if (run && run.src === src && run.to + 1 === from) run.to = from + length;
else runs.push({ src, from, to: from + length, at });
at += length + 1;
};
// Where each of the file's lines starts in its text.
const starts: number[] = [];
if (file)
for (let i = 0, o = 0; i < file.lines.length; o += file.lines[i].length + 1, i++)
starts.push(o);
const back = (g: Gap, n: number) => {
const text = file!.lines[n - 1];
shown.push({
key: `b${n}`,
row: { kind: '', line: true, old: n + g.shift, new: n, text },
back: true
});
take('f', starts[n - 1], text.length);
};
const end: DiffRowView = { kind: 'h', line: false, text: '' };
/** The gap before row `i`, its lines put back as asked, around its place while any are left. */
const gapAt = (i: number, header?: DiffRowView) => {
const g = all.find((g) => g.at === i);
if (!g) {
if (header) shown.push({ key: `r${i}`, row: header });
return;
}
if (!file) {
const left = g.to === null ? null : g.to - g.from + 1;
shown.push({ key: `r${i}`, row: header ?? end, gap: g, left });
return;
}
const to = Math.min(g.to ?? Infinity, file.lines.length);
const size = Math.max(0, to - g.from + 1);
const o = open.get(i) ?? { top: 0, bottom: 0 };
const top = Math.min(o.top, size);
const bottom = Math.min(o.bottom, size - top);
for (let n = g.from; n < g.from + top; n++) back(g, n);
if (size > top + bottom)
shown.push({ key: `r${i}`, row: header ?? end, gap: g, left: size - top - bottom });
for (let n = g.from + size - bottom; n < g.from + size; n++) back(g, n);
};
let d = 0;
rows.forEach((row, i) => {
if (row.kind === 'h') return gapAt(i, row);
shown.push({ key: `r${i}`, row });
if (!row.line) return;
take('d', d, row.text.length);
d += row.text.length + 1;
});
gapAt(rows.length);
if (!file || !runs.some((r) => r.src === 'f')) return { shown, ranges };
return { shown, ranges: carried(runs, ranges, file.ranges) };
}