feat(review): all-files diff view with unified scrolling (#640)

* 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.
This commit is contained in:
Michael Ramos
2026-04-30 23:39:43 -07:00
committed by GitHub
parent 108a61b7d8
commit 0fd9826c2c
21 changed files with 1247 additions and 312 deletions
+3 -3
View File
@@ -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:*",
+218 -80
View File
@@ -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<CodeAnnotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isAllFilesActive, setIsAllFilesActive] = useState(false);
const [isDiffPanelActive, setIsDiffPanelActive] = useState(false);
const [allFilesVisibleFile, setAllFilesVisibleFile] = useState<string | null>(null);
const [pendingSelection, setPendingSelection] = useState<SelectedLineRange | null>(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<ReviewSidebarTab>(true, 'annotations');
const [isFileTreeOpen, setIsFileTreeOpen] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
const [copyRawDiffStatus, setCopyRawDiffStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [viewedFiles, setViewedFiles] = useState<Set<string>>(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<string | null>(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 = () => {
<div className="h-screen flex flex-col bg-background overflow-hidden">
{/* Header */}
<header className="py-1 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl z-50">
<div className="min-w-0 flex items-center gap-2 md:gap-3">
<div className="min-w-0 flex items-center gap-2 md:gap-3 -ml-1.5 md:-ml-3">
{shouldShowFileTree && (
<button
onClick={() => setIsFileTreeOpen(prev => !prev)}
className={`p-1 rounded-md transition-all focus-visible:outline-none ${
isFileTreeOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isFileTreeOpen ? 'Hide file tree' : 'Show file tree'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</button>
)}
{prMetadata ? (
<div className="min-w-0 flex items-center gap-2 md:gap-3">
{(gitContext || agentCwd) && (
@@ -1886,13 +1967,67 @@ const ReviewApp: React.FC = () => {
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
{/* Utilities */}
{/* Sidebar tab toggles */}
<button
onClick={() => reviewSidebar.toggleTab('annotations')}
className={`relative p-1.5 rounded-md transition-all ${
reviewSidebar.isOpen && reviewSidebar.activeTab === 'annotations'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="Annotations"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
{totalAnnotationCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] flex items-center justify-center rounded-full bg-primary text-[8px] font-bold text-primary-foreground px-0.5">
{totalAnnotationCount > 99 ? '99+' : totalAnnotationCount}
</span>
)}
</button>
{aiAvailable && (
<button
onClick={() => reviewSidebar.toggleTab('ai')}
className={`relative p-1.5 rounded-md transition-all ${
reviewSidebar.isOpen && reviewSidebar.activeTab === 'ai'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="AI Chat"
>
<SparklesIcon className="w-4 h-4" />
{aiChat.messages.length > 0 && !(reviewSidebar.isOpen && reviewSidebar.activeTab === 'ai') && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
)}
{agentJobs.capabilities?.available && (
<button
onClick={() => reviewSidebar.toggleTab('agents')}
className={`relative p-1.5 rounded-md transition-all ${
reviewSidebar.isOpen && reviewSidebar.activeTab === 'agents'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="Review Agents"
>
<ReviewAgentsIcon className="w-4 h-4" />
{agentJobs.jobs.some(j => j.status === 'running' || j.status === 'starting') && !(reviewSidebar.isOpen && reviewSidebar.activeTab === 'agents') && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</button>
)}
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
<ReviewHeaderMenu
isPanelOpen={isPanelOpen}
annotationCount={totalAnnotationCount}
onTogglePanel={() => 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}
/>
</div>
@@ -1900,12 +2035,14 @@ const ReviewApp: React.FC = () => {
{/* Main content */}
<div className={`flex-1 flex overflow-hidden ${isResizing ? 'select-none' : ''}`}>
{/* Left sidebar stays mounted whenever it provides navigation or context. */}
{shouldShowFileTree && (
{shouldShowFileTree && isFileTreeOpen && (
<>
<FileTree
files={files}
activeFileIndex={activeFileIndex}
onSelectAllFiles={openAllFilesPanel}
isAllFilesActive={isAllFilesActive}
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
onSelectFile={handleFilePreview}
onDoubleClickFile={handleFilePinned}
annotations={allAnnotations}
@@ -2019,48 +2156,49 @@ const ReviewApp: React.FC = () => {
)}
</div>
{/* Resize Handle */}
{isPanelOpen && <ResizeHandle {...panelResize.handleProps} side="right" />}
{/* Annotations panel */}
<ReviewSidebar
isOpen={isPanelOpen}
onToggle={() => 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 && (
<>
<ResizeHandle {...panelResize.handleProps} side="right" />
<ReviewSidebar
isOpen
onClose={reviewSidebar.close}
activeTab={reviewSidebar.activeTab}
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}
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}
/>
</>
)}
</div>
{/* Export Modal */}
@@ -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<string>;
onToggleViewed?: (filePath: string) => void;
stagedFiles?: Set<string>;
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<AllFilesDiffViewProps> = ({
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<string | null>(null);
const pendingToolbarRange = useRef<SelectedLineRange | null>(null);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const collapseHistory = useRef<string[]>([]);
useEffect(() => {
setActiveFilePath(null);
setCollapsedFiles(new Set());
collapseHistory.current = [];
}, [files]);
const scrollRef = useRef<HTMLDivElement>(null);
const headerRefs = useRef<Map<string, HTMLDivElement>>(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<string, ReturnType<typeof getSingularPatch>>();
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<string | null>(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 (
<div className="h-full overflow-auto" ref={scrollRef} onMouseMove={toolbar.handleMouseMove}>
{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 (
<div key={file.path}>
<div
className="sticky top-0 z-10 bg-card border-t border-border/30 first:border-t-0"
ref={(el) => setHeaderRef(file.path, el)}
>
<FileHeader
filePath={file.path}
patch={file.patch}
isViewed={viewedFiles.has(file.path)}
onToggleViewed={onToggleViewed ? () => 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={
<button
onClick={(e) => { e.stopPropagation(); toggleCollapse(file.path); }}
className="flex items-center justify-center w-6 h-6 rounded hover:bg-foreground/10 transition-colors flex-shrink-0"
>
<svg
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
}
onCollapseToggle={() => toggleCollapse(file.path)}
/>
</div>
{!isCollapsed && (
<LazyFileDiff
file={file}
baseDiff={baseDiffs.get(file.path)!}
scrollRoot={scrollRef.current}
reviewBase={reviewBase}
diffStyle={diffStyle}
forceMount={selectedAnnotationId != null && fileAnnotations.some(a => 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<DiffAnnotationMetadata>) => {
if (!annotation.metadata) return null;
return (
<InlineAnnotation
metadata={annotation.metadata}
language={detectLanguage(file.path)}
onSelect={onSelectAnnotation}
onEdit={handleEdit}
onDelete={onDeleteAnnotation}
/>
);
}}
renderHoverUtility={(getHoveredLine: () => { lineNumber: number; side: 'deletions' | 'additions' } | undefined) => (
<button
className="hover-add-comment"
onClick={(e) => {
e.stopPropagation();
const line = getHoveredLine();
if (!line) return;
const range = { start: line.lineNumber, end: line.lineNumber, side: line.side };
if (activeFilePath === file.path) {
toolbar.handleLineSelectionEnd(range);
} else {
pendingToolbarRange.current = range;
setActiveFilePath(file.path);
}
}}
>
+
</button>
)}
/>
)}
</div>
);
})}
{toolbar.toolbarState && !toolbar.showCodeModal && (
<AnnotationToolbar
toolbarState={toolbar.toolbarState}
toolbarRef={toolbar.toolbarRef}
commentText={toolbar.commentText}
setCommentText={toolbar.setCommentText}
suggestedCode={toolbar.suggestedCode}
setSuggestedCode={toolbar.setSuggestedCode}
showSuggestedCode={toolbar.showSuggestedCode}
setShowSuggestedCode={toolbar.setShowSuggestedCode}
selectedOriginalCode={toolbar.selectedOriginalCode}
setShowCodeModal={toolbar.setShowCodeModal}
isEditing={!!toolbar.editingAnnotationId}
onSubmit={toolbar.handleSubmitAnnotation}
onDismiss={toolbar.handleDismiss}
onCancel={toolbar.handleCancel}
conventionalCommentsEnabled={conventionalCommentsEnabled}
conventionalLabel={toolbar.conventionalLabel}
onConventionalLabelChange={toolbar.setConventionalLabel}
decorations={toolbar.decorations}
onDecorationsChange={toolbar.setDecorations}
enabledLabels={enabledLabels}
/>
)}
{toolbar.showCodeModal && (
<SuggestionModal
filePath={activeFilePath ?? ''}
toolbarState={toolbar.toolbarState}
selectedOriginalCode={toolbar.selectedOriginalCode}
suggestedCode={toolbar.suggestedCode}
setSuggestedCode={toolbar.setSuggestedCode}
modalLayout={toolbar.modalLayout}
setModalLayout={toolbar.setModalLayout}
onClose={() => toolbar.setShowCodeModal(false)}
/>
)}
</div>
);
};
@@ -82,17 +82,18 @@ export const DiffOptionsPopover: React.FC = () => {
<Popover.Content
align="end"
sideOffset={6}
className="z-50 w-64 bg-popover text-popover-foreground border border-border rounded-lg shadow-lg overflow-hidden origin-[var(--radix-popover-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
className="z-50 w-72 bg-popover text-popover-foreground border border-border rounded-lg shadow-lg overflow-hidden origin-[var(--radix-popover-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<div className="p-2.5 space-y-2">
<div className="space-y-1.5">
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Layout</div>
<div className="grid grid-cols-2 gap-1.5">
<div>
<div className="flex items-center gap-2">
<div className="flex-1">
<CompactSegmented options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
</div>
<div>
<div className="w-px h-5 bg-border/50 flex-shrink-0" />
<div className="flex-1">
<CompactSegmented options={OVERFLOW_OPTIONS} value={diffOverflow} onChange={(v) => configStore.set('diffOverflow', v)} />
</div>
</div>
@@ -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<DiffViewerProps> = ({
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<HTMLDivElement>();
const splitSurfaceRef = useRef<HTMLDivElement>(null);
const diffContentRef = useRef<HTMLDivElement>(null);
const [fileCommentAnchor, setFileCommentAnchor] = useState<HTMLElement | null>(null);
// Resizable split pane — only applies when Pierre renders a two-column grid
@@ -521,6 +523,30 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
);
}, [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<DiffViewerProps> = ({
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<DiffViewerProps> = ({
onViewportReady={onViewportReady}
onMouseMove={toolbar.handleMouseMove}
>
<div className="p-4">
<div className="p-4" ref={diffContentRef}>
<div ref={splitSurfaceRef} className="relative min-w-0" style={splitGridStyle}>
{isSplitLayout && diffOverflow !== 'wrap' && (
<div
@@ -12,6 +12,8 @@ interface FileHeaderProps {
canStage?: boolean;
stageError?: string | null;
onFileComment?: (anchorEl: HTMLElement) => void;
collapseToggle?: React.ReactNode;
onCollapseToggle?: () => void;
}
function splitFilePath(filePath: string): { directory: string; name: string } {
@@ -43,6 +45,8 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
canStage = false,
stageError,
onFileComment,
collapseToggle,
onCollapseToggle,
}) => {
const [copied, setCopied] = useState(false);
const [headerWidth, setHeaderWidth] = useState<number>(0);
@@ -83,7 +87,8 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
className="flex-shrink-0 px-3 border-b border-border/50 flex items-center justify-between gap-2"
style={{ height: 'var(--panel-header-h)' }}
>
<div className="min-w-0 flex flex-1 items-center">
<div className="min-w-0 flex flex-1 items-center" onClick={onCollapseToggle} style={onCollapseToggle ? { cursor: 'pointer' } : undefined}>
{collapseToggle}
<span className="min-w-0 flex items-center text-xs font-semibold leading-none whitespace-nowrap" title={filePath}>
{!showFilenameOnly && directory && (
<span className="min-w-0 overflow-hidden text-ellipsis text-muted-foreground/70">
@@ -106,7 +111,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 (V)" : "Mark as viewed (V)"}
>
{isViewed ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -131,7 +136,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 (A)" : "Stage this file (A)"}
>
{isStaging ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
+48 -14
View File
@@ -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<FileTreeProps> = ({
@@ -95,9 +98,16 @@ export const FileTree: React.FC<FileTreeProps> = ({
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<FileTreeProps> = ({
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<FileTreeProps> = ({
return annotationCountMap.get(filePath) ?? 0;
}, [annotationCountMap]);
const tree = useMemo(() => buildFileTree(files), [files]);
const allFolderPaths = useMemo(() => getAllFolderPaths(tree), [tree]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set(allFolderPaths));
const [prevTree, setPrevTree] = useState(tree);
@@ -398,13 +409,35 @@ export const FileTree: React.FC<FileTreeProps> = ({
</div>
)
) : (
tree.map(node => (
<>
{onSelectAllFiles && (
<button
onClick={onSelectAllFiles}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors mb-0.5 ${
isAllFilesActive
? 'bg-primary/15 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.878V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0118 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 004.5 9v.878m13.5-3A2.25 2.25 0 0119.5 9v.878m-13.5 0A2.25 2.25 0 003 12v3a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 15v-3a2.25 2.25 0 00-2.25-2.25m-13.5 0h13.5" />
</svg>
<span>All files</span>
<span className="ml-auto text-[10px] tabular-nums opacity-60">
<span className="text-green-500">+{files.reduce((s, f) => s + f.additions, 0)}</span>
{' '}
<span className="text-red-500">-{files.reduce((s, f) => s + f.deletions, 0)}</span>
</span>
</button>
)}
{tree.map(node => (
<FileTreeNodeItem
key={node.type === 'file' ? node.path : `folder:${node.path}`}
node={node}
expandedFolders={expandedFolders}
onToggleFolder={handleToggleFolder}
activeFileIndex={activeFileIndex}
activeFileIndex={isAllFilesActive ? -1 : activeFileIndex}
scrollHighlightIndex={isAllFilesActive ? scrollHighlightIndex : undefined}
onSelectFile={onSelectFile}
onDoubleClickFile={onDoubleClickFile}
viewedFiles={viewedFiles}
@@ -413,7 +446,8 @@ export const FileTree: React.FC<FileTreeProps> = ({
getAnnotationCount={getAnnotationCount}
stagedFiles={stagedFiles}
/>
))
))}
</>
)}
</div>
</OverlayScrollArea>
@@ -13,6 +13,7 @@ interface FileTreeNodeProps {
hideViewedFiles: boolean;
getAnnotationCount: (filePath: string) => number;
stagedFiles?: Set<string>;
scrollHighlightIndex?: number;
}
function hasVisibleChildren(
@@ -44,6 +45,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
hideViewedFiles,
getAnnotationCount,
stagedFiles,
scrollHighlightIndex,
}) => {
const paddingLeft = 4 + node.depth * 8;
@@ -92,6 +94,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
hideViewedFiles={hideViewedFiles}
getAnnotationCount={getAnnotationCount}
stagedFiles={stagedFiles}
scrollHighlightIndex={scrollHighlightIndex}
/>
))}
</>
@@ -100,6 +103,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
// 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<FileTreeNodeProps> = ({
<button
onClick={() => onSelectFile(node.fileIndex!)}
onDoubleClick={() => onDoubleClickFile?.(node.fileIndex!)}
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''} ${isStaged ? 'staged' : ''}`}
className={`file-tree-item w-full text-left group ${isActive ? 'active' : isScrollActive ? 'scroll-active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''} ${isStaged ? 'staged' : ''}`}
style={{ paddingLeft }}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
@@ -0,0 +1,121 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
import type { FileDiffMetadata } from '@pierre/diffs';
import { processFile } from '@pierre/diffs';
import type { DiffAnnotationMetadata, SelectedLineRange } from '@plannotator/ui/types';
import type { DiffFile } from '../types';
interface LazyFileDiffProps {
file: DiffFile;
baseDiff: FileDiffMetadata;
forceMount?: boolean;
scrollRoot: HTMLElement | null;
reviewBase?: string;
diffStyle: 'split' | 'unified';
options: Record<string, unknown>;
annotations: DiffLineAnnotation<DiffAnnotationMetadata>[];
selectedLines: SelectedLineRange | undefined;
renderAnnotation: (annotation: DiffLineAnnotation<DiffAnnotationMetadata>) => React.ReactNode;
renderHoverUtility: (getHoveredLine: () => { lineNumber: number; side: 'deletions' | 'additions' } | undefined) => React.ReactNode;
}
function estimateHeight(fileDiff: FileDiffMetadata, diffStyle: 'split' | 'unified'): number {
const lineCount = diffStyle === 'split' ? fileDiff.splitLineCount : fileDiff.unifiedLineCount;
return (lineCount * 20) + (fileDiff.hunks.length * 32) + 8;
}
export const LazyFileDiff: React.FC<LazyFileDiffProps> = ({
file,
baseDiff,
forceMount = false,
scrollRoot,
reviewBase,
diffStyle,
options,
annotations,
selectedLines,
renderAnnotation,
renderHoverUtility,
}) => {
const [mounted, setMounted] = useState(forceMount);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (forceMount && !mounted) setMounted(true);
}, [forceMount, mounted]);
useEffect(() => {
if (mounted) return;
const el = sentinelRef.current;
if (!el || !scrollRoot) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setMounted(true);
observer.disconnect();
}
},
{ root: scrollRoot, rootMargin: '100% 0px 100% 0px', threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [mounted, scrollRoot]);
// Per-file content fetching (same pattern as DiffViewer.tsx)
const [fileContents, setFileContents] = useState<{ old: string | null; new: string | null } | null>(null);
useEffect(() => {
if (!mounted) return;
setFileContents(null);
const controller = new AbortController();
const params = new URLSearchParams({ path: file.path });
if (file.oldPath) params.set('oldPath', file.oldPath);
if (reviewBase) params.set('base', reviewBase);
fetch(`/api/file-content?${params}`, { signal: controller.signal })
.then(res => res.ok ? res.json() : null)
.then((data: { oldContent: string | null; newContent: string | null } | null) => {
if (data && (data.oldContent != null || data.newContent != null)) {
setFileContents({ old: data.oldContent, new: data.newContent });
}
})
.catch(() => {});
return () => controller.abort();
}, [mounted, file.path, file.oldPath, file.patch, reviewBase]);
const fileDiff = useMemo(() => {
if (!fileContents) return baseDiff;
try {
const result = processFile(file.patch, {
oldFile: fileContents.old != null ? { name: file.oldPath || file.path, contents: fileContents.old } : undefined,
newFile: fileContents.new != null ? { name: file.path, contents: fileContents.new } : undefined,
});
return result || baseDiff;
} catch {
return baseDiff;
}
}, [file.patch, file.path, file.oldPath, fileContents, baseDiff]);
if (!mounted) {
return (
<div
ref={sentinelRef}
style={{ height: estimateHeight(baseDiff, diffStyle) }}
className="pb-2"
/>
);
}
return (
<div className="pb-2">
<FileDiff
key={file.path}
fileDiff={fileDiff}
options={options}
lineAnnotations={annotations}
selectedLines={selectedLines}
renderAnnotation={renderAnnotation}
renderHoverUtility={renderHoverUtility}
/>
</div>
);
};
@@ -6,22 +6,25 @@ import {
ActionMenuSectionLabel,
} from '@plannotator/ui/components/ActionMenu';
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
import { modKey } from '@plannotator/ui/utils/platform';
interface ReviewHeaderMenuProps {
isPanelOpen: boolean;
annotationCount: number;
onTogglePanel: () => void;
onOpenSettings: () => void;
onOpenExport: () => void;
onToggleFileTree: () => void;
onToggleSidebar: () => void;
isFileTreeOpen: boolean;
isSidebarOpen: boolean;
appVersion: string;
}
export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
isPanelOpen,
annotationCount,
onTogglePanel,
onOpenSettings,
onOpenExport,
onToggleFileTree,
onToggleSidebar,
isFileTreeOpen,
isSidebarOpen,
appVersion,
}) => {
const { theme, resolvedMode, setTheme } = useTheme();
@@ -50,22 +53,6 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
>
{({ closeMenu }) => (
<>
<ActionMenuItem
onClick={() => {
closeMenu();
onTogglePanel();
}}
icon={<AnnotationsIcon />}
label={isPanelOpen ? 'Hide Annotations' : 'Show Annotations'}
badge={annotationCount > 0 ? (
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{annotationCount}
</span>
) : undefined}
/>
<ActionMenuDivider />
<div className="px-3 py-2 space-y-1.5">
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
@@ -110,6 +97,27 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onToggleFileTree();
}}
icon={<FileTreeMenuIcon />}
label={isFileTreeOpen ? 'Hide File Tree' : 'Show File Tree'}
badge={<KbdHint keys={[modKey, 'B']} />}
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onToggleSidebar();
}}
icon={<SidebarIcon />}
label={isSidebarOpen ? 'Hide Sidebar' : 'Show Sidebar'}
badge={<KbdHint keys={[modKey, '.']} />}
/>
<ActionMenuDivider />
<div className="px-3 py-2 space-y-2">
<div className="flex items-center justify-between gap-3">
<ActionMenuSectionLabel>Plannotator</ActionMenuSectionLabel>
@@ -163,11 +171,6 @@ const SettingsIcon = () => (
</svg>
);
const AnnotationsIcon = () => (
<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="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
);
const ExportIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -187,3 +190,25 @@ const MoonIcon = () => (
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12.79A9 9 0 1111.21 3c-.18.57-.21 1.19-.21 1.82A8 8 0 0019.18 13c.63 0 1.25-.03 1.82-.21z" />
</svg>
);
const FileTreeMenuIcon = () => (
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
);
const KbdHint: React.FC<{ keys: string[] }> = ({ keys }) => (
<span className="inline-flex items-center gap-0.5 ml-auto">
{keys.map((k, i) => (
<kbd key={i} className="inline-flex items-center justify-center h-[18px] min-w-[18px] px-1 rounded bg-muted border border-border/60 text-[10px] font-mono leading-none text-muted-foreground">
{k}
</kbd>
))}
</span>
);
const SidebarIcon = () => (
<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 4h10a2 2 0 012 2v12a2 2 0 01-2 2H9M9 4H5a2 2 0 00-2 2v12a2 2 0 002 2h4M9 4v16" />
</svg>
);
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { CodeAnnotation, type EditorAnnotation } from '@plannotator/ui/types';
import { isCurrentUser } from '@plannotator/ui/utils/identity';
import { EditorAnnotationCard } from '@plannotator/ui/components/EditorAnnotationCard';
@@ -9,8 +9,6 @@ import { detectLanguage } from '../utils/detectLanguage';
import { renderInlineMarkdown } from '../utils/renderInlineMarkdown';
import { formatRelativeTime } from '../utils/formatRelativeTime';
import { AITab } from './AITab';
import { SparklesIcon } from './SparklesIcon';
import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon';
import { AgentsTab } from '@plannotator/ui/components/AgentsTab';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
@@ -18,13 +16,13 @@ import type { AIChatEntry } from '../hooks/useAIChat';
import type { AgentJobInfo, AgentCapabilities } from '@plannotator/ui/types';
import type { DiffFile } from '../types';
type ReviewSidebarTab = 'annotations' | 'ai' | 'agents';
export type ReviewSidebarTab = 'annotations' | 'ai' | 'agents';
const REVIEW_AGENTS_ENABLED = true;
interface ReviewSidebarProps {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
activeTab: ReviewSidebarTab;
annotations: CodeAnnotation[];
files: DiffFile[];
selectedAnnotationId: string | null;
@@ -41,8 +39,6 @@ interface ReviewSidebarProps {
isAICreatingSession?: boolean;
isAIStreaming?: boolean;
onScrollToAILines?: (filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => void;
activeTabOverride?: ReviewSidebarTab;
onTabChange?: (tab: ReviewSidebarTab) => void;
activeFilePath?: string;
scrollToQuestionId?: string | null;
onAskGeneral?: (question: string) => void;
@@ -111,7 +107,8 @@ function compareCodeAnnotations(a: CodeAnnotation, b: CodeAnnotation): number {
export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
isOpen,
onToggle,
onClose,
activeTab,
annotations,
files,
selectedAnnotationId,
@@ -127,8 +124,6 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
isAICreatingSession = false,
isAIStreaming = false,
onScrollToAILines,
activeTabOverride,
onTabChange,
activeFilePath,
scrollToQuestionId,
onAskGeneral,
@@ -149,30 +144,6 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
}) => {
const totalCount = annotations.length + (editorAnnotations?.length ?? 0);
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<ReviewSidebarTab>('annotations');
const hasAgents = REVIEW_AGENTS_ENABLED && !!agentCapabilities?.available;
const runningAgentCount = (agentJobs ?? []).filter(j => j.status === 'running' || j.status === 'starting').length;
// Allow parent to control the active tab (e.g., switch to AI tab on ask)
useEffect(() => {
if (!activeTabOverride) return;
if (activeTabOverride === 'agents' && !REVIEW_AGENTS_ENABLED) {
setActiveTab('annotations');
return;
}
setActiveTab(activeTabOverride);
}, [activeTabOverride]);
const handleTabChange = (tab: ReviewSidebarTab | 'summary' | 'comments' | 'checks') => {
// PR tabs open as center dock panels instead of rendering in the sidebar
if (tab === 'summary' || tab === 'comments' || tab === 'checks') {
onOpenPRPanel?.(tab);
return;
}
if (tab === 'agents' && !REVIEW_AGENTS_ENABLED) return;
setActiveTab(tab);
onTabChange?.(tab);
};
const handleQuickCopy = async () => {
if (!feedbackMarkdown) return;
@@ -301,8 +272,17 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
{/* Header */}
<div className="px-3 flex items-center border-b border-border/50" style={{ height: 'var(--panel-header-h)' }}>
<div className="flex items-center gap-2 w-full min-w-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground truncate shrink">
{activeTab === 'annotations' ? 'Annotations' : activeTab === 'ai' ? 'AI' : activeTab === 'agents' ? 'Review Agents' : activeTab === 'summary' ? 'Summary' : activeTab === 'comments' ? 'Comments' : 'Checks'}
<button
onClick={onClose}
className="flex items-center justify-center w-5 h-5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
title="Close sidebar"
>
<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 5l7 7-7 7" />
</svg>
</button>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground truncate">
{activeTab === 'annotations' ? 'Annotations' : activeTab === 'ai' ? 'AI' : 'Review Agents'}
</h2>
{activeTab === 'annotations' && totalCount > 0 && (
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
@@ -319,61 +299,6 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
{aiMessages.length}
</span>
)}
<div className="flex items-center gap-0.5 ml-auto shrink-0">
{/* Annotations tab (always visible) */}
<button
onClick={() => handleTabChange('annotations')}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded text-[10px] font-medium transition-colors duration-150 ${
activeTab === 'annotations'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title="Annotations"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
{/* AI tab (visible when AI is available) */}
{aiAvailable && (
<button
onClick={() => handleTabChange('ai')}
className={`relative flex items-center gap-1 px-2.5 py-1.5 rounded text-[10px] font-medium transition-colors duration-150 ${
activeTab === 'ai'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title="AI Chat"
>
<SparklesIcon className="w-4 h-4" />
{aiMessages.length > 0 && activeTab !== 'ai' && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-primary" />
)}
</button>
)}
{/* Agents tab (visible when agent capabilities available) */}
{hasAgents && (
<button
onClick={() => handleTabChange('agents')}
className={`relative flex items-center gap-1 px-2.5 py-1.5 rounded text-[10px] font-medium transition-colors duration-150 ${
activeTab === 'agents'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title="Review Agents"
>
<ReviewAgentsIcon className="w-4 h-4" />
{runningAgentCount > 0 && activeTab !== 'agents' && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
</button>
)}
{/* PR tabs moved to header — see App.tsx */}
</div>
</div>
</div>
@@ -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;
@@ -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<IDockviewPanelProps> = () => {
const state = useReviewState();
return (
<AllFilesDiffView
files={state.files}
annotations={state.allAnnotations}
selectedAnnotationId={state.selectedAnnotationId}
pendingSelection={state.pendingSelection}
onLineSelection={state.onLineSelection}
onAddAnnotation={state.onAddAnnotationForFile}
onEditAnnotation={state.onEditAnnotation}
onSelectAnnotation={state.onSelectAnnotation}
onDeleteAnnotation={state.onDeleteAnnotation}
diffStyle={state.diffStyle}
diffOverflow={state.diffOverflow}
diffIndicators={state.diffIndicators}
lineDiffType={state.lineDiffType}
disableLineNumbers={state.disableLineNumbers}
disableBackground={state.disableBackground}
fontFamily={state.fontFamily}
fontSize={state.fontSize}
viewedFiles={state.viewedFiles}
onToggleViewed={state.onToggleViewed}
stagedFiles={state.stagedFiles}
onStage={state.onStage}
canStageFiles={state.canStageFiles}
stagingFile={state.stagingFile}
stageError={state.stageError}
reviewBase={state.reviewBase}
prUrl={state.prMetadata?.url}
prDiffScope={state.prDiffScope}
onVisibleFileChange={state.onAllFilesVisibleFileChange}
isActive={state.isAllFilesActive}
/>
);
};
@@ -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;
@@ -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;
@@ -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<PierreTheme>(() => {
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;
}
+5 -1
View File
@@ -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 {
@@ -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) {
@@ -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();
}
@@ -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: [
+11 -12
View File
@@ -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<T extends string = SidebarTab> {
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<T extends string = SidebarTab>(initialOpen: boolean, defaultTab?: T): UseSidebarReturn<T> {
const [isOpen, setIsOpen] = useState(initialOpen);
const [activeTab, setActiveTab] = useState<SidebarTab>("toc");
const [activeTab, setActiveTab] = useState<T>((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);