~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A repository's code page goes to any file as its path is typed

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/go-to-file
.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 &amp; pull</a>{/if}
<CloneMenu {authority} name={r.name} rev={l.ref} />
{/snippet}
web/src/app.css+17−4