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