fix: preserve inline comment drafts on focus changes

Keeps typed or pasted comment text when the editor remounts
Prevents focus changes from dismissing active inline comments
Allows cancelling by pressing the selected line number again
This commit is contained in:
Bohdan Triapitsyn
2026-06-13 00:48:42 +03:00
parent 20db4cbb27
commit 823cefd4b5
6 changed files with 79 additions and 18 deletions
@@ -14,6 +14,7 @@ interface CodeMirrorCommentWidgetsOptions {
drafts: InlineCommentDraft[]; drafts: InlineCommentDraft[];
editingDraftId: string | null; editingDraftId: string | null;
commentText: string; commentText: string;
onTextChange: (text: string) => void;
selection: LineRange | null; selection: LineRange | null;
isDragging: boolean; isDragging: boolean;
fileLabel: string; fileLabel: string;
@@ -30,6 +31,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO
drafts, drafts,
editingDraftId, editingDraftId,
commentText, commentText,
onTextChange,
selection, selection,
isDragging, isDragging,
fileLabel, fileLabel,
@@ -53,6 +55,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO
<InlineCommentInput <InlineCommentInput
key={`edit-${draft.id}`} key={`edit-${draft.id}`}
initialText={commentText} initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel} fileLabel={fileLabel}
lineRange={draftRange} lineRange={draftRange}
isEditing={true} isEditing={true}
@@ -92,6 +95,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO
<InlineCommentInput <InlineCommentInput
key={newWidgetId} key={newWidgetId}
initialText={commentText} initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel} fileLabel={fileLabel}
lineRange={normalizedSelection} lineRange={normalizedSelection}
isEditing={false} isEditing={false}
@@ -8,6 +8,7 @@ import { useI18n } from '@/lib/i18n';
export interface InlineCommentInputProps { export interface InlineCommentInputProps {
initialText?: string; initialText?: string;
onTextChange?: (text: string) => void;
onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void; onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void;
onCancel: () => void; onCancel: () => void;
fileLabel?: string; fileLabel?: string;
@@ -19,6 +20,7 @@ export interface InlineCommentInputProps {
export function InlineCommentInput({ export function InlineCommentInput({
initialText = '', initialText = '',
onTextChange,
onSave, onSave,
onCancel, onCancel,
fileLabel, fileLabel,
@@ -33,6 +35,11 @@ export function InlineCommentInput({
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText); const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleTextChange = (value: string) => {
setText(value);
onTextChange?.(value);
};
// Stable range snapshot to prevent race with selection clearing // Stable range snapshot to prevent race with selection clearing
const stableRangeRef = useRef(lineRange); const stableRangeRef = useRef(lineRange);
@@ -145,7 +152,7 @@ export function InlineCommentInput({
simple simple
ref={textareaRef} ref={textareaRef}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')}
outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]" outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]"
@@ -12,6 +12,7 @@ interface PierreDiffCommentOverlaysProps {
selection: SelectedLineRange | null; selection: SelectedLineRange | null;
editingDraftId: string | null; editingDraftId: string | null;
commentText: string; commentText: string;
onTextChange: (text: string) => void;
fileLabel: string; fileLabel: string;
onSave: (text: string, range?: SelectedLineRange) => void; onSave: (text: string, range?: SelectedLineRange) => void;
onCancel: () => void; onCancel: () => void;
@@ -48,6 +49,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps)
selection, selection,
editingDraftId, editingDraftId,
commentText, commentText,
onTextChange,
fileLabel, fileLabel,
onSave, onSave,
onCancel, onCancel,
@@ -178,6 +180,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps)
return createPortal( return createPortal(
<InlineCommentInput <InlineCommentInput
initialText={commentText} initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel} fileLabel={fileLabel}
lineRange={{ lineRange={{
start: draft.startLine, start: draft.startLine,
@@ -214,6 +217,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps)
return createPortal( return createPortal(
<InlineCommentInput <InlineCommentInput
initialText={commentText} initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel} fileLabel={fileLabel}
lineRange={selection} lineRange={selection}
isEditing={false} isEditing={false}
+31 -13
View File
@@ -990,9 +990,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}); });
const { const {
drafts: filesFileDrafts, drafts: filesFileDrafts,
commentText, commentText,
editingDraftId, setCommentText,
editingDraftId,
setSelection: setCommentSelection, setSelection: setCommentSelection,
saveComment, saveComment,
cancel, cancel,
@@ -1023,8 +1024,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (target.closest('.cm-gutterElement')) return; if (target.closest('.cm-gutterElement')) return;
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); if (!commentText.trim()) {
cancel(); setLineSelection(null);
cancel();
}
}; };
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -1035,7 +1038,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside); document.removeEventListener('click', handleClickOutside);
}; };
}, [cancel, editingDraftId, lineSelection]); }, [cancel, commentText, editingDraftId, lineSelection]);
const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => {
const finalRange = range ?? lineSelection ?? undefined; const finalRange = range ?? lineSelection ?? undefined;
@@ -2964,9 +2967,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const blockWidgets = React.useMemo(() => { const blockWidgets = React.useMemo(() => {
return buildCodeMirrorCommentWidgets({ return buildCodeMirrorCommentWidgets({
drafts: filesFileDrafts, drafts: filesFileDrafts,
editingDraftId, editingDraftId,
commentText, commentText,
selection: lineSelection, onTextChange: setCommentText,
selection: lineSelection,
isDragging, isDragging,
fileLabel: selectedFile?.path ?? '', fileLabel: selectedFile?.path ?? '',
newWidgetId: 'files-new-comment-input', newWidgetId: 'files-new-comment-input',
@@ -2982,7 +2986,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, },
onDelete: deleteDraft, onDelete: deleteDraft,
}); });
}, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, startEdit]); }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, setCommentText, startEdit]);
const renderShikiFileView = React.useCallback((file: FileNode, content: string) => { const renderShikiFileView = React.useCallback((file: FileNode, content: string) => {
return ( return (
@@ -3762,9 +3766,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} }
event.preventDefault(); event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number; const lineNumber = view.state.doc.lineAt(line.from).number;
// Mobile: tap-to-extend selection if (
lineSelection &&
!event.shiftKey &&
Math.min(lineSelection.start, lineSelection.end) === lineNumber &&
Math.max(lineSelection.start, lineSelection.end) === lineNumber
) {
setLineSelection(null);
cancel();
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return true;
}
// Mobile: tap-to-extend selection
if (isMobile && lineSelection && !event.shiftKey) { if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
@@ -253,6 +253,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const selectionRef = useRef<SelectedLineRange | null>(null); const selectionRef = useRef<SelectedLineRange | null>(null);
const editingDraftIdRef = useRef<string | null>(null); const editingDraftIdRef = useRef<string | null>(null);
const commentTextRef = useRef('');
// Use a ref to track if we're currently applying a selection programmatically // Use a ref to track if we're currently applying a selection programmatically
// to avoid loop with onLineSelected callback // to avoid loop with onLineSelected callback
const isApplyingSelectionRef = useRef(false); const isApplyingSelectionRef = useRef(false);
@@ -266,6 +267,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
editingDraftIdRef.current = editingDraftId; editingDraftIdRef.current = editingDraftId;
}, [editingDraftId]); }, [editingDraftId]);
useEffect(() => {
commentTextRef.current = commentText;
}, [commentText]);
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => { const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
// Ignore callbacks while we're programmatically applying selection // Ignore callbacks while we're programmatically applying selection
if (isApplyingSelectionRef.current) { if (isApplyingSelectionRef.current) {
@@ -274,6 +279,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const prevSelection = selectionRef.current; const prevSelection = selectionRef.current;
if (!range && prevSelection && commentTextRef.current.trim()) {
return;
}
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range // Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
if (isMobile && prevSelection && range && range.side === prevSelection.side) { if (isMobile && prevSelection && range && range.side === prevSelection.side) {
const start = Math.min(prevSelection.start, range.start); const start = Math.min(prevSelection.start, range.start);
@@ -715,6 +724,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
selection={selection} selection={selection}
editingDraftId={editingDraftId} editingDraftId={editingDraftId}
commentText={commentText} commentText={commentText}
onTextChange={setCommentText}
fileLabel={(fileName?.split('/').pop()) ?? ''} fileLabel={(fileName?.split('/').pop()) ?? ''}
onSave={handleSaveComment} onSave={handleSaveComment}
onCancel={handleCancelComment} onCancel={handleCancelComment}
+22 -4
View File
@@ -277,6 +277,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const { const {
drafts: planFileDrafts, drafts: planFileDrafts,
commentText, commentText,
setCommentText,
editingDraftId, editingDraftId,
setSelection: setCommentSelection, setSelection: setCommentSelection,
saveComment, saveComment,
@@ -328,8 +329,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
if (target.closest('.cm-gutterElement')) return; if (target.closest('.cm-gutterElement')) return;
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); if (!commentText.trim()) {
cancel(); setLineSelection(null);
cancel();
}
}; };
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
@@ -340,7 +343,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside); document.removeEventListener('click', handleClickOutside);
}; };
}, [cancel, editingDraftId, isMobile, lineSelection]); }, [cancel, commentText, editingDraftId, isMobile, lineSelection]);
const editorExtensions = React.useMemo(() => { const editorExtensions = React.useMemo(() => {
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
@@ -602,6 +605,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
drafts: planFileDrafts, drafts: planFileDrafts,
editingDraftId, editingDraftId,
commentText, commentText,
onTextChange: setCommentText,
selection: lineSelection, selection: lineSelection,
isDragging, isDragging,
fileLabel: planFileLabel, fileLabel: planFileLabel,
@@ -615,7 +619,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
}, },
onDelete: deleteDraft, onDelete: deleteDraft,
}); });
}, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, startEdit]); }, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, setCommentText, startEdit]);
return ( return (
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background"> <div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
@@ -811,6 +815,20 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
event.preventDefault(); event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number; const lineNumber = view.state.doc.lineAt(line.from).number;
if (
lineSelection &&
!event.shiftKey &&
Math.min(lineSelection.start, lineSelection.end) === lineNumber &&
Math.max(lineSelection.start, lineSelection.end) === lineNumber
) {
setLineSelection(null);
cancel();
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return true;
}
if (isMobile && lineSelection && !event.shiftKey) { if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);