Add edit comment in plan and diff panel (#328)
* feat: add view ready and destroy callbacks to CodeMirrorEditor Provide onViewReady callback that receives the EditorView once created Provide onViewDestroy callback invoked when the editor is destroyed * feat: render inline drafts in PlanView with portal Add editorView state to track the active CodeMirror instance Render inline drafts through a portal anchored to the editor scroll container Position drafts relative to the corresponding start line in the editor * feat(PierreDiffViewer): enable editing inline diff drafts Enable editing of existing inline diff drafts with draftID handling Auto-focus comment input when opening editor and place cursor at end * feat: enable editing inline drafts and auto focus Enable editing of existing inline drafts via new editingDraftId state and updateDraft Auto focus the comment input and place cursor at end when a line is selected Add edit action button in the inline actions bar to start editing * feat: add updateDraft to inline comment drafts store Add updateDraft action to modify a specific draft by session and id Merge updates into the target draft without altering other fields Retains existing draft management actions like remove and clear
This commit is contained in:
@@ -16,6 +16,8 @@ type CodeMirrorEditorProps = {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
|
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
|
||||||
highlightLines?: { start: number; end: number };
|
highlightLines?: { start: number; end: number };
|
||||||
|
onViewReady?: (view: EditorView) => void;
|
||||||
|
onViewDestroy?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lineNumbersCompartment = new Compartment();
|
const lineNumbersCompartment = new Compartment();
|
||||||
@@ -55,11 +57,23 @@ const createHighlightLinesExtension = (range?: { start: number; end: number }):
|
|||||||
}, { decorations: (v) => v.decorations });
|
}, { decorations: (v) => v.decorations });
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CodeMirrorEditor({ value, onChange, extensions, className, readOnly, lineNumbersConfig, highlightLines }: CodeMirrorEditorProps) {
|
export function CodeMirrorEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
extensions,
|
||||||
|
className,
|
||||||
|
readOnly,
|
||||||
|
lineNumbersConfig,
|
||||||
|
highlightLines,
|
||||||
|
onViewReady,
|
||||||
|
onViewDestroy,
|
||||||
|
}: CodeMirrorEditorProps) {
|
||||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = React.useRef<EditorView | null>(null);
|
const viewRef = React.useRef<EditorView | null>(null);
|
||||||
const valueRef = React.useRef(value);
|
const valueRef = React.useRef(value);
|
||||||
const onChangeRef = React.useRef(onChange);
|
const onChangeRef = React.useRef(onChange);
|
||||||
|
const onViewReadyRef = React.useRef(onViewReady);
|
||||||
|
const onViewDestroyRef = React.useRef(onViewDestroy);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
@@ -69,6 +83,11 @@ export function CodeMirrorEditor({ value, onChange, extensions, className, readO
|
|||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onViewReadyRef.current = onViewReady;
|
||||||
|
onViewDestroyRef.current = onViewDestroy;
|
||||||
|
}, [onViewReady, onViewDestroy]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!hostRef.current) {
|
if (!hostRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -101,7 +120,12 @@ export function CodeMirrorEditor({ value, onChange, extensions, className, readO
|
|||||||
parent: hostRef.current,
|
parent: hostRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (viewRef.current) {
|
||||||
|
onViewReadyRef.current?.(viewRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
onViewDestroyRef.current?.();
|
||||||
viewRef.current?.destroy();
|
viewRef.current?.destroy();
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange } from '@pierre/diffs';
|
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange } from '@pierre/diffs';
|
||||||
import { RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
|
import { RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -173,7 +173,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
|
|
||||||
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
|
||||||
|
const [pendingFocus, setPendingFocus] = useState(false);
|
||||||
const commentContainerRef = useRef<HTMLDivElement>(null);
|
const commentContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const commentInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
// Refs to prevent infinite loops when syncing selection with diff instance
|
// Refs to prevent infinite loops when syncing selection with diff instance
|
||||||
const selectionRef = useRef<SelectedLineRange | null>(null);
|
const selectionRef = useRef<SelectedLineRange | null>(null);
|
||||||
@@ -208,6 +211,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
|
|
||||||
// Inline comment drafts
|
// Inline comment drafts
|
||||||
const addDraft = useInlineCommentDraftStore(state => state.addDraft);
|
const addDraft = useInlineCommentDraftStore(state => state.addDraft);
|
||||||
|
const updateDraft = useInlineCommentDraftStore(state => state.updateDraft);
|
||||||
const removeDraft = useInlineCommentDraftStore(state => state.removeDraft);
|
const removeDraft = useInlineCommentDraftStore(state => state.removeDraft);
|
||||||
const allDrafts = useInlineCommentDraftStore(state => state.drafts);
|
const allDrafts = useInlineCommentDraftStore(state => state.drafts);
|
||||||
|
|
||||||
@@ -281,6 +285,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
setSelection(range);
|
setSelection(range);
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
|
setEditingDraftId(null);
|
||||||
|
setPendingFocus(false);
|
||||||
}
|
}
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
@@ -309,6 +315,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
if (!isLineNumber) {
|
if (!isLineNumber) {
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
|
setEditingDraftId(null);
|
||||||
|
setPendingFocus(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,8 +334,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const handleCancelComment = useCallback(() => {
|
const handleCancelComment = useCallback(() => {
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
setSelection(null);
|
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]);
|
||||||
|
|
||||||
const handleSaveComment = useCallback(() => {
|
const handleSaveComment = useCallback(() => {
|
||||||
if (!selection || !commentText.trim()) return;
|
if (!selection || !commentText.trim()) return;
|
||||||
|
|
||||||
@@ -340,24 +365,52 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const code = extractSelectedCode(original, modified, selection);
|
const code = extractSelectedCode(original, modified, selection);
|
||||||
const side = selection.side === 'deletions' ? 'original' : 'modified';
|
const side = selection.side === 'deletions' ? 'original' : 'modified';
|
||||||
|
|
||||||
addDraft({
|
if (editingDraftId) {
|
||||||
sessionKey,
|
updateDraft(sessionKey, editingDraftId, {
|
||||||
source: 'diff',
|
fileLabel: fileName,
|
||||||
fileLabel: fileName,
|
startLine: selection.start,
|
||||||
startLine: selection.start,
|
endLine: selection.end,
|
||||||
endLine: selection.end,
|
side,
|
||||||
side,
|
code,
|
||||||
code,
|
language,
|
||||||
language,
|
text: commentText.trim(),
|
||||||
text: commentText.trim(),
|
});
|
||||||
});
|
} else {
|
||||||
|
addDraft({
|
||||||
|
sessionKey,
|
||||||
|
source: 'diff',
|
||||||
|
fileLabel: fileName,
|
||||||
|
startLine: selection.start,
|
||||||
|
endLine: selection.end,
|
||||||
|
side,
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
text: commentText.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Clear selection and comment text
|
// Clear selection and comment text
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
|
setEditingDraftId(null);
|
||||||
|
|
||||||
toast.success('Comment saved');
|
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
||||||
}, [selection, commentText, original, modified, fileName, language, addDraft, getSessionKey]);
|
}, [selection, commentText, original, modified, fileName, language, addDraft, updateDraft, getSessionKey, editingDraftId]);
|
||||||
|
|
||||||
|
const applySelection = useCallback((range: SelectedLineRange) => {
|
||||||
|
setSelection(range);
|
||||||
|
const instance = diffInstanceRef.current;
|
||||||
|
if (!instance) return;
|
||||||
|
try {
|
||||||
|
isApplyingSelectionRef.current = true;
|
||||||
|
instance.setSelectedLines(range);
|
||||||
|
lastAppliedSelectionRef.current = range;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
isApplyingSelectionRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
ensurePierreThemeRegistered(lightTheme);
|
ensurePierreThemeRegistered(lightTheme);
|
||||||
ensurePierreThemeRegistered(darkTheme);
|
ensurePierreThemeRegistered(darkTheme);
|
||||||
@@ -558,6 +611,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Textarea - auto-grows from 1 line to max 5 lines */}
|
{/* Textarea - auto-grows from 1 line to max 5 lines */}
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={commentInputRef}
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCommentText(e.target.value);
|
setCommentText(e.target.value);
|
||||||
@@ -616,7 +670,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
color: currentTheme?.colors?.status?.successForeground,
|
color: currentTheme?.colors?.status?.successForeground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Comment
|
{editingDraftId ? 'Save' : 'Comment'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -670,7 +724,26 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
<RiMoreLine className="h-4 w-4" />
|
<RiMoreLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<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
|
<DropdownMenuItem
|
||||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||||
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||||
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
|
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
const normalize = (value: string): string => {
|
const normalize = (value: string): string => {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@@ -96,9 +97,11 @@ export const PlanView: React.FC = () => {
|
|||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
|
const [editorView, setEditorView] = React.useState<EditorView | null>(null);
|
||||||
|
|
||||||
// Inline comment drafts
|
// Inline comment drafts
|
||||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||||
|
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||||
|
|
||||||
@@ -134,6 +137,9 @@ export const PlanView: React.FC = () => {
|
|||||||
|
|
||||||
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
||||||
const [commentText, setCommentText] = React.useState('');
|
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';
|
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
|
||||||
|
|
||||||
@@ -173,8 +179,25 @@ export const PlanView: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setLineSelection(null);
|
setLineSelection(null);
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
|
setEditingDraftId(null);
|
||||||
|
setPendingFocus(false);
|
||||||
}, [content]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!lineSelection) return;
|
if (!lineSelection) return;
|
||||||
|
|
||||||
@@ -186,6 +209,8 @@ export const PlanView: React.FC = () => {
|
|||||||
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
||||||
setLineSelection(null);
|
setLineSelection(null);
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
|
setEditingDraftId(null);
|
||||||
|
setPendingFocus(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
@@ -209,6 +234,8 @@ export const PlanView: React.FC = () => {
|
|||||||
const handleCancelComment = React.useCallback(() => {
|
const handleCancelComment = React.useCallback(() => {
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
setLineSelection(null);
|
setLineSelection(null);
|
||||||
|
setEditingDraftId(null);
|
||||||
|
setPendingFocus(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveComment = React.useCallback(() => {
|
const handleSaveComment = React.useCallback(() => {
|
||||||
@@ -224,22 +251,34 @@ export const PlanView: React.FC = () => {
|
|||||||
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
||||||
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
|
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
|
||||||
|
|
||||||
addDraft({
|
if (editingDraftId) {
|
||||||
sessionKey,
|
updateDraft(sessionKey, editingDraftId, {
|
||||||
source: 'plan',
|
fileLabel,
|
||||||
fileLabel,
|
startLine: lineSelection.start,
|
||||||
startLine: lineSelection.start,
|
endLine: lineSelection.end,
|
||||||
endLine: lineSelection.end,
|
code,
|
||||||
code,
|
language,
|
||||||
language,
|
text: commentText.trim(),
|
||||||
text: commentText.trim(),
|
});
|
||||||
});
|
} else {
|
||||||
|
addDraft({
|
||||||
|
sessionKey,
|
||||||
|
source: 'plan',
|
||||||
|
fileLabel,
|
||||||
|
startLine: lineSelection.start,
|
||||||
|
endLine: lineSelection.end,
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
text: commentText.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
setLineSelection(null);
|
setLineSelection(null);
|
||||||
|
setEditingDraftId(null);
|
||||||
|
|
||||||
toast.success('Comment saved');
|
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
||||||
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey, extractSelectedCode]);
|
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
||||||
|
|
||||||
const editorExtensions = React.useMemo(() => {
|
const editorExtensions = React.useMemo(() => {
|
||||||
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||||
@@ -358,6 +397,7 @@ export const PlanView: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="w-full rounded-xl border bg-background flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
|
<div className="w-full rounded-xl border bg-background flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={commentInputRef}
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCommentText(e.target.value);
|
setCommentText(e.target.value);
|
||||||
@@ -414,7 +454,7 @@ export const PlanView: React.FC = () => {
|
|||||||
color: currentTheme?.colors?.status?.successForeground,
|
color: currentTheme?.colors?.status?.successForeground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Comment
|
{editingDraftId ? 'Save' : 'Comment'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -426,6 +466,7 @@ export const PlanView: React.FC = () => {
|
|||||||
// Render saved comment cards
|
// Render saved comment cards
|
||||||
const renderSavedComments = () => {
|
const renderSavedComments = () => {
|
||||||
if (mdViewMode === 'preview') return null;
|
if (mdViewMode === 'preview') return null;
|
||||||
|
if (!editorView) return null;
|
||||||
|
|
||||||
const sessionKey = getSessionKey();
|
const sessionKey = getSessionKey();
|
||||||
if (!sessionKey) return null;
|
if (!sessionKey) return null;
|
||||||
@@ -436,13 +477,13 @@ export const PlanView: React.FC = () => {
|
|||||||
|
|
||||||
if (fileDrafts.length === 0) return null;
|
if (fileDrafts.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="absolute inset-0 pointer-events-none z-40">
|
<div className="absolute inset-0 pointer-events-none z-40">
|
||||||
{fileDrafts.map((draft) => {
|
{fileDrafts.map((draft) => {
|
||||||
// For CodeMirror, we need to position based on line
|
const maxLine = editorView.state.doc.lines;
|
||||||
// This is a simplified version - in production, you'd query the editor's DOM
|
const safeLine = Math.min(Math.max(1, draft.startLine), maxLine);
|
||||||
const lineHeight = 24; // Approximate line height in pixels
|
const line = editorView.state.doc.line(safeLine);
|
||||||
const top = (draft.startLine - 1) * lineHeight;
|
const top = editorView.lineBlockAt(line.from).top;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -477,7 +518,21 @@ export const PlanView: React.FC = () => {
|
|||||||
<RiMoreLine className="h-4 w-4" />
|
<RiMoreLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<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
|
<DropdownMenuItem
|
||||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
@@ -492,7 +547,8 @@ export const PlanView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
editorView.scrollDOM
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -611,8 +667,10 @@ export const PlanView: React.FC = () => {
|
|||||||
// read-only
|
// read-only
|
||||||
}}
|
}}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)]"
|
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
|
||||||
extensions={editorExtensions}
|
extensions={editorExtensions}
|
||||||
|
onViewReady={setEditorView}
|
||||||
|
onViewDestroy={() => setEditorView(null)}
|
||||||
highlightLines={lineSelection
|
highlightLines={lineSelection
|
||||||
? {
|
? {
|
||||||
start: Math.min(lineSelection.start, lineSelection.end),
|
start: Math.min(lineSelection.start, lineSelection.end),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface InlineCommentDraftState {
|
|||||||
|
|
||||||
interface InlineCommentDraftActions {
|
interface InlineCommentDraftActions {
|
||||||
addDraft: (draft: Omit<InlineCommentDraft, 'id' | 'createdAt'>) => void;
|
addDraft: (draft: Omit<InlineCommentDraft, 'id' | 'createdAt'>) => void;
|
||||||
|
updateDraft: (sessionKey: string, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
|
||||||
removeDraft: (sessionKey: string, draftId: string) => void;
|
removeDraft: (sessionKey: string, draftId: string) => void;
|
||||||
clearDrafts: (sessionKey: string) => void;
|
clearDrafts: (sessionKey: string) => void;
|
||||||
getDrafts: (sessionKey: string) => InlineCommentDraft[];
|
getDrafts: (sessionKey: string) => InlineCommentDraft[];
|
||||||
@@ -61,6 +62,28 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
|||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateDraft: (sessionKey, draftId, updates) => {
|
||||||
|
set((state) => {
|
||||||
|
const currentDrafts = state.drafts[sessionKey] ?? [];
|
||||||
|
const newDrafts = currentDrafts.map((draft) => {
|
||||||
|
if (draft.id !== draftId) {
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[sessionKey]: newDrafts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
removeDraft: (sessionKey, draftId) => {
|
removeDraft: (sessionKey, draftId) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentDrafts = state.drafts[sessionKey] ?? [];
|
const currentDrafts = state.drafts[sessionKey] ?? [];
|
||||||
|
|||||||
Reference in New Issue
Block a user