| @@ -2,9 +2,11 @@ |
| | 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 { plural, repoHref, urlPath } from '#lib/format.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'; |
| @@ -14,34 +16,75 @@ |
| | * ⌘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. |
| | */ |
| | 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[]>([]); |
| | // The row picked: of the repositories, the owners and, last, all results. |
| | 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 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') }, |
| | { label: 'Merge queue', icon: 'queue', 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()); |
| | 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)}`), |
| | all |
| | ...(everywhere ? [all] : []) |
| | ]); |
| | |
| | function open(e?: Event) { |
| | 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. |
| | if (page.url.pathname === '/search') value = page.url.searchParams.get('q') ?? ''; |
| | value = !on && page.url.pathname === '/search' ? (page.url.searchParams.get('q') ?? '') : ''; |
| | repos = owners = []; |
| | items = []; |
| | at = 0; |
| | dialog.showModal(); |
| | input.select(); |
| | look(0); |
| @@ -49,23 +92,33 @@ |
| | |
| | function look(wait = 120) { |
| | clearTimeout(timer); |
| | at = 0; |
| | timer = setTimeout(async () => { |
| | asking?.abort(); |
| | asking = new AbortController(); |
| | const q = value.trim(); |
| | const query = new URLSearchParams({ limit: String(SHOWN), ...(q ? { q } : {}) }); |
| | 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 { |
| | 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 ?? []; |
| | 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. |
| @@ -86,14 +139,20 @@ |
| | } 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. |
| | // `/` 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)) || (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey)) open(e); |
| | 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. |
| @@ -103,26 +162,43 @@ |
| | </script> |
| | |
| | <svelte:window onkeydown={anywhere} /> |
| | <a class="search" href="/search" onclick={open} aria-label="Search"><Icon name="search" /><span>Search</span><kbd>/</kbd></a> |
| | <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" /><input bind:this={input} type="search" bind:value oninput={() => look()} onkeydown={keys} placeholder="Find a repository or owner" aria-label="Find a repository or owner" role="combobox" aria-expanded="true" aria-controls="palette-rows" aria-activedescendant="palette-{at}" autocomplete="off" /><kbd>esc</kbd></label> |
| | <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, i (r.owner.handle + '/' + r.name)} |
| | {#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 = repos.length + j} |
| | {@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} |
| | <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)}>{value.trim() ? `all results for “${value.trim()}” →` : 'all repositories →'}</a> |
| | </li> |
| | {#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><span>⇧↵ all results</span></footer> |
| | <footer><span>↑↓ move</span><span>↵ open</span>{#if everywhere}<span>⇧↵ all results</span>{/if}{#if scope}<span>⌫ all of knotless</span>{/if}</footer> |
| | </dialog> |
| |