feat: expand ~ and $HOME in mount target paths
- Add expand_container_path() helper that resolves ~/ and $HOME/ prefixes
- Add get_manifest_home_dir() to compute /home/{user.name} or /root from manifest
- Set ENV HOME=... and ENV USER=... in generated Dockerfile for runtime compatibility
- Pass home_dir through instance creation and startup pipeline
- Expand mount targets in apply_resolved_profile() for regular profile mounts
- Expand mapping targets in _resolve_git_mount_mappings() for git mounts
- Expand working_directory and volume targets in _modify_compose_file()
- Update _prepare_manifest_instance to return home_dir alongside image tag
- Fetch tool_type early in start_instance to determine home_dir before profile application
Quality gates: pytest 188 passed, frontend typecheck clean
Addresses: home-path-expansion
This commit is contained in:
+108
-102
@@ -1,163 +1,169 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ConfigProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
project_id: string | null;
|
||||
tool_type_id: string | null;
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ConfigProfileMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
is_default: boolean;
|
||||
includes: ConfigProfileInclude[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
project_id: string | null;
|
||||
tool_type_id: string | null;
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ConfigProfileMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
is_default: boolean;
|
||||
includes: ConfigProfileInclude[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigProfileMount {
|
||||
target: string;
|
||||
mode: "ro" | "rw";
|
||||
files: Record<string, string>;
|
||||
target: string;
|
||||
mode: "ro" | "rw";
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GitMountMapping {
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
}
|
||||
|
||||
export interface GitMount {
|
||||
remote_url: string;
|
||||
branch?: string;
|
||||
mappings: GitMountMapping[];
|
||||
// Legacy fields (for backward compatibility when reading old data)
|
||||
source_path?: string;
|
||||
target_path?: string;
|
||||
remote_url: string;
|
||||
branch?: string;
|
||||
mappings: GitMountMapping[];
|
||||
// Legacy fields (for backward compatibility when reading old data)
|
||||
source_path?: string;
|
||||
target_path?: string;
|
||||
}
|
||||
|
||||
export interface ConfigProfileInclude {
|
||||
id: string;
|
||||
included_profile_id: string;
|
||||
order_index: number;
|
||||
id: string;
|
||||
included_profile_id: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface ResolvedProfile {
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ResolvedMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
overrides: {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, string>;
|
||||
files: Record<string, string>;
|
||||
mounts: Record<string, string>;
|
||||
};
|
||||
included_profiles: Array<{ id: string; name: string }>;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ResolvedMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
overrides: {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, string>;
|
||||
files: Record<string, string>;
|
||||
mounts: Record<string, string>;
|
||||
};
|
||||
included_profiles: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export interface ResolvedMount {
|
||||
target: string;
|
||||
mode: "ro" | "rw";
|
||||
files: Record<string, string>;
|
||||
overridden_files: Record<string, string>;
|
||||
target: string;
|
||||
mode: "ro" | "rw";
|
||||
files: Record<string, string>;
|
||||
overridden_files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CreateConfigProfileRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
project_id?: string;
|
||||
tool_type_id?: string;
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
name: string;
|
||||
description?: string;
|
||||
project_id?: string;
|
||||
tool_type_id?: string;
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateConfigProfileRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
project_id?: string;
|
||||
tool_type_id?: string;
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
project_id?: string;
|
||||
tool_type_id?: string;
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateIncludesRequest {
|
||||
includes: string[];
|
||||
includes: string[];
|
||||
}
|
||||
|
||||
export const listConfigProfiles = async (
|
||||
projectId?: string,
|
||||
toolTypeId?: string
|
||||
projectId?: string,
|
||||
toolTypeId?: string,
|
||||
): Promise<ConfigProfile[]> => {
|
||||
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||
});
|
||||
return response.data;
|
||||
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createConfigProfile = async (
|
||||
data: CreateConfigProfileRequest
|
||||
data: CreateConfigProfileRequest,
|
||||
): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
|
||||
return response.data;
|
||||
const response = await apiClient.post<ConfigProfile>(
|
||||
"/config-profiles",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateConfigProfile = async (
|
||||
id: string,
|
||||
data: UpdateConfigProfileRequest
|
||||
id: string,
|
||||
data: UpdateConfigProfileRequest,
|
||||
): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
|
||||
return response.data;
|
||||
const response = await apiClient.put<ConfigProfile>(
|
||||
`/config-profiles/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteConfigProfile = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/config-profiles/${id}`);
|
||||
await apiClient.delete(`/config-profiles/${id}`);
|
||||
};
|
||||
|
||||
export const updateProfileIncludes = async (
|
||||
id: string,
|
||||
data: UpdateIncludesRequest
|
||||
id: string,
|
||||
data: UpdateIncludesRequest,
|
||||
): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.put<ConfigProfile>(
|
||||
`/config-profiles/${id}/includes`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.put<ConfigProfile>(
|
||||
`/config-profiles/${id}/includes`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const previewConfigProfile = async (
|
||||
id: string
|
||||
id: string,
|
||||
): Promise<ResolvedProfile> => {
|
||||
const response = await apiClient.get<ResolvedProfile>(
|
||||
`/config-profiles/${id}/preview`
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get<ResolvedProfile>(
|
||||
`/config-profiles/${id}/preview`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const resolveDefaultProfile = async (
|
||||
projectId: string,
|
||||
toolTypeId: string
|
||||
projectId: string,
|
||||
toolTypeId: string,
|
||||
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
||||
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||
});
|
||||
return response.data;
|
||||
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -3,309 +3,421 @@ import { Icon } from "./icon";
|
||||
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
mounts: GitMount[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
}
|
||||
|
||||
function normalizeMount(mount: GitMount): GitMount {
|
||||
// Auto-convert legacy source_path + target_path to mappings
|
||||
if ((!mount.mappings || mount.mappings.length === 0) && mount.source_path !== undefined && mount.target_path !== undefined) {
|
||||
return {
|
||||
remote_url: mount.remote_url,
|
||||
branch: mount.branch,
|
||||
mappings: [{ source_path: mount.source_path || ".", target_path: mount.target_path }],
|
||||
};
|
||||
}
|
||||
return mount;
|
||||
// Auto-convert legacy source_path + target_path to mappings
|
||||
if (
|
||||
(!mount.mappings || mount.mappings.length === 0) &&
|
||||
mount.source_path !== undefined &&
|
||||
mount.target_path !== undefined
|
||||
) {
|
||||
return {
|
||||
remote_url: mount.remote_url,
|
||||
branch: mount.branch,
|
||||
mappings: [
|
||||
{
|
||||
source_path: mount.source_path || ".",
|
||||
target_path: mount.target_path,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return mount;
|
||||
}
|
||||
|
||||
function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
||||
return mounts.map(normalizeMount);
|
||||
return mounts.map(normalizeMount);
|
||||
}
|
||||
|
||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => normalizeMounts(mounts));
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||
normalizeMounts(mounts),
|
||||
);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNormalizedMounts(normalizeMounts(mounts));
|
||||
}, [mounts]);
|
||||
useEffect(() => {
|
||||
setNormalizedMounts(normalizeMounts(mounts));
|
||||
}, [mounts]);
|
||||
|
||||
const handleAdd = (mount: GitMount) => {
|
||||
const updated = [...normalizedMounts, normalizeMount(mount)];
|
||||
setNormalizedMounts(updated);
|
||||
onChange(updated);
|
||||
setIsAdding(false);
|
||||
};
|
||||
const handleAdd = (mount: GitMount) => {
|
||||
const updated = [...normalizedMounts, normalizeMount(mount)];
|
||||
setNormalizedMounts(updated);
|
||||
onChange(updated);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updated: GitMount) => {
|
||||
const updatedMounts = [...normalizedMounts];
|
||||
updatedMounts[index] = normalizeMount(updated);
|
||||
setNormalizedMounts(updatedMounts);
|
||||
onChange(updatedMounts);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
const handleUpdate = (index: number, updated: GitMount) => {
|
||||
const updatedMounts = [...normalizedMounts];
|
||||
updatedMounts[index] = normalizeMount(updated);
|
||||
setNormalizedMounts(updatedMounts);
|
||||
onChange(updatedMounts);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const updated = normalizedMounts.filter((_, i) => i !== index);
|
||||
setNormalizedMounts(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
const handleRemove = (index: number) => {
|
||||
const updated = normalizedMounts.filter((_, i) => i !== index);
|
||||
setNormalizedMounts(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-editor">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
|
||||
Clone a repository once and mount multiple directories from it.
|
||||
</p>
|
||||
return (
|
||||
<div className="git-mount-editor">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
|
||||
>
|
||||
Clone a repository once and mount multiple directories from it.
|
||||
</p>
|
||||
|
||||
{normalizedMounts.length > 0 && (
|
||||
<div className="git-mount-list" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1rem" }}>
|
||||
{normalizedMounts.map((mount, index) => (
|
||||
<div key={index} className="card" style={{ padding: "1rem" }}>
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.5rem" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9375rem", marginBottom: "0.25rem" }}>
|
||||
{mount.remote_url}
|
||||
{mount.branch && (
|
||||
<span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: "0.5rem" }}>
|
||||
@{mount.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
||||
{mount.mappings?.map((m, mi) => (
|
||||
<div key={mi} style={{ fontSize: "0.875rem", color: "var(--muted)", fontFamily: "monospace" }}>
|
||||
{m.source_path || "."} → {m.target_path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => setEditingIndex(index)}
|
||||
title="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{normalizedMounts.length > 0 && (
|
||||
<div
|
||||
className="git-mount-list"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
{normalizedMounts.map((mount, index) => (
|
||||
<div key={index} className="card" style={{ padding: "1rem" }}>
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{mount.remote_url}
|
||||
{mount.branch && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
fontWeight: 400,
|
||||
marginLeft: "0.5rem",
|
||||
}}
|
||||
>
|
||||
@{mount.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{mount.mappings?.map((m, mi) => (
|
||||
<div
|
||||
key={mi}
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{m.source_path || "."} → {m.target_path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => setEditingIndex(index)}
|
||||
title="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding ? (
|
||||
<div className="card" style={{ padding: "1rem" }}>
|
||||
<GitMountForm
|
||||
mount={{ remote_url: "", branch: "", mappings: [{ source_path: ".", target_path: "" }] }}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="secondary-button" onClick={() => setIsAdding(true)}>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Git Mount
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{isAdding ? (
|
||||
<div className="card" style={{ padding: "1rem" }}>
|
||||
<GitMountForm
|
||||
mount={{
|
||||
remote_url: "",
|
||||
branch: "",
|
||||
mappings: [{ source_path: ".", target_path: "" }],
|
||||
}}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Git Mount
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GitMountFormProps {
|
||||
mount: GitMount;
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
mount: GitMount;
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||
const [branch, setBranch] = useState(mount.branch || "");
|
||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||
mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }]
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||
const [branch, setBranch] = useState(mount.branch || "");
|
||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||
mount.mappings?.length
|
||||
? mount.mappings
|
||||
: [{ source_path: ".", target_path: "" }],
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!remoteUrl.trim()) {
|
||||
newErrors.remote_url = "Git URL is required";
|
||||
} else if (
|
||||
!remoteUrl.startsWith("http://") &&
|
||||
!remoteUrl.startsWith("https://") &&
|
||||
!remoteUrl.startsWith("git@") &&
|
||||
!remoteUrl.startsWith("ssh://")
|
||||
) {
|
||||
newErrors.remote_url = "Must be a valid git URL (https://, git@, or ssh://)";
|
||||
}
|
||||
if (!remoteUrl.trim()) {
|
||||
newErrors.remote_url = "Git URL is required";
|
||||
} else if (
|
||||
!remoteUrl.startsWith("http://") &&
|
||||
!remoteUrl.startsWith("https://") &&
|
||||
!remoteUrl.startsWith("git@") &&
|
||||
!remoteUrl.startsWith("ssh://")
|
||||
) {
|
||||
newErrors.remote_url =
|
||||
"Must be a valid git URL (https://, git@, or ssh://)";
|
||||
}
|
||||
|
||||
mappings.forEach((m, i) => {
|
||||
if (!m.target_path.trim()) {
|
||||
newErrors[`mapping_${i}_target`] = "Target path is required";
|
||||
}
|
||||
if (m.source_path.includes("..")) {
|
||||
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
|
||||
}
|
||||
if (m.target_path.includes("..")) {
|
||||
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
|
||||
}
|
||||
});
|
||||
mappings.forEach((m, i) => {
|
||||
if (!m.target_path.trim()) {
|
||||
newErrors[`mapping_${i}_target`] = "Target path is required";
|
||||
}
|
||||
if (m.source_path.includes("..")) {
|
||||
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
|
||||
}
|
||||
if (m.target_path.includes("..")) {
|
||||
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
onSave({
|
||||
remote_url: remoteUrl.trim(),
|
||||
branch: branch.trim() || undefined,
|
||||
mappings: mappings.map((m) => ({
|
||||
source_path: m.source_path.trim() || ".",
|
||||
target_path: m.target_path.trim(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
onSave({
|
||||
remote_url: remoteUrl.trim(),
|
||||
branch: branch.trim() || undefined,
|
||||
mappings: mappings.map((m) => ({
|
||||
source_path: m.source_path.trim() || ".",
|
||||
target_path: m.target_path.trim(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
|
||||
};
|
||||
const addMapping = () => {
|
||||
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
|
||||
};
|
||||
|
||||
const updateMapping = (index: number, field: keyof GitMountMapping, value: string) => {
|
||||
setMappings((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
return next;
|
||||
});
|
||||
if (errors[`mapping_${index}_${field}`]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`mapping_${index}_${field}`];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
const updateMapping = (
|
||||
index: number,
|
||||
field: keyof GitMountMapping,
|
||||
value: string,
|
||||
) => {
|
||||
setMappings((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
return next;
|
||||
});
|
||||
if (errors[`mapping_${index}_${field}`]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`mapping_${index}_${field}`];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeMapping = (index: number) => {
|
||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
const removeMapping = (index: number) => {
|
||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem" }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Repository URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
onChange={(e) => {
|
||||
setRemoteUrl(e.target.value);
|
||||
if (errors.remote_url) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||
/>
|
||||
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Branch (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem" }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
onChange={(e) => {
|
||||
setRemoteUrl(e.target.value);
|
||||
if (errors.remote_url) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||
/>
|
||||
{errors.remote_url && (
|
||||
<span className="error-text">{errors.remote_url}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Branch (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Mappings</label>
|
||||
<p className="muted" style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}>
|
||||
Source paths within the repo and where to mount them in the container.
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.source_path}
|
||||
onChange={(e) => updateMapping(index, "source_path", e.target.value)}
|
||||
placeholder="packages/api"
|
||||
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ padding: "0.5rem 0", color: "var(--muted)", fontSize: "0.875rem" }}>→</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.target_path}
|
||||
onChange={(e) => updateMapping(index, "target_path", e.target.value)}
|
||||
placeholder="/app/api"
|
||||
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{mappings.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => removeMapping(index)}
|
||||
title="Remove mapping"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{errors[`mapping_${index}_source`] && (
|
||||
<span className="error-text">{errors[`mapping_${index}_source`]}</span>
|
||||
)}
|
||||
{errors[`mapping_${index}_target`] && (
|
||||
<span className="error-text">{errors[`mapping_${index}_target`]}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="secondary-button small" onClick={addMapping} style={{ marginTop: "0.5rem" }}>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Mappings
|
||||
</label>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||
>
|
||||
Source paths within the repo and where to mount them in the container.
|
||||
</p>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||
>
|
||||
{mappings.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="form-row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.source_path}
|
||||
onChange={(e) =>
|
||||
updateMapping(index, "source_path", e.target.value)
|
||||
}
|
||||
placeholder="packages/api"
|
||||
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
padding: "0.5rem 0",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.target_path}
|
||||
onChange={(e) =>
|
||||
updateMapping(index, "target_path", e.target.value)
|
||||
}
|
||||
placeholder="/app/api"
|
||||
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{mappings.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => removeMapping(index)}
|
||||
title="Remove mapping"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{errors[`mapping_${index}_source`] && (
|
||||
<span className="error-text">
|
||||
{errors[`mapping_${index}_source`]}
|
||||
</span>
|
||||
)}
|
||||
{errors[`mapping_${index}_target`] && (
|
||||
<span className="error-text">
|
||||
{errors[`mapping_${index}_target`]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={addMapping}
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions" style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div
|
||||
className="form-actions"
|
||||
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
|
||||
>
|
||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user