trident-git 0.2.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/README.md +198 -0
- package/bin/trident-git.mjs +153 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +30 -0
- package/package.json +60 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/credentials/route.ts +113 -0
- package/src/app/api/custom-scripts/route.ts +203 -0
- package/src/app/api/fs/route.ts +75 -0
- package/src/app/api/git/action/route.ts +383 -0
- package/src/app/api/git/branches/route.ts +20 -0
- package/src/app/api/git/diff/route.ts +104 -0
- package/src/app/api/git/log/route.ts +28 -0
- package/src/app/api/git/status/route.ts +28 -0
- package/src/app/api/repos/route.ts +84 -0
- package/src/app/api/settings/route.ts +37 -0
- package/src/app/credentials/page.tsx +408 -0
- package/src/app/globals.css +109 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +38 -0
- package/src/app/page.tsx +10 -0
- package/src/app/providers.tsx +21 -0
- package/src/app/workspace/changes/page.tsx +27 -0
- package/src/app/workspace/custom-scripts/page.tsx +247 -0
- package/src/app/workspace/history/page.tsx +27 -0
- package/src/app/workspace/layout.tsx +26 -0
- package/src/app/workspace/page.tsx +27 -0
- package/src/app/workspace/settings/page.tsx +233 -0
- package/src/app/workspace/stashes/page.tsx +395 -0
- package/src/components/command-palette.tsx +178 -0
- package/src/components/context-menu.tsx +200 -0
- package/src/components/fs-browser.tsx +154 -0
- package/src/components/git/diff-view.tsx +137 -0
- package/src/components/git/git-graph.tsx +489 -0
- package/src/components/git/grouped-diff-viewer.tsx +332 -0
- package/src/components/git/history-view.tsx +4862 -0
- package/src/components/git/image-diff-view.tsx +342 -0
- package/src/components/git/status-view.tsx +597 -0
- package/src/components/home-settings-modal.tsx +192 -0
- package/src/components/layout/sidebar.tsx +256 -0
- package/src/components/repo-list.tsx +206 -0
- package/src/components/theme-toggle.tsx +37 -0
- package/src/components/toaster.tsx +36 -0
- package/src/components/workspace-repo-open-tracker.tsx +39 -0
- package/src/hooks/use-credentials.ts +123 -0
- package/src/hooks/use-escape-dismiss.ts +72 -0
- package/src/hooks/use-git.ts +448 -0
- package/src/hooks/use-toast.ts +280 -0
- package/src/hooks/use-workspace-title.ts +23 -0
- package/src/lib/api-utils.ts +24 -0
- package/src/lib/branch-colors.ts +98 -0
- package/src/lib/credentials.ts +404 -0
- package/src/lib/git.ts +1510 -0
- package/src/lib/graph-utils.ts +253 -0
- package/src/lib/store.ts +145 -0
- package/src/lib/types.ts +95 -0
- package/src/lib/utils.ts +266 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useRef, useState, useImperativeHandle, forwardRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { Commit, BranchTrackingInfo } from '@/lib/types';
|
|
5
|
+
import { generateGraphData } from '@/lib/graph-utils';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import { ContextMenu, ContextMenuItem } from '@/components/context-menu';
|
|
8
|
+
import { getBranchTagColors } from '@/lib/branch-colors';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
const ROW_HEIGHT = 24; // Compact rows like Fork
|
|
12
|
+
const LANE_WIDTH = 12;
|
|
13
|
+
const DOT_SIZE = 3;
|
|
14
|
+
const STROKE_WIDTH = 2;
|
|
15
|
+
|
|
16
|
+
// Helper function to highlight matching text
|
|
17
|
+
function HighlightedText({ text, searchQuery }: { text: string; searchQuery: string }) {
|
|
18
|
+
if (!searchQuery || !text) return <>{text}</>;
|
|
19
|
+
|
|
20
|
+
const query = searchQuery.toLowerCase();
|
|
21
|
+
const lowerText = text.toLowerCase();
|
|
22
|
+
const parts: { text: string; highlighted: boolean }[] = [];
|
|
23
|
+
|
|
24
|
+
let lastIndex = 0;
|
|
25
|
+
let index = lowerText.indexOf(query);
|
|
26
|
+
|
|
27
|
+
while (index !== -1) {
|
|
28
|
+
// Add non-matching part
|
|
29
|
+
if (index > lastIndex) {
|
|
30
|
+
parts.push({ text: text.slice(lastIndex, index), highlighted: false });
|
|
31
|
+
}
|
|
32
|
+
// Add matching part (preserve original case)
|
|
33
|
+
parts.push({ text: text.slice(index, index + query.length), highlighted: true });
|
|
34
|
+
lastIndex = index + query.length;
|
|
35
|
+
index = lowerText.indexOf(query, lastIndex);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Add remaining non-matching part
|
|
39
|
+
if (lastIndex < text.length) {
|
|
40
|
+
parts.push({ text: text.slice(lastIndex), highlighted: false });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (parts.length === 0) return <>{text}</>;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{parts.map((part, i) =>
|
|
48
|
+
part.highlighted ? (
|
|
49
|
+
<mark key={i} className="bg-warning text-warning-content rounded-sm px-0.5">{part.text}</mark>
|
|
50
|
+
) : (
|
|
51
|
+
<span key={i}>{part.text}</span>
|
|
52
|
+
)
|
|
53
|
+
)}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GitGraphHandle {
|
|
59
|
+
scrollToCommit: (hash: string) => boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const GitGraph = forwardRef<GitGraphHandle, {
|
|
63
|
+
commits: Commit[],
|
|
64
|
+
onSelectCommit?: (hash: string, modifiers?: { isMultiSelect: boolean; isRangeSelect: boolean }) => void,
|
|
65
|
+
onResetToCommit?: (hash: string) => void,
|
|
66
|
+
onRevertCommit?: (hash: string, message: string) => void,
|
|
67
|
+
onCreateTag?: (hash: string) => void,
|
|
68
|
+
onCherryPickCommit?: (hash: string, message: string) => void,
|
|
69
|
+
onCherryPickSelectedCommits?: () => void,
|
|
70
|
+
onRewordCommit?: (hash: string, subject: string, body: string, branch: string) => void,
|
|
71
|
+
selectedHash?: string,
|
|
72
|
+
selectedHashes?: Set<string>,
|
|
73
|
+
onEndReached?: () => void,
|
|
74
|
+
isLoadingMore?: boolean,
|
|
75
|
+
currentBranch?: string,
|
|
76
|
+
hiddenBranches?: Set<string>,
|
|
77
|
+
localBranches?: string[],
|
|
78
|
+
trackingInfo?: Record<string, BranchTrackingInfo>,
|
|
79
|
+
getBranchTagContextMenuItems?: (displayRef: string) => ContextMenuItem[] | null
|
|
80
|
+
}>(function GitGraph({
|
|
81
|
+
commits,
|
|
82
|
+
onSelectCommit,
|
|
83
|
+
onResetToCommit,
|
|
84
|
+
onRevertCommit,
|
|
85
|
+
onCreateTag,
|
|
86
|
+
onCherryPickCommit,
|
|
87
|
+
onCherryPickSelectedCommits,
|
|
88
|
+
onRewordCommit,
|
|
89
|
+
selectedHash,
|
|
90
|
+
selectedHashes,
|
|
91
|
+
onEndReached,
|
|
92
|
+
isLoadingMore,
|
|
93
|
+
currentBranch,
|
|
94
|
+
hiddenBranches,
|
|
95
|
+
localBranches = [],
|
|
96
|
+
trackingInfo,
|
|
97
|
+
getBranchTagContextMenuItems
|
|
98
|
+
}, ref) {
|
|
99
|
+
const normalizeDecoratedRef = useCallback((ref: string) => {
|
|
100
|
+
if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length);
|
|
101
|
+
if (ref.startsWith('refs/remotes/')) return ref.slice('refs/remotes/'.length);
|
|
102
|
+
if (ref.startsWith('remotes/')) return ref.slice('remotes/'.length);
|
|
103
|
+
return ref;
|
|
104
|
+
}, []);
|
|
105
|
+
const getRefDisplayName = useCallback((ref: string) => {
|
|
106
|
+
if (ref.startsWith('tag:')) return ref.replace(/^tag:\s*/, '').trim();
|
|
107
|
+
return ref;
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const nodes = useMemo(
|
|
111
|
+
() => generateGraphData(commits, { localBranches }),
|
|
112
|
+
[commits, localBranches]
|
|
113
|
+
);
|
|
114
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
115
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
116
|
+
|
|
117
|
+
// Search state
|
|
118
|
+
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
119
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
120
|
+
|
|
121
|
+
// Handle Cmd+F to open search
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
124
|
+
// Cmd+F (Mac) or Ctrl+F (Windows/Linux)
|
|
125
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
setIsSearchOpen(true);
|
|
128
|
+
// Focus input after render
|
|
129
|
+
setTimeout(() => searchInputRef.current?.focus(), 0);
|
|
130
|
+
}
|
|
131
|
+
// Escape to close search
|
|
132
|
+
if (e.key === 'Escape' && isSearchOpen) {
|
|
133
|
+
setIsSearchOpen(false);
|
|
134
|
+
setSearchQuery('');
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
139
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
140
|
+
}, [isSearchOpen]);
|
|
141
|
+
|
|
142
|
+
const handleCloseSearch = useCallback(() => {
|
|
143
|
+
setIsSearchOpen(false);
|
|
144
|
+
setSearchQuery('');
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
// Expose scrollToCommit function via ref
|
|
148
|
+
useImperativeHandle(ref, () => ({
|
|
149
|
+
scrollToCommit: (hash: string) => {
|
|
150
|
+
if (!nodes || nodes.length === 0) return false;
|
|
151
|
+
|
|
152
|
+
const index = nodes.findIndex(n => n.hash === hash);
|
|
153
|
+
if (index === -1) return false;
|
|
154
|
+
|
|
155
|
+
// Scroll to the commit row
|
|
156
|
+
if (scrollRef.current) {
|
|
157
|
+
const scrollTop = index * ROW_HEIGHT - (scrollRef.current.clientHeight / 2) + ROW_HEIGHT / 2;
|
|
158
|
+
scrollRef.current.scrollTop = Math.max(0, scrollTop);
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}), [nodes]);
|
|
163
|
+
|
|
164
|
+
if (!nodes || nodes.length === 0) return null;
|
|
165
|
+
|
|
166
|
+
// Calculate SVG dimensions
|
|
167
|
+
const maxLane = Math.max(...nodes.map(n => n.x), 0);
|
|
168
|
+
const width = (maxLane + 1) * LANE_WIDTH + 20;
|
|
169
|
+
const height = nodes.length * ROW_HEIGHT;
|
|
170
|
+
|
|
171
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
172
|
+
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget;
|
|
173
|
+
if (scrollHeight - scrollTop - clientHeight < 100) {
|
|
174
|
+
onEndReached?.();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className="flex flex-col h-full bg-base-100 overflow-hidden font-mono text-sm select-none">
|
|
180
|
+
{/* Search Input - Sticky on top */}
|
|
181
|
+
{isSearchOpen && (
|
|
182
|
+
<div className="sticky top-0 z-30 bg-base-100 border-b border-base-300 px-2 py-2 flex items-center gap-2">
|
|
183
|
+
<span className="opacity-50">🔍</span>
|
|
184
|
+
<input
|
|
185
|
+
ref={searchInputRef}
|
|
186
|
+
type="text"
|
|
187
|
+
placeholder="Search in commits..."
|
|
188
|
+
value={searchQuery}
|
|
189
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
190
|
+
className="input input-bordered input-sm flex-1 text-sm"
|
|
191
|
+
autoFocus
|
|
192
|
+
/>
|
|
193
|
+
<div className="tooltip tooltip-left z-50" data-tip="Close search (Esc)">
|
|
194
|
+
<button
|
|
195
|
+
onClick={handleCloseSearch}
|
|
196
|
+
className="btn btn-ghost btn-sm btn-square"
|
|
197
|
+
>
|
|
198
|
+
✖️
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
<div className="flex-1 overflow-auto h-full max-w-full px-2" onScroll={handleScroll} ref={scrollRef}>
|
|
205
|
+
<div className="relative min-w-full" style={{ height }}>
|
|
206
|
+
{/* SVG Graph Layout */}
|
|
207
|
+
<svg width={width} height={height} className="absolute top-0 left-0 pointer-events-none z-10">
|
|
208
|
+
{nodes.map((node) => (
|
|
209
|
+
<g key={node.hash}>
|
|
210
|
+
{/* Draw paths */}
|
|
211
|
+
{node.paths.map((path, i) => {
|
|
212
|
+
const x1 = path.x1 * LANE_WIDTH + LANE_WIDTH / 2;
|
|
213
|
+
const y1 = path.y1 * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
214
|
+
const x2 = path.x2 * LANE_WIDTH + LANE_WIDTH / 2;
|
|
215
|
+
const y2 = path.y2 * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
216
|
+
|
|
217
|
+
let d = '';
|
|
218
|
+
if (path.type === 'straight') {
|
|
219
|
+
d = `M ${x1} ${y1} L ${x2} ${y2}`;
|
|
220
|
+
} else {
|
|
221
|
+
// Fork/Merge styled Bezier
|
|
222
|
+
// Standard cubic bezier: ctrl points at mid-y
|
|
223
|
+
const cy1 = y1 + ROW_HEIGHT * 0.5;
|
|
224
|
+
const cy2 = y2 - ROW_HEIGHT * 0.5;
|
|
225
|
+
d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<path
|
|
230
|
+
key={i}
|
|
231
|
+
d={d}
|
|
232
|
+
stroke={path.color}
|
|
233
|
+
strokeWidth={STROKE_WIDTH}
|
|
234
|
+
fill="none"
|
|
235
|
+
strokeLinecap="round"
|
|
236
|
+
/>
|
|
237
|
+
)
|
|
238
|
+
})}
|
|
239
|
+
</g>
|
|
240
|
+
))}
|
|
241
|
+
|
|
242
|
+
{/* Draw Nodes on top of all paths to avoid overlap ugliness */}
|
|
243
|
+
{nodes.map((node) => (
|
|
244
|
+
<circle
|
|
245
|
+
key={`dot-${node.hash}`}
|
|
246
|
+
cx={node.x * LANE_WIDTH + LANE_WIDTH / 2}
|
|
247
|
+
cy={node.y * ROW_HEIGHT + ROW_HEIGHT / 2}
|
|
248
|
+
r={DOT_SIZE}
|
|
249
|
+
fill={node.color}
|
|
250
|
+
stroke={node.color}
|
|
251
|
+
strokeWidth={STROKE_WIDTH}
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
</svg>
|
|
255
|
+
|
|
256
|
+
{/* List Rows */}
|
|
257
|
+
<div style={{ width: '100%' }}>
|
|
258
|
+
{nodes.map((node) => {
|
|
259
|
+
const isSelected = selectedHashes ? selectedHashes.has(node.hash) : selectedHash === node.hash;
|
|
260
|
+
const selectedCount = selectedHashes?.size ?? (selectedHash ? 1 : 0);
|
|
261
|
+
const menuItems = [
|
|
262
|
+
{ label: "Reset to here", onClick: () => onResetToCommit?.(node.hash) },
|
|
263
|
+
];
|
|
264
|
+
if (onRevertCommit) {
|
|
265
|
+
menuItems.push({
|
|
266
|
+
label: "Revert commit",
|
|
267
|
+
onClick: () => onRevertCommit(node.hash, node.message),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (onCreateTag) {
|
|
271
|
+
menuItems.push({
|
|
272
|
+
label: "Create tag",
|
|
273
|
+
onClick: () => onCreateTag(node.hash),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (onCherryPickCommit) {
|
|
277
|
+
menuItems.push({
|
|
278
|
+
label: "Cherry-pick commit",
|
|
279
|
+
onClick: () => onCherryPickCommit(node.hash, node.message),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (onCherryPickSelectedCommits && selectedCount > 1 && isSelected) {
|
|
283
|
+
menuItems.push({
|
|
284
|
+
label: `Cherry-pick ${selectedCount} selected commits`,
|
|
285
|
+
onClick: onCherryPickSelectedCommits,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (onRewordCommit && localBranches && localBranches.length > 0) {
|
|
290
|
+
// Clean up refs: remove parentheses and split
|
|
291
|
+
const refs = node.refs ? node.refs.replace(/[()]/g, '').split(',').map(r => r.trim()) : [];
|
|
292
|
+
let targetBranch: string | null = null;
|
|
293
|
+
|
|
294
|
+
for (const ref of refs) {
|
|
295
|
+
// Handle "HEAD -> branch" format
|
|
296
|
+
const cleanRef = ref.replace(/^HEAD\s*->\s*/, '');
|
|
297
|
+
|
|
298
|
+
// Check if it is in localBranches
|
|
299
|
+
if (localBranches.includes(cleanRef)) {
|
|
300
|
+
targetBranch = cleanRef;
|
|
301
|
+
// Prioritize current branch if found
|
|
302
|
+
if (currentBranch && cleanRef === currentBranch) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (targetBranch) {
|
|
309
|
+
menuItems.push({
|
|
310
|
+
label: "Reword commit",
|
|
311
|
+
onClick: () => onRewordCommit(node.hash, node.message, node.body ?? '', targetBranch!),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
// Process refs to combine local and tracking remote branches
|
|
318
|
+
const processRefs = () => {
|
|
319
|
+
if (!node.refs) return [];
|
|
320
|
+
|
|
321
|
+
const rawRefs = node.refs.replace(/^\s*\((.*)\)\s*$/, '$1').split(',').map(r => {
|
|
322
|
+
const isHead = r.startsWith('HEAD -> ');
|
|
323
|
+
const name = normalizeDecoratedRef(r.replace(/^HEAD\s*->\s*/, '').trim());
|
|
324
|
+
return { raw: r, name, isHead };
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const result: {
|
|
328
|
+
displayName: string;
|
|
329
|
+
primaryRef: string;
|
|
330
|
+
secondaryRef?: string;
|
|
331
|
+
isHead: boolean
|
|
332
|
+
}[] = [];
|
|
333
|
+
|
|
334
|
+
const processedIndices = new Set<number>();
|
|
335
|
+
const isHidden = (name: string) => hiddenBranches && (hiddenBranches.has(name) || hiddenBranches.has(`remotes/${name}`));
|
|
336
|
+
|
|
337
|
+
// First pass: find local branches and their tracking remotes
|
|
338
|
+
rawRefs.forEach((ref, idx) => {
|
|
339
|
+
if (processedIndices.has(idx)) return;
|
|
340
|
+
|
|
341
|
+
// Only attempt to combine if local branch is visible
|
|
342
|
+
if (localBranches.includes(ref.name) && !isHidden(ref.name)) {
|
|
343
|
+
const tracking = trackingInfo?.[ref.name];
|
|
344
|
+
if (tracking && tracking.upstream) {
|
|
345
|
+
const normalizedUpstream = normalizeDecoratedRef(tracking.upstream.trim());
|
|
346
|
+
const upstreamCandidates = new Set([
|
|
347
|
+
normalizedUpstream,
|
|
348
|
+
normalizeDecoratedRef(`remotes/${normalizedUpstream}`),
|
|
349
|
+
normalizeDecoratedRef(`refs/remotes/${normalizedUpstream}`),
|
|
350
|
+
]);
|
|
351
|
+
const upstreamIdx = rawRefs.findIndex(
|
|
352
|
+
(r, i) => i !== idx && !processedIndices.has(i) && upstreamCandidates.has(r.name)
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
if (upstreamIdx !== -1) {
|
|
356
|
+
const upstreamRef = rawRefs[upstreamIdx];
|
|
357
|
+
const parts = normalizedUpstream.split('/');
|
|
358
|
+
const remoteName = parts[0];
|
|
359
|
+
|
|
360
|
+
result.push({
|
|
361
|
+
displayName: `${ref.name} (${remoteName})`,
|
|
362
|
+
primaryRef: ref.name,
|
|
363
|
+
secondaryRef: upstreamRef.name,
|
|
364
|
+
isHead: ref.isHead
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
processedIndices.add(idx);
|
|
368
|
+
processedIndices.add(upstreamIdx);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Second pass: add remaining refs
|
|
376
|
+
rawRefs.forEach((ref, idx) => {
|
|
377
|
+
if (!processedIndices.has(idx)) {
|
|
378
|
+
// Skip hidden branches
|
|
379
|
+
if (isHidden(ref.name)) return;
|
|
380
|
+
|
|
381
|
+
result.push({
|
|
382
|
+
displayName: getRefDisplayName(ref.name),
|
|
383
|
+
primaryRef: ref.name,
|
|
384
|
+
isHead: ref.isHead
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return result;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const processedTags = processRefs();
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<ContextMenu key={node.hash} items={menuItems}>
|
|
396
|
+
<div
|
|
397
|
+
className={cn(
|
|
398
|
+
"flex items-center hover:bg-base-200 border-b border-base-200 last:border-0 cursor-pointer transition-colors text-xs",
|
|
399
|
+
isSelected && "bg-primary/10"
|
|
400
|
+
)}
|
|
401
|
+
style={{ height: ROW_HEIGHT }}
|
|
402
|
+
onClick={(e) => onSelectCommit?.(node.hash, {
|
|
403
|
+
isMultiSelect: e.metaKey || e.ctrlKey,
|
|
404
|
+
isRangeSelect: e.shiftKey,
|
|
405
|
+
})}
|
|
406
|
+
>
|
|
407
|
+
{/* Spacing for Graph */}
|
|
408
|
+
<div style={{ width: width, flexShrink: 0 }} />
|
|
409
|
+
|
|
410
|
+
{/* Content */}
|
|
411
|
+
<div className="flex flex-1 gap-4 overflow-hidden pr-4 items-center">
|
|
412
|
+
<div className="flex-1 truncate flex items-center gap-2">
|
|
413
|
+
{/* Refs Pills */}
|
|
414
|
+
{processedTags.map((tag, idx) => {
|
|
415
|
+
const isCurrent = currentBranch && (
|
|
416
|
+
tag.primaryRef === currentBranch ||
|
|
417
|
+
tag.isHead && tag.primaryRef === currentBranch
|
|
418
|
+
);
|
|
419
|
+
const isGitTag = tag.primaryRef.startsWith('tag:');
|
|
420
|
+
const tagColors = isGitTag
|
|
421
|
+
? { textColor: '#374151', backgroundColor: '#e5e7eb' }
|
|
422
|
+
: getBranchTagColors(tag.primaryRef);
|
|
423
|
+
|
|
424
|
+
const tagElement = (
|
|
425
|
+
<span
|
|
426
|
+
className={cn(
|
|
427
|
+
"text-[10px] px-1.5 rounded-full whitespace-nowrap shrink-0",
|
|
428
|
+
isCurrent && "font-bold"
|
|
429
|
+
)}
|
|
430
|
+
style={{
|
|
431
|
+
color: tagColors.textColor,
|
|
432
|
+
backgroundColor: tagColors.backgroundColor
|
|
433
|
+
}}
|
|
434
|
+
title={tag.displayName}
|
|
435
|
+
>
|
|
436
|
+
<HighlightedText text={tag.displayName} searchQuery={searchQuery} />
|
|
437
|
+
</span>
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const branchMenuItems = getBranchTagContextMenuItems?.(tag.primaryRef) || [];
|
|
441
|
+
|
|
442
|
+
if (branchMenuItems.length === 0) {
|
|
443
|
+
return <span key={idx} className="shrink-0">{tagElement}</span>;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<ContextMenu
|
|
448
|
+
key={idx}
|
|
449
|
+
items={branchMenuItems}
|
|
450
|
+
containerClassName="inline-flex shrink-0"
|
|
451
|
+
>
|
|
452
|
+
{tagElement}
|
|
453
|
+
</ContextMenu>
|
|
454
|
+
);
|
|
455
|
+
})}
|
|
456
|
+
<span className={cn("truncate min-w-0 max-w-[600px]", isSelected ? "font-semibold" : "")} title={node.message}>
|
|
457
|
+
<HighlightedText text={node.message} searchQuery={searchQuery} />
|
|
458
|
+
</span>
|
|
459
|
+
</div>
|
|
460
|
+
<div className="w-32 truncate opacity-70 text-right">
|
|
461
|
+
<HighlightedText text={node.author_name} searchQuery={searchQuery} />
|
|
462
|
+
</div>
|
|
463
|
+
<div className="w-20 truncate opacity-50 font-mono text-right">
|
|
464
|
+
<HighlightedText text={node.hash.substring(0, 7)} searchQuery={searchQuery} />
|
|
465
|
+
</div>
|
|
466
|
+
<div className="w-32 truncate opacity-70 text-right">
|
|
467
|
+
{new Date(node.date).toLocaleString(undefined, {
|
|
468
|
+
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
|
469
|
+
})}
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
</ContextMenu>
|
|
474
|
+
);
|
|
475
|
+
})}
|
|
476
|
+
|
|
477
|
+
{/* Loading More Indicator */}
|
|
478
|
+
{isLoadingMore && (
|
|
479
|
+
<div className="flex items-center justify-center py-8 border-b border-base-300">
|
|
480
|
+
<span className="loading loading-spinner text-base-content/50"></span>
|
|
481
|
+
<span className="ml-2 text-sm opacity-70">Loading more commits...</span>
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
);
|
|
489
|
+
});
|