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,342 @@
1
+ 'use client';
2
+ /* eslint-disable @next/next/no-img-element */
3
+
4
+ import { DiffImage } from '@/lib/types';
5
+ import { cn } from '@/lib/utils';
6
+ import { CSSProperties, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+
8
+ interface ImageDiffViewProps {
9
+ filePath: string;
10
+ imageDiff?: DiffImage | null;
11
+ }
12
+
13
+ type ImageDiffMode = 'side-by-side' | 'swipe' | 'onion-skin';
14
+
15
+ const IMAGE_MODE_STORAGE_KEY = 'git-web:image-diff-mode';
16
+
17
+ const MODE_OPTIONS: Array<{ value: ImageDiffMode; label: string }> = [
18
+ { value: 'side-by-side', label: 'Side-by-Side' },
19
+ { value: 'swipe', label: 'Swipe' },
20
+ { value: 'onion-skin', label: 'Onion Skin' },
21
+ ];
22
+
23
+ const CHECKERBOARD_STYLE: CSSProperties = {
24
+ backgroundImage: `
25
+ linear-gradient(45deg, rgba(120, 120, 120, 0.16) 25%, transparent 25%),
26
+ linear-gradient(-45deg, rgba(120, 120, 120, 0.16) 25%, transparent 25%),
27
+ linear-gradient(45deg, transparent 75%, rgba(120, 120, 120, 0.16) 75%),
28
+ linear-gradient(-45deg, transparent 75%, rgba(120, 120, 120, 0.16) 75%)
29
+ `,
30
+ backgroundSize: '22px 22px',
31
+ backgroundPosition: '0 0, 0 11px, 11px -11px, -11px 0px',
32
+ };
33
+
34
+ function formatBase64Size(base64?: string): string | null {
35
+ if (!base64) return null;
36
+
37
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
38
+ const bytes = Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
39
+
40
+ if (bytes < 1024) return `${bytes} B`;
41
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
42
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
43
+ }
44
+
45
+ function useImagePreview(mimeType?: string, base64?: string) {
46
+ const [loadedPreview, setLoadedPreview] = useState<{
47
+ src: string;
48
+ dimensions: { width: number; height: number } | null;
49
+ } | null>(null);
50
+
51
+ const src = useMemo(() => {
52
+ if (!mimeType || !base64) return null;
53
+ return `data:${mimeType};base64,${base64}`;
54
+ }, [mimeType, base64]);
55
+
56
+ const formattedSize = useMemo(() => formatBase64Size(base64), [base64]);
57
+
58
+ useEffect(() => {
59
+ if (!src) return;
60
+
61
+ let cancelled = false;
62
+ const image = new Image();
63
+ image.onload = () => {
64
+ if (!cancelled) {
65
+ setLoadedPreview({
66
+ src,
67
+ dimensions: { width: image.naturalWidth, height: image.naturalHeight },
68
+ });
69
+ }
70
+ };
71
+ image.onerror = () => {
72
+ if (!cancelled) {
73
+ setLoadedPreview({ src, dimensions: null });
74
+ }
75
+ };
76
+ image.src = src;
77
+
78
+ return () => {
79
+ cancelled = true;
80
+ };
81
+ }, [src]);
82
+
83
+ const dimensions = src && loadedPreview?.src === src ? loadedPreview.dimensions : null;
84
+
85
+ const metadata =
86
+ dimensions && formattedSize
87
+ ? `${dimensions.width} x ${dimensions.height} px, ${formattedSize}`
88
+ : formattedSize
89
+ ? `-, ${formattedSize}`
90
+ : null;
91
+
92
+ return {
93
+ src,
94
+ metadata,
95
+ };
96
+ }
97
+
98
+ function ImagePane({
99
+ title,
100
+ filePath,
101
+ imageSrc,
102
+ metadata,
103
+ }: {
104
+ title: string;
105
+ filePath: string;
106
+ imageSrc: string | null;
107
+ metadata: string | null;
108
+ }) {
109
+ return (
110
+ <section className="border border-base-300 rounded-lg bg-base-200/30 overflow-hidden h-full min-h-0 flex flex-col">
111
+ <header className="px-3 py-2 border-b border-base-300 text-[10px] uppercase tracking-wider font-bold opacity-70">
112
+ {metadata ? `${title} (${metadata})` : title}
113
+ </header>
114
+ <div className="flex-1 min-h-0 flex items-center justify-center p-3" style={CHECKERBOARD_STYLE}>
115
+ {imageSrc ? (
116
+ <img
117
+ src={imageSrc}
118
+ alt={`${title} image for ${filePath}`}
119
+ className="max-w-full max-h-full object-contain"
120
+ draggable={false}
121
+ />
122
+ ) : (
123
+ <span className="text-sm opacity-50">No image</span>
124
+ )}
125
+ </div>
126
+ </section>
127
+ );
128
+ }
129
+
130
+ export function ImageDiffView({ filePath, imageDiff }: ImageDiffViewProps) {
131
+ const leftPreview = useImagePreview(imageDiff?.left?.mimeType, imageDiff?.left?.base64);
132
+ const rightPreview = useImagePreview(imageDiff?.right?.mimeType, imageDiff?.right?.base64);
133
+ const hasBothImages = Boolean(leftPreview.src && rightPreview.src);
134
+
135
+ const [mode, setMode] = useState<ImageDiffMode>(() => {
136
+ if (typeof window === 'undefined') return 'side-by-side';
137
+
138
+ try {
139
+ const stored = localStorage.getItem(IMAGE_MODE_STORAGE_KEY);
140
+ if (stored === 'side-by-side' || stored === 'swipe' || stored === 'onion-skin') {
141
+ return stored;
142
+ }
143
+ } catch (error) {
144
+ console.error('Failed to load image diff mode preference:', error);
145
+ }
146
+
147
+ return 'side-by-side';
148
+ });
149
+
150
+ const [swipePosition, setSwipePosition] = useState(50);
151
+ const [onionBlend, setOnionBlend] = useState(50);
152
+ const [swipePointerId, setSwipePointerId] = useState<number | null>(null);
153
+ const compareCanvasRef = useRef<HTMLDivElement | null>(null);
154
+ const effectiveMode: ImageDiffMode = !hasBothImages && mode !== 'side-by-side' ? 'side-by-side' : mode;
155
+
156
+ useEffect(() => {
157
+ try {
158
+ localStorage.setItem(IMAGE_MODE_STORAGE_KEY, mode);
159
+ } catch (error) {
160
+ console.error('Failed to save image diff mode preference:', error);
161
+ }
162
+ }, [mode]);
163
+
164
+ const updateSwipePosition = useCallback((clientX: number) => {
165
+ const container = compareCanvasRef.current;
166
+ if (!container) return;
167
+
168
+ const rect = container.getBoundingClientRect();
169
+ if (rect.width <= 0) return;
170
+
171
+ const ratio = (clientX - rect.left) / rect.width;
172
+ const clamped = Math.max(0, Math.min(1, ratio));
173
+ setSwipePosition(clamped * 100);
174
+ }, []);
175
+
176
+ const onSwipePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
177
+ if (effectiveMode !== 'swipe' || !hasBothImages) return;
178
+
179
+ event.preventDefault();
180
+ event.currentTarget.setPointerCapture(event.pointerId);
181
+ setSwipePointerId(event.pointerId);
182
+ updateSwipePosition(event.clientX);
183
+ }, [effectiveMode, hasBothImages, updateSwipePosition]);
184
+
185
+ const onSwipePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
186
+ if (effectiveMode !== 'swipe' || swipePointerId !== event.pointerId) return;
187
+ updateSwipePosition(event.clientX);
188
+ }, [effectiveMode, swipePointerId, updateSwipePosition]);
189
+
190
+ const onSwipePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
191
+ if (swipePointerId !== event.pointerId) return;
192
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
193
+ event.currentTarget.releasePointerCapture(event.pointerId);
194
+ }
195
+ setSwipePointerId(null);
196
+ }, [swipePointerId]);
197
+
198
+ const renderSideBySide = () => (
199
+ <div className="min-w-[720px] h-full min-h-0 grid grid-cols-2 gap-4">
200
+ <ImagePane
201
+ title="Old"
202
+ filePath={filePath}
203
+ imageSrc={leftPreview.src}
204
+ metadata={leftPreview.metadata}
205
+ />
206
+ <ImagePane
207
+ title="New"
208
+ filePath={filePath}
209
+ imageSrc={rightPreview.src}
210
+ metadata={rightPreview.metadata}
211
+ />
212
+ </div>
213
+ );
214
+
215
+ const renderComparisonMode = () => (
216
+ <div className="h-full min-h-0 flex flex-col">
217
+ <div className="px-1 pb-2 text-sm font-semibold flex items-center justify-between gap-3">
218
+ <div className="flex items-center gap-2 min-w-0">
219
+ <span className="text-error shrink-0">Old</span>
220
+ <span className="text-[11px] opacity-60 truncate font-mono">{leftPreview.metadata ?? 'No image'}</span>
221
+ </div>
222
+ <div className="flex items-center gap-2 min-w-0 justify-end">
223
+ <span className="text-[11px] opacity-60 truncate font-mono">{rightPreview.metadata ?? 'No image'}</span>
224
+ <span className="text-success shrink-0">New</span>
225
+ </div>
226
+ </div>
227
+
228
+ <div
229
+ ref={compareCanvasRef}
230
+ className={cn(
231
+ 'relative flex-1 min-h-0 rounded-lg border border-base-300 overflow-hidden shadow-sm',
232
+ effectiveMode === 'swipe' && hasBothImages ? 'cursor-ew-resize touch-none' : ''
233
+ )}
234
+ style={CHECKERBOARD_STYLE}
235
+ onPointerDown={onSwipePointerDown}
236
+ onPointerMove={onSwipePointerMove}
237
+ onPointerUp={onSwipePointerUp}
238
+ onPointerCancel={onSwipePointerUp}
239
+ >
240
+ {leftPreview.src ? (
241
+ <img
242
+ src={leftPreview.src}
243
+ alt={`Old image for ${filePath}`}
244
+ className="absolute inset-0 w-full h-full object-contain pointer-events-none select-none"
245
+ draggable={false}
246
+ />
247
+ ) : null}
248
+
249
+ {effectiveMode === 'onion-skin' ? (
250
+ rightPreview.src ? (
251
+ <img
252
+ src={rightPreview.src}
253
+ alt={`New image for ${filePath}`}
254
+ className="absolute inset-0 w-full h-full object-contain pointer-events-none select-none"
255
+ style={{ opacity: onionBlend / 100 }}
256
+ draggable={false}
257
+ />
258
+ ) : null
259
+ ) : rightPreview.src ? (
260
+ <img
261
+ src={rightPreview.src}
262
+ alt={`New image for ${filePath}`}
263
+ className="absolute inset-0 w-full h-full object-contain pointer-events-none select-none"
264
+ style={{ clipPath: `inset(0 0 0 ${swipePosition}%)` }}
265
+ draggable={false}
266
+ />
267
+ ) : null}
268
+
269
+ {effectiveMode === 'swipe' && hasBothImages ? (
270
+ <>
271
+ <div
272
+ className="absolute top-0 bottom-0 w-[2px] bg-primary/90 pointer-events-none"
273
+ style={{ left: `${swipePosition}%`, transform: 'translateX(-1px)' }}
274
+ />
275
+ <div
276
+ className="absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border border-primary bg-base-100 pointer-events-none"
277
+ style={{ left: `${swipePosition}%`, transform: 'translate(-50%, -50%)' }}
278
+ />
279
+ </>
280
+ ) : null}
281
+
282
+ {!leftPreview.src && !rightPreview.src ? (
283
+ <div className="absolute inset-0 flex items-center justify-center text-sm opacity-50">No image data available</div>
284
+ ) : null}
285
+ </div>
286
+ </div>
287
+ );
288
+
289
+ return (
290
+ <div className="h-full min-h-0 p-4 box-border flex flex-col">
291
+ <div className={cn('flex-1 min-h-0', effectiveMode === 'side-by-side' ? 'overflow-auto' : 'overflow-hidden')}>
292
+ {effectiveMode === 'side-by-side' ? renderSideBySide() : renderComparisonMode()}
293
+ </div>
294
+
295
+ {effectiveMode === 'onion-skin' && hasBothImages ? (
296
+ <div className="mt-3 flex items-center justify-center gap-3 shrink-0">
297
+ <span className="text-sm font-semibold text-error">Old</span>
298
+ <input
299
+ type="range"
300
+ min={0}
301
+ max={100}
302
+ value={onionBlend}
303
+ onChange={(event) => setOnionBlend(Number(event.target.value))}
304
+ className="range range-primary range-sm w-[320px] max-w-[65vw]"
305
+ aria-label="Onion skin blend slider"
306
+ />
307
+ <span className="text-sm font-semibold text-success">New</span>
308
+ </div>
309
+ ) : null}
310
+
311
+ <div className="mt-3 flex items-center justify-center gap-2 shrink-0">
312
+ {MODE_OPTIONS.map((option) => {
313
+ const disabled = option.value !== 'side-by-side' && !hasBothImages;
314
+
315
+ return (
316
+ <button
317
+ key={option.value}
318
+ type="button"
319
+ onClick={() => setMode(option.value)}
320
+ disabled={disabled}
321
+ className={cn(
322
+ 'px-4 py-1.5 text-sm rounded-lg transition-colors font-semibold',
323
+ effectiveMode === option.value
324
+ ? 'bg-base-300 text-base-content'
325
+ : 'text-base-content/60 hover:text-base-content hover:bg-base-200',
326
+ disabled ? 'opacity-40 cursor-not-allowed hover:bg-transparent' : ''
327
+ )}
328
+ >
329
+ {option.label}
330
+ </button>
331
+ );
332
+ })}
333
+ </div>
334
+
335
+ {!hasBothImages ? (
336
+ <p className="text-center text-[11px] opacity-60 mt-2">
337
+ Swipe and Onion Skin modes require both old and new image versions.
338
+ </p>
339
+ ) : null}
340
+ </div>
341
+ );
342
+ }