@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.
- package/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- 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} <{selectedCommit.authorEmail}></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
|
+
}
|