refactor: extract ConfigProfilesPage components
- Extract use-config-profiles hook for state management - Extract ConfigProfileListSidebar, ConfigProfileEditorPanel, ConfigProfilesMobileView - Slim ConfigProfilesPage from 1,611 to 170 lines Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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 style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||
{!hasSelection ? (
|
||||
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
||||
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||
<Icon name="folder" size="lg" />
|
||||
</div>
|
||||
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
|
||||
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||
{isCreating ? "Create Profile" : selectedProfile?.name}
|
||||
</h1>
|
||||
{!isCreating && selectedProfile && (
|
||||
<p className="muted" style={{ margin: 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 style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button className="secondary-button" 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="error" style={{ marginBottom: "1rem" }}>{error}</div>}
|
||||
|
||||
{saveStatus === "saved" && (
|
||||
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Icon name="success" size="sm" />
|
||||
Profile saved successfully
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
|
||||
<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="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<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" style={{ flex: 1 }}>
|
||||
<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 style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Includes</h4>
|
||||
<span className="muted" style={{ fontSize: "0.875rem" }}>{includedProfileIds.length} included</span>
|
||||
</div>
|
||||
{includedProfileIds.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>No profiles included. Add profiles to compose configurations.</p>
|
||||
) : (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
{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)}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", border: "1px solid var(--border)", borderRadius: "0.375rem", marginBottom: "0.25rem", cursor: "grab", transition: "background 0.15s" }}
|
||||
>
|
||||
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
|
||||
<span style={{ fontSize: "0.75rem", padding: "0.125rem 0.375rem", background: "var(--badge-bg, #f3f4f6)", color: "var(--muted)", borderRadius: "0.25rem", textTransform: "uppercase", letterSpacing: "0.025em" }}>{getScopeLabel(profile)}</span>
|
||||
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{availableProfiles.length > 0 && (
|
||||
<div className="form-group" style={{ marginBottom: 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 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" style={{ flex: 1 }} />
|
||||
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" style={{ flex: 1 }} />
|
||||
<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 style={{ margin: "0 0 0.75rem 0" }}>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" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>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" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" style={{ flex: 1 }} />
|
||||
<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" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>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" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" style={{ flex: 1 }} />
|
||||
<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 style={{ marginLeft: "1rem" }}>
|
||||
{Object.entries(mount.files).map(([path, content], idx) => (
|
||||
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" style={{ flex: 1 }} />
|
||||
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input" style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<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)} style={{ fontSize: "0.875rem" }}><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" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||
<button type="submit" disabled={saveStatus === "saving"}>
|
||||
<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="button-secondary"><Icon name="cancel" size="sm" /> Discard</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{previewData && (
|
||||
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
|
||||
<h3>Resolved Profile Preview</h3>
|
||||
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>{JSON.stringify(previewData, null, 2)}</pre>
|
||||
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Icon } from "../../icon";
|
||||
import type { ConfigProfile } from "../../../api/config-profiles";
|
||||
|
||||
interface Props {
|
||||
profiles: ConfigProfile[];
|
||||
selectedProfileId: string | null;
|
||||
onSelect: (profile: ConfigProfile) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ConfigProfileListSidebar = ({
|
||||
profiles,
|
||||
selectedProfileId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
|
||||
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
|
||||
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => onSelect(profile)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "0.25rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "none",
|
||||
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
|
||||
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedProfileId !== profile.id) {
|
||||
e.currentTarget.style.background = "#ece7df";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedProfileId !== profile.id) {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{profile.name}
|
||||
{profile.is_default && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
marginLeft: "0.5rem",
|
||||
opacity: 0.8,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.025em",
|
||||
}}
|
||||
>
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
{profile.includes?.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
marginLeft: "0.5rem",
|
||||
opacity: 0.7,
|
||||
background:
|
||||
selectedProfileId === profile.id
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "var(--badge-bg, #f3f4f6)",
|
||||
padding: "0.0625rem 0.375rem",
|
||||
borderRadius: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}
|
||||
>
|
||||
{profile.project_id && "Project scoped"}
|
||||
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
|
||||
{!profile.project_id && !profile.tool_type_id && "Global"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(profile.id);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
className="delete-btn"
|
||||
title="Delete profile"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "2px dashed var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--brand)";
|
||||
e.currentTarget.style.color = "var(--brand)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
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 { Icon } from "../../icon";
|
||||
import type { ConfigProfile, CreateConfigProfileRequest } from "../../../api/config-profiles";
|
||||
|
||||
type MobileView = "list" | "detail" | "edit";
|
||||
|
||||
interface Props {
|
||||
profiles: ConfigProfile[];
|
||||
selectedProfile: ConfigProfile | null;
|
||||
mobileView: MobileView;
|
||||
isCreating: boolean;
|
||||
formData: CreateConfigProfileRequest;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
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: () => void;
|
||||
getScopeLabel: (profile: ConfigProfile) => string;
|
||||
}
|
||||
|
||||
export const ConfigProfilesMobileView = ({
|
||||
profiles,
|
||||
selectedProfile,
|
||||
mobileView,
|
||||
isCreating,
|
||||
formData,
|
||||
saveStatus,
|
||||
onViewChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onFormChange,
|
||||
onSubmit,
|
||||
getScopeLabel,
|
||||
}: Props) => {
|
||||
if (mobileView === "list") {
|
||||
return (
|
||||
<div className="mobile-page">
|
||||
<div className="mobile-page-header">
|
||||
<h1>Config Profiles</h1>
|
||||
</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("detail");
|
||||
}
|
||||
}}
|
||||
onItemDelete={(id: string) => onDelete(id)}
|
||||
emptyMessage="No config profiles yet"
|
||||
/>
|
||||
<MobileFAB
|
||||
onClick={() => {
|
||||
onCreate();
|
||||
onViewChange("edit");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mobileView === "detail" && selectedProfile) {
|
||||
const fields = [
|
||||
{ label: "Name", value: selectedProfile.name },
|
||||
{ label: "Description", value: selectedProfile.description || "-" },
|
||||
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
||||
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
|
||||
{
|
||||
label: "Environment Variables",
|
||||
value:
|
||||
Object.keys(selectedProfile.env_vars).length > 0
|
||||
? Object.entries(selectedProfile.env_vars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
label: "Mounts",
|
||||
value:
|
||||
selectedProfile.mounts.length > 0
|
||||
? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ")
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
label: "Includes",
|
||||
value:
|
||||
selectedProfile.includes.length > 0
|
||||
? `${selectedProfile.includes.length} profile(s)`
|
||||
: "-",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<MobileDetailView
|
||||
title={selectedProfile.name}
|
||||
subtitle={getScopeLabel(selectedProfile)}
|
||||
fields={fields}
|
||||
onBack={() => onViewChange("list")}
|
||||
onEdit={() => {
|
||||
onSelect(selectedProfile);
|
||||
onViewChange("edit");
|
||||
}}
|
||||
onDelete={() => onDelete(selectedProfile.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mobileView === "edit") {
|
||||
return (
|
||||
<MobileEditView
|
||||
title={isCreating ? "Create Profile" : "Edit Profile"}
|
||||
onCancel={() => {
|
||||
onViewChange(isCreating ? "list" : "detail");
|
||||
}}
|
||||
onSave={onSubmit}
|
||||
isSaving={saveStatus === "saving"}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormChange("name", e.target.value)}
|
||||
placeholder="Profile name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => onFormChange("description", e.target.value || undefined)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project</label>
|
||||
<select
|
||||
value={formData.project_id || ""}
|
||||
onChange={(e) => onFormChange("project_id", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">Global (all projects)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tool Type</label>
|
||||
<select
|
||||
value={formData.tool_type_id || ""}
|
||||
onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">Any tool type</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default || false}
|
||||
onChange={(e) => onFormChange("is_default", e.target.checked)}
|
||||
/>
|
||||
Default Profile
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Environment Variables</label>
|
||||
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
|
||||
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newEnvVars = { ...formData.env_vars };
|
||||
delete newEnvVars[key];
|
||||
newEnvVars[e.target.value] = value;
|
||||
onFormChange("env_vars", newEnvVars);
|
||||
}}
|
||||
placeholder="KEY"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newEnvVars = { ...formData.env_vars };
|
||||
newEnvVars[key] = e.target.value;
|
||||
onFormChange("env_vars", newEnvVars);
|
||||
}}
|
||||
placeholder="value"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newEnvVars = { ...formData.env_vars };
|
||||
delete newEnvVars[key];
|
||||
onFormChange("env_vars", newEnvVars);
|
||||
}}
|
||||
className="secondary-button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
onFormChange("env_vars", { ...formData.env_vars, "": "" });
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Mounts</label>
|
||||
{(formData.mounts || []).map((mount, index) => (
|
||||
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.target}
|
||||
onChange={(e) => {
|
||||
const newMounts = [...(formData.mounts || [])];
|
||||
newMounts[index] = { ...mount, target: e.target.value };
|
||||
onFormChange("mounts", newMounts);
|
||||
}}
|
||||
placeholder="Target path"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<select
|
||||
value={mount.mode}
|
||||
onChange={(e) => {
|
||||
const newMounts = [...(formData.mounts || [])];
|
||||
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
|
||||
onFormChange("mounts", newMounts);
|
||||
}}
|
||||
style={{ width: "80px" }}
|
||||
>
|
||||
<option value="ro">Read</option>
|
||||
<option value="rw">Write</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
|
||||
onFormChange("mounts", newMounts);
|
||||
}}
|
||||
className="secondary-button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
onFormChange("mounts", [...(formData.mounts || []), { target: "/", mode: "rw", files: {} }]);
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Mount
|
||||
</button>
|
||||
</div>
|
||||
</MobileEditView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-page">
|
||||
<div className="mobile-page-header">
|
||||
<h1>Config Profiles</h1>
|
||||
</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("detail");
|
||||
}
|
||||
}}
|
||||
onItemDelete={(id: string) => onDelete(id)}
|
||||
emptyMessage="No config profiles yet"
|
||||
/>
|
||||
<MobileFAB
|
||||
onClick={() => {
|
||||
onCreate();
|
||||
onViewChange("edit");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
createConfigProfile,
|
||||
deleteConfigProfile,
|
||||
listConfigProfiles,
|
||||
previewConfigProfile,
|
||||
updateConfigProfile,
|
||||
updateProfileIncludes,
|
||||
type ConfigProfile,
|
||||
type CreateConfigProfileRequest,
|
||||
type ResolvedProfile,
|
||||
} from "../api/config-profiles";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
|
||||
type Status = "loading" | "ready" | "error";
|
||||
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const defaultForm: CreateConfigProfileRequest = {
|
||||
name: "",
|
||||
description: "",
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
export const useConfigProfiles = () => {
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [profs, projs, types] = await Promise.all([
|
||||
listConfigProfiles(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setProfiles(profs || []);
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(defaultForm);
|
||||
setIncludedProfileIds([]);
|
||||
setError(null);
|
||||
setSaveStatus("idle");
|
||||
setPreviewData(null);
|
||||
};
|
||||
|
||||
const populateForm = (profile: ConfigProfile) => {
|
||||
setFormData({
|
||||
name: profile.name,
|
||||
description: profile.description || undefined,
|
||||
project_id: profile.project_id || undefined,
|
||||
tool_type_id: profile.tool_type_id || undefined,
|
||||
env_vars: profile.env_vars,
|
||||
runtime_hints: profile.runtime_hints,
|
||||
mounts: profile.mounts,
|
||||
git_mounts: profile.git_mounts || [],
|
||||
files: profile.files,
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
setIncludedProfileIds(
|
||||
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
||||
);
|
||||
setError(null);
|
||||
setSaveStatus("idle");
|
||||
setPreviewData(null);
|
||||
};
|
||||
|
||||
const handleSelectProfile = (profile: ConfigProfile | null) => {
|
||||
if (profile) {
|
||||
setSelectedProfileId(profile.id);
|
||||
setIsCreating(false);
|
||||
populateForm(profile);
|
||||
} else {
|
||||
setSelectedProfileId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedProfileId(null);
|
||||
setIsCreating(true);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const getIncludedProfile = (id: string): ConfigProfile | undefined =>
|
||||
profiles.find((p) => p.id === id);
|
||||
|
||||
const getScopeLabel = (profile: ConfigProfile): string => {
|
||||
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
|
||||
if (profile.project_id) return "Project";
|
||||
if (profile.tool_type_id) return "Tool";
|
||||
return "Global";
|
||||
};
|
||||
|
||||
const wouldCreateCycle = (
|
||||
profileId: string,
|
||||
targetId: string,
|
||||
visited = new Set<string>(),
|
||||
): boolean => {
|
||||
if (visited.has(targetId)) return true;
|
||||
const target = getIncludedProfile(targetId);
|
||||
if (!target) return false;
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(targetId);
|
||||
for (const inc of target.includes) {
|
||||
if (
|
||||
inc.included_profile_id === profileId ||
|
||||
wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const availableProfilesForInclude = (): ConfigProfile[] => {
|
||||
const currentId = selectedProfile?.id;
|
||||
if (!currentId) return [];
|
||||
return profiles.filter((p) => {
|
||||
if (p.id === currentId) return false;
|
||||
if (includedProfileIds.includes(p.id)) return false;
|
||||
if (wouldCreateCycle(currentId, p.id)) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const addInclude = (profileId: string) => {
|
||||
setIncludedProfileIds((prev) => [...prev, profileId]);
|
||||
};
|
||||
|
||||
const removeInclude = (index: number) => {
|
||||
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (dragIndex === dropIndex) {
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
setIncludedProfileIds((prev) => {
|
||||
const newOrder = [...prev];
|
||||
const [removed] = newOrder.splice(dragIndex, 1);
|
||||
newOrder.splice(dropIndex, 0, removed);
|
||||
return newOrder;
|
||||
});
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setError(null);
|
||||
setSaveStatus("saving");
|
||||
|
||||
if (!formData.name?.trim()) {
|
||||
setError("Name is required");
|
||||
setSaveStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
const newProfile = await createConfigProfile(formData);
|
||||
if (includedProfileIds.length > 0) {
|
||||
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
||||
}
|
||||
setIsCreating(false);
|
||||
setSelectedProfileId(newProfile.id);
|
||||
setSaveStatus("saved");
|
||||
await loadData();
|
||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
||||
if (refreshed) populateForm(refreshed);
|
||||
} else if (selectedProfile) {
|
||||
await updateConfigProfile(selectedProfile.id, formData);
|
||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
||||
setSaveStatus("saved");
|
||||
await loadData();
|
||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
||||
if (refreshed) populateForm(refreshed);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
||||
try {
|
||||
await deleteConfigProfile(id);
|
||||
if (selectedProfileId === id) {
|
||||
setSelectedProfileId(null);
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete config profile");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async (id: string) => {
|
||||
try {
|
||||
setPreviewingId(id);
|
||||
const data = await previewConfigProfile(id);
|
||||
setPreviewData(data);
|
||||
} catch {
|
||||
setError("Failed to preview config profile");
|
||||
} finally {
|
||||
setPreviewingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
|
||||
key: K,
|
||||
value: CreateConfigProfileRequest[K],
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
||||
setFormData((prev) => {
|
||||
const envVars = { ...prev.env_vars };
|
||||
if (oldKey !== newKey) delete envVars[oldKey];
|
||||
envVars[newKey] = value;
|
||||
return { ...prev, env_vars: envVars };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeEnvVar = (key: string) => {
|
||||
setFormData((prev) => {
|
||||
const envVars = { ...prev.env_vars };
|
||||
delete envVars[key];
|
||||
return { ...prev, env_vars: envVars };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addFile = () => {
|
||||
setFormData((prev) => ({ ...prev, files: { ...prev.files, "": "" } }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
||||
setFormData((prev) => {
|
||||
const files = { ...prev.files };
|
||||
if (oldPath !== newPath) delete files[oldPath];
|
||||
files[newPath] = content;
|
||||
return { ...prev, files };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeFile = (path: string) => {
|
||||
setFormData((prev) => {
|
||||
const files = { ...prev.files };
|
||||
delete files[path];
|
||||
return { ...prev, files };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addMount = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
||||
}));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[index] = { ...mounts[index], ...updates };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeMount = (index: number) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts.splice(index, 1);
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const addMountFile = (mountIndex: number) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[mountIndex] = {
|
||||
...mounts[mountIndex],
|
||||
files: { ...mounts[mountIndex].files, "": "" },
|
||||
};
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateMountFile = (
|
||||
mountIndex: number,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
content: string,
|
||||
) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
const files = { ...mounts[mountIndex].files };
|
||||
if (oldPath !== newPath) delete files[oldPath];
|
||||
files[newPath] = content;
|
||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const removeMountFile = (mountIndex: number, path: string) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
const files = { ...mounts[mountIndex].files };
|
||||
delete files[path];
|
||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
profiles,
|
||||
projects,
|
||||
toolTypes,
|
||||
selectedProfile,
|
||||
selectedProfileId,
|
||||
isCreating,
|
||||
saveStatus,
|
||||
error,
|
||||
previewData,
|
||||
previewingId,
|
||||
formData,
|
||||
includedProfileIds,
|
||||
dragOverIndex,
|
||||
loadData,
|
||||
handleSelectProfile,
|
||||
handleCreateNew,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handlePreview,
|
||||
updateFormField,
|
||||
addEnvVar,
|
||||
updateEnvVar,
|
||||
removeEnvVar,
|
||||
addFile,
|
||||
updateFile,
|
||||
removeFile,
|
||||
addMount,
|
||||
updateMount,
|
||||
removeMount,
|
||||
addMountFile,
|
||||
updateMountFile,
|
||||
removeMountFile,
|
||||
getIncludedProfile,
|
||||
getScopeLabel,
|
||||
availableProfilesForInclude,
|
||||
addInclude,
|
||||
removeInclude,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
setPreviewData,
|
||||
setFormData,
|
||||
setSaveStatus,
|
||||
setIncludedProfileIds,
|
||||
populateForm,
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user