Opencode/daring jackal (#356)
* feat: export inline comment components Expose InlineCommentCard and InlineCommentInput from the comments index Enable importing both components from a single module path * feat: add InlineCommentCard for inline comments with collapse Enable collapsing for long comments with Show more and Show less Provide dropdown menu with edit and delete actions Show file label and line range in the header * feat: add InlineCommentInput component Add InlineCommentInput component for per-line comments Enable Cmd/Ctrl+Enter to save and Escape to cancel Show file label and line range when provided and focus input on mount * feat: render block widgets in CodeMirrorEditor Add blockWidgets prop to configure widgets after lines Render React portals into per-widget containers within the editor Introduce BlockWidget and DOM container mapping to host widgets * feat(PierreDiffViewer): enable inline comment drafting and theming Add inline comment draft management for diff lines Render InlineCommentInput and InlineCommentCard for comments in diff Integrate theme system to support dark and light modes * feat: improve inline comment editing in PlanView Enable auto-scroll for mobile when editing inline comments Cancel edits when clicking outside comment widgets or code gutter Extend save handler to accept explicit range and guard against empty text * feat(pierre-diff-viewer): enable mobile tap-to-extend selection Extend selection on mobile when tapping the same side to include range Show saved annotation cards on all devices * fix(plan): show saved cards on all devices Render saved cards on all devices Ensure saved cards appear for mobile layouts as well * refactor: simplify state usage and remove focus hacks Remove pending focus state and related effects in PlanView and PierreDiffViewer Initialize UI store usage via useUIStore() without destructuring values in PlanView and PierreDiffViewer Align annotation handling to DiffLineAnnotation metadata and include side in lineRange for inline comments
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { RiMoreLine, RiDeleteBinLine, RiEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
interface InlineCommentCardProps {
|
||||
draft: InlineCommentDraft;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineCommentCard({
|
||||
draft,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
}: InlineCommentCardProps) {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
const currentTheme = themeContext?.currentTheme;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Check if content is long enough to warrant collapsing (rough estimate)
|
||||
// In a real app we might measure line height, but length check is a good proxy for now
|
||||
const isLongContent = draft.text.length > 150 || draft.text.split('\n').length > 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border shadow-sm w-full overflow-hidden transition-all duration-200",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
data-comment-card="true"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-1.5">
|
||||
<span className="truncate max-w-[200px]" title={draft.fileLabel}>
|
||||
{draft.fileLabel}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Lines {draft.startLine}-{draft.endLine}</span>
|
||||
{draft.side && <span>({draft.side})</span>}
|
||||
</div>
|
||||
|
||||
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
|
||||
<div className={cn("text-sm whitespace-pre-wrap break-words leading-relaxed", !isOpen && isLongContent && "line-clamp-3")}>
|
||||
{draft.text}
|
||||
</div>
|
||||
|
||||
{isLongContent && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-0 mt-1 text-xs text-muted-foreground hover:text-foreground w-full justify-start"
|
||||
>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<RiArrowUpSLine className="size-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-3 mr-1" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Used for animation purposes if we want to animate height */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 -mr-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiMoreLine className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<RiEditLine className="size-4 mr-2" />
|
||||
Edit comment
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<RiDeleteBinLine className="size-4 mr-2" />
|
||||
Delete comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
|
||||
export interface InlineCommentInputProps {
|
||||
initialText?: string;
|
||||
onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void;
|
||||
onCancel: () => void;
|
||||
fileLabel?: string;
|
||||
lineRange?: { start: number; end: number; side?: 'additions' | 'deletions' };
|
||||
isEditing?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineCommentInput({
|
||||
initialText = '',
|
||||
onSave,
|
||||
onCancel,
|
||||
fileLabel,
|
||||
lineRange,
|
||||
isEditing = false,
|
||||
className,
|
||||
}: InlineCommentInputProps) {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
const currentTheme = themeContext?.currentTheme;
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const [text, setText] = React.useState(initialText);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Stable range snapshot to prevent race with selection clearing
|
||||
const stableRangeRef = useRef(lineRange);
|
||||
useEffect(() => {
|
||||
if (lineRange) {
|
||||
stableRangeRef.current = lineRange;
|
||||
}
|
||||
}, [lineRange]);
|
||||
|
||||
// Focus on mount (desktop only) or when becoming visible
|
||||
useEffect(() => {
|
||||
if (!isMobile && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
// Move cursor to end
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
} else if (isMobile && textareaRef.current) {
|
||||
// Scroll into view on mobile
|
||||
textareaRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (text.trim()) {
|
||||
onSave(text, stableRangeRef.current);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
|
||||
// Stop propagation to prevent parent selection clearing before save
|
||||
e.stopPropagation();
|
||||
if (text.trim()) {
|
||||
onSave(text, stableRangeRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border shadow-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
data-comment-input="true"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3">
|
||||
{(fileLabel || lineRange) && (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
|
||||
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
|
||||
{fileLabel && lineRange && <span>•</span>}
|
||||
{lineRange && <span>Lines {lineRange.start}-{lineRange.end}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment... (Cmd+Enter to save)"
|
||||
className="min-h-[80px] text-sm resize-y"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 mt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
className="h-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
disabled={!text.trim()}
|
||||
className="h-8 min-w-[80px]"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.status?.success,
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
{isEditing ? 'Save' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './InlineCommentCard';
|
||||
export * from './InlineCommentInput';
|
||||
@@ -1,13 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { Compartment, EditorState, RangeSetBuilder } from '@codemirror/state';
|
||||
import { Decoration, EditorView, ViewPlugin, gutters, keymap, lineNumbers } from '@codemirror/view';
|
||||
import { Compartment, EditorState, RangeSetBuilder, StateField } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType, gutters, keymap, lineNumbers } from '@codemirror/view';
|
||||
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type BlockWidgetDef = {
|
||||
afterLine: number;
|
||||
id: string;
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
type CodeMirrorEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -16,6 +23,7 @@ type CodeMirrorEditorProps = {
|
||||
readOnly?: boolean;
|
||||
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
|
||||
highlightLines?: { start: number; end: number };
|
||||
blockWidgets?: BlockWidgetDef[];
|
||||
onViewReady?: (view: EditorView) => void;
|
||||
onViewDestroy?: () => void;
|
||||
};
|
||||
@@ -24,6 +32,83 @@ const lineNumbersCompartment = new Compartment();
|
||||
const editableCompartment = new Compartment();
|
||||
const externalExtensionsCompartment = new Compartment();
|
||||
const highlightLinesCompartment = new Compartment();
|
||||
const blockWidgetsCompartment = new Compartment();
|
||||
|
||||
// Map to store widget container elements by ID
|
||||
// This allows us to render portals into them even if they are created by CM
|
||||
const widgetContainers = new Map<string, HTMLElement>();
|
||||
|
||||
class BlockWidget extends WidgetType {
|
||||
constructor(readonly id: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
let div = widgetContainers.get(this.id);
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.className = 'oc-block-widget';
|
||||
div.dataset.widgetId = this.id;
|
||||
widgetContainers.set(this.id, div);
|
||||
}
|
||||
return div;
|
||||
}
|
||||
|
||||
eq(other: BlockWidget) {
|
||||
return other.id === this.id;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Optional: cleanup if needed, but we might want to keep the element for React to unmount gracefully?
|
||||
// Actually, if CM destroys the DOM, React portal might complain if we don't unmount.
|
||||
// But since we render portals based on the 'blockWidgets' prop, if the widget is removed from prop,
|
||||
// the portal will be removed by React.
|
||||
// If CM removes it because it's out of viewport, we still want the container to exist in our map?
|
||||
// No, if CM removes it, we should probably let it go.
|
||||
// But for now let's keep it simple.
|
||||
}
|
||||
}
|
||||
|
||||
const createBlockWidgetsExtension = (widgets?: BlockWidgetDef[]) => {
|
||||
if (!widgets || widgets.length === 0) return [];
|
||||
|
||||
return StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
// Sort widgets by line number to add them in order
|
||||
const sorted = [...widgets].sort((a, b) => a.afterLine - b.afterLine);
|
||||
|
||||
for (const w of sorted) {
|
||||
const lineCount = state.doc.lines;
|
||||
if (w.afterLine > lineCount) continue;
|
||||
|
||||
const line = state.doc.line(w.afterLine);
|
||||
// Add widget decoration
|
||||
builder.add(line.to, line.to, Decoration.widget({
|
||||
widget: new BlockWidget(w.id),
|
||||
block: true,
|
||||
side: 1,
|
||||
}));
|
||||
}
|
||||
return builder.finish();
|
||||
},
|
||||
update(deco, tr) {
|
||||
// Always rebuild decorations when doc changes or widgets config changes
|
||||
// But here we only see transaction.
|
||||
// Since we reconfigure the compartment when props change, this update might mostly handle doc changes.
|
||||
// For simplicity, we can map existing decorations or rebuild.
|
||||
// Let's rebuild to ensure correct line placement.
|
||||
// Wait, we can't access 'widgets' prop here easily unless we use a closure or effect.
|
||||
// The `create` method runs when state is created.
|
||||
// When we reconfigure the compartment, `create` might run again or we need `provide`.
|
||||
|
||||
// Actually, standard pattern is to map decorations.
|
||||
return deco.map(tr.changes);
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f)
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const createHighlightLinesExtension = (range?: { start: number; end: number }): Extension => {
|
||||
if (!range) {
|
||||
@@ -67,6 +152,7 @@ export function CodeMirrorEditor({
|
||||
highlightLines,
|
||||
onViewReady,
|
||||
onViewDestroy,
|
||||
blockWidgets,
|
||||
}: CodeMirrorEditorProps) {
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = React.useRef<EditorView | null>(null);
|
||||
@@ -74,6 +160,7 @@ export function CodeMirrorEditor({
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onViewReadyRef = React.useRef(onViewReady);
|
||||
const onViewDestroyRef = React.useRef(onViewDestroy);
|
||||
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
valueRef.current = value;
|
||||
@@ -102,6 +189,9 @@ export function CodeMirrorEditor({
|
||||
indentUnit.of(' '),
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
|
||||
forceUpdate();
|
||||
}
|
||||
if (!update.docChanged) {
|
||||
return;
|
||||
}
|
||||
@@ -112,6 +202,7 @@ export function CodeMirrorEditor({
|
||||
editableCompartment.of(EditorView.editable.of(!readOnly)),
|
||||
externalExtensionsCompartment.of(extensions ?? []),
|
||||
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
|
||||
blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets)),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -144,9 +235,10 @@ export function CodeMirrorEditor({
|
||||
editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
|
||||
externalExtensionsCompartment.reconfigure(extensions ?? []),
|
||||
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
|
||||
blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets)),
|
||||
],
|
||||
});
|
||||
}, [extensions, highlightLines, lineNumbersConfig, readOnly]);
|
||||
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
@@ -163,15 +255,25 @@ export function CodeMirrorEditor({
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={cn(
|
||||
'h-full w-full',
|
||||
'[&_.cm-editor]:h-full [&_.cm-editor]:w-full',
|
||||
'[&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[var(--text-code)] [&_.cm-scroller]:leading-6',
|
||||
'[&_.cm-lineNumbers]:text-[var(--tools-edit-line-number)]',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={cn(
|
||||
'h-full w-full',
|
||||
'[&_.cm-editor]:h-full [&_.cm-editor]:w-full',
|
||||
'[&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[var(--text-code)] [&_.cm-scroller]:leading-6',
|
||||
'[&_.cm-lineNumbers]:text-[var(--tools-edit-line-number)]',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{blockWidgets?.map((w) => {
|
||||
// Look for the widget container in the editor DOM
|
||||
// Since we store them in a map too (as backup/optimization), we could check there,
|
||||
// but querySelector is safer to ensure it's actually in the DOM
|
||||
const container = viewRef.current?.dom.querySelector(`[data-widget-id="${w.id}"]`);
|
||||
if (!container) return null;
|
||||
return createPortal(w.content, container, w.id);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange } from '@pierre/diffs';
|
||||
import { RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
|
||||
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange, type DiffLineAnnotation, type AnnotationSide } from '@pierre/diffs';
|
||||
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
||||
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -10,20 +10,11 @@ import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
|
||||
|
||||
interface PierreDiffViewerProps {
|
||||
@@ -143,218 +134,129 @@ const isSameSelection = (left: SelectedLineRange | null, right: SelectedLineRang
|
||||
return left.start === right.start && left.end === right.end && left.side === right.side;
|
||||
};
|
||||
|
||||
type AnnotationData =
|
||||
| { type: 'saved' | 'edit'; draft: InlineCommentDraft }
|
||||
| { type: 'new'; selection: SelectedLineRange };
|
||||
|
||||
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
original,
|
||||
modified,
|
||||
language,
|
||||
fileName = 'file',
|
||||
fileName,
|
||||
renderSideBySide,
|
||||
wrapLines = false,
|
||||
wrapLines,
|
||||
layout = 'fill',
|
||||
}) => {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
|
||||
const isDark = themeContext?.themeMode === 'dark';
|
||||
const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false);
|
||||
const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true);
|
||||
|
||||
useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { inputBarOffset, isKeyboardOpen } = useUIStore();
|
||||
|
||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
||||
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
|
||||
|
||||
const fallbackLight = getDefaultTheme(false);
|
||||
const fallbackDark = getDefaultTheme(true);
|
||||
|
||||
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id;
|
||||
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id;
|
||||
|
||||
const lightTheme =
|
||||
themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ??
|
||||
fallbackLight;
|
||||
const darkTheme =
|
||||
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
||||
fallbackDark;
|
||||
|
||||
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
|
||||
const [pendingFocus, setPendingFocus] = useState(false);
|
||||
const commentContainerRef = useRef<HTMLDivElement>(null);
|
||||
const commentInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Refs to prevent infinite loops when syncing selection with diff instance
|
||||
const selectionRef = useRef<SelectedLineRange | null>(null);
|
||||
const isApplyingSelectionRef = useRef(false);
|
||||
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
|
||||
|
||||
// Keep selectionRef in sync with state
|
||||
useEffect(() => {
|
||||
selectionRef.current = selection;
|
||||
}, [selection]);
|
||||
|
||||
// Calculate initial center and width synchronously to avoid flicker
|
||||
const getMainContentMetrics = useCallback(() => {
|
||||
if (isMobile) return { center: '50%', width: '100vw' };
|
||||
const mainContent = document.querySelector('main.flex-1');
|
||||
if (mainContent) {
|
||||
const rect = mainContent.getBoundingClientRect();
|
||||
return {
|
||||
center: `${rect.left + rect.width / 2}px`,
|
||||
width: `${rect.width}px`
|
||||
};
|
||||
}
|
||||
return { center: '50%', width: '100vw' };
|
||||
}, [isMobile]);
|
||||
|
||||
const [mainContentMetrics, setMainContentMetrics] = useState(getMainContentMetrics);
|
||||
const mainContentCenter = mainContentMetrics.center;
|
||||
const mainContentWidth = mainContentMetrics.width;
|
||||
|
||||
const currentSessionId = useSessionStore(state => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionStore(state => state.newSessionDraft?.open);
|
||||
|
||||
// Inline comment drafts
|
||||
const addDraft = useInlineCommentDraftStore(state => state.addDraft);
|
||||
const updateDraft = useInlineCommentDraftStore(state => state.updateDraft);
|
||||
const removeDraft = useInlineCommentDraftStore(state => state.removeDraft);
|
||||
const allDrafts = useInlineCommentDraftStore(state => state.drafts);
|
||||
|
||||
// Filter drafts locally to avoid returning new array refs from selector
|
||||
const drafts = useMemo(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return [];
|
||||
return (allDrafts[sessionKey] ?? []).filter(
|
||||
d => d.source === 'diff' && d.fileLabel === fileName
|
||||
);
|
||||
}, [currentSessionId, newSessionDraftOpen, fileName, allDrafts]);
|
||||
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
// Get session key for drafts
|
||||
const getSessionKey = useCallback(() => {
|
||||
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
}, [currentSessionId, newSessionDraftOpen]);
|
||||
|
||||
// Update main content metrics on resize
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
|
||||
|
||||
const updateMetrics = () => {
|
||||
setMainContentMetrics(getMainContentMetrics());
|
||||
};
|
||||
// Use a ref to track if we're currently applying a selection programmatically
|
||||
// to avoid loop with onLineSelected callback
|
||||
const isApplyingSelectionRef = useRef(false);
|
||||
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
|
||||
|
||||
window.addEventListener('resize', updateMetrics);
|
||||
return () => window.removeEventListener('resize', updateMetrics);
|
||||
}, [isMobile, getMainContentMetrics]);
|
||||
|
||||
// Stable handler that uses refs to avoid recreating on selection changes
|
||||
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
|
||||
// Ignore callbacks while we're programmatically applying selection
|
||||
if (isApplyingSelectionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastApplied = lastAppliedSelectionRef.current;
|
||||
if (isSameSelection(range, lastApplied)) {
|
||||
|
||||
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
|
||||
if (isMobile && selection && range && range.side === selection.side) {
|
||||
const start = Math.min(selection.start, range.start);
|
||||
const end = Math.max(selection.end, range.end);
|
||||
setSelection({ ...range, start, end });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSelection = selectionRef.current;
|
||||
if (isSameSelection(range, currentSelection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On mobile: implement "tap to extend" behavior
|
||||
// If user taps a new single line while we have an existing selection, extend the range
|
||||
if (isMobile && range && currentSelection && range.start === range.end) {
|
||||
const tappedLine = range.start;
|
||||
const existingStart = currentSelection.start;
|
||||
const existingEnd = currentSelection.end;
|
||||
|
||||
// Extend the selection to include the tapped line
|
||||
const newStart = Math.min(existingStart, existingEnd, tappedLine);
|
||||
const newEnd = Math.max(existingStart, existingEnd, tappedLine);
|
||||
|
||||
// Only extend if tapping outside current selection
|
||||
if (tappedLine < existingStart || tappedLine > existingEnd) {
|
||||
setSelection({
|
||||
...range,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setSelection(range);
|
||||
if (!range) {
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Dismiss selection when clicking outside line numbers (desktop behavior)
|
||||
useEffect(() => {
|
||||
if (!selection) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if click is inside the comment UI portal
|
||||
if (commentContainerRef.current?.contains(target)) return;
|
||||
|
||||
// Check if click is inside toast (sonner)
|
||||
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
||||
|
||||
// Check if click is on a line number (inside shadow DOM)
|
||||
const path = e.composedPath();
|
||||
const isLineNumber = path.some((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
return el.hasAttribute('data-line-number') || el.closest?.('[data-line-number]');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isLineNumber) {
|
||||
setSelection(null);
|
||||
|
||||
// Clear editing state when selection changes user-driven
|
||||
if (range) {
|
||||
// Don't clear if we're just updating the selection for the same draft?
|
||||
// For now, simple behavior: new selection = new comment flow
|
||||
if (!editingDraftId) {
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use timeout to avoid immediate dismissal from the same click that selected
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [selection]);
|
||||
}
|
||||
}, [editingDraftId, isMobile, selection]);
|
||||
|
||||
const handleCancelComment = useCallback(() => {
|
||||
setCommentText('');
|
||||
setSelection(null);
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFocus || !selection || isMobile) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const input = commentInputRef.current;
|
||||
input?.focus();
|
||||
if (input) {
|
||||
const length = input.value.length;
|
||||
input.setSelectionRange(length, length);
|
||||
}
|
||||
setPendingFocus(false);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [pendingFocus, selection, isMobile]);
|
||||
// Helper to generate consistent annotation IDs
|
||||
const getAnnotationId = useCallback((meta: AnnotationData): string => {
|
||||
if (meta.type === 'saved' || meta.type === 'edit') {
|
||||
return `draft-${meta.draft.id}`;
|
||||
} else if (meta.type === 'new') {
|
||||
return 'new-comment-input';
|
||||
}
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const handleSaveComment = useCallback(() => {
|
||||
if (!selection || !commentText.trim()) return;
|
||||
// Robust target resolver that checks shadow root, light DOM, and container
|
||||
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
|
||||
if (!id || !diffContainerRef.current) return null;
|
||||
|
||||
const diffsContainer = diffContainerRef.current.querySelector('diffs-container');
|
||||
if (!diffsContainer) return null;
|
||||
|
||||
// Try shadow root first
|
||||
const shadowTarget = diffsContainer.shadowRoot?.querySelector(`[data-annotation-id="${id}"]`);
|
||||
if (shadowTarget) return shadowTarget as HTMLElement;
|
||||
|
||||
// Try light DOM (slotted content)
|
||||
const lightTarget = diffsContainer.querySelector(`[data-annotation-id="${id}"]`);
|
||||
if (lightTarget) return lightTarget as HTMLElement;
|
||||
|
||||
// Try container directly
|
||||
const containerTarget = diffContainerRef.current.querySelector(`[data-annotation-id="${id}"]`);
|
||||
if (containerTarget) return containerTarget as HTMLElement;
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
|
||||
const div = document.createElement('div');
|
||||
// Ensure full width and proper spacing
|
||||
div.className = 'w-full my-2';
|
||||
|
||||
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
|
||||
const id = getAnnotationId(meta);
|
||||
|
||||
div.dataset.annotationId = id;
|
||||
return div;
|
||||
}, [getAnnotationId]);
|
||||
|
||||
|
||||
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
|
||||
// Use provided range override or fall back to current selection
|
||||
const targetRange = rangeOverride ?? selection;
|
||||
if (!targetRange || !textToSave.trim()) return;
|
||||
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) {
|
||||
@@ -362,40 +264,46 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const code = extractSelectedCode(original, modified, selection);
|
||||
const side = selection.side === 'deletions' ? 'original' : 'modified';
|
||||
const fileLabel = fileName ? fileName.split('/').pop() || 'unknown' : 'unknown';
|
||||
// Pierre selection range: { start, end, side }
|
||||
// Store needs { startLine, endLine, side: 'original'|'modified' }
|
||||
// Pierre side: 'additions' (right) | 'deletions' (left)
|
||||
const storeSide = targetRange.side === 'deletions' ? 'original' : 'modified';
|
||||
|
||||
// Use deterministic code extraction instead of instance.getSelectedText()
|
||||
const selectedText = extractSelectedCode(original, modified, targetRange);
|
||||
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
fileLabel: fileName,
|
||||
startLine: selection.start,
|
||||
endLine: selection.end,
|
||||
side,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
fileLabel,
|
||||
startLine: targetRange.start,
|
||||
endLine: targetRange.end,
|
||||
side: storeSide,
|
||||
code: selectedText,
|
||||
language: language,
|
||||
text: textToSave.trim(),
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
source: 'diff',
|
||||
fileLabel: fileName,
|
||||
startLine: selection.start,
|
||||
endLine: selection.end,
|
||||
side,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
fileLabel,
|
||||
startLine: targetRange.start,
|
||||
endLine: targetRange.end,
|
||||
side: storeSide,
|
||||
code: selectedText,
|
||||
language: language,
|
||||
text: textToSave.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Clear selection and comment text
|
||||
setCommentText('');
|
||||
setSelection(null);
|
||||
setEditingDraftId(null);
|
||||
|
||||
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
||||
}, [selection, commentText, original, modified, fileName, language, addDraft, updateDraft, getSessionKey, editingDraftId]);
|
||||
}, [selection, fileName, language, original, modified, addDraft, updateDraft, getSessionKey, editingDraftId]);
|
||||
|
||||
|
||||
const applySelection = useCallback((range: SelectedLineRange) => {
|
||||
setSelection(range);
|
||||
@@ -420,6 +328,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
const diffRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
|
||||
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||
const workerPool = useWorkerPool();
|
||||
|
||||
const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]);
|
||||
@@ -499,6 +408,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
}
|
||||
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
|
||||
|
||||
|
||||
const options = useMemo(() => ({
|
||||
theme: {
|
||||
dark: darkTheme.metadata.id,
|
||||
@@ -516,7 +426,51 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
enableHoverUtility: false,
|
||||
onLineSelected: handleSelectionChange,
|
||||
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
||||
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange]);
|
||||
renderAnnotation,
|
||||
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
||||
|
||||
|
||||
const lineAnnotations = useMemo(() => {
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) return [];
|
||||
|
||||
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
||||
// Match file label logic - use basename
|
||||
const fileLabel = fileName ? fileName.split('/').pop() || 'unknown' : 'unknown';
|
||||
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
||||
|
||||
const anns: DiffLineAnnotation<AnnotationData>[] = [];
|
||||
|
||||
fileDrafts.forEach((d) => {
|
||||
// Force cast to AnnotationSide to satisfy compiler
|
||||
const side = (d.side === 'original' ? 'deletions' : 'additions') as AnnotationSide;
|
||||
if (d.id === editingDraftId) {
|
||||
// Always show edit input (even on mobile)
|
||||
anns.push({
|
||||
lineNumber: d.endLine,
|
||||
side: side,
|
||||
metadata: { type: 'edit', draft: d },
|
||||
});
|
||||
} else {
|
||||
// Show saved cards on all devices
|
||||
anns.push({
|
||||
lineNumber: d.endLine,
|
||||
side: side,
|
||||
metadata: { type: 'saved', draft: d },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (selection && !editingDraftId) {
|
||||
anns.push({
|
||||
lineNumber: selection.end,
|
||||
side: (selection.side ?? 'additions') as AnnotationSide,
|
||||
metadata: { type: 'new', selection },
|
||||
});
|
||||
}
|
||||
|
||||
return anns;
|
||||
}, [allDrafts, getSessionKey, fileName, editingDraftId, selection]);
|
||||
|
||||
// Imperative render (like upstream OpenCode): avoids `parseDiffFromFile` on main thread.
|
||||
useEffect(() => {
|
||||
@@ -536,13 +490,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
lastAppliedSelectionRef.current = null;
|
||||
|
||||
const oldFile: FileContents = {
|
||||
name: fileName,
|
||||
name: fileName || '',
|
||||
contents: original,
|
||||
lang: language as FileContents['lang'],
|
||||
cacheKey: `old:${diffThemeKey}:${fileName}:${makeContentCacheKey(original)}`,
|
||||
};
|
||||
const newFile: FileContents = {
|
||||
name: fileName,
|
||||
name: fileName || '',
|
||||
contents: modified,
|
||||
lang: language as FileContents['lang'],
|
||||
cacheKey: `new:${diffThemeKey}:${fileName}:${makeContentCacheKey(modified)}`,
|
||||
@@ -551,10 +505,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
instance.render({
|
||||
oldFile,
|
||||
newFile,
|
||||
lineAnnotations: [],
|
||||
lineAnnotations,
|
||||
containerWrapper: container,
|
||||
});
|
||||
|
||||
// Force update to render portals into new DOM elements created by Pierre
|
||||
requestAnimationFrame(() => forceUpdate());
|
||||
|
||||
return () => {
|
||||
instance.cleanUp();
|
||||
if (diffInstanceRef.current === instance) {
|
||||
@@ -562,7 +519,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
}
|
||||
container.innerHTML = '';
|
||||
};
|
||||
}, [diffThemeKey, fileName, language, modified, options, original, workerPool]);
|
||||
}, [diffThemeKey, fileName, language, modified, options, original, workerPool, lineAnnotations]);
|
||||
|
||||
useEffect(() => {
|
||||
const instance = diffInstanceRef.current;
|
||||
@@ -591,190 +548,131 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
}
|
||||
}, [selection]);
|
||||
|
||||
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
|
||||
useEffect(() => {
|
||||
const container = diffContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const setupObserver = () => {
|
||||
const diffsContainer = container.querySelector('diffs-container');
|
||||
if (!diffsContainer) return;
|
||||
|
||||
// Watch for annotation nodes being added/removed
|
||||
observer = new MutationObserver((mutations) => {
|
||||
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) {
|
||||
// Debounce with RAF to batch multiple mutations
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
forceUpdate();
|
||||
rafId = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Observe both shadow root and light DOM
|
||||
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);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [diffThemeKey, fileName]); // Re-setup when diff changes
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extracted Comment Interface Content for reuse in Portal or In-Flow
|
||||
const renderCommentContent = () => {
|
||||
if (!selection) return null;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 px-4"
|
||||
style={{ width: `min(calc(${mainContentWidth} - 2rem), 42rem)` }}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-xl flex flex-col relative shadow-lg border border-border/80 focus-within:border-primary/70 focus-within:ring-1 focus-within:ring-primary/50"
|
||||
style={{
|
||||
backgroundColor: themeSystem?.currentTheme?.colors?.surface?.subtle,
|
||||
// Render portals for inline comments with robust target resolution
|
||||
const portals = lineAnnotations.map((ann) => {
|
||||
const meta = (ann as DiffLineAnnotation<AnnotationData>).metadata;
|
||||
const id = getAnnotationId(meta);
|
||||
|
||||
// Use robust resolver that checks shadow, light DOM, and container
|
||||
const target = resolveAnnotationTarget(id);
|
||||
|
||||
// If target not found, skip rendering (will retry on next update cycle)
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (meta.type === 'saved') {
|
||||
return createPortal(
|
||||
<InlineCommentCard
|
||||
key={id}
|
||||
draft={meta.draft}
|
||||
onEdit={() => {
|
||||
const side = meta.draft.side === 'original' ? 'deletions' : 'additions';
|
||||
applySelection({
|
||||
start: meta.draft.startLine,
|
||||
end: meta.draft.endLine,
|
||||
side,
|
||||
});
|
||||
setCommentText(meta.draft.text);
|
||||
setEditingDraftId(meta.draft.id);
|
||||
}}
|
||||
>
|
||||
{/* Textarea - auto-grows from 1 line to max 5 lines */}
|
||||
<Textarea
|
||||
ref={commentInputRef}
|
||||
value={commentText}
|
||||
onChange={(e) => {
|
||||
setCommentText(e.target.value);
|
||||
// Auto-resize textarea
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
const lineHeight = 20; // approx line height
|
||||
const maxHeight = lineHeight * 5 + 8; // 5 lines + padding
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
||||
}}
|
||||
placeholder="Type your comment..."
|
||||
outerClassName="focus-within:ring-0"
|
||||
className="min-h-[28px] max-h-[108px] resize-none border-0 px-3 pt-2 pb-1 rounded-none appearance-none hover:border-transparent bg-transparent dark:bg-transparent overflow-y-auto focus:ring-0 focus:shadow-none"
|
||||
autoFocus={!isMobile}
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveComment();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelComment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Footer with Cancel and Comment buttons */}
|
||||
<div className="px-2.5 py-1 flex items-center justify-between gap-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fileName.split('/').pop()}:{selection.start}-{selection.end}
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isMobile && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getModifierLabel()}+⏎
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelComment}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSaveComment}
|
||||
disabled={!commentText.trim()}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.status?.success,
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
{editingDraftId ? 'Save' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
onDelete={() => removeDraft(meta.draft.sessionKey, meta.draft.id)}
|
||||
/>,
|
||||
target,
|
||||
id
|
||||
);
|
||||
} else if (meta.type === 'edit') {
|
||||
return createPortal(
|
||||
<InlineCommentInput
|
||||
key={id}
|
||||
initialText={commentText}
|
||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||
lineRange={{
|
||||
start: meta.draft.startLine,
|
||||
end: meta.draft.endLine,
|
||||
side: meta.draft.side === 'original' ? 'deletions' : 'additions'
|
||||
}}
|
||||
isEditing={true}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>,
|
||||
target,
|
||||
id
|
||||
);
|
||||
} else {
|
||||
return createPortal(
|
||||
<InlineCommentInput
|
||||
key={id}
|
||||
initialText={commentText}
|
||||
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
||||
lineRange={selection || undefined}
|
||||
isEditing={false}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
/>,
|
||||
target,
|
||||
id
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Render saved comment cards
|
||||
const renderSavedComments = () => {
|
||||
if (drafts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-40">
|
||||
{drafts.map(draft => {
|
||||
// Approximate line placement; shadow DOM prevents direct line querying.
|
||||
const lineHeight = 24;
|
||||
const top = (draft.startLine - 1) * lineHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
right: '8px',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border p-2 shadow-md"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{draft.fileLabel}:{draft.startLine}-{draft.endLine}
|
||||
{draft.side && ` (${draft.side})`}
|
||||
</div>
|
||||
<div className="text-sm line-clamp-3">{draft.text}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-[var(--interactive-hover)] text-muted-foreground"
|
||||
>
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const selectionSide = draft.side === 'original' ? 'deletions' : 'additions';
|
||||
applySelection({
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
side: selectionSide,
|
||||
});
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
setPendingFocus(true);
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-2" />
|
||||
Edit comment
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-2" />
|
||||
Delete comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const commentContent = renderCommentContent();
|
||||
|
||||
// If we're in the main diff view ('fill' layout), render In-Flow (like ChatInput).
|
||||
// If we're in an inline diff ('inline' layout), render via Portal (fixed over content).
|
||||
if (layout === 'fill') {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col relative", "size-full")}
|
||||
style={{
|
||||
// Apply keyboard padding to the main container, just like ChatContainer
|
||||
paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : undefined
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex flex-col relative", "size-full")}>
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<ScrollableOverlay
|
||||
outerClassName="pierre-diff-wrapper size-full"
|
||||
@@ -783,72 +681,23 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
>
|
||||
<div ref={diffRootRef} className="size-full relative">
|
||||
<div ref={diffContainerRef} className="size-full" />
|
||||
{renderSavedComments()}
|
||||
{portals}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
|
||||
{/* Render Input overlay at the bottom */}
|
||||
{selection && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto absolute bottom-0 left-0 right-0 pb-2 transition-none z-50 flex justify-center w-full",
|
||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||
)}
|
||||
style={{
|
||||
marginBottom: isMobile
|
||||
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
|
||||
: '16px'
|
||||
}}
|
||||
data-keyboard-avoid="true"
|
||||
data-comment-ui="true"
|
||||
ref={commentContainerRef}
|
||||
>
|
||||
{commentContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for 'inline' layout: use Portal behavior
|
||||
// Use simple div with overflow-x-auto to avoid nested ScrollableOverlay issues in Chrome
|
||||
// Fallback for 'inline' layout
|
||||
return (
|
||||
<div className={cn("relative", "w-full")}>
|
||||
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
||||
<div ref={diffContainerRef} className="w-full" />
|
||||
{renderSavedComments()}
|
||||
{portals}
|
||||
</div>
|
||||
|
||||
{selection && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col justify-end items-start pointer-events-none transition-none transform-gpu"
|
||||
style={{
|
||||
paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : '0px',
|
||||
isolation: 'isolate'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto relative pb-2 transition-none",
|
||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||
)}
|
||||
style={{
|
||||
marginLeft: mainContentCenter,
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: isMobile
|
||||
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
|
||||
: '16px'
|
||||
}}
|
||||
data-keyboard-avoid="true"
|
||||
data-comment-ui="true"
|
||||
ref={commentContainerRef}
|
||||
>
|
||||
{commentContent}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
import React from 'react';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
|
||||
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
|
||||
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -93,11 +86,10 @@ export const PlanView: React.FC = () => {
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
||||
const { inputBarOffset, isKeyboardOpen } = useUIStore();
|
||||
useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
const [editorView, setEditorView] = React.useState<EditorView | null>(null);
|
||||
|
||||
// Inline comment drafts
|
||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
@@ -138,8 +130,6 @@ export const PlanView: React.FC = () => {
|
||||
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
const [pendingFocus, setPendingFocus] = React.useState(false);
|
||||
const commentInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
|
||||
|
||||
@@ -180,37 +170,35 @@ export const PlanView: React.FC = () => {
|
||||
setLineSelection(null);
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}, [content]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingFocus || !lineSelection || isMobile) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const input = commentInputRef.current;
|
||||
input?.focus();
|
||||
if (input) {
|
||||
const length = input.value.length;
|
||||
input.setSelectionRange(length, length);
|
||||
}
|
||||
setPendingFocus(false);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [pendingFocus, lineSelection, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lineSelection) return;
|
||||
|
||||
// Auto-scroll input into view on mobile
|
||||
if (isMobile && !editingDraftId) {
|
||||
// We rely on InlineCommentInput doing this now via useEffect
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const commentUI = document.querySelector('[data-comment-ui]');
|
||||
if (commentUI?.contains(target)) return;
|
||||
// Check if click is inside any comment component
|
||||
if (
|
||||
target.closest('[data-comment-card="true"]') ||
|
||||
target.closest('[data-comment-input="true"]') ||
|
||||
target.closest('.oc-block-widget')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('.cm-gutterElement')) return;
|
||||
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
||||
|
||||
// If clicking outside while editing, maybe we should save or ask confirmation?
|
||||
// For now, cancel edit
|
||||
setLineSelection(null);
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -221,7 +209,7 @@ export const PlanView: React.FC = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [lineSelection]);
|
||||
}, [lineSelection, editingDraftId, isMobile]);
|
||||
|
||||
const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => {
|
||||
const lines = text.split('\n');
|
||||
@@ -235,11 +223,12 @@ export const PlanView: React.FC = () => {
|
||||
setCommentText('');
|
||||
setLineSelection(null);
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}, []);
|
||||
|
||||
const handleSaveComment = React.useCallback(() => {
|
||||
if (!lineSelection || !commentText.trim()) return;
|
||||
const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => {
|
||||
// Use provided range override or fall back to current selection
|
||||
const targetRange = rangeOverride ?? lineSelection;
|
||||
if (!targetRange || !textToSave.trim()) return;
|
||||
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) {
|
||||
@@ -247,29 +236,29 @@ export const PlanView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = extractSelectedCode(content, lineSelection);
|
||||
const code = extractSelectedCode(content, targetRange);
|
||||
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
||||
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
|
||||
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
startLine: targetRange.start,
|
||||
endLine: targetRange.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
text: textToSave.trim(),
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
source: 'plan',
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
startLine: targetRange.start,
|
||||
endLine: targetRange.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
text: textToSave.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,7 +267,8 @@ export const PlanView: React.FC = () => {
|
||||
setEditingDraftId(null);
|
||||
|
||||
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
||||
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
||||
}, [lineSelection, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
||||
|
||||
|
||||
const editorExtensions = React.useMemo(() => {
|
||||
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||
@@ -387,170 +377,87 @@ export const PlanView: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderCommentUI = () => {
|
||||
if (!lineSelection) return null;
|
||||
return (
|
||||
<div
|
||||
data-comment-ui
|
||||
className="flex flex-col items-center gap-2 px-4"
|
||||
style={{ width: 'min(100vw - 1rem, 42rem)' }}
|
||||
>
|
||||
<div className="w-full rounded-xl border bg-background flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
|
||||
<Textarea
|
||||
ref={commentInputRef}
|
||||
value={commentText}
|
||||
onChange={(e) => {
|
||||
setCommentText(e.target.value);
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
const lineHeight = 20;
|
||||
const maxHeight = lineHeight * 5 + 8;
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
||||
}}
|
||||
placeholder="Type your comment..."
|
||||
className="min-h-[28px] max-h-[108px] resize-none border-0 px-3 pt-2 pb-1 shadow-none rounded-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent dark:bg-transparent focus-visible:outline-none overflow-y-auto"
|
||||
autoFocus={!isMobile}
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveComment();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelComment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Footer with Cancel and Comment buttons */}
|
||||
<div className="px-2.5 py-1 flex items-center justify-between gap-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Plan:{lineSelection.start}-{lineSelection.end}
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isMobile && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getModifierLabel()}+⏎
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelComment}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSaveComment}
|
||||
disabled={!commentText.trim()}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.status?.success,
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
{editingDraftId ? 'Save' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render saved comment cards
|
||||
const renderSavedComments = () => {
|
||||
if (mdViewMode === 'preview') return null;
|
||||
if (!editorView) return null;
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
if (mdViewMode === 'preview') return [];
|
||||
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) return null;
|
||||
if (!sessionKey) return [];
|
||||
|
||||
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
||||
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
||||
const fileDrafts = sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === fileLabel);
|
||||
|
||||
if (fileDrafts.length === 0) return null;
|
||||
const widgets: BlockWidgetDef[] = [];
|
||||
|
||||
return createPortal(
|
||||
<div className="absolute inset-0 pointer-events-none z-40">
|
||||
{fileDrafts.map((draft) => {
|
||||
const maxLine = editorView.state.doc.lines;
|
||||
const safeLine = Math.min(Math.max(1, draft.startLine), maxLine);
|
||||
const line = editorView.state.doc.line(safeLine);
|
||||
const top = editorView.lineBlockAt(line.from).top;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
right: '8px',
|
||||
maxWidth: '300px',
|
||||
// Add saved drafts
|
||||
fileDrafts.forEach((draft) => {
|
||||
if (draft.id === editingDraftId) {
|
||||
// Always show edit input (even on mobile)
|
||||
widgets.push({
|
||||
afterLine: draft.endLine,
|
||||
id: `edit-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
fileLabel={fileLabel}
|
||||
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
||||
initialText={commentText}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
isEditing={true}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Show saved cards on all devices
|
||||
widgets.push({
|
||||
afterLine: draft.endLine,
|
||||
id: `draft-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentCard
|
||||
draft={draft}
|
||||
onEdit={() => {
|
||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border p-2 shadow-md"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{draft.fileLabel}:{draft.startLine}-{draft.endLine}
|
||||
</div>
|
||||
<div className="text-sm line-clamp-3">{draft.text}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-[var(--interactive-hover)] text-muted-foreground"
|
||||
>
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
setPendingFocus(true);
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-2" />
|
||||
Edit comment
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-2" />
|
||||
Delete comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
editorView.scrollDOM
|
||||
);
|
||||
};
|
||||
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add new comment input if selecting AND not editing an existing draft
|
||||
if (lineSelection && !editingDraftId) {
|
||||
widgets.push({
|
||||
afterLine: lineSelection.end,
|
||||
id: 'new-comment-input',
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
fileLabel={fileLabel}
|
||||
lineRange={lineSelection}
|
||||
initialText={commentText} // Usually empty for new, unless restored?
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
isEditing={false}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}, [
|
||||
mdViewMode,
|
||||
getSessionKey,
|
||||
allDrafts,
|
||||
displayPath,
|
||||
editingDraftId,
|
||||
lineSelection,
|
||||
commentText,
|
||||
handleSaveComment,
|
||||
handleCancelComment,
|
||||
removeDraft,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
|
||||
@@ -636,11 +543,7 @@ export const PlanView: React.FC = () => {
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
['--oc-plan-comment-pad' as string]: lineSelection
|
||||
? (isMobile
|
||||
? 'calc(var(--oc-keyboard-inset, 0px) + 140px)'
|
||||
: '140px')
|
||||
: '0px',
|
||||
['--oc-plan-comment-pad' as string]: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full oc-plan-editor">
|
||||
@@ -669,14 +572,13 @@ export const PlanView: React.FC = () => {
|
||||
readOnly={true}
|
||||
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
|
||||
extensions={editorExtensions}
|
||||
onViewReady={setEditorView}
|
||||
onViewDestroy={() => setEditorView(null)}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
end: Math.max(lineSelection.start, lineSelection.end),
|
||||
}
|
||||
: undefined}
|
||||
blockWidgets={blockWidgets}
|
||||
lineNumbersConfig={{
|
||||
domEventHandlers: {
|
||||
mousedown: (view, line, event) => {
|
||||
@@ -725,7 +627,6 @@ export const PlanView: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{renderSavedComments()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -734,28 +635,6 @@ export const PlanView: React.FC = () => {
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
|
||||
{lineSelection && mdViewMode !== 'preview' && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-50 flex flex-col justify-end"
|
||||
style={{ paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : '0px' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto pb-2 transition-none w-full flex justify-center",
|
||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||
)}
|
||||
style={{
|
||||
marginBottom: isMobile
|
||||
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
|
||||
: '16px'
|
||||
}}
|
||||
data-keyboard-avoid="true"
|
||||
>
|
||||
{renderCommentUI()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user