chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A diff shows more of a file as asked, and links to the file
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> | ||