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("list"); const [status, setStatus] = useState("loading"); const [profiles, setProfiles] = useState([]); const [projects, setProjects] = useState([]); const [toolTypes, setToolTypes] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [error, setError] = useState(null); const [previewData, setPreviewData] = useState(null); const [previewingId, setPreviewingId] = useState(null); const [formData, setFormData] = useState({ name: "", description: "", env_vars: {}, runtime_hints: {}, mounts: [], git_mounts: [], files: {}, is_default: false, }); const [includedProfileIds, setIncludedProfileIds] = useState([]); const [dragOverIndex, setDragOverIndex] = useState(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()): 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 = ( 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) => { 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 (
); } if (status === "error") { return (
); } // Mobile view rendering if (isMobile) { if (mobileView === "list") { return (

Config Profiles

({ 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" /> { setIsCreating(true); setSelectedProfileId(null); resetForm(); setMobileView("edit"); }} />
); } 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 ( setMobileView("list")} onEdit={() => { populateForm(selectedProfile); setIsCreating(false); setMobileView("edit"); }} onDelete={() => handleDelete(selectedProfile.id)} /> ); } if (mobileView === "edit") { return ( { if (isCreating) { setMobileView("list"); } else if (selectedProfile) { setMobileView("detail"); } else { setMobileView("list"); } }} onSave={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)} isSaving={saveStatus === "saving"} > {/* Profile form fields */}
updateFormField("name", e.target.value)} placeholder="Profile name" required />