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

342 lines
17 KiB
TypeScript

import { Icon } from "../../icon";
import { GitMountEditor } from "../git/git-mount-editor";
import type { ConfigProfile, CreateConfigProfileRequest, ResolvedProfile } from "../../../api/config-profiles";
import type { ProjectWithRepos } from "../../../types";
import type { ToolType } from "../../../api/tool-types";
interface Props {
isCreating: boolean;
selectedProfile: ConfigProfile | null;
formData: CreateConfigProfileRequest;
includedProfileIds: string[];
dragOverIndex: number | null;
error: string | null;
saveStatus: "idle" | "saving" | "saved" | "error";
previewData: ResolvedProfile | null;
previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
projects: ProjectWithRepos[];
toolTypes: ToolType[];
availableProfiles: ConfigProfile[];
getIncludedProfile: (id: string) => ConfigProfile | undefined;
getScopeLabel: (profile: ConfigProfile) => string;
onFormChange: <K extends keyof CreateConfigProfileRequest>(key: K, value: CreateConfigProfileRequest[K]) => void;
onSubmit: (e?: React.FormEvent) => void;
onReset: () => void;
onPreview: () => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: () => void;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void;
onDragOver: (e: React.DragEvent, index: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, 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;
onClosePreview: () => void;
}
export const ConfigProfileEditorPanel = ({
isCreating,
selectedProfile,
formData,
includedProfileIds,
dragOverIndex,
error,
saveStatus,
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
projects,
toolTypes,
availableProfiles,
getIncludedProfile,
getScopeLabel,
onFormChange,
onSubmit,
onReset,
onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onAddInclude,
onRemoveInclude,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onAddEnvVar,
onUpdateEnvVar,
onRemoveEnvVar,
onAddFile,
onUpdateFile,
onRemoveFile,
onAddMount,
onUpdateMount,
onRemoveMount,
onAddMountFile,
onUpdateMountFile,
onRemoveMountFile,
onClosePreview,
}: Props) => {
const hasSelection = isCreating || selectedProfile;
return (
<div className="flex-1 overflow-auto p-5 pb-20 min-w-0">
{!hasSelection ? (
<div className="empty-state">
<div className="empty-state-icon">
<Icon name="folder" size="lg" />
</div>
<h3>Select a config profile</h3>
<p>Choose a profile from the list to edit, or create a new one.</p>
</div>
) : (
<div className="max-w-form">
<div className="row justify-between items-start mb-5">
<div>
<h1 className="m-0 mb-2 text-xl">
{isCreating ? "Create Profile" : selectedProfile?.name}
</h1>
{!isCreating && selectedProfile && (
<p className="muted m-0">
{selectedProfile.project_id &&
`Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
{selectedProfile.tool_type_id &&
`Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
</p>
)}
</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 ? (
<>
<Icon name="loading" size="sm" /> Refreshing Git mounts...
</>
) : (
<>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</>
)}
</button>
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</>
) : (
<><Icon name="info" size="sm" /> Preview</>
)}
</button>
</div>
)}
</div>
{error && <div className="form-error mb-3">{error}</div>}
{saveStatus === "saved" && (
<div className="alert alert-success mb-3">
<Icon name="success" size="sm" />
Profile saved successfully
</div>
)}
<form onSubmit={onSubmit} className="stack stack-lg">
<div className="form-group">
<label htmlFor="profile-name">Name *</label>
<input id="profile-name" type="text" value={formData.name} onChange={(e) => onFormChange("name", e.target.value)} placeholder="e.g., Development Environment" className="form-input" required />
</div>
<div className="form-group">
<label htmlFor="profile-description">Description</label>
<input id="profile-description" type="text" value={formData.description || ""} onChange={(e) => onFormChange("description", e.target.value || undefined)} placeholder="Optional description" className="form-input" />
</div>
<div className="form-row row-md">
<div className="form-group">
<label htmlFor="profile-project">Project</label>
<select id="profile-project" value={formData.project_id || ""} onChange={(e) => onFormChange("project_id", e.target.value || undefined)} className="form-input">
<option value="">None (Global)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="profile-tool">Tool Type</label>
<select id="profile-tool" value={formData.tool_type_id || ""} onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)} className="form-input">
<option value="">None</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={formData.is_default || false} onChange={(e) => onFormChange("is_default", e.target.checked)} />
Set as default for this scope
</label>
</div>
<section className="config-section">
<div className="config-section-header">
<div>
<h4 className="config-section-title">Includes</h4>
<p className="config-section-subtitle">Compose configurations from other profiles</p>
</div>
<span className="muted text-sm">{includedProfileIds.length} included</span>
</div>
{includedProfileIds.length === 0 ? (
<p className="muted text-sm m-0 mb-3">No profiles included. Add profiles to compose configurations.</p>
) : (
<div className="mb-3">
{includedProfileIds.map((profileId, index) => {
const profile = getIncludedProfile(profileId);
if (!profile) return null;
return (
<div
key={`${profileId}-${index}`}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, index)}
className={dragOverIndex === index ? "drag-item drag-item-active" : "drag-item"}
>
<span className="drag-handle"><Icon name="drag" size="sm" /></span>
<span className="flex-1 font-medium truncate">{profile.name}</span>
<span className="badge">{getScopeLabel(profile)}</span>
<button type="button" onClick={() => onRemoveInclude(index)} className="delete-btn" title="Remove include">
<Icon name="delete" size="sm" />
</button>
</div>
);
})}
</div>
)}
{availableProfiles.length > 0 && (
<div className="form-group mb-0">
<select value="" onChange={(e) => { if (e.target.value) { onAddInclude(e.target.value); e.target.value = ""; } }} className="form-input">
<option value="">+ Add Include...</option>
{availableProfiles.map((p) => (
<option key={p.id} value={p.id}>{p.name} ({getScopeLabel(p)})</option>
))}
</select>
</div>
)}
</section>
<section className="config-section">
<h4 className="config-section-title">Environment Variables</h4>
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
<div key={idx} className="form-row row-sm mb-2">
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" />
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="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" onClick={onAddEnvVar}><Icon name="add" size="sm" /> Add Variable</button>
</section>
<section className="config-section">
<h4 className="config-section-title">Runtime Hints</h4>
<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 */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input font-mono text-sm" />
</section>
<section className="config-section">
<div className="config-section-header">
<div>
<h4 className="config-section-title">Files</h4>
<p className="config-section-subtitle">Relative paths written to the instance directory</p>
</div>
</div>
{Object.entries(formData.files || {}).map(([path, content], idx) => (
<div key={idx} className="card-sm mb-2">
<div className="form-row row-sm mb-2">
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="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="form-input font-mono text-sm" />
</div>
))}
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
</section>
<section className="config-section">
<div className="config-section-header">
<div>
<h4 className="config-section-title">Mounts</h4>
<p className="config-section-subtitle">Bind directories into the container at absolute paths</p>
</div>
</div>
{(formData.mounts || []).map((mount, index) => (
<div key={index} className="card-sm mb-3">
<div className="form-row row-sm mb-3">
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" />
<select value={mount.mode} onChange={(e) => onUpdateMount(index, { mode: e.target.value as "ro" | "rw" })} className="form-input" style={{ width: "120px" }}>
<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="pl-4 border-l-2 stack stack-sm">
{Object.entries(mount.files).map(([path, content], idx) => (
<div key={idx} className="form-row row-sm">
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" />
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input font-mono text-sm" />
<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" onClick={() => onAddMountFile(index)}><Icon name="add" size="sm" /> Add File to Mount</button>
</div>
</div>
))}
<button type="button" className="secondary-button" onClick={onAddMount}><Icon name="add" size="sm" /> Add Mount</button>
</section>
<section className="config-section">
<h4 className="config-section-title">Git Mounts</h4>
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
</section>
<div className="config-section-actions">
<button type="submit" disabled={saveStatus === "saving"} className="btn btn-primary">
<Icon name={isCreating ? "add" : "save"} size="sm" />
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
</button>
{(isCreating || saveStatus !== "idle") && (
<button type="button" onClick={onReset} className="btn btn-secondary"><Icon name="cancel" size="sm" /> Discard</button>
)}
</div>
</form>
{previewData && (
<div className="card stack stack-md mt-6">
<h3>Resolved Profile Preview</h3>
<pre className="code-block" style={{ maxHeight: "400px" }}>{JSON.stringify(previewData, null, 2)}</pre>
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
</div>
)}
</div>
)}
</div>
);
};