Files
kandev/apps/web/components/editors/monaco/monaco-code-editor.tsx
T

246 lines
7.4 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import Editor from "@monaco-editor/react";
import { useTheme } from "next-themes";
import { Button } from "@kandev/ui/button";
import { IconMessagePlus } from "@tabler/icons-react";
import { getMonacoLanguage } from "@/lib/editor/language-map";
import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from "@/lib/theme/editor-theme";
import { CommentForm } from "@/components/diff/comment-form";
import { CommentDisplay } from "@/components/diff/comment-display";
import { useEditorViewZoneComments } from "@/hooks/use-editor-view-zone-comments";
import { MonacoEditorToolbar } from "./monaco-editor-toolbar";
import { useMonacoEditorComments } from "./use-monaco-editor-state";
import { useMonacoEditorLsp, useMonacoDiffDecorations } from "./use-monaco-editor-lsp";
import { initMonacoThemes } from "./monaco-init";
initMonacoThemes();
type MonacoCodeEditorProps = {
path: string;
content: string;
originalContent: string;
isDirty: boolean;
hasRemoteUpdate?: boolean;
vcsDiff?: string;
isSaving: boolean;
sessionId?: string;
worktreePath?: string;
enableComments?: boolean;
onToggleMarkdownPreview?: () => void;
onChange: (newContent: string) => void;
onSave: () => void;
onReloadFromAgent?: () => void;
onDelete?: () => void;
};
function getMonacoTheme(resolvedTheme: string | undefined): string {
return resolvedTheme === "dark" ? "kandev-dark" : "kandev-light";
}
const EDITOR_OPTIONS = {
fontSize: EDITOR_FONT_SIZE,
fontFamily: EDITOR_FONT_FAMILY,
lineHeight: 18,
minimap: { enabled: false },
scrollBeyondLastLine: false,
smoothScrolling: true,
cursorSmoothCaretAnimation: "on" as const,
glyphMargin: false,
lineDecorationsWidth: 10,
folding: true,
lineNumbers: "on" as const,
renderLineHighlight: "line" as const,
automaticLayout: true,
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
padding: { top: 4 },
"semanticHighlighting.enabled": true,
};
type EditorState = ReturnType<typeof useMonacoEditorComments>;
function buildCommentZones(
state: EditorState,
addZone: (line: number, height: number, node: React.ReactNode) => void,
) {
for (const comment of state.comments) {
const isEditing = state.editingCommentId === comment.id;
const node = isEditing ? (
<div className="px-2 py-0.5" data-comment-zone>
<CommentForm
initialContent={comment.text}
onSubmit={(c) => state.handleCommentUpdateRef.current(comment.id, c)}
onCancel={() => state.setEditingComment(null)}
isEditing
/>
</div>
) : (
<div className="px-2 py-0.5" data-comment-zone>
<CommentDisplay
comment={comment}
onDelete={() => state.handleCommentDeleteRef.current(comment.id)}
onEdit={() => state.setEditingComment(comment.id)}
onRun={() => state.handleCommentRunRef.current(comment)}
showCode={false}
compact
/>
</div>
);
addZone(comment.endLine, isEditing ? 120 : 32, node);
}
if (state.formZoneRange) {
addZone(
state.formZoneRange.endLine,
120,
<div className="px-2 py-1" data-comment-zone>
<CommentForm
onSubmit={(c) => state.handleCommentSubmitRef.current(c)}
onSubmitAndRun={(c) => state.handleCommentSubmitAndRunRef.current(c)}
onCancel={() => {
state.setFormZoneRange(null);
state.clearGutterSelection();
}}
/>
</div>,
);
}
}
function useMonacoCodeEditorSetup(props: MonacoCodeEditorProps) {
const {
path,
content,
originalContent,
isDirty,
vcsDiff,
sessionId,
worktreePath,
enableComments = false,
onChange,
onSave,
} = props;
const contentRef = useRef(content);
const wrapperRef = useRef<HTMLDivElement>(null);
const language = getMonacoLanguage(path);
const state = useMonacoEditorComments({
path,
enableComments,
sessionId,
wrapperRef,
onChange,
onSave,
contentRef,
});
const lsp = useMonacoEditorLsp({
sessionId,
worktreePath,
language,
path,
contentRef,
editorRef: state.editorRef,
});
const { diffStats } = useMonacoDiffDecorations({
originalContent,
isDirty,
showDiffIndicators: state.showDiffIndicators,
vcsDiff,
editorReady: state.editorInstance,
contentRef,
editorRef: state.editorRef,
diffDecorationsRef: state.diffDecorationsRef,
});
useEffect(() => {
contentRef.current = content;
}, [content]);
useEditorViewZoneComments(
state.editorInstance,
[state.comments, state.formZoneRange, state.editingCommentId],
(addZone) => buildCommentZones(state, addZone),
);
const options = {
...EDITOR_OPTIONS,
wordWrap: state.wrapEnabled ? ("on" as const) : ("off" as const),
};
return { contentRef, wrapperRef, language, state, lsp, diffStats, options };
}
export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
const {
path,
content,
isDirty,
hasRemoteUpdate = false,
vcsDiff,
isSaving,
sessionId,
worktreePath,
enableComments = false,
onToggleMarkdownPreview,
onSave,
onReloadFromAgent,
onDelete,
} = props;
const { resolvedTheme } = useTheme();
const { wrapperRef, language, state, lsp, diffStats, options } = useMonacoCodeEditorSetup(props);
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
<MonacoEditorToolbar
path={path}
worktreePath={worktreePath}
isDirty={isDirty}
isSaving={isSaving}
diffStats={diffStats}
wrapEnabled={state.wrapEnabled}
showDiffIndicators={state.showDiffIndicators}
enableComments={enableComments}
sessionId={sessionId}
commentCount={state.comments.length}
hasRemoteUpdate={hasRemoteUpdate}
hasVcsDiff={Boolean(vcsDiff)}
lspStatus={lsp.lspStatus}
lspLanguage={lsp.lspLanguage}
onToggleLsp={lsp.toggleLsp}
onToggleWrap={() => state.setWrapEnabled(!state.wrapEnabled)}
onToggleDiffIndicators={() => state.setShowDiffIndicators(!state.showDiffIndicators)}
onSave={onSave}
onReloadFromAgent={onReloadFromAgent}
onDelete={onDelete}
onToggleMarkdownPreview={onToggleMarkdownPreview}
/>
<div className="flex-1 overflow-hidden relative">
<Editor
height="100%"
language={language}
path={lsp.monacoPath}
value={content}
theme={getMonacoTheme(resolvedTheme)}
onChange={state.handleChange}
onMount={state.handleEditorDidMount}
keepCurrentModel
options={options}
loading={
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading editor...
</div>
}
/>
{state.floatingButtonPos && !state.formZoneRange && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn absolute z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{ left: state.floatingButtonPos.x + 4, top: state.floatingButtonPos.y + 2 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={state.handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
</div>
</div>
);
}