From e956d7c30d4c268e3f51c851583d4bd341590e0d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 18:18:00 +0200 Subject: [PATCH] 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 --- .../src/components/workspace-create-form.tsx | 315 +++++++++++++++--- apps/web/src/pages/projects.tsx | 38 +-- apps/web/src/pages/workspaces.tsx | 287 +--------------- 3 files changed, 281 insertions(+), 359 deletions(-) diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx index c907f3f..f25b962 100644 --- a/apps/web/src/components/workspace-create-form.tsx +++ b/apps/web/src/components/workspace-create-form.tsx @@ -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; + /** Called after successful creation. */ + onSubmit: () => void | Promise; + /** 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([]); + const [repos, setRepos] = useState([]); + const [branches, setBranches] = useState([]); + 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(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 ( +
+

Loading projects...

+
+ ); + } + return ( -
+

Create Workspace

-
- - setName(e.target.value)} - placeholder="e.g., feature-branch" - disabled={submitting} - /> -
-
- - setBranch(e.target.value)} - placeholder="main" - disabled={submitting} - /> -
- {error &&

{error}

} -
- - -
- +
+ {/* Project selector (standalone only) */} + {!isContextual && ( +
+ + +
+ )} + + {/* Repo selector (standalone only) */} + {!isContextual && ( +
+ + +
+ )} + +
+ + setName(e.target.value)} + placeholder="e.g., feature-branch" + required + disabled={submitting} + /> +
+ +
+ + {branches.length > 0 ? ( + <> + + {isNewBranch && ( + setNewBranchName(e.target.value)} + placeholder="new-branch-name" + required + style={{ marginTop: "0.5rem" }} + disabled={submitting} + /> + )} + + ) : ( + { + setIsNewBranch(true); + setNewBranchName(e.target.value); + setSelectedBranch("__new__"); + }} + placeholder="main" + required + disabled={!selectedRepo || submitting} + /> + )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
); } diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 622eb35..2eb3762 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -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(); + }} /> ))} @@ -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; + onCreated: () => void; }) { return (
@@ -399,9 +381,9 @@ function ProjectCard({ {showCreateForm === repo.id && ( onSubmitCreate(repo.id, data)} + defaultProjectId={project.id} + defaultRepoId={repo.id} + onSubmit={onCreated} onCancel={onCancelCreate} /> )} diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index 84945e0..9802f17 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -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 &&
{error}
} {showCreate && ( - { + { setShowCreate(false); - refresh(); + await refresh(); }} onCancel={() => setShowCreate(false)} /> @@ -140,274 +136,3 @@ export function WorkspacesPage() { ); } - -/* ─── Inline Workspace Creation Form ─── */ - -function WorkspaceCreateInline({ - onCreated, - onCancel, -}: { - onCreated: () => void; - onCancel: () => void; -}) { - const [projects, setProjects] = useState([]); - const [repos, setRepos] = useState([]); - const [branches, setBranches] = useState([]); - 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(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 ( -
-

Loading projects...

-
- ); - } - - return ( -
-

- Create Workspace -

-
-
- - -
- -
- - -
- -
- - setName(e.target.value)} - placeholder="e.g., feature-branch" - required - /> -
- -
- - {branches.length > 0 ? ( - <> - - {isNewBranch && ( - setNewBranchName(e.target.value)} - placeholder="new-branch-name" - required - style={{ marginTop: "0.5rem" }} - /> - )} - - ) : ( - { - setIsNewBranch(true); - setNewBranchName(e.target.value); - setSelectedBranch("__new__"); - }} - placeholder="main" - required - disabled={!selectedRepo} - /> - )} -
- - {error && ( -
- {error} -
- )} - -
- - -
-
-
- ); -}