From f0f651bfc74681988f56bd610074e1dce6dbee1c Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 4 May 2026 10:06:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20search=20data=20layer=20=E2=80=94?= =?UTF-8?q?=20adapter,=20highlight=20helper,=20hook,=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/adapters.ts | 51 ++++++ web/src/features/search/highlightSnippet.ts | 23 +++ web/src/features/search/useSearch.test.ts | 166 ++++++++++++++++++++ web/src/features/search/useSearch.ts | 74 +++++++++ 4 files changed, 314 insertions(+) create mode 100644 web/src/features/search/highlightSnippet.ts create mode 100644 web/src/features/search/useSearch.test.ts create mode 100644 web/src/features/search/useSearch.ts diff --git a/web/src/api/adapters.ts b/web/src/api/adapters.ts index b31a45d8ff645d4f4d1e3d932523cda85a271d67..48eeb9026ca579d301092d041f6da6272b7e6dbe 100644 --- a/web/src/api/adapters.ts +++ b/web/src/api/adapters.ts @@ -204,6 +204,57 @@ export function adaptStats(d: StatsDTO): Stats { } } +// ── Search ──────────────────────────────────────────────────────────────────── + +export interface SearchRowDTO { + owner: string + tool: string + host: string + session_id: string + working_dir?: string + turn_id: string + seq: number + role: string + timestamp: number + rank: number + match_source: string + snippet: string +} + +export interface SearchRow { + tool: string + host: string + sessionId: string + cwd: string + turnId: string + role: string + timestamp: number + rank: number + matchSource: string + snippet: string +} + +export interface SearchResult { + results: SearchRow[] + limit: number + nextCursor?: string +} + +export function adaptSearchRow(d: SearchRowDTO): SearchRow { + return { + tool: d.tool, + host: d.host, + sessionId: d.session_id, + cwd: d.working_dir ?? '', + turnId: d.turn_id, + role: d.role, + timestamp: d.timestamp, + rank: d.rank, + matchSource: d.match_source, + snippet: d.snippet, + } +} + // ── Saved searches ────────────────────────────────────────────────────────── export interface SavedSearchDTO { diff --git a/web/src/features/search/highlightSnippet.ts b/web/src/features/search/highlightSnippet.ts new file mode 100644 index 0000000000000000000000000000000000000000..95418f13938c35a592411cb2ff11e004fd9cdf34 --- /dev/null +++ b/web/src/features/search/highlightSnippet.ts @@ -0,0 +1,23 @@ +import React from 'react' + +const MARKER_RUNES = /\x02|\x03/g + +/** + * Splits a snippet string on \x02/\x03 marker runes and interleaves plain text + * with `` elements for matched segments. + * + * Respects IV2 — no dangerouslySetInnerHTML, marker-rune split with React text + * nodes only, safe against XSS. + */ +export function highlightSnippet(snippet: string): (string | React.JSX.Element)[] { + const parts = snippet.split(MARKER_RUNES) + const out: (string | React.JSX.Element)[] = [] + let inMatch = false + for (const p of parts) { + if (p === '') continue + if (inMatch) out.push(React.createElement('mark', { key: out.length }, p)) + else out.push(p) + inMatch = !inMatch + } + return out +} diff --git a/web/src/features/search/useSearch.test.ts b/web/src/features/search/useSearch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..437c872a23669840949ad2139a07ec0c78fe6538 --- /dev/null +++ b/web/src/features/search/useSearch.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useSearch } from './useSearch' +import type { SearchRowDTO } from '../../api/adapters' +import type { SearchFilters } from './useSearch' + +// ── Mock apiFetch ───────────────────────────────────────────────────────────── + +const mockApiFetch = vi.fn() + +vi.mock('../../api/client', () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), + apiFetchVoid: vi.fn().mockResolvedValue(undefined), + AuthError: class AuthError extends Error { + name = 'AuthError' + constructor(message: string) { super(message) } + }, + APIError: class APIError extends Error { + name = 'APIError' + status: number + code: string + constructor(message: string, status: number, code: string) { + super(message) + this.status = status + this.code = code + } + }, +})) + +// ── Test wrapper ────────────────────────────────────────────────────────────── + +function makeWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRowDTO(overrides?: Partial): SearchRowDTO { + return { + owner: '', + tool: 'opencode', + host: 'laptop', + session_id: 'ses-1', + working_dir: '/home/user', + turn_id: 'turn-1', + seq: 1, + role: 'user', + timestamp: 1000000, + rank: 0.5, + match_source: 'message', + snippet: 'hello \x02world\x03', + ...overrides, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useSearch', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + mockApiFetch.mockReset() + }) + + it('constructs correct URL from filters (q, tool, host → query string)', async () => { + mockApiFetch.mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 }) + + const filters: SearchFilters = { q: 'hello', tool: 'opencode', host: 'laptop' } + const wrapper = makeWrapper(queryClient) + renderHook(() => useSearch(filters, true), { wrapper }) + + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledTimes(1) + }) + + const url = mockApiFetch.mock.calls[0][0] as string + expect(url).toContain('/api/v1/search') + expect(url).toContain('q=hello') + expect(url).toContain('tool=opencode') + expect(url).toContain('host=laptop') + }) + + it('getNextPageParam returns next cursor from last page', async () => { + mockApiFetch + .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20, next_cursor: 'cursor-2' }) + .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 }) + + const filters: SearchFilters = { q: 'hello' } + const wrapper = makeWrapper(queryClient) + const { result } = renderHook(() => useSearch(filters, true), { wrapper }) + + // Wait for first page + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + // Fetch next page + await act(() => result.current.fetchNextPage()) + + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledTimes(2) + }) + + // Second call should have cursor param + const secondUrl = mockApiFetch.mock.calls[1][0] as string + expect(secondUrl).toContain('cursor=cursor-2') + }) + + it('returns flat results array from all pages', async () => { + mockApiFetch + .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'a' })], limit: 20, next_cursor: 'cursor-2' }) + .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'b' })], limit: 20 }) + + const filters: SearchFilters = { q: 'hello' } + const wrapper = makeWrapper(queryClient) + const { result } = renderHook(() => useSearch(filters, true), { wrapper }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.results).toHaveLength(1) + + await act(() => result.current.fetchNextPage()) + await waitFor(() => { + expect(result.current.results).toHaveLength(2) + }) + }) + + it('returns empty results when q is empty string', async () => { + const filters: SearchFilters = { q: '' } + const wrapper = makeWrapper(queryClient) + const { result } = renderHook(() => useSearch(filters, true), { wrapper }) + + expect(result.current.results).toEqual([]) + expect(mockApiFetch).not.toHaveBeenCalled() + }) + + it('does not fetch when disabled', async () => { + const filters: SearchFilters = { q: 'hello' } + const wrapper = makeWrapper(queryClient) + renderHook(() => useSearch(filters, false), { wrapper }) + + // Give any potential fetch a moment + await new Promise(r => setTimeout(r, 50)) + expect(mockApiFetch).not.toHaveBeenCalled() + }) + + it('propagates fetch errors', async () => { + mockApiFetch.mockRejectedValueOnce(new Error('network error')) + + const filters: SearchFilters = { q: 'hello' } + const wrapper = makeWrapper(queryClient) + const { result } = renderHook(() => useSearch(filters, true), { wrapper }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBeDefined() + expect(result.current.error).toBeInstanceOf(Error) + }) +}) diff --git a/web/src/features/search/useSearch.ts b/web/src/features/search/useSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7df17c102efb395a5e223419807addf161e671a --- /dev/null +++ b/web/src/features/search/useSearch.ts @@ -0,0 +1,74 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { apiFetch } from '../../api/client' +import { adaptSearchRow } from '../../api/adapters' +import type { SearchRow, SearchRowDTO } from '../../api/adapters' + +interface SearchResponseDTO { + results: SearchRowDTO[] + limit: number + next_cursor?: string +} + +export interface SearchResult { + results: SearchRow[] + limit: number + nextCursor?: string +} + +export interface SearchFilters { + q: string + tool?: string + host?: string +} + +interface UseSearchReturn { + results: SearchRow[] + fetchNextPage: () => void + hasNextPage: boolean + isFetchingNextPage: boolean + isLoading: boolean + error: Error | null +} + +/** + * Hook for cursor-paginated full-text search against the search API. + * + * - Query key includes filters so changing any filter triggers a fresh fetch. + * - Empty `q` short-circuits to an empty result set without issuing a request. + * - Cursor pagination appends results (never replaces) when fetchNextPage is called. + * + * Respects: IV3 (cursor pagination), AS1 (cursor pagination is stable). + */ +export function useSearch(filters: SearchFilters, enabled: boolean): UseSearchReturn { + const query = useInfiniteQuery({ + queryKey: ['search', filters], + queryFn: async ({ pageParam }) => { + const params = new URLSearchParams() + params.set('q', filters.q) + if (filters.tool) params.set('tool', filters.tool) + if (filters.host) params.set('host', filters.host) + if (pageParam) params.set('cursor', pageParam as string) + return apiFetch(`/api/v1/search?${params.toString()}`) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: enabled && filters.q.length > 0, + staleTime: 0, + select: (data) => ({ + pages: data.pages, + pageParams: data.pageParams, + results: data.pages.flatMap(p => p.results.map(adaptSearchRow)), + limit: data.pages[data.pages.length - 1]?.limit ?? 0, + nextCursor: data.pages[data.pages.length - 1]?.next_cursor, + }), + }) + + return { + results: query.data?.results ?? [], + fetchNextPage: query.fetchNextPage, + hasNextPage: query.hasNextPage ?? false, + isFetchingNextPage: query.isFetchingNextPage, + isLoading: query.isLoading, + error: query.error, + } +}