ab79080f0b
Frontend: - Create reusable DataStates components (LoadingState, ErrorState, EmptyState) - Refactor 12 pages to use shared state components instead of inline JSX - Extract useInstanceActions hook to eliminate session action duplication - Update dashboard and sessions pages to use shared hook OpenSpec: - Archive completed mobile-app-usability change (44/44 tasks) - Archive completed add-config-profiles change (15/15 tasks) Quality: TypeScript check passes, production build succeeds
1482 lines
58 KiB
TypeScript
1482 lines
58 KiB
TypeScript
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<MobileView>("list");
|
|
const [status, setStatus] = useState<Status>("loading");
|
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
|
const [folders, setFolders] = useState<ConfigFolder[]>([]);
|
|
|
|
// Selection state
|
|
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("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<string | null>(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<string | null>(null);
|
|
const [showConfigForm, setShowConfigForm] = useState(false);
|
|
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
|
|
|
// Folder form state
|
|
const [folderForm, setFolderForm] = useState({
|
|
name: "",
|
|
description: "",
|
|
mount_path: "/home/user",
|
|
files_json: "{}",
|
|
is_active: true,
|
|
});
|
|
const [folderError, setFolderError] = useState<string | null>(null);
|
|
const [showFolderForm, setShowFolderForm] = useState(false);
|
|
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(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<string, string> | 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<string, string> | 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 (
|
|
<div className="container">
|
|
<LoadingState message="Loading Tool Workshop..." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === "error") {
|
|
return (
|
|
<div className="container">
|
|
<ErrorState message="Failed to load Tool Workshop." onRetry={loadData} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isMobile) {
|
|
if (mobileView === "list") {
|
|
return (
|
|
<div className="mobile-page">
|
|
<div className="mobile-page-header">
|
|
<h1>Tool Workshop</h1>
|
|
<span className="muted">{toolTypes.length} tool types</span>
|
|
</div>
|
|
<MobileListView
|
|
items={toolTypes.map((t) => ({
|
|
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"
|
|
/>
|
|
<MobileFAB onClick={() => {
|
|
setSelectedToolTypeId(null);
|
|
setIsCreating(true);
|
|
resetToolTypeForm();
|
|
setMobileView("edit");
|
|
}} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mobileView === "detail" && selectedToolType) {
|
|
return (
|
|
<MobileDetailView
|
|
title={selectedToolType.display_name}
|
|
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
|
|
fields={[
|
|
{ label: "Name", value: selectedToolType.name },
|
|
{ label: "Display Name", value: selectedToolType.display_name },
|
|
{ label: "Description", value: selectedToolType.description },
|
|
{ label: "Category", value: selectedToolType.category },
|
|
{ label: "Interface Type", value: selectedToolType.interface_type },
|
|
{ label: "Requires Port", value: selectedToolType.requires_port, type: "boolean" },
|
|
{ label: "Default Port", value: selectedToolType.default_port },
|
|
{ label: "Definition Type", value: selectedToolType.definition_type },
|
|
{ label: "Startup Command", value: selectedToolType.startup_command },
|
|
{ label: "Readiness Command", value: selectedToolType.readiness_probe?.command ?? null },
|
|
{ label: "Readiness Timeout", value: selectedToolType.readiness_probe?.timeout ?? null },
|
|
{ label: "Readiness Interval", value: selectedToolType.readiness_probe?.interval ?? null },
|
|
{ label: "Required Variables", value: selectedToolType.required_variables?.join(", ") ?? null },
|
|
{ label: "Compose Template", value: selectedToolType.compose_template, type: "code" },
|
|
{ label: "Dockerfile Template", value: selectedToolType.dockerfile_template, type: "code" },
|
|
]}
|
|
onEdit={() => {
|
|
populateToolTypeForm(selectedToolType);
|
|
setIsCreating(false);
|
|
setMobileView("edit");
|
|
}}
|
|
onDelete={() => {
|
|
handleDeleteToolType(selectedToolType.id);
|
|
setMobileView("list");
|
|
}}
|
|
onBack={() => {
|
|
setSelectedToolTypeId(null);
|
|
setMobileView("list");
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (mobileView === "edit") {
|
|
return (
|
|
<MobileEditView
|
|
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
|
|
onCancel={() => {
|
|
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 */}
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Name *</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.name}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, name: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="e.g., my-tool"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Display Name *</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.display_name}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="e.g., My Tool"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Description</label>
|
|
<textarea
|
|
value={toolTypeForm.description}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })}
|
|
className="mobile-form-textarea"
|
|
placeholder="What does this tool do?"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Category</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.category}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, category: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="e.g., development"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Interface Type</label>
|
|
<select
|
|
value={toolTypeForm.interface_type}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, interface_type: e.target.value as "web" | "terminal" })}
|
|
className="mobile-form-select"
|
|
>
|
|
<option value="web">Web</option>
|
|
<option value="terminal">Terminal</option>
|
|
</select>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Requires Port</label>
|
|
<input
|
|
type="checkbox"
|
|
checked={toolTypeForm.requires_port}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, requires_port: e.target.checked })}
|
|
className="mobile-form-checkbox"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Default Port</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.default_port}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="e.g., 8080"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Definition Type</label>
|
|
<select
|
|
value={toolTypeForm.definition_type}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" })}
|
|
className="mobile-form-select"
|
|
>
|
|
<option value="compose">Compose</option>
|
|
<option value="dockerfile">Dockerfile</option>
|
|
</select>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Startup Command</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.startup_command}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, startup_command: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="Command to run on startup"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Readiness Command</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.readiness_command}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="e.g., curl -f http://localhost:8080/health"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Readiness Timeout</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.readiness_timeout}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="30"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Readiness Interval</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.readiness_interval}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="2"
|
|
/>
|
|
</div>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Required Variables</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.required_variables}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })}
|
|
className="mobile-form-input"
|
|
placeholder="VAR1, VAR2, VAR3"
|
|
/>
|
|
</div>
|
|
{toolTypeForm.definition_type === "compose" && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Compose Template</label>
|
|
<textarea
|
|
value={toolTypeForm.compose_template}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, compose_template: e.target.value })}
|
|
className="mobile-form-textarea mobile-form-code"
|
|
placeholder="version: '3'"
|
|
rows={10}
|
|
/>
|
|
</div>
|
|
)}
|
|
{toolTypeForm.definition_type === "dockerfile" && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Dockerfile Template</label>
|
|
<textarea
|
|
value={toolTypeForm.dockerfile_template}
|
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value })}
|
|
className="mobile-form-textarea mobile-form-code"
|
|
placeholder="FROM ubuntu:22.04"
|
|
rows={10}
|
|
/>
|
|
</div>
|
|
)}
|
|
</MobileEditView>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
|
|
{/* Left Sidebar - Tool List */}
|
|
<div
|
|
style={{
|
|
width: "280px",
|
|
minWidth: "280px",
|
|
borderRight: "1px solid var(--border)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
background: "var(--panel)",
|
|
}}
|
|
>
|
|
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
|
|
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
|
|
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
|
|
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
|
{toolTypes.map((toolType) => (
|
|
<button
|
|
key={toolType.id}
|
|
onClick={() => handleSelectToolType(toolType)}
|
|
style={{
|
|
width: "100%",
|
|
textAlign: "left",
|
|
padding: "0.75rem 1rem",
|
|
marginBottom: "0.25rem",
|
|
borderRadius: "0.375rem",
|
|
border: "none",
|
|
background: selectedToolTypeId === toolType.id ? "var(--brand)" : "transparent",
|
|
color: selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
transition: "background 0.15s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (selectedToolTypeId !== toolType.id) {
|
|
e.currentTarget.style.background = "#ece7df";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (selectedToolTypeId !== toolType.id) {
|
|
e.currentTarget.style.background = "transparent";
|
|
}
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600, fontSize: "0.9375rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{toolType.display_name}
|
|
</div>
|
|
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
|
{toolType.category || "Uncategorized"} · {toolType.interface_type === "web" ? `Port ${toolType.default_port}` : "Terminal"}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteToolType(toolType.id);
|
|
}}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: selectedToolTypeId === toolType.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
|
|
cursor: "pointer",
|
|
padding: "0.25rem",
|
|
borderRadius: "0.25rem",
|
|
flexShrink: 0,
|
|
opacity: 0,
|
|
}}
|
|
className="delete-btn"
|
|
title="Delete tool type"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
|
<button
|
|
onClick={handleCreateNew}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: "0.5rem",
|
|
padding: "0.75rem",
|
|
borderRadius: "0.5rem",
|
|
border: "2px dashed var(--border)",
|
|
background: "transparent",
|
|
color: "var(--muted)",
|
|
cursor: "pointer",
|
|
fontWeight: 600,
|
|
transition: "all 0.15s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--brand)";
|
|
e.currentTarget.style.color = "var(--brand)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--border)";
|
|
e.currentTarget.style.color = "var(--muted)";
|
|
}}
|
|
>
|
|
<Icon name="add" size="sm" /> New Tool Type
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Panel - Editor */}
|
|
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
|
{!selectedToolTypeId && !isCreating ? (
|
|
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
|
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
|
<Icon name="code" size="lg" />
|
|
</div>
|
|
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a tool type</h3>
|
|
<p style={{ margin: 0 }}>Choose a tool from the list to edit, or create a new one.</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: "1.5rem" }}>
|
|
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
|
{isCreating ? "Create Tool Type" : selectedToolType?.display_name}
|
|
</h1>
|
|
{!isCreating && (
|
|
<p className="muted" style={{ margin: 0 }}>
|
|
{selectedToolType?.name} · {selectedToolType?.definition_type} · {selectedToolType?.interface_type === "web" ? `Port ${selectedToolType?.default_port}` : "Terminal"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Panel Tabs */}
|
|
<div style={{ display: "flex", gap: "0", marginBottom: "1.5rem", borderBottom: "1px solid var(--border)" }}>
|
|
{(["details", "configs", "folders"] as RightPanelTab[]).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setRightPanelTab(tab)}
|
|
style={{
|
|
padding: "0.625rem 1.25rem",
|
|
borderBottom: rightPanelTab === tab ? "2px solid var(--brand)" : "2px solid transparent",
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
fontWeight: rightPanelTab === tab ? 600 : 400,
|
|
color: rightPanelTab === tab ? "var(--brand)" : "var(--muted)",
|
|
fontSize: "0.9375rem",
|
|
marginBottom: "-1px",
|
|
textTransform: "capitalize",
|
|
}}
|
|
>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Details Tab */}
|
|
{rightPanelTab === "details" && (
|
|
<form onSubmit={handleToolTypeSubmit} className="stack" style={{ gap: "1rem", maxWidth: "800px" }}>
|
|
<div className="form-group">
|
|
<label htmlFor="definition-type">Definition Type</label>
|
|
<select
|
|
id="definition-type"
|
|
value={toolTypeForm.definition_type}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
className="form-input"
|
|
disabled={!isCreating}
|
|
>
|
|
<option value="compose">Docker Compose</option>
|
|
<option value="dockerfile">Dockerfile</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label htmlFor="tool-type-name">Name *</label>
|
|
<input
|
|
id="tool-type-name"
|
|
type="text"
|
|
value={toolTypeForm.name}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, name: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
disabled={!isCreating}
|
|
placeholder="e.g., code-server"
|
|
className="form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label htmlFor="tool-type-display-name">Display Name *</label>
|
|
<input
|
|
id="tool-type-display-name"
|
|
type="text"
|
|
value={toolTypeForm.display_name}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, display_name: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="e.g., VS Code Server"
|
|
className="form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="tool-type-description">Description</label>
|
|
<input
|
|
id="tool-type-description"
|
|
type="text"
|
|
value={toolTypeForm.description}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, description: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="Optional description"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label htmlFor="tool-type-category">Category</label>
|
|
<input
|
|
id="tool-type-category"
|
|
type="text"
|
|
value={toolTypeForm.category}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, category: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="e.g., editor, notebook, ai-assistant"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label htmlFor="tool-type-interface">Interface Type</label>
|
|
<select
|
|
id="tool-type-interface"
|
|
value={toolTypeForm.interface_type}
|
|
onChange={(e) => {
|
|
const value = e.target.value as "web" | "terminal";
|
|
setToolTypeForm({
|
|
...toolTypeForm,
|
|
interface_type: value,
|
|
requires_port: value === "web",
|
|
default_port: value === "web" ? toolTypeForm.default_port : "",
|
|
});
|
|
setToolTypeDirty(true);
|
|
}}
|
|
className="form-input"
|
|
>
|
|
<option value="web">Web</option>
|
|
<option value="terminal">Terminal</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{toolTypeForm.interface_type === "terminal" && (
|
|
<div className="form-group">
|
|
<label htmlFor="tool-type-startup-command">Startup Command</label>
|
|
<input
|
|
id="tool-type-startup-command"
|
|
type="text"
|
|
value={toolTypeForm.startup_command}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, startup_command: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="e.g., cd /workspace && ls"
|
|
className="form-input"
|
|
/>
|
|
<small className="form-help">Command to run before the interactive shell for each new terminal session.</small>
|
|
</div>
|
|
)}
|
|
|
|
{toolTypeForm.requires_port && (
|
|
<div className="form-group">
|
|
<label htmlFor="tool-type-default-port">Default Port *</label>
|
|
<input
|
|
id="tool-type-default-port"
|
|
type="number"
|
|
value={toolTypeForm.default_port}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, default_port: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="e.g., 8443"
|
|
className="form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="tool-type-template">{toolTypeForm.definition_type === "compose" ? "Compose Template" : "Dockerfile"} *</label>
|
|
<textarea
|
|
id="tool-type-template"
|
|
value={toolTypeForm.definition_type === "compose" ? toolTypeForm.compose_template : toolTypeForm.dockerfile_template}
|
|
onChange={(e) => {
|
|
if (toolTypeForm.definition_type === "compose") {
|
|
setToolTypeForm({ ...toolTypeForm, compose_template: e.target.value });
|
|
} else {
|
|
setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value });
|
|
}
|
|
setToolTypeDirty(true);
|
|
}}
|
|
rows={12}
|
|
placeholder={toolTypeForm.definition_type === "compose" ? "version: '3.8'\nservices:\n app:\n image: ..." : "FROM node:18\nWORKDIR /app\n..."}
|
|
className="form-input"
|
|
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
|
<input
|
|
id="readiness-command"
|
|
type="text"
|
|
value={toolTypeForm.readiness_command}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="e.g., curl -f http://localhost:8080"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
|
<input
|
|
id="readiness-timeout"
|
|
type="number"
|
|
value={toolTypeForm.readiness_timeout}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
|
<input
|
|
id="readiness-interval"
|
|
type="number"
|
|
value={toolTypeForm.readiness_interval}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Required Variables (comma-separated)</label>
|
|
<input
|
|
type="text"
|
|
value={toolTypeForm.required_variables}
|
|
onChange={(e) => {
|
|
setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value });
|
|
setToolTypeDirty(true);
|
|
}}
|
|
placeholder="REPO_PATH, TOOL_NAME"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
|
|
|
|
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
|
|
<button type="submit">
|
|
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
|
{isCreating ? "Create Tool Type" : "Save Changes"}
|
|
</button>
|
|
{toolTypeDirty && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isCreating) {
|
|
resetToolTypeForm();
|
|
} else if (selectedToolType) {
|
|
populateToolTypeForm(selectedToolType);
|
|
}
|
|
}}
|
|
className="button-secondary"
|
|
>
|
|
<Icon name="cancel" size="sm" /> Discard
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Configs Tab */}
|
|
{rightPanelTab === "configs" && selectedToolTypeId && (
|
|
<div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
|
<h3 style={{ margin: 0 }}>Configurations for {selectedToolType?.display_name}</h3>
|
|
<button onClick={openCreateConfig}>
|
|
<Icon name="add" size="sm" /> Add Config
|
|
</button>
|
|
</div>
|
|
|
|
{showConfigForm && (
|
|
<div className="card stack" style={{ marginBottom: "1rem", padding: "1rem" }}>
|
|
<h4>{selectedConfig ? "Edit" : "Add"} Config</h4>
|
|
<form onSubmit={handleConfigSubmit} className="stack" style={{ gap: "0.75rem" }}>
|
|
<div className="form-group">
|
|
<label>Key *</label>
|
|
<input
|
|
type="text"
|
|
value={configForm.key}
|
|
onChange={(e) => setConfigForm({ ...configForm, key: e.target.value })}
|
|
placeholder="e.g., OPENAI_API_KEY"
|
|
className="form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Config Type</label>
|
|
<select
|
|
value={configForm.config_type}
|
|
onChange={(e) => setConfigForm({ ...configForm, config_type: e.target.value })}
|
|
className="form-input"
|
|
>
|
|
<option value="env">Environment Variable</option>
|
|
<option value="file">Configuration File</option>
|
|
</select>
|
|
</div>
|
|
|
|
{configForm.config_type === "file" && (
|
|
<div className="form-group">
|
|
<label>File Path</label>
|
|
<input
|
|
type="text"
|
|
value={configForm.file_path}
|
|
onChange={(e) => setConfigForm({ ...configForm, file_path: e.target.value })}
|
|
placeholder="e.g., /app/config.json"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-group">
|
|
<label>Value</label>
|
|
<textarea
|
|
value={configForm.value}
|
|
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
|
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
|
className="form-input"
|
|
rows={configForm.config_type === "file" ? 8 : 2}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Port Override</label>
|
|
<input
|
|
type="number"
|
|
value={configForm.port_override}
|
|
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
|
placeholder="e.g., 8080"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Start Command</label>
|
|
<input
|
|
type="text"
|
|
value={configForm.start_command}
|
|
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
|
placeholder="e.g., npm start"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Working Directory</label>
|
|
<input
|
|
type="text"
|
|
value={configForm.working_directory}
|
|
onChange={(e) => setConfigForm({ ...configForm, working_directory: e.target.value })}
|
|
placeholder="e.g., /workspace"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Environment Variables (JSON)</label>
|
|
<textarea
|
|
value={configForm.env_vars_json}
|
|
onChange={(e) => setConfigForm({ ...configForm, env_vars_json: e.target.value })}
|
|
placeholder='{"KEY": "value"}'
|
|
className="form-input"
|
|
rows={3}
|
|
style={{ fontFamily: "monospace", fontSize: "0.8125rem" }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Volumes (JSON array)</label>
|
|
<textarea
|
|
value={configForm.volumes_json}
|
|
onChange={(e) => setConfigForm({ ...configForm, volumes_json: e.target.value })}
|
|
placeholder='[{"source": "/host", "target": "/container"}]'
|
|
className="form-input"
|
|
rows={3}
|
|
style={{ fontFamily: "monospace", fontSize: "0.8125rem" }}
|
|
/>
|
|
</div>
|
|
|
|
{configError && <p className="text-error">{configError}</p>}
|
|
|
|
<div className="dialog-actions">
|
|
<button type="submit">{selectedConfig ? "Update" : "Add"}</button>
|
|
<button type="button" onClick={() => { setShowConfigForm(false); resetConfigForm(); }} className="button-secondary">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{toolConfigs.length === 0 ? (
|
|
<EmptyState message="No configurations for this tool type yet." />
|
|
) : (
|
|
toolConfigs.map((config) => (
|
|
<div
|
|
key={config.id}
|
|
className="card"
|
|
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "0.75rem 1rem",
|
|
}}
|
|
>
|
|
|
|
<div>
|
|
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
|
|
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
|
<span
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
textTransform: "uppercase",
|
|
padding: "0.125rem 0.5rem",
|
|
borderRadius: "9999px",
|
|
background: config.config_type === "env" ? "var(--info, #3b82f6)" : "var(--warning, #f59e0b)",
|
|
color: "white",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{config.config_type}
|
|
</span>
|
|
</div>
|
|
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
|
|
{config.config_type === "file" && config.file_path
|
|
? `File: ${config.file_path}`
|
|
: "Environment variable"}
|
|
{config.port_override && ` · Port: ${config.port_override}`}
|
|
</p>
|
|
</div>
|
|
<div className="row" style={{ gap: "0.5rem" }}>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={() => openEditConfig(config)}
|
|
type="button"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
</button>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={() => handleDeleteConfig(config.id)}
|
|
type="button"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Folders Tab */}
|
|
{rightPanelTab === "folders" && (
|
|
<div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
|
<h3 style={{ margin: 0 }}>Config Folders</h3>
|
|
<button onClick={openCreateFolder}>
|
|
<Icon name="add" size="sm" /> Create Folder
|
|
</button>
|
|
</div>
|
|
|
|
{showFolderForm && (
|
|
<div className="card stack" style={{ marginBottom: "1rem", padding: "1rem" }}>
|
|
<h4>{selectedFolder ? "Edit" : "Create"} Config Folder</h4>
|
|
<form onSubmit={handleFolderSubmit} className="stack" style={{ gap: "0.75rem" }}>
|
|
<div className="form-group">
|
|
<label>Name *</label>
|
|
<input
|
|
type="text"
|
|
value={folderForm.name}
|
|
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
|
placeholder="e.g., my-dotfiles"
|
|
className="form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Description</label>
|
|
<input
|
|
type="text"
|
|
value={folderForm.description}
|
|
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
|
placeholder="Optional description"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Mount Path *</label>
|
|
<input
|
|
type="text"
|
|
value={folderForm.mount_path}
|
|
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
|
placeholder="e.g., /home/user"
|
|
className="form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Files (JSON object)</label>
|
|
<textarea
|
|
value={folderForm.files_json}
|
|
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
|
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
|
className="form-input"
|
|
rows={8}
|
|
style={{ fontFamily: "monospace", fontSize: "0.8125rem" }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={folderForm.is_active}
|
|
onChange={(e) => setFolderForm({ ...folderForm, is_active: e.target.checked })}
|
|
/>
|
|
Active (mount into new instances)
|
|
</label>
|
|
</div>
|
|
|
|
{folderError && <p className="text-error">{folderError}</p>}
|
|
|
|
<div className="dialog-actions">
|
|
<button type="submit">{selectedFolder ? "Update" : "Create"}</button>
|
|
<button type="button" onClick={() => { setShowFolderForm(false); resetFolderForm(); }} className="button-secondary">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card-grid">
|
|
{toolFolders.map((folder) => (
|
|
<div key={folder.id} className="card">
|
|
<div className="card-header">
|
|
<h4>{folder.name}</h4>
|
|
{folder.is_active && <span className="badge">Active</span>}
|
|
</div>
|
|
<p className="text-secondary">{folder.description || "No description"}</p>
|
|
<div className="tool-type-meta">
|
|
<span>Mount: {folder.mount_path}</span>
|
|
<span>Files: {Object.keys(folder.files || {}).length}</span>
|
|
</div>
|
|
<div className="card-actions">
|
|
<button onClick={() => openEditFolder(folder)} className="button-secondary">
|
|
<Icon name="edit" size="sm" /> Edit
|
|
</button>
|
|
<button onClick={() => handleDeleteFolder(folder.id)} className="button-danger">
|
|
<Icon name="delete" size="sm" /> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|