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,37 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useTheme } from "next-themes"
5
+
6
+ export function ThemeToggle() {
7
+ const { theme, setTheme } = useTheme()
8
+ const [mounted, setMounted] = React.useState(false)
9
+
10
+ React.useEffect(() => {
11
+ setMounted(true)
12
+ }, [])
13
+
14
+ if (!mounted) {
15
+ return (
16
+ <button className="btn btn-ghost btn-sm btn-square">
17
+ <i className="iconoir-sun-light text-[20px]" aria-hidden="true" />
18
+ <span className="sr-only">Toggle theme</span>
19
+ </button>
20
+ )
21
+ }
22
+
23
+ const toggleTheme = () => {
24
+ if (theme === 'system') setTheme('light')
25
+ else if (theme === 'light') setTheme('dark')
26
+ else setTheme('system')
27
+ }
28
+
29
+ return (
30
+ <button className="btn btn-ghost btn-sm btn-square" onClick={toggleTheme} title={`Current theme: ${theme}`}>
31
+ {theme === 'system' && <i className="iconoir-computer text-[20px]" aria-hidden="true" />}
32
+ {theme === 'light' && <i className="iconoir-sun-light text-[20px]" aria-hidden="true" />}
33
+ {theme === 'dark' && <i className="iconoir-moon-sat text-[20px]" aria-hidden="true" />}
34
+ <span className="sr-only">Toggle theme</span>
35
+ </button>
36
+ )
37
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { useToast } from '@/hooks/use-toast';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ export function Toaster() {
7
+ const { toasts } = useToast();
8
+ const [mounted, setMounted] = useState(false);
9
+
10
+ useEffect(() => {
11
+ setMounted(true);
12
+ }, []);
13
+
14
+ if (!mounted) return null;
15
+
16
+ return (
17
+ <div className="toast toast-bottom toast-end z-50 p-4 gap-2">
18
+ {toasts.map((toast) => {
19
+ let alertClass = 'alert-info';
20
+ if (toast.type === 'success') alertClass = 'alert-success';
21
+ else if (toast.type === 'warning' || toast.variant === 'warning') alertClass = 'alert-warning';
22
+ else if (toast.type === 'error' || toast.variant === 'destructive') alertClass = 'alert-error';
23
+
24
+ return (
25
+ <div key={toast.id} className={`alert ${alertClass} shadow-lg max-w-md w-auto flex-col items-start gap-1 p-3 text-sm animate-in fade-in slide-in-from-bottom-2 duration-300`}>
26
+ <div className="w-full">
27
+ {toast.title && <h3 className="font-bold">{toast.title}</h3>}
28
+ {toast.description && <div className="opacity-90 break-words">{toast.description}</div>}
29
+ </div>
30
+ {toast.action && <div className="mt-2 w-full">{toast.action}</div>}
31
+ </div>
32
+ )
33
+ })}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { usePathname, useSearchParams } from 'next/navigation';
5
+ import { useRepositories, useUpdateRepository } from '@/hooks/use-git';
6
+
7
+ export function WorkspaceRepoOpenTracker() {
8
+ const pathname = usePathname();
9
+ const searchParams = useSearchParams();
10
+ const repoPath = searchParams.get('path');
11
+ const { data: repositories } = useRepositories();
12
+ const updateRepository = useUpdateRepository();
13
+ const lastTrackedPathRef = useRef<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ if (!pathname.startsWith('/workspace')) {
17
+ lastTrackedPathRef.current = null;
18
+ return;
19
+ }
20
+
21
+ if (!repoPath || !repositories?.some((repo) => repo.path === repoPath)) {
22
+ return;
23
+ }
24
+
25
+ if (lastTrackedPathRef.current === repoPath) {
26
+ return;
27
+ }
28
+
29
+ lastTrackedPathRef.current = repoPath;
30
+ updateRepository.mutate({
31
+ path: repoPath,
32
+ updates: {
33
+ lastOpenedAt: new Date().toISOString(),
34
+ },
35
+ });
36
+ }, [pathname, repoPath, repositories, updateRepository]);
37
+
38
+ return null;
39
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import type { Credential, CredentialType } from '@/lib/credentials';
5
+
6
+ // Re-export types for client-side use
7
+ export type { Credential, CredentialType };
8
+ export type { GitHubCredential, GitLabCredential } from '@/lib/credentials';
9
+
10
+ async function fetchCredentials(): Promise<Credential[]> {
11
+ const res = await fetch('/api/credentials');
12
+ if (!res.ok) {
13
+ throw new Error('Failed to fetch credentials');
14
+ }
15
+ return res.json();
16
+ }
17
+
18
+ export function useCredentials() {
19
+ return useQuery({
20
+ queryKey: ['credentials'],
21
+ queryFn: fetchCredentials,
22
+ });
23
+ }
24
+
25
+ // Create GitHub credential
26
+ interface CreateGitHubParams {
27
+ type: 'github';
28
+ token: string;
29
+ }
30
+
31
+ interface CreateGitLabParams {
32
+ type: 'gitlab';
33
+ serverUrl: string;
34
+ token: string;
35
+ }
36
+
37
+ type CreateCredentialParams = CreateGitHubParams | CreateGitLabParams;
38
+
39
+ async function createCredential(params: CreateCredentialParams): Promise<Credential> {
40
+ const res = await fetch('/api/credentials', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(params),
44
+ });
45
+
46
+ const data = await res.json();
47
+
48
+ if (!res.ok) {
49
+ throw new Error(data.error || 'Failed to create credential');
50
+ }
51
+
52
+ return data;
53
+ }
54
+
55
+ export function useCreateCredential() {
56
+ const queryClient = useQueryClient();
57
+
58
+ return useMutation({
59
+ mutationFn: createCredential,
60
+ onSuccess: () => {
61
+ queryClient.invalidateQueries({ queryKey: ['credentials'] });
62
+ },
63
+ });
64
+ }
65
+
66
+ // Update credential
67
+ interface UpdateCredentialParams {
68
+ id: string;
69
+ token: string;
70
+ }
71
+
72
+ async function updateCredential(params: UpdateCredentialParams): Promise<Credential> {
73
+ const res = await fetch('/api/credentials', {
74
+ method: 'PUT',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify(params),
77
+ });
78
+
79
+ const data = await res.json();
80
+
81
+ if (!res.ok) {
82
+ throw new Error(data.error || 'Failed to update credential');
83
+ }
84
+
85
+ return data;
86
+ }
87
+
88
+ export function useUpdateCredential() {
89
+ const queryClient = useQueryClient();
90
+
91
+ return useMutation({
92
+ mutationFn: updateCredential,
93
+ onSuccess: () => {
94
+ queryClient.invalidateQueries({ queryKey: ['credentials'] });
95
+ },
96
+ });
97
+ }
98
+
99
+ // Delete credential
100
+ async function deleteCredentialApi(id: string): Promise<void> {
101
+ const res = await fetch('/api/credentials', {
102
+ method: 'DELETE',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ id }),
105
+ });
106
+
107
+ const data = await res.json();
108
+
109
+ if (!res.ok) {
110
+ throw new Error(data.error || 'Failed to delete credential');
111
+ }
112
+ }
113
+
114
+ export function useDeleteCredential() {
115
+ const queryClient = useQueryClient();
116
+
117
+ return useMutation({
118
+ mutationFn: deleteCredentialApi,
119
+ onSuccess: () => {
120
+ queryClient.invalidateQueries({ queryKey: ['credentials'] });
121
+ },
122
+ });
123
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ type EscapeHandler = {
4
+ id: number;
5
+ onEscape: () => void;
6
+ };
7
+
8
+ const escapeHandlers: EscapeHandler[] = [];
9
+ let nextEscapeHandlerId = 0;
10
+ let hasGlobalKeyListener = false;
11
+
12
+ function handleEscapeKey(event: KeyboardEvent) {
13
+ if (event.key !== 'Escape') {
14
+ return;
15
+ }
16
+
17
+ const topHandler = escapeHandlers[escapeHandlers.length - 1];
18
+ if (!topHandler) {
19
+ return;
20
+ }
21
+
22
+ event.preventDefault();
23
+ topHandler.onEscape();
24
+ }
25
+
26
+ function attachGlobalListener() {
27
+ if (hasGlobalKeyListener || typeof document === 'undefined') {
28
+ return;
29
+ }
30
+
31
+ document.addEventListener('keydown', handleEscapeKey);
32
+ hasGlobalKeyListener = true;
33
+ }
34
+
35
+ function detachGlobalListenerIfUnused() {
36
+ if (!hasGlobalKeyListener || typeof document === 'undefined' || escapeHandlers.length > 0) {
37
+ return;
38
+ }
39
+
40
+ document.removeEventListener('keydown', handleEscapeKey);
41
+ hasGlobalKeyListener = false;
42
+ }
43
+
44
+ export function useEscapeDismiss(enabled: boolean, onEscape: () => void) {
45
+ const onEscapeRef = useRef(onEscape);
46
+
47
+ useEffect(() => {
48
+ onEscapeRef.current = onEscape;
49
+ }, [onEscape]);
50
+
51
+ useEffect(() => {
52
+ if (!enabled) {
53
+ return;
54
+ }
55
+
56
+ const id = ++nextEscapeHandlerId;
57
+ escapeHandlers.push({
58
+ id,
59
+ onEscape: () => onEscapeRef.current(),
60
+ });
61
+
62
+ attachGlobalListener();
63
+
64
+ return () => {
65
+ const index = escapeHandlers.findIndex((handler) => handler.id === id);
66
+ if (index >= 0) {
67
+ escapeHandlers.splice(index, 1);
68
+ }
69
+ detachGlobalListenerIfUnused();
70
+ };
71
+ }, [enabled]);
72
+ }