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,280 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ const TOAST_LIMIT = 5
6
+ const TOAST_REMOVE_DELAY = 5000
7
+
8
+ // Helper to copy text to clipboard
9
+ async function copyToClipboard(text: string): Promise<boolean> {
10
+ try {
11
+ await navigator.clipboard.writeText(text);
12
+ return true;
13
+ } catch {
14
+ // Fallback for older browsers
15
+ const textArea = document.createElement('textarea');
16
+ textArea.value = text;
17
+ textArea.style.position = 'fixed';
18
+ textArea.style.left = '-9999px';
19
+ document.body.appendChild(textArea);
20
+ textArea.select();
21
+ try {
22
+ document.execCommand('copy');
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ } finally {
27
+ document.body.removeChild(textArea);
28
+ }
29
+ }
30
+ }
31
+
32
+ export type ToastType = "default" | "success" | "warning" | "error" | "info"
33
+
34
+ export interface Toast {
35
+ id: string
36
+ title?: React.ReactNode
37
+ description?: React.ReactNode
38
+ type?: ToastType
39
+ variant?: "default" | "destructive" | "warning" // For compatibility
40
+ duration?: number
41
+ action?: React.ReactNode
42
+ }
43
+
44
+ let count = 0
45
+
46
+ function genId() {
47
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
48
+ return count.toString()
49
+ }
50
+
51
+ type Action =
52
+ | {
53
+ type: "ADD_TOAST"
54
+ toast: Toast
55
+ }
56
+ | {
57
+ type: "UPDATE_TOAST"
58
+ toast: Partial<Toast>
59
+ }
60
+ | {
61
+ type: "DISMISS_TOAST"
62
+ toastId?: string
63
+ }
64
+ | {
65
+ type: "REMOVE_TOAST"
66
+ toastId?: string
67
+ }
68
+
69
+ interface State {
70
+ toasts: Toast[]
71
+ }
72
+
73
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
74
+
75
+ const addToRemoveQueue = (toastId: string, duration = TOAST_REMOVE_DELAY) => {
76
+ if (toastTimeouts.has(toastId)) {
77
+ return
78
+ }
79
+
80
+ const timeout = setTimeout(() => {
81
+ toastTimeouts.delete(toastId)
82
+ dispatch({
83
+ type: "REMOVE_TOAST",
84
+ toastId: toastId,
85
+ })
86
+ }, duration)
87
+
88
+ toastTimeouts.set(toastId, timeout)
89
+ }
90
+
91
+ export const reducer = (state: State, action: Action): State => {
92
+ switch (action.type) {
93
+ case "ADD_TOAST":
94
+ return {
95
+ ...state,
96
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
97
+ }
98
+
99
+ case "UPDATE_TOAST":
100
+ return {
101
+ ...state,
102
+ toasts: state.toasts.map((t) =>
103
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
104
+ ),
105
+ }
106
+
107
+ case "DISMISS_TOAST": {
108
+ const { toastId } = action
109
+
110
+ if (toastId) {
111
+ addToRemoveQueue(toastId, 0) // Remove immediately for dismiss
112
+ } else {
113
+ state.toasts.forEach((toast) => {
114
+ addToRemoveQueue(toast.id, 0)
115
+ })
116
+ }
117
+
118
+ return {
119
+ ...state,
120
+ toasts: state.toasts.filter((t) => t.id !== toastId && toastId !== undefined),
121
+ }
122
+ }
123
+ case "REMOVE_TOAST":
124
+ if (action.toastId === undefined) {
125
+ return {
126
+ ...state,
127
+ toasts: [],
128
+ }
129
+ }
130
+ return {
131
+ ...state,
132
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
133
+ }
134
+ }
135
+ }
136
+
137
+ const listeners: Array<(state: State) => void> = []
138
+
139
+ let memoryState: State = { toasts: [] }
140
+
141
+ function dispatch(action: Action) {
142
+ memoryState = reducer(memoryState, action)
143
+ listeners.forEach((listener) => {
144
+ listener(memoryState)
145
+ })
146
+ }
147
+
148
+ function toast(props: Omit<Toast, "id">) {
149
+ const id = genId()
150
+
151
+ const update = (props: Toast) =>
152
+ dispatch({
153
+ type: "UPDATE_TOAST",
154
+ toast: { ...props, id },
155
+ })
156
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
157
+
158
+ dispatch({
159
+ type: "ADD_TOAST",
160
+ toast: {
161
+ ...props,
162
+ id,
163
+ },
164
+ })
165
+
166
+ // Auto remove
167
+ addToRemoveQueue(id, props.duration || TOAST_REMOVE_DELAY)
168
+
169
+ return {
170
+ id: id,
171
+ dismiss,
172
+ update,
173
+ }
174
+ }
175
+
176
+ function useToast() {
177
+ const [state, setState] = React.useState<State>(memoryState)
178
+
179
+ React.useEffect(() => {
180
+ listeners.push(setState)
181
+ return () => {
182
+ const index = listeners.indexOf(setState)
183
+ if (index > -1) {
184
+ listeners.splice(index, 1)
185
+ }
186
+ }
187
+ }, [state])
188
+
189
+ return {
190
+ ...state,
191
+ toast,
192
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
193
+ }
194
+ }
195
+
196
+ // Git error toast helper - shows a destructive toast with copy button
197
+ interface GitErrorToastOptions {
198
+ title?: string;
199
+ operation?: string;
200
+ onFix?: () => void | Promise<void>;
201
+ fixLabel?: string;
202
+ }
203
+
204
+ function showGitErrorToast(error: Error | string, options: GitErrorToastOptions = {}) {
205
+ const errorMessage = typeof error === 'string' ? error : error.message;
206
+ const title = options.title || (options.operation ? `${options.operation} Failed` : 'Git Operation Failed');
207
+
208
+ const id = genId();
209
+
210
+ // Create a stateful component for the copy button
211
+ const CopyableErrorDescription = () => {
212
+ const [copied, setCopied] = React.useState(false);
213
+ const [fixing, setFixing] = React.useState(false);
214
+
215
+ const handleCopy = async (e: React.MouseEvent) => {
216
+ e.stopPropagation();
217
+ const success = await copyToClipboard(errorMessage);
218
+ if (success) {
219
+ setCopied(true);
220
+ setTimeout(() => setCopied(false), 2000);
221
+ }
222
+ };
223
+
224
+ const handleFix = async (e: React.MouseEvent) => {
225
+ e.stopPropagation();
226
+ if (!options.onFix) return;
227
+
228
+ setFixing(true);
229
+ try {
230
+ await options.onFix();
231
+ dispatch({ type: "DISMISS_TOAST", toastId: id });
232
+ } catch (err) {
233
+ console.error("Fix failed", err);
234
+ setFixing(false);
235
+ }
236
+ };
237
+
238
+ // Container
239
+ return React.createElement('div', {
240
+ className: 'mt-2 space-y-3'
241
+ },
242
+ // Error message box
243
+ React.createElement('div', {
244
+ className: 'max-h-[120px] overflow-y-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed bg-black/20 p-3 rounded-md break-all text-error-content/90 border border-error-content/20'
245
+ }, errorMessage),
246
+ // Buttons container
247
+ React.createElement('div', { className: 'flex gap-2' },
248
+ // Copy button
249
+ React.createElement('button', {
250
+ onClick: handleCopy,
251
+ type: 'button',
252
+ className: `btn btn-xs ${copied ? 'btn-success text-white' : 'bg-white/20 hover:bg-white/30 text-white border-white/20'}`,
253
+ },
254
+ copied ? 'Copied!' : 'Copy Error'
255
+ ),
256
+ // Fix button
257
+ options.onFix ? React.createElement('button', {
258
+ onClick: handleFix,
259
+ type: 'button',
260
+ disabled: fixing,
261
+ className: 'btn btn-xs btn-warning text-white',
262
+ }, fixing ? 'Fixing...' : (options.fixLabel || 'Fix Issue')) : null
263
+ )
264
+ );
265
+ };
266
+
267
+ toast({
268
+ type: "error",
269
+ title,
270
+ description: React.createElement(CopyableErrorDescription),
271
+ duration: 10000 // Long duration for errors
272
+ });
273
+
274
+ return {
275
+ id,
276
+ dismiss: () => dispatch({ type: "DISMISS_TOAST", toastId: id }),
277
+ };
278
+ }
279
+
280
+ export { useToast, toast, showGitErrorToast }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useRepository } from './use-git';
5
+ import { getRepoFolderName, getRepositoryDisplayName } from '@/lib/utils';
6
+
7
+ /**
8
+ * Updates the document title with the repo name and page name.
9
+ * @param repoPath - The full path to the repository (e.g., "C:\\Users\\user\\projects\\repo" or "/Users/user/projects/repo")
10
+ * @param pageName - The name of the current page (e.g., "History", "Changes", "Stashes", "Settings")
11
+ */
12
+ export function useWorkspaceTitle(repoPath: string | null, pageName: string) {
13
+ const repository = useRepository(repoPath);
14
+
15
+ useEffect(() => {
16
+ const repoName = repoPath
17
+ ? repository
18
+ ? getRepositoryDisplayName(repository)
19
+ : getRepoFolderName(repoPath)
20
+ : 'Workspace';
21
+ document.title = `${repoName} | ${pageName}`;
22
+ }, [repoPath, repository, pageName]);
23
+ }
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Handles errors in Git API routes.
6
+ *
7
+ * @param error The error object caught in the try-catch block.
8
+ * @returns A NextResponse with the appropriate error message and status code.
9
+ */
10
+ export function handleGitError(error: unknown) {
11
+ console.error('Git API Error:', error);
12
+
13
+ if (error instanceof z.ZodError) {
14
+ return NextResponse.json({ error: error.issues }, { status: 400 });
15
+ }
16
+
17
+ const message = error instanceof Error ? error.message : String(error);
18
+
19
+ if (message.includes('not a git repository')) {
20
+ return NextResponse.json({ error: 'Not a git repository' }, { status: 400 });
21
+ }
22
+
23
+ return NextResponse.json({ error: message }, { status: 500 });
24
+ }
@@ -0,0 +1,98 @@
1
+ export interface BranchTagColors {
2
+ backgroundColor: string;
3
+ textColor: string;
4
+ }
5
+
6
+ function hashString(input: string): number {
7
+ let hash = 2166136261;
8
+ for (let i = 0; i < input.length; i++) {
9
+ hash ^= input.charCodeAt(i);
10
+ hash = Math.imul(hash, 16777619);
11
+ }
12
+ return hash >>> 0;
13
+ }
14
+
15
+ export function getBranchColorSeed(branchName: string): number {
16
+ return hashString(branchName);
17
+ }
18
+
19
+ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
20
+ const sat = s / 100;
21
+ const light = l / 100;
22
+ const c = (1 - Math.abs(2 * light - 1)) * sat;
23
+ const hh = h / 60;
24
+ const x = c * (1 - Math.abs((hh % 2) - 1));
25
+
26
+ let r1 = 0;
27
+ let g1 = 0;
28
+ let b1 = 0;
29
+
30
+ if (hh >= 0 && hh < 1) {
31
+ r1 = c;
32
+ g1 = x;
33
+ } else if (hh >= 1 && hh < 2) {
34
+ r1 = x;
35
+ g1 = c;
36
+ } else if (hh >= 2 && hh < 3) {
37
+ g1 = c;
38
+ b1 = x;
39
+ } else if (hh >= 3 && hh < 4) {
40
+ g1 = x;
41
+ b1 = c;
42
+ } else if (hh >= 4 && hh < 5) {
43
+ r1 = x;
44
+ b1 = c;
45
+ } else {
46
+ r1 = c;
47
+ b1 = x;
48
+ }
49
+
50
+ const m = light - c / 2;
51
+ return {
52
+ r: Math.round((r1 + m) * 255),
53
+ g: Math.round((g1 + m) * 255),
54
+ b: Math.round((b1 + m) * 255),
55
+ };
56
+ }
57
+
58
+ function relativeLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
59
+ const toLinear = (channel: number) => {
60
+ const value = channel / 255;
61
+ return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
62
+ };
63
+
64
+ const rLin = toLinear(r);
65
+ const gLin = toLinear(g);
66
+ const bLin = toLinear(b);
67
+ return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
68
+ }
69
+
70
+ function getReadableTextColor(rgb: { r: number; g: number; b: number }): string {
71
+ const backgroundLum = relativeLuminance(rgb);
72
+ const whiteContrast = (1.05) / (backgroundLum + 0.05);
73
+ const blackContrast = (backgroundLum + 0.05) / 0.05;
74
+ return whiteContrast >= blackContrast ? '#ffffff' : '#111827';
75
+ }
76
+
77
+ export function getBranchTagColors(branchName: string): BranchTagColors {
78
+ const hash = getBranchColorSeed(branchName);
79
+
80
+ const hue = hash % 360;
81
+ const saturation = 72 + ((hash >>> 9) % 20); // 72-91
82
+ const lightness = 84 + ((hash >>> 17) % 8); // 84-91
83
+
84
+ const rgb = hslToRgb(hue, saturation, lightness);
85
+ const textColor = getReadableTextColor(rgb);
86
+ const backgroundColor = `hsl(${hue} ${saturation}% ${lightness}%)`;
87
+
88
+ return {
89
+ backgroundColor,
90
+ textColor,
91
+ };
92
+ }
93
+
94
+ export function getBranchGraphColor(branchName: string): string {
95
+ const hash = getBranchColorSeed(branchName);
96
+ const hue = hash % 360;
97
+ return `hsl(${hue} 82% 52%)`;
98
+ }