@hienlh/ppm 0.7.41 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
  3. package/dist/web/assets/{code-editor-CaKnPjkU.js → code-editor-BakDn6rL.js} +1 -1
  4. package/dist/web/assets/{database-viewer-DUAq3r2M.js → database-viewer-COaZMlpv.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-C6w7tDMN.js → diff-viewer-COSbmidI.js} +1 -1
  6. package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
  7. package/dist/web/assets/index-BGTzm7B1.js +28 -0
  8. package/dist/web/assets/index-CeNox-VV.css +2 -0
  9. package/dist/web/assets/input-CE3bFwLk.js +41 -0
  10. package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-Ckj0mfYc.js → markdown-renderer-BKgH2iGf.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-m6qNfnAF.js → postgres-viewer-DBOv2ha2.js} +1 -1
  13. package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
  14. package/dist/web/assets/{sqlite-viewer-6d233-2k.js → sqlite-viewer-BY242odW.js} +1 -1
  15. package/dist/web/assets/switch-BEmt1alu.js +1 -0
  16. package/dist/web/assets/{terminal-tab-BaHGzGJ6.js → terminal-tab-BiUqECPk.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/providers/claude-agent-sdk.ts +108 -64
  21. package/src/providers/mock-provider.ts +1 -0
  22. package/src/providers/provider.interface.ts +1 -0
  23. package/src/server/routes/git.ts +16 -2
  24. package/src/server/ws/chat.ts +43 -26
  25. package/src/services/chat.service.ts +3 -1
  26. package/src/services/git.service.ts +45 -8
  27. package/src/types/api.ts +1 -1
  28. package/src/types/chat.ts +5 -0
  29. package/src/types/config.ts +21 -0
  30. package/src/types/git.ts +4 -0
  31. package/src/web/components/chat/chat-tab.tsx +26 -8
  32. package/src/web/components/chat/message-input.tsx +61 -1
  33. package/src/web/components/chat/message-list.tsx +9 -1
  34. package/src/web/components/chat/mode-selector.tsx +117 -0
  35. package/src/web/components/git/git-graph-branch-label.tsx +124 -0
  36. package/src/web/components/git/git-graph-constants.ts +185 -0
  37. package/src/web/components/git/git-graph-detail.tsx +107 -0
  38. package/src/web/components/git/git-graph-dialog.tsx +72 -0
  39. package/src/web/components/git/git-graph-row.tsx +167 -0
  40. package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
  41. package/src/web/components/git/git-graph-svg.tsx +54 -0
  42. package/src/web/components/git/git-graph-toolbar.tsx +195 -0
  43. package/src/web/components/git/git-graph.tsx +143 -681
  44. package/src/web/components/git/use-column-resize.ts +33 -0
  45. package/src/web/components/git/use-git-graph.ts +201 -0
  46. package/src/web/components/settings/ai-settings-section.tsx +42 -0
  47. package/src/web/hooks/use-chat.ts +3 -3
  48. package/src/web/lib/api-settings.ts +2 -0
  49. package/dist/web/assets/chat-tab-BoeC0a0w.js +0 -7
  50. package/dist/web/assets/git-graph-9GFTfA5p.js +0 -1
  51. package/dist/web/assets/index-CSS8Cy7l.css +0 -2
  52. package/dist/web/assets/index-CetGEOKq.js +0 -28
  53. package/dist/web/assets/input-CVIzrYsH.js +0 -41
  54. package/dist/web/assets/keybindings-store-DiEM7YZ4.js +0 -1
  55. package/dist/web/assets/settings-tab-Di-E48kC.js +0 -1
  56. package/dist/web/assets/switch-UODDpwuO.js +0 -1
@@ -0,0 +1,117 @@
1
+ import { useRef, useEffect, useCallback, type KeyboardEvent } from "react";
2
+ import { Hand, Code, ClipboardList, ShieldOff, Check } from "lucide-react";
3
+
4
+ const MODES = [
5
+ { id: "default", label: "Ask before edits", icon: Hand, description: "Claude will ask for approval before making each edit" },
6
+ { id: "acceptEdits", label: "Edit automatically", icon: Code, description: "Claude will edit files without asking first" },
7
+ { id: "plan", label: "Plan mode", icon: ClipboardList, description: "Claude will present a plan before editing" },
8
+ { id: "bypassPermissions", label: "Bypass permissions", icon: ShieldOff, description: "Claude will not ask before running commands" },
9
+ ] as const;
10
+
11
+ export type ModeId = typeof MODES[number]["id"];
12
+
13
+ /** Short label for the mode chip */
14
+ export function getModeLabel(id: string): string {
15
+ return MODES.find((m) => m.id === id)?.label ?? "Unknown";
16
+ }
17
+
18
+ /** Icon component for the mode chip */
19
+ export function getModeIcon(id: string) {
20
+ return MODES.find((m) => m.id === id)?.icon ?? Hand;
21
+ }
22
+
23
+ interface ModeSelectorProps {
24
+ value: string;
25
+ onChange: (mode: string) => void;
26
+ open: boolean;
27
+ onOpenChange: (open: boolean) => void;
28
+ }
29
+
30
+ export function ModeSelector({ value, onChange, open, onOpenChange }: ModeSelectorProps) {
31
+ const panelRef = useRef<HTMLDivElement>(null);
32
+ const focusedRef = useRef(0);
33
+
34
+ // Close on click outside
35
+ useEffect(() => {
36
+ if (!open) return;
37
+ const handler = (e: MouseEvent) => {
38
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
39
+ onOpenChange(false);
40
+ }
41
+ };
42
+ document.addEventListener("mousedown", handler);
43
+ return () => document.removeEventListener("mousedown", handler);
44
+ }, [open, onOpenChange]);
45
+
46
+ // Focus current mode on open
47
+ useEffect(() => {
48
+ if (open) {
49
+ focusedRef.current = MODES.findIndex((m) => m.id === value);
50
+ if (focusedRef.current < 0) focusedRef.current = 0;
51
+ }
52
+ }, [open, value]);
53
+
54
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
55
+ if (e.key === "Escape") {
56
+ onOpenChange(false);
57
+ return;
58
+ }
59
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
60
+ e.preventDefault();
61
+ const dir = e.key === "ArrowDown" ? 1 : -1;
62
+ focusedRef.current = (focusedRef.current + dir + MODES.length) % MODES.length;
63
+ const el = panelRef.current?.querySelector(`[data-idx="${focusedRef.current}"]`) as HTMLElement;
64
+ el?.focus();
65
+ }
66
+ if (e.key === "Enter") {
67
+ e.preventDefault();
68
+ const mode = MODES[focusedRef.current];
69
+ if (mode) { onChange(mode.id); onOpenChange(false); }
70
+ }
71
+ }, [onChange, onOpenChange]);
72
+
73
+ if (!open) return null;
74
+
75
+ return (
76
+ <div
77
+ ref={panelRef}
78
+ role="listbox"
79
+ aria-label="Permission modes"
80
+ onKeyDown={handleKeyDown}
81
+ onMouseDown={(e) => e.stopPropagation()}
82
+ onClick={(e) => e.stopPropagation()}
83
+ className="absolute bottom-full left-0 mb-1 z-50 w-72 md:w-80 rounded-lg border border-border bg-surface shadow-lg"
84
+ >
85
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
86
+ <span className="text-xs font-medium text-text-secondary">Modes</span>
87
+ <kbd className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-text-subtle border border-border">
88
+ Shift + Tab
89
+ </kbd>
90
+ </div>
91
+ <div className="py-1">
92
+ {MODES.map((mode, idx) => {
93
+ const Icon = mode.icon;
94
+ const isActive = mode.id === value;
95
+ return (
96
+ <button
97
+ key={mode.id}
98
+ data-idx={idx}
99
+ role="option"
100
+ aria-selected={isActive}
101
+ tabIndex={0}
102
+ onClick={() => { onChange(mode.id); onOpenChange(false); }}
103
+ className={`w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-elevated focus:bg-surface-elevated focus:outline-none ${isActive ? "bg-surface-elevated" : ""}`}
104
+ >
105
+ <Icon className="size-4 mt-0.5 shrink-0 text-text-secondary" />
106
+ <div className="flex-1 min-w-0">
107
+ <div className="text-sm font-medium text-text-primary">{mode.label}</div>
108
+ <div className="text-xs text-text-subtle leading-snug">{mode.description}</div>
109
+ </div>
110
+ {isActive && <Check className="size-4 mt-0.5 shrink-0 text-primary" />}
111
+ </button>
112
+ );
113
+ })}
114
+ </div>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ GitBranch,
3
+ GitMerge,
4
+ Trash2,
5
+ ArrowUpFromLine,
6
+ ExternalLink,
7
+ Tag,
8
+ } from "lucide-react";
9
+ import {
10
+ ContextMenu,
11
+ ContextMenuContent,
12
+ ContextMenuItem,
13
+ ContextMenuSeparator,
14
+ ContextMenuTrigger,
15
+ } from "@/components/ui/context-menu";
16
+ import type { GitBranch as GitBranchType } from "../../../types/git";
17
+
18
+ interface BranchLabelProps {
19
+ name: string;
20
+ type: "branch" | "tag";
21
+ remotes: string[];
22
+ isCurrent: boolean;
23
+ color: string;
24
+ currentBranch: GitBranchType | undefined;
25
+ onCheckout: (ref: string) => void;
26
+ onMerge: (source: string) => void;
27
+ onPush: (branch: string) => void;
28
+ onCreatePr: (branch: string) => void;
29
+ onDelete: (name: string) => void;
30
+ }
31
+
32
+ export function BranchLabel({
33
+ name,
34
+ type,
35
+ remotes,
36
+ isCurrent,
37
+ color,
38
+ currentBranch,
39
+ onCheckout,
40
+ onMerge,
41
+ onPush,
42
+ onCreatePr,
43
+ onDelete,
44
+ }: BranchLabelProps) {
45
+ if (type === "tag") {
46
+ return (
47
+ <span 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">
48
+ <Tag className="size-2.5" />
49
+ {name}
50
+ </span>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <ContextMenu>
56
+ <ContextMenuTrigger asChild>
57
+ <span
58
+ className="inline-flex items-center rounded text-[10px] font-medium shrink-0 cursor-context-menu overflow-hidden"
59
+ style={{
60
+ border: isCurrent
61
+ ? `1.5px solid ${color}`
62
+ : `1px solid ${color}50`,
63
+ }}
64
+ >
65
+ {/* Branch name segment */}
66
+ <span
67
+ className="inline-flex items-center gap-0.5 px-1.5 py-0.5"
68
+ style={{
69
+ backgroundColor: isCurrent ? color : `${color}30`,
70
+ color: isCurrent ? "#fff" : color,
71
+ }}
72
+ >
73
+ <GitBranch className="size-2.5" />
74
+ {name}
75
+ </span>
76
+ {/* Remote indicators (italic, separated by border) */}
77
+ {remotes.map((remote) => (
78
+ <span
79
+ key={remote}
80
+ className="px-1.5 py-0.5 italic opacity-70"
81
+ style={{
82
+ borderLeft: `1px solid ${color}40`,
83
+ color,
84
+ backgroundColor: `${color}15`,
85
+ }}
86
+ >
87
+ {remote}
88
+ </span>
89
+ ))}
90
+ </span>
91
+ </ContextMenuTrigger>
92
+ <ContextMenuContent>
93
+ <ContextMenuItem onClick={() => onCheckout(name)}>
94
+ Checkout
95
+ </ContextMenuItem>
96
+ <ContextMenuItem
97
+ onClick={() => onMerge(name)}
98
+ disabled={name === currentBranch?.name}
99
+ >
100
+ <GitMerge className="size-3" />
101
+ Merge into current
102
+ </ContextMenuItem>
103
+ <ContextMenuSeparator />
104
+ <ContextMenuItem onClick={() => onPush(name)}>
105
+ <ArrowUpFromLine className="size-3" />
106
+ Push
107
+ </ContextMenuItem>
108
+ <ContextMenuItem onClick={() => onCreatePr(name)}>
109
+ <ExternalLink className="size-3" />
110
+ Create PR
111
+ </ContextMenuItem>
112
+ <ContextMenuSeparator />
113
+ <ContextMenuItem
114
+ variant="destructive"
115
+ onClick={() => onDelete(name)}
116
+ disabled={name === currentBranch?.name}
117
+ >
118
+ <Trash2 className="size-3" />
119
+ Delete
120
+ </ContextMenuItem>
121
+ </ContextMenuContent>
122
+ </ContextMenu>
123
+ );
124
+ }
@@ -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} &lt;{commit.authorEmail}&gt;
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
+ }