886c863260
- Use profile-scoped canonical Git clone sources with locked refreshes - Mount shared Git configuration read-only and isolate profile content - Add API and desktop/mobile actions for live Git mount refresh Quality gates: frontend build and backend py_compile passed. Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
325 lines
16 KiB
TypeScript
325 lines
16 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;
|
|
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;
|
|
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,
|
|
projects,
|
|
toolTypes,
|
|
availableProfiles,
|
|
getIncludedProfile,
|
|
getScopeLabel,
|
|
onFormChange,
|
|
onSubmit,
|
|
onReset,
|
|
onPreview,
|
|
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={onRefreshGitMounts}>
|
|
<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>
|
|
);
|
|
};
|