import clsx from 'clsx';
import {
BookMarked,
CircleDot,
FileText,
GitFork,
GitPullRequest,
MessageSquare,
Search,
Tag,
UserRound,
X,
LineSquiggle,
} from 'lucide-solid';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import type { Did, ResourceUri } from '@atcute/lexicons/syntax';
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { ErrorState, LoadingState, PaginationControls } from '../components/common';
import { usePreloader, useRepoPreloader, useStringPreloader } from '../lib/preloading';
import { RepoStatsList } from '../components/repo';
import { getIssueRecord, getIssue } from '../lib/api/issues';
import { getPullRecord, getPull } from '../lib/api/pulls';
import { getRepoByDid } from '../lib/api/repos';
import { getStringRecord } from '../lib/api/strings';
import { issueQueryKey, pullQueryKey } from './repo/shared';
import {
ISSUE_COLLECTION,
STRING_COLLECTION,
} from '../lib/api/constants';
import {
parseAtUri,
} from '../lib/api/records';
import {
searchRecords,
type SearchCollection,
type SearchHit,
} from '../lib/api/search';
import { formatRelativeTime, getErrorMessage, issueHref, pullHref } from '../lib/repo-utils';
type SearchKind =
| 'all'
| 'repos'
| 'issues'
| 'pulls'
| 'comments'
| 'profiles'
| 'strings'
| 'labels';
interface SearchKindOption {
kind: SearchKind;
label: string;
nsid?: SearchCollection;
}
const SEARCH_KINDS: SearchKindOption[] = [
{ kind: 'all', label: 'all' },
{ kind: 'repos', label: 'repos', nsid: 'sh.tangled.repo' },
{ kind: 'issues', label: 'issues', nsid: 'sh.tangled.repo.issue' },
{ kind: 'pulls', label: 'pulls', nsid: 'sh.tangled.repo.pull' },
{ kind: 'comments', label: 'comments', nsid: 'sh.tangled.feed.comment' },
{ kind: 'profiles', label: 'profiles', nsid: 'sh.tangled.actor.profile' },
{ kind: 'strings', label: 'strings', nsid: 'sh.tangled.string' },
{ kind: 'labels', label: 'labels', nsid: 'sh.tangled.label.definition' },
];
const SEARCH_PAGE_LIMIT = 20;
const SEARCH_OFFSET_PATTERN = /^[0-9a-fA-F]{8}$/;
const paramValue = (value: string | string[] | undefined): string => Array.isArray(value) ? value[0] ?? '' : value ?? '';
const parseSearchKind = (value: string | string[] | undefined): SearchKind => {
const raw = paramValue(value);
return SEARCH_KINDS.some((option) => option.kind === raw) ? raw as SearchKind : 'all';
};
const searchHref = (q: string, kind: SearchKind, cursor?: string): string => {
const params = new URLSearchParams();
const trimmed = q.trim();
if (trimmed) params.set('q', trimmed);
if (kind !== 'all') params.set('type', kind);
if (cursor) params.set('cursor', cursor);
const query = params.toString();
return query ? `/search?${query}` : '/search';
};
const decodeSearchOffset = (cursor: string): number => {
const trimmed = cursor.trim();
if (!SEARCH_OFFSET_PATTERN.test(trimmed)) return 0;
const parsed = Number.parseInt(trimmed, 16);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
const normalizeSearchCursor = (cursor: string): string | undefined => {
const trimmed = cursor.trim();
return SEARCH_OFFSET_PATTERN.test(trimmed) ? trimmed.toLowerCase() : undefined;
};
const encodeSearchOffset = (offset: number): string | undefined => {
if (offset <= 0) return undefined;
return Math.max(0, offset).toString(16).padStart(8, '0');
};
const formatSearchElapsed = (milliseconds?: number): string | undefined => {
if (milliseconds === undefined) return undefined;
if (milliseconds < 1) return `${Math.max(1, Math.round(milliseconds * 1000))}us`;
if (milliseconds < 10) return `${milliseconds.toFixed(2)}ms`;
if (milliseconds < 100) return `${milliseconds.toFixed(1)}ms`;
return `${Math.round(milliseconds)}ms`;
};
const snippet = (value?: string, max = 220): string => {
if (typeof value !== 'string') return '';
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= max) {
return normalized;
}
return `${normalized.slice(0, max - 1).trimEnd()}...`;
};
const SearchResultIcon: Component<{ hit: SearchHit }> = (props) => (
<Switch fallback={<FileText class="w-4 h-4 mr-1.5 shrink-0" />}>
<Match when={props.hit.nsid === 'sh.tangled.repo'}>
<Show when={(props.hit.value as { source?: string }).source} fallback={<BookMarked class="w-4 h-4 mr-1.5 shrink-0" />}>
<GitFork class="w-4 h-4 mr-1.5 shrink-0" />
</Show>
</Match>
<Match when={props.hit.nsid === 'sh.tangled.repo.issue'}>
<CircleDot class="w-4 h-4 mr-1.5 shrink-0" />
</Match>
<Match when={props.hit.nsid === 'sh.tangled.repo.pull'}>
<GitPullRequest class="w-4 h-4 mr-1.5 shrink-0" />
</Match>
<Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment' || props.hit.nsid === 'sh.tangled.feed.comment'}>
<MessageSquare class="w-4 h-4 mr-1.5 shrink-0" />
</Match>
<Match when={props.hit.nsid === 'sh.tangled.actor.profile'}>
<UserRound class="w-4 h-4 mr-1.5 shrink-0" />
</Match>
<Match when={props.hit.nsid === 'sh.tangled.string'}>
<LineSquiggle class="w-4 h-4 mr-1.5 shrink-0" />
</Match>
<Match when={props.hit.nsid === 'sh.tangled.label.definition'}>
<Tag class="w-4 h-4 mr-1.5 shrink-0" />
</Match>
</Switch>
);
const RepoContextLink: Component<{ repoDid: Did }> = (props) => {
const repoQuery = createQuery(() => ({
queryKey: ['repo-by-did', props.repoDid],
queryFn: async () => getRepoByDid(props.repoDid),
staleTime: 300_000,
}));
return (
<Show
when={repoQuery.data}
fallback={
<div class="flex gap-1 items-center text-sm">
<BookMarked class="w-3 h-3" />
<span>repo</span>
</div>
}
>
{(repo) => (
<A
href={`/${repo().owner.handle}/${repo().slug}`}
class="flex min-w-0 gap-1 items-center text-sm no-underline hover:underline"
>
<BookMarked class="w-3 h-3 shrink-0" />
<span class="truncate">{repo().owner.handle}/{repo().slug}</span>
</A>
)}
</Show>
);
};
const RepoThreadLink: Component<{
kind: 'issue' | 'pull';
repoDid: Did;
threadRef: string;
commentRef?: string;
children: JSX.Element;
}> = (props) => {
const repoQuery = createQuery(() => ({
queryKey: ['repo-by-did', props.repoDid],
queryFn: async () => getRepoByDid(props.repoDid),
staleTime: 300_000,
}));
const preload = usePreloader();
const href = createMemo(() => {
const repo = repoQuery.data;
if (!repo) return undefined;
const fn = props.kind === 'issue' ? issueHref : pullHref;
const baseHref = fn(repo, props.threadRef);
return props.commentRef ? `${baseHref}#comment-${props.commentRef}` : baseHref;
});
const preloadProps = createMemo(() => {
const repo = repoQuery.data;
if (!repo) return {};
if (props.kind === 'issue') {
return preload(issueQueryKey(props.repoDid, props.threadRef), () => getIssue(repo, props.threadRef));
}
return preload(pullQueryKey(props.repoDid, props.threadRef), () => getPull(repo, props.threadRef));
});
return (
<Show when={href()} fallback={<span>{props.children}</span>}>
{(target) => <A href={target()} class="truncate min-w-0" {...preloadProps()}>{props.children}</A>}
</Show>
);
};
const CommentParentLink: Component<{ hit: SearchHit }> = (props) => {
const parentInfo = createMemo(() => {
const value = props.hit.value as any;
let uri: ResourceUri | undefined;
if (props.hit.nsid === 'sh.tangled.repo.issue.comment') {
uri = value.issue;
} else if (props.hit.nsid === 'sh.tangled.repo.pull.comment') {
uri = value.pull;
} else if (props.hit.nsid === 'sh.tangled.feed.comment') {
uri = value.subject?.uri;
}
if (!uri) return undefined;
try {
const parsed = parseAtUri(uri);
if (parsed.collection === STRING_COLLECTION) {
return {
uri,
kind: 'string' as const,
};
}
return {
uri,
kind: parsed.collection === ISSUE_COLLECTION ? 'issue' as const : 'pull' as const,
};
} catch {
return undefined;
}
});
const parentQuery = createQuery(() => ({
queryKey: ['search-parent', props.hit.nsid, parentInfo()?.uri],
enabled: Boolean(parentInfo()?.uri),
queryFn: async () => {
const info = parentInfo()!;
if (info.kind === 'string') {
return { kind: 'string' as const, data: await getStringRecord(info.uri) };
}
return info.kind === 'issue'
? { kind: 'issue' as const, record: await getIssueRecord(info.uri) }
: { kind: 'pull' as const, record: await getPullRecord(info.uri) };
},
staleTime: 300_000,
}));
return (
<Show when={parentQuery.data} fallback={<span>comment</span>}>
{(parent) => (
<Switch>
<Match when={parent().kind === 'string'}>
<A
href={`/strings/${(parent() as any).data.owner.handle || (parent() as any).data.owner.did}/${(parent() as any).data.record.rkey}#comment-${props.hit.rkey}`}
class="truncate min-w-0 font-medium"
>
comment on string
</A>
</Match>
<Match when={parent().kind !== 'string'}>
<RepoThreadLink
kind={(parent() as any).kind}
repoDid={(parent() as any).kind === 'issue'
? ((parent() as any).record.value as { repo: Did }).repo
: ((parent() as any).record.value as { target: { repo: Did } }).target.repo
}
threadRef={(parent() as any).record.rkey}
commentRef={props.hit.rkey}
>
comment on {(parent() as any).kind}
</RepoThreadLink>
</Match>
</Switch>
)}
</Show>
);
};
const ResultRepoContext: Component<{ hit: SearchHit }> = (props) => {
const directRepoDid = createMemo(() => {
if (props.hit.nsid === 'sh.tangled.repo.issue') {
return (props.hit.value as { repo: Did }).repo;
}
if (props.hit.nsid === 'sh.tangled.repo.pull') {
return (props.hit.value as { target: { repo: Did } }).target.repo;
}
return undefined;
});
const isRepoComment = createMemo(() =>
props.hit.nsid === 'sh.tangled.repo.issue.comment' ||
props.hit.nsid === 'sh.tangled.repo.pull.comment' ||
props.hit.nsid === 'sh.tangled.feed.comment'
);
const parentUri = createMemo(() => {
if (!isRepoComment()) return undefined;
const value = props.hit.value as any;
if (props.hit.nsid === 'sh.tangled.repo.issue.comment') {
return value.issue;
}
if (props.hit.nsid === 'sh.tangled.repo.pull.comment') {
return value.pull;
}
if (props.hit.nsid === 'sh.tangled.feed.comment') {
return value.subject?.uri;
}
return undefined;
});
const parentRepoDidQuery = createQuery(() => ({
queryKey: ['search-parent-repo', parentUri()],
enabled: Boolean(parentUri()),
queryFn: async () => {
const uri = parentUri()!;
const parsed = parseAtUri(uri);
if (parsed.collection === STRING_COLLECTION) {
return undefined;
}
if (parsed.collection === ISSUE_COLLECTION) {
const issue = await getIssueRecord(uri);
return (issue.value as { repo: Did }).repo;
}
const pull = await getPullRecord(uri);
return (pull.value as { target: { repo: Did } }).target.repo;
},
staleTime: 300_000,
}));
return (
<Show when={directRepoDid() || parentRepoDidQuery.data}>
{(did) => <RepoContextLink repoDid={did()} />}
</Show>
);
};
const ResultTitle: Component<{ hit: SearchHit }> = (props) => {
const preloadRepo = useRepoPreloader();
const preloadString = useStringPreloader();
const title = createMemo(() => {
const value = props.hit.value as {
title?: string;
name?: string;
filename?: string;
preferredHandle?: string;
};
return value.title || value.name || value.filename || value.preferredHandle || props.hit.rkey;
});
const repoName = createMemo(() => {
const name = (props.hit.value as { name?: string }).name;
return (typeof name === 'string' ? name.trim() : null) || props.hit.rkey;
});
return (
<div class="flex items-center min-w-0 flex-1 mr-2">
<SearchResultIcon hit={props.hit} />
<Switch fallback={<span class="truncate min-w-0">{title()}</span>}>
<Match when={props.hit.nsid === 'sh.tangled.repo'}>
<A
href={`/${props.hit.author.handle}/${repoName()}`}
class="truncate min-w-0"
{...preloadRepo(props.hit.author.handle, repoName())}
>
{props.hit.author.handle}/{repoName()}
</A>
</Match>
<Match when={props.hit.nsid === 'sh.tangled.repo.issue'}>
<RepoThreadLink kind="issue" repoDid={(props.hit.value as { repo: Did }).repo} threadRef={props.hit.rkey}>
{title()}
</RepoThreadLink>
</Match>
<Match when={props.hit.nsid === 'sh.tangled.repo.pull'}>
<RepoThreadLink kind="pull" repoDid={(props.hit.value as { target: { repo: Did } }).target.repo} threadRef={props.hit.rkey}>
{title()}
</RepoThreadLink>
</Match>
<Match when={props.hit.nsid === 'sh.tangled.actor.profile'}>
<A href={`/${props.hit.author.handle}`} class="truncate min-w-0">
{props.hit.author.handle}
</A>
</Match>
<Match when={props.hit.nsid === 'sh.tangled.string'}>
<A
href={`/strings/${props.hit.author.handle || props.hit.author.did}/${props.hit.rkey}`}
class="truncate min-w-0"
{...preloadString(props.hit.author.handle || props.hit.author.did, props.hit.rkey)}
>
{props.hit.author.handle || props.hit.author.did}/{title()}
</A>
</Match>
<Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment' || props.hit.nsid === 'sh.tangled.feed.comment'}>
<CommentParentLink hit={props.hit} />
</Match>
</Switch>
</div>
);
};
const SearchResultCard: Component<{ hit: SearchHit }> = (props) => {
const createdAt = createMemo(() => (props.hit.value as { createdAt?: string }).createdAt);
const body = createMemo(() => {
const value = props.hit.value as any;
const text = typeof value.body === 'string'
? value.body
: (value.body?.text ?? value.description ?? value.contents ?? [value.location, value.pronouns].filter(Boolean).join(' '));
return snippet(text);
});
const topics = createMemo(() => props.hit.nsid === 'sh.tangled.repo'
? (props.hit.value as { topics?: string[] }).topics ?? []
: []);
return (
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
<div class="min-h-32 py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800">
<div class="font-medium dark:text-white flex items-center justify-between">
<ResultTitle hit={props.hit} />
</div>
<Show when={body()}>
<div class="untangled-search-description text-gray-600 dark:text-gray-300 text-sm">{body()}</div>
</Show>
<div class="text-gray-400 text-sm font-mono inline-flex flex-wrap gap-4 mt-auto pt-4">
<div class="flex gap-1 items-center text-sm">
<UserRound class="w-3 h-3" />
<a href={`/${props.hit.author.did}`}>{props.hit.author.handle}</a>
</div>
<ResultRepoContext hit={props.hit} />
<Show when={createdAt()}>
<div class="flex gap-1 items-center text-sm">
<span>{formatRelativeTime(createdAt())}</span>
</div>
</Show>
<Show when={props.hit.nsid === 'sh.tangled.repo' && (props.hit.value as { repoDid?: string }).repoDid}>
{(repoDid) => <RepoStatsList repoDid={repoDid() as Did} />}
</Show>
</div>
<Show when={topics().length > 0}>
<div class="mt-2 flex flex-wrap gap-2">
<For each={topics()}>
{(topic) => (
<span class="rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
{topic}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
);
};
const TypeFilters: Component<{ query: string; active: SearchKind }> = (props) => (
<div class="flex gap-2 md:flex-wrap overflow-x-auto pb-2 md:pb-0">
<For each={SEARCH_KINDS}>
{(option) => (
<A
href={searchHref(props.query, option.kind)}
class={clsx(
'w-fit flex items-center flex-shrink-0 gap-2 font-normal normal-case rounded py-1 px-2 border text-sm transition-colors no-underline hover:no-underline',
option.kind === props.active
? 'border-gray-400 bg-gray-100 text-gray-900 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-100'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700',
)}
>
<span class="untangled-search-type-dot" />
<span>{option.label}</span>
</A>
)}
</For>
</div>
);
const SearchStatistics: Component<{
returnedCount?: number;
returnedStart?: number;
elapsedMs?: number;
hasMore?: boolean;
}> = (props) => {
const returnedThrough = createMemo(() => (props.returnedStart ?? 0) + (props.returnedCount ?? 0));
const elapsed = createMemo(() => formatSearchElapsed(props.elapsedMs));
return (
<Show when={props.returnedCount !== undefined}>
<div class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 py-2">
returned {returnedThrough()}{props.hasMore ? '+' : ''} indexed record{returnedThrough() === 1 ? '' : 's'}
<Show when={elapsed()}> in {elapsed()}</Show>
</div>
</Show>
);
};
const SearchOptionsPanel: Component<{
query: string;
kind: SearchKind;
returnedCount?: number;
returnedStart?: number;
elapsedMs?: number;
hasMore?: boolean;
}> = (props) => (
<div class="flex flex-col gap-6 px-2 md:px-0">
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">sort by</h3>
<select class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 dark:text-white text-sm">
<option>relevance</option>
</select>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">content</h3>
<TypeFilters query={props.query} active={props.kind} />
<p class="hidden md:block text-gray-500 dark:text-gray-400 text-xs mt-3">
filter by indexed record type.
</p>
</div>
<SearchStatistics
returnedCount={props.returnedCount}
returnedStart={props.returnedStart}
elapsedMs={props.elapsedMs}
hasMore={props.hasMore}
/>
</div>
);
export const SearchPage: Component = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const q = createMemo(() => paramValue(searchParams.q));
const kind = createMemo(() => parseSearchKind(searchParams.type));
const cursor = createMemo(() => paramValue(searchParams.cursor));
const normalizedCursor = createMemo(() => normalizeSearchCursor(cursor()));
const currentOffset = createMemo(() => decodeSearchOffset(normalizedCursor() ?? ''));
const selectedKind = createMemo(() => SEARCH_KINDS.find((option) => option.kind === kind()) ?? SEARCH_KINDS[0]);
const [draft, setDraft] = createSignal(q());
createEffect(() => {
setDraft(q());
});
const searchQuery = createQuery(() => ({
queryKey: ['global-search', q(), kind(), normalizedCursor()],
enabled: Boolean(q().trim()),
queryFn: async () => {
const startedAt = performance.now();
const result = await searchRecords({
q: q(),
nsid: selectedKind().nsid,
cursor: normalizedCursor(),
limit: SEARCH_PAGE_LIMIT,
});
return { ...result, elapsedMs: performance.now() - startedAt };
},
}));
const onSubmit = (event: SubmitEvent) => {
event.preventDefault();
navigate(searchHref(draft(), kind()));
};
const clearHref = createMemo(() => searchHref('', kind()));
const hits = createMemo(() => searchQuery.data?.hits ?? []);
const returnedCount = createMemo(() => searchQuery.data ? hits().length : undefined);
return (
<>
<h1 class="text-2xl font-bold mb-1 px-2">search</h1>
<div class="untangled-search-grid px-2">
<div class="untangled-search-main">
<div class="sticky top-0 z-10 bg-slate-100 dark:bg-gray-900">
<form onSubmit={onSubmit} class="py-1.5 flex items-center gap-2">
<div class="flex relative w-full">
<div class="flex-1 flex relative">
<input
id="search-q"
value={draft()}
onInput={(event) => setDraft(event.currentTarget.value)}
placeholder="search..."
class="untangled-search-page-input peer"
/>
<A
href={clearHref()}
class={clsx(
'absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300',
!draft().trim() && 'hidden',
)}
>
<X class="w-4 h-4" />
</A>
</div>
<button type="submit" class="untangled-search-page-submit">
<Search class="w-4 h-4" />
</button>
</div>
</form>
<div class="md:hidden pb-1.5">
<TypeFilters query={q()} active={kind()} />
</div>
</div>
<Show
when={q().trim()}
fallback={<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">enter a query to search indexed records.</div>}
>
<Switch>
<Match when={searchQuery.isLoading}>
<div class="p-12 border border-gray-200 dark:border-gray-700 rounded">
<LoadingState label="searching..." />
</div>
</Match>
<Match when={searchQuery.error}>
<div class="p-6 border border-gray-200 dark:border-gray-700 rounded">
<ErrorState message={getErrorMessage(searchQuery.error)} />
</div>
</Match>
<Match when={searchQuery.data}>
{(data) => (
<>
<div id="search-results" class="grid grid-cols-1 gap-4 mb-6">
<Show
when={data().hits.length > 0}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>no results found.</span>
</div>
}
>
<For each={data().hits}>
{(hit) => <SearchResultCard hit={hit} />}
</For>
</Show>
</div>
<div class="md:hidden mb-4">
<SearchStatistics
returnedCount={data().hits.length}
returnedStart={currentOffset()}
elapsedMs={data().elapsedMs}
hasMore={Boolean(data().cursor)}
/>
</div>
<PaginationControls
offset={currentOffset()}
limit={SEARCH_PAGE_LIMIT}
hasNext={Boolean(data().cursor)}
hrefForOffset={(offset) => searchHref(q(), kind(), encodeSearchOffset(offset))}
/>
</>
)}
</Match>
</Switch>
</Show>
</div>
<div class="untangled-search-sidebar">
<SearchOptionsPanel
query={q()}
kind={kind()}
returnedCount={returnedCount()}
returnedStart={currentOffset()}
elapsedMs={searchQuery.data?.elapsedMs}
hasMore={Boolean(searchQuery.data?.cursor)}
/>
</div>
</div>
</>
);
};