@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,149 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { match } from 'ts-pattern';
4
+ import { ConnectionBadge } from '@anymux/ui/components/connection-badge';
5
+ import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
6
+ import type { ServiceDefinition } from '../types/service';
7
+ import { getServiceConnectionState } from '../types/connection-state';
8
+ import { CredentialFormModel, type CredentialServiceType } from '../models/CredentialFormModel';
9
+ import { CredentialForm } from './CredentialForm';
10
+ import { ConnectedMenu } from './ConnectedMenu';
11
+
12
+ interface ConnectButtonProps {
13
+ service: ServiceDefinition;
14
+ connectionManager: ConnectionManagerModel;
15
+ }
16
+
17
+ export const ConnectButton: React.FC<ConnectButtonProps> = observer(({ service, connectionManager }) => {
18
+ const [formModel] = useState(() => new CredentialFormModel());
19
+ const state = getServiceConnectionState(connectionManager, service.id);
20
+
21
+ const needsCredentialForm = service.authProvider === 's3' || service.authProvider === 'webdav' || service.authProvider === 'gitea' || service.authProvider === 'icloud';
22
+ const isBrowserFs = service.authProvider === 'browser-fs';
23
+ const isIndexedDb = service.authProvider === 'indexeddb';
24
+
25
+ // Auto-trigger connect flow when reconnect is requested (e.g. from CapabilityPanel)
26
+ const pendingReconnect = connectionManager.pendingReconnect.has(service.id);
27
+ useEffect(() => {
28
+ if (pendingReconnect) {
29
+ connectionManager.clearReconnectRequest(service.id);
30
+ handleConnect();
31
+ }
32
+ }, [pendingReconnect]); // eslint-disable-line react-hooks/exhaustive-deps
33
+
34
+ const handleConnect = async () => {
35
+ if (isBrowserFs) {
36
+ try {
37
+ const { BrowserFileSystemFactory } = await import('@anymux/browser-fs');
38
+ const factory = new BrowserFileSystemFactory();
39
+ const [_fs, handleInfo] = await factory.createFromPicker();
40
+ connectionManager.connectWithCredentials(service.id, JSON.stringify({ id: handleInfo.id, name: handleInfo.name }));
41
+ } catch (err: any) {
42
+ if (err.name !== 'AbortError') {
43
+ console.error('Failed to pick directory:', err);
44
+ }
45
+ }
46
+ return;
47
+ }
48
+ if (isIndexedDb) {
49
+ connectionManager.connectWithCredentials(service.id, JSON.stringify({ type: 'indexeddb' }));
50
+ return;
51
+ }
52
+ if (needsCredentialForm) {
53
+ formModel.openForm(service.authProvider as CredentialServiceType);
54
+ } else {
55
+ connectionManager.connect(service.id);
56
+ }
57
+ };
58
+
59
+ const handleCredentialSubmit = (credentialToken: string) => {
60
+ connectionManager.connectWithCredentials(service.id, credentialToken);
61
+ formModel.closeForm();
62
+ };
63
+
64
+ const credentialFormElement = needsCredentialForm ? (
65
+ <CredentialForm model={formModel} onSubmit={handleCredentialSubmit} />
66
+ ) : null;
67
+
68
+ return match(state)
69
+ .with({ status: 'loading' }, () => (
70
+ <ConnectionBadge status="loading" />
71
+ ))
72
+ .with({ status: 'not_configured' }, () => (
73
+ <ConnectionBadge status="not-configured" />
74
+ ))
75
+ .with({ status: 'connecting' }, () => (
76
+ <ConnectionBadge status="connecting" />
77
+ ))
78
+ .with({ status: 'connected' }, (s) => {
79
+ const isOAuth = !needsCredentialForm && !isBrowserFs && !isIndexedDb;
80
+ const user = isOAuth ? s.user : null;
81
+ return (
82
+ <ConnectedMenu
83
+ service={service}
84
+ connectionManager={connectionManager}
85
+ user={user}
86
+ {...(s.profile ? { profile: s.profile } : {})}
87
+ isOAuth={isOAuth}
88
+ />
89
+ );
90
+ })
91
+ .with({ status: 'error' }, () => (
92
+ <>
93
+ <button
94
+ onClick={handleConnect}
95
+ className="rounded-md px-3 py-1.5 text-xs font-medium text-white bg-destructive hover:bg-destructive/90 transition-colors"
96
+ >
97
+ Retry
98
+ </button>
99
+ {credentialFormElement}
100
+ </>
101
+ ))
102
+ .with({ status: 'expired' }, () => (
103
+ <>
104
+ <button
105
+ onClick={handleConnect}
106
+ className="rounded-md px-3 py-1.5 text-xs font-medium text-white bg-destructive hover:bg-destructive/90 transition-colors"
107
+ >
108
+ Reconnect
109
+ </button>
110
+ {credentialFormElement}
111
+ </>
112
+ ))
113
+ .with({ status: 'disconnected' }, () => {
114
+ const testCreds = connectionManager.testCredentials[service.authProvider] as Record<string, unknown> | undefined;
115
+ return (
116
+ <>
117
+ <div className="inline-flex items-center gap-1.5">
118
+ <button
119
+ onClick={handleConnect}
120
+ className="rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors"
121
+ style={{ backgroundColor: service.color }}
122
+ >
123
+ Connect
124
+ </button>
125
+ {testCreds && (
126
+ <button
127
+ onClick={() => {
128
+ if (needsCredentialForm) {
129
+ const values: Record<string, string> = {};
130
+ for (const [k, v] of Object.entries(testCreds)) {
131
+ if (typeof v === 'string') values[k] = v;
132
+ }
133
+ formModel.openForm(service.authProvider as CredentialServiceType, values);
134
+ } else {
135
+ connectionManager.connectWithCredentials(service.id, JSON.stringify(testCreds));
136
+ }
137
+ }}
138
+ className="rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground bg-muted hover:bg-muted/80 transition-colors"
139
+ >
140
+ Quick Test
141
+ </button>
142
+ )}
143
+ </div>
144
+ {credentialFormElement}
145
+ </>
146
+ );
147
+ })
148
+ .exhaustive();
149
+ });
@@ -0,0 +1,142 @@
1
+ import React from 'react';
2
+ import { ExternalLink, Info, LogOut, MoreHorizontal, RefreshCw, Clock, Shield } from 'lucide-react';
3
+ import { ConnectionBadge } from '@anymux/ui/components/connection-badge';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuLabel,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ } from '@anymux/ui/components/dropdown-menu';
12
+ import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
13
+ import type { ServiceDefinition } from '../types/service';
14
+ import type { IUserProfile } from '../types/user-profile';
15
+ import { ServiceIcon } from './ServiceIcon';
16
+
17
+ interface ConnectedMenuProps {
18
+ service: ServiceDefinition;
19
+ connectionManager: ConnectionManagerModel;
20
+ /** @deprecated Use profile instead */
21
+ user: { name: string; image?: string } | null;
22
+ profile?: IUserProfile;
23
+ isOAuth: boolean;
24
+ }
25
+
26
+ function formatConnectedDate(date: Date | undefined): string | null {
27
+ if (!date) return null;
28
+ const d = date instanceof Date ? date : new Date(date);
29
+ if (isNaN(d.getTime())) return null;
30
+ return d.toLocaleDateString(undefined, {
31
+ month: 'short',
32
+ day: 'numeric',
33
+ year: 'numeric',
34
+ });
35
+ }
36
+
37
+ export function ConnectedMenu({
38
+ service,
39
+ connectionManager,
40
+ user,
41
+ profile,
42
+ isOAuth,
43
+ }: ConnectedMenuProps) {
44
+ // Prefer profile data (provider-specific) over generic session user data
45
+ const displayName = profile?.name ?? user?.name;
46
+ const avatarUrl = profile?.avatarUrl ?? user?.image;
47
+ const profileUrl = profile?.profileUrl;
48
+ const email = profile?.email;
49
+
50
+ const handleReconnect = async () => {
51
+ await connectionManager.disconnect(service.id);
52
+ await connectionManager.connect(service.id);
53
+ };
54
+
55
+ const handleDisconnect = () => {
56
+ connectionManager.disconnect(service.id);
57
+ };
58
+
59
+ const connection = connectionManager.connections.get(service.id);
60
+ const connectedDate = formatConnectedDate(connection?.connectedAt);
61
+
62
+ return (
63
+ <div className="inline-flex items-center gap-1">
64
+ <ConnectionBadge
65
+ status="connected"
66
+ {...(displayName ? { name: displayName } : {})}
67
+ {...(avatarUrl ? { avatarUrl } : {})}
68
+ />
69
+ <DropdownMenu>
70
+ <DropdownMenuTrigger asChild>
71
+ <button className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
72
+ <MoreHorizontal className="h-4 w-4" />
73
+ </button>
74
+ </DropdownMenuTrigger>
75
+ <DropdownMenuContent align="end" className="min-w-[200px]">
76
+ {/* Provider header with per-service profile info */}
77
+ <DropdownMenuLabel className="flex items-center gap-2">
78
+ {avatarUrl ? (
79
+ <img src={avatarUrl} alt="" className="h-5 w-5 rounded-full shrink-0" />
80
+ ) : (
81
+ <ServiceIcon
82
+ name={service.icon}
83
+ className="h-4 w-4 shrink-0"
84
+ style={{ color: service.color }}
85
+ />
86
+ )}
87
+ <div className="flex flex-col min-w-0">
88
+ <span className="truncate">{displayName ?? service.name}</span>
89
+ {email && (
90
+ <span className="text-xs font-normal text-muted-foreground truncate">{email}</span>
91
+ )}
92
+ </div>
93
+ </DropdownMenuLabel>
94
+
95
+ {/* Connection timestamp */}
96
+ {connectedDate && (
97
+ <div className="flex items-center gap-2 px-2 pb-1.5 text-xs text-muted-foreground">
98
+ <Clock className="h-3 w-3 shrink-0" />
99
+ <span>Connected {connectedDate}</span>
100
+ </div>
101
+ )}
102
+
103
+ <DropdownMenuSeparator />
104
+
105
+ {/* Actions */}
106
+ {profileUrl && (
107
+ <DropdownMenuItem asChild>
108
+ <a href={profileUrl} target="_blank" rel="noopener noreferrer">
109
+ <ExternalLink />
110
+ Open Profile
111
+ </a>
112
+ </DropdownMenuItem>
113
+ )}
114
+ {service.grantsUrl && (
115
+ <DropdownMenuItem asChild>
116
+ <a href={service.grantsUrl} target="_blank" rel="noopener noreferrer">
117
+ <Shield />
118
+ Manage Permissions
119
+ </a>
120
+ </DropdownMenuItem>
121
+ )}
122
+ <DropdownMenuItem asChild>
123
+ <a href={`/providers/${service.id}`}>
124
+ <Info />
125
+ About {service.name}
126
+ </a>
127
+ </DropdownMenuItem>
128
+ {isOAuth && (
129
+ <DropdownMenuItem onClick={handleReconnect}>
130
+ <RefreshCw />
131
+ Reconnect
132
+ </DropdownMenuItem>
133
+ )}
134
+ <DropdownMenuItem variant="destructive" onClick={handleDisconnect}>
135
+ <LogOut />
136
+ Disconnect
137
+ </DropdownMenuItem>
138
+ </DropdownMenuContent>
139
+ </DropdownMenu>
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+ import { StatusIndicator } from '@anymux/ui/components/status-indicator';
3
+ import { match } from 'ts-pattern';
4
+ import type { ConnectionStatus as Status } from '../types/connection';
5
+
6
+ interface ConnectionStatusProps {
7
+ status: Status;
8
+ }
9
+
10
+ export const ConnectionStatusIndicator: React.FC<ConnectionStatusProps> = ({ status }) => {
11
+ const config = match(status)
12
+ .with('disconnected', () => ({ status: 'neutral' as const, label: 'Disconnected' }))
13
+ .with('connecting', () => ({ status: 'warning' as const, label: 'Connecting...', pulse: true }))
14
+ .with('connected', () => ({ status: 'success' as const, label: 'Connected' }))
15
+ .with('expired', () => ({ status: 'warning' as const, label: 'Expired' }))
16
+ .with('error', () => ({ status: 'error' as const, label: 'Error' }))
17
+ .with('not_configured', () => ({ status: 'neutral' as const, label: 'Not Configured' }))
18
+ .with('loading', () => ({ status: 'neutral' as const, label: 'Loading...' }))
19
+ .exhaustive();
20
+
21
+ return (
22
+ <StatusIndicator
23
+ status={config.status}
24
+ label={config.label}
25
+ pulse={config.status === 'warning' && status === 'connecting'}
26
+ />
27
+ );
28
+ };
@@ -0,0 +1,246 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import type { CredentialFormModel } from '../models/CredentialFormModel';
4
+
5
+ interface CredentialFormProps {
6
+ model: CredentialFormModel;
7
+ onSubmit: (credentials: string) => void;
8
+ }
9
+
10
+ export const CredentialForm: React.FC<CredentialFormProps> = observer(({ model, onSubmit }) => {
11
+ if (!model.open) return null;
12
+
13
+ const handleSubmit = (e: React.FormEvent) => {
14
+ e.preventDefault();
15
+ onSubmit(model.serialize());
16
+ };
17
+
18
+ const inputClass =
19
+ 'w-full rounded-md border border-border px-3 py-2 text-base sm:text-sm bg-card text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring';
20
+ const labelClass = 'block text-sm font-medium text-foreground mb-1';
21
+
22
+ const title = model.serviceType === 's3' ? 'S3 Credentials' : model.serviceType === 'webdav' ? 'WebDAV Credentials' : model.serviceType === 'icloud' ? 'iCloud Credentials' : 'Gitea Credentials';
23
+
24
+ return (
25
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => model.closeForm()}>
26
+ <div
27
+ className="w-[calc(100%-2rem)] max-w-md max-h-[80vh] flex flex-col rounded-lg bg-card shadow-xl"
28
+ onClick={(e) => e.stopPropagation()}
29
+ >
30
+ <h2 className="px-4 sm:px-6 pt-4 sm:pt-6 pb-3 text-lg font-semibold text-foreground flex-shrink-0">{title}</h2>
31
+
32
+ <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
33
+ <div className="flex-1 overflow-y-auto px-4 sm:px-6 space-y-3">
34
+ {model.serviceType === 's3' ? (
35
+ <>
36
+ <div>
37
+ <label className={labelClass}>Access Key ID</label>
38
+ <input
39
+ type="text"
40
+ required
41
+ className={inputClass}
42
+ value={model.accessKeyId}
43
+ onChange={(e) => model.setField('accessKeyId', e.target.value)}
44
+ />
45
+ </div>
46
+ <div>
47
+ <label className={labelClass}>Secret Access Key</label>
48
+ <input
49
+ type="password"
50
+ required
51
+ className={inputClass}
52
+ value={model.secretAccessKey}
53
+ onChange={(e) => model.setField('secretAccessKey', e.target.value)}
54
+ />
55
+ </div>
56
+ <div>
57
+ <label className={labelClass}>Region</label>
58
+ <input
59
+ type="text"
60
+ required
61
+ className={inputClass}
62
+ value={model.region}
63
+ onChange={(e) => model.setField('region', e.target.value)}
64
+ />
65
+ </div>
66
+ <div>
67
+ <label className={labelClass}>Bucket</label>
68
+ <input
69
+ type="text"
70
+ required
71
+ className={inputClass}
72
+ value={model.bucket}
73
+ onChange={(e) => model.setField('bucket', e.target.value)}
74
+ />
75
+ </div>
76
+ <div>
77
+ <label className={labelClass}>Endpoint (optional)</label>
78
+ <input
79
+ type="text"
80
+ className={inputClass}
81
+ value={model.endpoint}
82
+ onChange={(e) => model.setField('endpoint', e.target.value)}
83
+ placeholder="https://s3.amazonaws.com"
84
+ />
85
+ </div>
86
+ </>
87
+ ) : model.serviceType === 'webdav' ? (
88
+ <>
89
+ <div>
90
+ <label className={labelClass}>URL</label>
91
+ <input
92
+ type="url"
93
+ required
94
+ className={inputClass}
95
+ value={model.url}
96
+ onChange={(e) => model.setField('url', e.target.value)}
97
+ placeholder="https://example.com/webdav"
98
+ />
99
+ </div>
100
+ <div>
101
+ <label className={labelClass}>Username</label>
102
+ <input
103
+ type="text"
104
+ required
105
+ className={inputClass}
106
+ value={model.username}
107
+ onChange={(e) => model.setField('username', e.target.value)}
108
+ />
109
+ </div>
110
+ <div>
111
+ <label className={labelClass}>Password</label>
112
+ <input
113
+ type="password"
114
+ required
115
+ className={inputClass}
116
+ value={model.password}
117
+ onChange={(e) => model.setField('password', e.target.value)}
118
+ />
119
+ </div>
120
+ </>
121
+ ) : model.serviceType === 'icloud' ? (
122
+ <>
123
+ <div>
124
+ <label className={labelClass}>Apple ID Email</label>
125
+ <input
126
+ type="email"
127
+ required
128
+ className={inputClass}
129
+ value={model.email}
130
+ onChange={(e) => model.setField('email', e.target.value)}
131
+ placeholder="you@icloud.com"
132
+ />
133
+ </div>
134
+ <div>
135
+ <label className={labelClass}>App-Specific Password</label>
136
+ <input
137
+ type="password"
138
+ required
139
+ className={inputClass}
140
+ value={model.appPassword}
141
+ onChange={(e) => model.setField('appPassword', e.target.value)}
142
+ placeholder="xxxx-xxxx-xxxx-xxxx"
143
+ />
144
+ </div>
145
+ <p className="text-xs text-muted-foreground">
146
+ Generate an app-specific password at{' '}
147
+ <a
148
+ href="https://appleid.apple.com/account/manage"
149
+ target="_blank"
150
+ rel="noopener noreferrer"
151
+ className="underline text-primary hover:text-primary/80"
152
+ >
153
+ appleid.apple.com
154
+ </a>
155
+ {' '}under Sign-In and Security &rarr; App-Specific Passwords.
156
+ </p>
157
+ </>
158
+ ) : (
159
+ <>
160
+ <div>
161
+ <label className={labelClass}>URL</label>
162
+ <input
163
+ type="url"
164
+ required
165
+ className={inputClass}
166
+ value={model.url}
167
+ onChange={(e) => model.setField('url', e.target.value)}
168
+ placeholder="https://gitea.example.com"
169
+ />
170
+ </div>
171
+ <div>
172
+ <label className={labelClass}>Token (or use username/password below)</label>
173
+ <input
174
+ type="password"
175
+ className={inputClass}
176
+ value={model.token}
177
+ onChange={(e) => model.setField('token', e.target.value)}
178
+ placeholder="API token"
179
+ />
180
+ </div>
181
+ {!model.token && (
182
+ <>
183
+ <div>
184
+ <label className={labelClass}>Username</label>
185
+ <input
186
+ type="text"
187
+ className={inputClass}
188
+ value={model.username}
189
+ onChange={(e) => model.setField('username', e.target.value)}
190
+ />
191
+ </div>
192
+ <div>
193
+ <label className={labelClass}>Password</label>
194
+ <input
195
+ type="password"
196
+ className={inputClass}
197
+ value={model.password}
198
+ onChange={(e) => model.setField('password', e.target.value)}
199
+ />
200
+ </div>
201
+ </>
202
+ )}
203
+ <div>
204
+ <label className={labelClass}>Owner</label>
205
+ <input
206
+ type="text"
207
+ required
208
+ className={inputClass}
209
+ value={model.owner}
210
+ onChange={(e) => model.setField('owner', e.target.value)}
211
+ />
212
+ </div>
213
+ <div>
214
+ <label className={labelClass}>Repo</label>
215
+ <input
216
+ type="text"
217
+ required
218
+ className={inputClass}
219
+ value={model.repo}
220
+ onChange={(e) => model.setField('repo', e.target.value)}
221
+ />
222
+ </div>
223
+ </>
224
+ )}
225
+ </div>
226
+
227
+ <div className="flex justify-end gap-2 px-4 sm:px-6 py-3 border-t border-border flex-shrink-0">
228
+ <button
229
+ type="button"
230
+ onClick={() => model.closeForm()}
231
+ className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
232
+ >
233
+ Cancel
234
+ </button>
235
+ <button
236
+ type="submit"
237
+ className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
238
+ >
239
+ Connect
240
+ </button>
241
+ </div>
242
+ </form>
243
+ </div>
244
+ </div>
245
+ );
246
+ });
@@ -0,0 +1,84 @@
1
+ import React, { Suspense } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { ArrowLeft } from 'lucide-react';
4
+ import { PathBreadcrumb } from '@anymux/ui/components/path-breadcrumb';
5
+ import { LoadingSpinner } from '@anymux/ui/components/loading-spinner';
6
+ import type { DashboardModel } from '../models/DashboardModel';
7
+ import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
8
+ import { CapabilityContent, CAPABILITY_LABELS, ScopeWarningBanner } from './CapabilityPanel';
9
+ import { ActionHistoryPanel } from './ActionHistoryPanel';
10
+
11
+ const REPO_SERVICES = new Set(['github', 'gitlab', 'bitbucket', 'gitea']);
12
+
13
+ interface FullScreenBrowserProps {
14
+ dashboardModel: DashboardModel;
15
+ connectionManager: ConnectionManagerModel;
16
+ }
17
+
18
+ export const FullScreenBrowser: React.FC<FullScreenBrowserProps> = observer(
19
+ ({ dashboardModel, connectionManager }) => {
20
+ const cell = dashboardModel.selectedCell;
21
+ if (!cell) return null;
22
+
23
+ const { serviceId, capabilityId } = cell;
24
+ const service = dashboardModel.selectedService;
25
+ if (!service) return null;
26
+
27
+ const selectedRepo = dashboardModel.getSelectedRepo(serviceId);
28
+ const hasRepo = REPO_SERVICES.has(serviceId) && selectedRepo;
29
+ const browserPath = dashboardModel.browserPath;
30
+
31
+ return (
32
+ <div className="flex flex-col h-full flex-1 min-h-0">
33
+ {/* Breadcrumb bar */}
34
+ <div className="flex items-center gap-1 px-2 sm:px-4 py-2 bg-muted border-b border-border text-xs sm:text-sm flex-shrink-0 min-h-[40px]">
35
+ <div className="flex items-center gap-1 flex-1 min-w-0 flex-wrap">
36
+ <PathBreadcrumb
37
+ path={browserPath}
38
+ onNavigate={(path) => dashboardModel.setBrowserPath(path)}
39
+ showHome={false}
40
+ editable
41
+ prefixSegments={[
42
+ { label: 'Dashboard', onClick: () => dashboardModel.closePanel() },
43
+ { label: service.name, onClick: () => dashboardModel.closePanel() },
44
+ { label: CAPABILITY_LABELS[capabilityId], onClick: () => dashboardModel.setBrowserPath('/') },
45
+ ...(hasRepo ? [{ label: `${selectedRepo.owner}/${selectedRepo.repo}` }] : []),
46
+ ]}
47
+ />
48
+ {hasRepo && (
49
+ <button
50
+ onClick={() => dashboardModel.clearSelectedRepo(serviceId)}
51
+ className="ml-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted hover:bg-muted/80 transition-colors"
52
+ >
53
+ <span className="hidden sm:inline">Change</span>
54
+ <ArrowLeft className="h-3 w-3 sm:hidden" />
55
+ </button>
56
+ )}
57
+ </div>
58
+ <ActionHistoryPanel model={dashboardModel.actionNotifications} />
59
+ </div>
60
+
61
+ {/* Scope warning */}
62
+ <ScopeWarningBanner serviceId={serviceId} capabilityId={capabilityId} connectionManager={connectionManager} />
63
+
64
+ {/* Browser content */}
65
+ <div className="flex-1 min-h-0 overflow-hidden">
66
+ <Suspense
67
+ fallback={
68
+ <div className="flex items-center justify-center h-64">
69
+ <LoadingSpinner label="Loading..." />
70
+ </div>
71
+ }
72
+ >
73
+ <CapabilityContent
74
+ serviceId={serviceId}
75
+ capabilityId={capabilityId}
76
+ connectionManager={connectionManager}
77
+ dashboardModel={dashboardModel}
78
+ />
79
+ </Suspense>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+ );