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:
Nelson Pires
2026-02-06 02:51:03 +02:00
committed by GitHub
parent fd08cc4e5d
commit 16d7a1820b
4 changed files with 217 additions and 39 deletions
@@ -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 } from '@remixicon/react';
import { RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -173,7 +173,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
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);
@@ -208,6 +211,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
// 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);
@@ -281,6 +285,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
setSelection(range);
if (!range) {
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}
}, [isMobile]);
@@ -309,6 +315,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
if (!isLineNumber) {
setSelection(null);
setCommentText('');
setEditingDraftId(null);
setPendingFocus(false);
}
};
@@ -326,8 +334,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
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]);
const handleSaveComment = useCallback(() => {
if (!selection || !commentText.trim()) return;
@@ -340,24 +365,52 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const code = extractSelectedCode(original, modified, selection);
const side = selection.side === 'deletions' ? 'original' : 'modified';
addDraft({
sessionKey,
source: 'diff',
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
text: commentText.trim(),
});
if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, {
fileLabel: fileName,
startLine: selection.start,
endLine: selection.end,
side,
code,
language,
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
setCommentText('');
setSelection(null);
setEditingDraftId(null);
toast.success('Comment saved');
}, [selection, commentText, original, modified, fileName, language, addDraft, getSessionKey]);
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
}, [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(darkTheme);
@@ -558,6 +611,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
>
{/* Textarea - auto-grows from 1 line to max 5 lines */}
<Textarea
ref={commentInputRef}
value={commentText}
onChange={(e) => {
setCommentText(e.target.value);
@@ -616,7 +670,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
color: currentTheme?.colors?.status?.successForeground,
}}
>
Comment
{editingDraftId ? 'Save' : 'Comment'}
</Button>
</div>
</div>
@@ -670,7 +724,26 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<RiMoreLine className="h-4 w-4" />
</button>
</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
onClick={() => removeDraft(draft.sessionKey, draft.id)}
className="text-destructive"