Files
kandev/apps/web/components/editors/monaco/use-monaco-editor-lsp.ts
T

399 lines
13 KiB
TypeScript

import { useCallback, useEffect, useState, useRef, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { editor as monacoEditor } from "monaco-editor";
import { useAppStore } from "@/components/state-provider";
import { useLsp } from "@/hooks/use-lsp";
import { lspClientManager } from "@/lib/lsp/lsp-client-manager";
import { getLspUnavailableSetupHint } from "@/lib/lsp/lsp-json-rpc";
import { computeLineDiffStats } from "@/lib/diff";
import { useToast } from "@/components/toast-provider";
import { diffLines } from "diff";
import { filePathToUri, joinFileUri, modelUriForDocument } from "@/lib/lsp/file-uri";
// ---------------------------------------------------------------------------
// Diff gutter decorations (pure function)
// ---------------------------------------------------------------------------
/** Compute gutter decorations for modified/added/deleted lines. */
export function computeDiffGutterDecorations(
originalContent: string,
currentContent: string,
): monacoEditor.IModelDeltaDecoration[] {
const changes = diffLines(originalContent, currentContent);
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) {
const next = changes[i + 1];
if (next?.added) {
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++;
} else {
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) {
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 {
currentLine += lineCount;
}
}
return decorations;
}
function computePatchGutterDecorations(diffText: string): monacoEditor.IModelDeltaDecoration[] {
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
const lines = diffText.split("\n");
let currentLine = 1;
let removedCount = 0;
let addedCount = 0;
const flushRun = () => {
if (removedCount === 0 && addedCount === 0) return;
if (removedCount > 0 && addedCount > 0) {
for (let i = 0; i < addedCount; i++) {
decorations.push({
range: {
startLineNumber: currentLine + i,
startColumn: 1,
endLineNumber: currentLine + i,
endColumn: 1,
},
options: { isWholeLine: true, linesDecorationsClassName: "monaco-diff-modified-gutter" },
});
}
currentLine += addedCount;
} else if (addedCount > 0) {
for (let i = 0; i < addedCount; i++) {
decorations.push({
range: {
startLineNumber: currentLine + i,
startColumn: 1,
endLineNumber: currentLine + i,
endColumn: 1,
},
options: { isWholeLine: true, linesDecorationsClassName: "monaco-diff-added-gutter" },
});
}
currentLine += addedCount;
} else {
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" },
});
}
removedCount = 0;
addedCount = 0;
};
for (const line of lines) {
if (line.startsWith("@@")) {
flushRun();
const match = line.match(/^@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/);
if (match) currentLine = Number.parseInt(match[1], 10);
continue;
}
if (
line.startsWith("---") ||
line.startsWith("+++") ||
line.startsWith("diff --git") ||
line.startsWith("index ")
) {
continue;
}
if (line.startsWith("-")) {
removedCount++;
continue;
}
if (line.startsWith("+")) {
addedCount++;
continue;
}
if (line.startsWith(" ")) {
flushRun();
currentLine++;
continue;
}
flushRun();
}
flushRun();
return decorations;
}
// ---------------------------------------------------------------------------
// useMonacoEditorLsp — LSP integration and toasts
// ---------------------------------------------------------------------------
type UseMonacoLspOpts = {
sessionId?: string;
worktreePath?: string;
repo?: string;
language: string;
path: string;
contentRef: RefObject<string>;
editorRef: RefObject<monacoEditor.IStandaloneCodeEditor | null>;
editorReady: boolean;
};
export function useMonacoEditorLsp(opts: UseMonacoLspOpts) {
const { sessionId, worktreePath, repo, language, path, contentRef, editorRef, editorReady } =
opts;
const { toast } = useToast();
const { t } = useTranslation();
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const lspSessionId = sessionId ?? activeSessionId ?? null;
const {
status: lspStatus,
progress: lspProgress,
lspLanguage,
toggle: toggleLsp,
} = useLsp(lspSessionId, language);
const hasLspActive = lspStatus.state === "ready" || lspStatus.state === "reconnecting";
const lspWorkspaceUri = lspSessionId
? lspClientManager.getWorkspaceUriForSession(lspSessionId)
: null;
let fallbackWorkspaceUri: string | null = null;
try {
fallbackWorkspaceUri = worktreePath ? filePathToUri(worktreePath) : null;
} catch {
fallbackWorkspaceUri = null;
}
const effectiveWorkspaceUri = lspWorkspaceUri ?? fallbackWorkspaceUri;
let documentUri: string | null = null;
try {
documentUri = effectiveWorkspaceUri ? joinFileUri(effectiveWorkspaceUri, repo, path) : null;
} catch {
documentUri = null;
}
const monacoPath =
documentUri && lspSessionId ? modelUriForDocument(documentUri, lspSessionId) : path;
// A definition/reference placeholder may be adopted by any real file tab,
// including a language with no active LSP. Promote it before document sync.
useEffect(() => {
if (!documentUri || !editorReady || !lspSessionId) return;
lspClientManager.promoteDocumentModel(lspSessionId, documentUri, contentRef.current);
}, [documentUri, editorReady, lspSessionId, contentRef]);
// Open/close document
useEffect(() => {
if (!documentUri || !editorReady || !hasLspActive || !lspSessionId || !lspLanguage) return;
lspClientManager.openDocument(lspSessionId, lspLanguage, {
uri: documentUri,
languageId: language,
text: contentRef.current,
repo,
});
return () => {
lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri);
};
}, [
editorReady,
hasLspActive,
lspSessionId,
lspLanguage,
documentUri,
language,
contentRef,
repo,
]);
// Document change sync
const changeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!documentUri || !editor || !hasLspActive || !lspSessionId || !lspLanguage) return;
const model = editor.getModel();
if (!model) return;
const disposable = model.onDidChangeContent(() => {
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
changeTimerRef.current = setTimeout(() => {
lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, contentRef.current);
}, 300);
});
return () => {
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
disposable.dispose();
};
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, contentRef, editorRef]);
// LSP status toasts
const lspStateForToast = lspStatus.state;
const lspReasonForToast = "reason" in lspStatus ? lspStatus.reason : null;
const lspSetupHintForToast = getLspUnavailableSetupHint(lspStatus, lspLanguage);
useEffect(() => {
if (lspStateForToast === "installing") {
toast({
title: t("lsp:installingLanguageServer"),
description: t("lsp:thisMayTakeAMoment"),
});
} else if (lspStateForToast === "unavailable" && lspReasonForToast) {
toast({
title: t("lsp:languageServerUnavailable"),
description: lspSetupHintForToast
? `${lspReasonForToast}. ${lspSetupHintForToast}`
: lspReasonForToast,
});
} else if (lspStateForToast === "error" && lspReasonForToast) {
toast({ title: t("lsp:languageServerError"), description: lspReasonForToast });
}
}, [lspStateForToast, lspReasonForToast, lspSetupHintForToast, t, toast]);
return { lspStatus, lspProgress, lspLanguage, toggleLsp, monacoPath };
}
// ---------------------------------------------------------------------------
// useMonacoDiffDecorations — diff gutter decorations + diff stats
// ---------------------------------------------------------------------------
type UseMonacoDiffDecorationsOpts = {
originalContent: string;
isDirty: boolean;
showDiffIndicators: boolean;
vcsDiff?: string;
editorReady?: monacoEditor.IStandaloneCodeEditor | null;
contentRef: RefObject<string>;
editorRef: RefObject<monacoEditor.IStandaloneCodeEditor | null>;
diffDecorationsRef: RefObject<monacoEditor.IEditorDecorationsCollection | null>;
};
export function useMonacoDiffDecorations(opts: UseMonacoDiffDecorationsOpts) {
const {
originalContent,
isDirty,
showDiffIndicators,
vcsDiff,
editorReady,
contentRef,
editorRef,
diffDecorationsRef,
} = opts;
const updateDiffDecorations = useCallback(() => {
if (!diffDecorationsRef.current || !editorRef.current) return;
if (!showDiffIndicators) {
diffDecorationsRef.current.set([]);
return;
}
if (isDirty && originalContent) {
diffDecorationsRef.current.set(
computeDiffGutterDecorations(originalContent, contentRef.current),
);
return;
}
if (vcsDiff) {
diffDecorationsRef.current.set(computePatchGutterDecorations(vcsDiff));
return;
}
diffDecorationsRef.current.set([]);
}, [
originalContent,
showDiffIndicators,
isDirty,
vcsDiff,
contentRef,
editorRef,
diffDecorationsRef,
]);
const diffTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
updateDiffDecorations();
const editor = editorReady ?? editorRef.current;
if (!editor) return;
const model = editor.getModel();
if (!model) return;
const disposable = model.onDidChangeContent(() => {
if (diffTimerRef.current) clearTimeout(diffTimerRef.current);
diffTimerRef.current = setTimeout(updateDiffDecorations, 150);
});
return () => {
if (diffTimerRef.current) clearTimeout(diffTimerRef.current);
disposable.dispose();
};
}, [updateDiffDecorations, editorRef, editorReady]);
// Diff stats
const [diffStats, setDiffStats] = useState<{ additions: number; deletions: number } | null>(null);
const computeDiffStats = useCallback(() => {
if (!isDirty) {
setDiffStats(null);
return;
}
setDiffStats(computeLineDiffStats(originalContent, contentRef.current));
}, [isDirty, originalContent, contentRef]);
const statsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Compute on mount and when deps change (without calling setState directly in effect)
useEffect(() => {
// Schedule diff stats computation via microtask to avoid synchronous setState in effect
const timer = setTimeout(computeDiffStats, 0);
const editor = editorRef.current;
if (!editor) return () => clearTimeout(timer);
const model = editor.getModel();
if (!model) return () => clearTimeout(timer);
const disposable = model.onDidChangeContent(() => {
if (statsTimerRef.current) clearTimeout(statsTimerRef.current);
statsTimerRef.current = setTimeout(computeDiffStats, 300);
});
return () => {
clearTimeout(timer);
if (statsTimerRef.current) clearTimeout(statsTimerRef.current);
disposable.dispose();
};
}, [computeDiffStats, editorRef]);
return { diffStats };
}