chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A commit's page loads its files' rows as they are scrolled to
crates/knotless-serve/src/web/items.rs+3−3
| @@ -243,8 +243,8 @@ pub struct ThreadView { | ||
viewer: Option<threads::ThreadViewer>, | ||
} | ||
| ||
/// The most files one `getPullDiff` asks for. | ||
const DIFF_PATHS_MAX: usize = 50; | ||
/// The most files one `getPullDiff` or `getCommit` asks for. | ||
pub(super) const DIFF_PATHS_MAX: usize = 50; | ||
| ||
/// `dev.knotless.getPullDiff`'s answer: some files of a pull's diff. | ||
#[derive(Serialize)] | ||
| @@ -283,7 +283,7 @@ pub async fn get_pull_diff( | ||
} | ||
| ||
/// The values of the parameters `pairs` names `name`, in order. | ||
fn named<'a>(pairs: &'a [(String, String)], name: &str) -> Vec<&'a str> { | ||
pub(super) fn named<'a>(pairs: &'a [(String, String)], name: &str) -> Vec<&'a str> { | ||
let named = pairs.iter().filter(|(k, _)| k == name); | ||
named.map(|(_, v)| v.as_str()).collect() | ||
} | ||
crates/knotless-serve/src/web/repo.rs+22−6
| @@ -14,7 +14,6 @@ pub struct RepoParams { | ||
path: Option<String>, | ||
limit: Option<usize>, | ||
cursor: Option<String>, | ||
sha: Option<String>, | ||
#[serde(rename = "subjectOnly", default)] | ||
subject_only: bool, | ||
#[serde(default)] | ||
| @@ -698,12 +697,29 @@ pub struct Change { | ||
/// `dev.knotless.getCommit`. A merge's diff is against its first parent: | ||
/// what it brought into the branch it was made on, where git's own would | ||
/// show only what differs from both, nothing at all for a clean merge. | ||
/// With `firstRows`, only the first files come with their rows, as the | ||
/// commit page draws them at once; with `paths`, only those files, rows | ||
/// and all, for the rest as a reader comes to them: as a pull's files | ||
/// come from `getThread` and `getPullDiff`. Its parameters are pairs, as | ||
/// `paths` repeats. | ||
pub async fn get_commit( | ||
State(app): State<Arc<App>>, | ||
Query(p): Query<RepoParams>, | ||
Query(pairs): Query<Vec<(String, String)>>, | ||
) -> Result<Response> { | ||
let repo = found(&app, &p.repo).await?; | ||
let sha = p.sha.as_deref().ok_or(invalid("sha is required".into()))?; | ||
let one = |name| super::items::named(&pairs, name).first().copied(); | ||
let (Some(repo), Some(sha)) = (one("repo"), one("sha")) else { | ||
return Err(invalid("repo and sha are required".into())); | ||
}; | ||
let paths = super::items::named(&pairs, "paths"); | ||
let max = super::items::DIFF_PATHS_MAX; | ||
if paths.len() > max { | ||
return Err(invalid(format!("paths names at most {max} files"))); | ||
} | ||
let rows = match (paths.is_empty(), one("firstRows")) { | ||
(true, Some("true")) => Rows::First, | ||
_ => Rows::All, | ||
}; | ||
let repo = found(&app, repo).await?; | ||
let rev = read::rev(Some(sha)).map_err(not_found)?; | ||
let log = repo.objects()?.log(&rev, "", 0, 1).map_err(not_found)?; | ||
let c = shown(&app, &repo, log).await.remove(0); | ||
| @@ -711,8 +727,8 @@ pub async fn get_commit( | ||
&repo.git, | ||
&["show", "--format=", "--patch", "--diff-merges=first-parent"], | ||
&c.sha, | ||
&[], | ||
Rows::All, | ||
&paths, | ||
rows, | ||
) | ||
.await?; | ||
Ok(answer(Change { commit: c, files })) | ||
crates/knotless-serve/src/moderation.rs+1−1
| @@ -321,7 +321,7 @@ async fn follow_once(app: &App, did: &str) -> anyhow::Result<()> { | ||
.ok_or_else(|| anyhow::anyhow!("following a labeler takes an index"))?; | ||
let doc = knotless::identity::document(&app.ctx, did).await?; | ||
let endpoint = knotless::identity::service(&doc, LABELER) | ||
.ok_or_else(|| anyhow::anyhow!("{did} names no {LABELER} service"))?; | ||
.context(format!("{did} names no {LABELER} service"))?; | ||
let cursor = index.labeler_seq(did)?.unwrap_or(0); | ||
let url = format!( | ||
"{}/xrpc/com.atproto.label.subscribeLabels?cursor={cursor}", | ||
crates/knotless-serve/tests/web.rs+47−1
| @@ -216,7 +216,9 @@ fn repo_query(page: &str) -> Option<(String, String)> { | ||
(Some("tree"), Some(r), _) => xrpc("getTree", &[repo, ("ref", r)]), | ||
(Some("blob"), Some(r), _) => xrpc("getFile", &[repo, ("ref", r)]), | ||
(Some("commits"), Some(r), _) => with("listCommits", &[repo, ("ref", r)]), | ||
(Some("commit"), Some(sha), _) => xrpc("getCommit", &[repo, ("sha", sha)]), | ||
(Some("commit"), Some(sha), _) => { | ||
xrpc("getCommit", &[repo, ("sha", sha), ("firstRows", "true")]) | ||
} | ||
(Some(kind @ ("pulls" | "issues")), None, _) => { | ||
with("listItems", &[repo, ("kind", kind.trim_end_matches('s'))]) | ||
} | ||
| @@ -916,6 +918,50 @@ fn a_merge_commit_shows_what_it_brought_in() { | ||
assert_eq!(files.len(), 1, "{change}"); | ||
assert_eq!(files[0]["new"], "brought.txt"); | ||
assert_eq!(files[0]["added"], 1); | ||
| ||
// Asked for its first rows, a few files come with them, as a page | ||
// draws them at once, and the rest without, but with how many. | ||
for i in 0..12 { | ||
write(&work, &format!("many/{i:02}.txt"), b"one\n"); | ||
} | ||
s.git(&work, &["add", "-A"]); | ||
s.git(&work, &["commit", "-q", "-m", "Many"]); | ||
let many = s.git(&work, &["rev-parse", "HEAD"]); | ||
s.git(&work, &["push", "-q", &url, "main"]); | ||
let first = xrpc( | ||
"getCommit", | ||
&[("repo", &uri), ("sha", &many), ("firstRows", "true")], | ||
); | ||
wait_until(|| site.status(&first).0 == 200); | ||
let first = site.json(&first); | ||
let rows = |f: &Value| f["rows"].as_array().unwrap().len(); | ||
let files = first["files"].as_array().unwrap(); | ||
assert_eq!(files.len(), 12, "{first}"); | ||
assert!(files[..10].iter().all(|f| rows(f) > 0), "{first}"); | ||
assert_eq!(rows(&files[10]), 0); | ||
assert!(files[10]["rowCount"].as_u64().unwrap() > 0, "{first}"); | ||
// Those it named, rows and all. | ||
let named = [ | ||
("repo", uri.as_str()), | ||
("sha", many.as_str()), | ||
("paths", "many/10.txt"), | ||
("paths", "many/11.txt"), | ||
("firstRows", "true"), | ||
]; | ||
let named = site.json(&xrpc("getCommit", &named)); | ||
let files = named["files"].as_array().unwrap(); | ||
assert_eq!(files.len(), 2, "{named}"); | ||
assert_eq!(files[1]["new"], "many/11.txt"); | ||
assert!(rows(&files[1]) > 0, "{named}"); | ||
// No more of them than a page of them. | ||
let path = ("paths", "many/00.txt"); | ||
let too_many = [ | ||
&[("repo", uri.as_str()), ("sha", many.as_str())][..], | ||
&[path; 51][..], | ||
] | ||
.concat(); | ||
let (code, body) = site.status(&xrpc("getCommit", &too_many)); | ||
assert_eq!(code, 400, "{body}"); | ||
} | ||
| ||
/// Who last changed each line of a file, as the blame page shows it. | ||
docs/design/serve.md+6−0
| @@ -1550,6 +1550,12 @@ 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. | ||
| ||
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 | ||
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. | ||
| ||
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 | ||
docs/reference/api.md+1−1
| @@ -601,7 +601,7 @@ name it has now. | ||
| `dev.knotless.getFile?repo=&ref=&path=` | a file, highlighted or rendered | | ||
| `dev.knotless.getBlame?repo=&ref=&path=` | who last changed each line of a text file, as `git blame` finds it: its text, highlighted; its lines in runs (`hunks`), each with its commit and, where the commit's parent had the file, `previous`, that parent and the file's path there; and each commit once. 400 for a binary or a file over 256 KiB | | ||
| `dev.knotless.listCommits?repo=&ref=&path=&limit=&cursor=&subjectOnly=` | history, 50 commits a page by default, with the newest checks of each; with `subjectOnly`, each message only to its first line | | ||
| `dev.knotless.getCommit?repo=&sha=` | a commit and its diff, a merge's against its first parent | | ||
| `dev.knotless.getCommit?repo=&sha=&firstRows=&paths=` | a commit and its diff, a merge's against its first parent; with `firstRows`, the rows of the first files only, up to 10 files or 1500 lines, passing over any that changes more than 400, the rest with their line counts alone; with `paths` (repeated, at most 50), only those files, rows and all: what the commit page fetches as the reader comes to each file | | ||
| `dev.knotless.listBranches?repo=&limit=&cursor=&commits=` | the branches, the default first, 100 a page by default; with `commits`, each with its tip and that commit's newest checks | | ||
| `dev.knotless.listTags?repo=&limit=&cursor=&commits=` | the tags that name a commit, the newest commit first, 100 a page by default; with `commits`, each with its commit and that commit's newest checks | | ||
| `dev.knotless.listItems?repo=&kind=&state=&q=&limit=&cursor=` | the open or closed pulls or issues that the filter `q` matches ([collaboration.md](../design/collaboration.md#filtering-pulls-and-issues)), `review-requested:@me` and `assignee:@me` the asker, each with `draft` for a draft pull, the `reviewers` asked, its base's required reviewers among them and named again in `requiredReviewers`, and the `assignees`, 50 a page by default, how many of each, and the repository's labels and the items' authors, for a filter to name | | ||
harness/shots/fixtures.json+2−2
| @@ -119,7 +119,7 @@ | ||
}, | ||
"body": "{\"check\":{\"uri\":\"at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.uuiududme6qwohxvshbn32i4nw.bea82d97f7448b5d6d93b6a8a108ccb9d86277ad.test\",\"repo\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"workflow\":\"test\",\"status\":\"failure\",\"commit\":\"bea82d97f7448b5d6d93b6a8a108ccb9d86277ad\",\"startedAt\":\"2026-10-10T16:13:00Z\",\"finishedAt\":\"2026-10-10T16:13:45.500Z\",\"images\":[{\"alt\":\"pull-desktop-light 1 before\",\"url\":\"/xrpc/dev.knotless.getCheckImage?repo=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.repo%2Ftally&uri=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.check%2Fqdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.uuiududme6qwohxvshbn32i4nw.bea82d97f7448b5d6d93b6a8a108ccb9d86277ad.test&cid=bafkreifhzh7dl36rji3rpd3yf5v76ktdultbqrwsaczwwaggigald7ooui\"},{\"alt\":\"pull-desktop-light 2 after\",\"url\":\"/xrpc/dev.knotless.getCheckImage?repo=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.repo%2Ftally&uri=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.check%2Fqdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.uuiududme6qwohxvshbn32i4nw.bea82d97f7448b5d6d93b6a8a108ccb9d86277ad.test&cid=bafkreia22sofk3n6ehep4qsfhd6bhvbysz4f4tazkigduv3xl5r7a6zm4m\"},{\"alt\":\"pull-desktop-light 3 difference\",\"url\":\"/xrpc/dev.knotless.getCheckImage?repo=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.repo%2Ftally&uri=at%3A%2F%2Fdid%3Aplc%3A55j6e4wxzlzndxm4nov2lz7x%2Fdev.knotless.check%2Fqdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.uuiududme6qwohxvshbn32i4nw.bea82d97f7448b5d6d93b6a8a108ccb9d86277ad.test&cid=bafkreidgy7vnqjmsemzlt5ah4fo3v3vn2q7uaj3bgq2jcaovfqr3lo2rq4\"}]},\"ref\":\"refs/heads/json\",\"runner\":{\"did\":\"did:plc:55j6e4wxzlzndxm4nov2lz7x\",\"handle\":\"bob.test\"},\"steps\":[{\"name\":\"build\",\"status\":\"success\",\"exitCode\":0,\"durationMs\":41200},{\"name\":\"test\",\"status\":\"failure\",\"exitCode\":101,\"durationMs\":3300}],\"log\":\"==> build\\n Compiling tally v0.1.0 (/work)\\n Finished `test` profile [unoptimized + debuginfo] target(s) in 41.17s\\n==> test\\n Running unittests src/lib.rs (target/debug/deps/tally-3f9c2a1b)\\n\\nrunning 4 tests\\ntest tests::counts_bytes ... ok\\ntest tests::counts_lines ... ok\\ntest tests::counts_words ... ok\\ntest tests::prints_json ... FAILED\\n\\nfailures:\\n\\n---- tests::prints_json stdout ----\\nthread 'tests::prints_json' panicked at src/lib.rs:41:9:\\nassertion `left == right` failed\\n left: \\\"{\\\"lines\\\":2,\\\"words\\\":3,\\\"bytes\\\":14}\\\"\\n right: \\\"{\\\"lines\\\":2,\\\"words\\\":3,\\\"bytes\\\":14,\\\"name\\\":\\\"-\\\"}\\\"\\n\\ntest result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\\n\"}" | ||
}, | ||
"/xrpc/dev.knotless.getCommit?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally&sha=052e48d313c5ebeb08f596cd683ba1eade3fb1ac": { | ||
"/xrpc/dev.knotless.getCommit?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally&sha=052e48d313c5ebeb08f596cd683ba1eade3fb1ac&firstRows=true": { | ||
"status": 200, | ||
"headers": { | ||
"content-type": "application/json", | ||
| @@ -127,7 +127,7 @@ | ||
}, | ||
"body": "{\"commit\":{\"sha\":\"052e48d313c5ebeb08f596cd683ba1eade3fb1ac\",\"parents\":[{\"sha\":\"4abc93fa7cb85e41149e17f70dfcc4d3830ba0f2\"}],\"author\":{\"name\":\"Alice Moreau\",\"actor\":{\"did\":\"did:plc:abe24xewga4qdbzjcxmuzdt3\",\"handle\":\"alice.test\"}},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"committedAt\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"files\":[{\"old\":\"docs/design.md\",\"new\":\"docs/DESIGN.md\",\"status\":\"renamed\",\"added\":0,\"removed\":0,\"note\":\"Moved without changes.\",\"rows\":[]},{\"old\":\"src/lib.rs\",\"new\":\"src/lib.rs\",\"status\":\"modified\",\"added\":9,\"removed\":2,\"ranges\":\"comment:0,58,14,22,58,30;string:529,18,153,6;keyword:60,3,69,3,1,6,41,3,1,2,35,3,1,3,65,3,6,2,20,3,6,2,177,3,120,2;number:384,1,203,1,9,1,9,2,105,1;function:190,5,67,3,14,7,27,5,28,5,65,16,3,5,35,5,43,5,30,10,73,29,13,10;type:104,5,2,7,2,9,14,6,54,3,5,6,24,6,24,6,295,6;attr:95,9,5,2,7,2,9,2,495,7;xins:339,17,112,17;xdel:308,11,92,30\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"text\":\"@@ -1,5 +1,7 @@\"},{\"kind\":\"\",\"line\":true,\"old\":1,\"new\":1,\"text\":\"//! Counting, kept apart from reading so it can be tested.\"},{\"kind\":\"\",\"line\":true,\"old\":2,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"new\":3,\"text\":\"mod parse;\"},{\"kind\":\"a\",\"line\":true,\"new\":4,\"text\":\"\"},{\"kind\":\"\",\"line\":true,\"old\":3,\"new\":5,\"text\":\"/// What a text holds.\"},{\"kind\":\"\",\"line\":true,\"old\":4,\"new\":6,\"text\":\"#[derive(Debug, Default, PartialEq)]\"},{\"kind\":\"\",\"line\":true,\"old\":5,\"new\":7,\"text\":\"pub struct Counts {\"},{\"kind\":\"h\",\"line\":false,\"text\":\"@@ -11,9 +13,9 @@ pub struct Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":11,\"new\":13,\"text\":\"/// Counts `text` in one pass.\"},{\"kind\":\"\",\"line\":true,\"old\":12,\"new\":14,\"text\":\"pub fn count(text: &str) -> Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":13,\"new\":15,\"text\":\" let mut counts = Counts { bytes: text.len(), ..Counts::default() };\"},{\"kind\":\"d\",\"line\":true,\"old\":14,\"text\":\" for line in text.lines() {\"},{\"kind\":\"a\",\"line\":true,\"new\":16,\"text\":\" for line in parse::lines(text) {\"},{\"kind\":\"\",\"line\":true,\"old\":15,\"new\":17,\"text\":\" counts.lines += 1;\"},{\"kind\":\"d\",\"line\":true,\"old\":16,\"text\":\" counts.words += line.split_whitespace().count();\"},{\"kind\":\"a\",\"line\":true,\"new\":18,\"text\":\" counts.words += parse::words(line);\"},{\"kind\":\"\",\"line\":true,\"old\":17,\"new\":19,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":18,\"new\":20,\"text\":\" counts\"},{\"kind\":\"\",\"line\":true,\"old\":19,\"new\":21,\"text\":\"}\"},{\"kind\":\"h\",\"line\":false,\"text\":\"@@ -27,4 +29,9 @@ mod tests {\"},{\"kind\":\"\",\"line\":true,\"old\":27,\"new\":29,\"text\":\" let c = count(\\\"one two\\\\nthree\\\\n\\\");\"},{\"kind\":\"\",\"line\":true,\"old\":28,\"new\":30,\"text\":\" assert_eq!(c, Counts { lines: 2, words: 3, bytes: 14 });\"},{\"kind\":\"\",\"line\":true,\"old\":29,\"new\":31,\"text\":\" }\"},{\"kind\":\"a\",\"line\":true,\"new\":32,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"new\":33,\"text\":\" #[test]\"},{\"kind\":\"a\",\"line\":true,\"new\":34,\"text\":\" fn a_last_line_without_a_newline() {\"},{\"kind\":\"a\",\"line\":true,\"new\":35,\"text\":\" assert_eq!(count(\\\"a\\\\nb\\\").lines, 2);\"},{\"kind\":\"a\",\"line\":true,\"new\":36,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":30,\"new\":37,\"text\":\"}\"}]},{\"old\":\"src/parse.rs\",\"new\":\"src/parse.rs\",\"status\":\"added\",\"added\":11,\"removed\":0,\"ranges\":\"comment:0,48,2,57,76,68;keyword:108,3,1,2,138,3,1,2,22,4;function:115,5,33,16,3,5,82,5,54,5;type:128,3,5,5,131,3,10,8,1,4,4,3\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"text\":\"@@ -0,0 +1,11 @@\"},{\"kind\":\"a\",\"line\":true,\"new\":1,\"text\":\"//! Splitting text into the things tally counts.\"},{\"kind\":\"a\",\"line\":true,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"new\":3,\"text\":\"/// The words of `line`: runs of anything but whitespace.\"},{\"kind\":\"a\",\"line\":true,\"new\":4,\"text\":\"pub fn words(line: &str) -> usize {\"},{\"kind\":\"a\",\"line\":true,\"new\":5,\"text\":\" line.split_whitespace().count()\"},{\"kind\":\"a\",\"line\":true,\"new\":6,\"text\":\"}\"},{\"kind\":\"a\",\"line\":true,\"new\":7,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"new\":8,\"text\":\"/// The lines of `text`. A last line without a newline still counts.\"},{\"kind\":\"a\",\"line\":true,\"new\":9,\"text\":\"pub fn lines(text: &str) -> impl Iterator<Item = &str> {\"},{\"kind\":\"a\",\"line\":true,\"new\":10,\"text\":\" text.lines()\"},{\"kind\":\"a\",\"line\":true,\"new\":11,\"text\":\"}\"}]}]}" | ||
}, | ||
"/xrpc/dev.knotless.getCommit?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally&sha=1be297a440088f5808a7cdcf5254ac71c024458f": { | ||
"/xrpc/dev.knotless.getCommit?repo=at%3A%2F%2Falice.test%2Fdev.knotless.repo%2Ftally&sha=1be297a440088f5808a7cdcf5254ac71c024458f&firstRows=true": { | ||
"status": 200, | ||
"headers": { | ||
"content-type": "application/json", | ||
lexicons/dev/knotless/getCommit.json+9−1
| @@ -11,7 +11,14 @@ | ||
"required": ["repo", "sha"], | ||
"properties": { | ||
"repo": { "type": "string", "format": "at-uri", "description": "The repository's dev.knotless.repo record. Its authority may be a handle; a name the repository had before, or an owner it moved from with its agreement, finds it under the name it has now." }, | ||
"sha": { "type": "string", "maxLength": 1024, "description": "The commit, by object name or anything else git takes." } | ||
"sha": { "type": "string", "maxLength": 1024, "description": "The commit, by object name or anything else git takes." }, | ||
"firstRows": { "type": "boolean", "default": false, "description": "The rows of the first files only, as a page draws them at once: from the first, those whose rows would be shown, up to 10 files and 1500 changed lines, passing over any that changes more than 400. The rest come without their rows, but with how many there are, to be asked for by `paths`." }, | ||
"paths": { | ||
"type": "array", | ||
"maxLength": 50, | ||
"description": "Only these files, rows and all, by their paths after the change, or before it for one deleted; a renamed file is found as such only when both its paths are named.", | ||
"items": { "type": "string", "maxLength": 4096 } | ||
} | ||
} | ||
}, | ||
"output": { | ||
| @@ -23,6 +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.", | ||
"items": { "type": "ref", "ref": "dev.knotless.defs#fileDiffView" } | ||
} | ||
} | ||
web/src/lib/organisms/Diff.svelte+25−3
| @@ -1,11 +1,25 @@ | ||
<script lang="ts"> | ||
import type { FileDiffView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import { plural } from '#lib/format.ts'; | ||
import { LaterRows, type More } from '#lib/later.svelte.ts'; | ||
import DiffFile from '#lib/organisms/DiffFile.svelte'; | ||
/** What a commit or a pull changes: how much, then each file. */ | ||
let { files }: { files: FileDiffView[] } = $props(); | ||
/** | ||
* 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. | ||
*/ | ||
let { files, more }: { files: FileDiffView[]; more?: More } = $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( | ||
new Map(files.map((f, i) => [i, f] as const).filter(([, f]) => f.rows.length)) | ||
); | ||
const later = new LaterRows( | ||
() => files, | ||
() => first, | ||
(paths) => more?.(paths) ?? Promise.resolve([]) | ||
); | ||
const { rowsOf, waiting, seen, askFor } = later; | ||
</script> | ||
| ||
<div class="summary"> | ||
| @@ -13,4 +27,12 @@ | ||
<b class="add">{plural(added, 'addition', 'additions')}</b> | ||
and <b class="rem">{plural(removed, 'deletion', 'deletions')}</b> | ||
</div> | ||
{#each files as f, i (i)}<DiffFile file={f} />{/each} | ||
{#if more} | ||
{#each files as f, i (i)} | ||
<div use:seen={i}> | ||
<DiffFile file={rowsOf(i) ?? f} waiting={waiting(i)} ask={() => askFor(i)} /> | ||
</div> | ||
{/each} | ||
{:else} | ||
{#each files as f, i (i)}<DiffFile file={f} />{/each} | ||
{/if} | ||
web/src/lib/organisms/PullDiff.svelte+11−71
| @@ -3,7 +3,8 @@ | ||
import type { FileDiffView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import type { AtUriString } from '@atproto/lex'; | ||
import { ask } from '#lib/data.ts'; | ||
import { BATCH, TREE, held, matched, pathsOf, unfetched } from '#lib/diffs.ts'; | ||
import { TREE, matched } 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'; | ||
import FileTree from '#lib/organisms/FileTree.svelte'; | ||
| @@ -52,76 +53,15 @@ | ||
shown | ||
) | ||
); | ||
/** Rows fetched here, why a fetch failed, and the large files asked for, by index. */ | ||
let fetched = $state<Record<number, FileDiffView>>({}); | ||
let failed = $state<Record<number, string>>({}); | ||
let opened = $state<Record<number, boolean>>({}); | ||
const asked = new Set<number>(); | ||
let queue: number[] = []; | ||
let flushing = false; | ||
| ||
const rowsOf = (i: number) => fetched[i] ?? first.get(i); | ||
const waiting = (i: number) => { | ||
const f = files[i]; | ||
if (!unfetched(f) || rowsOf(i)) return undefined; | ||
if (i in failed) return { failed: failed[i] }; | ||
if (held(f) && !opened[i]) return { held: true as const }; | ||
return { loading: true as const }; | ||
}; | ||
| ||
function want(i: number) { | ||
if (!unfetched(files[i]) || rowsOf(i) || asked.has(i)) return; | ||
asked.add(i); | ||
queue.push(i); | ||
if (!flushing) void flush(); | ||
} | ||
| ||
/** Asks for what is wanted, a batch at a time, as the reader scrolls on. */ | ||
async function flush() { | ||
flushing = true; | ||
await Promise.resolve(); | ||
while (queue.length) { | ||
const batch = queue.splice(0, BATCH); | ||
const paths = batch.flatMap((i) => pathsOf(files[i])); | ||
const got = await ask<typeof getPullDiff.main>('dev.knotless.getPullDiff', { | ||
repo, | ||
uri, | ||
paths | ||
}).then( | ||
(a) => matched(files, batch, a.files), | ||
(e: Error) => e.message | ||
); | ||
for (const i of batch) { | ||
const f = typeof got === 'string' ? undefined : got.get(i); | ||
if (f) fetched[i] = f; | ||
else { | ||
failed[i] = typeof got === 'string' ? got : 'Its diff could not be read.'; | ||
asked.delete(i); | ||
} | ||
} | ||
} | ||
flushing = false; | ||
} | ||
| ||
/** Fetches a file's rows once it nears the screen, unless it waits to be asked for. */ | ||
function seen(node: HTMLElement, i: number) { | ||
const watch = new IntersectionObserver( | ||
(at) => { | ||
if (!at.some((a) => a.isIntersecting)) return; | ||
watch.disconnect(); | ||
if (!held(files[i])) want(i); | ||
}, | ||
{ rootMargin: '800px 0px' } | ||
); | ||
watch.observe(node); | ||
return { destroy: () => watch.disconnect() }; | ||
} | ||
| ||
function askFor(i: number) { | ||
opened[i] = true; | ||
delete failed[i]; | ||
want(i); | ||
} | ||
const later = new LaterRows( | ||
() => files, | ||
() => first, | ||
(paths) => | ||
ask<typeof getPullDiff.main>('dev.knotless.getPullDiff', { repo, uri, paths }).then( | ||
(a) => a.files | ||
) | ||
); | ||
const { rowsOf, waiting, seen, askFor } = later; | ||
</script> | ||
| ||
<div class="pull-files" class:tree-shown={showing === true} class:tree-hidden={showing === false}> | ||