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.
- package/README.md +198 -0
- package/bin/trident-git.mjs +153 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +30 -0
- package/package.json +60 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/credentials/route.ts +113 -0
- package/src/app/api/custom-scripts/route.ts +203 -0
- package/src/app/api/fs/route.ts +75 -0
- package/src/app/api/git/action/route.ts +383 -0
- package/src/app/api/git/branches/route.ts +20 -0
- package/src/app/api/git/diff/route.ts +104 -0
- package/src/app/api/git/log/route.ts +28 -0
- package/src/app/api/git/status/route.ts +28 -0
- package/src/app/api/repos/route.ts +84 -0
- package/src/app/api/settings/route.ts +37 -0
- package/src/app/credentials/page.tsx +408 -0
- package/src/app/globals.css +109 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +38 -0
- package/src/app/page.tsx +10 -0
- package/src/app/providers.tsx +21 -0
- package/src/app/workspace/changes/page.tsx +27 -0
- package/src/app/workspace/custom-scripts/page.tsx +247 -0
- package/src/app/workspace/history/page.tsx +27 -0
- package/src/app/workspace/layout.tsx +26 -0
- package/src/app/workspace/page.tsx +27 -0
- package/src/app/workspace/settings/page.tsx +233 -0
- package/src/app/workspace/stashes/page.tsx +395 -0
- package/src/components/command-palette.tsx +178 -0
- package/src/components/context-menu.tsx +200 -0
- package/src/components/fs-browser.tsx +154 -0
- package/src/components/git/diff-view.tsx +137 -0
- package/src/components/git/git-graph.tsx +489 -0
- package/src/components/git/grouped-diff-viewer.tsx +332 -0
- package/src/components/git/history-view.tsx +4862 -0
- package/src/components/git/image-diff-view.tsx +342 -0
- package/src/components/git/status-view.tsx +597 -0
- package/src/components/home-settings-modal.tsx +192 -0
- package/src/components/layout/sidebar.tsx +256 -0
- package/src/components/repo-list.tsx +206 -0
- package/src/components/theme-toggle.tsx +37 -0
- package/src/components/toaster.tsx +36 -0
- package/src/components/workspace-repo-open-tracker.tsx +39 -0
- package/src/hooks/use-credentials.ts +123 -0
- package/src/hooks/use-escape-dismiss.ts +72 -0
- package/src/hooks/use-git.ts +448 -0
- package/src/hooks/use-toast.ts +280 -0
- package/src/hooks/use-workspace-title.ts +23 -0
- package/src/lib/api-utils.ts +24 -0
- package/src/lib/branch-colors.ts +98 -0
- package/src/lib/credentials.ts +404 -0
- package/src/lib/git.ts +1510 -0
- package/src/lib/graph-utils.ts +253 -0
- package/src/lib/store.ts +145 -0
- package/src/lib/types.ts +95 -0
- package/src/lib/utils.ts +266 -0
- 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
|
+
}
|