@hienlh/ppm 0.7.40 → 0.8.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/CHANGELOG.md +19 -0
- package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
- package/dist/web/assets/{code-editor-BMVhw78r.js → code-editor-BakDn6rL.js} +1 -1
- package/dist/web/assets/{database-viewer-IvsAIFVr.js → database-viewer-COaZMlpv.js} +1 -1
- package/dist/web/assets/{diff-viewer-BEx8B1nH.js → diff-viewer-COSbmidI.js} +1 -1
- package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
- package/dist/web/assets/index-BGTzm7B1.js +28 -0
- package/dist/web/assets/index-CeNox-VV.css +2 -0
- package/dist/web/assets/input-CE3bFwLk.js +41 -0
- package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
- package/dist/web/assets/{markdown-renderer-CVyVjnji.js → markdown-renderer-BKgH2iGf.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BluSPCxI.js → postgres-viewer-DBOv2ha2.js} +1 -1
- package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
- package/dist/web/assets/{sqlite-viewer-LdjnRshk.js → sqlite-viewer-BY242odW.js} +1 -1
- package/dist/web/assets/switch-BEmt1alu.js +1 -0
- package/dist/web/assets/{terminal-tab-DnTTRYK8.js → terminal-tab-BiUqECPk.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +108 -64
- package/src/providers/mock-provider.ts +1 -0
- package/src/providers/provider.interface.ts +1 -0
- package/src/server/routes/git.ts +16 -2
- package/src/server/ws/chat.ts +43 -26
- package/src/services/chat.service.ts +3 -1
- package/src/services/git.service.ts +45 -8
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +5 -0
- package/src/types/config.ts +21 -0
- package/src/types/git.ts +4 -0
- package/src/web/components/chat/chat-tab.tsx +26 -8
- package/src/web/components/chat/message-input.tsx +61 -1
- package/src/web/components/chat/message-list.tsx +9 -1
- package/src/web/components/chat/mode-selector.tsx +117 -0
- package/src/web/components/git/git-graph-branch-label.tsx +124 -0
- package/src/web/components/git/git-graph-constants.ts +185 -0
- package/src/web/components/git/git-graph-detail.tsx +107 -0
- package/src/web/components/git/git-graph-dialog.tsx +72 -0
- package/src/web/components/git/git-graph-row.tsx +167 -0
- package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
- package/src/web/components/git/git-graph-svg.tsx +54 -0
- package/src/web/components/git/git-graph-toolbar.tsx +195 -0
- package/src/web/components/git/git-graph.tsx +143 -681
- package/src/web/components/git/use-column-resize.ts +33 -0
- package/src/web/components/git/use-git-graph.ts +201 -0
- package/src/web/components/layout/cloud-share-popover.tsx +76 -26
- package/src/web/components/settings/ai-settings-section.tsx +42 -0
- package/src/web/hooks/use-chat.ts +3 -3
- package/src/web/lib/api-settings.ts +2 -0
- package/dist/web/assets/chat-tab-BNQmD9jh.js +0 -7
- package/dist/web/assets/git-graph-BdUhmJtk.js +0 -1
- package/dist/web/assets/index-M3zwguMz.css +0 -2
- package/dist/web/assets/index-y1G0ksWJ.js +0 -28
- package/dist/web/assets/input-CVIzrYsH.js +0 -41
- package/dist/web/assets/keybindings-store-BLrTeWmN.js +0 -1
- package/dist/web/assets/settings-tab-aOKIBhyp.js +0 -1
- package/dist/web/assets/switch-UODDpwuO.js +0 -1
|
@@ -1,49 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
RefreshCw,
|
|
4
|
-
Loader2,
|
|
5
|
-
GitBranch,
|
|
6
|
-
Tag,
|
|
7
|
-
Copy,
|
|
8
|
-
GitMerge,
|
|
9
|
-
Trash2,
|
|
10
|
-
ArrowUpFromLine,
|
|
11
|
-
ExternalLink,
|
|
12
|
-
RotateCcw,
|
|
13
|
-
CherryIcon,
|
|
14
|
-
GripVertical,
|
|
15
|
-
} from "lucide-react";
|
|
16
|
-
import { api, projectUrl } from "@/lib/api-client";
|
|
17
|
-
import { basename } from "@/lib/utils";
|
|
18
|
-
import { useTabStore } from "@/stores/tab-store";
|
|
1
|
+
import { useState, useRef, useCallback } from "react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
19
3
|
import { Button } from "@/components/ui/button";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} from "
|
|
28
|
-
import {
|
|
29
|
-
Dialog,
|
|
30
|
-
DialogContent,
|
|
31
|
-
DialogHeader,
|
|
32
|
-
DialogTitle,
|
|
33
|
-
DialogFooter,
|
|
34
|
-
} from "@/components/ui/dialog";
|
|
35
|
-
import { Input } from "@/components/ui/input";
|
|
36
|
-
import type { GitGraphData, GitCommit, GitBranch as GitBranchType } from "../../../types/git";
|
|
37
|
-
|
|
38
|
-
const LANE_COLORS = [
|
|
39
|
-
"#4fc3f7", "#81c784", "#ffb74d", "#e57373",
|
|
40
|
-
"#ba68c8", "#4dd0e1", "#aed581", "#ff8a65",
|
|
41
|
-
"#f06292", "#7986cb",
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
const ROW_HEIGHT = 32;
|
|
45
|
-
const LANE_WIDTH = 20;
|
|
46
|
-
const NODE_RADIUS = 5;
|
|
4
|
+
import { LANE_WIDTH, ROW_HEIGHT } from "./git-graph-constants";
|
|
5
|
+
import { useGitGraph } from "./use-git-graph";
|
|
6
|
+
import { useColumnResize } from "./use-column-resize";
|
|
7
|
+
import { GitGraphToolbar } from "./git-graph-toolbar";
|
|
8
|
+
import { GitGraphSvg } from "./git-graph-svg";
|
|
9
|
+
import { GitGraphRow } from "./git-graph-row";
|
|
10
|
+
import { GitGraphDetail } from "./git-graph-detail";
|
|
11
|
+
import { GitGraphDialog } from "./git-graph-dialog";
|
|
12
|
+
import { GitGraphSettingsDialog } from "./git-graph-settings-dialog";
|
|
47
13
|
|
|
48
14
|
interface GitGraphProps {
|
|
49
15
|
metadata?: Record<string, unknown>;
|
|
@@ -51,255 +17,31 @@ interface GitGraphProps {
|
|
|
51
17
|
|
|
52
18
|
export function GitGraph({ metadata }: GitGraphProps) {
|
|
53
19
|
const projectName = metadata?.projectName as string | undefined;
|
|
54
|
-
const
|
|
55
|
-
const [loading, setLoading] = useState(true);
|
|
56
|
-
const [error, setError] = useState<string | null>(null);
|
|
57
|
-
const [acting, setActing] = useState(false);
|
|
20
|
+
const g = useGitGraph(projectName);
|
|
58
21
|
const [dialogState, setDialogState] = useState<{
|
|
59
22
|
type: "branch" | "tag" | null;
|
|
60
23
|
hash?: string;
|
|
61
24
|
}>({ type: null });
|
|
62
|
-
const [
|
|
63
|
-
const [selectedCommit, setSelectedCommit] = useState<GitCommit | null>(null);
|
|
64
|
-
const [commitFiles, setCommitFiles] = useState<Array<{ path: string; additions: number; deletions: number }>>([]);
|
|
65
|
-
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
66
|
-
const { openTab } = useTabStore();
|
|
67
|
-
|
|
68
|
-
const fetchGraph = useCallback(async () => {
|
|
69
|
-
if (!projectName) return;
|
|
70
|
-
try {
|
|
71
|
-
setLoading(true);
|
|
72
|
-
const result = await api.get<GitGraphData>(
|
|
73
|
-
`${projectUrl(projectName)}/git/graph?max=200`,
|
|
74
|
-
);
|
|
75
|
-
setData(result);
|
|
76
|
-
setError(null);
|
|
77
|
-
} catch (e) {
|
|
78
|
-
setError(e instanceof Error ? e.message : "Failed to fetch graph");
|
|
79
|
-
} finally {
|
|
80
|
-
setLoading(false);
|
|
81
|
-
}
|
|
82
|
-
}, [projectName]);
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
fetchGraph();
|
|
86
|
-
// Auto-reload every 10 seconds
|
|
87
|
-
const interval = setInterval(fetchGraph, 10000);
|
|
88
|
-
return () => clearInterval(interval);
|
|
89
|
-
}, [fetchGraph]);
|
|
90
|
-
|
|
91
|
-
const gitAction = async (
|
|
92
|
-
path: string,
|
|
93
|
-
body: Record<string, unknown>,
|
|
94
|
-
) => {
|
|
95
|
-
if (!projectName) return;
|
|
96
|
-
setActing(true);
|
|
97
|
-
try {
|
|
98
|
-
await api.post(`${projectUrl(projectName)}${path}`, body);
|
|
99
|
-
await fetchGraph();
|
|
100
|
-
} catch (e) {
|
|
101
|
-
setError(e instanceof Error ? e.message : "Action failed");
|
|
102
|
-
} finally {
|
|
103
|
-
setActing(false);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const handleCheckout = (ref: string) =>
|
|
108
|
-
gitAction("/git/checkout", { ref });
|
|
109
|
-
const handleCherryPick = (hash: string) =>
|
|
110
|
-
gitAction("/git/cherry-pick", { hash });
|
|
111
|
-
const handleRevert = (hash: string) =>
|
|
112
|
-
gitAction("/git/revert", { hash });
|
|
113
|
-
const handleMerge = (source: string) =>
|
|
114
|
-
gitAction("/git/merge", { source });
|
|
115
|
-
const handleDeleteBranch = (name: string) =>
|
|
116
|
-
gitAction("/git/branch/delete", { name });
|
|
117
|
-
const handlePushBranch = (branch: string) =>
|
|
118
|
-
gitAction("/git/push", { branch });
|
|
119
|
-
const handleCreateBranch = async (name: string, from: string) => {
|
|
120
|
-
// Check if branch already exists
|
|
121
|
-
const exists = data?.branches.some((b) => b.name === name || b.name.endsWith(`/${name}`));
|
|
122
|
-
if (exists) {
|
|
123
|
-
const confirmed = window.confirm(
|
|
124
|
-
`Branch "${name}" already exists.\nDelete it and recreate from this commit?`,
|
|
125
|
-
);
|
|
126
|
-
if (!confirmed) return;
|
|
127
|
-
// Delete first, then recreate
|
|
128
|
-
await gitAction("/git/branch/delete", { name });
|
|
129
|
-
}
|
|
130
|
-
await gitAction("/git/branch/create", { name, from });
|
|
131
|
-
};
|
|
132
|
-
const handleCreateTag = (name: string, hash?: string) =>
|
|
133
|
-
gitAction("/git/tag", { name, hash });
|
|
134
|
-
|
|
135
|
-
const handleCreatePr = async (branch: string) => {
|
|
136
|
-
if (!projectName) return;
|
|
137
|
-
try {
|
|
138
|
-
const result = await api.get<{ url: string | null }>(
|
|
139
|
-
`${projectUrl(projectName)}/git/pr-url?branch=${encodeURIComponent(branch)}`,
|
|
140
|
-
);
|
|
141
|
-
if (result.url) {
|
|
142
|
-
window.open(result.url, "_blank");
|
|
143
|
-
}
|
|
144
|
-
} catch {
|
|
145
|
-
// silent
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const copyHash = (hash: string) => {
|
|
150
|
-
navigator.clipboard.writeText(hash);
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const selectCommit = async (commit: GitCommit) => {
|
|
154
|
-
if (selectedCommit?.hash === commit.hash) {
|
|
155
|
-
setSelectedCommit(null);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
setSelectedCommit(commit);
|
|
159
|
-
setLoadingDetail(true);
|
|
160
|
-
try {
|
|
161
|
-
const parent = commit.parents[0] ?? "";
|
|
162
|
-
// For root commits (no parent), diff against empty tree
|
|
163
|
-
const ref1Param = parent ? `ref1=${encodeURIComponent(parent)}&` : "";
|
|
164
|
-
const files = await api.get<Array<{ path: string; additions: number; deletions: number }>>(
|
|
165
|
-
`${projectUrl(projectName!)}/git/diff-stat?${ref1Param}ref2=${encodeURIComponent(commit.hash)}`,
|
|
166
|
-
);
|
|
167
|
-
setCommitFiles(Array.isArray(files) ? files : []);
|
|
168
|
-
} catch (e) {
|
|
169
|
-
console.error("diff-stat error:", e);
|
|
170
|
-
setCommitFiles([]);
|
|
171
|
-
} finally {
|
|
172
|
-
setLoadingDetail(false);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const openDiffForCommit = (commit: GitCommit) => {
|
|
177
|
-
const ref1 = commit.parents[0];
|
|
178
|
-
openTab({
|
|
179
|
-
type: "git-diff",
|
|
180
|
-
title: `Diff ${commit.abbreviatedHash}`,
|
|
181
|
-
closable: true,
|
|
182
|
-
metadata: {
|
|
183
|
-
projectName,
|
|
184
|
-
ref1: ref1 ?? undefined,
|
|
185
|
-
ref2: commit.hash,
|
|
186
|
-
},
|
|
187
|
-
projectId: projectName ?? null,
|
|
188
|
-
});
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
// Lane assignment algorithm
|
|
192
|
-
const { laneMap, maxLane } = useMemo(() => {
|
|
193
|
-
const map = new Map<string, number>();
|
|
194
|
-
if (!data) return { laneMap: map, maxLane: 0 };
|
|
195
|
-
|
|
196
|
-
let nextLane = 0;
|
|
197
|
-
const activeLanes = new Map<string, number>();
|
|
198
|
-
|
|
199
|
-
for (const commit of data.commits) {
|
|
200
|
-
let lane = activeLanes.get(commit.hash);
|
|
201
|
-
if (lane === undefined) {
|
|
202
|
-
lane = nextLane++;
|
|
203
|
-
}
|
|
204
|
-
map.set(commit.hash, lane);
|
|
205
|
-
activeLanes.delete(commit.hash);
|
|
206
|
-
|
|
207
|
-
for (let i = 0; i < commit.parents.length; i++) {
|
|
208
|
-
const parent = commit.parents[i]!;
|
|
209
|
-
if (!activeLanes.has(parent)) {
|
|
210
|
-
activeLanes.set(parent, i === 0 ? lane : nextLane++);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return { laneMap: map, maxLane: Math.max(nextLane - 1, 0) };
|
|
215
|
-
}, [data]);
|
|
216
|
-
|
|
217
|
-
const currentBranch = data?.branches.find((b) => b.current);
|
|
218
|
-
|
|
219
|
-
// Build commit -> branch/tag label map
|
|
220
|
-
const commitLabels = useMemo(() => {
|
|
221
|
-
const labels = new Map<string, Array<{ name: string; type: "branch" | "tag" }>>();
|
|
222
|
-
if (!data) return labels;
|
|
223
|
-
for (const branch of data.branches) {
|
|
224
|
-
const arr = labels.get(branch.commitHash) ?? [];
|
|
225
|
-
arr.push({ name: branch.name, type: "branch" });
|
|
226
|
-
labels.set(branch.commitHash, arr);
|
|
227
|
-
}
|
|
228
|
-
for (const commit of data.commits) {
|
|
229
|
-
for (const ref of commit.refs) {
|
|
230
|
-
if (ref.startsWith("tag: ")) {
|
|
231
|
-
const tagName = ref.replace("tag: ", "");
|
|
232
|
-
const arr = labels.get(commit.hash) ?? [];
|
|
233
|
-
arr.push({ name: tagName, type: "tag" });
|
|
234
|
-
labels.set(commit.hash, arr);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return labels;
|
|
239
|
-
}, [data]);
|
|
240
|
-
|
|
241
|
-
// Build SVG paths for connections
|
|
242
|
-
const svgPaths = useMemo(() => {
|
|
243
|
-
if (!data) return [];
|
|
244
|
-
const paths: Array<{ d: string; color: string }> = [];
|
|
245
|
-
|
|
246
|
-
for (let idx = 0; idx < data.commits.length; idx++) {
|
|
247
|
-
const commit = data.commits[idx]!;
|
|
248
|
-
const lane = laneMap.get(commit.hash) ?? 0;
|
|
249
|
-
const color = LANE_COLORS[lane % LANE_COLORS.length]!;
|
|
250
|
-
|
|
251
|
-
for (const parentHash of commit.parents) {
|
|
252
|
-
const parentIdx = data.commits.findIndex((c) => c.hash === parentHash);
|
|
253
|
-
if (parentIdx < 0) continue;
|
|
254
|
-
const parentLane = laneMap.get(parentHash) ?? 0;
|
|
255
|
-
const parentColor = LANE_COLORS[parentLane % LANE_COLORS.length]!;
|
|
256
|
-
|
|
257
|
-
const x1 = lane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
258
|
-
const y1 = idx * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
259
|
-
const x2 = parentLane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
260
|
-
const y2 = parentIdx * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
261
|
-
|
|
262
|
-
let d: string;
|
|
263
|
-
const isMerge = commit.parents.indexOf(parentHash) > 0;
|
|
264
|
-
if (x1 === x2) {
|
|
265
|
-
// Same lane: straight line
|
|
266
|
-
d = `M ${x1} ${y1} L ${x2} ${y2}`;
|
|
267
|
-
} else if (isMerge) {
|
|
268
|
-
// Merge: curve at child (top), straight down to parent
|
|
269
|
-
const curveEnd = y1 + ROW_HEIGHT;
|
|
270
|
-
d = `M ${x1} ${y1} C ${x1} ${curveEnd} ${x2} ${y1} ${x2} ${curveEnd} L ${x2} ${y2}`;
|
|
271
|
-
} else {
|
|
272
|
-
// Branch/fork: straight down from child, curve at parent (bottom)
|
|
273
|
-
const curveStart = y2 - ROW_HEIGHT;
|
|
274
|
-
d = `M ${x1} ${y1} L ${x1} ${curveStart} C ${x1} ${y2} ${x2} ${curveStart} ${x2} ${y2}`;
|
|
275
|
-
}
|
|
276
|
-
// Use parent color for merge lines, commit color for first parent
|
|
277
|
-
const lineColor = commit.parents.indexOf(parentHash) === 0 ? color : parentColor;
|
|
278
|
-
paths.push({ d, color: lineColor });
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return paths;
|
|
282
|
-
}, [data, laneMap]);
|
|
25
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
283
26
|
|
|
284
|
-
|
|
285
|
-
const svgHeight = (data?.commits.length ?? 0) * ROW_HEIGHT;
|
|
286
|
-
|
|
287
|
-
// Resizable graph column — default: 6 lanes mobile, 10 lanes desktop
|
|
27
|
+
// Resizable graph column — use ref to avoid stale closure
|
|
288
28
|
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
|
289
|
-
const
|
|
290
|
-
const [graphColWidth, setGraphColWidth] = useState(
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
29
|
+
const defaultGraphW = (isMobile ? 6 : 10) * LANE_WIDTH + LANE_WIDTH;
|
|
30
|
+
const [graphColWidth, setGraphColWidth] = useState(defaultGraphW);
|
|
31
|
+
const graphWidthRef = useRef(defaultGraphW);
|
|
32
|
+
graphWidthRef.current = graphColWidth;
|
|
33
|
+
const graphDragging = useRef(false);
|
|
34
|
+
|
|
35
|
+
const startGraphResize = (startX: number) => {
|
|
36
|
+
graphDragging.current = true;
|
|
37
|
+
const startW = graphWidthRef.current;
|
|
296
38
|
const onMove = (ev: MouseEvent | TouchEvent) => {
|
|
297
|
-
if (!
|
|
298
|
-
const
|
|
299
|
-
setGraphColWidth(Math.max(40, startW +
|
|
39
|
+
if (!graphDragging.current) return;
|
|
40
|
+
const cx = "touches" in ev ? ev.touches[0]!.clientX : ev.clientX;
|
|
41
|
+
setGraphColWidth(Math.max(40, startW + cx - startX));
|
|
300
42
|
};
|
|
301
43
|
const onUp = () => {
|
|
302
|
-
|
|
44
|
+
graphDragging.current = false;
|
|
303
45
|
window.removeEventListener("mousemove", onMove);
|
|
304
46
|
window.removeEventListener("mouseup", onUp);
|
|
305
47
|
window.removeEventListener("touchmove", onMove);
|
|
@@ -309,423 +51,143 @@ export function GitGraph({ metadata }: GitGraphProps) {
|
|
|
309
51
|
window.addEventListener("mouseup", onUp);
|
|
310
52
|
window.addEventListener("touchmove", onMove, { passive: false });
|
|
311
53
|
window.addEventListener("touchend", onUp);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
315
|
-
e.preventDefault();
|
|
316
|
-
handleDragStart(e.clientX);
|
|
317
|
-
}, [handleDragStart]);
|
|
318
|
-
|
|
319
|
-
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
320
|
-
handleDragStart(e.touches[0]!.clientX);
|
|
321
|
-
}, [handleDragStart]);
|
|
322
|
-
|
|
323
|
-
if (!projectName) {
|
|
324
|
-
return (
|
|
325
|
-
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
326
|
-
No project selected.
|
|
327
|
-
</div>
|
|
328
|
-
);
|
|
329
|
-
}
|
|
54
|
+
};
|
|
330
55
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
<div className="flex items-center justify-center h-full gap-2 text-muted-foreground">
|
|
334
|
-
<Loader2 className="size-5 animate-spin" />
|
|
335
|
-
<span className="text-sm">Loading git graph...</span>
|
|
336
|
-
</div>
|
|
337
|
-
);
|
|
338
|
-
}
|
|
56
|
+
// Resizable table columns (Date, Author, Commit)
|
|
57
|
+
const { widths: colW, startResize } = useColumnResize({ date: 80, author: 120, commit: 70 });
|
|
339
58
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
59
|
+
// Infinite scroll — ref-based to avoid stale closure
|
|
60
|
+
const loadMoreRef = useRef(g.loadMore);
|
|
61
|
+
loadMoreRef.current = g.loadMore;
|
|
62
|
+
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
63
|
+
const el = e.currentTarget;
|
|
64
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
|
65
|
+
loadMoreRef.current();
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
350
68
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const diffMs = now.getTime() - date.getTime();
|
|
355
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
356
|
-
if (diffMins < 1) return "just now";
|
|
357
|
-
if (diffMins < 60) return `${diffMins}m ago`;
|
|
358
|
-
const diffHours = Math.floor(diffMins / 60);
|
|
359
|
-
if (diffHours < 24) return `${diffHours}h ago`;
|
|
360
|
-
const diffDays = Math.floor(diffHours / 24);
|
|
361
|
-
if (diffDays < 30) return `${diffDays}d ago`;
|
|
362
|
-
const diffMonths = Math.floor(diffDays / 30);
|
|
363
|
-
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
|
364
|
-
return `${Math.floor(diffMonths / 12)}y ago`;
|
|
365
|
-
}
|
|
69
|
+
if (!projectName) return <EmptyState msg="No project selected." />;
|
|
70
|
+
if (g.loading && !g.data) return <LoadingState />;
|
|
71
|
+
if (g.error && !g.data) return <ErrorState error={g.error} onRetry={g.fetchGraph} />;
|
|
366
72
|
|
|
367
73
|
return (
|
|
368
74
|
<div className="flex flex-col h-full">
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
<div className="flex-1 overflow-y-auto overflow-x-auto md:overflow-x-hidden">
|
|
392
|
-
<div className="flex min-w-max md:min-w-0" style={{ height: `${svgHeight}px` }}>
|
|
393
|
-
{/* Graph SVG column — sticky left with resize handle */}
|
|
394
|
-
<div
|
|
395
|
-
className="sticky left-0 z-10 shrink-0 bg-background"
|
|
396
|
-
style={{ width: `${graphColWidth}px` }}
|
|
397
|
-
>
|
|
398
|
-
<svg width={graphColWidth} height={svgHeight}>
|
|
399
|
-
{svgPaths.map((p, i) => (
|
|
400
|
-
<path
|
|
401
|
-
key={i}
|
|
402
|
-
d={p.d}
|
|
403
|
-
stroke={p.color}
|
|
404
|
-
strokeWidth={2}
|
|
405
|
-
fill="none"
|
|
406
|
-
/>
|
|
407
|
-
))}
|
|
408
|
-
{data?.commits.map((c, ci) => {
|
|
409
|
-
const cLane = laneMap.get(c.hash) ?? 0;
|
|
410
|
-
const cx = cLane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
411
|
-
const cy = ci * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
412
|
-
const cColor = LANE_COLORS[cLane % LANE_COLORS.length]!;
|
|
413
|
-
return (
|
|
414
|
-
<circle
|
|
415
|
-
key={c.hash}
|
|
416
|
-
cx={cx}
|
|
417
|
-
cy={cy}
|
|
418
|
-
r={NODE_RADIUS}
|
|
419
|
-
fill={cColor}
|
|
420
|
-
stroke="#0f1419"
|
|
421
|
-
strokeWidth={2}
|
|
422
|
-
/>
|
|
423
|
-
);
|
|
424
|
-
})}
|
|
425
|
-
</svg>
|
|
426
|
-
{/* Drag handle — always visible on mobile, hover on desktop */}
|
|
427
|
-
<div
|
|
428
|
-
className="absolute top-0 right-0 w-3 md:w-2 h-full cursor-col-resize hover:bg-primary/20 flex items-center justify-center bg-primary/10 md:bg-transparent"
|
|
429
|
-
onMouseDown={handleMouseDown}
|
|
430
|
-
onTouchStart={handleTouchStart}
|
|
431
|
-
>
|
|
432
|
-
<GripVertical className="size-3 text-muted-foreground md:opacity-0 md:hover:opacity-100" />
|
|
433
|
-
</div>
|
|
75
|
+
<GitGraphToolbar
|
|
76
|
+
branches={g.data?.branches ?? []} branchFilter={g.branchFilter}
|
|
77
|
+
onBranchFilterChange={g.setBranchFilter} searchQuery={g.searchQuery}
|
|
78
|
+
onSearchQueryChange={g.setSearchQuery} showSearch={g.showSearch}
|
|
79
|
+
onToggleSearch={() => g.setShowSearch(!g.showSearch)}
|
|
80
|
+
onFetch={g.fetchFromRemotes} onRefresh={g.fetchGraph}
|
|
81
|
+
onOpenSettings={() => setShowSettings(true)}
|
|
82
|
+
loading={g.loading} acting={g.acting} projectName={projectName}
|
|
83
|
+
/>
|
|
84
|
+
{g.error && <div className="px-3 py-1.5 text-xs text-destructive bg-destructive/10">{g.error}</div>}
|
|
85
|
+
|
|
86
|
+
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
|
|
87
|
+
<div className="flex min-w-max md:min-w-0">
|
|
88
|
+
{/* Graph SVG column — overflow-hidden clips lanes beyond column width */}
|
|
89
|
+
<div className="sticky left-0 z-10 shrink-0 bg-background relative overflow-hidden" style={{ width: `${graphColWidth}px` }}>
|
|
90
|
+
<div className="text-[11px] font-semibold text-muted-foreground px-2 border-b bg-background sticky top-0 z-20"
|
|
91
|
+
style={{ height: `${ROW_HEIGHT}px`, lineHeight: `${ROW_HEIGHT}px` }}>Graph</div>
|
|
92
|
+
<GitGraphSvg commits={g.filteredCommits} laneMap={g.filteredLanes.laneMap}
|
|
93
|
+
svgPaths={g.svgPaths} width={(g.filteredLanes.maxLane + 2) * LANE_WIDTH} height={g.svgHeight} headHash={g.headHash} />
|
|
94
|
+
<div className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-primary/30"
|
|
95
|
+
onMouseDown={(e) => { e.preventDefault(); startGraphResize(e.clientX); }}
|
|
96
|
+
onTouchStart={(e) => startGraphResize(e.touches[0]!.clientX)} />
|
|
434
97
|
</div>
|
|
435
98
|
|
|
436
|
-
{/* Commit
|
|
99
|
+
{/* Commit table */}
|
|
437
100
|
<div className="flex-1 min-w-[400px]">
|
|
438
|
-
{
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
<
|
|
447
|
-
<
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
{label.name}
|
|
477
|
-
</span>
|
|
478
|
-
))}
|
|
479
|
-
<span className="flex-1 truncate">{commit.subject}</span>
|
|
480
|
-
<span className="text-xs text-muted-foreground shrink-0 hidden sm:inline">
|
|
481
|
-
{commit.authorName}
|
|
482
|
-
</span>
|
|
483
|
-
<span className="text-xs text-muted-foreground shrink-0 w-14 text-right">
|
|
484
|
-
{relativeDate(commit.authorDate)}
|
|
485
|
-
</span>
|
|
486
|
-
</div>
|
|
487
|
-
</div>
|
|
488
|
-
</ContextMenuTrigger>
|
|
489
|
-
|
|
490
|
-
<ContextMenuContent>
|
|
491
|
-
<ContextMenuItem onClick={() => handleCheckout(commit.hash)}>
|
|
492
|
-
Checkout
|
|
493
|
-
</ContextMenuItem>
|
|
494
|
-
<ContextMenuItem
|
|
495
|
-
onClick={() => {
|
|
496
|
-
setDialogState({ type: "branch", hash: commit.hash });
|
|
497
|
-
setInputValue("");
|
|
498
|
-
}}
|
|
499
|
-
>
|
|
500
|
-
<GitBranch className="size-3" />
|
|
501
|
-
Create Branch...
|
|
502
|
-
</ContextMenuItem>
|
|
503
|
-
<ContextMenuSeparator />
|
|
504
|
-
<ContextMenuItem onClick={() => handleCherryPick(commit.hash)}>
|
|
505
|
-
<CherryIcon className="size-3" />
|
|
506
|
-
Cherry Pick
|
|
507
|
-
</ContextMenuItem>
|
|
508
|
-
<ContextMenuItem onClick={() => handleRevert(commit.hash)}>
|
|
509
|
-
<RotateCcw className="size-3" />
|
|
510
|
-
Revert
|
|
511
|
-
</ContextMenuItem>
|
|
512
|
-
<ContextMenuItem
|
|
513
|
-
onClick={() => {
|
|
514
|
-
setDialogState({ type: "tag", hash: commit.hash });
|
|
515
|
-
setInputValue("");
|
|
516
|
-
}}
|
|
517
|
-
>
|
|
518
|
-
<Tag className="size-3" />
|
|
519
|
-
Create Tag...
|
|
520
|
-
</ContextMenuItem>
|
|
521
|
-
<ContextMenuSeparator />
|
|
522
|
-
<ContextMenuItem onClick={() => openDiffForCommit(commit)}>
|
|
523
|
-
View Diff
|
|
524
|
-
</ContextMenuItem>
|
|
525
|
-
<ContextMenuItem onClick={() => copyHash(commit.hash)}>
|
|
526
|
-
<Copy className="size-3" />
|
|
527
|
-
Copy Hash
|
|
528
|
-
</ContextMenuItem>
|
|
529
|
-
</ContextMenuContent>
|
|
530
|
-
</ContextMenu>
|
|
531
|
-
);
|
|
532
|
-
})}
|
|
533
|
-
</div>
|
|
534
|
-
</div>
|
|
535
|
-
</div>
|
|
536
|
-
|
|
537
|
-
{/* Commit detail panel — like vscode-git-graph */}
|
|
538
|
-
{selectedCommit && (
|
|
539
|
-
<div className="border-t bg-muted/30 max-h-[40%] overflow-auto">
|
|
540
|
-
<div className="px-3 py-2 border-b flex items-center justify-between">
|
|
541
|
-
<span className="text-sm font-medium truncate">
|
|
542
|
-
{selectedCommit.abbreviatedHash} — {selectedCommit.subject}
|
|
543
|
-
</span>
|
|
544
|
-
<Button variant="ghost" size="icon-xs" onClick={() => setSelectedCommit(null)}>
|
|
545
|
-
✕
|
|
546
|
-
</Button>
|
|
547
|
-
</div>
|
|
548
|
-
<div className="px-3 py-2 text-xs space-y-1">
|
|
549
|
-
<div className="flex gap-4">
|
|
550
|
-
<span className="text-muted-foreground">Author</span>
|
|
551
|
-
<span>{selectedCommit.authorName} <{selectedCommit.authorEmail}></span>
|
|
552
|
-
</div>
|
|
553
|
-
<div className="flex gap-4">
|
|
554
|
-
<span className="text-muted-foreground">Date</span>
|
|
555
|
-
<span>{new Date(selectedCommit.authorDate).toLocaleString()}</span>
|
|
556
|
-
</div>
|
|
557
|
-
<div className="flex gap-4">
|
|
558
|
-
<span className="text-muted-foreground">Hash</span>
|
|
559
|
-
<span className="font-mono cursor-pointer hover:text-primary" onClick={() => copyHash(selectedCommit.hash)}>
|
|
560
|
-
{selectedCommit.hash}
|
|
561
|
-
</span>
|
|
562
|
-
</div>
|
|
563
|
-
{selectedCommit.parents.length > 0 && (
|
|
564
|
-
<div className="flex gap-4">
|
|
565
|
-
<span className="text-muted-foreground">Parents</span>
|
|
566
|
-
<span className="font-mono">{selectedCommit.parents.map(p => p.slice(0, 7)).join(", ")}</span>
|
|
101
|
+
<table className="w-full border-collapse text-xs" style={{ tableLayout: "fixed" }}>
|
|
102
|
+
<colgroup>
|
|
103
|
+
<col />
|
|
104
|
+
<col style={{ width: `${colW.date}px` }} />
|
|
105
|
+
<col style={{ width: `${colW.author}px` }} />
|
|
106
|
+
<col style={{ width: `${colW.commit}px` }} />
|
|
107
|
+
</colgroup>
|
|
108
|
+
<thead className="sticky top-0 z-10 bg-background">
|
|
109
|
+
<tr className="border-b text-[11px] font-semibold text-muted-foreground" style={{ height: `${ROW_HEIGHT}px` }}>
|
|
110
|
+
<th className="text-left px-2 font-semibold">Description</th>
|
|
111
|
+
<ResizableTh label="Date" colKey="date" onStartResize={startResize} />
|
|
112
|
+
<ResizableTh label="Author" colKey="author" onStartResize={startResize} />
|
|
113
|
+
<th className="text-left px-2 font-semibold">Commit</th>
|
|
114
|
+
</tr>
|
|
115
|
+
</thead>
|
|
116
|
+
<tbody>
|
|
117
|
+
{g.filteredCommits.map((commit) => (
|
|
118
|
+
<GitGraphRow key={commit.hash} commit={commit}
|
|
119
|
+
lane={g.filteredLanes.laneMap.get(commit.hash) ?? 0}
|
|
120
|
+
isSelected={g.selectedCommit?.hash === commit.hash}
|
|
121
|
+
isHead={commit.hash === g.headHash}
|
|
122
|
+
labels={g.commitLabels.get(commit.hash) ?? []}
|
|
123
|
+
currentBranch={g.currentBranch}
|
|
124
|
+
onSelect={() => g.selectCommit(commit)}
|
|
125
|
+
onCheckout={g.handleCheckout} onCherryPick={g.handleCherryPick}
|
|
126
|
+
onRevert={g.handleRevert} onMerge={g.handleMerge}
|
|
127
|
+
onDeleteBranch={g.handleDeleteBranch} onPushBranch={g.handlePushBranch}
|
|
128
|
+
onCreatePr={g.handleCreatePr}
|
|
129
|
+
onOpenCreateBranch={(h) => setDialogState({ type: "branch", hash: h })}
|
|
130
|
+
onOpenCreateTag={(h) => setDialogState({ type: "tag", hash: h })}
|
|
131
|
+
onOpenDiff={() => g.openDiffForCommit(commit)}
|
|
132
|
+
onCopyHash={() => g.copyHash(commit.hash)} />
|
|
133
|
+
))}
|
|
134
|
+
</tbody>
|
|
135
|
+
</table>
|
|
136
|
+
{g.loadingMore && (
|
|
137
|
+
<div className="flex items-center justify-center gap-2 py-3 text-xs text-muted-foreground">
|
|
138
|
+
<Loader2 className="size-3.5 animate-spin" /> Loading more commits...
|
|
567
139
|
</div>
|
|
568
140
|
)}
|
|
569
|
-
{
|
|
570
|
-
<div className="
|
|
571
|
-
{
|
|
141
|
+
{!g.hasMore && g.data && g.data.commits.length > 0 && (
|
|
142
|
+
<div className="text-center py-2 text-xs text-muted-foreground">
|
|
143
|
+
{g.data.commits.length} commits loaded
|
|
572
144
|
</div>
|
|
573
145
|
)}
|
|
574
146
|
</div>
|
|
575
|
-
{/* Changed files */}
|
|
576
|
-
<div className="px-3 py-1 border-t">
|
|
577
|
-
<div className="text-xs text-muted-foreground py-1">
|
|
578
|
-
{loadingDetail ? "Loading files..." : `${commitFiles.length} file${commitFiles.length !== 1 ? "s" : ""} changed`}
|
|
579
|
-
</div>
|
|
580
|
-
{commitFiles.map((file) => (
|
|
581
|
-
<div
|
|
582
|
-
key={file.path}
|
|
583
|
-
className="flex items-center gap-2 py-0.5 text-xs hover:bg-muted/50 rounded px-1 cursor-pointer"
|
|
584
|
-
onClick={() => openTab({
|
|
585
|
-
type: "git-diff",
|
|
586
|
-
title: `Diff ${basename(file.path)}`,
|
|
587
|
-
closable: true,
|
|
588
|
-
metadata: {
|
|
589
|
-
projectName,
|
|
590
|
-
ref1: selectedCommit.parents[0] ?? undefined,
|
|
591
|
-
ref2: selectedCommit.hash,
|
|
592
|
-
filePath: file.path,
|
|
593
|
-
},
|
|
594
|
-
projectId: projectName ?? null,
|
|
595
|
-
})}
|
|
596
|
-
>
|
|
597
|
-
<span className="flex-1 truncate font-mono">{file.path}</span>
|
|
598
|
-
{file.additions > 0 && <span className="text-green-500">+{file.additions}</span>}
|
|
599
|
-
{file.deletions > 0 && <span className="text-red-500">-{file.deletions}</span>}
|
|
600
|
-
</div>
|
|
601
|
-
))}
|
|
602
|
-
</div>
|
|
603
147
|
</div>
|
|
604
|
-
|
|
148
|
+
</div>
|
|
605
149
|
|
|
606
|
-
{
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
{dialogState.type === "branch" ? "Create Branch" : "Create Tag"}
|
|
617
|
-
</DialogTitle>
|
|
618
|
-
</DialogHeader>
|
|
619
|
-
<Input
|
|
620
|
-
placeholder={
|
|
621
|
-
dialogState.type === "branch" ? "Branch name" : "Tag name"
|
|
622
|
-
}
|
|
623
|
-
value={inputValue}
|
|
624
|
-
onChange={(e) => setInputValue(e.target.value)}
|
|
625
|
-
onKeyDown={(e) => {
|
|
626
|
-
if (e.key === "Enter" && inputValue.trim()) {
|
|
627
|
-
if (dialogState.type === "branch") {
|
|
628
|
-
handleCreateBranch(inputValue.trim(), dialogState.hash!);
|
|
629
|
-
} else {
|
|
630
|
-
handleCreateTag(inputValue.trim(), dialogState.hash);
|
|
631
|
-
}
|
|
632
|
-
setDialogState({ type: null });
|
|
633
|
-
}
|
|
634
|
-
}}
|
|
635
|
-
autoFocus
|
|
636
|
-
/>
|
|
637
|
-
<DialogFooter>
|
|
638
|
-
<Button
|
|
639
|
-
variant="outline"
|
|
640
|
-
onClick={() => setDialogState({ type: null })}
|
|
641
|
-
>
|
|
642
|
-
Cancel
|
|
643
|
-
</Button>
|
|
644
|
-
<Button
|
|
645
|
-
disabled={!inputValue.trim()}
|
|
646
|
-
onClick={() => {
|
|
647
|
-
if (dialogState.type === "branch") {
|
|
648
|
-
handleCreateBranch(inputValue.trim(), dialogState.hash!);
|
|
649
|
-
} else {
|
|
650
|
-
handleCreateTag(inputValue.trim(), dialogState.hash);
|
|
651
|
-
}
|
|
652
|
-
setDialogState({ type: null });
|
|
653
|
-
}}
|
|
654
|
-
>
|
|
655
|
-
Create
|
|
656
|
-
</Button>
|
|
657
|
-
</DialogFooter>
|
|
658
|
-
</DialogContent>
|
|
659
|
-
</Dialog>
|
|
150
|
+
{g.selectedCommit && projectName && (
|
|
151
|
+
<GitGraphDetail commit={g.selectedCommit} files={g.commitFiles}
|
|
152
|
+
loadingDetail={g.loadingDetail} projectName={projectName}
|
|
153
|
+
onClose={() => g.setSelectedCommit(null)} copyHash={g.copyHash} />
|
|
154
|
+
)}
|
|
155
|
+
<GitGraphDialog type={dialogState.type} hash={dialogState.hash}
|
|
156
|
+
onClose={() => setDialogState({ type: null })}
|
|
157
|
+
onCreateBranch={g.handleCreateBranch} onCreateTag={g.handleCreateTag} />
|
|
158
|
+
<GitGraphSettingsDialog open={showSettings} onClose={() => setShowSettings(false)}
|
|
159
|
+
projectName={projectName} branches={g.data?.branches ?? []} />
|
|
660
160
|
</div>
|
|
661
161
|
);
|
|
662
162
|
}
|
|
663
163
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
label,
|
|
667
|
-
color,
|
|
668
|
-
currentBranch,
|
|
669
|
-
onCheckout,
|
|
670
|
-
onMerge,
|
|
671
|
-
onPush,
|
|
672
|
-
onCreatePr,
|
|
673
|
-
onDelete,
|
|
674
|
-
}: {
|
|
675
|
-
label: { name: string; type: string };
|
|
676
|
-
color: string;
|
|
677
|
-
currentBranch: GitBranchType | undefined;
|
|
678
|
-
onCheckout: (ref: string) => void;
|
|
679
|
-
onMerge: (source: string) => void;
|
|
680
|
-
onPush: (branch: string) => void;
|
|
681
|
-
onCreatePr: (branch: string) => void;
|
|
682
|
-
onDelete: (name: string) => void;
|
|
164
|
+
function ResizableTh({ label, colKey, onStartResize }: {
|
|
165
|
+
label: string; colKey: string; onStartResize: (key: string, x: number) => void;
|
|
683
166
|
}) {
|
|
684
167
|
return (
|
|
685
|
-
<
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
</ContextMenuItem>
|
|
710
|
-
<ContextMenuSeparator />
|
|
711
|
-
<ContextMenuItem onClick={() => onPush(label.name)}>
|
|
712
|
-
<ArrowUpFromLine className="size-3" />
|
|
713
|
-
Push
|
|
714
|
-
</ContextMenuItem>
|
|
715
|
-
<ContextMenuItem onClick={() => onCreatePr(label.name)}>
|
|
716
|
-
<ExternalLink className="size-3" />
|
|
717
|
-
Create PR
|
|
718
|
-
</ContextMenuItem>
|
|
719
|
-
<ContextMenuSeparator />
|
|
720
|
-
<ContextMenuItem
|
|
721
|
-
variant="destructive"
|
|
722
|
-
onClick={() => onDelete(label.name)}
|
|
723
|
-
disabled={label.name === currentBranch?.name}
|
|
724
|
-
>
|
|
725
|
-
<Trash2 className="size-3" />
|
|
726
|
-
Delete
|
|
727
|
-
</ContextMenuItem>
|
|
728
|
-
</ContextMenuContent>
|
|
729
|
-
</ContextMenu>
|
|
168
|
+
<th className="text-left px-2 font-semibold relative">
|
|
169
|
+
{label}
|
|
170
|
+
<div className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-primary/30"
|
|
171
|
+
onMouseDown={(e) => { e.preventDefault(); onStartResize(colKey, e.clientX); }}
|
|
172
|
+
onTouchStart={(e) => onStartResize(colKey, e.touches[0]!.clientX)} />
|
|
173
|
+
</th>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function EmptyState({ msg }: { msg: string }) {
|
|
178
|
+
return <div className="flex items-center justify-center h-full text-muted-foreground text-sm">{msg}</div>;
|
|
179
|
+
}
|
|
180
|
+
function LoadingState() {
|
|
181
|
+
return (
|
|
182
|
+
<div className="flex items-center justify-center h-full gap-2 text-muted-foreground">
|
|
183
|
+
<Loader2 className="size-5 animate-spin" /><span className="text-sm">Loading git graph...</span>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
function ErrorState({ error, onRetry }: { error: string; onRetry: () => void }) {
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive text-sm">
|
|
190
|
+
<p>{error}</p><Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>
|
|
191
|
+
</div>
|
|
730
192
|
);
|
|
731
193
|
}
|