@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.
- package/dist/GitBrowser-BLgTNQyd.js +905 -0
- package/dist/GitBrowser-BLgTNQyd.js.map +1 -0
- package/dist/GitBrowser-CIyWiuX-.js +3 -0
- package/dist/ObjectStorageBrowser-B2YkUxMl.js +3 -0
- package/dist/ObjectStorageBrowser-B_25Emfu.js +267 -0
- package/dist/ObjectStorageBrowser-B_25Emfu.js.map +1 -0
- package/dist/RepoPicker-BprFGOn7.js +3 -0
- package/dist/RepoPicker-CoHMiJ-3.js +168 -0
- package/dist/RepoPicker-CoHMiJ-3.js.map +1 -0
- package/dist/index.d.ts +697 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +3 -0
- package/dist/scope-labels-B4VAwoL6.js +582 -0
- package/dist/scope-labels-B4VAwoL6.js.map +1 -0
- package/dist/scope-labels-DvdJLcSL.d.ts +50 -0
- package/dist/scope-labels-DvdJLcSL.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/adapters/adapter-registry.ts +177 -0
- package/src/auth/auth-client.ts +101 -0
- package/src/auth/token-manager.ts +27 -0
- package/src/components/ActionHistoryPanel.tsx +137 -0
- package/src/components/CapabilityCell.tsx +97 -0
- package/src/components/CapabilityError.tsx +50 -0
- package/src/components/CapabilityPanel.tsx +530 -0
- package/src/components/CapabilityPill.tsx +56 -0
- package/src/components/ConnectButton.tsx +149 -0
- package/src/components/ConnectedMenu.tsx +142 -0
- package/src/components/ConnectionStatus.tsx +28 -0
- package/src/components/CredentialForm.tsx +246 -0
- package/src/components/FullScreenBrowser.tsx +84 -0
- package/src/components/GitBrowser.tsx +705 -0
- package/src/components/GitHubRepoPicker.tsx +125 -0
- package/src/components/ObjectStorageBrowser.tsx +176 -0
- package/src/components/RepoPicker.tsx +93 -0
- package/src/components/ServiceCard.tsx +77 -0
- package/src/components/ServiceCardGrid.tsx +141 -0
- package/src/components/ServiceDashboard.tsx +84 -0
- package/src/components/ServiceIcon.tsx +37 -0
- package/src/components/ServiceRow.tsx +50 -0
- package/src/components/useAdapter.ts +33 -0
- package/src/demos/ServiceDashboardDemo.tsx +108 -0
- package/src/index.ts +68 -0
- package/src/models/ActionNotificationModel.ts +72 -0
- package/src/models/ConnectionManagerModel.ts +410 -0
- package/src/models/CredentialFormModel.ts +111 -0
- package/src/models/DashboardModel.ts +157 -0
- package/src/models/GitHostBrowserModel.ts +89 -0
- package/src/models/GitRepoBrowserModel.ts +285 -0
- package/src/models/ObjectStorageBrowserModel.ts +131 -0
- package/src/models/RepoPickerModel.ts +132 -0
- package/src/registry/service-registry.ts +46 -0
- package/src/registry/services/apple.ts +22 -0
- package/src/registry/services/bitbucket.ts +24 -0
- package/src/registry/services/box.ts +22 -0
- package/src/registry/services/browser-fs.ts +19 -0
- package/src/registry/services/dropbox.ts +22 -0
- package/src/registry/services/flickr.ts +22 -0
- package/src/registry/services/gitea.ts +24 -0
- package/src/registry/services/github.ts +24 -0
- package/src/registry/services/gitlab.ts +24 -0
- package/src/registry/services/google.ts +24 -0
- package/src/registry/services/icloud.ts +23 -0
- package/src/registry/services/indexeddb.ts +19 -0
- package/src/registry/services/instagram.ts +22 -0
- package/src/registry/services/microsoft.ts +24 -0
- package/src/registry/services/s3.ts +21 -0
- package/src/registry/services/webdav.ts +21 -0
- package/src/registry.ts +4 -0
- package/src/types/connection-state.ts +33 -0
- package/src/types/connection.ts +11 -0
- package/src/types/optional-deps.d.ts +149 -0
- package/src/types/service.ts +18 -0
- package/src/types/user-profile.ts +21 -0
- package/src/utils/action-toast.ts +53 -0
- 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
|
+
});
|