226 lines
7.5 KiB
TypeScript
226 lines
7.5 KiB
TypeScript
import { useState } from "react";
|
|
import { Icon } from "./icon";
|
|
import type { GitMount } from "../api/config_profiles";
|
|
|
|
interface GitMountEditorProps {
|
|
mounts: GitMount[];
|
|
onChange: (mounts: GitMount[]) => void;
|
|
}
|
|
|
|
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
const [newMount, setNewMount] = useState<GitMount>({
|
|
remote_url: "",
|
|
source_path: ".",
|
|
target_path: "",
|
|
branch: "",
|
|
});
|
|
|
|
const handleAdd = (mount: GitMount) => {
|
|
onChange([...mounts, mount]);
|
|
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
|
};
|
|
|
|
const handleUpdate = (index: number, updated: GitMount) => {
|
|
const updatedMounts = [...mounts];
|
|
updatedMounts[index] = updated;
|
|
onChange(updatedMounts);
|
|
setEditingIndex(null);
|
|
};
|
|
|
|
const handleRemove = (index: number) => {
|
|
onChange(mounts.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const validatePath = (path: string, isTarget: boolean): string | null => {
|
|
if (!path) return isTarget ? "Target path is required" : null;
|
|
if (path.includes("..")) return "Path cannot contain ..";
|
|
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
|
return null;
|
|
};
|
|
|
|
const validateUrl = (url: string): string | null => {
|
|
if (!url) return "Git URL is required";
|
|
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
|
return "Must be a valid git URL (https://, git@, or ssh://)";
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div className="git-mount-editor">
|
|
<h4 className="section-subtitle">Git Mounts</h4>
|
|
|
|
{mounts.length > 0 && (
|
|
<div className="git-mount-list">
|
|
{mounts.map((mount, index) => (
|
|
<div key={index} className="git-mount-item">
|
|
{editingIndex === index ? (
|
|
<GitMountForm
|
|
mount={mount}
|
|
onSave={(updated) => handleUpdate(index, updated)}
|
|
onCancel={() => setEditingIndex(null)}
|
|
validatePath={validatePath}
|
|
validateUrl={validateUrl}
|
|
/>
|
|
) : (
|
|
<div className="git-mount-display">
|
|
<div className="git-mount-info">
|
|
<span className="git-mount-repo">{mount.remote_url}</span>
|
|
<span className="git-mount-paths">
|
|
{mount.source_path || "."} → {mount.target_path}
|
|
</span>
|
|
{mount.branch && (
|
|
<span className="git-mount-branch">@{mount.branch}</span>
|
|
)}
|
|
</div>
|
|
<div className="git-mount-actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={() => setEditingIndex(index)}
|
|
title="Edit"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button danger"
|
|
onClick={() => handleRemove(index)}
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="git-mount-add">
|
|
<h5>Add Git Mount</h5>
|
|
<GitMountForm
|
|
mount={newMount}
|
|
onSave={handleAdd}
|
|
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
|
validatePath={validatePath}
|
|
validateUrl={validateUrl}
|
|
isNew
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface GitMountFormProps {
|
|
mount: GitMount;
|
|
onSave: (mount: GitMount) => void;
|
|
onCancel: () => void;
|
|
validatePath: (path: string, isTarget: boolean) => string | null;
|
|
validateUrl: (url: string) => string | null;
|
|
isNew?: boolean;
|
|
}
|
|
|
|
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
|
const [form, setForm] = useState<GitMount>({ ...mount });
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
const handleChange = (field: keyof GitMount, value: string) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
if (errors[field]) {
|
|
setErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[field];
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
const urlError = validateUrl(form.remote_url);
|
|
if (urlError) newErrors.remote_url = urlError;
|
|
|
|
const sourceError = validatePath(form.source_path || ".", false);
|
|
if (sourceError) newErrors.source_path = sourceError;
|
|
|
|
const targetError = validatePath(form.target_path, true);
|
|
if (targetError) newErrors.target_path = targetError;
|
|
|
|
if (Object.keys(newErrors).length > 0) {
|
|
setErrors(newErrors);
|
|
return;
|
|
}
|
|
|
|
onSave(form);
|
|
if (isNew) {
|
|
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="git-mount-form">
|
|
<div className="form-row">
|
|
<label>Git URL</label>
|
|
<input
|
|
type="text"
|
|
value={form.remote_url}
|
|
onChange={(e) => handleChange("remote_url", e.target.value)}
|
|
placeholder="https://github.com/user/repo.git"
|
|
className={errors.remote_url ? "error" : ""}
|
|
/>
|
|
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
|
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label>Source Path</label>
|
|
<input
|
|
type="text"
|
|
value={form.source_path || "."}
|
|
onChange={(e) => handleChange("source_path", e.target.value)}
|
|
placeholder="e.g., . or configs/*.json"
|
|
className={errors.source_path ? "error" : ""}
|
|
/>
|
|
<span className="hint">Relative path in repo (supports glob patterns)</span>
|
|
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label>Target Path</label>
|
|
<input
|
|
type="text"
|
|
value={form.target_path}
|
|
onChange={(e) => handleChange("target_path", e.target.value)}
|
|
placeholder="e.g., /app/config"
|
|
className={errors.target_path ? "error" : ""}
|
|
/>
|
|
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
|
|
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label>Branch (optional)</label>
|
|
<input
|
|
type="text"
|
|
value={form.branch || ""}
|
|
onChange={(e) => handleChange("branch", e.target.value)}
|
|
placeholder="e.g., main or v1.0"
|
|
/>
|
|
<span className="hint">Branch or tag to checkout</span>
|
|
</div>
|
|
|
|
<div className="form-actions">
|
|
<button type="button" className="primary-button" onClick={handleSubmit}>
|
|
{isNew ? "Add" : "Save"}
|
|
</button>
|
|
<button type="button" className="secondary-button" onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |