ui: new extensions modal (#1711)
This commit is contained in:
@@ -1,18 +1,64 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
|
||||
interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode; // Optional footer
|
||||
onClose: () => void; // Function to call when modal should close
|
||||
preventBackdropClose?: boolean; // Optional prop to prevent closing on backdrop click
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable modal component that renders content with a semi-transparent backdrop and blur effect.
|
||||
* Closes when clicking outside the modal or pressing Esc key.
|
||||
*/
|
||||
export default function Modal({ children }: ModalProps) {
|
||||
export default function Modal({
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
preventBackdropClose = false,
|
||||
}: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside the modal content
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (preventBackdropClose) return;
|
||||
// Check if the click was on the backdrop and not on the modal content
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Esc key press
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards]">
|
||||
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] bg-bgApp rounded-xl overflow-hidden shadow-none p-6">
|
||||
<div className="space-y-6">{children}</div>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<Card
|
||||
ref={modalRef}
|
||||
className="relative w-[500px] max-w-full bg-bgApp rounded-xl shadow-none my-10 overflow-hidden max-h-[90vh] flex flex-col"
|
||||
>
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">{children}</div>
|
||||
{footer && (
|
||||
<div className="border-t border-borderSubtle bg-bgApp w-full mt-auto">{footer}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,17 @@ import { Button } from '../../ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { GPSIcon } from '../../ui/icons';
|
||||
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
import ExtensionList from './subcomponents/ExtensionList';
|
||||
import ExtensionModal from './modal/ExtensionModal';
|
||||
import {
|
||||
createExtensionConfig,
|
||||
ExtensionFormData,
|
||||
extensionToFormData,
|
||||
getDefaultFormData,
|
||||
} from './utils';
|
||||
|
||||
export default function ExtensionsSection() {
|
||||
const { toggleExtension, getExtensions, addExtension } = useConfig();
|
||||
const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [extensions, setExtensions] = useState<FixedExtensionEntry[]>([]);
|
||||
@@ -74,6 +79,16 @@ export default function ExtensionsSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteExtension = async (name: string) => {
|
||||
try {
|
||||
await removeExtension(name);
|
||||
handleModalClose();
|
||||
fetchExtensions(); // Refresh the list after deleting
|
||||
} catch (error) {
|
||||
console.error('Failed to delete extension:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setIsAddModalOpen(false);
|
||||
@@ -122,7 +137,9 @@ export default function ExtensionsSection() {
|
||||
initialData={extensionToFormData(selectedExtension)}
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleUpdateExtension}
|
||||
onDelete={handleDeleteExtension}
|
||||
submitLabel="Save Changes"
|
||||
modalType={'edit'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -134,90 +151,9 @@ export default function ExtensionsSection() {
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleAddExtension}
|
||||
submitLabel="Add Extension"
|
||||
modalType={'add'}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
export interface ExtensionFormData {
|
||||
name: string;
|
||||
type: 'stdio' | 'sse' | 'builtin';
|
||||
cmd?: string;
|
||||
args?: string[];
|
||||
endpoint?: string;
|
||||
enabled: boolean;
|
||||
envVars: { key: string; value: string }[];
|
||||
}
|
||||
|
||||
function getDefaultFormData(): ExtensionFormData {
|
||||
return {
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
cmd: '',
|
||||
args: [],
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
envVars: [],
|
||||
};
|
||||
}
|
||||
|
||||
function extensionToFormData(extension: FixedExtensionEntry): ExtensionFormData {
|
||||
// Type guard: Check if 'envs' property exists for this variant
|
||||
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
|
||||
|
||||
const envVars =
|
||||
hasEnvs && extension.envs
|
||||
? Object.entries(extension.envs).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: extension.name,
|
||||
type: extension.type,
|
||||
cmd: extension.type === 'stdio' ? extension.cmd : undefined,
|
||||
args: extension.type === 'stdio' ? extension.args : [],
|
||||
endpoint: extension.type === 'sse' ? extension.uri : undefined,
|
||||
enabled: extension.enabled,
|
||||
envVars,
|
||||
};
|
||||
}
|
||||
|
||||
function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
|
||||
const envs = formData.envVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
if (formData.type === 'stdio') {
|
||||
return {
|
||||
type: 'stdio',
|
||||
name: formData.name,
|
||||
cmd: formData.cmd,
|
||||
args: formData.args,
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
};
|
||||
} else if (formData.type === 'sse') {
|
||||
return {
|
||||
type: 'sse',
|
||||
name: formData.name,
|
||||
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
};
|
||||
} else {
|
||||
// For other types
|
||||
return {
|
||||
type: formData.type,
|
||||
name: formData.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Input } from '../../../ui/input';
|
||||
|
||||
interface EnvVarsSectionProps {
|
||||
@@ -8,6 +8,8 @@ interface EnvVarsSectionProps {
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
||||
submitAttempted: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export default function EnvVarsSection({
|
||||
@@ -15,40 +17,73 @@ export default function EnvVarsSection({
|
||||
onAdd,
|
||||
onRemove,
|
||||
onChange,
|
||||
submitAttempted,
|
||||
isValid,
|
||||
}: EnvVarsSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-medium">Environment Variables</label>
|
||||
<Button onClick={onAdd} variant="ghost" className="text-sm hover:bg-subtle">
|
||||
Add Variable
|
||||
</Button>
|
||||
<div className="relative mb-2">
|
||||
{' '}
|
||||
{/* Added relative positioning with minimal margin */}
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">
|
||||
Environment Variables
|
||||
</label>
|
||||
{submitAttempted && !isValid && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
{' '}
|
||||
{/* Removed absolute positioning */}
|
||||
Environment variables must consist of sets of variable names and values
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
{/* Existing environment variables */}
|
||||
{envVars.map((envVar, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<Input
|
||||
value={envVar.key}
|
||||
onChange={(e) => onChange(index, 'key', e.target.value)}
|
||||
placeholder="Key"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={envVar.value}
|
||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1"
|
||||
/>
|
||||
<React.Fragment key={index}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={envVar.key}
|
||||
onChange={(e) => onChange(index, 'key', e.target.value)}
|
||||
placeholder="Variable name"
|
||||
className={`w-full bg-bgSubtle border-borderSubtle text-textStandard`}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={envVar.value}
|
||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className={`w-full bg-bgSubtle border-borderSubtle text-textStandard`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
className="p-2 h-auto hover:bg-subtle"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent min-w-[60px] flex justify-start"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Empty row with Add button */}
|
||||
<Input
|
||||
placeholder="Variable name"
|
||||
className="w-full border-borderStandard text-textStandard"
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value"
|
||||
className="w-full border-borderStandard text-textStandard"
|
||||
disabled
|
||||
/>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-s font-medium rounded-full dark:bg-slate-400 dark:text-gray-300 bg-gray-300 text-slate-400 dark:hover:bg-slate-300 hover:bg-gray-500 hover:text-white dark:hover:text-gray-900 transition-colors min-w-[60px] h-9"
|
||||
>
|
||||
<Plus className="h-3 w-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,57 +3,55 @@ import React from 'react';
|
||||
|
||||
interface ExtensionConfigFieldsProps {
|
||||
type: 'stdio' | 'sse' | 'builtin';
|
||||
cmd: string;
|
||||
args: string;
|
||||
full_cmd: string;
|
||||
endpoint: string;
|
||||
onChange: (key: string, value: any) => void;
|
||||
submitAttempted?: boolean;
|
||||
isValid?: boolean;
|
||||
}
|
||||
|
||||
export default function ExtensionConfigFields({
|
||||
type,
|
||||
cmd,
|
||||
args,
|
||||
full_cmd,
|
||||
endpoint,
|
||||
onChange,
|
||||
submitAttempted = false,
|
||||
isValid,
|
||||
}: ExtensionConfigFieldsProps) {
|
||||
if (type === 'stdio') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Command</label>
|
||||
<Input
|
||||
value={cmd}
|
||||
onChange={(e) => onChange('cmd', e.target.value)}
|
||||
placeholder="Enter command..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Arguments</label>
|
||||
<Input
|
||||
value={args}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
'args',
|
||||
e.target.value.split(' ').filter((arg) => arg.length > 0)
|
||||
)
|
||||
}
|
||||
placeholder="Enter arguments..."
|
||||
className="w-full"
|
||||
/>
|
||||
<label className="text-sm font-medium mb-2 block text-textStandard">Command</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={full_cmd}
|
||||
onChange={(e) => onChange('cmd', e.target.value)}
|
||||
placeholder="e.g. npx -y @modelcontextprotocol/my-extension <filepath>"
|
||||
className={`w-full ${!submitAttempted || isValid ? 'border-borderSubtle' : 'border-red-500'} text-textStandard`}
|
||||
/>
|
||||
{submitAttempted && !isValid && (
|
||||
<div className="absolute text-xs text-red-500 mt-1">Command is required</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Endpoint</label>
|
||||
<Input
|
||||
value={endpoint}
|
||||
onChange={(e) => onChange('endpoint', e.target.value)}
|
||||
placeholder="Enter endpoint URL..."
|
||||
className="w-full"
|
||||
/>
|
||||
<label className="text-sm font-medium mb-2 block text-textStandard">Endpoint</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={endpoint}
|
||||
onChange={(e) => onChange('endpoint', e.target.value)}
|
||||
placeholder="Enter endpoint URL..."
|
||||
className={`w-full ${!submitAttempted || isValid ? 'border-borderSubtle' : 'border-red-500'} text-textStandard`}
|
||||
/>
|
||||
{submitAttempted && !isValid && (
|
||||
<div className="absolute text-xs text-red-500 mt-1">Endpoint URL is required</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
// ExtensionModal.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../../../ui/button';
|
||||
import Modal from '../../../Modal';
|
||||
import { Input } from '../../../ui/input';
|
||||
import Select from 'react-select';
|
||||
import { createDarkSelectStyles, darkSelectTheme } from '../../../ui/select-styles';
|
||||
import { ExtensionFormData } from '../ExtensionsSection';
|
||||
import { ExtensionFormData } from '../utils';
|
||||
import EnvVarsSection from './EnvVarsSection';
|
||||
import ExtensionConfigFields from './ExtensionConfigFields';
|
||||
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ExtensionModalProps {
|
||||
title: string;
|
||||
initialData: ExtensionFormData;
|
||||
onClose: () => void;
|
||||
onSubmit: (formData: ExtensionFormData) => void;
|
||||
onDelete?: (name: string) => void;
|
||||
submitLabel: string;
|
||||
modalType: 'add' | 'edit';
|
||||
}
|
||||
|
||||
export default function ExtensionModal({
|
||||
@@ -22,9 +24,13 @@ export default function ExtensionModal({
|
||||
initialData,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
submitLabel,
|
||||
modalType,
|
||||
}: ExtensionModalProps) {
|
||||
const [formData, setFormData] = useState<ExtensionFormData>(initialData);
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
|
||||
const handleAddEnvVar = () => {
|
||||
setFormData({
|
||||
@@ -51,69 +57,192 @@ export default function ExtensionModal({
|
||||
});
|
||||
};
|
||||
|
||||
// Function to determine which icon to display with proper styling
|
||||
const getModalIcon = () => {
|
||||
if (showDeleteConfirmation) {
|
||||
return <AlertTriangle className="text-red-500" size={24} />;
|
||||
}
|
||||
return modalType === 'add' ? (
|
||||
<PlusIcon className="text-iconStandard" size={24} />
|
||||
) : (
|
||||
<Edit className="text-iconStandard" size={24} />
|
||||
);
|
||||
};
|
||||
|
||||
const isNameValid = () => {
|
||||
return formData.name.trim() !== '';
|
||||
};
|
||||
|
||||
const isConfigValid = () => {
|
||||
return (
|
||||
(formData.type === 'stdio' && formData.cmd && formData.cmd.trim() !== '') ||
|
||||
(formData.type === 'sse' && formData.endpoint && formData.endpoint.trim() !== '')
|
||||
);
|
||||
};
|
||||
|
||||
const isEnvVarsValid = () => {
|
||||
return formData.envVars.every(
|
||||
({ key, value }) => (key === '' && value === '') || (key !== '' && value !== '')
|
||||
);
|
||||
};
|
||||
|
||||
// Form validation
|
||||
const isFormValid = () => {
|
||||
return isNameValid() && isConfigValid() && isEnvVarsValid();
|
||||
};
|
||||
|
||||
// Handle submit with validation
|
||||
const handleSubmit = () => {
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (isFormValid()) {
|
||||
onSubmit(formData);
|
||||
}
|
||||
};
|
||||
|
||||
// Create footer buttons based on current state
|
||||
const footerContent = showDeleteConfirmation ? (
|
||||
// Delete confirmation footer
|
||||
<>
|
||||
<div className="w-full px-6 py-4 bg-red-900/20 border-t border-red-500/30">
|
||||
<p className="text-red-400 text-sm mb-2">
|
||||
Are you sure you want to delete "{formData.name}"? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onDelete && onDelete(formData.name)}
|
||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-red-900/20 text-red-500 font-medium text-md"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Confirm Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirmation(false)}
|
||||
variant="ghost"
|
||||
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Normal footer
|
||||
<>
|
||||
{modalType === 'edit' && onDelete && (
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirmation(true)}
|
||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-red-500 font-medium text-md"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Delete Extension
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-md"
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
// Update title based on current state
|
||||
const modalTitle = showDeleteConfirmation ? `Delete Extension "${formData.name}"` : title;
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-medium">{title}</h2>
|
||||
|
||||
<div className="flex justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block">Extension Name</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter extension name..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">Type</label>
|
||||
<Select
|
||||
value={{ value: formData.type, label: formData.type.toUpperCase() }}
|
||||
onChange={(option: { value: string; label: string } | null) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
type: (option?.value as 'stdio' | 'sse' | 'builtin') || 'stdio',
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'stdio', label: 'STDIO' },
|
||||
{ value: 'sse', label: 'SSE' },
|
||||
]}
|
||||
styles={createDarkSelectStyles('200px')}
|
||||
theme={darkSelectTheme}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExtensionConfigFields
|
||||
type={formData.type}
|
||||
cmd={formData.cmd || ''}
|
||||
args={formData.args?.join(' ') || ''}
|
||||
endpoint={formData.endpoint || ''}
|
||||
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
|
||||
/>
|
||||
|
||||
<EnvVarsSection
|
||||
envVars={formData.envVars}
|
||||
onAdd={handleAddEnvVar}
|
||||
onRemove={handleRemoveEnvVar}
|
||||
onChange={handleEnvVarChange}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button onClick={onClose} variant="ghost" className="hover:bg-subtle">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onSubmit(formData)} className="bg-[#393838] hover:bg-subtle">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Modal footer={footerContent} onClose={onClose}>
|
||||
{/* Title and Icon */}
|
||||
<div className="flex flex-col mb-6">
|
||||
<div>{getModalIcon()}</div>
|
||||
<div className="mt-2">
|
||||
<h2 className="text-2xl font-regular text-textStandard">{modalTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirmation ? (
|
||||
<div className="mb-6">
|
||||
<p className="text-textStandard">
|
||||
This will permanently remove this extension and all of its settings.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Form Fields */}
|
||||
{/* Name */}
|
||||
<div className="flex justify-between gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block text-textStandard">
|
||||
Extension Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter extension name..."
|
||||
className={`${!submitAttempted || formData.name.trim() !== '' ? 'border-borderSubtle' : 'border-red-500'} text-textStandard focus:border-borderStandard`}
|
||||
/>
|
||||
{submitAttempted && !isNameValid() && (
|
||||
<div className="absolute text-xs text-red-500 mt-1">Name is required</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/*Type Dropdown */}
|
||||
<div className="w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block text-textStandard">Type</label>
|
||||
<Select
|
||||
value={{ value: formData.type, label: formData.type.toUpperCase() }}
|
||||
onChange={(option: { value: string; label: string } | null) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
type: (option?.value as 'stdio' | 'sse' | 'builtin') || 'stdio',
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'stdio', label: 'Standard IO (STDIO)' },
|
||||
{ value: 'sse', label: 'Security Service Edge (SSE)' },
|
||||
]}
|
||||
styles={createDarkSelectStyles('200px')}
|
||||
theme={darkSelectTheme}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-t border-borderSubtle mb-6" />
|
||||
|
||||
{/* Config Fields */}
|
||||
<div className="mb-6">
|
||||
<ExtensionConfigFields
|
||||
type={formData.type}
|
||||
full_cmd={formData.cmd || ''}
|
||||
endpoint={formData.endpoint || ''}
|
||||
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
|
||||
submitAttempted={submitAttempted}
|
||||
isValid={isConfigValid()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-t border-borderSubtle mb-6" />
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="mb-6">
|
||||
<EnvVarsSection
|
||||
envVars={formData.envVars}
|
||||
onAdd={handleAddEnvVar}
|
||||
onRemove={handleRemoveEnvVar}
|
||||
onChange={Object.assign(handleEnvVarChange, { setSubmitAttempted })}
|
||||
submitAttempted={submitAttempted}
|
||||
isValid={isEnvVarsValid()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ExtensionConfigFields.tsx
|
||||
|
||||
// EnvVarsSection.tsx
|
||||
|
||||
@@ -12,6 +12,15 @@ interface ExtensionItemProps {
|
||||
}
|
||||
|
||||
export default function ExtensionItem({ extension, onToggle, onConfigure }: ExtensionItemProps) {
|
||||
const renderFormattedSubtitle = () => {
|
||||
const subtitle = getSubtitle(extension);
|
||||
return subtitle.split('\n').map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index === 0 ? part : <span className="font-mono text-xs">{part}</span>}
|
||||
{index < subtitle.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
return (
|
||||
<div className="rounded-lg border border-borderSubtle p-4 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -33,7 +42,7 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-textSubtle">{getSubtitle(extension)}</p>
|
||||
<p className="text-sm text-textSubtle">{renderFormattedSubtitle()}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
import { ExtensionConfig } from '../../../../api/types.gen';
|
||||
import ExtensionItem from './ExtensionItem';
|
||||
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
||||
import { combineCmdAndArgs } from '../utils';
|
||||
|
||||
interface ExtensionListProps {
|
||||
extensions: FixedExtensionEntry[];
|
||||
@@ -45,7 +46,8 @@ export function getSubtitle(config: ExtensionConfig): string {
|
||||
return 'Built-in extension';
|
||||
}
|
||||
if (config.type === 'stdio') {
|
||||
return `STDIO extension${config.cmd ? ` (${config.cmd})` : ''}`;
|
||||
const full_command = combineCmdAndArgs(config.cmd, config.args);
|
||||
return `STDIO extension${full_command ? `\n${full_command}` : ''}`;
|
||||
}
|
||||
if (config.type === 'sse') {
|
||||
return `SSE extension${config.uri ? ` (${config.uri})` : ''}`;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { FixedExtensionEntry } from '../../ConfigContext';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
|
||||
export interface ExtensionFormData {
|
||||
name: string;
|
||||
type: 'stdio' | 'sse' | 'builtin';
|
||||
cmd?: string;
|
||||
endpoint?: string;
|
||||
enabled: boolean;
|
||||
envVars: { key: string; value: string }[];
|
||||
}
|
||||
|
||||
export function getDefaultFormData(): ExtensionFormData {
|
||||
return {
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
envVars: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFormData {
|
||||
// Type guard: Check if 'envs' property exists for this variant
|
||||
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
|
||||
|
||||
const envVars =
|
||||
hasEnvs && extension.envs
|
||||
? Object.entries(extension.envs).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: extension.name,
|
||||
type: extension.type,
|
||||
cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined,
|
||||
endpoint: extension.type === 'sse' ? extension.uri : undefined,
|
||||
enabled: extension.enabled,
|
||||
envVars,
|
||||
};
|
||||
}
|
||||
|
||||
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
|
||||
const envs = formData.envVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
if (formData.type === 'stdio') {
|
||||
// we put the cmd + args all in the form cmd field but need to split out into cmd + args
|
||||
const { cmd, args } = splitCmdAndArgs(formData.cmd);
|
||||
|
||||
return {
|
||||
type: 'stdio',
|
||||
name: formData.name,
|
||||
cmd: cmd,
|
||||
args: args,
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
};
|
||||
} else if (formData.type === 'sse') {
|
||||
return {
|
||||
type: 'sse',
|
||||
name: formData.name,
|
||||
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
};
|
||||
} else {
|
||||
// For other types
|
||||
return {
|
||||
type: formData.type,
|
||||
name: formData.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } {
|
||||
const words = str.trim().split(/\s+/);
|
||||
const cmd = words[0] || '';
|
||||
const args = words.slice(1);
|
||||
|
||||
return {
|
||||
cmd,
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
export function combineCmdAndArgs(cmd: string, args: string[]): string {
|
||||
return [cmd, ...args].join(' ');
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function ProviderConfigurationModal() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<Modal onClose={closeModal}>
|
||||
<div className="space-y-1">
|
||||
{/* Logo area - centered above title */}
|
||||
<ProviderLogo providerName={currentProvider.name} />
|
||||
|
||||
Reference in New Issue
Block a user