353 lines
11 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|