@anymux/connect 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 (78) hide show
  1. package/dist/GitBrowser-BLgTNQyd.js +905 -0
  2. package/dist/GitBrowser-BLgTNQyd.js.map +1 -0
  3. package/dist/GitBrowser-CIyWiuX-.js +3 -0
  4. package/dist/ObjectStorageBrowser-B2YkUxMl.js +3 -0
  5. package/dist/ObjectStorageBrowser-B_25Emfu.js +267 -0
  6. package/dist/ObjectStorageBrowser-B_25Emfu.js.map +1 -0
  7. package/dist/RepoPicker-BprFGOn7.js +3 -0
  8. package/dist/RepoPicker-CoHMiJ-3.js +168 -0
  9. package/dist/RepoPicker-CoHMiJ-3.js.map +1 -0
  10. package/dist/index.d.ts +697 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +2539 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/registry.d.ts +2 -0
  15. package/dist/registry.js +3 -0
  16. package/dist/scope-labels-B4VAwoL6.js +582 -0
  17. package/dist/scope-labels-B4VAwoL6.js.map +1 -0
  18. package/dist/scope-labels-DvdJLcSL.d.ts +50 -0
  19. package/dist/scope-labels-DvdJLcSL.d.ts.map +1 -0
  20. package/package.json +87 -0
  21. package/src/adapters/adapter-registry.ts +177 -0
  22. package/src/auth/auth-client.ts +101 -0
  23. package/src/auth/token-manager.ts +27 -0
  24. package/src/components/ActionHistoryPanel.tsx +137 -0
  25. package/src/components/CapabilityCell.tsx +97 -0
  26. package/src/components/CapabilityError.tsx +50 -0
  27. package/src/components/CapabilityPanel.tsx +530 -0
  28. package/src/components/CapabilityPill.tsx +56 -0
  29. package/src/components/ConnectButton.tsx +149 -0
  30. package/src/components/ConnectedMenu.tsx +142 -0
  31. package/src/components/ConnectionStatus.tsx +28 -0
  32. package/src/components/CredentialForm.tsx +246 -0
  33. package/src/components/FullScreenBrowser.tsx +84 -0
  34. package/src/components/GitBrowser.tsx +705 -0
  35. package/src/components/GitHubRepoPicker.tsx +125 -0
  36. package/src/components/ObjectStorageBrowser.tsx +176 -0
  37. package/src/components/RepoPicker.tsx +93 -0
  38. package/src/components/ServiceCard.tsx +77 -0
  39. package/src/components/ServiceCardGrid.tsx +141 -0
  40. package/src/components/ServiceDashboard.tsx +84 -0
  41. package/src/components/ServiceIcon.tsx +37 -0
  42. package/src/components/ServiceRow.tsx +50 -0
  43. package/src/components/useAdapter.ts +33 -0
  44. package/src/demos/ServiceDashboardDemo.tsx +108 -0
  45. package/src/index.ts +68 -0
  46. package/src/models/ActionNotificationModel.ts +72 -0
  47. package/src/models/ConnectionManagerModel.ts +410 -0
  48. package/src/models/CredentialFormModel.ts +111 -0
  49. package/src/models/DashboardModel.ts +157 -0
  50. package/src/models/GitHostBrowserModel.ts +89 -0
  51. package/src/models/GitRepoBrowserModel.ts +285 -0
  52. package/src/models/ObjectStorageBrowserModel.ts +131 -0
  53. package/src/models/RepoPickerModel.ts +132 -0
  54. package/src/registry/service-registry.ts +46 -0
  55. package/src/registry/services/apple.ts +22 -0
  56. package/src/registry/services/bitbucket.ts +24 -0
  57. package/src/registry/services/box.ts +22 -0
  58. package/src/registry/services/browser-fs.ts +19 -0
  59. package/src/registry/services/dropbox.ts +22 -0
  60. package/src/registry/services/flickr.ts +22 -0
  61. package/src/registry/services/gitea.ts +24 -0
  62. package/src/registry/services/github.ts +24 -0
  63. package/src/registry/services/gitlab.ts +24 -0
  64. package/src/registry/services/google.ts +24 -0
  65. package/src/registry/services/icloud.ts +23 -0
  66. package/src/registry/services/indexeddb.ts +19 -0
  67. package/src/registry/services/instagram.ts +22 -0
  68. package/src/registry/services/microsoft.ts +24 -0
  69. package/src/registry/services/s3.ts +21 -0
  70. package/src/registry/services/webdav.ts +21 -0
  71. package/src/registry.ts +4 -0
  72. package/src/types/connection-state.ts +33 -0
  73. package/src/types/connection.ts +11 -0
  74. package/src/types/optional-deps.d.ts +149 -0
  75. package/src/types/service.ts +18 -0
  76. package/src/types/user-profile.ts +21 -0
  77. package/src/utils/action-toast.ts +53 -0
  78. package/src/utils/scope-labels.ts +91 -0
@@ -0,0 +1,705 @@
1
+ import React, { useState, useEffect, useRef, useCallback, Suspense } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import {
4
+ GitBranch as GitBranchIcon,
5
+ Tag as TagIcon,
6
+ GitPullRequest,
7
+ AlertCircle,
8
+ Loader2,
9
+ ChevronDown,
10
+ ChevronLeft,
11
+ ChevronRight,
12
+ Check,
13
+ FolderTree,
14
+ GitCommit as GitCommitIcon,
15
+ Diff,
16
+ } from 'lucide-react';
17
+ import { toast } from 'sonner';
18
+ import type { ActionNotificationModel } from '../models/ActionNotificationModel';
19
+ import { showActionToast, showErrorToast } from '../utils/action-toast';
20
+ import { BrowserError } from '@anymux/ui/components/browser-error';
21
+ import type {
22
+ IGitRepo,
23
+ GitBranch,
24
+ GitTag,
25
+ IGitHost,
26
+ IFileSystem,
27
+ } from '@anymux/file-system';
28
+ import { GitRepoBrowserModel, type SidebarTab } from '../models/GitRepoBrowserModel';
29
+ import { GitHostBrowserModel } from '../models/GitHostBrowserModel';
30
+
31
+ // Lazy-load the heavy FileBrowser from fs-ui
32
+ const FileBrowser = React.lazy(() =>
33
+ import('@anymux/fs-ui').then((m) => ({ default: m.FileBrowser }))
34
+ );
35
+
36
+ const CommitList = React.lazy(() =>
37
+ import('@anymux/fs-ui').then((m) => ({ default: m.CommitList }))
38
+ );
39
+
40
+ const BranchList = React.lazy(() =>
41
+ import('@anymux/fs-ui').then((m) => ({ default: m.BranchList }))
42
+ );
43
+
44
+ const DiffViewer = React.lazy(() =>
45
+ import('@anymux/fs-ui').then((m) => ({ default: m.DiffViewer }))
46
+ );
47
+
48
+ // ---- Helpers ----
49
+
50
+ function formatRelativeTime(date: Date): string {
51
+ const now = Date.now();
52
+ const diffMs = now - new Date(date).getTime();
53
+ const seconds = Math.floor(diffMs / 1000);
54
+ const minutes = Math.floor(seconds / 60);
55
+ const hours = Math.floor(minutes / 60);
56
+ const days = Math.floor(hours / 24);
57
+ const weeks = Math.floor(days / 7);
58
+ const months = Math.floor(days / 30);
59
+
60
+ if (seconds < 60) return 'just now';
61
+ if (minutes < 60) return `${minutes}m ago`;
62
+ if (hours < 24) return `${hours}h ago`;
63
+ if (days < 7) return `${days}d ago`;
64
+ if (weeks < 5) return `${weeks}w ago`;
65
+ if (months < 12) return `${months}mo ago`;
66
+ return new Date(date).toLocaleDateString();
67
+ }
68
+
69
+ function LoadingSpinner() {
70
+ return (
71
+ <div className="flex items-center justify-center py-8">
72
+ <Loader2 className="h-5 w-5 text-gray-400 animate-spin" />
73
+ </div>
74
+ );
75
+ }
76
+
77
+ function ErrorMessage({ message }: { message: string }) {
78
+ return (
79
+ <div className="flex items-center gap-2 px-4 py-6 text-sm text-red-600 dark:text-red-400 justify-center">
80
+ <AlertCircle className="h-4 w-4 shrink-0" />
81
+ <span>{message}</span>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ // ---- Branch/Tag Selector Dropdown ----
87
+
88
+ type RefType = 'branch' | 'tag';
89
+ interface RefItem {
90
+ type: RefType;
91
+ name: string;
92
+ sha: string;
93
+ isDefault?: boolean;
94
+ }
95
+
96
+ interface RefSelectorProps {
97
+ branches: GitBranch[];
98
+ tags: GitTag[];
99
+ currentRef: string;
100
+ onSelectRef: (ref: string, type: RefType) => void;
101
+ }
102
+
103
+ function RefSelector({ branches, tags, currentRef, onSelectRef }: RefSelectorProps) {
104
+ const [isOpen, setIsOpen] = useState(false);
105
+ const [filterTab, setFilterTab] = useState<'branches' | 'tags'>('branches');
106
+ const [search, setSearch] = useState('');
107
+ const dropdownRef = useRef<HTMLDivElement>(null);
108
+
109
+ // Close on click outside
110
+ useEffect(() => {
111
+ function handleClickOutside(e: MouseEvent) {
112
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
113
+ setIsOpen(false);
114
+ }
115
+ }
116
+ if (isOpen) {
117
+ document.addEventListener('mousedown', handleClickOutside);
118
+ return () => document.removeEventListener('mousedown', handleClickOutside);
119
+ }
120
+ }, [isOpen]);
121
+
122
+ const items: RefItem[] = filterTab === 'branches'
123
+ ? branches.map((b) => ({ type: 'branch' as const, name: b.name, sha: b.sha, isDefault: b.isDefault }))
124
+ : tags.map((t) => ({ type: 'tag' as const, name: t.name, sha: t.sha }));
125
+
126
+ const filtered = search
127
+ ? items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
128
+ : items;
129
+
130
+ return (
131
+ <div ref={dropdownRef} className="relative">
132
+ <button
133
+ onClick={() => setIsOpen(!isOpen)}
134
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
135
+ >
136
+ <GitBranchIcon className="h-3.5 w-3.5 text-gray-500" />
137
+ <span className="max-w-[160px] truncate" title={currentRef}>{currentRef}</span>
138
+ <ChevronDown className="h-3 w-3 text-gray-400" />
139
+ </button>
140
+
141
+ {isOpen && (
142
+ <div className="absolute left-0 top-full mt-1 z-50 w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden">
143
+ {/* Search */}
144
+ <div className="p-2 border-b border-gray-200 dark:border-gray-700">
145
+ <input
146
+ type="text"
147
+ placeholder="Filter branches/tags..."
148
+ value={search}
149
+ onChange={(e) => setSearch(e.target.value)}
150
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 focus:outline-none focus:ring-1 focus:ring-blue-500"
151
+ autoFocus
152
+ />
153
+ </div>
154
+
155
+ {/* Tabs */}
156
+ <div className="flex border-b border-gray-200 dark:border-gray-700">
157
+ <button
158
+ onClick={() => { setFilterTab('branches'); setSearch(''); }}
159
+ className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
160
+ filterTab === 'branches'
161
+ ? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
162
+ : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
163
+ }`}
164
+ >
165
+ <GitBranchIcon className="h-3 w-3" />
166
+ Branches
167
+ </button>
168
+ <button
169
+ onClick={() => { setFilterTab('tags'); setSearch(''); }}
170
+ className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
171
+ filterTab === 'tags'
172
+ ? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
173
+ : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
174
+ }`}
175
+ >
176
+ <TagIcon className="h-3 w-3" />
177
+ Tags
178
+ </button>
179
+ </div>
180
+
181
+ {/* Items */}
182
+ <div className="max-h-64 overflow-auto">
183
+ {filtered.length === 0 ? (
184
+ <div className="px-3 py-4 text-center text-xs text-gray-400">
185
+ {search ? 'No matches found' : `No ${filterTab} found`}
186
+ </div>
187
+ ) : (
188
+ filtered.map((item) => (
189
+ <button
190
+ key={`${item.type}-${item.name}`}
191
+ onClick={() => {
192
+ onSelectRef(item.name, item.type);
193
+ setIsOpen(false);
194
+ setSearch('');
195
+ }}
196
+ className="flex items-center gap-2 w-full px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
197
+ >
198
+ <span className="w-4 flex-shrink-0">
199
+ {item.name === currentRef && (
200
+ <Check className="h-3.5 w-3.5 text-blue-500" />
201
+ )}
202
+ </span>
203
+ <span className="text-xs truncate flex-1" title={item.name}>{item.name}</span>
204
+ {item.isDefault && (
205
+ <span className="px-1.5 py-0.5 text-[9px] font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded flex-shrink-0">
206
+ default
207
+ </span>
208
+ )}
209
+ </button>
210
+ ))
211
+ )}
212
+ </div>
213
+ </div>
214
+ )}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ // ---- Git Repository File Browser ----
220
+
221
+ export interface GitRepoBrowserProps {
222
+ gitRepo: IGitRepo;
223
+ owner: string;
224
+ repo: string;
225
+ /** Creates an IFileSystem scoped to a particular branch/ref */
226
+ createFileSystem: (branch: string) => Promise<IFileSystem>;
227
+ /** Optional git host adapter for PRs/Issues tabs */
228
+ gitHost?: IGitHost;
229
+ onError?: (err: { message: string }) => void;
230
+ /** Optional action notifications model for recording actions */
231
+ actionNotifications?: ActionNotificationModel;
232
+ }
233
+
234
+ export const GitRepoBrowser: React.FC<GitRepoBrowserProps> = observer(({
235
+ gitRepo,
236
+ owner,
237
+ repo,
238
+ createFileSystem,
239
+ gitHost,
240
+ onError,
241
+ actionNotifications,
242
+ }) => {
243
+ const [model] = useState(
244
+ () => new GitRepoBrowserModel(gitRepo, gitHost, createFileSystem, onError)
245
+ );
246
+
247
+ // When gitHost arrives asynchronously after model construction, inject it
248
+ useEffect(() => {
249
+ if (gitHost && !model.hasGitHost) {
250
+ model.setGitHost(gitHost);
251
+ }
252
+ }, [gitHost, model]);
253
+
254
+ const handleNotify = useCallback((type: 'success' | 'error' | 'warning', message: string) => {
255
+ if (type === 'success' && actionNotifications) {
256
+ const actionType = message.startsWith('Renamed') ? 'rename' as const
257
+ : message.startsWith('Deleted') ? 'delete' as const
258
+ : message.startsWith('Created') ? 'create' as const
259
+ : message.startsWith('Uploaded') || message.includes('upload') ? 'upload' as const
260
+ : 'create' as const;
261
+ showActionToast(actionNotifications, actionType, message);
262
+ } else if (type === 'error') {
263
+ showErrorToast(message);
264
+ } else {
265
+ toast[type](message);
266
+ }
267
+ }, [actionNotifications]);
268
+
269
+ useEffect(() => {
270
+ model.initialize();
271
+ }, [model]);
272
+
273
+ if (model.loading && !model.fileSystem) {
274
+ return <LoadingSpinner />;
275
+ }
276
+
277
+ if (model.error && !model.fileSystem) {
278
+ return (
279
+ <BrowserError
280
+ error={model.error}
281
+ context={`${owner}/${repo}`}
282
+ onRetry={() => window.location.reload()}
283
+ />
284
+ );
285
+ }
286
+
287
+ const sidebarTabs: Array<{ id: SidebarTab; label: string; icon: React.ReactNode }> = [
288
+ { id: 'files', label: 'Files', icon: <FolderTree className="h-3.5 w-3.5" /> },
289
+ { id: 'branches', label: 'Branches', icon: <GitBranchIcon className="h-3.5 w-3.5" /> },
290
+ { id: 'commits', label: 'Commits', icon: <GitCommitIcon className="h-3.5 w-3.5" /> },
291
+ ...(model.hasGitHost ? [
292
+ { id: 'prs' as const, label: 'PRs', icon: <GitPullRequest className="h-3.5 w-3.5" /> },
293
+ { id: 'issues' as const, label: 'Issues', icon: <AlertCircle className="h-3.5 w-3.5" /> },
294
+ ] : []),
295
+ ];
296
+
297
+ return (
298
+ <div className="flex flex-col h-full">
299
+ {/* Header with branch selector */}
300
+ <div className="flex items-center gap-3 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
301
+ <RefSelector
302
+ branches={model.branches}
303
+ tags={model.tags}
304
+ currentRef={model.currentRef}
305
+ onSelectRef={(ref) => model.switchRef(ref)}
306
+ />
307
+ <div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 min-w-0">
308
+ <span className="font-medium text-gray-700 dark:text-gray-300 truncate" title={owner}>
309
+ {owner}
310
+ </span>
311
+ <span>/</span>
312
+ <span className="font-medium text-gray-700 dark:text-gray-300 truncate" title={repo}>
313
+ {repo}
314
+ </span>
315
+ </div>
316
+ </div>
317
+
318
+ {/* Sidebar tabs */}
319
+ <div className="flex border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
320
+ {sidebarTabs.map((tab) => (
321
+ <button
322
+ key={tab.id}
323
+ onClick={() => model.setActiveTab(tab.id)}
324
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
325
+ model.activeTab === tab.id
326
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
327
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
328
+ }`}
329
+ >
330
+ {tab.icon}
331
+ {tab.label}
332
+ </button>
333
+ ))}
334
+ </div>
335
+
336
+ {/* Tab content */}
337
+ <div className="flex-1 min-h-0 relative flex flex-col">
338
+ {model.loading && (
339
+ <div className="absolute inset-0 bg-white/60 dark:bg-gray-900/60 z-10 flex items-center justify-center">
340
+ <Loader2 className="h-5 w-5 text-gray-400 animate-spin" />
341
+ </div>
342
+ )}
343
+ {model.error && model.fileSystem && (
344
+ <div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 text-xs text-red-600 dark:text-red-400 border-b border-red-200 dark:border-red-800 flex-shrink-0">
345
+ {model.error}
346
+ </div>
347
+ )}
348
+
349
+ {/* Files tab */}
350
+ {model.activeTab === 'files' && model.fileSystem && (
351
+ <div className="flex-1 min-h-0">
352
+ <Suspense fallback={<LoadingSpinner />}>
353
+ <FileBrowser
354
+ key={model.currentRef}
355
+ fileSystem={model.fileSystem}
356
+ className="h-full"
357
+ onError={onError}
358
+ onNotify={handleNotify}
359
+ />
360
+ </Suspense>
361
+ </div>
362
+ )}
363
+
364
+ {/* Branches tab */}
365
+ {model.activeTab === 'branches' && (
366
+ <div className="flex-1 min-h-0">
367
+ <Suspense fallback={<LoadingSpinner />}>
368
+ <BranchList
369
+ branches={model.branches}
370
+ currentBranch={model.currentRef}
371
+ onSelectBranch={(branch) => model.selectBranch(branch)}
372
+ className="h-full"
373
+ />
374
+ </Suspense>
375
+ </div>
376
+ )}
377
+
378
+ {/* Commits tab */}
379
+ {model.activeTab === 'commits' && (
380
+ <div className="flex-1 min-h-0 flex flex-col">
381
+ {model.commitsLoading ? (
382
+ <LoadingSpinner />
383
+ ) : (
384
+ <>
385
+ <Suspense fallback={<LoadingSpinner />}>
386
+ <CommitList
387
+ commits={model.commits}
388
+ headSha={model.headSha}
389
+ selectedSha={model.selectedCommitSha}
390
+ onSelectCommit={(commit) => model.selectCommit(commit)}
391
+ className={model.selectedCommitSha ? 'max-h-[50%] overflow-auto flex-shrink-0' : 'flex-1 overflow-auto'}
392
+ />
393
+ </Suspense>
394
+
395
+ {/* Commits pagination */}
396
+ {(model.commitsPage > 0 || model.hasMoreCommits) && (
397
+ <div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
398
+ <button
399
+ onClick={() => model.prevCommitsPage()}
400
+ disabled={model.commitsPage === 0}
401
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
402
+ >
403
+ <ChevronLeft className="h-3.5 w-3.5" /> Previous
404
+ </button>
405
+ <span className="text-xs text-gray-500">Page {model.commitsPage + 1}</span>
406
+ <button
407
+ onClick={() => model.nextCommitsPage()}
408
+ disabled={!model.hasMoreCommits}
409
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
410
+ >
411
+ Next <ChevronRight className="h-3.5 w-3.5" />
412
+ </button>
413
+ </div>
414
+ )}
415
+
416
+ {/* Diff panel below commits when a commit is selected */}
417
+ {model.selectedCommitSha && (
418
+ <div className="flex-1 min-h-0 border-t border-gray-200 dark:border-gray-700 overflow-auto">
419
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
420
+ <Diff className="h-3.5 w-3.5 text-gray-400" />
421
+ <span className="text-[10px] font-medium text-gray-600 dark:text-gray-300">
422
+ Changes in{' '}
423
+ <code className="font-mono text-blue-600 dark:text-blue-400">
424
+ {model.selectedCommitSha.slice(0, 7)}
425
+ </code>
426
+ </span>
427
+ </div>
428
+ {model.diffLoading ? (
429
+ <LoadingSpinner />
430
+ ) : (
431
+ <Suspense fallback={<LoadingSpinner />}>
432
+ <DiffViewer entries={model.diffEntries} className="p-2" />
433
+ </Suspense>
434
+ )}
435
+ </div>
436
+ )}
437
+ </>
438
+ )}
439
+ </div>
440
+ )}
441
+
442
+ {/* PRs tab */}
443
+ {model.activeTab === 'prs' && model.hasGitHost && (
444
+ <div className="flex-1 min-h-0 flex flex-col">
445
+ {model.hostLoading && <LoadingSpinner />}
446
+ {!model.hostLoading && model.hostError && (
447
+ <BrowserError error={model.hostError} context="Pull Requests" onRetry={() => model.retryPRs()} />
448
+ )}
449
+ {!model.hostLoading && !model.hostError && (
450
+ model.prs.length === 0 ? (
451
+ <div className="flex items-center justify-center py-8 text-xs text-gray-400">No pull requests found</div>
452
+ ) : (
453
+ <>
454
+ <div className="flex-1 overflow-auto divide-y divide-gray-100 dark:divide-gray-800">
455
+ {model.prs.map((pr) => (
456
+ <div key={pr.number} className="flex items-start gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50">
457
+ <GitPullRequest className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
458
+ <div className="flex-1 min-w-0">
459
+ <div className="flex items-center gap-2">
460
+ <span className="text-sm truncate" title={pr.title}>{pr.title}</span>
461
+ <span className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
462
+ pr.state === 'open' ? 'text-green-600 bg-green-50 dark:text-green-400 dark:bg-green-900/30' :
463
+ pr.state === 'merged' ? 'text-purple-600 bg-purple-50 dark:text-purple-400 dark:bg-purple-900/30' :
464
+ 'text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-900/30'
465
+ }`}>
466
+ {pr.state}
467
+ </span>
468
+ </div>
469
+ <span className="text-xs text-gray-500">
470
+ #{pr.number} · {pr.author} · {formatRelativeTime(pr.createdAt)}
471
+ </span>
472
+ </div>
473
+ </div>
474
+ ))}
475
+ </div>
476
+ {(model.prPage > 0 || model.hasMorePrs) && (
477
+ <div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
478
+ <button
479
+ onClick={() => model.prevPrPage()}
480
+ disabled={model.prPage === 0}
481
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
482
+ >
483
+ <ChevronLeft className="h-3.5 w-3.5" /> Previous
484
+ </button>
485
+ <span className="text-xs text-gray-500">Page {model.prPage + 1}</span>
486
+ <button
487
+ onClick={() => model.nextPrPage()}
488
+ disabled={!model.hasMorePrs}
489
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
490
+ >
491
+ Next <ChevronRight className="h-3.5 w-3.5" />
492
+ </button>
493
+ </div>
494
+ )}
495
+ </>
496
+ )
497
+ )}
498
+ </div>
499
+ )}
500
+
501
+ {/* Issues tab */}
502
+ {model.activeTab === 'issues' && model.hasGitHost && (
503
+ <div className="flex-1 min-h-0 flex flex-col">
504
+ {model.hostLoading && <LoadingSpinner />}
505
+ {!model.hostLoading && model.hostError && (
506
+ <BrowserError error={model.hostError} context="Issues" onRetry={() => model.retryIssues()} />
507
+ )}
508
+ {!model.hostLoading && !model.hostError && (
509
+ model.issues.length === 0 ? (
510
+ <div className="flex items-center justify-center py-8 text-xs text-gray-400">No issues found</div>
511
+ ) : (
512
+ <>
513
+ <div className="flex-1 overflow-auto divide-y divide-gray-100 dark:divide-gray-800">
514
+ {model.issues.map((issue) => (
515
+ <div key={issue.number} className="flex items-start gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50">
516
+ <AlertCircle className={`h-4 w-4 shrink-0 mt-0.5 ${issue.state === 'open' ? 'text-green-500' : 'text-red-400'}`} />
517
+ <div className="flex-1 min-w-0">
518
+ <div className="flex items-center gap-2">
519
+ <span className="text-sm truncate" title={issue.title}>{issue.title}</span>
520
+ {issue.labels.map((l) => (
521
+ <span key={l} className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
522
+ {l}
523
+ </span>
524
+ ))}
525
+ </div>
526
+ <span className="text-xs text-gray-500">
527
+ #{issue.number} · {issue.author} · {formatRelativeTime(issue.createdAt)}
528
+ </span>
529
+ </div>
530
+ </div>
531
+ ))}
532
+ </div>
533
+ {(model.issuePage > 0 || model.hasMoreIssues) && (
534
+ <div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
535
+ <button
536
+ onClick={() => model.prevIssuePage()}
537
+ disabled={model.issuePage === 0}
538
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
539
+ >
540
+ <ChevronLeft className="h-3.5 w-3.5" /> Previous
541
+ </button>
542
+ <span className="text-xs text-gray-500">Page {model.issuePage + 1}</span>
543
+ <button
544
+ onClick={() => model.nextIssuePage()}
545
+ disabled={!model.hasMoreIssues}
546
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
547
+ >
548
+ Next <ChevronRight className="h-3.5 w-3.5" />
549
+ </button>
550
+ </div>
551
+ )}
552
+ </>
553
+ )
554
+ )}
555
+ </div>
556
+ )}
557
+ </div>
558
+ </div>
559
+ );
560
+ });
561
+
562
+ // ---- Git Host Browser (PRs / Issues) ----
563
+
564
+ interface GitHostBrowserProps {
565
+ gitHost: IGitHost;
566
+ }
567
+
568
+ function EmptyState({ message }: { message: string }) {
569
+ return (
570
+ <div className="flex items-center justify-center py-8 text-xs text-gray-400">
571
+ {message}
572
+ </div>
573
+ );
574
+ }
575
+
576
+ function statusColor(status: string) {
577
+ switch (status) {
578
+ case 'open': return 'text-green-600 bg-green-50 dark:text-green-400 dark:bg-green-900/30';
579
+ case 'merged': return 'text-purple-600 bg-purple-50 dark:text-purple-400 dark:bg-purple-900/30';
580
+ case 'closed': return 'text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-900/30';
581
+ default: return 'text-gray-600 bg-gray-50';
582
+ }
583
+ }
584
+
585
+ export const GitHostBrowser: React.FC<GitHostBrowserProps> = observer(({ gitHost }) => {
586
+ const [model] = useState(() => new GitHostBrowserModel(gitHost));
587
+
588
+ useEffect(() => {
589
+ model.loadData();
590
+ }, [model]);
591
+
592
+ const tabDefs: Array<{ id: 'prs' | 'issues'; label: string; icon: React.ReactNode }> = [
593
+ { id: 'prs', label: 'Pull Requests', icon: <GitPullRequest className="h-3.5 w-3.5" /> },
594
+ { id: 'issues', label: 'Issues', icon: <AlertCircle className="h-3.5 w-3.5" /> },
595
+ ];
596
+
597
+ return (
598
+ <div className="flex flex-col h-full">
599
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
600
+ <GitPullRequest className="h-4 w-4 text-gray-500" />
601
+ <span className="text-xs font-medium text-gray-700 dark:text-gray-300">
602
+ Git Host Browser
603
+ </span>
604
+ </div>
605
+
606
+ <div className="flex border-b border-gray-200 dark:border-gray-700">
607
+ {tabDefs.map((tab) => (
608
+ <button
609
+ key={tab.id}
610
+ onClick={() => model.setActiveTab(tab.id)}
611
+ className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
612
+ model.activeTab === tab.id
613
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
614
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
615
+ }`}
616
+ >
617
+ {tab.icon}
618
+ {tab.label}
619
+ </button>
620
+ ))}
621
+ </div>
622
+
623
+ <div className="flex-1 overflow-auto">
624
+ {model.loading && <LoadingSpinner />}
625
+ {!model.loading && model.error && (
626
+ <BrowserError
627
+ error={model.error}
628
+ context="Git Host"
629
+ onRetry={() => model.retry()}
630
+ />
631
+ )}
632
+
633
+ {!model.loading && !model.error && model.activeTab === 'prs' && (
634
+ model.prs.length === 0 ? <EmptyState message="No pull requests found" /> : (
635
+ <div className="divide-y divide-gray-100 dark:divide-gray-800">
636
+ {model.prs.map((pr) => (
637
+ <div key={pr.number} className="flex items-start gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50">
638
+ <GitPullRequest className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
639
+ <div className="flex-1 min-w-0">
640
+ <div className="flex items-center gap-2">
641
+ <span className="text-sm truncate" title={pr.title}>{pr.title}</span>
642
+ <span className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${statusColor(pr.state)}`}>
643
+ {pr.state}
644
+ </span>
645
+ </div>
646
+ <span className="text-xs text-gray-500">
647
+ #{pr.number} · {pr.author} · {formatRelativeTime(pr.createdAt)}
648
+ </span>
649
+ </div>
650
+ </div>
651
+ ))}
652
+ </div>
653
+ )
654
+ )}
655
+
656
+ {!model.loading && !model.error && model.activeTab === 'issues' && (
657
+ model.issues.length === 0 ? <EmptyState message="No issues found" /> : (
658
+ <div className="divide-y divide-gray-100 dark:divide-gray-800">
659
+ {model.issues.map((issue) => (
660
+ <div key={issue.number} className="flex items-start gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50">
661
+ <AlertCircle className="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
662
+ <div className="flex-1 min-w-0">
663
+ <div className="flex items-center gap-2">
664
+ <span className="text-sm truncate" title={issue.title}>{issue.title}</span>
665
+ {issue.labels.map((l) => (
666
+ <span key={l} className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
667
+ {l}
668
+ </span>
669
+ ))}
670
+ </div>
671
+ <span className="text-xs text-gray-500">
672
+ #{issue.number} · {issue.author} · {formatRelativeTime(issue.createdAt)}
673
+ </span>
674
+ </div>
675
+ </div>
676
+ ))}
677
+ </div>
678
+ )
679
+ )}
680
+ </div>
681
+
682
+ {model.showPagination && !model.loading && !model.error && model.currentItems.length > 0 && (
683
+ <div className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
684
+ <button
685
+ onClick={() => model.prevPage()}
686
+ disabled={model.page === 0}
687
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
688
+ >
689
+ <ChevronLeft className="h-3.5 w-3.5" />
690
+ Previous
691
+ </button>
692
+ <span className="text-xs text-gray-500">Page {model.page + 1}</span>
693
+ <button
694
+ onClick={() => model.nextPage()}
695
+ disabled={!model.hasMore}
696
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:pointer-events-none"
697
+ >
698
+ Next
699
+ <ChevronRight className="h-3.5 w-3.5" />
700
+ </button>
701
+ </div>
702
+ )}
703
+ </div>
704
+ );
705
+ });