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';
|
import { Card } from './ui/card';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
children: React.ReactNode;
|
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.
|
* 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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards]">
|
<div
|
||||||
<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">
|
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"
|
||||||
<div className="space-y-6">{children}</div>
|
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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import { Button } from '../../ui/button';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { GPSIcon } from '../../ui/icons';
|
import { GPSIcon } from '../../ui/icons';
|
||||||
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
|
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
|
||||||
import { ExtensionConfig } from '../../../api/types.gen';
|
|
||||||
import ExtensionList from './subcomponents/ExtensionList';
|
import ExtensionList from './subcomponents/ExtensionList';
|
||||||
import ExtensionModal from './modal/ExtensionModal';
|
import ExtensionModal from './modal/ExtensionModal';
|
||||||
|
import {
|
||||||
|
createExtensionConfig,
|
||||||
|
ExtensionFormData,
|
||||||
|
extensionToFormData,
|
||||||
|
getDefaultFormData,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
export default function ExtensionsSection() {
|
export default function ExtensionsSection() {
|
||||||
const { toggleExtension, getExtensions, addExtension } = useConfig();
|
const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig();
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [extensions, setExtensions] = useState<FixedExtensionEntry[]>([]);
|
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 = () => {
|
const handleModalClose = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setIsAddModalOpen(false);
|
setIsAddModalOpen(false);
|
||||||
@@ -122,7 +137,9 @@ export default function ExtensionsSection() {
|
|||||||
initialData={extensionToFormData(selectedExtension)}
|
initialData={extensionToFormData(selectedExtension)}
|
||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
onSubmit={handleUpdateExtension}
|
onSubmit={handleUpdateExtension}
|
||||||
|
onDelete={handleDeleteExtension}
|
||||||
submitLabel="Save Changes"
|
submitLabel="Save Changes"
|
||||||
|
modalType={'edit'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -134,90 +151,9 @@ export default function ExtensionsSection() {
|
|||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
onSubmit={handleAddExtension}
|
onSubmit={handleAddExtension}
|
||||||
submitLabel="Add Extension"
|
submitLabel="Add Extension"
|
||||||
|
modalType={'add'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 React from 'react';
|
||||||
import { Button } from '../../../ui/button';
|
import { Button } from '../../../ui/button';
|
||||||
import { X } from 'lucide-react';
|
import { Plus, X } from 'lucide-react';
|
||||||
import { Input } from '../../../ui/input';
|
import { Input } from '../../../ui/input';
|
||||||
|
|
||||||
interface EnvVarsSectionProps {
|
interface EnvVarsSectionProps {
|
||||||
@@ -8,6 +8,8 @@ interface EnvVarsSectionProps {
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
||||||
|
submitAttempted: boolean;
|
||||||
|
isValid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EnvVarsSection({
|
export default function EnvVarsSection({
|
||||||
@@ -15,40 +17,73 @@ export default function EnvVarsSection({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
onChange,
|
onChange,
|
||||||
|
submitAttempted,
|
||||||
|
isValid,
|
||||||
}: EnvVarsSectionProps) {
|
}: EnvVarsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="relative mb-2">
|
||||||
<label className="text-sm font-medium">Environment Variables</label>
|
{' '}
|
||||||
<Button onClick={onAdd} variant="ghost" className="text-sm hover:bg-subtle">
|
{/* Added relative positioning with minimal margin */}
|
||||||
Add Variable
|
<label className="text-sm font-medium text-textStandard mb-2 block">
|
||||||
</Button>
|
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>
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||||
<div className="space-y-2">
|
{/* Existing environment variables */}
|
||||||
{envVars.map((envVar, index) => (
|
{envVars.map((envVar, index) => (
|
||||||
<div key={index} className="flex gap-2 items-start">
|
<React.Fragment key={index}>
|
||||||
<Input
|
<div className="relative">
|
||||||
value={envVar.key}
|
<Input
|
||||||
onChange={(e) => onChange(index, 'key', e.target.value)}
|
value={envVar.key}
|
||||||
placeholder="Key"
|
onChange={(e) => onChange(index, 'key', e.target.value)}
|
||||||
className="flex-1"
|
placeholder="Variable name"
|
||||||
/>
|
className={`w-full bg-bgSubtle border-borderSubtle text-textStandard`}
|
||||||
<Input
|
/>
|
||||||
value={envVar.value}
|
</div>
|
||||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
<div className="relative">
|
||||||
placeholder="Value"
|
<Input
|
||||||
className="flex-1"
|
value={envVar.value}
|
||||||
/>
|
onChange={(e) => onChange(index, 'value', e.target.value)}
|
||||||
|
placeholder="Value"
|
||||||
|
className={`w-full bg-bgSubtle border-borderSubtle text-textStandard`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
variant="ghost"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,57 +3,55 @@ import React from 'react';
|
|||||||
|
|
||||||
interface ExtensionConfigFieldsProps {
|
interface ExtensionConfigFieldsProps {
|
||||||
type: 'stdio' | 'sse' | 'builtin';
|
type: 'stdio' | 'sse' | 'builtin';
|
||||||
cmd: string;
|
full_cmd: string;
|
||||||
args: string;
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
onChange: (key: string, value: any) => void;
|
onChange: (key: string, value: any) => void;
|
||||||
|
submitAttempted?: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExtensionConfigFields({
|
export default function ExtensionConfigFields({
|
||||||
type,
|
type,
|
||||||
cmd,
|
full_cmd,
|
||||||
args,
|
|
||||||
endpoint,
|
endpoint,
|
||||||
onChange,
|
onChange,
|
||||||
|
submitAttempted = false,
|
||||||
|
isValid,
|
||||||
}: ExtensionConfigFieldsProps) {
|
}: ExtensionConfigFieldsProps) {
|
||||||
if (type === 'stdio') {
|
if (type === 'stdio') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-2 block">Command</label>
|
<label className="text-sm font-medium mb-2 block text-textStandard">Command</label>
|
||||||
<Input
|
<div className="relative">
|
||||||
value={cmd}
|
<Input
|
||||||
onChange={(e) => onChange('cmd', e.target.value)}
|
value={full_cmd}
|
||||||
placeholder="Enter command..."
|
onChange={(e) => onChange('cmd', e.target.value)}
|
||||||
className="w-full"
|
placeholder="e.g. npx -y @modelcontextprotocol/my-extension <filepath>"
|
||||||
/>
|
className={`w-full ${!submitAttempted || isValid ? 'border-borderSubtle' : 'border-red-500'} text-textStandard`}
|
||||||
</div>
|
/>
|
||||||
<div>
|
{submitAttempted && !isValid && (
|
||||||
<label className="text-sm font-medium mb-2 block">Arguments</label>
|
<div className="absolute text-xs text-red-500 mt-1">Command is required</div>
|
||||||
<Input
|
)}
|
||||||
value={args}
|
</div>
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
'args',
|
|
||||||
e.target.value.split(' ').filter((arg) => arg.length > 0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Enter arguments..."
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-2 block">Endpoint</label>
|
<label className="text-sm font-medium mb-2 block text-textStandard">Endpoint</label>
|
||||||
<Input
|
<div className="relative">
|
||||||
value={endpoint}
|
<Input
|
||||||
onChange={(e) => onChange('endpoint', e.target.value)}
|
value={endpoint}
|
||||||
placeholder="Enter endpoint URL..."
|
onChange={(e) => onChange('endpoint', e.target.value)}
|
||||||
className="w-full"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
// ExtensionModal.tsx
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button } from '../../../ui/button';
|
import { Button } from '../../../ui/button';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import { Input } from '../../../ui/input';
|
import { Input } from '../../../ui/input';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { createDarkSelectStyles, darkSelectTheme } from '../../../ui/select-styles';
|
import { createDarkSelectStyles, darkSelectTheme } from '../../../ui/select-styles';
|
||||||
import { ExtensionFormData } from '../ExtensionsSection';
|
import { ExtensionFormData } from '../utils';
|
||||||
import EnvVarsSection from './EnvVarsSection';
|
import EnvVarsSection from './EnvVarsSection';
|
||||||
import ExtensionConfigFields from './ExtensionConfigFields';
|
import ExtensionConfigFields from './ExtensionConfigFields';
|
||||||
|
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface ExtensionModalProps {
|
interface ExtensionModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
initialData: ExtensionFormData;
|
initialData: ExtensionFormData;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (formData: ExtensionFormData) => void;
|
onSubmit: (formData: ExtensionFormData) => void;
|
||||||
|
onDelete?: (name: string) => void;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
|
modalType: 'add' | 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExtensionModal({
|
export default function ExtensionModal({
|
||||||
@@ -22,9 +24,13 @@ export default function ExtensionModal({
|
|||||||
initialData,
|
initialData,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
|
modalType,
|
||||||
}: ExtensionModalProps) {
|
}: ExtensionModalProps) {
|
||||||
const [formData, setFormData] = useState<ExtensionFormData>(initialData);
|
const [formData, setFormData] = useState<ExtensionFormData>(initialData);
|
||||||
|
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
|
||||||
const handleAddEnvVar = () => {
|
const handleAddEnvVar = () => {
|
||||||
setFormData({
|
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 (
|
return (
|
||||||
<Modal>
|
<Modal footer={footerContent} onClose={onClose}>
|
||||||
<div className="space-y-6">
|
{/* Title and Icon */}
|
||||||
<h2 className="text-xl font-medium">{title}</h2>
|
<div className="flex flex-col mb-6">
|
||||||
|
<div>{getModalIcon()}</div>
|
||||||
<div className="flex justify-between gap-4">
|
<div className="mt-2">
|
||||||
<div className="flex-1">
|
<h2 className="text-2xl font-regular text-textStandard">{modalTitle}</h2>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionConfigFields.tsx
|
|
||||||
|
|
||||||
// EnvVarsSection.tsx
|
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ interface ExtensionItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExtensionItem({ extension, onToggle, onConfigure }: 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 (
|
return (
|
||||||
<div className="rounded-lg border border-borderSubtle p-4 mb-2">
|
<div className="rounded-lg border border-borderSubtle p-4 mb-2">
|
||||||
<div className="flex items-center justify-between 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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-textSubtle">{getSubtitle(extension)}</p>
|
<p className="text-sm text-textSubtle">{renderFormattedSubtitle()}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FixedExtensionEntry } from '../../../ConfigContext';
|
|||||||
import { ExtensionConfig } from '../../../../api/types.gen';
|
import { ExtensionConfig } from '../../../../api/types.gen';
|
||||||
import ExtensionItem from './ExtensionItem';
|
import ExtensionItem from './ExtensionItem';
|
||||||
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
||||||
|
import { combineCmdAndArgs } from '../utils';
|
||||||
|
|
||||||
interface ExtensionListProps {
|
interface ExtensionListProps {
|
||||||
extensions: FixedExtensionEntry[];
|
extensions: FixedExtensionEntry[];
|
||||||
@@ -45,7 +46,8 @@ export function getSubtitle(config: ExtensionConfig): string {
|
|||||||
return 'Built-in extension';
|
return 'Built-in extension';
|
||||||
}
|
}
|
||||||
if (config.type === 'stdio') {
|
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') {
|
if (config.type === 'sse') {
|
||||||
return `SSE extension${config.uri ? ` (${config.uri})` : ''}`;
|
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 (
|
return (
|
||||||
<Modal>
|
<Modal onClose={closeModal}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Logo area - centered above title */}
|
{/* Logo area - centered above title */}
|
||||||
<ProviderLogo providerName={currentProvider.name} />
|
<ProviderLogo providerName={currentProvider.name} />
|
||||||
|
|||||||
Reference in New Issue
Block a user