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;
|
||||
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
|
||||
highlightLines?: { start: number; end: number };
|
||||
onViewReady?: (view: EditorView) => void;
|
||||
onViewDestroy?: () => void;
|
||||
};
|
||||
|
||||
const lineNumbersCompartment = new Compartment();
|
||||
@@ -55,11 +57,23 @@ const createHighlightLinesExtension = (range?: { start: number; end: number }):
|
||||
}, { 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 viewRef = React.useRef<EditorView | null>(null);
|
||||
const valueRef = React.useRef(value);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onViewReadyRef = React.useRef(onViewReady);
|
||||
const onViewDestroyRef = React.useRef(onViewDestroy);
|
||||
|
||||
React.useEffect(() => {
|
||||
valueRef.current = value;
|
||||
@@ -69,6 +83,11 @@ export function CodeMirrorEditor({ value, onChange, extensions, className, readO
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onViewReadyRef.current = onViewReady;
|
||||
onViewDestroyRef.current = onViewDestroy;
|
||||
}, [onViewReady, onViewDestroy]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hostRef.current) {
|
||||
return;
|
||||
@@ -101,7 +120,12 @@ export function CodeMirrorEditor({ value, onChange, extensions, className, readO
|
||||
parent: hostRef.current,
|
||||
});
|
||||
|
||||
if (viewRef.current) {
|
||||
onViewReadyRef.current?.(viewRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
onViewDestroyRef.current?.();
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 } from '@remixicon/react';
|
||||
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -96,9 +97,11 @@ export const PlanView: React.FC = () => {
|
||||
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);
|
||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||
|
||||
@@ -134,6 +137,9 @@ 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';
|
||||
|
||||
@@ -173,8 +179,25 @@ export const PlanView: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
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;
|
||||
|
||||
@@ -186,6 +209,8 @@ export const PlanView: React.FC = () => {
|
||||
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
||||
setLineSelection(null);
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -209,6 +234,8 @@ export const PlanView: React.FC = () => {
|
||||
const handleCancelComment = React.useCallback(() => {
|
||||
setCommentText('');
|
||||
setLineSelection(null);
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}, []);
|
||||
|
||||
const handleSaveComment = React.useCallback(() => {
|
||||
@@ -224,22 +251,34 @@ export const PlanView: React.FC = () => {
|
||||
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
||||
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
|
||||
|
||||
addDraft({
|
||||
sessionKey,
|
||||
source: 'plan',
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
});
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
source: 'plan',
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
setCommentText('');
|
||||
setLineSelection(null);
|
||||
setEditingDraftId(null);
|
||||
|
||||
toast.success('Comment saved');
|
||||
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey, extractSelectedCode]);
|
||||
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
||||
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
||||
|
||||
const editorExtensions = React.useMemo(() => {
|
||||
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)' }}>
|
||||
<Textarea
|
||||
ref={commentInputRef}
|
||||
value={commentText}
|
||||
onChange={(e) => {
|
||||
setCommentText(e.target.value);
|
||||
@@ -414,7 +454,7 @@ export const PlanView: React.FC = () => {
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
{editingDraftId ? 'Save' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -426,6 +466,7 @@ export const PlanView: React.FC = () => {
|
||||
// Render saved comment cards
|
||||
const renderSavedComments = () => {
|
||||
if (mdViewMode === 'preview') return null;
|
||||
if (!editorView) return null;
|
||||
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) return null;
|
||||
@@ -436,13 +477,13 @@ export const PlanView: React.FC = () => {
|
||||
|
||||
if (fileDrafts.length === 0) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="absolute inset-0 pointer-events-none z-40">
|
||||
{fileDrafts.map((draft) => {
|
||||
// For CodeMirror, we need to position based on line
|
||||
// This is a simplified version - in production, you'd query the editor's DOM
|
||||
const lineHeight = 24; // Approximate line height in pixels
|
||||
const top = (draft.startLine - 1) * lineHeight;
|
||||
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
|
||||
@@ -477,7 +518,21 @@ export const PlanView: React.FC = () => {
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</button>
|
||||
</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
|
||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
className="text-destructive"
|
||||
@@ -492,7 +547,8 @@ export const PlanView: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
editorView.scrollDOM
|
||||
);
|
||||
};
|
||||
|
||||
@@ -611,8 +667,10 @@ export const PlanView: React.FC = () => {
|
||||
// read-only
|
||||
}}
|
||||
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}
|
||||
onViewReady={setEditorView}
|
||||
onViewDestroy={() => setEditorView(null)}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
|
||||
@@ -24,6 +24,7 @@ interface InlineCommentDraftState {
|
||||
|
||||
interface InlineCommentDraftActions {
|
||||
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;
|
||||
clearDrafts: (sessionKey: string) => void;
|
||||
getDrafts: (sessionKey: string) => InlineCommentDraft[];
|
||||
@@ -61,6 +62,28 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
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) => {
|
||||
set((state) => {
|
||||
const currentDrafts = state.drafts[sessionKey] ?? [];
|
||||
|
||||
Reference in New Issue
Block a user