Files
headquarter/apps/web/src/pages/config-profiles.tsx
T
alex 22474cdba5 style: fix all ruff and eslint errors across codebase
Backend (ruff):
- Fix 106 errors: move imports to top of file (E402)
- Remove unused imports (F401)
- Add missing imports for undefined names (F821)
- Remove unused variables (F841)
- Fix test_models.py broken RefreshToken test
- Fix test_projects_api.py missing TestClient import

Frontend (eslint):
- Remove unused imports/variables across 10 files
- Fix explicit any types in client.ts and sessions.ts
- Clean up empty block statements in terminal.tsx

Quality gates: ruff (pass), eslint (pass), tsc --noEmit (pass),
pytest (98 passed, 4 pre-existing failures)
2026-05-28 10:15:59 +02:00

1294 lines
48 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { 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 {
createConfigProfile,
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
updateConfigProfile,
updateProfileIncludes,
type ConfigProfile,
type CreateConfigProfileRequest,
type ResolvedProfile,
} from "../api/config_profiles";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { GitMountEditor } from "../components/git-mount-editor";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
export const ConfigProfilesPage = () => {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [profs, projs, types] = await Promise.all([
listConfigProfiles(),
listProjects(),
listToolTypes(),
]);
setProfiles(profs || []);
setProjects(projs || []);
setToolTypes(types || []);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const resetForm = () => {
setFormData({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
setIncludedProfileIds([]);
setError(null);
setSaveStatus("idle");
setPreviewData(null);
};
const populateForm = (profile: ConfigProfile) => {
setFormData({
name: profile.name,
description: profile.description || undefined,
project_id: profile.project_id || undefined,
tool_type_id: profile.tool_type_id || undefined,
env_vars: profile.env_vars,
runtime_hints: profile.runtime_hints,
mounts: profile.mounts,
git_mounts: profile.git_mounts || [],
files: profile.files,
is_default: profile.is_default,
});
setIncludedProfileIds(
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id)
);
setError(null);
setSaveStatus("idle");
setPreviewData(null);
};
const handleSelectProfile = (profile: ConfigProfile | null) => {
if (profile) {
setSelectedProfileId(profile.id);
setIsCreating(false);
populateForm(profile);
} else {
setSelectedProfileId(null);
}
};
const handleCreateNew = () => {
setSelectedProfileId(null);
setIsCreating(true);
resetForm();
};
// Include management functions
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
const getScopeLabel = (profile: ConfigProfile): string => {
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
if (profile.project_id) return "Project";
if (profile.tool_type_id) return "Tool";
return "Global";
};
// Cycle detection: returns true if adding targetId would create a cycle
const wouldCreateCycle = (profileId: string, targetId: string, visited = new Set<string>()): boolean => {
if (visited.has(targetId)) return true;
const target = getIncludedProfile(targetId);
if (!target) return false;
const nextVisited = new Set(visited);
nextVisited.add(targetId);
for (const inc of target.includes) {
if (inc.included_profile_id === profileId || wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)) {
return true;
}
}
return false;
};
const availableProfilesForInclude = (): ConfigProfile[] => {
const currentId = selectedProfile?.id;
if (!currentId) return [];
return profiles.filter((p) => {
if (p.id === currentId) return false;
if (includedProfileIds.includes(p.id)) return false;
if (wouldCreateCycle(currentId, p.id)) return false;
return true;
});
};
const addInclude = (profileId: string) => {
setIncludedProfileIds((prev) => [...prev, profileId]);
};
const removeInclude = (index: number) => {
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
if (dragIndex === dropIndex) {
setDragOverIndex(null);
return;
}
setIncludedProfileIds((prev) => {
const newOrder = [...prev];
const [removed] = newOrder.splice(dragIndex, 1);
newOrder.splice(dropIndex, 0, removed);
return newOrder;
});
setDragOverIndex(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaveStatus("saving");
if (!formData.name?.trim()) {
setError("Name is required");
setSaveStatus("error");
return;
}
try {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
}
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
await loadData();
// Re-populate with the new profile data
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
if (refreshed) populateForm(refreshed);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
setSaveStatus("saved");
await loadData();
// Refresh the selected profile data
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
if (refreshed) populateForm(refreshed);
}
} catch (err) {
setError(extractErrorMessage(err));
setSaveStatus("error");
}
};
const handleDelete = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
try {
await deleteConfigProfile(id);
if (selectedProfileId === id) {
setSelectedProfileId(null);
setIsCreating(false);
resetForm();
}
await loadData();
} catch {
alert("Failed to delete config profile");
}
};
const handlePreview = async (id: string) => {
try {
setPreviewingId(id);
const data = await previewConfigProfile(id);
setPreviewData(data);
} catch {
setError("Failed to preview config profile");
} finally {
setPreviewingId(null);
}
};
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
key: K,
value: CreateConfigProfileRequest[K]
) => {
setFormData((prev) => ({ ...prev, [key]: value }));
setSaveStatus("idle");
};
const addEnvVar = () => {
setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
setSaveStatus("idle");
};
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
if (oldKey !== newKey) {
delete envVars[oldKey];
}
envVars[newKey] = value;
return { ...prev, env_vars: envVars };
});
setSaveStatus("idle");
};
const removeEnvVar = (key: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
delete envVars[key];
return { ...prev, env_vars: envVars };
});
setSaveStatus("idle");
};
const addFile = () => {
setFormData((prev) => ({
...prev,
files: { ...prev.files, "": "" },
}));
setSaveStatus("idle");
};
const updateFile = (oldPath: string, newPath: string, content: string) => {
setFormData((prev) => {
const files = { ...prev.files };
if (oldPath !== newPath) {
delete files[oldPath];
}
files[newPath] = content;
return { ...prev, files };
});
setSaveStatus("idle");
};
const removeFile = (path: string) => {
setFormData((prev) => {
const files = { ...prev.files };
delete files[path];
return { ...prev, files };
});
setSaveStatus("idle");
};
const addMount = () => {
setFormData((prev) => ({
...prev,
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
}));
setSaveStatus("idle");
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const removeMount = (index: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts.splice(index, 1);
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const addMountFile = (mountIndex: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[mountIndex] = {
...mounts[mountIndex],
files: { ...mounts[mountIndex].files, "": "" },
};
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const updateMountFile = (
mountIndex: number,
oldPath: string,
newPath: string,
content: string
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
if (oldPath !== newPath) {
delete files[oldPath];
}
files[newPath] = content;
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const removeMountFile = (mountIndex: number, path: string) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
delete files[path];
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
if (status === "loading") {
return (
<div className="container">
<LoadingState message="Loading Config Profiles..." />
</div>
);
}
if (status === "error") {
return (
<div className="container">
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
</div>
);
}
// Mobile view rendering
if (isMobile) {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
setSelectedProfileId(id);
setIsCreating(false);
const profile = profiles.find((p) => p.id === id);
if (profile) populateForm(profile);
setMobileView("detail");
}}
onItemDelete={(id: string) => handleDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB onClick={() => {
setIsCreating(true);
setSelectedProfileId(null);
resetForm();
setMobileView("edit");
}} />
</div>
);
}
if (mobileView === "detail" && selectedProfile) {
const fields = [
{ label: "Name", value: selectedProfile.name },
{ label: "Description", value: selectedProfile.description || "-" },
{ label: "Scope", value: getScopeLabel(selectedProfile) },
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
{ label: "Environment Variables", value: Object.keys(selectedProfile.env_vars).length > 0 ? Object.entries(selectedProfile.env_vars).map(([k, v]) => `${k}=${v}`).join(", ") : "-" },
{ label: "Mounts", value: selectedProfile.mounts.length > 0 ? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ") : "-" },
{ label: "Includes", value: selectedProfile.includes.length > 0 ? `${selectedProfile.includes.length} profile(s)` : "-" },
];
return (
<MobileDetailView
title={selectedProfile.name}
subtitle={getScopeLabel(selectedProfile)}
fields={fields}
onBack={() => setMobileView("list")}
onEdit={() => {
populateForm(selectedProfile);
setIsCreating(false);
setMobileView("edit");
}}
onDelete={() => handleDelete(selectedProfile.id)}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Profile" : "Edit Profile"}
onCancel={() => {
if (isCreating) {
setMobileView("list");
} else if (selectedProfile) {
setMobileView("detail");
} else {
setMobileView("list");
}
}}
onSave={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
isSaving={saveStatus === "saving"}
>
{/* Profile form fields */}
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => updateFormField("name", e.target.value)}
placeholder="Profile name"
required
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={formData.description || ""}
onChange={(e) => updateFormField("description", e.target.value)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="form-group">
<label>Project</label>
<select
value={formData.project_id || ""}
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
>
<option value="">Global (all projects)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Tool Type</label>
<select
value={formData.tool_type_id || ""}
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
>
<option value="">Any tool type</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
))}
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => updateFormField("is_default", e.target.checked)}
/>
Default Profile
</label>
</div>
{/* Environment Variables */}
<div className="form-group">
<label>Environment Variables</label>
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={key}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
newEnvVars[e.target.value] = value;
updateFormField("env_vars", newEnvVars);
}}
placeholder="KEY"
style={{ flex: 1 }}
/>
<input
type="text"
value={value}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
newEnvVars[key] = e.target.value;
updateFormField("env_vars", newEnvVars);
}}
placeholder="value"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
updateFormField("env_vars", newEnvVars);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addEnvVar}>
<Icon name="add" size="sm" /> Add Variable
</button>
</div>
{/* Mounts */}
<div className="form-group">
<label>Mounts</label>
{(formData.mounts || []).map((mount, index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={mount.target}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, target: e.target.value };
updateFormField("mounts", newMounts);
}}
placeholder="Target path"
style={{ flex: 1 }}
/>
<select
value={mount.mode}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
updateFormField("mounts", newMounts);
}}
style={{ width: "80px" }}
>
<option value="ro">Read</option>
<option value="rw">Write</option>
</select>
<button
type="button"
onClick={() => {
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
updateFormField("mounts", newMounts);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addMount}>
<Icon name="add" size="sm" /> Add Mount
</button>
</div>
</MobileEditView>
);
}
// Fallback to list
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
setSelectedProfileId(id);
setIsCreating(false);
const profile = profiles.find((p) => p.id === id);
if (profile) populateForm(profile);
setMobileView("detail");
}}
onItemDelete={(id: string) => handleDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB onClick={() => {
setIsCreating(true);
setSelectedProfileId(null);
resetForm();
setMobileView("edit");
}} />
</div>
);
}
return (
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
{/* Left Sidebar - Profile 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" }}>Config Profiles</h2>
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => handleSelectProfile(profile)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedProfileId !== profile.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" }}>
{profile.name}
{profile.is_default && (
<span style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.8,
textTransform: "uppercase",
letterSpacing: "0.025em"
}}>
default
</span>
)}
{profile.includes?.length > 0 && (
<span style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.7,
background: selectedProfileId === profile.id ? "rgba(255,255,255,0.2)" : "var(--badge-bg, #f3f4f6)",
padding: "0.0625rem 0.375rem",
borderRadius: "0.25rem",
}}>
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
</span>
)}
</div>
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
{profile.project_id && "Project scoped"}
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
{!profile.project_id && !profile.tool_type_id && "Global"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(profile.id);
}}
style={{
background: "none",
border: "none",
color: selectedProfileId === profile.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 profile"
>
<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 Profile
</button>
</div>
</div>
{/* Right Panel - Editor */}
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!selectedProfileId && !isCreating ? (
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="folder" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
</div>
) : (
<div>
{/* Header */}
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating ? "Create Profile" : selectedProfile?.name}
</h1>
{!isCreating && selectedProfile && (
<p className="muted" style={{ margin: 0 }}>
{selectedProfile.project_id && `Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
{selectedProfile.tool_type_id && `Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
</p>
)}
</div>
{!isCreating && selectedProfile && (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
className="secondary-button"
onClick={() => handlePreview(selectedProfile.id)}
disabled={previewingId === selectedProfile.id}
>
{previewingId === selectedProfile.id ? (
<>
<Icon name="loading" size="sm" />
Previewing...
</>
) : (
<>
<Icon name="info" size="sm" />
Preview
</>
)}
</button>
</div>
)}
</div>
{error && (
<div className="error" style={{ marginBottom: "1rem" }}>
{error}
</div>
)}
{saveStatus === "saved" && (
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Icon name="success" size="sm" />
Profile saved successfully
</div>
)}
<form onSubmit={handleSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
<div className="form-group">
<label htmlFor="profile-name">Name *</label>
<input
id="profile-name"
type="text"
value={formData.name}
onChange={(e) => updateFormField("name", e.target.value)}
placeholder="e.g., Development Environment"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="profile-description">Description</label>
<input
id="profile-description"
type="text"
value={formData.description || ""}
onChange={(e) => updateFormField("description", e.target.value || undefined)}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-project">Project</label>
<select
id="profile-project"
value={formData.project_id || ""}
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
className="form-input"
>
<option value="">None (Global)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-tool">Tool Type</label>
<select
id="profile-tool"
value={formData.tool_type_id || ""}
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
className="form-input"
>
<option value="">None</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>
{toolType.display_name}
</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => updateFormField("is_default", e.target.checked)}
/>
Set as default for this scope
</label>
</div>
{/* Includes Section */}
<div className="form-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<h4 style={{ margin: 0 }}>Includes</h4>
<span className="muted" style={{ fontSize: "0.875rem" }}>
{includedProfileIds.length} included
</span>
</div>
{includedProfileIds.length === 0 ? (
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>
No profiles included. Add profiles to compose configurations.
</p>
) : (
<div style={{ marginBottom: "0.75rem" }}>
{includedProfileIds.map((profileId, index) => {
const profile = getIncludedProfile(profileId);
if (!profile) return null;
return (
<div
key={`${profileId}-${index}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0.75rem",
background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
marginBottom: "0.25rem",
cursor: "grab",
transition: "background 0.15s",
}}
>
<span style={{ cursor: "grab", color: "var(--muted)" }}>
<Icon name="drag" size="sm" />
</span>
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
<span
style={{
fontSize: "0.75rem",
padding: "0.125rem 0.375rem",
background: "var(--badge-bg, #f3f4f6)",
color: "var(--muted)",
borderRadius: "0.25rem",
textTransform: "uppercase",
letterSpacing: "0.025em",
}}
>
{getScopeLabel(profile)}
</span>
<button
type="button"
onClick={() => removeInclude(index)}
style={{
background: "none",
border: "none",
color: "var(--danger)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
}}
title="Remove include"
>
<Icon name="delete" size="sm" />
</button>
</div>
);
})}
</div>
)}
{availableProfilesForInclude().length > 0 && (
<div className="form-group" style={{ marginBottom: 0 }}>
<select
value=""
onChange={(e) => {
if (e.target.value) {
addInclude(e.target.value);
e.target.value = "";
}
}}
className="form-input"
>
<option value="">+ Add Include...</option>
{availableProfilesForInclude().map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({getScopeLabel(p)})
</option>
))}
</select>
</div>
)}
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={key}
onChange={(e) => updateEnvVar(key, e.target.value, value)}
placeholder="VAR_NAME"
className="form-input"
style={{ flex: 1 }}
/>
<input
type="text"
value={value}
onChange={(e) => updateEnvVar(key, key, e.target.value)}
placeholder="value"
className="form-input"
style={{ flex: 1 }}
/>
<button
type="button"
className="ghost-button small"
onClick={() => removeEnvVar(key)}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addEnvVar}>
<Icon name="add" size="sm" />
Add Variable
</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
<textarea
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateFormField("runtime_hints", parsed);
} catch {
// Invalid JSON, ignore
}
}}
placeholder='{"start_command": "npm start"}'
rows={4}
className="form-input"
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
/>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
Relative paths written to the instance directory. Use Mounts below for absolute container paths.
</p>
{Object.entries(formData.files || {}).map(([path, content], idx) => (
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={path}
onChange={(e) => updateFile(path, e.target.value, content)}
placeholder="relative/path/to/file"
className="form-input"
style={{ flex: 1 }}
/>
<button
type="button"
className="ghost-button small"
onClick={() => removeFile(path)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<textarea
value={content}
onChange={(e) => updateFile(path, path, e.target.value)}
placeholder="File content"
rows={3}
className="form-input"
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
/>
</div>
))}
<button type="button" className="secondary-button" onClick={addFile}>
<Icon name="add" size="sm" />
Add File
</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
Bind directories into the container at absolute paths. Files are relative to the mount target.
</p>
{(formData.mounts || []).map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
<input
type="text"
value={mount.target}
onChange={(e) => updateMount(index, { target: e.target.value })}
placeholder="/target/path"
className="form-input"
style={{ flex: 1 }}
/>
<select
value={mount.mode}
onChange={(e) =>
updateMount(index, { mode: e.target.value as "ro" | "rw" })
}
className="form-input"
style={{ width: "120px" }}
>
<option value="rw">Read/Write</option>
<option value="ro">Read-Only</option>
</select>
<button
type="button"
className="ghost-button small"
onClick={() => removeMount(index)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<div style={{ marginLeft: "1rem" }}>
{Object.entries(mount.files).map(([path, content], idx) => (
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={path}
onChange={(e) =>
updateMountFile(index, path, e.target.value, content)
}
placeholder="relative/path"
className="form-input"
style={{ flex: 1 }}
/>
<textarea
value={content}
onChange={(e) =>
updateMountFile(index, path, path, e.target.value)
}
placeholder="File content"
rows={2}
className="form-input"
style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }}
/>
<button
type="button"
className="ghost-button small"
onClick={() => removeMountFile(index, path)}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button small"
onClick={() => addMountFile(index)}
style={{ fontSize: "0.875rem" }}
>
<Icon name="add" size="sm" />
Add File to Mount
</button>
</div>
</div>
))}
<button type="button" className="secondary-button" onClick={addMount}>
<Icon name="add" size="sm" />
Add Mount
</button>
</div>
<div className="form-section">
<GitMountEditor
mounts={formData.git_mounts || []}
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
/>
</div>
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
<button type="submit" disabled={saveStatus === "saving"}>
<Icon name={isCreating ? "add" : "save"} size="sm" />
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
</button>
{(isCreating || saveStatus !== "idle") && (
<button
type="button"
onClick={() => {
if (isCreating) {
resetForm();
} else if (selectedProfile) {
populateForm(selectedProfile);
}
}}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
)}
</div>
</form>
{previewData && (
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
<h3>Resolved Profile Preview</h3>
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>
{JSON.stringify(previewData, null, 2)}
</pre>
<button className="secondary-button" onClick={() => setPreviewData(null)}>
Close Preview
</button>
</div>
)}
</div>
)}
</div>
</div>
);
};