diff --git a/src/client/app.tsx b/src/client/app.tsx index 00f46af..4c40467 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,12 +1,14 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect, useCallback, useRef } from "react"; import { SessionList } from "./components/SessionList"; import { SessionViewer } from "./components/SessionViewer"; import { FilterPanel } from "./components/FilterPanel"; import { SearchBar } from "./components/SearchBar"; +import { SearchMinimap } from "./components/SearchMinimap"; import { ExportButton } from "./components/ExportButton"; import { useSession } from "./hooks/useSession"; import { useFilters } from "./hooks/useFilters"; + export function App() { const { sessions, @@ -23,23 +25,91 @@ export function App() { return filters.filterMessages(currentSession.messages); }, [currentSession, filters.filterMessages]); - // Derive match count from filtered messages - no setState during render - const matchCount = useMemo( - () => filters.getMatchCount(filteredMessages), - [filters.getMatchCount, filteredMessages] - ); + // Track which filtered-message indices match the search query + const matchIndices = useMemo(() => { + if (!filters.searchQuery) return []; + const lq = filters.searchQuery.toLowerCase(); + return filteredMessages.reduce((acc, msg, i) => { + if (msg.content.toLowerCase().includes(lq)) acc.push(i); + return acc; + }, []); + }, [filteredMessages, filters.searchQuery]); + + const matchCount = matchIndices.length; + + // Which match is currently focused (index into matchIndices) + const [currentMatchPosition, setCurrentMatchPosition] = useState(-1); + + // Reset to first match when search results change + useEffect(() => { + setCurrentMatchPosition(matchIndices.length > 0 ? 0 : -1); + }, [matchIndices]); + + const goToNextMatch = useCallback(() => { + if (matchIndices.length === 0) return; + setCurrentMatchPosition((prev) => + prev < matchIndices.length - 1 ? prev + 1 : 0 + ); + }, [matchIndices.length]); + + const goToPrevMatch = useCallback(() => { + if (matchIndices.length === 0) return; + setCurrentMatchPosition((prev) => + prev > 0 ? prev - 1 : matchIndices.length - 1 + ); + }, [matchIndices.length]); + + const focusedMessageIndex = + currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1; const visibleUuids = useMemo( () => filteredMessages.map((m) => m.uuid), [filteredMessages] ); + // Scroll tracking for minimap viewport indicator + const scrollRef = useRef(null); + const [viewportTop, setViewportTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(1); + + const updateViewport = useCallback(() => { + const el = scrollRef.current; + if (!el || el.scrollHeight === 0) return; + setViewportTop(el.scrollTop / el.scrollHeight); + setViewportHeight(Math.min(el.clientHeight / el.scrollHeight, 1)); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.addEventListener("scroll", updateViewport, { passive: true }); + // Initial measurement + updateViewport(); + // Re-measure when content changes + const ro = new ResizeObserver(updateViewport); + ro.observe(el); + return () => { + el.removeEventListener("scroll", updateViewport); + ro.disconnect(); + }; + }, [updateViewport]); + + // Re-measure when messages change (content size changes) + useEffect(() => { + updateViewport(); + }, [filteredMessages, updateViewport]); + return ( -
+
{/* Sidebar */} -
-
-

Session Viewer

+
+
+

+ Session Viewer +

+

+ Browse and export Claude sessions +

-
- -
+
{/* Main */}
-
- - {filters.selectedForRedaction.size > 0 && ( -
- - {filters.selectedForRedaction.size} selected - - - -
- )} - {currentSession && ( - + {/* Left spacer — mirrors right side width to keep search centered */} +
+ + {/* Center — search bar + contextual redaction controls */} +
+ - )} + {filters.selectedForRedaction.size > 0 && ( +
+ + {filters.selectedForRedaction.size} selected + + + +
+ )} +
+ + {/* Right — export button, right-justified */} +
+ {currentSession && ( + + )} +
-
- +
+ +
+
diff --git a/src/client/components/SearchBar.tsx b/src/client/components/SearchBar.tsx index 1631d2b..3cd903f 100644 --- a/src/client/components/SearchBar.tsx +++ b/src/client/components/SearchBar.tsx @@ -4,11 +4,22 @@ interface Props { query: string; onQueryChange: (q: string) => void; matchCount: number; + currentMatchPosition: number; + onNext: () => void; + onPrev: () => void; } -export function SearchBar({ query, onQueryChange, matchCount }: Props) { +export function SearchBar({ + query, + onQueryChange, + matchCount, + currentMatchPosition, + onNext, + onPrev, +}: Props) { const inputRef = useRef(null); const [localQuery, setLocalQuery] = useState(query); + const [isFocused, setIsFocused] = useState(false); const debounceRef = useRef>(); // Sync external query changes (e.g., clearing from Escape key) @@ -25,8 +36,10 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) { return () => clearTimeout(debounceRef.current); }, [localQuery, query, onQueryChange]); + // Global keyboard shortcuts useEffect(() => { function handleKeyDown(e: KeyboardEvent) { + // "/" to focus search if ( e.key === "/" && !e.ctrlKey && @@ -35,40 +48,154 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) { ) { e.preventDefault(); inputRef.current?.focus(); + return; + } + + // Escape to blur and clear + if (e.key === "Escape" && document.activeElement === inputRef.current) { + e.preventDefault(); + if (localQuery) { + setLocalQuery(""); + onQueryChange(""); + } + inputRef.current?.blur(); + return; + } + + // Ctrl+G / Ctrl+Shift+G for next/prev match + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") { + e.preventDefault(); + if (e.shiftKey) { + onPrev(); + } else { + onNext(); + } } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [localQuery, onNext, onPrev, onQueryChange]); + + function handleInputKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + // Flush debounce immediately + if (localQuery !== query) { + clearTimeout(debounceRef.current); + onQueryChange(localQuery); + } + if (e.shiftKey) { + onPrev(); + } else { + onNext(); + } + } + } + + const hasResults = query && matchCount > 0; + const hasNoResults = query && matchCount === 0; return ( -
-
+
+ {/* Unified search container */} +
+ {/* Search icon */} +
+ + + +
+ + {/* Input */} setLocalQuery(e.target.value)} - placeholder='Search messages... (press "/" to focus)' - className="w-full pl-3 pr-8 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + onKeyDown={handleInputKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder="Search messages..." + className="flex-1 min-w-0 bg-transparent px-2.5 py-2 text-body text-foreground + placeholder:text-foreground-muted + focus:outline-none" /> - {localQuery && ( - - )} + + {/* Right-side controls — all inside the unified bar */} +
+ {/* Match count badge */} + {query && ( +
+ {hasNoResults ? ( + No results + ) : ( + {currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}/{matchCount} + )} +
+ )} + + {/* Navigation arrows — only when there are results */} + {hasResults && ( +
+ + +
+ )} + + {/* Clear button or keyboard hint */} + {localQuery ? ( + + ) : ( + + / + + )} +
- {query && ( - - {matchCount} match{matchCount !== 1 ? "es" : ""} - - )}
); } diff --git a/src/client/components/SearchMinimap.test.tsx b/src/client/components/SearchMinimap.test.tsx new file mode 100644 index 0000000..07563d2 --- /dev/null +++ b/src/client/components/SearchMinimap.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { SearchMinimap } from "./SearchMinimap"; + +const defaultProps = { + onClickMatch: vi.fn(), + viewportTop: 0, + viewportHeight: 0.3, +}; + +describe("SearchMinimap", () => { + it("renders nothing when matchIndices is empty", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when totalMessages is 0", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders a tick for each match", () => { + const { container } = render( + + ); + const ticks = container.querySelectorAll(".search-minimap-tick"); + expect(ticks).toHaveLength(3); + }); + + it("positions ticks proportionally", () => { + const { container } = render( + + ); + const tick = container.querySelector(".search-minimap-tick") as HTMLElement; + expect(tick.style.top).toBe("50%"); + }); + + it("marks the active tick with the active class", () => { + const { container } = render( + + ); + const ticks = container.querySelectorAll(".search-minimap-tick"); + expect(ticks[0]).not.toHaveClass("search-minimap-tick-active"); + expect(ticks[1]).toHaveClass("search-minimap-tick-active"); + expect(ticks[2]).not.toHaveClass("search-minimap-tick-active"); + }); + + it("calls onClickMatch with the position index when a tick is clicked", () => { + const onClickMatch = vi.fn(); + const { container } = render( + + ); + const ticks = container.querySelectorAll(".search-minimap-tick"); + fireEvent.click(ticks[1]); + expect(onClickMatch).toHaveBeenCalledWith(1); + }); + + it("renders a viewport highlight rectangle", () => { + const { container } = render( + + ); + const viewport = container.querySelector(".search-minimap-viewport") as HTMLElement; + expect(viewport).toBeInTheDocument(); + expect(viewport.style.top).toBe("25%"); + expect(viewport.style.height).toBe("40%"); + }); +}); diff --git a/src/client/components/SearchMinimap.tsx b/src/client/components/SearchMinimap.tsx new file mode 100644 index 0000000..990c46e --- /dev/null +++ b/src/client/components/SearchMinimap.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +interface Props { + matchIndices: number[]; + totalMessages: number; + currentPosition: number; + onClickMatch: (position: number) => void; + /** scrollTop / scrollHeight ratio (0-1) */ + viewportTop: number; + /** clientHeight / scrollHeight ratio (0-1, clamped) */ + viewportHeight: number; +} + +export function SearchMinimap({ + matchIndices, + totalMessages, + currentPosition, + onClickMatch, + viewportTop, + viewportHeight, +}: Props) { + if (matchIndices.length === 0 || totalMessages === 0) return null; + + return ( +