Skip to content

Commit 97f6867

Browse files
authored
Implement config (#67)
* Implement prompt configs * Finalize UI for config changing with `config.yaml` * Include sync in config change * Refactor build for smaller bundle * Re-add missing props * Fix config toggle button * Implement tile config * Fix issues after performance profiling * Improve how config gets written and synced * Fix build for config * Add badge to config icon * Fix bug with failing to save config * Get `Your Tools` working * Make sure config changes yield diff before writing * Make config badge subtle color
1 parent 818cc01 commit 97f6867

18 files changed

+1420
-428
lines changed

src/extension/ui/package-lock.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/extension/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@mui/icons-material": "6.4.5",
1212
"@mui/material": "6.4.5",
1313
"ansi-to-html": "^0.7.2",
14+
"json-edit-react": "^1.23.1",
1415
"react": "^18.2.0",
1516
"react-dom": "^18.2.0",
1617
"yaml": "^2.3.1"

src/extension/ui/src/App.tsx

Lines changed: 115 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect, useState, Suspense } from 'react';
22
import { createDockerDesktopClient } from '@docker/extension-api-client';
3-
import { Stack, Typography, Button, ButtonGroup, Grid, debounce, Card, CardContent, IconButton, Alert, DialogTitle, Dialog, DialogContent, FormControlLabel, Checkbox, CircularProgress, Paper, DialogActions, Box } from '@mui/material';
4-
import { CatalogItemWithName } from './components/PromptCard';
5-
import { getRegistry } from './Registry';
6-
import { Close, FolderOpenRounded, } from '@mui/icons-material';
7-
import { ExecResult } from '@docker/extension-api-client-types/dist/v0';
3+
import { Stack, Typography, Button, IconButton, Alert, DialogTitle, Dialog, DialogContent, CircularProgress, Paper, Box } from '@mui/material';
4+
import { CatalogItemWithName } from './components/tile/Tile';
5+
import { Close } from '@mui/icons-material';
86
import { CatalogGrid } from './components/CatalogGrid';
97
import { POLL_INTERVAL } from './Constants';
108
import MCPCatalogLogo from './MCP Catalog.svg'
11-
import Settings from './components/Settings';
129
import { getMCPClientStates, MCPClientState } from './MCPClients';
10+
import { CatalogProvider, useCatalogContext } from './context/CatalogContext';
11+
import ConfigurationModal from './components/ConfigurationModal';
12+
13+
const Settings = React.lazy(() => import('./components/Settings'));
1314

1415
export const client = createDockerDesktopClient();
1516

@@ -19,42 +20,9 @@ const DEFAULT_SETTINGS = {
1920
}
2021

2122
export function App() {
22-
const [canRegister, setCanRegister] = useState(false);
23-
const [registryItems, setRegistryItems] = useState<{ [key: string]: { ref: string } }>({});
24-
const [imagesLoadingResults, setImagesLoadingResults] = useState<ExecResult | null>(null);
2523
const [settings, setSettings] = useState<{ showModal: boolean, pollIntervalSeconds: number }>(localStorage.getItem('settings') ? JSON.parse(localStorage.getItem('settings') || '{}') : DEFAULT_SETTINGS);
2624
const [mcpClientStates, setMcpClientStates] = useState<{ [name: string]: MCPClientState }>({});
27-
const loadRegistry = async () => {
28-
setCanRegister(false);
29-
try {
30-
const result = await getRegistry(client)
31-
setRegistryItems(result || {});
32-
}
33-
catch (error) {
34-
if (error instanceof Error) {
35-
client.desktopUI.toast.error('Failed to get prompt registry: ' + error.message);
36-
} else {
37-
client.desktopUI.toast.error('Failed to get prompt registry: ' + JSON.stringify(error));
38-
}
39-
}
40-
setCanRegister(true);
41-
}
42-
43-
const startImagesLoading = async () => {
44-
setImagesLoadingResults(null);
45-
try {
46-
const result = await client.docker.cli.exec('pull', ['vonwig/function_write_files:latest'])
47-
await client.docker.cli.exec('pull', ['alpine:latest'])
48-
await client.docker.cli.exec('pull', ['keinos/sqlite3:latest'])
49-
setImagesLoadingResults(result);
50-
}
51-
catch (error) {
52-
console.error(error)
53-
if (error) {
54-
setImagesLoadingResults(error as ExecResult)
55-
}
56-
}
57-
}
25+
const [configuringItem, setConfiguringItem] = useState<CatalogItemWithName | null>(null);
5826

5927
const updateMCPClientStates = async () => {
6028
const oldStates = mcpClientStates;
@@ -70,55 +38,131 @@ export function App() {
7038
}
7139

7240
useEffect(() => {
73-
startImagesLoading();
74-
loadRegistry();
41+
let interval: NodeJS.Timeout;
7542
updateMCPClientStates();
76-
const interval = setInterval(() => {
77-
loadRegistry();
43+
interval = setInterval(() => {
7844
updateMCPClientStates();
7945
}, POLL_INTERVAL);
80-
return () => {
81-
clearInterval(interval)
82-
}
46+
return () => clearInterval(interval);
8347
}, []);
8448

49+
// Wrap the entire application with our CatalogProvider
50+
return (
51+
<CatalogProvider client={client}>
52+
<AppContent
53+
settings={settings}
54+
setSettings={setSettings}
55+
mcpClientStates={mcpClientStates}
56+
configuringItem={configuringItem}
57+
setConfiguringItem={setConfiguringItem}
58+
updateMCPClientStates={updateMCPClientStates}
59+
/>
60+
</CatalogProvider>
61+
);
62+
}
63+
64+
interface AppContentProps {
65+
settings: { showModal: boolean, pollIntervalSeconds: number };
66+
setSettings: React.Dispatch<React.SetStateAction<{ showModal: boolean, pollIntervalSeconds: number }>>;
67+
mcpClientStates: { [name: string]: MCPClientState };
68+
configuringItem: CatalogItemWithName | null;
69+
setConfiguringItem: React.Dispatch<React.SetStateAction<CatalogItemWithName | null>>;
70+
updateMCPClientStates: () => Promise<void>;
71+
}
72+
73+
function AppContent({ settings, setSettings, mcpClientStates, configuringItem, setConfiguringItem, updateMCPClientStates }: AppContentProps) {
74+
const { imagesLoadingResults, loadImagesIfNeeded, secrets, catalogItems, registryItems, tryLoadSecrets } = useCatalogContext();
75+
8576
if (!imagesLoadingResults || imagesLoadingResults.stderr) {
8677
return <Paper sx={{ padding: 2, height: '90vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
8778
{!imagesLoadingResults && <CircularProgress sx={{ marginBottom: 2 }} />}
8879
{!imagesLoadingResults && <Typography>Loading images...</Typography>}
89-
{imagesLoadingResults && <Alert sx={{ fontSize: '1.5em' }} action={<Button variant='outlined' color='secondary' onClick={() => startImagesLoading()}>Retry</Button>} title="Error loading images" severity="error">{imagesLoadingResults.stderr}</Alert>}
80+
{imagesLoadingResults && <Alert sx={{ fontSize: '1.5em' }} action={<Button variant='outlined' color='secondary' onClick={() => loadImagesIfNeeded()}>Retry</Button>} title="Error loading images" severity="error">{imagesLoadingResults.stderr}</Alert>}
9081
<Typography>{imagesLoadingResults?.stdout}</Typography>
9182
</Paper>
9283
}
9384

94-
const hasMCPConfigured = Object.values(mcpClientStates).some(state => state.exists && state.configured)
85+
if (!secrets) {
86+
return <Paper sx={{ padding: 2, height: '90vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
87+
<CircularProgress />
88+
<Typography>Loading secrets...</Typography>
89+
</Paper>
90+
}
91+
92+
if (!catalogItems) {
93+
return <Paper sx={{ padding: 2, height: '90vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
94+
<CircularProgress />
95+
<Typography>Loading catalog...</Typography>
96+
</Paper>
97+
}
98+
99+
if (!registryItems) {
100+
return <Paper sx={{ padding: 2, height: '90vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
101+
<CircularProgress />
102+
<Typography>Loading registry...</Typography>
103+
</Paper>
104+
}
105+
106+
const hasMCPConfigured = Object.values(mcpClientStates).some(state => state.exists && state.configured);
95107

96108
return (
97109
<>
98-
<Dialog open={settings.showModal} onClose={() => setSettings({ ...settings, showModal: false })} fullWidth maxWidth='md'>
99-
<DialogTitle>
100-
<Typography variant='h2' sx={{ fontWeight: 'bold', m: 2 }}>Catalog Settings</Typography>
101-
</DialogTitle>
102-
<DialogContent>
103-
<Settings onUpdate={updateMCPClientStates} mcpClientStates={mcpClientStates} settings={settings} setSettings={setSettings} />
104-
</DialogContent>
105-
</Dialog>
110+
{settings.showModal && (
111+
<Dialog open={settings.showModal} fullWidth maxWidth="xl">
112+
<DialogTitle>
113+
Settings
114+
<IconButton
115+
aria-label="close"
116+
onClick={() => setSettings({ ...settings, showModal: false })}
117+
sx={{ position: 'absolute', right: 8, top: 8 }}
118+
>
119+
<Close />
120+
</IconButton>
121+
</DialogTitle>
122+
<DialogContent>
123+
<Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>}>
124+
<Settings
125+
settings={settings}
126+
setSettings={setSettings}
127+
mcpClientStates={mcpClientStates}
128+
onUpdate={updateMCPClientStates}
129+
/>
130+
</Suspense>
131+
</DialogContent>
132+
</Dialog>
133+
)}
134+
135+
{/* Replace the old PromptConfig dialog with our new ConfigurationModal */}
136+
{configuringItem && (
137+
<ConfigurationModal
138+
open={configuringItem !== null}
139+
onClose={() => setConfiguringItem(null)}
140+
catalogItem={configuringItem}
141+
client={client}
142+
secrets={secrets}
143+
onSecretChange={tryLoadSecrets}
144+
/>
145+
)}
146+
106147
<Stack direction="column" spacing={1} justifyContent='center' alignItems='center'>
107148
<img src={MCPCatalogLogo} alt="MCP Catalog" height={100} />
108149
{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>}
109-
<CatalogGrid settingsBadgeProps={hasMCPConfigured ? {} : {
110-
color: hasMCPConfigured ? 'default' : 'error',
111-
badgeContent: '0 MCP Clients',
112-
sx: {
113-
width: 80,
114-
height: '100%',
115-
display: 'flex',
116-
justifyContent: 'center',
117-
alignItems: 'center',
118-
},
119-
}} showSettings={() => setSettings({ ...settings, showModal: true })} registryItems={registryItems} canRegister={canRegister} client={client} onRegistryChange={loadRegistry} />
150+
<CatalogGrid
151+
settingsBadgeProps={hasMCPConfigured ? {} : {
152+
color: hasMCPConfigured ? 'default' : 'error',
153+
badgeContent: '0 MCP Clients',
154+
sx: {
155+
width: 80,
156+
height: '100%',
157+
display: 'flex',
158+
justifyContent: 'center',
159+
alignItems: 'center',
160+
},
161+
}}
162+
setConfiguringItem={setConfiguringItem}
163+
showSettings={() => setSettings({ ...settings, showModal: true })}
164+
/>
120165
</Stack>
121166
</>
122-
)
123-
167+
);
124168
}

src/extension/ui/src/FileWatcher.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import { ExecResult } from "@docker/extension-api-client-types/dist/v0"
77

88
const allWatches: { [key: string]: any } = {}
99

10-
export const tryRunImageSync = async (client: v1.DockerDesktopClient, args: string[]) => {
11-
const showError = client.desktopUI.toast.error
10+
export const tryRunImageSync = async (client: v1.DockerDesktopClient, args: string[], ignoreError = false) => {
11+
const showError = ignoreError ? () => { } : client.desktopUI.toast.error
1212
try {
1313
const result = await client.docker.cli.exec('run', args)
1414
if (result.stderr) {
1515
console.error(result.stderr)
1616
showError(result.stderr)
1717
}
18-
return result.stdout
18+
return result.stdout || ''
1919
}
2020
catch (e) {
2121
if (e instanceof Error) {
@@ -34,7 +34,7 @@ export const getUser = async (client: v1.DockerDesktopClient) => {
3434
}
3535

3636
export const readFileInPromptsVolume = async (client: v1.DockerDesktopClient, path: string) => {
37-
return tryRunImageSync(client, ['--rm', '-v', 'docker-prompts:/docker-prompts', '--workdir', '/docker-prompts', 'alpine:latest', 'sh', '-c', `"cat ${path}"`])
37+
return tryRunImageSync(client, ['--rm', '-v', 'docker-prompts:/docker-prompts', '--workdir', '/docker-prompts', 'alpine:latest', 'sh', '-c', `"cat ${path}"`], true)
3838
}
3939

4040
export const writeFileToPromptsVolume = async (client: v1.DockerDesktopClient, content: string) => {

src/extension/ui/src/MergeDeep.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Type for any object with string keys
3+
*/
4+
export type DeepObject = { [key: string]: any };
5+
6+
/**
7+
* Simple object check.
8+
* @param item
9+
* @returns {boolean}
10+
*/
11+
export function isObject(item: unknown): item is DeepObject {
12+
return Boolean(item && typeof item === 'object' && !Array.isArray(item));
13+
}
14+
15+
/**
16+
* Deep merge two objects.
17+
* @param target The target object to merge into
18+
* @param sources The source objects to merge from
19+
* @returns The merged object
20+
*/
21+
export function mergeDeep<T extends DeepObject>(target: T, ...sources: DeepObject[]): T {
22+
if (!sources.length) return target;
23+
const source = sources.shift();
24+
25+
if (isObject(target) && isObject(source)) {
26+
for (const key in source) {
27+
if (isObject(source[key])) {
28+
if (!target[key]) Object.assign(target, { [key]: {} });
29+
mergeDeep(target[key], source[key]);
30+
} else {
31+
Object.assign(target, { [key]: source[key] });
32+
}
33+
}
34+
}
35+
36+
return mergeDeep(target, ...sources);
37+
}

0 commit comments

Comments
 (0)