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,247 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useMemo, useState } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import { useWorkspaceTitle } from '@/hooks/use-workspace-title';
|
|
6
|
+
import { useRepositories, useRepository, useUpdateRepository } from '@/hooks/use-git';
|
|
7
|
+
import { RepositoryCustomScript, RepositoryCustomScriptAction, RepositoryCustomScriptTarget } from '@/lib/types';
|
|
8
|
+
|
|
9
|
+
type ScriptDraft = {
|
|
10
|
+
name: string;
|
|
11
|
+
target: RepositoryCustomScriptTarget;
|
|
12
|
+
action: RepositoryCustomScriptAction;
|
|
13
|
+
content: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const EMPTY_DRAFT: ScriptDraft = {
|
|
17
|
+
name: '',
|
|
18
|
+
target: 'branch',
|
|
19
|
+
action: 'run-bash-script',
|
|
20
|
+
content: '',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function generateScriptId(): string {
|
|
24
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
25
|
+
return crypto.randomUUID();
|
|
26
|
+
}
|
|
27
|
+
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function WorkspaceCustomScriptsContent() {
|
|
31
|
+
const searchParams = useSearchParams();
|
|
32
|
+
const repoPath = searchParams.get('path');
|
|
33
|
+
|
|
34
|
+
useWorkspaceTitle(repoPath, 'Custom scripts');
|
|
35
|
+
|
|
36
|
+
const { isLoading: isReposLoading } = useRepositories();
|
|
37
|
+
const repository = useRepository(repoPath);
|
|
38
|
+
const updateRepository = useUpdateRepository();
|
|
39
|
+
|
|
40
|
+
const scripts = useMemo(() => repository?.customScripts ?? [], [repository?.customScripts]);
|
|
41
|
+
const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
|
|
42
|
+
const [draft, setDraft] = useState<ScriptDraft>(EMPTY_DRAFT);
|
|
43
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
if (isReposLoading) {
|
|
46
|
+
return <div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!repoPath || !repository) {
|
|
50
|
+
return <div className="p-8">Repository not found.</div>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resetForm = () => {
|
|
54
|
+
setDraft(EMPTY_DRAFT);
|
|
55
|
+
setEditingScriptId(null);
|
|
56
|
+
setFormError(null);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const saveScripts = (nextScripts: RepositoryCustomScript[]) => {
|
|
60
|
+
updateRepository.mutate({
|
|
61
|
+
path: repoPath,
|
|
62
|
+
updates: {
|
|
63
|
+
customScripts: nextScripts,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
|
|
71
|
+
const name = draft.name.trim();
|
|
72
|
+
const content = draft.content;
|
|
73
|
+
|
|
74
|
+
if (!name) {
|
|
75
|
+
setFormError('Script name is required.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!content.trim()) {
|
|
80
|
+
setFormError('Script content is required.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const nextScript: RepositoryCustomScript = {
|
|
85
|
+
id: editingScriptId || generateScriptId(),
|
|
86
|
+
name,
|
|
87
|
+
target: draft.target,
|
|
88
|
+
action: draft.action,
|
|
89
|
+
content,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const nextScripts = editingScriptId
|
|
93
|
+
? scripts.map((script) => (script.id === editingScriptId ? nextScript : script))
|
|
94
|
+
: [...scripts, nextScript];
|
|
95
|
+
|
|
96
|
+
saveScripts(nextScripts);
|
|
97
|
+
resetForm();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleEdit = (script: RepositoryCustomScript) => {
|
|
101
|
+
setEditingScriptId(script.id);
|
|
102
|
+
setDraft({
|
|
103
|
+
name: script.name,
|
|
104
|
+
target: script.target,
|
|
105
|
+
action: script.action,
|
|
106
|
+
content: script.content,
|
|
107
|
+
});
|
|
108
|
+
setFormError(null);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleDelete = (scriptId: string) => {
|
|
112
|
+
const nextScripts = scripts.filter((script) => script.id !== scriptId);
|
|
113
|
+
saveScripts(nextScripts);
|
|
114
|
+
if (editingScriptId === scriptId) {
|
|
115
|
+
resetForm();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="p-8 max-w-4xl mx-auto space-y-6">
|
|
121
|
+
<div>
|
|
122
|
+
<h1 className="text-2xl font-bold">Custom Scripts</h1>
|
|
123
|
+
<p className="text-sm opacity-70 mt-2">
|
|
124
|
+
Manage custom bash scripts for this repository. Scripts appear in branch context menus.
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="card bg-base-100 shadow-xl border border-base-200">
|
|
129
|
+
<div className="card-body">
|
|
130
|
+
<h2 className="card-title">{editingScriptId ? 'Edit Script' : 'New Script'}</h2>
|
|
131
|
+
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
132
|
+
<div className="form-control">
|
|
133
|
+
<label className="label">
|
|
134
|
+
<span className="label-text">Name</span>
|
|
135
|
+
</label>
|
|
136
|
+
<input
|
|
137
|
+
type="text"
|
|
138
|
+
className="input input-bordered w-full"
|
|
139
|
+
value={draft.name}
|
|
140
|
+
onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))}
|
|
141
|
+
placeholder="Example: Run tests"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
146
|
+
<div className="form-control">
|
|
147
|
+
<label className="label">
|
|
148
|
+
<span className="label-text">Target</span>
|
|
149
|
+
</label>
|
|
150
|
+
<select
|
|
151
|
+
className="select select-bordered w-full"
|
|
152
|
+
value={draft.target}
|
|
153
|
+
onChange={(e) => setDraft((prev) => ({ ...prev, target: e.target.value as RepositoryCustomScriptTarget }))}
|
|
154
|
+
>
|
|
155
|
+
<option value="branch">Branch</option>
|
|
156
|
+
</select>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div className="form-control">
|
|
160
|
+
<label className="label">
|
|
161
|
+
<span className="label-text">Action</span>
|
|
162
|
+
</label>
|
|
163
|
+
<select
|
|
164
|
+
className="select select-bordered w-full"
|
|
165
|
+
value={draft.action}
|
|
166
|
+
onChange={(e) => setDraft((prev) => ({ ...prev, action: e.target.value as RepositoryCustomScriptAction }))}
|
|
167
|
+
>
|
|
168
|
+
<option value="run-bash-script">Run bash script</option>
|
|
169
|
+
</select>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="form-control">
|
|
174
|
+
<label className="label">
|
|
175
|
+
<span className="label-text">Script Content</span>
|
|
176
|
+
</label>
|
|
177
|
+
<textarea
|
|
178
|
+
className="textarea textarea-bordered w-full h-56 font-mono text-sm"
|
|
179
|
+
value={draft.content}
|
|
180
|
+
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
|
|
181
|
+
placeholder={'#!/usr/bin/env bash\nset -euo pipefail\n\necho "Hello from custom script"'}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{formError && (
|
|
186
|
+
<div className="alert alert-error py-2">
|
|
187
|
+
<span>{formError}</span>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
<div className="flex items-center gap-2">
|
|
192
|
+
<button type="submit" className="btn btn-primary btn-sm" disabled={updateRepository.isPending}>
|
|
193
|
+
{editingScriptId ? 'Update Script' : 'Add Script'}
|
|
194
|
+
</button>
|
|
195
|
+
{editingScriptId && (
|
|
196
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={resetForm} disabled={updateRepository.isPending}>
|
|
197
|
+
Cancel Edit
|
|
198
|
+
</button>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</form>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="card bg-base-100 shadow-xl border border-base-200">
|
|
206
|
+
<div className="card-body">
|
|
207
|
+
<h2 className="card-title">Saved Scripts</h2>
|
|
208
|
+
{scripts.length === 0 ? (
|
|
209
|
+
<p className="text-sm opacity-60">No custom scripts yet.</p>
|
|
210
|
+
) : (
|
|
211
|
+
<div className="space-y-3">
|
|
212
|
+
{scripts.map((script) => (
|
|
213
|
+
<div key={script.id} className="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
214
|
+
<div className="flex items-start justify-between gap-3">
|
|
215
|
+
<div className="min-w-0">
|
|
216
|
+
<div className="font-medium truncate">{script.name}</div>
|
|
217
|
+
<div className="text-xs opacity-60 mt-1">
|
|
218
|
+
Target: Branch | Action: Run bash script
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
222
|
+
<button className="btn btn-ghost btn-xs" onClick={() => handleEdit(script)} disabled={updateRepository.isPending}>
|
|
223
|
+
Edit
|
|
224
|
+
</button>
|
|
225
|
+
<button className="btn btn-ghost btn-xs text-error" onClick={() => handleDelete(script.id)} disabled={updateRepository.isPending}>
|
|
226
|
+
Delete
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<pre className="mt-3 text-xs font-mono bg-base-200 rounded p-3 overflow-auto max-h-36 whitespace-pre-wrap break-words">{script.content}</pre>
|
|
231
|
+
</div>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default function WorkspaceCustomScriptsPage() {
|
|
242
|
+
return (
|
|
243
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>}>
|
|
244
|
+
<WorkspaceCustomScriptsContent />
|
|
245
|
+
</Suspense>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from 'next/navigation';
|
|
4
|
+
import { HistoryView } from '@/components/git/history-view';
|
|
5
|
+
import { Suspense } from 'react';
|
|
6
|
+
import { useWorkspaceTitle } from '@/hooks/use-workspace-title';
|
|
7
|
+
|
|
8
|
+
function WorkspaceHistoryContent() {
|
|
9
|
+
const searchParams = useSearchParams();
|
|
10
|
+
const repoPath = searchParams.get('path');
|
|
11
|
+
|
|
12
|
+
useWorkspaceTitle(repoPath, 'History');
|
|
13
|
+
|
|
14
|
+
if (!repoPath) {
|
|
15
|
+
return <div className="p-8">No repository path specified.</div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return <HistoryView repoPath={repoPath} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function WorkspaceHistoryPage() {
|
|
22
|
+
return (
|
|
23
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>}>
|
|
24
|
+
<WorkspaceHistoryContent />
|
|
25
|
+
</Suspense>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Sidebar } from '@/components/layout/sidebar';
|
|
2
|
+
import { Suspense } from 'react';
|
|
3
|
+
import { WorkspaceRepoOpenTracker } from '@/components/workspace-repo-open-tracker';
|
|
4
|
+
import { getSettings } from '@/lib/store';
|
|
5
|
+
|
|
6
|
+
export default function WorkspaceLayout({
|
|
7
|
+
children,
|
|
8
|
+
}: {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}) {
|
|
11
|
+
const sidebarCollapsed = getSettings().sidebarCollapsed ?? false;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex min-h-screen max-h-screen bg-base-100">
|
|
15
|
+
<Suspense fallback={null}>
|
|
16
|
+
<WorkspaceRepoOpenTracker />
|
|
17
|
+
</Suspense>
|
|
18
|
+
<Suspense fallback={<div className={`${sidebarCollapsed ? 'w-16' : 'w-64'} border-r border-base-300 min-h-screen bg-base-200/30 flex items-center justify-center`}><span className="loading loading-spinner"></span></div>}>
|
|
19
|
+
<Sidebar initialCollapsed={sidebarCollapsed} />
|
|
20
|
+
</Suspense>
|
|
21
|
+
<main className="flex-1 overflow-auto">
|
|
22
|
+
{children}
|
|
23
|
+
</main>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from 'next/navigation';
|
|
4
|
+
import { HistoryView } from '@/components/git/history-view';
|
|
5
|
+
import { Suspense } from 'react';
|
|
6
|
+
import { useWorkspaceTitle } from '@/hooks/use-workspace-title';
|
|
7
|
+
|
|
8
|
+
function WorkspaceHistoryContent() {
|
|
9
|
+
const searchParams = useSearchParams();
|
|
10
|
+
const repoPath = searchParams.get('path');
|
|
11
|
+
|
|
12
|
+
useWorkspaceTitle(repoPath, 'History');
|
|
13
|
+
|
|
14
|
+
if (!repoPath) {
|
|
15
|
+
return <div className="p-8">No repository path specified.</div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return <HistoryView repoPath={repoPath} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function WorkspacePage() {
|
|
22
|
+
return (
|
|
23
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>}>
|
|
24
|
+
<WorkspaceHistoryContent />
|
|
25
|
+
</Suspense>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from 'next/navigation';
|
|
4
|
+
import { Suspense, useMemo, useState } from 'react';
|
|
5
|
+
import { useWorkspaceTitle } from '@/hooks/use-workspace-title';
|
|
6
|
+
import { useRepositories, useUpdateRepository, useGitBranches } from '@/hooks/use-git';
|
|
7
|
+
import { useCredentials } from '@/hooks/use-credentials';
|
|
8
|
+
import { getRepoFolderName, getRepositoryDisplayName } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
function getHostname(url: string): string | null {
|
|
11
|
+
try {
|
|
12
|
+
// Handle git@github.com:user/repo.git format
|
|
13
|
+
if (url.startsWith('git@')) {
|
|
14
|
+
const match = url.match(/git@([^:]+):/);
|
|
15
|
+
return match ? match[1] : null;
|
|
16
|
+
}
|
|
17
|
+
// Handle https://github.com/user/repo.git format
|
|
18
|
+
return new URL(url).hostname;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function WorkspaceSettingsContent() {
|
|
25
|
+
const searchParams = useSearchParams();
|
|
26
|
+
const repoPath = searchParams.get('path');
|
|
27
|
+
|
|
28
|
+
useWorkspaceTitle(repoPath, 'Settings');
|
|
29
|
+
|
|
30
|
+
const { data: repos, isLoading: isLoadingRepos } = useRepositories();
|
|
31
|
+
const { data: credentials, isLoading: isLoadingCreds } = useCredentials();
|
|
32
|
+
const { data: gitData, isLoading: isLoadingGit } = useGitBranches(repoPath);
|
|
33
|
+
const updateRepo = useUpdateRepository();
|
|
34
|
+
|
|
35
|
+
const currentRepo = useMemo(() =>
|
|
36
|
+
repos?.find(r => r.path === repoPath),
|
|
37
|
+
[repos, repoPath]);
|
|
38
|
+
const [displayNameDraftState, setDisplayNameDraftState] = useState<{
|
|
39
|
+
path: string | null;
|
|
40
|
+
value: string;
|
|
41
|
+
isDirty: boolean;
|
|
42
|
+
}>({
|
|
43
|
+
path: null,
|
|
44
|
+
value: '',
|
|
45
|
+
isDirty: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const matchingCredentials = useMemo(() => {
|
|
49
|
+
if (!credentials || !gitData?.remoteUrls) return [];
|
|
50
|
+
|
|
51
|
+
// specific remote URLs
|
|
52
|
+
const urls = Object.values(gitData.remoteUrls);
|
|
53
|
+
const remoteHosts = new Set(urls.map(getHostname).filter(Boolean) as string[]);
|
|
54
|
+
|
|
55
|
+
return credentials.filter(cred => {
|
|
56
|
+
if (cred.type === 'github') {
|
|
57
|
+
return remoteHosts.has('github.com');
|
|
58
|
+
}
|
|
59
|
+
if (cred.type === 'gitlab' && cred.serverUrl) {
|
|
60
|
+
const credHost = getHostname(cred.serverUrl);
|
|
61
|
+
return credHost && remoteHosts.has(credHost);
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
});
|
|
65
|
+
}, [credentials, gitData]);
|
|
66
|
+
|
|
67
|
+
const isLoading = isLoadingRepos || isLoadingCreds || isLoadingGit;
|
|
68
|
+
|
|
69
|
+
if (isLoading) {
|
|
70
|
+
return <div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!repoPath || !currentRepo) {
|
|
74
|
+
return <div className="p-8">Repository not found.</div>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const displayNameDraft = displayNameDraftState.path === repoPath && displayNameDraftState.isDirty
|
|
78
|
+
? displayNameDraftState.value
|
|
79
|
+
: (currentRepo.displayName ?? '');
|
|
80
|
+
const normalizedSavedDisplayName = currentRepo.displayName?.trim() ?? '';
|
|
81
|
+
const normalizedDraftDisplayName = displayNameDraft.trim();
|
|
82
|
+
const isDisplayNameDirty = normalizedDraftDisplayName !== normalizedSavedDisplayName;
|
|
83
|
+
const previewName = getRepositoryDisplayName({
|
|
84
|
+
path: currentRepo.path,
|
|
85
|
+
name: currentRepo.name,
|
|
86
|
+
displayName: displayNameDraft,
|
|
87
|
+
});
|
|
88
|
+
const fallbackFolderName = getRepoFolderName(currentRepo.path);
|
|
89
|
+
|
|
90
|
+
const handleDisplayNameSave = () => {
|
|
91
|
+
setDisplayNameDraftState({
|
|
92
|
+
path: repoPath,
|
|
93
|
+
value: displayNameDraft,
|
|
94
|
+
isDirty: false,
|
|
95
|
+
});
|
|
96
|
+
updateRepo.mutate({
|
|
97
|
+
path: repoPath,
|
|
98
|
+
updates: { displayName: displayNameDraft }
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleDisplayNameReset = () => {
|
|
103
|
+
setDisplayNameDraftState({
|
|
104
|
+
path: repoPath,
|
|
105
|
+
value: '',
|
|
106
|
+
isDirty: false,
|
|
107
|
+
});
|
|
108
|
+
updateRepo.mutate({
|
|
109
|
+
path: repoPath,
|
|
110
|
+
updates: { displayName: null }
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleCredentialChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
115
|
+
const credentialId = e.target.value;
|
|
116
|
+
updateRepo.mutate({
|
|
117
|
+
path: repoPath,
|
|
118
|
+
updates: { credentialId: credentialId === 'none' ? null : credentialId }
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="p-8 max-w-2xl mx-auto">
|
|
124
|
+
<h1 className="text-2xl font-bold mb-6">Workspace Settings</h1>
|
|
125
|
+
|
|
126
|
+
<div className="space-y-6">
|
|
127
|
+
<div className="card bg-base-100 shadow-xl border border-base-200">
|
|
128
|
+
<div className="card-body">
|
|
129
|
+
<h2 className="card-title">Repository Display Name</h2>
|
|
130
|
+
<p className="text-sm opacity-70">
|
|
131
|
+
Set a custom name for this repository in the workspace UI.
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
<div className="form-control w-full mt-4">
|
|
135
|
+
<label className="label">
|
|
136
|
+
<span className="label-text">Display Name</span>
|
|
137
|
+
</label>
|
|
138
|
+
<input
|
|
139
|
+
type="text"
|
|
140
|
+
className="input input-bordered w-full"
|
|
141
|
+
placeholder={fallbackFolderName}
|
|
142
|
+
value={displayNameDraft}
|
|
143
|
+
onChange={(e) => {
|
|
144
|
+
setDisplayNameDraftState({
|
|
145
|
+
path: repoPath,
|
|
146
|
+
value: e.target.value,
|
|
147
|
+
isDirty: true,
|
|
148
|
+
});
|
|
149
|
+
}}
|
|
150
|
+
onKeyDown={(e) => {
|
|
151
|
+
if (e.key === 'Enter' && isDisplayNameDirty && !updateRepo.isPending) {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
handleDisplayNameSave();
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
<label className="label">
|
|
158
|
+
<span className="label-text-alt opacity-70">
|
|
159
|
+
Preview: <span className="font-medium">{previewName}</span>
|
|
160
|
+
</span>
|
|
161
|
+
</label>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div className="flex items-center gap-2 mt-2">
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="btn btn-primary btn-sm"
|
|
168
|
+
onClick={handleDisplayNameSave}
|
|
169
|
+
disabled={!isDisplayNameDirty || updateRepo.isPending}
|
|
170
|
+
>
|
|
171
|
+
Save Name
|
|
172
|
+
</button>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
className="btn btn-ghost btn-sm"
|
|
176
|
+
onClick={handleDisplayNameReset}
|
|
177
|
+
disabled={(!normalizedDraftDisplayName && !currentRepo.displayName) || updateRepo.isPending}
|
|
178
|
+
>
|
|
179
|
+
Reset
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="card bg-base-100 shadow-xl border border-base-200">
|
|
186
|
+
<div className="card-body">
|
|
187
|
+
<h2 className="card-title">Repository Credentials</h2>
|
|
188
|
+
<p className="text-sm opacity-70">
|
|
189
|
+
Associate a credential with this repository to authenticate with remote servers.
|
|
190
|
+
</p>
|
|
191
|
+
|
|
192
|
+
<div className="form-control w-full mt-4">
|
|
193
|
+
<label className="label">
|
|
194
|
+
<span className="label-text">Associated Credential</span>
|
|
195
|
+
</label>
|
|
196
|
+
<select
|
|
197
|
+
className="select select-bordered w-full"
|
|
198
|
+
value={currentRepo.credentialId || 'none'}
|
|
199
|
+
onChange={handleCredentialChange}
|
|
200
|
+
>
|
|
201
|
+
<option value="none">None</option>
|
|
202
|
+
{matchingCredentials.map((cred) => (
|
|
203
|
+
<option key={cred.id} value={cred.id}>
|
|
204
|
+
{cred.type === 'github' ? 'GitHub' : 'GitLab'}
|
|
205
|
+
{cred.type === 'gitlab' && ` (${new URL(cred.serverUrl!).hostname})`}
|
|
206
|
+
{' - '}
|
|
207
|
+
{cred.type === 'github' ? 'Account' : 'Token'}
|
|
208
|
+
</option>
|
|
209
|
+
))}
|
|
210
|
+
</select>
|
|
211
|
+
{matchingCredentials.length === 0 && (
|
|
212
|
+
<label className="label">
|
|
213
|
+
<span className="label-text-alt opacity-70">
|
|
214
|
+
No matching credentials found for this repository's remotes.
|
|
215
|
+
Add credentials in the Credentials page.
|
|
216
|
+
</span>
|
|
217
|
+
</label>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default function WorkspaceSettingsPage() {
|
|
228
|
+
return (
|
|
229
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>}>
|
|
230
|
+
<WorkspaceSettingsContent />
|
|
231
|
+
</Suspense>
|
|
232
|
+
);
|
|
233
|
+
}
|