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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
301 lines · 11.5 KB Raw History
<script lang="ts">
	import { goto } from '$app/navigation';
	import { page } from '$app/state';
	import type * as listRepos from '#lib/lexicons/dev/knotless/listRepos.ts';
	import type * as listItems from '#lib/lexicons/dev/knotless/listItems.ts';
	import type * as searchOwners from '#lib/lexicons/dev/knotless/searchOwners.ts';
	import type { Answer } from '#lib/data.ts';
	import { itemHref, plural, repoHref, urlPath } from '#lib/format.ts';
	import { stateIcon, type IconName } from '#lib/icons.ts';
	import Avatar from '#lib/atoms/Avatar.svelte';
	import Icon from '#lib/atoms/Icon.svelte';
	import PrivateTag from '#lib/atoms/PrivateTag.svelte';
	/**
	 * Search, from any page: in the header, a link to the results page,
	 * which with script opens a palette over the page instead, as `/` or
	 * ⌘K do. It shows repositories as they are typed, the newest before
	 * and the private ones the viewer may read before those, and the
	 * owners whose handles hold what was typed.
	 *
	 * On a repository's pages, ⌘K opens it on that repository instead: its
	 * pages to jump to, and its open pulls and issues as they are typed.
	 * Backspace with nothing typed leaves the repository for all of
	 * knotless, as `/` always opens. What is typed can be searched for in
	 * code too: everywhere's, or on a repository, its own.
	 */
	type Repo = Answer<typeof listRepos.main>['repos'][number];
	type Shown = Repo & { owner: NonNullable<Repo['owner']> };
	type Owner = Answer<typeof searchOwners.main>['owners'][number];
	type Item = Answer<typeof listItems.main>['items'][number];
	type Scope = { authority: string; name: string };
	const SHOWN = 6;
	const OWNERS = 3;
	const ITEMS = 4;

	let dialog: HTMLDialogElement;
	let input: HTMLInputElement;
	let value = $state('');
	let repos = $state<Shown[]>([]);
	let owners = $state<Owner[]>([]);
	let items = $state<Item[]>([]);
	// The repository the palette is open on, or none for all of knotless.
	let scope = $state<Scope | null>(null);
	// The row picked: of the pages, items, repositories, owners and, last, all results.
	let at = $state(0);
	let timer: ReturnType<typeof setTimeout>;
	let asking: AbortController | undefined;

	// The repository this page is about, if it is about one.
	const here = $derived<Scope | null>(
		page.route.id?.startsWith('/[authority]/[repo]') && page.params.authority && page.params.repo
			? { authority: page.params.authority, name: page.params.repo }
			: null
	);
	const queues = $derived(!!(page.data.repo as { mergeQueue?: boolean } | undefined)?.mergeQueue);
	const owns = $derived(
		!!page.data.viewer?.actor &&
			(page.data.repo as { owner?: { did?: string } } | undefined)?.owner?.did ===
				page.data.viewer.actor.did
	);
	const pages = $derived.by(() => {
		if (!scope) return [];
		const href = (tail = '') => repoHref(scope!.authority, scope!.name, tail);
		const all: { label: string; icon: IconName; href: string }[] = [
			{ label: 'Code', icon: 'code', href: href() },
			{ label: 'Issues', icon: 'issue', href: href('/issues') },
			{ label: 'Pulls', icon: 'pull', href: href('/pulls') },
			{ label: 'Commits', icon: 'commit', href: href('/commits') },
			{ label: 'Branches', icon: 'branch', href: href('/branches') },
			{ label: 'Tags', icon: 'tag', href: href('/tags') },
			{ label: 'Releases', icon: 'tag', href: href('/releases') },
			{ label: 'Checks', icon: 'checks', href: href('/checks') },
			...(queues
				? [{ label: 'Merge queue', icon: 'queue' as IconName, href: href('/queue') }]
				: []),
			...(owns ? [{ label: 'Settings', icon: 'gear' as IconName, href: href('/settings') }] : [])
		];
		const q = value.trim().toLowerCase();
		return q ? all.filter((p) => p.label.toLowerCase().includes(q)) : all;
	});
	const all = $derived(value.trim() ? `/search?q=${encodeURIComponent(value.trim())}` : '/search');
	// On a repository with nothing typed, its pages are all there is: all results are of knotless.
	const everywhere = $derived(!scope || !!value.trim());
	// What was typed, searched for in code: the repository's own, on one.
	const code = $derived.by(() => {
		const q = value.trim();
		if (!q) return null;
		const within = scope ? `repo:${scope.authority}/${scope.name} ${q}` : q;
		return `/search?${new URLSearchParams({ q: within, type: 'code' })}`;
	});
	const hrefs = $derived([
		...pages.map((p) => p.href),
		...(scope ? items.map((i) => itemHref(repoHref(scope!.authority, scope!.name), i.uri)) : []),
		...repos.map((r) => repoHref(r.owner.handle, r.name)),
		...owners.map((o) => `/${urlPath(o.owner.handle)}`),
		...(code ? [code] : []),
		...(everywhere ? [all] : [])
	]);

	function open(e?: Event, on: Scope | null = null) {
		e?.preventDefault();
		if (dialog.open) return;
		scope = on;
		// On the results page, it opens on what they were asked for, to be changed.
		value = !on && page.url.pathname === '/search' ? (page.url.searchParams.get('q') ?? '') : '';
		repos = owners = [];
		items = [];
		at = 0;
		dialog.showModal();
		input.select();
		look(0);
	}

	function look(wait = 120) {
		clearTimeout(timer);
		at = 0;
		timer = setTimeout(async () => {
			asking?.abort();
			asking = new AbortController();
			const q = value.trim();
			const signal = asking.signal;
			const ask = async <T>(path: string): Promise<T | null> => {
				const res = await fetch(path, { signal });
				return res.ok ? res.json() : null;
			};
			try {
				if (scope) {
					const repo = `at://${scope.authority}/dev.knotless.repo/${scope.name}`;
					const of = (kind: string) =>
						ask<Answer<typeof listItems.main>>(
							`/xrpc/dev.knotless.listItems?${new URLSearchParams({ repo, kind, q, limit: String(ITEMS) })}`
						);
					const found = q ? await Promise.all([of('pull'), of('issue')]) : [];
					items = [
						...new Map(found.flatMap((f) => f?.items ?? []).map((i) => [i.uri, i])).values()
					];
					repos = owners = [];
				} else {
					const query = new URLSearchParams({ limit: String(SHOWN), ...(q ? { q } : {}) });
					const [found, people] = await Promise.all([
						ask<Answer<typeof listRepos.main>>(`/xrpc/dev.knotless.listRepos?${query}`),
						q
							? ask<Answer<typeof searchOwners.main>>(
									`/xrpc/dev.knotless.searchOwners?${new URLSearchParams({ q, limit: String(OWNERS) })}`
								)
							: null
					]);
					repos = (found?.repos ?? []).filter((r): r is Shown => !!r.owner).slice(0, SHOWN);
					owners = people?.owners ?? [];
					items = [];
				}
				at = 0;
			} catch {
				// Asked again as it was typed: this answer is no longer wanted.
			}
		}, wait);
	}

	function go(href: string) {
		dialog.close();
		goto(href);
	}

	function keys(e: KeyboardEvent) {
		const rows = hrefs.length;
		if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
			e.preventDefault();
			at = (at + (e.key === 'ArrowDown' ? 1 : rows - 1)) % rows;
		} else if (e.key === 'Enter') {
			e.preventDefault();
			go(e.shiftKey ? all : hrefs[at]);
		} else if (e.key === 'Backspace' && scope && !input.value) {
			e.preventDefault();
			scope = null;
			items = [];
			look(0);
		}
	}

	// `/` anywhere but where text is typed, and ⌘K or Ctrl-K anywhere: ⌘K on the repository a page is about.
	function anywhere(e: KeyboardEvent) {
		const t = e.target as HTMLElement;
		const typing = t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName);
		if (e.key === 'k' && (e.metaKey || e.ctrlKey)) open(e, here);
		else if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey) open(e);
	}

	// A click on the backdrop, outside the box, closes it.
	function outside(e: MouseEvent) {
		if (e.target === dialog) dialog.close();
	}
</script>

<svelte:window onkeydown={anywhere} />
<a class="search" href="/search" onclick={(e) => open(e)} aria-label="Search"
	><Icon name="search" /><span>Search</span><kbd>/</kbd></a
>
<dialog class="palette" bind:this={dialog} onclick={outside} aria-label="Search">
	<label
		><Icon name="search" />{#if scope}<span class="scope clip"
				><span class="mute">{scope.authority} /</span> <b>{scope.name}</b>
				<span class="mute">/</span></span
			>{/if}<input
			bind:this={input}
			type="search"
			bind:value
			oninput={() => look()}
			onkeydown={keys}
			placeholder={scope ? 'Search or jump to' : 'Find a repository or owner'}
			aria-label={scope ? `Search ${scope.authority}/${scope.name}` : 'Find a repository or owner'}
			role="combobox"
			aria-expanded="true"
			aria-controls="palette-rows"
			aria-activedescendant="palette-{at}"
			autocomplete="off"
		/><kbd>esc</kbd></label
	>
	<ul id="palette-rows" role="listbox">
		{#if pages.length}<li class="head" role="presentation">Pages</li>{/if}
		{#each pages as p, i (p.href)}
			<li id="palette-{i}" role="option" aria-selected={i === at}>
				<a href={p.href} onclick={() => dialog.close()} onmousemove={() => (at = i)}
					><Icon name={p.icon} /><span class="name clip">{p.label}</span></a
				>
			</li>
		{/each}
		{#if items.length}<li class="head" role="presentation">Open pulls and issues</li>{/if}
		{#each items as it, j (it.uri)}
			{@const i = pages.length + j}
			{@const shown = it.draft && it.state === 'open' ? 'draft' : it.state}
			<li id="palette-{i}" role="option" aria-selected={i === at}>
				<a href={hrefs[i]} onclick={() => dialog.close()} onmousemove={() => (at = i)}
					><span class="st {shown} {it.kind}"
						><Icon name={stateIcon(it.kind, it.state, it.draft)} /></span
					><span class="clip">{it.title}</span></a
				>
			</li>
		{/each}
		{#if repos.length}<li class="head" role="presentation">
				{value.trim() ? 'Repositories' : 'Newest repositories'}
			</li>{/if}
		{#each repos as r, j (r.owner.handle + '/' + r.name)}
			{@const i = pages.length + items.length + j}
			<li id="palette-{i}" role="option" aria-selected={i === at}>
				<a
					href={repoHref(r.owner.handle, r.name)}
					onclick={() => dialog.close()}
					onmousemove={() => (at = i)}
					><Avatar name={r.owner.handle} did={r.owner.did} src={r.owner.avatar} /><span
						class="name clip"><span class="mute">{r.owner.handle}/</span><b>{r.name}</b></span
					>{#if r.private}<PrivateTag />{/if}{#if r.description}<span class="clip faint"
							>{r.description}</span
						>{/if}</a
				>
			</li>
		{/each}
		{#if owners.length}<li class="head" role="presentation">Owners</li>{/if}
		{#each owners as o, j (o.owner.did)}
			{@const i = pages.length + items.length + repos.length + j}
			<li id="palette-{i}" role="option" aria-selected={i === at}>
				<a
					href="/{urlPath(o.owner.handle)}"
					onclick={() => dialog.close()}
					onmousemove={() => (at = i)}
					><Avatar name={o.owner.handle} did={o.owner.did} src={o.owner.avatar} /><b
						class="name clip">{o.owner.handle}</b
					><span class="clip faint">{plural(o.repoCount, 'repository', 'repositories')}</span></a
				>
			</li>
		{/each}
		{#if code}
			{@const i = hrefs.length - (everywhere ? 2 : 1)}
			<li id="palette-{i}" class="all" role="option" aria-selected={at === i}>
				<a href={code} onclick={() => dialog.close()} onmousemove={() => (at = i)}
					>{scope
						? `“${value.trim()}” in this repository’s code →`
						: `code containing “${value.trim()}” →`}</a
				>
			</li>
		{/if}
		{#if everywhere}
			<li
				id="palette-{hrefs.length - 1}"
				class="all"
				role="option"
				aria-selected={at === hrefs.length - 1}
			>
				<a href={all} onclick={() => dialog.close()} onmousemove={() => (at = hrefs.length - 1)}
					>{scope
						? `“${value.trim()}” in all of knotless →`
						: value.trim()
							? `all results for “${value.trim()}” →`
							: 'all repositories →'}</a
				>
			</li>
		{/if}
	</ul>
	<footer>
		<span>↑↓ move</span><span>↵ open</span>{#if everywhere}<span>⇧↵ all results</span
			>{/if}{#if scope}<span>⌫ all of knotless</span>{/if}
	</footer>
</dialog>