@hienlh/ppm 0.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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,727 @@
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 { useTabStore } from "@/stores/tab-store";
18
+ import { Button } from "@/components/ui/button";
19
+ import { ScrollArea } from "@/components/ui/scroll-area";
20
+ import {
21
+ ContextMenu,
22
+ ContextMenuContent,
23
+ ContextMenuItem,
24
+ ContextMenuSeparator,
25
+ ContextMenuTrigger,
26
+ } from "@/components/ui/context-menu";
27
+ import {
28
+ Dialog,
29
+ DialogContent,
30
+ DialogHeader,
31
+ DialogTitle,
32
+ DialogFooter,
33
+ } from "@/components/ui/dialog";
34
+ import { Input } from "@/components/ui/input";
35
+ import type { GitGraphData, GitCommit, GitBranch as GitBranchType } from "../../../types/git";
36
+
37
+ const LANE_COLORS = [
38
+ "#4fc3f7", "#81c784", "#ffb74d", "#e57373",
39
+ "#ba68c8", "#4dd0e1", "#aed581", "#ff8a65",
40
+ "#f06292", "#7986cb",
41
+ ];
42
+
43
+ const ROW_HEIGHT = 32;
44
+ const LANE_WIDTH = 20;
45
+ const NODE_RADIUS = 5;
46
+
47
+ interface GitGraphProps {
48
+ metadata?: Record<string, unknown>;
49
+ }
50
+
51
+ export function GitGraph({ metadata }: GitGraphProps) {
52
+ const projectName = metadata?.projectName as string | undefined;
53
+ const [data, setData] = useState<GitGraphData | null>(null);
54
+ const [loading, setLoading] = useState(true);
55
+ const [error, setError] = useState<string | null>(null);
56
+ const [acting, setActing] = useState(false);
57
+ const [dialogState, setDialogState] = useState<{
58
+ type: "branch" | "tag" | null;
59
+ hash?: string;
60
+ }>({ type: null });
61
+ const [inputValue, setInputValue] = useState("");
62
+ const [selectedCommit, setSelectedCommit] = useState<GitCommit | null>(null);
63
+ const [commitFiles, setCommitFiles] = useState<Array<{ path: string; additions: number; deletions: number }>>([]);
64
+ const [loadingDetail, setLoadingDetail] = useState(false);
65
+ const { openTab } = useTabStore();
66
+
67
+ const fetchGraph = useCallback(async () => {
68
+ if (!projectName) return;
69
+ try {
70
+ setLoading(true);
71
+ const result = await api.get<GitGraphData>(
72
+ `${projectUrl(projectName)}/git/graph?max=200`,
73
+ );
74
+ setData(result);
75
+ setError(null);
76
+ } catch (e) {
77
+ setError(e instanceof Error ? e.message : "Failed to fetch graph");
78
+ } finally {
79
+ setLoading(false);
80
+ }
81
+ }, [projectName]);
82
+
83
+ useEffect(() => {
84
+ fetchGraph();
85
+ }, [fetchGraph]);
86
+
87
+ const gitAction = async (
88
+ path: string,
89
+ body: Record<string, unknown>,
90
+ ) => {
91
+ if (!projectName) return;
92
+ setActing(true);
93
+ try {
94
+ await api.post(`${projectUrl(projectName)}${path}`, body);
95
+ await fetchGraph();
96
+ } catch (e) {
97
+ setError(e instanceof Error ? e.message : "Action failed");
98
+ } finally {
99
+ setActing(false);
100
+ }
101
+ };
102
+
103
+ const handleCheckout = (ref: string) =>
104
+ gitAction("/git/checkout", { ref });
105
+ const handleCherryPick = (hash: string) =>
106
+ gitAction("/git/cherry-pick", { hash });
107
+ const handleRevert = (hash: string) =>
108
+ gitAction("/git/revert", { hash });
109
+ const handleMerge = (source: string) =>
110
+ gitAction("/git/merge", { source });
111
+ const handleDeleteBranch = (name: string) =>
112
+ gitAction("/git/branch/delete", { name });
113
+ const handlePushBranch = (branch: string) =>
114
+ gitAction("/git/push", { branch });
115
+ const handleCreateBranch = async (name: string, from: string) => {
116
+ // Check if branch already exists
117
+ const exists = data?.branches.some((b) => b.name === name || b.name.endsWith(`/${name}`));
118
+ if (exists) {
119
+ const confirmed = window.confirm(
120
+ `Branch "${name}" already exists.\nDelete it and recreate from this commit?`,
121
+ );
122
+ if (!confirmed) return;
123
+ // Delete first, then recreate
124
+ await gitAction("/git/branch/delete", { name });
125
+ }
126
+ await gitAction("/git/branch/create", { name, from });
127
+ };
128
+ const handleCreateTag = (name: string, hash?: string) =>
129
+ gitAction("/git/tag", { name, hash });
130
+
131
+ const handleCreatePr = async (branch: string) => {
132
+ if (!projectName) return;
133
+ try {
134
+ const result = await api.get<{ url: string | null }>(
135
+ `${projectUrl(projectName)}/git/pr-url?branch=${encodeURIComponent(branch)}`,
136
+ );
137
+ if (result.url) {
138
+ window.open(result.url, "_blank");
139
+ }
140
+ } catch {
141
+ // silent
142
+ }
143
+ };
144
+
145
+ const copyHash = (hash: string) => {
146
+ navigator.clipboard.writeText(hash);
147
+ };
148
+
149
+ const selectCommit = async (commit: GitCommit) => {
150
+ if (selectedCommit?.hash === commit.hash) {
151
+ setSelectedCommit(null);
152
+ return;
153
+ }
154
+ setSelectedCommit(commit);
155
+ setLoadingDetail(true);
156
+ try {
157
+ const parent = commit.parents[0] ?? "";
158
+ // For root commits (no parent), diff against empty tree
159
+ const ref1Param = parent ? `ref1=${encodeURIComponent(parent)}&` : "";
160
+ const files = await api.get<Array<{ path: string; additions: number; deletions: number }>>(
161
+ `${projectUrl(projectName!)}/git/diff-stat?${ref1Param}ref2=${encodeURIComponent(commit.hash)}`,
162
+ );
163
+ setCommitFiles(Array.isArray(files) ? files : []);
164
+ } catch (e) {
165
+ console.error("diff-stat error:", e);
166
+ setCommitFiles([]);
167
+ } finally {
168
+ setLoadingDetail(false);
169
+ }
170
+ };
171
+
172
+ const openDiffForCommit = (commit: GitCommit) => {
173
+ const ref1 = commit.parents[0];
174
+ openTab({
175
+ type: "git-diff",
176
+ title: `Diff ${commit.abbreviatedHash}`,
177
+ closable: true,
178
+ metadata: {
179
+ projectName,
180
+ ref1: ref1 ?? undefined,
181
+ ref2: commit.hash,
182
+ },
183
+ projectId: projectName ?? null,
184
+ });
185
+ };
186
+
187
+ // Lane assignment algorithm
188
+ const { laneMap, maxLane } = useMemo(() => {
189
+ const map = new Map<string, number>();
190
+ if (!data) return { laneMap: map, maxLane: 0 };
191
+
192
+ let nextLane = 0;
193
+ const activeLanes = new Map<string, number>();
194
+
195
+ for (const commit of data.commits) {
196
+ let lane = activeLanes.get(commit.hash);
197
+ if (lane === undefined) {
198
+ lane = nextLane++;
199
+ }
200
+ map.set(commit.hash, lane);
201
+ activeLanes.delete(commit.hash);
202
+
203
+ for (let i = 0; i < commit.parents.length; i++) {
204
+ const parent = commit.parents[i]!;
205
+ if (!activeLanes.has(parent)) {
206
+ activeLanes.set(parent, i === 0 ? lane : nextLane++);
207
+ }
208
+ }
209
+ }
210
+ return { laneMap: map, maxLane: Math.max(nextLane - 1, 0) };
211
+ }, [data]);
212
+
213
+ const currentBranch = data?.branches.find((b) => b.current);
214
+
215
+ // Build commit -> branch/tag label map
216
+ const commitLabels = useMemo(() => {
217
+ const labels = new Map<string, Array<{ name: string; type: "branch" | "tag" }>>();
218
+ if (!data) return labels;
219
+ for (const branch of data.branches) {
220
+ const arr = labels.get(branch.commitHash) ?? [];
221
+ arr.push({ name: branch.name, type: "branch" });
222
+ labels.set(branch.commitHash, arr);
223
+ }
224
+ for (const commit of data.commits) {
225
+ for (const ref of commit.refs) {
226
+ if (ref.startsWith("tag: ")) {
227
+ const tagName = ref.replace("tag: ", "");
228
+ const arr = labels.get(commit.hash) ?? [];
229
+ arr.push({ name: tagName, type: "tag" });
230
+ labels.set(commit.hash, arr);
231
+ }
232
+ }
233
+ }
234
+ return labels;
235
+ }, [data]);
236
+
237
+ // Build SVG paths for connections
238
+ const svgPaths = useMemo(() => {
239
+ if (!data) return [];
240
+ const paths: Array<{ d: string; color: string }> = [];
241
+
242
+ for (let idx = 0; idx < data.commits.length; idx++) {
243
+ const commit = data.commits[idx]!;
244
+ const lane = laneMap.get(commit.hash) ?? 0;
245
+ const color = LANE_COLORS[lane % LANE_COLORS.length]!;
246
+
247
+ for (const parentHash of commit.parents) {
248
+ const parentIdx = data.commits.findIndex((c) => c.hash === parentHash);
249
+ if (parentIdx < 0) continue;
250
+ const parentLane = laneMap.get(parentHash) ?? 0;
251
+ const parentColor = LANE_COLORS[parentLane % LANE_COLORS.length]!;
252
+
253
+ const x1 = lane * LANE_WIDTH + LANE_WIDTH / 2;
254
+ const y1 = idx * ROW_HEIGHT + ROW_HEIGHT / 2;
255
+ const x2 = parentLane * LANE_WIDTH + LANE_WIDTH / 2;
256
+ const y2 = parentIdx * ROW_HEIGHT + ROW_HEIGHT / 2;
257
+
258
+ let d: string;
259
+ const isMerge = commit.parents.indexOf(parentHash) > 0;
260
+ if (x1 === x2) {
261
+ // Same lane: straight line
262
+ d = `M ${x1} ${y1} L ${x2} ${y2}`;
263
+ } else if (isMerge) {
264
+ // Merge: curve at child (top), straight down to parent
265
+ const curveEnd = y1 + ROW_HEIGHT;
266
+ d = `M ${x1} ${y1} C ${x1} ${curveEnd} ${x2} ${y1} ${x2} ${curveEnd} L ${x2} ${y2}`;
267
+ } else {
268
+ // Branch/fork: straight down from child, curve at parent (bottom)
269
+ const curveStart = y2 - ROW_HEIGHT;
270
+ d = `M ${x1} ${y1} L ${x1} ${curveStart} C ${x1} ${y2} ${x2} ${curveStart} ${x2} ${y2}`;
271
+ }
272
+ // Use parent color for merge lines, commit color for first parent
273
+ const lineColor = commit.parents.indexOf(parentHash) === 0 ? color : parentColor;
274
+ paths.push({ d, color: lineColor });
275
+ }
276
+ }
277
+ return paths;
278
+ }, [data, laneMap]);
279
+
280
+ const svgWidth = (maxLane + 1) * LANE_WIDTH + LANE_WIDTH;
281
+ const svgHeight = (data?.commits.length ?? 0) * ROW_HEIGHT;
282
+
283
+ // Resizable graph column — default: 6 lanes mobile, 10 lanes desktop
284
+ const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
285
+ const defaultWidth = (isMobile ? 6 : 10) * LANE_WIDTH + LANE_WIDTH;
286
+ const [graphColWidth, setGraphColWidth] = useState(defaultWidth);
287
+ const isDragging = useRef(false);
288
+
289
+ const handleDragStart = useCallback((startX: number) => {
290
+ isDragging.current = true;
291
+ const startW = graphColWidth;
292
+ const onMove = (ev: MouseEvent | TouchEvent) => {
293
+ if (!isDragging.current) return;
294
+ const clientX = "touches" in ev ? ev.touches[0]!.clientX : ev.clientX;
295
+ setGraphColWidth(Math.max(40, startW + clientX - startX));
296
+ };
297
+ const onUp = () => {
298
+ isDragging.current = false;
299
+ window.removeEventListener("mousemove", onMove);
300
+ window.removeEventListener("mouseup", onUp);
301
+ window.removeEventListener("touchmove", onMove);
302
+ window.removeEventListener("touchend", onUp);
303
+ };
304
+ window.addEventListener("mousemove", onMove);
305
+ window.addEventListener("mouseup", onUp);
306
+ window.addEventListener("touchmove", onMove, { passive: false });
307
+ window.addEventListener("touchend", onUp);
308
+ }, [graphColWidth]);
309
+
310
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
311
+ e.preventDefault();
312
+ handleDragStart(e.clientX);
313
+ }, [handleDragStart]);
314
+
315
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
316
+ handleDragStart(e.touches[0]!.clientX);
317
+ }, [handleDragStart]);
318
+
319
+ if (!projectName) {
320
+ return (
321
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
322
+ No project selected.
323
+ </div>
324
+ );
325
+ }
326
+
327
+ if (loading && !data) {
328
+ return (
329
+ <div className="flex items-center justify-center h-full gap-2 text-muted-foreground">
330
+ <Loader2 className="size-5 animate-spin" />
331
+ <span className="text-sm">Loading git graph...</span>
332
+ </div>
333
+ );
334
+ }
335
+
336
+ if (error && !data) {
337
+ return (
338
+ <div className="flex flex-col items-center justify-center h-full gap-2 text-destructive text-sm">
339
+ <p>{error}</p>
340
+ <Button variant="outline" size="sm" onClick={fetchGraph}>
341
+ Retry
342
+ </Button>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ function relativeDate(dateStr: string): string {
348
+ const date = new Date(dateStr);
349
+ const now = new Date();
350
+ const diffMs = now.getTime() - date.getTime();
351
+ const diffMins = Math.floor(diffMs / 60000);
352
+ if (diffMins < 1) return "just now";
353
+ if (diffMins < 60) return `${diffMins}m ago`;
354
+ const diffHours = Math.floor(diffMins / 60);
355
+ if (diffHours < 24) return `${diffHours}h ago`;
356
+ const diffDays = Math.floor(diffHours / 24);
357
+ if (diffDays < 30) return `${diffDays}d ago`;
358
+ const diffMonths = Math.floor(diffDays / 30);
359
+ if (diffMonths < 12) return `${diffMonths}mo ago`;
360
+ return `${Math.floor(diffMonths / 12)}y ago`;
361
+ }
362
+
363
+ return (
364
+ <div className="flex flex-col h-full">
365
+ {/* Header */}
366
+ <div className="flex items-center justify-between px-3 py-2 border-b">
367
+ <span className="text-sm font-medium">
368
+ Git Graph{currentBranch ? ` - ${currentBranch.name}` : ""}
369
+ </span>
370
+ <Button
371
+ variant="ghost"
372
+ size="icon-xs"
373
+ onClick={fetchGraph}
374
+ disabled={acting}
375
+ >
376
+ <RefreshCw className={loading ? "animate-spin" : ""} />
377
+ </Button>
378
+ </div>
379
+
380
+ {error && (
381
+ <div className="px-3 py-1.5 text-xs text-destructive bg-destructive/10">
382
+ {error}
383
+ </div>
384
+ )}
385
+
386
+ {/* Scrollable graph + commit list: mobile scrolls both, desktop only vertical */}
387
+ <div className="flex-1 overflow-y-auto overflow-x-auto md:overflow-x-hidden">
388
+ <div className="flex min-w-max md:min-w-0" style={{ height: `${svgHeight}px` }}>
389
+ {/* Graph SVG column — sticky left with resize handle */}
390
+ <div
391
+ className="sticky left-0 z-10 shrink-0 bg-background"
392
+ style={{ width: `${graphColWidth}px` }}
393
+ >
394
+ <svg width={graphColWidth} height={svgHeight}>
395
+ {svgPaths.map((p, i) => (
396
+ <path
397
+ key={i}
398
+ d={p.d}
399
+ stroke={p.color}
400
+ strokeWidth={2}
401
+ fill="none"
402
+ />
403
+ ))}
404
+ {data?.commits.map((c, ci) => {
405
+ const cLane = laneMap.get(c.hash) ?? 0;
406
+ const cx = cLane * LANE_WIDTH + LANE_WIDTH / 2;
407
+ const cy = ci * ROW_HEIGHT + ROW_HEIGHT / 2;
408
+ const cColor = LANE_COLORS[cLane % LANE_COLORS.length]!;
409
+ return (
410
+ <circle
411
+ key={c.hash}
412
+ cx={cx}
413
+ cy={cy}
414
+ r={NODE_RADIUS}
415
+ fill={cColor}
416
+ stroke="#0f1419"
417
+ strokeWidth={2}
418
+ />
419
+ );
420
+ })}
421
+ </svg>
422
+ {/* Drag handle — always visible on mobile, hover on desktop */}
423
+ <div
424
+ 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"
425
+ onMouseDown={handleMouseDown}
426
+ onTouchStart={handleTouchStart}
427
+ >
428
+ <GripVertical className="size-3 text-muted-foreground md:opacity-0 md:hover:opacity-100" />
429
+ </div>
430
+ </div>
431
+
432
+ {/* Commit rows */}
433
+ <div className="flex-1 min-w-[400px]">
434
+ {data?.commits.map((commit, idx) => {
435
+ const lane = laneMap.get(commit.hash) ?? 0;
436
+ const color = LANE_COLORS[lane % LANE_COLORS.length]!;
437
+ const labels = commitLabels.get(commit.hash) ?? [];
438
+ const branchLabels = labels.filter((l) => l.type === "branch");
439
+ const tagLabels = labels.filter((l) => l.type === "tag");
440
+
441
+ return (
442
+ <ContextMenu key={commit.hash}>
443
+ <ContextMenuTrigger asChild>
444
+ <div
445
+ className={`flex items-center hover:bg-muted/50 cursor-pointer text-sm border-b border-border/30 ${selectedCommit?.hash === commit.hash ? "bg-primary/10" : ""}`}
446
+ style={{ height: `${ROW_HEIGHT}px` }}
447
+ onClick={() => selectCommit(commit)}
448
+ >
449
+ <div className="flex items-center gap-2 flex-1 min-w-0 px-2">
450
+ <span className="font-mono text-xs text-muted-foreground w-14 shrink-0">
451
+ {commit.abbreviatedHash}
452
+ </span>
453
+ {branchLabels.map((label) => (
454
+ <BranchLabel
455
+ key={`branch-${label.name}`}
456
+ label={label}
457
+ color={color}
458
+ currentBranch={currentBranch}
459
+ onCheckout={handleCheckout}
460
+ onMerge={handleMerge}
461
+ onPush={handlePushBranch}
462
+ onCreatePr={handleCreatePr}
463
+ onDelete={handleDeleteBranch}
464
+ />
465
+ ))}
466
+ {tagLabels.map((label) => (
467
+ <span
468
+ key={`tag-${label.name}`}
469
+ 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"
470
+ >
471
+ <Tag className="size-2.5" />
472
+ {label.name}
473
+ </span>
474
+ ))}
475
+ <span className="flex-1 truncate">{commit.subject}</span>
476
+ <span className="text-xs text-muted-foreground shrink-0 hidden sm:inline">
477
+ {commit.authorName}
478
+ </span>
479
+ <span className="text-xs text-muted-foreground shrink-0 w-14 text-right">
480
+ {relativeDate(commit.authorDate)}
481
+ </span>
482
+ </div>
483
+ </div>
484
+ </ContextMenuTrigger>
485
+
486
+ <ContextMenuContent>
487
+ <ContextMenuItem onClick={() => handleCheckout(commit.hash)}>
488
+ Checkout
489
+ </ContextMenuItem>
490
+ <ContextMenuItem
491
+ onClick={() => {
492
+ setDialogState({ type: "branch", hash: commit.hash });
493
+ setInputValue("");
494
+ }}
495
+ >
496
+ <GitBranch className="size-3" />
497
+ Create Branch...
498
+ </ContextMenuItem>
499
+ <ContextMenuSeparator />
500
+ <ContextMenuItem onClick={() => handleCherryPick(commit.hash)}>
501
+ <CherryIcon className="size-3" />
502
+ Cherry Pick
503
+ </ContextMenuItem>
504
+ <ContextMenuItem onClick={() => handleRevert(commit.hash)}>
505
+ <RotateCcw className="size-3" />
506
+ Revert
507
+ </ContextMenuItem>
508
+ <ContextMenuItem
509
+ onClick={() => {
510
+ setDialogState({ type: "tag", hash: commit.hash });
511
+ setInputValue("");
512
+ }}
513
+ >
514
+ <Tag className="size-3" />
515
+ Create Tag...
516
+ </ContextMenuItem>
517
+ <ContextMenuSeparator />
518
+ <ContextMenuItem onClick={() => openDiffForCommit(commit)}>
519
+ View Diff
520
+ </ContextMenuItem>
521
+ <ContextMenuItem onClick={() => copyHash(commit.hash)}>
522
+ <Copy className="size-3" />
523
+ Copy Hash
524
+ </ContextMenuItem>
525
+ </ContextMenuContent>
526
+ </ContextMenu>
527
+ );
528
+ })}
529
+ </div>
530
+ </div>
531
+ </div>
532
+
533
+ {/* Commit detail panel — like vscode-git-graph */}
534
+ {selectedCommit && (
535
+ <div className="border-t bg-muted/30 max-h-[40%] overflow-auto">
536
+ <div className="px-3 py-2 border-b flex items-center justify-between">
537
+ <span className="text-sm font-medium truncate">
538
+ {selectedCommit.abbreviatedHash} — {selectedCommit.subject}
539
+ </span>
540
+ <Button variant="ghost" size="icon-xs" onClick={() => setSelectedCommit(null)}>
541
+
542
+ </Button>
543
+ </div>
544
+ <div className="px-3 py-2 text-xs space-y-1">
545
+ <div className="flex gap-4">
546
+ <span className="text-muted-foreground">Author</span>
547
+ <span>{selectedCommit.authorName} &lt;{selectedCommit.authorEmail}&gt;</span>
548
+ </div>
549
+ <div className="flex gap-4">
550
+ <span className="text-muted-foreground">Date</span>
551
+ <span>{new Date(selectedCommit.authorDate).toLocaleString()}</span>
552
+ </div>
553
+ <div className="flex gap-4">
554
+ <span className="text-muted-foreground">Hash</span>
555
+ <span className="font-mono cursor-pointer hover:text-primary" onClick={() => copyHash(selectedCommit.hash)}>
556
+ {selectedCommit.hash}
557
+ </span>
558
+ </div>
559
+ {selectedCommit.parents.length > 0 && (
560
+ <div className="flex gap-4">
561
+ <span className="text-muted-foreground">Parents</span>
562
+ <span className="font-mono">{selectedCommit.parents.map(p => p.slice(0, 7)).join(", ")}</span>
563
+ </div>
564
+ )}
565
+ {selectedCommit.body && (
566
+ <div className="mt-2 p-2 bg-background rounded text-xs whitespace-pre-wrap">
567
+ {selectedCommit.body}
568
+ </div>
569
+ )}
570
+ </div>
571
+ {/* Changed files */}
572
+ <div className="px-3 py-1 border-t">
573
+ <div className="text-xs text-muted-foreground py-1">
574
+ {loadingDetail ? "Loading files..." : `${commitFiles.length} file${commitFiles.length !== 1 ? "s" : ""} changed`}
575
+ </div>
576
+ {commitFiles.map((file) => (
577
+ <div
578
+ key={file.path}
579
+ className="flex items-center gap-2 py-0.5 text-xs hover:bg-muted/50 rounded px-1 cursor-pointer"
580
+ onClick={() => openTab({
581
+ type: "git-diff",
582
+ title: `Diff ${file.path.split("/").pop()}`,
583
+ closable: true,
584
+ metadata: {
585
+ projectName,
586
+ ref1: selectedCommit.parents[0] ?? undefined,
587
+ ref2: selectedCommit.hash,
588
+ filePath: file.path,
589
+ },
590
+ projectId: projectName ?? null,
591
+ })}
592
+ >
593
+ <span className="flex-1 truncate font-mono">{file.path}</span>
594
+ {file.additions > 0 && <span className="text-green-500">+{file.additions}</span>}
595
+ {file.deletions > 0 && <span className="text-red-500">-{file.deletions}</span>}
596
+ </div>
597
+ ))}
598
+ </div>
599
+ </div>
600
+ )}
601
+
602
+ {/* Create Branch/Tag Dialog */}
603
+ <Dialog
604
+ open={dialogState.type !== null}
605
+ onOpenChange={(open) => {
606
+ if (!open) setDialogState({ type: null });
607
+ }}
608
+ >
609
+ <DialogContent>
610
+ <DialogHeader>
611
+ <DialogTitle>
612
+ {dialogState.type === "branch" ? "Create Branch" : "Create Tag"}
613
+ </DialogTitle>
614
+ </DialogHeader>
615
+ <Input
616
+ placeholder={
617
+ dialogState.type === "branch" ? "Branch name" : "Tag name"
618
+ }
619
+ value={inputValue}
620
+ onChange={(e) => setInputValue(e.target.value)}
621
+ onKeyDown={(e) => {
622
+ if (e.key === "Enter" && inputValue.trim()) {
623
+ if (dialogState.type === "branch") {
624
+ handleCreateBranch(inputValue.trim(), dialogState.hash!);
625
+ } else {
626
+ handleCreateTag(inputValue.trim(), dialogState.hash);
627
+ }
628
+ setDialogState({ type: null });
629
+ }
630
+ }}
631
+ autoFocus
632
+ />
633
+ <DialogFooter>
634
+ <Button
635
+ variant="outline"
636
+ onClick={() => setDialogState({ type: null })}
637
+ >
638
+ Cancel
639
+ </Button>
640
+ <Button
641
+ disabled={!inputValue.trim()}
642
+ onClick={() => {
643
+ if (dialogState.type === "branch") {
644
+ handleCreateBranch(inputValue.trim(), dialogState.hash!);
645
+ } else {
646
+ handleCreateTag(inputValue.trim(), dialogState.hash);
647
+ }
648
+ setDialogState({ type: null });
649
+ }}
650
+ >
651
+ Create
652
+ </Button>
653
+ </DialogFooter>
654
+ </DialogContent>
655
+ </Dialog>
656
+ </div>
657
+ );
658
+ }
659
+
660
+ /** Branch label with its own context menu */
661
+ function BranchLabel({
662
+ label,
663
+ color,
664
+ currentBranch,
665
+ onCheckout,
666
+ onMerge,
667
+ onPush,
668
+ onCreatePr,
669
+ onDelete,
670
+ }: {
671
+ label: { name: string; type: string };
672
+ color: string;
673
+ currentBranch: GitBranchType | undefined;
674
+ onCheckout: (ref: string) => void;
675
+ onMerge: (source: string) => void;
676
+ onPush: (branch: string) => void;
677
+ onCreatePr: (branch: string) => void;
678
+ onDelete: (name: string) => void;
679
+ }) {
680
+ return (
681
+ <ContextMenu>
682
+ <ContextMenuTrigger asChild>
683
+ <span
684
+ className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 cursor-context-menu"
685
+ style={{
686
+ backgroundColor: `${color}30`,
687
+ color,
688
+ border: `1px solid ${color}50`,
689
+ }}
690
+ >
691
+ <GitBranch className="size-2.5" />
692
+ {label.name}
693
+ </span>
694
+ </ContextMenuTrigger>
695
+ <ContextMenuContent>
696
+ <ContextMenuItem onClick={() => onCheckout(label.name)}>
697
+ Checkout
698
+ </ContextMenuItem>
699
+ <ContextMenuItem
700
+ onClick={() => onMerge(label.name)}
701
+ disabled={label.name === currentBranch?.name}
702
+ >
703
+ <GitMerge className="size-3" />
704
+ Merge into current
705
+ </ContextMenuItem>
706
+ <ContextMenuSeparator />
707
+ <ContextMenuItem onClick={() => onPush(label.name)}>
708
+ <ArrowUpFromLine className="size-3" />
709
+ Push
710
+ </ContextMenuItem>
711
+ <ContextMenuItem onClick={() => onCreatePr(label.name)}>
712
+ <ExternalLink className="size-3" />
713
+ Create PR
714
+ </ContextMenuItem>
715
+ <ContextMenuSeparator />
716
+ <ContextMenuItem
717
+ variant="destructive"
718
+ onClick={() => onDelete(label.name)}
719
+ disabled={label.name === currentBranch?.name}
720
+ >
721
+ <Trash2 className="size-3" />
722
+ Delete
723
+ </ContextMenuItem>
724
+ </ContextMenuContent>
725
+ </ContextMenu>
726
+ );
727
+ }