~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
237 lines · 8.9 KB Raw History
<script lang="ts" module>
	/** A line of a file in a pull's diff, on the side it leaves (`old`) or brings in (`new`). */
	export type LineAt = { path: string; side: 'old' | 'new'; line: number };
	/**
	 * What a diff shows between its lines and lets be done with them: `has`
	 * a line something to show under it, drawn by `below`; `add`, given,
	 * starts a comment on a line, `more` for a range on from one picked
	 * already; `picked`, whether a line is in the range being commented on.
	 */
	export type Notes = {
		has: (at: LineAt) => boolean;
		below: Snippet<[LineAt]>;
		add?: (at: LineAt, more: boolean) => void;
		picked?: (at: LineAt) => boolean;
	};
</script>

<script lang="ts">
	import type { DiffRowView, FileDiffView } from '#lib/lexicons/dev/knotless/defs.ts';
	import type * as getFile from '#lib/lexicons/dev/knotless/getFile.ts';
	import type { Snippet } from 'svelte';
	import { STEP, expand, gaps, whole, type Gap, type Open, type Read } from '#lib/context.ts';
	import { ask as query } from '#lib/data.ts';
	import type { Source } from '#lib/diffs.ts';
	import { highlight } from '#lib/highlight.ts';
	import Bars from '#lib/atoms/Bars.svelte';
	import Icon from '#lib/atoms/Icon.svelte';
	import ActionMenu from '#lib/molecules/ActionMenu.svelte';
	/**
	 * One file of a diff: its header, then a row per line with the line's old
	 * and new numbers, and under any line what `notes` has for it. Rows still
	 * to come show as `waiting` says: being fetched, held back until `ask`ed
	 * for, or failed with the reason why. `near`, highlighted only once it
	 * nears the screen, for a file drawn where `content-visibility` skips it.
	 * Given `source`, its header links to the file there, and the lines the
	 * diff leaves out of a file changed in place are put back as asked for,
	 * a step at a time or all at once, read from it.
	 */
	let {
		file: f,
		waiting,
		ask,
		notes,
		near = false,
		source
	}: {
		file: FileDiffView;
		waiting?: { loading: true } | { held: true } | { failed: string };
		ask?: () => void;
		notes?: Notes;
		near?: boolean;
		source?: Source;
	} = $props();
	const path = $derived(
		f.status === 'renamed' ? `${f.old} → ${f.new}` : f.status === 'deleted' ? f.old : f.new
	);
	const tags: Record<string, string> = { added: 'new', deleted: 'deleted', renamed: 'renamed' };
	const tag = $derived(tags[f.status]);
	/** Where a row's line is, as a comment names it: the side a pull brings in, but for a line it removes. */
	const at = (row: DiffRowView): LineAt | undefined =>
		row.new != null
			? { path: f.new, side: 'new', line: row.new }
			: row.old != null
				? { path: f.old, side: 'old', line: row.old }
				: undefined;
	const actions = $derived(
		source && f.status !== 'deleted'
			? [{ label: 'View file', href: source.view(f.new), icon: 'eye' as const }]
			: []
	);

	/** The lines left out: only a file changed in place has any, an added or deleted one being all there. */
	const all = $derived(
		source && (f.status === 'modified' || f.status === 'renamed') ? gaps(f.rows) : []
	);
	/**
	 * What the reader asked to see of the lines left out, by gap, and the
	 * file they are read from, kept for the file at `of` alone: a page that
	 * moves to another commit draws another file here.
	 */
	type More = {
		of: string;
		open: Map<number, Open>;
		file?: Read;
		reading?: boolean;
		failed?: string;
	};
	const of = $derived(`${source?.rev}\n${f.old}\n${f.new}`);
	let kept = $state.raw<More>({ of: '', open: new Map() });
	const more = $derived<More>(kept.of === of ? kept : { of, open: new Map() });
	const drawn = $derived(expand(f.rows, f.ranges, all, more.file ?? null, more.open));
	/** Whether nothing is left out, once the file is read and shows it. */
	const none = $derived(!all.length || (!!more.file && whole(all, more.file, new Map())));
	const everything = $derived(whole(all, more.file ?? null, more.open));

	/** Shows what `open` asks for, reading the file first if it is not yet. */
	function show(open: Map<number, Open>) {
		kept = { ...more, open, failed: undefined };
		if (open.size && !kept.file && !kept.reading) read();
	}
	async function read() {
		const mine = (kept = { ...kept, reading: true });
		const got = await query<typeof getFile.main>('dev.knotless.getFile', {
			repo: source!.repo,
			ref: source!.rev,
			path: f.new
		}).catch((e: Error) => e);
		if (kept.of !== mine.of) return;
		if (got instanceof Error || got.text === undefined) {
			const failed = got instanceof Error ? got.message : 'Too large to show more of here.';
			kept = { of: mine.of, open: new Map(), failed };
			return;
		}
		const lines = got.text.split('\n');
		if (got.text.endsWith('\n')) lines.pop();
		kept = { ...kept, reading: false, file: { lines, ranges: got.ranges } };
	}
	/** Shows a step more of gap `g`: from its start down, from its end up, or all of it. */
	function step(g: Gap, how: 'down' | 'up' | 'all') {
		const o = more.open.get(g.at) ?? { top: 0, bottom: 0 };
		const next =
			how === 'down'
				? { ...o, top: o.top + STEP }
				: how === 'up'
					? { ...o, bottom: o.bottom + STEP }
					: { top: Infinity, bottom: 0 };
		show(new Map(more.open).set(g.at, next));
	}
	function toggleAll() {
		show(everything ? new Map() : new Map(all.map((g) => [g.at, { top: Infinity, bottom: 0 }])));
	}
</script>

{#snippet expander(how: 'down' | 'up' | 'all', g: Gap)}
	{@const label = how === 'all' ? 'Expand all' : how === 'down' ? 'Expand down' : 'Expand up'}
	<button
		type="button"
		aria-label={label}
		title={label}
		aria-busy={more.reading}
		disabled={more.reading}
		onclick={() => step(g, how)}
		><Icon name={how === 'all' ? 'unfold' : how === 'down' ? 'foldDown' : 'foldUp'} /></button
	>
{/snippet}

<details class="file" id="file-{f.status === 'deleted' ? f.old : f.new}" open>
	<summary
		><span class="path">{path}</span>{#if tag}<span class="tag">{tag}</span>{/if}<span class="stat"
			><span class="add">+{f.added}</span><span class="rem">−{f.removed}</span><Bars
				added={f.added}
				removed={f.removed}
			/></span
		>{#if (!none && !f.note && !waiting) || actions.length}<span class="file-tools"
				>{#if !none && !f.note && !waiting}<button
						class="file-tool"
						type="button"
						aria-label={everything ? 'Collapse expanded lines' : 'Expand all lines'}
						title={everything ? 'Collapse expanded lines' : 'Expand all lines'}
						aria-busy={more.reading}
						disabled={more.reading}
						onclick={toggleAll}><Icon name={everything ? 'fold' : 'unfold'} /></button
					>{/if}<ActionMenu {actions} label="More for this file" /></span
			>{/if}</summary
	>
	{#if f.note}
		<div class="note">{f.note}</div>
	{:else if waiting && 'held' in waiting}
		<div class="note held">
			{`${f.added + f.removed} lines changed: not loaded until asked for.`}<button
				class="btn"
				type="button"
				onclick={ask}>Load diff</button
			>
		</div>
	{:else if waiting && 'failed' in waiting}
		<div class="note held">
			{waiting.failed}<button class="btn" type="button" onclick={ask}>Try again</button>
		</div>
	{:else if waiting}
		<div
			class="skeleton"
			class:sized={f.rowCount !== undefined}
			style:--rows={f.rowCount}
			style:--hunks={f.hunkCount ?? 0}
			role="status"
			aria-label="Loading the diff of {path}"
		>
			<i></i><i></i><i></i>
		</div>
	{:else}
		{#if more.failed}<div class="note">{more.failed}</div>{/if}
		<div class="scroll">
			<table class="diff" use:highlight={{ ranges: drawn.ranges, text: drawn.shown, near }}>
				<tbody>
					{#each drawn.shown as s (s.key)}
						{@const row = s.row}
						{@const line = row.line && !s.back ? at(row) : undefined}
						{#if row.line}
							<tr class={row.kind} class:sel={!!line && !!notes?.picked?.(line)}>
								<td class="n" data-n={row.old ?? ''}></td><td class="n" data-n={row.new ?? ''}
									>{#if line && notes?.add}{@const to = line}<button
											class="add-line"
											type="button"
											aria-label="Comment on line {line.line}"
											title="Comment on this line; shift-click to take in the lines from one picked"
											onclick={(e) => notes?.add?.(to, e.shiftKey)}><Icon name="plus" /></button
										>{/if}</td
								>
								<td class="c"><code>{row.text}</code></td>
							</tr>
							{#if line && notes?.has(line)}<tr class="t"
									><td colspan="3"><div class="t-in">{@render notes.below(line)}</div></td></tr
								>{/if}
						{:else if s.gap}
							{@const g = s.gap}
							{@const few = s.left != null && s.left <= STEP}
							<tr class="h"
								><td class="n unfold" colspan="2"
									>{#if g.to === null}{@render expander('down', g)}{:else if few}{@render expander(
											'all',
											g
										)}{:else}{#if g.from > 1}{@render expander('down', g)}{/if}{@render expander(
											'up',
											g
										)}{/if}</td
								><td class="c">{row.text}</td></tr
							>
						{:else}
							<tr class={row.kind}><td class="n" colspan="2"></td><td class="c">{row.text}</td></tr>
						{/if}
					{/each}
				</tbody>
			</table>
		</div>
	{/if}
</details>