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 { 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>