对比功能更换为monaco
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-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"
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { api } from "~/lib/api"
|
||||
import type { TextDiff } from "~/lib/api"
|
||||
import { cn } from "~/lib/utils"
|
||||
@@ -21,121 +21,42 @@ export type DiffRequest =
|
||||
| { mode: "localLatest"; serverPath: string; localPath: string }
|
||||
| { mode: "revisions"; serverPath: string; sourceChangeset: number; targetChangeset: number }
|
||||
|
||||
// 解析后的 Diff 行:种类 + 双侧行号。
|
||||
interface DiffLine {
|
||||
kind: "same" | "removed" | "added"
|
||||
text: string
|
||||
sourceLine: number | null
|
||||
targetLine: number | null
|
||||
}
|
||||
|
||||
// 左右分栏视图中的一行:左旧右新,行级对齐。
|
||||
interface SplitRow {
|
||||
kind: "same" | "removed" | "added" | "changed"
|
||||
left: { line: number; text: string } | null
|
||||
right: { line: number; text: string } | null
|
||||
}
|
||||
|
||||
// 差异块:用于概览条与上一处 / 下一处差异导航。
|
||||
interface DiffBlock {
|
||||
start: number
|
||||
length: number
|
||||
}
|
||||
|
||||
// 视图模式在同一会话内记忆。
|
||||
const VIEW_MODE_KEY = "mactfs-diff-view-mode"
|
||||
// 内容区行高(leading-5 = 20px),用于滚动定位与概览条换算。
|
||||
const LINE_HEIGHT = 20
|
||||
|
||||
/**
|
||||
* 把后端返回的前缀行(" " / "-" / "+")解析为带双侧行号的结构。
|
||||
* 把后端返回的前缀行(" " / "-" / "+")还原成左右两侧完整文本:
|
||||
* 空格与 "-" 行属于旧版(左),空格与 "+" 行属于新版(右)。
|
||||
*/
|
||||
function parseDiffLines(raw: string[]): DiffLine[] {
|
||||
const result: DiffLine[] = []
|
||||
let sourceLine = 0
|
||||
let targetLine = 0
|
||||
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 === "-") {
|
||||
sourceLine += 1
|
||||
result.push({ kind: "removed", text, sourceLine, targetLine: null })
|
||||
original.push(text)
|
||||
} else if (prefix === "+") {
|
||||
targetLine += 1
|
||||
result.push({ kind: "added", text, sourceLine: null, targetLine })
|
||||
modified.push(text)
|
||||
} else {
|
||||
sourceLine += 1
|
||||
targetLine += 1
|
||||
result.push({ kind: "same", text, sourceLine, targetLine })
|
||||
original.push(text)
|
||||
modified.push(text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return { original: original.join("\n"), modified: modified.join("\n") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把统一行流配对成左右分栏行:连续的删除与新增按顺序两两对齐。
|
||||
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
|
||||
*/
|
||||
function buildSplitRows(lines: DiffLine[]): SplitRow[] {
|
||||
const rows: SplitRow[] = []
|
||||
let index = 0
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]
|
||||
if (line.kind === "same") {
|
||||
rows.push({
|
||||
kind: "same",
|
||||
left: { line: line.sourceLine as number, text: line.text },
|
||||
right: { line: line.targetLine as number, text: line.text },
|
||||
})
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
// 收集连续的删除块与紧随其后的新增块,按行配对。
|
||||
const removed: DiffLine[] = []
|
||||
const added: DiffLine[] = []
|
||||
while (index < lines.length && lines[index].kind === "removed") {
|
||||
removed.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
while (index < lines.length && lines[index].kind === "added") {
|
||||
added.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
const max = Math.max(removed.length, added.length)
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const left = removed[i]
|
||||
const right = added[i]
|
||||
rows.push({
|
||||
kind: left && right ? "changed" : left ? "removed" : "added",
|
||||
left: left ? { line: left.sourceLine as number, text: left.text } : null,
|
||||
right: right ? { line: right.targetLine as number, text: right.text } : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
function isDarkTheme(): boolean {
|
||||
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
}
|
||||
|
||||
/**
|
||||
* 从行种类序列计算差异块(连续非 same 行)。
|
||||
*/
|
||||
function computeBlocks(kinds: string[]): DiffBlock[] {
|
||||
const blocks: DiffBlock[] = []
|
||||
let start = -1
|
||||
for (let index = 0; index <= kinds.length; index += 1) {
|
||||
const isDiff = index < kinds.length && kinds[index] !== "same"
|
||||
if (isDiff && start < 0) {
|
||||
start = index
|
||||
}
|
||||
if (!isDiff && start >= 0) {
|
||||
blocks.push({ start, length: index - start })
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本 Diff 弹窗:支持 本地 vs 服务器 latest 与 两个历史版本 对比。
|
||||
* 提供统一 / 左右分栏视图、差异概览条、上一处 / 下一处差异导航、搜索与仅看差异。
|
||||
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
|
||||
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
|
||||
* 仅看差异(折叠未变动区域)、搜索(Cmd+F)、字级高亮与语法着色。
|
||||
*/
|
||||
export function DiffDialog({
|
||||
request,
|
||||
@@ -147,17 +68,18 @@ export function DiffDialog({
|
||||
const [diff, setDiff] = useState<TextDiff | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [onlyChanges, setOnlyChanges] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<"unified" | "split">(() => {
|
||||
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "split") {
|
||||
return "split"
|
||||
if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(VIEW_MODE_KEY) === "unified") {
|
||||
return "unified"
|
||||
}
|
||||
return "unified"
|
||||
return "split"
|
||||
})
|
||||
// 当前差异块序号(用于导航展示与跳转)。
|
||||
// 差异块总数与当前导航到的差异块序号。
|
||||
const [blockCount, setBlockCount] = useState(0)
|
||||
const [blockIndex, setBlockIndex] = useState(-1)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneDiffEditor | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -190,69 +112,124 @@ export function DiffDialog({
|
||||
}
|
||||
}, [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
|
||||
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",
|
||||
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
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
editorRef.current = null
|
||||
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)
|
||||
setBlockIndex(-1)
|
||||
editorRef.current?.updateOptions({ renderSideBySide: mode === "split" })
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
sessionStorage.setItem(VIEW_MODE_KEY, mode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const lines = useMemo(() => parseDiffLines(diff?.lines ?? []), [diff])
|
||||
const changedCount = useMemo(
|
||||
() => lines.filter((line) => line.kind !== "same").length,
|
||||
[lines],
|
||||
)
|
||||
const keyword = search.trim().toLowerCase()
|
||||
|
||||
const visibleLines = useMemo(
|
||||
() => (onlyChanges ? lines.filter((line) => line.kind !== "same") : lines),
|
||||
[lines, onlyChanges],
|
||||
)
|
||||
const splitRows = useMemo(() => buildSplitRows(visibleLines), [visibleLines])
|
||||
|
||||
// 当前视图的行种类序列与总行数(决定概览条与导航定位)。
|
||||
const rowKinds = useMemo(
|
||||
() =>
|
||||
viewMode === "split"
|
||||
? splitRows.map((row) => row.kind)
|
||||
: visibleLines.map((line) => line.kind),
|
||||
[viewMode, splitRows, visibleLines],
|
||||
)
|
||||
const blocks = useMemo(() => computeBlocks(rowKinds), [rowKinds])
|
||||
|
||||
/**
|
||||
* 滚动内容区到指定行(行高固定,按索引换算)。
|
||||
* 切换「仅看差异」:折叠 / 展开未变动区域。
|
||||
*/
|
||||
const scrollToRow = useCallback((rowIdx: number) => {
|
||||
const container = scrollRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
container.scrollTo({
|
||||
top: Math.max(rowIdx * LINE_HEIGHT - container.clientHeight / 3, 0),
|
||||
behavior: "smooth",
|
||||
})
|
||||
const switchOnlyChanges = useCallback((value: boolean) => {
|
||||
setOnlyChanges(value)
|
||||
editorRef.current?.updateOptions({ hideUnchangedRegions: { enabled: value } })
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 跳到上一处 / 下一处差异块(循环)。
|
||||
* 跳到上一处 / 下一处差异块(循环),定位到新版一侧并居中。
|
||||
*/
|
||||
const goToBlock = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (blocks.length === 0) {
|
||||
const diffEditor = editorRef.current
|
||||
const changes = diffEditor?.getLineChanges() ?? []
|
||||
if (!diffEditor || changes.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = (blockIndex + direction + blocks.length) % blocks.length
|
||||
const next = (blockIndex + direction + changes.length) % changes.length
|
||||
setBlockIndex(next)
|
||||
scrollToRow(blocks[next].start)
|
||||
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()
|
||||
},
|
||||
[blocks, blockIndex, scrollToRow],
|
||||
[blockIndex],
|
||||
)
|
||||
|
||||
/**
|
||||
* 打开 Monaco 内置搜索框(等价于在内容区按 Cmd+F)。
|
||||
*/
|
||||
const openSearch = useCallback(() => {
|
||||
const modifiedEditor = editorRef.current?.getModifiedEditor()
|
||||
modifiedEditor?.focus()
|
||||
void modifiedEditor?.getAction("actions.find")?.run()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[92svh] max-h-[92svh] flex-col sm:max-w-[94vw]">
|
||||
@@ -285,7 +262,7 @@ export function DiffDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={blocks.length === 0}
|
||||
disabled={blockCount === 0}
|
||||
onClick={() => goToBlock(-1)}
|
||||
aria-label="上一处差异"
|
||||
>
|
||||
@@ -294,66 +271,45 @@ export function DiffDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={blocks.length === 0}
|
||||
disabled={blockCount === 0}
|
||||
onClick={() => goToBlock(1)}
|
||||
aria-label="下一处差异"
|
||||
>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{blocks.length === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blocks.length}`}
|
||||
{blockCount === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blockCount}`}
|
||||
</span>
|
||||
</div>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={onlyChanges}
|
||||
onCheckedChange={(value) => {
|
||||
setOnlyChanges(value === true)
|
||||
setBlockIndex(-1)
|
||||
}}
|
||||
onCheckedChange={(value) => switchOnlyChanges(value === true)}
|
||||
/>
|
||||
仅看差异
|
||||
</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索内容…"
|
||||
className="h-7 w-48 text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={openSearch}>
|
||||
<Search className="size-3.5" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-1">
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
|
||||
{loading ? (
|
||||
<div className="flex h-60 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在生成 Diff…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : rowKinds.length === 0 ? (
|
||||
<div className="flex h-60 items-center justify-center text-sm text-muted-foreground">
|
||||
{changedCount === 0 ? "两个版本内容一致" : "没有符合条件的行"}
|
||||
</div>
|
||||
) : viewMode === "unified" ? (
|
||||
<UnifiedView lines={visibleLines} keyword={keyword} />
|
||||
) : (
|
||||
<SplitView rows={splitRows} keyword={keyword} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && !error && blocks.length > 0 && (
|
||||
<DiffMinimap
|
||||
blocks={blocks}
|
||||
totalRows={rowKinds.length}
|
||||
activeBlock={blockIndex}
|
||||
onJump={(index) => {
|
||||
setBlockIndex(index)
|
||||
scrollToRow(blocks[index].start)
|
||||
}}
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在生成 Diff…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : changedCount === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
两个版本内容一致
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -386,141 +342,3 @@ function ViewTab({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一视图:单栏行流,删除红底、新增绿底。
|
||||
*/
|
||||
function UnifiedView({ lines, keyword }: { lines: DiffLine[]; keyword: string }) {
|
||||
return (
|
||||
<pre className="min-w-full font-mono text-xs leading-5">
|
||||
{lines.map((line, index) => {
|
||||
const hit = keyword.length > 0 && line.text.toLowerCase().includes(keyword)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex h-5",
|
||||
line.kind === "removed" && "bg-red-500/10",
|
||||
line.kind === "added" && "bg-green-500/10",
|
||||
hit && "bg-amber-500/20",
|
||||
)}
|
||||
>
|
||||
<span className="w-10 shrink-0 pr-1 text-right text-muted-foreground/60 select-none">
|
||||
{line.sourceLine ?? ""}
|
||||
</span>
|
||||
<span className="w-10 shrink-0 border-r pr-1 text-right text-muted-foreground/60 select-none">
|
||||
{line.targetLine ?? ""}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 shrink-0 text-center select-none",
|
||||
line.kind === "removed" && "text-red-600",
|
||||
line.kind === "added" && "text-green-600",
|
||||
)}
|
||||
>
|
||||
{line.kind === "removed" ? "−" : line.kind === "added" ? "+" : ""}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line.text}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 左右分栏视图:旧版在左、新版在右,行级对齐。
|
||||
*/
|
||||
function SplitView({ rows, keyword }: { rows: SplitRow[]; keyword: string }) {
|
||||
return (
|
||||
<pre className="min-w-full font-mono text-xs leading-5">
|
||||
{rows.map((row, index) => {
|
||||
const hit =
|
||||
keyword.length > 0 &&
|
||||
((row.left?.text.toLowerCase().includes(keyword) ?? false) ||
|
||||
(row.right?.text.toLowerCase().includes(keyword) ?? false))
|
||||
return (
|
||||
<div key={index} className={cn("flex h-5", hit && "bg-amber-500/20")}>
|
||||
<SplitCell
|
||||
side="left"
|
||||
cell={row.left}
|
||||
changed={row.kind === "changed" || row.kind === "removed"}
|
||||
/>
|
||||
<span className="w-px shrink-0 bg-border" />
|
||||
<SplitCell
|
||||
side="right"
|
||||
cell={row.right}
|
||||
changed={row.kind === "changed" || row.kind === "added"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分栏单元格:行号 + 内容,按差异种类着色。
|
||||
*/
|
||||
function SplitCell({
|
||||
side,
|
||||
cell,
|
||||
changed,
|
||||
}: {
|
||||
side: "left" | "right"
|
||||
cell: { line: number; text: string } | null
|
||||
changed: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex w-1/2 min-w-0",
|
||||
cell && changed && side === "left" && "bg-red-500/10",
|
||||
cell && changed && side === "right" && "bg-green-500/10",
|
||||
!cell && "bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<span className="w-10 shrink-0 border-r pr-1 text-right text-muted-foreground/60 select-none">
|
||||
{cell?.line ?? ""}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate whitespace-pre pl-1">{cell?.text ?? ""}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异概览条:按差异块在全文中的位置渲染标记,点击跳转。
|
||||
*/
|
||||
function DiffMinimap({
|
||||
blocks,
|
||||
totalRows,
|
||||
activeBlock,
|
||||
onJump,
|
||||
}: {
|
||||
blocks: DiffBlock[]
|
||||
totalRows: number
|
||||
activeBlock: number
|
||||
onJump: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-2.5 shrink-0 rounded-sm bg-muted/60" title="差异概览,点击标记跳转">
|
||||
{blocks.map((block, index) => {
|
||||
const top = (block.start / totalRows) * 100
|
||||
const height = Math.max((block.length / totalRows) * 100, 0.8)
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`跳到第 ${index + 1} 处差异`}
|
||||
onClick={() => onJump(index)}
|
||||
className={cn(
|
||||
"absolute right-0 left-0 rounded-[2px]",
|
||||
index === activeBlock ? "bg-primary" : "bg-amber-500/80 hover:bg-amber-600",
|
||||
)}
|
||||
style={{ top: `${top}%`, height: `${height}%`, minHeight: "3px" }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user