@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.
- package/CHANGELOG.md +19 -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 +108 -64
- 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/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 +5 -0
- 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,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} <{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
|
+
}
|