Files
headquarter/apps/web/src/hooks/use-config-profiles.ts
T
alex 2247ec47c9 fix(config-profiles): synchronize shared mount working copies
Use canonical profile files and writable Git working copies so editor and container changes share one source. Require confirmation before destructive Git refreshes and overlay profile files without composite snapshots.
2026-07-22 11:23:19 +02:00

522 lines
13 KiB
TypeScript

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<Status>("loading");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false);
const [isReloadingWorkingCopy, setIsReloadingWorkingCopy] = useState(false);
const [formData, setFormData] =
useState<CreateConfigProfileRequest>(defaultForm);
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(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<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));
};
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<boolean> => {
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<void> => {
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<boolean> => {
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 = <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");
};
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,
};
};