feat: unify workspace creation component with branch dropdown

- Rewrite WorkspaceCreateForm as unified component used in both pages
- Standalone mode (WorkspacesPage): shows project/repo/branch selectors
- Contextual mode (ProjectsPage): accepts defaultProjectId/defaultRepoId,
  skips project/repo selectors, shows only name + branch dropdown
- Branch dropdown fetched from repo via listRepositoryBranches API
- Auto-selects first/only option for project, repo, and branch
- '+ Create new branch...' option reveals text input for custom branch
- Falls back to free-text branch input if branch API fails
- Removes duplicated inline creation logic from WorkspacesPage
- TypeScript + eslint clean
This commit is contained in:
2026-06-01 18:18:00 +02:00
parent b8fc4e6642
commit e956d7c30d
3 changed files with 281 additions and 359 deletions
+265 -50
View File
@@ -1,37 +1,152 @@
/** Form for creating a new workspace. */
/** Unified workspace creation form with project/repo/branch selectors. */
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { Icon } from "./icon";
import type { CreateWorkspaceRequest } from "../types/workspace";
import { listProjects } from "../api/projects";
import { listRepositories, listRepositoryBranches } from "../api/git_repositories";
import { createWorkspaceTopLevel } from "../api/workspaces";
import type { ProjectWithRepos } from "../types";
import type { GitRepository } from "../api/git_repositories";
export interface WorkspaceCreateFormProps {
projectId: string;
repoId: string;
defaultBranch?: string;
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
/** Called after successful creation. */
onSubmit: () => void | Promise<void>;
/** Cancel callback. */
onCancel: () => void;
/** Optional: pre-selected project ID (hides project selector). */
defaultProjectId?: string;
/** Optional: pre-selected repo ID (hides repo selector). */
defaultRepoId?: string;
}
export function WorkspaceCreateForm({
defaultBranch = "main",
onSubmit,
onCancel,
defaultProjectId,
defaultRepoId,
}: WorkspaceCreateFormProps) {
const isContextual = Boolean(defaultProjectId && defaultRepoId);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repos, setRepos] = useState<GitRepository[]>([]);
const [branches, setBranches] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState(defaultProjectId ?? "");
const [selectedRepo, setSelectedRepo] = useState(defaultRepoId ?? "");
const [selectedBranch, setSelectedBranch] = useState("");
const [newBranchName, setNewBranchName] = useState("");
const [isNewBranch, setIsNewBranch] = useState(false);
const [name, setName] = useState("");
const [branch, setBranch] = useState(defaultBranch);
const [submitting, setSubmitting] = useState(false);
const [fetching, setFetching] = useState(!isContextual);
const [error, setError] = useState<string | null>(null);
/* ── Load projects (standalone mode only) ── */
const loadProjects = useCallback(async () => {
if (isContextual) return;
try {
const data = await listProjects();
setProjects(data);
if (data.length === 1 && !defaultProjectId) {
setSelectedProject(data[0].id);
}
} catch {
setError("Failed to load projects");
} finally {
setFetching(false);
}
}, [isContextual, defaultProjectId]);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
/* ── Load repos when project changes ── */
useEffect(() => {
if (!selectedProject) {
setRepos([]);
if (!defaultRepoId) setSelectedRepo("");
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepos(data);
if (data.length === 1 && !defaultRepoId) {
setSelectedRepo(data[0].id);
}
} catch {
setError("Failed to load repositories");
}
};
void loadRepos();
}, [selectedProject, defaultRepoId]);
/* ── Load branches when repo changes ── */
useEffect(() => {
if (!selectedProject || !selectedRepo) {
setBranches([]);
setSelectedBranch("");
setIsNewBranch(false);
return;
}
const loadBranches = async () => {
try {
const data = await listRepositoryBranches(selectedProject, selectedRepo);
const branchNames = data.branches.map((b) => b.name);
setBranches(branchNames);
if (branchNames.length >= 1) {
// Prefer default branch, else first branch
const preferred = data.default_branch && branchNames.includes(data.default_branch)
? data.default_branch
: branchNames[0];
setSelectedBranch(preferred);
setIsNewBranch(false);
}
} catch {
// Fallback to free-text branch input
setBranches([]);
setIsNewBranch(true);
setSelectedBranch("__new__");
}
};
void loadBranches();
}, [selectedProject, selectedRepo]);
const handleBranchChange = (value: string) => {
if (value === "__new__") {
setIsNewBranch(true);
setSelectedBranch("__new__");
setNewBranchName("");
} else {
setIsNewBranch(false);
setSelectedBranch(value);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRepo) {
setError("Please select a repository");
return;
}
if (!name.trim()) {
setError("Workspace name is required");
return;
}
const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
if (!branchName) {
setError("Please select or enter a branch");
return;
}
setSubmitting(true);
setError(null);
try {
await onSubmit({ name: name.trim(), branch: branch.trim() });
await createWorkspaceTopLevel({
repo_id: selectedRepo,
name: name.trim(),
branch: branchName,
});
await onSubmit();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create workspace",
@@ -41,49 +156,149 @@ export function WorkspaceCreateForm({
}
};
if (fetching) {
return (
<div className="card workspace-create-inline">
<p className="muted">Loading projects...</p>
</div>
);
}
return (
<form className="workspace-create-form card" onSubmit={handleSubmit}>
<div className="card workspace-create-inline">
<h3>
<Icon name="add" size="sm" /> Create Workspace
</h3>
<div className="form-group">
<label htmlFor="ws-name">Name</label>
<input
id="ws-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., feature-branch"
disabled={submitting}
/>
</div>
<div className="form-group">
<label htmlFor="ws-branch">
<Icon name="branch" size="sm" /> Branch
</label>
<input
id="ws-branch"
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
disabled={submitting}
/>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={submitting}
>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Creating..." : "Create"}
</button>
</div>
</form>
<form onSubmit={handleSubmit} className="workspace-create-form-grid">
{/* Project selector (standalone only) */}
{!isContextual && (
<div className="form-group">
<label>Project</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
required
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
{/* Repo selector (standalone only) */}
{!isContextual && (
<div className="form-group">
<label>Repository</label>
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
required
disabled={!selectedProject || repos.length === 0}
>
<option value="">
{!selectedProject
? "Select a project first"
: repos.length === 0
? "No repositories"
: "Select repository..."}
</option>
{repos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
)}
<div className="form-group">
<label>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., feature-branch"
required
disabled={submitting}
/>
</div>
<div className="form-group">
<label>
<Icon name="branch" size="sm" /> Branch
</label>
{branches.length > 0 ? (
<>
<select
value={selectedBranch}
onChange={(e) => handleBranchChange(e.target.value)}
required
disabled={!selectedRepo || submitting}
>
<option value="">Select branch...</option>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
<option value="__new__">+ Create new branch...</option>
</select>
{isNewBranch && (
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="new-branch-name"
required
style={{ marginTop: "0.5rem" }}
disabled={submitting}
/>
)}
</>
) : (
<input
type="text"
value={isNewBranch ? newBranchName : selectedBranch}
onChange={(e) => {
setIsNewBranch(true);
setNewBranchName(e.target.value);
setSelectedBranch("__new__");
}}
placeholder="main"
required
disabled={!selectedRepo || submitting}
/>
)}
</div>
{error && (
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
{error}
</div>
)}
<div className="form-actions" style={{ gridColumn: "1 / -1" }}>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={submitting || !selectedRepo}
>
{submitting ? "Creating..." : "Create Workspace"}
</button>
</div>
</form>
</div>
);
}
+10 -28
View File
@@ -11,7 +11,6 @@ import {
type ProjectUpdateInput,
} from "../api/projects";
import {
createWorkspace,
deleteWorkspace,
syncWorkspace,
} from "../api/workspaces";
@@ -112,22 +111,7 @@ export const ProjectsPage = () => {
}
};
const handleCreateWorkspace = async (
projectId: string,
repoId: string,
data: { name: string; branch: string },
) => {
setWorkspaceLoading(repoId);
try {
await createWorkspace(projectId, repoId, data);
setCreatingWorkspace(null);
reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to create workspace");
} finally {
setWorkspaceLoading(null);
}
};
const handleSyncWorkspace = async (
projectId: string,
@@ -218,9 +202,10 @@ export const ProjectsPage = () => {
: null
}
onCancelCreate={() => setCreatingWorkspace(null)}
onSubmitCreate={async (repoId, data) =>
await handleCreateWorkspace(project.id, repoId, data)
}
onCreated={() => {
setCreatingWorkspace(null);
reload();
}}
/>
))}
</div>
@@ -299,7 +284,7 @@ function ProjectCard({
workspaceLoading,
showCreateForm,
onCancelCreate,
onSubmitCreate,
onCreated,
}: {
project: ProjectWithRepos;
expanded: boolean;
@@ -318,10 +303,7 @@ function ProjectCard({
workspaceLoading: string | null;
onCancelCreate: () => void;
showCreateForm: string | null;
onSubmitCreate: (
repoId: string,
data: { name: string; branch: string },
) => Promise<void>;
onCreated: () => void;
}) {
return (
<article className="card project-card">
@@ -399,9 +381,9 @@ function ProjectCard({
</div>
{showCreateForm === repo.id && (
<WorkspaceCreateForm
projectId={project.id}
repoId={repo.id}
onSubmit={(data) => onSubmitCreate(repo.id, data)}
defaultProjectId={project.id}
defaultRepoId={repo.id}
onSubmit={onCreated}
onCancel={onCancelCreate}
/>
)}
+6 -281
View File
@@ -1,18 +1,14 @@
/** Workspaces list page with direct creation. */
/** Workspaces list page. */
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/workspace-card";
import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { StartToolModal } from "../components/start-tool-modal";
import { createInstance, startInstance } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, listRepositoryBranches } from "../api/git_repositories";
import { createWorkspaceTopLevel } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
import type { ProjectWithRepos } from "../types";
import type { GitRepository } from "../api/git_repositories";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
@@ -94,10 +90,10 @@ export function WorkspacesPage() {
{error && <div className="alert alert-error">{error}</div>}
{showCreate && (
<WorkspaceCreateInline
onCreated={() => {
<WorkspaceCreateForm
onSubmit={async () => {
setShowCreate(false);
refresh();
await refresh();
}}
onCancel={() => setShowCreate(false)}
/>
@@ -140,274 +136,3 @@ export function WorkspacesPage() {
</div>
);
}
/* ─── Inline Workspace Creation Form ─── */
function WorkspaceCreateInline({
onCreated,
onCancel,
}: {
onCreated: () => void;
onCancel: () => void;
}) {
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repos, setRepos] = useState<GitRepository[]>([]);
const [branches, setBranches] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedBranch, setSelectedBranch] = useState("");
const [newBranchName, setNewBranchName] = useState("");
const [isNewBranch, setIsNewBranch] = useState(false);
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProjects = useCallback(async () => {
try {
const data = await listProjects();
setProjects(data);
if (data.length === 1) {
setSelectedProject(data[0].id);
}
} catch {
setError("Failed to load projects");
} finally {
setFetching(false);
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
useEffect(() => {
if (!selectedProject) {
setRepos([]);
setSelectedRepo("");
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepos(data);
if (data.length === 1) {
setSelectedRepo(data[0].id);
}
} catch {
setError("Failed to load repositories");
}
};
void loadRepos();
}, [selectedProject]);
useEffect(() => {
if (!selectedProject || !selectedRepo) {
setBranches([]);
setSelectedBranch("");
setIsNewBranch(false);
return;
}
const loadBranches = async () => {
try {
const data = await listRepositoryBranches(selectedProject, selectedRepo);
const branchNames = data.branches.map((b) => b.name);
setBranches(branchNames);
if (branchNames.length === 1) {
setSelectedBranch(branchNames[0]);
setIsNewBranch(false);
} else if (data.default_branch) {
setSelectedBranch(data.default_branch);
setIsNewBranch(false);
}
} catch {
// If branch fetch fails, fall back to free-text
setBranches([]);
setIsNewBranch(true);
}
};
void loadBranches();
}, [selectedProject, selectedRepo]);
const handleBranchChange = (value: string) => {
if (value === "__new__") {
setIsNewBranch(true);
setSelectedBranch("__new__");
setNewBranchName("");
} else {
setIsNewBranch(false);
setSelectedBranch(value);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRepo) {
setError("Please select a repository");
return;
}
if (!name.trim()) {
setError("Workspace name is required");
return;
}
const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
if (!branchName) {
setError("Please select or enter a branch");
return;
}
setLoading(true);
setError(null);
try {
await createWorkspaceTopLevel({
repo_id: selectedRepo,
name: name.trim(),
branch: branchName,
});
onCreated();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create workspace",
);
} finally {
setLoading(false);
}
};
if (fetching) {
return (
<div className="card workspace-create-inline">
<p className="muted">Loading projects...</p>
</div>
);
}
return (
<div className="card workspace-create-inline">
<h3>
<Icon name="add" size="sm" /> Create Workspace
</h3>
<form onSubmit={handleSubmit} className="workspace-create-form-grid">
<div className="form-group">
<label>Project</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
required
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Repository</label>
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
required
disabled={!selectedProject || repos.length === 0}
>
<option value="">
{!selectedProject
? "Select a project first"
: repos.length === 0
? "No repositories"
: "Select repository..."}
</option>
{repos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., feature-branch"
required
/>
</div>
<div className="form-group">
<label>
<Icon name="branch" size="sm" /> Branch
</label>
{branches.length > 0 ? (
<>
<select
value={selectedBranch}
onChange={(e) => handleBranchChange(e.target.value)}
required
disabled={!selectedRepo}
>
<option value="">Select branch...</option>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
<option value="__new__">+ Create new branch...</option>
</select>
{isNewBranch && (
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="new-branch-name"
required
style={{ marginTop: "0.5rem" }}
/>
)}
</>
) : (
<input
type="text"
value={isNewBranch ? newBranchName : selectedBranch}
onChange={(e) => {
setIsNewBranch(true);
setNewBranchName(e.target.value);
setSelectedBranch("__new__");
}}
placeholder="main"
required
disabled={!selectedRepo}
/>
)}
</div>
{error && (
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
{error}
</div>
)}
<div className="form-actions" style={{ gridColumn: "1 / -1" }}>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading || !selectedRepo}
>
{loading ? "Creating..." : "Create Workspace"}
</button>
</div>
</form>
</div>
);
}