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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A diff shows more of a file as asked, and links to the file

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/diff-expand
docs/design/serve.md+15−0
@@ -1612,6 +1612,21 @@ browser fetches the rest from `getCommit` with `paths`, ten at a time, as
each nears the screen. A commit that touches hundreds of files is otherwise
an answer of megabytes, read, highlighted and drawn before any of it shows.
A diff shows each hunk with three lines around it; the rest of a file
changed in place is left out. A pull's, a commit's and a compare's files can
show more: the arrows in a hunk's header put back 20 lines at a time from
the hunk above down, or from the hunk below up, or all of a gap of 20 or
fewer; one after the last hunk reads on to the end, and the button in a
file's header shows the whole file, or folds it back. The first ask reads
the file once, with `getFile`, at the commit the diff's new side is at: an
open pull's head, a merged pull's merge, the commit itself. The lines put
back are the file's, numbered on the old side by the hunk they sit by, and
painted with the file's highlighting where the diff's does not reach. A
gap shown whole joins the hunks either side of it. The lines put back
take no comments, as they are no part of the diff. A file's "…" menu has
View file, its page in the tree: an open pull's on its branch, a merged
pull's and a commit's at that commit; a deleted file has none.
An open pull's changes compare the base branch in the target's mirror with the
head branch in the source repository's mirror, read together. A pull's checks
show only at its current head, because checks of an older head would read as
web/src/lib/molecules/ActionMenu.svelte+27−12
@@ -1,6 +1,14 @@
<script lang="ts" module>
/** One thing a menu does: `run` it, or open the popover `opens`, or both. */
export type Action = { label: string; run?: () => void; opens?: string; danger?: boolean };
import type { IconName } from '#lib/icons.ts';
/** One thing a menu does: go to `href`, or `run` it, or open the popover `opens`, or both; marked by `icon`, when given. */
export type Action = {
label: string;
href?: string;
run?: () => void;
opens?: string;
icon?: IconName;
danger?: boolean;
};
</script>
<script lang="ts">
@@ -15,16 +23,23 @@
<summary class="bare" aria-label={label} title={label}><Icon name="kebab" /></summary>
<div class="menu">
{#each actions as a (a.label)}
<button
class="menu-item"
class:danger={a.danger}
type="button"
popovertarget={a.opens}
onclick={() => {
open = false;
a.run?.();
}}>{a.label}</button
>
{#if a.href}
<a class="menu-item" class:danger={a.danger} href={a.href} onclick={() => (open = false)}
>{#if a.icon}<Icon name={a.icon} />{/if}{a.label}</a
>
{:else}
<button
class="menu-item"
class:danger={a.danger}
type="button"
popovertarget={a.opens}
onclick={() => {
open = false;
a.run?.();
}}
>{#if a.icon}<Icon name={a.icon} />{/if}{a.label}</button
>
{/if}
{/each}
</div>
</details>
web/src/lib/organisms/Diff.svelte+6−3
@@ -1,5 +1,6 @@
<script lang="ts">
import type { FileDiffView } from '#lib/lexicons/dev/knotless/defs.ts';
import type { Source } from '#lib/diffs.ts';
import { plural } from '#lib/format.ts';
import { LaterRows, type More } from '#lib/later.svelte.ts';
import DiffFile from '#lib/organisms/DiffFile.svelte';
@@ -7,8 +8,10 @@
* What a commit or a pull changes: how much, then each file. Given
* `more`, files that came without their rows have them fetched as each
* nears the screen, but for one so large it waits to be asked for.
* Given `source`, where the files are after it, each links there and
* shows more of itself as asked.
*/
let { files, more }: { files: FileDiffView[]; more?: More } = $props();
let { files, more, source }: { files: FileDiffView[]; more?: More; source?: Source } = $props();
const added = $derived(files.reduce((n, f) => n + f.added, 0));
const removed = $derived(files.reduce((n, f) => n + f.removed, 0));
const first = $derived(
@@ -30,9 +33,9 @@
{#if more}
{#each files as f, i (i)}
<div use:seen={i}>
<DiffFile file={rowsOf(i) ?? f} waiting={waiting(i)} ask={() => askFor(i)} />
<DiffFile file={rowsOf(i) ?? f} waiting={waiting(i)} ask={() => askFor(i)} {source} />
</div>
{/each}
{:else}
{#each files as f, i (i)}<DiffFile file={f} />{/each}
{#each files as f, i (i)}<DiffFile file={f} {source} />{/each}
{/if}
web/src/lib/organisms/DiffFile.svelte+120−5
@@ -17,29 +17,39 @@
<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
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
@@ -53,8 +63,87 @@
: 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"
@@ -62,7 +151,17 @@
added={f.added}
removed={f.removed}
/></span
></summary
>{#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>
@@ -90,11 +189,13 @@
<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: f.ranges, text: f.rows, near }}>
<table class="diff" use:highlight={{ ranges: drawn.ranges, text: drawn.shown, near }}>
<tbody>
{#each f.rows as row, i (i)}
{@const line = row.line ? at(row) : undefined}
{#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 ?? ''}
@@ -111,6 +212,20 @@
{#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}
web/src/lib/organisms/PullDiff.svelte+15−4
@@ -4,7 +4,7 @@
import type { AtUriString } from '@atproto/lex';
import { untrack } from 'svelte';
import { ask } from '#lib/data.ts';
import { TREE, matched } from '#lib/diffs.ts';
import { TREE, matched, type Source } from '#lib/diffs.ts';
import { LaterRows } from '#lib/later.svelte.ts';
import Icon from '#lib/atoms/Icon.svelte';
import DiffFile, { type Notes } from '#lib/organisms/DiffFile.svelte';
@@ -16,7 +16,9 @@
* so large it waits to be asked for. The tree shows as `tree` says,
* as the viewer last chose, or where it is null by the screen's width:
* beside the diffs, and not on a phone. `notes` go between their lines,
* and the tree counts each file's `comments`.
* and the tree counts each file's `comments`. Given `source`, where
* the files are after it, each links there and shows more of itself as
* asked.
*/
let {
files,
@@ -25,7 +27,8 @@
uri,
tree,
notes,
comments
comments,
source
}: {
files: FileDiffView[];
shown: FileDiffView[];
@@ -34,6 +37,7 @@
tree: boolean | null;
notes?: Notes;
comments?: (f: FileDiffView) => number;
source?: Source;
} = $props();
/** The viewer's choice here, over what the page came with. */
let chosen = $state<boolean | null>(null);
@@ -111,7 +115,14 @@
<div class="pull-files-diffs" bind:this={diffs}>
{#each files as f, i (i)}
<div id="diff-{i}" class="anchor" use:seen={i}>
<DiffFile file={rowsOf(i) ?? f} waiting={waiting(i)} ask={() => askFor(i)} {notes} near />
<DiffFile
file={rowsOf(i) ?? f}
waiting={waiting(i)}
ask={() => askFor(i)}
{notes}
{source}
near
/>
</div>
{/each}
</div>
web/src/lib/context.tsnew+211−0
@@ -0,0 +1,211 @@
// 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) };
}
web/src/lib/diffs.ts+17−1
@@ -5,7 +5,9 @@
// by dev.knotless.getCommit's `firstRows` and then its `paths`
// (later.svelte.ts).
import type { FileDiffView } from '#lib/lexicons/dev/knotless/defs.ts';
import type { FileDiffView, RepoViewBasic } from '#lib/lexicons/dev/knotless/defs.ts';
import type { AtUriString } from '@atproto/lex';
import { repoHref, urlPath } from '#lib/format.ts';
/** The most lines a file changes before its rows wait to be asked for. */
export const HELD = 400;
@@ -16,6 +18,20 @@ export const BATCH = 10;
/** The cookie that keeps whether the viewer last showed a pull's file tree. */
export const TREE = 'tree';
/**
* Where a diff's files are after the change: in the repository `repo` at
* the commit `rev`, which the lines a diff leaves out are read from, and
* on the page `view` gives for a file's path.
*/
export type Source = { repo: AtUriString; rev: string; view: (path: string) => string };
/** The files of `on` at the commit `rev`, each shown on its page at `ref`: a branch, or the commit itself. */
export const sourceAt = (on: RepoViewBasic, rev: string, ref = rev): Source => ({
repo: `at://${on.did}/dev.knotless.repo/${on.name}`,
rev,
view: (path) => repoHref(on.handle, on.name, `/blob/${urlPath(ref)}/${urlPath(path)}`)
});
/** Whether the cookie `TREE` says to show the tree, or null where it says nothing and the screen's width decides. */
export const treeShown = (said: string | undefined): boolean | null =>
said === 'shown' ? true : said === 'hidden' ? false : null;
web/src/lib/icons.ts+14−2
@@ -69,6 +69,10 @@ import versions from '@primer/octicons/build/svg/versions-16.svg?raw';
import undo from '@primer/octicons/build/svg/undo-16.svg?raw';
import history from '@primer/octicons/build/svg/history-16.svg?raw';
import download from '@primer/octicons/build/svg/download-16.svg?raw';
import fold from '@primer/octicons/build/svg/fold-16.svg?raw';
import foldDown from '@primer/octicons/build/svg/fold-down-16.svg?raw';
import foldUp from '@primer/octicons/build/svg/fold-up-16.svg?raw';
import unfold from '@primer/octicons/build/svg/unfold-16.svg?raw';
/** What is drawn inside an icon's `<svg>`. */
const inside = (svg: string) => svg.replace(/^<svg[^>]*>|<\/svg>\s*$/g, '');
@@ -143,7 +147,11 @@ export const icons = Object.fromEntries(
moon,
download,
stack,
bot
bot,
fold,
foldDown,
foldUp,
unfold
}).map(([k, v]) => [k, inside(v)])
) as Record<
| 'code'
@@ -214,7 +222,11 @@ export const icons = Object.fromEntries(
| 'sun'
| 'moon'
| 'download'
| 'stack',
| 'stack'
| 'fold'
| 'foldDown'
| 'foldUp'
| 'unfold',
string
>;
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/files/+page.svelte+10−0
@@ -12,6 +12,7 @@
import { call } from '#lib/data.ts';
import { stateIcon } from '#lib/icons.ts';
import { written } from '#lib/write.ts';
import { sourceAt } from '#lib/diffs.ts';
import { capitalized, itemHref, mergeSaid, repoHref } from '#lib/format.ts';
import Icon from '#lib/atoms/Icon.svelte';
import Ref from '#lib/atoms/Ref.svelte';
@@ -36,6 +37,14 @@
const files = $derived(ch?.files ?? []);
const sum = (k: 'added' | 'removed') => files.reduce((total, f) => total + f[k], 0);
const from = $derived(t.head?.repo ? `${t.head.repo.handle}/${t.head.repo.name}` : '');
/** Where its files are after it: an open pull's at its branch's head, shown on that branch; a merged one's at its merge. */
const source = $derived(
ch?.head && t.head
? sourceAt(ch.repo, ch.head, t.head.branch)
: ch && t.merged
? sourceAt(ch.repo, t.merged)
: undefined
);
// Who is looking, and whether they may comment on its lines: anyone
// logged in, while it is open.
@@ -258,6 +267,7 @@
repo={r.uri}
uri={d.uri}
tree={data.tree}
{source}
{notes}
{comments}
/>{/key}
web/src/routes/[authority]/[repo]/commit/[rev]/+page.svelte+8−2
@@ -1,7 +1,7 @@
<script lang="ts">
import type * as getCommit from '#lib/lexicons/dev/knotless/getCommit.ts';
import { ask } from '#lib/data.ts';
import { itemHref, repoHref, subject } from '#lib/format.ts';
import { itemHref, repoHref, subject, urlPath } from '#lib/format.ts';
import Committed from '#lib/molecules/Committed.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import Sha from '#lib/atoms/Sha.svelte';
@@ -21,6 +21,12 @@
sha: c.sha,
paths
}).then((a) => a.files);
/** Where its files are after it: at the commit, each on its page there. */
const source = $derived({
repo: r.uri,
rev: c.sha,
view: (path: string) => repoHref(data.authority, r.name, `/blob/${c.sha}/${urlPath(path)}`)
});
</script>
<RepoPage repo={r} tab="code" title="{subject(c.message)} · {r.name}">
@@ -44,5 +50,5 @@
>
</div>
</div>
<Diff files={ch.files} {more} />
<Diff files={ch.files} {more} {source} />
</RepoPage>
web/src/routes/[authority]/[repo]/pulls/new/+page.svelte+6−1
web/src/app.css+51−1