fix: remove legacy config APIs

Remove stale ToolConfig and ConfigFolder backend/frontend surfaces after the ConfigProfile refactor. Drop dead routers, schemas, model exports, frontend routes, clients, pages, and tests; keep ToolType API compatibility for existing interface/is_builtin response shape.

Quality gates: backend LSP diagnostics passed; backend py_compile passed; backend ruff passed; frontend ToolWorkshopPage test passed. Frontend typecheck blocked by unrelated missing xterm-addon-serialize types.
This commit is contained in:
2026-06-03 12:40:47 +02:00
parent b6f89f9df0
commit 4201326467
33 changed files with 290 additions and 2932 deletions
-131
View File
@@ -1,131 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
} from "../api/config-folders";
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("config_folders API", () => {
describe("listConfigFolders", () => {
it("returns folders with files and overrides", async () => {
const mockResponse = {
data: [
{
id: "folder-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listConfigFolders();
expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
expect(mockGet).toHaveBeenCalledWith("/config-folders");
});
});
describe("createConfigFolder", () => {
it("creates folder with files", async () => {
const mockResponse = {
data: {
id: "folder-new",
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createConfigFolder({
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
});
expect(result.name).toBe("new-folder");
expect(mockPost).toHaveBeenCalledWith(
"/config-folders",
expect.objectContaining({
name: "new-folder",
mount_path: "/workspace",
})
);
});
});
describe("updateConfigFolder", () => {
it("updates folder files", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "updated-folder",
mount_path: "/home/user",
files: { ".bashrc": "alias ll='ls -la'" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPut.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
files: { ".bashrc": "alias ll='ls -la'" },
});
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
expect(mockPut).toHaveBeenCalledWith(
"/config-folders/folder-1",
expect.objectContaining({
files: { ".bashrc": "alias ll='ls -la'" },
})
);
});
});
describe("deleteConfigFolder", () => {
it("deletes folder", async () => {
mockDelete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1");
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
});
});
});
-77
View File
@@ -1,77 +0,0 @@
import { apiClient } from "./client";
import type {
ConfigFolder,
CreateConfigFolderRequest,
UpdateConfigFolderRequest,
ProjectOverrideRequest,
} from "../types/config-folder";
export type {
ConfigFolder,
CreateConfigFolderRequest,
UpdateConfigFolderRequest,
ProjectOverrideRequest,
} from "../types/config-folder";
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
return response.data;
};
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
return response.data;
};
export const createConfigFolder = async (
data: CreateConfigFolderRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data;
};
export const updateConfigFolder = async (
id: string,
data: UpdateConfigFolderRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}`,
data,
);
return response.data;
};
export const deleteConfigFolder = async (id: string): Promise<void> => {
await apiClient.delete(`/config-folders/${id}`);
};
export const addProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data,
);
return response.data;
};
export const updateProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest,
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data,
);
return response.data;
};
export const deleteProjectOverride = async (
id: string,
projectId: string,
): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
};
-52
View File
@@ -1,52 +0,0 @@
import { apiClient } from "./client";
import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
export const listToolConfigs = async (
tool_type_id?: string,
project_id?: string,
): Promise<ToolConfig[]> => {
const params = new URLSearchParams();
if (tool_type_id) params.append("tool_type_id", tool_type_id);
if (project_id) params.append("project_id", project_id);
const response = await apiClient.get<{ configs: ToolConfig[] }>(
`/tool-configs?${params.toString()}`,
);
return response.data.configs;
};
export const createToolConfig = async (
data: CreateToolConfigRequest,
): Promise<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>(
"/tool-configs",
data,
);
return response.data.configs[0];
};
export const updateToolConfig = async (
id: string,
data: CreateToolConfigRequest,
): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`,
data,
);
return response.data.configs[0];
};
export const deleteToolConfig = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-configs/${id}`);
};
export const getToolConfigDefaults = async (
toolTypeId: string,
): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}`,
);
return response.data;
};
@@ -1,129 +0,0 @@
import { useState } from "react";
import type { ToolConfig } from "../../../types/tool-config";
interface ToolConfigFormProps {
editingConfig: ToolConfig | null;
onSubmit: (data: {
key: string;
value: string;
config_type: string;
file_path: string;
}) => Promise<void>;
onCancel: () => void;
}
export const ToolConfigForm = ({
editingConfig,
onSubmit,
onCancel,
}: ToolConfigFormProps) => {
const [formData, setFormData] = useState({
key: editingConfig?.key ?? "",
value: editingConfig?.value ?? "",
config_type: editingConfig?.config_type ?? "env",
file_path: editingConfig?.file_path ?? "",
});
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
await onSubmit(formData);
setSaveStatus("saved");
} catch {
setSaveStatus("error");
}
};
return (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) =>
setFormData({ ...formData, value: e.target.value })
}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div
className="row"
style={{ gap: "0.5rem", justifyContent: "flex-end" }}
>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
);
};
@@ -1,84 +0,0 @@
import { Icon } from "../../ui/Icon";
import type { ToolConfig } from "../../../types/tool-config";
interface ToolConfigListProps {
configs: ToolConfig[];
onEdit: (config: ToolConfig) => void;
onDelete: (id: string) => void;
}
export const ToolConfigList = ({
configs,
onEdit,
onDelete,
}: ToolConfigListProps) => {
if (configs.length === 0) {
return <p className="muted">No configurations for this tool yet.</p>;
}
return (
<div className="stack" style={{ gap: "0.5rem" }}>
{configs.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
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background:
config.config_type === "env"
? "var(--color-info)"
: "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{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"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => onEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void onDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
);
};
@@ -1,2 +0,0 @@
export { ToolConfigForm } from "./ToolConfigForm";
export { ToolConfigList } from "./ToolConfigList";
@@ -1,289 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../ui/Icon";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
type ConfigFolder,
type CreateConfigFolderRequest,
type UpdateConfigFolderRequest,
} from "../../../api/config-folders";
export const ConfigFoldersTab = () => {
const [folders, setFolders] = useState<ConfigFolder[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(
null,
);
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 loadFolders = useCallback(async () => {
setStatus("loading");
try {
const data = await listConfigFolders();
setFolders(data);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadFolders();
}, [loadFolders]);
const openCreateFolder = () => {
setFolderForm({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
setFolderError(null);
setShowFolderForm(true);
setSelectedFolder(null);
};
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);
await loadFolders();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
setFolderError(
axiosError?.response?.data?.detail || "Failed to save folder",
);
}
};
const handleDeleteFolder = async (id: string) => {
if (!window.confirm("Delete this config folder?")) return;
try {
await deleteConfigFolder(id);
await loadFolders();
} catch {
alert("Failed to delete folder");
}
};
if (status === "loading") {
return <p className="muted">Loading Config Folders...</p>;
}
if (status === "error") {
return (
<div className="card stack">
<p className="text-error">Failed to load config folders.</p>
<button onClick={() => void loadFolders()}>
<Icon name="refresh" size="sm" /> Retry
</button>
</div>
);
}
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Config Folders</h2>
<button onClick={openCreateFolder}>
<Icon name="add" size="sm" /> Create Folder
</button>
</div>
{showFolderForm && (
<div className="card stack" style={{ marginBottom: "1rem" }}>
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
<form onSubmit={handleFolderSubmit} className="stack">
<div className="form-group">
<label htmlFor="folder-name">Name *</label>
<input
id="folder-name"
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 htmlFor="folder-description">Description</label>
<input
id="folder-description"
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 htmlFor="folder-mount-path">Mount Path *</label>
<input
id="folder-mount-path"
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 htmlFor="folder-files">Files (JSON object)</label>
<textarea
id="folder-files"
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}
/>
</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)}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card-grid">
{folders.map((folder) => (
<div key={folder.id} className="card">
<div className="card-header">
<h3>{folder.name}</h3>
{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>
);
};
@@ -1,460 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../ui/Icon";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type CreateToolConfigRequest,
type ToolConfig,
} from "../../../api/tool-configs";
import { listToolTypes, type ToolType } from "../../../api/tool-types";
export const ToolConfigsTab = () => {
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
const [configForm, setConfigForm] = useState({
tool_type_id: "",
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 loadData = useCallback(async () => {
setStatus("loading");
try {
const [cfgs, types] = await Promise.all([
listToolConfigs(),
listToolTypes(),
]);
setConfigs(cfgs);
setToolTypes(types);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const openCreateConfig = () => {
setConfigForm({
tool_type_id: toolTypes[0]?.id || "",
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
setConfigError(null);
setShowConfigForm(true);
setSelectedConfig(null);
};
const openEditConfig = (config: ToolConfig) => {
setConfigForm({
tool_type_id: config.tool_type_id,
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 (!configForm.tool_type_id || !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: configForm.tool_type_id,
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);
await loadData();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
setConfigError(
axiosError?.response?.data?.detail || "Failed to save config",
);
}
};
const handleDeleteConfig = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
alert("Failed to delete config");
}
};
if (status === "loading") {
return <p className="muted">Loading Configurations...</p>;
}
if (status === "error") {
return (
<div className="card stack">
<p className="text-error">Failed to load configurations.</p>
<button onClick={() => void loadData()}>
<Icon name="refresh" size="sm" /> Retry
</button>
</div>
);
}
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Tool Configurations</h2>
<button onClick={openCreateConfig}>
<Icon name="add" size="sm" /> Add Config
</button>
</div>
{showConfigForm && (
<div className="card stack" style={{ marginBottom: "1rem" }}>
<h3>{selectedConfig ? "Edit" : "Add"} Config</h3>
<form onSubmit={handleConfigSubmit} className="stack">
<div className="form-group">
<label htmlFor="config-tool-type">Tool Type *</label>
<select
id="config-tool-type"
value={configForm.tool_type_id}
onChange={(e) =>
setConfigForm({ ...configForm, tool_type_id: e.target.value })
}
className="form-input"
required
>
<option value="">Select a tool type...</option>
{toolTypes.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="config-key">Key *</label>
<input
id="config-key"
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 htmlFor="config-type">Config Type</label>
<select
id="config-type"
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 htmlFor="config-value">Value</label>
<textarea
id="config-value"
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 htmlFor="config-port-override">Port Override</label>
<input
id="config-port-override"
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 htmlFor="config-start-command">Start Command</label>
<input
id="config-start-command"
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={4}
/>
</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={4}
/>
</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)}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="stack" style={{ gap: "0.5rem" }}>
{configs.length === 0 ? (
<p className="muted">No configurations yet.</p>
) : (
configs.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
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background:
config.config_type === "env"
? "var(--color-info)"
: "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{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>
);
};
@@ -196,7 +196,7 @@ export const ToolTypesTab = () => {
const handleDeleteToolType = async (id: string) => {
if (
!window.confirm(
"Delete this tool type? All associated configs will be removed.",
"Delete this tool type? Existing instances using it may be affected.",
)
)
return;
@@ -327,33 +327,22 @@ export const ToolTypesTab = () => {
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
{["web", "terminal"].map((iface) => (
<label key={iface} className="checkbox-label">
<input
type="checkbox"
checked={toolTypeForm.interfaces.includes(iface)}
onChange={(e) => {
if (e.target.checked) {
setToolTypeForm({
...toolTypeForm,
interfaces: [...toolTypeForm.interfaces, iface],
});
} else {
setToolTypeForm({
...toolTypeForm,
interfaces: toolTypeForm.interfaces.filter(
(i) => i !== iface,
),
});
}
}}
/>
{iface}
</label>
))}
</div>
<label htmlFor="tool-type-interface">Interface</label>
<select
id="tool-type-interface"
value={toolTypeForm.interfaces[0] ?? ""}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
interfaces: e.target.value ? [e.target.value] : [],
})
}
className="form-input"
>
<option value="">Select interface</option>
<option value="web">web</option>
<option value="terminal">terminal</option>
</select>
</div>
<div className="form-group">
@@ -1,3 +1 @@
export { ToolTypesTab } from "./ToolTypesTab";
export { ToolConfigsTab } from "./ToolConfigsTab";
export { ConfigFoldersTab } from "./ConfigFoldersTab";
+1 -2
View File
@@ -10,7 +10,6 @@ const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const;
const THEME_OPTIONS = [
@@ -106,7 +105,7 @@ export const SettingsPage = () => {
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
<p className="muted">General preferences, SSH keys, and tool types live here.</p>
</header>
<nav className="settings-tabs" aria-label="Settings sections">
-205
View File
@@ -1,205 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/ui/Icon";
import type { ToolType } from "../types/tool-type";
import type { ToolConfig } from "../types/tool-config";
import { listToolTypes } from "../api/tool-types";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
} from "../api/tool-configs";
import {
ToolConfigForm,
ToolConfigList,
} from "../components/features/tool-configs";
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const loadData = useCallback(async () => {
try {
const [typesData, configsData] = await Promise.all([
listToolTypes(),
listToolConfigs(),
]);
setToolTypes(typesData);
setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [selectedToolType]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (data: {
key: string;
value: string;
config_type: string;
file_path: string;
}) => {
const payload = {
tool_type_id: selectedToolType,
key: data.key,
value: data.value,
config_type: data.config_type,
file_path: data.config_type === "file" ? data.file_path : undefined,
};
if (editingConfig) {
await updateToolConfig(editingConfig.id, payload);
} else {
await createToolConfig(payload);
}
setShowForm(false);
setEditingConfig(null);
await loadData();
};
const handleEdit = (config: ToolConfig) => {
setEditingConfig(config);
setSelectedToolType(config.tool_type_id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
// Error handled by UI state
}
};
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType,
);
if (status === "loading") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<p className="muted">Loading...</p>
</section>
);
}
if (status === "error") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button
className="secondary-button"
onClick={() => void loadData()}
type="button"
>
<Icon name="refresh" size="sm" /> Retry
</button>
</div>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/settings")}
>
Back to settings
</button>
</div>
<div className="card">
<label htmlFor="tool-type-select">Select Tool</label>
<select
id="tool-type-select"
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setShowForm(false);
setEditingConfig(null);
}}
className="form-input"
>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces:{" "}
{selectedTool.interfaces?.join(", ")}
</p>
)}
</div>
<div className="card stack">
<div
className="row"
style={{ justifyContent: "space-between", alignItems: "center" }}
>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
}}
type="button"
>
<Icon name="add" size="sm" /> Add Config
</button>
</div>
<ToolConfigList
configs={filteredConfigs}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
{showForm && (
<ToolConfigForm
editingConfig={editingConfig}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false);
setEditingConfig(null);
}}
/>
)}
</section>
);
};
+77 -505
View File
@@ -1,527 +1,99 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToolWorkshopPage } from "./ToolWorkshopPage";
import * as toolTypesApi from "../api/tool-types";
import * as toolConfigsApi from "../api/tool-configs";
import * as configFoldersApi from "../api/config-folders";
import { ToolWorkshopPage } from "./ToolWorkshopPage";
const mockToolTypes = [
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null,
build_context: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
build_context: null,
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
const mockConfigs = [
{
id: "config-1",
tool_type_id: "type-1",
project_id: null,
key: "OPENAI_API_KEY",
value: "sk-test123",
config_type: "env",
file_path: null,
port_override: null,
start_command: null,
working_directory: null,
environment_variables: {},
volumes: [],
},
{
id: "config-2",
tool_type_id: "type-2",
project_id: null,
key: "advanced-config",
value: "test-value",
config_type: "env",
file_path: null,
port_override: 9090,
start_command: "python app.py",
working_directory: "/app",
environment_variables: { DEBUG: "true" },
volumes: [{ source: "data", target: "/data", type: "bind" }],
},
];
const mockFolders = [
{
id: "folder-1",
user_id: "user-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "folder-2",
user_id: "user-1",
name: "project-configs",
description: "Project specific configs",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost:8080" },
project_overrides: {
"proj-1": {
mount_path: "/app",
files: { ".env": "API_URL=http://prod.api" },
},
},
is_active: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\nservices:\n app:\n image: codercom/code-server",
dockerfile_template: null,
build_context: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
build_context: null,
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
cleanup();
vi.restoreAllMocks();
});
describe("ToolWorkshopPage", () => {
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(
() => new Promise(() => {}),
);
render(<ToolWorkshopPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
render(<ToolWorkshopPage />);
it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
});
render(<ToolWorkshopPage />);
it("renders tool types", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(
mockToolTypes as unknown as toolTypesApi.ToolType[],
);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
render(<ToolWorkshopPage />);
it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /configs/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /folders/i })).not.toBeInTheDocument();
});
render(<ToolWorkshopPage />);
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(
mockToolTypes as unknown as toolTypesApi.ToolType[],
);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
render(<ToolWorkshopPage />);
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
});
it("creates tool type with dockerfile definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile
fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByLabelText(/value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByLabelText(/port override/i), {
target: { value: "9090" },
});
fireEvent.change(screen.getByLabelText(/start command/i), {
target: { value: "python app.py" },
});
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "MY_CONFIG",
value: "my-value",
port_override: 9090,
start_command: "python app.py",
})
);
});
expect(configsListMock).toHaveBeenCalledTimes(2);
});
it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByLabelText("Mount Path *"), {
target: { value: "/home/dev" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-folder",
mount_path: "/home/dev",
})
);
});
expect(foldersListMock).toHaveBeenCalledTimes(2);
});
it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
// Check that active folder shows Active badge
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("handles error state gracefully", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
// Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
screen.getByText("Custom Tool").parentElement;
if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) {
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2");
});
expect(listMock).toHaveBeenCalledTimes(2);
}
}
});
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
});
+2 -59
View File
@@ -1,16 +1,6 @@
import { useState } from "react";
import { Icon } from "../components/ui";
import {
ToolTypesTab,
ToolConfigsTab,
ConfigFoldersTab,
} from "../components/features/tool-workshop";
type Tab = "types" | "configs" | "folders";
import { ToolTypesTab } from "../components/features/tool-workshop";
export const ToolWorkshopPage = () => {
const [activeTab, setActiveTab] = useState<Tab>("types");
return (
<div className="container">
<div
@@ -24,54 +14,7 @@ export const ToolWorkshopPage = () => {
<h1>Tool Workshop</h1>
</div>
<div
className="tabs"
style={{
display: "flex",
gap: "0.5rem",
marginBottom: "1rem",
borderBottom: "1px solid var(--color-border)",
}}
>
{(["types", "configs", "folders"] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={activeTab === tab ? "tab-active" : "tab"}
style={{
padding: "0.5rem 1rem",
borderBottom:
activeTab === tab
? "2px solid var(--color-primary)"
: "2px solid transparent",
background: "none",
border: "none",
cursor: "pointer",
fontWeight: activeTab === tab ? 600 : 400,
}}
>
{tab === "types" && (
<>
<Icon name="code" size="sm" /> Tool Types
</>
)}
{tab === "configs" && (
<>
<Icon name="settings" size="sm" /> Configs
</>
)}
{tab === "folders" && (
<>
<Icon name="folder" size="sm" /> Config Folders
</>
)}
</button>
))}
</div>
{activeTab === "types" && <ToolTypesTab />}
{activeTab === "configs" && <ToolConfigsTab />}
{activeTab === "folders" && <ConfigFoldersTab />}
<ToolTypesTab />
</div>
);
};
-6
View File
@@ -14,7 +14,6 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/SettingsPage";
import { TerminalPage } from "./pages/TerminalPage";
import { ToolWorkshopPage } from "./pages/ToolWorkshopPage";
import { SSHKeysPage } from "./pages/SshKeysPage";
import { ToolConfigsPage } from "./pages/ToolConfigsPage";
import { ToolTypesPage } from "./pages/ToolTypesPage";
import { SessionsPage } from "./pages/SessionsPage";
@@ -31,10 +30,6 @@ export const AppRouter = () => {
path="/tool-types"
element={<Navigate to="/settings/tool-types" replace />}
/>
<Route
path="/tool-configs"
element={<Navigate to="/settings/tool-configs" replace />}
/>
<Route
path="/"
element={
@@ -64,7 +59,6 @@ export const AppRouter = () => {
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
-36
View File
@@ -1,36 +0,0 @@
export interface ConfigFolder {
id: string;
user_id: string;
name: string;
description: string | null;
mount_path: string;
files: Record<string, string>;
project_overrides: Record<
string,
{ mount_path?: string; files?: Record<string, string> }
> | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateConfigFolderRequest {
name: string;
description?: string;
mount_path: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface UpdateConfigFolderRequest {
name?: string;
description?: string;
mount_path?: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface ProjectOverrideRequest {
mount_path?: string;
files?: Record<string, string>;
}
-7
View File
@@ -1,10 +1,4 @@
export type { ApiResponse, PaginatedResponse } from "./api-response";
export type {
ConfigFolder,
CreateConfigFolderRequest,
UpdateConfigFolderRequest,
ProjectOverrideRequest,
} from "./config-folder";
export type {
CommitDetail,
CommitHistoryEntry,
@@ -18,7 +12,6 @@ export type {
} from "./git-repository";
export type { Project, ProjectWithRepos } from "./project";
export type { Session } from "./session";
export type { ToolConfig, CreateToolConfigRequest } from "./tool-config";
export type { ToolInstance } from "./tool-instance";
export type {
CreateToolTypeRequest,
-28
View File
@@ -1,28 +0,0 @@
export interface ToolConfig {
id: string;
tool_type_id: string;
project_id: string | null;
key: string;
value: string;
config_type: string;
file_path: string | null;
port_override: number | null;
start_command: string | null;
working_directory: string | null;
environment_variables: Record<string, string> | null;
volumes: Array<{ source: string; target: string; type?: string }> | null;
}
export interface CreateToolConfigRequest {
tool_type_id: string;
project_id?: string;
key: string;
value: string;
config_type?: string;
file_path?: string;
port_override?: number;
start_command?: string;
working_directory?: string;
environment_variables?: Record<string, string>;
volumes?: Array<{ source: string; target: string; type?: string }>;
}