@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
  3. package/dist/web/assets/{code-editor-BMVhw78r.js → code-editor-BakDn6rL.js} +1 -1
  4. package/dist/web/assets/{database-viewer-IvsAIFVr.js → database-viewer-COaZMlpv.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-BEx8B1nH.js → diff-viewer-COSbmidI.js} +1 -1
  6. package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
  7. package/dist/web/assets/index-BGTzm7B1.js +28 -0
  8. package/dist/web/assets/index-CeNox-VV.css +2 -0
  9. package/dist/web/assets/input-CE3bFwLk.js +41 -0
  10. package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-CVyVjnji.js → markdown-renderer-BKgH2iGf.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-BluSPCxI.js → postgres-viewer-DBOv2ha2.js} +1 -1
  13. package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
  14. package/dist/web/assets/{sqlite-viewer-LdjnRshk.js → sqlite-viewer-BY242odW.js} +1 -1
  15. package/dist/web/assets/switch-BEmt1alu.js +1 -0
  16. package/dist/web/assets/{terminal-tab-DnTTRYK8.js → terminal-tab-BiUqECPk.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/providers/claude-agent-sdk.ts +108 -64
  21. package/src/providers/mock-provider.ts +1 -0
  22. package/src/providers/provider.interface.ts +1 -0
  23. package/src/server/routes/git.ts +16 -2
  24. package/src/server/ws/chat.ts +43 -26
  25. package/src/services/chat.service.ts +3 -1
  26. package/src/services/git.service.ts +45 -8
  27. package/src/types/api.ts +1 -1
  28. package/src/types/chat.ts +5 -0
  29. package/src/types/config.ts +21 -0
  30. package/src/types/git.ts +4 -0
  31. package/src/web/components/chat/chat-tab.tsx +26 -8
  32. package/src/web/components/chat/message-input.tsx +61 -1
  33. package/src/web/components/chat/message-list.tsx +9 -1
  34. package/src/web/components/chat/mode-selector.tsx +117 -0
  35. package/src/web/components/git/git-graph-branch-label.tsx +124 -0
  36. package/src/web/components/git/git-graph-constants.ts +185 -0
  37. package/src/web/components/git/git-graph-detail.tsx +107 -0
  38. package/src/web/components/git/git-graph-dialog.tsx +72 -0
  39. package/src/web/components/git/git-graph-row.tsx +167 -0
  40. package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
  41. package/src/web/components/git/git-graph-svg.tsx +54 -0
  42. package/src/web/components/git/git-graph-toolbar.tsx +195 -0
  43. package/src/web/components/git/git-graph.tsx +143 -681
  44. package/src/web/components/git/use-column-resize.ts +33 -0
  45. package/src/web/components/git/use-git-graph.ts +201 -0
  46. package/src/web/components/layout/cloud-share-popover.tsx +76 -26
  47. package/src/web/components/settings/ai-settings-section.tsx +42 -0
  48. package/src/web/hooks/use-chat.ts +3 -3
  49. package/src/web/lib/api-settings.ts +2 -0
  50. package/dist/web/assets/chat-tab-BNQmD9jh.js +0 -7
  51. package/dist/web/assets/git-graph-BdUhmJtk.js +0 -1
  52. package/dist/web/assets/index-M3zwguMz.css +0 -2
  53. package/dist/web/assets/index-y1G0ksWJ.js +0 -28
  54. package/dist/web/assets/input-CVIzrYsH.js +0 -41
  55. package/dist/web/assets/keybindings-store-BLrTeWmN.js +0 -1
  56. package/dist/web/assets/settings-tab-aOKIBhyp.js +0 -1
  57. package/dist/web/assets/switch-UODDpwuO.js +0 -1
@@ -1,49 +1,15 @@
1
- import { useEffect, useState, useCallback, useMemo, useRef } from "react";
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 { ScrollArea } from "@/components/ui/scroll-area";
21
- import {
22
- ContextMenu,
23
- ContextMenuContent,
24
- ContextMenuItem,
25
- ContextMenuSeparator,
26
- ContextMenuTrigger,
27
- } from "@/components/ui/context-menu";
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 [data, setData] = useState<GitGraphData | null>(null);
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 [inputValue, setInputValue] = useState("");
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
- const svgWidth = (maxLane + 1) * LANE_WIDTH + LANE_WIDTH;
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 defaultWidth = (isMobile ? 6 : 10) * LANE_WIDTH + LANE_WIDTH;
290
- const [graphColWidth, setGraphColWidth] = useState(defaultWidth);
291
- const isDragging = useRef(false);
292
-
293
- const handleDragStart = useCallback((startX: number) => {
294
- isDragging.current = true;
295
- const startW = graphColWidth;
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 (!isDragging.current) return;
298
- const clientX = "touches" in ev ? ev.touches[0]!.clientX : ev.clientX;
299
- setGraphColWidth(Math.max(40, startW + clientX - startX));
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
- isDragging.current = false;
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
- }, [graphColWidth]);
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
- if (loading && !data) {
332
- return (
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
- if (error && !data) {
341
- return (
342
- <div className="flex flex-col items-center justify-center h-full gap-2 text-destructive text-sm">
343
- <p>{error}</p>
344
- <Button variant="outline" size="sm" onClick={fetchGraph}>
345
- Retry
346
- </Button>
347
- </div>
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
- function relativeDate(dateStr: string): string {
352
- const date = new Date(dateStr);
353
- const now = new Date();
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
- {/* Header */}
370
- <div className="flex items-center justify-between px-3 py-2 border-b">
371
- <span className="text-sm font-medium">
372
- Git Graph{currentBranch ? ` - ${currentBranch.name}` : ""}
373
- </span>
374
- <Button
375
- variant="ghost"
376
- size="icon-xs"
377
- onClick={fetchGraph}
378
- disabled={acting}
379
- >
380
- <RefreshCw className={loading ? "animate-spin" : ""} />
381
- </Button>
382
- </div>
383
-
384
- {error && (
385
- <div className="px-3 py-1.5 text-xs text-destructive bg-destructive/10">
386
- {error}
387
- </div>
388
- )}
389
-
390
- {/* Scrollable graph + commit list: mobile scrolls both, desktop only vertical */}
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 rows */}
99
+ {/* Commit table */}
437
100
  <div className="flex-1 min-w-[400px]">
438
- {data?.commits.map((commit, idx) => {
439
- const lane = laneMap.get(commit.hash) ?? 0;
440
- const color = LANE_COLORS[lane % LANE_COLORS.length]!;
441
- const labels = commitLabels.get(commit.hash) ?? [];
442
- const branchLabels = labels.filter((l) => l.type === "branch");
443
- const tagLabels = labels.filter((l) => l.type === "tag");
444
-
445
- return (
446
- <ContextMenu key={commit.hash}>
447
- <ContextMenuTrigger asChild>
448
- <div
449
- className={`flex items-center hover:bg-muted/50 cursor-pointer text-sm border-b border-border/30 ${selectedCommit?.hash === commit.hash ? "bg-primary/10" : ""}`}
450
- style={{ height: `${ROW_HEIGHT}px` }}
451
- onClick={() => selectCommit(commit)}
452
- >
453
- <div className="flex items-center gap-2 flex-1 min-w-0 px-2">
454
- <span className="font-mono text-xs text-muted-foreground w-14 shrink-0">
455
- {commit.abbreviatedHash}
456
- </span>
457
- {branchLabels.map((label) => (
458
- <BranchLabel
459
- key={`branch-${label.name}`}
460
- label={label}
461
- color={color}
462
- currentBranch={currentBranch}
463
- onCheckout={handleCheckout}
464
- onMerge={handleMerge}
465
- onPush={handlePushBranch}
466
- onCreatePr={handleCreatePr}
467
- onDelete={handleDeleteBranch}
468
- />
469
- ))}
470
- {tagLabels.map((label) => (
471
- <span
472
- key={`tag-${label.name}`}
473
- className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 bg-amber-500/20 text-amber-500 border border-amber-500/30"
474
- >
475
- <Tag className="size-2.5" />
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} &lt;{selectedCommit.authorEmail}&gt;</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
- {selectedCommit.body && (
570
- <div className="mt-2 p-2 bg-background rounded text-xs whitespace-pre-wrap">
571
- {selectedCommit.body}
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
- {/* Create Branch/Tag Dialog */}
607
- <Dialog
608
- open={dialogState.type !== null}
609
- onOpenChange={(open) => {
610
- if (!open) setDialogState({ type: null });
611
- }}
612
- >
613
- <DialogContent>
614
- <DialogHeader>
615
- <DialogTitle>
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
- /** Branch label with its own context menu */
665
- function BranchLabel({
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
- <ContextMenu>
686
- <ContextMenuTrigger asChild>
687
- <span
688
- className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 cursor-context-menu"
689
- style={{
690
- backgroundColor: `${color}30`,
691
- color,
692
- border: `1px solid ${color}50`,
693
- }}
694
- >
695
- <GitBranch className="size-2.5" />
696
- {label.name}
697
- </span>
698
- </ContextMenuTrigger>
699
- <ContextMenuContent>
700
- <ContextMenuItem onClick={() => onCheckout(label.name)}>
701
- Checkout
702
- </ContextMenuItem>
703
- <ContextMenuItem
704
- onClick={() => onMerge(label.name)}
705
- disabled={label.name === currentBranch?.name}
706
- >
707
- <GitMerge className="size-3" />
708
- Merge into current
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
  }