399 lines
13 KiB
TypeScript
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 };
|
|
}
|