Skip to content

Commit 4de5fd8

Browse files
committed
feat(browse): highlight search matches and disable infinite scroll while searching (Closes #24)
Wire the FTS5 snippet from /api/search into the browse feed so matched terms render with <mark> highlighting, and suspend the scroll-triggered auto-load while a search query is active. - Preserve the FTS5 snippet through the API client (drop only rank) and add it to BrowseItem - Render highlighted snippets in ContentCard, falling back to the plain preview when there is no snippet - Consolidate the XSS-safe snippet parser into src/lib/snippet.ts, shared by the browse card and the command palette; segments render as React text children so no dangerouslySetInnerHTML ever touches untrusted content (single source of truth for the safety property) - Add an infiniteScroll prop to ContentFeed; browse-page passes !filters.q so scrolling no longer auto-loads mid-search (a manual Load more still paginates) - Style <mark> with the --primary-muted design token - Bump 0.10.0 -> 0.11.0 + README badge Closes #24
1 parent 79fe373 commit 4de5fd8

14 files changed

Lines changed: 443 additions & 81 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# ShadowBrain
77

8-
[![Version](https://img.shields.io/badge/version-0.10.0-yellow)](CHANGELOG.md)
8+
[![Version](https://img.shields.io/badge/version-0.11.0-yellow)](CHANGELOG.md)
99

1010
</div>
1111

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shadowbrain",
3-
"version": "0.10.0",
3+
"version": "0.11.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",

src/app/browse/api.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,45 @@ describe("fetchBrowseItems", () => {
252252
const call = calls[0];
253253
expect(call.url).toMatch(/^\/api\/search\?/);
254254
expect(call.url).toMatch(/q=docker/);
255-
// The search-only fields (rank, snippet) must not leak into the
256-
// Browse response shape.
255+
// `rank` (BM25 score) is search-only and must not leak into the
256+
// Browse response shape, but `snippet` is preserved so the card
257+
// can render highlighted matches (issue #24).
257258
expect(result.items).toHaveLength(1);
258259
const item = result.items[0];
259260
expect(item).not.toHaveProperty("rank");
260-
expect(item).not.toHaveProperty("snippet");
261+
expect(item.snippet).toBe("docker <mark>networking</mark>");
261262
expect(item.id).toBe("x");
262263
});
263264

265+
it("preserves the snippet as null when the search row omits it", async () => {
266+
nextResponse = () =>
267+
new Response(
268+
JSON.stringify({
269+
query: "docker",
270+
results: [
271+
{
272+
id: "x",
273+
type: "note",
274+
title: null,
275+
content: "docker networking",
276+
source: "manual",
277+
source_url: null,
278+
created_at: "2026-06-21T00:00:00.000Z",
279+
updated_at: "2026-06-21T00:00:00.000Z",
280+
rank: 1.23,
281+
},
282+
],
283+
total: 1,
284+
page: 1,
285+
limit: 20,
286+
}),
287+
{ status: 200, headers: { "Content-Type": "application/json" } }
288+
);
289+
290+
const result = await fetchBrowseItems({ q: "docker" });
291+
expect(result.items[0].snippet).toBeNull();
292+
});
293+
264294
it("drops empty filter values from the query string", async () => {
265295
nextResponse = () =>
266296
new Response(

src/app/browse/api.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ function endpointFor(filters: BrowseFilters): {
7070
return {
7171
url: "/api/search",
7272
// The search endpoint returns `results` (with a `rank` and a
73-
// FTS5 `snippet`); we strip those to the canonical BrowseItem
74-
// shape so the feed component never has to branch.
73+
// FTS5 `snippet`); we keep the `snippet` (the card renders it
74+
// with highlighted matches) but drop `rank` to the canonical
75+
// BrowseItem shape so the feed component never branches on it.
7576
mapResults: (body) =>
7677
Array.isArray(body.results)
7778
? (body.results as Record<string, unknown>[]).map(stripSearchOnly)
@@ -127,12 +128,12 @@ function coerceTags(raw: unknown): string[] {
127128
}
128129

129130
/** The search endpoint carries a `rank` and a `snippet` per row;
130-
* the items endpoint does not. Both are valid for the Browse feed,
131-
* but the type only declares the shared columns. This normaliser
132-
* maps a raw DB row to the canonical `BrowseItem` shape — it
133-
* prefixes the `image_path` with `/api/images/`, picks up the
134-
* batched `tags`, and drops any search-only fields (`rank`,
135-
* `snippet`). */
131+
* the items endpoint does not. This normaliser maps a raw DB row to
132+
* the canonical `BrowseItem` shape — it prefixes the `image_path`
133+
* with `/api/images/`, picks up the batched `tags`, and omits the
134+
* search-only fields. `stripSearchOnly` layers the `snippet` back on
135+
* for search results (it renders as highlighted matches in the card);
136+
* `rank` (BM25 score) is never surfaced to the client. */
136137
function normaliseItem(row: Record<string, unknown>): BrowseItem {
137138
return {
138139
id: String(row.id),
@@ -149,11 +150,18 @@ function normaliseItem(row: Record<string, unknown>): BrowseItem {
149150
};
150151
}
151152

152-
/** The search endpoint returns `results` (with a `rank` and a
153-
* FTS5 `snippet`); we strip those to the canonical BrowseItem
154-
* shape so the feed component never has to branch. */
153+
/** The search endpoint carries a `rank` (BM25 score) and a `snippet`
154+
* (FTS5 highlight) per row. We keep the `snippet` — the card renders
155+
* it with highlighted matches — but drop `rank` so the canonical
156+
* `BrowseItem` shape stays free of search-only scoring metadata. */
155157
function stripSearchOnly(row: Record<string, unknown>): BrowseItem {
156-
return normaliseItem(row);
158+
return { ...normaliseItem(row), snippet: asStringOrNull(row.snippet) };
159+
}
160+
161+
/** Coerce an unknown value to `string | null` for snippet passthrough.
162+
* Defensive: a missing / non-string `snippet` collapses to `null`. */
163+
function asStringOrNull(value: unknown): string | null {
164+
return typeof value === "string" ? value : null;
157165
}
158166

159167
export async function fetchBrowseItems(

src/app/browse/browse-page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ export function BrowsePage() {
158158
hasMore={hasMore}
159159
onLoadMore={loadMore}
160160
cardVariant={cardVariant}
161+
// Issue #24: while a search query is active, the feed shows
162+
// search results as a finite set — the scroll-triggered
163+
// auto-load is suspended (a manual "Load more" still
164+
// paginates). Clearing `q` re-enables infinite scroll.
165+
infiniteScroll={!filters.q}
161166
// Clicking a card's tag pill narrows the feed to that tag.
162167
// `setFilters({ tag })` updates the URL (`?tag=…`) and resets
163168
// to page 1, exactly like picking the tag from the advanced

src/app/browse/content-card.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,53 @@ describe("ContentCard", () => {
258258
screen.queryByTestId("content-card-metadata-summary")
259259
).not.toBeInTheDocument();
260260
});
261+
262+
it("renders the FTS5 snippet with <mark> highlighting matched terms", () => {
263+
const item: BrowseItem = {
264+
...baseItem,
265+
snippet: "…bridge <mark>networks</mark> are the default…",
266+
};
267+
render(<ContentCard item={item} />);
268+
const snippet = screen.getByTestId("content-card-snippet");
269+
const mark = snippet.querySelector("mark");
270+
expect(mark).not.toBeNull();
271+
expect(mark).toHaveTextContent("networks");
272+
});
273+
274+
it("keeps the line-clamped styling on the snippet paragraph", () => {
275+
const item: BrowseItem = { ...baseItem, snippet: "a <mark>b</mark> c" };
276+
render(<ContentCard item={item} />);
277+
expect(screen.getByTestId("content-card-snippet").className).toMatch(
278+
/line-clamp-3/
279+
);
280+
});
281+
282+
it("falls back to the plain content preview when there is no snippet", () => {
283+
render(<ContentCard item={baseItem} />);
284+
// No snippet test id, and the plain preview text is shown.
285+
expect(
286+
screen.queryByTestId("content-card-snippet")
287+
).not.toBeInTheDocument();
288+
expect(screen.getByText(/Bridge networks/)).toBeInTheDocument();
289+
});
290+
291+
it("treats markup inside the snippet as inert text (XSS-safe)", () => {
292+
// FTS5's snippet() does not escape source content, so a note
293+
// containing a <script> tag would surface in the snippet. We
294+
// render segments as React text children, which escape markup —
295+
// so no executable element is ever created.
296+
const item: BrowseItem = {
297+
...baseItem,
298+
snippet: "…<mark>docker</mark><script>alert(1)</script>…",
299+
};
300+
const { container } = render(<ContentCard item={item} />);
301+
expect(container.querySelector("script")).toBeNull();
302+
// The matched term still highlights.
303+
const mark = screen
304+
.getByTestId("content-card-snippet")
305+
.querySelector("mark");
306+
expect(mark).toHaveTextContent("docker");
307+
});
261308
});
262309

263310
describe("metadataSummary", () => {

src/app/browse/content-card.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import Link from "next/link";
4444
import { useMemo, useState } from "react";
4545

4646
import { cn } from "@/lib/utils";
47+
import { parseSnippet } from "@/lib/snippet";
4748
import {
4849
Tooltip,
4950
TooltipContent,
@@ -222,6 +223,10 @@ export function ContentCard({
222223
[item.created_at]
223224
);
224225
const summary = metadataSummary(item.type, item.metadata);
226+
// Parse the FTS5 snippet (search results only) into highlighted
227+
// segments. `null` when there is no snippet (the regular list view)
228+
// so the card falls back to the plain content preview.
229+
const snippetParts = item.snippet ? parseSnippet(item.snippet) : null;
225230
// When the image 404s, show a text placeholder instead of the
226231
// browser's default broken-image glyph. The file may genuinely
227232
// not exist (the image capture pipeline hasn't created it yet),
@@ -325,9 +330,28 @@ export function ContentCard({
325330
</h3>
326331
) : null}
327332

328-
<p className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words">
329-
{previewText(item.content)}
330-
</p>
333+
{snippetParts ? (
334+
// Search result: render the FTS5 snippet with `<mark>`
335+
// highlighting the matched terms. Segments are React text
336+
// children (auto-escaped), so markup in the source content
337+
// is neutralised — see `parseSnippet`.
338+
<p
339+
data-testid="content-card-snippet"
340+
className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words"
341+
>
342+
{snippetParts.map((part, i) =>
343+
part.highlight ? (
344+
<mark key={i}>{part.text}</mark>
345+
) : (
346+
<span key={i}>{part.text}</span>
347+
)
348+
)}
349+
</p>
350+
) : (
351+
<p className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words">
352+
{previewText(item.content)}
353+
</p>
354+
)}
331355

332356
{summary ? (
333357
<p

src/app/browse/content-feed.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,98 @@ describe("ContentFeed", () => {
234234
expect(onLoadMore).toHaveBeenCalledOnce();
235235
});
236236

237+
it("attaches the scroll observer by default (infinite scroll on)", () => {
238+
const observeSpy = vi.spyOn(IntersectionObserver.prototype, "observe");
239+
render(
240+
<ContentFeed
241+
items={[item]}
242+
status="success"
243+
error={null}
244+
onRetry={() => undefined}
245+
hasActiveFilters={false}
246+
{...defaultProps}
247+
hasMore
248+
/>
249+
);
250+
expect(observeSpy).toHaveBeenCalled();
251+
});
252+
253+
it("does not attach the scroll observer during an active search (infiniteScroll off)", () => {
254+
// Issue #24: while searching, the feed shows results as a finite
255+
// set — the IntersectionObserver sentinel must not auto-load.
256+
const observeSpy = vi.spyOn(IntersectionObserver.prototype, "observe");
257+
render(
258+
<ContentFeed
259+
items={[item]}
260+
status="success"
261+
error={null}
262+
onRetry={() => undefined}
263+
hasActiveFilters={false}
264+
{...defaultProps}
265+
hasMore
266+
infiniteScroll={false}
267+
/>
268+
);
269+
expect(observeSpy).not.toHaveBeenCalled();
270+
// The sentinel element is still rendered (it doubles as a layout
271+
// spacer), but it has no observer attached.
272+
expect(screen.getByTestId("feed-sentinel")).toBeInTheDocument();
273+
});
274+
275+
it("detaches the observer when infiniteScroll flips from on to off (typing a query)", () => {
276+
// The real user flow: browse (infinite scroll on) → type a query
277+
// (infinite scroll off). The effect cleanup must disconnect the
278+
// observer so scrolling no longer auto-loads mid-search.
279+
const disconnectSpy = vi.spyOn(
280+
IntersectionObserver.prototype,
281+
"disconnect"
282+
);
283+
const { rerender } = render(
284+
<ContentFeed
285+
items={[item]}
286+
status="success"
287+
error={null}
288+
onRetry={() => undefined}
289+
hasActiveFilters={false}
290+
{...defaultProps}
291+
hasMore
292+
infiniteScroll
293+
/>
294+
);
295+
expect(disconnectSpy).not.toHaveBeenCalled();
296+
rerender(
297+
<ContentFeed
298+
items={[item]}
299+
status="success"
300+
error={null}
301+
onRetry={() => undefined}
302+
hasActiveFilters={false}
303+
{...defaultProps}
304+
hasMore
305+
infiniteScroll={false}
306+
/>
307+
);
308+
expect(disconnectSpy).toHaveBeenCalled();
309+
});
310+
311+
it("still shows the manual Load more button during search when hasMore", () => {
312+
// Infinite scroll is off, but manual pagination stays available so
313+
// search results are never cut off.
314+
render(
315+
<ContentFeed
316+
items={[item]}
317+
status="success"
318+
error={null}
319+
onRetry={() => undefined}
320+
hasActiveFilters={false}
321+
{...defaultProps}
322+
hasMore
323+
infiniteScroll={false}
324+
/>
325+
);
326+
expect(screen.getByTestId("feed-load-more-button")).toBeInTheDocument();
327+
});
328+
237329
it("threads onTagClick through to each card's tag pill", async () => {
238330
const onTagClick = vi.fn();
239331
const user = userEvent.setup();

src/app/browse/content-feed.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export interface ContentFeedProps {
6060
* to `setFilters({ tag })` so a click narrows the feed and the
6161
* URL picks up `?tag=…`. */
6262
onTagClick?: (tag: string) => void;
63+
/** Whether the scroll-triggered auto-load (the IntersectionObserver
64+
* on the sentinel) is active. Disabled during an active search so
65+
* results appear as a finite set "replacing infinite scroll" (issue
66+
* #24); a manual "Load more" button still paginates if `hasMore`.
67+
* Defaults to `true` (the normal browse feed). */
68+
infiniteScroll?: boolean;
6369
}
6470

6571
const SKELETON_CARD_COUNT = 6;
@@ -96,6 +102,7 @@ export function ContentFeed({
96102
onLoadMore,
97103
cardVariant = "larger-dot",
98104
onTagClick,
105+
infiniteScroll = true,
99106
}: ContentFeedProps) {
100107
// ---- Column-count derivation for the masonry grid -----------
101108
// We watch the container width (via ResizeObserver) and derive
@@ -125,8 +132,14 @@ export function ContentFeed({
125132
}, [items, view, gridColumnCount]);
126133

127134
// ---- Infinite-scroll sentinel -------------------------------
135+
// Disabled during an active search (`infiniteScroll === false`):
136+
// the observer is not attached, so scrolling to the bottom does
137+
// not auto-fetch the next page. A manual "Load more" button below
138+
// still paginates, so search results are never cut off — only the
139+
// scroll-triggered auto-load (the "infinite scroll") is suspended.
128140
const sentinelRef = useRef<HTMLDivElement | null>(null);
129141
useEffect(() => {
142+
if (!infiniteScroll) return;
130143
const node = sentinelRef.current;
131144
if (!node) return;
132145
if (typeof IntersectionObserver === "undefined") return;
@@ -140,7 +153,7 @@ export function ContentFeed({
140153
);
141154
observer.observe(node);
142155
return () => observer.disconnect();
143-
}, [onLoadMore]);
156+
}, [onLoadMore, infiniteScroll]);
144157

145158
if (status === "error") {
146159
return (

src/app/browse/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ export interface BrowseItem {
4444
* when the item has no tags. Surfaced by the list / search API
4545
* routes via a batched `content_tags` lookup. */
4646
tags: string[];
47+
/** FTS5-generated snippet from `/api/search`, with matched terms
48+
* wrapped in `<mark>…</mark>` and `…` as the ellipsis. Present
49+
* only for search results; `null` / `undefined` for the regular
50+
* `/api/items` list. The card renders this in place of the
51+
* plain content preview so the match is visually highlighted.
52+
* The snippet is parsed into plain / highlight segments by
53+
* `parseSnippet` (src/lib/snippet.ts) and rendered as React text
54+
* children, which auto-escape any markup the source content
55+
* contained — there is no `dangerouslySetInnerHTML` anywhere in
56+
* the path. */
57+
snippet?: string | null;
4758
created_at: string;
4859
updated_at: string;
4960
}

0 commit comments

Comments
 (0)