refactor: organize frontend components into features/ directories
Moved 43 component files into 9 feature domains: - features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor, git-toolbar, merge-dialog - features/project/ — repositories-settings-tab, repository-create-dialog - features/terminal/ — special-keys-panel, special-keys-strip, terminal-session-tabs, terminal - features/workspace/ — workspace-card, workspace-create-form, workspace-header, workspace-instance-chips - features/session/ — create-session-form, session-card, session-list - features/tool/ — instance-list, manifest-editor, start-tool-fab, start-tool-modal, tool-starter, tools-bottom-sheet - features/notification/ — event-toast-bridge, notification-center, notification-item - features/settings/ — settings-tab-layout - features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view, mobile-fab, mobile-list-view, mobile-nav, mobile-page-header, mobile-terminal-header, mobile-terminal-wrapper Updated all imports across pages and components. Root components/ now only contains generic UI pieces: app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter. Quality gates: verified no remaining old imports.
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user