@yuangc/vibe-review 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/client/clarify.ts +127 -0
  2. package/client/commit-preview.tsx +151 -0
  3. package/client/commit-result.tsx +8 -0
  4. package/client/composer-fill.ts +18 -0
  5. package/client/connector-masks.ts +5 -0
  6. package/client/diff.test.ts +63 -0
  7. package/client/diff.ts +153 -0
  8. package/client/file-icons.tsx +138 -0
  9. package/client/file-tree.tsx +147 -0
  10. package/client/intraline.test.ts +44 -0
  11. package/client/intraline.ts +129 -0
  12. package/client/material-icon-theme.LICENSE +8 -0
  13. package/client/mobile-clarify.tsx +67 -0
  14. package/client/prompt-template-editor.tsx +194 -0
  15. package/client/review.tsx +486 -0
  16. package/client/settings.tsx +104 -0
  17. package/client/split-diff-render.tsx +219 -0
  18. package/client/split-diff.tsx +420 -0
  19. package/client/syntax.ts +47 -0
  20. package/client/vertical-sync.test.ts +19 -0
  21. package/client/vertical-sync.ts +37 -0
  22. package/client/web.ts +12 -0
  23. package/index.client.tsx +94 -0
  24. package/index.server.ts +283 -0
  25. package/package.json +29 -4
  26. package/paseo-plugin.json +6 -0
  27. package/server/clarify.ts +43 -0
  28. package/server/commit-availability.test.ts +24 -0
  29. package/server/commit-availability.ts +27 -0
  30. package/server/commit-context.test.ts +25 -0
  31. package/server/commit-context.ts +18 -0
  32. package/server/commit-proposals.test.ts +23 -0
  33. package/server/commit-proposals.ts +44 -0
  34. package/server/distill.test.ts +81 -0
  35. package/server/distill.ts +169 -0
  36. package/server/inspect.test.ts +20 -0
  37. package/server/inspect.ts +45 -0
  38. package/server/llm-response.test.ts +12 -0
  39. package/server/llm-response.ts +24 -0
  40. package/server/notify.ts +14 -0
  41. package/server/paseo-api-types.ts +27 -0
  42. package/server/pi-commit.test.ts +151 -0
  43. package/server/pi-commit.ts +185 -0
  44. package/server/review-append.test.ts +32 -0
  45. package/server/review-append.ts +32 -0
  46. package/server/review-progress.test.ts +168 -0
  47. package/server/review.ts +446 -0
  48. package/server/snapshot.test.ts +45 -0
  49. package/server/snapshot.ts +81 -0
  50. package/server/turn-snapshots.test.ts +39 -0
  51. package/server/turn-snapshots.ts +48 -0
  52. package/server/verify.test.ts +151 -0
  53. package/server/verify.ts +228 -0
  54. package/shared/clarify.ts +8 -0
  55. package/shared/commit.ts +60 -0
  56. package/shared/drift.ts +4 -0
  57. package/shared/prompts.test.ts +62 -0
  58. package/shared/prompts.ts +132 -0
  59. package/shared/review.ts +63 -0
  60. package/shared/settings.ts +48 -0
  61. package/README.md +0 -3
@@ -0,0 +1,127 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { clarifyRpc } from "../shared/clarify";
3
+
4
+ type Selection = { input: HTMLTextAreaElement; fullText: string; start: number; end: number; text: string };
5
+
6
+ /** HACK(Paseo 0.11): the plugin API has no composer selection or draft replacement capability.
7
+ * This web-only adapter can be removed when Paseo exposes both to composer pills. */
8
+ export function createClarifyController(client: PluginClientContext) {
9
+ if (typeof document === "undefined") return { open: (_agentId: string) => {}, dispose: () => {} };
10
+ let selection: Selection | null = null;
11
+ let card: HTMLDivElement | null = null;
12
+ let requestId = 0;
13
+
14
+ const isComposer = (input: Element | null): input is HTMLTextAreaElement =>
15
+ input instanceof HTMLTextAreaElement && input.hasAttribute("data-composer-input");
16
+ const visible = (input: HTMLTextAreaElement) => {
17
+ const box = input.getBoundingClientRect();
18
+ return box.width > 0 && box.height > 0;
19
+ };
20
+ const capture = () => {
21
+ const input = document.activeElement;
22
+ if (!isComposer(input) || !visible(input)) return;
23
+ const { selectionStart: start, selectionEnd: end, value: fullText } = input;
24
+ if (start < end) selection = { input, fullText, start, end, text: fullText.slice(start, end) };
25
+ };
26
+ const findInput = () => Array.from(document.querySelectorAll<HTMLTextAreaElement>("textarea[data-composer-input]")).find(visible) ?? null;
27
+ const insertAboveComposer = (input: HTMLTextAreaElement, content: HTMLDivElement) => {
28
+ // HACK(Paseo 0.11): insert into the host layout until composer slots are public.
29
+ const candidates = Array.from(document.querySelectorAll<HTMLElement>('button,[role="button"]'));
30
+ const trigger = candidates.find((node) => node.textContent?.trim() === "澄清表达" && node.getBoundingClientRect().width > 0);
31
+ if (!trigger) return false;
32
+ let common: HTMLElement | null = input.parentElement;
33
+ while (common && !common.contains(trigger)) common = common.parentElement;
34
+ if (!common) return false;
35
+ let composerWidth: string | null = null;
36
+ for (let node = input.parentElement; node && node !== common; node = node.parentElement) {
37
+ const maxWidth = window.getComputedStyle(node).maxWidth;
38
+ if (maxWidth.endsWith("px") && Number.parseFloat(maxWidth) > 0) {
39
+ composerWidth = maxWidth;
40
+ break;
41
+ }
42
+ }
43
+ content.style.maxWidth = composerWidth ?? `${input.getBoundingClientRect().width}px`;
44
+ content.style.marginInline = "auto";
45
+ let inputBranch: HTMLElement = input;
46
+ while (inputBranch.parentElement && inputBranch.parentElement !== common) inputBranch = inputBranch.parentElement;
47
+ common.insertBefore(content, inputBranch);
48
+ const gapAbove = content.getBoundingClientRect().top - trigger.getBoundingClientRect().bottom;
49
+ content.style.marginBottom = `${Math.max(0, Math.round(gapAbove))}px`;
50
+ return true;
51
+ };
52
+ const close = () => { requestId++; card?.remove(); card = null; };
53
+ const element = <K extends keyof HTMLElementTagNameMap>(tag: K, text: string, style = "") => {
54
+ const node = document.createElement(tag);
55
+ node.textContent = text;
56
+ node.style.cssText = style;
57
+ return node;
58
+ };
59
+ const button = (label: string, onClick: () => void, primary = false) => {
60
+ const isClose = label === "×";
61
+ const border = isClose ? "none" : `1px solid ${primary ? "#176b42" : "#d1d5db"}`;
62
+ const closeStyle = isClose ? "position:absolute;top:6px;right:8px;width:32px;height:32px;padding:0;font-size:24px;line-height:1;" : "padding:6px 11px;";
63
+ const node = element("button", label, `cursor:pointer;border:${border};border-radius:7px;${closeStyle}background:${primary ? "#176b42" : "transparent"};color:${primary ? "#fff" : "inherit"};font:inherit;${isClose ? "font-size:24px;" : ""}`);
64
+ node.type = "button";
65
+ if (isClose) node.setAttribute("aria-label", "关闭建议");
66
+ node.addEventListener("click", onClick);
67
+ return node;
68
+ };
69
+ const addLine = (text: string, muted = false) => card?.append(element("div", text, `white-space:pre-wrap;overflow-wrap:anywhere;${muted ? "opacity:.7;font-size:12px;" : ""}`));
70
+ const open = (agentId: string) => {
71
+ capture();
72
+ close();
73
+ const chosen = selection && selection.input.isConnected && visible(selection.input) ? selection : null;
74
+ const input = chosen?.input ?? findInput();
75
+ if (!input) return;
76
+ const dark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
77
+ card = document.createElement("div");
78
+ card.dataset.vibeClarify = "";
79
+ card.style.cssText = `position:relative;box-sizing:border-box;width:100%;max-height:45vh;overflow:auto;padding:14px 48px 14px 16px;display:flex;flex-direction:column;gap:9px;border-radius:9px;background:${dark ? "#20252c" : "#f8fafc"};color:${dark ? "#f8fafc" : "#1f2937"};font:13px system-ui,sans-serif`;
80
+ if (!insertAboveComposer(input, card)) { card = null; return; }
81
+ card.append(button("×", close));
82
+ if (!chosen || chosen.fullText !== input.value) {
83
+ addLine("先在消息输入框中选中要澄清的文字,再点击这个按钮。", true);
84
+ return;
85
+ }
86
+ addLine(`原文:${chosen.text}`, true);
87
+ addLine("正在整理措辞…", true);
88
+ const id = ++requestId;
89
+ void client.rpc(clarifyRpc, { agentId, text: chosen.text }).then((result) => {
90
+ if (!card || id !== requestId) return;
91
+ card.lastElementChild?.remove();
92
+ addLine(result.suggestion);
93
+ if (result.reason) addLine(result.reason, true);
94
+ const actions = element("div", "", "display:flex;gap:8px;align-items:center");
95
+ actions.append(button("采用建议", () => {
96
+ if (!chosen.input.isConnected || chosen.input.value !== chosen.fullText) {
97
+ addLine("输入内容已变化,请重新选中文字生成建议。", true);
98
+ return;
99
+ }
100
+ const next = chosen.fullText.slice(0, chosen.start) + result.suggestion + chosen.fullText.slice(chosen.end);
101
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
102
+ setter?.call(chosen.input, next);
103
+ chosen.input.dispatchEvent(new Event("input", { bubbles: true }));
104
+ chosen.input.focus();
105
+ chosen.input.setSelectionRange(chosen.start + result.suggestion.length, chosen.start + result.suggestion.length);
106
+ selection = null;
107
+ close();
108
+ }, true), button("忽略", close));
109
+ card.append(actions);
110
+ }, (error: unknown) => {
111
+ if (!card || id !== requestId) return;
112
+ card.lastElementChild?.remove();
113
+ addLine(error instanceof Error ? error.message : String(error));
114
+ });
115
+ };
116
+ document.addEventListener("selectionchange", capture);
117
+ document.addEventListener("select", capture, true);
118
+ document.addEventListener("keyup", capture, true);
119
+ document.addEventListener("mouseup", capture, true);
120
+ return { open, dispose: () => {
121
+ close();
122
+ document.removeEventListener("selectionchange", capture);
123
+ document.removeEventListener("select", capture, true);
124
+ document.removeEventListener("keyup", capture, true);
125
+ document.removeEventListener("mouseup", capture, true);
126
+ } };
127
+ }
@@ -0,0 +1,151 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
3
+ import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
4
+ import type { z } from "zod";
5
+ import { cancelPiCommitRpc, commitPreviewSchema, confirmPiCommitRpc, getPiCommitPreviewRpc } from "../shared/commit";
6
+ import { parseDiffFiles } from "./diff";
7
+ import { FileTree } from "./file-tree";
8
+
9
+ export function CommitPreviewItem({ item, agentId }: PluginTimelineItemProps<z.infer<typeof commitPreviewSchema>>) {
10
+ const [diff, setDiff] = useState<string | null>(null);
11
+ const [error, setError] = useState("");
12
+ const [busy, setBusy] = useState(false);
13
+ const [done, setDone] = useState("");
14
+ const [showPrompt, setShowPrompt] = useState(false);
15
+ const [previewRequest, setPreviewRequest] = useState(0);
16
+ const [previewState, setPreviewState] = useState<"loading" | "pending" | "finished" | "stale" | "unavailable">("loading");
17
+ const [messageFocused, setMessageFocused] = useState(false);
18
+ const getPreview = useRpc(getPiCommitPreviewRpc);
19
+ const getPreviewRef = useRef(getPreview);
20
+ getPreviewRef.current = getPreview;
21
+ const confirm = useRpc(confirmPiCommitRpc);
22
+ const cancel = useRpc(cancelPiCommitRpc);
23
+ const { proposalId, message, files, promptPreview } = item.data;
24
+ const [editedMessage, setEditedMessage] = useState(message);
25
+ const validMessage = editedMessage.trim().length > 0 && editedMessage.trim().length <= 80;
26
+ const submitted = /^已提交:([0-9a-f]{7,40})\s+([\s\S]+)$/.exec(done);
27
+
28
+ useEffect(() => {
29
+ let active = true;
30
+ let timer: ReturnType<typeof setTimeout> | undefined;
31
+ const load = (attempt: number) => {
32
+ void getPreviewRef.current({ agentId, proposalId }).then(
33
+ (result) => {
34
+ if (!active) return;
35
+ setError("");
36
+ setPreviewState(result.state);
37
+ if (result.state === "finished") setDone(result.result ?? "已结束提交预览。");
38
+ if (result.state === "pending") setDiff(result.diff ?? null);
39
+ },
40
+ (reason: unknown) => {
41
+ if (!active) return;
42
+ const message = reason instanceof Error ? reason.message : String(reason);
43
+ if (attempt < 2 && /Transport closed|connection closed/i.test(message)) timer = setTimeout(() => load(attempt + 1), 500 * (attempt + 1));
44
+ else { setPreviewState("unavailable"); setError(message); }
45
+ },
46
+ );
47
+ };
48
+ load(0);
49
+ return () => { active = false; if (timer) clearTimeout(timer); };
50
+ }, [agentId, proposalId, previewRequest]);
51
+
52
+ useEffect(() => {
53
+ if (previewState !== "pending") return;
54
+ let active = true;
55
+ const refresh = () => {
56
+ void getPreviewRef.current({ agentId, proposalId }).then((result) => {
57
+ if (!active) return;
58
+ if (result.state === "finished") {
59
+ setDone(result.result ?? "已结束提交预览。");
60
+ setPreviewState("finished");
61
+ } else if (result.state === "stale") {
62
+ setPreviewState("stale");
63
+ } else if (result.diff) {
64
+ setDiff(result.diff);
65
+ }
66
+ }, () => { /* Retain the visible preview and retry after reconnect. */ });
67
+ };
68
+ const timer = setInterval(refresh, 3_000);
69
+ return () => { active = false; clearInterval(timer); };
70
+ }, [agentId, proposalId, previewState]);
71
+
72
+ const submit = () => {
73
+ if (busy || previewState !== "pending" || !diff || !validMessage) return;
74
+ setBusy(true);
75
+ void confirm({ agentId, proposalId, message: editedMessage.trim() }).then(
76
+ (result) => { setDone(`已提交:${result.summary}`); setPreviewState("finished"); },
77
+ (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)),
78
+ ).finally(() => setBusy(false));
79
+ };
80
+ const dismiss = () => {
81
+ if (busy) return;
82
+ setBusy(true);
83
+ void cancel({ agentId, proposalId }).then(
84
+ () => { setDone("已取消提交。"); setPreviewState("finished"); },
85
+ (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)),
86
+ ).finally(() => setBusy(false));
87
+ };
88
+
89
+ const promptToggle = promptPreview ? <TouchableOpacity accessibilityRole="button" accessibilityLabel={showPrompt ? "收起 commit 任务提示词" : "查看 commit 任务提示词"} onPress={() => setShowPrompt((value) => !value)} style={styles.promptToggle}>
90
+ <Text style={styles.promptToggleText}>{showPrompt ? "收起 commit 任务提示词" : "查看 commit 任务提示词"}</Text>
91
+ </TouchableOpacity> : null;
92
+
93
+ if (previewState === "loading" || previewState === "stale") return null;
94
+ if (previewState === "unavailable") return <View style={styles.card}>
95
+ <Text style={styles.error}>暂时无法读取提交预览。{error}</Text>
96
+ <TouchableOpacity accessibilityRole="button" onPress={() => { setError(""); setPreviewState("loading"); setPreviewRequest((value) => value + 1); }} style={styles.retry}>
97
+ <Text style={styles.promptToggleText}>重新读取预览</Text>
98
+ </TouchableOpacity>
99
+ </View>;
100
+
101
+ return <View style={styles.card}>
102
+ {done ? submitted ? <>
103
+ <Text selectable style={styles.done}>已提交 · {submitted[1]}</Text>
104
+ <Text selectable style={styles.doneMessage}>{submitted[2]}</Text>
105
+ </> : <Text selectable style={styles.done}>{done}</Text> : <>
106
+ <Text style={styles.heading}>提交预览</Text>
107
+ <Text style={styles.label}>Commit message</Text>
108
+ <TextInput accessibilityLabel="Commit message" value={editedMessage} onChangeText={setEditedMessage} onFocus={() => setMessageFocused(true)} onBlur={() => setMessageFocused(false)} editable={!busy} maxLength={80} selectTextOnFocus style={[styles.messageInput, messageFocused && styles.messageInputFocused]} />
109
+ {!validMessage ? <Text style={styles.error}>请输入不超过 80 个字符的提交标题。</Text> : null}
110
+ <Text style={styles.files}>{files.length} 个文件 · 请展开查看具体 diff</Text>
111
+ {diff === null && !error ? <Text style={styles.muted}>正在读取 diff…</Text> : null}
112
+ {diff ? <View style={styles.diff}><FileTree files={parseDiffFiles(diff)} /></View> : null}
113
+ {error ? <Text style={styles.error}>{error}</Text> : null}
114
+ {error ? <TouchableOpacity accessibilityRole="button" onPress={() => { setError(""); setPreviewRequest((value) => value + 1); }} style={styles.retry}><Text style={styles.promptToggleText}>重新读取预览</Text></TouchableOpacity> : null}
115
+ </>}
116
+ {!done || promptToggle ? <View style={styles.actions}>
117
+ {promptToggle}
118
+ {!done ? <View style={styles.actionButtons}>
119
+ <TouchableOpacity onPress={dismiss} disabled={busy} style={styles.cancel}><Text style={styles.cancelText}>取消</Text></TouchableOpacity>
120
+ <TouchableOpacity onPress={submit} disabled={busy || !diff || Boolean(error) || !validMessage} style={[styles.confirm, (busy || !diff || error || !validMessage) ? styles.disabled : null]}><Text style={styles.confirmText}>{busy ? "处理中…" : "确认提交"}</Text></TouchableOpacity>
121
+ </View> : null}
122
+ </View> : null}
123
+ {showPrompt && promptPreview ? <ScrollView style={styles.promptBox} nestedScrollEnabled><Text selectable style={styles.promptText}>{promptPreview}</Text></ScrollView> : null}
124
+ </View>;
125
+ }
126
+
127
+ const styles = StyleSheet.create({
128
+ card: { alignSelf: "stretch", backgroundColor: "#fff", borderRadius: 10, borderWidth: StyleSheet.hairlineWidth, borderColor: "#d8dee6", padding: 14, gap: 10 },
129
+ heading: { color: "#111827", fontSize: 15, fontWeight: "700" },
130
+ label: { color: "#6b7280", fontSize: 11, fontWeight: "600" },
131
+ messageInput: { color: "#1f2937", fontSize: 13, lineHeight: 19, borderWidth: 1, borderColor: "#d8dee6", borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, outlineWidth: 0 },
132
+ messageInputFocused: { borderColor: "#15803d" },
133
+ files: { color: "#6b7280", fontSize: 12 },
134
+ diff: { backgroundColor: "#f8fafc", borderRadius: 8, overflow: "hidden" },
135
+ muted: { color: "#6b7280", fontSize: 12 },
136
+ error: { color: "#b91c1c", fontSize: 12 },
137
+ done: { color: "#166534", fontSize: 13 },
138
+ doneMessage: { color: "#166534", fontSize: 13, lineHeight: 20 },
139
+ actions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 },
140
+ actionButtons: { flexDirection: "row", alignItems: "center", gap: 8, marginLeft: "auto" },
141
+ cancel: { minHeight: 36, paddingHorizontal: 15, justifyContent: "center", borderRadius: 8, backgroundColor: "#f3f4f6" },
142
+ cancelText: { color: "#374151", fontSize: 13, fontWeight: "600" },
143
+ confirm: { minHeight: 36, paddingHorizontal: 15, justifyContent: "center", borderRadius: 8, backgroundColor: "#4f46e5" },
144
+ confirmText: { color: "#fff", fontSize: 13, fontWeight: "600" },
145
+ disabled: { opacity: 0.55 },
146
+ promptToggle: { minHeight: 36, justifyContent: "center" },
147
+ promptToggleText: { color: "#4f46e5", fontSize: 12, fontWeight: "600" },
148
+ promptBox: { maxHeight: 320, backgroundColor: "#f8fafc", borderRadius: 8, padding: 10 },
149
+ promptText: { color: "#374151", fontFamily: "monospace", fontSize: 11, lineHeight: 17 },
150
+ retry: { alignSelf: "flex-start", paddingVertical: 4 },
151
+ });
@@ -0,0 +1,8 @@
1
+ import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
+ import type { z } from "zod";
3
+ import { commitResultSchema } from "../shared/commit";
4
+
5
+ export function CommitResultItem(_props: PluginTimelineItemProps<z.infer<typeof commitResultSchema>>) {
6
+ // Keep the timeline marker for recentCommitContext, but the preview card already shows the result.
7
+ return null;
8
+ }
@@ -0,0 +1,18 @@
1
+ /** HACK(Paseo web): timeline renderers do not have a composer draft setter. */
2
+ export function fillVisibleComposer(text: string): boolean {
3
+ if (typeof document === "undefined") return false;
4
+ const inputs = Array.from(document.querySelectorAll<HTMLTextAreaElement>("textarea[data-composer-input]")).filter((input) => {
5
+ const rect = input.getBoundingClientRect();
6
+ return rect.width > 0 && rect.height > 0;
7
+ });
8
+ // Multiple visible agent panes make a document-wide lookup ambiguous.
9
+ if (inputs.length !== 1) return false;
10
+ const input = inputs[0];
11
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
12
+ if (!setter) return false;
13
+ setter.call(input, text);
14
+ input.dispatchEvent(new Event("input", { bubbles: true }));
15
+ input.focus();
16
+ input.setSelectionRange(text.length, text.length);
17
+ return true;
18
+ }
@@ -0,0 +1,5 @@
1
+ // Alpha masks for native trapezoid connectors; tintColor supplies the diff tone.
2
+ export const topRising = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAD70lEQVR42u3UoQEAMAzDsG7//9yRfWGJhAf47O7OzBkg5/5dV0A3ACIA8QCIAMQDIAIQD4AIQDwAIgDxAIgAxAMgAhAPgAhAPAAiAPEAiADEAyACEA+ACEA8ACIA8QCIAMQDIAIQD4AIQDwAIgDxAIgAxAMgAhAPgAhAPAAiAPEAiADEAyACEA+ACEA8ACIA8QCIAMQDIAIQD4AIQDwAIgDxAIgAxAMgAhAPgAhAPAAiAPEAiADEAyACEA+ACEA8ACIA8QCIAMQDIAIQD4AIQDwAIgDxAIgAxAMgAhAPgAhAPAAiAPEAiADEAyACEA+ACEA8ACIA8QCIAMQDIAIQD4AIQDwAIgDxAIgAxAMgAhAPgAhAPAAiAPEAiADEAyACEA+ACEA8ACIA8QCIAALgAhFAAEQABEAEQABEAARABEAARAAEQARAAEQABEAEQABEAARABEAARAAEQARAAEQABEAEQABEAARABEAARAAEQARAAEQABEAEQABEAARABEAARAAEQARAAEQABEAEQABEAARABEAARAAEQARAAEQABEAEQABEAARABEAARAAEQAQQAEQAAUAEEABEAAFABBAARAABQAQQAEQAAUAEEABEAAFABBAARAABQAQQAEQAAUAEEABEAAFABBAARAABQAQQAEQAAUAEEABEAAFABBAARAABQAQQAEQAAUAEEABEAAFABBAARAABQAQQAEQAAUAEEABEAAFABBAARAABQAQQAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBAARQAAQAQQAEUAAEAEEABFAABABBAARQAAQAQQAEUAAEAEEABFAABABBAARQAAQAQQAEUAAEAEEABFAABABBAARQAAQAQQAEUAAEAEEABFAABABAQAREAAQAQEAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBABEQABABAQAREAAQAQEAERAAEAEBABEQABABAYB0BAQAwhEQAAhHQAAgHAEBgHAEBADCERAACEdAACAcAQGAcAQEAMIREAAIR0AAIBwBAYBwBAQAwhEQAAhHQAAgHAEBgHAEBADCERAACEdAACAcAQGAcAQEAMIREAAIR0AAIBwBAYBwBAQAwhEQAAhHQAAgHAEBgHAEBADCERAACEdAACAcAQGAcAQEAMIREAAIR0AAIBwBAYBwBAQAwhEQAAhHQAAgHAEBgHAEBADCERAACEdAACAcAQGAcAQe1aIEDeUBl8oAAAAASUVORK5CYII=";
3
+ export const topFalling = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAD9UlEQVR42u3UAQ3AQBDDsG78Od8DiQ2hUvrd3YCk+20Azfi3zQFANH4HAOH4HQCE43cAEI7fAUA4fgcA4fgdAITjdwAQjt8BQDh+BwDh+B0AhON3ABCO3wFAOH4HAOH4HQCE43cAEI7fAUA4fgcA4fgdAITjdwAQjt8BQDh+BwDh+B0AhON3ABCO3wFAOH4HAOH4HQCE43cAEI7fAUA4fgcA4fgdAITjdwAQjt8BQDh+BwDh+B0AhON3ABCO3wFAOH4HAOH4HQCE43cAEI7fAUA4fgcA4fgdAITjdwAQjt8BQDh+BwDh+B0AhON3ABCO3wFAOH4HAOH4HQCE43cAEI7fAUA4fgcA4fgdAITjdwAQjt8BQDh+BwDh+B0AhON3ABCO3wFAOH4HAOH4HQDij3MAiN8BgPgdAIjfAYD4HQCI3wGA+B0AiN8BgPgdAIjfAYD4HQCI3wGA+B0AiN8BgPgdAIjfAYD4HQCI3wGA+B0AiN8BgPgdAIjfAYD4HQCI3wEgfhwA4scBIH4cAOLHASB+HADixwEgfhwA4scBIH4cAOLHASB+HADixwEgfhwA4ncAJkD8DgDE7wBA/A4AxO8AQPwOAMTvAED8DgDE7wBA/A4AxO8AED8OAPHjABA/DgDx4wAQPw4A8eMAED8OAPHjABA/DgDx4wAQPw4A8eMAED8OAPHjABA/DgDx4wAQPw4A8eMAED8OAPHjABA/DgDx4wAQPw5A/OAAxA8OQPzgAMSPA0D8OADEjwNA/DgAxI8DQPw4AMSPA0D8OADEjwNA/DgAxI8DQPw4AMSPA0D8OADEjwNA/DgAxI8DQPw4AMSPA0D8OADxmwAHIH5wAOIHByB+cADiBwcgfnAA4gcHIH5wAOIHByB+cADiBwcgfnAA4gcHIH5wAOIHByB+cADiBwcgfhwA4scBIH4cAOLHASB+HADixwEgfhyA+MEBiB8cgPjBAYgfHID4wQGIHxyA+MEBiB8cgPjBAYgfHID4wQGIHxyA+MEBiB8cgPjBAYgfHID4wQGIHxyA+MEBiB8cgPjBAYgfHID4oX4A4ofoAYgfogcgfogegPghegDih+gBiB+iByB+iB6A+CF6AOKH6AGIH6IHIH6IHoD4IXoA4ofoAYgfogcgfogegPghegDih+gBiB+iByB+iB6A+CF6AOKH6AGIH6IHIH6IHoD4IXoA4ofoAYgfogcgfogegPghegDih+gBiB+iByB+iB6A+CF6AOKH6AGIH6IHIH6IHoD4IXoA4ofoAYgfogcgfogegPghegDih+gBiB+iByB+iB6A+CF6AOKH6AGIH6IHIH6IHoD4IXoA4ofoAYgfogcgfoh61G4EDCKlQvoAAAAASUVORK5CYII=";
4
+ export const bottomRising = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAD7ElEQVR42u3UQQHAQBCEsLn697wVQiKBB+/ubkDSt+3JAN0BzASgPQATgPgATADiAzABiA/ABCA+ABOA+ABMAOIDMAGID8AEID4AE4D4AEwA4gMwAYgPwAQgPgATgPgATADiAzABiA/ABCA+ABOA+ABMAOIDMAGID8AEID4AE4D4AEwA4gMwAYgPwAQgPgATgPgATADiAzABiA/ABCA+ABOA+ABMAOIDMAGID8AEID4AE4D4AEwA4gMwAYgPwAQgPgATgPgATADiAzABiA/ABCA+ABOA+ABMAOIDMAGID8AEID4AE4D4AEwA4gMwAYgPwAQgPgATgPgATADiAzABiA/ABCA+ABOA+ABMAOIDMAGID8AEID4AE4D4AEwA4gMwAYgPwAQgPgATgPgATAADkMAEMAATAAMwATAAEwADMAEwABMAAzABMAATAAMwATAAEwADMAEwABMAAzABMAATAAMwATAAEwADMAEwABMAAzABMAATAAMwATAAEwADMAEwABMAAzABMAATAAMwATAAEwADMAEwABMAAzABMAATAAMwATAAEwADMAEwABMAAzABDAATwAAwAQwAE8AAMAEMABPAADABDAATwAAwAQwAE8AAMAEMABPAADABDAATwAAwAQwAE8AAMAEMABPAADABDAATwAAwAQwAE8AAMAEMABPAADABDAATwAAwAQwAE8AAMAEMABPAADABDAATwAAwAQwAE8AAMAEMABPAADABDAATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQPABDAATAADwAQwAEwAA8AEMABMAAPABDAATAADwAQwAEwAA8AEMABMAAPABDAATAADwAQwAEwAA8AEMABMAAPABDAATAADwAQwAEwAA8AEMABMwADABAwATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQMAEzAAMAEDABMwADABAwATMAAwAQMAEzAAMAEDABMwAAgPQEDgPAEDADCEzAACE/AACA8AQOA8AQMAMITMAAIT8AAIDwBA4DwBAwAwhMwAAhPwAAgPAEDgPAEDADCEzAACE/AACA8AQOA8AQMAMITMAAIT8AAIDwBA4DwBAwAwhMwAAhPwAAgPAEDgPAEDADCEzAACE/AACA8AQOA8AQMAMITMAAIT8AAIDwBA4DwBAwAwhMwAAhPwAAgPAEDgPAEDADCEzAACE/AACA8AQOA8AQMAMITMAAIT+AHvfQG+2PA58QAAAAASUVORK5CYII=";
5
+ export const bottomFalling = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAD9ElEQVR42u3WgQ0AIAzDMOD/n8shsU+YlGp32w6Q9JwAsq4BgGj8PgAIx28AIBy/AYBw/AYAwvEbAAjHbwAgHL8BgHD8BgDC8RsACMdvACAcvwGAcPwGAMLxGwAIx28AIBy/AYBw/AYAwvEbAAjHbwAgHL8BgHD8BgDC8RsACMdvACAcvwGAcPwGAMLxGwAIx28AIBy/AYBw/AYAwvEbAAjHbwAgHL8BgHD8BgDC8RsACMdvACAcvwGAcPwGAMLxGwAIx28AIBy/AYBw/AYAwvEbAAjHbwAgHL8BgHD8BgDC8RsACMdvACAcvwGAcPwGAMLxGwAIx28AIBy/AYBw/AYAwvEbAAjHbwAgHL8BgHD8BgDC8RsACMdvACAcvwGAcPwGAMLxGwAIx28AIBy/AUD8cQYA8RsAEL8BAPEbABC/AQDxGwAQvwEA8RsAEL8BAPEbABC/AQDxGwAQvwEA8RsAEL8BAPEbABC/AQDxGwAQvwEA8RsAEL8BAPEbABC/AQDxGwDEjwFA/BgAxI8BQPwYAMSPAUD8GADEjwFA/BgAxI8BQPwYAMSPAUD8GADEjwFA/AbACRC/AQDxGwAQvwEA8RsAEL8BAPEbABC/AQDxGwAQvwEA8RsAxI8BQPwYAMSPAUD8GADEjwFA/BgAxI8BQPwYAMSPAUD8GADEjwFA/BgAxI8BQPwYAMSPAUD8GADEjwFA/BgAxI8BQPwYAMSPAUD8GADEjwEQPxgA8YMBED8YAPFjABA/BgDxYwAQPwYA8WMAED8GAPFjABA/BgDxYwAQPwYA8WMAED8GAPFjABA/BgDxYwAQPwYA8WMAED8GAPFjABA/BkD8ToABED8YAPGDARA/GADxgwEQPxgA8YMBED8YAPGDARA/GADxgwEQPxgA8YMBED8YAPGDARA/GADxgwEQPwYA8WMAED8GAPFjABA/BgDxYwAQPwZA/GAAxA8GQPxgAMQPBkD8YADEDwZA/GAAxA8GQPxgAMQPBkD8YADEDwZA/GAAxA8GQPxgAMQPBkD8YADEDwZA/GAAxA8GQPxgAMQPBkD8UB8A8UN0AMQP0QEQP0QHQPwQHQDxQ3QAxA/RARA/RAdA/BAdAPFDdADED9EBED9EB0D8EB0A8UN0AMQP0QEQP0QHQPwQHQDxQ3QAxA/RARA/RAdA/BAdAPFDdADED9EBED9EB0D8EB0A8UN0AMQP0QEQP0QHQPwQHQDxQ3QAxA/RARA/RAdA/BAdAPFDdADED9EBED9EB0D8EB0A8UN0AMQP0QEQP0QHQPwQHQDxQ3QAxA/RARA/RAdA/BAdAPFDdADED9EBED9EB0D8EB0A8UN0AMQPQR+5+Ab7jPB0hAAAAABJRU5ErkJggg==";
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildPaneRows, buildSplitDiffRows, parseDiffFiles } from "./diff.ts";
4
+
5
+ test("snapshot diff renders only code rows, with correct file and line numbers", () => {
6
+ const diff = [
7
+ "diff --git a/src/edited.ts b/src/edited.ts",
8
+ "index 1234567..abcdef0 100644",
9
+ "--- a/src/edited.ts",
10
+ "+++ b/src/edited.ts",
11
+ "@@ -93,2 +95,3 @@",
12
+ " before",
13
+ "-old",
14
+ "+new",
15
+ "+another",
16
+ "diff --git a/src/new.ts b/src/new.ts",
17
+ "new file mode 100644",
18
+ "--- /dev/null",
19
+ "+++ b/src/new.ts",
20
+ "@@ -0,0 +1 @@",
21
+ "+created",
22
+ "diff --git a/src/deleted.ts b/src/deleted.ts",
23
+ "deleted file mode 100644",
24
+ "--- a/src/deleted.ts",
25
+ "+++ /dev/null",
26
+ "@@ -1 +0,0 @@",
27
+ "-removed",
28
+ ].join("\n");
29
+ const files = parseDiffFiles(diff);
30
+ assert.deepEqual(files.map((file) => file.path), ["src/edited.ts", "src/new.ts", "src/deleted.ts"]);
31
+ assert.deepEqual(files.map((file) => file.status), ["modified", "added", "deleted"]);
32
+ const rows = buildSplitDiffRows(files[0]!.lines);
33
+ assert.deepEqual(rows.map((row) => row.kind), ["context", "change"]);
34
+ assert.equal(rows[0]!.kind === "context" && rows[0].before.number, 93);
35
+ assert.equal(rows[0]!.kind === "context" && rows[0].after.number, 95);
36
+ assert.equal(rows[1]!.kind === "change" && rows[1].after.at(-1)?.number, 97);
37
+ assert.deepEqual(buildSplitDiffRows(files[1]!.lines).map((row) => row.kind), ["change"]);
38
+ assert.deepEqual(buildSplitDiffRows(files[2]!.lines).map((row) => row.kind), ["change"]);
39
+ });
40
+
41
+ test("pane rows flow independently and show only skipped ranges as waves", () => {
42
+ const files = parseDiffFiles([
43
+ "diff --git a/a.ts b/a.ts", "--- a/a.ts", "+++ b/a.ts",
44
+ "@@ -1,2 +1,3 @@", " same", "+extra", " next",
45
+ "@@ -20,2 +21,2 @@", " later", "-old", "+new",
46
+ ].join("\n"));
47
+ const rows = buildSplitDiffRows(files[0]!.lines);
48
+ const before = buildPaneRows(rows, "before");
49
+ const after = buildPaneRows(rows, "after");
50
+ assert.deepEqual(before.map((row) => row.kind), ["code", "change", "code", "wave", "code", "change"]);
51
+ assert.deepEqual(after.map((row) => row.kind), ["code", "change", "code", "wave", "code", "change"]);
52
+ assert.equal(before[1]!.kind === "change" && before[1].cells.length, 0);
53
+ assert.equal(after[1]!.kind === "change" && after[1].cells.length, 1);
54
+ });
55
+
56
+ test("legacy repeated edit markers do not appear as visible rows", () => {
57
+ const files = parseDiffFiles([
58
+ "diff --git a/a.ts b/a.ts", "@@ -1 +1 @@", "-first", "+second",
59
+ "diff --git a/a.ts b/a.ts", "@@ -1 +1 @@", "-second", "+final",
60
+ ].join("\n"));
61
+ assert.equal(files.length, 1);
62
+ assert.deepEqual(buildSplitDiffRows(files[0]!.lines).map((row) => row.kind), ["change", "change"]);
63
+ });
package/client/diff.ts ADDED
@@ -0,0 +1,153 @@
1
+ /** Group unified diffs by file for the review card. */
2
+ export interface DiffLine {
3
+ type: "add" | "remove" | "context" | "header";
4
+ content: string;
5
+ }
6
+
7
+ export interface DiffFile {
8
+ path: string;
9
+ status: "added" | "modified" | "deleted";
10
+ added: number;
11
+ removed: number;
12
+ lines: DiffLine[];
13
+ }
14
+
15
+ export interface DiffCell {
16
+ number?: number;
17
+ text: string;
18
+ type: "context" | "add" | "remove";
19
+ }
20
+
21
+ export type SplitDiffRow =
22
+ | { kind: "header"; label: string }
23
+ | { kind: "context"; before: DiffCell; after: DiffCell }
24
+ | { kind: "change"; before: DiffCell[]; after: DiffCell[]; tone: "modify" | "add" | "remove" };
25
+
26
+ export type PaneRow =
27
+ | { kind: "wave"; key: string; index: number }
28
+ | { kind: "code"; key: string; cell: DiffCell }
29
+ | { kind: "change"; key: string; index: number; cells: DiffCell[]; tone: "modify" | "add" | "remove" };
30
+
31
+ /** Each side flows independently. A skipped source range gets a small wave divider. */
32
+ export function buildPaneRows(rows: SplitDiffRow[], side: "before" | "after"): PaneRow[] {
33
+ const result: PaneRow[] = [];
34
+ let lastNumber: number | undefined;
35
+ rows.forEach((row, index) => {
36
+ if (row.kind === "header") return;
37
+ const cells = row.kind === "context" ? [row[side]] : row[side];
38
+ const first = cells[0]?.number;
39
+ if (first !== undefined && (lastNumber === undefined ? first > 1 : first !== lastNumber + 1)) {
40
+ result.push({ kind: "wave", key: `wave-${index}`, index });
41
+ }
42
+ if (row.kind === "context") result.push({ kind: "code", key: `code-${index}`, cell: row[side] });
43
+ else result.push({ kind: "change", key: `change-${index}`, index, cells: row[side], tone: row.tone });
44
+ const last = cells.at(-1)?.number;
45
+ if (last !== undefined) lastNumber = last;
46
+ });
47
+ return result;
48
+ }
49
+
50
+ /** Keep each change run together so different line counts can share one block height. */
51
+ export function buildSplitDiffRows(lines: DiffLine[]): SplitDiffRow[] {
52
+ const rows: SplitDiffRow[] = [];
53
+ let beforeNumber: number | undefined;
54
+ let afterNumber: number | undefined;
55
+ let removed: DiffCell[] = [];
56
+ let added: DiffCell[] = [];
57
+
58
+ const flushChanges = () => {
59
+ if (removed.length || added.length) rows.push({
60
+ kind: "change",
61
+ before: removed,
62
+ after: added,
63
+ tone: removed.length && added.length ? "modify" : removed.length ? "remove" : "add",
64
+ });
65
+ removed = [];
66
+ added = [];
67
+ };
68
+
69
+ for (const line of lines) {
70
+ if (line.type === "header") {
71
+ flushChanges();
72
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line.content);
73
+ if (hunk) {
74
+ beforeNumber = Number(hunk[1]);
75
+ afterNumber = Number(hunk[2]);
76
+ } else if (line.content === "再次修改") {
77
+ beforeNumber = undefined;
78
+ afterNumber = undefined;
79
+ }
80
+ // Hunk coordinates only set line numbers; they are not visible diff rows.
81
+ continue;
82
+ }
83
+
84
+ const text = line.content.slice(1);
85
+ if (line.type === "remove") {
86
+ if (beforeNumber === undefined) beforeNumber = 1;
87
+ removed.push({ number: beforeNumber++, text, type: "remove" });
88
+ } else if (line.type === "add") {
89
+ if (afterNumber === undefined) afterNumber = 1;
90
+ added.push({ number: afterNumber++, text, type: "add" });
91
+ } else {
92
+ flushChanges();
93
+ const content = line.content.startsWith(" ") ? text : line.content;
94
+ rows.push({ kind: "context", before: { number: beforeNumber, text: content, type: "context" }, after: { number: afterNumber, text: content, type: "context" } });
95
+ if (beforeNumber !== undefined) beforeNumber++;
96
+ if (afterNumber !== undefined) afterNumber++;
97
+ }
98
+ }
99
+ flushChanges();
100
+ return rows;
101
+ }
102
+
103
+ export function parseDiffFiles(diffText?: string): DiffFile[] {
104
+ if (!diffText?.trim()) return [];
105
+ const files: DiffFile[] = [];
106
+ const byPath = new Map<string, DiffFile>();
107
+ let current: DiffFile | undefined;
108
+ const startFile = (path: string) => {
109
+ current = byPath.get(path);
110
+ if (current) {
111
+ current.lines.push({ type: "header", content: "再次修改" });
112
+ } else {
113
+ current = { path, status: "modified", added: 0, removed: 0, lines: [] };
114
+ byPath.set(path, current);
115
+ files.push(current);
116
+ }
117
+ };
118
+
119
+ for (const line of diffText.replace(/\r\n/g, "\n").split("\n")) {
120
+ const gitHeader = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
121
+ if (gitHeader) {
122
+ startFile(gitHeader[2]);
123
+ continue;
124
+ }
125
+ const newFile = /^\+\+\+ (?:b\/)?(.+?)(?: \(new file\))?$/.exec(line);
126
+ if (newFile && newFile[1] !== "/dev/null") {
127
+ if (!current || current.path !== newFile[1]) startFile(newFile[1]);
128
+ continue;
129
+ }
130
+ if (line.startsWith("new file mode ") || line === "--- /dev/null") {
131
+ if (current) current.status = "added";
132
+ continue;
133
+ }
134
+ if (line.startsWith("deleted file mode ") || line === "+++ /dev/null") {
135
+ if (current) current.status = "deleted";
136
+ continue;
137
+ }
138
+ if (/^--- (?:a\/|\/dev\/null)/.test(line) || /^(?:index |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to )/.test(line)) continue;
139
+ if (!current) startFile("修改内容");
140
+ if (line.startsWith("+")) {
141
+ current!.added++;
142
+ current!.lines.push({ type: "add", content: line });
143
+ } else if (line.startsWith("-")) {
144
+ current!.removed++;
145
+ current!.lines.push({ type: "remove", content: line });
146
+ } else if (line.startsWith("@@") || line.startsWith("\\ No newline")) {
147
+ current!.lines.push({ type: "header", content: line });
148
+ } else if (line.length) {
149
+ current!.lines.push({ type: "context", content: line });
150
+ }
151
+ }
152
+ return files.filter((file) => file.lines.length || file.added || file.removed);
153
+ }