289 lines
9.1 KiB
Svelte
289 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
$effect(() => {
|
|
const libParam = page.url.searchParams.get('lib');
|
|
const qParam = page.url.searchParams.get('q');
|
|
|
|
if (libParam) {
|
|
selectedLibraryId = libParam;
|
|
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>
|