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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

⌘K on a repository's pages jumps around that repository

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/repo-palette
docs/guide/server.md+4−1
@@ -97,7 +97,10 @@ Every page's header searches repositories by name or description, and
their owners by handle: `/` or ⌘K opens a palette over the page, showing
matches as they are typed, and `/search?q=` lists them all, or every
repository, newest first, without `q`. Logged in, the private repositories
they may read come first, marked so.
they may read come first, marked so. On a repository's pages, ⌘K opens
the palette on that repository instead: its pages to jump to, and its
open pulls and issues whose words match what is typed. Backspace with
nothing typed widens it to all of knotless, which `/` always searches.
The front page, repositories, pulls and issues unfurl into social cards
that the server draws itself.
harness/shots/look.mjs+9−0
@@ -21,6 +21,8 @@
// then only the screen is shot, not the page
// --type <selector>=<text> type into it, emptied, in turn with the clicks,
// and wait for what that asks for
// --press <key> press it, in turn with the clicks, as
// Playwright names keys (Meta+k, Escape)
// --hover <selector> hover over it last
// --hold <method> never answer what the page asks of it
// (as inviteToOrg), so what a click
@@ -92,6 +94,7 @@ for (let i = 0; i < argv.length; i++) {
const [, sel, text] = next().match(/^([^=]+)=(.*)$/s) ?? fail('--type takes <selector>=<text>')
clicks.push({ sel, text })
}
else if (a === '--press') clicks.push({ key: next() })
else if (a === '--hover') hover = next()
else if (a === '--hold') holds.push(next())
else if (a === '--theme') theme = next()
@@ -240,6 +243,12 @@ try {
for (const h of holds) await page.route((url) => url.pathname.endsWith(`.${h}`), () => {})
// Each in the middle of the screen, so what it opens is in the picture.
for (const c of clicks) {
if (c.key) {
await page.keyboard.press(c.key)
await page.waitForTimeout(400)
await page.waitForLoadState('networkidle')
continue
}
const it = page.locator(c.sel ?? c).first()
await it.evaluate((e) => e.scrollIntoView({ block: 'center' }))
if (c.sel) {
web/src/lib/organisms/SearchPalette.svelte+98−22
@@ -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>
web/src/app.css+2−1
@@ -912,6 +912,7 @@ textarea.field{resize:vertical;font-family:var(--font-mono);font-size:var(--text
.dialog .create{margin-top:var(--space-3);padding-top:var(--space-3);border-top:var(--border);text-align:center;color:var(--mute);font-size:var(--text-sm)}
.dialog .create button{padding:0;border:0;background:none;color:var(--fg);font:inherit;text-decoration:underline;text-decoration-color:var(--link-line);text-underline-offset:var(--underline-offset);cursor:pointer}
.palette label{display:flex;align-items:center;gap:var(--space-2-5);padding:0 var(--pad-x);min-height:var(--strip);border-bottom:var(--border);color:var(--faint)}
.palette .scope{flex:none;max-width:55%;font:max(var(--text-md),var(--text-field)) var(--font-mono);color:var(--fg)}
.palette input{flex:1;min-width:0;font:max(var(--text-md),var(--text-field)) var(--font-mono);color:var(--fg);background:transparent;border:0;outline:0}
.palette input::-webkit-search-cancel-button{display:none}
.palette ul{list-style:none;margin:0;padding:var(--space-1) 0;overflow-y:auto}
@@ -1398,7 +1399,7 @@ textarea.field{resize:vertical;font-family:var(--font-mono);font-size:var(--text
/* The header's icons the same space apart, each tappable wider than it is drawn. */
.search,.new-link,.bell{padding-inline:var(--space-2);margin-inline:calc(-1 * var(--space-2))}
.search{margin-left:auto}
.search span,.search kbd,.palette footer{display:none}
.search span,.search kbd,.palette footer,.palette .scope>.mute:first-child{display:none}
.palette{margin-top:var(--space-4)}
.profile{max-width:none;flex-basis:100%}
.profile .av.xl{width:var(--avatar-lg);height:var(--avatar-lg);font-size:34px}