@yuangc/vibe-review 0.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.
@@ -0,0 +1,649 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Image, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
3
+ import { useRpc, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import { gitCommitDetailRpc, gitCommitFileDiffRpc, gitHistoryRpc } from "../shared/git-history";
6
+ import { workspaceGitDiffRpc } from "../shared/workspace-commit";
7
+ import { parseDiffFiles } from "./diff";
8
+ import { FileTypeIcon } from "./file-icons";
9
+ import { SplitDiff } from "./split-diff";
10
+ import { WorkspaceCommitPanel } from "./workspace-commit";
11
+ import { WrapToggleButton } from "./wrap-toggle";
12
+
13
+ type Commit = { hash: string; parents: string[]; author: string; email: string; date: string; subject: string; refs: string[] };
14
+ type ChangedFile = { path: string; status: string };
15
+ type DiffTab = { id: string; source: "working"; path: string; staged: boolean; status?: string } | { id: string; source: "history"; path: string; hash: string; status: string };
16
+ type TreeNode = { kind: "folder"; name: string; path: string; count: number; children: TreeNode[] } | { kind: "file"; name: string; file: ChangedFile };
17
+ type Branch = { name: string; group: "local" | "remote" | "tag"; ahead: number; behind: number };
18
+ type BranchNode = { kind: "folder"; name: string; path: string; children: BranchNode[] } | { kind: "branch"; name: string; fullName: string; ahead: number; behind: number };
19
+ const TAB_DRAG_TYPE = "application/x-vibe-review-diff-tab";
20
+ const SPLITTER_SIZE = 6;
21
+ const PANEL_PADDING = 8;
22
+ const CURRENT_BRANCH_ICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f5a400" d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.828 8.828a2 2 0 0 0 2.828 0l7.172-7.172a2 2 0 0 0 0-2.828z"/><circle cx="7.5" cy="7.5" r="1.7" fill="white"/></svg>')}`;
23
+
24
+ function CommitHash({ value }: { value: string }) {
25
+ const ref = useRef<Text>(null);
26
+ useEffect(() => {
27
+ if (Platform.OS !== "web") return;
28
+ const node = ref.current as unknown as HTMLElement | null;
29
+ node?.setAttribute("data-pmono", "");
30
+ return () => { node?.removeAttribute("data-pmono"); };
31
+ }, []);
32
+ return <Text ref={ref} style={styles.hash} numberOfLines={1}>{value.slice(0, 8)}</Text>;
33
+ }
34
+
35
+ function CommitDate({ value }: { value: string }) {
36
+ const ref = useRef<Text>(null);
37
+ useEffect(() => {
38
+ if (Platform.OS !== "web") return;
39
+ const node = ref.current as unknown as HTMLElement | null;
40
+ node?.setAttribute("data-pmono", "");
41
+ return () => { node?.removeAttribute("data-pmono"); };
42
+ }, []);
43
+ return <Text ref={ref} style={styles.date} numberOfLines={1}>{value}</Text>;
44
+ }
45
+
46
+ function CommitMessageText({ value }: { value: string }) {
47
+ const ref = useRef<Text>(null);
48
+ useEffect(() => {
49
+ if (Platform.OS !== "web") return;
50
+ const node = ref.current as unknown as HTMLElement | null;
51
+ node?.setAttribute("data-pmono", "");
52
+ return () => { node?.removeAttribute("data-pmono"); };
53
+ }, []);
54
+ return <Text ref={ref} style={styles.detailMessage}>{value}</Text>;
55
+ }
56
+
57
+ function ResizeHandle({ direction, appearance, label, onResize }: { direction: "horizontal" | "vertical"; appearance: "gutter" | "line"; label: string; onResize: (delta: number) => void }) {
58
+ const ref = useRef<View>(null);
59
+ const resizeRef = useRef(onResize);
60
+ resizeRef.current = onResize;
61
+ useEffect(() => {
62
+ if (Platform.OS !== "web") return;
63
+ const node = ref.current as unknown as HTMLElement | null;
64
+ if (!node) return;
65
+ node.style.cursor = direction === "vertical" ? "col-resize" : "row-resize";
66
+ const down = (event: PointerEvent) => {
67
+ if (event.button !== 0) return;
68
+ event.preventDefault();
69
+ let previous = direction === "vertical" ? event.clientX : event.clientY;
70
+ const oldCursor = document.body.style.cursor;
71
+ const oldSelection = document.body.style.userSelect;
72
+ document.body.style.cursor = direction === "vertical" ? "col-resize" : "row-resize";
73
+ document.body.style.userSelect = "none";
74
+ const move = (next: PointerEvent) => {
75
+ const position = direction === "vertical" ? next.clientX : next.clientY;
76
+ resizeRef.current(position - previous);
77
+ previous = position;
78
+ };
79
+ const up = () => {
80
+ window.removeEventListener("pointermove", move);
81
+ window.removeEventListener("pointerup", up);
82
+ document.body.style.cursor = oldCursor;
83
+ document.body.style.userSelect = oldSelection;
84
+ };
85
+ window.addEventListener("pointermove", move);
86
+ window.addEventListener("pointerup", up, { once: true });
87
+ };
88
+ node.addEventListener("pointerdown", down);
89
+ return () => node.removeEventListener("pointerdown", down);
90
+ }, [direction]);
91
+ return <View ref={ref} accessibilityRole="adjustable" accessibilityLabel={label} style={direction === "vertical" ? appearance === "line" ? styles.verticalLineSplitter : styles.verticalGutter : appearance === "line" ? styles.horizontalLineSplitter : styles.horizontalGutter}>{appearance === "line" ? <View style={direction === "vertical" ? styles.splitterLine : styles.splitterLineHorizontal} /> : null}</View>;
92
+ }
93
+
94
+ function ReorderableTab({ tab, active, onActivate, onClose, onReorder, onNode }: {
95
+ tab: DiffTab; active: boolean; onActivate: () => void; onClose: () => void;
96
+ onReorder: (source: string, target: string, after: boolean) => void;
97
+ onNode: (id: string, node: HTMLElement | null) => void;
98
+ }) {
99
+ const ref = useRef<View>(null);
100
+ const [hovered, setHovered] = useState(false);
101
+ const [closeHovered, setCloseHovered] = useState(false);
102
+ const reorderRef = useRef(onReorder);
103
+ reorderRef.current = onReorder;
104
+ useEffect(() => {
105
+ if (Platform.OS !== "web") return;
106
+ const node = ref.current as unknown as HTMLElement | null;
107
+ if (!node) return;
108
+ onNode(tab.id, node);
109
+ node.draggable = true;
110
+ node.title = tab.path;
111
+ const start = (event: DragEvent) => {
112
+ event.dataTransfer?.setData(TAB_DRAG_TYPE, tab.id);
113
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
114
+ };
115
+ const over = (event: DragEvent) => {
116
+ if (event.dataTransfer?.types.includes(TAB_DRAG_TYPE)) event.preventDefault();
117
+ };
118
+ const drop = (event: DragEvent) => {
119
+ const source = event.dataTransfer?.getData(TAB_DRAG_TYPE);
120
+ if (!source) return;
121
+ event.preventDefault();
122
+ const bounds = node.getBoundingClientRect();
123
+ reorderRef.current(source, tab.id, event.clientX > bounds.left + bounds.width / 2);
124
+ };
125
+ const enter = () => setHovered(true);
126
+ const leave = () => { setHovered(false); setCloseHovered(false); };
127
+ node.addEventListener("dragstart", start);
128
+ node.addEventListener("dragover", over);
129
+ node.addEventListener("drop", drop);
130
+ node.addEventListener("mouseenter", enter);
131
+ node.addEventListener("mouseleave", leave);
132
+ return () => {
133
+ node.draggable = false;
134
+ node.removeAttribute("title");
135
+ node.removeEventListener("dragstart", start);
136
+ node.removeEventListener("dragover", over);
137
+ node.removeEventListener("drop", drop);
138
+ node.removeEventListener("mouseenter", enter);
139
+ node.removeEventListener("mouseleave", leave);
140
+ onNode(tab.id, null);
141
+ };
142
+ }, [tab.id, tab.path, onNode]);
143
+ return <View ref={ref} style={[styles.tab, hovered && !active && styles.hoveredTab, active && styles.activeTab]}>
144
+ <Pressable accessibilityRole="tab" accessibilityLabel={`${tab.source === "working" ? "工作区" : "历史提交"} ${tab.path}`} accessibilityState={{ selected: active }} onPress={onActivate} style={styles.tabSelect}>
145
+ <FileTypeIcon fileName={tab.path} />
146
+ <Text style={[styles.tabTitle, fileColor(tab.status ?? "M")]} numberOfLines={1}>{tab.path.split("/").at(-1)}</Text>
147
+ <Text style={styles.tabSource} numberOfLines={1}>{tab.source === "working" ? "工作区" : tab.hash.slice(0, 7)}</Text>
148
+ </Pressable>
149
+ <Pressable accessibilityRole="button" accessibilityLabel={`关闭 ${tab.path} 标签`} onPress={onClose} onHoverIn={() => setCloseHovered(true)} onHoverOut={() => setCloseHovered(false)} style={[styles.tabClose, closeHovered && styles.tabCloseHovered]}><Icon name="X" size={13} color="#94a3b8" /></Pressable>
150
+ </View>;
151
+ }
152
+
153
+ function branchTree(branches: Branch[], group: Branch["group"]): BranchNode[] {
154
+ const root: BranchNode[] = [];
155
+ const folders = new Map<string, Extract<BranchNode, { kind: "folder" }>>();
156
+ for (const branch of branches.filter((item) => item.group === group)) {
157
+ const displayName = group === "tag" && branch.name.startsWith("tags/") ? branch.name.slice(5) : branch.name;
158
+ const parts = displayName.split("/");
159
+ let children = root;
160
+ let path = group;
161
+ for (const name of parts.slice(0, -1)) {
162
+ path += `/${name}`;
163
+ let folder = folders.get(path);
164
+ if (!folder) { folder = { kind: "folder", name, path, children: [] }; folders.set(path, folder); children.push(folder); }
165
+ children = folder.children;
166
+ }
167
+ children.push({ kind: "branch", name: parts.at(-1) ?? displayName, fullName: branch.name, ahead: branch.ahead, behind: branch.behind });
168
+ }
169
+ const sort = (nodes: BranchNode[]) => {
170
+ nodes.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "folder" ? -1 : 1);
171
+ nodes.forEach((node) => { if (node.kind === "folder") sort(node.children); });
172
+ };
173
+ sort(root);
174
+ return root;
175
+ }
176
+
177
+ function tree(files: ChangedFile[]): TreeNode[] {
178
+ const root: TreeNode[] = [];
179
+ const folders = new Map<string, Extract<TreeNode, { kind: "folder" }>>();
180
+ for (const file of files) {
181
+ const parts = file.path.split("/").filter(Boolean);
182
+ let children = root;
183
+ let path = "";
184
+ for (const name of parts.slice(0, -1)) {
185
+ path = path ? `${path}/${name}` : name;
186
+ let folder = folders.get(path);
187
+ if (!folder) { folder = { kind: "folder", name, path, count: 0, children: [] }; folders.set(path, folder); children.push(folder); }
188
+ folder.count++;
189
+ children = folder.children;
190
+ }
191
+ children.push({ kind: "file", name: parts.at(-1) ?? file.path, file });
192
+ }
193
+ const sort = (nodes: TreeNode[]) => {
194
+ nodes.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "folder" ? -1 : 1);
195
+ nodes.forEach((node) => { if (node.kind === "folder") sort(node.children); });
196
+ };
197
+ sort(root);
198
+ const compact = (nodes: TreeNode[]): TreeNode[] => nodes.map((node) => {
199
+ if (node.kind === "folder") {
200
+ node.children = compact(node.children);
201
+ while (node.children.length === 1 && node.children[0]?.kind === "folder") {
202
+ const child = node.children[0];
203
+ node.name += `/${child.name}`;
204
+ node.path = child.path;
205
+ node.children = child.children;
206
+ }
207
+ }
208
+ return node;
209
+ });
210
+ return compact(root);
211
+ }
212
+
213
+ function fileColor(status: string) {
214
+ if (status.includes("D") || status.includes("?")) return styles.deleted;
215
+ if (status.includes("A")) return styles.added;
216
+ return styles.modified;
217
+ }
218
+
219
+ function formatCommitTime(value: string, nowMs: number) {
220
+ const date = new Date(value);
221
+ if (Number.isNaN(date.getTime())) return value;
222
+ const elapsed = nowMs - date.getTime();
223
+ if (elapsed >= 0 && elapsed < 60_000) return "Moments ago";
224
+ if (elapsed >= 0 && elapsed < 60 * 60_000) {
225
+ const minutes = Math.floor(elapsed / 60_000);
226
+ return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
227
+ }
228
+ const now = new Date(nowMs);
229
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
230
+ const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
231
+ if (date.getTime() >= today && date.getTime() <= nowMs) return `Today ${time}`;
232
+ const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).getTime();
233
+ if (date.getTime() >= yesterday && date.getTime() < today) return `Yesterday ${time}`;
234
+ return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${time}`;
235
+ }
236
+
237
+ export function GitHistoryPanel(props: PluginWorkspacePanelProps) {
238
+ const { workspaceId } = props;
239
+ const getHistory = useRpc(gitHistoryRpc);
240
+ const getDetail = useRpc(gitCommitDetailRpc);
241
+ const getFileDiff = useRpc(gitCommitFileDiffRpc);
242
+ const getWorkingDiff = useRpc(workspaceGitDiffRpc);
243
+ const [commits, setCommits] = useState<Commit[]>([]);
244
+ const [branches, setBranches] = useState<Branch[]>([]);
245
+ const [currentBranch, setCurrentBranch] = useState("");
246
+ const [currentGitUser, setCurrentGitUser] = useState({ name: "", email: "" });
247
+ const [nowMs, setNowMs] = useState(Date.now());
248
+ const [branch, setBranch] = useState("");
249
+ const [searchText, setSearchText] = useState("");
250
+ const [query, setQuery] = useState("");
251
+ const [skip, setSkip] = useState(0);
252
+ const [hasMore, setHasMore] = useState(false);
253
+ const [loading, setLoading] = useState(false);
254
+ const [revision, setRevision] = useState(0);
255
+ const [selectedHash, setSelectedHash] = useState<string | null>(null);
256
+ const [detail, setDetail] = useState<{ message: string; files: ChangedFile[]; containingBranches: string[] } | null>(null);
257
+ const [detailPanelHeight, setDetailPanelHeight] = useState(0);
258
+ const [preferredFilesHeight, setPreferredFilesHeight] = useState<number | null>(null);
259
+ const [tabs, setTabs] = useState<DiffTab[]>([]);
260
+ const [activeTabId, setActiveTabId] = useState<string | null>(null);
261
+ const [tabMenuOpen, setTabMenuOpen] = useState(false);
262
+ const [tabViewportWidth, setTabViewportWidth] = useState(0);
263
+ const [tabContentWidth, setTabContentWidth] = useState(0);
264
+ const tabBarRef = useRef<ScrollView>(null);
265
+ const tabNodes = useRef(new Map<string, HTMLElement>());
266
+ const [diff, setDiff] = useState<{ tabId: string | null; text: string; loading: boolean }>({ tabId: null, text: "", loading: false });
267
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
268
+ const [branchCollapsed, setBranchCollapsed] = useState<Set<string>>(new Set());
269
+ const [branchSearch, setBranchSearch] = useState("");
270
+ const [panelWidth, setPanelWidth] = useState(0);
271
+ const [panelHeight, setPanelHeight] = useState(0);
272
+ const [commitWidth, setCommitWidth] = useState(260);
273
+ const [branchColumnWidth, setBranchColumnWidth] = useState<number | null>(null);
274
+ const [logColumnWidth, setLogColumnWidth] = useState<number | null>(null);
275
+ const [preferredTopHeight, setPreferredTopHeight] = useState<number | null>(null);
276
+ const [historyMinimized, setHistoryMinimized] = useState(false);
277
+ const [diffHeight, setDiffHeight] = useState(0);
278
+ const [wrapLines, setWrapLines] = useState(false);
279
+ const [error, setError] = useState("");
280
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null;
281
+ const tabOverflow = tabContentWidth > tabViewportWidth + 2;
282
+
283
+ useEffect(() => {
284
+ const timer = setInterval(() => setNowMs(Date.now()), 30_000);
285
+ return () => clearInterval(timer);
286
+ }, []);
287
+
288
+ const registerTabNode = useCallback((id: string, node: HTMLElement | null) => {
289
+ if (node) tabNodes.current.set(id, node);
290
+ else tabNodes.current.delete(id);
291
+ }, []);
292
+ const reorderTab = useCallback((source: string, target: string, after: boolean) => {
293
+ if (source === target) return;
294
+ setTabs((current) => {
295
+ const moved = current.find((item) => item.id === source);
296
+ if (!moved) return current;
297
+ const next = current.filter((item) => item.id !== source);
298
+ const targetIndex = next.findIndex((item) => item.id === target);
299
+ if (targetIndex < 0) return current;
300
+ next.splice(targetIndex + (after ? 1 : 0), 0, moved);
301
+ return next;
302
+ });
303
+ }, []);
304
+
305
+ useEffect(() => {
306
+ if (Platform.OS !== "web" || !activeTabId) return;
307
+ const frame = requestAnimationFrame(() => {
308
+ const scroller = tabBarRef.current as unknown as HTMLElement | null;
309
+ const tabNode = tabNodes.current.get(activeTabId);
310
+ if (!scroller || !tabNode) return;
311
+ const viewport = scroller.getBoundingClientRect();
312
+ const selected = tabNode.getBoundingClientRect();
313
+ const offset = selected.left < viewport.left ? selected.left - viewport.left
314
+ : selected.right > viewport.right ? selected.right - viewport.right : 0;
315
+ if (offset) scroller.scrollLeft += offset;
316
+ });
317
+ return () => cancelAnimationFrame(frame);
318
+ }, [activeTabId, tabs, tabViewportWidth]);
319
+
320
+ useEffect(() => { setTabs([]); setActiveTabId(null); setTabMenuOpen(false); setDiff({ tabId: null, text: "", loading: false }); }, [workspaceId]);
321
+
322
+ useEffect(() => {
323
+ const timer = setTimeout(() => { setSkip(0); setQuery(searchText); }, 250);
324
+ return () => clearTimeout(timer);
325
+ }, [searchText]);
326
+
327
+ useEffect(() => {
328
+ let active = true;
329
+ setLoading(true);
330
+ void getHistory({ workspaceId, branch, query, skip }).then((value) => {
331
+ if (!active) return;
332
+ setCommits((existing) => skip === 0 ? value.commits : [...existing, ...value.commits]);
333
+ setBranches(value.branches);
334
+ setCurrentBranch(value.currentBranch);
335
+ setCurrentGitUser(value.currentGitUser);
336
+ setHasMore(value.hasMore);
337
+ if (skip === 0) setSelectedHash(value.commits[0]?.hash ?? null);
338
+ setError("");
339
+ }, (reason: unknown) => { if (active) setError(String(reason)); }).finally(() => { if (active) setLoading(false); });
340
+ return () => { active = false; };
341
+ }, [workspaceId, branch, query, skip, revision]);
342
+
343
+ useEffect(() => {
344
+ let active = true;
345
+ setDetail(null);
346
+ if (selectedHash) void getDetail({ workspaceId, hash: selectedHash }).then((value) => {
347
+ if (!active) return;
348
+ setDetail(value);
349
+ setCollapsed(new Set());
350
+ }, (reason: unknown) => { if (active) setError(String(reason)); });
351
+ return () => { active = false; };
352
+ }, [workspaceId, selectedHash]);
353
+
354
+ useEffect(() => {
355
+ let active = true;
356
+ setDiff({ tabId: activeTab?.id ?? null, text: "", loading: Boolean(activeTab) });
357
+ if (activeTab) {
358
+ const request = activeTab.source === "working"
359
+ ? getWorkingDiff({ workspaceId, path: activeTab.path, staged: activeTab.staged })
360
+ : getFileDiff({ workspaceId, hash: activeTab.hash, path: activeTab.path });
361
+ void request.then(
362
+ (value) => { if (active) setDiff({ tabId: activeTab.id, text: value.diff, loading: false }); },
363
+ (reason: unknown) => { if (active) { setDiff({ tabId: activeTab.id, text: "", loading: false }); setError(String(reason)); } },
364
+ );
365
+ }
366
+ return () => { active = false; };
367
+ }, [workspaceId, activeTab?.id, activeTab?.source === "working" ? activeTab.staged : undefined]);
368
+
369
+ const toggleFolder = (path: string) => setCollapsed((current) => {
370
+ const next = new Set(current);
371
+ if (next.has(path)) next.delete(path);
372
+ else next.add(path);
373
+ return next;
374
+ });
375
+ const toggleBranchFolder = (path: string) => setBranchCollapsed((current) => {
376
+ const next = new Set(current);
377
+ if (next.has(path)) next.delete(path);
378
+ else next.add(path);
379
+ return next;
380
+ });
381
+ const chooseBranch = (name: string) => { setBranch(name); setSkip(0); };
382
+ const openTab = (tab: DiffTab) => {
383
+ setTabs((current) => current.some((item) => item.id === tab.id)
384
+ ? current.map((item) => item.id === tab.id ? tab : item)
385
+ : [...current, tab]);
386
+ setActiveTabId(tab.id);
387
+ };
388
+ const closeTab = (id: string) => {
389
+ const index = tabs.findIndex((tab) => tab.id === id);
390
+ const next = tabs.filter((tab) => tab.id !== id);
391
+ setTabs(next);
392
+ if (activeTabId === id) setActiveTabId(next[Math.min(index, next.length - 1)]?.id ?? null);
393
+ };
394
+ const renderBranches = (nodes: BranchNode[], group: Branch["group"]): React.ReactNode[] => nodes.map((node) => {
395
+ if (node.kind === "folder") return <View key={node.path}>
396
+ <Pressable accessibilityRole="button" accessibilityLabel={`${branchCollapsed.has(node.path) ? "展开" : "折叠"} ${node.name}`} onPress={() => toggleBranchFolder(node.path)} style={styles.branchRow}>
397
+ <Icon name={branchCollapsed.has(node.path) ? "ChevronRight" : "ChevronDown"} size={14} color="#64748b" />
398
+ <Icon name="Folder" size={15} color="#64748b" />
399
+ <Text style={styles.branchName} numberOfLines={1}>{node.name}</Text>
400
+ </Pressable>
401
+ {!branchCollapsed.has(node.path) || branchSearch.trim() ? <View style={styles.branchChildren}>{renderBranches(node.children, group)}</View> : null}
402
+ </View>;
403
+ const isCurrent = group === "local" && node.fullName === currentBranch;
404
+ const isMain = (group === "local" && (node.fullName === "main" || node.fullName === "master")) || (group === "remote" && (node.fullName === "origin/main" || node.fullName === "origin/master"));
405
+ return <Pressable key={`${group}/${node.fullName}`} accessibilityRole="button" accessibilityLabel={`查看${group === "tag" ? "标签" : "分支"} ${node.fullName}`} accessibilityState={{ selected: branch === node.fullName }} onPress={() => chooseBranch(node.fullName)} style={[styles.branchRow, branch === node.fullName && styles.branchSelected]}>
406
+ <View style={styles.branchIndent} />
407
+ {isCurrent && isMain ? <Image source={{ uri: CURRENT_BRANCH_ICON }} style={styles.currentBranchIcon} /> : isMain ? <Text style={styles.mainBranchIcon}>★</Text> : <Icon name={group === "tag" || isCurrent ? "Tag" : "GitBranch"} size={14} color={isCurrent ? "#f5a400" : "#64748b"} />}
408
+ <Text style={[styles.branchLeafName, branch === node.fullName && styles.activeBranch]} numberOfLines={1}>{node.name}</Text>
409
+ {group === "local" && (node.ahead > 0 || node.behind > 0) ? <View style={styles.branchTracking}>{node.behind > 0 ? <><Icon name="ArrowDownLeft" size={14} color="#3478f6" /><Text style={styles.behindCount}>{node.behind}</Text></> : null}{node.ahead > 0 ? <><Icon name="ArrowUpRight" size={14} color="#30a653" /><Text style={styles.aheadCount}>{node.ahead}</Text></> : null}</View> : null}
410
+ </Pressable>;
411
+ });
412
+ const renderFiles = (nodes: TreeNode[], depth: number): React.ReactNode[] => nodes.map((node) => {
413
+ if (node.kind === "folder") return <View key={node.path}>
414
+ <Pressable accessibilityRole="button" accessibilityLabel={`${collapsed.has(node.path) ? "展开" : "折叠"} ${node.path}`} onPress={() => toggleFolder(node.path)} style={[styles.fileRow, { paddingLeft: 8 + depth * 18 }]}>
415
+ <Icon name={collapsed.has(node.path) ? "ChevronRight" : "ChevronDown"} size={14} color="#64748b" />
416
+ <Icon name="Folder" size={16} color="#64748b" />
417
+ <Text style={styles.folderName} numberOfLines={1}>{node.name}</Text>
418
+ <Text style={styles.count}>{node.count} 个文件</Text>
419
+ </Pressable>
420
+ {!collapsed.has(node.path) ? renderFiles(node.children, depth + 1) : null}
421
+ </View>;
422
+ const tabId = `history:${selectedHash}:${node.file.path}`;
423
+ return <Pressable key={node.file.path} accessibilityRole="button" accessibilityLabel={`查看 ${node.file.path} 的提交差异`} onPress={() => { if (selectedHash) openTab({ id: tabId, source: "history", hash: selectedHash, path: node.file.path, status: node.file.status }); }} style={[styles.fileRow, { paddingLeft: 8 + depth * 18 }, activeTabId === tabId && styles.selected]}>
424
+ <View style={styles.fileIndent} />
425
+ <FileTypeIcon fileName={node.name} />
426
+ <Text style={[styles.fileName, fileColor(node.file.status)]} numberOfLines={1}>{node.name}</Text>
427
+ </Pressable>;
428
+ });
429
+ const selectedCommit = commits.find((item) => item.hash === selectedHash);
430
+ const parsed = parseDiffFiles(activeTab?.id === diff.tabId ? diff.text : "");
431
+ const diffLoading = Boolean(activeTab && (activeTab.id !== diff.tabId || diff.loading));
432
+ const contentHeight = Math.max(0, panelHeight - PANEL_PADDING * 2);
433
+ const contentWidth = Math.max(0, panelWidth - PANEL_PADDING * 2);
434
+ const availableHeight = Math.max(0, contentHeight - SPLITTER_SIZE);
435
+ const minimumTop = Math.min(160, Math.max(0, availableHeight - 36));
436
+ const minimumBottom = Math.min(160, Math.max(36, availableHeight - minimumTop));
437
+ const defaultTopHeight = Math.round(availableHeight * 0.53);
438
+ const topHeight = historyMinimized ? Math.max(0, availableHeight - 36) : Math.max(minimumTop, Math.min(availableHeight - minimumBottom, preferredTopHeight ?? defaultTopHeight));
439
+ const bottomHeight = Math.max(0, availableHeight - topHeight);
440
+ const minimumCommitWidth = Math.min(180, Math.max(0, contentWidth * 0.25));
441
+ const minimumDiffWidth = Math.min(240, Math.max(0, contentWidth * 0.35));
442
+ const maximumCommitWidth = Math.max(minimumCommitWidth, contentWidth - SPLITTER_SIZE - minimumDiffWidth);
443
+ const resolvedCommitWidth = Math.max(minimumCommitWidth, Math.min(maximumCommitWidth, commitWidth));
444
+ const columnSpace = Math.max(0, contentWidth - SPLITTER_SIZE * 2);
445
+ const minimumBranchWidth = Math.min(160, columnSpace * 0.25);
446
+ const minimumLogWidth = Math.min(250, columnSpace * 0.35);
447
+ const minimumDetailWidth = Math.min(200, columnSpace * 0.2);
448
+ const defaultBranchColumnWidth = resolvedCommitWidth;
449
+ const maximumBranchWidth = Math.max(minimumBranchWidth, columnSpace - minimumLogWidth - minimumDetailWidth);
450
+ const resolvedBranchColumnWidth = Math.max(minimumBranchWidth, Math.min(maximumBranchWidth, branchColumnWidth ?? defaultBranchColumnWidth));
451
+ const defaultLogColumnWidth = Math.round((columnSpace - resolvedBranchColumnWidth) * 0.68);
452
+ const maximumLogWidth = Math.max(minimumLogWidth, columnSpace - resolvedBranchColumnWidth - minimumDetailWidth);
453
+ const resolvedLogColumnWidth = Math.max(minimumLogWidth, Math.min(maximumLogWidth, logColumnWidth ?? defaultLogColumnWidth));
454
+ const minimumFilesHeight = Math.min(100, Math.max(0, detailPanelHeight - SPLITTER_SIZE));
455
+ const minimumMessageHeight = Math.min(100, Math.max(0, detailPanelHeight - SPLITTER_SIZE - minimumFilesHeight));
456
+ const maximumFilesHeight = Math.max(minimumFilesHeight, detailPanelHeight - SPLITTER_SIZE - minimumMessageHeight);
457
+ const filesHeight = Math.max(minimumFilesHeight, Math.min(maximumFilesHeight, preferredFilesHeight ?? detailPanelHeight * 0.58));
458
+
459
+ return <View style={styles.root} onLayout={(event) => { setPanelWidth(event.nativeEvent.layout.width); setPanelHeight(event.nativeEvent.layout.height); }}>
460
+ <View style={[styles.topRow, { height: topHeight }]}>
461
+ <View style={[styles.commitArea, { width: resolvedCommitWidth }]}><WorkspaceCommitPanel {...props} externalPreview={activeTab?.source === "working" ? activeTab : null} onFilePreview={(selection) => openTab({ id: `working:${selection.path}`, source: "working", ...selection })} /></View>
462
+ <ResizeHandle direction="vertical" appearance="gutter" label="调整提交更改面板宽度" onResize={(delta) => setCommitWidth((width) => Math.max(minimumCommitWidth, Math.min(maximumCommitWidth, Math.max(minimumCommitWidth, Math.min(maximumCommitWidth, width)) + delta)))} />
463
+ <View style={styles.topDiff} onLayout={(event) => setDiffHeight(event.nativeEvent.layout.height)}>
464
+ <View style={styles.tabStrip}>
465
+ <ScrollView ref={tabBarRef} horizontal style={styles.tabBar} contentContainerStyle={styles.tabBarContent} showsHorizontalScrollIndicator={false} onLayout={(event) => setTabViewportWidth(event.nativeEvent.layout.width)} onContentSizeChange={(width) => setTabContentWidth(width)}>
466
+ {tabs.length ? tabs.map((tab) => <ReorderableTab key={tab.id} tab={tab} active={activeTabId === tab.id} onActivate={() => setActiveTabId(tab.id)} onClose={() => closeTab(tab.id)} onReorder={reorderTab} onNode={registerTabNode} />) : <Text style={styles.emptyTab}>Diff 预览</Text>}
467
+ </ScrollView>
468
+ {tabOverflow && tabs.length ? <Pressable accessibilityRole="button" accessibilityLabel="显示所有打开的文件" accessibilityState={{ expanded: tabMenuOpen }} onPress={() => setTabMenuOpen((value) => !value)} style={[styles.tabMenuButton, tabMenuOpen && styles.tabMenuButtonActive]}><Icon name="ChevronDown" size={16} color="#64748b" /></Pressable> : null}
469
+ {tabMenuOpen && tabOverflow ? <ScrollView style={[styles.tabMenu, { maxHeight: Math.min(360, Math.max(100, diffHeight - 45)) }]} nestedScrollEnabled>
470
+ {tabs.map((tab) => <View key={tab.id} style={[styles.tabMenuRow, activeTabId === tab.id && styles.tabMenuRowActive]}>
471
+ <Pressable accessibilityRole="button" accessibilityLabel={`切换到 ${tab.path}`} onPress={() => { setActiveTabId(tab.id); setTabMenuOpen(false); }} style={styles.tabMenuSelect}>
472
+ <FileTypeIcon fileName={tab.path} />
473
+ <Text style={[styles.tabMenuTitle, fileColor(tab.status ?? "M")]} numberOfLines={1}>{tab.path.split("/").at(-1)}</Text>
474
+ <Text style={styles.tabSource}>{tab.source === "working" ? "工作区" : tab.hash.slice(0, 7)}</Text>
475
+ </Pressable>
476
+ <Pressable accessibilityRole="button" accessibilityLabel={`关闭 ${tab.path} 标签`} onPress={() => closeTab(tab.id)} style={styles.tabMenuClose}><Icon name="X" size={14} color="#94a3b8" /></Pressable>
477
+ </View>)}
478
+ </ScrollView> : null}
479
+ </View>
480
+ <View style={styles.diffHeader}>
481
+ <Text style={styles.diffPath} numberOfLines={1}>{activeTab ? `${activeTab.source === "working" ? "Working Diff" : "Repository Diff"}: ${activeTab.path}` : "选择文件查看差异"}</Text>
482
+ {activeTab ? <>
483
+ <WrapToggleButton active={wrapLines} onToggle={() => setWrapLines((value) => !value)} tooltipPlacement="below" />
484
+ </> : null}
485
+ </View>
486
+ {activeTab && parsed[0] && diffHeight > 75 ? <SplitDiff file={parsed[0]} wrapLines={wrapLines} maxHeight={Math.max(32, diffHeight - 75)} /> : <Text style={styles.empty}>{diffLoading ? "加载差异…" : activeTab ? "没有可显示的文本差异" : "点击左侧或下方变更文件,在这里查看 Diff"}</Text>}
487
+ </View>
488
+ </View>
489
+ <ResizeHandle direction="horizontal" appearance="gutter" label="调整 Git 历史面板高度" onResize={(delta) => { setPreferredTopHeight((height) => Math.max(minimumTop, Math.min(availableHeight - minimumBottom, (historyMinimized ? availableHeight - 36 : height ?? defaultTopHeight) + delta))); setHistoryMinimized(false); }} />
490
+ <View style={[styles.bottomPanel, { height: bottomHeight }]}>
491
+ <View style={styles.historyHeader}>
492
+ <Text style={styles.historyTitle} numberOfLines={1}>Log: {branch || "全部分支"}</Text>
493
+ <View style={styles.historyActions}>
494
+ <Pressable accessibilityRole="button" accessibilityLabel="刷新提交历史" onPress={() => { setSkip(0); setRevision((value) => value + 1); }} style={styles.historyMinimize}><Icon name="RefreshCw" size={15} color="#64748b" /></Pressable>
495
+ <Pressable accessibilityRole="button" accessibilityLabel={historyMinimized ? "展开 Git 历史面板" : "最小化 Git 历史面板"} onPress={() => setHistoryMinimized((value) => !value)} style={styles.historyMinimize}><Icon name={historyMinimized ? "Maximize2" : "Minus"} size={15} color="#64748b" /></Pressable>
496
+ </View>
497
+ </View>
498
+ {!historyMinimized ? <View style={styles.bottomViewport}>
499
+ <View style={[styles.columns, { height: Math.max(0, bottomHeight - 58) }]}>
500
+ <View style={[styles.sidebar, { width: resolvedBranchColumnWidth }]}>
501
+ <View style={styles.branchSearchRow}><Icon name="Search" size={16} color="#64748b" /><TextInput value={branchSearch} onChangeText={setBranchSearch} placeholder="Branch or tag" style={styles.branchSearchInput} /></View>
502
+ <ScrollView style={styles.sidebarScroll}>
503
+ {(["local", "remote", "tag"] as const).map((group) => {
504
+ const query = branchSearch.trim().toLowerCase();
505
+ const matching = branches.filter((item) => item.group === group && (!query || item.name.toLowerCase().includes(query)));
506
+ if (!matching.length) return null;
507
+ const label = group === "local" ? "Local" : group === "remote" ? "Remote" : "Tags";
508
+ return <View key={group}>
509
+ <Pressable accessibilityRole="button" accessibilityLabel={`${branchCollapsed.has(group) ? "展开" : "折叠"}${label}`} onPress={() => toggleBranchFolder(group)} style={styles.branchGroup}><Icon name={branchCollapsed.has(group) ? "ChevronRight" : "ChevronDown"} size={14} color="#64748b" /><Text style={styles.branchGroupText}>{label}</Text></Pressable>
510
+ {!branchCollapsed.has(group) || query ? <View style={styles.branchChildren}>{renderBranches(branchTree(matching, group), group)}</View> : null}
511
+ </View>;
512
+ })}
513
+ </ScrollView>
514
+ </View>
515
+ <ResizeHandle direction="vertical" appearance="line" label="调整分支列表宽度" onResize={(delta) => setBranchColumnWidth((width) => Math.max(minimumBranchWidth, Math.min(maximumBranchWidth, Math.max(minimumBranchWidth, Math.min(maximumBranchWidth, width ?? resolvedBranchColumnWidth)) + delta)))} />
516
+ <View style={[styles.logColumn, { width: resolvedLogColumnWidth }]}>
517
+ <View style={styles.logToolbar}><TextInput value={searchText} onChangeText={setSearchText} placeholder="搜索提交说明" style={styles.search} /></View>
518
+ <ScrollView style={styles.log}>
519
+ {commits.map((item) => {
520
+ const ownCommit = Boolean((currentGitUser.email && item.email.toLowerCase() === currentGitUser.email.toLowerCase()) || (!currentGitUser.email && currentGitUser.name && item.author === currentGitUser.name));
521
+ return <Pressable key={item.hash} accessibilityRole="button" accessibilityLabel={`查看提交 ${item.subject}`} accessibilityState={{ selected: selectedHash === item.hash }} onPress={() => setSelectedHash(item.hash)} style={[styles.commitRow, selectedHash === item.hash && styles.selected]}>
522
+ <Text style={[styles.author, ownCommit && styles.currentAuthor]} numberOfLines={1}>{item.author}</Text>
523
+ <View style={styles.graph}><View style={styles.graphLine} /><View style={[styles.graphDot, item.parents.length > 1 && styles.mergeDot]} /></View>
524
+ <Text style={styles.subject} numberOfLines={1}>{item.subject}{item.refs[0] ? <Text style={styles.ref}> {item.refs[0]}</Text> : null}</Text>
525
+ <CommitDate value={formatCommitTime(item.date, nowMs)} />
526
+ <CommitHash value={item.hash} />
527
+ </Pressable>;
528
+ })}
529
+ {hasMore ? <Pressable accessibilityRole="button" onPress={() => { if (!loading) setSkip((value) => value + 50); }} style={styles.loadMore}><Text style={styles.loadMoreText}>{loading ? "加载中…" : "加载更多提交"}</Text></Pressable> : null}
530
+ {!loading && commits.length === 0 ? <Text style={styles.empty}>没有找到提交记录</Text> : null}
531
+ </ScrollView>
532
+ </View>
533
+ <ResizeHandle direction="vertical" appearance="line" label="调整提交列表宽度" onResize={(delta) => setLogColumnWidth((width) => Math.max(minimumLogWidth, Math.min(maximumLogWidth, Math.max(minimumLogWidth, Math.min(maximumLogWidth, width ?? resolvedLogColumnWidth)) + delta)))} />
534
+ <View style={styles.detail} onLayout={(event) => setDetailPanelHeight(event.nativeEvent.layout.height)}>
535
+ {selectedCommit ? <>
536
+ <View style={[styles.filesSection, { height: filesHeight }]}><Text style={styles.columnHeading}>变更文件 {detail?.files.length ?? ""}</Text><ScrollView style={styles.files}>{detail ? renderFiles(tree(detail.files), 0) : <Text style={styles.empty}>加载变更文件…</Text>}</ScrollView></View>
537
+ <ResizeHandle direction="horizontal" appearance="line" label="调整变更文件与提交说明的高度" onResize={(delta) => setPreferredFilesHeight((height) => Math.max(minimumFilesHeight, Math.min(maximumFilesHeight, Math.max(minimumFilesHeight, Math.min(maximumFilesHeight, height ?? filesHeight)) + delta)))} />
538
+ <ScrollView key={selectedCommit.hash} style={styles.detailMessageScroll} contentContainerStyle={styles.detailMessageContent}>
539
+ <CommitMessageText value={detail?.message ?? selectedCommit.subject} />
540
+ <Text style={styles.detailMeta}>{selectedCommit.hash.slice(0, 8)} {selectedCommit.author} &lt;{selectedCommit.email}&gt; on {new Date(selectedCommit.date).toLocaleString("zh-CN", { year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })}</Text>
541
+ {detail?.containingBranches.length ? <Text style={styles.detailMeta}>In {detail.containingBranches.length} branches: {detail.containingBranches.join(", ")}</Text> : null}
542
+ </ScrollView>
543
+ </> : <Text style={styles.empty}>选择一条提交查看详情</Text>}
544
+ </View>
545
+ </View>
546
+ </View> : null}
547
+ {!historyMinimized ? <View style={styles.historyFooter}><Icon name="GitBranch" size={13} color="#64748b" /><Text style={styles.historyBranch} numberOfLines={1}>{currentBranch || "未检测到分支"}</Text></View> : null}
548
+ </View>
549
+ {error ? <Text style={styles.error} numberOfLines={2}>{error}</Text> : null}
550
+ </View>;
551
+ }
552
+
553
+ const styles = StyleSheet.create({
554
+ root: { flex: 1, minWidth: 0, minHeight: 0, overflow: "hidden", padding: PANEL_PADDING, backgroundColor: "#eef0f3" },
555
+ topRow: { flexDirection: "row", minWidth: 0, minHeight: 0, overflow: "hidden" },
556
+ commitArea: { minHeight: 0, overflow: "hidden", backgroundColor: "#fff", borderRadius: 8 },
557
+ verticalGutter: { width: SPLITTER_SIZE, backgroundColor: "#eef0f3" },
558
+ horizontalGutter: { height: SPLITTER_SIZE, backgroundColor: "#eef0f3" },
559
+ verticalLineSplitter: { width: SPLITTER_SIZE, alignItems: "center" },
560
+ splitterLine: { width: 1, height: "100%", backgroundColor: "#d1d5db" },
561
+ horizontalLineSplitter: { height: SPLITTER_SIZE, justifyContent: "center" },
562
+ splitterLineHorizontal: { height: 1, width: "100%", backgroundColor: "#d1d5db" },
563
+ topDiff: { flex: 1, minHeight: 0, minWidth: 0, overflow: "hidden", backgroundColor: "#fff", borderRadius: 8 },
564
+ tabStrip: { flexDirection: "row", height: 36, minWidth: 0, position: "relative", zIndex: 10, borderBottomWidth: 1, borderColor: "#e5e7eb" },
565
+ tabBar: { flex: 1, minWidth: 0, height: 35 },
566
+ tabBarContent: { flexDirection: "row", alignItems: "center", paddingHorizontal: 4 },
567
+ tab: { flexDirection: "row", alignItems: "center", maxWidth: 220, height: 29, marginHorizontal: 3, borderWidth: 1, borderColor: "transparent", borderRadius: 7, paddingRight: 3 },
568
+ hoveredTab: { backgroundColor: "#eeeeef" },
569
+ activeTab: { backgroundColor: "#e4ecff", borderColor: "#9cbcff" },
570
+ tabSelect: { flexDirection: "row", alignItems: "center", gap: 5, flexShrink: 1, paddingHorizontal: 8, height: 27 },
571
+ tabTitle: { flexShrink: 1, fontSize: 12, color: "#475569" },
572
+ tabSource: { flexShrink: 0, fontSize: 10, color: "#9ca3af" },
573
+ tabClose: { width: 20, height: 20, flexShrink: 0, borderRadius: 10, alignItems: "center", justifyContent: "center" },
574
+ tabCloseHovered: { backgroundColor: "#d7d9de" },
575
+ tabMenuButton: { width: 34, height: 35, alignItems: "center", justifyContent: "center", borderLeftWidth: 1, borderColor: "#e5e7eb", backgroundColor: "#fff" },
576
+ tabMenuButtonActive: { backgroundColor: "#e8f0ff" },
577
+ tabMenu: { position: "absolute", top: 35, right: 0, width: 300, backgroundColor: "#fff", borderWidth: 1, borderColor: "#cbd5e1", borderRadius: 5, zIndex: 20, shadowColor: "#000", shadowOpacity: 0.15, shadowRadius: 8, elevation: 8 },
578
+ tabMenuRow: { flexDirection: "row", alignItems: "center", minHeight: 32 },
579
+ tabMenuRowActive: { backgroundColor: "#e8f0ff" },
580
+ tabMenuSelect: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 7, paddingLeft: 10, minHeight: 32 },
581
+ tabMenuTitle: { flex: 1, minWidth: 0, fontSize: 12, color: "#374151" },
582
+ tabMenuClose: { width: 30, height: 30, alignItems: "center", justifyContent: "center" },
583
+ emptyTab: { paddingHorizontal: 10, paddingVertical: 9, color: "#6b7280", fontSize: 12 },
584
+ bottomPanel: { minHeight: 0, overflow: "hidden", backgroundColor: "#fff", borderTopLeftRadius: 8, borderTopRightRadius: 8 },
585
+ historyHeader: { height: 34, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingLeft: 10, paddingRight: 7, borderBottomWidth: 1, borderColor: "#e5e7eb" },
586
+ historyTitle: { flex: 1, fontSize: 12, fontWeight: "600", color: "#374151" },
587
+ historyActions: { flexDirection: "row", alignItems: "center", gap: 3 },
588
+ historyMinimize: { width: 25, height: 25, borderRadius: 4, alignItems: "center", justifyContent: "center" },
589
+ bottomViewport: { flex: 1, minWidth: 0, minHeight: 0, overflow: "hidden" },
590
+ historyFooter: { height: 24, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 5, paddingHorizontal: 10, borderTopWidth: 1, borderColor: "#e5e7eb", backgroundColor: "#f8fafc" },
591
+ historyBranch: { fontSize: 11, color: "#64748b" },
592
+ columns: { flexDirection: "row", minWidth: 0, minHeight: 0 },
593
+ sidebar: { minHeight: 0 },
594
+ sidebarScroll: { flex: 1, minHeight: 0 },
595
+ branchSearchRow: { height: 38, flexDirection: "row", alignItems: "center", gap: 7, margin: 5, paddingHorizontal: 9, borderWidth: 1, borderColor: "#cbd5e1", borderRadius: 5 },
596
+ branchSearchInput: { flex: 1, minWidth: 0, fontSize: 12, color: "#111827" },
597
+ columnHeading: { paddingHorizontal: 10, paddingVertical: 9, fontSize: 12, fontWeight: "600", color: "#374151", borderBottomWidth: 1, borderColor: "#e5e7eb" },
598
+ branchGroup: { minHeight: 32, flexDirection: "row", alignItems: "center", gap: 7, paddingHorizontal: 10 },
599
+ branchGroupText: { fontSize: 12, color: "#374151", fontWeight: "600" },
600
+ branchChildren: { marginLeft: 17, borderLeftWidth: 1, borderLeftColor: "#e2e8f0" },
601
+ branchRow: { minHeight: 29, flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 8 },
602
+ branchSelected: { backgroundColor: "#d7e4ff", borderRadius: 5, marginHorizontal: 4, paddingHorizontal: 4 },
603
+ branchIndent: { width: 14 },
604
+ branchName: { flex: 1, fontSize: 12, color: "#374151" },
605
+ branchLeafName: { minWidth: 0, flexShrink: 1, fontSize: 12, color: "#374151" },
606
+ branchTracking: { flexDirection: "row", alignItems: "center", gap: 2, marginLeft: 8, flexShrink: 0 },
607
+ aheadCount: { fontSize: 11, color: "#30a653" },
608
+ behindCount: { fontSize: 11, color: "#3478f6" },
609
+ activeBranch: { color: "#1641ad", fontWeight: "600" },
610
+ mainBranchIcon: { width: 14, fontSize: 18, lineHeight: 20, color: "#f5a400", textAlign: "center" },
611
+ currentBranchIcon: { width: 16, height: 16 },
612
+ logColumn: { minWidth: 0, minHeight: 0 },
613
+ logToolbar: { flexDirection: "row", alignItems: "center", gap: 8, borderBottomWidth: 1, borderColor: "#e5e7eb" },
614
+ search: { flex: 1, marginHorizontal: 8, borderWidth: 1, borderColor: "#cbd5e1", borderRadius: 5, paddingHorizontal: 8, paddingVertical: 4, fontSize: 12 },
615
+ log: { flex: 1, minHeight: 0 },
616
+ commitRow: { flexDirection: "row", alignItems: "center", minHeight: 36, paddingHorizontal: 8, gap: 5 },
617
+ selected: { backgroundColor: "#e8f0ff" },
618
+ graph: { width: 24, height: 36, flexShrink: 0, alignItems: "center", justifyContent: "center" },
619
+ graphLine: { position: "absolute", top: 0, bottom: 0, width: 2, backgroundColor: "#b19bd2" },
620
+ graphDot: { width: 9, height: 9, borderRadius: 5, backgroundColor: "#9754bd" },
621
+ mergeDot: { backgroundColor: "#36aaa5" },
622
+ subject: { flex: 1, minWidth: 0, fontSize: 12, color: "#111827", fontWeight: "600" },
623
+ hash: { width: 56, flexShrink: 0, textAlign: "left", fontFamily: "monospace", fontSize: 11, color: "#64748b" },
624
+ author: { width: 80, minWidth: 40, flexShrink: 1, fontSize: 11, color: "#374151" },
625
+ currentAuthor: { fontWeight: "700", color: "#111827" },
626
+ date: { width: 121, minWidth: 0, flexShrink: 1, textAlign: "left", fontFamily: "monospace", fontSize: 11, color: "#64748b" },
627
+ ref: { fontSize: 11, fontWeight: "400", color: "#0f8b59" },
628
+ loadMore: { alignItems: "center", padding: 12 },
629
+ loadMoreText: { color: "#1641ad", fontSize: 12 },
630
+ detail: { flex: 1, minWidth: 0, minHeight: 0, overflow: "hidden" },
631
+ filesSection: { minHeight: 0 },
632
+ detailMessageScroll: { flex: 1, minHeight: 0 },
633
+ detailMessageContent: { padding: 10, gap: 14 },
634
+ detailMeta: { fontSize: 11, color: "#64748b" },
635
+ detailMessage: { fontFamily: "monospace", fontSize: 13, lineHeight: 19, color: "#111827" },
636
+ files: { flex: 1, minHeight: 0 },
637
+ fileRow: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 28, paddingRight: 8 },
638
+ fileIndent: { width: 14 },
639
+ folderName: { fontSize: 12, color: "#374151", flexShrink: 1 },
640
+ count: { fontSize: 10, color: "#9ca3af" },
641
+ fileName: { fontSize: 12, flexShrink: 1 },
642
+ added: { color: "#07833d" },
643
+ modified: { color: "#1641ad" },
644
+ deleted: { color: "#bc3145" },
645
+ diffHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", minHeight: 38, paddingHorizontal: 9, position: "relative", zIndex: 3 },
646
+ diffPath: { flex: 1, fontSize: 12, color: "#374151" },
647
+ empty: { padding: 12, fontSize: 12, color: "#6b7280" },
648
+ error: { position: "absolute", left: 0, right: 0, bottom: 0, paddingHorizontal: 9, paddingVertical: 5, color: "#b91c1c", backgroundColor: "#fff", fontSize: 11 },
649
+ });