From 63f0b495ff322a1aa0aecc0614a449a18dd3b7eb Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Wed, 29 Jul 2026 22:24:47 -0400 Subject: [PATCH] Add Bobbin repository search client --- src/hooks/useRepoSearch.ts | 63 +++++++++++++++++++++++++++ src/lib/tangled/index.ts | 1 + src/lib/tangled/search/index.ts | 1 + src/lib/tangled/search/searchRepos.ts | 48 ++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/hooks/useRepoSearch.ts create mode 100644 src/lib/tangled/search/index.ts create mode 100644 src/lib/tangled/search/searchRepos.ts diff --git a/src/hooks/useRepoSearch.ts b/src/hooks/useRepoSearch.ts new file mode 100644 index 0000000..9842f94 --- /dev/null +++ b/src/hooks/useRepoSearch.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' +import { searchRepos, type Repo } from '../lib/tangled' + +const MINIMUM_QUERY_LENGTH = 2 +const SEARCH_DEBOUNCE_MS = 180 + +type SearchState = { + query: string + results: Repo[] +} + +type UseRepoSearchOptions = { + limit?: number + minimumQueryLength?: number +} + +export function useRepoSearch(input: string, options: UseRepoSearchOptions = {}) { + const { limit = 8, minimumQueryLength = MINIMUM_QUERY_LENGTH } = options + const [state, setState] = useState({ query: '', results: [] }) + const [isSearching, setIsSearching] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const query = input.trim() + if (query.length < minimumQueryLength) { + setState({ query, results: [] }) + setIsSearching(false) + setError(null) + return + } + + const controller = new AbortController() + const timeout = window.setTimeout(async () => { + setIsSearching(true) + setError(null) + try { + setState({ query, results: await searchRepos(query, controller.signal, limit) }) + } catch (error) { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + setState({ query, results: [] }) + setError(error instanceof Error ? error : new Error('Could not search repositories.')) + } + } finally { + if (!controller.signal.aborted) setIsSearching(false) + } + }, SEARCH_DEBOUNCE_MS) + + return () => { + controller.abort() + window.clearTimeout(timeout) + } + }, [input, limit, minimumQueryLength]) + + const query = input.trim() + const hasCurrentResults = state.query === query + const isPending = query.length >= minimumQueryLength && !hasCurrentResults + + return { + results: hasCurrentResults ? state.results : [], + isSearching: isSearching || isPending, + error: hasCurrentResults ? error : null, + } +} diff --git a/src/lib/tangled/index.ts b/src/lib/tangled/index.ts index e9a7805..313be97 100644 --- a/src/lib/tangled/index.ts +++ b/src/lib/tangled/index.ts @@ -4,4 +4,5 @@ export { getRepo, getRepoName, getRepoRkey } from './repo' export type { Repo } from './repo' export { getString, listStrings } from './string' export type { ListStringsOptions, StringList, StringRecord } from './string' +export { searchRepos } from './search' export { BOBBIN_URL, rpc } from './client' diff --git a/src/lib/tangled/search/index.ts b/src/lib/tangled/search/index.ts new file mode 100644 index 0000000..7e817b2 --- /dev/null +++ b/src/lib/tangled/search/index.ts @@ -0,0 +1 @@ +export { searchRepos } from './searchRepos' diff --git a/src/lib/tangled/search/searchRepos.ts b/src/lib/tangled/search/searchRepos.ts new file mode 100644 index 0000000..0d367bf --- /dev/null +++ b/src/lib/tangled/search/searchRepos.ts @@ -0,0 +1,48 @@ +import { ok } from '@atcute/client' +import { parseResourceUri, type ResourceUri } from '@atcute/lexicons' +import { rpc } from '../client' +import type { Repo } from '../repo' +import { validateRepo } from '../repo/validators' + +const REPOSITORY_COLLECTION = 'sh.tangled.repo' + +export async function searchRepos(query: string, signal: AbortSignal, limit = 8): Promise { + const response = await ok( + rpc.get('sh.tangled.search.query', { + signal, + params: { + q: query, + nsid: REPOSITORY_COLLECTION, + limit, + }, + }), + ) + + return validateSearchResponse(response) +} + +function validateSearchResponse(response: unknown): Repo[] { + if (!isRecord(response) || !Array.isArray(response.hits)) { + throw new Error('Bobbin returned an invalid search response.') + } + + return response.hits.map((hit, index) => validateSearchHit(hit, index)) +} + +function validateSearchHit(hit: unknown, index: number): Repo { + if (!isRecord(hit) || hit.nsid !== REPOSITORY_COLLECTION || typeof hit.uri !== 'string') { + throw new Error(`Bobbin returned an invalid repository search hit at index ${index}.`) + } + + try { + parseResourceUri(hit.uri) + } catch { + throw new Error(`Bobbin returned an invalid repository URI at index ${index}.`) + } + + return validateRepo({ uri: hit.uri as ResourceUri, value: hit.value }) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} -- 2.51.2