import { useCallback, useEffect, useState } from "react"; import { extractErrorMessage } from "../utils/errors"; import { createConfigProfile, deleteConfigProfile, listConfigProfiles, previewConfigProfile, refreshConfigProfileGitMounts, updateConfigProfile, updateProfileIncludes, type ConfigProfile, type CreateConfigProfileRequest, type ResolvedProfile, } from "../api/config-profiles"; import { listProjects } from "../api/projects"; import { listToolTypes, type ToolType } from "../api/tool-types"; import type { ProjectWithRepos } from "../types"; type Status = "loading" | "ready" | "error"; type SaveStatus = "idle" | "saving" | "saved" | "error"; const defaultForm: CreateConfigProfileRequest = { name: "", description: "", env_vars: {}, runtime_hints: {}, mounts: [], git_mounts: [], files: {}, is_default: false, }; export const useConfigProfiles = () => { 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"); const [error, setError] = useState(null); const [previewData, setPreviewData] = useState(null); const [previewingId, setPreviewingId] = useState(null); const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false); const [isReloadingWorkingCopy, setIsReloadingWorkingCopy] = useState(false); const [formData, setFormData] = useState(defaultForm); 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(defaultForm); 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(); }; 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"; }; 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)); }; 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): Promise => { e?.preventDefault(); setError(null); setSaveStatus("saving"); if (!formData.name?.trim()) { setError("Name is required"); setSaveStatus("error"); return false; } 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(); const refreshed = (await listConfigProfiles()).find( (p) => p.id === newProfile.id, ); if (refreshed) populateForm(refreshed); } else if (selectedProfile) { const updatedProfile = await updateConfigProfile( selectedProfile.id, formData, ); await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds, }); const restartOutcomes = updatedProfile.refresh_outcomes?.filter( (outcome) => outcome.status === "restart_required", ); if (restartOutcomes?.length) { setError( `Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`, ); } setSaveStatus("saved"); await loadData(); const refreshed = (await listConfigProfiles()).find( (p) => p.id === selectedProfile.id, ); if (refreshed) populateForm(refreshed); } return true; } catch (err) { setError(extractErrorMessage(err)); setSaveStatus("error"); return false; } }; 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 (err) { setError(extractErrorMessage(err)); } }; const handleReloadWorkingCopy = async (): Promise => { if (!selectedProfileId || isReloadingWorkingCopy) return; setError(null); setIsReloadingWorkingCopy(true); try { const refreshedProfiles = await listConfigProfiles(); setProfiles(refreshedProfiles); const refreshedProfile = refreshedProfiles.find( (profile) => profile.id === selectedProfileId, ); if (refreshedProfile) populateForm(refreshedProfile); } catch (err) { setError(extractErrorMessage(err)); } finally { setIsReloadingWorkingCopy(false); } }; const handleRefreshGitMounts = async (id: string): Promise => { if (isRefreshingGitMounts) return false; if ( !window.confirm( "Refresh Git mounts? This replaces local container and editor edits with the configured remote branch.", ) ) return false; setError(null); setIsRefreshingGitMounts(true); try { const result = await refreshConfigProfileGitMounts(id); const refreshed = result.refresh_outcomes.filter( (outcome) => outcome.status === "refreshed", ); setError( refreshed.length ? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.` : "No running instances currently use this profile's Git mounts.", ); return true; } catch (err) { setError(extractErrorMessage(err)); return false; } finally { setIsRefreshingGitMounts(false); } }; 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"); }; return { status, profiles, projects, toolTypes, selectedProfile, selectedProfileId, isCreating, saveStatus, error, previewData, previewingId, isRefreshingGitMounts, isReloadingWorkingCopy, formData, includedProfileIds, dragOverIndex, loadData, handleSelectProfile, handleCreateNew, handleSubmit, handleDelete, handlePreview, handleReloadWorkingCopy, handleRefreshGitMounts, updateFormField, addEnvVar, updateEnvVar, removeEnvVar, addFile, updateFile, removeFile, addMount, updateMount, removeMount, addMountFile, updateMountFile, removeMountFile, getIncludedProfile, getScopeLabel, availableProfilesForInclude, addInclude, removeInclude, handleDragStart, handleDragOver, handleDragLeave, handleDrop, setPreviewData, setFormData, setSaveStatus, setIncludedProfileIds, populateForm, }; };