体验: Diff 弹窗支持左右分栏与差异概览跳转,历史对比查看弹窗放大至接近满屏
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Checkbox } from "~/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -28,6 +29,24 @@ interface DiffLine {
|
||||
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
|
||||
|
||||
/**
|
||||
* 把后端返回的前缀行(" " / "-" / "+")解析为带双侧行号的结构。
|
||||
*/
|
||||
@@ -54,8 +73,69 @@ function parseDiffLines(raw: string[]): DiffLine[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本 Diff 弹窗:支持 本地 vs 服务器 latest 与 两个历史版本 对比,
|
||||
* 提供行号、差异高亮、搜索与仅看差异。
|
||||
* 把统一行流配对成左右分栏行:连续的删除与新增按顺序两两对齐。
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 从行种类序列计算差异块(连续非 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 与 两个历史版本 对比。
|
||||
* 提供统一 / 左右分栏视图、差异概览条、上一处 / 下一处差异导航、搜索与仅看差异。
|
||||
*/
|
||||
export function DiffDialog({
|
||||
request,
|
||||
@@ -69,6 +149,15 @@ export function DiffDialog({
|
||||
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"
|
||||
}
|
||||
return "unified"
|
||||
})
|
||||
// 当前差异块序号(用于导航展示与跳转)。
|
||||
const [blockIndex, setBlockIndex] = useState(-1)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -101,20 +190,72 @@ export function DiffDialog({
|
||||
}
|
||||
}, [request])
|
||||
|
||||
/**
|
||||
* 切换视图模式并记忆到当前会话。
|
||||
*/
|
||||
const switchView = useCallback((mode: "unified" | "split") => {
|
||||
setViewMode(mode)
|
||||
setBlockIndex(-1)
|
||||
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 goToBlock = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (blocks.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = (blockIndex + direction + blocks.length) % blocks.length
|
||||
setBlockIndex(next)
|
||||
scrollToRow(blocks[next].start)
|
||||
},
|
||||
[blocks, blockIndex, scrollToRow],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex max-h-[85svh] flex-col sm:max-w-5xl">
|
||||
<DialogContent className="flex h-[92svh] max-h-[92svh] flex-col sm:max-w-[94vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{request.mode === "localLatest" ? "本地 vs 服务器 latest" : "历史版本对比"}
|
||||
@@ -136,10 +277,40 @@ export function DiffDialog({
|
||||
<span className="text-muted-foreground">{changedCount} 行差异</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-2 flex items-center rounded-md border p-0.5">
|
||||
<ViewTab label="统一视图" active={viewMode === "unified"} onClick={() => switchView("unified")} />
|
||||
<ViewTab label="左右分栏" active={viewMode === "split"} onClick={() => switchView("split")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={blocks.length === 0}
|
||||
onClick={() => goToBlock(-1)}
|
||||
aria-label="上一处差异"
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={blocks.length === 0}
|
||||
onClick={() => goToBlock(1)}
|
||||
aria-label="下一处差异"
|
||||
>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{blocks.length === 0 ? "无差异块" : `${blockIndex + 1 > 0 ? blockIndex + 1 : "—"} / ${blocks.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={onlyChanges}
|
||||
onCheckedChange={(value) => setOnlyChanges(value === true)}
|
||||
onCheckedChange={(value) => {
|
||||
setOnlyChanges(value === true)
|
||||
setBlockIndex(-1)
|
||||
}}
|
||||
/>
|
||||
仅看差异
|
||||
</label>
|
||||
@@ -151,57 +322,205 @@ export function DiffDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
) : visibleLines.length === 0 ? (
|
||||
<div className="flex h-60 items-center justify-center text-sm text-muted-foreground">
|
||||
{changedCount === 0 ? "两个版本内容一致" : "没有符合条件的行"}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="min-w-full font-mono text-xs leading-5">
|
||||
{visibleLines.map((line, index) => {
|
||||
const hit = keyword.length > 0 && line.text.toLowerCase().includes(keyword)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex",
|
||||
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-wrap break-all">{line.text}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图切换小标签按钮。
|
||||
*/
|
||||
function ViewTab({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-[5px] px-2 py-0.5 text-xs",
|
||||
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</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