johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<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>