Restore embedded inline comments in Plan/File/Diff views (#456)
* fix(plan-comments): restore embedded inline comment widgets in Plan view Reinstate CodeMirror block-widget comments so plan annotations stay anchored to selected lines and preserve drag/selection behavior without floating overlays. * fix(file-comments): return Files editor comments to embedded widgets Use inline block widgets for file drafts while keeping full-path draft scoping, so similarly named files no longer risk comment collisions. * fix(diff-comments): render inline comments through annotation portals Replace absolute floating positioning with annotation-target portals to keep diff comments attached to their lines across shadow DOM updates. * chore(comments): remove deprecated floating comment hook Drop the unused floating-comment implementation now that plan, file, and diff views all use embedded comment rendering paths. * fix(codemirror): expose gutter width as CSS variable Measure the current CodeMirror gutter and publish --oc-editor-gutter-width on the editor host so inline widgets can size to the visible code area without hardcoded dimensions. * fix(context-panel): publish panel width for embedded widgets Set --oc-context-panel-width on the context panel in both docked and expanded modes so comment widgets can follow the active panel width dynamically. * fix(file-comments): constrain inline input to editor content width Use context-panel and editor-gutter CSS variables to cap comment input width to the visible editor content area, keeping action buttons fully visible in no-wrap mode. * fix(file-comments): constrain inline comment cards to content area Apply the same variable-based width cap to saved comment cards so card actions stay visible when long lines force horizontal scrolling. * fix(diff-comments): stabilize new comment annotation identity Derive new-comment annotation ids from selection side and line range, and reuse that id for portal target lookup and keys to avoid remount glitches.
This commit is contained in:
@@ -36,7 +36,7 @@ export function InlineCommentCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border shadow-sm w-full overflow-hidden transition-all duration-200",
|
"rounded-lg border shadow-sm w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function InlineCommentInput({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border shadow-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
"rounded-lg border shadow-sm w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import type { EditorView } from '@codemirror/view';
|
|
||||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
|
||||||
import { InlineCommentCard } from './InlineCommentCard';
|
|
||||||
import { InlineCommentInput } from './InlineCommentInput';
|
|
||||||
|
|
||||||
type SelectedLineRange = { start: number; end: number };
|
|
||||||
|
|
||||||
type CommentPos = {
|
|
||||||
top: number;
|
|
||||||
flipUp: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const COMMENT_POPOVER_HEIGHT = 200;
|
|
||||||
|
|
||||||
function getLineTop(view: EditorView, wrapper: HTMLElement, lineNumber: number, position: 'top' | 'bottom'): number | undefined {
|
|
||||||
const lineCount = view.state.doc.lines;
|
|
||||||
if (lineNumber < 1 || lineNumber > lineCount) return undefined;
|
|
||||||
|
|
||||||
const line = view.state.doc.line(lineNumber);
|
|
||||||
const coords = view.coordsAtPos(line.from);
|
|
||||||
if (!coords) return undefined;
|
|
||||||
|
|
||||||
const wrapperRect = wrapper.getBoundingClientRect();
|
|
||||||
if (position === 'bottom') {
|
|
||||||
return coords.bottom - wrapperRect.top;
|
|
||||||
}
|
|
||||||
return coords.top - wrapperRect.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFlipUp(view: EditorView, endLine: number, scrollContainer: HTMLElement | null): boolean {
|
|
||||||
const lineCount = view.state.doc.lines;
|
|
||||||
if (endLine < 1 || endLine > lineCount) return false;
|
|
||||||
|
|
||||||
const line = view.state.doc.line(endLine);
|
|
||||||
const coords = view.coordsAtPos(line.from);
|
|
||||||
if (!coords) return false;
|
|
||||||
|
|
||||||
const viewportBottom = scrollContainer
|
|
||||||
? scrollContainer.getBoundingClientRect().bottom
|
|
||||||
: window.innerHeight;
|
|
||||||
|
|
||||||
return (coords.bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePosition(
|
|
||||||
view: EditorView,
|
|
||||||
wrapper: HTMLElement,
|
|
||||||
scrollContainer: HTMLElement | null,
|
|
||||||
range: { start: number; end: number },
|
|
||||||
): CommentPos | undefined {
|
|
||||||
const flipUp = shouldFlipUp(view, range.end, scrollContainer);
|
|
||||||
|
|
||||||
const top = flipUp
|
|
||||||
? getLineTop(view, wrapper, range.start, 'top')
|
|
||||||
: getLineTop(view, wrapper, range.end, 'bottom');
|
|
||||||
|
|
||||||
if (top === undefined) return undefined;
|
|
||||||
return { top, flipUp };
|
|
||||||
}
|
|
||||||
|
|
||||||
type FloatingCommentsProps = {
|
|
||||||
editorView: EditorView | null;
|
|
||||||
wrapperRef: React.RefObject<HTMLElement | null>;
|
|
||||||
fileDrafts: InlineCommentDraft[];
|
|
||||||
editingDraftId: string | null;
|
|
||||||
commentText: string;
|
|
||||||
lineSelection: SelectedLineRange | null;
|
|
||||||
isDragging: boolean;
|
|
||||||
fileLabel: string;
|
|
||||||
onSaveComment: (text: string, range?: SelectedLineRange) => void;
|
|
||||||
onCancelComment: () => void;
|
|
||||||
onEditDraft: (draft: InlineCommentDraft) => void;
|
|
||||||
onDeleteDraft: (draft: InlineCommentDraft) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useFloatingComments({
|
|
||||||
editorView,
|
|
||||||
wrapperRef,
|
|
||||||
fileDrafts,
|
|
||||||
editingDraftId,
|
|
||||||
commentText,
|
|
||||||
lineSelection,
|
|
||||||
isDragging,
|
|
||||||
fileLabel,
|
|
||||||
onSaveComment,
|
|
||||||
onCancelComment,
|
|
||||||
onEditDraft,
|
|
||||||
onDeleteDraft,
|
|
||||||
}: FloatingCommentsProps): React.ReactNode {
|
|
||||||
const [positions, setPositions] = React.useState<Record<string, CommentPos | undefined>>({});
|
|
||||||
|
|
||||||
const updatePositions = React.useCallback(() => {
|
|
||||||
const view = editorView;
|
|
||||||
const wrapper = wrapperRef.current;
|
|
||||||
if (!view || !wrapper) return;
|
|
||||||
|
|
||||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
|
||||||
const next: Record<string, CommentPos | undefined> = {};
|
|
||||||
|
|
||||||
for (const d of fileDrafts) {
|
|
||||||
next[d.id] = computePosition(view, wrapper, scrollContainer, {
|
|
||||||
start: d.startLine,
|
|
||||||
end: d.endLine,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineSelection && !editingDraftId && !isDragging) {
|
|
||||||
next['__new__'] = computePosition(view, wrapper, scrollContainer, {
|
|
||||||
start: lineSelection.start,
|
|
||||||
end: lineSelection.end,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setPositions(next);
|
|
||||||
}, [editorView, wrapperRef, fileDrafts, editingDraftId, lineSelection, isDragging]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
requestAnimationFrame(updatePositions);
|
|
||||||
}, [updatePositions]);
|
|
||||||
|
|
||||||
// Also update on scroll
|
|
||||||
React.useEffect(() => {
|
|
||||||
const wrapper = wrapperRef.current;
|
|
||||||
if (!wrapper) return;
|
|
||||||
|
|
||||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
|
||||||
if (!scrollContainer) return;
|
|
||||||
|
|
||||||
const onScroll = () => requestAnimationFrame(updatePositions);
|
|
||||||
scrollContainer.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => scrollContainer.removeEventListener('scroll', onScroll);
|
|
||||||
}, [wrapperRef, updatePositions]);
|
|
||||||
|
|
||||||
const popoverStyle = (flipUp: boolean): React.CSSProperties => flipUp
|
|
||||||
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
|
||||||
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{fileDrafts.map((d) => {
|
|
||||||
const pos = positions[d.id];
|
|
||||||
if (!pos) return null;
|
|
||||||
|
|
||||||
if (d.id === editingDraftId) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`edit-${d.id}`}
|
|
||||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
|
|
||||||
>
|
|
||||||
<div style={popoverStyle(pos.flipUp)}>
|
|
||||||
<InlineCommentInput
|
|
||||||
initialText={commentText}
|
|
||||||
fileLabel={fileLabel}
|
|
||||||
lineRange={{ start: d.startLine, end: d.endLine }}
|
|
||||||
isEditing={true}
|
|
||||||
onSave={onSaveComment}
|
|
||||||
onCancel={onCancelComment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`saved-${d.id}`}
|
|
||||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
|
|
||||||
>
|
|
||||||
<InlineCommentCard
|
|
||||||
draft={d}
|
|
||||||
onEdit={() => onEditDraft(d)}
|
|
||||||
onDelete={() => onDeleteDraft(d)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{lineSelection && !editingDraftId && !isDragging && positions['__new__'] && (
|
|
||||||
<div
|
|
||||||
key="new-comment"
|
|
||||||
style={{ position: 'absolute', right: 24, top: positions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
|
|
||||||
>
|
|
||||||
<div style={popoverStyle(positions['__new__'].flipUp)}>
|
|
||||||
<InlineCommentInput
|
|
||||||
initialText={commentText}
|
|
||||||
fileLabel={fileLabel}
|
|
||||||
lineRange={lineSelection}
|
|
||||||
isEditing={false}
|
|
||||||
onSave={onSaveComment}
|
|
||||||
onCancel={onCancelComment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -194,6 +194,17 @@ export const ContextPanel: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const panelStyle: React.CSSProperties = isExpanded
|
||||||
|
? {
|
||||||
|
['--oc-context-panel-width' as string]: '100vw',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: `${width}px`,
|
||||||
|
minWidth: `${width}px`,
|
||||||
|
maxWidth: `${width}px`,
|
||||||
|
['--oc-context-panel-width' as string]: `${width}px`,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@@ -207,13 +218,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
|
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
|
||||||
)}
|
)}
|
||||||
onKeyDownCapture={handlePanelKeyDownCapture}
|
onKeyDownCapture={handlePanelKeyDownCapture}
|
||||||
style={isExpanded
|
style={panelStyle}
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
width: `${width}px`,
|
|
||||||
minWidth: `${width}px`,
|
|
||||||
maxWidth: `${width}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -165,6 +165,18 @@ export function CodeMirrorEditor({
|
|||||||
// Scoped map for widget containers to avoid global collisions and memory leaks
|
// Scoped map for widget containers to avoid global collisions and memory leaks
|
||||||
const widgetContainersRef = React.useRef(new Map<string, HTMLElement>());
|
const widgetContainersRef = React.useRef(new Map<string, HTMLElement>());
|
||||||
|
|
||||||
|
const syncEditorCssVars = React.useCallback((view?: EditorView | null) => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
const resolvedView = view ?? viewRef.current;
|
||||||
|
if (!host || !resolvedView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gutters = resolvedView.dom.querySelector('.cm-gutters');
|
||||||
|
const gutterWidth = gutters instanceof HTMLElement ? gutters.getBoundingClientRect().width : 0;
|
||||||
|
host.style.setProperty('--oc-editor-gutter-width', `${gutterWidth}px`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@@ -201,6 +213,7 @@ export function CodeMirrorEditor({
|
|||||||
indentUnit.of(' '),
|
indentUnit.of(' '),
|
||||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
syncEditorCssVars(update.view);
|
||||||
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
|
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}
|
}
|
||||||
@@ -226,6 +239,7 @@ export function CodeMirrorEditor({
|
|||||||
|
|
||||||
forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
|
forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
|
||||||
viewRef.current.requestMeasure();
|
viewRef.current.requestMeasure();
|
||||||
|
requestAnimationFrame(() => syncEditorCssVars(viewRef.current));
|
||||||
|
|
||||||
if (viewRef.current) {
|
if (viewRef.current) {
|
||||||
onViewReadyRef.current?.(viewRef.current);
|
onViewReadyRef.current?.(viewRef.current);
|
||||||
@@ -258,11 +272,12 @@ export function CodeMirrorEditor({
|
|||||||
|
|
||||||
forceParsingCompat(view, view.state.doc.length, 200);
|
forceParsingCompat(view, view.state.doc.length, 200);
|
||||||
view.requestMeasure();
|
view.requestMeasure();
|
||||||
|
requestAnimationFrame(() => syncEditorCssVars(view));
|
||||||
|
|
||||||
// Force a re-render to ensure Portals can find the new widget containers in the DOM
|
// Force a re-render to ensure Portals can find the new widget containers in the DOM
|
||||||
// The containers are created synchronously by CodeMirror during dispatch -> toDOM
|
// The containers are created synchronously by CodeMirror during dispatch -> toDOM
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]);
|
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch, syncEditorCssVars]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const view = viewRef.current;
|
const view = viewRef.current;
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { useGitStatus } from '@/stores/useGitStore';
|
import { useGitStatus } from '@/stores/useGitStore';
|
||||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { useFloatingComments } from '@/components/comments/useFloatingComments';
|
import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
|
||||||
|
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
@@ -1798,23 +1799,65 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path);
|
return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path);
|
||||||
}, [selectedFile, currentSessionId, allDrafts]);
|
}, [selectedFile, currentSessionId, allDrafts]);
|
||||||
|
|
||||||
const floatingComments = useFloatingComments({
|
const blockWidgets = React.useMemo(() => {
|
||||||
editorView: editorViewRef.current,
|
const widgets: import('@/components/ui/CodeMirrorEditor').BlockWidgetDef[] = [];
|
||||||
wrapperRef: editorWrapperRef,
|
|
||||||
fileDrafts: filesFileDrafts,
|
for (const draft of filesFileDrafts) {
|
||||||
editingDraftId,
|
if (draft.id === editingDraftId) {
|
||||||
commentText: '',
|
widgets.push({
|
||||||
lineSelection,
|
afterLine: draft.endLine,
|
||||||
isDragging,
|
id: `edit-${draft.id}`,
|
||||||
fileLabel: selectedFile?.path ?? '',
|
content: (
|
||||||
onSaveComment: handleSaveComment,
|
<InlineCommentInput
|
||||||
onCancelComment: () => setLineSelection(null),
|
key={draft.id}
|
||||||
onEditDraft: (draft) => {
|
initialText={draft.text}
|
||||||
setEditingDraftId(draft.id);
|
fileLabel={selectedFile?.path}
|
||||||
setLineSelection(null);
|
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
||||||
},
|
isEditing={true}
|
||||||
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
|
onSave={handleSaveComment}
|
||||||
});
|
onCancel={() => setEditingDraftId(null)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
widgets.push({
|
||||||
|
afterLine: draft.endLine,
|
||||||
|
id: `card-${draft.id}`,
|
||||||
|
content: (
|
||||||
|
<InlineCommentCard
|
||||||
|
key={draft.id}
|
||||||
|
draft={draft}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingDraftId(draft.id);
|
||||||
|
setLineSelection(null);
|
||||||
|
}}
|
||||||
|
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineSelection && !editingDraftId && !isDragging) {
|
||||||
|
widgets.push({
|
||||||
|
afterLine: lineSelection.end,
|
||||||
|
id: 'files-new-comment-input',
|
||||||
|
content: (
|
||||||
|
<InlineCommentInput
|
||||||
|
key="new-comment"
|
||||||
|
initialText=""
|
||||||
|
fileLabel={selectedFile?.path}
|
||||||
|
lineRange={lineSelection}
|
||||||
|
isEditing={false}
|
||||||
|
onSave={handleSaveComment}
|
||||||
|
onCancel={() => setLineSelection(null)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}, [filesFileDrafts, editingDraftId, lineSelection, isDragging, selectedFile?.path, handleSaveComment, removeDraft]);
|
||||||
|
|
||||||
const fileViewer = (
|
const fileViewer = (
|
||||||
<div
|
<div
|
||||||
@@ -2184,6 +2227,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
onChange={setDraftContent}
|
onChange={setDraftContent}
|
||||||
extensions={editorExtensions}
|
extensions={editorExtensions}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
blockWidgets={blockWidgets}
|
||||||
onViewReady={(view) => {
|
onViewReady={(view) => {
|
||||||
editorViewRef.current = view;
|
editorViewRef.current = view;
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
@@ -2269,7 +2313,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{floatingComments}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||||
// createPortal no longer needed — comments float absolutely outside shadow DOM
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
FileDiff as PierreFileDiff,
|
FileDiff as PierreFileDiff,
|
||||||
VirtualizedFileDiff,
|
VirtualizedFileDiff,
|
||||||
@@ -275,115 +275,48 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
if (meta.type === 'saved' || meta.type === 'edit') {
|
if (meta.type === 'saved' || meta.type === 'edit') {
|
||||||
return `draft-${meta.draft.id}`;
|
return `draft-${meta.draft.id}`;
|
||||||
} else if (meta.type === 'new') {
|
} else if (meta.type === 'new') {
|
||||||
return 'new-comment-input';
|
const start = Math.min(meta.selection.start, meta.selection.end);
|
||||||
|
const end = Math.max(meta.selection.start, meta.selection.end);
|
||||||
|
const side = meta.selection.side ?? 'additions';
|
||||||
|
return `new-comment-${side}-${start}-${end}`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
|
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
// Invisible — comments are rendered as floating elements outside shadow DOM
|
div.style.position = 'relative';
|
||||||
div.style.display = 'none';
|
|
||||||
|
|
||||||
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
|
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
|
||||||
const id = getAnnotationId(meta);
|
const id = getAnnotationId(meta);
|
||||||
|
|
||||||
div.dataset.annotationId = id;
|
div.dataset.annotationId = id;
|
||||||
|
div.dataset.annotationSide = annotation.side;
|
||||||
|
div.dataset.annotationLine = String(annotation.lineNumber);
|
||||||
return div;
|
return div;
|
||||||
}, [getAnnotationId]);
|
}, [getAnnotationId]);
|
||||||
|
|
||||||
// Compute floating comment positions by finding target lines in Pierre's shadow DOM
|
const annotationTargetsRef = useRef<Record<string, HTMLElement | null>>({});
|
||||||
const findLineElement = useCallback((root: ShadowRoot, line: number, side?: string) => {
|
|
||||||
const nodes = Array.from(
|
|
||||||
root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)
|
|
||||||
).filter((n): n is HTMLElement => n instanceof HTMLElement);
|
|
||||||
if (nodes.length === 0) return undefined;
|
|
||||||
if (!side) return nodes[0];
|
|
||||||
const match = nodes.find((n) => {
|
|
||||||
const lineType = n.closest('[data-line-type]')?.getAttribute('data-line-type') ?? n.getAttribute('data-line-type');
|
|
||||||
if (side === 'deletions') return lineType === 'change-deletion';
|
|
||||||
return lineType !== 'change-deletion';
|
|
||||||
});
|
|
||||||
return match ?? nodes[0];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getAnchorPositions = useCallback((wrapper: HTMLElement, root: ShadowRoot, range: { start: number; end: number; side?: string }) => {
|
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
|
||||||
const wrapperRect = wrapper.getBoundingClientRect();
|
|
||||||
const first = findLineElement(root, range.start, range.side);
|
|
||||||
const last = findLineElement(root, range.end, range.side);
|
|
||||||
|
|
||||||
// Bottom of last line (for below placement)
|
|
||||||
const lastEl = last ?? first;
|
|
||||||
const bottomTop = lastEl
|
|
||||||
? lastEl.getBoundingClientRect().top - wrapperRect.top + lastEl.getBoundingClientRect().height
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Top of first line (for above placement)
|
|
||||||
const firstEl = first ?? last;
|
|
||||||
const aboveTop = firstEl
|
|
||||||
? firstEl.getBoundingClientRect().top - wrapperRect.top
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { bottomTop, aboveTop };
|
|
||||||
}, [findLineElement]);
|
|
||||||
|
|
||||||
const [commentPositions, setCommentPositions] = useState<Record<string, { top: number; flipUp: boolean } | undefined>>({});
|
|
||||||
type CommentPos = { top: number; flipUp: boolean };
|
|
||||||
|
|
||||||
const COMMENT_POPOVER_HEIGHT = 200; // approximate height of comment popover
|
|
||||||
|
|
||||||
const updateCommentPositions = useCallback(() => {
|
|
||||||
const wrapper = diffRootRef.current;
|
const wrapper = diffRootRef.current;
|
||||||
if (!wrapper) return;
|
if (!wrapper) return null;
|
||||||
|
|
||||||
const host = wrapper.querySelector('diffs-container') ?? diffContainerRef.current?.querySelector('diffs-container');
|
const cached = annotationTargetsRef.current[id];
|
||||||
const shadow = (host as HTMLElement | null)?.shadowRoot;
|
if (cached && wrapper.contains(cached)) {
|
||||||
if (!shadow) return;
|
return cached;
|
||||||
|
|
||||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
|
||||||
const viewportBottom = scrollContainer
|
|
||||||
? scrollContainer.getBoundingClientRect().bottom
|
|
||||||
: window.innerHeight;
|
|
||||||
|
|
||||||
const computePos = (range: { start: number; end: number; side?: string }): CommentPos | undefined => {
|
|
||||||
const anchors = getAnchorPositions(wrapper, shadow, range);
|
|
||||||
if (anchors.bottomTop === undefined) return undefined;
|
|
||||||
|
|
||||||
// Check if placing below last line would overflow viewport
|
|
||||||
const lastEl = findLineElement(shadow, range.end, range.side) ?? findLineElement(shadow, range.start, range.side);
|
|
||||||
const flipUp = lastEl
|
|
||||||
? (lastEl.getBoundingClientRect().bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom
|
|
||||||
: false;
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: flipUp ? (anchors.aboveTop ?? anchors.bottomTop) : anchors.bottomTop,
|
|
||||||
flipUp,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const next: Record<string, CommentPos | undefined> = {};
|
|
||||||
const sessionKey = getSessionKey();
|
|
||||||
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
|
|
||||||
const fileLabel = fileName || 'unknown';
|
|
||||||
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
|
||||||
|
|
||||||
for (const d of fileDrafts) {
|
|
||||||
const side = d.side === 'original' ? 'deletions' : 'additions';
|
|
||||||
next[d.id] = computePos({ start: d.startLine, end: d.endLine, side });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection && !editingDraftId) {
|
const host = wrapper.querySelector('diffs-container');
|
||||||
const side = selection.side ?? 'additions';
|
if (!host) return null;
|
||||||
next['__new__'] = computePos({ start: selection.start, end: selection.end, side });
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommentPositions(next);
|
const shadowRoot = host.shadowRoot;
|
||||||
}, [allDrafts, editingDraftId, fileName, findLineElement, getAnchorPositions, getSessionKey, selection]);
|
if (!shadowRoot) return null;
|
||||||
|
|
||||||
const updateCommentPositionsRef = useRef(updateCommentPositions);
|
const target = shadowRoot.querySelector(`[data-annotation-id="${id}"]`) as HTMLElement | null;
|
||||||
useEffect(() => {
|
annotationTargetsRef.current[id] = target;
|
||||||
updateCommentPositionsRef.current = updateCommentPositions;
|
return target;
|
||||||
}, [updateCommentPositions]);
|
}, []);
|
||||||
|
|
||||||
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
|
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
|
||||||
// Use provided range override or fall back to current selection
|
// Use provided range override or fall back to current selection
|
||||||
@@ -667,10 +600,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
containerWrapper: container,
|
containerWrapper: container,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update floating comment positions after Pierre renders
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
updateCommentPositionsRef.current();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -699,9 +630,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
void err;
|
void err;
|
||||||
}
|
}
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
updateCommentPositions();
|
|
||||||
});
|
});
|
||||||
}, [lineAnnotations, updateCommentPositions]);
|
}, [lineAnnotations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const instance = diffInstanceRef.current;
|
const instance = diffInstanceRef.current;
|
||||||
@@ -788,10 +718,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [diffThemeKey, fileName, handleSelectionChange]);
|
}, [diffThemeKey, fileName, handleSelectionChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(updateCommentPositions);
|
|
||||||
}, [selection, editingDraftId, allDrafts, updateCommentPositions]);
|
|
||||||
|
|
||||||
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
|
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = diffContainerRef.current;
|
const container = diffContainerRef.current;
|
||||||
@@ -804,35 +730,20 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const diffsContainer = container.querySelector('diffs-container');
|
const diffsContainer = container.querySelector('diffs-container');
|
||||||
if (!diffsContainer) return;
|
if (!diffsContainer) return;
|
||||||
|
|
||||||
// Watch for annotation nodes being added/removed
|
const shadowRoot = diffsContainer.shadowRoot;
|
||||||
observer = new MutationObserver((mutations) => {
|
if (!shadowRoot) return;
|
||||||
const hasAnnotationChanges = mutations.some(m =>
|
|
||||||
Array.from(m.addedNodes).some(n =>
|
|
||||||
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
|
|
||||||
) ||
|
|
||||||
Array.from(m.removedNodes).some(n =>
|
|
||||||
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasAnnotationChanges) {
|
observer = new MutationObserver(() => {
|
||||||
// Debounce with RAF to batch multiple mutations
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
rafId = requestAnimationFrame(() => {
|
||||||
rafId = requestAnimationFrame(() => {
|
forceUpdate();
|
||||||
forceUpdate();
|
rafId = null;
|
||||||
rafId = null;
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Observe both shadow root and light DOM
|
observer.observe(shadowRoot, { childList: true, subtree: true });
|
||||||
if (diffsContainer.shadowRoot) {
|
|
||||||
observer.observe(diffsContainer.shadowRoot, { childList: true, subtree: true });
|
|
||||||
}
|
|
||||||
observer.observe(diffsContainer, { childList: true, subtree: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delay setup to allow Pierre to initialize
|
|
||||||
const timeoutId = setTimeout(setupObserver, 100);
|
const timeoutId = setTimeout(setupObserver, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -840,91 +751,76 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
if (rafId) cancelAnimationFrame(rafId);
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
};
|
};
|
||||||
}, [diffThemeKey, fileName]); // Re-setup when diff changes
|
}, [diffThemeKey, fileName]);
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating comment elements positioned absolutely over the diff
|
|
||||||
const sessionKey = getSessionKey();
|
const sessionKey = getSessionKey();
|
||||||
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
|
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
|
||||||
const fileLabel = fileName || 'unknown';
|
const fileLabel = fileName || 'unknown';
|
||||||
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
||||||
|
|
||||||
const floatingComments = (
|
const commentPortals = (
|
||||||
<>
|
<>
|
||||||
{fileDrafts.map((d) => {
|
{fileDrafts.map((d) => {
|
||||||
const pos = commentPositions[d.id];
|
const target = resolveAnnotationTarget(`draft-${d.id}`);
|
||||||
if (!pos) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
const popoverStyle: React.CSSProperties = pos.flipUp
|
|
||||||
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
|
||||||
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
|
|
||||||
|
|
||||||
if (d.id === editingDraftId) {
|
if (d.id === editingDraftId) {
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
|
||||||
key={`edit-${d.id}`}
|
|
||||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
|
|
||||||
>
|
|
||||||
<div style={popoverStyle}>
|
|
||||||
<InlineCommentInput
|
|
||||||
initialText={commentText}
|
|
||||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
|
||||||
lineRange={{
|
|
||||||
start: d.startLine,
|
|
||||||
end: d.endLine,
|
|
||||||
side: d.side === 'original' ? 'deletions' : 'additions'
|
|
||||||
}}
|
|
||||||
isEditing={true}
|
|
||||||
onSave={handleSaveComment}
|
|
||||||
onCancel={handleCancelComment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`saved-${d.id}`}
|
|
||||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
|
|
||||||
>
|
|
||||||
<InlineCommentCard
|
|
||||||
draft={d}
|
|
||||||
onEdit={() => {
|
|
||||||
const side = d.side === 'original' ? 'deletions' : 'additions';
|
|
||||||
applySelection({ start: d.startLine, end: d.endLine, side });
|
|
||||||
setCommentText(d.text);
|
|
||||||
setEditingDraftId(d.id);
|
|
||||||
}}
|
|
||||||
onDelete={() => removeDraft(d.sessionKey, d.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{selection && !editingDraftId && commentPositions['__new__'] && (
|
|
||||||
<div
|
|
||||||
key="new-comment"
|
|
||||||
style={{ position: 'absolute', right: 24, top: commentPositions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
|
|
||||||
>
|
|
||||||
<div style={commentPositions['__new__'].flipUp
|
|
||||||
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
|
||||||
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
|
|
||||||
}>
|
|
||||||
<InlineCommentInput
|
<InlineCommentInput
|
||||||
initialText={commentText}
|
initialText={commentText}
|
||||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||||
lineRange={selection || undefined}
|
lineRange={{
|
||||||
isEditing={false}
|
start: d.startLine,
|
||||||
|
end: d.endLine,
|
||||||
|
side: d.side === 'original' ? 'deletions' : 'additions'
|
||||||
|
}}
|
||||||
|
isEditing={true}
|
||||||
onSave={handleSaveComment}
|
onSave={handleSaveComment}
|
||||||
onCancel={handleCancelComment}
|
onCancel={handleCancelComment}
|
||||||
/>
|
/>,
|
||||||
</div>
|
target,
|
||||||
</div>
|
`draft-edit-${d.id}`
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<InlineCommentCard
|
||||||
|
draft={d}
|
||||||
|
onEdit={() => {
|
||||||
|
const side = d.side === 'original' ? 'deletions' : 'additions';
|
||||||
|
applySelection({ start: d.startLine, end: d.endLine, side });
|
||||||
|
setCommentText(d.text);
|
||||||
|
setEditingDraftId(d.id);
|
||||||
|
}}
|
||||||
|
onDelete={() => removeDraft(d.sessionKey, d.id)}
|
||||||
|
/>,
|
||||||
|
target,
|
||||||
|
`draft-card-${d.id}`
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{selection && !editingDraftId && (() => {
|
||||||
|
const newCommentAnnotationId = getAnnotationId({ type: 'new', selection });
|
||||||
|
const target = resolveAnnotationTarget(newCommentAnnotationId);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<InlineCommentInput
|
||||||
|
initialText={commentText}
|
||||||
|
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||||
|
lineRange={selection || undefined}
|
||||||
|
isEditing={false}
|
||||||
|
onSave={handleSaveComment}
|
||||||
|
onCancel={handleCancelComment}
|
||||||
|
/>,
|
||||||
|
target,
|
||||||
|
newCommentAnnotationId
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -940,9 +836,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
>
|
>
|
||||||
<div ref={diffRootRef} className="size-full relative">
|
<div ref={diffRootRef} className="size-full relative">
|
||||||
<div ref={diffContainerRef} className="size-full" />
|
<div ref={diffContainerRef} className="size-full" />
|
||||||
{floatingComments}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
{commentPortals}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -953,8 +849,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
<div className={cn("relative", "w-full")}>
|
<div className={cn("relative", "w-full")}>
|
||||||
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
||||||
<div ref={diffContainerRef} className="w-full" />
|
<div ref={diffContainerRef} className="w-full" />
|
||||||
{floatingComments}
|
|
||||||
</div>
|
</div>
|
||||||
|
{commentPortals}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||||
import { useFloatingComments } from '@/components/comments/useFloatingComments';
|
|
||||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { InlineCommentCard } from '@/components/comments/InlineCommentCard';
|
||||||
|
import { InlineCommentInput } from '@/components/comments/InlineCommentInput';
|
||||||
|
|
||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
@@ -389,24 +390,55 @@ export const PlanView: React.FC = () => {
|
|||||||
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
|
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
|
||||||
}, [getSessionKey, allDrafts, planFileLabel]);
|
}, [getSessionKey, allDrafts, planFileLabel]);
|
||||||
|
|
||||||
const floatingComments = useFloatingComments({
|
const blockWidgets = React.useMemo(() => {
|
||||||
editorView: editorViewRef.current,
|
const widgets: Array<{ afterLine: number; id: string; content: React.ReactNode }> = [];
|
||||||
wrapperRef: editorWrapperRef,
|
|
||||||
fileDrafts: planFileDrafts,
|
for (const draft of planFileDrafts) {
|
||||||
editingDraftId,
|
const content = draft.id === editingDraftId ? (
|
||||||
commentText,
|
<InlineCommentInput
|
||||||
lineSelection,
|
key={`edit-${draft.id}`}
|
||||||
isDragging,
|
initialText={commentText}
|
||||||
fileLabel: planFileLabel,
|
fileLabel={draft.fileLabel}
|
||||||
onSaveComment: handleSaveComment,
|
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
||||||
onCancelComment: handleCancelComment,
|
isEditing={true}
|
||||||
onEditDraft: (draft) => {
|
onSave={handleSaveComment}
|
||||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
onCancel={handleCancelComment}
|
||||||
setCommentText(draft.text);
|
/>
|
||||||
setEditingDraftId(draft.id);
|
) : (
|
||||||
},
|
<InlineCommentCard
|
||||||
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
|
key={`saved-${draft.id}`}
|
||||||
});
|
draft={draft}
|
||||||
|
onEdit={() => {
|
||||||
|
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||||
|
setCommentText(draft.text);
|
||||||
|
setEditingDraftId(draft.id);
|
||||||
|
}}
|
||||||
|
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
widgets.push({ afterLine: draft.endLine, id: draft.id, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineSelection && !editingDraftId && !isDragging) {
|
||||||
|
widgets.push({
|
||||||
|
afterLine: Math.max(lineSelection.start, lineSelection.end),
|
||||||
|
id: 'plan-new-comment-input',
|
||||||
|
content: (
|
||||||
|
<InlineCommentInput
|
||||||
|
key="new-comment"
|
||||||
|
initialText={commentText}
|
||||||
|
fileLabel={planFileLabel}
|
||||||
|
lineRange={lineSelection}
|
||||||
|
isEditing={false}
|
||||||
|
onSave={handleSaveComment}
|
||||||
|
onCancel={handleCancelComment}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}, [planFileDrafts, editingDraftId, commentText, lineSelection, isDragging, planFileLabel, handleSaveComment, handleCancelComment, removeDraft]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
|
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
|
||||||
@@ -489,103 +521,95 @@ export const PlanView: React.FC = () => {
|
|||||||
<div className="p-3 typography-ui text-muted-foreground">Loading…</div>
|
<div className="p-3 typography-ui text-muted-foreground">Loading…</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div
|
<div className="h-full oc-plan-editor">
|
||||||
className="h-full"
|
{mdViewMode === 'preview' ? (
|
||||||
style={{
|
<div className="h-full overflow-auto p-3">
|
||||||
['--oc-plan-comment-pad' as string]: '0px',
|
<ErrorBoundary
|
||||||
}}
|
fallback={
|
||||||
>
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
||||||
<div className="h-full oc-plan-editor">
|
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
||||||
{mdViewMode === 'preview' ? (
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="h-full overflow-auto p-3">
|
Switch to edit mode to fix the issue.
|
||||||
<ErrorBoundary
|
|
||||||
fallback={
|
|
||||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
|
||||||
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Switch to edit mode to fix the issue.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative h-full" ref={editorWrapperRef}>
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={content}
|
||||||
|
onChange={() => {
|
||||||
|
// read-only
|
||||||
|
}}
|
||||||
|
readOnly={true}
|
||||||
|
className="h-full"
|
||||||
|
extensions={editorExtensions}
|
||||||
|
onViewReady={(view) => { editorViewRef.current = view; }}
|
||||||
|
onViewDestroy={() => { editorViewRef.current = null; }}
|
||||||
|
blockWidgets={blockWidgets}
|
||||||
|
highlightLines={lineSelection
|
||||||
|
? {
|
||||||
|
start: Math.min(lineSelection.start, lineSelection.end),
|
||||||
|
end: Math.max(lineSelection.start, lineSelection.end),
|
||||||
}
|
}
|
||||||
>
|
: undefined}
|
||||||
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
|
lineNumbersConfig={{
|
||||||
</ErrorBoundary>
|
domEventHandlers: {
|
||||||
</div>
|
mousedown: (view, line, event) => {
|
||||||
) : (
|
if (!(event instanceof MouseEvent)) return false;
|
||||||
<div className="relative h-full" ref={editorWrapperRef}>
|
if (event.button !== 0) return false;
|
||||||
<CodeMirrorEditor
|
event.preventDefault();
|
||||||
value={content}
|
|
||||||
onChange={() => {
|
|
||||||
// read-only
|
|
||||||
}}
|
|
||||||
readOnly={true}
|
|
||||||
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
|
|
||||||
extensions={editorExtensions}
|
|
||||||
onViewReady={(view) => { editorViewRef.current = view; }}
|
|
||||||
onViewDestroy={() => { editorViewRef.current = null; }}
|
|
||||||
highlightLines={lineSelection
|
|
||||||
? {
|
|
||||||
start: Math.min(lineSelection.start, lineSelection.end),
|
|
||||||
end: Math.max(lineSelection.start, lineSelection.end),
|
|
||||||
}
|
|
||||||
: undefined}
|
|
||||||
lineNumbersConfig={{
|
|
||||||
domEventHandlers: {
|
|
||||||
mousedown: (view, line, event) => {
|
|
||||||
if (!(event instanceof MouseEvent)) return false;
|
|
||||||
if (event.button !== 0) return false;
|
|
||||||
event.preventDefault();
|
|
||||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
|
||||||
|
|
||||||
if (isMobile && lineSelection && !event.shiftKey) {
|
|
||||||
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
|
||||||
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
|
||||||
setLineSelection({ start, end });
|
|
||||||
isSelectingRef.current = false;
|
|
||||||
selectionStartRef.current = null;
|
|
||||||
// Mobile tap-extend is atomic, so we don't start drag
|
|
||||||
setIsDragging(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelectingRef.current = true;
|
|
||||||
selectionStartRef.current = lineNumber;
|
|
||||||
setIsDragging(true);
|
|
||||||
|
|
||||||
if (lineSelection && event.shiftKey) {
|
|
||||||
const start = Math.min(lineSelection.start, lineNumber);
|
|
||||||
const end = Math.max(lineSelection.end, lineNumber);
|
|
||||||
setLineSelection({ start, end });
|
|
||||||
} else {
|
|
||||||
setLineSelection({ start: lineNumber, end: lineNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
mouseover: (view, line, event) => {
|
|
||||||
if (!(event instanceof MouseEvent)) return false;
|
|
||||||
if (event.buttons !== 1) return false;
|
|
||||||
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
|
||||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
|
||||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
if (isMobile && lineSelection && !event.shiftKey) {
|
||||||
|
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
||||||
|
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
||||||
setLineSelection({ start, end });
|
setLineSelection({ start, end });
|
||||||
setIsDragging(true);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
mouseup: () => {
|
|
||||||
isSelectingRef.current = false;
|
isSelectingRef.current = false;
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
return false;
|
return true;
|
||||||
},
|
}
|
||||||
|
|
||||||
|
isSelectingRef.current = true;
|
||||||
|
selectionStartRef.current = lineNumber;
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
|
if (lineSelection && event.shiftKey) {
|
||||||
|
const start = Math.min(lineSelection.start, lineNumber);
|
||||||
|
const end = Math.max(lineSelection.end, lineNumber);
|
||||||
|
setLineSelection({ start, end });
|
||||||
|
} else {
|
||||||
|
setLineSelection({ start: lineNumber, end: lineNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
}}
|
mouseover: (view, line, event) => {
|
||||||
/>
|
if (!(event instanceof MouseEvent)) return false;
|
||||||
{floatingComments}
|
if (event.buttons !== 1) return false;
|
||||||
</div>
|
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
||||||
)}
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||||
|
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||||
|
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||||
|
setLineSelection({ start, end });
|
||||||
|
setIsDragging(true);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
mouseup: () => {
|
||||||
|
isSelectingRef.current = false;
|
||||||
|
selectionStartRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user