Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 131 additions & 11 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider';
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
import { ModeToggle } from '@plannotator/ui/components/ModeToggle';
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
import { Settings } from '@plannotator/ui/components/Settings';
Expand All @@ -12,6 +12,7 @@ import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannota
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
import { useGitAdd } from './hooks/useGitAdd';
import { isTypingTarget, useReviewSearch } from './hooks/useReviewSearch';
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
import { exportEditorAnnotations } from '@plannotator/ui/utils/parser';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
Expand Down Expand Up @@ -127,6 +128,32 @@ const ReviewApp: React.FC = () => {
if (restored.viewedFiles.length > 0) setViewedFiles(new Set(restored.viewedFiles));
}, [restoreDraft]);

const clearPendingSelection = useCallback(() => {
setPendingSelection(null);
}, []);

const {
searchQuery,
isSearchOpen,
activeSearchMatchId,
activeSearchMatch,
activeFileSearchMatches,
searchMatches,
searchGroups,
searchInputRef,
openSearch,
closeSearch,
clearSearch,
stepSearchMatch,
handleSearchInputChange,
handleSelectSearchMatch,
} = useReviewSearch({
files,
activeFileIndex,
setActiveFileIndex,
clearPendingSelection,
});

// VS Code editor annotations (only polls when inside VS Code webview)
const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations();

Expand All @@ -141,8 +168,30 @@ const ReviewApp: React.FC = () => {
// Global keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) {
e.preventDefault();
openSearch();
return;
}

if ((e.key === 'Enter' || e.key === 'F3') && searchMatches.length > 0 && !isTypingTarget(e.target)) {
e.preventDefault();
stepSearchMatch(e.shiftKey ? -1 : 1);
return;
}

// Escape closes modals
if (e.key === 'Escape') {
if (searchQuery) {
e.preventDefault();
clearSearch();
return;
}
if (isSearchOpen) {
e.preventDefault();
closeSearch();
return;
}
if (showExportModal) {
setShowExportModal(false);
}
Expand All @@ -157,7 +206,7 @@ const ReviewApp: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showExportModal]);
}, [showExportModal, isSearchOpen, searchQuery, searchMatches, openSearch, stepSearchMatch]);

// Get annotations for active file
const activeFileAnnotations = useMemo(() => {
Expand Down Expand Up @@ -652,6 +701,69 @@ const ReviewApp: React.FC = () => {
</button>
</div>

<div className={`review-search ${isSearchOpen || searchQuery ? 'open' : ''}`}>
<button
onClick={() => {
openSearch();
}}
className="review-search-toggle"
title="Search diff (Cmd/Ctrl+F)"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-4.35-4.35m1.85-5.15a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" />
</svg>
</button>
{(isSearchOpen || searchQuery) && (
<>
<input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
stepSearchMatch(e.shiftKey ? -1 : 1);
}
}}
placeholder="Search whole diff"
className="review-search-input"
/>
<span className="review-search-count">
{searchMatches.length === 0 && searchQuery.trim() ? '0' : searchMatches.length}
</span>
<button
onClick={() => stepSearchMatch(-1)}
disabled={searchMatches.length === 0}
className="review-search-nav"
title="Previous match"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15 19-7-7 7-7" />
</svg>
</button>
<button
onClick={() => stepSearchMatch(1)}
disabled={searchMatches.length === 0}
className="review-search-nav"
title="Next match"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
</svg>
</button>
<button
onClick={clearSearch}
className="review-search-clear"
title="Clear search"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</>
)}
</div>

{/* Primary actions */}
<button
onClick={handleCopyDiff}
Expand Down Expand Up @@ -809,18 +921,22 @@ const ReviewApp: React.FC = () => {
onToggleHideViewed={() => setHideViewedFiles(prev => !prev)}
enableKeyboardNav={!showExportModal}
diffOptions={gitContext?.diffOptions}
activeDiffType={activeDiffBase}
onSelectDiff={handleDiffSwitch}
isLoadingDiff={isLoadingDiff}
width={fileTreeResize.width}
activeDiffType={activeDiffBase}
onSelectDiff={handleDiffSwitch}
isLoadingDiff={isLoadingDiff}
width={fileTreeResize.width}
worktrees={gitContext?.worktrees}
activeWorktreePath={activeWorktreePath}
onSelectWorktree={handleWorktreeSwitch}
currentBranch={gitContext?.currentBranch}
stagedFiles={stagedFiles}
/>
<ResizeHandle {...fileTreeResize.handleProps} />
</>
currentBranch={gitContext?.currentBranch}
stagedFiles={stagedFiles}
searchQuery={searchQuery}
searchGroups={searchGroups}
activeSearchMatchId={activeSearchMatchId}
onSelectSearchMatch={handleSelectSearchMatch}
/>
<ResizeHandle {...fileTreeResize.handleProps} />
</>
)}

{/* Diff viewer */}
Expand Down Expand Up @@ -862,6 +978,10 @@ const ReviewApp: React.FC = () => {
onStage={() => stageFile(activeFile.path)}
canStage={canStageFiles}
stageError={stageError}
searchQuery={searchQuery}
searchMatches={activeFileSearchMatches}
activeSearchMatchId={activeSearchMatchId}
activeSearchMatch={activeSearchMatch?.filePath === activeFile.path ? activeSearchMatch : null}
/>
) : (
<div className="h-full flex items-center justify-center">
Expand Down
51 changes: 32 additions & 19 deletions packages/review-editor/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { FileDiff } from '@pierre/diffs/react';
import { getSingularPatch, processFile } from '@pierre/diffs';
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types';
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
import { CommentPopover } from '@plannotator/ui/components/CommentPopover';
import { detectLanguage } from '../utils/detectLanguage';
import { useAnnotationToolbar } from '../hooks/useAnnotationToolbar';
import { FileHeader } from './FileHeader';
import { InlineAnnotation } from './InlineAnnotation';
import { AnnotationToolbar } from './AnnotationToolbar';
import { SuggestionModal } from './SuggestionModal';
import { type ReviewSearchMatch } from '../utils/reviewSearch';
import {
applySearchHighlights,
getSearchRoots,
retryScrollToSearchMatch,
} from '../utils/reviewSearchHighlight';

interface DiffViewerProps {
patch: string;
Expand All @@ -32,6 +37,10 @@ interface DiffViewerProps {
onStage?: () => void;
canStage?: boolean;
stageError?: string | null;
searchQuery?: string;
searchMatches?: ReviewSearchMatch[];
activeSearchMatchId?: string | null;
activeSearchMatch?: ReviewSearchMatch | null;
}

export const DiffViewer: React.FC<DiffViewerProps> = ({
Expand All @@ -55,11 +64,13 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onStage,
canStage = false,
stageError,
searchQuery = '',
searchMatches = [],
activeSearchMatchId = null,
activeSearchMatch = null,
}) => {
const { theme, colorTheme, resolvedMode } = useTheme();
const { colorTheme, resolvedMode } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const [fileCommentAnchor, setFileCommentAnchor] = useState<HTMLElement | null>(null);

const toolbar = useAnnotationToolbar({ patch, filePath, onLineSelection, onAddAnnotation, onEditAnnotation });

// Parse patch into FileDiffMetadata for @pierre/diffs FileDiff component
Expand Down Expand Up @@ -120,6 +131,22 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
return () => clearTimeout(timeoutId);
}, [selectedAnnotationId]);

useEffect(() => {
if (!containerRef.current) return;

const frameId = requestAnimationFrame(() => {
const roots = getSearchRoots(containerRef.current);
roots.forEach(root => applySearchHighlights(root, searchQuery, searchMatches, activeSearchMatchId));
});

return () => cancelAnimationFrame(frameId);
}, [searchQuery, searchMatches, activeSearchMatchId, filePath, diffStyle, augmentedDiff]);

useEffect(() => {
if (!activeSearchMatch || !containerRef.current) return;
return retryScrollToSearchMatch(containerRef.current, activeSearchMatch);
}, [activeSearchMatch, filePath, diffStyle]);

// Map annotations to @pierre/diffs format
const lineAnnotations = useMemo(() => {
return annotations
Expand Down Expand Up @@ -224,12 +251,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onStage={onStage}
canStage={canStage}
stageError={stageError}
onFileComment={setFileCommentAnchor}
onAddFileComment={onAddFileComment}
/>

<div className="p-4">
<FileDiff
key={filePath}
fileDiff={augmentedDiff}
options={{
themeType: pierreTheme.type,
Expand Down Expand Up @@ -278,19 +304,6 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onClose={() => toolbar.setShowCodeModal(false)}
/>
)}

{fileCommentAnchor && (
<CommentPopover
anchorEl={fileCommentAnchor}
contextText={filePath.split('/').pop() || filePath}
isGlobal={false}
onSubmit={(text) => {
onAddFileComment(text);
setFileCommentAnchor(null);
}}
onClose={() => setFileCommentAnchor(null)}
/>
)}
</div>
);
};
4 changes: 2 additions & 2 deletions packages/review-editor/components/FileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
? 'bg-success/15 text-success'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isViewed ? "Mark as not viewed" : "Mark as viewed"}
title={isViewed ? 'Mark as not viewed' : 'Mark as viewed'}
>
{isViewed ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
Expand All @@ -66,7 +66,7 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isStaged ? "Unstage this file (git reset)" : "Stage this file (git add)"}
title={isStaged ? 'Unstage this file (git reset)' : 'Stage this file (git add)'}
>
{isStaging ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
Expand Down
Loading