From 0fd9826c2caed5b989ac33ebbb13fd5b94ff98d6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 30 Apr 2026 23:39:43 -0700 Subject: [PATCH] feat(review): all-files diff view with unified scrolling (#640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(review): all-files diff view with lazy mounting Renders all diffs in a single scrollable pane using individual FileDiff components with sticky file headers, collapse toggles, and viewport-based lazy mounting via IntersectionObserver. For provenance purposes, this commit was AI assisted. * feat(review): convert all-files view to dock panel + fixes - Register AllFilesDiffView as a proper dock panel so it coexists with single-file tabs, AI chat, PR panels - Remove viewMode toggle in favor of dock panel lifecycle - Fix annotation toolbar positioning (mouse tracking) - Fix annotation PR/scope filtering for stacked PRs - Fix stale patch on file switch (deferred toolbar open) - Fix conventional comments boolean comparison - Fix SuggestionModal props to match DiffViewer pattern - Wire reviewBase for correct hunk expansion context - Fix pre-existing CSS bug on .has-annotations background - Add Shift+V/A keyboard shortcuts for viewed/stage toggle - Add [/] keyboard shortcuts for file-to-file scrolling - Add keyboard shortcuts to settings panel - Restore scroll-based file tree highlight via dock panel state For provenance purposes, this commit was AI assisted. * feat(review): sidebar toggles, keyboard shortcuts, click-drag selection - Move sidebar tab buttons (annotations/AI/agents) to header bar - Generalize useSidebar hook for both plan and review editors - Remove internal tab state from ReviewSidebar, receive from parent - Add file tree toggle button at far-left of header - Add click-and-drag line selection in diff content area - Change keyboard shortcuts from Shift+V/A to bare v/a/c v = toggle viewed (+ collapse in all-files mode) a = toggle git add/stage c = collapse/expand file (all-files mode) - Widen DiffOptionsPopover to prevent word-alt line break - Fix Shift+V/A targeting wrong file in all-files mode - Default to all-files panel on initial load - Hide dock headers when all-files panel is the only panel For provenance purposes, this commit was AI assisted. * feat(review): click-and-drag line selection in diff content area Extract shared selection helpers to utils/diffSelection.ts and add mouseup handlers to both DiffViewer and AllFilesDiffView so users can click and drag across code lines to open the annotation toolbar — not just the gutter. For provenance purposes, this commit was AI assisted. * fix(review): address AI review findings - Gate all-files keyboard shortcuts on isActive to prevent double-toggle when a single-file tab is focused - Fix getDiffSelection to iterate all diffs-container elements so click-drag selection works on files beyond the first - Clear needsInitialDiffPanel after opening all-files panel to prevent forced reactivation on diff switches - Clear activeFilePath and collapsedFiles when files change to prevent stale state across PR/diff switches - Add file.patch to LazyFileDiff fetch deps so file contents refetch when the diff context changes - Add toggleCollapse to keyboard effect dependency array For provenance purposes, this commit was AI assisted. * feat(review): add z to undo collapse with scroll-back Press z to reopen the last collapsed file and scroll back to it. Maintains a history stack so repeated z walks back through collapses. Skips already-expanded entries. History clears on diff/PR switch. For provenance purposes, this commit was AI assisted. * fix(review): review findings + sidebar toggles + folder icon - Fix stale context memo: add isAllFilesActive + handleAddAnnotationForFile to useMemo deps - Fix v handler: clear activeFilePath after collapse, only collapse when marking viewed ON (not when toggling OFF) - Fix click-drag copy regression: only trigger annotation toolbar for multi-line selections, preserve single-line/word selection for Cmd+C - Fix LazyFileDiff: clear stale file contents before refetch - Clean up App.tsx v/a handler: early return when isAllFilesActive - Add Cmd+B (file tree) and Cmd+. (sidebar) keyboard shortcuts - Add toggle menu items with kbd badges to Options dropdown - Replace file tree icon with simple folder - Add z (undo collapse) to keyboard shortcuts panel For provenance purposes, this commit was AI assisted. * chore: remove dead code from iterations - Remove unused FileTreeIcon import and component file - Remove unused AnnotationsIcon component from ReviewHeaderMenu - Remove unused onClose prop and close button from FileTree For provenance purposes, this commit was AI assisted. * fix(review): gate v/a on diff panel, keep annotation selection in all-files - Track isDiffPanelActive so v/a shortcuts only fire when viewing a single-file diff, not PR Comments/Summary/Agent panels - Annotation selection stays in all-files mode instead of switching to a single-file tab — lets the scroll-to-annotation effect handle it For provenance purposes, this commit was AI assisted. --- bun.lock | 6 +- packages/review-editor/App.tsx | 298 +++++++--- .../components/AllFilesDiffView.tsx | 527 ++++++++++++++++++ .../components/DiffOptionsPopover.tsx | 9 +- .../review-editor/components/DiffViewer.tsx | 104 +--- .../review-editor/components/FileHeader.tsx | 11 +- .../review-editor/components/FileTree.tsx | 62 ++- .../review-editor/components/FileTreeNode.tsx | 6 +- .../review-editor/components/LazyFileDiff.tsx | 121 ++++ .../components/ReviewHeaderMenu.tsx | 79 ++- .../components/ReviewSidebar.tsx | 109 +--- .../review-editor/dock/ReviewStateContext.tsx | 3 + .../dock/panels/ReviewAllFilesDiffPanel.tsx | 42 ++ .../dock/reviewPanelComponents.ts | 2 + .../review-editor/dock/reviewPanelTypes.ts | 2 + .../review-editor/hooks/usePierreTheme.ts | 84 +++ packages/review-editor/index.css | 6 +- packages/review-editor/utils/buildFileTree.ts | 12 + packages/review-editor/utils/diffSelection.ts | 40 ++ packages/ui/components/KeyboardShortcuts.tsx | 13 + packages/ui/hooks/useSidebar.ts | 23 +- 21 files changed, 1247 insertions(+), 312 deletions(-) create mode 100644 packages/review-editor/components/AllFilesDiffView.tsx create mode 100644 packages/review-editor/components/LazyFileDiff.tsx create mode 100644 packages/review-editor/dock/panels/ReviewAllFilesDiffPanel.tsx create mode 100644 packages/review-editor/hooks/usePierreTheme.ts create mode 100644 packages/review-editor/utils/diffSelection.ts diff --git a/bun.lock b/bun.lock index 6967e91c..f1bda364 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.19.3", + "version": "0.19.4", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.19.3", + "version": "0.19.4", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", @@ -190,7 +190,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.3", + "version": "0.19.4", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 57db41be..2e1faab0 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -38,6 +38,10 @@ import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockview-react'; import { ReviewHeaderMenu } from './components/ReviewHeaderMenu'; import { ReviewSidebar } from './components/ReviewSidebar'; +import type { ReviewSidebarTab } from './components/ReviewSidebar'; +import { SparklesIcon } from './components/SparklesIcon'; +import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon'; +import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; import { FileTree } from './components/FileTree'; import { StackedPRLabel } from './components/StackedPRLabel'; import { PRSelector } from './components/PRSelector'; @@ -62,6 +66,7 @@ import { REVIEW_PR_SUMMARY_PANEL_ID, REVIEW_PR_COMMENTS_PANEL_ID, REVIEW_PR_CHECKS_PANEL_ID, + REVIEW_ALL_FILES_PANEL_ID, } from './dock/reviewPanelTypes'; import type { DiffFile } from './types'; import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; @@ -130,6 +135,9 @@ const ReviewApp: React.FC = () => { const [activeFileIndex, setActiveFileIndex] = useState(0); const [annotations, setAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [isAllFilesActive, setIsAllFilesActive] = useState(false); + const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); + const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null); const [pendingSelection, setPendingSelection] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [showWorktreeDialog, setShowWorktreeDialog] = useState(false); @@ -161,7 +169,8 @@ const ReviewApp: React.FC = () => { } }, [diffFontFamily, diffFontSize]); - const [isPanelOpen, setIsPanelOpen] = useState(true); + const reviewSidebar = useSidebar(true, 'annotations'); + const [isFileTreeOpen, setIsFileTreeOpen] = useState(true); const [copyFeedback, setCopyFeedback] = useState(null); const [copyRawDiffStatus, setCopyRawDiffStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [viewedFiles, setViewedFiles] = useState>(new Set()); @@ -404,7 +413,6 @@ const ReviewApp: React.FC = () => { const [aiCheckComplete, setAiCheckComplete] = useState(false); const [showDiffTypeSetup, setShowDiffTypeSetup] = useState(false); const [diffTypeSetupPending, setDiffTypeSetupPending] = useState(false); - const [sidebarTabOverride, setSidebarTabOverride] = useState<'ai' | undefined>(undefined); const aiChat = useAIChat({ patch: diffData?.rawPatch ?? '', providerId: aiConfig.providerId, @@ -460,8 +468,7 @@ const ReviewApp: React.FC = () => { }, [pendingSelection, files, activeFileIndex, aiChat]); const handleViewAIResponse = useCallback((questionId?: string) => { - setSidebarTabOverride('ai'); - setIsPanelOpen(true); + reviewSidebar.open('ai'); if (questionId) { setScrollToQuestionId(questionId); setTimeout(() => setScrollToQuestionId(null), 500); @@ -498,8 +505,7 @@ const ReviewApp: React.FC = () => { const [scrollToQuestionId, setScrollToQuestionId] = useState(null); const handleClickAIMarker = useCallback((questionId: string) => { setScrollToQuestionId(questionId); - setSidebarTabOverride('ai'); - setIsPanelOpen(true); + reviewSidebar.open('ai'); // Clear after a tick so it can re-trigger for the same question setTimeout(() => setScrollToQuestionId(null), 500); }, []); @@ -524,7 +530,10 @@ const ReviewApp: React.FC = () => { // Sync activeFileIndex when user switches between dock tabs event.api.onDidActivePanelChange((panel) => { - if (!panel || !isReviewDiffPanelId(panel.id)) return; + if (!panel) { setIsAllFilesActive(false); setIsDiffPanelActive(false); return; } + setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID); + setIsDiffPanelActive(isReviewDiffPanelId(panel.id)); + if (!isReviewDiffPanelId(panel.id)) return; const filePath = getReviewDiffPanelFilePath(panel.params); if (!filePath) return; const fileIndex = filesRef.current.findIndex(file => file.path === filePath); @@ -541,7 +550,7 @@ const ReviewApp: React.FC = () => { event.api.totalPanels === 1 && event.api.groups.length === 1 ? event.api.groups[0]?.panels[0] : undefined; - const hideHeaders = lonePanel?.id === REVIEW_DIFF_PANEL_ID; + const hideHeaders = lonePanel?.id === REVIEW_DIFF_PANEL_ID || lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID; for (const group of event.api.groups) { group.header.hidden = hideHeaders; } @@ -555,13 +564,6 @@ const ReviewApp: React.FC = () => { updateHeaders(); }, []); - // Create the initial diff panel on first load and after diff switches. - useEffect(() => { - if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return; - openDiffFile(files[0].path); - }, [dockApi, files, openDiffFile]); - - // Open agent job detail as center dock panel const handleOpenJobDetail = useCallback((jobId: string) => { const api = dockApi; @@ -635,6 +637,24 @@ const ReviewApp: React.FC = () => { }); }, [dockApi]); + const openAllFilesPanel = useCallback(() => { + if (!dockApi) return; + const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID); + if (existing) { existing.api.setActive(); return; } + dockApi.addPanel({ + id: REVIEW_ALL_FILES_PANEL_ID, + component: REVIEW_PANEL_TYPES.ALL_FILES, + title: 'All files', + }); + }, [dockApi]); + + // Open the all-files panel on first load. + useEffect(() => { + if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return; + needsInitialDiffPanel.current = false; + openAllFilesPanel(); + }, [dockApi, files, openAllFilesPanel]); + // Global keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -642,6 +662,7 @@ const ReviewApp: React.FC = () => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) { if (hasSearchableFiles) { e.preventDefault(); + setIsFileTreeOpen(true); openSearch(); } return; @@ -675,12 +696,23 @@ const ReviewApp: React.FC = () => { e.preventDefault(); handleCopyDiff(); } + // Cmd/Ctrl+B to toggle file tree + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) { + e.preventDefault(); + setIsFileTreeOpen(prev => !prev); + } + // Cmd/Ctrl+. to toggle sidebar + if ((e.metaKey || e.ctrlKey) && e.key === '.' && !isTypingTarget(e.target)) { + e.preventDefault(); + if (reviewSidebar.isOpen) reviewSidebar.close(); + else reviewSidebar.open(); + } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showExportModal, showDestinationMenu, isSearchOpen, searchQuery, searchMatches, isSearchPending, openSearch, stepSearchMatch, clearSearch, closeSearch, hasSearchableFiles]); + }, [showExportModal, showDestinationMenu, isSearchOpen, searchQuery, searchMatches, isSearchPending, openSearch, stepSearchMatch, clearSearch, closeSearch, hasSearchableFiles, reviewSidebar.isOpen, reviewSidebar.open, reviewSidebar.close, isFileTreeOpen]); // Load diff content - try API first, fall back to demo @@ -789,8 +821,8 @@ const ReviewApp: React.FC = () => { setPendingSelection(range); }, []); - // Add annotation - const handleAddAnnotation = useCallback(( + const handleAddAnnotationForFile = useCallback(( + filePath: string, type: CodeAnnotationType, text?: string, suggestedCode?: string, @@ -799,17 +831,14 @@ const ReviewApp: React.FC = () => { decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta ) => { - if (!pendingSelection || !files[activeFileIndex]) return; - - // Normalize line range (in case user selected bottom-to-top) + if (!pendingSelection) return; const lineStart = Math.min(pendingSelection.start, pendingSelection.end); const lineEnd = Math.max(pendingSelection.start, pendingSelection.end); - const newAnnotation: CodeAnnotation = { id: generateId(), type, scope: 'line', - filePath: files[activeFileIndex].path, + filePath, lineStart, lineEnd, side: pendingSelection.side === 'additions' ? 'new' : 'old', @@ -826,10 +855,22 @@ const ReviewApp: React.FC = () => { conventionalLabel, decorations, }; - setAnnotations(prev => [...prev, withPRContext(newAnnotation)]); setPendingSelection(null); - }, [pendingSelection, files, activeFileIndex, identity, withPRContext]); + }, [pendingSelection, identity, withPRContext]); + + const handleAddAnnotation = useCallback(( + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta + ) => { + if (!files[activeFileIndex]) return; + handleAddAnnotationForFile(files[activeFileIndex].path, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta); + }, [files, activeFileIndex, handleAddAnnotationForFile]); const handleAddFileComment = useCallback((text: string) => { const activeFile = files[activeFileIndex]; @@ -973,6 +1014,25 @@ const ReviewApp: React.FC = () => { // Staging is never available in PR review mode — the server rejects it and the UI shouldn't offer it. const canStageFiles = canStageRaw && !prMetadata; + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || isTypingTarget(e.target)) return; + if (!isDiffPanelActive) return; + const filePath = files[activeFileIndex]?.path; + if (!filePath) return; + + if (e.key === 'v') { + e.preventDefault(); + handleToggleViewed(filePath); + } else if (e.key === 'a' && canStageFiles) { + e.preventDefault(); + stageFile(filePath); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [files, activeFileIndex, isDiffPanelActive, handleToggleViewed, canStageFiles, stageFile]); + // Shared function: apply a PR response (used by both initial load and PR switch) function applyPRResponse(data: PRSessionUpdate & { rawPatch: string; gitRef: string; @@ -1170,14 +1230,17 @@ const ReviewApp: React.FC = () => { return; } - // Find and switch to the file containing this annotation - const fileIndex = files.findIndex(f => f.path === annotation.filePath); - if (fileIndex !== -1) { - handleFileSwitch(fileIndex); + // In all-files mode, just set the selection — the panel's scroll-to-annotation + // effect handles expanding and scrolling. In single-file mode, switch to the file. + if (!isAllFilesActive) { + const fileIndex = files.findIndex(f => f.path === annotation.filePath); + if (fileIndex !== -1) { + handleFileSwitch(fileIndex); + } } setSelectedAnnotationId(id); - }, [allAnnotations, files, handleFileSwitch]); + }, [allAnnotations, files, isAllFilesActive, handleFileSwitch]); // Diff context bundled into local-mode feedback headers so the receiving // agent knows which diff the annotations are anchored to. Uses committedBase @@ -1239,6 +1302,7 @@ const ReviewApp: React.FC = () => { pendingSelection, onLineSelection: handleLineSelection, onAddAnnotation: handleAddAnnotation, + onAddAnnotationForFile: handleAddAnnotationForFile, onAddFileComment: handleAddFileComment, onEditAnnotation: handleEditAnnotation, onSelectAnnotation: handleSelectAnnotation, @@ -1271,6 +1335,8 @@ const ReviewApp: React.FC = () => { fetchPRContext, platformUser, openDiffFile, + onAllFilesVisibleFileChange: setAllFilesVisibleFile, + isAllFilesActive, openTourPanel: handleOpenTour, }), [ files, activeFileIndex, diffStyle, diffOverflow, diffIndicators, @@ -1287,7 +1353,7 @@ const ReviewApp: React.FC = () => { handleAskAI, handleViewAIResponse, handleClickAIMarker, aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, - handleOpenTour, + handleOpenTour, isAllFilesActive, handleAddAnnotationForFile, ]); // Separate context for high-frequency job logs — prevents re-rendering all panels on every SSE event @@ -1628,7 +1694,22 @@ const ReviewApp: React.FC = () => {
{/* Header */}
-
+
+ {shouldShowFileTree && ( + + )} {prMetadata ? (
{(gitContext || agentCwd) && ( @@ -1886,13 +1967,67 @@ const ReviewApp: React.FC = () => {
- {/* Utilities */} + {/* Sidebar tab toggles */} + + {aiAvailable && ( + + )} + {agentJobs.capabilities?.available && ( + + )} + +
+ setIsPanelOpen(!isPanelOpen)} onOpenSettings={() => setOpenSettingsMenu(true)} onOpenExport={() => setShowExportModal(true)} + onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} + onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} + isFileTreeOpen={isFileTreeOpen} + isSidebarOpen={reviewSidebar.isOpen} appVersion={appVersion} />
@@ -1900,12 +2035,14 @@ const ReviewApp: React.FC = () => { {/* Main content */}
- {/* Left sidebar stays mounted whenever it provides navigation or context. */} - {shouldShowFileTree && ( + {shouldShowFileTree && isFileTreeOpen && ( <> f.path === allFilesVisibleFile) : undefined} onSelectFile={handleFilePreview} onDoubleClickFile={handleFilePinned} annotations={allAnnotations} @@ -2019,48 +2156,49 @@ const ReviewApp: React.FC = () => { )}
- {/* Resize Handle */} - {isPanelOpen && } - - {/* Annotations panel */} - setIsPanelOpen(!isPanelOpen)} - annotations={allAnnotations} - files={files} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onDeleteAnnotation={handleDeleteAnnotation} - feedbackMarkdown={feedbackMarkdown} - width={panelResize.width} - editorAnnotations={editorAnnotations} - onDeleteEditorAnnotation={deleteEditorAnnotation} - prMetadata={prMetadata} - aiAvailable={aiAvailable} - aiMessages={aiChat.messages} - isAICreatingSession={aiChat.isCreatingSession} - isAIStreaming={aiChat.isStreaming} - onScrollToAILines={handleScrollToAILines} - activeTabOverride={sidebarTabOverride} - onTabChange={() => setSidebarTabOverride(undefined)} - activeFilePath={files[activeFileIndex]?.path} - scrollToQuestionId={scrollToQuestionId} - onAskGeneral={handleAskGeneral} - aiPermissionRequests={aiChat.permissionRequests} - onRespondToPermission={aiChat.respondToPermission} - aiProviders={aiProviders} - aiConfig={aiConfig} - onAIConfigChange={handleAIConfigChange} - hasAISession={!!aiChat.sessionId} - agentJobs={agentJobs.jobs} - agentCapabilities={agentJobs.capabilities} - onAgentLaunch={agentJobs.launchJob} - onAgentKillJob={agentJobs.killJob} - onAgentKillAll={agentJobs.killAll} - externalAnnotations={externalAnnotations} - onOpenJobDetail={handleOpenJobDetail} - onOpenPRPanel={handleOpenPRPanel} - /> + {/* Resize Handle + Sidebar */} + {reviewSidebar.isOpen && ( + <> + + + + )}
{/* Export Modal */} diff --git a/packages/review-editor/components/AllFilesDiffView.tsx b/packages/review-editor/components/AllFilesDiffView.tsx new file mode 100644 index 00000000..04d42237 --- /dev/null +++ b/packages/review-editor/components/AllFilesDiffView.tsx @@ -0,0 +1,527 @@ +import React, { useMemo, useCallback, useRef, useState, useEffect } from 'react'; +import { type DiffLineAnnotation } from '@pierre/diffs/react'; +import { getSingularPatch } from '@pierre/diffs'; +import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; +import { usePierreTheme } from '../hooks/usePierreTheme'; +import { LazyFileDiff } from './LazyFileDiff'; +import { useAnnotationToolbar } from '../hooks/useAnnotationToolbar'; +import { useConfigValue } from '@plannotator/ui/config'; +import { getEnabledLabels } from './ConventionalLabelPicker'; +import { InlineAnnotation } from './InlineAnnotation'; +import { AnnotationToolbar } from './AnnotationToolbar'; +import { SuggestionModal } from './SuggestionModal'; +import { FileHeader } from './FileHeader'; +import { detectLanguage } from '../utils/detectLanguage'; +import type { DiffFile } from '../types'; +import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree'; +import { getLineNumberFromNode, getSideFromNode, getDiffSelection } from '../utils/diffSelection'; + +interface AllFilesDiffViewProps { + files: DiffFile[]; + annotations: CodeAnnotation[]; + selectedAnnotationId: string | null; + pendingSelection: SelectedLineRange | null; + onLineSelection: (range: SelectedLineRange | null) => void; + onAddAnnotation: (filePath: string, type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void; + onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[]) => void; + onSelectAnnotation: (id: string | null) => void; + onDeleteAnnotation: (id: string) => void; + diffStyle: 'split' | 'unified'; + diffOverflow?: 'scroll' | 'wrap'; + diffIndicators?: 'bars' | 'classic' | 'none'; + lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; + disableLineNumbers?: boolean; + disableBackground?: boolean; + fontFamily?: string; + fontSize?: string; + viewedFiles: Set; + onToggleViewed?: (filePath: string) => void; + stagedFiles?: Set; + onStage?: (filePath: string) => void; + canStageFiles?: boolean; + stagingFile?: string | null; + stageError?: string | null; + reviewBase?: string; + prUrl?: string; + prDiffScope?: string; + onVisibleFileChange?: (filePath: string | null) => void; + isActive?: boolean; +} + +export const AllFilesDiffView: React.FC = ({ + files, + annotations, + selectedAnnotationId, + pendingSelection, + onLineSelection, + onAddAnnotation, + onEditAnnotation, + onSelectAnnotation, + onDeleteAnnotation, + diffStyle, + diffOverflow, + diffIndicators, + lineDiffType, + disableLineNumbers, + disableBackground, + fontFamily, + fontSize, + viewedFiles, + onToggleViewed, + stagedFiles, + onStage, + canStageFiles, + stagingFile, + stageError, + reviewBase, + prUrl, + prDiffScope, + onVisibleFileChange, + isActive = true, +}) => { + const pierreTheme = usePierreTheme({ fontFamily, fontSize }); + const [activeFilePath, setActiveFilePath] = useState(null); + const pendingToolbarRange = useRef(null); + const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); + const collapseHistory = useRef([]); + + useEffect(() => { + setActiveFilePath(null); + setCollapsedFiles(new Set()); + collapseHistory.current = []; + }, [files]); + + const scrollRef = useRef(null); + const headerRefs = useRef>(new Map()); + const conventionalCommentsEnabled = useConfigValue('conventionalComments'); + const conventionalLabelsJson = useConfigValue('conventionalLabels'); + const enabledLabels = useMemo(() => getEnabledLabels(conventionalLabelsJson), [conventionalLabelsJson]); + + const activePatch = useMemo( + () => files.find(f => f.path === activeFilePath)?.patch ?? '', + [files, activeFilePath], + ); + + const handleAddAnnotation = useCallback(( + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta, + ) => { + if (!activeFilePath) return; + onAddAnnotation(activeFilePath, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta); + }, [activeFilePath, onAddAnnotation]); + + const toolbar = useAnnotationToolbar({ + patch: activePatch, + filePath: activeFilePath ?? '', + isFocused: true, + onLineSelection, + onAddAnnotation: handleAddAnnotation, + onEditAnnotation, + }); + + useEffect(() => { + if (pendingToolbarRange.current && activePatch) { + toolbar.handleLineSelectionEnd(pendingToolbarRange.current); + pendingToolbarRange.current = null; + } + }, [activePatch, toolbar.handleLineSelectionEnd]); + + const handleEdit = useCallback((id: string) => { + const ann = annotations.find(a => a.id === id); + if (!ann) return; + toolbar.startEdit(ann); + }, [annotations, toolbar.startEdit]); + + const visualOrder = useMemo(() => { + const tree = buildFileTree(files); + return getVisualFileOrder(tree); + }, [files]); + + const sortedFiles = useMemo(() => visualOrder.map(i => files[i]), [files, visualOrder]); + + const baseDiffs = useMemo(() => { + const map = new Map>(); + for (const file of files) map.set(file.path, getSingularPatch(file.patch)); + return map; + }, [files]); + + const toggleCollapse = useCallback((filePath: string) => { + setCollapsedFiles(prev => { + const next = new Set(prev); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + collapseHistory.current.push(filePath); + if (activeFilePath === filePath) setActiveFilePath(null); + } + return next; + }); + }, [activeFilePath]); + + const setHeaderRef = useCallback((path: string, el: HTMLDivElement | null) => { + if (el) headerRefs.current.set(path, el); + else headerRefs.current.delete(path); + }, []); + + // Scroll tracking for file tree highlight + const visibleFileRef = useRef(null); + useEffect(() => { + if (!onVisibleFileChange) return; + const container = scrollRef.current; + if (!container) return; + const onScroll = () => { + const containerTop = container.getBoundingClientRect().top; + const expandedFiles = sortedFiles.filter(f => !collapsedFiles.has(f.path)); + let bestPath: string | null = expandedFiles[0]?.path ?? null; + const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20; + if (atBottom && expandedFiles.length > 0) { + bestPath = expandedFiles[expandedFiles.length - 1].path; + } else { + for (const file of expandedFiles) { + const header = headerRefs.current.get(file.path); + if (!header) continue; + const rect = header.getBoundingClientRect(); + if (rect.top <= containerTop + 50) bestPath = file.path; + else break; + } + } + if (bestPath !== visibleFileRef.current) { + visibleFileRef.current = bestPath; + onVisibleFileChange(bestPath); + } + }; + onScroll(); + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, [onVisibleFileChange, sortedFiles, collapsedFiles]); + + // Keyboard shortcuts for all-files mode: [/] scroll, v viewed+collapse, a stage, c collapse + useEffect(() => { + if (!isActive) return; + const handler = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return; + if (e.metaKey || e.ctrlKey || e.shiftKey) return; + + const currentPath = activeFilePath || visibleFileRef.current; + + if (e.key === 'z') { + // Find the most recent entry that's actually still collapsed + let last: string | undefined; + while (collapseHistory.current.length > 0) { + const candidate = collapseHistory.current.pop()!; + if (collapsedFiles.has(candidate)) { last = candidate; break; } + } + if (!last) return; + e.preventDefault(); + setCollapsedFiles(prev => { + const next = new Set(prev); + next.delete(last!); + return next; + }); + setActiveFilePath(last); + setTimeout(() => { + const header = headerRefs.current.get(last!); + header?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 50); + return; + } + + if (e.key === 'c' && currentPath) { + e.preventDefault(); + toggleCollapse(currentPath); + return; + } + + if (e.key === 'v' && currentPath) { + e.preventDefault(); + const isCurrentlyViewed = viewedFiles.has(currentPath); + onToggleViewed?.(currentPath); + if (!isCurrentlyViewed) { + collapseHistory.current.push(currentPath); + setCollapsedFiles(prev => { + const next = new Set(prev); + next.add(currentPath); + return next; + }); + setActiveFilePath(null); + } + return; + } + + if (e.key === 'a' && currentPath && canStageFiles) { + e.preventDefault(); + onStage?.(currentPath); + return; + } + + if (e.key !== '[' && e.key !== ']') return; + + e.preventDefault(); + const expandedFiles = sortedFiles.filter(f => !collapsedFiles.has(f.path)); + if (expandedFiles.length === 0) return; + + const currentIdx = currentPath ? expandedFiles.findIndex(f => f.path === currentPath) : -1; + + let targetIdx: number; + if (e.key === ']') { + targetIdx = currentIdx < expandedFiles.length - 1 ? currentIdx + 1 : expandedFiles.length - 1; + } else { + targetIdx = currentIdx > 0 ? currentIdx - 1 : 0; + } + + const targetFile = expandedFiles[targetIdx]; + const header = headerRefs.current.get(targetFile.path); + if (header) { + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setActiveFilePath(targetFile.path); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isActive, sortedFiles, collapsedFiles, activeFilePath, toggleCollapse, viewedFiles, onToggleViewed, canStageFiles, onStage]); + + // Click-and-drag line selection in diff content + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + const handler = () => { + requestAnimationFrame(() => { + const selection = getDiffSelection(root); + if (!selection || selection.isCollapsed || !selection.toString().trim()) return; + const anchorLine = getLineNumberFromNode(selection.anchorNode); + const focusLine = getLineNumberFromNode(selection.focusNode); + if (anchorLine == null || focusLine == null) return; + if (anchorLine === focusLine) return; + const side = getSideFromNode(selection.anchorNode); + // Determine which file the selection is in by checking header positions + const anchorRect = selection.getRangeAt(0).getBoundingClientRect(); + let closestFile: string | null = null; + for (const file of sortedFiles) { + const header = headerRefs.current.get(file.path); + if (header && header.getBoundingClientRect().top <= anchorRect.top) { + closestFile = file.path; + } + } + if (closestFile) { + if (activeFilePath !== closestFile) { + pendingToolbarRange.current = { + start: Math.min(anchorLine, focusLine), + end: Math.max(anchorLine, focusLine), + side, + }; + setActiveFilePath(closestFile); + selection.removeAllRanges(); + return; + } + setActiveFilePath(closestFile); + } + toolbar.handleLineSelectionEnd({ + start: Math.min(anchorLine, focusLine), + end: Math.max(anchorLine, focusLine), + side, + }); + selection.removeAllRanges(); + }); + }; + root.addEventListener('mouseup', handler, true); + return () => root.removeEventListener('mouseup', handler, true); + }, [toolbar.handleLineSelectionEnd, sortedFiles, activeFilePath]); + + // Scroll to selected annotation — auto-expand collapsed file + useEffect(() => { + if (!selectedAnnotationId) return; + const ann = annotations.find(a => a.id === selectedAnnotationId); + if (!ann) return; + setCollapsedFiles(prev => { + if (!prev.has(ann.filePath)) return prev; + const next = new Set(prev); + next.delete(ann.filePath); + return next; + }); + requestAnimationFrame(() => { + const header = headerRefs.current.get(ann.filePath); + header?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }, [selectedAnnotationId, annotations]); + + return ( +
+ {sortedFiles.map(file => { + const isCollapsed = collapsedFiles.has(file.path); + const fileAnnotations = annotations + .filter(a => + a.filePath === file.path && + (a.scope ?? 'line') === 'line' && + (!a.prUrl || !prUrl || a.prUrl === prUrl) && + (!a.diffScope || !prDiffScope || a.diffScope === prDiffScope) + ) + .map(ann => ({ + side: ann.side === 'new' ? 'additions' as const : 'deletions' as const, + lineNumber: ann.lineEnd, + metadata: { + annotationId: ann.id, + type: ann.type, + text: ann.text, + suggestedCode: ann.suggestedCode, + originalCode: ann.originalCode, + author: ann.author, + severity: ann.severity, + reasoning: ann.reasoning, + conventionalLabel: ann.conventionalLabel, + decorations: ann.decorations, + } as DiffAnnotationMetadata, + })); + + return ( +
+
setHeaderRef(file.path, el)} + > + onToggleViewed(file.path) : undefined} + isStaged={stagedFiles?.has(file.path)} + isStaging={stagingFile === file.path} + onStage={onStage ? () => onStage(file.path) : undefined} + canStage={canStageFiles} + stageError={stagingFile === file.path ? stageError : null} + collapseToggle={ + + } + onCollapseToggle={() => toggleCollapse(file.path)} + /> +
+ {!isCollapsed && ( + a.metadata.annotationId === selectedAnnotationId)} + options={{ + themeType: pierreTheme.type, + unsafeCSS: pierreTheme.css, + diffStyle, + overflow: diffOverflow, + diffIndicators, + lineDiffType, + disableLineNumbers, + disableBackground, + hunkSeparators: 'line-info', + enableLineSelection: true, + enableHoverUtility: true, + onLineSelectionEnd: (range: SelectedLineRange | null) => { + if (range) { + if (activeFilePath === file.path) { + toolbar.handleLineSelectionEnd(range); + } else { + pendingToolbarRange.current = range; + setActiveFilePath(file.path); + } + } + onLineSelection(range); + }, + }} + annotations={fileAnnotations} + selectedLines={activeFilePath === file.path ? (pendingSelection || undefined) : undefined} + renderAnnotation={(annotation: DiffLineAnnotation) => { + if (!annotation.metadata) return null; + return ( + + ); + }} + renderHoverUtility={(getHoveredLine: () => { lineNumber: number; side: 'deletions' | 'additions' } | undefined) => ( + + )} + /> + )} +
+ ); + })} + + {toolbar.toolbarState && !toolbar.showCodeModal && ( + + )} + + {toolbar.showCodeModal && ( + toolbar.setShowCodeModal(false)} + /> + )} +
+ ); +}; diff --git a/packages/review-editor/components/DiffOptionsPopover.tsx b/packages/review-editor/components/DiffOptionsPopover.tsx index b7d820b6..12c88425 100644 --- a/packages/review-editor/components/DiffOptionsPopover.tsx +++ b/packages/review-editor/components/DiffOptionsPopover.tsx @@ -82,17 +82,18 @@ export const DiffOptionsPopover: React.FC = () => {
Layout
-
-
+
+
configStore.set('diffStyle', v)} />
-
+
+
configStore.set('diffOverflow', v)} />
diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx index f0a4d85a..3f3c9d3c 100644 --- a/packages/review-editor/components/DiffViewer.tsx +++ b/packages/review-editor/components/DiffViewer.tsx @@ -3,7 +3,7 @@ import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react'; import { getSingularPatch, processFile } from '@pierre/diffs'; import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; import type { DiffTokenEventBaseProps } from '@pierre/diffs'; -import { useTheme } from '@plannotator/ui/components/ThemeProvider'; +import { usePierreTheme } from '../hooks/usePierreTheme'; import { CommentPopover } from '@plannotator/ui/components/CommentPopover'; import { storage } from '@plannotator/ui/utils/storage'; import { detectLanguage } from '../utils/detectLanguage'; @@ -13,6 +13,7 @@ import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea' import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; import { getEnabledLabels } from './ConventionalLabelPicker'; import { FileHeader } from './FileHeader'; +import { getLineNumberFromNode, getSideFromNode, getDiffSelection } from '../utils/diffSelection'; import { InlineAnnotation } from './InlineAnnotation'; import { InlineAIMarker } from './InlineAIMarker'; import { AnnotationToolbar } from './AnnotationToolbar'; @@ -202,13 +203,14 @@ export const DiffViewer: React.FC = ({ onClickAIMarker, aiHistoryMessages = [], }) => { - const { theme, colorTheme, resolvedMode } = useTheme(); + const pierreTheme = usePierreTheme({ fontFamily, fontSize }); // containerRef must point at the actual scrolling element (the // OverlayScrollbars viewport), not the OverlayScrollArea host. `viewport` // is state so effects re-run once the library has mounted the viewport. const { ref: containerRef, viewport, onViewportReady } = useOverlayViewport(); const splitSurfaceRef = useRef(null); + const diffContentRef = useRef(null); const [fileCommentAnchor, setFileCommentAnchor] = useState(null); // Resizable split pane — only applies when Pierre renders a two-column grid @@ -521,6 +523,30 @@ export const DiffViewer: React.FC = ({ ); }, [toolbar.handleLineSelectionEnd]); + useEffect(() => { + const root = diffContentRef.current; + if (!root) return; + const handler = () => { + requestAnimationFrame(() => { + const selection = getDiffSelection(root); + if (!selection || selection.isCollapsed || !selection.toString().trim()) return; + const anchorLine = getLineNumberFromNode(selection.anchorNode); + const focusLine = getLineNumberFromNode(selection.focusNode); + if (anchorLine == null || focusLine == null) return; + if (anchorLine === focusLine) return; + const side = getSideFromNode(selection.anchorNode); + toolbar.handleLineSelectionEnd({ + start: Math.min(anchorLine, focusLine), + end: Math.max(anchorLine, focusLine), + side, + }); + selection.removeAllRanges(); + }); + }; + root.addEventListener('mouseup', handler, true); + return () => root.removeEventListener('mouseup', handler, true); + }, [toolbar.handleLineSelectionEnd]); + // Token interaction handlers (code area clicks) const handleTokenClick = useCallback((props: DiffTokenEventBaseProps, event: MouseEvent) => { toolbar.handleTokenClick(props, event); @@ -534,78 +560,6 @@ export const DiffViewer: React.FC = ({ props.tokenElement.classList.remove('pn-token-hover'); }, []); - // Inject resolved colors into @pierre/diffs shadow DOM. - // CSS custom properties don't cross the shadow boundary, so we read computed - // values and pass them via unsafeCSS. Single state object avoids split renders. - const [pierreTheme, setPierreTheme] = useState<{ type: 'dark' | 'light'; css: string }>(() => { - // Compute initial theme synchronously to avoid a flash of unstyled dark content - const styles = getComputedStyle(document.documentElement); - const bg = styles.getPropertyValue('--background').trim(); - const fg = styles.getPropertyValue('--foreground').trim(); - if (!bg || !fg) return { type: resolvedMode ?? 'dark', css: '' }; - return { type: resolvedMode ?? 'dark', css: ` - :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { - --diffs-bg: ${bg} !important; --diffs-fg: ${fg} !important; - --diffs-dark-bg: ${bg}; --diffs-light-bg: ${bg}; --diffs-dark: ${fg}; --diffs-light: ${fg}; - } - pre, code { background-color: ${bg} !important; } - `}; - }); - - useEffect(() => { - requestAnimationFrame(() => { - const styles = getComputedStyle(document.documentElement); - const bg = styles.getPropertyValue('--background').trim(); - const fg = styles.getPropertyValue('--foreground').trim(); - const muted = styles.getPropertyValue('--muted').trim(); - const primary = styles.getPropertyValue('--primary').trim(); - if (!bg || !fg) return; - - const fontCSS = fontFamily || fontSize ? ` - pre, code, [data-line-content], [data-column-number] { - ${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''} - ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} - }` : ''; - - setPierreTheme({ - type: resolvedMode, - css: ` - :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { - --diffs-bg: ${bg} !important; - --diffs-fg: ${fg} !important; - --diffs-dark-bg: ${bg}; - --diffs-light-bg: ${bg}; - --diffs-dark: ${fg}; - --diffs-light: ${fg}; - } - pre, code { background-color: ${bg} !important; } - [data-file-info] { background-color: ${muted} !important; } - [data-column-number] { background-color: ${bg} !important; } - [data-diffs-header] [data-title] { display: none !important; } - [data-diff-type='split'][data-overflow='scroll'] { - grid-template-columns: - minmax(0, var(--split-left, 1fr)) - minmax(0, var(--split-right, 1fr)) !important; - } - [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-deletions], - [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions], - [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-deletions] [data-content], - [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions] [data-content] { - min-width: 0 !important; - } - .pn-token-hover { - text-decoration: underline; - text-decoration-color: ${primary || 'oklch(0.70 0.20 280)'}; - text-decoration-thickness: 1.5px; - text-underline-offset: 2px; - cursor: pointer; - } - ${fontCSS} - `, - }); - }); - }, [resolvedMode, colorTheme, fontFamily, fontSize]); - const splitGridStyle = useMemo(() => { if (!isSplitLayout || diffOverflow === 'wrap') return undefined; return { @@ -635,7 +589,7 @@ export const DiffViewer: React.FC = ({ onViewportReady={onViewportReady} onMouseMove={toolbar.handleMouseMove} > -
+
{isSplitLayout && diffOverflow !== 'wrap' && (
void; + collapseToggle?: React.ReactNode; + onCollapseToggle?: () => void; } function splitFilePath(filePath: string): { directory: string; name: string } { @@ -43,6 +45,8 @@ export const FileHeader: React.FC = ({ canStage = false, stageError, onFileComment, + collapseToggle, + onCollapseToggle, }) => { const [copied, setCopied] = useState(false); const [headerWidth, setHeaderWidth] = useState(0); @@ -83,7 +87,8 @@ export const FileHeader: React.FC = ({ className="flex-shrink-0 px-3 border-b border-border/50 flex items-center justify-between gap-2" style={{ height: 'var(--panel-header-h)' }} > -
+
+ {collapseToggle} {!showFilenameOnly && directory && ( @@ -106,7 +111,7 @@ export const FileHeader: React.FC = ({ ? '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 (V)" : "Mark as viewed (V)"} > {isViewed ? ( @@ -131,7 +136,7 @@ export const FileHeader: React.FC = ({ ? '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 (A)" : "Stage this file (A)"} > {isStaging ? ( diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx index 5f980085..1261de81 100644 --- a/packages/review-editor/components/FileTree.tsx +++ b/packages/review-editor/components/FileTree.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useCallback, useState, useMemo } from 'react'; import { CodeAnnotation } from '@plannotator/ui/types'; import type { AvailableBranches, DiffOption, WorktreeInfo } from '@plannotator/shared/types'; -import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/buildFileTree'; +import { buildFileTree, getAncestorPaths, getAllFolderPaths, getVisualFileOrder } from '../utils/buildFileTree'; import { FileTreeNodeItem } from './FileTreeNode'; import { BaseBranchPicker } from './BaseBranchPicker'; import { DiffTypePicker } from './DiffTypePicker'; @@ -52,6 +52,9 @@ interface FileTreeProps { activeSearchMatchId?: string | null; onSelectSearchMatch?: (matchId: string) => void; onStepSearchMatch?: (direction: 1 | -1) => void; + onSelectAllFiles?: () => void; + isAllFilesActive?: boolean; + scrollHighlightIndex?: number; } export const FileTree: React.FC = ({ @@ -95,9 +98,16 @@ export const FileTree: React.FC = ({ activeSearchMatchId, onSelectSearchMatch, onStepSearchMatch, + onSelectAllFiles, + isAllFilesActive = false, + scrollHighlightIndex, }) => { const isSearchVisible = !!onSearchChange && (isSearchOpen || !!searchQuery.trim()); + const tree = useMemo(() => buildFileTree(files), [files]); + const allFolderPaths = useMemo(() => getAllFolderPaths(tree), [tree]); + const visualOrder = useMemo(() => getVisualFileOrder(tree), [tree]); + // Keyboard navigation: j/k or arrow keys const handleKeyDown = useCallback((e: KeyboardEvent) => { if (!enableKeyboardNav) return; @@ -121,22 +131,26 @@ export const FileTree: React.FC = ({ return; } + const visualPos = visualOrder.indexOf(activeFileIndex); + if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); - const nextIndex = Math.min(activeFileIndex + 1, files.length - 1); - onSelectFile(nextIndex); + if (visualPos < visualOrder.length - 1) { + onSelectFile(visualOrder[visualPos + 1]); + } } else if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); - const prevIndex = Math.max(activeFileIndex - 1, 0); - onSelectFile(prevIndex); + if (visualPos > 0) { + onSelectFile(visualOrder[visualPos - 1]); + } } else if (e.key === 'Home') { e.preventDefault(); - onSelectFile(0); + onSelectFile(visualOrder[0]); } else if (e.key === 'End') { e.preventDefault(); - onSelectFile(files.length - 1); + onSelectFile(visualOrder[visualOrder.length - 1]); } - }, [enableKeyboardNav, activeFileIndex, files.length, onSelectFile]); + }, [enableKeyboardNav, activeFileIndex, visualOrder, onSelectFile]); useEffect(() => { window.addEventListener('keydown', handleKeyDown); @@ -155,9 +169,6 @@ export const FileTree: React.FC = ({ return annotationCountMap.get(filePath) ?? 0; }, [annotationCountMap]); - const tree = useMemo(() => buildFileTree(files), [files]); - const allFolderPaths = useMemo(() => getAllFolderPaths(tree), [tree]); - const [expandedFolders, setExpandedFolders] = useState>(() => new Set(allFolderPaths)); const [prevTree, setPrevTree] = useState(tree); @@ -398,13 +409,35 @@ export const FileTree: React.FC = ({
) ) : ( - tree.map(node => ( + <> + {onSelectAllFiles && ( + + )} + {tree.map(node => ( = ({ getAnnotationCount={getAnnotationCount} stagedFiles={stagedFiles} /> - )) + ))} + )}
diff --git a/packages/review-editor/components/FileTreeNode.tsx b/packages/review-editor/components/FileTreeNode.tsx index 254f3e22..7a78f0b9 100644 --- a/packages/review-editor/components/FileTreeNode.tsx +++ b/packages/review-editor/components/FileTreeNode.tsx @@ -13,6 +13,7 @@ interface FileTreeNodeProps { hideViewedFiles: boolean; getAnnotationCount: (filePath: string) => number; stagedFiles?: Set; + scrollHighlightIndex?: number; } function hasVisibleChildren( @@ -44,6 +45,7 @@ export const FileTreeNodeItem: React.FC = ({ hideViewedFiles, getAnnotationCount, stagedFiles, + scrollHighlightIndex, }) => { const paddingLeft = 4 + node.depth * 8; @@ -92,6 +94,7 @@ export const FileTreeNodeItem: React.FC = ({ hideViewedFiles={hideViewedFiles} getAnnotationCount={getAnnotationCount} stagedFiles={stagedFiles} + scrollHighlightIndex={scrollHighlightIndex} /> ))} @@ -100,6 +103,7 @@ export const FileTreeNodeItem: React.FC = ({ // File node const isActive = node.fileIndex === activeFileIndex; + const isScrollActive = !isActive && scrollHighlightIndex != null && node.fileIndex === scrollHighlightIndex; const isViewed = viewedFiles.has(node.path); const isStaged = stagedFiles?.has(node.path) ?? false; const annotationCount = getAnnotationCount(node.path); @@ -112,7 +116,7 @@ export const FileTreeNodeItem: React.FC = ({ +

+ {activeTab === 'annotations' ? 'Annotations' : activeTab === 'ai' ? 'AI' : 'Review Agents'}

{activeTab === 'annotations' && totalCount > 0 && ( @@ -319,61 +299,6 @@ export const ReviewSidebar: React.FC = /* React.memo */({ {aiMessages.length} )} - -
- {/* Annotations tab (always visible) */} - - - {/* AI tab (visible when AI is available) */} - {aiAvailable && ( - - )} - - {/* Agents tab (visible when agent capabilities available) */} - {hasAgents && ( - - )} - - {/* PR tabs moved to header — see App.tsx */} -
diff --git a/packages/review-editor/dock/ReviewStateContext.tsx b/packages/review-editor/dock/ReviewStateContext.tsx index 2ca097ae..d7b1558c 100644 --- a/packages/review-editor/dock/ReviewStateContext.tsx +++ b/packages/review-editor/dock/ReviewStateContext.tsx @@ -48,6 +48,7 @@ export interface ReviewState { pendingSelection: SelectedLineRange | null; onLineSelection: (range: SelectedLineRange | null) => void; onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void; + onAddAnnotationForFile: (filePath: string, type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void; onAddFileComment: (text: string) => void; onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[]) => void; onSelectAnnotation: (id: string | null) => void; @@ -92,6 +93,8 @@ export interface ReviewState { // Diff navigation openDiffFile: (filePath: string) => void; + onAllFilesVisibleFileChange: (filePath: string | null) => void; + isAllFilesActive: boolean; // Tour openTourPanel: (jobId: string) => void; diff --git a/packages/review-editor/dock/panels/ReviewAllFilesDiffPanel.tsx b/packages/review-editor/dock/panels/ReviewAllFilesDiffPanel.tsx new file mode 100644 index 00000000..b42f28e3 --- /dev/null +++ b/packages/review-editor/dock/panels/ReviewAllFilesDiffPanel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type { IDockviewPanelProps } from 'dockview-react'; +import { AllFilesDiffView } from '../../components/AllFilesDiffView'; +import { useReviewState } from '../ReviewStateContext'; + +export const ReviewAllFilesDiffPanel: React.FC = () => { + const state = useReviewState(); + + return ( + + ); +}; diff --git a/packages/review-editor/dock/reviewPanelComponents.ts b/packages/review-editor/dock/reviewPanelComponents.ts index d33b10a1..883e1671 100644 --- a/packages/review-editor/dock/reviewPanelComponents.ts +++ b/packages/review-editor/dock/reviewPanelComponents.ts @@ -4,6 +4,7 @@ import { ReviewAgentJobDetailPanel } from './panels/ReviewAgentJobDetailPanel'; import { ReviewPRSummaryPanel } from './panels/ReviewPRSummaryPanel'; import { ReviewPRCommentsPanel } from './panels/ReviewPRCommentsPanel'; import { ReviewPRChecksPanel } from './panels/ReviewPRChecksPanel'; +import { ReviewAllFilesDiffPanel } from './panels/ReviewAllFilesDiffPanel'; /** * Component registry for dockview — maps panel type strings to React components. @@ -15,4 +16,5 @@ export const reviewPanelComponents = { [REVIEW_PANEL_TYPES.PR_SUMMARY]: ReviewPRSummaryPanel, [REVIEW_PANEL_TYPES.PR_COMMENTS]: ReviewPRCommentsPanel, [REVIEW_PANEL_TYPES.PR_CHECKS]: ReviewPRChecksPanel, + [REVIEW_PANEL_TYPES.ALL_FILES]: ReviewAllFilesDiffPanel, } as const; diff --git a/packages/review-editor/dock/reviewPanelTypes.ts b/packages/review-editor/dock/reviewPanelTypes.ts index 967761df..73aebeef 100644 --- a/packages/review-editor/dock/reviewPanelTypes.ts +++ b/packages/review-editor/dock/reviewPanelTypes.ts @@ -11,6 +11,7 @@ export const REVIEW_PANEL_TYPES = { PR_SUMMARY: 'review-pr-summary', PR_COMMENTS: 'review-pr-comments', PR_CHECKS: 'review-pr-checks', + ALL_FILES: 'review-all-files', } as const; export const REVIEW_DIFF_PANEL_ID = 'review-diff'; @@ -25,6 +26,7 @@ export const makeReviewAgentJobPanelId = (jobId: string) => export const REVIEW_PR_SUMMARY_PANEL_ID = 'review-pr-summary'; export const REVIEW_PR_COMMENTS_PANEL_ID = 'review-pr-comments'; export const REVIEW_PR_CHECKS_PANEL_ID = 'review-pr-checks'; +export const REVIEW_ALL_FILES_PANEL_ID = 'review-all-files'; export function isReviewDiffPanelId(panelId: string): boolean { return panelId === REVIEW_DIFF_PANEL_ID; diff --git a/packages/review-editor/hooks/usePierreTheme.ts b/packages/review-editor/hooks/usePierreTheme.ts new file mode 100644 index 00000000..ff7aa79a --- /dev/null +++ b/packages/review-editor/hooks/usePierreTheme.ts @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; +import { useTheme } from '@plannotator/ui/components/ThemeProvider'; + +export interface PierreTheme { + type: 'dark' | 'light'; + css: string; +} + +export function usePierreTheme(options?: { fontFamily?: string; fontSize?: string; showFileHeader?: boolean }): PierreTheme { + const { colorTheme, resolvedMode } = useTheme(); + const fontFamily = options?.fontFamily; + const fontSize = options?.fontSize; + const showFileHeader = options?.showFileHeader ?? false; + + const [pierreTheme, setPierreTheme] = useState(() => { + const styles = getComputedStyle(document.documentElement); + const bg = styles.getPropertyValue('--background').trim(); + const fg = styles.getPropertyValue('--foreground').trim(); + if (!bg || !fg) return { type: resolvedMode ?? 'dark', css: '' }; + return { type: resolvedMode ?? 'dark', css: ` + :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { + --diffs-bg: ${bg} !important; --diffs-fg: ${fg} !important; + --diffs-dark-bg: ${bg}; --diffs-light-bg: ${bg}; --diffs-dark: ${fg}; --diffs-light: ${fg}; + } + pre, code { background-color: ${bg} !important; } + `}; + }); + + useEffect(() => { + requestAnimationFrame(() => { + const styles = getComputedStyle(document.documentElement); + const bg = styles.getPropertyValue('--background').trim(); + const fg = styles.getPropertyValue('--foreground').trim(); + const muted = styles.getPropertyValue('--muted').trim(); + const primary = styles.getPropertyValue('--primary').trim(); + if (!bg || !fg) return; + + const fontCSS = fontFamily || fontSize ? ` + pre, code, [data-line-content], [data-column-number] { + ${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''} + ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} + }` : ''; + + setPierreTheme({ + type: resolvedMode, + css: ` + :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { + --diffs-bg: ${bg} !important; + --diffs-fg: ${fg} !important; + --diffs-dark-bg: ${bg}; + --diffs-light-bg: ${bg}; + --diffs-dark: ${fg}; + --diffs-light: ${fg}; + } + pre, code { background-color: ${bg} !important; } + [data-file-info] { background-color: ${muted} !important; } + [data-column-number] { background-color: ${bg} !important; } + ${showFileHeader ? '' : '[data-diffs-header] [data-title] { display: none !important; }'} + [data-diff-type='split'][data-overflow='scroll'] { + grid-template-columns: + minmax(0, var(--split-left, 1fr)) + minmax(0, var(--split-right, 1fr)) !important; + } + [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-deletions], + [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions], + [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-deletions] [data-content], + [data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions] [data-content] { + min-width: 0 !important; + } + .pn-token-hover { + text-decoration: underline; + text-decoration-color: ${primary || 'oklch(0.70 0.20 280)'}; + text-decoration-thickness: 1.5px; + text-underline-offset: 2px; + cursor: pointer; + } + ${fontCSS} + `, + }); + }); + }, [resolvedMode, colorTheme, fontFamily, fontSize, showFileHeader]); + + return pierreTheme; +} diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index b34a2eb9..cd2f6ee7 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -289,8 +289,12 @@ diffs-container { color: var(--primary); } +.file-tree-item.scroll-active { + background: oklch(from var(--foreground) l c h / 0.06); +} + .file-tree-item.has-annotations { - background: var(--primary) / 0.08; + background: oklch(from var(--primary) l c h / 0.08); } .file-tree-item.has-annotations:hover { diff --git a/packages/review-editor/utils/buildFileTree.ts b/packages/review-editor/utils/buildFileTree.ts index 11a38c24..1fbc9be5 100644 --- a/packages/review-editor/utils/buildFileTree.ts +++ b/packages/review-editor/utils/buildFileTree.ts @@ -141,6 +141,18 @@ export function getAncestorPaths(filePath: string): string[] { return paths; } +export function getVisualFileOrder(nodes: FileTreeNode[]): number[] { + const order: number[] = []; + for (const node of nodes) { + if (node.type === 'file' && node.fileIndex != null) { + order.push(node.fileIndex); + } else if (node.children) { + order.push(...getVisualFileOrder(node.children)); + } + } + return order; +} + export function getAllFolderPaths(nodes: FileTreeNode[]): string[] { const paths: string[] = []; for (const node of nodes) { diff --git a/packages/review-editor/utils/diffSelection.ts b/packages/review-editor/utils/diffSelection.ts new file mode 100644 index 00000000..00a3012e --- /dev/null +++ b/packages/review-editor/utils/diffSelection.ts @@ -0,0 +1,40 @@ +export function getLineNumberFromNode(node: Node | null): number | null { + let current: Node | null = node; + if (current?.nodeType === Node.TEXT_NODE) current = current.parentNode; + while (current) { + if (current instanceof HTMLElement) { + const line = current.closest('[data-line]')?.getAttribute('data-line'); + if (line) { + const parsed = Number(line); + return Number.isFinite(parsed) ? parsed : null; + } + } + current = current.parentNode; + } + return null; +} + +export function getSideFromNode(node: Node | null): 'additions' | 'deletions' { + let current: Node | null = node; + if (current?.nodeType === Node.TEXT_NODE) current = current.parentNode; + while (current) { + if (current instanceof HTMLElement) { + if (current.hasAttribute('data-deletions')) return 'deletions'; + if (current.hasAttribute('data-additions')) return 'additions'; + } + current = current.parentNode; + } + return 'additions'; +} + +export function getDiffSelection(root: HTMLElement | null): Selection | null { + if (!root) return window.getSelection(); + const containers = root.querySelectorAll('diffs-container'); + for (const container of containers) { + const sr = container.shadowRoot; + if (!sr) continue; + const sel = (sr as ShadowRoot & { getSelection?: () => Selection | null }).getSelection?.(); + if (sel && !sel.isCollapsed) return sel; + } + return window.getSelection(); +} diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index 59613f2f..9b4fd68b 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -117,6 +117,8 @@ const reviewShortcuts: ShortcutSection[] = [ { keys: [modKey, enter], desc: 'Approve / Send feedback' }, { keys: [altKey, altKey], desc: 'Toggle destination', hint: 'Double-tap to switch between GitHub and Agent in PR review mode' }, { keys: [modKey, '⇧', 'C'], desc: 'Toggle comment mode' }, + { keys: [modKey, 'B'], desc: 'Toggle file tree' }, + { keys: [modKey, '.'], desc: 'Toggle sidebar' }, { keys: ['Esc'], desc: 'Collapse sidebar' }, ], }, @@ -125,10 +127,21 @@ const reviewShortcuts: ShortcutSection[] = [ shortcuts: [ { keys: ['J'], desc: 'Next file' }, { keys: ['K'], desc: 'Previous file' }, + { keys: ['['], desc: 'Scroll to previous file', hint: 'In all-files view, scrolls the viewport to the previous file' }, + { keys: [']'], desc: 'Scroll to next file', hint: 'In all-files view, scrolls the viewport to the next file' }, { keys: ['Home'], desc: 'First file' }, { keys: ['End'], desc: 'Last file' }, ], }, + { + title: 'File Actions', + shortcuts: [ + { keys: ['V'], desc: 'Toggle viewed', hint: 'In all-files view, also collapses the file' }, + { keys: ['A'], desc: 'Toggle git add / stage' }, + { keys: ['C'], desc: 'Collapse / expand file', hint: 'In all-files view, toggles the focused file' }, + { keys: ['Z'], desc: 'Undo collapse', hint: 'Reopens the last collapsed file and scrolls to it' }, + ], + }, { title: 'Annotations', shortcuts: [ diff --git a/packages/ui/hooks/useSidebar.ts b/packages/ui/hooks/useSidebar.ts index dc69131f..0424b240 100644 --- a/packages/ui/hooks/useSidebar.ts +++ b/packages/ui/hooks/useSidebar.ts @@ -1,20 +1,19 @@ /** * Sidebar Hook * - * Manages the shared left sidebar state: open/close and active tab. - * The sidebar is shared between the Table of Contents, Version Browser, File Browser, and Archive views. + * Manages sidebar state: open/close and active tab. + * Generic over tab type — used by both the plan editor (left sidebar) + * and the review editor (right sidebar). */ import { useState, useCallback } from "react"; export type SidebarTab = "toc" | "versions" | "files" | "archive"; -export interface UseSidebarReturn { +export interface UseSidebarReturn { isOpen: boolean; - activeTab: SidebarTab; - /** Open the sidebar, optionally switching to a specific tab */ - open: (tab?: SidebarTab) => void; - /** Close the sidebar */ + activeTab: T; + open: (tab?: T) => void; close: () => void; /** * Toggle a tab: @@ -22,14 +21,14 @@ export interface UseSidebarReturn { * - If sidebar is open and same tab → close * - If sidebar is open and different tab → switch to that tab */ - toggleTab: (tab: SidebarTab) => void; + toggleTab: (tab: T) => void; } -export function useSidebar(initialOpen: boolean): UseSidebarReturn { +export function useSidebar(initialOpen: boolean, defaultTab?: T): UseSidebarReturn { const [isOpen, setIsOpen] = useState(initialOpen); - const [activeTab, setActiveTab] = useState("toc"); + const [activeTab, setActiveTab] = useState((defaultTab ?? "toc") as T); - const open = useCallback((tab?: SidebarTab) => { + const open = useCallback((tab?: T) => { setIsOpen(true); if (tab) setActiveTab(tab); }, []); @@ -39,7 +38,7 @@ export function useSidebar(initialOpen: boolean): UseSidebarReturn { }, []); const toggleTab = useCallback( - (tab: SidebarTab) => { + (tab: T) => { if (!isOpen) { setIsOpen(true); setActiveTab(tab);