@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
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@anymux/connect",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "HelloJS-style service connection dashboard for AnyMux",
7
+ "keywords": [
8
+ "anymux",
9
+ "connect",
10
+ "oauth",
11
+ "dashboard",
12
+ "services"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/AnyMux/AnyMuxMonorepo.git",
17
+ "directory": "packages/connect"
18
+ },
19
+ "homepage": "https://github.com/AnyMux/AnyMuxMonorepo/tree/main/packages/connect#readme",
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "main": "./src/index.ts",
24
+ "types": "./src/index.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./registry": {
32
+ "types": "./dist/registry.d.ts",
33
+ "import": "./dist/registry.js",
34
+ "default": "./dist/registry.js"
35
+ }
36
+ },
37
+ "sideEffects": false,
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md"
42
+ ],
43
+ "dependencies": {
44
+ "better-auth": "^1.2.0",
45
+ "sonner": "^2.0.3",
46
+ "ts-pattern": "^5.6.0",
47
+ "zod": "^3.24.0",
48
+ "@anymux/box": "0.1.0",
49
+ "@anymux/bitbucket": "0.1.0",
50
+ "@anymux/dropbox": "0.1.0",
51
+ "@anymux/file-system": "0.1.0",
52
+ "@anymux/browser-fs": "0.1.0",
53
+ "@anymux/flickr": "0.1.0",
54
+ "@anymux/instagram": "0.1.0",
55
+ "@anymux/gitea": "0.1.0",
56
+ "@anymux/github": "0.1.0",
57
+ "@anymux/icloud": "0.1.0",
58
+ "@anymux/gitlab": "0.1.0",
59
+ "@anymux/microsoft": "0.1.0",
60
+ "@anymux/indexeddb": "0.1.0",
61
+ "@anymux/google-drive": "0.1.0",
62
+ "@anymux/ui": "0.1.0",
63
+ "@anymux/s3": "0.1.0",
64
+ "@anymux/webdav": "0.1.0"
65
+ },
66
+ "peerDependencies": {
67
+ "mobx": "^6.13.5",
68
+ "mobx-react-lite": "^4.0.7",
69
+ "react": "^19.0.0",
70
+ "react-dom": "^19.0.0"
71
+ },
72
+ "devDependencies": {
73
+ "@types/node": "^22.10.2",
74
+ "@types/react": "^19.0.0",
75
+ "@types/react-dom": "^19.0.0",
76
+ "lucide-react": "^0.475.0",
77
+ "tsdown": "^0.10.2",
78
+ "typescript": "^5.7.3",
79
+ "@anymux/fs-ui": "0.1.0",
80
+ "@anymux/object-ui": "0.1.0",
81
+ "@anymux/typescript-config": "0.0.0"
82
+ },
83
+ "scripts": {
84
+ "build": "tsdown",
85
+ "check": "tsc -p tsconfig.json --noEmit"
86
+ }
87
+ }
@@ -0,0 +1,177 @@
1
+ import type { CapabilityId } from '../types/service';
2
+
3
+ export interface AdapterContext {
4
+ /** Selected repo for git-backed services */
5
+ selectedRepo?: { owner: string; repo: string };
6
+ /** Override the branch/ref for file-system adapters */
7
+ branch?: string;
8
+ }
9
+
10
+ export type AdapterFactory = (
11
+ token: string,
12
+ capabilityId: CapabilityId,
13
+ context: AdapterContext,
14
+ ) => Promise<unknown>;
15
+
16
+ const factories = new Map<string, AdapterFactory>();
17
+
18
+ export const adapterRegistry = {
19
+ register(serviceId: string, factory: AdapterFactory): void {
20
+ factories.set(serviceId, factory);
21
+ },
22
+
23
+ async create(
24
+ serviceId: string,
25
+ token: string,
26
+ capabilityId: CapabilityId,
27
+ context: AdapterContext = {},
28
+ ): Promise<unknown> {
29
+ const factory = factories.get(serviceId);
30
+ if (!factory) {
31
+ throw new Error(`No adapter factory registered for service: ${serviceId}`);
32
+ }
33
+ return factory(token, capabilityId, context);
34
+ },
35
+
36
+ has(serviceId: string): boolean {
37
+ return factories.has(serviceId);
38
+ },
39
+ };
40
+
41
+ // --- Register all adapter factories ---
42
+ // Capability validation is handled by the service registry; factories only create adapters.
43
+
44
+ // Google Drive / Contacts / Calendar
45
+ adapterRegistry.register('google', async (token, capabilityId) => {
46
+ const m = await import('@anymux/google-drive');
47
+ const adapters: Partial<Record<CapabilityId, () => unknown>> = {
48
+ 'file-system': () => new m.GoogleDriveFileSystem({ accessToken: token }),
49
+ 'contacts': () => new m.GoogleContactsProvider(token),
50
+ 'calendar': () => new m.GoogleCalendarProvider(token),
51
+ };
52
+ return adapters[capabilityId]!();
53
+ });
54
+
55
+ // Dropbox
56
+ adapterRegistry.register('dropbox', async (token) => {
57
+ const { DropboxFileSystem } = await import('@anymux/dropbox');
58
+ return new DropboxFileSystem({ accessToken: token });
59
+ });
60
+
61
+ // Box
62
+ adapterRegistry.register('box', async (token) => {
63
+ const { BoxFileSystem } = await import('@anymux/box');
64
+ return new BoxFileSystem({ accessToken: token });
65
+ });
66
+
67
+ // GitHub
68
+ adapterRegistry.register('github', async (token, capabilityId, ctx) => {
69
+ const repo = ctx.selectedRepo;
70
+ if (!repo) return null; // needs repo selection first
71
+ const config = { token, owner: repo.owner, repo: repo.repo, branch: ctx.branch ?? 'main' };
72
+ const m = await import('@anymux/github');
73
+ const adapters: Partial<Record<CapabilityId, () => unknown>> = {
74
+ 'file-system': () => new m.GitHubFileSystem(config),
75
+ 'git-repo': () => new m.GitHubGitRepo(config),
76
+ 'git-host': () => new m.GitHubGitHost(config),
77
+ };
78
+ return adapters[capabilityId]!();
79
+ });
80
+
81
+ // GitLab
82
+ adapterRegistry.register('gitlab', async (token, capabilityId, ctx) => {
83
+ const repo = ctx.selectedRepo;
84
+ if (!repo) return null; // needs repo selection first
85
+ const config = { token, projectId: `${repo.owner}/${repo.repo}`, branch: ctx.branch ?? 'main' };
86
+ const m = await import('@anymux/gitlab');
87
+ const adapters: Partial<Record<CapabilityId, () => unknown>> = {
88
+ 'file-system': () => new m.GitLabFileSystem(config),
89
+ 'git-repo': () => new m.GitLabGitRepo(config),
90
+ 'git-host': () => new m.GitLabGitHost(config),
91
+ };
92
+ return adapters[capabilityId]!();
93
+ });
94
+
95
+ // Bitbucket
96
+ adapterRegistry.register('bitbucket', async (token, capabilityId, ctx) => {
97
+ const repo = ctx.selectedRepo;
98
+ if (!repo) return null; // needs repo selection first
99
+ const config = { token, workspace: repo.owner, repo: repo.repo, branch: ctx.branch ?? 'main' };
100
+ const m = await import('@anymux/bitbucket');
101
+ const adapters: Partial<Record<CapabilityId, () => unknown>> = {
102
+ 'file-system': () => new m.BitbucketFileSystem(config),
103
+ 'git-repo': () => new m.BitbucketGitRepo(config),
104
+ 'git-host': () => new m.BitbucketGitHost(config),
105
+ };
106
+ return adapters[capabilityId]!();
107
+ });
108
+
109
+ // Gitea
110
+ adapterRegistry.register('gitea', async (token, capabilityId, ctx) => {
111
+ const creds = JSON.parse(token);
112
+ const proxyUrl = typeof window !== 'undefined' ? window.location.origin : undefined;
113
+ const config = { url: creds.url, token: creds.token, username: creds.username, password: creds.password, owner: creds.owner, repo: creds.repo, branch: ctx.branch ?? creds.branch ?? 'main', proxyUrl };
114
+ const m = await import('@anymux/gitea');
115
+ const adapters: Partial<Record<CapabilityId, () => unknown>> = {
116
+ 'file-system': () => new m.GiteaFileSystem(config),
117
+ 'git-repo': () => new m.GiteaGitRepo(config),
118
+ 'git-host': () => new m.GiteaGitHost(config),
119
+ };
120
+ return adapters[capabilityId]!();
121
+ });
122
+
123
+ // WebDAV
124
+ adapterRegistry.register('webdav', async (token) => {
125
+ const creds = JSON.parse(token);
126
+ const { WebDAVFileSystem } = await import('@anymux/webdav');
127
+ const proxyUrl = typeof window !== 'undefined' ? window.location.origin : undefined;
128
+ return new WebDAVFileSystem({ url: creds.url, username: creds.username, password: creds.password, proxyUrl });
129
+ });
130
+
131
+ // S3
132
+ adapterRegistry.register('s3', async (token) => {
133
+ const creds = JSON.parse(token);
134
+ const proxyUrl = typeof window !== 'undefined' ? window.location.origin : undefined;
135
+ if (proxyUrl) {
136
+ // Browser: use proxy to avoid CORS
137
+ const { S3ProxyStorage } = await import('@anymux/s3');
138
+ const storage = new S3ProxyStorage({
139
+ proxyUrl,
140
+ endpoint: creds.endpoint,
141
+ region: creds.region?.trim(),
142
+ accessKeyId: creds.accessKeyId?.trim(),
143
+ secretAccessKey: creds.secretAccessKey?.trim(),
144
+ forcePathStyle: true,
145
+ });
146
+ return { storage, bucket: creds.bucket || 'anymux-test' };
147
+ }
148
+ // Server: use direct SDK
149
+ const { S3ObjectStorage } = await import('@anymux/s3');
150
+ const storage = new S3ObjectStorage({
151
+ region: creds.region?.trim(),
152
+ endpoint: creds.endpoint,
153
+ credentials: { accessKeyId: creds.accessKeyId?.trim(), secretAccessKey: creds.secretAccessKey?.trim() },
154
+ forcePathStyle: true,
155
+ });
156
+ return { storage, bucket: creds.bucket || 'anymux-test' };
157
+ });
158
+
159
+ // BrowserFS (File System Access API)
160
+ adapterRegistry.register('browser-fs', async () => {
161
+ const { BrowserFileSystemFactory } = await import('@anymux/browser-fs');
162
+ const factory = new BrowserFileSystemFactory();
163
+ const handleInfos = await factory.getAllHandleInfos();
164
+ if (handleInfos.length > 0) {
165
+ return factory.createFromHandleInfo(handleInfos[0]!.id);
166
+ }
167
+ throw new Error('No local file system connected. Please connect first.');
168
+ });
169
+
170
+ // IndexedDB
171
+ adapterRegistry.register('indexeddb', async () => {
172
+ const { IndexedDBFileSystem } = await import('@anymux/indexeddb');
173
+ const fs = new IndexedDBFileSystem();
174
+ await fs.init();
175
+ return fs;
176
+ });
177
+
@@ -0,0 +1,101 @@
1
+ import { createAuthClient } from 'better-auth/client';
2
+ import { genericOAuthClient } from 'better-auth/client/plugins';
3
+
4
+ const PENDING_SERVICE_KEY = 'anymux-pending-service';
5
+
6
+ export interface ConnectAuthClient {
7
+ signIn(provider: string, serviceId: string): Promise<void>;
8
+ signOut(): Promise<void>;
9
+ getSession(): Promise<{ user: { id: string; name: string; image?: string } } | null>;
10
+ getAccessToken(providerId: string): Promise<string | null>;
11
+ fetchConfiguredProviders(): Promise<{ database: boolean; authSecret: boolean; providers: Record<string, boolean> }>;
12
+ fetchGrantedScopes(): Promise<Record<string, string[]>>;
13
+ fetchTestCredentials(): Promise<Record<string, unknown>>;
14
+ fetchUserProfiles(): Promise<Record<string, { id: string; name: string; email?: string; avatarUrl?: string; profileUrl?: string }>>;
15
+ revokeProvider(providerId: string): Promise<void>;
16
+ getPendingServiceId(): string | null;
17
+ clearPendingServiceId(): void;
18
+ }
19
+
20
+ export function createConnectAuthClient(baseURL: string): ConnectAuthClient {
21
+ const authClient = createAuthClient({ baseURL, plugins: [genericOAuthClient()] });
22
+
23
+ return {
24
+ async signIn(provider, serviceId) {
25
+ sessionStorage.setItem(PENDING_SERVICE_KEY, serviceId);
26
+ const GENERIC_OAUTH_PROVIDERS = ['box', 'bitbucket'];
27
+ if (GENERIC_OAUTH_PROVIDERS.includes(provider)) {
28
+ await authClient.signIn.oauth2({
29
+ providerId: provider,
30
+ callbackURL: window.location.href,
31
+ });
32
+ } else {
33
+ await authClient.signIn.social({
34
+ provider: provider as 'google' | 'github' | 'dropbox' | 'microsoft' | 'apple',
35
+ callbackURL: window.location.href,
36
+ });
37
+ }
38
+ // Browser redirects away — this line won't execute
39
+ },
40
+
41
+ async signOut() {
42
+ await authClient.signOut();
43
+ },
44
+
45
+ async getSession() {
46
+ const session = await authClient.getSession();
47
+ if (!session.data) return null;
48
+ return { user: { id: session.data.user.id, name: session.data.user.name, ...(session.data.user.image ? { image: session.data.user.image } : {}) } };
49
+ },
50
+
51
+ async getAccessToken(providerId: string) {
52
+ const result = await authClient.getAccessToken({ providerId });
53
+ return result.data?.accessToken ?? null;
54
+ },
55
+
56
+ async fetchConfiguredProviders() {
57
+ const res = await fetch(`${baseURL}/api/auth/configured`);
58
+ if (!res.ok) return { database: false, authSecret: false, providers: {} };
59
+ return res.json();
60
+ },
61
+
62
+ async fetchGrantedScopes() {
63
+ const res = await fetch(`${baseURL}/api/auth/scopes`, { credentials: 'include' });
64
+ if (!res.ok) return {};
65
+ return res.json();
66
+ },
67
+
68
+ async fetchTestCredentials() {
69
+ const res = await fetch(`${baseURL}/api/auth/test-credentials`);
70
+ if (!res.ok) return {};
71
+ return res.json();
72
+ },
73
+
74
+ async fetchUserProfiles() {
75
+ const res = await fetch(`${baseURL}/api/auth/user-profiles`, { credentials: 'include' });
76
+ if (!res.ok) return {};
77
+ return res.json();
78
+ },
79
+
80
+ async revokeProvider(providerId: string) {
81
+ const res = await fetch(`${baseURL}/api/auth/revoke-provider?provider=${encodeURIComponent(providerId)}`, {
82
+ method: 'DELETE',
83
+ credentials: 'include',
84
+ });
85
+ if (!res.ok) {
86
+ const text = await res.text().catch(() => '');
87
+ throw new Error(`revokeProvider ${providerId} failed: ${res.status} ${text}`);
88
+ }
89
+ const data = await res.json().catch(() => ({}));
90
+ console.info(`[AnyMux] revokeProvider(${providerId}): deleted=${(data as Record<string, unknown>).deleted ?? '?'}`);
91
+ },
92
+
93
+ getPendingServiceId() {
94
+ return sessionStorage.getItem(PENDING_SERVICE_KEY);
95
+ },
96
+
97
+ clearPendingServiceId() {
98
+ sessionStorage.removeItem(PENDING_SERVICE_KEY);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,27 @@
1
+ const STORAGE_PREFIX = 'anymux-connect-token:';
2
+
3
+ export class TokenManager {
4
+ getToken(serviceId: string): string | null {
5
+ try {
6
+ return localStorage.getItem(STORAGE_PREFIX + serviceId);
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ setToken(serviceId: string, token: string): void {
13
+ try {
14
+ localStorage.setItem(STORAGE_PREFIX + serviceId, token);
15
+ } catch {
16
+ // localStorage unavailable
17
+ }
18
+ }
19
+
20
+ removeToken(serviceId: string): void {
21
+ try {
22
+ localStorage.removeItem(STORAGE_PREFIX + serviceId);
23
+ } catch {
24
+ // localStorage unavailable
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,137 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { History, Undo2, Trash2, FileEdit, FilePlus, Upload, Copy, Move, X } from 'lucide-react';
4
+ import type { ActionNotificationModel, ActionRecord, ActionType } from '../models/ActionNotificationModel';
5
+
6
+ const ACTION_ICONS: Record<ActionType, React.ReactNode> = {
7
+ delete: <Trash2 className="h-3.5 w-3.5 text-destructive" />,
8
+ rename: <FileEdit className="h-3.5 w-3.5 text-blue-500" />,
9
+ create: <FilePlus className="h-3.5 w-3.5 text-green-500" />,
10
+ upload: <Upload className="h-3.5 w-3.5 text-purple-500" />,
11
+ copy: <Copy className="h-3.5 w-3.5 text-muted-foreground" />,
12
+ move: <Move className="h-3.5 w-3.5 text-orange-500" />,
13
+ };
14
+
15
+ function formatTimeAgo(date: Date): string {
16
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
17
+ if (seconds < 60) return 'just now';
18
+ const minutes = Math.floor(seconds / 60);
19
+ if (minutes < 60) return `${minutes}m ago`;
20
+ const hours = Math.floor(minutes / 60);
21
+ if (hours < 24) return `${hours}h ago`;
22
+ return `${Math.floor(hours / 24)}d ago`;
23
+ }
24
+
25
+ const ActionItem: React.FC<{ action: ActionRecord; model: ActionNotificationModel }> = observer(({ action, model }) => {
26
+ const [undoing, setUndoing] = useState(false);
27
+
28
+ const handleUndo = async () => {
29
+ if (!action.undo || action.undone || undoing) return;
30
+ setUndoing(true);
31
+ try {
32
+ await action.undo();
33
+ model.markUndone(action.id);
34
+ } catch {
35
+ // Error toast is handled by showActionToast
36
+ } finally {
37
+ setUndoing(false);
38
+ }
39
+ };
40
+
41
+ return (
42
+ <div className={`flex items-start gap-2 px-3 py-2 text-xs ${action.undone ? 'opacity-50' : ''}`}>
43
+ <span className="mt-0.5 flex-shrink-0">{ACTION_ICONS[action.type]}</span>
44
+ <div className="flex-1 min-w-0">
45
+ <p className={`text-foreground truncate ${action.undone ? 'line-through' : ''}`} title={action.description}>
46
+ {action.description}
47
+ </p>
48
+ <p className="text-muted-foreground mt-0.5">{formatTimeAgo(action.timestamp)}</p>
49
+ </div>
50
+ {action.undo && !action.undone && (
51
+ <button
52
+ onClick={handleUndo}
53
+ disabled={undoing}
54
+ className="flex-shrink-0 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
55
+ title="Undo"
56
+ >
57
+ <Undo2 className="h-3.5 w-3.5" />
58
+ </button>
59
+ )}
60
+ </div>
61
+ );
62
+ });
63
+
64
+ interface ActionHistoryPanelProps {
65
+ model: ActionNotificationModel;
66
+ }
67
+
68
+ export const ActionHistoryPanel: React.FC<ActionHistoryPanelProps> = observer(({ model }) => {
69
+ const [open, setOpen] = useState(false);
70
+ const panelRef = useRef<HTMLDivElement>(null);
71
+ const actions = model.recentActions;
72
+
73
+ // Close on click outside
74
+ useEffect(() => {
75
+ if (!open) return;
76
+ const handleClick = (e: MouseEvent) => {
77
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
78
+ setOpen(false);
79
+ }
80
+ };
81
+ document.addEventListener('mousedown', handleClick);
82
+ return () => document.removeEventListener('mousedown', handleClick);
83
+ }, [open]);
84
+
85
+ return (
86
+ <div className="relative" ref={panelRef}>
87
+ <button
88
+ onClick={() => setOpen(!open)}
89
+ className="relative p-1.5 rounded-md hover:bg-background/80 text-muted-foreground hover:text-foreground transition-colors"
90
+ title="Action history"
91
+ >
92
+ <History className="h-4 w-4" />
93
+ {actions.length > 0 && (
94
+ <span className="absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-primary text-primary-foreground text-[9px] font-medium flex items-center justify-center">
95
+ {actions.length > 9 ? '9+' : actions.length}
96
+ </span>
97
+ )}
98
+ </button>
99
+
100
+ {open && (
101
+ <div className="absolute right-0 top-full mt-1 w-72 rounded-lg border border-border bg-card shadow-lg z-50 animate-in fade-in slide-in-from-top-1 duration-150">
102
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
103
+ <span className="text-xs font-medium text-foreground">Recent Actions</span>
104
+ <div className="flex items-center gap-1">
105
+ {actions.length > 0 && (
106
+ <button
107
+ onClick={() => model.clear()}
108
+ className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded hover:bg-muted transition-colors"
109
+ >
110
+ Clear
111
+ </button>
112
+ )}
113
+ <button
114
+ onClick={() => setOpen(false)}
115
+ className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
116
+ >
117
+ <X className="h-3.5 w-3.5" />
118
+ </button>
119
+ </div>
120
+ </div>
121
+
122
+ <div className="max-h-64 overflow-y-auto divide-y divide-border">
123
+ {actions.length === 0 ? (
124
+ <div className="px-3 py-6 text-center text-xs text-muted-foreground">
125
+ No actions yet
126
+ </div>
127
+ ) : (
128
+ actions.map((action) => (
129
+ <ActionItem key={action.id} action={action} model={model} />
130
+ ))
131
+ )}
132
+ </div>
133
+ </div>
134
+ )}
135
+ </div>
136
+ );
137
+ });
@@ -0,0 +1,97 @@
1
+ import React from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import { Check, Minus } from 'lucide-react';
4
+ import { match } from 'ts-pattern';
5
+ import type { CapabilityId, ServiceDefinition } from '../types/service';
6
+ import type { ConnectionManagerModel } from '../models/ConnectionManagerModel';
7
+ import type { DashboardModel } from '../models/DashboardModel';
8
+
9
+ interface CapabilityCellProps {
10
+ service: ServiceDefinition;
11
+ capabilityId: CapabilityId;
12
+ connectionManager: ConnectionManagerModel;
13
+ dashboardModel: DashboardModel;
14
+ }
15
+
16
+ export const CapabilityCell: React.FC<CapabilityCellProps> = observer(
17
+ ({ service, capabilityId, connectionManager, dashboardModel }) => {
18
+ const capability = service.capabilities.find((c) => c.id === capabilityId);
19
+ // For merged git column: show as supported if either git-repo or git-host is supported
20
+ const gitHostCap = capabilityId === 'git-repo'
21
+ ? service.capabilities.find((c) => c.id === 'git-host')
22
+ : undefined;
23
+ const supported = (capability?.supported ?? false) || (gitHostCap?.supported ?? false);
24
+ // Route to the right capability when clicking: prefer git-repo, fallback to git-host
25
+ const effectiveCapabilityId = capabilityId === 'git-repo' && !capability?.supported && gitHostCap?.supported
26
+ ? 'git-host' as CapabilityId
27
+ : capabilityId;
28
+ const status = connectionManager.getStatus(service.id);
29
+ const isSelected =
30
+ dashboardModel.selectedCell?.serviceId === service.id &&
31
+ (dashboardModel.selectedCell?.capabilityId === effectiveCapabilityId ||
32
+ (capabilityId === 'git-repo' && dashboardModel.selectedCell?.capabilityId === 'git-host'));
33
+
34
+ if (!supported) {
35
+ return (
36
+ <td className="px-3 py-2 text-center align-middle">
37
+ <Minus className="h-4 w-4 text-muted-foreground/30 mx-auto" />
38
+ </td>
39
+ );
40
+ }
41
+
42
+ const handleKeyDown = (e: React.KeyboardEvent) => {
43
+ if (e.key === 'Enter' || e.key === ' ') {
44
+ e.preventDefault();
45
+ dashboardModel.selectCell(service.id, effectiveCapabilityId);
46
+ }
47
+ };
48
+
49
+ return match(status)
50
+ .with('loading', () => (
51
+ <td className="px-3 py-2 text-center align-middle">
52
+ <span className="inline-block h-4 w-4 rounded bg-muted animate-pulse mx-auto" />
53
+ </td>
54
+ ))
55
+ .with('connected', () => {
56
+ // Don't show green/orange until scopes have been fetched
57
+ if (!connectionManager.initialized) {
58
+ return (
59
+ <td className="px-3 py-2 text-center align-middle">
60
+ <span className="inline-block h-4 w-4 rounded bg-muted animate-pulse mx-auto" />
61
+ </td>
62
+ );
63
+ }
64
+ const hasScopes = connectionManager.hasCapabilityScopes(service.id, effectiveCapabilityId);
65
+ const hoverBg = hasScopes
66
+ ? 'hover:bg-green-50 dark:hover:bg-green-900/20'
67
+ : 'hover:bg-orange-50 dark:hover:bg-orange-900/20';
68
+ return (
69
+ <td
70
+ className={`px-3 py-2 text-center align-middle cursor-pointer transition-colors ${
71
+ isSelected
72
+ ? 'bg-blue-100 dark:bg-blue-900/40'
73
+ : hoverBg
74
+ }`}
75
+ role="button"
76
+ tabIndex={0}
77
+ onClick={() => dashboardModel.selectCell(service.id, effectiveCapabilityId)}
78
+ onKeyDown={handleKeyDown}
79
+ title={hasScopes ? undefined : 'Scope not granted'}
80
+ >
81
+ <span className={`inline-flex items-center justify-center w-5 h-5 rounded-full mx-auto ${
82
+ hasScopes ? 'bg-green-500' : 'bg-orange-400'
83
+ } text-white`}>
84
+ <Check className="h-3 w-3" />
85
+ </span>
86
+ </td>
87
+ );
88
+ })
89
+ .otherwise(() => (
90
+ <td className="px-3 py-2 text-center align-middle">
91
+ <span className="inline-flex items-center justify-center w-5 h-5 rounded border border-muted-foreground/30 mx-auto">
92
+ <Check className="h-3 w-3 text-muted-foreground/50" />
93
+ </span>
94
+ </td>
95
+ ));
96
+ }
97
+ );
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import { ArrowLeft, RefreshCw } from 'lucide-react';
3
+ import { ErrorDisplay } from '@anymux/ui/components/error-display';
4
+
5
+ function isAuthError(error: string): boolean {
6
+ return /\b(401|403|auth|token|expired|unauthorized|forbidden|denied|scope|permission|sign.?in|log.?in)\b/i.test(error);
7
+ }
8
+
9
+ export { isAuthError };
10
+
11
+ /** Render error with contextual actions for capability panels */
12
+ export function CapabilityError({ error, onRetry, onReconnect, onGoBack }: {
13
+ error: string;
14
+ onRetry?: () => void;
15
+ onReconnect?: () => void;
16
+ onGoBack?: () => void;
17
+ }) {
18
+ const authError = isAuthError(error);
19
+ return (
20
+ <div className="flex flex-col items-center justify-center h-64 gap-4 p-4">
21
+ <ErrorDisplay
22
+ error={error}
23
+ title={authError ? 'Authentication Required' : 'Failed to Load'}
24
+ variant={authError ? 'warning' as const : 'destructive' as const}
25
+ className="max-w-md w-full"
26
+ {...(onRetry != null ? { onRetry } : {})}
27
+ />
28
+ <div className="flex items-center gap-2">
29
+ {onReconnect && (
30
+ <button
31
+ onClick={onReconnect}
32
+ className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
33
+ >
34
+ <RefreshCw className="h-3 w-3" />
35
+ Reconnect
36
+ </button>
37
+ )}
38
+ {onGoBack && (
39
+ <button
40
+ onClick={onGoBack}
41
+ className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-muted hover:bg-muted/80 transition-colors"
42
+ >
43
+ <ArrowLeft className="h-3 w-3" />
44
+ Back to Dashboard
45
+ </button>
46
+ )}
47
+ </div>
48
+ </div>
49
+ );
50
+ }