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.
Files changed (64) hide show
  1. package/README.md +198 -0
  2. package/bin/trident-git.mjs +153 -0
  3. package/eslint.config.mjs +18 -0
  4. package/next.config.ts +30 -0
  5. package/package.json +60 -0
  6. package/postcss.config.mjs +7 -0
  7. package/public/favicon.png +0 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/credentials/route.ts +113 -0
  14. package/src/app/api/custom-scripts/route.ts +203 -0
  15. package/src/app/api/fs/route.ts +75 -0
  16. package/src/app/api/git/action/route.ts +383 -0
  17. package/src/app/api/git/branches/route.ts +20 -0
  18. package/src/app/api/git/diff/route.ts +104 -0
  19. package/src/app/api/git/log/route.ts +28 -0
  20. package/src/app/api/git/status/route.ts +28 -0
  21. package/src/app/api/repos/route.ts +84 -0
  22. package/src/app/api/settings/route.ts +37 -0
  23. package/src/app/credentials/page.tsx +408 -0
  24. package/src/app/globals.css +109 -0
  25. package/src/app/icon.png +0 -0
  26. package/src/app/layout.tsx +38 -0
  27. package/src/app/page.tsx +10 -0
  28. package/src/app/providers.tsx +21 -0
  29. package/src/app/workspace/changes/page.tsx +27 -0
  30. package/src/app/workspace/custom-scripts/page.tsx +247 -0
  31. package/src/app/workspace/history/page.tsx +27 -0
  32. package/src/app/workspace/layout.tsx +26 -0
  33. package/src/app/workspace/page.tsx +27 -0
  34. package/src/app/workspace/settings/page.tsx +233 -0
  35. package/src/app/workspace/stashes/page.tsx +395 -0
  36. package/src/components/command-palette.tsx +178 -0
  37. package/src/components/context-menu.tsx +200 -0
  38. package/src/components/fs-browser.tsx +154 -0
  39. package/src/components/git/diff-view.tsx +137 -0
  40. package/src/components/git/git-graph.tsx +489 -0
  41. package/src/components/git/grouped-diff-viewer.tsx +332 -0
  42. package/src/components/git/history-view.tsx +4862 -0
  43. package/src/components/git/image-diff-view.tsx +342 -0
  44. package/src/components/git/status-view.tsx +597 -0
  45. package/src/components/home-settings-modal.tsx +192 -0
  46. package/src/components/layout/sidebar.tsx +256 -0
  47. package/src/components/repo-list.tsx +206 -0
  48. package/src/components/theme-toggle.tsx +37 -0
  49. package/src/components/toaster.tsx +36 -0
  50. package/src/components/workspace-repo-open-tracker.tsx +39 -0
  51. package/src/hooks/use-credentials.ts +123 -0
  52. package/src/hooks/use-escape-dismiss.ts +72 -0
  53. package/src/hooks/use-git.ts +448 -0
  54. package/src/hooks/use-toast.ts +280 -0
  55. package/src/hooks/use-workspace-title.ts +23 -0
  56. package/src/lib/api-utils.ts +24 -0
  57. package/src/lib/branch-colors.ts +98 -0
  58. package/src/lib/credentials.ts +404 -0
  59. package/src/lib/git.ts +1510 -0
  60. package/src/lib/graph-utils.ts +253 -0
  61. package/src/lib/store.ts +145 -0
  62. package/src/lib/types.ts +95 -0
  63. package/src/lib/utils.ts +266 -0
  64. 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
+ });