~ 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

Pull files sort as an editor's tree, and tree links land in Safari

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/file-tree-order
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 />