git-drive 0.1.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/.github/workflows/ci.yml +77 -0
- package/.planning/codebase/ARCHITECTURE.md +151 -0
- package/.planning/codebase/CONCERNS.md +191 -0
- package/.planning/codebase/CONVENTIONS.md +169 -0
- package/.planning/codebase/INTEGRATIONS.md +94 -0
- package/.planning/codebase/STACK.md +77 -0
- package/.planning/codebase/STRUCTURE.md +157 -0
- package/.planning/codebase/TESTING.md +156 -0
- package/Dockerfile.cli +30 -0
- package/Dockerfile.server +32 -0
- package/README.md +95 -0
- package/docker-compose.yml +48 -0
- package/package.json +25 -0
- package/packages/cli/Dockerfile +26 -0
- package/packages/cli/package.json +57 -0
- package/packages/cli/src/commands/archive.ts +39 -0
- package/packages/cli/src/commands/init.ts +34 -0
- package/packages/cli/src/commands/link.ts +115 -0
- package/packages/cli/src/commands/list.ts +94 -0
- package/packages/cli/src/commands/push.ts +64 -0
- package/packages/cli/src/commands/restore.ts +36 -0
- package/packages/cli/src/commands/status.ts +127 -0
- package/packages/cli/src/config.ts +73 -0
- package/packages/cli/src/errors.ts +23 -0
- package/packages/cli/src/git.ts +55 -0
- package/packages/cli/src/index.ts +97 -0
- package/packages/cli/src/server.ts +514 -0
- package/packages/cli/tsconfig.json +13 -0
- package/packages/cli/ui/assets/index-Cc2q1t5k.js +17 -0
- package/packages/cli/ui/assets/index-DrL7ojPA.css +1 -0
- package/packages/cli/ui/index.html +14 -0
- package/packages/cli/ui/vite.svg +1 -0
- package/packages/git-drive-docker/package.json +15 -0
- package/packages/server/package.json +44 -0
- package/packages/server/src/index.ts +569 -0
- package/packages/server/tsconfig.json +9 -0
- package/packages/ui/README.md +73 -0
- package/packages/ui/eslint.config.js +23 -0
- package/packages/ui/index.html +13 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/postcss.config.js +6 -0
- package/packages/ui/public/vite.svg +1 -0
- package/packages/ui/src/App.css +23 -0
- package/packages/ui/src/App.tsx +726 -0
- package/packages/ui/src/assets/react.svg +8 -0
- package/packages/ui/src/assets/vite.svg +3 -0
- package/packages/ui/src/index.css +37 -0
- package/packages/ui/src/main.tsx +14 -0
- package/packages/ui/tailwind.config.js +11 -0
- package/packages/ui/tsconfig.app.json +28 -0
- package/packages/ui/tsconfig.json +26 -0
- package/packages/ui/tsconfig.node.json +12 -0
- package/packages/ui/vite.config.ts +7 -0
- package/pnpm-workspace.yaml +4 -0
- package/rewrite_app.js +731 -0
- package/tsconfig.json +14 -0
package/rewrite_app.js
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
const code = `import React, { useState, useEffect } from 'react';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import { Routes, Route, Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
6
|
+
import { HardDrive, Search, FolderGit2, Trash2, Plus, ArrowLeft, File as FileIcon, Folder as FolderIcon, ChevronRight } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
type Drive = {
|
|
9
|
+
device: string;
|
|
10
|
+
description: string;
|
|
11
|
+
size: number;
|
|
12
|
+
isRemovable: boolean;
|
|
13
|
+
isSystem: boolean;
|
|
14
|
+
mountpoints: string[];
|
|
15
|
+
hasGitDrive: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Repo = {
|
|
19
|
+
name: string;
|
|
20
|
+
path: string;
|
|
21
|
+
lastModified: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type TreeItem = {
|
|
25
|
+
mode: string;
|
|
26
|
+
type: string;
|
|
27
|
+
hash: string;
|
|
28
|
+
path: string;
|
|
29
|
+
name: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default function App() {
|
|
33
|
+
return (
|
|
34
|
+
<div className="min-h-screen bg-[#0d1117] text-gray-200 p-8 font-sans">
|
|
35
|
+
<div className="max-w-5xl mx-auto space-y-8">
|
|
36
|
+
<header className="flex items-center justify-between border-b border-gray-800 pb-6">
|
|
37
|
+
<Link to="/" className="flex items-center gap-3">
|
|
38
|
+
<div className="p-3 bg-blue-500/10 rounded-xl">
|
|
39
|
+
<FolderGit2 className="w-8 h-8 text-blue-400" />
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<h1 className="text-2xl font-bold text-white tracking-tight">Git Drive</h1>
|
|
43
|
+
<p className="text-gray-400 text-sm">Turn any drive into a git remote.</p>
|
|
44
|
+
</div>
|
|
45
|
+
</Link>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
<Routes>
|
|
49
|
+
<Route path="/" element={<DriveList />} />
|
|
50
|
+
<Route path="/drives/:mountpoint" element={<RepoList />} />
|
|
51
|
+
<Route path="/drives/:mountpoint/repos/:repoName" element={<RepoBrowser />} />
|
|
52
|
+
<Route path="/drives/:mountpoint/repos/:repoName/commit/:hash" element={<CommitViewer />} />
|
|
53
|
+
</Routes>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function DriveList() {
|
|
60
|
+
const [drives, setDrives] = useState<Drive[]>([]);
|
|
61
|
+
const [loading, setLoading] = useState(true);
|
|
62
|
+
const navigate = useNavigate();
|
|
63
|
+
|
|
64
|
+
const fetchDrives = async () => {
|
|
65
|
+
try {
|
|
66
|
+
const { data } = await axios.get('/api/drives');
|
|
67
|
+
const sortedDrives = data.sort((a: Drive, b: Drive) => {
|
|
68
|
+
if (a.hasGitDrive === b.hasGitDrive) return 0;
|
|
69
|
+
return a.hasGitDrive ? -1 : 1;
|
|
70
|
+
});
|
|
71
|
+
setDrives(sortedDrives);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.error(e);
|
|
74
|
+
} finally {
|
|
75
|
+
setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
fetchDrives();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const handleInitDrive = async (mountpoint: string) => {
|
|
84
|
+
try {
|
|
85
|
+
await axios.post(\`/api/drives/\${encodeURIComponent(mountpoint)}/init\`);
|
|
86
|
+
fetchDrives();
|
|
87
|
+
} catch (e: any) {
|
|
88
|
+
alert(e.response?.data?.error || \`Failed to initialize \${mountpoint}\`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-6 animate-in fade-in">
|
|
94
|
+
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
|
95
|
+
<HardDrive className="w-5 h-5 text-gray-400" /> Connected Drives
|
|
96
|
+
</h2>
|
|
97
|
+
|
|
98
|
+
{loading ? (
|
|
99
|
+
<div className="animate-pulse flex gap-4">
|
|
100
|
+
<div className="h-24 w-full bg-gray-800/50 rounded-xl"></div>
|
|
101
|
+
<div className="h-24 w-full bg-gray-800/50 rounded-xl"></div>
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2">
|
|
105
|
+
{drives.map((drive, idx) => {
|
|
106
|
+
const mountpoint = drive.mountpoints[0];
|
|
107
|
+
if (!mountpoint || drive.isSystem) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
key={idx}
|
|
112
|
+
className="group p-6 bg-gray-900 border border-gray-800 rounded-2xl hover:border-gray-700 hover:bg-gray-800/50 transition-all cursor-pointer shadow-lg shadow-black/20"
|
|
113
|
+
onClick={() => {
|
|
114
|
+
if (drive.hasGitDrive) navigate(\`/drives/\${encodeURIComponent(mountpoint)}\`);
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<div className="flex justify-between items-start mb-4">
|
|
118
|
+
<div className="p-3 bg-gray-800 rounded-xl group-hover:bg-gray-700 transition-colors">
|
|
119
|
+
<HardDrive className="w-6 h-6 text-gray-300" />
|
|
120
|
+
</div>
|
|
121
|
+
{drive.hasGitDrive ? (
|
|
122
|
+
<span className="px-3 py-1 bg-green-500/10 text-green-400 text-xs font-semibold rounded-full border border-green-500/20">
|
|
123
|
+
Ready
|
|
124
|
+
</span>
|
|
125
|
+
) : (
|
|
126
|
+
<span className="px-3 py-1 bg-gray-800 text-gray-400 text-xs font-semibold rounded-full border border-gray-700">
|
|
127
|
+
Unconfigured
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<h3 className="font-medium text-white text-lg mb-1 truncate" title={drive.description}>
|
|
133
|
+
{mountpoint}
|
|
134
|
+
</h3>
|
|
135
|
+
|
|
136
|
+
<div className="flex gap-2 text-xs text-gray-500 font-medium">
|
|
137
|
+
<span className="px-2 py-1 bg-gray-950 rounded-md border border-gray-800">
|
|
138
|
+
{drive.device}
|
|
139
|
+
</span>
|
|
140
|
+
<span>{(drive.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{!drive.hasGitDrive && (
|
|
144
|
+
<button
|
|
145
|
+
onClick={(e) => {
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
handleInitDrive(mountpoint);
|
|
148
|
+
}}
|
|
149
|
+
className="mt-6 w-full py-2 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors cursor-pointer"
|
|
150
|
+
>
|
|
151
|
+
Initialize Git Drive
|
|
152
|
+
</button>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function RepoList() {
|
|
164
|
+
const { mountpoint } = useParams<{ mountpoint: string }>();
|
|
165
|
+
const navigate = useNavigate();
|
|
166
|
+
const [repos, setRepos] = useState<Repo[]>([]);
|
|
167
|
+
const [repoStatuses, setRepoStatuses] = useState<Record<string, { linked: boolean; hasChanges: boolean; unpushed: boolean }>>({});
|
|
168
|
+
const [pushing, setPushing] = useState<string | null>(null);
|
|
169
|
+
|
|
170
|
+
const fetchRepos = async () => {
|
|
171
|
+
if (!mountpoint) return;
|
|
172
|
+
try {
|
|
173
|
+
const { data } = await axios.get(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos\`);
|
|
174
|
+
const repoList = data.repos || [];
|
|
175
|
+
setRepos(repoList);
|
|
176
|
+
|
|
177
|
+
repoList.forEach(async (r: Repo) => {
|
|
178
|
+
try {
|
|
179
|
+
const res = await axios.get(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(r.name)}/local-status\`);
|
|
180
|
+
setRepoStatuses((prev) => ({ ...prev, [r.name]: res.data }));
|
|
181
|
+
} catch (err) {}
|
|
182
|
+
});
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.error(e);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
fetchRepos();
|
|
190
|
+
}, [mountpoint]);
|
|
191
|
+
|
|
192
|
+
const handleCreateRepo = async () => {
|
|
193
|
+
if (!mountpoint) return;
|
|
194
|
+
const name = prompt('Repository Name:');
|
|
195
|
+
if (!name) return;
|
|
196
|
+
try {
|
|
197
|
+
await axios.post(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos\`, { name });
|
|
198
|
+
fetchRepos();
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.error(e);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handlePushRepo = async (e: React.MouseEvent, repoName: string) => {
|
|
205
|
+
e.stopPropagation();
|
|
206
|
+
if (!mountpoint) return;
|
|
207
|
+
setPushing(repoName);
|
|
208
|
+
try {
|
|
209
|
+
await axios.post(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}/push\`);
|
|
210
|
+
fetchRepos();
|
|
211
|
+
} catch (err: any) {
|
|
212
|
+
alert(err.response?.data?.error || \`Failed to push \${repoName}\`);
|
|
213
|
+
} finally {
|
|
214
|
+
setPushing(null);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const handleDeleteRepo = async (repoName: string) => {
|
|
219
|
+
if (!mountpoint) return;
|
|
220
|
+
if (!confirm(\`Are you sure you want to delete \${repoName}?\`)) return;
|
|
221
|
+
try {
|
|
222
|
+
await axios.delete(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}\`);
|
|
223
|
+
fetchRepos();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error(e);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="space-y-6 animate-in slide-in-from-right-4 fade-in">
|
|
231
|
+
<div className="flex items-center gap-4">
|
|
232
|
+
<button
|
|
233
|
+
onClick={() => navigate('/')}
|
|
234
|
+
className="p-2 hover:bg-gray-800 rounded-xl transition-colors text-gray-400 hover:text-white"
|
|
235
|
+
>
|
|
236
|
+
<ArrowLeft className="w-5 h-5" />
|
|
237
|
+
</button>
|
|
238
|
+
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
|
239
|
+
<HardDrive className="w-5 h-5 text-blue-400" />
|
|
240
|
+
{mountpoint}
|
|
241
|
+
</h2>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div className="flex justify-between items-center bg-gray-900 border border-gray-800 p-4 rounded-2xl">
|
|
245
|
+
<div className="flex items-center gap-3 px-3 py-2 bg-gray-950 border border-gray-800 rounded-lg w-full max-w-sm">
|
|
246
|
+
<Search className="w-4 h-4 text-gray-500" />
|
|
247
|
+
<input
|
|
248
|
+
type="text"
|
|
249
|
+
placeholder="Filter repositories..."
|
|
250
|
+
className="bg-transparent border-none outline-none text-sm w-full text-white placeholder-gray-600"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<button
|
|
255
|
+
onClick={handleCreateRepo}
|
|
256
|
+
className="flex items-center gap-2 px-4 py-2 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors cursor-pointer"
|
|
257
|
+
>
|
|
258
|
+
<Plus className="w-4 h-4" /> New Repository
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div className="grid gap-4">
|
|
263
|
+
{repos.length === 0 ? (
|
|
264
|
+
<div className="text-center py-20 border border-dashed border-gray-800 rounded-2xl">
|
|
265
|
+
<FolderGit2 className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
|
266
|
+
<h3 className="text-lg font-medium text-white mb-2">No repositories yet</h3>
|
|
267
|
+
<p className="text-gray-500 text-sm">Create a new repository to get started backing up to this drive.</p>
|
|
268
|
+
</div>
|
|
269
|
+
) : (
|
|
270
|
+
repos.map((repo, idx) => (
|
|
271
|
+
<div
|
|
272
|
+
key={idx}
|
|
273
|
+
className="flex items-center justify-between p-5 bg-gray-900 border border-gray-800 rounded-2xl group hover:border-gray-700 transition-all cursor-pointer shadow-lg shadow-black/20"
|
|
274
|
+
onClick={() => navigate(\`/drives/\${encodeURIComponent(mountpoint as string)}/repos/\${encodeURIComponent(repo.name)}\`)}
|
|
275
|
+
>
|
|
276
|
+
<div className="flex items-center gap-4">
|
|
277
|
+
<div className="p-3 bg-gray-800 rounded-xl text-blue-400 group-hover:bg-blue-500/10 transition-colors">
|
|
278
|
+
<FolderGit2 className="w-6 h-6" />
|
|
279
|
+
</div>
|
|
280
|
+
<div>
|
|
281
|
+
<h3 className="text-white font-medium text-lg leading-tight group-hover:text-blue-400 transition-colors">{repo.name}</h3>
|
|
282
|
+
<p className="text-gray-500 text-xs font-mono mt-1 opacity-60">{repo.path}</p>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div className="flex items-center gap-4">
|
|
287
|
+
{repoStatuses[repo.name]?.linked && (repoStatuses[repo.name]?.hasChanges || repoStatuses[repo.name]?.unpushed) && (
|
|
288
|
+
<div className="flex items-center gap-2">
|
|
289
|
+
<span className="px-2 py-1 text-xs font-semibold bg-amber-500/10 text-amber-500 border border-amber-500/20 rounded-md">
|
|
290
|
+
Pending Changes
|
|
291
|
+
</span>
|
|
292
|
+
<button
|
|
293
|
+
onClick={(e) => handlePushRepo(e, repo.name)}
|
|
294
|
+
disabled={pushing === repo.name}
|
|
295
|
+
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors cursor-pointer disabled:opacity-50"
|
|
296
|
+
>
|
|
297
|
+
{pushing === repo.name ? 'Pushing...' : 'Push to Drive'}
|
|
298
|
+
</button>
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
<div className="text-right text-sm">
|
|
303
|
+
<div className="text-gray-400">Modified</div>
|
|
304
|
+
<div className="text-gray-500 font-medium">{new Date(repo.lastModified).toLocaleDateString()}</div>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<button
|
|
308
|
+
onClick={(e) => {
|
|
309
|
+
e.stopPropagation();
|
|
310
|
+
handleDeleteRepo(repo.name);
|
|
311
|
+
}}
|
|
312
|
+
className="p-3 text-red-500 hover:bg-red-500/10 rounded-xl opacity-0 group-hover:opacity-100 transition-all ml-4"
|
|
313
|
+
title="Delete Repository"
|
|
314
|
+
>
|
|
315
|
+
<Trash2 className="w-5 h-5" />
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
))
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function RepoBrowser() {
|
|
327
|
+
const { mountpoint, repoName } = useParams<{ mountpoint: string, repoName: string }>();
|
|
328
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
329
|
+
const navigate = useNavigate();
|
|
330
|
+
|
|
331
|
+
const branch = searchParams.get('branch') || 'main';
|
|
332
|
+
const treePath = searchParams.get('path') || '';
|
|
333
|
+
const viewMode = searchParams.get('view') || 'code';
|
|
334
|
+
|
|
335
|
+
const [treeFiles, setTreeFiles] = useState<TreeItem[]>([]);
|
|
336
|
+
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
337
|
+
const [readmeContent, setReadmeContent] = useState<string | null>(null);
|
|
338
|
+
const [repoDetails, setRepoDetails] = useState<any>(null);
|
|
339
|
+
const [historyData, setHistoryData] = useState<{ commits: any[]; pushLogs: any[] } | null>(null);
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!mountpoint || !repoName) return;
|
|
343
|
+
|
|
344
|
+
axios.get(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}\`)
|
|
345
|
+
.then(({ data }) => {
|
|
346
|
+
setRepoDetails(data);
|
|
347
|
+
if (!searchParams.has('branch')) {
|
|
348
|
+
let defaultBranch = 'main';
|
|
349
|
+
if (data.branches && data.branches.length > 0) {
|
|
350
|
+
if (data.branches.includes('main')) defaultBranch = 'main';
|
|
351
|
+
else if (data.branches.includes('master')) defaultBranch = 'master';
|
|
352
|
+
else defaultBranch = data.branches[0];
|
|
353
|
+
}
|
|
354
|
+
setSearchParams({ branch: defaultBranch, path: treePath, view: viewMode });
|
|
355
|
+
}
|
|
356
|
+
}).catch(console.error);
|
|
357
|
+
}, [mountpoint, repoName]);
|
|
358
|
+
|
|
359
|
+
const loadData = async () => {
|
|
360
|
+
if (!mountpoint || !repoName || !branch) return;
|
|
361
|
+
|
|
362
|
+
if (viewMode === 'code') {
|
|
363
|
+
if (!treePath.includes('/') && treePath !== '') {
|
|
364
|
+
// Assuming it's a file
|
|
365
|
+
try {
|
|
366
|
+
const { data } = await axios.get(
|
|
367
|
+
\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}/blob?branch=\${encodeURIComponent(branch)}&path=\${encodeURIComponent(treePath)}\`
|
|
368
|
+
);
|
|
369
|
+
setFileContent(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
|
370
|
+
setTreeFiles([]);
|
|
371
|
+
setReadmeContent(null);
|
|
372
|
+
} catch(e) {}
|
|
373
|
+
} else {
|
|
374
|
+
// Assuming it's a directory
|
|
375
|
+
setFileContent(null);
|
|
376
|
+
try {
|
|
377
|
+
const { data } = await axios.get(
|
|
378
|
+
\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}/tree?branch=\${encodeURIComponent(branch)}&path=\${encodeURIComponent(treePath)}\`
|
|
379
|
+
);
|
|
380
|
+
setTreeFiles(data.files || []);
|
|
381
|
+
|
|
382
|
+
// Check for README if it's a directory view
|
|
383
|
+
const readmeFile = data.files?.find((f: any) => f.name.toLowerCase() === 'readme.md');
|
|
384
|
+
if (readmeFile) {
|
|
385
|
+
try {
|
|
386
|
+
const res = await axios.get(
|
|
387
|
+
\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}/blob?branch=\${encodeURIComponent(branch)}&path=\${encodeURIComponent(readmeFile.path)}\`
|
|
388
|
+
);
|
|
389
|
+
setReadmeContent(res.data);
|
|
390
|
+
} catch(e) {}
|
|
391
|
+
} else {
|
|
392
|
+
setReadmeContent(null);
|
|
393
|
+
}
|
|
394
|
+
} catch (e) {
|
|
395
|
+
setTreeFiles([]);
|
|
396
|
+
setReadmeContent(null);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
// History mode
|
|
401
|
+
try {
|
|
402
|
+
const { data } = await axios.get(
|
|
403
|
+
\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}/commits?branch=\${encodeURIComponent(branch)}\`
|
|
404
|
+
);
|
|
405
|
+
setHistoryData(data);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
setHistoryData(null);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
loadData();
|
|
414
|
+
}, [mountpoint, repoName, branch, treePath, viewMode]);
|
|
415
|
+
|
|
416
|
+
const handlePathClick = (index: number) => {
|
|
417
|
+
if (index === -1) {
|
|
418
|
+
setSearchParams({ branch, path: '', view: viewMode });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const parts = treePath.split('/');
|
|
422
|
+
const newPath = parts.slice(0, index + 1).join('/') + '/';
|
|
423
|
+
setSearchParams({ branch, path: newPath, view: viewMode });
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<div className="space-y-4 animate-in slide-in-from-right-4 fade-in">
|
|
428
|
+
<div className="flex items-center gap-4 font-mono text-sm bg-gray-900 border border-gray-800 p-4 rounded-xl">
|
|
429
|
+
<button
|
|
430
|
+
onClick={() => navigate(\`/drives/\${encodeURIComponent(mountpoint as string)}\`)}
|
|
431
|
+
className="hover:text-white text-gray-400 transition-colors p-1"
|
|
432
|
+
>
|
|
433
|
+
<ArrowLeft className="w-4 h-4" />
|
|
434
|
+
</button>
|
|
435
|
+
|
|
436
|
+
<div className="flex items-center gap-2 flex-wrap flex-1">
|
|
437
|
+
<span
|
|
438
|
+
className="font-bold text-blue-400 hover:underline cursor-pointer"
|
|
439
|
+
onClick={() => handlePathClick(-1)}
|
|
440
|
+
>
|
|
441
|
+
{repoName}
|
|
442
|
+
</span>
|
|
443
|
+
|
|
444
|
+
{treePath.replace(/\\/$/, '').split('/').filter(Boolean).map((part, idx, arr) => (
|
|
445
|
+
<React.Fragment key={idx}>
|
|
446
|
+
<ChevronRight className="w-4 h-4 text-gray-600" />
|
|
447
|
+
<span
|
|
448
|
+
className={\`hover:underline cursor-pointer \${idx === arr.length - 1 && !fileContent ? 'text-white' : 'text-blue-400'}\`}
|
|
449
|
+
onClick={() => handlePathClick(idx)}
|
|
450
|
+
>
|
|
451
|
+
{part}
|
|
452
|
+
</span>
|
|
453
|
+
</React.Fragment>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{repoDetails && (
|
|
458
|
+
<div className="flex items-center gap-3">
|
|
459
|
+
<select
|
|
460
|
+
value={branch}
|
|
461
|
+
onChange={(e) => {
|
|
462
|
+
setSearchParams({ branch: e.target.value, path: treePath, view: viewMode });
|
|
463
|
+
}}
|
|
464
|
+
className="bg-gray-800 border border-gray-700 text-sm rounded-lg px-3 py-1.5 outline-none cursor-pointer focus:border-gray-500 font-sans"
|
|
465
|
+
>
|
|
466
|
+
{repoDetails.branches && repoDetails.branches.length > 0 && (
|
|
467
|
+
<optgroup label="Branches">
|
|
468
|
+
{repoDetails.branches.map((b: string) => <option key={b} value={b}>{b}</option>)}
|
|
469
|
+
</optgroup>
|
|
470
|
+
)}
|
|
471
|
+
{repoDetails.tags && repoDetails.tags.length > 0 && (
|
|
472
|
+
<optgroup label="Tags">
|
|
473
|
+
{repoDetails.tags.map((t: string) => <option key={t} value={t}>{t}</option>)}
|
|
474
|
+
</optgroup>
|
|
475
|
+
)}
|
|
476
|
+
</select>
|
|
477
|
+
|
|
478
|
+
<div className="flex bg-gray-950 border border-gray-800 rounded-lg p-1 ml-2">
|
|
479
|
+
<button
|
|
480
|
+
onClick={() => setSearchParams({ branch, path: treePath, view: 'code' })}
|
|
481
|
+
className={\`px-3 py-1 text-xs font-semibold rounded-md transition-colors \${viewMode === 'code' ? 'bg-blue-600/20 text-blue-400' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'}\`}
|
|
482
|
+
>
|
|
483
|
+
Code
|
|
484
|
+
</button>
|
|
485
|
+
<button
|
|
486
|
+
onClick={() => setSearchParams({ branch, path: treePath, view: 'history' })}
|
|
487
|
+
className={\`px-3 py-1 text-xs font-semibold rounded-md transition-colors \${viewMode === 'history' ? 'bg-blue-600/20 text-blue-400' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'}\`}
|
|
488
|
+
>
|
|
489
|
+
History
|
|
490
|
+
</button>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
{viewMode === 'code' && (
|
|
497
|
+
<>
|
|
498
|
+
{repoDetails?.lastCommit && treePath === '' && (
|
|
499
|
+
<div className="bg-gray-900 border border-gray-800 p-4 rounded-xl flex items-center justify-between shadow-lg shadow-black/20">
|
|
500
|
+
<div className="flex items-center gap-3">
|
|
501
|
+
<div className="w-10 h-10 min-w-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center font-bold text-gray-300 shrink-0 uppercase overflow-hidden text-clip">
|
|
502
|
+
{repoDetails.lastCommit.hash.substring(0, 5)}
|
|
503
|
+
</div>
|
|
504
|
+
<div>
|
|
505
|
+
<div className="text-sm font-medium text-gray-200">{repoDetails.lastCommit.message}</div>
|
|
506
|
+
<div className="text-xs text-gray-500 mt-0.5">
|
|
507
|
+
Committed on {new Date(repoDetails.lastCommit.date).toLocaleString()}
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
<div className="text-xs font-mono text-gray-500 bg-gray-950 px-2 py-1 rounded-md border border-gray-800">
|
|
512
|
+
{repoDetails.lastCommit.hash.substring(0, 8)}
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
|
|
517
|
+
{fileContent !== null ? (
|
|
518
|
+
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
|
519
|
+
<div className="bg-gray-800/80 px-4 py-2 border-b border-gray-700 text-xs font-mono text-gray-400 flex items-center gap-2">
|
|
520
|
+
<FileIcon className="w-4 h-4" />
|
|
521
|
+
{treePath.split('/').pop()}
|
|
522
|
+
</div>
|
|
523
|
+
<pre className="p-4 overflow-x-auto text-sm font-mono text-gray-300 leading-relaxed whitespace-pre" style={{ tabSize: 2 }}>
|
|
524
|
+
{fileContent}
|
|
525
|
+
</pre>
|
|
526
|
+
</div>
|
|
527
|
+
) : (
|
|
528
|
+
<>
|
|
529
|
+
<div className="bg-gray-900 border border-gray-800 rounded-xl shadow-lg shadow-black/20 overflow-hidden">
|
|
530
|
+
{treeFiles.length === 0 ? (
|
|
531
|
+
<div className="p-8 text-center text-gray-500 text-sm">
|
|
532
|
+
This directory is empty or the repository has no commits yet.
|
|
533
|
+
</div>
|
|
534
|
+
) : (
|
|
535
|
+
<div className="divide-y divide-gray-800/50">
|
|
536
|
+
{treeFiles.map((file, idx) => (
|
|
537
|
+
<div
|
|
538
|
+
key={idx}
|
|
539
|
+
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/50 cursor-pointer transition-colors group"
|
|
540
|
+
onClick={() => {
|
|
541
|
+
if (file.type === 'tree') {
|
|
542
|
+
setSearchParams({ branch, path: file.path + '/', view: viewMode });
|
|
543
|
+
} else {
|
|
544
|
+
setSearchParams({ branch, path: file.path, view: viewMode });
|
|
545
|
+
}
|
|
546
|
+
}}
|
|
547
|
+
>
|
|
548
|
+
{file.type === 'tree' ? (
|
|
549
|
+
<FolderIcon className="w-5 h-5 text-blue-400 fill-blue-400/20" />
|
|
550
|
+
) : (
|
|
551
|
+
<FileIcon className="w-5 h-5 text-gray-400 group-hover:text-gray-300" />
|
|
552
|
+
)}
|
|
553
|
+
<span className={\`text-sm \${file.type === 'tree' ? 'text-white font-medium' : 'text-gray-300'}\`}>
|
|
554
|
+
{file.name}
|
|
555
|
+
</span>
|
|
556
|
+
</div>
|
|
557
|
+
))}
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
{readmeContent && (
|
|
562
|
+
<div className="bg-gray-900 border border-gray-800 rounded-xl shadow-lg shadow-black/20 overflow-hidden mt-6">
|
|
563
|
+
<div className="bg-gray-800/80 px-4 py-2 border-b border-gray-700 text-xs font-mono text-gray-400 flex items-center gap-2">
|
|
564
|
+
<FileIcon className="w-4 h-4" />
|
|
565
|
+
README.md
|
|
566
|
+
</div>
|
|
567
|
+
<pre className="p-6 overflow-x-auto text-sm font-sans text-gray-300 leading-relaxed whitespace-pre-wrap whitespace-normal" style={{ tabSize: 2 }}>
|
|
568
|
+
{readmeContent}
|
|
569
|
+
</pre>
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
</>
|
|
573
|
+
)}
|
|
574
|
+
</>
|
|
575
|
+
)}
|
|
576
|
+
|
|
577
|
+
{viewMode === 'history' && historyData && (
|
|
578
|
+
<div className="space-y-6">
|
|
579
|
+
{historyData.pushLogs.length > 0 && (
|
|
580
|
+
<div className="space-y-3">
|
|
581
|
+
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-widest pl-1 border-b border-gray-800 pb-2">Git Drive Transfer Logs</h3>
|
|
582
|
+
<div className="grid gap-3">
|
|
583
|
+
{historyData.pushLogs.map((log, idx) => (
|
|
584
|
+
<div key={idx} className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 flex gap-4 text-sm">
|
|
585
|
+
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400 h-fit">
|
|
586
|
+
<HardDrive className="w-4 h-4" />
|
|
587
|
+
</div>
|
|
588
|
+
<div className="space-y-1 w-full">
|
|
589
|
+
<div className="flex justify-between items-start text-gray-300">
|
|
590
|
+
<span className="font-semibold text-white">{log.user} pushed this repo</span>
|
|
591
|
+
<span className="text-xs text-gray-500">{new Date(log.date).toLocaleString()}</span>
|
|
592
|
+
</div>
|
|
593
|
+
<div className="text-gray-500 text-xs font-mono grid grid-cols-[80px_1fr] gap-x-2">
|
|
594
|
+
<span>Computer:</span><span className="text-gray-400">{log.computer}</span>
|
|
595
|
+
<span>Source:</span><span className="text-gray-400 truncate" title={log.localDir}>{log.localDir}</span>
|
|
596
|
+
<span>Operation:</span><span className="text-gray-400">{log.mode}</span>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
))}
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
|
|
605
|
+
<div className="space-y-3 pt-4">
|
|
606
|
+
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-widest pl-1 border-b border-gray-800 pb-2">Git Commits ({branch})</h3>
|
|
607
|
+
{historyData.commits.length === 0 ? (
|
|
608
|
+
<div className="text-gray-500 text-sm p-4 text-center">No commits in this branch.</div>
|
|
609
|
+
) : (
|
|
610
|
+
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800/50">
|
|
611
|
+
{historyData.commits.map((commit, idx) => (
|
|
612
|
+
<div
|
|
613
|
+
key={idx}
|
|
614
|
+
className="p-4 hover:bg-gray-800/30 transition-colors flex gap-4 items-start cursor-pointer group"
|
|
615
|
+
onClick={() => navigate(\`/drives/\${encodeURIComponent(mountpoint as string)}/repos/\${encodeURIComponent(repoName as string)}/commit/\${commit.hash}\`)}
|
|
616
|
+
>
|
|
617
|
+
<div className="w-10 h-10 min-w-10 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center font-bold text-gray-300 shrink-0 uppercase overflow-hidden text-clip group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:text-blue-400 transition-colors">
|
|
618
|
+
{commit.author.substring(0, 2)}
|
|
619
|
+
</div>
|
|
620
|
+
<div className="w-full">
|
|
621
|
+
<div className="flex justify-between gap-4">
|
|
622
|
+
<div className="font-medium text-gray-200 mb-1 leading-snug break-words group-hover:text-blue-400 transition-colors">
|
|
623
|
+
{commit.message}
|
|
624
|
+
</div>
|
|
625
|
+
<div className="text-xs font-mono text-gray-500 shrink-0 mt-1">
|
|
626
|
+
{commit.hash.substring(0, 7)}
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
<div className="text-xs text-gray-500 flex gap-2 items-center flex-wrap">
|
|
630
|
+
<span className="font-medium text-gray-400">{commit.author}</span>
|
|
631
|
+
<span>•</span>
|
|
632
|
+
<span className="opacity-75">{commit.email}</span>
|
|
633
|
+
<span>•</span>
|
|
634
|
+
<span>{new Date(commit.date).toLocaleString()}</span>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
))}
|
|
639
|
+
</div>
|
|
640
|
+
)}
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function CommitViewer() {
|
|
649
|
+
const { mountpoint, repoName, hash } = useParams<{ mountpoint: string, repoName: string, hash: string }>();
|
|
650
|
+
const navigate = useNavigate();
|
|
651
|
+
const [commit, setCommit] = useState<any>(null);
|
|
652
|
+
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
if (!mountpoint || !repoName || !hash) return;
|
|
655
|
+
axios.get(\`/api/drives/\${encodeURIComponent(mountpoint)}/repos/\${encodeURIComponent(repoName)}/commits/\${hash}\`)
|
|
656
|
+
.then(({ data }) => setCommit(data))
|
|
657
|
+
.catch(console.error);
|
|
658
|
+
}, [mountpoint, repoName, hash]);
|
|
659
|
+
|
|
660
|
+
if (!commit) {
|
|
661
|
+
return (
|
|
662
|
+
<div className="animate-pulse space-y-4">
|
|
663
|
+
<div className="h-20 bg-gray-800/50 rounded-xl"></div>
|
|
664
|
+
<div className="h-64 bg-gray-800/50 rounded-xl"></div>
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
<div className="space-y-6 animate-in slide-in-from-right-4 fade-in">
|
|
671
|
+
<div className="flex items-center gap-4">
|
|
672
|
+
<button
|
|
673
|
+
onClick={() => navigate(-1)}
|
|
674
|
+
className="p-2 hover:bg-gray-800 rounded-xl transition-colors text-gray-400 hover:text-white"
|
|
675
|
+
>
|
|
676
|
+
<ArrowLeft className="w-5 h-5" />
|
|
677
|
+
</button>
|
|
678
|
+
<div className="text-xl font-semibold text-white">
|
|
679
|
+
Commit <span className="font-mono text-blue-400">{commit.hash.substring(0, 7)}</span>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<div className="bg-gray-900 border border-gray-800 p-6 rounded-2xl flex items-start gap-4 shadow-lg shadow-black/20">
|
|
684
|
+
<div className="w-12 h-12 min-w-12 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center font-bold text-gray-300 shrink-0 uppercase overflow-hidden text-clip text-lg">
|
|
685
|
+
{commit.author.substring(0, 2)}
|
|
686
|
+
</div>
|
|
687
|
+
<div className="space-y-2">
|
|
688
|
+
<div className="text-lg font-medium text-white">{commit.message}</div>
|
|
689
|
+
<div className="text-sm text-gray-500 flex gap-2 items-center flex-wrap">
|
|
690
|
+
<span className="font-medium text-gray-300">{commit.author}</span>
|
|
691
|
+
<span className="text-gray-600"><{commit.email}></span>
|
|
692
|
+
<span>committed on {new Date(commit.date).toLocaleString()}</span>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
|
698
|
+
<div className="bg-gray-800/80 px-4 py-3 border-b border-gray-700 text-sm font-semibold text-white flex items-center gap-2">
|
|
699
|
+
<FileIcon className="w-4 h-4 text-gray-400" />
|
|
700
|
+
Diff Changes
|
|
701
|
+
</div>
|
|
702
|
+
<div className="overflow-x-auto p-4 bg-[#0d1117]">
|
|
703
|
+
{commit.patch.split('\\n').map((line: string, i: number) => {
|
|
704
|
+
let lineClass = "text-gray-300";
|
|
705
|
+
let bgClass = "bg-transparent";
|
|
706
|
+
|
|
707
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
708
|
+
lineClass = "text-green-400";
|
|
709
|
+
bgClass = "bg-green-500/10";
|
|
710
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
711
|
+
lineClass = "text-red-400";
|
|
712
|
+
bgClass = "bg-red-500/10";
|
|
713
|
+
} else if (line.startsWith('@@')) {
|
|
714
|
+
lineClass = "text-blue-400 font-semibold";
|
|
715
|
+
bgClass = "bg-blue-500/5";
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return (
|
|
719
|
+
<pre key={i} className={\`font-mono text-xs p-1 px-2 -mx-4 leading-relaxed \${lineClass} \${bgClass}\`} style={{ tabSize: 2 }}>
|
|
720
|
+
{line || ' '}
|
|
721
|
+
</pre>
|
|
722
|
+
);
|
|
723
|
+
})}
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
`
|
|
730
|
+
|
|
731
|
+
fs.writeFileSync('packages/ui/src/App.tsx', code);
|