ab1843b1c3
- Replace awkward first-workspace-guessing logic with inline project/repo selector - New WorkspaceCreateInline component with cascading dropdowns: - Select project → loads repositories for that project - Select repository → enter workspace name + branch - Submit creates workspace via top-level POST /workspaces/ - Add createWorkspaceTopLevel() API client for flat endpoint - Works even with zero existing workspaces (shows create button in empty state) - Add CSS grid layout for inline create form - TypeScript + eslint clean
1612 lines
43 KiB
TypeScript
1612 lines
43 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { ErrorState, LoadingState } from "../components/data-states";
|
|
import { Icon } from "../components/icon";
|
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
import { extractErrorMessage } from "../utils/errors";
|
|
import { MobileListView } from "../components/mobile-list-view";
|
|
import { MobileDetailView } from "../components/mobile-detail-view";
|
|
import { MobileEditView } from "../components/mobile-edit-view";
|
|
import { MobileFAB } from "../components/mobile-fab";
|
|
import {
|
|
createConfigProfile,
|
|
deleteConfigProfile,
|
|
listConfigProfiles,
|
|
previewConfigProfile,
|
|
updateConfigProfile,
|
|
updateProfileIncludes,
|
|
type ConfigProfile,
|
|
type CreateConfigProfileRequest,
|
|
type ResolvedProfile,
|
|
} from "../api/config_profiles";
|
|
import { listProjects } from "../api/projects";
|
|
import type { ProjectWithRepos } from "../types";
|
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
|
import { GitMountEditor } from "../components/git-mount-editor";
|
|
|
|
type Status = "loading" | "ready" | "error";
|
|
type MobileView = "list" | "detail" | "edit";
|
|
|
|
export const ConfigProfilesPage = () => {
|
|
const isMobile = useMobileViewport();
|
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
|
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<
|
|
"idle" | "saving" | "saved" | "error"
|
|
>("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>({
|
|
name: "",
|
|
description: "",
|
|
env_vars: {},
|
|
runtime_hints: {},
|
|
mounts: [],
|
|
git_mounts: [],
|
|
files: {},
|
|
is_default: false,
|
|
});
|
|
|
|
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({
|
|
name: "",
|
|
description: "",
|
|
env_vars: {},
|
|
runtime_hints: {},
|
|
mounts: [],
|
|
git_mounts: [],
|
|
files: {},
|
|
is_default: false,
|
|
});
|
|
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();
|
|
};
|
|
|
|
// Include management functions
|
|
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";
|
|
};
|
|
|
|
// Cycle detection: returns true if adding targetId would create a cycle
|
|
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));
|
|
};
|
|
|
|
// Drag and drop handlers
|
|
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();
|
|
// Re-populate with the new profile data
|
|
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();
|
|
// Refresh the selected profile data
|
|
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");
|
|
};
|
|
|
|
if (status === "loading") {
|
|
return (
|
|
<div className="container">
|
|
<LoadingState message="Loading Config Profiles..." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === "error") {
|
|
return (
|
|
<div className="container">
|
|
<ErrorState
|
|
message="Failed to load Config Profiles."
|
|
onRetry={loadData}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Mobile view rendering
|
|
if (isMobile) {
|
|
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) => {
|
|
setSelectedProfileId(id);
|
|
setIsCreating(false);
|
|
const profile = profiles.find((p) => p.id === id);
|
|
if (profile) populateForm(profile);
|
|
setMobileView("detail");
|
|
}}
|
|
onItemDelete={(id: string) => handleDelete(id)}
|
|
emptyMessage="No config profiles yet"
|
|
/>
|
|
<MobileFAB
|
|
onClick={() => {
|
|
setIsCreating(true);
|
|
setSelectedProfileId(null);
|
|
resetForm();
|
|
setMobileView("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={() => setMobileView("list")}
|
|
onEdit={() => {
|
|
populateForm(selectedProfile);
|
|
setIsCreating(false);
|
|
setMobileView("edit");
|
|
}}
|
|
onDelete={() => handleDelete(selectedProfile.id)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (mobileView === "edit") {
|
|
return (
|
|
<MobileEditView
|
|
title={isCreating ? "Create Profile" : "Edit Profile"}
|
|
onCancel={() => {
|
|
if (isCreating) {
|
|
setMobileView("list");
|
|
} else if (selectedProfile) {
|
|
setMobileView("detail");
|
|
} else {
|
|
setMobileView("list");
|
|
}
|
|
}}
|
|
onSave={() =>
|
|
handleSubmit({ preventDefault: () => {} } as React.FormEvent)
|
|
}
|
|
isSaving={saveStatus === "saving"}
|
|
>
|
|
{/* Profile form fields */}
|
|
<div className="form-group">
|
|
<label>Name *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => updateFormField("name", e.target.value)}
|
|
placeholder="Profile name"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Description</label>
|
|
<textarea
|
|
value={formData.description || ""}
|
|
onChange={(e) => updateFormField("description", e.target.value)}
|
|
placeholder="Optional description"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Project</label>
|
|
<select
|
|
value={formData.project_id || ""}
|
|
onChange={(e) =>
|
|
updateFormField("project_id", e.target.value || undefined)
|
|
}
|
|
>
|
|
<option value="">Global (all projects)</option>
|
|
{projects.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Tool Type</label>
|
|
<select
|
|
value={formData.tool_type_id || ""}
|
|
onChange={(e) =>
|
|
updateFormField("tool_type_id", e.target.value || undefined)
|
|
}
|
|
>
|
|
<option value="">Any tool type</option>
|
|
{toolTypes.map((toolType) => (
|
|
<option key={toolType.id} value={toolType.id}>
|
|
{toolType.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_default || false}
|
|
onChange={(e) =>
|
|
updateFormField("is_default", e.target.checked)
|
|
}
|
|
/>
|
|
Default Profile
|
|
</label>
|
|
</div>
|
|
|
|
{/* Environment Variables */}
|
|
<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;
|
|
updateFormField("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;
|
|
updateFormField("env_vars", newEnvVars);
|
|
}}
|
|
placeholder="value"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const newEnvVars = { ...formData.env_vars };
|
|
delete newEnvVars[key];
|
|
updateFormField("env_vars", newEnvVars);
|
|
}}
|
|
className="secondary-button"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
),
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
onClick={addEnvVar}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Variable
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mounts */}
|
|
<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 };
|
|
updateFormField("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",
|
|
};
|
|
updateFormField("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,
|
|
);
|
|
updateFormField("mounts", newMounts);
|
|
}}
|
|
className="secondary-button"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
onClick={addMount}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Mount
|
|
</button>
|
|
</div>
|
|
</MobileEditView>
|
|
);
|
|
}
|
|
|
|
// Fallback to 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) => {
|
|
setSelectedProfileId(id);
|
|
setIsCreating(false);
|
|
const profile = profiles.find((p) => p.id === id);
|
|
if (profile) populateForm(profile);
|
|
setMobileView("detail");
|
|
}}
|
|
onItemDelete={(id: string) => handleDelete(id)}
|
|
emptyMessage="No config profiles yet"
|
|
/>
|
|
<MobileFAB
|
|
onClick={() => {
|
|
setIsCreating(true);
|
|
setSelectedProfileId(null);
|
|
resetForm();
|
|
setMobileView("edit");
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="container"
|
|
style={{
|
|
display: "flex",
|
|
height: "calc(100vh - 4rem)",
|
|
gap: 0,
|
|
padding: 0,
|
|
}}
|
|
>
|
|
{/* Left Sidebar - Profile List */}
|
|
<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={() => handleSelectProfile(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();
|
|
handleDelete(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={handleCreateNew}
|
|
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>
|
|
|
|
{/* Right Panel - Editor */}
|
|
<div
|
|
style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}
|
|
>
|
|
{!selectedProfileId && !isCreating ? (
|
|
<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>
|
|
{/* Header */}
|
|
<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={() => handlePreview(selectedProfile.id)}
|
|
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={handleSubmit}
|
|
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) => updateFormField("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) =>
|
|
updateFormField("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) =>
|
|
updateFormField("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) =>
|
|
updateFormField(
|
|
"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) =>
|
|
updateFormField("is_default", e.target.checked)
|
|
}
|
|
/>
|
|
Set as default for this scope
|
|
</label>
|
|
</div>
|
|
|
|
{/* Includes Section */}
|
|
<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) => handleDragStart(e, index)}
|
|
onDragOver={(e) => handleDragOver(e, index)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(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={() => removeInclude(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>
|
|
)}
|
|
|
|
{availableProfilesForInclude().length > 0 && (
|
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
<select
|
|
value=""
|
|
onChange={(e) => {
|
|
if (e.target.value) {
|
|
addInclude(e.target.value);
|
|
e.target.value = "";
|
|
}
|
|
}}
|
|
className="form-input"
|
|
>
|
|
<option value="">+ Add Include...</option>
|
|
{availableProfilesForInclude().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) =>
|
|
updateEnvVar(key, e.target.value, value)
|
|
}
|
|
placeholder="VAR_NAME"
|
|
className="form-input"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => updateEnvVar(key, key, e.target.value)}
|
|
placeholder="value"
|
|
className="form-input"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="ghost-button small"
|
|
onClick={() => removeEnvVar(key)}
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
),
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
onClick={addEnvVar}
|
|
>
|
|
<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);
|
|
updateFormField("runtime_hints", parsed);
|
|
} catch {
|
|
// Invalid JSON, 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) =>
|
|
updateFile(path, e.target.value, content)
|
|
}
|
|
placeholder="relative/path/to/file"
|
|
className="form-input"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="ghost-button small"
|
|
onClick={() => removeFile(path)}
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) => updateFile(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={addFile}
|
|
>
|
|
<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) =>
|
|
updateMount(index, { target: e.target.value })
|
|
}
|
|
placeholder="/target/path"
|
|
className="form-input"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<select
|
|
value={mount.mode}
|
|
onChange={(e) =>
|
|
updateMount(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={() => removeMount(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) =>
|
|
updateMountFile(
|
|
index,
|
|
path,
|
|
e.target.value,
|
|
content,
|
|
)
|
|
}
|
|
placeholder="relative/path"
|
|
className="form-input"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) =>
|
|
updateMountFile(
|
|
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={() => removeMountFile(index, path)}
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
),
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="secondary-button small"
|
|
onClick={() => addMountFile(index)}
|
|
style={{ fontSize: "0.875rem" }}
|
|
>
|
|
<Icon name="add" size="sm" />
|
|
Add File to Mount
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
onClick={addMount}
|
|
>
|
|
<Icon name="add" size="sm" />
|
|
Add Mount
|
|
</button>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<GitMountEditor
|
|
mounts={formData.git_mounts || []}
|
|
onChange={(git_mounts) =>
|
|
updateFormField("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={() => {
|
|
if (isCreating) {
|
|
resetForm();
|
|
} else if (selectedProfile) {
|
|
populateForm(selectedProfile);
|
|
}
|
|
}}
|
|
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={() => setPreviewData(null)}
|
|
>
|
|
Close Preview
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|