mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user