Skip to content

Commit c0aa850

Browse files
committed
feat(servers): Phase 2 — Shared helpers + panel shell + JSON mode
Autonomous decisions: - Wired Add custom server to CustomServerPanel ahead of Phase 4 — mechanical draft wiring per plan scope - Default panel mode is JSON — Form is stub-only this phase; JSON is the working save path Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent febafda commit c0aa850

4 files changed

Lines changed: 523 additions & 3 deletions

File tree

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { X, Plus, Loader2, Check, AlertTriangle } from 'lucide-react';
4+
import { type Monaco } from '@monaco-editor/react';
5+
import type { editor } from 'monaco-editor';
6+
import { Button, useToast } from '@mcpmux/ui';
7+
import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces';
8+
import { MonacoJsonEditor } from '@/components/monaco-json-editor.component';
9+
import {
10+
createDefaultStdioEntry,
11+
nextCustomServerKey,
12+
SINGLE_SERVER_ENTRY_SCHEMA,
13+
upsertServerEntry,
14+
type SpaceConfigJson,
15+
} from './custom-server-entry.helpers';
16+
17+
const EDITOR_MOUNT_TIMEOUT_MS = 10_000;
18+
19+
type PanelMode = 'form' | 'json';
20+
21+
interface CustomServerPanelProps {
22+
spaceId: string;
23+
spaceName: string;
24+
onClose: () => void;
25+
onSaved: () => void;
26+
}
27+
28+
/**
29+
* Segmented Form / JSON toggle matching WorkspacesPage filter styling.
30+
*/
31+
function ModeToggle({
32+
mode,
33+
onChange,
34+
formLabel,
35+
jsonLabel,
36+
}: {
37+
mode: PanelMode;
38+
onChange: (mode: PanelMode) => void;
39+
formLabel: string;
40+
jsonLabel: string;
41+
}) {
42+
const options: Array<{ value: PanelMode; label: string }> = [
43+
{ value: 'form', label: formLabel },
44+
{ value: 'json', label: jsonLabel },
45+
];
46+
47+
return (
48+
<div
49+
className="inline-flex rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] p-0.5 gap-0.5"
50+
data-testid="custom-server-panel-mode-toggle"
51+
>
52+
{options.map((o) => {
53+
const active = o.value === mode;
54+
return (
55+
<button
56+
key={o.value}
57+
type="button"
58+
onClick={() => onChange(o.value)}
59+
aria-pressed={active}
60+
data-testid={`custom-server-panel-mode-${o.value}`}
61+
className={[
62+
'inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg transition-all',
63+
active
64+
? 'bg-[rgb(var(--background))] text-[rgb(var(--foreground))] shadow-sm'
65+
: 'text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))]',
66+
].join(' ')}
67+
>
68+
{o.label}
69+
</button>
70+
);
71+
})}
72+
</div>
73+
);
74+
}
75+
76+
/**
77+
* Slide-in panel for adding a custom server via JSON (Form mode stub until Phase 3).
78+
*/
79+
export function CustomServerPanel({ spaceId, spaceName, onClose, onSaved }: CustomServerPanelProps) {
80+
const { t } = useTranslation('servers');
81+
const { success, error: showError } = useToast();
82+
83+
const [mode, setMode] = useState<PanelMode>('json');
84+
const [serverKey, setServerKey] = useState('');
85+
const [jsonContent, setJsonContent] = useState('');
86+
const [isLoading, setIsLoading] = useState(true);
87+
const [isSaving, setIsSaving] = useState(false);
88+
const [loadError, setLoadError] = useState<string | null>(null);
89+
const [saveError, setSaveError] = useState<string | null>(null);
90+
const [isValidJson, setIsValidJson] = useState(true);
91+
const [validationErrors, setValidationErrors] = useState<string[]>([]);
92+
const [editorReady, setEditorReady] = useState(false);
93+
const [editorMounted, setEditorMounted] = useState(false);
94+
const [editorLoadFailed, setEditorLoadFailed] = useState(false);
95+
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
96+
97+
useEffect(() => {
98+
const timer = setTimeout(() => setEditorReady(true), 100);
99+
return () => clearTimeout(timer);
100+
}, []);
101+
102+
/**
103+
* Load space config and seed default server key + stdio template.
104+
*/
105+
const initializeFromConfig = useCallback(async () => {
106+
try {
107+
setIsLoading(true);
108+
setLoadError(null);
109+
const raw = await readSpaceConfig(spaceId);
110+
const parsed = JSON.parse(raw) as SpaceConfigJson;
111+
const servers = parsed.mcpServers ?? {};
112+
const key = nextCustomServerKey(servers);
113+
setServerKey(key);
114+
setJsonContent(JSON.stringify(createDefaultStdioEntry(key), null, 2));
115+
setIsValidJson(true);
116+
setValidationErrors([]);
117+
} catch (e) {
118+
setLoadError(e instanceof Error ? e.message : String(e));
119+
} finally {
120+
setIsLoading(false);
121+
setEditorMounted(false);
122+
setEditorLoadFailed(false);
123+
}
124+
}, [spaceId]);
125+
126+
useEffect(() => {
127+
void initializeFromConfig();
128+
}, [initializeFromConfig]);
129+
130+
useEffect(() => {
131+
if (isLoading || !editorReady || editorMounted || editorLoadFailed) {
132+
return;
133+
}
134+
const timer = setTimeout(() => setEditorLoadFailed(true), EDITOR_MOUNT_TIMEOUT_MS);
135+
return () => clearTimeout(timer);
136+
}, [isLoading, editorReady, editorMounted, editorLoadFailed]);
137+
138+
useEffect(() => {
139+
const onKey = (e: KeyboardEvent) => {
140+
if (e.key === 'Escape') {
141+
onClose();
142+
}
143+
};
144+
window.addEventListener('keydown', onKey);
145+
return () => window.removeEventListener('keydown', onKey);
146+
}, [onClose]);
147+
148+
/**
149+
* Register single-entry JSON schema with Monaco before the editor mounts.
150+
*/
151+
const handleEditorBeforeMount = (monaco: Monaco) => {
152+
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
153+
validate: true,
154+
schemas: [
155+
{
156+
uri: 'mcpmux://schemas/single-server-entry.json',
157+
fileMatch: ['*'],
158+
schema: SINGLE_SERVER_ENTRY_SCHEMA,
159+
},
160+
],
161+
enableSchemaRequest: false,
162+
allowComments: false,
163+
trailingCommas: 'error',
164+
});
165+
};
166+
167+
/** Sync Monaco validation markers into panel save state. */
168+
const handleEditorValidation = (markers: editor.IMarker[]) => {
169+
const errors = markers.map((m) =>
170+
t('customServerPanel.validation.line', { line: m.startLineNumber, message: m.message }),
171+
);
172+
setValidationErrors(errors);
173+
setIsValidJson(markers.length === 0);
174+
};
175+
176+
/** Persist the edited entry into the space config file. */
177+
const handleSave = useCallback(async () => {
178+
const trimmedKey = serverKey.trim();
179+
if (!trimmedKey) {
180+
showError(
181+
t('customServerPanel.toast.saveFailed'),
182+
t('customServerPanel.toast.serverIdRequired'),
183+
);
184+
return;
185+
}
186+
187+
let entry: Record<string, unknown>;
188+
try {
189+
entry = JSON.parse(jsonContent) as Record<string, unknown>;
190+
} catch (e) {
191+
const message = (e as Error).message;
192+
setSaveError(t('customServerPanel.validation.invalidJson', { message }));
193+
showError(t('customServerPanel.toast.invalidJsonTitle'), message);
194+
return;
195+
}
196+
197+
if (!isValidJson) {
198+
return;
199+
}
200+
201+
setIsSaving(true);
202+
setSaveError(null);
203+
try {
204+
const raw = await readSpaceConfig(spaceId);
205+
const parsed = JSON.parse(raw) as SpaceConfigJson;
206+
const updated = upsertServerEntry(parsed, trimmedKey, entry);
207+
await saveSpaceConfig(spaceId, JSON.stringify(updated, null, 2));
208+
success(t('customServerPanel.toast.saved'), t('customServerPanel.toast.savedBody'));
209+
onSaved();
210+
onClose();
211+
} catch (e) {
212+
const message = e instanceof Error ? e.message : String(e);
213+
setSaveError(message);
214+
showError(t('customServerPanel.toast.saveFailed'), message);
215+
} finally {
216+
setIsSaving(false);
217+
}
218+
}, [
219+
serverKey,
220+
jsonContent,
221+
isValidJson,
222+
spaceId,
223+
success,
224+
showError,
225+
onSaved,
226+
onClose,
227+
t,
228+
]);
229+
230+
const panelWidthClass =
231+
mode === 'json'
232+
? 'w-full max-w-[720px] min-w-[600px]'
233+
: 'w-full max-w-[480px] min-w-[420px]';
234+
235+
return (
236+
<>
237+
<div
238+
className="fixed inset-0 bg-black/20 backdrop-blur-[2px] z-[55] animate-in fade-in duration-200"
239+
onClick={onClose}
240+
data-testid="custom-server-panel-backdrop"
241+
/>
242+
<div
243+
className={`fixed right-0 top-0 bottom-0 bg-[rgb(var(--surface))] border-l border-[rgb(var(--border))] shadow-2xl flex flex-col animate-in slide-in-from-right duration-300 z-[60] ${panelWidthClass}`}
244+
data-testid="custom-server-panel"
245+
>
246+
<div className="flex-shrink-0 p-4 border-b border-[rgb(var(--border))] bg-[rgb(var(--surface-elevated))]">
247+
<div className="flex items-start justify-between gap-2">
248+
<div className="flex items-start gap-3 flex-1 min-w-0">
249+
<div className="w-11 h-11 flex-shrink-0 flex items-center justify-center bg-[rgb(var(--background))] rounded-lg border border-[rgb(var(--border-subtle))]">
250+
<Plus className="h-5 w-5 text-[rgb(var(--primary))]" />
251+
</div>
252+
<div className="min-w-0">
253+
<h2 className="text-lg font-bold text-[rgb(var(--foreground))]">
254+
{t('customServerPanel.title')}
255+
</h2>
256+
<p className="text-xs text-[rgb(var(--muted))] mt-0.5">
257+
{t('customServerPanel.subtitle', { spaceName })}
258+
</p>
259+
</div>
260+
</div>
261+
<button
262+
type="button"
263+
onClick={onClose}
264+
className="p-1.5 rounded-lg hover:bg-[rgb(var(--surface-hover))] transition-colors flex-shrink-0"
265+
aria-label={t('customServerPanel.closeAria')}
266+
>
267+
<X className="h-5 w-5" />
268+
</button>
269+
</div>
270+
<div className="mt-4">
271+
<ModeToggle
272+
mode={mode}
273+
onChange={setMode}
274+
formLabel={t('customServerPanel.modeForm')}
275+
jsonLabel={t('customServerPanel.modeJson')}
276+
/>
277+
</div>
278+
</div>
279+
280+
<div className="flex-1 overflow-y-auto p-6 space-y-4">
281+
{isLoading ? (
282+
<div className="flex items-center justify-center py-12">
283+
<Loader2 className="h-8 w-8 animate-spin text-primary-500" />
284+
</div>
285+
) : loadError ? (
286+
<p className="text-sm text-[rgb(var(--error))]">{loadError}</p>
287+
) : mode === 'form' ? (
288+
<p className="text-sm text-[rgb(var(--muted))]">{t('customServerPanel.formComingSoon')}</p>
289+
) : (
290+
<>
291+
<div>
292+
<label
293+
htmlFor="custom-server-id"
294+
className="block text-sm font-medium text-[rgb(var(--foreground))] mb-1"
295+
>
296+
{t('customServerPanel.serverId')}
297+
</label>
298+
<p className="text-xs text-[rgb(var(--muted))] mb-2">
299+
{t('customServerPanel.serverIdDesc')}
300+
</p>
301+
<input
302+
id="custom-server-id"
303+
type="text"
304+
value={serverKey}
305+
onChange={(e) => setServerKey(e.target.value)}
306+
placeholder={t('customServerPanel.serverIdPlaceholder')}
307+
className="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
308+
data-testid="custom-server-panel-server-id"
309+
/>
310+
</div>
311+
<div className="flex flex-col min-h-[280px] rounded-lg border border-[rgb(var(--border))] overflow-hidden bg-[#1e1e1e]">
312+
{!editorReady ? (
313+
<div className="flex flex-1 items-center justify-center py-12">
314+
<Loader2 className="h-8 w-8 animate-spin text-[rgb(var(--muted))]" />
315+
</div>
316+
) : editorLoadFailed ? (
317+
<textarea
318+
value={jsonContent}
319+
onChange={(e) => setJsonContent(e.target.value)}
320+
className="flex-1 min-h-[280px] w-full resize-none bg-[#1e1e1e] p-3 font-mono text-sm text-[#d4d4d4] focus:outline-none"
321+
spellCheck={false}
322+
/>
323+
) : (
324+
<MonacoJsonEditor
325+
value={jsonContent}
326+
onChange={(v) => v !== undefined && setJsonContent(v)}
327+
beforeMount={handleEditorBeforeMount}
328+
onMount={(mounted) => {
329+
editorRef.current = mounted;
330+
setEditorMounted(true);
331+
}}
332+
onMountFailed={() => setEditorLoadFailed(true)}
333+
onValidate={handleEditorValidation}
334+
testId="custom-server-panel-monaco"
335+
/>
336+
)}
337+
</div>
338+
{!isValidJson && (
339+
<span className="flex items-center gap-1.5 text-xs font-medium text-[rgb(var(--error))]">
340+
<AlertTriangle className="h-3 w-3" />
341+
{validationErrors.length > 0
342+
? t('customServerPanel.schemaError')
343+
: t('customServerPanel.invalidJson')}
344+
</span>
345+
)}
346+
</>
347+
)}
348+
</div>
349+
350+
<div className="flex-shrink-0 p-4 border-t border-[rgb(var(--border))] bg-[rgb(var(--surface-elevated))]">
351+
{saveError && (
352+
<p className="text-xs text-[rgb(var(--error))] mb-2">{saveError}</p>
353+
)}
354+
<div className="flex items-center gap-2">
355+
<Button
356+
variant="primary"
357+
size="md"
358+
onClick={() => void handleSave()}
359+
disabled={isSaving || isLoading || !!loadError || (mode === 'json' && !isValidJson)}
360+
className="flex-1"
361+
data-testid="custom-server-panel-save"
362+
>
363+
{isSaving ? (
364+
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
365+
) : (
366+
<Check className="h-4 w-4 mr-1.5" />
367+
)}
368+
{isSaving ? t('customServerPanel.saving') : t('customServerPanel.save')}
369+
</Button>
370+
<Button variant="secondary" size="md" onClick={onClose} disabled={isSaving}>
371+
{t('customServerPanel.cancel')}
372+
</Button>
373+
</div>
374+
</div>
375+
</div>
376+
</>
377+
);
378+
}

0 commit comments

Comments
 (0)