2247ec47c9
Use canonical profile files and writable Git working copies so editor and container changes share one source. Require confirmation before destructive Git refreshes and overlay profile files without composite snapshots.
841 lines
22 KiB
TypeScript
841 lines
22 KiB
TypeScript
import { useState } from "react";
|
|
|
|
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 { GitMountEditor } from "../git/git-mount-editor";
|
|
import { Icon } from "../../icon";
|
|
import type {
|
|
ConfigProfile,
|
|
CreateConfigProfileRequest,
|
|
ResolvedProfile,
|
|
} from "../../../api/config-profiles";
|
|
import type { ProjectWithRepos } from "../../../types";
|
|
import type { ToolType } from "../../../api/tool-types";
|
|
|
|
type MobileView = "list" | "detail" | "edit" | "preview";
|
|
|
|
interface Props {
|
|
profiles: ConfigProfile[];
|
|
projects: ProjectWithRepos[];
|
|
toolTypes: ToolType[];
|
|
selectedProfile: ConfigProfile | null;
|
|
mobileView: MobileView;
|
|
isCreating: boolean;
|
|
formData: CreateConfigProfileRequest;
|
|
includedProfileIds: string[];
|
|
availableProfiles: ConfigProfile[];
|
|
previewData: ResolvedProfile | null;
|
|
previewingId: string | null;
|
|
isRefreshingGitMounts: boolean;
|
|
isReloadingWorkingCopy: boolean;
|
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
|
error: string | null;
|
|
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: (e?: React.FormEvent) => Promise<boolean>;
|
|
getScopeLabel: (profile: ConfigProfile) => string;
|
|
getIncludedProfile: (id: string) => ConfigProfile | undefined;
|
|
onAddInclude: (id: string) => void;
|
|
onRemoveInclude: (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;
|
|
onPreview: (id: string) => void;
|
|
onReloadWorkingCopy: () => void;
|
|
onRefreshGitMounts: (id: string) => void;
|
|
onClosePreview: () => void;
|
|
}
|
|
|
|
export const ConfigProfilesMobileView = ({
|
|
profiles,
|
|
projects,
|
|
toolTypes,
|
|
selectedProfile,
|
|
mobileView,
|
|
isCreating,
|
|
formData,
|
|
includedProfileIds,
|
|
availableProfiles,
|
|
previewData,
|
|
previewingId,
|
|
isRefreshingGitMounts,
|
|
isReloadingWorkingCopy,
|
|
saveStatus,
|
|
error,
|
|
onViewChange,
|
|
onSelect,
|
|
onCreate,
|
|
onDelete,
|
|
onFormChange,
|
|
onSubmit,
|
|
getScopeLabel,
|
|
getIncludedProfile,
|
|
onAddInclude,
|
|
onRemoveInclude,
|
|
onAddEnvVar,
|
|
onUpdateEnvVar,
|
|
onRemoveEnvVar,
|
|
onAddFile,
|
|
onUpdateFile,
|
|
onRemoveFile,
|
|
onAddMount,
|
|
onUpdateMount,
|
|
onRemoveMount,
|
|
onAddMountFile,
|
|
onUpdateMountFile,
|
|
onRemoveMountFile,
|
|
onPreview,
|
|
onReloadWorkingCopy,
|
|
onRefreshGitMounts,
|
|
onClosePreview,
|
|
}: Props) => {
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
const ok = await onSubmit();
|
|
if (ok) {
|
|
onViewChange(isCreating ? "list" : "edit");
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteClick = () => {
|
|
if (selectedProfile) {
|
|
onDelete(selectedProfile.id);
|
|
onViewChange("list");
|
|
}
|
|
};
|
|
|
|
const projectName = (id?: string) =>
|
|
projects.find((p) => p.id === id)?.name || id || "None";
|
|
|
|
const toolTypeName = (id?: string) =>
|
|
toolTypes.find((t) => t.id === id)?.display_name || id || "None";
|
|
|
|
if (mobileView === "list") {
|
|
return (
|
|
<div className="mobile-page">
|
|
<div className="mobile-page-header">
|
|
<h1>Config Profiles</h1>
|
|
<span className="muted">{profiles.length} profiles</span>
|
|
</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("edit");
|
|
}
|
|
}}
|
|
onItemDelete={(id: string) => onDelete(id)}
|
|
emptyMessage="No config profiles yet"
|
|
/>
|
|
<MobileFAB
|
|
onClick={() => {
|
|
onCreate();
|
|
onViewChange("edit");
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mobileView === "preview" && selectedProfile && previewData) {
|
|
return (
|
|
<div className="mobile-edit-view">
|
|
<header className="mobile-edit-header">
|
|
<button
|
|
className="mobile-edit-cancel"
|
|
onClick={() => onViewChange("detail")}
|
|
type="button"
|
|
>
|
|
Back
|
|
</button>
|
|
<h1 className="mobile-edit-title">Resolved Preview</h1>
|
|
<div style={{ width: "4rem" }} />
|
|
</header>
|
|
<div className="mobile-edit-form">
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Profile</label>
|
|
<p>{previewData.profile_name}</p>
|
|
</div>
|
|
|
|
{previewData.included_profiles.length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Included Profiles</label>
|
|
<ul className="mobile-list-plain">
|
|
{previewData.included_profiles.map((p) => (
|
|
<li key={p.id}>{p.name}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{Object.keys(previewData.env_vars).length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Environment Variables</label>
|
|
<pre className="mobile-preview-code">
|
|
{JSON.stringify(previewData.env_vars, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{Object.keys(previewData.runtime_hints).length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Runtime Hints</label>
|
|
<pre className="mobile-preview-code">
|
|
{JSON.stringify(previewData.runtime_hints, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{previewData.mounts.length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Mounts</label>
|
|
{previewData.mounts.map((m, idx) => (
|
|
<div key={idx} className="mobile-preview-item">
|
|
<strong>
|
|
{m.target} ({m.mode})
|
|
</strong>
|
|
{Object.keys(m.files).length > 0 && (
|
|
<pre className="mobile-preview-code">
|
|
{JSON.stringify(m.files, null, 2)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{previewData.git_mounts.length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Git Mounts</label>
|
|
{previewData.git_mounts.map((m, idx) => (
|
|
<div key={idx} className="mobile-preview-item">
|
|
<strong>
|
|
{m.remote_url}
|
|
{m.branch ? ` @${m.branch}` : ""}
|
|
</strong>
|
|
{m.mappings.map((mapping, mi) => (
|
|
<div
|
|
key={mi}
|
|
className="mobile-preview-mapping"
|
|
>
|
|
{mapping.source_path || "."} →{" "}
|
|
{mapping.target_path}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{Object.keys(previewData.files).length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Files</label>
|
|
<pre className="mobile-preview-code">
|
|
{JSON.stringify(previewData.files, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{Object.keys(previewData.overrides.env_vars).length > 0 && (
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label">Overrides</label>
|
|
<pre className="mobile-preview-code">
|
|
{JSON.stringify(previewData.overrides, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mobileView === "detail" && selectedProfile) {
|
|
const fields = [
|
|
{ label: "Name", value: selectedProfile.name },
|
|
{
|
|
label: "Description",
|
|
value: selectedProfile.description || null,
|
|
},
|
|
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
|
{ label: "Project", value: projectName(selectedProfile.project_id || undefined) },
|
|
{ label: "Tool Type", value: toolTypeName(selectedProfile.tool_type_id || undefined) },
|
|
{
|
|
label: "Default",
|
|
value: selectedProfile.is_default,
|
|
type: "boolean" as const,
|
|
},
|
|
{
|
|
label: "Includes",
|
|
value:
|
|
selectedProfile.includes.length > 0
|
|
? selectedProfile.includes
|
|
.map(
|
|
(inc) =>
|
|
getIncludedProfile(inc.included_profile_id)?.name ||
|
|
inc.included_profile_id,
|
|
)
|
|
.join(", ")
|
|
: null,
|
|
},
|
|
{
|
|
label: "Environment Variables",
|
|
value:
|
|
Object.keys(selectedProfile.env_vars).length > 0
|
|
? Object.entries(selectedProfile.env_vars)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(", ")
|
|
: null,
|
|
},
|
|
{
|
|
label: "Runtime Hints",
|
|
value:
|
|
Object.keys(selectedProfile.runtime_hints).length > 0
|
|
? JSON.stringify(selectedProfile.runtime_hints, null, 2)
|
|
: null,
|
|
type: "code" as const,
|
|
},
|
|
{
|
|
label: "Files",
|
|
value:
|
|
Object.keys(selectedProfile.files).length > 0
|
|
? Object.keys(selectedProfile.files).join(", ")
|
|
: null,
|
|
},
|
|
{
|
|
label: "Mounts",
|
|
value:
|
|
selectedProfile.mounts.length > 0
|
|
? selectedProfile.mounts
|
|
.map((m) => `${m.target} (${m.mode})`)
|
|
.join(", ")
|
|
: null,
|
|
},
|
|
{
|
|
label: "Git Mounts",
|
|
value:
|
|
selectedProfile.git_mounts.length > 0
|
|
? selectedProfile.git_mounts
|
|
.map((m) => m.remote_url)
|
|
.join(", ")
|
|
: null,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<MobileDetailView
|
|
title={selectedProfile.name}
|
|
subtitle={getScopeLabel(selectedProfile)}
|
|
fields={fields}
|
|
onBack={() => onViewChange("list")}
|
|
onEdit={() => {
|
|
onSelect(selectedProfile);
|
|
onViewChange("edit");
|
|
}}
|
|
onDelete={handleDeleteClick}
|
|
>
|
|
<div className="mobile-detail-actions-extra">
|
|
<button type="button" className="secondary-button" disabled={isReloadingWorkingCopy} onClick={onReloadWorkingCopy}>
|
|
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
disabled={isRefreshingGitMounts}
|
|
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
|
>
|
|
{isRefreshingGitMounts ? (
|
|
<>
|
|
<Icon name="loading" size="sm" />
|
|
Refreshing Git mounts...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
disabled={previewingId === selectedProfile.id}
|
|
onClick={() => {
|
|
onClosePreview();
|
|
void onPreview(selectedProfile.id);
|
|
onViewChange("preview");
|
|
}}
|
|
>
|
|
{previewingId === selectedProfile.id ? (
|
|
<>
|
|
<Icon name="loading" size="sm" />
|
|
Previewing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon name="info" size="sm" />
|
|
Preview
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</MobileDetailView>
|
|
);
|
|
}
|
|
|
|
if (mobileView === "edit") {
|
|
return (
|
|
<MobileEditView
|
|
title={isCreating ? "Create Profile" : "Edit Profile"}
|
|
onCancel={() => {
|
|
onViewChange("list");
|
|
}}
|
|
onSave={handleSave}
|
|
onDelete={isCreating ? undefined : handleDeleteClick}
|
|
deleteLabel="Delete Profile"
|
|
isSaving={isSaving || saveStatus === "saving"}
|
|
>
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label" htmlFor="cp-name">
|
|
Name *
|
|
</label>
|
|
<input
|
|
id="cp-name"
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => onFormChange("name", e.target.value)}
|
|
placeholder="e.g., Development Environment"
|
|
className="mobile-form-input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label" htmlFor="cp-description">
|
|
Description
|
|
</label>
|
|
<input
|
|
id="cp-description"
|
|
type="text"
|
|
value={formData.description || ""}
|
|
onChange={(e) =>
|
|
onFormChange("description", e.target.value || undefined)
|
|
}
|
|
placeholder="Optional description"
|
|
className="mobile-form-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label" htmlFor="cp-project">
|
|
Project
|
|
</label>
|
|
<select
|
|
id="cp-project"
|
|
value={formData.project_id || ""}
|
|
onChange={(e) =>
|
|
onFormChange("project_id", e.target.value || undefined)
|
|
}
|
|
className="mobile-form-select"
|
|
>
|
|
<option value="">None (Global)</option>
|
|
{projects.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="mobile-form-group">
|
|
<label className="mobile-form-label" htmlFor="cp-tool">
|
|
Tool Type
|
|
</label>
|
|
<select
|
|
id="cp-tool"
|
|
value={formData.tool_type_id || ""}
|
|
onChange={(e) =>
|
|
onFormChange("tool_type_id", e.target.value || undefined)
|
|
}
|
|
className="mobile-form-select"
|
|
>
|
|
<option value="">None</option>
|
|
{toolTypes.map((toolType) => (
|
|
<option key={toolType.id} value={toolType.id}>
|
|
{toolType.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="mobile-form-group mobile-form-row">
|
|
<label className="mobile-form-label mobile-form-checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_default || false}
|
|
onChange={(e) =>
|
|
onFormChange("is_default", e.target.checked)
|
|
}
|
|
className="mobile-form-checkbox"
|
|
/>
|
|
Set as default for this scope
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mobile-form-section">
|
|
<div className="mobile-form-section-header">
|
|
<h4>Includes</h4>
|
|
<span className="muted">{includedProfileIds.length} included</span>
|
|
</div>
|
|
{includedProfileIds.length === 0 ? (
|
|
<p className="muted mobile-form-help">
|
|
No profiles included. Add profiles to compose configurations.
|
|
</p>
|
|
) : (
|
|
<div className="mobile-includes-list">
|
|
{includedProfileIds.map((profileId, index) => {
|
|
const profile = getIncludedProfile(profileId);
|
|
if (!profile) return null;
|
|
return (
|
|
<div key={`${profileId}-${index}`} className="mobile-include-item">
|
|
<span className="mobile-include-name">{profile.name}</span>
|
|
<span className="mobile-include-scope">
|
|
{getScopeLabel(profile)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="ghost-button small"
|
|
onClick={() => onRemoveInclude(index)}
|
|
title="Remove include"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{availableProfiles.length > 0 && (
|
|
<select
|
|
value=""
|
|
onChange={(e) => {
|
|
if (e.target.value) {
|
|
onAddInclude(e.target.value);
|
|
e.target.value = "";
|
|
}
|
|
}}
|
|
className="mobile-form-select"
|
|
>
|
|
<option value="">+ Add Include...</option>
|
|
{availableProfiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name} ({getScopeLabel(p)})
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mobile-form-section">
|
|
<div className="mobile-form-section-header">
|
|
<h4>Environment Variables</h4>
|
|
</div>
|
|
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
|
<div key={idx} className="mobile-key-value-row">
|
|
<input
|
|
type="text"
|
|
value={key}
|
|
onChange={(e) =>
|
|
onUpdateEnvVar(key, e.target.value, value)
|
|
}
|
|
placeholder="VAR_NAME"
|
|
className="mobile-form-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) =>
|
|
onUpdateEnvVar(key, key, e.target.value)
|
|
}
|
|
placeholder="value"
|
|
className="mobile-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 mobile-add-button"
|
|
onClick={onAddEnvVar}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Variable
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mobile-form-section">
|
|
<div className="mobile-form-section-header">
|
|
<h4>Runtime Hints</h4>
|
|
</div>
|
|
<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 invalid JSON while typing */
|
|
}
|
|
}}
|
|
placeholder='{"start_command": "npm start"}'
|
|
rows={4}
|
|
className="mobile-form-textarea mobile-form-code"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mobile-form-section">
|
|
<div className="mobile-form-section-header">
|
|
<h4>Files</h4>
|
|
</div>
|
|
<p className="muted mobile-form-help">
|
|
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="mobile-file-card">
|
|
<div className="mobile-file-header">
|
|
<input
|
|
type="text"
|
|
value={path}
|
|
onChange={(e) =>
|
|
onUpdateFile(path, e.target.value, content)
|
|
}
|
|
placeholder="relative/path/to/file"
|
|
className="mobile-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="mobile-form-textarea mobile-form-code"
|
|
/>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="secondary-button mobile-add-button"
|
|
onClick={onAddFile}
|
|
>
|
|
<Icon name="add" size="sm" /> Add File
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mobile-form-section">
|
|
<div className="mobile-form-section-header">
|
|
<h4>Mounts</h4>
|
|
</div>
|
|
<p className="muted mobile-form-help">
|
|
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="mobile-mount-card">
|
|
<div className="mobile-mount-header">
|
|
<input
|
|
type="text"
|
|
value={mount.target}
|
|
onChange={(e) =>
|
|
onUpdateMount(index, { target: e.target.value })
|
|
}
|
|
placeholder="/target/path"
|
|
className="mobile-form-input"
|
|
/>
|
|
<select
|
|
value={mount.mode}
|
|
onChange={(e) =>
|
|
onUpdateMount(index, {
|
|
mode: e.target.value as "ro" | "rw",
|
|
})
|
|
}
|
|
className="mobile-form-select"
|
|
>
|
|
<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="mobile-mount-files">
|
|
{Object.entries(mount.files).map(([path, content], idx) => (
|
|
<div key={idx} className="mobile-mount-file-row">
|
|
<input
|
|
type="text"
|
|
value={path}
|
|
onChange={(e) =>
|
|
onUpdateMountFile(
|
|
index,
|
|
path,
|
|
e.target.value,
|
|
content,
|
|
)
|
|
}
|
|
placeholder="relative/path"
|
|
className="mobile-form-input"
|
|
/>
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) =>
|
|
onUpdateMountFile(
|
|
index,
|
|
path,
|
|
path,
|
|
e.target.value,
|
|
)
|
|
}
|
|
placeholder="File content"
|
|
rows={2}
|
|
className="mobile-form-textarea mobile-form-code"
|
|
/>
|
|
<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 mobile-add-button"
|
|
onClick={() => onAddMountFile(index)}
|
|
>
|
|
<Icon name="add" size="sm" /> Add File to Mount
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="secondary-button mobile-add-button"
|
|
onClick={onAddMount}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Mount
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mobile-form-section">
|
|
<GitMountEditor
|
|
mounts={formData.git_mounts || []}
|
|
onChange={(git_mounts) =>
|
|
onFormChange("git_mounts", git_mounts)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{(error || saveStatus === "error") && (
|
|
<p className="mobile-form-error">
|
|
<Icon name="warning" size="sm" /> {error || "Failed to save"}
|
|
</p>
|
|
)}
|
|
|
|
{saveStatus === "saved" && (
|
|
<div className="mobile-form-success">
|
|
<Icon name="success" size="sm" />
|
|
Profile saved successfully
|
|
</div>
|
|
)}
|
|
</MobileEditView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mobile-page">
|
|
<div className="mobile-page-header">
|
|
<h1>Config Profiles</h1>
|
|
<span className="muted">{profiles.length} profiles</span>
|
|
</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("edit");
|
|
}
|
|
}}
|
|
onItemDelete={(id: string) => onDelete(id)}
|
|
emptyMessage="No config profiles yet"
|
|
/>
|
|
<MobileFAB
|
|
onClick={() => {
|
|
onCreate();
|
|
onViewChange("edit");
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|