Skip to content

Commit b641acd

Browse files
committed
feat(desktop): My Servers search, filters popover, and shared UI primitives
Add DropdownMenu, ChipButton, SearchField, and HoverTooltip to @mcpmux/ui. Refactor My Servers with transport/status filters, tool-aware search, row-click expand, inline gateway status, and Add Server discover/custom menu. Signed-off-by: Joe Sangiorgio <jsangio1@gmail.com> Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 8ff352b commit b641acd

12 files changed

Lines changed: 1093 additions & 292 deletions

File tree

apps/desktop/src/components/Contribute.tsx

Lines changed: 26 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { useEffect, useRef, useState } from 'react';
21
import { Bug, Github, Heart, Lightbulb, Package, SendHorizontal } from 'lucide-react';
3-
import { Button } from '@mcpmux/ui';
2+
import {
3+
Button,
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuTrigger,
8+
} from '@mcpmux/ui';
49
import { CONTRIBUTE, openExternal } from '@/lib/contribute';
510

611
/**
@@ -71,18 +76,6 @@ export function ContributeMenu({
7176
variant?: 'primary' | 'secondary' | 'ghost';
7277
size?: 'sm' | 'md';
7378
}) {
74-
const [open, setOpen] = useState(false);
75-
const ref = useRef<HTMLDivElement>(null);
76-
77-
useEffect(() => {
78-
if (!open) return;
79-
const handler = (e: MouseEvent) => {
80-
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
81-
};
82-
document.addEventListener('mousedown', handler);
83-
return () => document.removeEventListener('mousedown', handler);
84-
}, [open]);
85-
8679
const items = [
8780
{
8881
label: 'Request a new server',
@@ -111,42 +104,24 @@ export function ContributeMenu({
111104
];
112105

113106
return (
114-
<div className="relative inline-block" ref={ref}>
115-
<Button
116-
variant={variant}
117-
size={size}
118-
onClick={() => setOpen((v) => !v)}
119-
data-testid="contribute-menu-trigger"
120-
>
121-
<Heart className="h-4 w-4 mr-1.5" />
122-
Contribute
123-
</Button>
124-
{open && (
125-
<div
126-
className="absolute right-0 mt-2 z-20 w-72 rounded-xl border border-[rgb(var(--border))] bg-white dark:bg-zinc-900 shadow-xl p-1"
127-
data-testid="contribute-menu"
128-
>
129-
{items.map((item) => (
130-
<button
131-
key={item.label}
132-
type="button"
133-
onClick={() => {
134-
setOpen(false);
135-
openExternal(item.href);
136-
}}
137-
className="w-full text-left flex items-start gap-3 px-3 py-2.5 rounded-lg hover:bg-[rgb(var(--surface))] transition-colors"
138-
>
139-
<item.icon className="h-4 w-4 mt-0.5 text-[rgb(var(--muted))] flex-shrink-0" />
140-
<div className="flex-1 min-w-0">
141-
<p className="text-sm font-medium">{item.label}</p>
142-
<p className="text-[11px] text-[rgb(var(--muted))] leading-snug">
143-
{item.caption}
144-
</p>
145-
</div>
146-
</button>
147-
))}
148-
</div>
149-
)}
150-
</div>
107+
<DropdownMenu>
108+
<DropdownMenuTrigger data-testid="contribute-menu-trigger">
109+
<Button variant={variant} size={size} type="button">
110+
<Heart className="h-4 w-4 mr-1.5" />
111+
Contribute
112+
</Button>
113+
</DropdownMenuTrigger>
114+
<DropdownMenuContent align="end" className="w-72 p-1.5" data-testid="contribute-menu">
115+
{items.map((item) => (
116+
<DropdownMenuItem
117+
key={item.label}
118+
icon={item.icon}
119+
label={item.label}
120+
description={item.caption}
121+
onSelect={() => openExternal(item.href)}
122+
/>
123+
))}
124+
</DropdownMenuContent>
125+
</DropdownMenu>
151126
);
152127
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ChevronDown, Compass, FileJson, Plus } from 'lucide-react';
2+
import {
3+
Button,
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuTrigger,
8+
} from '@mcpmux/ui';
9+
10+
interface AddServerMenuProps {
11+
/** Opens the Discover page to browse the community server registry. */
12+
onDiscover: () => void;
13+
/** Opens the Space JSON editor to add a custom server definition. */
14+
onCustom: () => void;
15+
}
16+
17+
/**
18+
* Dropdown for the two ways to add MCP servers: registry discover vs custom JSON.
19+
*/
20+
export function AddServerMenu({ onDiscover, onCustom }: AddServerMenuProps) {
21+
return (
22+
<DropdownMenu>
23+
<DropdownMenuTrigger data-testid="add-server-menu-trigger">
24+
<Button variant="primary" size="md" type="button">
25+
<Plus className="h-4 w-4" />
26+
Add Server
27+
<ChevronDown className="h-4 w-4" />
28+
</Button>
29+
</DropdownMenuTrigger>
30+
<DropdownMenuContent align="end" className="w-80 p-1.5" data-testid="add-server-menu">
31+
<DropdownMenuItem
32+
icon={Compass}
33+
label="Discover from registry"
34+
description="Browse the community catalog — install with guided setup, OAuth, and credential fields."
35+
onSelect={onDiscover}
36+
data-testid="add-server-option-discover"
37+
/>
38+
<DropdownMenuItem
39+
icon={FileJson}
40+
label="Add custom server"
41+
description="Edit your Space JSON config for local CLIs, private servers, or anything not in the registry."
42+
onSelect={onCustom}
43+
data-testid="add-server-option-custom"
44+
/>
45+
</DropdownMenuContent>
46+
</DropdownMenu>
47+
);
48+
}
Lines changed: 66 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
1-
/**
2-
* ServerActionMenu - Overflow menu for server actions
3-
*
4-
* Actions:
5-
* - Configure: Edit server inputs
6-
* - Refresh: Quick reconnect with existing credentials
7-
* - Reconnect: Logout + re-authenticate (OAuth only)
8-
* - View Logs: Open log viewer
9-
* - View Definition: View server definition JSON
10-
* - Uninstall: Remove server
11-
*/
12-
13-
import { useState, useRef, useEffect } from 'react';
141
import { MoreVertical, Settings, RefreshCw, RotateCcw, FileText, Code, Trash2, Copy } from 'lucide-react';
2+
import {
3+
DropdownMenu,
4+
DropdownMenuAction,
5+
DropdownMenuContent,
6+
DropdownMenuSeparator,
7+
DropdownMenuTrigger,
8+
} from '@mcpmux/ui';
159

1610
export interface ServerActionMenuProps {
1711
serverId: string;
@@ -31,6 +25,9 @@ export interface ServerActionMenuProps {
3125
onUninstall: () => void;
3226
}
3327

28+
/**
29+
* Overflow menu for per-server actions (configure, logs, uninstall, etc.).
30+
*/
3431
export function ServerActionMenu({
3532
serverId,
3633
serverName: _serverName,
@@ -47,155 +44,63 @@ export function ServerActionMenu({
4744
onCloneAccount,
4845
onUninstall,
4946
}: ServerActionMenuProps) {
50-
const [isOpen, setIsOpen] = useState(false);
51-
const menuRef = useRef<HTMLDivElement>(null);
52-
const buttonRef = useRef<HTMLButtonElement>(null);
53-
54-
// Close menu when clicking outside
55-
useEffect(() => {
56-
function handleClickOutside(event: MouseEvent) {
57-
if (
58-
menuRef.current &&
59-
!menuRef.current.contains(event.target as Node) &&
60-
buttonRef.current &&
61-
!buttonRef.current.contains(event.target as Node)
62-
) {
63-
setIsOpen(false);
64-
}
65-
}
66-
67-
if (isOpen) {
68-
document.addEventListener('mousedown', handleClickOutside);
69-
return () => document.removeEventListener('mousedown', handleClickOutside);
70-
}
71-
}, [isOpen]);
72-
73-
// Close menu on escape
74-
useEffect(() => {
75-
function handleEscape(event: KeyboardEvent) {
76-
if (event.key === 'Escape') {
77-
setIsOpen(false);
78-
}
79-
}
80-
81-
if (isOpen) {
82-
document.addEventListener('keydown', handleEscape);
83-
return () => document.removeEventListener('keydown', handleEscape);
84-
}
85-
}, [isOpen]);
86-
87-
const handleAction = (action: () => void) => {
88-
setIsOpen(false);
89-
action();
90-
};
91-
9247
return (
93-
<div className="relative">
94-
<button
95-
ref={buttonRef}
96-
onClick={() => setIsOpen(!isOpen)}
97-
className="p-2 text-sm rounded-lg bg-[rgb(var(--surface-hover))] border border-[rgb(var(--border))] text-[rgb(var(--foreground))]/70 hover:bg-[rgb(var(--surface-elevated))] hover:text-[rgb(var(--foreground))] transition-colors"
98-
title="More actions"
99-
aria-label="More actions"
100-
aria-expanded={isOpen}
101-
aria-haspopup="menu"
102-
data-testid={`action-menu-${serverId}`}
103-
>
104-
<MoreVertical className="h-4 w-4" />
105-
</button>
106-
107-
{isOpen && (
108-
<div
109-
ref={menuRef}
110-
className="absolute right-0 mt-1 w-48 py-1 bg-[rgb(var(--surface-elevated))] border border-[rgb(var(--border))] rounded-lg shadow-lg z-50 animate-in fade-in slide-in-from-top-1 duration-150"
111-
role="menu"
48+
<DropdownMenu>
49+
<DropdownMenuTrigger>
50+
<button
51+
type="button"
52+
className="p-2 text-sm rounded-lg bg-[rgb(var(--surface-hover))] border border-[rgb(var(--border))] text-[rgb(var(--foreground))]/70 hover:bg-[rgb(var(--surface-elevated))] hover:text-[rgb(var(--foreground))] transition-colors"
53+
title="More actions"
54+
aria-label="More actions"
55+
data-testid={`action-menu-${serverId}`}
11256
>
113-
{/* Configure - visible if server has inputs */}
114-
{hasInputs && (
115-
<button
116-
onClick={() => handleAction(onConfigure)}
117-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
118-
role="menuitem"
119-
>
120-
<Settings className="h-4 w-4 text-[rgb(var(--muted))]" />
121-
Configure
122-
</button>
123-
)}
124-
125-
{/* Refresh - visible when enabled (quick reconnect with existing creds) */}
126-
{isEnabled && (
127-
<button
128-
onClick={() => handleAction(onRefresh)}
129-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
130-
role="menuitem"
131-
>
132-
<RefreshCw className="h-4 w-4 text-[rgb(var(--muted))]" />
133-
Refresh
134-
</button>
135-
)}
136-
137-
{/* Reconnect - OAuth only (logout + re-auth) */}
138-
{isOAuth && isEnabled && (
139-
<button
140-
onClick={() => handleAction(onReconnect)}
141-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--warning))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
142-
role="menuitem"
143-
>
144-
<RotateCcw className="h-4 w-4" />
145-
Reconnect
146-
</button>
147-
)}
148-
149-
{/* View Logs - always visible */}
150-
<button
151-
onClick={() => handleAction(onViewLogs)}
152-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
153-
role="menuitem"
154-
data-testid={`view-logs-${serverId}`}
155-
>
156-
<FileText className="h-4 w-4 text-[rgb(var(--muted))]" />
157-
View Logs
158-
</button>
159-
160-
{/* View Definition - always visible */}
161-
<button
162-
onClick={() => handleAction(onViewDefinition)}
163-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
164-
role="menuitem"
165-
data-testid={`view-definition-${serverId}`}
166-
>
167-
<Code className="h-4 w-4 text-[rgb(var(--muted))]" />
168-
View Definition
169-
</button>
170-
171-
{/* Add another account - registry/manual installs only, not clones-of-clones */}
172-
{canCloneAccount && onCloneAccount && (
173-
<button
174-
onClick={() => handleAction(onCloneAccount)}
175-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
176-
role="menuitem"
177-
data-testid={`clone-account-${serverId}`}
178-
>
179-
<Copy className="h-4 w-4 text-[rgb(var(--muted))]" />
180-
Add another account…
181-
</button>
182-
)}
183-
184-
{/* Separator */}
185-
<div className="my-1 border-t border-[rgb(var(--border-subtle))]" />
186-
187-
{/* Uninstall - always visible, destructive */}
188-
<button
189-
onClick={() => handleAction(onUninstall)}
190-
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--error))] hover:bg-[rgb(var(--error))]/10 transition-colors"
191-
role="menuitem"
192-
data-testid={`uninstall-menu-${serverId}`}
193-
>
194-
<Trash2 className="h-4 w-4" />
195-
Uninstall
196-
</button>
197-
</div>
198-
)}
199-
</div>
57+
<MoreVertical className="h-4 w-4" />
58+
</button>
59+
</DropdownMenuTrigger>
60+
<DropdownMenuContent align="end" className="w-48 py-1 p-1">
61+
{hasInputs && (
62+
<DropdownMenuAction icon={Settings} label="Configure" onSelect={onConfigure} />
63+
)}
64+
{isEnabled && (
65+
<DropdownMenuAction icon={RefreshCw} label="Refresh" onSelect={onRefresh} />
66+
)}
67+
{isOAuth && isEnabled && (
68+
<DropdownMenuAction
69+
icon={RotateCcw}
70+
label="Reconnect"
71+
onSelect={onReconnect}
72+
variant="warning"
73+
/>
74+
)}
75+
<DropdownMenuAction
76+
icon={FileText}
77+
label="View Logs"
78+
onSelect={onViewLogs}
79+
data-testid={`view-logs-${serverId}`}
80+
/>
81+
<DropdownMenuAction
82+
icon={Code}
83+
label="View Definition"
84+
onSelect={onViewDefinition}
85+
data-testid={`view-definition-${serverId}`}
86+
/>
87+
{canCloneAccount && onCloneAccount && (
88+
<DropdownMenuAction
89+
icon={Copy}
90+
label="Add another account…"
91+
onSelect={onCloneAccount}
92+
data-testid={`clone-account-${serverId}`}
93+
/>
94+
)}
95+
<DropdownMenuSeparator />
96+
<DropdownMenuAction
97+
icon={Trash2}
98+
label="Uninstall"
99+
onSelect={onUninstall}
100+
variant="danger"
101+
data-testid={`uninstall-menu-${serverId}`}
102+
/>
103+
</DropdownMenuContent>
104+
</DropdownMenu>
200105
);
201106
}

0 commit comments

Comments
 (0)