johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<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>