@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,530 @@
|
|
|
1
|
+
import React, { Suspense, useState, useCallback } from 'react';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import { showActionToast, showErrorToast } from '../utils/action-toast';
|
|
4
|
+
import { match } from 'ts-pattern';
|
|
5
|
+
import { X, ArrowLeft, AlertTriangle } from 'lucide-react';
|
|
6
|
+
import { LoadingSpinner } from '@anymux/ui/components/loading-spinner';
|
|
7
|
+
import type { DashboardModel } from '../models/DashboardModel';
|
|
8
|
+
import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
|
|
9
|
+
import type { ActionNotificationModel } from '../models/ActionNotificationModel';
|
|
10
|
+
import type { CapabilityId } from '../types/service';
|
|
11
|
+
import type { IFileSystem, IObjectStorage } from '@anymux/file-system';
|
|
12
|
+
import { adapterRegistry } from '../adapters/adapter-registry';
|
|
13
|
+
import { useAdapter } from './useAdapter';
|
|
14
|
+
import { CapabilityError, isAuthError } from './CapabilityError';
|
|
15
|
+
|
|
16
|
+
// Lazy-load browser components
|
|
17
|
+
const FileBrowser = React.lazy(() =>
|
|
18
|
+
import('@anymux/fs-ui').then((m) => ({ default: m.FileBrowser }))
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const LazyMediaBrowser = React.lazy(() =>
|
|
22
|
+
import('@anymux/object-ui').then((m) => ({
|
|
23
|
+
default: ({ provider }: { provider: unknown }) => {
|
|
24
|
+
const model = new m.MediaBrowserModel(provider as never);
|
|
25
|
+
return React.createElement(m.MediaBrowser, { model, provider: provider as never, className: 'h-full' });
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const LazyContactBrowser = React.lazy(() =>
|
|
31
|
+
import('@anymux/object-ui').then((m) => ({
|
|
32
|
+
default: ({ provider }: { provider: unknown }) => {
|
|
33
|
+
const model = new m.ContactListModel(provider as never);
|
|
34
|
+
return React.createElement(m.ContactBrowser, { model, className: 'h-full' });
|
|
35
|
+
},
|
|
36
|
+
}))
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const LazyCalendarBrowser = React.lazy(() =>
|
|
40
|
+
import('@anymux/object-ui').then((m) => ({
|
|
41
|
+
default: ({ provider }: { provider: unknown }) => {
|
|
42
|
+
const model = new m.CalendarModel(provider as never);
|
|
43
|
+
return React.createElement(m.CalendarBrowser, { model, className: 'h-full' });
|
|
44
|
+
},
|
|
45
|
+
}))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Lazy-load RepoPicker
|
|
49
|
+
const RepoPicker = React.lazy(() =>
|
|
50
|
+
import('./RepoPicker').then((m) => ({ default: m.RepoPicker }))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Lazy-load browser components from this package
|
|
54
|
+
const ObjectStorageBrowserLazy = React.lazy(() =>
|
|
55
|
+
import('./ObjectStorageBrowser').then((m) => ({ default: m.ObjectStorageBrowser }))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const GitRepoBrowserLazy = React.lazy(() =>
|
|
59
|
+
import('./GitBrowser').then((m) => ({ default: m.GitRepoBrowser }))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const GitHostBrowserLazy = React.lazy(() =>
|
|
63
|
+
import('./GitBrowser').then((m) => ({ default: m.GitHostBrowser }))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const CAPABILITY_LABELS: Record<CapabilityId, string> = {
|
|
67
|
+
'file-system': 'File System',
|
|
68
|
+
'object-storage': 'Object Storage',
|
|
69
|
+
'git-repo': 'Git',
|
|
70
|
+
'git-host': 'Git',
|
|
71
|
+
'media': 'Media',
|
|
72
|
+
'contacts': 'Contacts',
|
|
73
|
+
'calendar': 'Calendar',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Services that need repo selection before showing capability content
|
|
77
|
+
const REPO_SERVICES = new Set(['github', 'gitlab', 'bitbucket', 'gitea']);
|
|
78
|
+
|
|
79
|
+
const LoadingFallback = () => (
|
|
80
|
+
<div className="flex items-center justify-center h-64">
|
|
81
|
+
<LoadingSpinner label="Loading..." />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// --- Git repo browser wrapper that provides createFileSystem ---
|
|
86
|
+
|
|
87
|
+
function GitRepoBrowserContent({
|
|
88
|
+
serviceId,
|
|
89
|
+
token,
|
|
90
|
+
gitRepo,
|
|
91
|
+
owner,
|
|
92
|
+
repo,
|
|
93
|
+
selectedRepo,
|
|
94
|
+
onError,
|
|
95
|
+
actionNotifications,
|
|
96
|
+
}: {
|
|
97
|
+
serviceId: string;
|
|
98
|
+
token: string;
|
|
99
|
+
gitRepo: import('@anymux/file-system').IGitRepo;
|
|
100
|
+
owner: string;
|
|
101
|
+
repo: string;
|
|
102
|
+
selectedRepo?: { owner: string; repo: string };
|
|
103
|
+
onError?: (err: { message: string }) => void;
|
|
104
|
+
actionNotifications?: ActionNotificationModel;
|
|
105
|
+
}) {
|
|
106
|
+
const createFileSystem = useCallback(
|
|
107
|
+
async (branch: string): Promise<IFileSystem> => {
|
|
108
|
+
const context = { selectedRepo, branch };
|
|
109
|
+
const fs = await adapterRegistry.create(serviceId, token, 'file-system', context);
|
|
110
|
+
return fs as IFileSystem;
|
|
111
|
+
},
|
|
112
|
+
[serviceId, token, selectedRepo],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Also load git-host adapter for PRs/Issues tabs
|
|
116
|
+
const [gitHost, setGitHost] = useState<import('@anymux/file-system').IGitHost | undefined>();
|
|
117
|
+
React.useEffect(() => {
|
|
118
|
+
let cancelled = false;
|
|
119
|
+
adapterRegistry.create(serviceId, token, 'git-host', { selectedRepo })
|
|
120
|
+
.then((adapter) => { if (!cancelled) setGitHost(adapter as any); })
|
|
121
|
+
.catch(() => {}); // Silently fail — PRs/Issues just won't show
|
|
122
|
+
return () => { cancelled = true; };
|
|
123
|
+
}, [serviceId, token, selectedRepo]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<GitRepoBrowserLazy
|
|
127
|
+
gitRepo={gitRepo}
|
|
128
|
+
owner={owner}
|
|
129
|
+
repo={repo}
|
|
130
|
+
createFileSystem={createFileSystem}
|
|
131
|
+
gitHost={gitHost}
|
|
132
|
+
onError={onError}
|
|
133
|
+
actionNotifications={actionNotifications}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Git Host wrapper that also loads git-repo adapter for unified browser ---
|
|
139
|
+
|
|
140
|
+
function GitHostBrowserWithRepo({
|
|
141
|
+
serviceId,
|
|
142
|
+
token,
|
|
143
|
+
gitHost,
|
|
144
|
+
owner,
|
|
145
|
+
repo,
|
|
146
|
+
selectedRepo,
|
|
147
|
+
onError,
|
|
148
|
+
actionNotifications,
|
|
149
|
+
}: {
|
|
150
|
+
serviceId: string;
|
|
151
|
+
token: string;
|
|
152
|
+
gitHost: import('@anymux/file-system').IGitHost;
|
|
153
|
+
owner: string;
|
|
154
|
+
repo: string;
|
|
155
|
+
selectedRepo?: { owner: string; repo: string };
|
|
156
|
+
onError?: (err: { message: string }) => void;
|
|
157
|
+
actionNotifications?: ActionNotificationModel;
|
|
158
|
+
}) {
|
|
159
|
+
const [gitRepo, setGitRepo] = useState<import('@anymux/file-system').IGitRepo | undefined>();
|
|
160
|
+
const [repoLoaded, setRepoLoaded] = useState(false);
|
|
161
|
+
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
let cancelled = false;
|
|
164
|
+
adapterRegistry.create(serviceId, token, 'git-repo', { selectedRepo })
|
|
165
|
+
.then((adapter) => { if (!cancelled) setGitRepo(adapter as any); })
|
|
166
|
+
.catch(() => {}) // Silently fail — just show PRs/Issues
|
|
167
|
+
.finally(() => { if (!cancelled) setRepoLoaded(true); });
|
|
168
|
+
return () => { cancelled = true; };
|
|
169
|
+
}, [serviceId, token, selectedRepo]);
|
|
170
|
+
|
|
171
|
+
const createFileSystem = useCallback(
|
|
172
|
+
async (branch: string): Promise<IFileSystem> => {
|
|
173
|
+
const context = { selectedRepo, branch };
|
|
174
|
+
const fs = await adapterRegistry.create(serviceId, token, 'file-system', context);
|
|
175
|
+
return fs as IFileSystem;
|
|
176
|
+
},
|
|
177
|
+
[serviceId, token, selectedRepo],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (!repoLoaded) return <LoadingFallback />;
|
|
181
|
+
|
|
182
|
+
// If we have git-repo, show unified browser with all tabs
|
|
183
|
+
if (gitRepo) {
|
|
184
|
+
return (
|
|
185
|
+
<GitRepoBrowserLazy
|
|
186
|
+
gitRepo={gitRepo}
|
|
187
|
+
owner={owner}
|
|
188
|
+
repo={repo}
|
|
189
|
+
createFileSystem={createFileSystem}
|
|
190
|
+
gitHost={gitHost}
|
|
191
|
+
onError={onError}
|
|
192
|
+
actionNotifications={actionNotifications}
|
|
193
|
+
/>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Fallback: just show PRs/Issues if git-repo adapter not available
|
|
198
|
+
return <GitHostBrowserLazy gitHost={gitHost} />;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- Generic capability content renderer ---
|
|
202
|
+
|
|
203
|
+
const GenericCapabilityContent = observer(function GenericCapabilityContent({
|
|
204
|
+
serviceId,
|
|
205
|
+
capabilityId,
|
|
206
|
+
token,
|
|
207
|
+
dashboardModel,
|
|
208
|
+
connectionManager,
|
|
209
|
+
}: {
|
|
210
|
+
serviceId: string;
|
|
211
|
+
capabilityId: CapabilityId;
|
|
212
|
+
token: string;
|
|
213
|
+
dashboardModel: DashboardModel;
|
|
214
|
+
connectionManager: ConnectionManagerModel;
|
|
215
|
+
}) {
|
|
216
|
+
const selectedRepo = dashboardModel.getSelectedRepo(serviceId);
|
|
217
|
+
const context = { selectedRepo: selectedRepo ?? undefined };
|
|
218
|
+
|
|
219
|
+
const { adapter, loading, error, retry } = useAdapter(
|
|
220
|
+
async () => {
|
|
221
|
+
// Refresh OAuth token before creating adapter to avoid stale 401s
|
|
222
|
+
console.info(`[AnyMux] CapabilityPanel: refreshing token for ${serviceId}/${capabilityId}`);
|
|
223
|
+
const freshToken = await connectionManager.refreshToken(serviceId) ?? token;
|
|
224
|
+
console.info(`[AnyMux] CapabilityPanel: creating adapter with token ${freshToken.slice(0, 8)}…${freshToken.slice(-4)}`);
|
|
225
|
+
const a = adapterRegistry.create(serviceId, freshToken, capabilityId, context);
|
|
226
|
+
return a;
|
|
227
|
+
},
|
|
228
|
+
[serviceId, capabilityId, token, selectedRepo?.owner, selectedRepo?.repo],
|
|
229
|
+
// Pre-flight: verify the adapter actually works before mounting the browser UI.
|
|
230
|
+
// If we get a 401, try one more refresh — better-auth may not have detected
|
|
231
|
+
// the token as expired (e.g. missing accessTokenExpiresAt in the DB).
|
|
232
|
+
async (a) => {
|
|
233
|
+
const runCheck = async (adapter: any) => {
|
|
234
|
+
if (capabilityId === 'file-system') {
|
|
235
|
+
await (adapter as IFileSystem).readdir('/');
|
|
236
|
+
} else if (capabilityId === 'git-host') {
|
|
237
|
+
if (adapter.listRepositories) await adapter.listRepositories({ page: 1, perPage: 1 });
|
|
238
|
+
} else if (capabilityId === 'git-repo') {
|
|
239
|
+
if (adapter.listBranches) await adapter.listBranches();
|
|
240
|
+
} else if (capabilityId === 'calendar') {
|
|
241
|
+
if (adapter.getCalendars) await adapter.getCalendars();
|
|
242
|
+
} else if (capabilityId === 'contacts') {
|
|
243
|
+
if (adapter.getContacts) await adapter.getContacts({ limit: 1 });
|
|
244
|
+
} else if (capabilityId === 'media') {
|
|
245
|
+
if (adapter.getAlbums) await adapter.getAlbums();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
console.info(`[AnyMux] CapabilityPanel: running pre-flight for ${serviceId}/${capabilityId}`);
|
|
251
|
+
await runCheck(a);
|
|
252
|
+
console.info(`[AnyMux] CapabilityPanel: pre-flight passed`);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.warn(`[AnyMux] CapabilityPanel: pre-flight failed for ${serviceId}/${capabilityId}:`, err instanceof Error ? err.message : err);
|
|
255
|
+
// If we get a 401/auth error, try one more refresh and rebuild the adapter
|
|
256
|
+
if (err instanceof Error && isAuthError(err.message)) {
|
|
257
|
+
console.info(`[AnyMux] CapabilityPanel: auth error detected, attempting second refresh`);
|
|
258
|
+
const retryToken = await connectionManager.refreshToken(serviceId);
|
|
259
|
+
if (retryToken && retryToken !== token) {
|
|
260
|
+
console.info(`[AnyMux] CapabilityPanel: got different token on retry, rebuilding adapter`);
|
|
261
|
+
const retryAdapter = await adapterRegistry.create(serviceId, retryToken, capabilityId, context);
|
|
262
|
+
await runCheck(retryAdapter);
|
|
263
|
+
Object.assign(a as any, retryAdapter);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
console.warn(`[AnyMux] CapabilityPanel: retry token same as original, giving up`);
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const handleReconnect = async () => {
|
|
274
|
+
const service = dashboardModel.selectedService;
|
|
275
|
+
await connectionManager.disconnect(serviceId);
|
|
276
|
+
dashboardModel.closePanel();
|
|
277
|
+
// For OAuth services, immediately redirect to provider consent screen
|
|
278
|
+
const isOAuth = service && !['s3', 'webdav', 'gitea', 'icloud', 'browser-fs', 'indexeddb'].includes(service.authProvider);
|
|
279
|
+
if (isOAuth) {
|
|
280
|
+
await connectionManager.connect(serviceId);
|
|
281
|
+
} else {
|
|
282
|
+
// Signal ConnectButton to auto-open credential form / file picker
|
|
283
|
+
connectionManager.requestReconnect(serviceId);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const handleGoBack = () => {
|
|
288
|
+
dashboardModel.closePanel();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Track runtime auth errors (e.g. 401 from API calls after adapter is created)
|
|
292
|
+
const [runtimeError, setRuntimeError] = useState<string | null>(null);
|
|
293
|
+
|
|
294
|
+
const handleRuntimeError = (err: { message: string }) => {
|
|
295
|
+
if (isAuthError(err.message)) {
|
|
296
|
+
setRuntimeError(err.message);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const handleNotify = useCallback((type: 'success' | 'error' | 'warning', message: string) => {
|
|
301
|
+
if (type === 'success') {
|
|
302
|
+
showActionToast(dashboardModel.actionNotifications, 'create', message);
|
|
303
|
+
} else {
|
|
304
|
+
showErrorToast(message);
|
|
305
|
+
}
|
|
306
|
+
}, [dashboardModel.actionNotifications]);
|
|
307
|
+
|
|
308
|
+
const handleAction = useCallback((action: { type: 'create' | 'rename' | 'delete' | 'upload'; message: string; undo?: () => Promise<void> }) => {
|
|
309
|
+
showActionToast(dashboardModel.actionNotifications, action.type, action.message, { undo: action.undo });
|
|
310
|
+
}, [dashboardModel.actionNotifications]);
|
|
311
|
+
|
|
312
|
+
if (loading) return <LoadingFallback />;
|
|
313
|
+
if (error) return <CapabilityError error={error} onRetry={retry} onReconnect={handleReconnect} onGoBack={handleGoBack} />;
|
|
314
|
+
if (runtimeError) return <CapabilityError error={runtimeError} onReconnect={handleReconnect} onGoBack={handleGoBack} />;
|
|
315
|
+
if (adapter === null) return null; // Should not happen after repo selection
|
|
316
|
+
|
|
317
|
+
// Route by capability type
|
|
318
|
+
return match(capabilityId)
|
|
319
|
+
.with('file-system', () => (
|
|
320
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
321
|
+
<FileBrowser
|
|
322
|
+
fileSystem={adapter as IFileSystem}
|
|
323
|
+
className="h-full"
|
|
324
|
+
initialPath={dashboardModel.browserPath !== '/' ? dashboardModel.browserPath : undefined}
|
|
325
|
+
onPathChange={(path) => dashboardModel.setBrowserPath(path)}
|
|
326
|
+
onError={handleRuntimeError}
|
|
327
|
+
onNotify={handleNotify}
|
|
328
|
+
onAction={handleAction}
|
|
329
|
+
showBreadcrumbs={false}
|
|
330
|
+
/>
|
|
331
|
+
</Suspense>
|
|
332
|
+
))
|
|
333
|
+
.with('object-storage', () => {
|
|
334
|
+
const s3Result = adapter as { storage: IObjectStorage; bucket: string };
|
|
335
|
+
return (
|
|
336
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
337
|
+
<ObjectStorageBrowserLazy storage={s3Result.storage} bucket={s3Result.bucket} />
|
|
338
|
+
</Suspense>
|
|
339
|
+
);
|
|
340
|
+
})
|
|
341
|
+
.with('git-repo', () => {
|
|
342
|
+
const repo = selectedRepo ?? { owner: '', repo: '' };
|
|
343
|
+
return (
|
|
344
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
345
|
+
<GitRepoBrowserContent
|
|
346
|
+
serviceId={serviceId}
|
|
347
|
+
token={token}
|
|
348
|
+
gitRepo={adapter as any}
|
|
349
|
+
owner={repo.owner}
|
|
350
|
+
repo={repo.repo}
|
|
351
|
+
selectedRepo={selectedRepo ?? undefined}
|
|
352
|
+
onError={handleRuntimeError}
|
|
353
|
+
actionNotifications={dashboardModel.actionNotifications}
|
|
354
|
+
/>
|
|
355
|
+
</Suspense>
|
|
356
|
+
);
|
|
357
|
+
})
|
|
358
|
+
.with('git-host', () => {
|
|
359
|
+
// Route git-host to unified browser: show PRs/Issues alongside git-repo tabs
|
|
360
|
+
const repo = selectedRepo ?? { owner: '', repo: '' };
|
|
361
|
+
return (
|
|
362
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
363
|
+
<GitHostBrowserWithRepo
|
|
364
|
+
serviceId={serviceId}
|
|
365
|
+
token={token}
|
|
366
|
+
gitHost={adapter as any}
|
|
367
|
+
owner={repo.owner}
|
|
368
|
+
repo={repo.repo}
|
|
369
|
+
selectedRepo={selectedRepo ?? undefined}
|
|
370
|
+
onError={handleRuntimeError}
|
|
371
|
+
actionNotifications={dashboardModel.actionNotifications}
|
|
372
|
+
/>
|
|
373
|
+
</Suspense>
|
|
374
|
+
);
|
|
375
|
+
})
|
|
376
|
+
.with('media', () => (
|
|
377
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
378
|
+
<LazyMediaBrowser provider={adapter} />
|
|
379
|
+
</Suspense>
|
|
380
|
+
))
|
|
381
|
+
.with('contacts', () => (
|
|
382
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
383
|
+
<LazyContactBrowser provider={adapter} />
|
|
384
|
+
</Suspense>
|
|
385
|
+
))
|
|
386
|
+
.with('calendar', () => (
|
|
387
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
388
|
+
<LazyCalendarBrowser provider={adapter} />
|
|
389
|
+
</Suspense>
|
|
390
|
+
))
|
|
391
|
+
.exhaustive();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
/** Routes to the correct capability content based on service + capability */
|
|
395
|
+
export { CAPABILITY_LABELS };
|
|
396
|
+
export const CapabilityContent = observer(({
|
|
397
|
+
serviceId,
|
|
398
|
+
capabilityId,
|
|
399
|
+
connectionManager,
|
|
400
|
+
dashboardModel,
|
|
401
|
+
}: {
|
|
402
|
+
serviceId: string;
|
|
403
|
+
capabilityId: CapabilityId;
|
|
404
|
+
connectionManager: ConnectionManagerModel;
|
|
405
|
+
dashboardModel: DashboardModel;
|
|
406
|
+
}) => {
|
|
407
|
+
const service = dashboardModel.selectedService;
|
|
408
|
+
if (!service) return null;
|
|
409
|
+
|
|
410
|
+
const token = connectionManager.getToken(serviceId);
|
|
411
|
+
if (!token) {
|
|
412
|
+
return <CapabilityError error="Not connected. Please connect the service first." />;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Services needing repo selection show picker first
|
|
416
|
+
if (REPO_SERVICES.has(serviceId) && (capabilityId === 'file-system' || capabilityId === 'git-repo' || capabilityId === 'git-host')) {
|
|
417
|
+
let selectedRepo = dashboardModel.getSelectedRepo(serviceId);
|
|
418
|
+
// Auto-extract owner/repo from credential token (e.g. Gitea Quick Test includes them)
|
|
419
|
+
if (!selectedRepo) {
|
|
420
|
+
try {
|
|
421
|
+
const creds = JSON.parse(token);
|
|
422
|
+
if (creds.owner && creds.repo) {
|
|
423
|
+
dashboardModel.setSelectedRepo(serviceId, { owner: creds.owner, repo: creds.repo });
|
|
424
|
+
selectedRepo = { owner: creds.owner, repo: creds.repo };
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
// Token is not JSON (e.g. OAuth token) — show picker
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (!selectedRepo) {
|
|
431
|
+
return (
|
|
432
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
433
|
+
<RepoPicker
|
|
434
|
+
serviceId={serviceId}
|
|
435
|
+
accessToken={token}
|
|
436
|
+
onSelectRepo={(repo) => dashboardModel.setSelectedRepo(serviceId, repo)}
|
|
437
|
+
/>
|
|
438
|
+
</Suspense>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<GenericCapabilityContent
|
|
445
|
+
serviceId={serviceId}
|
|
446
|
+
capabilityId={capabilityId}
|
|
447
|
+
token={token}
|
|
448
|
+
dashboardModel={dashboardModel}
|
|
449
|
+
connectionManager={connectionManager}
|
|
450
|
+
/>
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
export const ScopeWarningBanner = observer(({ serviceId, capabilityId, connectionManager }: {
|
|
455
|
+
serviceId: string;
|
|
456
|
+
capabilityId: CapabilityId;
|
|
457
|
+
connectionManager: ConnectionManagerModel;
|
|
458
|
+
}) => {
|
|
459
|
+
const token = connectionManager.getToken(serviceId);
|
|
460
|
+
if (!token) return null;
|
|
461
|
+
const hasScopes = connectionManager.hasCapabilityScopes(serviceId, capabilityId);
|
|
462
|
+
if (hasScopes) return null;
|
|
463
|
+
return (
|
|
464
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-800">
|
|
465
|
+
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
|
466
|
+
<span className="text-xs text-yellow-700 dark:text-yellow-400">
|
|
467
|
+
Required scopes not granted. Some features may not work. Try reconnecting with the needed permissions.
|
|
468
|
+
</span>
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
interface CapabilityPanelProps {
|
|
474
|
+
dashboardModel: DashboardModel;
|
|
475
|
+
connectionManager: ConnectionManagerModel;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export const CapabilityPanel: React.FC<CapabilityPanelProps> = observer(({ dashboardModel, connectionManager }) => {
|
|
479
|
+
if (!dashboardModel.panelOpen || !dashboardModel.selectedCell) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const { serviceId, capabilityId } = dashboardModel.selectedCell;
|
|
484
|
+
const service = dashboardModel.selectedService;
|
|
485
|
+
const selectedRepo = dashboardModel.getSelectedRepo(serviceId);
|
|
486
|
+
const hasRepo = REPO_SERVICES.has(serviceId) && selectedRepo;
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<div className="border border-border rounded-lg mt-4 overflow-hidden bg-card flex flex-col h-[70vh]">
|
|
490
|
+
<div className="flex items-center justify-between px-4 py-2 bg-muted border-b border-border">
|
|
491
|
+
<div className="flex items-center gap-2">
|
|
492
|
+
<span className="text-sm font-medium truncate" title={`${service?.name} — ${CAPABILITY_LABELS[capabilityId]}`}>
|
|
493
|
+
{service?.name} — {CAPABILITY_LABELS[capabilityId]}
|
|
494
|
+
</span>
|
|
495
|
+
{hasRepo && (
|
|
496
|
+
<>
|
|
497
|
+
<span className="text-xs text-muted-foreground truncate" title={`${selectedRepo.owner}/${selectedRepo.repo}`}>
|
|
498
|
+
({selectedRepo.owner}/{selectedRepo.repo})
|
|
499
|
+
</span>
|
|
500
|
+
<button
|
|
501
|
+
onClick={() => dashboardModel.clearSelectedRepo(serviceId)}
|
|
502
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded bg-muted hover:bg-muted/80 transition-colors"
|
|
503
|
+
>
|
|
504
|
+
<ArrowLeft className="h-3 w-3" />
|
|
505
|
+
Change repo
|
|
506
|
+
</button>
|
|
507
|
+
</>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
<button
|
|
511
|
+
onClick={() => dashboardModel.closePanel()}
|
|
512
|
+
className="p-1 rounded hover:bg-muted transition-colors"
|
|
513
|
+
>
|
|
514
|
+
<X className="h-4 w-4" />
|
|
515
|
+
</button>
|
|
516
|
+
</div>
|
|
517
|
+
<ScopeWarningBanner serviceId={serviceId} capabilityId={capabilityId} connectionManager={connectionManager} />
|
|
518
|
+
<div className="flex-1 min-h-0 overflow-hidden">
|
|
519
|
+
<Suspense fallback={<LoadingFallback />}>
|
|
520
|
+
<CapabilityContent
|
|
521
|
+
serviceId={serviceId}
|
|
522
|
+
capabilityId={capabilityId}
|
|
523
|
+
connectionManager={connectionManager}
|
|
524
|
+
dashboardModel={dashboardModel}
|
|
525
|
+
/>
|
|
526
|
+
</Suspense>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
);
|
|
530
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Check, Minus } from 'lucide-react';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import { LoadingSpinner } from '@anymux/ui/components/loading-spinner';
|
|
5
|
+
import type { ConnectionStatus } from '../types/connection';
|
|
6
|
+
import type { CapabilityId } from '../types/service';
|
|
7
|
+
|
|
8
|
+
interface CapabilityPillProps {
|
|
9
|
+
label: string;
|
|
10
|
+
capabilityId: CapabilityId;
|
|
11
|
+
status: ConnectionStatus;
|
|
12
|
+
isSelected: boolean;
|
|
13
|
+
hasScopes: boolean;
|
|
14
|
+
onSelect: () => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CapabilityPill: React.FC<CapabilityPillProps> = ({
|
|
19
|
+
label,
|
|
20
|
+
status,
|
|
21
|
+
isSelected,
|
|
22
|
+
hasScopes,
|
|
23
|
+
onSelect,
|
|
24
|
+
disabled = false,
|
|
25
|
+
}) => {
|
|
26
|
+
const connected = status === 'connected';
|
|
27
|
+
const loading = status === 'loading';
|
|
28
|
+
|
|
29
|
+
const className = `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
|
30
|
+
loading
|
|
31
|
+
? 'bg-muted text-muted-foreground'
|
|
32
|
+
: isSelected
|
|
33
|
+
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
|
|
34
|
+
: connected
|
|
35
|
+
? 'bg-muted text-foreground hover:bg-muted/80 cursor-pointer'
|
|
36
|
+
: 'bg-muted/50 text-muted-foreground'
|
|
37
|
+
}`;
|
|
38
|
+
|
|
39
|
+
const icon = match(status)
|
|
40
|
+
.with('loading', () => <LoadingSpinner size="sm" label="Loading" className="h-3 w-3" />)
|
|
41
|
+
.with('connected', () => (
|
|
42
|
+
<Check className={`h-3 w-3 ${hasScopes ? 'text-green-500' : 'text-orange-400'}`} />
|
|
43
|
+
))
|
|
44
|
+
.otherwise(() => <Minus className="h-3 w-3" />);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
onClick={onSelect}
|
|
49
|
+
disabled={disabled || !connected}
|
|
50
|
+
className={className}
|
|
51
|
+
>
|
|
52
|
+
{icon}
|
|
53
|
+
{label}
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
};
|