@yuangc/vibe-review 1.0.0 → 1.1.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.
- package/client/edit-diff-data.test.ts +21 -0
- package/client/edit-diff-data.ts +12 -0
- package/client/edit-diff.tsx +66 -0
- package/client/file-tree.tsx +2 -21
- package/client/git-history.tsx +649 -0
- package/client/review.tsx +2 -2
- package/client/split-diff.tsx +3 -3
- package/client/workspace-commit.tsx +381 -0
- package/client/wrap-toggle.tsx +30 -0
- package/index.client.tsx +16 -0
- package/index.server.ts +4 -0
- package/package.json +4 -4
- package/paseo-plugin.json +1 -1
- package/server/git-history.ts +83 -0
- package/server/paseo-api-types.ts +1 -1
- package/server/review.ts +2 -1
- package/server/verify.ts +4 -4
- package/server/workspace-commit.ts +79 -0
- package/shared/git-history.ts +21 -0
- package/shared/workspace-commit.ts +29 -0
package/client/review.tsx
CHANGED
|
@@ -166,10 +166,10 @@ export const ReviewResultItem: ComponentType<PluginTimelineItemProps<ReviewResul
|
|
|
166
166
|
setCommitProgress(null);
|
|
167
167
|
setPendingDiff(null);
|
|
168
168
|
} else if (status.kind === "idle") {
|
|
169
|
-
void paseo.agents.ref(props.agentId).timeline.refetch({ limit: 50 }).then((timeline
|
|
169
|
+
void paseo.agents.ref(props.agentId).timeline.refetch({ limit: 50 }).then((timeline) => {
|
|
170
170
|
if (!active) return;
|
|
171
171
|
const items = timeline.entries.map((entry) => entry.item);
|
|
172
|
-
const reviewIndex = items.findLastIndex((item) => item.type === "plugin" && item.kind === "review-result" && item.data
|
|
172
|
+
const reviewIndex = items.findLastIndex((item) => item.type === "plugin" && item.kind === "review-result" && typeof item.data === "object" && item.data !== null && !Array.isArray(item.data) && item.data.scoredAt === data.scoredAt);
|
|
173
173
|
const completed = reviewIndex >= 0 && items.slice(reviewIndex + 1).some((item) => item.type === "plugin" &&
|
|
174
174
|
(item.kind === COMMIT_PREVIEW_KIND || item.kind === COMMIT_RESULT_KIND));
|
|
175
175
|
if (completed) {
|
package/client/split-diff.tsx
CHANGED
|
@@ -42,7 +42,7 @@ function measuredBounds(fallback: ChangeBounds, measured: ChangeBounds): ChangeB
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Two aligned panes modeled after an IDE side-by-side diff. */
|
|
45
|
-
export function SplitDiff({ file, wrapLines }: { file: DiffFile; wrapLines: boolean }) {
|
|
45
|
+
export function SplitDiff({ file, wrapLines, maxHeight = MAX_VIEWPORT_HEIGHT }: { file: DiffFile; wrapLines: boolean; maxHeight?: number }) {
|
|
46
46
|
const rows = useMemo(() => buildSplitDiffRows(file.lines), [file.lines]);
|
|
47
47
|
const changedSpans = useMemo(() => changedSpansByCell(rows), [rows]);
|
|
48
48
|
const beforeRows = useMemo(() => buildPaneRows(rows, "before"), [rows]);
|
|
@@ -89,9 +89,9 @@ export function SplitDiff({ file, wrapLines }: { file: DiffFile; wrapLines: bool
|
|
|
89
89
|
const measuredLayoutKey = useRef(`${file.path}\0${wrapLines}`);
|
|
90
90
|
const requestedVertical = useRef({ before: 0, after: 0 });
|
|
91
91
|
const virtualPosition = useRef(0);
|
|
92
|
-
const viewportHeight = Math.min(
|
|
92
|
+
const viewportHeight = Math.min(maxHeight, Math.max(32, paneHeights.before, paneHeights.after));
|
|
93
93
|
const nativeContentHeight = Math.max(fixedNoWrapHeight(beforeRows), fixedNoWrapHeight(afterRows), paneHeights.before, paneHeights.after);
|
|
94
|
-
const nativeViewportHeight = Math.min(
|
|
94
|
+
const nativeViewportHeight = Math.min(maxHeight, Math.max(32, nativeContentHeight));
|
|
95
95
|
const [nativeScrollPosition, setNativeScrollPosition] = useState(0);
|
|
96
96
|
const scrollHeights = Platform.OS === "web" ? paneHeights : {
|
|
97
97
|
before: paneHeights.before || fixedNoWrapHeight(beforeRows),
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, type ViewStyle } from "react-native";
|
|
3
|
+
import { useRpc, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
5
|
+
import { workspaceGitCommitRpc, workspaceGitDiffRpc, workspaceGitPushRpc, workspaceGitTrackRpc, workspaceGitStatusRpc } from "../shared/workspace-commit";
|
|
6
|
+
import { parseDiffFiles } from "./diff";
|
|
7
|
+
import { FileTypeIcon } from "./file-icons";
|
|
8
|
+
import { SplitDiff } from "./split-diff";
|
|
9
|
+
import { WrapToggleButton } from "./wrap-toggle";
|
|
10
|
+
|
|
11
|
+
type File = { path: string; status: string; staged: boolean; unstaged: boolean; untracked: boolean };
|
|
12
|
+
type Selection = { path: string; staged: boolean; status?: string };
|
|
13
|
+
type TreeNode = { kind: "folder"; name: string; path: string; files: File[]; children: TreeNode[] } | { kind: "file"; name: string; file: File };
|
|
14
|
+
const checkboxState = (files: File[], selected: Set<string>) => files.length > 0 && files.every((file) => selected.has(file.path)) ? "all" : files.some((file) => selected.has(file.path)) ? "partial" : "none";
|
|
15
|
+
const checkboxIcon = (state: "all" | "partial" | "none") => state === "all" ? "SquareCheck" : state === "partial" ? "SquareMinus" : "Square";
|
|
16
|
+
const dragType = "application/x-vibe-review-git-files";
|
|
17
|
+
function fileNameColor(file: File) {
|
|
18
|
+
if (file.untracked || file.status.includes("D")) return styles.removedName;
|
|
19
|
+
if (file.status.includes("A") || file.status.includes("?")) return styles.addedName;
|
|
20
|
+
return styles.path;
|
|
21
|
+
}
|
|
22
|
+
function changeKind(file: File): "added" | "modified" | "deleted" {
|
|
23
|
+
if (file.status.includes("D")) return "deleted";
|
|
24
|
+
if (file.untracked || file.status.includes("A")) return "added";
|
|
25
|
+
return "modified";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function DraggableRow({ children, paths, workspaceId, style }: { children: ReactNode; paths: string[]; workspaceId: string; style: ViewStyle | Array<ViewStyle | false | undefined> }) {
|
|
29
|
+
const ref = useRef<View>(null);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (Platform.OS !== "web") return;
|
|
32
|
+
const node = ref.current as unknown as HTMLElement | null;
|
|
33
|
+
if (!node) return;
|
|
34
|
+
node.draggable = true;
|
|
35
|
+
const start = (event: DragEvent) => {
|
|
36
|
+
if (!event.dataTransfer) return;
|
|
37
|
+
event.stopPropagation();
|
|
38
|
+
event.dataTransfer.effectAllowed = "copy";
|
|
39
|
+
event.dataTransfer.setData(dragType, JSON.stringify({ workspaceId, paths }));
|
|
40
|
+
};
|
|
41
|
+
node.addEventListener("dragstart", start);
|
|
42
|
+
return () => { node.removeEventListener("dragstart", start); node.draggable = false; };
|
|
43
|
+
}, [workspaceId, paths.join("\0")]);
|
|
44
|
+
return <View ref={ref} style={style}>{children}</View>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function TrackedDropZone({ children, workspaceId, onFiles }: { children: ReactNode; workspaceId: string; onFiles(paths: string[]): void }) {
|
|
48
|
+
const ref = useRef<View>(null);
|
|
49
|
+
const [hovered, setHovered] = useState(false);
|
|
50
|
+
const onFilesRef = useRef(onFiles);
|
|
51
|
+
onFilesRef.current = onFiles;
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (Platform.OS !== "web") return;
|
|
54
|
+
const node = ref.current as unknown as HTMLElement | null;
|
|
55
|
+
if (!node) return;
|
|
56
|
+
const over = (event: DragEvent) => {
|
|
57
|
+
if (!event.dataTransfer?.types.includes(dragType)) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
event.dataTransfer.dropEffect = "copy";
|
|
60
|
+
setHovered(true);
|
|
61
|
+
};
|
|
62
|
+
const leave = (event: DragEvent) => { if (!node.contains(event.relatedTarget as Node | null)) setHovered(false); };
|
|
63
|
+
const drop = (event: DragEvent) => {
|
|
64
|
+
setHovered(false);
|
|
65
|
+
const data = event.dataTransfer?.getData(dragType);
|
|
66
|
+
if (!data) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
try {
|
|
69
|
+
const value: unknown = JSON.parse(data);
|
|
70
|
+
if (typeof value !== "object" || value === null || !("workspaceId" in value) || value.workspaceId !== workspaceId || !("paths" in value) || !Array.isArray(value.paths) || !value.paths.every((path) => typeof path === "string")) return;
|
|
71
|
+
onFilesRef.current(value.paths);
|
|
72
|
+
} catch { /* Ignore unrelated browser drag payloads. */ }
|
|
73
|
+
};
|
|
74
|
+
node.addEventListener("dragover", over);
|
|
75
|
+
node.addEventListener("dragleave", leave);
|
|
76
|
+
node.addEventListener("drop", drop);
|
|
77
|
+
return () => { node.removeEventListener("dragover", over); node.removeEventListener("dragleave", leave); node.removeEventListener("drop", drop); };
|
|
78
|
+
}, [workspaceId]);
|
|
79
|
+
return <View ref={ref} style={hovered && styles.dropTarget}>{children}</View>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fileTree(files: File[]): TreeNode[] {
|
|
83
|
+
const root: TreeNode[] = [];
|
|
84
|
+
const folders = new Map<string, Extract<TreeNode, { kind: "folder" }>>();
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const parts = file.path.split("/").filter(Boolean);
|
|
87
|
+
let children = root;
|
|
88
|
+
let path = "";
|
|
89
|
+
for (const name of parts.slice(0, -1)) {
|
|
90
|
+
path = path ? `${path}/${name}` : name;
|
|
91
|
+
let folder = folders.get(path);
|
|
92
|
+
if (!folder) {
|
|
93
|
+
folder = { kind: "folder", name, path, files: [], children: [] };
|
|
94
|
+
folders.set(path, folder);
|
|
95
|
+
children.push(folder);
|
|
96
|
+
}
|
|
97
|
+
folder.files.push(file);
|
|
98
|
+
children = folder.children;
|
|
99
|
+
}
|
|
100
|
+
children.push({ kind: "file", name: parts.at(-1) ?? file.path, file });
|
|
101
|
+
}
|
|
102
|
+
const sort = (nodes: TreeNode[]) => {
|
|
103
|
+
nodes.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "folder" ? -1 : 1);
|
|
104
|
+
nodes.forEach((node) => { if (node.kind === "folder") sort(node.children); });
|
|
105
|
+
};
|
|
106
|
+
sort(root);
|
|
107
|
+
const compact = (nodes: TreeNode[]): TreeNode[] => nodes.map((node) => {
|
|
108
|
+
if (node.kind === "folder") {
|
|
109
|
+
node.children = compact(node.children);
|
|
110
|
+
while (node.children.length === 1 && node.children[0]?.kind === "folder") {
|
|
111
|
+
const child = node.children[0];
|
|
112
|
+
node.name += `/${child.name}`;
|
|
113
|
+
node.path = child.path;
|
|
114
|
+
node.children = child.children;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return node;
|
|
118
|
+
});
|
|
119
|
+
return compact(root);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function WorkspaceCommitPanel({ workspaceId, onFilePreview, externalPreview }: PluginWorkspacePanelProps & { onFilePreview?: (selection: Selection) => void; externalPreview?: Selection | null }) {
|
|
123
|
+
const getStatus = useRpc(workspaceGitStatusRpc);
|
|
124
|
+
const getDiff = useRpc(workspaceGitDiffRpc);
|
|
125
|
+
const trackFiles = useRpc(workspaceGitTrackRpc);
|
|
126
|
+
const commit = useRpc(workspaceGitCommitRpc);
|
|
127
|
+
const push = useRpc(workspaceGitPushRpc);
|
|
128
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
129
|
+
const [branch, setBranch] = useState("");
|
|
130
|
+
const [selected, setSelected] = useState<Selection | null>(null);
|
|
131
|
+
const [diff, setDiff] = useState("");
|
|
132
|
+
const [message, setMessage] = useState("");
|
|
133
|
+
const [busy, setBusy] = useState(false);
|
|
134
|
+
const [pushing, setPushing] = useState(false);
|
|
135
|
+
const [error, setError] = useState("");
|
|
136
|
+
const [result, setResult] = useState("");
|
|
137
|
+
const [revision, setRevision] = useState(0);
|
|
138
|
+
const [wrapLines, setWrapLines] = useState(false);
|
|
139
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
140
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
141
|
+
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
|
142
|
+
const [previewHeight, setPreviewHeight] = useState(0);
|
|
143
|
+
const [previewHeaderHeight, setPreviewHeaderHeight] = useState(0);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
let active = true;
|
|
147
|
+
void getStatus({ workspaceId }).then((value) => {
|
|
148
|
+
if (active) {
|
|
149
|
+
setFiles(value.files);
|
|
150
|
+
setSelectedPaths((current) => new Set([...current].filter((path) => value.files.some((file) => file.path === path))));
|
|
151
|
+
setBranch(value.branch);
|
|
152
|
+
setError("");
|
|
153
|
+
}
|
|
154
|
+
}, (reason: unknown) => { if (active) setError(String(reason)); });
|
|
155
|
+
return () => { active = false; };
|
|
156
|
+
}, [workspaceId, revision]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
let active = true;
|
|
160
|
+
setDiff("");
|
|
161
|
+
if (!onFilePreview && selected) void getDiff({ workspaceId, path: selected.path, staged: selected.staged }).then(
|
|
162
|
+
(value) => { if (active) setDiff(value.diff); },
|
|
163
|
+
(reason: unknown) => { if (active) setError(String(reason)); },
|
|
164
|
+
);
|
|
165
|
+
return () => { active = false; };
|
|
166
|
+
}, [workspaceId, selected?.path, selected?.staged, revision]);
|
|
167
|
+
|
|
168
|
+
const toggleSelection = (targetFiles: File[]) => setSelectedPaths((current) => {
|
|
169
|
+
const next = new Set(current);
|
|
170
|
+
const select = checkboxState(targetFiles, current) !== "all";
|
|
171
|
+
for (const file of targetFiles) {
|
|
172
|
+
if (select) next.add(file.path);
|
|
173
|
+
else next.delete(file.path);
|
|
174
|
+
}
|
|
175
|
+
return next;
|
|
176
|
+
});
|
|
177
|
+
const track = async (paths: string[]) => {
|
|
178
|
+
if (!paths.length || busy) return;
|
|
179
|
+
setBusy(true);
|
|
180
|
+
try {
|
|
181
|
+
await trackFiles({ workspaceId, paths });
|
|
182
|
+
setSelected(null);
|
|
183
|
+
setRevision((value) => value + 1);
|
|
184
|
+
setError("");
|
|
185
|
+
} catch (reason) { setError(String(reason)); }
|
|
186
|
+
finally { setBusy(false); }
|
|
187
|
+
};
|
|
188
|
+
const submit = async () => {
|
|
189
|
+
if (busy) return;
|
|
190
|
+
if (selectedPaths.size === 0) { setError("请先勾选要提交的文件"); return; }
|
|
191
|
+
if (!message.trim()) { setError("请填写提交说明"); return; }
|
|
192
|
+
setBusy(true);
|
|
193
|
+
try {
|
|
194
|
+
const value = await commit({ workspaceId, message: message.trim(), paths: [...selectedPaths] });
|
|
195
|
+
setResult(value.summary);
|
|
196
|
+
setMessage("");
|
|
197
|
+
setSelected(null);
|
|
198
|
+
setSelectedPaths(new Set());
|
|
199
|
+
setRevision((current) => current + 1);
|
|
200
|
+
setError("");
|
|
201
|
+
} catch (reason) { setError(String(reason)); }
|
|
202
|
+
finally { setBusy(false); }
|
|
203
|
+
};
|
|
204
|
+
const pushBranch = async () => {
|
|
205
|
+
if (busy) return;
|
|
206
|
+
setBusy(true);
|
|
207
|
+
setPushing(true);
|
|
208
|
+
try {
|
|
209
|
+
const value = await push({ workspaceId });
|
|
210
|
+
setResult(value.summary);
|
|
211
|
+
setError("");
|
|
212
|
+
} catch (reason) { setError(String(reason)); }
|
|
213
|
+
finally { setPushing(false); setBusy(false); }
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const groups: Array<{ title: string; files: File[] }> = [
|
|
217
|
+
{ title: "已跟踪", files: files.filter((file) => !file.untracked) },
|
|
218
|
+
...(files.some((file) => file.untracked) ? [{ title: "未跟踪", files: files.filter((file) => file.untracked) }] : []),
|
|
219
|
+
];
|
|
220
|
+
const selectedFiles = files.filter((file) => selectedPaths.has(file.path));
|
|
221
|
+
const counts = selectedFiles.reduce((result, file) => {
|
|
222
|
+
result[changeKind(file)]++;
|
|
223
|
+
return result;
|
|
224
|
+
}, { added: 0, modified: 0, deleted: 0 });
|
|
225
|
+
const parsed = parseDiffFiles(diff);
|
|
226
|
+
const toggleFolder = (key: string) => setCollapsed((current) => {
|
|
227
|
+
const next = new Set(current);
|
|
228
|
+
if (next.has(key)) next.delete(key);
|
|
229
|
+
else next.add(key);
|
|
230
|
+
return next;
|
|
231
|
+
});
|
|
232
|
+
const toggleGroup = (title: string) => setCollapsedGroups((current) => {
|
|
233
|
+
const next = new Set(current);
|
|
234
|
+
if (next.has(title)) next.delete(title);
|
|
235
|
+
else next.add(title);
|
|
236
|
+
return next;
|
|
237
|
+
});
|
|
238
|
+
const renderNodes = (nodes: TreeNode[], group: { title: string }, depth: number): React.ReactNode[] => nodes.map((node) => {
|
|
239
|
+
if (node.kind === "folder") {
|
|
240
|
+
const key = `${group.title}:${node.path}`;
|
|
241
|
+
const isCollapsed = collapsed.has(key);
|
|
242
|
+
const row = <>
|
|
243
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`${isCollapsed ? "展开" : "折叠"} ${node.path}`} accessibilityState={{ expanded: !isCollapsed }} onPress={() => toggleFolder(key)} style={styles.chevron}>
|
|
244
|
+
<Icon name={isCollapsed ? "ChevronRight" : "ChevronDown"} size={15} color="#64748b" />
|
|
245
|
+
</Pressable>
|
|
246
|
+
<Pressable accessibilityRole="checkbox" accessibilityState={{ checked: checkboxState(node.files, selectedPaths) === "all" }} accessibilityLabel={`${checkboxState(node.files, selectedPaths) === "all" ? "取消选择" : "选择"} ${node.path} 下的文件`} onPress={() => toggleSelection(node.files)} style={styles.checkbox}>
|
|
247
|
+
<Icon name={checkboxIcon(checkboxState(node.files, selectedPaths))} size={17} color="#64748b" />
|
|
248
|
+
</Pressable>
|
|
249
|
+
<Pressable accessibilityRole="button" onPress={() => toggleFolder(key)} style={styles.fileButton}>
|
|
250
|
+
<Icon name="Folder" size={16} color="#6b7280" />
|
|
251
|
+
<Text style={[styles.folderName, group.title === "未跟踪" && styles.untrackedName]} numberOfLines={1}>{node.name}</Text>
|
|
252
|
+
<Text style={styles.count} numberOfLines={1}>{node.files.length} 个文件</Text>
|
|
253
|
+
</Pressable>
|
|
254
|
+
</>;
|
|
255
|
+
const rowStyle = [styles.fileRow, { paddingLeft: 29 + depth * 20 }];
|
|
256
|
+
return <View key={key}>
|
|
257
|
+
{group.title === "未跟踪" ? <DraggableRow workspaceId={workspaceId} paths={node.files.map((file) => file.path)} style={rowStyle}>{row}</DraggableRow> : <View style={rowStyle}>{row}</View>}
|
|
258
|
+
{!isCollapsed ? renderNodes(node.children, group, depth + 1) : null}
|
|
259
|
+
</View>;
|
|
260
|
+
}
|
|
261
|
+
const file = node.file;
|
|
262
|
+
const row = <>
|
|
263
|
+
<View style={styles.chevron} />
|
|
264
|
+
<Pressable accessibilityRole="checkbox" accessibilityState={{ checked: selectedPaths.has(file.path) }} accessibilityLabel={`${selectedPaths.has(file.path) ? "取消选择" : "选择"} ${file.path} 用于提交`} onPress={() => toggleSelection([file])} style={styles.checkbox}>
|
|
265
|
+
<Icon name={checkboxIcon(checkboxState([file], selectedPaths))} size={17} color="#64748b" />
|
|
266
|
+
</Pressable>
|
|
267
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`查看 ${file.path} 的差异`} onPress={() => {
|
|
268
|
+
const selection = { path: file.path, staged: !file.unstaged && file.staged, status: file.untracked ? "??" : file.status };
|
|
269
|
+
if (onFilePreview) onFilePreview(selection);
|
|
270
|
+
else setSelected(selection);
|
|
271
|
+
}} style={styles.fileButton}>
|
|
272
|
+
<FileTypeIcon fileName={node.name} />
|
|
273
|
+
<Text style={[styles.path, fileNameColor(file)]} numberOfLines={1}>{node.name}</Text>
|
|
274
|
+
</Pressable>
|
|
275
|
+
</>;
|
|
276
|
+
const rowStyle = [styles.fileRow, { paddingLeft: 29 + depth * 20 }, (onFilePreview ? externalPreview?.path === file.path : selected?.path === file.path) && styles.active];
|
|
277
|
+
return group.title === "未跟踪"
|
|
278
|
+
? <DraggableRow key={`${group.title}:${file.path}`} workspaceId={workspaceId} paths={[file.path]} style={rowStyle}>{row}</DraggableRow>
|
|
279
|
+
: <View key={`${group.title}:${file.path}`} style={rowStyle}>{row}</View>;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return <View style={styles.root}>
|
|
283
|
+
<View style={styles.toolbar}>
|
|
284
|
+
<Text style={styles.heading}>提交更改{branch ? ` · ${branch}` : ""}</Text>
|
|
285
|
+
<Pressable accessibilityRole="button" accessibilityLabel="刷新 Git 状态" onPress={() => setRevision((value) => value + 1)}><Icon name="RefreshCw" size={17} color="#64748b" /></Pressable>
|
|
286
|
+
</View>
|
|
287
|
+
<ScrollView style={!onFilePreview && selected ? styles.list : styles.listExpanded}>
|
|
288
|
+
{groups.map((group) => {
|
|
289
|
+
const isCollapsed = collapsedGroups.has(group.title);
|
|
290
|
+
const header = <>
|
|
291
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`${isCollapsed ? "展开" : "折叠"}${group.title}`} accessibilityState={{ expanded: !isCollapsed }} onPress={() => toggleGroup(group.title)} style={styles.chevron}>
|
|
292
|
+
<Icon name={isCollapsed ? "ChevronRight" : "ChevronDown"} size={15} color="#64748b" />
|
|
293
|
+
</Pressable>
|
|
294
|
+
<Pressable accessibilityRole="checkbox" accessibilityState={{ checked: checkboxState(group.files, selectedPaths) === "all" }} accessibilityLabel={`${checkboxState(group.files, selectedPaths) === "all" ? "取消选择" : "选择"}全部${group.title}文件用于提交`} onPress={() => toggleSelection(group.files)} disabled={group.files.length === 0} style={styles.checkbox}>
|
|
295
|
+
<Icon name={checkboxIcon(checkboxState(group.files, selectedPaths))} size={17} color="#64748b" />
|
|
296
|
+
</Pressable>
|
|
297
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`${isCollapsed ? "展开" : "折叠"}${group.title}`} onPress={() => toggleGroup(group.title)} style={styles.groupButton}>
|
|
298
|
+
<Text style={styles.group}>{group.title} {group.files.length} 个文件</Text>
|
|
299
|
+
</Pressable>
|
|
300
|
+
</>;
|
|
301
|
+
const content = <View>
|
|
302
|
+
{group.title === "未跟踪" && group.files.length > 0
|
|
303
|
+
? <DraggableRow workspaceId={workspaceId} paths={group.files.map((file) => file.path)} style={styles.groupRow}>{header}</DraggableRow>
|
|
304
|
+
: <View style={styles.groupRow}>{header}</View>}
|
|
305
|
+
{!isCollapsed ? renderNodes(fileTree(group.files), group, 0) : null}
|
|
306
|
+
</View>;
|
|
307
|
+
return group.title === "已跟踪"
|
|
308
|
+
? <TrackedDropZone key={group.title} workspaceId={workspaceId} onFiles={(paths) => void track(files.filter((file) => file.untracked && paths.includes(file.path)).map((file) => file.path))}>{content}</TrackedDropZone>
|
|
309
|
+
: <View key={group.title}>{content}</View>;
|
|
310
|
+
})}
|
|
311
|
+
{files.length === 0 && !error ? <Text style={styles.empty}>工作区没有待提交的改动</Text> : null}
|
|
312
|
+
</ScrollView>
|
|
313
|
+
{!onFilePreview && selected ? <View style={styles.preview} onLayout={(event) => setPreviewHeight(event.nativeEvent.layout.height)}>
|
|
314
|
+
<View style={styles.previewHeader} onLayout={(event) => setPreviewHeaderHeight(event.nativeEvent.layout.height)}>
|
|
315
|
+
<Text style={styles.previewTitle} numberOfLines={1}>{selected.path}</Text>
|
|
316
|
+
<WrapToggleButton active={wrapLines} onToggle={() => setWrapLines((value) => !value)} tooltipPlacement="below" />
|
|
317
|
+
<Pressable accessibilityRole="button" accessibilityLabel="关闭文件差异预览" onPress={() => setSelected(null)} style={styles.previewAction}><Icon name="X" size={16} color="#64748b" /></Pressable>
|
|
318
|
+
</View>
|
|
319
|
+
{parsed[0] && previewHeight > previewHeaderHeight ? <SplitDiff file={parsed[0]} wrapLines={wrapLines} maxHeight={Math.max(32, previewHeight - previewHeaderHeight - 1)} /> : !parsed[0] ? <Text style={styles.empty}>没有可显示的文本差异</Text> : null}
|
|
320
|
+
</View> : null}
|
|
321
|
+
<View style={styles.footer}>
|
|
322
|
+
{selectedFiles.length > 0 ? <View style={styles.summaryRow}>
|
|
323
|
+
<Text style={styles.summaryCount}>已选 {selectedFiles.length} 个文件</Text>
|
|
324
|
+
<View style={styles.summaryDetails}>
|
|
325
|
+
{counts.added > 0 ? <Text style={styles.addedName}>新增 {counts.added}</Text> : null}
|
|
326
|
+
{counts.modified > 0 ? <Text style={styles.summaryModified}>修改 {counts.modified}</Text> : null}
|
|
327
|
+
{counts.deleted > 0 ? <Text style={styles.removedName}>删除 {counts.deleted}</Text> : null}
|
|
328
|
+
</View>
|
|
329
|
+
</View> : null}
|
|
330
|
+
<TextInput value={message} onChangeText={setMessage} multiline placeholder="提交说明" style={styles.message} />
|
|
331
|
+
<View style={styles.actions}>
|
|
332
|
+
<Pressable accessibilityRole="button" accessibilityLabel="推送当前分支" accessibilityState={{ disabled: busy }} disabled={busy} onPress={() => void pushBranch()} style={[styles.push, busy && styles.disabled]}><Text style={styles.pushText}>{pushing ? "推送中…" : "推送"}</Text></Pressable>
|
|
333
|
+
<Pressable accessibilityRole="button" accessibilityLabel="提交勾选的文件" accessibilityState={{ disabled: busy }} disabled={busy} onPress={() => void submit()} style={[styles.commit, busy && styles.disabled]}><Text style={styles.commitText}>{busy && !pushing ? "提交中…" : "提交"}</Text></Pressable>
|
|
334
|
+
</View>
|
|
335
|
+
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
336
|
+
{result ? <Text style={styles.result}>{result}</Text> : null}
|
|
337
|
+
</View>
|
|
338
|
+
</View>;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const styles = StyleSheet.create({
|
|
342
|
+
root: { flex: 1, minHeight: 0, minWidth: 0, overflow: "hidden", backgroundColor: "#fff" },
|
|
343
|
+
toolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 12, borderBottomWidth: 1, borderColor: "#e5e7eb" },
|
|
344
|
+
heading: { fontSize: 15, fontWeight: "600", color: "#111827" },
|
|
345
|
+
list: { flexGrow: 0, flexShrink: 1, maxHeight: 280 },
|
|
346
|
+
listExpanded: { flex: 1, minHeight: 0 },
|
|
347
|
+
groupRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: 9, paddingTop: 10, paddingBottom: 4 },
|
|
348
|
+
group: { fontSize: 12, fontWeight: "600", color: "#6b7280" },
|
|
349
|
+
groupButton: { flex: 1, minHeight: 27, justifyContent: "center" },
|
|
350
|
+
fileRow: { flexDirection: "row", alignItems: "center", minHeight: 32, paddingHorizontal: 9 },
|
|
351
|
+
active: { backgroundColor: "#e9f0ff" },
|
|
352
|
+
checkbox: { padding: 4 },
|
|
353
|
+
chevron: { width: 20, height: 27, alignItems: "center", justifyContent: "center" },
|
|
354
|
+
fileButton: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6, padding: 3 },
|
|
355
|
+
folderName: { flexShrink: 1, fontSize: 13, color: "#111827" },
|
|
356
|
+
untrackedName: { color: "#bc3145" },
|
|
357
|
+
dropTarget: { backgroundColor: "#e9f0ff" },
|
|
358
|
+
count: { flexShrink: 0, fontSize: 11, color: "#9ca3af" },
|
|
359
|
+
path: { flex: 1, fontSize: 13, color: "#1641ad" },
|
|
360
|
+
addedName: { color: "#07833d" },
|
|
361
|
+
removedName: { color: "#bc3145" },
|
|
362
|
+
empty: { padding: 12, color: "#6b7280" },
|
|
363
|
+
preview: { flex: 1, minHeight: 0, minWidth: 0, overflow: "hidden", borderTopWidth: 1, borderColor: "#e5e7eb" },
|
|
364
|
+
previewHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 9, position: "relative", zIndex: 3 },
|
|
365
|
+
previewTitle: { flex: 1, fontSize: 13, color: "#111827" },
|
|
366
|
+
previewAction: { width: 28, height: 28, alignItems: "center", justifyContent: "center" },
|
|
367
|
+
footer: { borderTopWidth: 1, borderColor: "#e5e7eb", padding: 10, gap: 7 },
|
|
368
|
+
summaryRow: { minHeight: 24, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 },
|
|
369
|
+
summaryCount: { color: "#6b7280", fontSize: 12 },
|
|
370
|
+
summaryDetails: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
371
|
+
summaryModified: { color: "#1641ad" },
|
|
372
|
+
message: { minHeight: 58, padding: 8, borderWidth: 1, borderColor: "#cbd5e1", borderRadius: 5, textAlignVertical: "top", fontSize: 13 },
|
|
373
|
+
actions: { flexDirection: "row", justifyContent: "flex-end", gap: 8 },
|
|
374
|
+
push: { backgroundColor: "#fff", borderWidth: 1, borderColor: "#1664d9", paddingHorizontal: 18, paddingVertical: 7, borderRadius: 5 },
|
|
375
|
+
pushText: { color: "#1664d9", fontWeight: "600" },
|
|
376
|
+
commit: { backgroundColor: "#1664d9", paddingHorizontal: 18, paddingVertical: 8, borderRadius: 5 },
|
|
377
|
+
disabled: { opacity: 0.65 },
|
|
378
|
+
commitText: { color: "#fff", fontWeight: "600" },
|
|
379
|
+
error: { color: "#b91c1c", fontSize: 12 },
|
|
380
|
+
result: { color: "#15803d", fontSize: 12 },
|
|
381
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
|
|
5
|
+
export function WrapToggleButton({ active, onToggle, tooltipPlacement = "above" }: { active: boolean; onToggle(): void; tooltipPlacement?: "above" | "below" }) {
|
|
6
|
+
const [hovered, setHovered] = useState(false);
|
|
7
|
+
return <View style={styles.container}>
|
|
8
|
+
<Pressable
|
|
9
|
+
accessibilityRole="button"
|
|
10
|
+
accessibilityLabel="折行显示"
|
|
11
|
+
aria-pressed={active}
|
|
12
|
+
onHoverIn={() => setHovered(true)}
|
|
13
|
+
onHoverOut={() => setHovered(false)}
|
|
14
|
+
onPress={onToggle}
|
|
15
|
+
style={[styles.button, active && styles.active, hovered && styles.hover]}
|
|
16
|
+
><Icon name="WrapText" size={13} color="#64748b" /></Pressable>
|
|
17
|
+
{hovered ? <View pointerEvents="none" style={[styles.tooltip, tooltipPlacement === "above" ? styles.above : styles.below]}><Text style={styles.tooltipText}>折行显示</Text></View> : null}
|
|
18
|
+
</View>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
container: { position: "relative", zIndex: 2 },
|
|
23
|
+
button: { width: 20, height: 20, borderRadius: 4, alignItems: "center", justifyContent: "center" },
|
|
24
|
+
active: { backgroundColor: "#cbcbcd" },
|
|
25
|
+
hover: { backgroundColor: "#e5e7eb" },
|
|
26
|
+
tooltip: { position: "absolute", right: 0, width: 72, paddingHorizontal: 9, paddingVertical: 5, borderRadius: 5, backgroundColor: "#1e293b", zIndex: 4 },
|
|
27
|
+
above: { bottom: 24 },
|
|
28
|
+
below: { top: 24 },
|
|
29
|
+
tooltipText: { color: "#fff", fontSize: 11, textAlign: "center" },
|
|
30
|
+
});
|
package/index.client.tsx
CHANGED
|
@@ -7,8 +7,14 @@ import { MobileClarify } from "./client/mobile-clarify";
|
|
|
7
7
|
import { COMMIT_RESULT_KIND, COMMIT_PREVIEW_KIND, commitResultSchema, commitPreviewSchema, preparePiCommitRpc } from "./shared/commit";
|
|
8
8
|
import { CommitResultItem } from "./client/commit-result";
|
|
9
9
|
import { CommitPreviewItem } from "./client/commit-preview";
|
|
10
|
+
import { editDiffFiles } from "./client/edit-diff-data";
|
|
11
|
+
import { EDIT_DIFF_KIND, editDiffSchema, EditDiffItem } from "./client/edit-diff";
|
|
12
|
+
import { WorkspaceCommitPanel } from "./client/workspace-commit";
|
|
13
|
+
import { GitHistoryPanel } from "./client/git-history";
|
|
10
14
|
|
|
11
15
|
export default function contribute(client: PluginClientContext) {
|
|
16
|
+
client.addWorkspacePanel({ id: "vibe-review-commit", title: "提交更改", icon: "GitCommitHorizontal", context: "workspace", locations: ["explorer"], Component: WorkspaceCommitPanel });
|
|
17
|
+
client.addWorkspacePanel({ id: "vibe-review-git-history", title: "Git 历史", icon: "GitBranch", context: "workspace", locations: ["workspace", "explorer"], Component: GitHistoryPanel });
|
|
12
18
|
const clarify = createClarifyController(client);
|
|
13
19
|
const pills = new Map<string, { workspaceId: string; remove(): void }>();
|
|
14
20
|
const lifetime = new AbortController();
|
|
@@ -54,6 +60,16 @@ export default function contribute(client: PluginClientContext) {
|
|
|
54
60
|
});
|
|
55
61
|
client.addTimelineRenderer({ kind: COMMIT_RESULT_KIND, version: 1, schema: commitResultSchema, Component: CommitResultItem });
|
|
56
62
|
client.addTimelineRenderer({ kind: COMMIT_PREVIEW_KIND, version: 1, schema: commitPreviewSchema, Component: CommitPreviewItem });
|
|
63
|
+
client.addTimelineTransformer({
|
|
64
|
+
id: "edit-diff",
|
|
65
|
+
query: { itemType: "tool_call" },
|
|
66
|
+
transform({ item }) {
|
|
67
|
+
if (!editDiffFiles(item) || item.detail.type !== "edit") return undefined;
|
|
68
|
+
return { items: [{ type: "plugin", kind: EDIT_DIFF_KIND, version: 1,
|
|
69
|
+
data: { path: item.detail.filePath, diff: item.detail.unifiedDiff!, status: item.status } }] };
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
client.addTimelineRenderer({ kind: EDIT_DIFF_KIND, version: 1, schema: editDiffSchema, Component: EditDiffItem });
|
|
57
73
|
|
|
58
74
|
const commit = async (agent: { id: string; cwd: string }, rpc: (contract: typeof preparePiCommitRpc, input: { agentId: string; cwd: string }) => Promise<unknown>) => {
|
|
59
75
|
await rpc(preparePiCommitRpc, { agentId: agent.id, cwd: agent.cwd });
|
package/index.server.ts
CHANGED
|
@@ -14,8 +14,12 @@ import { recentCommitContext } from "./server/commit-context";
|
|
|
14
14
|
import { isCurrentCommitPreview, isLatestReviewTurn } from "./server/commit-availability";
|
|
15
15
|
import { buildTaskSummary, isSessionControlCommand } from "./server/inspect";
|
|
16
16
|
import { isVerifierHookAgent, verifyWithPaseoProvider } from "./server/verify";
|
|
17
|
+
import { registerWorkspaceCommit } from "./server/workspace-commit";
|
|
18
|
+
import { registerGitHistory } from "./server/git-history";
|
|
17
19
|
|
|
18
20
|
export default function contribute(server: PluginServerContext) {
|
|
21
|
+
registerWorkspaceCommit(server);
|
|
22
|
+
registerGitHistory(server);
|
|
19
23
|
const settings = server.registerSettings(llmSettings);
|
|
20
24
|
const piSettings = server.registerSettings(piCommitSettings);
|
|
21
25
|
const verifySettings = server.registerSettings(verificationSettings);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yuangc/vibe-review",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@getpaseo/plugin": "0.
|
|
19
|
+
"@getpaseo/plugin": "0.11.2",
|
|
20
20
|
"@tanstack/react-query": "^5.90.11",
|
|
21
21
|
"@types/react": "~19.2.0",
|
|
22
22
|
"react": "19.1.0",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"zod": "^4.4.3"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@getpaseo/highlight": "0.
|
|
29
|
-
"@getpaseo/protocol": "0.
|
|
28
|
+
"@getpaseo/highlight": "0.11.2",
|
|
29
|
+
"@getpaseo/protocol": "0.11.2"
|
|
30
30
|
}
|
|
31
31
|
}
|
package/paseo-plugin.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
|
|
4
|
+
import { gitCommitDetailRpc, gitCommitFileDiffRpc, gitHistoryRpc } from "../shared/git-history";
|
|
5
|
+
|
|
6
|
+
const runFile = promisify(execFile);
|
|
7
|
+
const PAGE_SIZE = 50;
|
|
8
|
+
const git = async (cwd: string, args: string[]) => (await runFile("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
|
|
9
|
+
const gitConfig = async (cwd: string, key: string) => { try { return (await git(cwd, ["config", "--get", key])).trim(); } catch { return ""; } };
|
|
10
|
+
|
|
11
|
+
async function directory(paseo: PluginHandlerContext["paseo"], workspaceId: string) {
|
|
12
|
+
const workspace = await paseo.workspaces.ref(workspaceId).refresh();
|
|
13
|
+
if (!workspace?.workspaceDirectory) throw new Error("工作区目录不可用");
|
|
14
|
+
await git(workspace.workspaceDirectory, ["rev-parse", "--show-toplevel"]);
|
|
15
|
+
return workspace.workspaceDirectory;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const format = "%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%D%x1e";
|
|
19
|
+
function parseCommits(raw: string) {
|
|
20
|
+
return raw.split("\x1e").map((record) => record.trimStart()).filter(Boolean).map((record) => {
|
|
21
|
+
const [hash = "", parents = "", author = "", email = "", date = "", subject = "", refs = ""] = record.split("\x1f");
|
|
22
|
+
return { hash, parents: parents ? parents.split(" ") : [], author, email, date, subject, refs: refs ? refs.split(", ") : [] };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function commitBase(cwd: string, hash: string) {
|
|
27
|
+
if (!/^[0-9a-f]{40,64}$/i.test(hash)) throw new Error("无效提交哈希");
|
|
28
|
+
const ancestry = (await git(cwd, ["rev-list", "--parents", "-n", "1", hash])).trim().split(" ");
|
|
29
|
+
if (ancestry[0] !== hash) throw new Error("提交不存在");
|
|
30
|
+
return ancestry[1] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function changedFiles(cwd: string, hash: string, base: string | null) {
|
|
34
|
+
const args = base
|
|
35
|
+
? ["diff", "--name-status", "--no-renames", "-z", base, hash]
|
|
36
|
+
: ["diff-tree", "--root", "--no-commit-id", "--name-status", "--no-renames", "-r", "-z", hash];
|
|
37
|
+
const parts = (await git(cwd, args)).split("\0").filter(Boolean);
|
|
38
|
+
const files: Array<{ path: string; status: string }> = [];
|
|
39
|
+
for (let i = 0; i + 1 < parts.length; i += 2) files.push({ status: parts[i]!, path: parts[i + 1]! });
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerGitHistory(server: PluginServerContext) {
|
|
44
|
+
server.handle(gitHistoryRpc, async ({ workspaceId, branch, query, skip }, { paseo }) => {
|
|
45
|
+
const cwd = await directory(paseo, workspaceId);
|
|
46
|
+
const branches = (await git(cwd, ["for-each-ref", "--format=%(refname)%09%(upstream:track)", "refs/heads", "refs/remotes", "refs/tags"])).trim().split("\n").filter(Boolean).map((line) => {
|
|
47
|
+
const [ref = "", tracking = ""] = line.split("\t");
|
|
48
|
+
const ahead = Number(tracking.match(/\bahead (\d+)/)?.[1]) || 0;
|
|
49
|
+
const behind = Number(tracking.match(/\bbehind (\d+)/)?.[1]) || 0;
|
|
50
|
+
if (ref.startsWith("refs/heads/")) return { name: ref.slice("refs/heads/".length), group: "local" as const, ahead, behind };
|
|
51
|
+
if (ref.startsWith("refs/tags/")) return { name: ref.slice("refs/tags/".length), group: "tag" as const, ahead: 0, behind: 0 };
|
|
52
|
+
return { name: ref.slice("refs/remotes/".length), group: "remote" as const, ahead: 0, behind: 0 };
|
|
53
|
+
}).filter((item) => item.name && !(item.group === "remote" && item.name.endsWith("/HEAD")));
|
|
54
|
+
if (branch && !branches.some((item) => item.name === branch)) throw new Error("分支不存在,请刷新");
|
|
55
|
+
const currentBranch = (await git(cwd, ["branch", "--show-current"])).trim();
|
|
56
|
+
const [gitUserName, gitUserEmail] = await Promise.all([gitConfig(cwd, "user.name"), gitConfig(cwd, "user.email")]);
|
|
57
|
+
const args = ["log", `--skip=${skip}`, `-n${PAGE_SIZE + 1}`, `--pretty=format:${format}`];
|
|
58
|
+
if (!branch) args.push("--all");
|
|
59
|
+
if (query.trim()) args.push("--regexp-ignore-case", "--fixed-strings", `--grep=${query.trim()}`);
|
|
60
|
+
if (branch) args.push(branch);
|
|
61
|
+
const raw = branches.length ? await git(cwd, args) : "";
|
|
62
|
+
const commits = parseCommits(raw);
|
|
63
|
+
return { commits: commits.slice(0, PAGE_SIZE), hasMore: commits.length > PAGE_SIZE, branches, currentBranch, currentGitUser: { name: gitUserName, email: gitUserEmail } };
|
|
64
|
+
});
|
|
65
|
+
server.handle(gitCommitDetailRpc, async ({ workspaceId, hash }, { paseo }) => {
|
|
66
|
+
const cwd = await directory(paseo, workspaceId);
|
|
67
|
+
const base = await commitBase(cwd, hash);
|
|
68
|
+
const message = (await git(cwd, ["show", "-s", "--format=%B", hash])).trim();
|
|
69
|
+
const branchNames = (await git(cwd, ["for-each-ref", `--contains=${hash}`, "--format=%(refname:short)", "refs/heads", "refs/remotes"])).trim().split("\n").filter(Boolean);
|
|
70
|
+
const inHead = await git(cwd, ["merge-base", "--is-ancestor", hash, "HEAD"]).then(() => true, () => false);
|
|
71
|
+
return { message, files: await changedFiles(cwd, hash, base), containingBranches: inHead ? ["HEAD", ...branchNames] : branchNames };
|
|
72
|
+
});
|
|
73
|
+
server.handle(gitCommitFileDiffRpc, async ({ workspaceId, hash, path }, { paseo }) => {
|
|
74
|
+
const cwd = await directory(paseo, workspaceId);
|
|
75
|
+
const base = await commitBase(cwd, hash);
|
|
76
|
+
const files = await changedFiles(cwd, hash, base);
|
|
77
|
+
if (!files.some((file) => file.path === path)) throw new Error("文件不属于该提交");
|
|
78
|
+
const diff = base
|
|
79
|
+
? await git(cwd, ["diff", "--no-ext-diff", "--no-renames", "--unified=3", base, hash, "--", path])
|
|
80
|
+
: await git(cwd, ["show", "--format=", "--root", "--no-ext-diff", hash, "--", path]);
|
|
81
|
+
return { diff };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -8,7 +8,7 @@ export interface PaseoAgentTimelineHandle {
|
|
|
8
8
|
version: number;
|
|
9
9
|
data: unknown;
|
|
10
10
|
}): Promise<{ seq: number; epoch: string }>;
|
|
11
|
-
refetch(
|
|
11
|
+
refetch(options?: { limit?: number }): Promise<{ entries?: Array<{ item: AgentTimelineItem }> }>;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface PaseoAgentRef {
|
package/server/review.ts
CHANGED
|
@@ -77,7 +77,8 @@ export async function reconcileVerification(paseo: PluginHandlerContext["paseo"]
|
|
|
77
77
|
if (!verification) return;
|
|
78
78
|
if (activeVerifications.has(key)) return;
|
|
79
79
|
const latest = await findCard();
|
|
80
|
-
|
|
80
|
+
const latestParsed = latest?.type === "plugin" ? reviewResultSchema.safeParse(latest.data) : null;
|
|
81
|
+
if (!latestParsed?.success || latestParsed.data.verificationState !== "running") return;
|
|
81
82
|
const result = card.result === "done" && verification.status === "failed" ? "partial"
|
|
82
83
|
: card.result === "done" && verification.status === "unavailable" ? "unknown" : card.result;
|
|
83
84
|
const finalVerdict = verdictOf({ result: result ?? "unknown", drifts: card.drifts ?? [] });
|