import { useCallback, useEffect, useState } from "react"; import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { extractErrorMessage } from "../utils/errors"; import { MobileListView } from "../components/mobile-list-view"; import { MobileDetailView } from "../components/mobile-detail-view"; import { MobileEditView } from "../components/mobile-edit-view"; import { MobileFAB } from "../components/mobile-fab"; import { createToolType, deleteToolType, listToolTypes, updateToolType, type CreateToolTypeRequest, type ReadinessProbe, type ToolType, type UpdateToolTypeRequest, } from "../api/tool_types"; import { createToolConfig, deleteToolConfig, listToolConfigs, updateToolConfig, type CreateToolConfigRequest, type ToolConfig, } from "../api/tool_configs"; import { createConfigFolder, deleteConfigFolder, listConfigFolders, updateConfigFolder, type ConfigFolder, type CreateConfigFolderRequest, type UpdateConfigFolderRequest, } from "../api/config_folders"; type RightPanelTab = "details" | "configs" | "folders"; type Status = "loading" | "ready" | "error"; type MobileView = "list" | "detail" | "edit"; export const ToolWorkshopPage = () => { const isMobile = useMobileViewport(); const [mobileView, setMobileView] = useState("list"); const [status, setStatus] = useState("loading"); const [toolTypes, setToolTypes] = useState([]); const [configs, setConfigs] = useState([]); const [folders, setFolders] = useState([]); // Selection state const [selectedToolTypeId, setSelectedToolTypeId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [rightPanelTab, setRightPanelTab] = useState("details"); // Tool Type form state const [toolTypeForm, setToolTypeForm] = useState({ name: "", display_name: "", description: "", category: "", interface_type: "web" as "web" | "terminal", requires_port: true, default_port: "", definition_type: "compose" as "compose" | "dockerfile", compose_template: "", dockerfile_template: "", readiness_command: "", readiness_timeout: "30", readiness_interval: "2", required_variables: "", startup_command: "", }); const [toolTypeError, setToolTypeError] = useState(null); const [toolTypeDirty, setToolTypeDirty] = useState(false); // Config form state const [configForm, setConfigForm] = useState({ key: "", value: "", config_type: "env", file_path: "", port_override: "", start_command: "", working_directory: "", env_vars_json: "{}", volumes_json: "[]", }); const [configError, setConfigError] = useState(null); const [showConfigForm, setShowConfigForm] = useState(false); const [selectedConfig, setSelectedConfig] = useState(null); // Folder form state const [folderForm, setFolderForm] = useState({ name: "", description: "", mount_path: "/home/user", files_json: "{}", is_active: true, }); const [folderError, setFolderError] = useState(null); const [showFolderForm, setShowFolderForm] = useState(false); const [selectedFolder, setSelectedFolder] = useState(null); const selectedToolType = (toolTypes || []).find((t) => t.id === selectedToolTypeId) || null; const toolConfigs = (configs || []).filter((c) => c.tool_type_id === selectedToolTypeId); const toolFolders = folders || []; // Config folders are global, not per-tool-type in current API const loadData = useCallback(async () => { setStatus("loading"); try { const [types, cfgs, fldrs] = await Promise.all([ listToolTypes(), listToolConfigs(), listConfigFolders(), ]); setToolTypes(types || []); setConfigs(cfgs || []); setFolders(fldrs || []); setStatus("ready"); } catch { setStatus("error"); } }, []); useEffect(() => { void loadData(); }, [loadData]); const resetToolTypeForm = () => { setToolTypeForm({ name: "", display_name: "", description: "", category: "", interface_type: "web", requires_port: true, default_port: "", definition_type: "compose", compose_template: "", dockerfile_template: "", readiness_command: "", readiness_timeout: "30", readiness_interval: "2", required_variables: "", startup_command: "", }); setToolTypeError(null); setToolTypeDirty(false); }; const populateToolTypeForm = (toolType: ToolType) => { setToolTypeForm({ name: toolType.name, display_name: toolType.display_name, description: toolType.description || "", category: toolType.category || "", interface_type: (toolType.interface_type as "web" | "terminal") || "web", requires_port: toolType.requires_port ?? true, default_port: toolType.default_port?.toString() || "", definition_type: toolType.definition_type || "compose", compose_template: toolType.compose_template || "", dockerfile_template: toolType.dockerfile_template || "", readiness_command: toolType.readiness_probe?.command || "", readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30", readiness_interval: toolType.readiness_probe?.interval?.toString() || "2", required_variables: toolType.required_variables?.join(", ") || "", startup_command: toolType.startup_command || "", }); setToolTypeError(null); setToolTypeDirty(false); }; const handleSelectToolType = (toolType: ToolType | null) => { if (toolTypeDirty) { if (!window.confirm("You have unsaved changes. Discard them?")) { return; } } if (toolType) { setSelectedToolTypeId(toolType.id); setIsCreating(false); populateToolTypeForm(toolType); } else { setSelectedToolTypeId(null); } setRightPanelTab("details"); setShowConfigForm(false); setShowFolderForm(false); }; const handleCreateNew = () => { if (toolTypeDirty) { if (!window.confirm("You have unsaved changes. Discard them?")) { return; } } setSelectedToolTypeId(null); setIsCreating(true); resetToolTypeForm(); setRightPanelTab("details"); setShowConfigForm(false); setShowFolderForm(false); }; const handleToolTypeSubmit = async (e: React.FormEvent) => { e.preventDefault(); setToolTypeError(null); if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) { setToolTypeError("Name and display name are required"); return; } if (toolTypeForm.requires_port && (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port)))) { setToolTypeError("Default port is required and must be a number"); return; } const template = toolTypeForm.definition_type === "compose" ? toolTypeForm.compose_template : toolTypeForm.dockerfile_template; if (!template.trim()) { setToolTypeError(`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`); return; } const variables = toolTypeForm.required_variables .split(",") .map((v) => v.trim()) .filter((v) => v.length > 0); const readinessProbe: ReadinessProbe | undefined = toolTypeForm.readiness_command.trim() ? { command: toolTypeForm.readiness_command.trim(), timeout: parseInt(toolTypeForm.readiness_timeout) || 30, interval: parseInt(toolTypeForm.readiness_interval) || 2, } : undefined; try { if (isCreating) { const input: CreateToolTypeRequest = { name: toolTypeForm.name.trim(), display_name: toolTypeForm.display_name.trim(), description: toolTypeForm.description.trim() || undefined, category: toolTypeForm.category.trim() || undefined, interface_type: toolTypeForm.interface_type, requires_port: toolTypeForm.requires_port, default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0, definition_type: toolTypeForm.definition_type, compose_template: toolTypeForm.definition_type === "compose" ? template : undefined, dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined, readiness_probe: readinessProbe, required_variables: variables, startup_command: toolTypeForm.startup_command.trim() || undefined, }; const newTool = await createToolType(input); setIsCreating(false); setSelectedToolTypeId(newTool.id); setToolTypeDirty(false); } else if (selectedToolType) { const input: UpdateToolTypeRequest = { display_name: toolTypeForm.display_name.trim(), description: toolTypeForm.description.trim() || undefined, category: toolTypeForm.category.trim() || undefined, interface_type: toolTypeForm.interface_type, requires_port: toolTypeForm.requires_port, default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0, definition_type: toolTypeForm.definition_type, compose_template: toolTypeForm.definition_type === "compose" ? template : undefined, dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined, readiness_probe: readinessProbe, required_variables: variables, startup_command: toolTypeForm.startup_command.trim() || undefined, }; await updateToolType(selectedToolType.id, input); setToolTypeDirty(false); } await loadData(); } catch (err) { setToolTypeError(extractErrorMessage(err)); } }; const handleDeleteToolType = async (id: string) => { if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return; try { await deleteToolType(id); if (selectedToolTypeId === id) { setSelectedToolTypeId(null); setIsCreating(false); resetToolTypeForm(); } await loadData(); } catch { alert("Failed to delete tool type"); } }; // Config handlers const resetConfigForm = () => { setConfigForm({ key: "", value: "", config_type: "env", file_path: "", port_override: "", start_command: "", working_directory: "", env_vars_json: "{}", volumes_json: "[]", }); setConfigError(null); setSelectedConfig(null); }; const openCreateConfig = () => { resetConfigForm(); setShowConfigForm(true); }; const openEditConfig = (config: ToolConfig) => { setConfigForm({ key: config.key, value: config.value, config_type: config.config_type, file_path: config.file_path || "", port_override: config.port_override?.toString() || "", start_command: config.start_command || "", working_directory: config.working_directory || "", env_vars_json: config.environment_variables ? JSON.stringify(config.environment_variables, null, 2) : "{}", volumes_json: config.volumes ? JSON.stringify(config.volumes, null, 2) : "[]", }); setConfigError(null); setShowConfigForm(true); setSelectedConfig(config); }; const handleConfigSubmit = async (e: React.FormEvent) => { e.preventDefault(); setConfigError(null); if (!selectedToolTypeId || !configForm.key.trim()) { setConfigError("Tool type and key are required"); return; } let envVars: Record | undefined; let volumes: Array<{ source: string; target: string; type?: string }> | undefined; try { if (configForm.env_vars_json.trim() && configForm.env_vars_json.trim() !== "{}") { envVars = JSON.parse(configForm.env_vars_json); } } catch { setConfigError("Environment variables must be valid JSON"); return; } try { if (configForm.volumes_json.trim() && configForm.volumes_json.trim() !== "[]") { volumes = JSON.parse(configForm.volumes_json); } } catch { setConfigError("Volumes must be valid JSON array"); return; } const data: CreateToolConfigRequest = { tool_type_id: selectedToolTypeId, key: configForm.key.trim(), value: configForm.value, config_type: configForm.config_type, file_path: configForm.config_type === "file" ? configForm.file_path : undefined, port_override: configForm.port_override ? Number(configForm.port_override) : undefined, start_command: configForm.start_command.trim() || undefined, working_directory: configForm.working_directory.trim() || undefined, environment_variables: envVars, volumes, }; try { if (selectedConfig) { await updateToolConfig(selectedConfig.id, data); } else { await createToolConfig(data); } setShowConfigForm(false); setSelectedConfig(null); resetConfigForm(); await loadData(); } catch (err) { setConfigError(extractErrorMessage(err)); } }; const handleDeleteConfig = async (id: string) => { if (!window.confirm("Delete this config?")) return; try { await deleteToolConfig(id); await loadData(); } catch { alert("Failed to delete config"); } }; // Folder handlers const resetFolderForm = () => { setFolderForm({ name: "", description: "", mount_path: "/home/user", files_json: "{}", is_active: true, }); setFolderError(null); setSelectedFolder(null); }; const openCreateFolder = () => { resetFolderForm(); setShowFolderForm(true); }; const openEditFolder = (folder: ConfigFolder) => { setFolderForm({ name: folder.name, description: folder.description || "", mount_path: folder.mount_path, files_json: JSON.stringify(folder.files, null, 2), is_active: folder.is_active, }); setFolderError(null); setShowFolderForm(true); setSelectedFolder(folder); }; const handleFolderSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFolderError(null); if (!folderForm.name.trim() || !folderForm.mount_path.trim()) { setFolderError("Name and mount path are required"); return; } let files: Record | undefined; try { if (folderForm.files_json.trim() && folderForm.files_json.trim() !== "{}") { files = JSON.parse(folderForm.files_json); } } catch { setFolderError("Files must be valid JSON object"); return; } const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = { name: folderForm.name.trim(), description: folderForm.description.trim() || undefined, mount_path: folderForm.mount_path.trim(), files, is_active: folderForm.is_active, }; try { if (selectedFolder) { await updateConfigFolder(selectedFolder.id, data); } else { await createConfigFolder(data as CreateConfigFolderRequest); } setShowFolderForm(false); setSelectedFolder(null); resetFolderForm(); await loadData(); } catch (err) { setFolderError(extractErrorMessage(err)); } }; const handleDeleteFolder = async (id: string) => { if (!window.confirm("Delete this config folder?")) return; try { await deleteConfigFolder(id); await loadData(); } catch { alert("Failed to delete folder"); } }; if (status === "loading") { return (
); } if (status === "error") { return (
); } if (isMobile) { if (mobileView === "list") { return (

Tool Workshop

{toolTypes.length} tool types
({ id: t.id, title: t.display_name, subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`, }))} onItemClick={(id) => { setSelectedToolTypeId(id); setIsCreating(false); setMobileView("detail"); }} emptyMessage="No tool types yet" /> { setSelectedToolTypeId(null); setIsCreating(true); resetToolTypeForm(); setMobileView("edit"); }} />
); } if (mobileView === "detail" && selectedToolType) { return ( { populateToolTypeForm(selectedToolType); setIsCreating(false); setMobileView("edit"); }} onDelete={() => { handleDeleteToolType(selectedToolType.id); setMobileView("list"); }} onBack={() => { setSelectedToolTypeId(null); setMobileView("list"); }} /> ); } if (mobileView === "edit") { return ( { if (toolTypeDirty) { if (!window.confirm("You have unsaved changes. Discard them?")) { return; } } setMobileView(isCreating ? "list" : "detail"); }} onSave={() => { // Create a synthetic form event to call handleToolTypeSubmit const syntheticEvent = { preventDefault: () => {} } as React.FormEvent; void handleToolTypeSubmit(syntheticEvent); if (!toolTypeError) { setMobileView("list"); } }} isSaving={false} > {/* Tool Type Form Fields */}
setToolTypeForm({ ...toolTypeForm, name: e.target.value })} className="mobile-form-input" placeholder="e.g., my-tool" />
setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })} className="mobile-form-input" placeholder="e.g., My Tool" />