chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A repository's code page goes to any file as its path is typed
.changeset/go-to-file.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Go to file | ||
| ||
A repository's code page has Go to file beside Code, and `t` opens it: type part of a path to find any folder or file at the branch shown, and Enter goes to it. | ||
crates/knotless-serve/src/web/repo.rs+42−1
| @@ -1,5 +1,5 @@ | ||
//! A repository and what it holds, as XRPC queries: `dev.knotless.getRepo`, | ||
//! `getTree`, `getLastCommits`, `getFile`, `getBlame`, `listCommits`, `getCommit`, | ||
//! `getTree`, `listPaths`, `getLastCommits`, `getFile`, `getBlame`, `listCommits`, `getCommit`, | ||
//! `listBranches` and `listTags`. | ||
| ||
use super::*; | ||
| @@ -228,6 +228,47 @@ async fn tree(app: &App, repo: &Repo, rev: String, path: String) -> Result<Tree> | ||
}) | ||
} | ||
| ||
/// The most paths `listPaths` gives: what a page can filter as it is | ||
/// typed into, and more than nearly every repository holds. | ||
const PATHS_MAX: usize = 50_000; | ||
| ||
/// One path of a tree, as `listPaths` gives it. | ||
#[derive(Serialize)] | ||
pub struct PathView { | ||
path: String, | ||
kind: &'static str, | ||
} | ||
| ||
/// `dev.knotless.listPaths`'s answer. | ||
#[derive(Serialize)] | ||
pub struct Paths { | ||
#[serde(rename = "ref")] | ||
refname: String, | ||
paths: Vec<PathView>, | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
truncated: bool, | ||
} | ||
| ||
/// `dev.knotless.listPaths`: every path at a revision, for a page to | ||
/// find a file by as it is typed. | ||
pub async fn list_paths( | ||
State(app): State<Arc<App>>, | ||
Query(p): Query<RepoParams>, | ||
) -> Result<Response> { | ||
let repo = found(&app, &p.repo).await?; | ||
let (rev, _) = located(&repo, &p)?; | ||
let limit = p.limit.unwrap_or(PATHS_MAX).clamp(1, PATHS_MAX); | ||
let (paths, truncated) = repo.objects()?.paths(&rev, limit).map_err(not_found)?; | ||
Ok(answer(Paths { | ||
refname: rev, | ||
paths: paths | ||
.into_iter() | ||
.map(|(path, kind)| PathView { path, kind }) | ||
.collect(), | ||
truncated, | ||
})) | ||
} | ||
| ||
/// One entry of a directory, and the commit that last changed it. | ||
#[derive(Serialize)] | ||
pub struct LastCommit { | ||
crates/knotless-serve/src/objects.rs+32−7
| @@ -124,6 +124,18 @@ fn id_at(tree: &gix::Tree<'_>, path: &str) -> Result<Option<gix::ObjectId>> { | ||
Ok(tree.lookup_entry_by_path(path)?.map(|e| e.oid().to_owned())) | ||
} | ||
| ||
/// What an entry of a tree is: a directory, a submodule's commit, or a | ||
/// file, as links are taken to be. | ||
fn kind(mode: gix::object::tree::EntryMode) -> &'static str { | ||
if mode.is_tree() { | ||
"tree" | ||
} else if mode.is_commit() { | ||
"commit" | ||
} else { | ||
"blob" | ||
} | ||
} | ||
| ||
impl Objects { | ||
/// The branch HEAD names, if it names one. | ||
pub fn head(&self) -> Option<String> { | ||
| @@ -211,13 +223,7 @@ impl Objects { | ||
for e in tree.iter() { | ||
let e = e?; | ||
let mode = e.mode(); | ||
let kind = if mode.is_tree() { | ||
"tree" | ||
} else if mode.is_commit() { | ||
"commit" | ||
} else { | ||
"blob" | ||
}; | ||
let kind = kind(mode); | ||
let size = match kind { | ||
"blob" => Some(self.0.find_header(e.oid())?.size()), | ||
_ => None, | ||
| @@ -256,6 +262,25 @@ impl Objects { | ||
Ok(out) | ||
} | ||
| ||
/// Every path in the whole tree at `rev`, folders as well as files, | ||
/// each with its kind as `tree` names them: the shallowest first, so | ||
/// that what `limit` cuts off is the deepest. And whether it cut any. | ||
pub fn paths(&self, rev: &str, limit: usize) -> Result<(Vec<(String, &'static str)>, bool)> { | ||
let mut found = gix::traverse::tree::Recorder::default(); | ||
self.commit(rev)? | ||
.tree()? | ||
.traverse() | ||
.breadthfirst(&mut found)?; | ||
let cut = found.records.len() > limit; | ||
let out = found | ||
.records | ||
.into_iter() | ||
.take(limit) | ||
.map(|e| (e.filepath.to_string(), kind(e.mode))) | ||
.collect(); | ||
Ok((out, cut)) | ||
} | ||
| ||
/// Whether `path` is a directory at `rev`: the root is one. | ||
pub fn is_dir(&self, rev: &str, path: &str) -> bool { | ||
let tree = self.commit(rev).and_then(|c| Ok(c.tree()?)); | ||
crates/knotless-serve/src/web.rs+2−1
| @@ -87,7 +87,7 @@ pub use pages::pages; | ||
use reactions::{add_reaction, get_reactions, remove_reaction}; | ||
use repo::{ | ||
get_blame, get_commit, get_file, get_last_commits, get_repo, get_tree, list_branches, | ||
list_commits, list_tags, | ||
list_commits, list_paths, list_tags, | ||
}; | ||
use stars::{get_viewer, star_repo, unstar_repo}; | ||
pub use themes::{cookie as palette_cookie, palette}; | ||
| @@ -195,6 +195,7 @@ pub fn queries() -> Router<Arc<App>> { | ||
.route("/xrpc/dev.knotless.updateSeen", post(update_seen)) | ||
.route("/xrpc/dev.knotless.getTree", get(get_tree)) | ||
.route("/xrpc/dev.knotless.getLastCommits", get(get_last_commits)) | ||
.route("/xrpc/dev.knotless.listPaths", get(list_paths)) | ||
.route("/xrpc/dev.knotless.getFile", get(get_file)) | ||
.route("/xrpc/dev.knotless.getBlame", get(get_blame)) | ||
.route("/xrpc/dev.knotless.listCommits", get(list_commits)) | ||
crates/knotless-serve/tests/web.rs+30−0
| @@ -472,6 +472,36 @@ fn browsing_a_repository() { | ||
let file = [("repo", uri), ("ref", "main"), ("path", "src/main.rs")]; | ||
let file = site.get(&xrpc("getLastCommits", &file)); | ||
assert_eq!(file.status().as_u16(), 404); | ||
// Every path, folders and files, to find a file by: the root's | ||
// first, so a limit leaves out the deepest. | ||
let paths = site.json(&xrpc("listPaths", &[("repo", uri)])); | ||
assert_eq!(paths["ref"], "main"); | ||
assert!(paths.get("truncated").is_none()); | ||
let all: Vec<(&str, &str)> = paths["paths"] | ||
.as_array() | ||
.unwrap() | ||
.iter() | ||
.map(|p| (p["path"].as_str().unwrap(), p["kind"].as_str().unwrap())) | ||
.collect(); | ||
for want in [ | ||
("docs", "tree"), | ||
("src/main.rs", "blob"), | ||
("README.md", "blob"), | ||
] { | ||
assert!(all.contains(&want), "{want:?} not in {all:?}"); | ||
} | ||
let deep = all.iter().position(|(p, _)| p.contains('/')).unwrap(); | ||
assert!(all[deep..].iter().all(|(p, _)| p.contains('/')), "{all:?}"); | ||
let some = [("repo", uri), ("ref", "feature/x"), ("limit", "3")]; | ||
let some = site.json(&xrpc("listPaths", &some)); | ||
assert_eq!(some["ref"], "feature/x"); | ||
assert_eq!(some["truncated"], true); | ||
assert_eq!(some["paths"].as_array().unwrap().len(), 3); | ||
let nowhere = [("repo", uri), ("ref", "no-such-branch")]; | ||
assert_eq!( | ||
site.get(&xrpc("listPaths", &nowhere)).status().as_u16(), | ||
404 | ||
); | ||
// Every branch, the default first, a page at a time. | ||
let branches = |params: &[(&str, &str)]| { | ||
site.json(&xrpc("listBranches", &[&[("repo", uri)], params].concat())) | ||
docs/design/serve.md+5−0
| @@ -255,6 +255,11 @@ Details: | ||
- Pages read mirrors in process through gix, since starting a git process costs | ||
more than most reads. A mirror's handle is reopened when its packs change, | ||
because an open repository has room for only so many packs. | ||
- Go to file, on a directory's page (or `t` anywhere on it), asks for every | ||
path at the revision once it opens (`dev.knotless.listPaths`) and filters | ||
them in the browser as they are typed, those whose name holds the text | ||
before those whose folders do. The paths come from one walk of the tree, | ||
the shallowest first, so a tree past the limit loses its deepest. | ||
- A directory's page shows the commit that last changed each entry, asked | ||
apart from the directory (`dev.knotless.getLastCommits`), since finding them | ||
can walk a long history. One walk serves every entry: it passes over each | ||
docs/reference/api.md+1−0
| @@ -542,6 +542,7 @@ name it has now. | ||
| `dev.knotless.getRepo?repo=` | a repository: its owner, description, `links` (only http and https ones), `topics`, default branch, counts, languages, `collaborators` (the first twenty its record names, besides its owner) and `collaboratorCount`, `private` if it is, and `archivedAt` if its owner archived it; with an asker, `viewer`: its `star`, a `transfer` offered to it, the `labels` it may put on the repository's pulls and issues, and `admin: true` when it may change the repository's settings, as its owner or an admin who joined the organization that owns it | | ||
| `dev.knotless.listReleases?repo=&package=&limit=&cursor=` | its releases ([packages.md](../design/packages.md#on-the-pages)), newest first, 30 a page by default, of one `package` if given: each with `latest` when it is its package's latest, `image` when its files are an OCI layout's, its notes as Markdown and as `html`, and its files' names, sizes and SHA-256; and every package it publishes, by name, with its latest version, how many releases it has and whether it is an image. None for a private repository or without an index | | ||
| `dev.knotless.getTree?repo=&ref=&path=` | a folder, its README and the newest checks of its last commit | | ||
| `dev.knotless.listPaths?repo=&ref=&limit=` | every folder and file at a revision, the shallowest first, at most 50,000, and `truncated` when the deepest were left out: for Go to file | | ||
| `dev.knotless.getLastCommits?repo=&ref=&path=` | the newest commit to change each entry of a folder, and at the root, how many commits the ref's history holds | | ||
| `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 | | ||
lexicons/dev/knotless/listPaths.jsonnew+47−0
| @@ -0,0 +1,47 @@ | ||
{ | ||
"lexicon": 1, | ||
"id": "dev.knotless.listPaths", | ||
"description": "Every path in a repository's tree at a revision.", | ||
"defs": { | ||
"main": { | ||
"type": "query", | ||
"description": "Every path in a repository's tree at a revision, folders as well as files, the shallowest first: for finding a file by name as it is typed. No authentication needed; asked as an account, with a service token or the pages' session cookie, a private repository it is a member of is read too, and the answer is that account's alone.", | ||
"parameters": { | ||
"type": "params", | ||
"required": ["repo"], | ||
"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." }, | ||
"ref": { "type": "string", "maxLength": 1024, "description": "A branch, a tag or a commit; the default branch if absent." }, | ||
"limit": { "type": "integer", "minimum": 1, "maximum": 50000, "default": 50000, "description": "The most paths to give: those deepest in the tree are left out first." } | ||
} | ||
}, | ||
"output": { | ||
"encoding": "application/json", | ||
"schema": { | ||
"type": "object", | ||
"required": ["ref", "paths"], | ||
"properties": { | ||
"ref": { "type": "string", "description": "The ref or commit read, as `ref` named it." }, | ||
"paths": { | ||
"type": "array", | ||
"items": { "type": "ref", "ref": "#pathView" } | ||
}, | ||
"truncated": { "type": "boolean", "description": "True when the tree holds more paths than `limit`, and the deepest were left out." } | ||
} | ||
} | ||
}, | ||
"errors": [ | ||
{ "name": "NotFound", "description": "No such repository or revision, or a repository never pushed." } | ||
] | ||
}, | ||
"pathView": { | ||
"type": "object", | ||
"description": "One path in a tree.", | ||
"required": ["path", "kind"], | ||
"properties": { | ||
"path": { "type": "string", "description": "From the tree's root, with no leading slash." }, | ||
"kind": { "type": "string", "knownValues": ["tree", "blob", "commit"], "description": "A directory, a file, or a submodule's commit." } | ||
} | ||
} | ||
} | ||
} | ||
web/src/lib/organisms/FileFinder.sveltenew+109−0
| @@ -0,0 +1,109 @@ | ||
<script lang="ts"> | ||
import { goto } from '$app/navigation'; | ||
import type * as listPaths from '#lib/lexicons/dev/knotless/listPaths.ts'; | ||
import type { Answer } from '#lib/data.ts'; | ||
import { repoHref, urlPath } from '#lib/format.ts'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
/** | ||
* Go to file: opening, as `t` does anywhere but where text is typed, | ||
* to every folder and file of the repository `name` that `authority` | ||
* owns at `rev`, asked for once it opens, found by what is typed. | ||
* Those whose name holds it come before those whose folders do, the | ||
* shorter path first; arrows move, Enter goes. | ||
*/ | ||
let { authority, name, rev }: { authority: string; name: string; rev: string } = $props(); | ||
type Path = Answer<typeof listPaths.main>['paths'][number]; | ||
const SHOWN = 50; | ||
| ||
let open = $state(false); | ||
let filter = $state(''); | ||
let at = $state(0); | ||
let input = $state<HTMLInputElement>(); | ||
let found = $state<{ rev: string; paths: Path[]; truncated: boolean } | null>(null); | ||
let failed = $state(false); | ||
| ||
// Asked for once it first opens on a revision. | ||
$effect(() => { | ||
if (!open || found?.rev === rev) return; | ||
const asked = rev; | ||
failed = false; | ||
const q = new URLSearchParams({ repo: `at://${authority}/dev.knotless.repo/${name}`, ref: asked }); | ||
fetch(`/xrpc/dev.knotless.listPaths?${q}`) | ||
.then((res) => (res.ok ? (res.json() as Promise<Answer<typeof listPaths.main>>) : Promise.reject(res.status))) | ||
.then((a) => (found = { rev: asked, paths: a.paths.filter((p) => p.kind !== 'commit'), truncated: !!a.truncated })) | ||
.catch(() => (failed = true)); | ||
}); | ||
| ||
$effect(() => { | ||
if (open) input?.focus(); | ||
}); | ||
| ||
type Shown = Path & { before: string; match: string; after: string }; | ||
const shown = $derived.by((): Shown[] => { | ||
const paths = found?.rev === rev ? found.paths : []; | ||
const q = filter.trim().toLowerCase(); | ||
if (!q) return paths.slice(0, SHOWN).map((p) => ({ ...p, before: p.path, match: '', after: '' })); | ||
const ranked: [number, number, Shown][] = []; | ||
for (const p of paths) { | ||
const lower = p.path.toLowerCase(); | ||
const i = lower.lastIndexOf(q); | ||
if (i < 0) continue; | ||
// Held by its own name, not only by the folders it is in. | ||
const inName = i > lower.lastIndexOf('/', lower.length - 2); | ||
ranked.push([inName ? 0 : 1, p.path.length, { ...p, before: p.path.slice(0, i), match: p.path.slice(i, i + q.length), after: p.path.slice(i + q.length) }]); | ||
} | ||
ranked.sort((a, b) => a[0] - b[0] || a[1] - b[1]); | ||
return ranked.slice(0, SHOWN).map(([, , s]) => s); | ||
}); | ||
| ||
const href = (p: Path) => repoHref(authority, name, `/${p.kind === 'tree' ? 'tree' : 'blob'}/${urlPath(rev)}/${urlPath(p.path)}`); | ||
| ||
function keys(e: KeyboardEvent) { | ||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | ||
e.preventDefault(); | ||
const n = shown.length; | ||
if (n) at = (at + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; | ||
} else if (e.key === 'Enter' && shown[at]) { | ||
e.preventDefault(); | ||
open = false; | ||
goto(href(shown[at])); | ||
} | ||
} | ||
| ||
function anywhere(e: KeyboardEvent) { | ||
const t = e.target as HTMLElement; | ||
const typing = t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName); | ||
if (e.key === 'Escape') open = false; | ||
else if (e.key === 't' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey && !document.querySelector('dialog[open]')) { | ||
e.preventDefault(); | ||
open = true; | ||
} | ||
} | ||
| ||
function outside(e: MouseEvent) { | ||
if (open && !(e.target as Element).closest('.file-finder')) open = false; | ||
} | ||
</script> | ||
| ||
<svelte:window onkeydown={anywhere} onclick={outside} /> | ||
| ||
<details class="popover file-finder" bind:open> | ||
<summary class="btn" aria-label="Go to file" title="Go to file"><Icon name="search" /><span class="label">Go to file</span><kbd>t</kbd></summary> | ||
<div class="menu"> | ||
<input bind:this={input} type="search" placeholder="Find a file…" aria-label="Find a file" bind:value={filter} oninput={() => (at = 0)} onkeydown={keys} autocomplete="off" /> | ||
{#if failed} | ||
<div class="none">The files could not be listed.</div> | ||
{:else if found?.rev !== rev} | ||
<div class="none">Listing the files…</div> | ||
{:else if shown.length} | ||
<ul> | ||
{#each shown as p, i (p.path)} | ||
<li><a href={href(p)} class:dir={p.kind === 'tree'} aria-current={i === at ? 'true' : undefined} onclick={() => (open = false)}><Icon name={p.kind === 'tree' ? 'dir' : 'file'} /><span class="name">{p.before}<b>{p.match}</b>{p.after}</span></a></li> | ||
{/each} | ||
</ul> | ||
{#if found?.truncated}<div class="none">Only the first {found?.paths.length} paths are searched.</div>{/if} | ||
{:else} | ||
<div class="none">No files match “{filter.trim()}”</div> | ||
{/if} | ||
</div> | ||
</details> | ||
web/src/lib/templates/Listing.svelte+3−1
| @@ -7,9 +7,10 @@ | ||
import { repoHref } from '#lib/format.ts'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
import CloneMenu from '#lib/molecules/CloneMenu.svelte'; | ||
import FileFinder from '#lib/organisms/FileFinder.svelte'; | ||
import Toolbar from '#lib/organisms/Toolbar.svelte'; | ||
import RepoPage from './RepoPage.svelte'; | ||
/** A directory at a revision, in one panel with the branch it is on, the ways to take it and what it is written in; the commit that last changed each entry, the branches to see it on (and, given, the tags), and its README, of the repository `authority` owns. */ | ||
/** A directory at a revision, in one panel with the branch it is on, the ways to take it, a file to go to, and what it is written in; the commit that last changed each entry, the branches to see it on (and, given, the tags), and its README, of the repository `authority` owns. */ | ||
let { repo: r, authority, tree: l, branches, tags, last }: { repo: RepoViewDetailed; authority: string; tree: Answer<typeof getTree.main>; branches: string[]; tags?: string[]; last: LastCommits | Promise<LastCommits> } = $props(); | ||
</script> | ||
| ||
| @@ -18,6 +19,7 @@ | ||
{#snippet bar()} | ||
<Toolbar {authority} name={r.name} rev={l.ref} view="tree" path={l.path} {branches} head={r.defaultBranch} {tags}> | ||
{#snippet end()} | ||
<FileFinder {authority} name={r.name} rev={l.ref} /> | ||
{#if l.ref !== r.defaultBranch && branches.includes(l.ref)}<a class="btn" href={repoHref(authority, r.name, `/pulls/new?branch=${encodeURIComponent(l.ref)}&expand=1`)}><Icon name="pull" />Compare & pull</a>{/if} | ||
<CloneMenu {authority} name={r.name} rev={l.ref} /> | ||
{/snippet} | ||