返回 DeepSeek-Reasonix
InlineDiff.tsx
根目录 / desktop / frontend / src / components / InlineDiff.tsx
1 import { useMemo, useState } from "react";
2 import { Check, Copy, ChevronDown, ChevronRight } from "lucide-react";
3 import { diffLines, type DiffRow } from "../lib/diff";
4
5 // InlineDiff is a compact, expandable diff view for tool results that
6 // showed a before/after pair (Edit, MultiEdit, sed-style bash). It
7 // renders the unified diff from lib/diff's diffLines() and folds at
8 // 12 visible rows by default — clicking the row count expands the
9 // full output, and the "Copy diff" button copies a plain-text unified
10 // diff (without line numbers, which most clipboard targets don't
11 // parse) to the clipboard.
12 //
13 // The diff seam is a pure function: this component is the only
14 // place that needs to know about row layout, expansion state, and
15 // clipboard. A future migration to a real editor (Monaco/CodeMirror
16 // merge) would replace this file and leave the call sites in
17 // ToolCard untouched.
18 export function InlineDiff({
19 before,
20 after,
21 filename,
22 maxRows = 12,
23 }: {
24 before: string;
25 after: string;
26 filename?: string;
27 maxRows?: number;
28 }) {
29 const rows = useMemo(() => diffLines(before, after), [before, after]);
30 const [open, setOpen] = useState(false);
31 const [copied, setCopied] = useState(false);
32
33 const visible = open ? rows : rows.slice(0, maxRows);
34 const hidden = rows.length - visible.length;
35
36 const addCount = rows.filter((r) => r.type === "add").length;
37 const delCount = rows.filter((r) => r.type === "del").length;
38
39 const copy = async () => {
40 const text = rows.map((r) => (r.type === "add" ? "+ " : r.type === "del" ? "- " : " ") + r.text).join("\n");
41 try {
42 await navigator.clipboard.writeText(text);
43 setCopied(true);
44 setTimeout(() => setCopied(false), 1200);
45 } catch {
46 /* clipboard unavailable */
47 }
48 };
49
50 return (
51 <div className="inline-diff">
52 <header className="inline-diff__head">
53 <span className="inline-diff__file" title={filename}>
54 {filename ?? "diff"}
55 </span>
56 <span className="inline-diff__stats">
57 <span className="inline-diff__add">+{addCount}</span>
58 <span className="inline-diff__del">−{delCount}</span>
59 </span>
60 <span className="inline-diff__spacer" />
61 <button type="button" className="inline-diff__btn" onClick={copy} title="Copy diff">
62 {copied ? <Check size={11} /> : <Copy size={11} />}
63 <span>{copied ? "Copied" : "Copy"}</span>
64 </button>
65 </header>
66 <pre className="inline-diff__body">
67 <span className="inline-diff__table">
68 {visible.map((r, i) => (
69 <span key={i} className={`inline-diff__row inline-diff__row--${r.type}`}>
70 <span className="inline-diff__gutter">
71 <span className="inline-diff__line">{r.oldLine ?? ""}</span>
72 <span className="inline-diff__line">{r.newLine ?? ""}</span>
73 <span className="inline-diff__sign">{r.type === "add" ? "+" : r.type === "del" ? "−" : " "}</span>
74 </span>
75 <span className="inline-diff__text">{r.text || " "}</span>
76 </span>
77 ))}
78 </span>
79 </pre>
80 {hidden > 0 && (
81 <button
82 type="button"
83 className="inline-diff__more"
84 onClick={() => setOpen(true)}
85 aria-expanded={open}
86 >
87 <ChevronRight size={11} />
88 <span>Show {hidden} more lines</span>
89 </button>
90 )}
91 {open && rows.length > maxRows && (
92 <button
93 type="button"
94 className="inline-diff__more"
95 onClick={() => setOpen(false)}
96 aria-expanded={open}
97 >
98 <ChevronDown size={11} />
99 <span>Collapse</span>
100 </button>
101 )}
102 </div>
103 );
104 }
105
106 // Re-export for callers that want to drive the row coloring themselves.
107 export type { DiffRow };
108
108 lines Plain Text