Skip to content

Commit 044a50e

Browse files
authored
✨ feat: add resizable side panels (#141)
Add drag-to-resize for the annotation panel, table of contents, and file tree sidebar. Panel widths persist in cookie storage across sessions and random-port hook invocations. - Add useResizablePanel hook with left/right side support - Extract ResizeHandle component with spread-friendly handleProps API - Apply to AnnotationPanel, ReviewPanel, TableOfContents, FileTree - Double-click handle to reset to default width - Add min-w-0 on content areas for proper flex shrinking
1 parent 8c44625 commit 044a50e

8 files changed

Lines changed: 180 additions & 30 deletions

File tree

packages/editor/App.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/u
2525
import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave';
2626
import { getUIPreferences, needsUIFeaturesSetup, type UIPreferences } from '@plannotator/ui/utils/uiPreferences';
2727
import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode';
28+
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
29+
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
2830
import {
2931
getPermissionModeSettings,
3032
needsPermissionModeSetup,
@@ -364,6 +366,14 @@ const App: React.FC = () => {
364366
const viewerRef = useRef<ViewerHandle>(null);
365367
const containerRef = useRef<HTMLElement>(null);
366368

369+
// Resizable panels
370+
const panelResize = useResizablePanel({ storageKey: 'plannotator-panel-width' });
371+
const tocResize = useResizablePanel({
372+
storageKey: 'plannotator-toc-width',
373+
defaultWidth: 240, minWidth: 160, maxWidth: 400, side: 'left',
374+
});
375+
const isResizing = panelResize.isDragging || tocResize.isDragging;
376+
367377
// Track active section for TOC highlighting
368378
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
369379
const activeSection = useActiveSection(containerRef, headingCount);
@@ -1021,20 +1031,24 @@ const App: React.FC = () => {
10211031
</header>
10221032

10231033
{/* Main Content */}
1024-
<div className="flex-1 flex overflow-hidden">
1034+
<div className={`flex-1 flex overflow-hidden ${isResizing ? 'select-none' : ''}`}>
10251035
{/* Table of Contents */}
10261036
{uiPrefs.tocEnabled && (
1027-
<TableOfContents
1028-
blocks={blocks}
1029-
annotations={annotations}
1030-
activeId={activeSection}
1031-
onNavigate={handleTocNavigate}
1032-
className="hidden lg:block w-60 sticky top-12 h-[calc(100vh-3rem)] flex-shrink-0"
1033-
/>
1037+
<>
1038+
<TableOfContents
1039+
blocks={blocks}
1040+
annotations={annotations}
1041+
activeId={activeSection}
1042+
onNavigate={handleTocNavigate}
1043+
className="hidden lg:block sticky top-12 h-[calc(100vh-3rem)] flex-shrink-0"
1044+
style={{ width: tocResize.width }}
1045+
/>
1046+
<ResizeHandle {...tocResize.handleProps} className="hidden lg:block" />
1047+
</>
10341048
)}
10351049

10361050
{/* Document Area */}
1037-
<main ref={containerRef} className="flex-1 overflow-y-auto bg-grid">
1051+
<main ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
10381052
<div className="min-h-full flex flex-col items-center px-4 py-3 md:px-10 md:py-8 xl:px-16">
10391053
{/* Mode Switcher */}
10401054
<div className="w-full max-w-[832px] 2xl:max-w-5xl mb-3 md:mb-4 flex justify-start">
@@ -1061,6 +1075,9 @@ const App: React.FC = () => {
10611075
</div>
10621076
</main>
10631077

1078+
{/* Resize Handle */}
1079+
{isPanelOpen && <ResizeHandle {...panelResize.handleProps} />}
1080+
10641081
{/* Annotation Panel */}
10651082
<AnnotationPanel
10661083
isOpen={isPanelOpen}
@@ -1072,6 +1089,7 @@ const App: React.FC = () => {
10721089
onEdit={handleEditAnnotation}
10731090
shareUrl={shareUrl}
10741091
sharingEnabled={sharingEnabled}
1092+
width={panelResize.width}
10751093
/>
10761094
</div>
10771095

packages/review-editor/App.tsx

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { storage, getAutoClose } from '@plannotator/ui/utils/storage';
88
import { getIdentity } from '@plannotator/ui/utils/identity';
99
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
1010
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types';
11+
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
12+
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
1113
import { DiffViewer } from './components/DiffViewer';
1214
import { ReviewPanel } from './components/ReviewPanel';
1315
import { FileTree } from './components/FileTree';
@@ -155,6 +157,14 @@ const ReviewApp: React.FC = () => {
155157

156158
const identity = useMemo(() => getIdentity(), []);
157159

160+
// Resizable panels
161+
const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' });
162+
const fileTreeResize = useResizablePanel({
163+
storageKey: 'plannotator-filetree-width',
164+
defaultWidth: 256, minWidth: 160, maxWidth: 400, side: 'left',
165+
});
166+
const isResizing = panelResize.isDragging || fileTreeResize.isDragging;
167+
158168
// Global keyboard shortcuts
159169
useEffect(() => {
160170
const handleKeyDown = (e: KeyboardEvent) => {
@@ -714,28 +724,32 @@ const ReviewApp: React.FC = () => {
714724
</header>
715725

716726
{/* Main content */}
717-
<div className="flex-1 flex overflow-hidden">
727+
<div className={`flex-1 flex overflow-hidden ${isResizing ? 'select-none' : ''}`}>
718728
{/* File tree sidebar - show when multiple files OR diff options available */}
719729
{(files.length > 1 || gitContext?.diffOptions) && (
720-
<FileTree
721-
files={files}
722-
activeFileIndex={activeFileIndex}
723-
onSelectFile={handleFileSwitch}
724-
annotations={annotations}
725-
viewedFiles={viewedFiles}
726-
onToggleViewed={handleToggleViewed}
727-
hideViewedFiles={hideViewedFiles}
728-
onToggleHideViewed={() => setHideViewedFiles(prev => !prev)}
729-
enableKeyboardNav={!showExportModal}
730-
diffOptions={gitContext?.diffOptions}
731-
activeDiffType={diffType}
732-
onSelectDiff={handleDiffSwitch}
733-
isLoadingDiff={isLoadingDiff}
734-
/>
730+
<>
731+
<FileTree
732+
files={files}
733+
activeFileIndex={activeFileIndex}
734+
onSelectFile={handleFileSwitch}
735+
annotations={annotations}
736+
viewedFiles={viewedFiles}
737+
onToggleViewed={handleToggleViewed}
738+
hideViewedFiles={hideViewedFiles}
739+
onToggleHideViewed={() => setHideViewedFiles(prev => !prev)}
740+
enableKeyboardNav={!showExportModal}
741+
diffOptions={gitContext?.diffOptions}
742+
activeDiffType={diffType}
743+
onSelectDiff={handleDiffSwitch}
744+
isLoadingDiff={isLoadingDiff}
745+
width={fileTreeResize.width}
746+
/>
747+
<ResizeHandle {...fileTreeResize.handleProps} />
748+
</>
735749
)}
736750

737751
{/* Diff viewer */}
738-
<main className="flex-1 overflow-hidden">
752+
<main className="flex-1 min-w-0 overflow-hidden">
739753
{activeFile ? (
740754
<DiffViewer
741755
patch={activeFile.patch}
@@ -779,6 +793,9 @@ const ReviewApp: React.FC = () => {
779793
)}
780794
</main>
781795

796+
{/* Resize Handle */}
797+
{isPanelOpen && <ResizeHandle {...panelResize.handleProps} />}
798+
782799
{/* Annotations panel */}
783800
<ReviewPanel
784801
isOpen={isPanelOpen}
@@ -789,6 +806,7 @@ const ReviewApp: React.FC = () => {
789806
onSelectAnnotation={handleSelectAnnotation}
790807
onDeleteAnnotation={handleDeleteAnnotation}
791808
feedbackMarkdown={feedbackMarkdown}
809+
width={panelResize.width}
792810
/>
793811
</div>
794812

packages/review-editor/components/FileTree.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface FileTreeProps {
2828
activeDiffType?: string;
2929
onSelectDiff?: (diffType: string) => void;
3030
isLoadingDiff?: boolean;
31+
width?: number;
3132
}
3233

3334
export const FileTree: React.FC<FileTreeProps> = ({
@@ -44,6 +45,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
4445
activeDiffType,
4546
onSelectDiff,
4647
isLoadingDiff,
48+
width,
4749
}) => {
4850
// Keyboard navigation: j/k or arrow keys
4951
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -82,7 +84,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
8284
};
8385

8486
return (
85-
<aside className="w-64 border-r border-border bg-card/30 flex flex-col overflow-hidden">
87+
<aside className="border-r border-border bg-card/30 flex flex-col flex-shrink-0 overflow-hidden" style={{ width: width ?? 256 }}>
8688
{/* Header */}
8789
<div className="p-3 border-b border-border/50">
8890
<div className="flex items-center justify-between">

packages/review-editor/components/ReviewPanel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface ReviewPanelProps {
1717
onSelectAnnotation: (id: string | null) => void;
1818
onDeleteAnnotation: (id: string) => void;
1919
feedbackMarkdown?: string;
20+
width?: number;
2021
}
2122

2223
function formatTimestamp(ts: number): string {
@@ -45,6 +46,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
4546
onSelectAnnotation,
4647
onDeleteAnnotation,
4748
feedbackMarkdown,
49+
width,
4850
}) => {
4951
const [copied, setCopied] = useState(false);
5052

@@ -76,7 +78,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
7678
if (!isOpen) return null;
7779

7880
return (
79-
<aside className="w-72 border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col">
81+
<aside className="border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col flex-shrink-0" style={{ width: width ?? 288 }}>
8082
{/* Header */}
8183
<div className="p-3 border-b border-border/50">
8284
<div className="flex items-center justify-between">

packages/ui/components/AnnotationPanel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface PanelProps {
1313
selectedId: string | null;
1414
shareUrl?: string;
1515
sharingEnabled?: boolean;
16+
width?: number;
1617
}
1718

1819
export const AnnotationPanel: React.FC<PanelProps> = ({
@@ -24,7 +25,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
2425
onEdit,
2526
selectedId,
2627
shareUrl,
27-
sharingEnabled = true
28+
sharingEnabled = true,
29+
width,
2830
}) => {
2931
const [copied, setCopied] = useState(false);
3032
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
@@ -43,7 +45,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
4345
if (!isOpen) return null;
4446

4547
return (
46-
<aside className="w-72 border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col">
48+
<aside className="border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col flex-shrink-0" style={{ width: width ?? 288 }}>
4749
{/* Header */}
4850
<div className="p-3 border-b border-border/50">
4951
<div className="flex items-center justify-between">
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react';
2+
import type { ResizeHandleProps as BaseProps } from '../hooks/useResizablePanel';
3+
4+
interface Props extends BaseProps {
5+
className?: string;
6+
}
7+
8+
export const ResizeHandle: React.FC<Props> = ({
9+
isDragging,
10+
onMouseDown,
11+
onDoubleClick,
12+
className,
13+
}) => (
14+
<div
15+
onMouseDown={onMouseDown}
16+
onDoubleClick={onDoubleClick}
17+
className={`w-1 cursor-col-resize flex-shrink-0 transition-colors ${
18+
isDragging ? 'bg-primary/50' : 'hover:bg-border'
19+
}${className ? ` ${className}` : ''}`}
20+
/>
21+
);

packages/ui/components/TableOfContents.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface TableOfContentsProps {
1212
activeId: string | null;
1313
onNavigate: (blockId: string) => void;
1414
className?: string;
15+
style?: React.CSSProperties;
1516
}
1617

1718
interface TocItemProps {
@@ -142,6 +143,7 @@ export function TableOfContents({
142143
activeId,
143144
onNavigate,
144145
className = '',
146+
style,
145147
}: TableOfContentsProps) {
146148
// Calculate annotation counts per section
147149
const annotationCounts = useMemo(
@@ -192,6 +194,7 @@ export function TableOfContents({
192194
<nav
193195
className={`bg-card/50 backdrop-blur-sm border-r border-border overflow-y-auto ${className}`}
194196
aria-label="Table of contents"
197+
style={style}
195198
>
196199
<div className="p-4">
197200
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState, useRef, useCallback, useEffect } from 'react';
2+
import { storage } from '../utils/storage';
3+
4+
interface UseResizablePanelOptions {
5+
storageKey: string;
6+
defaultWidth?: number;
7+
minWidth?: number;
8+
maxWidth?: number;
9+
side?: 'left' | 'right';
10+
}
11+
12+
export interface ResizeHandleProps {
13+
isDragging: boolean;
14+
onMouseDown: (e: React.MouseEvent) => void;
15+
onDoubleClick: () => void;
16+
}
17+
18+
export function useResizablePanel({
19+
storageKey,
20+
defaultWidth = 288,
21+
minWidth = 200,
22+
maxWidth = 600,
23+
side = 'right',
24+
}: UseResizablePanelOptions) {
25+
const [width, setWidth] = useState(() => {
26+
const saved = storage.getItem(storageKey);
27+
if (saved) {
28+
const n = Number(saved);
29+
if (!Number.isNaN(n) && n >= minWidth && n <= maxWidth) return n;
30+
}
31+
return defaultWidth;
32+
});
33+
34+
const [isDragging, setIsDragging] = useState(false);
35+
const startXRef = useRef(0);
36+
const startWidthRef = useRef(0);
37+
const widthRef = useRef(width);
38+
39+
const updateWidth = useCallback((value: number) => {
40+
widthRef.current = value;
41+
setWidth(value);
42+
}, []);
43+
44+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
45+
e.preventDefault();
46+
startXRef.current = e.clientX;
47+
startWidthRef.current = widthRef.current;
48+
setIsDragging(true);
49+
}, []);
50+
51+
useEffect(() => {
52+
if (!isDragging) return;
53+
54+
const onMove = (e: MouseEvent) => {
55+
const delta = side === 'right'
56+
? startXRef.current - e.clientX
57+
: e.clientX - startXRef.current;
58+
updateWidth(Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + delta)));
59+
};
60+
61+
const onUp = () => {
62+
setIsDragging(false);
63+
storage.setItem(storageKey, String(widthRef.current));
64+
};
65+
66+
document.addEventListener('mousemove', onMove);
67+
document.addEventListener('mouseup', onUp);
68+
return () => {
69+
document.removeEventListener('mousemove', onMove);
70+
document.removeEventListener('mouseup', onUp);
71+
};
72+
}, [isDragging, minWidth, maxWidth, storageKey, side, updateWidth]);
73+
74+
const resetWidth = useCallback(() => {
75+
updateWidth(defaultWidth);
76+
storage.setItem(storageKey, String(defaultWidth));
77+
}, [defaultWidth, storageKey, updateWidth]);
78+
79+
return {
80+
width,
81+
isDragging,
82+
handleProps: { isDragging, onMouseDown: handleMouseDown, onDoubleClick: resetWidth } as ResizeHandleProps,
83+
};
84+
}

0 commit comments

Comments
 (0)