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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
166 lines · 4.8 KB Raw History
<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>