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,192 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useTheme } from 'next-themes';
5
+ import { FileSystemBrowser } from './fs-browser';
6
+ import { useEscapeDismiss } from '@/hooks/use-escape-dismiss';
7
+
8
+ interface Settings {
9
+ defaultRootFolder: string | null;
10
+ resolvedDefaultFolder: string;
11
+ }
12
+
13
+ interface HomeSettingsModalProps {
14
+ open: boolean;
15
+ onOpenChange: (open: boolean) => void;
16
+ onSettingsChange?: (settings: Settings) => void;
17
+ }
18
+
19
+ export function HomeSettingsModal({ open, onOpenChange, onSettingsChange }: HomeSettingsModalProps) {
20
+ const { theme, setTheme } = useTheme();
21
+ const [mounted, setMounted] = useState(false);
22
+ const [settings, setSettings] = useState<Settings | null>(null);
23
+ const [isLoading, setIsLoading] = useState(false);
24
+ const [isSaving, setIsSaving] = useState(false);
25
+ const [folderBrowserOpen, setFolderBrowserOpen] = useState(false);
26
+ const [localDefaultFolder, setLocalDefaultFolder] = useState<string>('');
27
+
28
+ useEffect(() => {
29
+ setMounted(true);
30
+ }, []);
31
+
32
+ const loadSettings = async () => {
33
+ setIsLoading(true);
34
+ try {
35
+ const res = await fetch('/api/settings');
36
+ if (res.ok) {
37
+ const data = await res.json();
38
+ setSettings(data);
39
+ setLocalDefaultFolder(data.defaultRootFolder || '');
40
+ }
41
+ } catch (e) {
42
+ console.error('Failed to load settings:', e);
43
+ } finally {
44
+ setIsLoading(false);
45
+ }
46
+ };
47
+
48
+ useEffect(() => {
49
+ if (open) {
50
+ loadSettings();
51
+ }
52
+ }, [open]);
53
+
54
+ useEscapeDismiss(open, () => onOpenChange(false));
55
+
56
+ const handleSave = async () => {
57
+ setIsSaving(true);
58
+ try {
59
+ const res = await fetch('/api/settings', {
60
+ method: 'PUT',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({
63
+ defaultRootFolder: localDefaultFolder.trim() || null,
64
+ }),
65
+ });
66
+ if (res.ok) {
67
+ const data = await res.json();
68
+ setSettings(data);
69
+ onSettingsChange?.(data);
70
+ onOpenChange(false);
71
+ }
72
+ } catch (e) {
73
+ console.error('Failed to save settings:', e);
74
+ } finally {
75
+ setIsSaving(false);
76
+ }
77
+ };
78
+
79
+ const handleFolderSelect = (path: string) => {
80
+ setLocalDefaultFolder(path);
81
+ };
82
+
83
+ const handleReset = () => {
84
+ setLocalDefaultFolder('');
85
+ };
86
+
87
+ if (!open) return null;
88
+
89
+ return (
90
+ <>
91
+ <dialog className="modal modal-open">
92
+ <div className="modal-box">
93
+ <h3 className="font-bold text-lg">Settings</h3>
94
+ <p className="py-4 opacity-70">Configure your application preferences.</p>
95
+
96
+ {isLoading ? (
97
+ <div className="flex items-center justify-center py-8">
98
+ <span className="loading loading-spinner loading-md"></span>
99
+ </div>
100
+ ) : (
101
+ <div className="space-y-6">
102
+ {/* Theme Selection */}
103
+ <div className="form-control w-full">
104
+ <label className="label">
105
+ <span className="label-text">Color Theme</span>
106
+ </label>
107
+ <div className="text-xs opacity-70 mb-2">
108
+ Choose your preferred color theme for the application.
109
+ </div>
110
+ <div className="flex gap-2">
111
+ <button
112
+ className={`btn flex-1 ${theme === 'system' ? 'btn-primary' : ''}`}
113
+ onClick={() => setTheme('system')}
114
+ disabled={!mounted}
115
+ >
116
+ <i className="iconoir-computer text-[20px] mr-2" aria-hidden="true" />
117
+ System
118
+ </button>
119
+ <button
120
+ className={`btn flex-1 ${theme === 'light' ? 'btn-primary' : ''}`}
121
+ onClick={() => setTheme('light')}
122
+ disabled={!mounted}
123
+ >
124
+ <i className="iconoir-sun-light text-[20px] mr-2" aria-hidden="true" />
125
+ Light
126
+ </button>
127
+ <button
128
+ className={`btn flex-1 ${theme === 'dark' ? 'btn-primary' : ''}`}
129
+ onClick={() => setTheme('dark')}
130
+ disabled={!mounted}
131
+ >
132
+ <i className="iconoir-moon-sat text-[20px] mr-2" aria-hidden="true" />
133
+ Dark
134
+ </button>
135
+ </div>
136
+ </div>
137
+
138
+ {/* Default Root Folder */}
139
+ <div className="form-control w-full">
140
+ <label className="label">
141
+ <span className="label-text">Default Root Folder</span>
142
+ </label>
143
+ <div className="text-xs opacity-70 mb-2">
144
+ The starting folder when browsing for new repositories. Leave empty to use your home folder.
145
+ </div>
146
+ <div className="flex gap-2">
147
+ <input
148
+ type="text"
149
+ placeholder={settings?.resolvedDefaultFolder || 'User home folder'}
150
+ className="input input-bordered w-full font-mono text-sm"
151
+ value={localDefaultFolder}
152
+ onChange={(e) => setLocalDefaultFolder(e.target.value)}
153
+ autoFocus
154
+ />
155
+ <button className="btn btn-square" onClick={() => setFolderBrowserOpen(true)} title="Browse folders">
156
+ <i className="iconoir-folder text-[20px]" aria-hidden="true" />
157
+ </button>
158
+ </div>
159
+ {localDefaultFolder && (
160
+ <div className="mt-2">
161
+ <button type="button" className="link link-hover text-primary text-xs" onClick={handleReset}>
162
+ Reset to default (home folder)
163
+ </button>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+ )}
169
+
170
+ <div className="modal-action">
171
+ <button className="btn" onClick={() => onOpenChange(false)}>Close</button>
172
+ <button className="btn btn-primary" onClick={handleSave} disabled={isSaving || isLoading}>
173
+ {isSaving && <span className="loading loading-spinner loading-xs"></span>}
174
+ Save Folder Settings
175
+ </button>
176
+ </div>
177
+ </div>
178
+ <form method="dialog" className="modal-backdrop">
179
+ <button onClick={() => onOpenChange(false)}>close</button>
180
+ </form>
181
+ </dialog>
182
+
183
+ <FileSystemBrowser
184
+ open={folderBrowserOpen}
185
+ onOpenChange={setFolderBrowserOpen}
186
+ onSelect={handleFolderSelect}
187
+ initialPath={localDefaultFolder || settings?.resolvedDefaultFolder}
188
+ title="Select default root folder"
189
+ />
190
+ </>
191
+ );
192
+ }
@@ -0,0 +1,256 @@
1
+ 'use client';
2
+
3
+ import { cn, getRepoFolderName, getRepositoryDisplayName } from '@/lib/utils';
4
+ import Link from 'next/link';
5
+ import { HomeSettingsModal } from '@/components/home-settings-modal';
6
+ import { usePathname, useSearchParams, useRouter } from 'next/navigation';
7
+ import { useState, useEffect, useCallback } from 'react';
8
+ import { useGitStatus, useRepository, useUpdateSettings } from '@/hooks/use-git';
9
+
10
+ const SIDEBAR_COLLAPSED_KEY = 'workspace-sidebar-collapsed';
11
+ const SIDEBAR_WIDTH_EXPANDED = 256; // w-64
12
+ const SIDEBAR_WIDTH_COLLAPSED = 64; // w-16
13
+
14
+ type SidebarProps = React.HTMLAttributes<HTMLDivElement>;
15
+ type SidebarPropsWithInitialState = SidebarProps & {
16
+ initialCollapsed?: boolean;
17
+ };
18
+
19
+ export function Sidebar({ className, initialCollapsed = false }: SidebarPropsWithInitialState) {
20
+ const pathname = usePathname();
21
+ const searchParams = useSearchParams();
22
+ const router = useRouter();
23
+ const repoPath = searchParams.get('path') || '';
24
+ const repository = useRepository(repoPath || null);
25
+ const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
26
+ const [enableTransition, setEnableTransition] = useState(false);
27
+ const [settingsOpen, setSettingsOpen] = useState(false);
28
+
29
+ const updateSettings = useUpdateSettings();
30
+
31
+ // Fetch git status to get uncommitted changes count
32
+ const { data: gitStatus } = useGitStatus(repoPath || null);
33
+ const changesCount = gitStatus?.files?.length ?? 0;
34
+ const repoDisplayName = repository
35
+ ? getRepositoryDisplayName(repository)
36
+ : (repoPath ? getRepoFolderName(repoPath) : '');
37
+
38
+ // Enable transitions only after initial paint to avoid first-load animation.
39
+ useEffect(() => {
40
+ let frame2: number | null = null;
41
+ const frame1 = requestAnimationFrame(() => {
42
+ frame2 = requestAnimationFrame(() => {
43
+ setEnableTransition(true);
44
+ });
45
+ });
46
+
47
+ return () => {
48
+ cancelAnimationFrame(frame1);
49
+ if (frame2 !== null) {
50
+ cancelAnimationFrame(frame2);
51
+ }
52
+ };
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
57
+ }, [isCollapsed]);
58
+
59
+ // Save collapsed state to global settings and localStorage
60
+ const toggleCollapsed = useCallback(() => {
61
+ const newValue = !isCollapsed;
62
+ setIsCollapsed(newValue);
63
+
64
+ updateSettings.mutate({ sidebarCollapsed: newValue });
65
+ }, [isCollapsed, updateSettings]);
66
+
67
+ const getHref = (subPath: string = '') => {
68
+ const p = new URLSearchParams(searchParams.toString());
69
+ // Clean up tab if it exists from previous version, though we are moving away from it.
70
+ p.delete('tab');
71
+ return `/workspace${subPath}?${p.toString()}`;
72
+ };
73
+
74
+ const isActive = (view: 'status' | 'history' | 'custom-scripts' | 'settings' | 'stashes') => {
75
+ if (view === 'status') return pathname === '/workspace/changes';
76
+ if (view === 'history') return pathname === '/workspace' || pathname.startsWith('/workspace/history');
77
+ if (view === 'custom-scripts') return pathname.startsWith('/workspace/custom-scripts');
78
+ if (view === 'settings') return pathname.startsWith('/workspace/settings');
79
+ if (view === 'stashes') return pathname.startsWith('/workspace/stashes');
80
+ return false;
81
+ };
82
+
83
+ // Calculate width
84
+ const sidebarWidth = isCollapsed ? SIDEBAR_WIDTH_COLLAPSED : SIDEBAR_WIDTH_EXPANDED;
85
+
86
+ return (
87
+ <div
88
+ style={{
89
+ width: sidebarWidth
90
+ }}
91
+ className={cn(
92
+ "pb-12 border-r border-base-300 min-h-screen bg-base-100 relative",
93
+ enableTransition && "transition-all duration-300",
94
+ className
95
+ )}
96
+ >
97
+ <div className="space-y-4 py-4">
98
+ <div className={cn("px-3 py-2", isCollapsed && "px-2")}>
99
+ <div className={cn("mb-6 flex items-center", isCollapsed ? "flex-col gap-2 px-0" : "justify-between px-4")}>
100
+ {!isCollapsed && (
101
+ <a
102
+ href="/"
103
+ onClick={(e) => {
104
+ if (e.metaKey || e.ctrlKey) {
105
+ // Cmd/Ctrl+click: open in new tab (default behavior)
106
+ return;
107
+ }
108
+ e.preventDefault();
109
+ router.push('/');
110
+ }}
111
+ className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer text-base-content overflow-hidden"
112
+ title={repoDisplayName ? `${repoDisplayName} - Go to Home` : "Go to Home"}
113
+ >
114
+ <img src="/icon.png" alt="Trident" className="h-5 w-5 flex-shrink-0" />
115
+ <h2 className="text-lg font-bold tracking-tight truncate">
116
+ {repoDisplayName || "Trident"}
117
+ </h2>
118
+ </a>
119
+ )}
120
+ {isCollapsed && (
121
+ <a
122
+ href="/"
123
+ onClick={(e) => {
124
+ if (e.metaKey || e.ctrlKey) {
125
+ // Cmd/Ctrl+click: open in new tab (default behavior)
126
+ return;
127
+ }
128
+ e.preventDefault();
129
+ router.push('/');
130
+ }}
131
+ className="flex items-center justify-center h-8 w-8 hover:opacity-80 transition-opacity cursor-pointer"
132
+ title={repoDisplayName ? `${repoDisplayName} - Go to Home` : "Go to Home"}
133
+ >
134
+ <img src="/icon.png" alt={repoDisplayName || "Trident"} className="h-5 w-5" />
135
+ </a>
136
+ )}
137
+ <div className={cn("flex items-center gap-1", isCollapsed && "flex-col")}>
138
+ <button
139
+ className="btn btn-ghost btn-sm btn-square"
140
+ onClick={toggleCollapsed}
141
+ title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
142
+ >
143
+ {isCollapsed ? <i className="iconoir-fast-arrow-right text-[16px]" aria-hidden="true" /> : <i className="iconoir-fast-arrow-left text-[16px]" aria-hidden="true" />}
144
+ </button>
145
+ </div>
146
+ </div>
147
+
148
+ {!isCollapsed && repoPath && (
149
+ <div className="px-4 mb-6">
150
+ <p className="text-[10px] font-mono opacity-60 break-all border border-base-300 rounded p-2 bg-base-200/30" title={repoPath}>
151
+ {repoPath}
152
+ </p>
153
+ </div>
154
+ )}
155
+
156
+ <div className="space-y-1">
157
+ <Link
158
+ href={getHref()}
159
+ className={cn(
160
+ "btn btn-ghost w-full justify-start font-normal",
161
+ isCollapsed ? "px-0 justify-center" : "",
162
+ isActive('history') && "btn-active font-medium"
163
+ )}
164
+ title={isCollapsed ? "History" : undefined}
165
+ >
166
+ <i className={cn("iconoir-git-fork text-[20px]", !isCollapsed && "mr-2")} aria-hidden="true" />
167
+ {!isCollapsed && "History"}
168
+ </Link>
169
+
170
+ <Link
171
+ href={getHref('/changes')}
172
+ className={cn(
173
+ "btn btn-ghost w-full justify-start font-normal",
174
+ isCollapsed ? "px-0 justify-center" : "",
175
+ isActive('status') && "btn-active font-medium"
176
+ )}
177
+ title={isCollapsed ? `Changes${changesCount > 0 ? ` (${changesCount})` : ''}` : undefined}
178
+ >
179
+ <div className={cn("relative flex items-center", !isCollapsed && "mr-2")}>
180
+ <i className="iconoir-clock text-[20px]" aria-hidden="true" />
181
+ {isCollapsed && changesCount > 0 && (
182
+ <span className="absolute -top-1 -right-1 badge badge-primary badge-xs scale-75">
183
+ {changesCount > 99 ? '99+' : changesCount}
184
+ </span>
185
+ )}
186
+ </div>
187
+ {!isCollapsed && (
188
+ <span className="flex-1 flex justify-between items-center">
189
+ Changes
190
+ {changesCount > 0 && <span className="badge badge-sm">{changesCount}</span>}
191
+ </span>
192
+ )}
193
+ </Link>
194
+
195
+ <Link
196
+ href={getHref('/stashes')}
197
+ className={cn(
198
+ "btn btn-ghost w-full justify-start font-normal",
199
+ isCollapsed ? "px-0 justify-center" : "",
200
+ isActive('stashes') && "btn-active font-medium"
201
+ )}
202
+ title={isCollapsed ? "Stashes" : undefined}
203
+ >
204
+ <i className={cn("iconoir-download-square text-[20px]", !isCollapsed && "mr-2")} aria-hidden="true" />
205
+ {!isCollapsed && "Stashes"}
206
+ </Link>
207
+
208
+ <Link
209
+ href={getHref('/custom-scripts')}
210
+ className={cn(
211
+ "btn btn-ghost w-full justify-start font-normal",
212
+ isCollapsed ? "px-0 justify-center" : "",
213
+ isActive('custom-scripts') && "btn-active font-medium"
214
+ )}
215
+ title={isCollapsed ? "Custom scripts" : undefined}
216
+ >
217
+ <i className={cn("iconoir-terminal text-[20px]", !isCollapsed && "mr-2")} aria-hidden="true" />
218
+ {!isCollapsed && "Custom scripts"}
219
+ </Link>
220
+
221
+ <Link
222
+ href={getHref('/settings')}
223
+ className={cn(
224
+ "btn btn-ghost w-full justify-start font-normal",
225
+ isCollapsed ? "px-0 justify-center" : "",
226
+ isActive('settings') && "btn-active font-medium"
227
+ )}
228
+ title={isCollapsed ? "Settings" : undefined}
229
+ >
230
+ <i className={cn("iconoir-settings text-[20px]", !isCollapsed && "mr-2")} aria-hidden="true" />
231
+ {!isCollapsed && "Settings"}
232
+ </Link>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ <div className={cn("absolute bottom-4 left-0 w-full", isCollapsed ? "px-2" : "px-6")}>
238
+ <div className={cn("flex items-center", isCollapsed ? "justify-center" : "gap-2")}>
239
+ <button
240
+ className="btn btn-ghost btn-sm btn-square"
241
+ onClick={() => setSettingsOpen(true)}
242
+ title={isCollapsed ? "Preferences" : undefined}
243
+ >
244
+ <i className="iconoir-ios-settings text-[20px]" aria-hidden="true" />
245
+ </button>
246
+ {!isCollapsed && <span className="text-xs opacity-70">Preferences</span>}
247
+ </div>
248
+ </div>
249
+
250
+ <HomeSettingsModal
251
+ open={settingsOpen}
252
+ onOpenChange={setSettingsOpen}
253
+ />
254
+ </div>
255
+ );
256
+ }
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import { useRepositories, useAddRepository, useDeleteRepository } from '@/hooks/use-git';
4
+ import { useState, useEffect } from 'react';
5
+ import Link from 'next/link';
6
+ import { useRouter } from 'next/navigation';
7
+ import { FileSystemBrowser } from './fs-browser';
8
+ import { toast } from '@/hooks/use-toast';
9
+ import { HomeSettingsModal } from './home-settings-modal';
10
+ import { getRepositoryDisplayName } from '@/lib/utils';
11
+ import { useEscapeDismiss } from '@/hooks/use-escape-dismiss';
12
+
13
+ export function RepoList() {
14
+ const { data: repos, isLoading } = useRepositories();
15
+ const addRepo = useAddRepository();
16
+ const deleteRepo = useDeleteRepository();
17
+ const [browserOpen, setBrowserOpen] = useState(false);
18
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
19
+ const [repoToDelete, setRepoToDelete] = useState<{ path: string; displayName: string } | null>(null);
20
+ const [settingsOpen, setSettingsOpen] = useState(false);
21
+ const [defaultRootFolder, setDefaultRootFolder] = useState<string | undefined>(undefined);
22
+ const router = useRouter();
23
+ useEscapeDismiss(deleteDialogOpen, () => setDeleteDialogOpen(false));
24
+
25
+ // Load settings on mount
26
+ useEffect(() => {
27
+ const loadSettings = async () => {
28
+ try {
29
+ const res = await fetch('/api/settings');
30
+ if (res.ok) {
31
+ const data = await res.json();
32
+ setDefaultRootFolder(data.resolvedDefaultFolder);
33
+ }
34
+ } catch (e) {
35
+ console.error('Failed to load settings:', e);
36
+ }
37
+ };
38
+ loadSettings();
39
+ }, []);
40
+
41
+ const handleAdd = async (path: string) => {
42
+ if (!path) return;
43
+ try {
44
+ await addRepo.mutateAsync({ path });
45
+ // Navigate to workspace page after successfully adding repository
46
+ router.push(`/workspace?path=${encodeURIComponent(path)}`);
47
+ } catch (error) {
48
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
49
+
50
+ if (errorMessage.includes('already exists')) {
51
+ toast({
52
+ type: 'warning',
53
+ title: 'Repository already added',
54
+ description: 'This repository is already in your list. Select it from the list to open it.',
55
+ });
56
+ } else {
57
+ toast({
58
+ type: 'error',
59
+ title: 'Failed to add repository',
60
+ description: errorMessage,
61
+ });
62
+ }
63
+ }
64
+ };
65
+
66
+ const handleDeleteClick = (e: React.MouseEvent, repo: { path: string; displayName: string }) => {
67
+ e.stopPropagation();
68
+ setRepoToDelete(repo);
69
+ setDeleteDialogOpen(true);
70
+ };
71
+
72
+ const handleDeleteConfirm = async () => {
73
+ if (!repoToDelete) return;
74
+ try {
75
+ await deleteRepo.mutateAsync({ path: repoToDelete.path });
76
+ setDeleteDialogOpen(false);
77
+ setRepoToDelete(null);
78
+ } catch (error) {
79
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
80
+ toast({
81
+ type: 'error',
82
+ title: 'Failed to delete repository',
83
+ description: errorMessage,
84
+ });
85
+ }
86
+ };
87
+
88
+ if (isLoading) return <div className="p-12 text-center opacity-70">Loading repositories...</div>;
89
+
90
+ return (
91
+ <div className="container mx-auto max-w-5xl py-12 px-6">
92
+ <div className="flex items-center justify-between mb-8">
93
+ <div>
94
+ <h1 className="text-2xl font-bold tracking-tight">Repositories</h1>
95
+ <p className="text-sm opacity-70 mt-1">Manage your git repositories.</p>
96
+ </div>
97
+ <div className="flex items-center gap-2">
98
+ <button className="btn btn-square btn-ghost" onClick={() => setSettingsOpen(true)} title="Settings">
99
+ <i className="iconoir-ios-settings text-[20px]" aria-hidden="true" />
100
+ </button>
101
+ <Link href="/credentials" className="btn gap-2">
102
+ <i className="iconoir-key text-[20px]" aria-hidden="true" />
103
+ Credentials
104
+ </Link>
105
+ <button onClick={() => setBrowserOpen(true)} className="btn btn-accent gap-2">
106
+ <i className="iconoir-plus-circle text-[20px]" aria-hidden="true" />
107
+ Add Repository
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="overflow-x-auto border border-base-300 rounded-lg bg-base-100">
113
+ <table className="table w-full">
114
+ <thead className="bg-base-200/50">
115
+ <tr>
116
+ <th>Name</th>
117
+ <th>Path</th>
118
+ <th className="text-right">Action</th>
119
+ </tr>
120
+ </thead>
121
+ <tbody>
122
+ {repos?.length === 0 && (
123
+ <tr>
124
+ <td colSpan={3} className="text-center py-12 text-muted-foreground">
125
+ <div className="flex flex-col items-center gap-2">
126
+ <p>No repositories found.</p>
127
+ <button className="btn btn-link" onClick={() => setBrowserOpen(true)}>Add your first repository</button>
128
+ </div>
129
+ </td>
130
+ </tr>
131
+ )}
132
+ {repos?.map((repo) => {
133
+ const repoDisplayName = getRepositoryDisplayName(repo);
134
+ return (
135
+ <tr
136
+ key={repo.path}
137
+ className="hover:bg-base-200/30 cursor-pointer group"
138
+ onClick={() => router.push(`/workspace?path=${encodeURIComponent(repo.path)}`)}
139
+ >
140
+ <td>
141
+ <div className="flex items-center gap-3">
142
+ <i className="iconoir-bookmark text-[20px] opacity-70 group-hover:text-primary transition-colors" aria-hidden="true" />
143
+ <span className="font-bold text-sm">{repoDisplayName}</span>
144
+ </div>
145
+ </td>
146
+ <td className="text-sm opacity-70 font-mono truncate max-w-xs" title={repo.path}>
147
+ {repo.path}
148
+ </td>
149
+ <td className="text-right">
150
+ <div className="flex items-center justify-end gap-1">
151
+ <Link
152
+ href={`/workspace?path=${encodeURIComponent(repo.path)}`}
153
+ className="btn btn-ghost btn-sm btn-square"
154
+ onClick={(e) => e.stopPropagation()}
155
+ >
156
+ <i className="iconoir-arrow-right text-[16px]" aria-hidden="true" />
157
+ </Link>
158
+ <button
159
+ className="btn btn-ghost btn-sm btn-square text-error hover:bg-error/10"
160
+ onClick={(e) => handleDeleteClick(e, { path: repo.path, displayName: repoDisplayName })}
161
+ >
162
+ <i className="iconoir-trash text-[16px]" aria-hidden="true" />
163
+ </button>
164
+ </div>
165
+ </td>
166
+ </tr>
167
+ );
168
+ })}
169
+ </tbody>
170
+ </table>
171
+ </div>
172
+
173
+ <FileSystemBrowser
174
+ open={browserOpen}
175
+ onOpenChange={setBrowserOpen}
176
+ onSelect={(path) => handleAdd(path)}
177
+ initialPath={defaultRootFolder}
178
+ />
179
+
180
+ <HomeSettingsModal
181
+ open={settingsOpen}
182
+ onOpenChange={setSettingsOpen}
183
+ onSettingsChange={(settings) => setDefaultRootFolder(settings.resolvedDefaultFolder)}
184
+ />
185
+
186
+ {deleteDialogOpen && (
187
+ <dialog className="modal modal-open">
188
+ <div className="modal-box">
189
+ <h3 className="font-bold text-lg">Delete Repository</h3>
190
+ <p className="py-4 break-words">
191
+ Are you sure you want to remove <strong className="break-all">{repoToDelete?.displayName}</strong> from the list?
192
+ This will only remove it from your repository list, not delete the files from your file system.
193
+ </p>
194
+ <div className="modal-action">
195
+ <button className="btn" onClick={() => setDeleteDialogOpen(false)}>Cancel</button>
196
+ <button className="btn btn-error" onClick={handleDeleteConfirm}>Delete</button>
197
+ </div>
198
+ </div>
199
+ <form method="dialog" className="modal-backdrop">
200
+ <button onClick={() => setDeleteDialogOpen(false)}>close</button>
201
+ </form>
202
+ </dialog>
203
+ )}
204
+ </div>
205
+ );
206
+ }