import { useState, useEffect } from "react"; import { Icon } from "./icon"; import { validateGitUrl } from "../api/config_profiles"; import type { GitMount, GitMountMapping } from "../api/config_profiles"; interface GitMountEditorProps { 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; } function normalizeMounts(mounts: GitMount[]): GitMount[] { return mounts.map(normalizeMount); } export const GitMountEditor = ({ mounts, onChange, }: GitMountEditorProps) => { const [normalizedMounts, setNormalizedMounts] = useState(() => normalizeMounts(mounts), ); const [editingIndex, setEditingIndex] = useState(null); const [isAdding, setIsAdding] = useState(false); useEffect(() => { setNormalizedMounts(normalizeMounts(mounts)); }, [mounts]); 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 handleRemove = (index: number) => { const updated = normalizedMounts.filter((_, i) => i !== index); setNormalizedMounts(updated); onChange(updated); }; return (

Git Mounts

Clone a repository once and mount multiple directories from it.

{normalizedMounts.length > 0 && (
{normalizedMounts.map((mount, index) => (
{editingIndex === index ? ( handleUpdate(index, updated)} onCancel={() => setEditingIndex(null)} /> ) : (
{mount.remote_url} {mount.branch && ( @{mount.branch} )}
{mount.mappings?.map((m, mi) => (
{m.source_path || "."} → {m.target_path}
))}
)}
))}
)} {isAdding ? (
setIsAdding(false)} />
) : ( )}
); }; interface GitMountFormProps { mount: GitMount; onSave: (mount: GitMount) => void; onCancel: () => void; } type ValidationState = | { status: "idle" } | { status: "loading" } | { status: "valid"; branches: string[]; defaultBranch: string } | { status: "suggestion"; suggestedUrl: string; message: string } | { status: "invalid"; message: string }; const GitMountForm = ({ mount, onSave, onCancel, }: GitMountFormProps) => { const [remoteUrl, setRemoteUrl] = useState(mount.remote_url); const [branch, setBranch] = useState(mount.branch || ""); const [mappings, setMappings] = useState( mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }], ); const [errors, setErrors] = useState>({}); const [validation, setValidation] = useState({ status: "idle", }); const isUrlValidated = validation.status === "valid" || (validation.status === "idle" && mount.remote_url.length > 0); const handleCheckUrl = async () => { if (!remoteUrl.trim()) { setErrors((prev) => ({ ...prev, remote_url: "Git URL is required" })); return; } setValidation({ status: "loading" }); setErrors((prev) => { const next = { ...prev }; delete next.remote_url; return next; }); try { const result = await validateGitUrl(remoteUrl.trim()); if (result.valid && result.branches) { setValidation({ status: "valid", branches: result.branches, defaultBranch: result.default_branch || "main", }); if (!branch) { setBranch(result.default_branch || "main"); } if (result.suggested_url && result.suggested_url !== remoteUrl.trim()) { setRemoteUrl(result.suggested_url); } } else if (result.suggested_url) { setValidation({ status: "suggestion", suggestedUrl: result.suggested_url, message: result.error || "URL needs correction", }); } else { setValidation({ status: "invalid", message: result.error || "Invalid repository URL", }); } } catch { setValidation({ status: "invalid", message: "Failed to validate URL. Please try again.", }); } }; const applySuggestion = () => { if (validation.status === "suggestion") { setRemoteUrl(validation.suggestedUrl); setValidation({ status: "idle" }); } }; const validate = (): boolean => { const newErrors: Record = {}; 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 .."; } }); 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 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 removeMapping = (index: number) => { setMappings((prev) => prev.filter((_, i) => i !== index)); }; return (
{ setRemoteUrl(e.target.value); setValidation({ status: "idle" }); 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" : ""}`} style={{ flex: 1 }} />
{errors.remote_url && ( {errors.remote_url} )} {validation.status === "valid" && ( Repository is accessible ( { (validation as Extract) .branches.length }{" "} branches) )} {validation.status === "suggestion" && (
{validation.message}
{validation.suggestedUrl}
)} {validation.status === "invalid" && ( {validation.message} )}
{validation.status === "valid" ? ( ) : ( setBranch(e.target.value)} placeholder="main" className="form-input" disabled={!isUrlValidated} /> )}

Source paths within the repo and where to mount them in the container. {!isUrlValidated && ( {" "} Validate the URL first. )}

{mappings.map((mapping, index) => (
updateMapping(index, "source_path", e.target.value) } placeholder="packages/api" className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`} style={{ flex: 1 }} /> updateMapping(index, "target_path", e.target.value) } placeholder="/app/api" className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`} style={{ flex: 1 }} /> {mappings.length > 1 && ( )} {errors[`mapping_${index}_source`] && ( {errors[`mapping_${index}_source`]} )} {errors[`mapping_${index}_target`] && ( {errors[`mapping_${index}_target`]} )}
))}
); };