9ad11a021c
- Add ConfigProfile and ConfigProfileInclude data models with migrations - Implement profile resolver service with ordered includes and merge rules - Add profile CRUD API with validation, compatibility, and cycle detection - Add instance API plumbing for profile selection on create/start/restart - Add resolved profile preview and default resolution APIs - Add frontend config profile API client and management UI - Add launch/restart profile selection UI - Add backend integration and unit tests (31 passing) OpenSpec: add-config-profiles Quality gates: ruff, TypeScript compile, 31 tests passing
522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
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";
|
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
|
|
|
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 [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
|
|
|
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 config profiles when tool type is selected
|
|
useEffect(() => {
|
|
const projectId = fixedProjectId || selectedProject;
|
|
if (!selectedToolType || !projectId) {
|
|
setConfigProfiles([]);
|
|
setSelectedConfigProfile("");
|
|
return;
|
|
}
|
|
const loadProfiles = async () => {
|
|
try {
|
|
const profiles = await listConfigProfiles(projectId, selectedToolType);
|
|
setConfigProfiles(profiles);
|
|
// Auto-select default if available
|
|
const defaultProfile = profiles.find((p) => p.is_default);
|
|
if (defaultProfile) {
|
|
setSelectedConfigProfile(defaultProfile.id);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
void loadProfiles();
|
|
}, [selectedToolType, selectedProject, fixedProjectId]);
|
|
|
|
// Load branches when selected repo changes
|
|
useEffect(() => {
|
|
const projectId = fixedProjectId || selectedProject;
|
|
if (!selectedRepo || !projectId || !showCloneMode) {
|
|
setBranches([]);
|
|
return;
|
|
}
|
|
const loadBranches = async () => {
|
|
setIsLoadingBranches(true);
|
|
try {
|
|
const response = await listRepositoryBranches(projectId, selectedRepo);
|
|
setBranches(response.branches);
|
|
if (response.default_branch) {
|
|
setBranch(response.default_branch);
|
|
setBaseBranch(response.default_branch);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setIsLoadingBranches(false);
|
|
}
|
|
};
|
|
void loadBranches();
|
|
}, [selectedRepo, selectedProject, fixedProjectId, 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,
|
|
selectedConfigProfile || 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";
|
|
|
|
// Determine which steps are active/unlocked
|
|
const hasProject = !!(fixedProjectId || selectedProject);
|
|
const hasRepo = !!(fixedRepoId || selectedRepo);
|
|
const hasToolType = !!selectedToolType;
|
|
|
|
const renderStep = (
|
|
label: string,
|
|
number: number,
|
|
isActive: boolean,
|
|
isComplete: boolean,
|
|
children: React.ReactNode
|
|
) => {
|
|
const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
|
|
return (
|
|
<div className={stepClass}>
|
|
<div className="workflow-step-header">
|
|
<span className="workflow-step-number">{number}</span>
|
|
<span className="workflow-step-label">{label}</span>
|
|
</div>
|
|
<div className="workflow-step-content">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 workflow-form">
|
|
{/* Step 1: Project */}
|
|
{renderStep("Select Project", 1, true, hasProject,
|
|
fixedProjectId && showFixedFields ? (
|
|
<label className="form-field">
|
|
<input
|
|
type="text"
|
|
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
</label>
|
|
) : (
|
|
<label className="form-field">
|
|
<select
|
|
value={selectedProject}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setSelectedProject(value);
|
|
setSelectedRepo("");
|
|
setSelectedToolType("");
|
|
setCloneMode("mount");
|
|
setIsCreatingNewBranch(false);
|
|
onProjectChange?.(value);
|
|
}}
|
|
disabled={isSubmitting}
|
|
>
|
|
<option value="">Select project...</option>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)
|
|
)}
|
|
|
|
{/* Step 2: Repository */}
|
|
{hasProject && renderStep("Select Repository", 2, true, hasRepo,
|
|
fixedRepoId && showFixedFields ? (
|
|
<label className="form-field">
|
|
<input
|
|
type="text"
|
|
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
</label>
|
|
) : (
|
|
<label className="form-field">
|
|
<select
|
|
value={selectedRepo}
|
|
onChange={(e) => {
|
|
setSelectedRepo(e.target.value);
|
|
setSelectedToolType("");
|
|
setCloneMode("mount");
|
|
setIsCreatingNewBranch(false);
|
|
}}
|
|
disabled={!hasProject || isSubmitting}
|
|
>
|
|
<option value="">Select repository...</option>
|
|
{availableRepos.map((r) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)
|
|
)}
|
|
|
|
{/* Step 3: Tool Type */}
|
|
{hasRepo && renderStep("Select Tool", 3, true, hasToolType,
|
|
<label className="form-field">
|
|
<select
|
|
value={selectedToolType}
|
|
onChange={(e) => {
|
|
setSelectedToolType(e.target.value);
|
|
setCloneMode("mount");
|
|
setIsCreatingNewBranch(false);
|
|
}}
|
|
disabled={!hasRepo || isSubmitting}
|
|
>
|
|
<option value="">Select tool...</option>
|
|
{toolTypes.map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
{t.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{/* Step 4: Config Profile */}
|
|
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
|
|
<label className="form-field">
|
|
<select
|
|
value={selectedConfigProfile}
|
|
onChange={(e) => setSelectedConfigProfile(e.target.value)}
|
|
disabled={!hasToolType || isSubmitting}
|
|
>
|
|
<option value="">No profile (use tool defaults)</option>
|
|
{configProfiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name} {p.is_default ? "(default)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{/* Step 5: Clone Mode & Branch */}
|
|
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
|
<div className="form-row">
|
|
<label className="form-field">
|
|
<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");
|
|
setIsCreatingNewBranch(false);
|
|
}}
|
|
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");
|
|
setIsCreatingNewBranch(false);
|
|
}}
|
|
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>
|
|
)}
|
|
|
|
{/* Step 6: Display Name */}
|
|
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
|
<label className="form-field">
|
|
<input
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
placeholder="My Development Environment"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{/* Error & Submit */}
|
|
{error && <p className="error-text">{error}</p>}
|
|
|
|
{hasToolType && (
|
|
<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>
|
|
);
|
|
};
|