diff --git a/apps/desktop/src/assets/client-icons/opencode-dark.svg b/apps/desktop/src/assets/client-icons/opencode-dark.svg new file mode 100644 index 00000000..b79c7332 --- /dev/null +++ b/apps/desktop/src/assets/client-icons/opencode-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/assets/client-icons/opencode.svg b/apps/desktop/src/assets/client-icons/opencode.svg new file mode 100644 index 00000000..b79140a5 --- /dev/null +++ b/apps/desktop/src/assets/client-icons/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/assets/client-icons/zed.svg b/apps/desktop/src/assets/client-icons/zed.svg new file mode 100644 index 00000000..4618dc2d --- /dev/null +++ b/apps/desktop/src/assets/client-icons/zed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/src/components/ClientBrandIcon.tsx b/apps/desktop/src/components/ClientBrandIcon.tsx new file mode 100644 index 00000000..62bc27d5 --- /dev/null +++ b/apps/desktop/src/components/ClientBrandIcon.tsx @@ -0,0 +1,31 @@ +/** + * Brand icon for an MCP client. + * + * Some official marks are theme-specific — opencode's logo is a dark mark meant + * for light backgrounds and a light mark for dark backgrounds, so neither reads + * on both themes. When both variants are provided we render both and toggle with + * Tailwind's `dark:` variant; a single asset is shown as-is. Returns `null` when + * no asset is given so the caller can render its own fallback glyph. + */ +export function ClientBrandIcon({ + light, + dark, + alt = '', + className = '', +}: { + light?: string; + dark?: string; + alt?: string; + className?: string; +}) { + if (!light && !dark) return null; + if (light && dark) { + return ( + <> + {alt} + + + ); + } + return {alt}; +} diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx index b951f0e7..7ca7fdb7 100644 --- a/apps/desktop/src/components/ConnectIDEs.tsx +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -7,7 +7,10 @@ import claudeIcon from '@/assets/client-icons/claude.svg'; import windsurfIcon from '@/assets/client-icons/windsurf.svg'; import jetbrainsIcon from '@/assets/client-icons/jetbrains.svg'; import androidStudioIcon from '@/assets/client-icons/android-studio.svg'; +import opencodeIcon from '@/assets/client-icons/opencode.svg'; +import opencodeIconDark from '@/assets/client-icons/opencode-dark.svg'; import { addToVscode, addToCursor } from '@/lib/api/clientInstall'; +import { ClientBrandIcon } from './ClientBrandIcon'; type GridAction = 'deep_link' | 'copy_command' | 'copy_config'; @@ -16,6 +19,8 @@ interface GridEntry { name: string; label: string; icon?: string; + /** Optional dark-theme variant; rendered via ClientBrandIcon when present. */ + iconDark?: string; action: GridAction; handler: (() => Promise) | string; /** @@ -94,6 +99,19 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP 'loads mcpmux on the next `claude` invocation (existing sessions need ' + '/restart). Approve on this page when it connects.', }, + { + id: 'opencode', + name: 'opencode', + label: 'opencode', + icon: opencodeIcon, + iconDark: opencodeIconDark, + action: 'copy_config', + handler: `"mcpmux": {\n "type": "remote",\n "url": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. In opencode, paste it under "mcp" in opencode.json ' + + '(project) or ~/.config/opencode/opencode.json (global), then restart ' + + 'opencode. Approve on this page when it connects.', + }, { id: 'jetbrains', name: 'JetBrains IDEs', @@ -184,8 +202,9 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP data-testid={`client-icon-${entry.id}`} > {entry.icon ? ( - {entry.name} diff --git a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx index 287c684d..73d6bc96 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx @@ -1,8 +1,34 @@ import { useCallback, useEffect, useState } from 'react'; -import { Check, Copy, Download, Loader2, ShieldCheck, ShieldOff, AlertCircle } from 'lucide-react'; +import { + AppWindow, + Check, + Copy, + Download, + Loader2, + ShieldCheck, + ShieldOff, + AlertCircle, +} from 'lucide-react'; import { Button } from '@mcpmux/ui'; +import cursorIcon from '@/assets/client-icons/cursor.svg'; +import claudeIcon from '@/assets/client-icons/claude.svg'; +import vscodeIcon from '@/assets/client-icons/vscode.png'; +import opencodeIcon from '@/assets/client-icons/opencode.svg'; +import opencodeIconDark from '@/assets/client-icons/opencode-dark.svg'; +import zedIcon from '@/assets/client-icons/zed.svg'; +import { ClientBrandIcon } from '@/components/ClientBrandIcon'; import { getGatewayStatus } from '@/lib/api/gateway'; import { useNavigateTo, useSetPendingSettingsSection } from '@/stores'; + +/** Brand icon per supported client id (falls back to a generic glyph). opencode + * ships theme-specific marks, so it carries a dark variant. */ +const CLIENT_ICONS: Record = { + cursor: { light: cursorIcon }, + 'claude-code': { light: claudeIcon }, + vscode: { light: vscodeIcon }, + opencode: { light: opencodeIcon, dark: opencodeIconDark }, + zed: { light: zedIcon }, +}; import { generateWorkspaceConfigSnippet, getGatewayAuthDisabled, @@ -209,6 +235,15 @@ export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string onChange={() => toggleClient(c.id)} className="h-4 w-4 flex-shrink-0 accent-primary-500" /> + {CLIENT_ICONS[c.id] ? ( + + ) : ( + + )}
{c.label}
diff --git a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx index 21e0231c..d0e88f57 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { + AlertCircle, ArrowLeft, ArrowRight, Check, @@ -89,6 +90,12 @@ export function WorkspaceSetupWizard({ () => reportedRoots.filter((r) => !boundRoots.has(r.toLowerCase())), [reportedRoots, boundRoots] ); + // Block picking a folder that already has a mapping (e.g. chosen via the + // folder dialog) — it must be edited from the Workspaces list, not re-created. + const alreadyMapped = useMemo( + () => !!folder && boundRoots.has(folder.toLowerCase()), + [folder, boundRoots] + ); const pickFolder = async () => { try { @@ -182,13 +189,31 @@ export function WorkspaceSetupWizard({ {folder && ( -
- +
+ {alreadyMapped ? ( + + ) : ( + + )} {folder}
)} + {alreadyMapped && ( +

+ This folder is already mapped — edit it from the Workspaces list instead. +

+ )} {unmappedRoots.length > 0 && (
@@ -315,7 +340,7 @@ export function WorkspaceSetupWizard({ variant="primary" size="sm" onClick={() => setStep((s) => (s + 1) as 1 | 2 | 3)} - disabled={step === 1 && !folder} + disabled={step === 1 && (!folder || alreadyMapped)} data-testid="wizard-next" > {step === 2 ? 'Next' : 'Continue'} diff --git a/tests/ts/components/ConnectIDEs.test.tsx b/tests/ts/components/ConnectIDEs.test.tsx index ae847fd7..a456ea6a 100644 --- a/tests/ts/components/ConnectIDEs.test.tsx +++ b/tests/ts/components/ConnectIDEs.test.tsx @@ -35,9 +35,30 @@ describe('ConnectIDEs', () => { expect(screen.getByTestId('client-icon-vscode')).toBeInTheDocument(); expect(screen.getByTestId('client-icon-cursor')).toBeInTheDocument(); expect(screen.getByTestId('client-icon-claude-code')).toBeInTheDocument(); + expect(screen.getByTestId('client-icon-opencode')).toBeInTheDocument(); expect(screen.getByTestId('client-icon-copy-config')).toBeInTheDocument(); }); + it('offers a global opencode config snippet (no workspace header)', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + render(); + + await user.click(screen.getByTestId('client-icon-opencode')); + await user.click(screen.getByRole('button', { name: /Copy config/i })); + + const copied = writeText.mock.calls[0][0] as string; + expect(copied).toContain('"type": "remote"'); + expect(copied).toContain('localhost:45818/mcp'); + // Global connect carries no per-workspace header. + expect(copied).not.toContain('X-Mcpmux-Workspace'); + }); + it('should show labels under icons', () => { render( diff --git a/tests/ts/components/WorkspaceInstallPanel.test.tsx b/tests/ts/components/WorkspaceInstallPanel.test.tsx index 95e7e9e3..a9c95669 100644 --- a/tests/ts/components/WorkspaceInstallPanel.test.tsx +++ b/tests/ts/components/WorkspaceInstallPanel.test.tsx @@ -74,11 +74,14 @@ describe('WorkspaceInstallPanel', () => { .mockResolvedValue({ running: true, url: 'http://localhost:45818' }); }); - it('lists every supported client', async () => { + it('lists every supported client, each with an icon', async () => { render(); expect(await screen.findByText('Cursor')).toBeTruthy(); for (const c of CLIENTS) { - expect(screen.getByTestId(`workspace-install-client-${c.id}`)).toBeTruthy(); + const row = screen.getByTestId(`workspace-install-client-${c.id}`); + expect(row).toBeTruthy(); + // Each known client renders a brand icon image. + expect(row.querySelector('img')).toBeTruthy(); } }); diff --git a/tests/ts/components/WorkspaceSetupWizard.test.tsx b/tests/ts/components/WorkspaceSetupWizard.test.tsx index ab96dded..d19241e9 100644 --- a/tests/ts/components/WorkspaceSetupWizard.test.tsx +++ b/tests/ts/components/WorkspaceSetupWizard.test.tsx @@ -11,12 +11,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -const { openMock, validateMock } = vi.hoisted(() => ({ - openMock: vi.fn(), +const { validateMock } = vi.hoisted(() => ({ validateMock: vi.fn(), })); -vi.mock('@tauri-apps/plugin-dialog', () => ({ open: openMock })); +// `@tauri-apps/plugin-dialog` is mocked globally in setup.ts (open: vi.fn()). +// We reconfigure that shared mock per-test via vi.importMock (a static import +// of this mocked-only package isn't Vite-resolvable from the test). vi.mock('@/lib/api/workspaceBindings', () => ({ validateWorkspaceRoot: validateMock })); vi.mock('@/lib/api/featureSets', () => ({ isStarterFeatureSet: (fs: { feature_set_type: string }) => @@ -50,7 +51,6 @@ const props = (over: any = {}) => ({ describe('WorkspaceSetupWizard', () => { beforeEach(() => { - openMock.mockReset(); validateMock.mockReset(); }); @@ -87,6 +87,25 @@ describe('WorkspaceSetupWizard', () => { expect(p.onClose).not.toHaveBeenCalled(); }); + it('does not offer an already-mapped folder in the detected list', () => { + // The quick-pick list filters out folders that already have a binding, so a + // mapped folder can't be re-picked there; an unmapped one is still offered. + // (Picking a mapped folder via the OS dialog is guarded separately by the + // alreadyMapped check, which disables Next and shows an inline error.) + render( + + ); + expect(screen.queryByRole('button', { name: /\/proj\/app$/ })).toBeNull(); + expect(screen.getByRole('button', { name: /\/proj\/other$/ })).toBeTruthy(); + }); + it('lets you go Back from a later step', async () => { const user = userEvent.setup(); render();