Files
headquarter/apps/web/src/components/features/config-profiles/ConfigProfilesMobileView.tsx
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

841 lines
22 KiB
TypeScript

import { useState } from "react";
import { MobileListView } from "../mobile/mobile-list-view";
import { MobileDetailView } from "../mobile/mobile-detail-view";
import { MobileEditView } from "../mobile/mobile-edit-view";
import { MobileFAB } from "../mobile/mobile-fab";
import { GitMountEditor } from "../git/git-mount-editor";
import { Icon } from "../../icon";
import type {
ConfigProfile,
CreateConfigProfileRequest,
ResolvedProfile,
} from "../../../api/config-profiles";
import type { ProjectWithRepos } from "../../../types";
import type { ToolType } from "../../../api/tool-types";
type MobileView = "list" | "detail" | "edit" | "preview";
interface Props {
profiles: ConfigProfile[];
projects: ProjectWithRepos[];
toolTypes: ToolType[];
selectedProfile: ConfigProfile | null;
mobileView: MobileView;
isCreating: boolean;
formData: CreateConfigProfileRequest;
includedProfileIds: string[];
availableProfiles: ConfigProfile[];
previewData: ResolvedProfile | null;
previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
saveStatus: "idle" | "saving" | "saved" | "error";
error: string | null;
onViewChange: (view: MobileView) => void;
onSelect: (profile: ConfigProfile) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onFormChange: <K extends keyof CreateConfigProfileRequest>(
key: K,
value: CreateConfigProfileRequest[K],
) => void;
onSubmit: (e?: React.FormEvent) => Promise<boolean>;
getScopeLabel: (profile: ConfigProfile) => string;
getIncludedProfile: (id: string) => ConfigProfile | undefined;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
onAddEnvVar: () => void;
onUpdateEnvVar: (oldKey: string, newKey: string, value: string) => void;
onRemoveEnvVar: (key: string) => void;
onAddFile: () => void;
onUpdateFile: (oldPath: string, newPath: string, content: string) => void;
onRemoveFile: (path: string) => void;
onAddMount: () => void;
onUpdateMount: (
index: number,
updates: Partial<ConfigProfile["mounts"][0]>,
) => void;
onRemoveMount: (index: number) => void;
onAddMountFile: (mountIndex: number) => void;
onUpdateMountFile: (
mountIndex: number,
oldPath: string,
newPath: string,
content: string,
) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void;
}
export const ConfigProfilesMobileView = ({
profiles,
projects,
toolTypes,
selectedProfile,
mobileView,
isCreating,
formData,
includedProfileIds,
availableProfiles,
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
saveStatus,
error,
onViewChange,
onSelect,
onCreate,
onDelete,
onFormChange,
onSubmit,
getScopeLabel,
getIncludedProfile,
onAddInclude,
onRemoveInclude,
onAddEnvVar,
onUpdateEnvVar,
onRemoveEnvVar,
onAddFile,
onUpdateFile,
onRemoveFile,
onAddMount,
onUpdateMount,
onRemoveMount,
onAddMountFile,
onUpdateMountFile,
onRemoveMountFile,
onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onClosePreview,
}: Props) => {
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
const ok = await onSubmit();
if (ok) {
onViewChange(isCreating ? "list" : "edit");
}
} finally {
setIsSaving(false);
}
};
const handleDeleteClick = () => {
if (selectedProfile) {
onDelete(selectedProfile.id);
onViewChange("list");
}
};
const projectName = (id?: string) =>
projects.find((p) => p.id === id)?.name || id || "None";
const toolTypeName = (id?: string) =>
toolTypes.find((t) => t.id === id)?.display_name || id || "None";
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
<span className="muted">{profiles.length} profiles</span>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
const profile = profiles.find((p) => p.id === id);
if (profile) {
onSelect(profile);
onViewChange("edit");
}
}}
onItemDelete={(id: string) => onDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
}
if (mobileView === "preview" && selectedProfile && previewData) {
return (
<div className="mobile-edit-view">
<header className="mobile-edit-header">
<button
className="mobile-edit-cancel"
onClick={() => onViewChange("detail")}
type="button"
>
Back
</button>
<h1 className="mobile-edit-title">Resolved Preview</h1>
<div style={{ width: "4rem" }} />
</header>
<div className="mobile-edit-form">
<div className="mobile-form-group">
<label className="mobile-form-label">Profile</label>
<p>{previewData.profile_name}</p>
</div>
{previewData.included_profiles.length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Included Profiles</label>
<ul className="mobile-list-plain">
{previewData.included_profiles.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
)}
{Object.keys(previewData.env_vars).length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Environment Variables</label>
<pre className="mobile-preview-code">
{JSON.stringify(previewData.env_vars, null, 2)}
</pre>
</div>
)}
{Object.keys(previewData.runtime_hints).length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Runtime Hints</label>
<pre className="mobile-preview-code">
{JSON.stringify(previewData.runtime_hints, null, 2)}
</pre>
</div>
)}
{previewData.mounts.length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Mounts</label>
{previewData.mounts.map((m, idx) => (
<div key={idx} className="mobile-preview-item">
<strong>
{m.target} ({m.mode})
</strong>
{Object.keys(m.files).length > 0 && (
<pre className="mobile-preview-code">
{JSON.stringify(m.files, null, 2)}
</pre>
)}
</div>
))}
</div>
)}
{previewData.git_mounts.length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Git Mounts</label>
{previewData.git_mounts.map((m, idx) => (
<div key={idx} className="mobile-preview-item">
<strong>
{m.remote_url}
{m.branch ? ` @${m.branch}` : ""}
</strong>
{m.mappings.map((mapping, mi) => (
<div
key={mi}
className="mobile-preview-mapping"
>
{mapping.source_path || "."} {" "}
{mapping.target_path}
</div>
))}
</div>
))}
</div>
)}
{Object.keys(previewData.files).length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Files</label>
<pre className="mobile-preview-code">
{JSON.stringify(previewData.files, null, 2)}
</pre>
</div>
)}
{Object.keys(previewData.overrides.env_vars).length > 0 && (
<div className="mobile-form-group">
<label className="mobile-form-label">Overrides</label>
<pre className="mobile-preview-code">
{JSON.stringify(previewData.overrides, null, 2)}
</pre>
</div>
)}
</div>
</div>
);
}
if (mobileView === "detail" && selectedProfile) {
const fields = [
{ label: "Name", value: selectedProfile.name },
{
label: "Description",
value: selectedProfile.description || null,
},
{ label: "Scope", value: getScopeLabel(selectedProfile) },
{ label: "Project", value: projectName(selectedProfile.project_id || undefined) },
{ label: "Tool Type", value: toolTypeName(selectedProfile.tool_type_id || undefined) },
{
label: "Default",
value: selectedProfile.is_default,
type: "boolean" as const,
},
{
label: "Includes",
value:
selectedProfile.includes.length > 0
? selectedProfile.includes
.map(
(inc) =>
getIncludedProfile(inc.included_profile_id)?.name ||
inc.included_profile_id,
)
.join(", ")
: null,
},
{
label: "Environment Variables",
value:
Object.keys(selectedProfile.env_vars).length > 0
? Object.entries(selectedProfile.env_vars)
.map(([k, v]) => `${k}=${v}`)
.join(", ")
: null,
},
{
label: "Runtime Hints",
value:
Object.keys(selectedProfile.runtime_hints).length > 0
? JSON.stringify(selectedProfile.runtime_hints, null, 2)
: null,
type: "code" as const,
},
{
label: "Files",
value:
Object.keys(selectedProfile.files).length > 0
? Object.keys(selectedProfile.files).join(", ")
: null,
},
{
label: "Mounts",
value:
selectedProfile.mounts.length > 0
? selectedProfile.mounts
.map((m) => `${m.target} (${m.mode})`)
.join(", ")
: null,
},
{
label: "Git Mounts",
value:
selectedProfile.git_mounts.length > 0
? selectedProfile.git_mounts
.map((m) => m.remote_url)
.join(", ")
: null,
},
];
return (
<MobileDetailView
title={selectedProfile.name}
subtitle={getScopeLabel(selectedProfile)}
fields={fields}
onBack={() => onViewChange("list")}
onEdit={() => {
onSelect(selectedProfile);
onViewChange("edit");
}}
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"
disabled={isRefreshingGitMounts}
onClick={() => onRefreshGitMounts(selectedProfile.id)}
>
{isRefreshingGitMounts ? (
<>
<Icon name="loading" size="sm" />
Refreshing Git mounts...
</>
) : (
<>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</>
)}
</button>
<button
type="button"
className="secondary-button"
disabled={previewingId === selectedProfile.id}
onClick={() => {
onClosePreview();
void onPreview(selectedProfile.id);
onViewChange("preview");
}}
>
{previewingId === selectedProfile.id ? (
<>
<Icon name="loading" size="sm" />
Previewing...
</>
) : (
<>
<Icon name="info" size="sm" />
Preview
</>
)}
</button>
</div>
</MobileDetailView>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Profile" : "Edit Profile"}
onCancel={() => {
onViewChange("list");
}}
onSave={handleSave}
onDelete={isCreating ? undefined : handleDeleteClick}
deleteLabel="Delete Profile"
isSaving={isSaving || saveStatus === "saving"}
>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="cp-name">
Name *
</label>
<input
id="cp-name"
type="text"
value={formData.name}
onChange={(e) => onFormChange("name", e.target.value)}
placeholder="e.g., Development Environment"
className="mobile-form-input"
required
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="cp-description">
Description
</label>
<input
id="cp-description"
type="text"
value={formData.description || ""}
onChange={(e) =>
onFormChange("description", e.target.value || undefined)
}
placeholder="Optional description"
className="mobile-form-input"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="cp-project">
Project
</label>
<select
id="cp-project"
value={formData.project_id || ""}
onChange={(e) =>
onFormChange("project_id", e.target.value || undefined)
}
className="mobile-form-select"
>
<option value="">None (Global)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label" htmlFor="cp-tool">
Tool Type
</label>
<select
id="cp-tool"
value={formData.tool_type_id || ""}
onChange={(e) =>
onFormChange("tool_type_id", e.target.value || undefined)
}
className="mobile-form-select"
>
<option value="">None</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>
{toolType.display_name}
</option>
))}
</select>
</div>
<div className="mobile-form-group mobile-form-row">
<label className="mobile-form-label mobile-form-checkbox-label">
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) =>
onFormChange("is_default", e.target.checked)
}
className="mobile-form-checkbox"
/>
Set as default for this scope
</label>
</div>
<div className="mobile-form-section">
<div className="mobile-form-section-header">
<h4>Includes</h4>
<span className="muted">{includedProfileIds.length} included</span>
</div>
{includedProfileIds.length === 0 ? (
<p className="muted mobile-form-help">
No profiles included. Add profiles to compose configurations.
</p>
) : (
<div className="mobile-includes-list">
{includedProfileIds.map((profileId, index) => {
const profile = getIncludedProfile(profileId);
if (!profile) return null;
return (
<div key={`${profileId}-${index}`} className="mobile-include-item">
<span className="mobile-include-name">{profile.name}</span>
<span className="mobile-include-scope">
{getScopeLabel(profile)}
</span>
<button
type="button"
className="ghost-button small"
onClick={() => onRemoveInclude(index)}
title="Remove include"
>
<Icon name="delete" size="sm" />
</button>
</div>
);
})}
</div>
)}
{availableProfiles.length > 0 && (
<select
value=""
onChange={(e) => {
if (e.target.value) {
onAddInclude(e.target.value);
e.target.value = "";
}
}}
className="mobile-form-select"
>
<option value="">+ Add Include...</option>
{availableProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({getScopeLabel(p)})
</option>
))}
</select>
)}
</div>
<div className="mobile-form-section">
<div className="mobile-form-section-header">
<h4>Environment Variables</h4>
</div>
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
<div key={idx} className="mobile-key-value-row">
<input
type="text"
value={key}
onChange={(e) =>
onUpdateEnvVar(key, e.target.value, value)
}
placeholder="VAR_NAME"
className="mobile-form-input"
/>
<input
type="text"
value={value}
onChange={(e) =>
onUpdateEnvVar(key, key, e.target.value)
}
placeholder="value"
className="mobile-form-input"
/>
<button
type="button"
className="ghost-button small"
onClick={() => onRemoveEnvVar(key)}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button mobile-add-button"
onClick={onAddEnvVar}
>
<Icon name="add" size="sm" /> Add Variable
</button>
</div>
<div className="mobile-form-section">
<div className="mobile-form-section-header">
<h4>Runtime Hints</h4>
</div>
<textarea
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onFormChange("runtime_hints", parsed);
} catch {
/* ignore invalid JSON while typing */
}
}}
placeholder='{"start_command": "npm start"}'
rows={4}
className="mobile-form-textarea mobile-form-code"
/>
</div>
<div className="mobile-form-section">
<div className="mobile-form-section-header">
<h4>Files</h4>
</div>
<p className="muted mobile-form-help">
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="mobile-file-card">
<div className="mobile-file-header">
<input
type="text"
value={path}
onChange={(e) =>
onUpdateFile(path, e.target.value, content)
}
placeholder="relative/path/to/file"
className="mobile-form-input"
/>
<button
type="button"
className="ghost-button small"
onClick={() => onRemoveFile(path)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<textarea
value={content}
onChange={(e) =>
onUpdateFile(path, path, e.target.value)
}
placeholder="File content"
rows={3}
className="mobile-form-textarea mobile-form-code"
/>
</div>
))}
<button
type="button"
className="secondary-button mobile-add-button"
onClick={onAddFile}
>
<Icon name="add" size="sm" /> Add File
</button>
</div>
<div className="mobile-form-section">
<div className="mobile-form-section-header">
<h4>Mounts</h4>
</div>
<p className="muted mobile-form-help">
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="mobile-mount-card">
<div className="mobile-mount-header">
<input
type="text"
value={mount.target}
onChange={(e) =>
onUpdateMount(index, { target: e.target.value })
}
placeholder="/target/path"
className="mobile-form-input"
/>
<select
value={mount.mode}
onChange={(e) =>
onUpdateMount(index, {
mode: e.target.value as "ro" | "rw",
})
}
className="mobile-form-select"
>
<option value="rw">Read/Write</option>
<option value="ro">Read-Only</option>
</select>
<button
type="button"
className="ghost-button small"
onClick={() => onRemoveMount(index)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<div className="mobile-mount-files">
{Object.entries(mount.files).map(([path, content], idx) => (
<div key={idx} className="mobile-mount-file-row">
<input
type="text"
value={path}
onChange={(e) =>
onUpdateMountFile(
index,
path,
e.target.value,
content,
)
}
placeholder="relative/path"
className="mobile-form-input"
/>
<textarea
value={content}
onChange={(e) =>
onUpdateMountFile(
index,
path,
path,
e.target.value,
)
}
placeholder="File content"
rows={2}
className="mobile-form-textarea mobile-form-code"
/>
<button
type="button"
className="ghost-button small"
onClick={() =>
onRemoveMountFile(index, path)
}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button small mobile-add-button"
onClick={() => onAddMountFile(index)}
>
<Icon name="add" size="sm" /> Add File to Mount
</button>
</div>
</div>
))}
<button
type="button"
className="secondary-button mobile-add-button"
onClick={onAddMount}
>
<Icon name="add" size="sm" /> Add Mount
</button>
</div>
<div className="mobile-form-section">
<GitMountEditor
mounts={formData.git_mounts || []}
onChange={(git_mounts) =>
onFormChange("git_mounts", git_mounts)
}
/>
</div>
{(error || saveStatus === "error") && (
<p className="mobile-form-error">
<Icon name="warning" size="sm" /> {error || "Failed to save"}
</p>
)}
{saveStatus === "saved" && (
<div className="mobile-form-success">
<Icon name="success" size="sm" />
Profile saved successfully
</div>
)}
</MobileEditView>
);
}
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
<span className="muted">{profiles.length} profiles</span>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
const profile = profiles.find((p) => p.id === id);
if (profile) {
onSelect(profile);
onViewChange("edit");
}
}}
onItemDelete={(id: string) => onDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB
onClick={() => {
onCreate();
onViewChange("edit");
}}
/>
</div>
);
};