5d5b39bec1
Pass 2 of the web UI spacing/typography/visual-rhythm rework. - Add layout, spacing, typography, visual, card, and component utilities - Add form-section, form-row, form-help, text-error, alert-success - Unify .form-group and .form-field; add .status-badge family - Alias legacy button classes to .btn primitives - Refactor ToolTypeListSidebar and ConfigProfileListSidebar to use .sidebar and var(--sidebar-width) instead of hardcoded 280px - Refactor ToolTypeEditorPanel, ConfigProfileEditorPanel, git-mount-editor, and manifest-editor to use utility classes Quality gates: npm run typecheck, npm run lint, npm run build pass. Inline style blocks in target components reduced from 198 to 11.
504 lines
13 KiB
TypeScript
504 lines
13 KiB
TypeScript
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<GitMount[]>(() =>
|
|
normalizeMounts(mounts),
|
|
);
|
|
const [editingIndex, setEditingIndex] = useState<number | null>(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 (
|
|
<div className="git-mount-editor">
|
|
<h4 className="m-0 mb-2">Git Mounts</h4>
|
|
<p className="muted text-sm mb-3 m-0">
|
|
Clone a repository once and mount multiple directories from it.
|
|
</p>
|
|
|
|
{normalizedMounts.length > 0 && (
|
|
<div className="git-mount-list mb-4">
|
|
{normalizedMounts.map((mount, index) => (
|
|
<div key={index} className="card-md">
|
|
{editingIndex === index ? (
|
|
<GitMountForm
|
|
mount={mount}
|
|
onSave={(updated) => handleUpdate(index, updated)}
|
|
onCancel={() => setEditingIndex(null)}
|
|
/>
|
|
) : (
|
|
<div>
|
|
<div className="row justify-between items-start mb-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-semibold text-base mb-1">
|
|
{mount.remote_url}
|
|
{mount.branch && (
|
|
<span className="muted font-normal ml-2">
|
|
@{mount.branch}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="stack stack-sm">
|
|
{mount.mappings?.map((m, mi) => (
|
|
<div
|
|
key={mi}
|
|
className="text-sm muted font-mono"
|
|
>
|
|
{m.source_path || "."} → {m.target_path}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="row row-sm flex-none">
|
|
<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-md">
|
|
<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;
|
|
}
|
|
|
|
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<GitMountMapping[]>(
|
|
mount.mappings?.length
|
|
? mount.mappings
|
|
: [{ source_path: ".", target_path: "" }],
|
|
);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [validation, setValidation] = useState<ValidationState>({
|
|
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<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://)";
|
|
}
|
|
|
|
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 (
|
|
<div className="stack stack-md">
|
|
<div className="form-row row-sm items-start">
|
|
<div className="form-group flex-2">
|
|
<label className="text-sm font-medium">Repository URL</label>
|
|
<div className="row row-sm">
|
|
<input
|
|
type="text"
|
|
value={remoteUrl}
|
|
onChange={(e) => {
|
|
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" : ""}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="secondary-button small"
|
|
onClick={handleCheckUrl}
|
|
disabled={validation.status === "loading"}
|
|
>
|
|
{validation.status === "loading" ? (
|
|
<Icon name="loading" size="sm" />
|
|
) : (
|
|
"Check"
|
|
)}
|
|
</button>
|
|
</div>
|
|
{errors.remote_url && (
|
|
<span className="text-error">{errors.remote_url}</span>
|
|
)}
|
|
{validation.status === "valid" && (
|
|
<span className="validation-status valid">
|
|
Repository is accessible (
|
|
{
|
|
(validation as Extract<ValidationState, { status: "valid" }>)
|
|
.branches.length
|
|
}{" "}
|
|
branches)
|
|
</span>
|
|
)}
|
|
{validation.status === "suggestion" && (
|
|
<div className="url-suggestion">
|
|
<span>{validation.message}</span>
|
|
<div className="suggestion-actions">
|
|
<code className="suggested-url">{validation.suggestedUrl}</code>
|
|
<button
|
|
type="button"
|
|
className="secondary-button small"
|
|
onClick={applySuggestion}
|
|
>
|
|
Use this
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{validation.status === "invalid" && (
|
|
<span className="validation-status invalid">
|
|
{validation.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="form-group flex-1">
|
|
<label className="text-sm font-medium">Branch</label>
|
|
{validation.status === "valid" ? (
|
|
<select
|
|
value={branch}
|
|
onChange={(e) => setBranch(e.target.value)}
|
|
className="form-input"
|
|
>
|
|
{(
|
|
validation as Extract<ValidationState, { status: "valid" }>
|
|
).branches.map((b) => (
|
|
<option key={b} value={b}>
|
|
{b}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={branch}
|
|
onChange={(e) => setBranch(e.target.value)}
|
|
placeholder="main"
|
|
className="form-input"
|
|
disabled={!isUrlValidated}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="stack stack-sm"
|
|
style={{
|
|
opacity: isUrlValidated ? 1 : 0.5,
|
|
pointerEvents: isUrlValidated ? "auto" : "none",
|
|
}}
|
|
>
|
|
<label className="text-sm font-medium">Mappings</label>
|
|
<p className="muted text-sm m-0">
|
|
Source paths within the repo and where to mount them in the container.
|
|
{!isUrlValidated && (
|
|
<span style={{ color: "var(--warning)" }}>
|
|
{" "}
|
|
Validate the URL first.
|
|
</span>
|
|
)}
|
|
</p>
|
|
<div className="stack stack-sm">
|
|
{mappings.map((mapping, index) => (
|
|
<div
|
|
key={index}
|
|
className="form-row row-sm items-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" : ""}`}
|
|
/>
|
|
<span className="text-sm muted py-2">→</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" : ""}`}
|
|
/>
|
|
{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="text-error">
|
|
{errors[`mapping_${index}_source`]}
|
|
</span>
|
|
)}
|
|
{errors[`mapping_${index}_target`] && (
|
|
<span className="text-error">
|
|
{errors[`mapping_${index}_target`]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="secondary-button small"
|
|
onClick={addMapping}
|
|
>
|
|
<Icon name="add" size="sm" />
|
|
Add Mapping
|
|
</button>
|
|
</div>
|
|
|
|
<div className="form-actions row-sm mt-2">
|
|
<button type="button" className="primary-button" onClick={handleSubmit}>
|
|
Save
|
|
</button>
|
|
<button type="button" className="secondary-button" onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|