Merge branch 'feat/session-branch-selection' into dev
Resolved conflicts: - Moved branch selection UI from inline sessions.tsx to CreateSessionForm component - Integrated branch dropdown and new branch creation into CreateSessionForm - Removed duplicate branch state management from sessions.tsx All branch selection tests pass (7/7).
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||
import type { Project } from "../types";
|
||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
repositories: GitRepository[];
|
||||
toolTypes: ToolType[];
|
||||
fixedProjectId?: string;
|
||||
fixedRepoId?: string;
|
||||
projectName?: string;
|
||||
repoName?: string;
|
||||
showCloneMode?: boolean;
|
||||
showFixedFields?: boolean;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
onSuccess?: (instance: ToolInstance) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CreateSessionForm = ({
|
||||
projects,
|
||||
repositories,
|
||||
toolTypes,
|
||||
fixedProjectId,
|
||||
fixedRepoId,
|
||||
projectName,
|
||||
repoName,
|
||||
showCloneMode = true,
|
||||
showFixedFields = true,
|
||||
onProjectChange,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
submitLabel = "Create Session",
|
||||
className = "",
|
||||
}: CreateSessionFormProps) => {
|
||||
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
||||
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
|
||||
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||
const [progress, setProgress] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load SSH keys when clone mode is shown
|
||||
useEffect(() => {
|
||||
if (!showCloneMode) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await listSSHKeys();
|
||||
setSshKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [showCloneMode]);
|
||||
|
||||
// Load branches when selected repo changes
|
||||
useEffect(() => {
|
||||
if (!selectedRepo || !showCloneMode) {
|
||||
setBranches([]);
|
||||
return;
|
||||
}
|
||||
const loadBranches = async () => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const branchList = await listRepositoryBranches(selectedRepo);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = branchList.find((b) => b.is_default);
|
||||
if (defaultBranch) {
|
||||
setBranch(defaultBranch.name);
|
||||
setBaseBranch(defaultBranch.name);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
void loadBranches();
|
||||
}, [selectedRepo, showCloneMode]);
|
||||
|
||||
// Filter repositories by selected project
|
||||
const availableRepos = selectedProject
|
||||
? repositories.filter((r) => r.project_id === selectedProject)
|
||||
: [];
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
const repoId = fixedRepoId || selectedRepo;
|
||||
|
||||
if (!projectId || !repoId || !selectedToolType) {
|
||||
setError("Project, repository, and tool type are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (showCloneMode && cloneMode === "clone") {
|
||||
const repo = repositories.find((r) => r.id === repoId);
|
||||
if (!repo?.ssh_key_id) {
|
||||
setError("Repository must have an SSH key assigned for clone mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus("creating");
|
||||
setProgress("Creating instance...");
|
||||
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
showCloneMode ? cloneMode : undefined,
|
||||
showCloneMode && cloneMode === "clone"
|
||||
? isCreatingNewBranch
|
||||
? baseBranch
|
||||
: branch
|
||||
: undefined,
|
||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||
? newBranchName
|
||||
: undefined
|
||||
);
|
||||
|
||||
setProgress("Starting container...");
|
||||
await startInstance(projectId, repoId, instance.id);
|
||||
|
||||
// Reset form
|
||||
if (!fixedProjectId) setSelectedProject("");
|
||||
if (!fixedRepoId) setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
setCloneMode("mount");
|
||||
setBranch("main");
|
||||
setIsCreatingNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setBaseBranch("");
|
||||
setBranches([]);
|
||||
setStatus("idle");
|
||||
|
||||
onSuccess?.(instance);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setError("Failed to create session");
|
||||
setProgress("");
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitting = status === "creating";
|
||||
|
||||
return (
|
||||
<div className={`create-session-form-wrapper ${className}`}>
|
||||
{isSubmitting && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<Icon name="loading" size="lg" />
|
||||
<p>{progress || "Creating session..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="stack create-session-form">
|
||||
<div className="form-row">
|
||||
{fixedProjectId && showFixedFields ? (
|
||||
<label className="form-field">
|
||||
Project
|
||||
<input
|
||||
type="text"
|
||||
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedProject(value);
|
||||
setSelectedRepo("");
|
||||
onProjectChange?.(value);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{fixedRepoId && showFixedFields ? (
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
disabled={!selectedProject || isSubmitting}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{availableRepos.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showCloneMode && (
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Repository Access
|
||||
<div className="radio-group">
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="mount"
|
||||
checked={cloneMode === "mount"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Mount (live sync)
|
||||
</label>
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="clone"
|
||||
checked={cloneMode === "clone"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Clone fresh copy
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{cloneMode === "clone" && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Branch
|
||||
{isLoadingBranches ? (
|
||||
<span className="muted">Loading branches...</span>
|
||||
) : (
|
||||
<select
|
||||
value={isCreatingNewBranch ? "__new__" : branch}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__new__") {
|
||||
setIsCreatingNewBranch(true);
|
||||
setNewBranchName("");
|
||||
} else {
|
||||
setIsCreatingNewBranch(false);
|
||||
setBranch(value);
|
||||
setBaseBranch(value);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">Create new branch...</option>
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{isCreatingNewBranch && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
New Branch Name
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
placeholder="feature/my-new-branch"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Base Branch
|
||||
<select
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRepo && (
|
||||
<div className="form-field ssh-key-info">
|
||||
{(() => {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo) return null;
|
||||
if (repo.ssh_key_id) {
|
||||
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||
return (
|
||||
<span className="success-text">
|
||||
SSH key: {key?.name || "Assigned"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="warning-text">
|
||||
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
{onCancel && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
{submitLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user