Skip to content

Commit b112ee5

Browse files
authored
Add Cursor Support (#73)
* Make UX more consistent with DD * Refactor MCP client states * Fix cursor path * Implement new MCP client and catalog UI * Remove unsupported sort options for now * Fix JSON payload escaping for windows * Improve notifications for client connection changes * Implement sort
1 parent 4863ca7 commit b112ee5

24 files changed

+547
-428
lines changed

src/extension/ui/src/App.tsx

Lines changed: 38 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
11
import React, { useEffect, useState, Suspense } from 'react';
22
import { createDockerDesktopClient } from '@docker/extension-api-client';
3-
import { Stack, Typography, Button, IconButton, Alert, DialogTitle, Dialog, DialogContent, CircularProgress, Paper, Box } from '@mui/material';
3+
import { Stack, Typography, Button, IconButton, Alert, DialogTitle, Dialog, DialogContent, CircularProgress, Paper, Box, SvgIcon, useTheme } from '@mui/material';
44
import { CatalogItemWithName } from './components/tile/Tile';
55
import { Close } from '@mui/icons-material';
66
import { CatalogGrid } from './components/CatalogGrid';
77
import { POLL_INTERVAL } from './Constants';
8-
import MCPCatalogLogo from './MCP Catalog.svg'
9-
import { getMCPClientStates, MCPClientState } from './MCPClients';
108
import { CatalogProvider, useCatalogContext } from './context/CatalogContext';
9+
import { MCPClientProvider } from './context/MCPClientContext';
1110
import ConfigurationModal from './components/ConfigurationModal';
11+
import { Settings as SettingsIcon } from '@mui/icons-material';
1212

1313
const Settings = React.lazy(() => import('./components/Settings'));
1414

15+
// Create lazy-loaded logo components
16+
const LazyDarkLogo = React.lazy(() => import('./components/DarkLogo'));
17+
const LazyLightLogo = React.lazy(() => import('./components/LightLogo'));
18+
19+
// Logo component that uses Suspense for conditional loading
20+
const Logo = () => {
21+
const theme = useTheme();
22+
const isDarkMode = theme.palette.mode === 'dark';
23+
24+
return (
25+
<Suspense fallback={<Box sx={{ height: '5em', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CircularProgress size={24} /></Box>}>
26+
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
27+
{isDarkMode ? <LazyDarkLogo /> : <LazyLightLogo />}
28+
</Box>
29+
</Suspense>
30+
);
31+
}
32+
1533
export const client = createDockerDesktopClient();
1634

1735
const DEFAULT_SETTINGS = {
@@ -21,58 +39,31 @@ const DEFAULT_SETTINGS = {
2139

2240
export function App() {
2341
const [settings, setSettings] = useState<{ showModal: boolean, pollIntervalSeconds: number }>(localStorage.getItem('settings') ? JSON.parse(localStorage.getItem('settings') || '{}') : DEFAULT_SETTINGS);
24-
const [mcpClientStates, setMcpClientStates] = useState<{ [name: string]: MCPClientState }>({});
2542
const [configuringItem, setConfiguringItem] = useState<CatalogItemWithName | null>(null);
26-
27-
const updateMCPClientStates = async () => {
28-
const oldStates = mcpClientStates;
29-
const states = await getMCPClientStates(client)
30-
setMcpClientStates(states);
31-
// Whenever a client connection changes, show toast to user
32-
if (Object.values(oldStates).some(state => state.exists && !state.configured) && Object.values(states).every(state => state.configured)) {
33-
client.desktopUI.toast.success('MCP Client Connected. Restart it to load the Catalog.');
34-
}
35-
if (Object.values(oldStates).some(state => state.exists && state.configured) && Object.values(states).every(state => !state.configured)) {
36-
client.desktopUI.toast.error('MCP Client Disconnected. Restart it to remove the Catalog.');
37-
}
38-
}
39-
40-
useEffect(() => {
41-
let interval: NodeJS.Timeout;
42-
updateMCPClientStates();
43-
interval = setInterval(() => {
44-
updateMCPClientStates();
45-
}, POLL_INTERVAL);
46-
return () => clearInterval(interval);
47-
}, []);
48-
49-
// Wrap the entire application with our CatalogProvider
43+
// Wrap the entire application with our providers
5044
return (
5145
<CatalogProvider client={client}>
52-
<AppContent
53-
settings={settings}
54-
setSettings={setSettings}
55-
mcpClientStates={mcpClientStates}
56-
configuringItem={configuringItem}
57-
setConfiguringItem={setConfiguringItem}
58-
updateMCPClientStates={updateMCPClientStates}
59-
/>
46+
<MCPClientProvider client={client}>
47+
<AppContent
48+
settings={settings}
49+
setSettings={setSettings}
50+
configuringItem={configuringItem}
51+
setConfiguringItem={setConfiguringItem}
52+
/>
53+
</MCPClientProvider>
6054
</CatalogProvider>
6155
);
6256
}
6357

6458
interface AppContentProps {
6559
settings: { showModal: boolean, pollIntervalSeconds: number };
6660
setSettings: React.Dispatch<React.SetStateAction<{ showModal: boolean, pollIntervalSeconds: number }>>;
67-
mcpClientStates: { [name: string]: MCPClientState };
6861
configuringItem: CatalogItemWithName | null;
6962
setConfiguringItem: React.Dispatch<React.SetStateAction<CatalogItemWithName | null>>;
70-
updateMCPClientStates: () => Promise<void>;
7163
}
7264

73-
function AppContent({ settings, setSettings, mcpClientStates, configuringItem, setConfiguringItem, updateMCPClientStates }: AppContentProps) {
74-
const { imagesLoadingResults, loadImagesIfNeeded, secrets, catalogItems, registryItems, tryLoadSecrets } = useCatalogContext();
75-
65+
function AppContent({ settings, setSettings, configuringItem, setConfiguringItem }: AppContentProps) {
66+
const { imagesLoadingResults, loadImagesIfNeeded, secrets, catalogItems, registryItems, } = useCatalogContext();
7667
if (!imagesLoadingResults || imagesLoadingResults.stderr) {
7768
return <Paper sx={{ padding: 2, height: '90vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
7869
{!imagesLoadingResults && <CircularProgress sx={{ marginBottom: 2 }} />}
@@ -103,8 +94,6 @@ function AppContent({ settings, setSettings, mcpClientStates, configuringItem, s
10394
</Paper>
10495
}
10596

106-
const hasMCPConfigured = Object.values(mcpClientStates).some(state => state.exists && state.configured);
107-
10897
return (
10998
<>
11099
{settings.showModal && (
@@ -124,8 +113,6 @@ function AppContent({ settings, setSettings, mcpClientStates, configuringItem, s
124113
<Settings
125114
settings={settings}
126115
setSettings={setSettings}
127-
mcpClientStates={mcpClientStates}
128-
onUpdate={updateMCPClientStates}
129116
/>
130117
</Suspense>
131118
</DialogContent>
@@ -143,20 +130,13 @@ function AppContent({ settings, setSettings, mcpClientStates, configuringItem, s
143130
)}
144131

145132
<Stack direction="column" spacing={1} justifyContent='center' alignItems='center'>
146-
<img src={MCPCatalogLogo} alt="MCP Catalog" height={100} />
147-
{hasMCPConfigured ? <></> : <Alert action={<Button variant='outlined' color='secondary' onClick={() => setSettings({ ...settings, showModal: true })}>Configure</Button>} severity="error" sx={{ fontWeight: 'bold' }}>MCP Clients are not configured. Please configure MCP Clients to use the MCP Catalog.</Alert>}
133+
<Stack direction="row" spacing={1} justifyContent='space-evenly' alignItems='center' sx={{ width: '100%', maxWidth: '1000px' }}>
134+
<Logo />
135+
<IconButton sx={{ ml: 2, alignSelf: 'flex-end', justifyContent: 'flex-end' }} onClick={() => setSettings({ ...settings, showModal: true })}>
136+
<SettingsIcon sx={{ fontSize: '1.5em' }} />
137+
</IconButton>
138+
</Stack>
148139
<CatalogGrid
149-
settingsBadgeProps={hasMCPConfigured ? {} : {
150-
color: hasMCPConfigured ? 'default' : 'error',
151-
badgeContent: '0 MCP Clients',
152-
sx: {
153-
width: 80,
154-
height: '100%',
155-
display: 'flex',
156-
justifyContent: 'center',
157-
alignItems: 'center',
158-
},
159-
}}
160140
setConfiguringItem={setConfiguringItem}
161141
showSettings={() => setSettings({ ...settings, showModal: true })}
162142
/>

src/extension/ui/src/FileWatcher.ts

Lines changed: 7 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
*/
55
import { v1 } from "@docker/extension-api-client-types"
66
import { ExecResult } from "@docker/extension-api-client-types/dist/v0"
7-
8-
const allWatches: { [key: string]: any } = {}
7+
import { Serializable } from "child_process"
98

109
export const tryRunImageSync = async (client: v1.DockerDesktopClient, args: string[], ignoreError = false) => {
1110
const showError = ignoreError ? () => { } : client.desktopUI.toast.error
@@ -42,63 +41,12 @@ export const writeFileToPromptsVolume = async (client: v1.DockerDesktopClient, c
4241
return tryRunImageSync(client, ['--rm', '-v', 'docker-prompts:/docker-prompts', '--workdir', '/docker-prompts', 'vonwig/function_write_files:latest', `'${content}'`])
4342
}
4443

45-
export const writeFilesToHost = async (client: v1.DockerDesktopClient, files: { path: string, content: string }[], hostPaths: { source: string, target: string }[], workdir: string) => {
46-
const bindArgs = hostPaths.map(path => `--mount type=bind,source="${path.source}",target="${path.target}"`)
47-
const args = ['--rm', ...bindArgs, '--workdir', workdir, 'vonwig/function_write_files:latest', `'${JSON.stringify({ files })}'`]
48-
return tryRunImageSync(client, args)
49-
}
50-
51-
export const watchFile = async (client: v1.DockerDesktopClient, path: string, stream: { onOutput: (data: { stdout?: string; stderr?: string }) => void, onError: (error: string) => void, onClose: (exitCode: number) => void }, host = false) => {
52-
let user: string | undefined
53-
if (host) {
54-
user = await getUser(client)
55-
}
56-
return new Promise((resolve, reject) => {
57-
let args = ['--rm', 'vonwig/inotifywait:latest', '-e', 'modify', '-e', 'create', '-e', 'delete', '-q', '-m']
58-
if (host) {
59-
const replacedPath = path.replace(`$USER`, user!)
60-
args = ['--mount', `type=bind,source=${replacedPath},target=/config.json`, ...args, '/config.json']
61-
}
62-
else {
63-
args = ['-v', 'docker-prompts:/docker-prompts', '--workdir', '/docker-prompts', ...args, path]
64-
}
65-
console.log('starting watch', path)
66-
if (path in allWatches) {
67-
console.log('stopping duplicate watch', path)
68-
allWatches[path].close()
69-
delete allWatches[path]
70-
}
71-
allWatches[path] = client.docker.cli.exec('run', args, {
72-
stream: {
73-
onOutput: (data) => {
74-
stream.onOutput(data)
75-
},
76-
onError: (error) => {
77-
stream.onError(error)
78-
console.log('error', error)
79-
},
80-
onClose: (exitCode: number) => {
81-
stream.onClose(exitCode)
82-
console.log('close', exitCode)
83-
}
84-
}
85-
})
86-
console.log('allWatches', allWatches)
87-
})
88-
89-
}
90-
91-
export const stopWatch = (path: string) => {
92-
if (allWatches[path]) {
93-
console.log('stopping watch', path)
94-
allWatches[path].close()
95-
delete allWatches[path]
44+
export const escapeJSONForPlatformShell = (json: Serializable, platform: string) => {
45+
const jsonString = JSON.stringify(json)
46+
if (platform === 'win32') {
47+
// Use triple quotes to escape quotes
48+
return `"${jsonString.replace(/"/g, '\\"')}"`
9649
}
50+
return `'${jsonString}'`
9751
}
9852

99-
export const stopAllWatches = () => {
100-
Object.keys(allWatches).forEach(path => {
101-
console.log('stopping watch', path)
102-
stopWatch(path)
103-
})
104-
}

src/extension/ui/src/MCP Catalog.svg

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/extension/ui/src/MCP Catalog_dark.svg

Lines changed: 1 addition & 0 deletions
Loading

src/extension/ui/src/MCP Catalog_light.svg

Lines changed: 1 addition & 0 deletions
Loading

src/extension/ui/src/MCPClients.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { v1 } from "@docker/extension-api-client-types";
22
import { SUPPORTED_MCP_CLIENTS } from "./mcp-clients";
3-
import { MCPClient } from "./mcp-clients";
3+
import { MCPClient } from "./mcp-clients/MCPTypes";
44

55
export type MCPClientState = {
66
client: MCPClient;
@@ -26,9 +26,6 @@ export const getMCPClientStates = async (ddClient: v1.DockerDesktopClient) => {
2626
else {
2727
mcpClientStates[mcpClient.name] = { exists: true, configured: mcpClient.validateConfig(content), path: path, client: mcpClient };
2828
}
29-
if (mcpClient.name === 'Cursor') {
30-
mcpClientStates[mcpClient.name].preventAutoConnectMessage = 'Connecting Cursor automatically is not yet supported. Please configure manually in Cursor Settings.';
31-
}
3229
}
3330
return mcpClientStates;
3431
}
Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)