'use client'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import Editor, { type OnMount, type OnChange } from '@monaco-editor/react'; import type { editor as monacoEditor, IDisposable } from 'monaco-editor'; import { useTheme } from 'next-themes'; import { Button } from '@kandev/ui/button'; import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus, IconArrowsDiff, IconPlugConnected, IconPlugOff, IconAlertTriangle } from '@tabler/icons-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { formatDiffStats } from '@/lib/utils/file-diff'; import { toRelativePath } from '@/lib/utils'; import { getMonacoLanguage } from '@/lib/editor/language-map'; import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme'; import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice'; import type { DiffComment } from '@/lib/diff/types'; import { useToast } from '@/components/toast-provider'; import { EditorCommentPopover } from '@/components/task/editor-comment-popover'; import { CommentViewPopover } from '@/components/task/comment-view-popover'; import { diffLines } from 'diff'; import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown'; import { useAppStore } from '@/components/state-provider'; import { useLsp } from '@/hooks/use-lsp'; import type { LspStatus } from '@/lib/lsp/lsp-client-manager'; import { lspClientManager } from '@/lib/lsp/lsp-client-manager'; import { consumePendingCursorPosition } from '@/hooks/use-file-editors'; import { initMonacoThemes } from './monaco-init'; initMonacoThemes(); type MonacoCodeEditorProps = { path: string; content: string; originalContent: string; isDirty: boolean; isSaving: boolean; sessionId?: string; worktreePath?: string; enableComments?: boolean; onChange: (newContent: string) => void; onSave: () => void; onDelete?: () => void; }; type TextSelection = { text: string; startLine: number; endLine: number; position: { x: number; y: number }; } | null; type FloatingButtonPosition = { x: number; y: number; } | null; type CommentViewState = { comments: DiffComment[]; position: { x: number; y: number }; } | null; function LspStatusButton({ status, lspLanguage, onToggle, }: { status: LspStatus; lspLanguage: string | null; onToggle: () => void; }) { if (!lspLanguage) return null; type StateConfig = { icon: React.ReactNode; tooltip: string; clickable: boolean }; const configs: Record = { disabled: { icon: , tooltip: 'LSP: Off \u2014 click to start', clickable: true, }, connecting: { icon: , tooltip: 'LSP: Connecting...', clickable: true, }, installing: { icon: , tooltip: 'LSP: Installing language server...', clickable: false, }, starting: { icon: , tooltip: 'LSP: Starting language server...', clickable: true, }, ready: { icon: , tooltip: 'LSP: Connected \u2014 click to stop', clickable: true, }, stopping: { icon: , tooltip: 'LSP: Stopping...', clickable: false, }, unavailable: { icon: , tooltip: `LSP: ${'reason' in status ? status.reason : 'Unavailable'}`, clickable: true, }, error: { icon: , tooltip: `LSP: ${'reason' in status ? status.reason : 'Error'}`, clickable: true, }, }; const c = configs[status.state]; if (!c) return null; return ( {c.tooltip} ); } export function MonacoCodeEditor({ path, content, originalContent, isDirty, isSaving, sessionId, worktreePath, enableComments = false, onChange, onSave, onDelete, }: MonacoCodeEditorProps) { const { resolvedTheme } = useTheme(); const [wrapEnabled, setWrapEnabled] = useState(true); const [textSelection, setTextSelection] = useState(null); const [floatingButtonPos, setFloatingButtonPos] = useState(null); const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null); const [commentView, setCommentView] = useState(null); const [showDiffIndicators, setShowDiffIndicators] = useState(true); const editorRef = useRef(null); const wrapperRef = useRef(null); const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const decorationsRef = useRef(null); const diffDecorationsRef = useRef(null); const disposablesRef = useRef([]); const { toast } = useToast(); const addComment = useDiffCommentsStore((state) => state.addComment); const removeComment = useDiffCommentsStore((state) => state.removeComment); const comments = useFileComments(sessionId ?? '', path); const language = getMonacoLanguage(path); // LSP integration const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const lspSessionId = sessionId ?? activeSessionId ?? null; const { status: lspStatus, lspLanguage, toggle: toggleLsp } = useLsp(lspSessionId, language); const hasLspActive = lspStatus.state === 'ready'; // Use absolute path for Monaco model URI so the LSP server receives correct file:// URIs const monacoPath = worktreePath ? `${worktreePath}/${path}` : path; // LSP document sync: open/close document when LSP becomes ready or component mounts/unmounts const documentUri = `file://${monacoPath}`; useEffect(() => { if (!hasLspActive || !lspSessionId || !lspLanguage) return; lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content); return () => { lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri); }; // Only run on mount/unmount and when LSP readiness changes — NOT on content changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]); // LSP document change: notify server of content changes (debounced) const changeTimerRef = useRef | null>(null); useEffect(() => { if (!hasLspActive || !lspSessionId || !lspLanguage) return; if (changeTimerRef.current) clearTimeout(changeTimerRef.current); changeTimerRef.current = setTimeout(() => { lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, content); }, 300); return () => { if (changeTimerRef.current) clearTimeout(changeTimerRef.current); }; }, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]); // Toast on LSP status changes const lspStateForToast = lspStatus.state; const lspReasonForToast = 'reason' in lspStatus ? lspStatus.reason : null; useEffect(() => { if (lspStateForToast === 'installing') { toast({ title: 'Installing language server', description: 'This may take a moment...', }); } else if (lspStateForToast === 'unavailable' && lspReasonForToast) { toast({ title: 'Language server not found', description: `${lspReasonForToast}. Enable auto-install in Settings → Editors.`, }); } else if (lspStateForToast === 'error' && lspReasonForToast) { toast({ title: 'LSP error', description: lspReasonForToast, }); } }, [lspStateForToast, lspReasonForToast, toast]); // Track mouse position useEffect(() => { const handleMouseMove = (e: MouseEvent) => { mousePositionRef.current = { x: e.clientX, y: e.clientY }; }; document.addEventListener('mousemove', handleMouseMove); return () => document.removeEventListener('mousemove', handleMouseMove); }, []); // Handle editor mount const handleEditorDidMount: OnMount = useCallback( (editor) => { editorRef.current = editor; decorationsRef.current = editor.createDecorationsCollection([]); diffDecorationsRef.current = editor.createDecorationsCollection([]); // Jump to pending cursor position (e.g. from LSP Go-to-Definition) const pendingPos = consumePendingCursorPosition(path); if (pendingPos) { editor.setPosition({ lineNumber: pendingPos.line, column: pendingPos.column }); editor.revealLineInCenter(pendingPos.line); } // Cmd/Ctrl+S to save editor.addCommand( // eslint-disable-next-line no-bitwise (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd | (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS, () => { onSave(); } ); // Selection change listener for comments if (enableComments && sessionId) { const disposable = editor.onDidChangeCursorSelection(() => { const selection = editor.getSelection(); if (!selection || selection.isEmpty()) { setCurrentSelection(null); return; } const model = editor.getModel(); if (!model) return; const text = model.getValueInRange(selection); if (!text.trim()) { setCurrentSelection(null); return; } setCurrentSelection({ text, startLine: selection.startLineNumber, endLine: selection.endLineNumber, }); }); disposablesRef.current.push(disposable); } // Gutter click for viewing comments const glyphDisposable = editor.onMouseDown((e) => { if (e.target.type === 2 /* GUTTER_GLYPH_MARGIN */ || e.target.type === 3 /* GUTTER_LINE_NUMBERS */) { const lineNumber = e.target.position?.lineNumber; if (!lineNumber) return; const lineComments = comments.filter( (c) => lineNumber >= c.startLine && lineNumber <= c.endLine ); if (lineComments.length > 0 && e.event.browserEvent) { setCommentView({ comments: lineComments, position: { x: e.event.browserEvent.clientX, y: e.event.browserEvent.clientY }, }); } } }); disposablesRef.current.push(glyphDisposable); }, [path, onSave, enableComments, sessionId, comments] ); // Cleanup disposables useEffect(() => { return () => { for (const d of disposablesRef.current) d.dispose(); disposablesRef.current = []; }; }, []); // Update decorations when comments change useEffect(() => { if (!decorationsRef.current || !editorRef.current) return; const decorations: monacoEditor.IModelDeltaDecoration[] = []; const linesWithComments = new Set(); const firstLines = new Set(); for (const comment of comments) { firstLines.add(comment.startLine); for (let line = comment.startLine; line <= comment.endLine; line++) { linesWithComments.add(line); } } for (const lineNum of linesWithComments) { decorations.push({ range: { startLineNumber: lineNum, startColumn: 1, endLineNumber: lineNum, endColumn: 1, }, options: { isWholeLine: true, className: 'monaco-comment-line', glyphMarginClassName: firstLines.has(lineNum) ? 'monaco-comment-glyph' : 'monaco-comment-glyph-bg', glyphMarginHoverMessage: { value: `${comments.filter((c) => lineNum >= c.startLine && lineNum <= c.endLine).length} comment(s) - click to view`, }, }, }); } decorationsRef.current.set(decorations); }, [comments]); // Diff gutter indicators useEffect(() => { if (!diffDecorationsRef.current || !editorRef.current) return; if (!showDiffIndicators || !isDirty || !originalContent) { diffDecorationsRef.current.set([]); return; } const changes = diffLines(originalContent, content); const decorations: monacoEditor.IModelDeltaDecoration[] = []; let currentLine = 1; for (let i = 0; i < changes.length; i++) { const change = changes[i]; const lineCount = change.count ?? 0; if (change.removed) { // Check if next change is an addition (modified lines) const next = changes[i + 1]; if (next && next.added) { // Modified: removed + added pair const addedLineCount = next.count ?? 0; for (let j = 0; j < addedLineCount; j++) { decorations.push({ range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 }, options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-modified-gutter' }, }); } currentLine += addedLineCount; i++; // skip the added part } else { // Standalone deletion: red indicator at previous line const indicatorLine = Math.max(1, currentLine - 1); decorations.push({ range: { startLineNumber: indicatorLine, startColumn: 1, endLineNumber: indicatorLine, endColumn: 1 }, options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-deleted-gutter' }, }); } } else if (change.added) { // Standalone addition for (let j = 0; j < lineCount; j++) { decorations.push({ range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 }, options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-added-gutter' }, }); } currentLine += lineCount; } else { // Unchanged currentLine += lineCount; } } diffDecorationsRef.current.set(decorations); }, [content, originalContent, showDiffIndicators, isDirty]); // Show floating button after mouse up useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper || !enableComments || !sessionId) return; const handleMouseUp = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('.floating-comment-btn')) return; setTimeout(() => { if (currentSelection) { setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y }); } }, 10); }; const handleMouseDown = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('.floating-comment-btn')) return; setFloatingButtonPos(null); }; wrapper.addEventListener('mouseup', handleMouseUp); wrapper.addEventListener('mousedown', handleMouseDown); return () => { wrapper.removeEventListener('mouseup', handleMouseUp); wrapper.removeEventListener('mousedown', handleMouseDown); }; }, [enableComments, sessionId, currentSelection]); // Cmd+I for comment popover useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper || !enableComments || !sessionId) return; const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'i') { if (!currentSelection || !floatingButtonPos) return; e.preventDefault(); e.stopPropagation(); setTextSelection({ ...currentSelection, position: floatingButtonPos }); setFloatingButtonPos(null); } }; wrapper.addEventListener('keydown', handleKeyDown, true); return () => wrapper.removeEventListener('keydown', handleKeyDown, true); }, [enableComments, sessionId, currentSelection, floatingButtonPos]); // Escape to close popovers useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (textSelection) setTextSelection(null); if (commentView) setCommentView(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [textSelection, commentView]); const handleChange: OnChange = useCallback( (value) => { if (value !== undefined) onChange(value); }, [onChange] ); const handleFloatingButtonClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!currentSelection || !floatingButtonPos) return; setTextSelection({ ...currentSelection, position: floatingButtonPos }); setFloatingButtonPos(null); }, [currentSelection, floatingButtonPos] ); const handleCommentSubmit = useCallback( (annotation: string) => { if (!textSelection || !sessionId) return; const comment: DiffComment = { id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, sessionId, filePath: path, startLine: textSelection.startLine, endLine: textSelection.endLine, side: 'additions', codeContent: textSelection.text, annotation, createdAt: new Date().toISOString(), status: 'pending', }; addComment(comment); setTextSelection(null); // Clear editor selection const editor = editorRef.current; if (editor) { const pos = editor.getPosition(); if (pos) editor.setSelection({ startLineNumber: pos.lineNumber, startColumn: pos.column, endLineNumber: pos.lineNumber, endColumn: pos.column }); } toast({ title: 'Comment added', description: 'Your comment will be sent with your next message.' }); }, [textSelection, sessionId, path, addComment, toast] ); const handlePopoverClose = useCallback(() => setTextSelection(null), []); const handleDeleteComment = useCallback( (commentId: string) => { if (!sessionId) return; removeComment(sessionId, path, commentId); if (commentView && commentView.comments.length <= 1) { setCommentView(null); } else if (commentView) { setCommentView({ ...commentView, comments: commentView.comments.filter((c) => c.id !== commentId), }); } toast({ title: 'Comment deleted' }); }, [sessionId, path, removeComment, commentView, toast] ); const handleCommentViewClose = useCallback(() => setCommentView(null), []); // Diff stats const diffStats = useMemo(() => { if (!isDirty) return null; const originalLines = originalContent.split('\n'); const currentLines = content.split('\n'); let additions = 0; let deletions = 0; const maxLen = Math.max(originalLines.length, currentLines.length); for (let i = 0; i < maxLen; i++) { const origLine = originalLines[i]; const currLine = currentLines[i]; if (origLine === undefined && currLine !== undefined) additions++; else if (origLine !== undefined && currLine === undefined) deletions++; else if (origLine !== currLine) { additions++; deletions++; } } return { additions, deletions }; }, [isDirty, content, originalContent]); return (
{/* Editor header */}
{toRelativePath(path, worktreePath)} {isDirty && diffStats && ( {formatDiffStats(diffStats.additions, diffStats.deletions)} )}
{enableComments && sessionId && comments.length > 0 && (
{comments.length} comment{comments.length > 1 ? 's' : ''}
)} {isDirty && ( {showDiffIndicators ? 'Hide diff indicators' : 'Show diff indicators'} )} {wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'} {onDelete && ( Delete file )}
{/* Monaco editor */}
Loading editor...
} /> {/* Floating comment button */} {floatingButtonPos && !textSelection && ( )} {textSelection && ( )} {commentView && ( )}
); }