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.
This commit is contained in:
2026-07-22 11:23:19 +02:00
parent 0d6c1926ae
commit 2247ec47c9
14 changed files with 285 additions and 159 deletions
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockPost = vi.fn();
vi.mock("./client", () => ({
apiClient: {
post: (...args: unknown[]) => mockPost(...args),
},
}));
import { refreshConfigProfileGitMounts } from "./config-profiles";
describe("refreshConfigProfileGitMounts", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("confirms the destructive refresh at the API boundary", async () => {
mockPost.mockResolvedValue({ data: { refresh_outcomes: [] } });
await expect(refreshConfigProfileGitMounts("profile-1")).resolves.toEqual({
refresh_outcomes: [],
});
expect(mockPost).toHaveBeenCalledWith(
"/config-profiles/profile-1/refresh-git-mounts",
undefined,
{ params: { confirm_destructive_refresh: true } },
);
});
});
+8 -2
View File
@@ -2,7 +2,11 @@ import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome {
instance_id: string;
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
status:
| "compatible"
| "refreshed"
| "restart_required"
| "incompatible_permissions";
reason?: string;
}
@@ -150,7 +154,9 @@ export const refreshConfigProfileGitMounts = async (
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
const response = await apiClient.post<{
refresh_outcomes: ConfigProfileRefreshOutcome[];
}>(`/config-profiles/${id}/refresh-git-mounts`);
}>(`/config-profiles/${id}/refresh-git-mounts`, undefined, {
params: { confirm_destructive_refresh: true },
});
return response.data;
};
@@ -15,6 +15,7 @@ interface Props {
previewData: ResolvedProfile | null;
previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
projects: ProjectWithRepos[];
toolTypes: ToolType[];
availableProfiles: ConfigProfile[];
@@ -24,6 +25,7 @@ interface Props {
onSubmit: (e?: React.FormEvent) => void;
onReset: () => void;
onPreview: () => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: () => void;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
@@ -57,6 +59,7 @@ export const ConfigProfileEditorPanel = ({
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
projects,
toolTypes,
availableProfiles,
@@ -66,6 +69,7 @@ export const ConfigProfileEditorPanel = ({
onSubmit,
onReset,
onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onAddInclude,
onRemoveInclude,
@@ -118,6 +122,9 @@ export const ConfigProfileEditorPanel = ({
</div>
{!isCreating && selectedProfile && (
<div className="row row-sm">
<button className="btn btn-secondary" onClick={onReloadWorkingCopy} disabled={isReloadingWorkingCopy}>
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
</button>
<button className="btn btn-secondary" onClick={onRefreshGitMounts} disabled={isRefreshingGitMounts}>
{isRefreshingGitMounts ? (
<>
@@ -29,6 +29,7 @@ interface Props {
previewData: ResolvedProfile | null;
previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
saveStatus: "idle" | "saving" | "saved" | "error";
error: string | null;
onViewChange: (view: MobileView) => void;
@@ -65,6 +66,7 @@ interface Props {
) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void;
}
@@ -82,6 +84,7 @@ export const ConfigProfilesMobileView = ({
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
saveStatus,
error,
onViewChange,
@@ -107,6 +110,7 @@ export const ConfigProfilesMobileView = ({
onUpdateMountFile,
onRemoveMountFile,
onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onClosePreview,
}: Props) => {
@@ -367,6 +371,9 @@ export const ConfigProfilesMobileView = ({
onDelete={handleDeleteClick}
>
<div className="mobile-detail-actions-extra">
<button type="button" className="secondary-button" disabled={isReloadingWorkingCopy} onClick={onReloadWorkingCopy}>
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
</button>
<button
type="button"
className="secondary-button"
+28
View File
@@ -44,6 +44,7 @@ export const useConfigProfiles = () => {
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[]>([]);
@@ -275,8 +276,33 @@ export const useConfigProfiles = () => {
}
};
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);
@@ -452,6 +478,7 @@ export const useConfigProfiles = () => {
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
formData,
includedProfileIds,
dragOverIndex,
@@ -461,6 +488,7 @@ export const useConfigProfiles = () => {
handleSubmit,
handleDelete,
handlePreview,
handleReloadWorkingCopy,
handleRefreshGitMounts,
updateFormField,
addEnvVar,
@@ -24,6 +24,7 @@ export const ConfigProfilesPage = () => {
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
formData,
includedProfileIds,
dragOverIndex,
@@ -33,6 +34,7 @@ export const ConfigProfilesPage = () => {
handleSubmit,
handleDelete,
handlePreview,
handleReloadWorkingCopy,
handleRefreshGitMounts,
updateFormField,
addEnvVar,
@@ -113,6 +115,7 @@ export const ConfigProfilesPage = () => {
previewData={previewData}
previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
isReloadingWorkingCopy={isReloadingWorkingCopy}
saveStatus={saveStatus}
error={error}
onViewChange={setMobileView}
@@ -138,6 +141,7 @@ export const ConfigProfilesPage = () => {
onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile}
onPreview={handlePreview}
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
onClosePreview={() => setPreviewData(null)}
/>
@@ -172,6 +176,7 @@ export const ConfigProfilesPage = () => {
previewData={previewData}
previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
isReloadingWorkingCopy={isReloadingWorkingCopy}
projects={projects}
toolTypes={toolTypes}
availableProfiles={availableProfilesForInclude()}
@@ -181,6 +186,7 @@ export const ConfigProfilesPage = () => {
onSubmit={handleSubmit}
onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
onRefreshGitMounts={() =>
selectedProfile && handleRefreshGitMounts(selectedProfile.id)
}