Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/assets/client-icons/opencode-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop/src/assets/client-icons/opencode.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions apps/desktop/src/assets/client-icons/zed.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions apps/desktop/src/components/ClientBrandIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<img src={light} alt={alt} className={`${className} block dark:hidden`} />
<img src={dark} alt={alt} className={`${className} hidden dark:block`} />
</>
);
}
return <img src={(light ?? dark) as string} alt={alt} className={className} />;
}
23 changes: 21 additions & 2 deletions apps/desktop/src/components/ConnectIDEs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<void>) | string;
/**
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -184,8 +202,9 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP
data-testid={`client-icon-${entry.id}`}
>
{entry.icon ? (
<img
src={entry.icon}
<ClientBrandIcon
light={entry.icon}
dark={entry.iconDark}
alt={entry.name}
className="h-5 w-5 object-contain"
/>
Expand Down
37 changes: 36 additions & 1 deletion apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { light: string; dark?: string }> = {
cursor: { light: cursorIcon },
'claude-code': { light: claudeIcon },
vscode: { light: vscodeIcon },
opencode: { light: opencodeIcon, dark: opencodeIconDark },
zed: { light: zedIcon },
};
import {
generateWorkspaceConfigSnippet,
getGatewayAuthDisabled,
Expand Down Expand Up @@ -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] ? (
<ClientBrandIcon
light={CLIENT_ICONS[c.id].light}
dark={CLIENT_ICONS[c.id].dark}
className="h-5 w-5 flex-shrink-0 object-contain"
/>
) : (
<AppWindow className="h-5 w-5 flex-shrink-0 text-[rgb(var(--muted))]" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[rgb(var(--foreground))]">{c.label}</div>
<div className="truncate font-mono text-[11px] text-[rgb(var(--muted))]">
Expand Down
31 changes: 28 additions & 3 deletions apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Check,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -182,13 +189,31 @@ export function WorkspaceSetupWizard({
</Button>

{folder && (
<div className="flex items-center gap-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2">
<Check className="h-4 w-4 flex-shrink-0 text-green-600" />
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 ${
alreadyMapped
? 'border-amber-300 bg-amber-50 dark:border-amber-800/60 dark:bg-amber-900/20'
: 'border-[rgb(var(--border))] bg-[rgb(var(--background))]'
}`}
>
{alreadyMapped ? (
<AlertCircle className="h-4 w-4 flex-shrink-0 text-amber-600" />
) : (
<Check className="h-4 w-4 flex-shrink-0 text-green-600" />
)}
<span className="truncate font-mono text-xs" title={folder}>
{folder}
</span>
</div>
)}
{alreadyMapped && (
<p
className="text-xs text-amber-700 dark:text-amber-400"
data-testid="wizard-folder-mapped-error"
>
This folder is already mapped — edit it from the Workspaces list instead.
</p>
)}

{unmappedRoots.length > 0 && (
<div>
Expand Down Expand Up @@ -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'}
Expand Down
21 changes: 21 additions & 0 deletions tests/ts/components/ConnectIDEs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ConnectIDEs gatewayUrl="http://localhost:45818" gatewayRunning={true} />);

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(
<ConnectIDEs gatewayUrl="http://localhost:45818" gatewayRunning={true} />
Expand Down
7 changes: 5 additions & 2 deletions tests/ts/components/WorkspaceInstallPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<WorkspaceInstallPanel workspaceRoot={ROOT} />);
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();
}
});

Expand Down
27 changes: 23 additions & 4 deletions tests/ts/components/WorkspaceSetupWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down Expand Up @@ -50,7 +51,6 @@ const props = (over: any = {}) => ({

describe('WorkspaceSetupWizard', () => {
beforeEach(() => {
openMock.mockReset();
validateMock.mockReset();
});

Expand Down Expand Up @@ -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(
<WorkspaceSetupWizard
{...props({
reportedRoots: ['/proj/app', '/proj/other'],
existingBindings: [
{ id: 'b1', workspace_root: '/proj/app', space_id: 's1', feature_set_ids: ['fs_starter'] },
],
})}
/>
);
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(<WorkspaceSetupWizard {...props()} />);
Expand Down
Loading