chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
web: a feed for the front page, and a quieter look
The front page is now what happened on the repositories the index knows: repositories made and forked, pushes with the commits they brought, branches and tags made and deleted, and pulls and issues opened, discussed, merged, closed and reopened, a day at a time. Searching moves to its own results page, and getting started to /start. The index keeps each push from its claim, so the feed outlives compaction. A fork's copy of its source's refs is not shown as pushes, and a fork's new branch lists only what its source lacked just before. The pages take a neutral palette with GitHub's code and diff colours, round avatars, monospace only for code-like text, and no icon on profile repository lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 4de1da8 commit 698c4c0
Showing 176 changed files with 1103 additions and 164 deletions
crates/knotless-serve/src/index.rs+198−2
| @@ -33,13 +33,33 @@ pub struct Listed { | ||
pub created_at: String, | ||
} | ||
| ||
/// A ref moving, as a claim recorded it. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct Push { | ||
/// The repository's record. | ||
pub repo: String, | ||
pub did: String, | ||
/// The repository's name now. | ||
pub name: String, | ||
/// The repository it was forked from, if it is a fork. | ||
pub fork_of: Option<String>, | ||
/// The full ref name, such as `refs/heads/main`. | ||
pub refname: String, | ||
/// Where it was, when it was there before. | ||
pub from: Option<String>, | ||
/// Where it is now, unless it was deleted. | ||
pub to: Option<String>, | ||
pub created_at: String, | ||
} | ||
| ||
/// The shape of the tables. An index of another shape is dropped and | ||
/// built again: it is a cache, and `--backfill` refills it. | ||
const VERSION: i64 = 5; | ||
const VERSION: i64 = 6; | ||
| ||
/// Drops the tables that are a cache. Takedowns, the labels given out for | ||
/// them and reports are not, and stay. | ||
const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; | ||
const CACHE: &str = | ||
"DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; DROP TABLE IF EXISTS push; | ||
DROP TABLE IF EXISTS moved; DROP TABLE IF EXISTS block; DROP TABLE IF EXISTS label; | ||
DROP TABLE IF EXISTS labeler;"; | ||
| ||
| @@ -67,6 +87,10 @@ impl Index { | ||
uri TEXT PRIMARY KEY, author TEXT NOT NULL, repo_did TEXT NOT NULL, | ||
repo_key TEXT NOT NULL, value TEXT NOT NULL); | ||
CREATE INDEX IF NOT EXISTS forge_repo ON forge (repo_did, repo_key); | ||
CREATE TABLE IF NOT EXISTS push ( | ||
uri TEXT PRIMARY KEY, did TEXT NOT NULL, key TEXT NOT NULL, ref TEXT NOT NULL, | ||
before TEXT, after TEXT, created_at TEXT NOT NULL); | ||
CREATE INDEX IF NOT EXISTS push_created ON push (created_at); | ||
CREATE TABLE IF NOT EXISTS moved ( | ||
uri TEXT PRIMARY KEY, from_did TEXT NOT NULL, from_key TEXT NOT NULL, | ||
to_did TEXT NOT NULL, to_key TEXT NOT NULL); | ||
| @@ -140,6 +164,21 @@ impl Index { | ||
text | ||
], | ||
) | ||
} else if collection == nsid::CLAIM { | ||
// A ref moving, kept after compaction drops its claim: the | ||
// claims are the reflog, and this is the history of it. | ||
db.execute( | ||
"INSERT OR REPLACE INTO push VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", | ||
params![ | ||
uri, | ||
did, | ||
value["repo"].as_str().unwrap_or_default(), | ||
value["name"].as_str().unwrap_or_default(), | ||
value["from"].as_str(), | ||
value["to"].as_str(), | ||
value["createdAt"].as_str().unwrap_or_default() | ||
], | ||
) | ||
} else if let Some(repo) = named("repo") { | ||
db.execute( | ||
"INSERT OR REPLACE INTO forge VALUES (?1, ?2, ?3, ?4, ?5)", | ||
| @@ -409,6 +448,52 @@ impl Index { | ||
Ok((rows.into_iter().map(|(r, _)| r).collect(), next)) | ||
} | ||
| ||
/// Pulls, issues, comments and changes of state on any repository, | ||
/// newest first, with the owner and key of the repository each is on. | ||
pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<(Entry, String, String)>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT uri, author, value, repo_did, repo_key FROM forge | ||
WHERE uri NOT LIKE '%/dev.knotless.gate/%' AND uri NOT LIKE '%/dev.knotless.block/%' | ||
AND uri NOT IN down AND author NOT IN down | ||
AND coalesce(json_extract(value, '$.subject'), '') NOT IN down | ||
ORDER BY json_extract(value, '$.createdAt') DESC LIMIT ?1", | ||
)?; | ||
stmt.query_map([limit as i64], recent)?.collect() | ||
} | ||
| ||
/// Refs that moved on repositories that stand, newest first. | ||
pub fn pushes(&self, limit: usize) -> rusqlite::Result<Vec<Push>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT r.uri, p.did, r.name, r.fork_of, p.ref, p.before, p.after, p.created_at FROM push p | ||
JOIN repo r ON r.did = p.did AND r.key = p.key | ||
WHERE p.did NOT IN down AND r.uri NOT IN down | ||
ORDER BY p.created_at DESC LIMIT ?1", | ||
)?; | ||
stmt.query_map([limit as i64], push)?.collect() | ||
} | ||
| ||
/// Where the repository at `repo` had `refname` just before `at`, as | ||
/// far as the index saw. | ||
pub fn tip_before( | ||
&self, | ||
repo: &str, | ||
refname: &str, | ||
at: &str, | ||
) -> rusqlite::Result<Option<String>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT p.after FROM push p JOIN repo r ON r.did = p.did AND r.key = p.key | ||
WHERE r.uri = ?1 AND p.ref = ?2 AND p.created_at < ?3 | ||
ORDER BY p.created_at DESC LIMIT 1", | ||
)?; | ||
let tip = stmt | ||
.query_row([repo, refname, at], |row| row.get(0)) | ||
.optional()?; | ||
Ok(tip.flatten()) | ||
} | ||
| ||
/// Forks of `did`'s repository `key`. | ||
pub fn forks(&self, did: &str, key: &str) -> rusqlite::Result<Vec<Listed>> { | ||
let db = self.db.lock().unwrap(); | ||
| @@ -445,6 +530,23 @@ fn entry(row: &rusqlite::Row) -> rusqlite::Result<Entry> { | ||
}) | ||
} | ||
| ||
fn push(row: &rusqlite::Row) -> rusqlite::Result<Push> { | ||
Ok(Push { | ||
repo: row.get(0)?, | ||
did: row.get(1)?, | ||
name: row.get(2)?, | ||
fork_of: row.get(3)?, | ||
refname: row.get(4)?, | ||
from: row.get(5)?, | ||
to: row.get(6)?, | ||
created_at: row.get(7)?, | ||
}) | ||
} | ||
| ||
fn recent(row: &rusqlite::Row) -> rusqlite::Result<(Entry, String, String)> { | ||
Ok((entry(row)?, row.get(3)?, row.get(4)?)) | ||
} | ||
| ||
/// A listed repository, and its URI to page from. | ||
fn paged(row: &rusqlite::Row) -> rusqlite::Result<(Listed, String)> { | ||
Ok((listed(row)?, row.get(5)?)) | ||
| @@ -494,6 +596,8 @@ mod tests { | ||
assert!(index.forks("did:a", "k").is_err()); | ||
assert!(index.forge("did:a", "k").is_err()); | ||
assert!(index.name("did:a", "k").is_err()); | ||
assert!(index.recent(1).is_err()); | ||
assert!(index.pushes(1).is_err()); | ||
} | ||
| ||
#[test] | ||
| @@ -641,6 +745,98 @@ mod tests { | ||
assert_eq!(index.labeler_seq("did:web:l").unwrap(), None); | ||
} | ||
| ||
#[test] | ||
fn what_happened() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let index = Index::open(&dir.path().join("i.db")).unwrap(); | ||
let repo = json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z"}); | ||
index.put("did:a", nsid::REPO, "r", &repo).unwrap(); | ||
let claim = |rkey: &str, from: Option<&str>, to: Option<&str>, at: &str| { | ||
let mut c = | ||
json!({"repo": "k", "name": "refs/heads/main", "generation": 0, "createdAt": at}); | ||
if let Some(f) = from { | ||
c["from"] = json!(f); | ||
} | ||
if let Some(t) = to { | ||
c["to"] = json!(t); | ||
} | ||
index.put("did:a", nsid::CLAIM, rkey, &c).unwrap(); | ||
}; | ||
claim("k.m.0", None, Some("a1"), "2026-01-02T00:00:00Z"); | ||
claim("k.m.1", Some("a1"), Some("b2"), "2026-01-03T00:00:00Z"); | ||
// Of a repository the index does not know. | ||
index | ||
.put( | ||
"did:a", | ||
nsid::CLAIM, | ||
"x.m.0", | ||
&json!({"repo": "x", "name": "refs/heads/main", "createdAt": "2026-01-04T00:00:00Z"}), | ||
) | ||
.unwrap(); | ||
let pushes = index.pushes(10).unwrap(); | ||
assert_eq!( | ||
pushes, | ||
[ | ||
Push { | ||
repo: "at://did:a/dev.knotless.repo/r".into(), | ||
did: "did:a".into(), | ||
name: "r".into(), | ||
fork_of: None, | ||
refname: "refs/heads/main".into(), | ||
from: Some("a1".into()), | ||
to: Some("b2".into()), | ||
created_at: "2026-01-03T00:00:00Z".into(), | ||
}, | ||
Push { | ||
repo: "at://did:a/dev.knotless.repo/r".into(), | ||
did: "did:a".into(), | ||
name: "r".into(), | ||
fork_of: None, | ||
refname: "refs/heads/main".into(), | ||
from: None, | ||
to: Some("a1".into()), | ||
created_at: "2026-01-02T00:00:00Z".into(), | ||
}, | ||
] | ||
); | ||
let uri = "at://did:a/dev.knotless.repo/r"; | ||
let main = "refs/heads/main"; | ||
let tip = |at| index.tip_before(uri, main, at).unwrap(); | ||
assert_eq!(tip("2026-01-03T00:00:00Z"), Some("a1".into())); | ||
assert_eq!(tip("2026-01-02T00:00:00Z"), None); | ||
claim("k.m.2", Some("b2"), None, "2026-01-03T12:00:00Z"); | ||
assert_eq!(tip("2026-01-04T00:00:00Z"), None); | ||
index.delete("did:a", nsid::CLAIM, "k.m.2").unwrap(); | ||
// Compaction drops claims; the history stays. | ||
index.delete("did:a", nsid::CLAIM, "k.m.0").unwrap(); | ||
assert_eq!(index.pushes(10).unwrap().len(), 3); | ||
assert_eq!(index.pushes(1).unwrap().len(), 1); | ||
| ||
// Pulls, issues and what was said, newest first, without gates. | ||
let named = json!({"uri": "at://did:a/dev.knotless.repo/r", "key": "k"}); | ||
for (collection, rkey, at) in [ | ||
(nsid::ISSUE, "1", "2026-01-05"), | ||
(nsid::COMMENT, "2", "2026-01-07"), | ||
(nsid::GATE, "3", "2026-01-08"), | ||
(nsid::PULL, "4", "2026-01-06"), | ||
] { | ||
let value = json!({"repo": named, "createdAt": at}); | ||
index.put("did:b", collection, rkey, &value).unwrap(); | ||
} | ||
let recent = index.recent(10).unwrap(); | ||
let kinds: Vec<&str> = recent | ||
.iter() | ||
.map(|(e, _, _)| e.uri.rsplit('/').nth(1).unwrap()) | ||
.collect(); | ||
assert_eq!(kinds, [nsid::COMMENT, nsid::PULL, nsid::ISSUE]); | ||
assert_eq!((recent[0].1.as_str(), recent[0].2.as_str()), ("did:a", "k")); | ||
assert_eq!(index.recent(1).unwrap().len(), 1); | ||
| ||
// Gone with the repository. | ||
index.delete("did:a", nsid::REPO, "r").unwrap(); | ||
assert!(index.pushes(10).unwrap().is_empty()); | ||
} | ||
| ||
#[test] | ||
fn an_index_of_another_version_is_rebuilt() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
crates/knotless-serve/src/lib.rs+6−2
| @@ -327,8 +327,12 @@ pub async fn backfill(ctx: &Ctx, index: &index::Index, service: &str) -> Result< | ||
for did in dids { | ||
let url = RepoUrl::parse(&format!("knotless://{did}/_"))?; | ||
let remote = Remote::connect(ctx, &url).await?; | ||
let collections = | ||
std::iter::once(knotless::records::nsid::REPO).chain(knotless::records::nsid::FORGE); | ||
let collections = [ | ||
knotless::records::nsid::REPO, | ||
knotless::records::nsid::CLAIM, | ||
] | ||
.into_iter() | ||
.chain(knotless::records::nsid::FORGE); | ||
for collection in collections { | ||
for r in remote.list_all::<serde_json::Value>(collection).await? { | ||
index.put(&did, collection, &r.rkey, &r.value)?; | ||
crates/knotless-serve/src/objects.rs+50−14
| @@ -159,26 +159,52 @@ impl Objects { | ||
skipped += 1; | ||
continue; | ||
} | ||
let author = commit.author()?; | ||
let date = author | ||
.time()? | ||
.format(gix::date::time::format::ISO8601_STRICT)?; | ||
out.push(Commit { | ||
sha: commit.id.to_string(), | ||
parents: commit.parent_ids().map(|p| p.to_string()).collect(), | ||
author: Author { | ||
name: author.name.to_string(), | ||
email: author.email.to_string(), | ||
date, | ||
}, | ||
message: commit.message_raw()?.to_string().trim_end().to_string(), | ||
}); | ||
out.push(summary(&commit)?); | ||
if out.len() == limit { | ||
break; | ||
} | ||
} | ||
Ok(out) | ||
} | ||
| ||
/// What moving a ref to `to` brought: the commits it reaches that none | ||
/// of `known` does, newest first, at most `limit` of them. A revision | ||
/// in `known` that is not here is passed over. | ||
pub fn brought(&self, to: &str, known: &[&str], limit: usize) -> Result<Vec<Commit>> { | ||
let tip = self.commit(to)?.id; | ||
let hidden: Vec<gix::ObjectId> = known | ||
.iter() | ||
.filter_map(|k| self.commit(k).ok().map(|c| c.id)) | ||
.collect(); | ||
let walk = self | ||
.0 | ||
.rev_walk([tip]) | ||
.with_hidden(hidden) | ||
.sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)) | ||
.all()?; | ||
let mut out = Vec::new(); | ||
for info in walk.take(limit) { | ||
out.push(summary(&info?.object()?)?); | ||
} | ||
Ok(out) | ||
} | ||
} | ||
| ||
fn summary(commit: &gix::Commit<'_>) -> Result<Commit> { | ||
let author = commit.author()?; | ||
let date = author | ||
.time()? | ||
.format(gix::date::time::format::ISO8601_STRICT)?; | ||
Ok(Commit { | ||
sha: commit.id.to_string(), | ||
parents: commit.parent_ids().map(|p| p.to_string()).collect(), | ||
author: Author { | ||
name: author.name.to_string(), | ||
email: author.email.to_string(), | ||
date, | ||
}, | ||
message: commit.message_raw()?.to_string().trim_end().to_string(), | ||
}) | ||
} | ||
| ||
#[cfg(test)] | ||
| @@ -286,6 +312,16 @@ mod tests { | ||
assert!(o.log("nope", "", 0, 10).is_err()); | ||
assert!(open(&d.join("missing")).is_err()); | ||
| ||
// What a push of main brought, past side and a ref it never saw. | ||
assert_eq!( | ||
subjects(o.brought("main", &["side", "gone"], 10).unwrap()), | ||
["vendor", "Merge branch 'side'", "two"] | ||
); | ||
assert_eq!(o.brought("main", &[], 10).unwrap().len(), 5); | ||
assert_eq!(o.brought("main", &[], 2).unwrap().len(), 2); | ||
assert!(o.brought("main", &["main"], 10).unwrap().is_empty()); | ||
assert!(o.brought("nope", &[], 10).is_err()); | ||
| ||
run(vec!["checkout", "-q", "--detach"]); | ||
assert_eq!(open(&d.join(".git")).unwrap().head(), None); | ||
} | ||
crates/knotless-serve/src/web.rs+81−64
| @@ -29,9 +29,12 @@ use std::collections::HashMap; | ||
use std::fmt::Write; | ||
use std::sync::{Arc, LazyLock, Mutex}; | ||
| ||
mod feed; | ||
| ||
pub fn routes() -> Router<Arc<App>> { | ||
Router::new() | ||
.route("/", get(home)) | ||
.route("/start", get(start)) | ||
.route("/{authority}", get(profile)) | ||
.route("/{authority}/{repo}", get(code)) | ||
.route("/{authority}/{repo}/tree/{*rest}", get(tree)) | ||
| @@ -83,8 +86,8 @@ const COMMITS_PER_PAGE: usize = 50; | ||
const DIFF_ROWS: usize = 2000; | ||
| ||
const CSS: &str = r#" | ||
:root{color-scheme:light;--bg:#fff;--fg:#1c1d20;--mute:#6a6d75;--faint:#9a9ca3;--line:#e6e6e9;--soft:#f6f6f7;--panel:#fbfbfc;--accent:#2f6af5;--accent-soft:#eef3ff;--open:#1d8348;--merged:#7a48d6;--closed:#c2410c;--ins:#e9f8ee;--ins-n:#d3f2dd;--ins-x:#b2e8c4;--del:#fdeeee;--del-n:#fbdcdc;--del-x:#f6bcbc;--hunk:#f0f5ff;--target:#fff4c2;--comment:#8a8f98;--string:#0b7a4b;--keyword:#c0285e;--number:#a85d00;--function:#2b59c3;--type:#7a4fc2;--tag:#c0285e;--attr:#a85d00;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--r:10px} | ||
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0d0e10;--fg:#e8e8ea;--mute:#999ba3;--faint:#6b6e76;--line:#26282d;--soft:#17181b;--panel:#121316;--accent:#7aa2ff;--accent-soft:#18213a;--open:#4cc38a;--merged:#b197fc;--closed:#f0884f;--ins:#0f2418;--ins-n:#163421;--ins-x:#1f5232;--del:#2a1214;--del-n:#3b191c;--del-x:#6a2a2f;--hunk:#121b2e;--target:#3b3313;--comment:#6c717a;--string:#7dd3a8;--keyword:#ff8fb3;--number:#efb36a;--function:#8db3ff;--type:#c4a5ff;--tag:#ff8fb3;--attr:#efb36a}} | ||
:root{color-scheme:light;--bg:#fff;--fg:#171717;--mute:#636363;--faint:#8f8f8f;--line:#e8e8e8;--soft:#f4f4f4;--panel:#fafafa;--accent:#2f5fb3;--accent-soft:#eaeff7;--open:#1a7f37;--merged:#7048b5;--closed:#b35a14;--add:#1a7f37;--rem:#cf222e;--ins:#e6ffec;--ins-n:#ccffd8;--ins-x:#abf2bc;--del:#ffebe9;--del-n:#ffd7d5;--del-x:#ffb8b0;--hunk:#ddf4ff;--target:#fff8c5;--dir:#54aeff;--comment:#59636e;--string:#0a3069;--keyword:#cf222e;--number:#0550ae;--function:#6639ba;--type:#953800;--tag:#116329;--attr:#0550ae;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--r:4px} | ||
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#111;--fg:#ededed;--mute:#a1a1a1;--faint:#707070;--line:#262626;--soft:#1a1a1a;--panel:#161616;--accent:#7aa7f0;--accent-soft:#222935;--open:#3fb950;--merged:#b39af0;--closed:#e8a05c;--add:#3fb950;--rem:#f85149;--ins:rgba(46,160,67,.15);--ins-n:rgba(63,185,80,.3);--ins-x:rgba(46,160,67,.4);--del:rgba(248,81,73,.1);--del-n:rgba(248,81,73,.3);--del-x:rgba(248,81,73,.4);--hunk:rgba(56,139,253,.1);--target:#3b3313;--dir:#9198a1;--comment:#9198a1;--string:#a5d6ff;--keyword:#ff7b72;--number:#79c0ff;--function:#d2a8ff;--type:#ffa657;--tag:#7ee787;--attr:#79c0ff}} | ||
@view-transition{navigation:auto} | ||
*{box-sizing:border-box} | ||
html{-webkit-text-size-adjust:100%} | ||
| @@ -95,7 +98,6 @@ a:hover{color:var(--accent)} | ||
.top{border-bottom:1px solid var(--line);view-transition-name:top} | ||
.top .wrap{display:flex;align-items:center;gap:1.5rem;height:3.5rem} | ||
.brand{display:flex;align-items:center;gap:.55rem;font-weight:650;font-size:15px;letter-spacing:-.01em} | ||
.brand svg{width:22px;height:22px} | ||
.top nav{margin-left:auto;display:flex;gap:1.25rem;color:var(--mute)} | ||
main.wrap{flex:1;padding-top:1.75rem;padding-bottom:4rem} | ||
.foot{color:var(--faint);font-size:12.5px;padding-top:1.25rem;padding-bottom:2rem;border-top:1px solid var(--line)} | ||
| @@ -136,24 +138,11 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px} | ||
.sha{font:12px var(--mono);color:var(--mute);padding:.15rem .45rem;border:1px solid var(--line);border-radius:6px} | ||
.sha:hover{color:var(--accent)} | ||
.empty{padding:2.5rem 1rem;text-align:center;color:var(--mute)} | ||
.hero{padding:2.5rem 0 2.25rem;max-width:44rem} | ||
.hero h1{font-size:clamp(28px,5vw,40px);line-height:1.12;letter-spacing:-.03em;font-weight:650} | ||
.hero p{font-size:16px;color:var(--mute);margin-top:.9rem;max-width:36rem} | ||
.steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;margin-bottom:3rem} | ||
.step{border:1px solid var(--line);border-radius:var(--r);padding:1.1rem 1.1rem 1rem;background:var(--panel);min-width:0} | ||
.step h3{display:flex;align-items:center;gap:.55rem} | ||
.step h3 b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;background:var(--fg);color:var(--bg);font-size:11px} | ||
.step p{color:var(--mute);margin:.4rem 0 .8rem} | ||
.step details{margin-top:.6rem;color:var(--mute);font-size:12.5px} | ||
.step summary{cursor:pointer} | ||
.step details pre{margin-top:.5rem} | ||
.cmd{display:flex;align-items:flex-start;gap:.5rem;border:1px solid var(--line);border-radius:8px;background:var(--bg);padding:.5rem .5rem .5rem .75rem} | ||
.cmd+.cmd{margin-top:.5rem} | ||
.cmd pre{flex:1;min-width:0;margin:0;padding:.2rem 0;white-space:pre-wrap;overflow-wrap:anywhere} | ||
.cmd .btn{height:24px;padding:0 .5rem;font-size:11.5px} | ||
.section{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin:0 0 .9rem} | ||
.search{flex:1;min-width:12rem;max-width:22rem;margin-left:auto;font:inherit;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:.4rem .75rem;height:32px} | ||
.search:focus{outline:2px solid var(--accent);outline-offset:-1px} | ||
.repohead{display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem} | ||
.repohead h1{font-weight:500;display:flex;flex-wrap:wrap;align-items:center;gap:.35rem} | ||
.repohead h1 b{font-weight:650} | ||
| @@ -200,9 +189,8 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px} | ||
.commit-msg pre{white-space:pre-wrap;color:var(--mute);margin:.6rem 0 0;font-size:12.5px} | ||
.summary{margin:1.5rem 0 .9rem;color:var(--mute)} | ||
.summary b{color:var(--fg)} | ||
.add{color:var(--open)}.rem{color:#cf222e} | ||
@media (prefers-color-scheme:dark){.rem{color:#f47067}} | ||
.bars{display:inline-flex;gap:1px;vertical-align:-1px}.bars i{width:7px;height:7px;border-radius:1px;background:var(--line)}.bars i.a{background:var(--open)}.bars i.d{background:#cf222e} | ||
.add{color:var(--add)}.rem{color:var(--rem)} | ||
.bars{display:inline-flex;gap:1px;vertical-align:-1px}.bars i{width:7px;height:7px;border-radius:1px;background:var(--line)}.bars i.a{background:var(--add)}.bars i.d{background:var(--rem)} | ||
.file{border:1px solid var(--line);border-radius:var(--r);margin:0 0 1rem;overflow:hidden} | ||
.file>summary{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem .75rem;padding:.55rem 1rem;background:var(--panel);cursor:pointer;list-style:none;position:sticky;top:0} | ||
.file>summary::-webkit-details-marker{display:none} | ||
| @@ -248,18 +236,14 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px} | ||
.error{padding:4rem 0;text-align:center} | ||
.error h1{font-size:44px;letter-spacing:-.04em} | ||
.error p{color:var(--mute);margin-top:.5rem} | ||
@media (max-width:860px){.steps{grid-template-columns:minmax(0,1fr)}} | ||
@media (max-width:640px){ | ||
.av.xl{width:64px;height:64px;font-size:26px} | ||
.profile{gap:1rem;align-items:flex-start} | ||
.wrap{padding:0 1rem} | ||
main.wrap{padding-top:1.25rem} | ||
.hero{padding:1.25rem 0 1.75rem} | ||
.hero p{font-size:15px} | ||
.top nav{gap:1rem} | ||
.tabs a{padding:.55rem .5rem;gap:.35rem} | ||
.clone{width:100%} | ||
.search{max-width:none;min-width:100%} | ||
.prose{padding:1rem 1.1rem 1.25rem} | ||
.list li{align-items:flex-start} | ||
.list .sha,.list .hide-sm{display:none} | ||
| @@ -268,6 +252,46 @@ main.wrap{padding-top:1.25rem} | ||
.diff .n{min-width:2.25rem;padding:0 .35rem} | ||
.item-title{font-size:20px} | ||
} | ||
body{border-top:3px solid var(--fg)} | ||
.wrap{max-width:58rem} | ||
.top{border-bottom:0} | ||
.top .wrap{height:3.25rem} | ||
.brand{font:600 14px var(--mono)} | ||
.top nav{font:13px var(--mono)} | ||
.foot{font-family:var(--mono);font-size:11.5px;border-top:1px dashed var(--line)} | ||
.repohead h1,.profile h1{font-size:20px;font-weight:600;letter-spacing:-.01em} | ||
.tabs{gap:1.25rem;font-family:var(--mono);font-size:13px} | ||
.tabs svg{display:none} | ||
.tabs a{padding:.45rem 0;text-transform:lowercase} | ||
.boxhead{background:transparent} | ||
.list li:hover{background:var(--soft)} | ||
.files .dir svg.i{color:var(--dir)} | ||
.intro{max-width:44rem;padding:1.25rem 0 1rem;font-size:15px;line-height:1.6;color:var(--mute)} | ||
.intro b{color:var(--fg);font-weight:600} | ||
.intro a{color:var(--accent);text-decoration:underline;text-underline-offset:3px} | ||
.find input{font:13px var(--mono);color:inherit;background:transparent;border:0;border-bottom:1px solid var(--line);padding:.4rem 0;width:min(22rem,100%)} | ||
.find input:focus{outline:0;border-color:var(--accent)} | ||
.feed-day{font-size:13px;font-weight:600;color:var(--mute);margin:2rem 0 .5rem;padding-bottom:.35rem;border-bottom:1px solid var(--line);letter-spacing:0} | ||
.feed{list-style:none;margin:0;padding:0} | ||
.feed>li{display:flex;gap:1rem;padding:.45rem 0;align-items:baseline} | ||
.feed time{font:12px var(--mono);color:var(--faint);flex:none;width:2.75rem} | ||
.feed p{overflow-wrap:anywhere} | ||
.feed .who,.feed .repo{font-weight:600} | ||
.feed a:not(.who):not(.repo):not(.hash){text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px} | ||
.feed .ref{background:transparent;color:var(--mute);padding:0} | ||
.feed-note{color:var(--mute);font-size:13px;margin-top:.1rem} | ||
.commits{list-style:none;margin:.25rem 0 .1rem;padding:0 0 0 .9rem;border-left:1px solid var(--line)} | ||
.commits li{display:flex;align-items:baseline;gap:.7rem;font-size:13px;color:var(--mute);min-width:0} | ||
.commits .hash{font:12px var(--mono);color:var(--accent);flex:none} | ||
.commits .rest{display:block;font:12px var(--mono)} | ||
.start{max-width:40rem;margin-top:1rem} | ||
.start h1{font-size:18px;font-weight:600;margin:0 0 .4rem} | ||
.start .lede{color:var(--mute);margin:0 0 1.5rem} | ||
.start>p:not(.lede){font:12.5px var(--mono);color:var(--faint);margin:.9rem 0 .35rem} | ||
.start .cmd{background:var(--panel)} | ||
.results h1{font-size:18px;font-weight:600;margin:1rem 0} | ||
.results .find{margin-bottom:1.25rem} | ||
@media (max-width:640px){.feed>li{flex-direction:column;gap:.1rem}.feed time{width:auto}} | ||
::highlight(comment){color:var(--comment)}::highlight(string){color:var(--string)}::highlight(keyword){color:var(--keyword)}::highlight(number){color:var(--number)}::highlight(function){color:var(--function)}::highlight(type){color:var(--type)}::highlight(tag){color:var(--tag)}::highlight(attr){color:var(--attr)} | ||
::highlight(xins){background-color:var(--ins-x)}::highlight(xdel){background-color:var(--del-x)}::highlight(target){background-color:var(--target)} | ||
"#; | ||
| @@ -289,9 +313,6 @@ static POLICY: LazyLock<String> = LazyLock::new(|| { | ||
) | ||
}); | ||
| ||
/// The mark: two commits on one straight thread. | ||
const LOGO: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6.5" fill="currentColor"/><path d="M7 12h10" stroke="var(--bg)" stroke-width="2" stroke-linecap="round"/><circle cx="7" cy="12" r="2.6" fill="var(--bg)"/><circle cx="17" cy="12" r="2.6" fill="var(--bg)"/></svg>"#; | ||
| ||
/// Icons, each a 16-unit path. | ||
mod icon { | ||
pub const CODE: &str = "M4.7 3.3a.75.75 0 0 1 0 1.06L1.06 8l3.64 3.64a.75.75 0 1 1-1.06 1.06L-.53 8.53a.75.75 0 0 1 0-1.06L3.64 3.3a.75.75 0 0 1 1.06 0Zm6.6 0a.75.75 0 0 1 1.06 0l4.17 4.17a.75.75 0 0 1 0 1.06l-4.17 4.17a.75.75 0 1 1-1.06-1.06L14.94 8 11.3 4.36a.75.75 0 0 1 0-1.06Z"; | ||
| @@ -304,7 +325,6 @@ mod icon { | ||
pub const FILE: &str = "M2 1.75C2 .78 2.78 0 3.75 0h6.59c.46 0 .9.18 1.23.51l2.92 2.92c.33.33.51.77.51 1.23v9.59A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .14.11.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.06V4.25c0 .14.11.25.25.25h2.69L10.5 1.56Z"; | ||
pub const BRANCH: &str = "M9.5 3.25a2.25 2.25 0 1 1 3 2.12V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.13a2.25 2.25 0 1 1-1.5 0V5.37a2.25 2.25 0 1 1 1.5 0v1.84A2.49 2.49 0 0 1 6 7h4a1 1 0 0 0 1-1v-.63a2.25 2.25 0 0 1-1.5-2.12ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"; | ||
pub const CHAT: &str = "M1 2.75C1 1.78 1.78 1 2.75 1h10.5c.97 0 1.75.78 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.57 2.57A1.46 1.46 0 0 1 4 13.54V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .14.11.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.75.75 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"; | ||
pub const REPO: &str = "M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.71 1.7.75.75 0 1 1-1.08 1.05A2.5 2.5 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.71A2.49 2.49 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.09a.25.25 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"; | ||
pub const FORK: &str = "M5 5.37v.88c0 .41.34.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.88a2.25 2.25 0 1 1 1.5 0v.88A2.25 2.25 0 0 1 10.25 8.5h-1.5v2.13a2.25 2.25 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.88a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"; | ||
} | ||
| ||
| @@ -412,7 +432,7 @@ fn command(text: &str) -> String { | ||
| ||
fn layout(title: &str, body: &str) -> String { | ||
format!( | ||
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">{LOGO}<span>knotless</span></a><nav><a href="/#repositories">Repositories</a><a href="/#connect">Connect</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">Git repositories stored in atproto accounts. This server only reads them.</footer><script>{SCRIPT}</script></body></html>"#, | ||
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">~ knotless</a><nav><a href="/">feed</a><a href="/?q=">repos</a><a href="/start">getting started</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">every repository here is records on its owner’s PDS. this server only reads them.</footer><script>{SCRIPT}</script></body></html>"#, | ||
esc(title) | ||
) | ||
} | ||
| @@ -748,47 +768,45 @@ pub struct HomeQuery { | ||
q: Option<String>, | ||
} | ||
| ||
/// What knotless is, how to start, and the repositories the index knows. | ||
async fn home( | ||
State(app): State<Arc<App>>, | ||
headers: HeaderMap, | ||
Query(query): Query<HomeQuery>, | ||
) -> Result<Response> { | ||
/// What happened on the repositories the index knows; | ||
/// or, asked for `q`, the repositories whose name or description holds it. | ||
async fn home(State(app): State<Arc<App>>, Query(query): Query<HomeQuery>) -> Result<Response> { | ||
let body = match (&app.index, query.q) { | ||
(Some(index), Some(q)) => found(&app, index, q.trim()).await?, | ||
(index, _) => feed::home(&app, index.as_deref()).await?, | ||
}; | ||
Ok(page(StatusCode::OK, "no-cache", "knotless", &body)) | ||
} | ||
| ||
/// How to start, with clone commands for this server. | ||
async fn start(headers: HeaderMap) -> Response { | ||
let host = headers | ||
.get(header::HOST) | ||
.and_then(|h| h.to_str().ok()) | ||
.unwrap_or("localhost"); | ||
let mcp = r#"{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }"#; | ||
page( | ||
StatusCode::OK, | ||
"no-cache", | ||
"getting started · knotless", | ||
&feed::start(host), | ||
) | ||
} | ||
| ||
/// The repositories whose name or description holds `q`, newest first. | ||
async fn found(app: &App, index: &crate::index::Index, q: &str) -> Result<String> { | ||
let heading = match q { | ||
"" => "repositories".to_string(), | ||
q => format!("repositories matching “{}”", esc(q)), | ||
}; | ||
let mut body = format!( | ||
r#"<section class="hero"><h1>Git that lives in your atproto account.</h1><p>Each repository is a few records on your own PDS: no git server to run, nothing to install on the PDS. Clone and push with plain git, and let agents read, commit, review and merge over MCP.</p></section> | ||
<section class="steps" id="connect"> | ||
<div class="step"><h3><b>1</b>Install and log in</h3><p>From a checkout of knotless. Use an app password.</p>{}{}</div> | ||
<div class="step"><h3><b>2</b>Push</h3><p>Any repository, to any account on any PDS.</p>{}{}</div> | ||
<div class="step"><h3><b>3</b>Connect your agent</h3><p>The MCP server acts as the account you logged in as.</p>{}<details><summary>Other MCP clients</summary><p>Add this to the client's MCP settings, such as Claude Desktop's or Cursor's:</p>{}</details></div> | ||
</section>"#, | ||
command("cargo install --path crates/knotless\ncargo install --path crates/knotless-mcp"), | ||
command("knotless login you.bsky.social"), | ||
command("git push knotless://you.bsky.social/project main"), | ||
command(&format!("git clone http://{host}/you.bsky.social/project")), | ||
command("claude mcp add knotless -- knotless-mcp"), | ||
command(mcp), | ||
r#"<section class="results"><h1>{heading}</h1><form action="/" role="search" class="find"><input type="search" name="q" value="{}" placeholder="find a repository" aria-label="Find a repository"></form><div class="box"><ul class="list">"#, | ||
esc(q) | ||
); | ||
let Some(index) = &app.index else { | ||
return Ok(page(StatusCode::OK, "no-cache", "knotless", &body)); | ||
}; | ||
let q = query.q.unwrap_or_default(); | ||
write!( | ||
body, | ||
r#"<section id="repositories"><div class="section"><h2>Repositories</h2><form action="/" role="search" class="search-form"><input class="search" type="search" name="q" value="{}" placeholder="Find a repository" aria-label="Find a repository"></form></div>"#, | ||
esc(&q) | ||
) | ||
.unwrap(); | ||
let repos = index | ||
.repos(&q, 50) | ||
.repos(q, 50) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let owners = futures::future::join_all(repos.iter().map(|r| handle(&app, &r.did))).await; | ||
body.push_str(r#"<div class="box"><ul class="list">"#); | ||
let owners = futures::future::join_all(repos.iter().map(|r| handle(app, &r.did))).await; | ||
for (r, owner) in repos.iter().zip(owners) { | ||
write!( | ||
body, | ||
| @@ -806,8 +824,8 @@ async fn home( | ||
if repos.is_empty() { | ||
body.push_str(r#"<li class="empty">No repositories found.</li>"#); | ||
} | ||
body.push_str("</ul></div></section>"); | ||
Ok(page(StatusCode::OK, "no-cache", "knotless", &body)) | ||
body.push_str(r#"</ul></div></section>"#); | ||
Ok(body) | ||
} | ||
| ||
/// An account: who it is, as its Bluesky profile says if it has one, and | ||
| @@ -868,8 +886,7 @@ async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) -> | ||
}); | ||
write!( | ||
body, | ||
r#"<li>{}<div class="grow"><a class="title" href="{href}">{}</a><div class="meta">{}</div>{}</div><span class="mute hide-sm">{}</span></li>"#, | ||
svg(icon::REPO), | ||
r#"<li><div class="grow"><a class="title" href="{href}">{}</a><div class="meta">{}</div>{}</div><span class="mute hide-sm">{}</span></li>"#, | ||
esc(&r.value.name), | ||
esc(r.value.description.as_deref().unwrap_or("No description")), | ||
from.unwrap_or_default(), | ||
crates/knotless-serve/src/web/feed.rsnew+570−0
| @@ -0,0 +1,570 @@ | ||
//! The front page: what happened on the repositories the index knows, | ||
//! newest first and a day at a time, and how to start. | ||
| ||
use super::*; | ||
use crate::index::Index; | ||
use knotless::records::nsid; | ||
use std::collections::HashSet; | ||
| ||
/// How far back the feed reaches, in each kind of thing that happens. | ||
const REACH: usize = 40; | ||
/// Commits listed under a push; the rest are counted. | ||
const LISTED: usize = 5; | ||
/// Commits a push is walked for: past this, it says only that there were | ||
/// more. | ||
const WALKED: usize = 100; | ||
| ||
/// Something `who` did to `repo`, shown as `handle/name`, and its page. | ||
struct Event { | ||
at: String, | ||
who: String, | ||
repo: (String, String), | ||
what: What, | ||
} | ||
| ||
enum What { | ||
/// A repository made, or forked from another. | ||
Created { | ||
description: Option<String>, | ||
from: Option<(String, String)>, | ||
}, | ||
/// A ref made or moved, and the commits that brought. | ||
Pushed { | ||
refname: String, | ||
new: bool, | ||
commits: Vec<read::Commit>, | ||
}, | ||
Deleted { | ||
refname: String, | ||
}, | ||
Opened { | ||
kind: &'static str, | ||
title: String, | ||
href: String, | ||
}, | ||
Said { | ||
kind: &'static str, | ||
title: String, | ||
href: String, | ||
body: String, | ||
verdict: Option<String>, | ||
}, | ||
Changed { | ||
kind: &'static str, | ||
title: String, | ||
href: String, | ||
state: String, | ||
}, | ||
} | ||
| ||
const SEARCH: &str = r#"<form action="/" role="search" class="find"><input type="search" name="q" placeholder="find a repository" aria-label="Find a repository"></form>"#; | ||
| ||
/// The page: what knotless is, and what happened. | ||
pub(super) async fn home(app: &App, index: Option<&Index>) -> Result<String> { | ||
let mut body = String::from( | ||
r##"<section class="intro"><p><b>knotless</b> keeps git repositories as records in your atproto account. push with plain git, clone from any PDS, and let agents commit over MCP. <a href="/start">getting started →</a></p></section>"##, | ||
); | ||
if let Some(index) = index { | ||
body.push_str(SEARCH); | ||
let events = events(app, index).await?; | ||
for (day, events) in by_day(&events) { | ||
write!( | ||
body, | ||
r#"<h2 class="feed-day">{}</h2><ul class="feed">"#, | ||
weekday(day) | ||
) | ||
.unwrap(); | ||
for e in events { | ||
write!( | ||
body, | ||
r#"<li><time datetime="{}">{}</time><div class="grow"><p><a class="who" href="/{}">{}</a> {}</p>{}</div></li>"#, | ||
esc(&e.at), | ||
esc(e.at.get(11..16).unwrap_or_default()), | ||
url_path(&e.who), | ||
esc(&e.who), | ||
sentence(e), | ||
detail(e) | ||
) | ||
.unwrap(); | ||
} | ||
body.push_str("</ul>"); | ||
} | ||
body.push_str(r#"<p class="feed-note">pushes, pulls and issues show up here as they happen, from any account on any PDS.</p>"#); | ||
} | ||
Ok(body) | ||
} | ||
| ||
/// How to start: install, log in, push, clone and connect an agent. | ||
pub(super) fn start(host: &str) -> String { | ||
let mut body = String::from( | ||
r#"<section class="start"><h1>getting started</h1><p class="lede">knotless works with any atmosphere account. log in with an app password.</p>"#, | ||
); | ||
let mcp = r#"{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }"#; | ||
for (say, run) in [ | ||
( | ||
"install, from a checkout of knotless", | ||
"cargo install --path crates/knotless\ncargo install --path crates/knotless-mcp", | ||
), | ||
( | ||
"log in with your handle and an app password", | ||
"knotless login you.bsky.social", | ||
), | ||
( | ||
"push any repository, to any account on any PDS", | ||
"git push knotless://you.bsky.social/project main", | ||
), | ||
( | ||
"or clone one without knotless installed", | ||
&format!("git clone http://{host}/you.bsky.social/project"), | ||
), | ||
( | ||
"connect an agent: it acts as the account you logged in as", | ||
"claude mcp add knotless -- knotless-mcp", | ||
), | ||
("other MCP clients take this in their settings", mcp), | ||
] { | ||
write!(body, "<p># {say}</p>{}", command(run)).unwrap(); | ||
} | ||
body.push_str("</section>"); | ||
body | ||
} | ||
| ||
/// `handle/name`, and its page. | ||
fn named(owner: &str, name: &str) -> (String, String) { | ||
( | ||
format!("{owner}/{name}"), | ||
format!("/{}/{}", url_path(owner), url_path(name)), | ||
) | ||
} | ||
| ||
/// The owner and name of the repository `uri` names. | ||
fn owner_and_name(uri: &str) -> Option<(String, String)> { | ||
let (did, _, name) = forge::parse_uri(uri).ok()?; | ||
Some((did.to_string(), name.to_string())) | ||
} | ||
| ||
/// `pull` or `issue`, for the record at `uri`. | ||
fn kind(uri: &str) -> &'static str { | ||
if uri.contains(&format!("/{}/", nsid::PULL)) { | ||
"pull" | ||
} else { | ||
"issue" | ||
} | ||
} | ||
| ||
/// What happened, newest first: repositories made, refs moved, and what | ||
/// was opened, said and decided on them. | ||
async fn events(app: &App, index: &Index) -> Result<Vec<Event>> { | ||
let repos = index | ||
.repos("", REACH) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let pushes = index | ||
.pushes(REACH) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let said = index | ||
.recent(REACH) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
| ||
// Each account named, resolved together. | ||
let parents: Vec<(String, String)> = repos | ||
.iter() | ||
.filter_map(|r| r.fork_of.as_deref()) | ||
.filter_map(owner_and_name) | ||
.collect(); | ||
let mut dids: HashSet<&str> = parents.iter().map(|(did, _)| did.as_str()).collect(); | ||
dids.extend(repos.iter().map(|r| r.did.as_str())); | ||
dids.extend(pushes.iter().map(|p| p.did.as_str())); | ||
dids.extend( | ||
said.iter() | ||
.flat_map(|(e, did, _)| [e.author.as_str(), did.as_str()]), | ||
); | ||
let handles: HashMap<&str, String> = futures::future::join_all( | ||
dids.into_iter() | ||
.map(|did| async move { (did, handle(app, did).await) }), | ||
) | ||
.await | ||
.into_iter() | ||
.collect(); | ||
let h = |did: &str| handles[did].clone(); | ||
| ||
let mut events: Vec<Event> = repos | ||
.iter() | ||
.map(|r| Event { | ||
at: r.created_at.clone(), | ||
who: h(&r.did), | ||
repo: named(&h(&r.did), &r.name), | ||
what: What::Created { | ||
description: r.description.clone(), | ||
from: r | ||
.fork_of | ||
.as_deref() | ||
.and_then(owner_and_name) | ||
.map(|(did, name)| named(&h(&did), &name)), | ||
}, | ||
}) | ||
.collect(); | ||
| ||
// The mirror of each repository pushed to, opened once. | ||
let wanted: HashSet<(String, String)> = pushes | ||
.iter() | ||
.map(|p| (p.did.clone(), p.name.clone())) | ||
.collect(); | ||
let mirrors: HashMap<(String, String), Repo> = | ||
futures::future::join_all(wanted.into_iter().map(|at| async move { | ||
let repo = Repo::open(app, &at.0, &at.1).await; | ||
(at, repo) | ||
})) | ||
.await | ||
.into_iter() | ||
.filter_map(|(at, repo)| Some((at, repo.ok()?))) | ||
.collect(); | ||
for p in &pushes { | ||
let repo = mirrors.get(&(p.did.clone(), p.name.clone())); | ||
let refname = p | ||
.refname | ||
.strip_prefix("refs/heads/") | ||
.or_else(|| p.refname.strip_prefix("refs/tags/")) | ||
.unwrap_or(&p.refname) | ||
.to_string(); | ||
let what = match &p.to { | ||
None => What::Deleted { refname }, | ||
Some(to) => { | ||
// A ref that moved brought what it did not reach before; a | ||
// new one, what the default branch did not reach just | ||
// before, nor the default branch of a fork's source, which | ||
// a fork's is named for. | ||
let head = repo.map(|r| r.head.as_str()).unwrap_or_default(); | ||
let branch = format!("refs/heads/{head}"); | ||
let before = |at: &str| index.tip_before(at, &branch, &p.created_at); | ||
let known: Vec<String> = match &p.from { | ||
Some(from) => vec![from.clone()], | ||
None => [ | ||
(head != refname).then_some(p.repo.as_str()), | ||
p.fork_of.as_deref(), | ||
] | ||
.into_iter() | ||
.flatten() | ||
.map(before) | ||
.collect::<rusqlite::Result<Vec<_>>>() | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)? | ||
.into_iter() | ||
.flatten() | ||
.collect(), | ||
}; | ||
let known: Vec<&str> = known.iter().map(String::as_str).collect(); | ||
let commits = repo | ||
.and_then(|r| r.objects().ok()) | ||
.and_then(|o| o.brought(to, &known, WALKED).ok()) | ||
.unwrap_or_default(); | ||
// A fork's copy of its source's refs is the fork itself. | ||
if p.fork_of.is_some() && p.from.is_none() && commits.is_empty() { | ||
continue; | ||
} | ||
What::Pushed { | ||
refname, | ||
new: p.from.is_none(), | ||
commits, | ||
} | ||
} | ||
}; | ||
let who = h(&p.did); | ||
events.push(Event { | ||
at: p.created_at.clone(), | ||
repo: named(&who, &p.name), | ||
who, | ||
what, | ||
}); | ||
} | ||
| ||
// Pulls and issues, as their repositories' owners let them stand, by | ||
// title and page. | ||
let mut titles: HashMap<String, (String, String)> = HashMap::new(); | ||
let on: HashSet<(&str, &str)> = said | ||
.iter() | ||
.map(|(_, did, key)| (did.as_str(), key.as_str())) | ||
.collect(); | ||
let mut pages = HashMap::new(); | ||
for (did, key) in on { | ||
let name = index | ||
.name(did, key) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)? | ||
.unwrap_or_default(); | ||
let page = named(&h(did), &name); | ||
let entries = index | ||
.forge(did, key) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
for e in entries { | ||
let (author, collection, rkey) = forge::parse_uri(&e.uri).unwrap_or_default(); | ||
if !matches!(collection, nsid::PULL | nsid::ISSUE) { | ||
continue; | ||
} | ||
let href = format!("{}/{}/{author}/{rkey}", page.1, kind(&e.uri)); | ||
let title = e.value["title"].as_str().unwrap_or_default().to_string(); | ||
titles.insert(e.uri, (title, href)); | ||
} | ||
pages.insert((did, key), page); | ||
} | ||
for (e, did, key) in &said { | ||
let page = &pages[&(did.as_str(), key.as_str())]; | ||
let subject = e.value["subject"].as_str().unwrap_or_default(); | ||
let shown = titles.contains_key(&e.uri) || titles.contains_key(subject); | ||
let text = |field: &str| e.value[field].as_str().unwrap_or_default().to_string(); | ||
let (title, href) = titles | ||
.get(&e.uri) | ||
.or_else(|| titles.get(subject)) | ||
.cloned() | ||
.unwrap_or_default(); | ||
let what = match forge::parse_uri(&e.uri).unwrap_or_default().1 { | ||
nsid::PULL | nsid::ISSUE => What::Opened { | ||
kind: kind(&e.uri), | ||
title, | ||
href, | ||
}, | ||
nsid::COMMENT => What::Said { | ||
kind: kind(subject), | ||
title, | ||
href, | ||
body: text("body"), | ||
verdict: e.value["verdict"].as_str().map(str::to_string), | ||
}, | ||
_ => What::Changed { | ||
kind: kind(subject), | ||
title, | ||
href, | ||
state: text("state"), | ||
}, | ||
}; | ||
let event = Event { | ||
at: text("createdAt"), | ||
who: h(&e.author), | ||
repo: page.clone(), | ||
what, | ||
}; | ||
events.extend(shown.then_some(event)); | ||
} | ||
events.sort_by(|a, b| b.at.cmp(&a.at)); | ||
events.truncate(REACH); | ||
Ok(events) | ||
} | ||
| ||
/// Events by the day they happened on, as `2026-10-09`, in order. | ||
fn by_day(events: &[Event]) -> Vec<(&str, Vec<&Event>)> { | ||
let mut days: Vec<(&str, Vec<&Event>)> = Vec::new(); | ||
for e in events { | ||
let on = e.at.get(..10).unwrap_or_default(); | ||
match days.last_mut() { | ||
Some((day, list)) if *day == on => list.push(e), | ||
_ => days.push((on, vec![e])), | ||
} | ||
} | ||
days | ||
} | ||
| ||
/// What the event says `who` did, as HTML. | ||
fn sentence(e: &Event) -> String { | ||
let repo = format!( | ||
r#"<a class="repo" href="{}">{}</a>"#, | ||
e.repo.1, | ||
esc(&e.repo.0) | ||
); | ||
let item = |title: &str, href: &str| format!(r#"<a href="{href}">{}</a>"#, esc(title)); | ||
let branch = |name: &str| format!(r#"<span class="ref">{}</span>"#, esc(name)); | ||
match &e.what { | ||
What::Created { | ||
from: Some((from, href)), | ||
.. | ||
} => format!( | ||
r#"forked <a class="repo" href="{href}">{}</a> to {repo}"#, | ||
esc(from) | ||
), | ||
What::Created { from: None, .. } => format!("created {repo}"), | ||
What::Pushed { | ||
refname, new: true, .. | ||
} => format!("created {} on {repo}", branch(refname)), | ||
What::Pushed { | ||
refname, commits, .. | ||
} => format!( | ||
"pushed {} to {} on {repo}", | ||
count(commits.len()), | ||
branch(refname) | ||
), | ||
What::Deleted { refname } => format!("deleted {} on {repo}", branch(refname)), | ||
What::Opened { kind, title, href } => { | ||
format!("opened {kind} {} on {repo}", item(title, href)) | ||
} | ||
What::Said { | ||
kind, | ||
title, | ||
href, | ||
verdict, | ||
.. | ||
} => format!( | ||
"{} {kind} {} on {repo}", | ||
said(verdict.as_deref()), | ||
item(title, href) | ||
), | ||
What::Changed { | ||
kind, | ||
title, | ||
href, | ||
state, | ||
} => format!("{} {kind} {} on {repo}", changed(state), item(title, href)), | ||
} | ||
} | ||
| ||
/// How many commits a push brought, in words. | ||
fn count(n: usize) -> String { | ||
match n { | ||
0 => "nothing new".into(), | ||
1 => "1 commit".into(), | ||
WALKED => format!("{WALKED}+ commits"), | ||
n => format!("{n} commits"), | ||
} | ||
} | ||
| ||
/// What a comment with `verdict` did. | ||
fn said(verdict: Option<&str>) -> &'static str { | ||
match verdict { | ||
Some("approve") => "approved", | ||
Some("changes") => "asked for changes on", | ||
_ => "commented on", | ||
} | ||
} | ||
| ||
/// What moving to `state` did. | ||
fn changed(state: &str) -> &str { | ||
match state { | ||
"open" => "reopened", | ||
s => s, | ||
} | ||
} | ||
| ||
/// What the event shows under its sentence: a repository's description, | ||
/// the commits a push brought, or the start of what was said. | ||
fn detail(e: &Event) -> String { | ||
match &e.what { | ||
What::Created { | ||
description: Some(d), | ||
.. | ||
} => format!(r#"<p class="feed-note">{}</p>"#, esc(d)), | ||
What::Pushed { commits, .. } => { | ||
let lines: String = commits | ||
.iter() | ||
.take(LISTED) | ||
.map(|c| { | ||
format!( | ||
r#"<li><a class="hash" href="{}/commit/{}">{}</a><span class="clip">{}</span></li>"#, | ||
e.repo.1, | ||
c.sha, | ||
&c.sha[..7], | ||
esc(subject(&c.message)) | ||
) | ||
}) | ||
.collect(); | ||
let rest = if commits.len() > LISTED { | ||
format!( | ||
r#"<li class="rest">and {} more</li>"#, | ||
commits.len() - LISTED | ||
) | ||
} else { | ||
String::new() | ||
}; | ||
if commits.is_empty() { | ||
String::new() | ||
} else { | ||
format!(r#"<ul class="commits">{lines}{rest}</ul>"#) | ||
} | ||
} | ||
What::Said { body, .. } => { | ||
format!(r#"<p class="feed-note">“{}”</p>"#, esc(&excerpt(body, 120))) | ||
} | ||
_ => String::new(), | ||
} | ||
} | ||
| ||
/// `2026-10-09` as `friday, october 9`. | ||
fn weekday(iso: &str) -> String { | ||
const MONTHS: [&str; 12] = [ | ||
"january", | ||
"february", | ||
"march", | ||
"april", | ||
"may", | ||
"june", | ||
"july", | ||
"august", | ||
"september", | ||
"october", | ||
"november", | ||
"december", | ||
]; | ||
// From 1970-01-01, a Thursday. | ||
const DAYS: [&str; 7] = [ | ||
"thursday", | ||
"friday", | ||
"saturday", | ||
"sunday", | ||
"monday", | ||
"tuesday", | ||
"wednesday", | ||
]; | ||
let num = |at: std::ops::Range<usize>| iso.get(at).and_then(|s| s.parse::<i64>().ok()); | ||
let (Some(y), Some(m @ 1..=12), Some(d)) = (num(0..4), num(5..7), num(8..10)) else { | ||
return iso.to_string(); | ||
}; | ||
// Days since the epoch, as civil_from_days in reverse. | ||
let (y, mp) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; | ||
let era = y.div_euclid(400); | ||
let yoe = y - era * 400; | ||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + (153 * mp + 2) / 5 + d - 1; | ||
let days = era * 146_097 + doe - 719_468; | ||
format!( | ||
"{}, {} {d}", | ||
DAYS[days.rem_euclid(7) as usize], | ||
MONTHS[(m - 1) as usize] | ||
) | ||
} | ||
| ||
/// The first paragraph of Markdown, as plain text, cut short. | ||
fn excerpt(text: &str, max: usize) -> String { | ||
let first = text.split("\n\n").next().unwrap_or_default(); | ||
let plain: String = first | ||
.chars() | ||
.map(|c| if c == '\n' { ' ' } else { c }) | ||
.filter(|c| !matches!(c, '`' | '*')) | ||
.collect(); | ||
match plain.char_indices().nth(max) { | ||
Some((at, _)) => format!("{}…", plain[..at].trim_end()), | ||
None => plain, | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn days_and_words() { | ||
assert_eq!(weekday("2026-10-09T10:00:00Z"), "friday, october 9"); | ||
assert_eq!(weekday("1970-01-01"), "thursday, january 1"); | ||
assert_eq!(weekday("2024-02-29"), "thursday, february 29"); | ||
assert_eq!(weekday("1969-12-31"), "wednesday, december 31"); | ||
assert_eq!(weekday("2026-13-01"), "2026-13-01"); | ||
assert_eq!(weekday("soon"), "soon"); | ||
assert_eq!(count(0), "nothing new"); | ||
assert_eq!(count(1), "1 commit"); | ||
assert_eq!(count(7), "7 commits"); | ||
assert_eq!(count(WALKED), "100+ commits"); | ||
assert_eq!(excerpt("one\ntwo `x`\n\nthree", 50), "one two x"); | ||
assert_eq!(excerpt("abcdef ghij", 7), "abcdef…"); | ||
assert_eq!(kind("at://did:a/dev.knotless.pull/1"), "pull"); | ||
assert_eq!(kind("at://did:a/dev.knotless.issue/1"), "issue"); | ||
assert_eq!(owner_and_name("nope"), None); | ||
} | ||
} | ||
crates/knotless-serve/tests/web.rs+111−5
| @@ -120,8 +120,8 @@ fn browsing_a_repository() { | ||
let h = &s.account.handle; | ||
let repo = format!("/{h}/browsed"); | ||
| ||
// The front page finds it once the index has seen it. | ||
wait_until(|| site.page("/").contains("<b>browsed</b></a>")); | ||
// The search finds it once the index has seen it. | ||
wait_until(|| site.page("/?q=").contains("<b>browsed</b></a>")); | ||
assert!(site.page("/?q=BROWS").contains("<b>browsed</b></a>")); | ||
assert!(site.page("/?q=zzzz").contains("No repositories found.")); | ||
| ||
| @@ -487,10 +487,116 @@ fn pulls_and_issues() { | ||
// Without an index, there is no knowing. | ||
let bare = serve(false); | ||
let front = bare.page("/"); | ||
assert!(front.contains("Git that lives in your atproto account.")); | ||
assert!(front.contains("claude mcp add knotless -- knotless-mcp")); | ||
assert!(!front.contains("Find a repository")); | ||
assert!(front.contains("keeps git repositories as records")); | ||
assert!(front.contains(r#"href="/start">getting started"#)); | ||
let start = bare.page("/start"); | ||
assert!(start.contains("claude mcp add knotless -- knotless-mcp")); | ||
assert!(start.contains(&format!("git clone {}/you.bsky.social/project", bare.base))); | ||
assert!(!front.contains("Find a repository") && !front.contains(r#"class="feed""#)); | ||
let (status, body) = bare.status(&format!("{repo}/pulls")); | ||
assert_eq!(status, 404); | ||
assert!(body.contains("keeps no index")); | ||
} | ||
| ||
#[test] | ||
fn the_feed() { | ||
let site = serve(true); | ||
let owner = person(account(), &site); | ||
let other = person(account(), &site); | ||
let (o, t) = (&owner.account.handle, &other.account.handle); | ||
let repo = format!(r#"<a class="repo" href="/{o}/fed">{o}/fed</a>"#); | ||
let fork_repo = format!(r#"<a class="repo" href="/{t}/fed">{t}/fed</a>"#); | ||
// What the feed shows once it shows `last`: other tests' pushes and | ||
// pulls share it, so each part is looked for while it is recent. | ||
let shows = |last: &str, wants: &[String]| { | ||
wait_until(|| site.page("/").contains(last)); | ||
let feed = site.page("/"); | ||
for want in wants { | ||
assert!(feed.contains(want), "{want} not in\n{feed}"); | ||
} | ||
feed | ||
}; | ||
| ||
let url = owner.url("fed"); | ||
ok( | ||
&owner, | ||
&["create", &url, "--description", "Fed <to> the feed"], | ||
); | ||
let work = owner.work("w", 1); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
// A push of several commits lists the first few and counts the rest; | ||
// one back to where it was brings nothing. | ||
for i in 0..6 { | ||
owner.commit_file(&work, "f", &format!("{i}\n")); | ||
} | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
owner.git(&work, &["push", "-q", "-f", &url, "HEAD~2:main"]); | ||
owner.git(&work, &["checkout", "-q", "-b", "side"]); | ||
owner.commit_file(&work, "s", "s\n"); | ||
owner.git(&work, &["push", "-q", &url, "side"]); | ||
owner.git(&work, &["tag", "v1"]); | ||
owner.git(&work, &["push", "-q", &url, "v1"]); | ||
owner.git(&work, &["push", "-q", &url, ":side"]); | ||
shows( | ||
&format!(r#"deleted <span class="ref">side</span> on {repo}"#), | ||
&[ | ||
format!("created {repo}"), | ||
"<p class=\"feed-note\">Fed <to> the feed</p>".into(), | ||
format!(r#"pushed 6 commits to <span class="ref">main</span> on {repo}"#), | ||
r#"<li class="rest">and 1 more</li>"#.into(), | ||
format!(r#"pushed nothing new to <span class="ref">main</span> on {repo}"#), | ||
format!(r#"created <span class="ref">side</span> on {repo}"#), | ||
format!(r#"created <span class="ref">v1</span> on {repo}"#), | ||
r#"<h2 class="feed-day">"#.into(), | ||
], | ||
); | ||
| ||
let fork = ok(&other, &["fork", &url]); | ||
let clone = other.path("clone"); | ||
other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]); | ||
other.commit_file(&clone, "b", "b\n"); | ||
other.git(&clone, &["push", "-q", "origin", "main:topic"]); | ||
let feed = shows( | ||
&format!(r#"created <span class="ref">topic</span> on {fork_repo}"#), | ||
&[ | ||
format!("forked {repo} to {fork_repo}"), | ||
format!(r#"<a class="who" href="/{t}">{t}</a>"#), | ||
], | ||
); | ||
// A fork's copy of its source's branches is the fork itself. | ||
assert!(!feed.contains(&format!(r#"<span class="ref">main</span> on {fork_repo}"#))); | ||
| ||
let pull = ok(&other, &["pull", &url, "main", &fork, "topic", "Add b"]); | ||
let issue = ok(&other, &["issue", &url, "Broken <thing>"]); | ||
ok(&owner, &["comment", &url, &pull, "Not *yet*", "--changes"]); | ||
ok(&other, &["comment", &url, &pull, "Fixed", "--approve"]); | ||
ok(&owner, &["comment", &url, &issue, "Seen it"]); | ||
ok(&owner, &["close", &url, &issue]); | ||
ok(&owner, &["reopen", &url, &issue]); | ||
let reopened = format!(r#"reopened issue <a href="/{o}/fed/issue/"#); | ||
wait_until(|| site.page("/").contains(&reopened)); | ||
ok(&owner, &["merge", &url, &pull]); | ||
let feed = shows( | ||
&format!(r#"merged pull <a href="/{o}/fed/pull/"#), | ||
&[ | ||
format!(r#"opened pull <a href="/{o}/fed/pull/"#), | ||
format!(r#"asked for changes on pull <a href="/{o}/fed/pull/"#), | ||
"<p class=\"feed-note\">“Not yet”</p>".into(), | ||
format!(r#"approved pull <a href="/{o}/fed/pull/"#), | ||
format!(r#"opened issue <a href="/{o}/fed/issue/"#), | ||
format!(r#"commented on issue <a href="/{o}/fed/issue/"#), | ||
format!(r#"closed issue <a href="/{o}/fed/issue/"#), | ||
reopened.clone(), | ||
], | ||
); | ||
// Opened, said and decided about, by title. | ||
assert_eq!(feed.matches(&format!(">Add b</a> on {repo}")).count(), 4); | ||
assert_eq!( | ||
feed.matches(&format!(">Broken <thing></a> on {repo}")) | ||
.count(), | ||
4 | ||
); | ||
let found = site.page("/?q=FED"); | ||
assert!(found.contains("repositories matching “FED”"), "{found}"); | ||
assert!(found.contains("<b>fed</b></a>"), "{found}"); | ||
} | ||
harness/shots/baseline/darwin/commit-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33 | ||
| \ No newline at end of file | ||
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commit-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33 | ||
| \ No newline at end of file | ||
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commit-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33 | ||
| \ No newline at end of file | ||
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commit-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33 | ||
| \ No newline at end of file | ||
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commits-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b | ||
| \ No newline at end of file | ||
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commits-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b | ||
| \ No newline at end of file | ||
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commits-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b | ||
| \ No newline at end of file | ||
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/commits-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b | ||
| \ No newline at end of file | ||
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/directory-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6 | ||
| \ No newline at end of file | ||
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/directory-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6 | ||
| \ No newline at end of file | ||
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/directory-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6 | ||
| \ No newline at end of file | ||
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/directory-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6 | ||
| \ No newline at end of file | ||
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/empty-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e | ||
| \ No newline at end of file | ||
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/empty-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e | ||
| \ No newline at end of file | ||
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/empty-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e | ||
| \ No newline at end of file | ||
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/empty-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e | ||
| \ No newline at end of file | ||
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/file-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47 | ||
| \ No newline at end of file | ||
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/file-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47 | ||
| \ No newline at end of file | ||
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/file-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47 | ||
| \ No newline at end of file | ||
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/file-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47 | ||
| \ No newline at end of file | ||
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/fork-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563 | ||
| \ No newline at end of file | ||
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/fork-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563 | ||
| \ No newline at end of file | ||
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/fork-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563 | ||
| \ No newline at end of file | ||
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/fork-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563 | ||
| \ No newline at end of file | ||
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/home-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97 | ||
| \ No newline at end of file | ||
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/home-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97 | ||
| \ No newline at end of file | ||
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/home-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97 | ||
| \ No newline at end of file | ||
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/home-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97 | ||
| \ No newline at end of file | ||
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/image-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8 | ||
| \ No newline at end of file | ||
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/image-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8 | ||
| \ No newline at end of file | ||
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/image-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8 | ||
| \ No newline at end of file | ||
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/image-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8 | ||
| \ No newline at end of file | ||
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issue-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda | ||
| \ No newline at end of file | ||
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issue-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda | ||
| \ No newline at end of file | ||
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issue-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda | ||
| \ No newline at end of file | ||
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issue-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda | ||
| \ No newline at end of file | ||
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issues-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c | ||
| \ No newline at end of file | ||
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issues-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c | ||
| \ No newline at end of file | ||
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issues-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c | ||
| \ No newline at end of file | ||
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/issues-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c | ||
| \ No newline at end of file | ||
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/markdown-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca | ||
| \ No newline at end of file | ||
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/markdown-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca | ||
| \ No newline at end of file | ||
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/markdown-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca | ||
| \ No newline at end of file | ||
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/markdown-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca | ||
| \ No newline at end of file | ||
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/missing-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561 | ||
| \ No newline at end of file | ||
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/missing-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561 | ||
| \ No newline at end of file | ||
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/missing-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561 | ||
| \ No newline at end of file | ||
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/missing-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561 | ||
| \ No newline at end of file | ||
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce | ||
| \ No newline at end of file | ||
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce | ||
| \ No newline at end of file | ||
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-fork-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75 | ||
| \ No newline at end of file | ||
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-fork-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75 | ||
| \ No newline at end of file | ||
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-fork-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75 | ||
| \ No newline at end of file | ||
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-fork-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75 | ||
| \ No newline at end of file | ||
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce | ||
| \ No newline at end of file | ||
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/profile-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce | ||
| \ No newline at end of file | ||
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e | ||
| \ No newline at end of file | ||
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e | ||
| \ No newline at end of file | ||
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-open-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b | ||
| \ No newline at end of file | ||
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-open-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b | ||
| \ No newline at end of file | ||
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-open-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b | ||
| \ No newline at end of file | ||
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-open-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b | ||
| \ No newline at end of file | ||
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e | ||
| \ No newline at end of file | ||
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pull-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e | ||
| \ No newline at end of file | ||
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-closed-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45 | ||
| \ No newline at end of file | ||
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-closed-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45 | ||
| \ No newline at end of file | ||
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-closed-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45 | ||
| \ No newline at end of file | ||
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-closed-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45 | ||
| \ No newline at end of file | ||
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7 | ||
| \ No newline at end of file | ||
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7 | ||
| \ No newline at end of file | ||
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7 | ||
| \ No newline at end of file | ||
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/pulls-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7 | ||
| \ No newline at end of file | ||
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/repository-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-desktop-dark.sha256+1−1
| @@ -1 +1 @@ | ||
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55 | ||
| \ No newline at end of file | ||
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/repository-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-desktop-light.sha256+1−1
| @@ -1 +1 @@ | ||
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55 | ||
| \ No newline at end of file | ||
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/repository-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-phone-dark.sha256+1−1
| @@ -1 +1 @@ | ||
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55 | ||
| \ No newline at end of file | ||
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/repository-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-phone-light.sha256+1−1
| @@ -1 +1 @@ | ||
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55 | ||
| \ No newline at end of file | ||
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/search-desktop-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-desktop-dark.sha256new+1−0
| @@ -0,0 +1 @@ | ||
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/search-desktop-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-desktop-light.sha256new+1−0
| @@ -0,0 +1 @@ | ||
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/search-phone-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-phone-dark.sha256new+1−0
| @@ -0,0 +1 @@ | ||
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/search-phone-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-phone-light.sha256new+1−0
| @@ -0,0 +1 @@ | ||
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8 | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/start-desktop-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-dark.sha256new+1−0
| @@ -0,0 +1 @@ | ||
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/start-desktop-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-light.sha256new+1−0
| @@ -0,0 +1 @@ | ||
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/start-phone-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-dark.sha256new+1−0
| @@ -0,0 +1 @@ | ||
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df | ||
| \ No newline at end of file | ||
harness/shots/baseline/darwin/start-phone-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-light.sha256new+1−0
| @@ -0,0 +1 @@ | ||
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df | ||
| \ No newline at end of file | ||
harness/shots/views.spec.mjs+1−1
| @@ -21,7 +21,7 @@ const read = (file, otherwise) => { | ||
const unchanged = new Set(read('unchanged.json', [])) | ||
| ||
const names = [ | ||
'home', 'profile', 'profile-fork', 'repository', 'directory', 'file', 'markdown', 'image', 'commits', 'commit', | ||
'home', 'search', 'start', 'profile', 'profile-fork', 'repository', 'directory', 'file', 'markdown', 'image', 'commits', 'commit', | ||
'pulls', 'pulls-closed', 'pull', 'pull-open', 'issues', 'issue', 'fork', 'empty', 'missing', | ||
] | ||
| ||
harness/shots/world.mjs+2−0
| @@ -269,6 +269,8 @@ export async function create() { | ||
} | ||
const views = { | ||
home: '/', | ||
search: '/?q=t', | ||
start: '/start', | ||
profile: '/alice.test', | ||
'profile-fork': '/bob.test', | ||
repository: '/alice.test/tally', | ||