git-drive 0.1.6 → 0.1.7

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