Files
kandev/apps/web/components/editors/monaco/monaco-code-editor.tsx
T
2026-09-15 12:20:54 +01:00

353 lines
11 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import Editor from "@monaco-editor/react";
import { useTheme } from "@/components/theme/app-theme";
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 { useLspStatusPlacement } from "@/hooks/use-lsp-status-placement";
import { MonacoEditorToolbar } from "./monaco-editor-toolbar";
import { useMonacoEditorComments } from "./use-monaco-editor-state";
import { useMonacoEditorLsp, useMonacoDiffDecorations } from "./use-monaco-editor-lsp";
import { useMonacoWalkthroughRange } from "./use-monaco-walkthrough-range";
import { useMonacoCursorNavigation } from "./use-monaco-cursor-navigation";
import { initMonacoThemes } from "./monaco-init";
import { useTranslation } from "react-i18next";
import type { FilePreviewKind } from "@/lib/utils/file-types";
initMonacoThemes();
type MonacoCodeEditorProps = {
path: string;
content: string;
originalContent: string;
isDirty: boolean;
hasRemoteUpdate?: boolean;
vcsDiff?: string;
isSaving: boolean;
sessionId?: string;
worktreePath?: string;
isSymlink?: boolean;
repo?: string;
enableComments?: boolean;
previewKind?: FilePreviewKind;
onTogglePreview?: () => void;
onPreviewHtml?: () => void;
isPublishingHtmlPreview?: boolean;
onChange: (newContent: string) => void;
onSave: () => void;
onReloadFromAgent?: () => void;
onDelete?: () => void;
onDownload?: () => 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,
repo,
enableComments = false,
onChange,
onSave,
} = props;
const contentRef = useRef(content);
useEffect(() => {
contentRef.current = content;
}, [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const language = getMonacoLanguage(path);
const state = useMonacoEditorComments({
path,
repo,
enableComments,
sessionId,
wrapperRef,
onChange,
onSave,
contentRef,
});
const lsp = useMonacoEditorLsp({
sessionId,
worktreePath,
repo,
language,
path,
contentRef,
editorRef: state.editorRef,
editorReady: state.editorInstance !== null,
});
useMonacoCursorNavigation({
editor: state.editorInstance,
path,
worktreePath,
repo,
sessionId,
});
const { diffStats } = useMonacoDiffDecorations({
originalContent,
isDirty,
showDiffIndicators: state.showDiffIndicators,
vcsDiff,
editorReady: state.editorInstance,
contentRef,
editorRef: state.editorRef,
diffDecorationsRef: state.diffDecorationsRef,
});
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 };
}
function EditorLoading() {
const { t } = useTranslation();
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
{t("editors:loadingEditor")}
</div>
);
}
function MonacoFloatingCommentButton({ state }: { state: EditorState }) {
const { t } = useTranslation();
if (!state.floatingButtonPos || state.formZoneRange) return null;
return (
<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" />
{t("editors:comment")}
</Button>
);
}
type MonacoEditorViewProps = {
props: MonacoCodeEditorProps;
wrapperRef: React.RefObject<HTMLDivElement | null>;
editorAreaRef: React.RefObject<HTMLDivElement | null>;
language: string;
state: EditorState;
lsp: ReturnType<typeof useMonacoEditorLsp>;
diffStats: { additions: number; deletions: number } | null;
options: typeof EDITOR_OPTIONS & { wordWrap: "on" | "off" };
resolvedTheme: string | undefined;
showLspStatus: boolean;
walkthroughRange: ReturnType<typeof useMonacoWalkthroughRange>;
};
function MonacoEditorView({
props,
wrapperRef,
editorAreaRef,
language,
state,
lsp,
diffStats,
options,
resolvedTheme,
showLspStatus,
walkthroughRange,
}: MonacoEditorViewProps) {
const {
path,
content,
isDirty,
hasRemoteUpdate = false,
vcsDiff,
isSaving,
sessionId,
worktreePath,
repo,
enableComments = false,
} = props;
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
<MonacoEditorToolbar
isSymlink={props.isSymlink}
path={path}
repositoryName={repo}
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}
lspProgress={lsp.lspProgress}
lspLanguage={lsp.lspLanguage}
showLspStatus={showLspStatus}
onToggleLsp={lsp.toggleLsp}
onToggleWrap={() => state.setWrapEnabled(!state.wrapEnabled)}
onToggleDiffIndicators={() => state.setShowDiffIndicators(!state.showDiffIndicators)}
onSave={props.onSave}
onReloadFromAgent={props.onReloadFromAgent}
onDelete={props.onDelete}
onDownload={props.onDownload}
previewKind={props.previewKind}
onTogglePreview={props.onTogglePreview}
onPreviewHtml={props.onPreviewHtml}
isPublishingHtmlPreview={props.isPublishingHtmlPreview}
/>
<div className="flex-1 overflow-hidden relative" ref={editorAreaRef}>
<Editor
height="100%"
language={language}
path={lsp.monacoPath}
value={content}
theme={getMonacoTheme(resolvedTheme)}
onChange={state.handleChange}
onMount={state.handleEditorDidMount}
keepCurrentModel
options={options}
loading={<EditorLoading />}
/>
{walkthroughRange ? (
<div
aria-hidden="true"
data-testid="walkthrough-editor-range"
data-line-range={`${walkthroughRange.startLine}-${walkthroughRange.endLine}`}
className="pointer-events-none absolute z-20 rounded-sm border-l-2 border-primary/70 bg-primary/10"
style={{
top: walkthroughRange.top,
left: walkthroughRange.left,
width: walkthroughRange.width,
height: walkthroughRange.height,
boxShadow: "inset 0 0 0 1px color-mix(in oklab, var(--primary) 22%, transparent)",
}}
/>
) : null}
<MonacoFloatingCommentButton state={state} />
</div>
</div>
);
}
export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
const { path, repo } = props;
const { wrapperRef, language, state, lsp, diffStats, options } = useMonacoCodeEditorSetup(props);
const { resolvedTheme } = useTheme();
const lspStatusPlacement = useLspStatusPlacement();
const editorAreaRef = useRef<HTMLDivElement>(null);
const walkthroughRange = useMonacoWalkthroughRange({
editor: state.editorInstance,
editorAreaRef,
path,
repo,
});
return (
<MonacoEditorView
props={props}
wrapperRef={wrapperRef}
editorAreaRef={editorAreaRef}
language={language}
state={state}
lsp={lsp}
diffStats={diffStats}
options={options}
resolvedTheme={resolvedTheme}
showLspStatus={lspStatusPlacement === "toolbar"}
walkthroughRange={walkthroughRange}
/>
);
}