元素
DiffView
DiffView 像代码评审工具那样显示两段文本之间的改动:并排显示时旧文本在左、新文本在右,行与行对齐;合并显示时删除的行在新增的行上方。改动的行内,变化的单词会被高亮;改动之间未改动的行折叠成可以展开的分隔行;文本使用 Editor 的语法颜色高亮。它显示两段文本(oldValue 和 newValue)或统一 diff 格式中的各个文件(patch:git diff 的输出、拉取请求的 diff),每个文件位于自己的标题之下,为评审改动而设计,其中包括 AI 提出的修改:应用在每处改动上的操作可以保留或撤销它。只创建视野中的行,所以长文件滚动起来与短文件一样流畅。
import { mount, signal, Text, View } from "nsh";
import { DiffView, type DiffViewRef } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, size, ui, wide } from "./palette";
const OLD = `fn greet(name: &str) -> String {
format!("Hello, {}", name)
}
fn main() {
let names = vec!["Ada", "Grace"];
for name in names {
println!("{}", greet(name));
}
}
`;
const NEW = `fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
fn main() {
let names = ["Ada", "Grace", "Linus"];
for name in names {
println!("{}", greet(name));
}
}
`;
// The texts are the app's: a change reverted or kept is reported
// (`onChange`) and set back as `oldValue` and `newValue`.
function App() {
const layout = signal<"split" | "unified">(desktop ? "split" : "unified");
const inline = signal<"wordAlt" | "char" | "none">("wordAlt");
const oldValue = signal(OLD);
const newValue = signal(NEW);
const stats = signal("");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
let diff: DiffViewRef | undefined;
const count = async () => {
const s = await diff?.stats();
stats.set(s ? `+${s.added} −${s.deleted} · ${s.hunks} changes` : "");
};
const action = (label: string, onPress: () => void, primary?: boolean) => (
<View
onPress={onPress}
role="button"
accessibilityLabel={label}
activeStyle={{ opacity: 0.6 }}
style={{ height: 22, paddingHorizontal: 8, borderRadius: 6, justifyContent: "center", ...(primary ? { backgroundColor: ui().accent } : ui().raised) }}
>
<Text style={{ fontSize: 11, fontWeight: "600", color: primary ? ui().onAccent : ui().text }}>{label}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>diff view</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click Revert or Keep on a change, or a line number" : "Tap Revert or Keep on a change, or a line number"}
</Text>
<Split
grow
width={680}
inspector={
<>
<Segmented label="layout" options={["split", "unified"] as const} value={layout()} onChange={(v) => layout.set(v)} />
<Segmented label="inline" options={["wordAlt", "char", "none"] as const} value={inline()} onChange={(v) => inline.set(v)} />
<Panel title="stats">
<Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>{stats()}</Text>
<View style={{ flexDirection: "row", gap: 8, marginTop: 6 }}>
<View
onPress={() => {
oldValue.set(OLD);
newValue.set(NEW);
setTimeout(count, 0);
}}
role="button"
activeStyle={{ opacity: 0.6 }}
style={{ height: size.controlSm, paddingHorizontal: 12, borderRadius: 8, justifyContent: "center", ...ui().raised }}
>
<Text style={{ fontSize: 13, fontWeight: "500", color: ui().text }}>Reset</Text>
</View>
</View>
</Panel>
<Log title="events" lines={events()} minHeight={96} />
</>
}
>
<View style={{ height: wide() ? 360 : 320, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<DiffView
ref={(r) => {
diff = r;
count();
}}
oldValue={oldValue()}
newValue={newValue()}
language="rust"
layout={layout()}
inline={inline()}
contextLines={1}
renderHunkActions={(hunk) => (
<View style={{ flexDirection: "row", gap: 4 }}>
{action("Revert", () => diff?.revertHunk(hunk.index))}
{action("Keep", () => diff?.acceptHunk(hunk.index), true)}
</View>
)}
onChange={(change) => {
oldValue.set(change.oldValue);
newValue.set(change.newValue);
log(`onChange: ${change.newValue.split("\n").length - 1} lines`);
count();
}}
onLineNumberPress={(line) => log(`onLineNumberPress: ${line.side} ${line.line + 1}`)}
style={{ flex: 1 }}
/>
</View>
</Split>
</View>
);
}
mount(App);DiffView 负责行为,并带有一套由主题令牌构成的默认外观:主题的 font-mono,13pt 字号、20pt 行高,行号用 muted-foreground,新增的行染上绿色、删除的行染上红色,改动的单词颜色更深,每个改动行的行号栏边缘有一条竖条,分隔行在 muted 底色上。每个部件都在这些默认值之上接受应用的样式(classes 和 styles)。Rust 用 nsh_base::diff_view() 创建它,TSX 用 nsh/base 中的 <DiffView>。
use nsh_base::diff_view;
use nsh_core::ui::prelude::*;
const OLD: &str = "fn total(prices: &[u32]) -> u32 {\n prices.iter().sum()\n}\n";
const NEW: &str = "fn total(prices: &[u64]) -> u64 {\n prices.iter().copied().sum()\n}\n";
fn review() -> impl IntoElement {
diff_view().old_value(OLD).new_value(NEW).language("rust").flex_1()
}import { mount } from "nsh";
import { DiffView } from "nsh/base";
const OLD = `fn total(prices: &[u32]) -> u32 {
prices.iter().sum()
}
`;
const NEW = `fn total(prices: &[u64]) -> u64 {
prices.iter().copied().sum()
}
`;
function App() {
return <DiffView oldValue={OLD} newValue={NEW} language="rust" class="flex-1" />;
}
mount(App);视图填满应用给它的盒子(flex-1、一个高度),在其中滚动:纵向滚动所有行,横向滚动到最长的一行,并排视图的两侧一起滚动。设置 softWrap 时长行在这一侧的宽度处换行,并排的一行和较高的一侧一样高,两侧始终对齐。
#两段文本或一个补丁
- 两段文本:
oldValue和newValue。逐行按原样比较,所以增加或去掉末尾的换行符也是一处改动,与 git 的显示相同。 - 一个补丁:
patch,一个或多个文件的统一 diff(git diff、git format-patch、diff -u;重命名、文件模式、新增、删除和二进制文件,以及前面的提交信息,都按 git 的写法读取)。视图显示它各个 hunk 的行,行号与文件中相同,每个 hunk 位于显示其标题(所在的函数)的分隔行之下;有多个文件时,每个文件位于自己的标题之下(补丁中的文件)。未设置language时,由每个文件的路径决定它的语言。
const PATCH = `diff --git a/src/total.rs b/src/total.rs
--- a/src/total.rs
+++ b/src/total.rs
@@ -10,3 +10,3 @@ impl Cart {
- fn total(&self) -> u32 {
+ fn total(&self) -> u64 {
self.prices.iter().sum()
}
`;
<DiffView patch={PATCH} layout="unified" class="flex-1" />
language 的写法与 Editor 相同:名称、别名、扩展名或文件名("rust"、"tsx"、"main.go"、"Dockerfile")。
#补丁中的文件
包含多个文件的补丁把每个文件显示在一个标题行之下:默认显示它的路径(重命名时为 旧 → 新)、新增和删除的行数,以及 ▾;点击标题会把文件折叠到只剩标题(▸),或重新展开。renderFileHeader 按应用的方式绘制标题(只有一个文件的补丁也会显示标题),并告诉它这个文件(DiffFile):index、path、oldPath(重命名或复制的来源)、status("modified"、"added"、"deleted"、"renamed"、"copied")、binary(补丁中没有它的行)、added、deleted、hunks、firstHunk 和 collapsed。应用的标题调用 ref 的 toggleFile(path);collapseFile 和 expandFile 设置折叠状态,每次变化都由 onFileToggle(file) 报告。新的 patch 仍包含某个已折叠的文件时,它保持折叠。
改动跨文件按顺序计数:一个文件的改动是第 firstHunk 到第 firstHunk + hunks - 1 处,goToHunk、renderHunkActions 和 hunks() 都用这个序号(DiffHunk 的 file 是它所在文件的序号,它的行号在该文件中计数),stats() 是所有文件之和。goToHunk 会先展开已折叠的文件。
let diff!: DiffViewRef;
const files = signal<DiffFile[]>([]);
<View class="flex-1 flex-row">
<View class="w-48">
<Each of={files()}>
{(file) => (
<Button onPress={() => diff.scrollToFile(file().path)} class="px-2 py-1">
<Text class="text-sm">{file().path} +{file().added} −{file().deleted}</Text>
</Button>
)}
</Each>
</View>
<DiffView
ref={(r) => {
diff = r;
r.files().then((list) => files.set(list));
}}
patch={PULL_REQUEST}
renderFileHeader={(file) => (
<Button onPress={() => diff.toggleFile(file.path)} class="flex-1 flex-row items-center gap-2 bg-muted px-2">
<Text>{file.collapsed ? "▸" : "▾"}</Text>
<Text class="flex-1 text-sm font-medium">{file.oldPath ? `${file.oldPath} → ${file.path}` : file.path}</Text>
<Text class="text-xs text-muted-foreground">{file.binary ? "Binary" : file.status}</Text>
</Button>
)}
class="flex-1"
/>
</View>
#布局与行内改动
layout 是 "split"(默认:并排,每个删除的行放在与它最相似的新增行旁边)或 "unified"(一列,一处改动的删除行在新增行上方,两种行号都显示)。inline 决定改动行内高亮什么:
inline | 高亮的内容 |
|---|---|
"wordAlt"(默认) | 改动的单词,相隔一个字符时合并,使改动的短语作为一个整体呈现 |
"word" | 每个改动的单词 |
"char" | 每个改动的字符 |
"none" | 不高亮:只有行的底色 |
单词是由字母、数字和 _ 组成的连续字符;每个汉字、假名和韩文音节各自是一个单词,所以句子中改动了一个字时只标记这一个字。几乎整行都改动了的行只显示行的底色。
indicators 在行号旁标记改动的行:"bars"(默认,行号栏边缘的一条竖条)、"classic"(文本前的 + 和 −)或 "none"。background={false} 让改动的行不染色,lineNumbers={false} 隐藏行号。
<DiffView oldValue={before()} newValue={after()} language="md" layout="unified" inline="word" indicators="classic" class="flex-1" />
#折叠的行
每处改动周围显示 contextLines 行未改动的行(默认 3 行);改动之间、第一处改动之前和最后一处改动之后未改动的行折叠成一个分隔行,标明它隐藏了多少行。点击它会显示全部。expandAll 显示所有行;ref 的 expand、expandAll() 和 collapseAll() 由应用展开和折叠它们。
renderSeparator 按应用的方式绘制分隔行。它得到分隔行的信息(DiffSeparator):gap,折叠段的序号(第一处改动之前为 0,改动 i - 1 和 i 之间为 i);lines,隐藏的行数;first 和 last;补丁的话还有 hunk 的 header。expand(gap, direction, lines) 显示 "all",即全部行(默认),或者 lines 行(默认 20 行):"up" 是分隔行下方那处改动紧上方的行,"down" 是分隔行上方那处改动紧下方的行。第一个分隔行向上展开,最后一个向下展开。
let diff!: DiffViewRef;
<DiffView
ref={diff}
oldValue={before()}
newValue={after()}
language="ts"
renderSeparator={(sep) => (
<View class="flex-1 flex-row items-center gap-1 bg-muted px-2">
<If when={!sep.first}>
<Button onPress={() => diff.expand(sep.gap, "down")} class="px-1"><Text>↓</Text></Button>
</If>
<If when={!sep.last}>
<Button onPress={() => diff.expand(sep.gap, "up")} class="px-1"><Text>↑</Text></Button>
</If>
<Button onPress={() => diff.expand(sep.gap)} class="flex-1">
<Text class="text-xs text-muted-foreground">{sep.header ?? `${sep.lines} unchanged lines`}</Text>
</Button>
</View>
)}
class="flex-1"
/>
#保留和撤销改动
renderHunkActions 把应用的操作放在视野中每处改动的右上角,并告诉它这处改动(DiffHunk:index、oldStart、oldLines、newStart、newLines,行号从 0 开始;以及 file,补丁中它所在文件的序号)。这些操作调用 ref:revertHunk(index) 把这处改动的旧行放回新文本,acceptHunk(index) 让它的新行成为旧文本的内容。视图立即显示结果,并用 onChange({ oldValue, newValue }) 报告两段文本,由应用把它们设为属性。逐处保留或撤销 AI 的修改就是这样:
import { signal, Text, View } from "nsh";
import { Button, DiffView, type DiffViewRef } from "nsh/base";
function Proposal(props: { file: string; current: string; proposed: string; onApply: (text: string) => void }) {
let diff!: DiffViewRef;
const current = signal(props.current);
const proposed = signal(props.proposed);
return (
<View class="flex-1">
<DiffView
ref={diff}
oldValue={current()}
newValue={proposed()}
language={props.file}
onChange={({ oldValue, newValue }) => {
current.set(oldValue);
proposed.set(newValue);
props.onApply(oldValue);
}}
renderHunkActions={(hunk) => (
<View class="flex-row gap-1">
<Button onPress={() => diff.revertHunk(hunk.index)} class="rounded bg-background px-2 py-0.5 border border-border">
<Text class="text-xs">Revert</Text>
</Button>
<Button onPress={() => diff.acceptHunk(hunk.index)} class="rounded bg-primary px-2 py-0.5">
<Text class="text-xs text-primary-foreground">Keep</Text>
</Button>
</View>
)}
class="flex-1"
/>
</View>
);
}
这里旧文本就是文件现在的内容:保留一处改动就把它应用到文件(onApply),撤销它就把它从提议中去掉。显示 patch 的视图显示的是文件的片段,它的改动保持原样。
#方法
ref={diff} 提供视图的方法(DiffViewRef)。返回值的方法(hunks()、stats())在 JS 中返回一个 Promise,所以要 await 它们:const changes = await diff.hunks()。要在界面上显示的统计数字,由 onStatsChange 在变化时报告。
| 方法 | 说明 |
|---|---|
hunks() | Promise<DiffHunk[]>:所有改动,按顺序。 |
stats() | Promise<DiffStats>:{ added, deleted, hunks },新增和删除的行数,以及改动的数量。 |
goToHunk(index) | 把第 index 处改动滚动到视野中,它的第一行在顶部。 |
revertHunk(index) | 把第 index 处改动的旧行放回新文本,并报告两段文本(onChange)。 |
acceptHunk(index) | 让第 index 处改动的新行成为旧文本的内容,并报告两段文本(onChange)。 |
expand(gap, direction?, lines?) | 显示分隔行 gap 隐藏的行:"all"(默认),或者 lines 行(默认 20 行),"up" 或 "down"。 |
expandAll() | 显示所有未改动的行。 |
collapseAll() | 重新折叠未改动的行。 |
nextHunk() | Promise<number | null>:把下一处改动滚动到顶部,并给出它的序号(最后一处之后是第一处)。 |
previousHunk() | Promise<number | null>:把上一处改动滚动到顶部,并给出它的序号(第一处之前是最后一处)。 |
currentHunk() | Promise<number | null>:视图仍停在上次跳转到的改动时就是它,否则是视图顶部的改动,或视野中的第一处。 |
selectedLines() | Promise<DiffLineRange | null>:在行号上选中的行。 |
setSelectedLines(range) | 选中一侧的若干行(null:不选),不触发 onLineSelectionChange。 |
selectedText() | Promise<string>:选中各行的文本。 |
files() | Promise<DiffFile[]>:补丁中的文件,按顺序,带有各自的统计数字(两段文本时为空)。 |
scrollToFile(path) | 把路径为 path 的文件的标题滚动到顶部。 |
collapseFile(path) | 只显示路径为 path 的文件的标题。 |
expandFile(path) | 重新显示路径为 path 的文件的行。 |
toggleFile(path) | 折叠路径为 path 的文件,已折叠时则展开它。 |
视图停在上次跳转到的改动时,nextHunk() 和 previousHunk() 从那处改动继续,否则从视图顶部算起,所以靠近末尾、无法滚动到顶部的改动也会被数到。上一处和下一处改动的按钮,以及来自 onStatsChange 的统计数字:
let diff!: DiffViewRef;
const stats = signal<DiffStats>({ added: 0, deleted: 0, hunks: 0 });
<View class="flex-row items-center gap-2 p-2">
<Text class="text-sm text-green-600">+{stats().added}</Text>
<Text class="text-sm text-red-600">−{stats().deleted}</Text>
<Text class="text-sm text-muted-foreground">{stats().hunks} changes</Text>
<Button onPress={() => diff.previousHunk()}><Text>Previous</Text></Button>
<Button onPress={() => diff.nextHunk()}><Text>Next</Text></Button>
</View>
<DiffView ref={diff} oldValue={before()} newValue={after()} onStatsChange={(s) => stats.set(s)} class="flex-1" />
#评审评论
行号用来选中一侧的行:点击选中一行,按住 Shift 点击把选区扩展到这一行,用鼠标在行号上拖动选中经过的各行。onLineSelectionChange 报告选中的行(DiffLineRange:{ side, start, end },行号从 0 开始,没有选中时为 null),点击行号还会报告 onLineNumberPress({ side, line })。ref 读取选区(selectedLines()、selectedText())并可以设置它。
annotations 把应用的内容放在行的下方:每个 { id, side, line } 由 renderAnnotation 绘制,横跨所在行的一侧(合并显示时横跨整个视图),高度就是它布局后的高度,所在的行显示时它也显示。renderAddAnnotation 是应用用来添加批注的控件,显示在指针所在行和最后一个选中行的行号栏右侧;所在行被选中时它拿到选中的各行,否则拿到这一行。
type Comment = { id: string; side: "old" | "new"; line: number; text: string };
const comments = signal<Comment[]>([]);
const drafting = signal<DiffLineRange | null>(null);
<DiffView
oldValue={before()}
newValue={after()}
annotations={comments().map(({ id, side, line }) => ({ id, side, line }))}
renderAnnotation={(a) => (
<View class="m-2 rounded-md border border-border bg-card p-2">
<Text class="text-sm">{comments().find((c) => c.id === a.id)?.text}</Text>
</View>
)}
renderAddAnnotation={(range) => (
<Button onPress={() => drafting.set(range)} class="size-4 items-center justify-center rounded bg-primary">
<Text class="text-xs text-primary-foreground">+</Text>
</Button>
)}
class="flex-1"
/>
#部件样式
class 和 style 设置视图盒子的样式。classes 按部件接受 Tailwind 类,styles 按部件接受内联样式;部件的类在它自己的默认样式之后应用,因此优先,它的 styles 又优先于类。字体用 fontFamily、fontSize 和 lineHeight 设置。
<DiffView
oldValue={before()}
newValue={after()}
language="py"
fontSize={12}
lineHeight={18}
class="flex-1 rounded-lg border border-border"
classes={{
gutter: "bg-muted",
lineAdded: "bg-blue-500/10",
wordAdded: "bg-blue-500/30",
indicatorAdded: "bg-blue-500",
separator: "bg-transparent",
}}
/>
| 部件 | 是什么 | 默认值 |
|---|---|---|
side | 每一侧(并排)或唯一的一列(合并) | 两侧之间一条 border 线 |
gutter | 一侧的行号和标记 | |
lineNumber | 每个行号 | 右对齐,muted-foreground |
lineNumberAdded | 新增行的行号,在 lineNumber 之后应用 | |
lineNumberDeleted | 删除行的行号,在 lineNumber 之后应用 | |
content | 一侧的文本,在行号栏旁边 | 文本前后各 8pt |
line | 一侧的每一行 | |
lineAdded | 新增的行,在 line 之后应用 | 绿色底色 |
lineDeleted | 删除的行,在 line 之后应用 | 红色底色 |
filler | 并排行中另一侧没有对应行时空着的一半 | 淡淡的 muted 填充 |
wordAdded | 新增行内改动的单词 | 更深的绿色 |
wordDeleted | 删除行内改动的单词 | 更深的红色 |
indicatorAdded | 新增行的竖条("bars")或 +("classic") | 绿色,3pt 宽 |
indicatorDeleted | 删除行的竖条或 − | 红色,3pt 宽 |
separator | 折叠行的分隔行,或补丁的 hunk 标题 | muted 底色上的 ⋯ 和行数 |
hunkActions | 应用在一处改动上的操作所在的盒子 | 改动的右上角 |
lineSelected | 覆盖在选中各行所在一侧的行上 | 主题的选区颜色(与 Editor 的选区一致) |
annotation | 一条批注的盒子,在所在行的下方 | 横跨所在行的一侧 |
addAnnotation | 添加控件的盒子,与行号栏同宽、一行高 | 控件在它的右侧 |
fileHeader | 补丁中一个文件的标题 | secondary 底色上的路径和行数,下方一条 border 线 |
#属性
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
oldValue | .old_value(..) | string | 旧文本。 | |
newValue | .new_value(..) | string | 新文本。 | |
patch | .patch(..) | string | 一个或多个文件的统一 diff,在未设置 oldValue 和 newValue 时显示。 | |
language | .language(..) | string | 纯文本(补丁:每个文件的语言) | 语言的名称、别名、扩展名或文件名。 |
layout | .layout(..) | "split" | "unified" | "split" | 并排或一列。 |
inline | .inline(..) | "wordAlt" | "word" | "char" | "none" | "wordAlt" | 改动行内高亮的内容。 |
indicators | .indicators(..) | "bars" | "classic" | "none" | "bars" | 在行号旁如何标记改动的行。 |
contextLines | .context_lines(..) | number | 3 | 每处改动周围显示的未改动行数。 |
expandAll | .expand_all(..) | boolean | false | 显示所有未改动的行。 |
lineNumbers | .line_numbers(..) | boolean | true | 行号。 |
background | .background(..) | boolean | true | 改动的行带底色。 |
fontFamily | .font_family(..) | string | 主题的 font-mono | 文本的字体。 |
fontSize | .font_size(..) | number | 13 | 字号,单位为点。 |
lineHeight | .line_height(..) | number | 20 | 一行的高度,单位为点。 |
renderSeparator | .render_separator(..) | (separator: DiffSeparator) => JSX.Element | 绘制折叠行的分隔行。 | |
renderHunkActions | .render_hunk_actions(..) | (hunk: DiffHunk) => JSX.Element | 应用在每处改动上的操作。 | |
softWrap | .soft_wrap(..) | boolean | false | 长行在这一侧的宽度处换行。 |
annotations | .annotations(..) | DiffAnnotation[] | [] | 放在行下方的应用内容:{ id, side, line }。 |
renderAnnotation | .render_annotation(..) | (annotation: DiffAnnotation) => JSX.Element | 在所在行的下方绘制一条批注。 | |
renderAddAnnotation | .render_add_annotation(..) | (range: DiffLineRange) => JSX.Element | 应用用来添加批注的控件,在指针所在行和最后一个选中行旁边。 | |
renderFileHeader | .render_file_header(..) | (file: DiffFile) => JSX.Element | 绘制补丁中每个文件的标题,高一行。 | |
classes | .classes(..) | DiffViewClasses | {} | 按部件的 Tailwind 类。 |
styles | .styles(..) | DiffViewStyles | {} | 按部件的内联样式,优先于类。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 视图的盒子。 |
#事件
| 事件 | Rust | 说明 |
|---|---|---|
onChange | .on_change(|change: &DiffChange| ..) | 一处改动被保留或撤销:{ oldValue, newValue },现在的两段文本。 |
onLineNumberPress | .on_line_number_press(|line: &DiffLine| ..) | 行号被点击:{ side, line }。 |
onLineSelectionChange | .on_line_selection_change(|range: &Option<DiffLineRange>| ..) | 选中的行变了:{ side, start, end },或 null。 |
onStatsChange | .on_stats_change(|stats: &DiffStats| ..) | 统计数字变了:{ added, deleted, hunks }。视图创建时触发一次,之后每次比较文本得到不同的数字时再触发,用于显示“+8 −8 · 5 处改动”这样的一行。 |
onFileToggle | .on_file_toggle(|file: &DiffFile| ..) | 补丁中的一个文件被折叠或展开(通过它的标题或 ref):现在的 DiffFile。 |