chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Pull files sort as an editor's tree, and tree links land in Safari
crates/knotless-serve/src/diff.rs+115−0
| @@ -1,6 +1,8 @@ | ||
//! A patch read into files and rows, so a page can show each line with its | ||
//! old and new numbers rather than as the text git prints. | ||
| ||
use std::cmp::Ordering; | ||
| ||
/// One file's part of a patch. | ||
#[derive(Debug, Default, PartialEq)] | ||
pub struct File { | ||
| @@ -18,6 +20,17 @@ pub struct File { | ||
pub removed: usize, | ||
} | ||
| ||
impl File { | ||
/// The path it goes by: where it is after the change, or was, if deleted. | ||
pub fn path(&self) -> &str { | ||
if self.status == Status::Deleted { | ||
&self.old | ||
} else { | ||
&self.new | ||
} | ||
} | ||
} | ||
| ||
#[derive(Debug, Default, Clone, Copy, PartialEq)] | ||
pub enum Status { | ||
Added, | ||
| @@ -167,6 +180,57 @@ pub fn parse(patch: &str) -> Vec<File> { | ||
files | ||
} | ||
| ||
/// Puts `files` in the order an editor's file tree lists them, so a diff | ||
/// read down is its tree read down: in each directory, its directories | ||
/// before its files, each by name ([`by_name`]). | ||
pub fn sort(files: &mut [File]) { | ||
files.sort_by(|a, b| in_tree(a.path(), b.path())); | ||
} | ||
| ||
fn in_tree(a: &str, b: &str) -> Ordering { | ||
let (mut a, mut b) = (a.split('/'), b.split('/')); | ||
loop { | ||
match (a.next(), b.next()) { | ||
(Some(x), Some(y)) if x == y => {} | ||
(Some(x), Some(y)) => { | ||
// Whether each is a directory, with more of its path to come. | ||
let (x_dir, y_dir) = (a.clone().next().is_some(), b.clone().next().is_some()); | ||
return y_dir.cmp(&x_dir).then_with(|| by_name(x, y)); | ||
} | ||
// One path goes on where the other ends: a directory, before a file of its name. | ||
(x, y) => return y.is_some().cmp(&x.is_some()), | ||
} | ||
} | ||
} | ||
| ||
/// Names as an editor sorts them: case aside, and a run of digits by its | ||
/// number (`v2` before `v10`); as written where those leave them alike. | ||
fn by_name(a: &str, b: &str) -> Ordering { | ||
let (mut x, mut y) = (a, b); | ||
loop { | ||
let (Some(c), Some(d)) = (x.chars().next(), y.chars().next()) else { | ||
return (!x.is_empty()).cmp(&!y.is_empty()).then_with(|| a.cmp(b)); | ||
}; | ||
let order = if c.is_ascii_digit() && d.is_ascii_digit() { | ||
let (n, m) = (digits(x), digits(y)); | ||
(x, y) = (&x[n.len()..], &y[m.len()..]); | ||
let (n, m) = (n.trim_start_matches('0'), m.trim_start_matches('0')); | ||
n.len().cmp(&m.len()).then_with(|| n.cmp(m)) | ||
} else { | ||
(x, y) = (&x[c.len_utf8()..], &y[d.len_utf8()..]); | ||
c.to_lowercase().cmp(d.to_lowercase()) | ||
}; | ||
if order.is_ne() { | ||
return order; | ||
} | ||
} | ||
} | ||
| ||
/// The run of digits `s` starts with. | ||
fn digits(s: &str) -> &str { | ||
&s[..s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())] | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| @@ -278,4 +342,55 @@ new mode 100755 | ||
assert_eq!(range("-3"), Some((3, 1))); | ||
assert_eq!(range("+a,1"), None); | ||
} | ||
| ||
#[test] | ||
fn sorts_as_a_file_tree() { | ||
let at = |path: &str| File { | ||
new: path.to_string(), | ||
..File::default() | ||
}; | ||
let gone = File { | ||
old: "a".to_string(), | ||
status: Status::Deleted, | ||
..File::default() | ||
}; | ||
let mut files = vec![ | ||
at("Cargo.lock"), | ||
at("crates/a/lib.rs"), | ||
at("README.md"), | ||
at("justfile"), | ||
at("web/README.md"), | ||
at("web/src/app.css"), | ||
at("web/src/lib/a.ts"), | ||
at("docs/v10.md"), | ||
at("docs/v2.md"), | ||
at("docs/v02.md"), | ||
at("docs/v2.mdx"), | ||
at("a/b"), | ||
gone, | ||
]; | ||
sort(&mut files); | ||
let paths: Vec<&str> = files.iter().map(File::path).collect(); | ||
assert_eq!( | ||
paths, | ||
[ | ||
"a/b", | ||
"crates/a/lib.rs", | ||
"docs/v02.md", | ||
"docs/v2.md", | ||
"docs/v2.mdx", | ||
"docs/v10.md", | ||
"web/src/lib/a.ts", | ||
"web/src/app.css", | ||
"web/README.md", | ||
"a", | ||
"Cargo.lock", | ||
"justfile", | ||
"README.md", | ||
] | ||
); | ||
// A directory goes before a file of its name, and a path is level with itself. | ||
assert_eq!(in_tree("a", "a/b"), Ordering::Greater); | ||
assert_eq!(in_tree("a/b", "a/b"), Ordering::Equal); | ||
} | ||
} | ||
crates/knotless-serve/src/web.rs+6−8
| @@ -2862,11 +2862,7 @@ fn listed(f: &diff::File) -> FileDiffView { | ||
/// `texts` has, by blob. | ||
fn file_diff(f: diff::File, texts: &HashMap<String, String>) -> FileDiffView { | ||
let note = note(&f); | ||
let path = if f.status == Status::Deleted { | ||
&f.old | ||
} else { | ||
&f.new | ||
}; | ||
let path = f.path(); | ||
let sides = highlight::Sides { | ||
old: text_of(texts, &f.old_blob), | ||
new: text_of(texts, &f.new_blob), | ||
| @@ -2906,8 +2902,9 @@ fn row(r: diff::Row) -> DiffRowView { | ||
} | ||
| ||
/// What `git <verb> <rev>` prints as a patch of `paths` (all, if none), | ||
/// read into files, with their rows as `rows` says: a file without is | ||
/// neither highlighted nor has its blobs read. | ||
/// read into files in the order an editor's file tree lists them, with | ||
/// their rows as `rows` says: a file without is neither highlighted nor | ||
/// has its blobs read. | ||
async fn files( | ||
git: &Git, | ||
verb: &[&str], | ||
| @@ -2927,7 +2924,8 @@ async fn files( | ||
]); | ||
args.extend(paths); | ||
let patch = git.text(&args, None).await.map_err(not_found)?; | ||
let parsed = diff::parse(&patch); | ||
let mut parsed = diff::parse(&patch); | ||
diff::sort(&mut parsed); | ||
let with = match rows { | ||
Rows::None => return Ok(parsed.iter().map(listed).collect()), | ||
Rows::All => vec![true; parsed.len()], | ||
docs/design/serve.md+12−5
| @@ -1577,11 +1577,18 @@ with their rows in the same answer, up to 10 files or 1500 lines, from the | ||
same patch. A second request would wait on the first and repeat its work. | ||
The browser fetches the rest with `getPullDiff`, ten at a time, as each file | ||
nears the screen. A file changing more than | ||
400 lines waits for the reader to ask for it. Beside the diffs, a tree of the | ||
files links to each, with how many comments the threads on a file's lines | ||
hold. A button hides or shows the tree, and a `tree` cookie | ||
keeps the choice, so the server draws the next pull the same way; with no | ||
cookie, the tree shows beside the diffs and is hidden on a phone. | ||
400 lines waits for the reader to ask for it. The files come in the order | ||
an editor's file tree lists them, in each directory its directories before | ||
its files, each by name, case aside and numbers by value, so the first few | ||
with rows are the first on the page. Beside the diffs, a tree of the | ||
files, in that same order, links to each, with how many comments the | ||
threads on a file's lines hold. A file reached by its link is held at the | ||
top of the screen while the files above it are drawn and fetched, until | ||
the reader scrolls: they grow from a guessed height, and WebKit keeps no | ||
scroll anchor to make up for it. A button hides or shows the tree, and a | ||
`tree` cookie keeps the choice, so the server draws the next pull the same | ||
way; with no cookie, the tree shows beside the diffs and is hidden on a | ||
phone. | ||
| ||
A commit's page works the same way. It asks `getCommit` with `firstRows`, | ||
and the first few files come with their rows, by the same limits. The | ||
lexicons/dev/knotless/defs.json+1−0
| @@ -670,6 +670,7 @@ | ||
}, | ||
"files": { | ||
"type": "array", | ||
"description": "As an editor's file tree lists them: in each directory, its directories before its files, each by name.", | ||
"items": { "type": "ref", "ref": "#fileDiffView" } | ||
}, | ||
"head": { "type": "string", "description": "For an open pull, the hex object ID of the commit its branch is at: what its checks and reviews are of." } | ||
lexicons/dev/knotless/getCommit.json+1−1
| @@ -30,7 +30,7 @@ | ||
"commit": { "type": "ref", "ref": "dev.knotless.defs#commitView" }, | ||
"files": { | ||
"type": "array", | ||
"description": "The files it changed, in the diff's order: of those `paths` names, if any.", | ||
"description": "The files it changed, as an editor's file tree lists them, in each directory its directories before its files, each by name: of those `paths` names, if any.", | ||
"items": { "type": "ref", "ref": "dev.knotless.defs#fileDiffView" } | ||
} | ||
} | ||
lexicons/dev/knotless/getPullDiff.json+1−1
| @@ -29,7 +29,7 @@ | ||
"properties": { | ||
"files": { | ||
"type": "array", | ||
"description": "Those of the files named that the pull changes, in the diff's order.", | ||
"description": "Those of the files named that the pull changes, as an editor's file tree lists them, in each directory its directories before its files, each by name.", | ||
"items": { "type": "ref", "ref": "dev.knotless.defs#fileDiffView" } | ||
} | ||
} | ||
web/src/lib/organisms/FileTree.svelte+35−30
| @@ -4,61 +4,66 @@ | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
/** | ||
* A diff's files as a tree of their directories, each a link to its | ||
* file's diff (`href` of its index). A directory with one directory in | ||
* it, and nothing else, goes by both their names. A file with comments | ||
* on its lines says how many, as `comments` counts them. | ||
* file's diff (`href` of its index). Each directory lists what it holds | ||
* in the order the diffs come, so the tree read down is the diffs read | ||
* down. A directory with one directory in it, and nothing else, goes by | ||
* both their names. A file with comments on its lines says how many, as | ||
* `comments` counts them. `onpick` hears which file was clicked. | ||
*/ | ||
let { | ||
files, | ||
href, | ||
onpick, | ||
comments | ||
}: { | ||
files: FileDiffView[]; | ||
href: (i: number) => string; | ||
onpick?: (i: number) => void; | ||
comments?: (f: FileDiffView) => number; | ||
} = $props(); | ||
type Dir = { name: string; dirs: Dir[]; files: { name: string; i: number; f: FileDiffView }[] }; | ||
type File = { name: string; i: number; f: FileDiffView }; | ||
type Dir = { name: string; entries: (Dir | File)[] }; | ||
const isDir = (e: Dir | File): e is Dir => 'entries' in e; | ||
const tree = $derived.by(() => { | ||
const root: Dir = { name: '', dirs: [], files: [] }; | ||
const root: Dir = { name: '', entries: [] }; | ||
for (const [i, f] of files.entries()) { | ||
const parts = pathOf(f).split('/'); | ||
const name = parts.pop() ?? ''; | ||
let at = root; | ||
for (const part of parts) { | ||
let next = at.dirs.find((d) => d.name === part); | ||
if (!next) at.dirs.push((next = { name: part, dirs: [], files: [] })); | ||
let next = at.entries.find((e): e is Dir => isDir(e) && e.name === part); | ||
if (!next) at.entries.push((next = { name: part, entries: [] })); | ||
at = next; | ||
} | ||
at.files.push({ name, i, f }); | ||
at.entries.push({ name, i, f }); | ||
} | ||
const folded = (d: Dir): Dir => { | ||
while (d.dirs.length === 1 && d.files.length === 0) | ||
d = { ...d.dirs[0], name: `${d.name}/${d.dirs[0].name}` }; | ||
return { ...d, dirs: d.dirs.map(folded) }; | ||
const folded = (e: Dir | File): Dir | File => { | ||
if (!isDir(e)) return e; | ||
let d = e; | ||
while (d.entries.length === 1 && isDir(d.entries[0])) | ||
d = { ...d.entries[0], name: `${d.name}/${d.entries[0].name}` }; | ||
return { ...d, entries: d.entries.map(folded) }; | ||
}; | ||
return { ...root, dirs: root.dirs.map(folded) }; | ||
return { ...root, entries: root.entries.map(folded) }; | ||
}); | ||
</script> | ||
| ||
{#snippet dir(d: Dir)} | ||
{#each d.dirs as sub (sub.name)} | ||
{#each d.entries as e (isDir(e) ? `d:${e.name}` : e.i)} | ||
<li> | ||
<details open> | ||
<summary><Icon name="dir" /><span class="name">{sub.name}</span></summary> | ||
<ul>{@render dir(sub)}</ul> | ||
</details> | ||
</li> | ||
{/each} | ||
{#each d.files as file (file.i)} | ||
<li> | ||
<a href={href(file.i)} title={pathOf(file.f)} | ||
><Icon name="file" /><span class="name">{file.name}</span | ||
>{#if comments?.(file.f)}{@const n = comments(file.f)}<span | ||
class="said" | ||
aria-label="{n} {n === 1 ? 'comment' : 'comments'}"><Icon name="chat" />{n}</span | ||
>{/if}<span class="add">+{file.f.added}</span><span class="rem">−{file.f.removed}</span | ||
></a | ||
> | ||
{#if isDir(e)} | ||
<details open> | ||
<summary><Icon name="dir" /><span class="name">{e.name}</span></summary> | ||
<ul>{@render dir(e)}</ul> | ||
</details> | ||
{:else} | ||
<a href={href(e.i)} title={pathOf(e.f)} onclick={() => onpick?.(e.i)} | ||
><Icon name="file" /><span class="name">{e.name}</span>{#if comments?.(e.f)}{@const n = | ||
comments(e.f)}<span class="said" aria-label="{n} {n === 1 ? 'comment' : 'comments'}" | ||
><Icon name="chat" />{n}</span | ||
>{/if}<span class="add">+{e.f.added}</span><span class="rem">−{e.f.removed}</span></a | ||
> | ||
{/if} | ||
</li> | ||
{/each} | ||
{/snippet} | ||
web/src/lib/organisms/PullDiff.svelte+35−2
| @@ -2,6 +2,7 @@ | ||
import type * as getPullDiff from '#lib/lexicons/dev/knotless/getPullDiff.ts'; | ||
import type { FileDiffView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import type { AtUriString } from '@atproto/lex'; | ||
import { untrack } from 'svelte'; | ||
import { ask } from '#lib/data.ts'; | ||
import { TREE, matched } from '#lib/diffs.ts'; | ||
import { LaterRows } from '#lib/later.svelte.ts'; | ||
| @@ -62,6 +63,36 @@ | ||
) | ||
); | ||
const { rowsOf, waiting, seen, askFor } = later; | ||
| ||
let diffs = $state<HTMLElement>(); | ||
let letGo: (() => void) | undefined; | ||
/** | ||
* Keeps the diff the reader jumped to, `#diff-<i>`, at the top of the | ||
* screen as the files above it are drawn and their rows come in, until | ||
* they scroll themselves: WebKit holds no scroll anchor, so each file | ||
* above that grows past its guessed height would push it down the page. | ||
*/ | ||
function hold(i: number) { | ||
const target = document.getElementById(`diff-${i}`); | ||
if (!target || !diffs) return; | ||
letGo?.(); | ||
const grown = new ResizeObserver(() => target.scrollIntoView({ block: 'start' })); | ||
grown.observe(diffs); | ||
const moved = ['wheel', 'touchstart', 'keydown', 'pointerdown']; | ||
const stop = () => { | ||
grown.disconnect(); | ||
for (const m of moved) removeEventListener(m, stop, true); | ||
letGo = undefined; | ||
}; | ||
for (const m of moved) addEventListener(m, stop, { capture: true, passive: true }); | ||
letGo = stop; | ||
} | ||
/** A page opened at a file's link holds it too. */ | ||
$effect(() => { | ||
const at = /^#diff-(\d+)$/.exec(location.hash); | ||
if (at) untrack(() => hold(Number(at[1]))); | ||
return () => letGo?.(); | ||
}); | ||
</script> | ||
| ||
<div class="pull-files" class:tree-shown={showing === true} class:tree-hidden={showing === false}> | ||
| @@ -74,8 +105,10 @@ | ||
><Icon name="sidebarExpand" /></span | ||
></button | ||
> | ||
<aside bind:this={aside}><FileTree {files} href={(i) => `#diff-${i}`} {comments} /></aside> | ||
<div class="pull-files-diffs"> | ||
<aside bind:this={aside}> | ||
<FileTree {files} href={(i) => `#diff-${i}`} onpick={hold} {comments} /> | ||
</aside> | ||
<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 /> | ||