5d5b39bec1
Pass 2 of the web UI spacing/typography/visual-rhythm rework. - Add layout, spacing, typography, visual, card, and component utilities - Add form-section, form-row, form-help, text-error, alert-success - Unify .form-group and .form-field; add .status-badge family - Alias legacy button classes to .btn primitives - Refactor ToolTypeListSidebar and ConfigProfileListSidebar to use .sidebar and var(--sidebar-width) instead of hardcoded 280px - Refactor ToolTypeEditorPanel, ConfigProfileEditorPanel, git-mount-editor, and manifest-editor to use utility classes Quality gates: npm run typecheck, npm run lint, npm run build pass. Inline style blocks in target components reduced from 198 to 11.
308 lines
15 KiB
TypeScript
308 lines
15 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;
|
|
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,
|
|
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 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={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>
|
|
|
|
<div className="form-section">
|
|
<div className="row justify-between items-center mb-3">
|
|
<h4 className="m-0">Includes</h4>
|
|
<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>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<h4 className="m-0 mb-3">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>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<h4 className="m-0 mb-3">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" />
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<h4 className="m-0 mb-2">Files</h4>
|
|
<p className="muted text-sm m-0 mb-3">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="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>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<h4 className="m-0 mb-2">Mounts</h4>
|
|
<p className="muted text-sm m-0 mb-3">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="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>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
|
|
</div>
|
|
|
|
<div className="dialog-actions sticky-footer">
|
|
<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>
|
|
);
|
|
};
|