Files
kandev/apps/web/components/editors/monaco/use-diff-viewer-comments.ts
T

337 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { editor as monacoEditor } from "monaco-editor";
import type { DiffOnMount } from "@monaco-editor/react";
import type { DiffComment } from "@/lib/diff/types";
import { buildDiffComment, useCommentedLines, useCommentActions } from "@/lib/diff/comment-utils";
import { useDiffComments } from "@/components/diff/use-diff-comments";
import { useGutterComments } from "@/hooks/use-gutter-comments";
import type { ContextMenuState } from "./diff-viewer-context-menu";
import { useViewZones } from "./use-diff-view-zones";
/** Check if a line number falls within a changed region on the given side. */
function isLineInChanges(
lineChanges: ReturnType<monacoEditor.IStandaloneDiffEditor["getLineChanges"]>,
lineNumber: number,
side: "original" | "modified",
): boolean {
if (!lineChanges) return false;
for (const change of lineChanges) {
const [start, end] =
side === "modified"
? [change.modifiedStartLineNumber, change.modifiedEndLineNumber]
: [change.originalStartLineNumber, change.originalEndLineNumber];
if (lineNumber >= start && lineNumber <= end) return true;
}
return false;
}
interface UseDiffViewerCommentsOpts {
data: { filePath: string; diff?: string; newContent?: string; oldContent?: string };
sessionId?: string;
compact: boolean;
onCommentAdd?: (comment: DiffComment) => void;
onCommentDelete?: (commentId: string) => void;
onCommentRun?: (comment: DiffComment) => void;
externalComments?: DiffComment[];
onModifiedContentChange?: (filePath: string, content: string) => void;
}
// eslint-disable-next-line max-lines-per-function
export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
const {
data,
sessionId,
compact,
onCommentAdd,
onCommentDelete,
onCommentRun,
externalComments,
onModifiedContentChange,
} = opts;
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null);
const [modifiedEditor, setModifiedEditor] = useState<monacoEditor.ICodeEditor | null>(null);
const [originalEditor, setOriginalEditor] = useState<monacoEditor.ICodeEditor | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const [showCommentForm, setShowCommentForm] = useState(false);
const [selectedLineRange, setSelectedLineRange] = useState<{
start: number;
end: number;
side: string;
} | null>(null);
const {
comments: internalComments,
addComment,
removeComment,
updateComment,
editingCommentId,
setEditingComment,
} = useDiffComments({
sessionId: sessionId || "",
filePath: data.filePath,
diff: data.diff,
newContent: data.newContent,
oldContent: data.oldContent,
});
const comments = externalComments || internalComments;
// Gutter comment interactions
const gutterEnabled = !!sessionId && !compact;
const commentedLines = useCommentedLines(comments);
const handleGutterSelect = useCallback(
(side: "additions" | "deletions") =>
(params: {
range: { start: number; end: number };
code: string;
position: { x: number; y: number };
}) => {
setSelectedLineRange({ start: params.range.start, end: params.range.end, side });
setShowCommentForm(true);
},
[],
);
const { clearGutterSelection: clearModifiedGutter } = useGutterComments(modifiedEditor, {
enabled: gutterEnabled,
commentedLines,
onSelectionComplete: handleGutterSelect("additions"),
});
const { clearGutterSelection: clearOriginalGutter } = useGutterComments(originalEditor, {
enabled: gutterEnabled,
commentedLines,
onSelectionComplete: handleGutterSelect("deletions"),
});
// Stable ref for onModifiedContentChange
const onModifiedContentChangeRef = useRef(onModifiedContentChange);
useEffect(() => {
onModifiedContentChangeRef.current = onModifiedContentChange;
}, [onModifiedContentChange]);
const handleContextMenuEvent = useCallback(
(
diffEditor: monacoEditor.IStandaloneDiffEditor,
targetEditor: monacoEditor.ICodeEditor,
side: "original" | "modified",
) =>
(e: monacoEditor.IEditorMouseEvent) => {
if (!e.target.position) return;
e.event.preventDefault();
e.event.stopPropagation();
const lineNumber = e.target.position.lineNumber;
const model = targetEditor.getModel();
const lineContent = model ? model.getLineContent(lineNumber) : "";
const isChangedLine = isLineInChanges(diffEditor.getLineChanges(), lineNumber, side);
setContextMenu({
x: e.event.posx,
y: e.event.posy,
lineNumber,
side,
isChangedLine,
lineContent,
});
},
[],
);
const handleDiffEditorMount: DiffOnMount = useCallback(
(editor) => {
diffEditorRef.current = editor;
const modEditor = editor.getModifiedEditor();
const origEditor = editor.getOriginalEditor();
setModifiedEditor(modEditor);
setOriginalEditor(origEditor);
if (!compact) {
modEditor.onDidChangeModelContent(() => {
onModifiedContentChangeRef.current?.(data.filePath, modEditor.getValue());
});
}
modEditor.onContextMenu(handleContextMenuEvent(editor, modEditor, "modified"));
origEditor.onContextMenu(handleContextMenuEvent(editor, origEditor, "original"));
},
[compact, data.filePath, handleContextMenuEvent],
);
// Comment submission
const handleCommentSubmit = useCallback(
(content: string) => {
if (!selectedLineRange) return;
if (onCommentAdd && externalComments !== undefined) {
onCommentAdd(
buildDiffComment({
filePath: data.filePath,
sessionId: sessionId || "",
startLine: selectedLineRange.start,
endLine: selectedLineRange.end,
side: (selectedLineRange.side || "additions") as DiffComment["side"],
text: content,
}),
);
} else if (sessionId) {
addComment(
{
start: selectedLineRange.start,
end: selectedLineRange.end,
side: selectedLineRange.side as "additions" | "deletions",
},
content,
);
}
setShowCommentForm(false);
setSelectedLineRange(null);
clearModifiedGutter();
clearOriginalGutter();
},
[
selectedLineRange,
sessionId,
data.filePath,
addComment,
onCommentAdd,
externalComments,
clearModifiedGutter,
clearOriginalGutter,
],
);
const handleCommentSubmitAndRun = useCallback(
(content: string) => {
if (!selectedLineRange || !onCommentRun) return;
const comment = buildDiffComment({
filePath: data.filePath,
sessionId: sessionId || "",
startLine: selectedLineRange.start,
endLine: selectedLineRange.end,
side: (selectedLineRange.side || "additions") as DiffComment["side"],
text: content,
});
if (onCommentAdd && externalComments !== undefined) {
onCommentAdd(comment);
} else if (sessionId) {
addComment(
{
start: selectedLineRange.start,
end: selectedLineRange.end,
side: selectedLineRange.side as "additions" | "deletions",
},
content,
);
}
onCommentRun(comment);
setShowCommentForm(false);
setSelectedLineRange(null);
clearModifiedGutter();
clearOriginalGutter();
},
[
selectedLineRange,
sessionId,
data.filePath,
addComment,
onCommentAdd,
onCommentRun,
externalComments,
clearModifiedGutter,
clearOriginalGutter,
],
);
const { handleCommentDelete, handleCommentUpdate } = useCommentActions({
removeComment,
updateComment,
setEditingComment,
onCommentDelete,
externalComments,
});
// Stable refs for ViewZone renders
const handleCommentSubmitRef = useRef(handleCommentSubmit);
useEffect(() => {
handleCommentSubmitRef.current = handleCommentSubmit;
}, [handleCommentSubmit]);
const handleCommentSubmitAndRunRef = useRef(onCommentRun ? handleCommentSubmitAndRun : undefined);
useEffect(() => {
handleCommentSubmitAndRunRef.current = onCommentRun ? handleCommentSubmitAndRun : undefined;
}, [handleCommentSubmitAndRun, onCommentRun]);
const handleCommentDeleteRef = useRef(handleCommentDelete);
useEffect(() => {
handleCommentDeleteRef.current = handleCommentDelete;
}, [handleCommentDelete]);
const handleCommentUpdateRef = useRef(handleCommentUpdate);
useEffect(() => {
handleCommentUpdateRef.current = handleCommentUpdate;
}, [handleCommentUpdate]);
const handleCommentRunRef = useRef(onCommentRun);
useEffect(() => {
handleCommentRunRef.current = onCommentRun;
}, [onCommentRun]);
useViewZones({
modifiedEditor,
originalEditor,
comments,
showCommentForm,
selectedLineRange,
editingCommentId,
setEditingComment,
handleCommentSubmitRef,
handleCommentSubmitAndRunRef,
handleCommentDeleteRef,
handleCommentUpdateRef,
handleCommentRunRef,
clearModifiedGutter,
clearOriginalGutter,
setShowCommentForm,
setSelectedLineRange,
});
// Close context menu on click or Escape
useEffect(() => {
if (!contextMenu) return;
const handleClose = () => setContextMenu(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(null);
};
window.addEventListener("mousedown", handleClose);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("mousedown", handleClose);
window.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu]);
// Copy all changed lines
const copyAllChangedLines = useCallback(() => {
const editor = diffEditorRef.current;
if (!editor) return;
const lineChanges = editor.getLineChanges();
if (!lineChanges) return;
const modifiedModel = editor.getModifiedEditor().getModel();
if (!modifiedModel) return;
const changedLines: string[] = [];
for (const change of lineChanges) {
if (change.modifiedStartLineNumber <= change.modifiedEndLineNumber) {
for (let i = change.modifiedStartLineNumber; i <= change.modifiedEndLineNumber; i++) {
changedLines.push(modifiedModel.getLineContent(i));
}
}
}
navigator.clipboard.writeText(changedLines.join("\n"));
setContextMenu(null);
}, []);
return {
diffEditorRef,
modifiedEditor,
originalEditor,
contextMenu,
setContextMenu,
copyAllChangedLines,
handleDiffEditorMount,
};
}