Skip to content

Commit 26318b1

Browse files
committed
fix(desktop): restore emoji picker and Monaco modals in Tauri prod
Expand prod CSP for emoji CDN fetch and Monaco worker/eval/style needs. Add shared MonacoJsonEditor with measured height and textarea fallbacks. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent e38a390 commit 26318b1

9 files changed

Lines changed: 233 additions & 72 deletions

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
}
4545
],
4646
"security": {
47-
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost https: data: blob:",
47+
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https: data: blob:",
4848
"devCsp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost http://localhost:* ws://localhost:* ws://127.0.0.1:*; img-src 'self' asset: http://asset.localhost https: http: data: blob:",
4949
"assetProtocol": {
5050
"enable": true,

apps/desktop/src/components/ConfigEditorModal.tsx

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { useTranslation } from 'react-i18next';
33
import { X, Save, Loader2, AlertTriangle, Wand2, Plus } from 'lucide-react';
44
import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces';
55
import { refreshRegistry } from '@/lib/api/registry';
6-
import Editor, { type Monaco } from '@monaco-editor/react';
6+
import { type Monaco } from '@monaco-editor/react';
77
import type { editor } from 'monaco-editor';
88
import { useToast, ToastContainer } from '@mcpmux/ui';
99
import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json';
1010
import { RequestServerCTA } from './Contribute';
11+
import { MonacoJsonEditor } from './monaco-json-editor.component';
1112

1213
const EDITOR_MOUNT_TIMEOUT_MS = 10_000;
1314

@@ -75,7 +76,6 @@ export function ConfigEditorModal({
7576
const [editorMounted, setEditorMounted] = useState(false);
7677
const [editorLoadFailed, setEditorLoadFailed] = useState(false);
7778
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
78-
const monacoRef = useRef<Monaco | null>(null);
7979
const { toasts, success, error: showError } = useToast();
8080

8181
// Delay editor mount to avoid glitch during modal open
@@ -211,13 +211,19 @@ export function ConfigEditorModal({
211211
};
212212

213213
/**
214-
* Mount handler — marks Monaco ready and focuses the editor.
214+
* Mount handler — marks Monaco ready for timeout/fallback logic.
215215
*/
216-
const handleEditorMount = (mountedEditor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
216+
const handleEditorMount = (mountedEditor: editor.IStandaloneCodeEditor) => {
217217
editorRef.current = mountedEditor;
218-
monacoRef.current = monaco;
219218
setEditorMounted(true);
220-
mountedEditor.focus();
219+
};
220+
221+
/**
222+
* Signal mount failure when the editor container has no measurable height.
223+
*/
224+
const handleEditorMountFailed = () => {
225+
setEditorLoadFailed(true);
226+
setError(t('configEditorModal.editorLoadFailed'));
221227
};
222228

223229
const handleEditorValidation = (markers: editor.IMarker[]) => {
@@ -388,39 +394,14 @@ export function ConfigEditorModal({
388394
spellCheck={false}
389395
/>
390396
) : (
391-
<Editor
392-
height="100%"
393-
defaultLanguage="json"
397+
<MonacoJsonEditor
394398
value={content}
395-
theme="vs-dark"
396399
onChange={handleContentChange}
397400
beforeMount={handleEditorBeforeMount}
398401
onMount={handleEditorMount}
402+
onMountFailed={handleEditorMountFailed}
399403
onValidate={handleEditorValidation}
400-
options={{
401-
minimap: { enabled: false },
402-
fontSize: 14,
403-
fontFamily: "'Fira Code', 'Consolas', monospace",
404-
lineNumbers: 'on',
405-
scrollBeyondLastLine: false,
406-
automaticLayout: true,
407-
tabSize: 2,
408-
wordWrap: 'on',
409-
formatOnPaste: true,
410-
formatOnType: true,
411-
folding: true,
412-
bracketPairColorization: { enabled: true },
413-
guides: {
414-
bracketPairs: true,
415-
indentation: true,
416-
},
417-
padding: { top: 12, bottom: 12 },
418-
}}
419-
loading={
420-
<div className="flex h-full items-center justify-center bg-[#1e1e1e]">
421-
<Loader2 className="h-8 w-8 animate-spin text-[rgb(var(--muted))]" />
422-
</div>
423-
}
404+
testId="config-editor-monaco"
424405
/>
425406
)}
426407
</div>

apps/desktop/src/components/ServerDefinitionModal.tsx

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useState, useEffect, useCallback } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { X, Copy, Check, Loader2 } from 'lucide-react';
4-
import Editor from '@monaco-editor/react';
54
import type { ServerViewModel, ServerDefinition } from '../types/registry';
5+
import { MonacoJsonEditor } from './monaco-json-editor.component';
6+
7+
const EDITOR_MOUNT_TIMEOUT_MS = 10_000;
68

79
interface ServerDefinitionModalProps {
810
server: ServerViewModel;
@@ -38,6 +40,8 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
3840
const { t } = useTranslation('servers');
3941
const [copied, setCopied] = useState(false);
4042
const [editorReady, setEditorReady] = useState(false);
43+
const [editorMounted, setEditorMounted] = useState(false);
44+
const [editorLoadFailed, setEditorLoadFailed] = useState(false);
4145

4246
const definition = extractDefinition(server);
4347
const json = JSON.stringify(definition, null, 2);
@@ -47,6 +51,18 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
4751
return () => clearTimeout(timer);
4852
}, []);
4953

54+
useEffect(() => {
55+
if (!editorReady || editorMounted || editorLoadFailed) {
56+
return;
57+
}
58+
59+
const timer = setTimeout(() => {
60+
setEditorLoadFailed(true);
61+
}, EDITOR_MOUNT_TIMEOUT_MS);
62+
63+
return () => clearTimeout(timer);
64+
}, [editorReady, editorMounted, editorLoadFailed]);
65+
5066
useEffect(() => {
5167
const handleKeyDown = (e: KeyboardEvent) => {
5268
if (e.key === 'Escape') {
@@ -67,6 +83,20 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
6783
}
6884
}, [json]);
6985

86+
/**
87+
* Mark Monaco mounted so the mount-timeout fallback does not fire.
88+
*/
89+
const handleEditorMount = () => {
90+
setEditorMounted(true);
91+
};
92+
93+
/**
94+
* Fall back to plain JSON when the editor container has no measurable height.
95+
*/
96+
const handleEditorMountFailed = () => {
97+
setEditorLoadFailed(true);
98+
};
99+
70100
return (
71101
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
72102
<div className="bg-[rgb(var(--surface))] w-full max-w-3xl h-[70vh] rounded-xl shadow-2xl flex flex-col border border-[rgb(var(--border))] animate-in fade-in scale-in duration-150">
@@ -113,39 +143,30 @@ export function ServerDefinitionModal({ server, onClose }: ServerDefinitionModal
113143
<div className="absolute inset-0 flex items-center justify-center">
114144
<Loader2 className="h-8 w-8 animate-spin text-[rgb(var(--muted))]" />
115145
</div>
146+
) : editorLoadFailed ? (
147+
<textarea
148+
readOnly
149+
value={json}
150+
className="h-full w-full resize-none bg-[#1e1e1e] p-3 font-mono text-sm text-[#d4d4d4] focus:outline-none"
151+
spellCheck={false}
152+
aria-label={t('definitionModal.subtitle')}
153+
/>
116154
) : (
117-
<Editor
118-
height="100%"
119-
defaultLanguage="json"
155+
<MonacoJsonEditor
120156
value={json}
121-
theme="vs-dark"
122-
options={{
123-
readOnly: true,
124-
minimap: { enabled: false },
125-
fontSize: 14,
126-
fontFamily: "'Fira Code', 'Consolas', monospace",
127-
lineNumbers: 'on',
128-
scrollBeyondLastLine: false,
129-
automaticLayout: true,
130-
tabSize: 2,
131-
wordWrap: 'on',
132-
folding: true,
133-
bracketPairColorization: { enabled: true },
134-
guides: {
135-
bracketPairs: true,
136-
indentation: true,
137-
},
138-
padding: { top: 12, bottom: 12 },
139-
domReadOnly: true,
140-
}}
141-
loading={
142-
<div className="flex items-center justify-center h-full bg-[#1e1e1e]">
143-
<Loader2 className="h-8 w-8 animate-spin text-[rgb(var(--muted))]" />
144-
</div>
145-
}
157+
readOnly
158+
onMount={handleEditorMount}
159+
onMountFailed={handleEditorMountFailed}
160+
testId="server-definition-monaco"
146161
/>
147162
)}
148163
</div>
164+
165+
{editorLoadFailed && (
166+
<div className="border-t border-[rgb(var(--border))] bg-[rgb(var(--surface-dim))] px-4 py-2 text-xs text-[rgb(var(--muted))]">
167+
{t('definitionModal.editorLoadFailed')}
168+
</div>
169+
)}
149170
</div>
150171
</div>
151172
);

apps/desktop/src/components/emoji-picker-button.component.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
/**
22
* Button that opens an emoji picker popover.
3-
* Uses emoji-picker-element (web component) — no external data fetch required.
3+
* Uses emoji-picker-element (web component), which fetches emoji metadata from
4+
* cdn.jsdelivr.net unless a custom data-source is set (allowed in prod via CSP).
45
* The popover renders in a portal with fixed positioning so it is never clipped
56
* by a scrolling drawer/panel and always stays inside the viewport.
67
*/
78

9+
const EMOJI_DATA_SOURCE =
10+
'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
11+
812
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
913
import { createPortal } from 'react-dom';
1014
import { Smile } from 'lucide-react';
@@ -121,7 +125,7 @@ export function EmojiPickerButton({ value, onChange, disabled, testId }: Props)
121125
>
122126
{/* ponytail: emoji-picker-element renders its own shadow DOM; sizing via style only */}
123127
{/* @ts-expect-error -- emoji-picker is a custom web component not in React's intrinsic elements */}
124-
<emoji-picker style={{ width: '100%' }} />
128+
<emoji-picker data-source={EMOJI_DATA_SOURCE} style={{ width: '100%' }} />
125129
</div>,
126130
document.body,
127131
)}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Monaco JSON editor with measured height for flex/Tauri WebView layouts.
3+
* Avoids height="100%" collapse and re-layouts on container resize.
4+
*/
5+
6+
import { useCallback, useEffect, useRef, useState } from 'react';
7+
import Editor, { type Monaco } from '@monaco-editor/react';
8+
import { Loader2 } from 'lucide-react';
9+
import type { editor } from 'monaco-editor';
10+
11+
const MIN_EDITOR_HEIGHT = 200;
12+
13+
const BASE_JSON_OPTIONS: editor.IStandaloneEditorConstructionOptions = {
14+
minimap: { enabled: false },
15+
fontSize: 14,
16+
fontFamily: "'Fira Code', 'Consolas', monospace",
17+
lineNumbers: 'on',
18+
scrollBeyondLastLine: false,
19+
automaticLayout: false,
20+
tabSize: 2,
21+
wordWrap: 'on',
22+
folding: true,
23+
bracketPairColorization: { enabled: true },
24+
guides: {
25+
bracketPairs: true,
26+
indentation: true,
27+
},
28+
padding: { top: 12, bottom: 12 },
29+
};
30+
31+
interface MonacoJsonEditorProps {
32+
value: string;
33+
onChange?: (value: string | undefined) => void;
34+
readOnly?: boolean;
35+
beforeMount?: (monaco: Monaco) => void;
36+
onValidate?: (markers: editor.IMarker[]) => void;
37+
onMount?: (mountedEditor: editor.IStandaloneCodeEditor, monaco: Monaco) => void;
38+
onMountFailed?: () => void;
39+
testId?: string;
40+
extraOptions?: editor.IStandaloneEditorConstructionOptions;
41+
}
42+
43+
/**
44+
* JSON editor backed by Monaco with ResizeObserver-driven layout.
45+
*/
46+
export function MonacoJsonEditor({
47+
value,
48+
onChange,
49+
readOnly = false,
50+
beforeMount,
51+
onValidate,
52+
onMount,
53+
onMountFailed,
54+
testId,
55+
extraOptions,
56+
}: MonacoJsonEditorProps) {
57+
const containerRef = useRef<HTMLDivElement>(null);
58+
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
59+
const [height, setHeight] = useState(MIN_EDITOR_HEIGHT);
60+
61+
const layoutEditor = useCallback(() => {
62+
const container = containerRef.current;
63+
const mountedEditor = editorRef.current;
64+
if (!container || !mountedEditor) {
65+
return;
66+
}
67+
68+
const nextHeight = Math.max(container.clientHeight, MIN_EDITOR_HEIGHT);
69+
setHeight(nextHeight);
70+
mountedEditor.layout({ width: container.clientWidth, height: nextHeight });
71+
}, []);
72+
73+
useEffect(() => {
74+
const container = containerRef.current;
75+
if (!container) {
76+
return;
77+
}
78+
79+
const observer = new ResizeObserver(() => {
80+
layoutEditor();
81+
});
82+
observer.observe(container);
83+
84+
const initialHeight = Math.max(container.clientHeight, MIN_EDITOR_HEIGHT);
85+
setHeight(initialHeight);
86+
87+
return () => observer.disconnect();
88+
}, [layoutEditor]);
89+
90+
/**
91+
* Mount handler — stores editor ref, lays out, and notifies parent.
92+
*/
93+
const handleMount = (mountedEditor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
94+
editorRef.current = mountedEditor;
95+
96+
const container = containerRef.current;
97+
if (!container || container.clientHeight === 0) {
98+
onMountFailed?.();
99+
return;
100+
}
101+
102+
layoutEditor();
103+
onMount?.(mountedEditor, monaco);
104+
105+
if (!readOnly) {
106+
mountedEditor.focus();
107+
}
108+
};
109+
110+
const options: editor.IStandaloneEditorConstructionOptions = {
111+
...BASE_JSON_OPTIONS,
112+
...extraOptions,
113+
readOnly,
114+
domReadOnly: readOnly,
115+
formatOnPaste: readOnly ? undefined : true,
116+
formatOnType: readOnly ? undefined : true,
117+
};
118+
119+
return (
120+
<div ref={containerRef} className="h-full w-full" data-testid={testId}>
121+
<Editor
122+
height={height}
123+
defaultLanguage="json"
124+
value={value}
125+
theme="vs-dark"
126+
onChange={onChange}
127+
beforeMount={beforeMount}
128+
onMount={handleMount}
129+
onValidate={onValidate}
130+
options={options}
131+
loading={
132+
<div className="flex h-full items-center justify-center bg-[#1e1e1e]">
133+
<Loader2 className="h-8 w-8 animate-spin text-[rgb(var(--muted))]" />
134+
</div>
135+
}
136+
/>
137+
</div>
138+
);
139+
}

apps/desktop/src/locales/en/servers.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@
263263
"subtitle": "Server Definition",
264264
"copy": "Copy",
265265
"copied": "Copied",
266-
"copyTitle": "Copy to clipboard"
266+
"copyTitle": "Copy to clipboard",
267+
"editorLoadFailed": "Editor failed to load. Showing plain JSON instead."
267268
}
268269
}

0 commit comments

Comments
 (0)