@hienlh/ppm 0.7.41 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
- package/dist/web/assets/{code-editor-CaKnPjkU.js → code-editor-BakDn6rL.js} +1 -1
- package/dist/web/assets/{database-viewer-DUAq3r2M.js → database-viewer-COaZMlpv.js} +1 -1
- package/dist/web/assets/{diff-viewer-C6w7tDMN.js → diff-viewer-COSbmidI.js} +1 -1
- package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
- package/dist/web/assets/index-BGTzm7B1.js +28 -0
- package/dist/web/assets/index-CeNox-VV.css +2 -0
- package/dist/web/assets/input-CE3bFwLk.js +41 -0
- package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
- package/dist/web/assets/{markdown-renderer-Ckj0mfYc.js → markdown-renderer-BKgH2iGf.js} +1 -1
- package/dist/web/assets/{postgres-viewer-m6qNfnAF.js → postgres-viewer-DBOv2ha2.js} +1 -1
- package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
- package/dist/web/assets/{sqlite-viewer-6d233-2k.js → sqlite-viewer-BY242odW.js} +1 -1
- package/dist/web/assets/switch-BEmt1alu.js +1 -0
- package/dist/web/assets/{terminal-tab-BaHGzGJ6.js → terminal-tab-BiUqECPk.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +168 -71
- package/src/providers/mock-provider.ts +1 -0
- package/src/providers/provider.interface.ts +1 -0
- package/src/server/routes/git.ts +16 -2
- package/src/server/ws/chat.ts +43 -26
- package/src/services/account-selector.service.ts +18 -2
- package/src/services/chat.service.ts +3 -1
- package/src/services/git.service.ts +45 -8
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +7 -1
- package/src/types/config.ts +21 -0
- package/src/types/git.ts +4 -0
- package/src/web/components/chat/chat-tab.tsx +26 -8
- package/src/web/components/chat/message-input.tsx +61 -1
- package/src/web/components/chat/message-list.tsx +9 -1
- package/src/web/components/chat/mode-selector.tsx +117 -0
- package/src/web/components/git/git-graph-branch-label.tsx +124 -0
- package/src/web/components/git/git-graph-constants.ts +185 -0
- package/src/web/components/git/git-graph-detail.tsx +107 -0
- package/src/web/components/git/git-graph-dialog.tsx +72 -0
- package/src/web/components/git/git-graph-row.tsx +167 -0
- package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
- package/src/web/components/git/git-graph-svg.tsx +54 -0
- package/src/web/components/git/git-graph-toolbar.tsx +195 -0
- package/src/web/components/git/git-graph.tsx +143 -681
- package/src/web/components/git/use-column-resize.ts +33 -0
- package/src/web/components/git/use-git-graph.ts +201 -0
- package/src/web/components/settings/ai-settings-section.tsx +42 -0
- package/src/web/hooks/use-chat.ts +3 -3
- package/src/web/lib/api-settings.ts +2 -0
- package/dist/web/assets/chat-tab-BoeC0a0w.js +0 -7
- package/dist/web/assets/git-graph-9GFTfA5p.js +0 -1
- package/dist/web/assets/index-CSS8Cy7l.css +0 -2
- package/dist/web/assets/index-CetGEOKq.js +0 -28
- package/dist/web/assets/input-CVIzrYsH.js +0 -41
- package/dist/web/assets/keybindings-store-DiEM7YZ4.js +0 -1
- package/dist/web/assets/settings-tab-Di-E48kC.js +0 -1
- package/dist/web/assets/switch-UODDpwuO.js +0 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { GitGraphData } from "../../../types/git";
|
|
2
|
+
|
|
3
|
+
export const LANE_COLORS = [
|
|
4
|
+
"#0085d9", "#d73a49", "#6f42c1", "#2cbe4e", "#e36209",
|
|
5
|
+
"#005cc5", "#b31d28", "#5a32a3", "#22863a", "#cb2431",
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const ROW_HEIGHT = 24;
|
|
9
|
+
export const LANE_WIDTH = 16;
|
|
10
|
+
export const NODE_RADIUS = 4;
|
|
11
|
+
|
|
12
|
+
/** Build commit → branch/tag label map (shows local + remote-only branches) */
|
|
13
|
+
export function buildCommitLabels(data: GitGraphData | null) {
|
|
14
|
+
const labels = new Map<string, Array<{ name: string; type: "branch" | "tag"; remotes: string[]; current: boolean }>>();
|
|
15
|
+
if (!data) return labels;
|
|
16
|
+
|
|
17
|
+
// Collect which remote branches are already covered by a local branch
|
|
18
|
+
const coveredRemotes = new Set<string>();
|
|
19
|
+
for (const branch of data.branches) {
|
|
20
|
+
if (!branch.remote) {
|
|
21
|
+
for (const remote of branch.remotes) {
|
|
22
|
+
coveredRemotes.add(`remotes/${remote}/${branch.name}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const branch of data.branches) {
|
|
28
|
+
if (branch.remote) {
|
|
29
|
+
// Show remote-only branches (not covered by a local branch)
|
|
30
|
+
if (coveredRemotes.has(branch.name)) continue;
|
|
31
|
+
const arr = labels.get(branch.commitHash) ?? [];
|
|
32
|
+
// Display as "remote/branch" (strip "remotes/" prefix)
|
|
33
|
+
const displayName = branch.name.replace(/^remotes\//, "");
|
|
34
|
+
arr.push({ name: displayName, type: "branch", remotes: [], current: false });
|
|
35
|
+
labels.set(branch.commitHash, arr);
|
|
36
|
+
} else {
|
|
37
|
+
const arr = labels.get(branch.commitHash) ?? [];
|
|
38
|
+
arr.push({ name: branch.name, type: "branch", remotes: branch.remotes, current: branch.current });
|
|
39
|
+
labels.set(branch.commitHash, arr);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const commit of data.commits) {
|
|
44
|
+
for (const ref of commit.refs) {
|
|
45
|
+
if (ref.startsWith("tag: ")) {
|
|
46
|
+
const tagName = ref.replace("tag: ", "");
|
|
47
|
+
const arr = labels.get(commit.hash) ?? [];
|
|
48
|
+
arr.push({ name: tagName, type: "tag", remotes: [], current: false });
|
|
49
|
+
labels.set(commit.hash, arr);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return labels;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Lane assignment algorithm — recycles freed lanes to keep graph compact */
|
|
57
|
+
export function computeLanes(data: GitGraphData | null) {
|
|
58
|
+
const map = new Map<string, number>();
|
|
59
|
+
if (!data) return { laneMap: map, maxLane: 0, unloadedParentLanes: new Map<string, number>() };
|
|
60
|
+
|
|
61
|
+
let nextLane = 0;
|
|
62
|
+
let maxLaneUsed = 0;
|
|
63
|
+
const activeLanes = new Map<string, number>();
|
|
64
|
+
const commitSet = new Set(data.commits.map((c) => c.hash));
|
|
65
|
+
const freeLanes: number[] = [];
|
|
66
|
+
|
|
67
|
+
const allocLane = () => {
|
|
68
|
+
if (freeLanes.length > 0) {
|
|
69
|
+
freeLanes.sort((a, b) => a - b);
|
|
70
|
+
return freeLanes.shift()!;
|
|
71
|
+
}
|
|
72
|
+
return nextLane++;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const commit of data.commits) {
|
|
76
|
+
let lane = activeLanes.get(commit.hash);
|
|
77
|
+
if (lane === undefined) lane = allocLane();
|
|
78
|
+
map.set(commit.hash, lane);
|
|
79
|
+
if (lane > maxLaneUsed) maxLaneUsed = lane;
|
|
80
|
+
activeLanes.delete(commit.hash);
|
|
81
|
+
|
|
82
|
+
let laneReused = false;
|
|
83
|
+
for (let i = 0; i < commit.parents.length; i++) {
|
|
84
|
+
const parent = commit.parents[i]!;
|
|
85
|
+
if (!activeLanes.has(parent)) {
|
|
86
|
+
if (i === 0) {
|
|
87
|
+
activeLanes.set(parent, lane);
|
|
88
|
+
laneReused = true;
|
|
89
|
+
} else {
|
|
90
|
+
const newLane = allocLane();
|
|
91
|
+
activeLanes.set(parent, newLane);
|
|
92
|
+
if (newLane > maxLaneUsed) maxLaneUsed = newLane;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!laneReused) freeLanes.push(lane);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const unloadedParentLanes = new Map<string, number>();
|
|
100
|
+
for (const [hash, lane] of activeLanes) {
|
|
101
|
+
if (!commitSet.has(hash)) unloadedParentLanes.set(hash, lane);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { laneMap: map, maxLane: maxLaneUsed, unloadedParentLanes };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build SVG paths for connections (including unloaded parent extension) */
|
|
108
|
+
export function computeSvgPaths(
|
|
109
|
+
data: GitGraphData | null,
|
|
110
|
+
laneMap: Map<string, number>,
|
|
111
|
+
unloadedParentLanes: Map<string, number>,
|
|
112
|
+
totalHeight: number,
|
|
113
|
+
) {
|
|
114
|
+
if (!data) return [];
|
|
115
|
+
const paths: Array<{ d: string; color: string }> = [];
|
|
116
|
+
const commitSet = new Set(data.commits.map((c) => c.hash));
|
|
117
|
+
|
|
118
|
+
for (let idx = 0; idx < data.commits.length; idx++) {
|
|
119
|
+
const commit = data.commits[idx]!;
|
|
120
|
+
const lane = laneMap.get(commit.hash) ?? 0;
|
|
121
|
+
const color = LANE_COLORS[lane % LANE_COLORS.length]!;
|
|
122
|
+
|
|
123
|
+
for (const parentHash of commit.parents) {
|
|
124
|
+
const parentIdx = data.commits.findIndex((c) => c.hash === parentHash);
|
|
125
|
+
|
|
126
|
+
if (parentIdx >= 0) {
|
|
127
|
+
// Parent is loaded — draw connection
|
|
128
|
+
const parentLane = laneMap.get(parentHash) ?? 0;
|
|
129
|
+
const parentColor = LANE_COLORS[parentLane % LANE_COLORS.length]!;
|
|
130
|
+
const x1 = lane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
131
|
+
const y1 = idx * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
132
|
+
const x2 = parentLane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
133
|
+
const y2 = parentIdx * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
134
|
+
|
|
135
|
+
let d: string;
|
|
136
|
+
const isMerge = commit.parents.indexOf(parentHash) > 0;
|
|
137
|
+
if (x1 === x2) {
|
|
138
|
+
d = `M ${x1} ${y1} L ${x2} ${y2}`;
|
|
139
|
+
} else if (isMerge) {
|
|
140
|
+
const curveEnd = y1 + ROW_HEIGHT;
|
|
141
|
+
d = `M ${x1} ${y1} C ${x1} ${curveEnd} ${x2} ${y1} ${x2} ${curveEnd} L ${x2} ${y2}`;
|
|
142
|
+
} else {
|
|
143
|
+
const curveStart = y2 - ROW_HEIGHT;
|
|
144
|
+
d = `M ${x1} ${y1} L ${x1} ${curveStart} C ${x1} ${y2} ${x2} ${curveStart} ${x2} ${y2}`;
|
|
145
|
+
}
|
|
146
|
+
const lineColor = commit.parents.indexOf(parentHash) === 0 ? color : parentColor;
|
|
147
|
+
paths.push({ d, color: lineColor });
|
|
148
|
+
} else if (!commitSet.has(parentHash)) {
|
|
149
|
+
// Parent NOT loaded — use the parent's assigned lane for the extension line
|
|
150
|
+
const parentLane = unloadedParentLanes.get(parentHash) ?? lane;
|
|
151
|
+
const parentColor = LANE_COLORS[parentLane % LANE_COLORS.length]!;
|
|
152
|
+
const x1 = lane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
153
|
+
const y1 = idx * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
154
|
+
const x2 = parentLane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
155
|
+
|
|
156
|
+
if (x1 === x2) {
|
|
157
|
+
// Same lane — straight down
|
|
158
|
+
paths.push({ d: `M ${x1} ${y1} L ${x1} ${totalHeight}`, color });
|
|
159
|
+
} else {
|
|
160
|
+
// Different lane — curve then straight down
|
|
161
|
+
const curveEnd = y1 + ROW_HEIGHT;
|
|
162
|
+
const d = `M ${x1} ${y1} C ${x1} ${curveEnd} ${x2} ${y1} ${x2} ${curveEnd} L ${x2} ${totalHeight}`;
|
|
163
|
+
paths.push({ d, color: parentColor });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return paths;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function relativeDate(dateStr: string): string {
|
|
172
|
+
const date = new Date(dateStr);
|
|
173
|
+
const now = new Date();
|
|
174
|
+
const diffMs = now.getTime() - date.getTime();
|
|
175
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
176
|
+
if (diffMins < 1) return "just now";
|
|
177
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
178
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
179
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
180
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
181
|
+
if (diffDays < 30) return `${diffDays}d ago`;
|
|
182
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
183
|
+
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
|
184
|
+
return `${Math.floor(diffMonths / 12)}y ago`;
|
|
185
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
3
|
+
import { basename } from "@/lib/utils";
|
|
4
|
+
import type { GitCommit } from "../../../types/git";
|
|
5
|
+
|
|
6
|
+
interface GitGraphDetailProps {
|
|
7
|
+
commit: GitCommit;
|
|
8
|
+
files: Array<{ path: string; additions: number; deletions: number }>;
|
|
9
|
+
loadingDetail: boolean;
|
|
10
|
+
projectName: string;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
copyHash: (hash: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function GitGraphDetail({
|
|
16
|
+
commit,
|
|
17
|
+
files,
|
|
18
|
+
loadingDetail,
|
|
19
|
+
projectName,
|
|
20
|
+
onClose,
|
|
21
|
+
copyHash,
|
|
22
|
+
}: GitGraphDetailProps) {
|
|
23
|
+
const { openTab } = useTabStore();
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="border-t bg-muted/30 max-h-[40%] overflow-auto">
|
|
27
|
+
<div className="px-3 py-2 border-b flex items-center justify-between">
|
|
28
|
+
<span className="text-sm font-medium truncate">
|
|
29
|
+
{commit.abbreviatedHash} — {commit.subject}
|
|
30
|
+
</span>
|
|
31
|
+
<Button variant="ghost" size="icon-xs" onClick={onClose}>
|
|
32
|
+
✕
|
|
33
|
+
</Button>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="px-3 py-2 text-xs space-y-1">
|
|
36
|
+
<div className="flex gap-4">
|
|
37
|
+
<span className="text-muted-foreground w-12 shrink-0">Author</span>
|
|
38
|
+
<span>
|
|
39
|
+
{commit.authorName} <{commit.authorEmail}>
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
<div className="flex gap-4">
|
|
43
|
+
<span className="text-muted-foreground w-12 shrink-0">Date</span>
|
|
44
|
+
<span>{new Date(commit.authorDate).toLocaleString()}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="flex gap-4">
|
|
47
|
+
<span className="text-muted-foreground w-12 shrink-0">Hash</span>
|
|
48
|
+
<span
|
|
49
|
+
className="font-mono cursor-pointer hover:text-primary"
|
|
50
|
+
onClick={() => copyHash(commit.hash)}
|
|
51
|
+
>
|
|
52
|
+
{commit.hash}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
{commit.parents.length > 0 && (
|
|
56
|
+
<div className="flex gap-4">
|
|
57
|
+
<span className="text-muted-foreground w-12 shrink-0">Parents</span>
|
|
58
|
+
<span className="font-mono">
|
|
59
|
+
{commit.parents.map((p) => p.slice(0, 7)).join(", ")}
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
{commit.body && (
|
|
64
|
+
<div className="mt-2 p-2 bg-background rounded text-xs whitespace-pre-wrap">
|
|
65
|
+
{commit.body}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
{/* Changed files */}
|
|
70
|
+
<div className="px-3 py-1 border-t">
|
|
71
|
+
<div className="text-xs text-muted-foreground py-1">
|
|
72
|
+
{loadingDetail
|
|
73
|
+
? "Loading files..."
|
|
74
|
+
: `${files.length} file${files.length !== 1 ? "s" : ""} changed`}
|
|
75
|
+
</div>
|
|
76
|
+
{files.map((file) => (
|
|
77
|
+
<div
|
|
78
|
+
key={file.path}
|
|
79
|
+
className="flex items-center gap-2 py-0.5 text-xs hover:bg-muted/50 rounded px-1 cursor-pointer"
|
|
80
|
+
onClick={() =>
|
|
81
|
+
openTab({
|
|
82
|
+
type: "git-diff",
|
|
83
|
+
title: `Diff ${basename(file.path)}`,
|
|
84
|
+
closable: true,
|
|
85
|
+
metadata: {
|
|
86
|
+
projectName,
|
|
87
|
+
ref1: commit.parents[0] ?? undefined,
|
|
88
|
+
ref2: commit.hash,
|
|
89
|
+
filePath: file.path,
|
|
90
|
+
},
|
|
91
|
+
projectId: projectName,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<span className="flex-1 truncate font-mono">{file.path}</span>
|
|
96
|
+
{file.additions > 0 && (
|
|
97
|
+
<span className="text-green-500">+{file.additions}</span>
|
|
98
|
+
)}
|
|
99
|
+
{file.deletions > 0 && (
|
|
100
|
+
<span className="text-red-500">-{file.deletions}</span>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogHeader,
|
|
6
|
+
DialogTitle,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
} from "@/components/ui/dialog";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
|
|
12
|
+
interface GitGraphDialogProps {
|
|
13
|
+
type: "branch" | "tag" | null;
|
|
14
|
+
hash?: string;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onCreateBranch: (name: string, from: string) => void;
|
|
17
|
+
onCreateTag: (name: string, hash?: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function GitGraphDialog({
|
|
21
|
+
type,
|
|
22
|
+
hash,
|
|
23
|
+
onClose,
|
|
24
|
+
onCreateBranch,
|
|
25
|
+
onCreateTag,
|
|
26
|
+
}: GitGraphDialogProps) {
|
|
27
|
+
const [inputValue, setInputValue] = useState("");
|
|
28
|
+
|
|
29
|
+
const handleSubmit = () => {
|
|
30
|
+
if (!inputValue.trim()) return;
|
|
31
|
+
if (type === "branch") {
|
|
32
|
+
onCreateBranch(inputValue.trim(), hash!);
|
|
33
|
+
} else {
|
|
34
|
+
onCreateTag(inputValue.trim(), hash);
|
|
35
|
+
}
|
|
36
|
+
onClose();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Dialog
|
|
41
|
+
open={type !== null}
|
|
42
|
+
onOpenChange={(open) => {
|
|
43
|
+
if (!open) onClose();
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<DialogContent>
|
|
47
|
+
<DialogHeader>
|
|
48
|
+
<DialogTitle>
|
|
49
|
+
{type === "branch" ? "Create Branch" : "Create Tag"}
|
|
50
|
+
</DialogTitle>
|
|
51
|
+
</DialogHeader>
|
|
52
|
+
<Input
|
|
53
|
+
placeholder={type === "branch" ? "Branch name" : "Tag name"}
|
|
54
|
+
value={inputValue}
|
|
55
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
56
|
+
onKeyDown={(e) => {
|
|
57
|
+
if (e.key === "Enter") handleSubmit();
|
|
58
|
+
}}
|
|
59
|
+
autoFocus
|
|
60
|
+
/>
|
|
61
|
+
<DialogFooter>
|
|
62
|
+
<Button variant="outline" onClick={onClose}>
|
|
63
|
+
Cancel
|
|
64
|
+
</Button>
|
|
65
|
+
<Button disabled={!inputValue.trim()} onClick={handleSubmit}>
|
|
66
|
+
Create
|
|
67
|
+
</Button>
|
|
68
|
+
</DialogFooter>
|
|
69
|
+
</DialogContent>
|
|
70
|
+
</Dialog>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GitBranch as GitBranchIcon,
|
|
3
|
+
Tag,
|
|
4
|
+
Copy,
|
|
5
|
+
RotateCcw,
|
|
6
|
+
CherryIcon,
|
|
7
|
+
} from "lucide-react";
|
|
8
|
+
import {
|
|
9
|
+
ContextMenu,
|
|
10
|
+
ContextMenuContent,
|
|
11
|
+
ContextMenuItem,
|
|
12
|
+
ContextMenuSeparator,
|
|
13
|
+
ContextMenuTrigger,
|
|
14
|
+
} from "@/components/ui/context-menu";
|
|
15
|
+
import { BranchLabel } from "./git-graph-branch-label";
|
|
16
|
+
import { LANE_COLORS, ROW_HEIGHT, relativeDate } from "./git-graph-constants";
|
|
17
|
+
import type { GitCommit, GitBranch } from "../../../types/git";
|
|
18
|
+
|
|
19
|
+
interface CommitLabel {
|
|
20
|
+
name: string;
|
|
21
|
+
type: "branch" | "tag";
|
|
22
|
+
remotes: string[];
|
|
23
|
+
current: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface GitGraphRowProps {
|
|
27
|
+
commit: GitCommit;
|
|
28
|
+
lane: number;
|
|
29
|
+
isSelected: boolean;
|
|
30
|
+
isHead: boolean;
|
|
31
|
+
labels: CommitLabel[];
|
|
32
|
+
currentBranch: GitBranch | undefined;
|
|
33
|
+
onSelect: () => void;
|
|
34
|
+
onCheckout: (ref: string) => void;
|
|
35
|
+
onCherryPick: (hash: string) => void;
|
|
36
|
+
onRevert: (hash: string) => void;
|
|
37
|
+
onMerge: (source: string) => void;
|
|
38
|
+
onDeleteBranch: (name: string) => void;
|
|
39
|
+
onPushBranch: (branch: string) => void;
|
|
40
|
+
onCreatePr: (branch: string) => void;
|
|
41
|
+
onOpenCreateBranch: (hash: string) => void;
|
|
42
|
+
onOpenCreateTag: (hash: string) => void;
|
|
43
|
+
onOpenDiff: () => void;
|
|
44
|
+
onCopyHash: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function GitGraphRow({
|
|
48
|
+
commit,
|
|
49
|
+
lane,
|
|
50
|
+
isSelected,
|
|
51
|
+
isHead,
|
|
52
|
+
labels,
|
|
53
|
+
currentBranch,
|
|
54
|
+
onSelect,
|
|
55
|
+
onCheckout,
|
|
56
|
+
onCherryPick,
|
|
57
|
+
onRevert,
|
|
58
|
+
onMerge,
|
|
59
|
+
onDeleteBranch,
|
|
60
|
+
onPushBranch,
|
|
61
|
+
onCreatePr,
|
|
62
|
+
onOpenCreateBranch,
|
|
63
|
+
onOpenCreateTag,
|
|
64
|
+
onOpenDiff,
|
|
65
|
+
onCopyHash,
|
|
66
|
+
}: GitGraphRowProps) {
|
|
67
|
+
const color = LANE_COLORS[lane % LANE_COLORS.length]!;
|
|
68
|
+
const branchLabels = labels.filter((l) => l.type === "branch");
|
|
69
|
+
const tagLabels = labels.filter((l) => l.type === "tag");
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<ContextMenu>
|
|
73
|
+
<ContextMenuTrigger asChild>
|
|
74
|
+
<tr
|
|
75
|
+
className={`hover:bg-muted/50 cursor-pointer border-b border-border/20 ${
|
|
76
|
+
isSelected ? "bg-primary/10" : ""
|
|
77
|
+
} ${isHead ? "font-medium" : ""}`}
|
|
78
|
+
style={{ height: `${ROW_HEIGHT}px` }}
|
|
79
|
+
onClick={onSelect}
|
|
80
|
+
>
|
|
81
|
+
{/* Description column */}
|
|
82
|
+
<td className="px-2 truncate max-w-0">
|
|
83
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
84
|
+
{branchLabels.map((label) => (
|
|
85
|
+
<BranchLabel
|
|
86
|
+
key={`branch-${label.name}`}
|
|
87
|
+
name={label.name}
|
|
88
|
+
type="branch"
|
|
89
|
+
remotes={label.remotes}
|
|
90
|
+
isCurrent={label.current}
|
|
91
|
+
color={color}
|
|
92
|
+
currentBranch={currentBranch}
|
|
93
|
+
onCheckout={onCheckout}
|
|
94
|
+
onMerge={onMerge}
|
|
95
|
+
onPush={onPushBranch}
|
|
96
|
+
onCreatePr={onCreatePr}
|
|
97
|
+
onDelete={onDeleteBranch}
|
|
98
|
+
/>
|
|
99
|
+
))}
|
|
100
|
+
{tagLabels.map((label) => (
|
|
101
|
+
<BranchLabel
|
|
102
|
+
key={`tag-${label.name}`}
|
|
103
|
+
name={label.name}
|
|
104
|
+
type="tag"
|
|
105
|
+
remotes={[]}
|
|
106
|
+
isCurrent={false}
|
|
107
|
+
color={color}
|
|
108
|
+
currentBranch={currentBranch}
|
|
109
|
+
onCheckout={onCheckout}
|
|
110
|
+
onMerge={onMerge}
|
|
111
|
+
onPush={onPushBranch}
|
|
112
|
+
onCreatePr={onCreatePr}
|
|
113
|
+
onDelete={onDeleteBranch}
|
|
114
|
+
/>
|
|
115
|
+
))}
|
|
116
|
+
<span className="truncate text-xs">{commit.subject}</span>
|
|
117
|
+
</div>
|
|
118
|
+
</td>
|
|
119
|
+
|
|
120
|
+
{/* Date column */}
|
|
121
|
+
<td className="px-2 text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
|
122
|
+
{relativeDate(commit.authorDate)}
|
|
123
|
+
</td>
|
|
124
|
+
|
|
125
|
+
{/* Author column */}
|
|
126
|
+
<td className="px-2 text-xs text-muted-foreground truncate max-w-[120px]">
|
|
127
|
+
{commit.authorName}
|
|
128
|
+
</td>
|
|
129
|
+
|
|
130
|
+
{/* Commit hash column (last) */}
|
|
131
|
+
<td className="px-2 text-xs text-muted-foreground font-mono whitespace-nowrap">
|
|
132
|
+
{commit.abbreviatedHash}
|
|
133
|
+
</td>
|
|
134
|
+
</tr>
|
|
135
|
+
</ContextMenuTrigger>
|
|
136
|
+
|
|
137
|
+
<ContextMenuContent>
|
|
138
|
+
<ContextMenuItem onClick={() => onCheckout(commit.hash)}>
|
|
139
|
+
Checkout
|
|
140
|
+
</ContextMenuItem>
|
|
141
|
+
<ContextMenuItem onClick={() => onOpenCreateBranch(commit.hash)}>
|
|
142
|
+
<GitBranchIcon className="size-3" />
|
|
143
|
+
Create Branch...
|
|
144
|
+
</ContextMenuItem>
|
|
145
|
+
<ContextMenuSeparator />
|
|
146
|
+
<ContextMenuItem onClick={() => onCherryPick(commit.hash)}>
|
|
147
|
+
<CherryIcon className="size-3" />
|
|
148
|
+
Cherry Pick
|
|
149
|
+
</ContextMenuItem>
|
|
150
|
+
<ContextMenuItem onClick={() => onRevert(commit.hash)}>
|
|
151
|
+
<RotateCcw className="size-3" />
|
|
152
|
+
Revert
|
|
153
|
+
</ContextMenuItem>
|
|
154
|
+
<ContextMenuItem onClick={() => onOpenCreateTag(commit.hash)}>
|
|
155
|
+
<Tag className="size-3" />
|
|
156
|
+
Create Tag...
|
|
157
|
+
</ContextMenuItem>
|
|
158
|
+
<ContextMenuSeparator />
|
|
159
|
+
<ContextMenuItem onClick={onOpenDiff}>View Diff</ContextMenuItem>
|
|
160
|
+
<ContextMenuItem onClick={onCopyHash}>
|
|
161
|
+
<Copy className="size-3" />
|
|
162
|
+
Copy Hash
|
|
163
|
+
</ContextMenuItem>
|
|
164
|
+
</ContextMenuContent>
|
|
165
|
+
</ContextMenu>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Dialog,
|
|
3
|
+
DialogContent,
|
|
4
|
+
DialogHeader,
|
|
5
|
+
DialogTitle,
|
|
6
|
+
} from "@/components/ui/dialog";
|
|
7
|
+
import type { GitBranch } from "../../../types/git";
|
|
8
|
+
|
|
9
|
+
interface GitGraphSettingsDialogProps {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
projectName: string;
|
|
13
|
+
branches: GitBranch[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function GitGraphSettingsDialog({
|
|
17
|
+
open,
|
|
18
|
+
onClose,
|
|
19
|
+
projectName,
|
|
20
|
+
branches,
|
|
21
|
+
}: GitGraphSettingsDialogProps) {
|
|
22
|
+
const localBranches = branches.filter((b) => !b.remote);
|
|
23
|
+
const remoteBranches = branches.filter((b) => b.remote);
|
|
24
|
+
|
|
25
|
+
// Extract unique remotes from remote branch names
|
|
26
|
+
const remotes = new Map<string, string[]>();
|
|
27
|
+
for (const b of remoteBranches) {
|
|
28
|
+
const stripped = b.name.replace(/^remotes\//, "");
|
|
29
|
+
const slashIdx = stripped.indexOf("/");
|
|
30
|
+
if (slashIdx < 0) continue;
|
|
31
|
+
const remoteName = stripped.slice(0, slashIdx);
|
|
32
|
+
const branchName = stripped.slice(slashIdx + 1);
|
|
33
|
+
const arr = remotes.get(remoteName) ?? [];
|
|
34
|
+
arr.push(branchName);
|
|
35
|
+
remotes.set(remoteName, arr);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
40
|
+
<DialogContent className="max-w-md">
|
|
41
|
+
<DialogHeader>
|
|
42
|
+
<DialogTitle>Repository Settings</DialogTitle>
|
|
43
|
+
</DialogHeader>
|
|
44
|
+
<div className="space-y-4 text-sm">
|
|
45
|
+
{/* General */}
|
|
46
|
+
<Section title="General">
|
|
47
|
+
<Row label="Name" value={projectName} />
|
|
48
|
+
<Row label="Branches" value={`${localBranches.length} local, ${remoteBranches.length} remote`} />
|
|
49
|
+
</Section>
|
|
50
|
+
|
|
51
|
+
{/* Local branches */}
|
|
52
|
+
<Section title="Local Branches">
|
|
53
|
+
{localBranches.map((b) => (
|
|
54
|
+
<div key={b.name} className="flex items-center gap-2 py-0.5">
|
|
55
|
+
<span className={`text-xs ${b.current ? "font-semibold text-primary" : "text-foreground"}`}>
|
|
56
|
+
{b.name}
|
|
57
|
+
</span>
|
|
58
|
+
{b.current && <span className="text-[10px] text-muted-foreground italic">HEAD</span>}
|
|
59
|
+
{b.remotes.length > 0 && (
|
|
60
|
+
<span className="text-[10px] text-muted-foreground">
|
|
61
|
+
({b.remotes.join(", ")})
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</Section>
|
|
67
|
+
|
|
68
|
+
{/* Remotes */}
|
|
69
|
+
<Section title="Remotes">
|
|
70
|
+
{[...remotes.entries()].map(([name, rBranches]) => (
|
|
71
|
+
<div key={name} className="py-0.5">
|
|
72
|
+
<span className="text-xs font-medium">{name}</span>
|
|
73
|
+
<span className="text-[10px] text-muted-foreground ml-2">
|
|
74
|
+
{rBranches.length} branch{rBranches.length !== 1 ? "es" : ""}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
{remotes.size === 0 && (
|
|
79
|
+
<span className="text-xs text-muted-foreground">No remotes configured</span>
|
|
80
|
+
)}
|
|
81
|
+
</Section>
|
|
82
|
+
</div>
|
|
83
|
+
</DialogContent>
|
|
84
|
+
</Dialog>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
89
|
+
return (
|
|
90
|
+
<div>
|
|
91
|
+
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">{title}</h4>
|
|
92
|
+
<div className="pl-1">{children}</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Row({ label, value }: { label: string; value: string }) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex items-center gap-3 py-0.5">
|
|
100
|
+
<span className="text-xs text-muted-foreground w-16 shrink-0">{label}</span>
|
|
101
|
+
<span className="text-xs">{value}</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { LANE_COLORS, LANE_WIDTH, ROW_HEIGHT, NODE_RADIUS } from "./git-graph-constants";
|
|
2
|
+
import type { GitCommit } from "../../../types/git";
|
|
3
|
+
|
|
4
|
+
interface GitGraphSvgProps {
|
|
5
|
+
commits: GitCommit[];
|
|
6
|
+
laneMap: Map<string, number>;
|
|
7
|
+
svgPaths: Array<{ d: string; color: string }>;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
headHash: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function GitGraphSvg({
|
|
14
|
+
commits,
|
|
15
|
+
laneMap,
|
|
16
|
+
svgPaths,
|
|
17
|
+
width,
|
|
18
|
+
height,
|
|
19
|
+
headHash,
|
|
20
|
+
}: GitGraphSvgProps) {
|
|
21
|
+
return (
|
|
22
|
+
<svg width={width} height={height}>
|
|
23
|
+
{/* Connection lines */}
|
|
24
|
+
{svgPaths.map((p, i) => (
|
|
25
|
+
<path
|
|
26
|
+
key={i}
|
|
27
|
+
d={p.d}
|
|
28
|
+
stroke={p.color}
|
|
29
|
+
strokeWidth={2}
|
|
30
|
+
fill="none"
|
|
31
|
+
/>
|
|
32
|
+
))}
|
|
33
|
+
{/* Commit dots */}
|
|
34
|
+
{commits.map((c, ci) => {
|
|
35
|
+
const cLane = laneMap.get(c.hash) ?? 0;
|
|
36
|
+
const cx = cLane * LANE_WIDTH + LANE_WIDTH / 2;
|
|
37
|
+
const cy = ci * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
38
|
+
const cColor = LANE_COLORS[cLane % LANE_COLORS.length]!;
|
|
39
|
+
const isHead = c.hash === headHash;
|
|
40
|
+
return (
|
|
41
|
+
<circle
|
|
42
|
+
key={c.hash}
|
|
43
|
+
cx={cx}
|
|
44
|
+
cy={cy}
|
|
45
|
+
r={isHead ? NODE_RADIUS + 1 : NODE_RADIUS}
|
|
46
|
+
fill={cColor}
|
|
47
|
+
stroke={isHead ? "#000" : "none"}
|
|
48
|
+
strokeWidth={isHead ? 2 : 0}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|