Files
headquarter/apps/web/src/components/workspace-create-form.tsx
T
alex ee3c5af7a4 fix: handle missing/corrupt repos when fetching branches + clearer manual fallback
Backend (git_repositories.py):
- get_repository_branches now checks for .git subdirectory (not just dir existence)
- If local repo is corrupt/missing but has remote_url, falls back to git ls-remote
  to list branches from the remote
- Returns 404 with actionable message instead of 400 with raw git stderr
- Pre-existing test failure in test_git_repository_clone_preflight.py unchanged

Frontend (workspace-create-form.tsx):
- When branch API fails, auto-switches to manual text input (no dropdown selection needed)
- Shows hint text: 'Couldn't load branches — type one manually'
- useGitRepo hook auto-fetches branches when projectId/repoId change

Quality gates: ruff clean, tsc --noEmit clean, 93 passed + 1 pre-existing failure
2026-06-01 19:00:59 +02:00

322 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Unified workspace creation form with project/repo/branch selectors. */
import { useState, useEffect, useCallback } from "react";
import { Icon } from "./icon";
import { listProjects } from "../api/projects";
import { listRepositories } from "../api/git_repositories";
import { createWorkspaceTopLevel } from "../api/workspaces";
import { useGitRepo } from "../hooks/use-git-repo";
import type { ProjectWithRepos } from "../types";
import type { GitRepository } from "../api/git_repositories";
export interface WorkspaceCreateFormProps {
/** 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({
onSubmit,
onCancel,
defaultProjectId,
defaultRepoId,
}: WorkspaceCreateFormProps) {
const isContextual = Boolean(defaultProjectId && defaultRepoId);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repos, setRepos] = useState<GitRepository[]>([]);
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 [submitting, setSubmitting] = useState(false);
const [fetchingProjects, setFetchingProjects] = useState(!isContextual);
const [error, setError] = useState<string | null>(null);
/* Git repo hook handles branch fetching, loading, errors */
const git = useGitRepo(
selectedProject || undefined,
selectedRepo || undefined,
);
/* Sync local branch state with hook data */
useEffect(() => {
if (git.branches.length > 0 && !selectedBranch) {
const preferred =
git.defaultBranch && git.branches.includes(git.defaultBranch)
? git.defaultBranch
: git.branches[0];
setSelectedBranch(preferred);
setIsNewBranch(false);
} else if (git.error && git.branches.length === 0 && !isNewBranch) {
// API failed — default to manual entry so user can type a branch
setIsNewBranch(true);
setSelectedBranch("__manual__");
}
}, [git.branches, git.defaultBranch, git.error, selectedBranch, isNewBranch]);
/* ── 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 {
setFetchingProjects(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]);
const handleBranchChange = (value: string) => {
if (value === "__new__") {
setIsNewBranch(true);
setSelectedBranch("__new__");
setNewBranchName("");
} else if (value === "__manual__") {
setIsNewBranch(true);
setSelectedBranch("__manual__");
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 createWorkspaceTopLevel({
repo_id: selectedRepo,
name: name.trim(),
branch: branchName,
});
await onSubmit();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create workspace",
);
} finally {
setSubmitting(false);
}
};
/* Show single combined error */
const displayError = error || git.error;
if (fetchingProjects) {
return (
<div className="card workspace-create-inline">
<p className="muted">Loading projects...</p>
</div>
);
}
const branchSelectDisabled =
!selectedRepo || submitting || (git.loading && git.branches.length === 0);
return (
<div className="card workspace-create-inline">
<h3>
<Icon name="add" size="sm" /> Create Workspace
</h3>
<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);
setSelectedBranch("");
}}
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);
setSelectedBranch("");
}}
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>
{/* Show a hint when branches couldnt be loaded */}
{git.error && git.branches.length === 0 && selectedRepo && (
<p
className="muted"
style={{
fontSize: "var(--font-size-xs)",
marginBottom: "0.25rem",
}}
>
Couldnt load branches type one manually.
</p>
)}
<select
value={selectedBranch}
onChange={(e) => handleBranchChange(e.target.value)}
required
disabled={branchSelectDisabled}
>
<option value="">
{git.loading && git.branches.length === 0
? "Loading branches..."
: !selectedRepo
? "Select a repository first"
: "Select branch..."}
</option>
{git.branches.map((b) => (
<option key={b} value={b}>
{b}
{b === git.defaultBranch ? " (default)" : ""}
</option>
))}
<option value="__new__">+ Create new branch...</option>
</select>
{/* Text input for new branch or manual entry */}
{isNewBranch && (
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="new-branch-name"
required
style={{ marginTop: "0.5rem" }}
disabled={submitting}
/>
)}
</div>
{displayError && (
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
{displayError}
</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>
);
}