Skip to content

Commit 68418e2

Browse files
authored
feat(review): add dockview center workspace (#453)
* chore(review): add dockview-react dependency * feat(review): add dockview center workspace * fix(review): stabilize diff search navigation * feat(review): add progressive file search UI * feat(review): streamline header controls * fix(review): portal annotation toolbar * refactor(review): streamline header and action menus * fix(review): make file header responsive * fix(review): align split diff drag handle * chore(review): hide review agents sidebar * fix(review): remount diff viewer on file switch * fix(review): wrap inline diff comments
1 parent dc1190f commit 68418e2

28 files changed

+2050
-598
lines changed

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ plannotator/
6262
│ ├── editor/ # Plan review App.tsx
6363
│ └── review-editor/ # Code review UI
6464
│ ├── App.tsx # Main review app
65-
│ ├── components/ # DiffViewer, FileTree, ReviewPanel
65+
│ ├── components/ # DiffViewer, FileTree, ReviewSidebar
66+
│ ├── dock/ # Dockview center panel infrastructure
6667
│ ├── demoData.ts # Demo diff for standalone mode
6768
│ └── index.css # Review-specific styles
6869
├── .claude-plugin/marketplace.json # For marketplace install

bun.lock

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@opencode-ai/sdk": "^1.3.0",
3939
"@pierre/diffs": "^1.1.0-beta.19",
4040
"diff": "^8.0.3",
41+
"dockview-react": "^5.2.0",
4142
"dompurify": "^3.3.3",
4243
"marked": "^17.0.5"
4344
},

packages/review-editor/App.tsx

Lines changed: 386 additions & 235 deletions
Large diffs are not rendered by default.

packages/review-editor/components/AnnotationToolbar.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useState, useEffect } from 'react';
2+
import { createPortal } from 'react-dom';
23
import { ToolbarState } from '../hooks/useAnnotationToolbar';
34
import { useTabIndent } from '../hooks/useTabIndent';
45
import { formatLineRange } from '../utils/formatLineRange';
@@ -81,7 +82,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
8182
onCancel(); // close the whole toolbar
8283
};
8384

84-
return (
85+
const content = (
8586
<div
8687
ref={toolbarRef}
8788
className="review-toolbar"
@@ -216,4 +217,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
216217
)}
217218
</div>
218219
);
220+
221+
if (typeof document === 'undefined') {
222+
return content;
223+
}
224+
225+
return createPortal(content, document.body);
219226
};

packages/review-editor/components/DiffViewer.tsx

Lines changed: 131 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
2-
import { FileDiff } from '@pierre/diffs/react';
2+
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
33
import { getSingularPatch, processFile } from '@pierre/diffs';
44
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types';
55
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
@@ -22,10 +22,86 @@ import {
2222
swapActiveSearchHighlight,
2323
} from '../utils/reviewSearchHighlight';
2424

25+
interface PierreDiffContentProps {
26+
filePath: string;
27+
fileDiff: ReturnType<typeof getSingularPatch>;
28+
pierreTheme: { type: 'dark' | 'light'; css: string };
29+
diffStyle: 'split' | 'unified';
30+
diffOverflow?: 'scroll' | 'wrap';
31+
diffIndicators?: 'bars' | 'classic' | 'none';
32+
lineDiffType?: 'word-alt' | 'word' | 'char' | 'none';
33+
disableLineNumbers?: boolean;
34+
disableBackground?: boolean;
35+
mergedAnnotations: DiffLineAnnotation<DiffAnnotationMetadata>[];
36+
pendingSelection: SelectedLineRange | null;
37+
onLineSelectionEnd: (range: SelectedLineRange | null) => void;
38+
renderAnnotation: (annotation: { side: string; lineNumber: number; metadata?: DiffAnnotationMetadata }) => React.ReactNode;
39+
renderHoverUtility: (getHoveredLine: () => { lineNumber: number; side: 'deletions' | 'additions' } | undefined) => React.ReactNode;
40+
}
41+
42+
const PierreDiffContent = React.memo(({
43+
filePath,
44+
fileDiff,
45+
pierreTheme,
46+
diffStyle,
47+
diffOverflow,
48+
diffIndicators,
49+
lineDiffType,
50+
disableLineNumbers,
51+
disableBackground,
52+
mergedAnnotations,
53+
pendingSelection,
54+
onLineSelectionEnd,
55+
renderAnnotation,
56+
renderHoverUtility,
57+
}: PierreDiffContentProps) => {
58+
return (
59+
<FileDiff
60+
key={filePath}
61+
fileDiff={fileDiff}
62+
options={{
63+
themeType: pierreTheme.type,
64+
unsafeCSS: pierreTheme.css,
65+
diffStyle,
66+
overflow: diffOverflow,
67+
diffIndicators,
68+
lineDiffType,
69+
disableLineNumbers,
70+
disableBackground,
71+
hunkSeparators: 'line-info',
72+
enableLineSelection: true,
73+
enableHoverUtility: true,
74+
onLineSelectionEnd,
75+
}}
76+
lineAnnotations={mergedAnnotations}
77+
selectedLines={pendingSelection || undefined}
78+
renderAnnotation={renderAnnotation}
79+
renderHoverUtility={renderHoverUtility}
80+
/>
81+
);
82+
}, (prev, next) => (
83+
prev.filePath === next.filePath &&
84+
prev.fileDiff === next.fileDiff &&
85+
prev.pierreTheme.type === next.pierreTheme.type &&
86+
prev.pierreTheme.css === next.pierreTheme.css &&
87+
prev.diffStyle === next.diffStyle &&
88+
prev.diffOverflow === next.diffOverflow &&
89+
prev.diffIndicators === next.diffIndicators &&
90+
prev.lineDiffType === next.lineDiffType &&
91+
prev.disableLineNumbers === next.disableLineNumbers &&
92+
prev.disableBackground === next.disableBackground &&
93+
prev.mergedAnnotations === next.mergedAnnotations &&
94+
prev.pendingSelection === next.pendingSelection &&
95+
prev.onLineSelectionEnd === next.onLineSelectionEnd &&
96+
prev.renderAnnotation === next.renderAnnotation &&
97+
prev.renderHoverUtility === next.renderHoverUtility
98+
));
99+
25100
interface DiffViewerProps {
26101
patch: string;
27102
filePath: string;
28103
oldPath?: string;
104+
isFocused?: boolean;
29105
diffStyle: 'split' | 'unified';
30106
diffOverflow?: 'scroll' | 'wrap';
31107
diffIndicators?: 'bars' | 'classic' | 'none';
@@ -69,6 +145,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
69145
patch,
70146
filePath,
71147
oldPath,
148+
isFocused = false,
72149
diffStyle,
73150
diffOverflow,
74151
diffIndicators = 'bars',
@@ -107,6 +184,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
107184
}) => {
108185
const { theme, colorTheme, resolvedMode } = useTheme();
109186
const containerRef = useRef<HTMLDivElement>(null);
187+
const splitSurfaceRef = useRef<HTMLDivElement>(null);
110188
const [fileCommentAnchor, setFileCommentAnchor] = useState<HTMLElement | null>(null);
111189

112190
// Resizable split pane — only applies when Pierre renders a two-column grid
@@ -134,13 +212,13 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
134212

135213
const handleSplitDragStart = useCallback((e: React.PointerEvent) => {
136214
e.preventDefault();
137-
const container = containerRef.current;
138-
if (!container) return;
139-
const rect = container.getBoundingClientRect();
215+
if (!splitSurfaceRef.current) return;
140216
setIsDraggingSplit(true);
141217

142-
const onMove = (e: PointerEvent) => {
143-
const ratio = (e.clientX - rect.left) / rect.width;
218+
const onMove = (moveEvent: PointerEvent) => {
219+
const rect = splitSurfaceRef.current?.getBoundingClientRect();
220+
if (!rect || rect.width <= 0) return;
221+
const ratio = (moveEvent.clientX - rect.left) / rect.width;
144222
setSplitRatio(Math.min(0.8, Math.max(0.2, ratio)));
145223
};
146224

@@ -160,7 +238,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
160238
storage.setItem('review-split-ratio', '0.5');
161239
}, []);
162240

163-
const toolbar = useAnnotationToolbar({ patch, filePath, onLineSelection, onAddAnnotation, onEditAnnotation });
241+
const toolbar = useAnnotationToolbar({ patch, filePath, isFocused, onLineSelection, onAddAnnotation, onEditAnnotation });
164242

165243
// Parse patch into FileDiffMetadata for @pierre/diffs FileDiff component
166244
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
@@ -396,14 +474,30 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
396474
[data-column-number] { background-color: ${bg} !important; }
397475
[data-diffs-header] [data-title] { display: none !important; }
398476
[data-diff-type='split'][data-overflow='scroll'] {
399-
grid-template-columns: var(--split-left, 1fr) var(--split-right, 1fr) !important;
477+
grid-template-columns:
478+
minmax(0, var(--split-left, 1fr))
479+
minmax(0, var(--split-right, 1fr)) !important;
480+
}
481+
[data-diff-type='split'][data-overflow='scroll'] > [data-code][data-deletions],
482+
[data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions],
483+
[data-diff-type='split'][data-overflow='scroll'] > [data-code][data-deletions] [data-content],
484+
[data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions] [data-content] {
485+
min-width: 0 !important;
400486
}
401487
${fontCSS}
402488
`,
403489
});
404490
});
405491
}, [resolvedMode, colorTheme, fontFamily, fontSize]);
406492

493+
const splitGridStyle = useMemo(() => {
494+
if (!isSplitLayout || diffOverflow === 'wrap') return undefined;
495+
return {
496+
'--split-left': `${splitRatio}fr`,
497+
'--split-right': `${1 - splitRatio}fr`,
498+
} as React.CSSProperties;
499+
}, [diffOverflow, isSplitLayout, splitRatio]);
500+
407501
return (
408502
<div className="h-full flex flex-col">
409503
<FileHeader
@@ -420,46 +514,36 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
420514
/>
421515

422516
<div ref={containerRef} className={`flex-1 overflow-auto relative ${isDraggingSplit ? 'select-none' : ''}`} onMouseMove={toolbar.handleMouseMove}>
423-
{isSplitLayout && diffOverflow !== 'wrap' && (
424-
<div
425-
className="absolute top-0 bottom-0 z-10 cursor-col-resize group"
426-
style={{ left: `${splitRatio * 100}%`, width: 9, marginLeft: -4 }}
427-
onPointerDown={handleSplitDragStart}
428-
onDoubleClick={resetSplitRatio}
429-
>
430-
<div className="absolute inset-y-0 left-1/2 w-px bg-border group-hover:bg-primary/50 group-active:bg-primary/70 transition-colors" />
517+
<div className="p-4">
518+
<div ref={splitSurfaceRef} className="relative min-w-0" style={splitGridStyle}>
519+
{isSplitLayout && diffOverflow !== 'wrap' && (
520+
<div
521+
className="absolute top-0 bottom-0 z-10 cursor-col-resize group"
522+
style={{ left: `${splitRatio * 100}%`, width: 9, marginLeft: -4 }}
523+
onPointerDown={handleSplitDragStart}
524+
onDoubleClick={resetSplitRatio}
525+
>
526+
<div className="pointer-events-none absolute inset-y-0 left-1/2 -translate-x-1/2 w-px bg-border transition-[width,background-color] group-hover:w-0.5 group-hover:bg-primary/50 group-active:w-0.5 group-active:bg-primary/70" />
527+
</div>
528+
)}
529+
<PierreDiffContent
530+
filePath={filePath}
531+
fileDiff={augmentedDiff}
532+
pierreTheme={pierreTheme}
533+
diffStyle={diffStyle}
534+
diffOverflow={diffOverflow}
535+
diffIndicators={diffIndicators}
536+
lineDiffType={lineDiffType}
537+
disableLineNumbers={disableLineNumbers}
538+
disableBackground={disableBackground}
539+
mergedAnnotations={mergedAnnotations}
540+
pendingSelection={pendingSelection}
541+
onLineSelectionEnd={toolbar.handleLineSelectionEnd}
542+
renderAnnotation={renderAnnotation}
543+
renderHoverUtility={renderHoverUtility}
544+
/>
545+
</div>
431546
</div>
432-
)}
433-
<div
434-
className="p-4"
435-
style={isSplitLayout && diffOverflow !== 'wrap' ? {
436-
'--split-left': `${splitRatio}fr`,
437-
'--split-right': `${1 - splitRatio}fr`,
438-
} as React.CSSProperties : undefined}
439-
>
440-
<FileDiff
441-
key={filePath}
442-
fileDiff={augmentedDiff}
443-
options={{
444-
themeType: pierreTheme.type,
445-
unsafeCSS: pierreTheme.css,
446-
diffStyle,
447-
overflow: diffOverflow,
448-
diffIndicators,
449-
lineDiffType,
450-
disableLineNumbers,
451-
disableBackground,
452-
hunkSeparators: 'line-info',
453-
enableLineSelection: true,
454-
enableHoverUtility: true,
455-
onLineSelectionEnd: toolbar.handleLineSelectionEnd,
456-
}}
457-
lineAnnotations={mergedAnnotations}
458-
selectedLines={pendingSelection || undefined}
459-
renderAnnotation={renderAnnotation}
460-
renderHoverUtility={renderHoverUtility}
461-
/>
462-
</div>
463547

464548
{toolbar.toolbarState && (
465549
<AnnotationToolbar

0 commit comments

Comments
 (0)