Skip to content

Commit d880eef

Browse files
backnotpropclaude
andcommitted
feat(review): wire search into file tree sidebar
VS Code-style search UI in the file tree panel. Search input at the top of the sidebar replaces the tree with grouped results when active. Clicking a match navigates to the file and scrolls to the line. Keyboard: Cmd/Ctrl+F focuses input, Enter/F3 steps through matches, Escape clears. In-diff highlights via shadow DOM traversal. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2fd47c6 commit d880eef

5 files changed

Lines changed: 305 additions & 23 deletions

File tree

bun.lock

Lines changed: 3 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/review-editor/App.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannota
1212
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
1313
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
1414
import { useGitAdd } from './hooks/useGitAdd';
15+
import { isTypingTarget, useReviewSearch } from './hooks/useReviewSearch';
1516
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
1617
import { exportEditorAnnotations } from '@plannotator/ui/utils/parser';
1718
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
@@ -113,6 +114,32 @@ const ReviewApp: React.FC = () => {
113114

114115
const identity = useMemo(() => getIdentity(), []);
115116

117+
const clearPendingSelection = useCallback(() => {
118+
setPendingSelection(null);
119+
}, []);
120+
121+
const {
122+
searchQuery,
123+
isSearchOpen,
124+
activeSearchMatchId,
125+
activeSearchMatch,
126+
activeFileSearchMatches,
127+
searchMatches,
128+
searchGroups,
129+
searchInputRef,
130+
openSearch,
131+
closeSearch,
132+
clearSearch,
133+
stepSearchMatch,
134+
handleSearchInputChange,
135+
handleSelectSearchMatch,
136+
} = useReviewSearch({
137+
files,
138+
activeFileIndex,
139+
setActiveFileIndex,
140+
clearPendingSelection,
141+
});
142+
116143
// Auto-save code annotation drafts
117144
const { draftBanner, restoreDraft, dismissDraft } = useCodeAnnotationDraft({
118145
annotations,
@@ -141,10 +168,28 @@ const ReviewApp: React.FC = () => {
141168
// Global keyboard shortcuts
142169
useEffect(() => {
143170
const handleKeyDown = (e: KeyboardEvent) => {
144-
// Escape closes modals
171+
// Cmd/Ctrl+F to focus search (only when sidebar is rendered)
172+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) {
173+
if (files.length > 1 || gitContext?.diffOptions) {
174+
e.preventDefault();
175+
openSearch();
176+
}
177+
return;
178+
}
179+
180+
// Enter/F3 to step through search matches
181+
if ((e.key === 'Enter' || e.key === 'F3') && searchMatches.length > 0 && !isTypingTarget(e.target)) {
182+
e.preventDefault();
183+
stepSearchMatch(e.shiftKey ? -1 : 1);
184+
return;
185+
}
186+
187+
// Escape closes modals or clears search
145188
if (e.key === 'Escape') {
146189
if (showExportModal) {
147190
setShowExportModal(false);
191+
} else if (searchQuery) {
192+
clearSearch();
148193
}
149194
}
150195
// Cmd/Ctrl+Shift+C to copy diff
@@ -157,7 +202,7 @@ const ReviewApp: React.FC = () => {
157202
window.addEventListener('keydown', handleKeyDown);
158203
return () => window.removeEventListener('keydown', handleKeyDown);
159204
// eslint-disable-next-line react-hooks/exhaustive-deps
160-
}, [showExportModal]);
205+
}, [showExportModal, searchQuery, searchMatches, openSearch, stepSearchMatch, clearSearch, files, gitContext?.diffOptions]);
161206

162207
// Get annotations for active file
163208
const activeFileAnnotations = useMemo(() => {
@@ -818,6 +863,15 @@ const ReviewApp: React.FC = () => {
818863
onSelectWorktree={handleWorktreeSwitch}
819864
currentBranch={gitContext?.currentBranch}
820865
stagedFiles={stagedFiles}
866+
searchQuery={searchQuery}
867+
searchInputRef={searchInputRef}
868+
onSearchChange={handleSearchInputChange}
869+
onSearchClear={clearSearch}
870+
searchGroups={searchGroups}
871+
searchMatches={searchMatches}
872+
activeSearchMatchId={activeSearchMatchId}
873+
onSelectSearchMatch={handleSelectSearchMatch}
874+
onStepSearchMatch={stepSearchMatch}
821875
/>
822876
<ResizeHandle {...fileTreeResize.handleProps} />
823877
</>
@@ -862,6 +916,10 @@ const ReviewApp: React.FC = () => {
862916
onStage={() => stageFile(activeFile.path)}
863917
canStage={canStageFiles}
864918
stageError={stageError}
919+
searchQuery={searchQuery}
920+
searchMatches={activeFileSearchMatches}
921+
activeSearchMatchId={activeSearchMatchId}
922+
activeSearchMatch={activeSearchMatch?.filePath === activeFile.path ? activeSearchMatch : null}
865923
/>
866924
) : (
867925
<div className="h-full flex items-center justify-center">

packages/review-editor/components/DiffViewer.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { FileHeader } from './FileHeader';
1010
import { InlineAnnotation } from './InlineAnnotation';
1111
import { AnnotationToolbar } from './AnnotationToolbar';
1212
import { SuggestionModal } from './SuggestionModal';
13+
import { type ReviewSearchMatch } from '../utils/reviewSearch';
14+
import {
15+
applySearchHighlights,
16+
getSearchRoots,
17+
retryScrollToSearchMatch,
18+
} from '../utils/reviewSearchHighlight';
1319

1420
interface DiffViewerProps {
1521
patch: string;
@@ -32,6 +38,10 @@ interface DiffViewerProps {
3238
onStage?: () => void;
3339
canStage?: boolean;
3440
stageError?: string | null;
41+
searchQuery?: string;
42+
searchMatches?: ReviewSearchMatch[];
43+
activeSearchMatchId?: string | null;
44+
activeSearchMatch?: ReviewSearchMatch | null;
3545
}
3646

3747
export const DiffViewer: React.FC<DiffViewerProps> = ({
@@ -55,6 +65,10 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
5565
onStage,
5666
canStage = false,
5767
stageError,
68+
searchQuery = '',
69+
searchMatches = [],
70+
activeSearchMatchId = null,
71+
activeSearchMatch = null,
5872
}) => {
5973
const { theme, colorTheme, resolvedMode } = useTheme();
6074
const containerRef = useRef<HTMLDivElement>(null);
@@ -120,6 +134,24 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
120134
return () => clearTimeout(timeoutId);
121135
}, [selectedAnnotationId]);
122136

137+
// Apply search highlights to diff lines (including inside shadow DOM)
138+
useEffect(() => {
139+
if (!containerRef.current) return;
140+
141+
const frameId = requestAnimationFrame(() => {
142+
const roots = getSearchRoots(containerRef.current!);
143+
roots.forEach(root => applySearchHighlights(root, searchQuery, searchMatches, activeSearchMatchId));
144+
});
145+
146+
return () => cancelAnimationFrame(frameId);
147+
}, [searchQuery, searchMatches, activeSearchMatchId, filePath, diffStyle, augmentedDiff]);
148+
149+
// Scroll to active search match (with retry for lazy-rendered content)
150+
useEffect(() => {
151+
if (!activeSearchMatch || !containerRef.current) return;
152+
return retryScrollToSearchMatch(containerRef.current, activeSearchMatch);
153+
}, [activeSearchMatch, filePath, diffStyle]);
154+
123155
// Map annotations to @pierre/diffs format
124156
const lineAnnotations = useMemo(() => {
125157
return annotations

0 commit comments

Comments
 (0)