feat(TRUEREF-0016): implement web UI search explorer
- Two-step search workflow: resolve library then query documentation - URL state sync (/search?lib=...&q=...) - LibraryResult, SnippetCard, SearchInput components - Code/info snippet display with breadcrumbs and token counts - Copy-as-markdown button for full response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
src/lib/components/search/LibraryResult.svelte
Normal file
33
src/lib/components/search/LibraryResult.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
result,
|
||||
onSelect
|
||||
}: {
|
||||
result: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
totalSnippets: number | null;
|
||||
trustScore: number | null;
|
||||
};
|
||||
onSelect: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const snippetCount = $derived(result.totalSnippets ?? 0);
|
||||
const trust = $derived(result.trustScore ?? 0);
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={() => onSelect(result.id)}
|
||||
class="w-full rounded-xl border border-gray-200 bg-white p-4 text-left shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-900">{result.title}</span>
|
||||
<span class="text-xs text-gray-400">Trust {trust.toFixed(1)}/10</span>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-400">{result.id}</p>
|
||||
{#if result.description}
|
||||
<p class="mt-1.5 line-clamp-2 text-sm text-gray-600">{result.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-gray-400">{snippetCount.toLocaleString()} snippets</p>
|
||||
</button>
|
||||
56
src/lib/components/search/SearchInput.svelte
Normal file
56
src/lib/components/search/SearchInput.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = 'Search...',
|
||||
onsubmit,
|
||||
loading = false
|
||||
}: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onsubmit: () => void;
|
||||
loading?: boolean;
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
onsubmit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value
|
||||
{placeholder}
|
||||
onkeydown={handleKeydown}
|
||||
disabled={loading}
|
||||
class="flex-1 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm outline-none transition-all focus:border-blue-400 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
onclick={onsubmit}
|
||||
disabled={loading || !value.trim()}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Searching
|
||||
</span>
|
||||
{:else}
|
||||
Search
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
55
src/lib/components/search/SnippetCard.svelte
Normal file
55
src/lib/components/search/SnippetCard.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { SnippetJson } from '$lib/server/api/formatters';
|
||||
|
||||
let { snippet }: { snippet: SnippetJson } = $props();
|
||||
|
||||
const isCode = $derived(snippet.type === 'code');
|
||||
|
||||
const title = $derived(
|
||||
snippet.type === 'code' ? snippet.title : null
|
||||
);
|
||||
|
||||
const breadcrumb = $derived(
|
||||
snippet.type === 'code' ? snippet.description : snippet.breadcrumb
|
||||
);
|
||||
|
||||
const content = $derived(
|
||||
snippet.type === 'code' ? snippet.codeList[0]?.code ?? '' : snippet.text
|
||||
);
|
||||
|
||||
const language = $derived(
|
||||
snippet.type === 'code' ? (snippet.codeList[0]?.language ?? '') : null
|
||||
);
|
||||
|
||||
const tokenCount = $derived(snippet.tokenCount ?? 0);
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isCode}
|
||||
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">code</span>
|
||||
{:else}
|
||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">info</span>
|
||||
{/if}
|
||||
{#if title}
|
||||
<span class="text-sm font-medium text-gray-800">{title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{tokenCount} tokens</span>
|
||||
</div>
|
||||
|
||||
{#if breadcrumb}
|
||||
<p class="bg-gray-50 px-4 py-1.5 text-xs italic text-gray-500">{breadcrumb}</p>
|
||||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
{#if isCode}
|
||||
<pre
|
||||
class="overflow-x-auto rounded bg-gray-950 p-4 text-sm text-gray-100"
|
||||
><code>{content}</code></pre>
|
||||
{:else}
|
||||
<div class="prose prose-sm max-w-none whitespace-pre-wrap text-gray-700">{content}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
36
src/lib/utils/copy-to-clipboard.ts
Normal file
36
src/lib/utils/copy-to-clipboard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copy text to the system clipboard.
|
||||
*
|
||||
* Falls back gracefully when the Clipboard API is unavailable (e.g. non-HTTPS
|
||||
* or older browsers) by using a temporary textarea element.
|
||||
*
|
||||
* @returns true when the copy succeeded, false otherwise.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to legacy path.
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback via execCommand.
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
292
src/routes/search/+page.svelte
Normal file
292
src/routes/search/+page.svelte
Normal file
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import LibraryResult from '$lib/components/search/LibraryResult.svelte';
|
||||
import SnippetCard from '$lib/components/search/SnippetCard.svelte';
|
||||
import SearchInput from '$lib/components/search/SearchInput.svelte';
|
||||
import { copyToClipboard } from '$lib/utils/copy-to-clipboard';
|
||||
import type { LibrarySearchJsonResult, SnippetJson } from '$lib/server/api/formatters';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let libraryName = $state('');
|
||||
let selectedLibraryId = $state<string | null>(null);
|
||||
let selectedLibraryTitle = $state<string | null>(null);
|
||||
let query = $state('');
|
||||
|
||||
let libraryResults = $state<LibrarySearchJsonResult[]>([]);
|
||||
let snippets = $state<SnippetJson[]>([]);
|
||||
let totalTokens = $state(0);
|
||||
|
||||
let loadingLibraries = $state(false);
|
||||
let loadingSnippets = $state(false);
|
||||
let libraryError = $state<string | null>(null);
|
||||
let snippetError = $state<string | null>(null);
|
||||
let copyLabel = $state('Copy as Markdown');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const hasLibraryResults = $derived(libraryResults.length > 0);
|
||||
const hasSnippets = $derived(snippets.length > 0);
|
||||
const step = $derived<'library' | 'docs'>(selectedLibraryId ? 'docs' : 'library');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialise from URL params on mount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onMount(() => {
|
||||
const currentPage = get(page);
|
||||
const libParam = currentPage.url.searchParams.get('lib');
|
||||
const qParam = currentPage.url.searchParams.get('q');
|
||||
|
||||
if (libParam) {
|
||||
selectedLibraryId = libParam;
|
||||
// Try to restore library name from id for display.
|
||||
selectedLibraryTitle = libParam;
|
||||
}
|
||||
if (qParam) {
|
||||
query = qParam;
|
||||
}
|
||||
if (libParam && qParam) {
|
||||
searchDocs();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function searchLibraries() {
|
||||
if (!libraryName.trim()) return;
|
||||
loadingLibraries = true;
|
||||
libraryError = null;
|
||||
libraryResults = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/libs/search?libraryName=${encodeURIComponent(libraryName)}&query=${encodeURIComponent(query)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
libraryResults = data.results ?? [];
|
||||
} catch (e) {
|
||||
libraryError = (e as Error).message;
|
||||
} finally {
|
||||
loadingLibraries = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDocs() {
|
||||
if (!selectedLibraryId || !query.trim()) return;
|
||||
loadingSnippets = true;
|
||||
snippetError = null;
|
||||
snippets = [];
|
||||
totalTokens = 0;
|
||||
|
||||
try {
|
||||
const url = new URL('/api/v1/context', window.location.origin);
|
||||
url.searchParams.set('libraryId', selectedLibraryId);
|
||||
url.searchParams.set('query', query);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
snippets = data.snippets ?? [];
|
||||
totalTokens = data.totalTokens ?? 0;
|
||||
|
||||
// Sync URL state.
|
||||
goto(
|
||||
`/search?lib=${encodeURIComponent(selectedLibraryId)}&q=${encodeURIComponent(query)}`,
|
||||
{ replaceState: true, keepFocus: true }
|
||||
);
|
||||
} catch (e) {
|
||||
snippetError = (e as Error).message;
|
||||
} finally {
|
||||
loadingSnippets = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectLibrary(id: string) {
|
||||
const found = libraryResults.find((r) => r.id === id);
|
||||
selectedLibraryId = id;
|
||||
selectedLibraryTitle = found?.title ?? id;
|
||||
libraryResults = [];
|
||||
libraryName = '';
|
||||
}
|
||||
|
||||
function changeLibrary() {
|
||||
selectedLibraryId = null;
|
||||
selectedLibraryTitle = null;
|
||||
snippets = [];
|
||||
totalTokens = 0;
|
||||
snippetError = null;
|
||||
query = '';
|
||||
// Reset URL.
|
||||
goto('/search', { replaceState: true, keepFocus: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copy as Markdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildMarkdown(): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const snippet of snippets) {
|
||||
if (snippet.type === 'code') {
|
||||
const lang = snippet.codeList[0]?.language ?? '';
|
||||
const code = snippet.codeList[0]?.code ?? '';
|
||||
if (snippet.title) parts.push(`### ${snippet.title}`);
|
||||
if (snippet.description) parts.push(`*${snippet.description}*`);
|
||||
parts.push(`\`\`\`${lang}\n${code}\n\`\`\``);
|
||||
} else {
|
||||
if (snippet.breadcrumb) parts.push(`*${snippet.breadcrumb}*`);
|
||||
parts.push(snippet.text);
|
||||
}
|
||||
parts.push('---');
|
||||
}
|
||||
|
||||
if (parts.at(-1) === '---') parts.pop();
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const markdown = buildMarkdown();
|
||||
const ok = await copyToClipboard(markdown);
|
||||
copyLabel = ok ? 'Copied!' : 'Failed to copy';
|
||||
setTimeout(() => {
|
||||
copyLabel = 'Copy as Markdown';
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Search — TrueRef</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900">Search Documentation</h1>
|
||||
<p class="mt-0.5 text-sm text-gray-500">
|
||||
Find libraries and query their indexed documentation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if step === 'library'}
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Step 1: Library search -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">Step 1 — Select a library</h2>
|
||||
<p class="mb-4 text-xs text-gray-500">
|
||||
Enter a library name to find matching indexed repositories.
|
||||
</p>
|
||||
|
||||
<SearchInput
|
||||
bind:value={libraryName}
|
||||
placeholder="Library name, e.g. react, tailwind..."
|
||||
onsubmit={searchLibraries}
|
||||
loading={loadingLibraries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if libraryError}
|
||||
<div class="rounded-lg bg-red-50 px-4 py-3">
|
||||
<p class="text-sm text-red-700">{libraryError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingLibraries}
|
||||
<div class="flex items-center justify-center py-12 text-sm text-gray-400">
|
||||
Searching libraries...
|
||||
</div>
|
||||
{:else if hasLibraryResults}
|
||||
<div class="space-y-3">
|
||||
{#each libraryResults as result (result.id)}
|
||||
<LibraryResult {result} onSelect={selectLibrary} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if libraryName && !loadingLibraries && libraryResults.length === 0 && !libraryError}
|
||||
<!-- Only show empty state after a search was executed -->
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<!-- Step 2: Documentation query -->
|
||||
<!-- ------------------------------------------------------------------ -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="shrink-0 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
Selected
|
||||
</span>
|
||||
<span class="truncate font-mono text-sm text-gray-700">{selectedLibraryTitle}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={changeLibrary}
|
||||
class="shrink-0 rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">Step 2 — Query documentation</h2>
|
||||
<p class="mb-4 text-xs text-gray-500">Ask a question about this library.</p>
|
||||
|
||||
<SearchInput
|
||||
bind:value={query}
|
||||
placeholder="What would you like to know?"
|
||||
onsubmit={searchDocs}
|
||||
loading={loadingSnippets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if snippetError}
|
||||
<div class="rounded-lg bg-red-50 px-4 py-3">
|
||||
<p class="text-sm text-red-700">{snippetError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingSnippets}
|
||||
<div class="flex items-center justify-center py-12 text-sm text-gray-400">
|
||||
Fetching documentation snippets...
|
||||
</div>
|
||||
{:else if hasSnippets}
|
||||
<!-- Summary bar -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">
|
||||
{snippets.length} snippet{snippets.length === 1 ? '' : 's'} ·
|
||||
<span class="font-medium text-gray-700">{totalTokens.toLocaleString()} tokens</span>
|
||||
</p>
|
||||
<button
|
||||
onclick={handleCopy}
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Snippet list -->
|
||||
<div class="space-y-4">
|
||||
{#each snippets as snippet, i (i)}
|
||||
<SnippetCard {snippet} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if query && !loadingSnippets && snippets.length === 0 && !snippetError}
|
||||
<div class="flex flex-col items-center py-16 text-center">
|
||||
<p class="text-sm text-gray-500">No snippets found for that query.</p>
|
||||
<p class="mt-1 text-xs text-gray-400">Try a different question or select another library.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
1
src/routes/search/+page.ts
Normal file
1
src/routes/search/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user