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[];
editingDraftId: string | null;
commentText: string;
onTextChange: (text: string) => void;
selection: LineRange | null;
isDragging: boolean;
fileLabel: string;
@@ -30,6 +31,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO
drafts,
editingDraftId,
commentText,
onTextChange,
selection,
isDragging,
fileLabel,
@@ -53,6 +55,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO
<InlineCommentInput
key={`edit-${draft.id}`}
initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel}
lineRange={draftRange}
isEditing={true}
@@ -92,6 +95,7 @@ export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsO
<InlineCommentInput
key={newWidgetId}
initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel}
lineRange={normalizedSelection}
isEditing={false}
@@ -8,6 +8,7 @@ import { useI18n } from '@/lib/i18n';
export interface InlineCommentInputProps {
initialText?: string;
onTextChange?: (text: string) => void;
onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void;
onCancel: () => void;
fileLabel?: string;
@@ -19,6 +20,7 @@ export interface InlineCommentInputProps {
export function InlineCommentInput({
initialText = '',
onTextChange,
onSave,
onCancel,
fileLabel,
@@ -33,6 +35,11 @@ export function InlineCommentInput({
const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleTextChange = (value: string) => {
setText(value);
onTextChange?.(value);
};
// Stable range snapshot to prevent race with selection clearing
const stableRangeRef = useRef(lineRange);
@@ -145,7 +152,7 @@ export function InlineCommentInput({
simple
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown}
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)]"
@@ -12,6 +12,7 @@ interface PierreDiffCommentOverlaysProps {
selection: SelectedLineRange | null;
editingDraftId: string | null;
commentText: string;
onTextChange: (text: string) => void;
fileLabel: string;
onSave: (text: string, range?: SelectedLineRange) => void;
onCancel: () => void;
@@ -48,6 +49,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps)
selection,
editingDraftId,
commentText,
onTextChange,
fileLabel,
onSave,
onCancel,
@@ -178,6 +180,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps)
return createPortal(
<InlineCommentInput
initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel}
lineRange={{
start: draft.startLine,
@@ -214,6 +217,7 @@ export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps)
return createPortal(
<InlineCommentInput
initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel}
lineRange={selection}
isEditing={false}
+31 -13
View File
@@ -990,9 +990,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
});
const {
drafts: filesFileDrafts,
commentText,
editingDraftId,
drafts: filesFileDrafts,
commentText,
setCommentText,
editingDraftId,
setSelection: setCommentSelection,
saveComment,
cancel,
@@ -1023,8 +1024,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (target.closest('.cm-gutterElement')) return;
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
setLineSelection(null);
cancel();
if (!commentText.trim()) {
setLineSelection(null);
cancel();
}
};
const timeoutId = setTimeout(() => {
@@ -1035,7 +1038,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [cancel, editingDraftId, lineSelection]);
}, [cancel, commentText, editingDraftId, lineSelection]);
const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => {
const finalRange = range ?? lineSelection ?? undefined;
@@ -2964,9 +2967,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const blockWidgets = React.useMemo(() => {
return buildCodeMirrorCommentWidgets({
drafts: filesFileDrafts,
editingDraftId,
commentText,
selection: lineSelection,
editingDraftId,
commentText,
onTextChange: setCommentText,
selection: lineSelection,
isDragging,
fileLabel: selectedFile?.path ?? '',
newWidgetId: 'files-new-comment-input',
@@ -2982,7 +2986,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
},
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) => {
return (
@@ -3762,9 +3766,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number;
// Mobile: tap-to-extend selection
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;
}
// Mobile: tap-to-extend selection
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(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 editingDraftIdRef = useRef<string | null>(null);
const commentTextRef = useRef('');
// Use a ref to track if we're currently applying a selection programmatically
// to avoid loop with onLineSelected callback
const isApplyingSelectionRef = useRef(false);
@@ -266,6 +267,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
editingDraftIdRef.current = editingDraftId;
}, [editingDraftId]);
useEffect(() => {
commentTextRef.current = commentText;
}, [commentText]);
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
// Ignore callbacks while we're programmatically applying selection
if (isApplyingSelectionRef.current) {
@@ -274,6 +279,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
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
if (isMobile && prevSelection && range && range.side === prevSelection.side) {
const start = Math.min(prevSelection.start, range.start);
@@ -715,6 +724,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
selection={selection}
editingDraftId={editingDraftId}
commentText={commentText}
onTextChange={setCommentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
onSave={handleSaveComment}
onCancel={handleCancelComment}
+22 -4
View File
@@ -277,6 +277,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const {
drafts: planFileDrafts,
commentText,
setCommentText,
editingDraftId,
setSelection: setCommentSelection,
saveComment,
@@ -328,8 +329,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
if (target.closest('.cm-gutterElement')) return;
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
setLineSelection(null);
cancel();
if (!commentText.trim()) {
setLineSelection(null);
cancel();
}
};
const timeoutId = window.setTimeout(() => {
@@ -340,7 +343,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
window.clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [cancel, editingDraftId, isMobile, lineSelection]);
}, [cancel, commentText, editingDraftId, isMobile, lineSelection]);
const editorExtensions = React.useMemo(() => {
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
@@ -602,6 +605,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
drafts: planFileDrafts,
editingDraftId,
commentText,
onTextChange: setCommentText,
selection: lineSelection,
isDragging,
fileLabel: planFileLabel,
@@ -615,7 +619,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
},
onDelete: deleteDraft,
});
}, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, startEdit]);
}, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, setCommentText, startEdit]);
return (
<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();
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) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);