import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { editor as MonacoEditor } from "monaco-editor" import { ChevronDown, ChevronUp, Loader2, Search } from "lucide-react" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Checkbox } from "~/components/ui/checkbox" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { api } from "~/lib/api" import type { TextDiff } from "~/lib/api" import { cn } from "~/lib/utils" // Diff 请求:本地 vs latest,或同一服务端文件两个历史版本。 export type DiffRequest = | { mode: "localLatest"; serverPath: string; localPath: string } | { mode: "revisions"; serverPath: string; sourceChangeset: number; targetChangeset: number } // 视图模式在同一会话内记忆。 const VIEW_MODE_KEY = "mactfs-diff-view-mode" /** * 把后端返回的前缀行(" " / "-" / "+")还原成左右两侧完整文本: * 空格与 "-" 行属于旧版(左),空格与 "+" 行属于新版(右)。 */ function splitDiffSides(raw: string[]): { original: string; modified: string } { const original: string[] = [] const modified: string[] = [] for (const line of raw) { const prefix = line.charAt(0) const text = line.slice(1) if (prefix === "-") { original.push(text) } else if (prefix === "+") { modified.push(text) } else { original.push(text) modified.push(text) } } return { original: original.join("\n"), modified: modified.join("\n") } } /** * 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。 */ function isDarkTheme(): boolean { return typeof document !== "undefined" && document.documentElement.classList.contains("dark") } /** * 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest * 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、 * 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。 */ export function DiffDialog({ request, onClose, }: { request: DiffRequest onClose: () => void }) { const [diff, setDiff] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [onlyChanges, setOnlyChanges] = useState(false) const [viewMode, setViewMode] = useState<"unified" | "split">(() => { if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") { return "unified" } return "split" }) // 差异块总数与当前导航到的差异块序号。 const [blockCount, setBlockCount] = useState(0) const [blockIndex, setBlockIndex] = useState(-1) const containerRef = useRef(null) const editorRef = useRef(null) useEffect(() => { let active = true setLoading(true) setError(null) void (async () => { const result = request.mode === "localLatest" ? await api.diffLocalLatest({ serverPath: request.serverPath, localPath: request.localPath, }) : await api.diffRevisions({ serverPath: request.serverPath, sourceChangeset: request.sourceChangeset, targetChangeset: request.targetChangeset, }) if (!active) { return } setLoading(false) if (!result.ok || !result.data) { setError(result.errorMessage ?? "Diff 加载失败") return } setDiff(result.data.diff) })() return () => { active = false } }, [request]) const changedCount = useMemo( () => (diff?.lines ?? []).filter((line) => line.charAt(0) === "-" || line.charAt(0) === "+").length, [diff], ) // 创建 Monaco DiffEditor:动态 import 保证大体积依赖只在打开弹窗时加载。 useEffect(() => { if (!diff) { return } let disposed = false let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null let originalModel: MonacoEditor.ITextModel | null = null let modifiedModel: MonacoEditor.ITextModel | null = null let themeObserver: MutationObserver | null = null void (async () => { const { monaco, detectLanguage } = await import("~/lib/monaco") if (disposed || !containerRef.current) { return } const { original, modified } = splitDiffSides(diff.lines) const language = detectLanguage(request.serverPath) originalModel = monaco.editor.createModel(original, language) modifiedModel = monaco.editor.createModel(modified, language) diffEditor = monaco.editor.createDiffEditor(containerRef.current, { readOnly: true, originalEditable: false, automaticLayout: true, renderSideBySide: viewMode === "split", // 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。 compactMode: viewMode === "unified", hideUnchangedRegions: { enabled: onlyChanges }, theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light", fontSize: 12, lineHeight: 20, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace', minimap: { enabled: false }, scrollBeyondLastLine: false, renderOverviewRuler: true, overviewRulerBorder: false, hideCursorInOverviewRuler: true, diffWordWrap: "off", diffAlgorithm: "advanced", renderLineHighlight: "none", folding: false, glyphMargin: false, guides: { indentation: false }, padding: { top: 8, bottom: 8 }, scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, useShadows: false }, renderMarginRevertIcon: false, }) diffEditor.setModel({ original: originalModel, modified: modifiedModel }) diffEditor.onDidUpdateDiff(() => { setBlockCount(diffEditor?.getLineChanges()?.length ?? 0) }) editorRef.current = diffEditor // 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。 themeObserver = new MutationObserver(() => { monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light") }) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }) })() return () => { disposed = true editorRef.current = null themeObserver?.disconnect() diffEditor?.dispose() originalModel?.dispose() modifiedModel?.dispose() setBlockCount(0) setBlockIndex(-1) } }, [diff, request.serverPath]) /** * 切换 统一视图(inline)/ 左右分栏(side by side),并记忆到当前会话。 */ const switchView = useCallback((mode: "unified" | "split") => { setViewMode(mode) editorRef.current?.updateOptions({ renderSideBySide: mode === "split", compactMode: mode === "unified", }) if (typeof sessionStorage !== "undefined") { sessionStorage.setItem(VIEW_MODE_KEY, mode) } }, []) /** * 切换「仅看差异」:折叠 / 展开未变动区域。 */ const switchOnlyChanges = useCallback((value: boolean) => { setOnlyChanges(value) editorRef.current?.updateOptions({ hideUnchangedRegions: { enabled: value } }) }, []) /** * 跳到上一处 / 下一处差异块(循环),定位到新版一侧并居中。 */ const goToBlock = useCallback( (direction: 1 | -1) => { const diffEditor = editorRef.current const changes = diffEditor?.getLineChanges() ?? [] if (!diffEditor || changes.length === 0) { return } const next = (blockIndex + direction + changes.length) % changes.length setBlockIndex(next) const change = changes[next] const line = change.modifiedStartLineNumber > 0 ? change.modifiedStartLineNumber : Math.max(change.modifiedEndLineNumber, 1) const modifiedEditor = diffEditor.getModifiedEditor() modifiedEditor.revealLineInCenter(line) modifiedEditor.setPosition({ lineNumber: line, column: 1 }) modifiedEditor.focus() }, [blockIndex], ) /** * 打开 Monaco 内置搜索框(等价于在内容区按 Cmd+F)。 */ const openSearch = useCallback(() => { const modifiedEditor = editorRef.current?.getModifiedEditor() modifiedEditor?.focus() void modifiedEditor?.getAction("actions.find")?.run() }, []) return ( !open && onClose()}> {request.mode === "localLatest" ? "服务器 latest vs 本地" : "历史版本对比"} {request.serverPath}
{diff && ( <> − {diff.sourceLabel} + {diff.targetLabel} {changedCount} 行差异 )}
switchView("unified")} /> switchView("split")} />
{blockCount === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blockCount}`}
{loading ? (
正在生成 Diff…
) : error ? (
{error}
) : changedCount === 0 ? (
两个版本内容一致
) : (
)}
) } /** * 视图切换小标签按钮。 */ function ViewTab({ label, active, onClick, }: { label: string active: boolean onClick: () => void }) { return ( ) }