e9364fa70f
- Revert mistaken ssh_key_id from ConfigProfile (model, API, resolver, frontend)
- Add ssh_key_ids JSON column to tool_instances via migration
- Update create_instance to accept and store ssh_key_ids
- Update start_instance to mount selected SSH keys to {home_dir}/.ssh
- Update list_instances to return ssh_key_ids
- Frontend CreateSessionForm: multi-select SSH key checkboxes
- Frontend instance-list: SSH key selector for start/restart actions
- Maintain separate SSH key dirs per key to avoid conflicts
Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean
575 lines
15 KiB
TypeScript
575 lines
15 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 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>
|
|
)}
|
|
|
|
{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;
|
|
}
|
|
|
|
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 style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
<div
|
|
className="form-row"
|
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
|
>
|
|
<div style={{ flex: 2 }}>
|
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
|
Repository URL
|
|
</label>
|
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
<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" : ""}`}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<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="error-text">{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 style={{ flex: 1 }}>
|
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
|
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
|
|
style={{
|
|
opacity: isUrlValidated ? 1 : 0.5,
|
|
pointerEvents: isUrlValidated ? "auto" : "none",
|
|
}}
|
|
>
|
|
<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.
|
|
{!isUrlValidated && (
|
|
<span style={{ color: "var(--warning)" }}>
|
|
{" "}
|
|
Validate the URL first.
|
|
</span>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|