ee3c5af7a4
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
322 lines
8.4 KiB
TypeScript
322 lines
8.4 KiB
TypeScript
/** 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 couldn’t be loaded */}
|
||
{git.error && git.branches.length === 0 && selectedRepo && (
|
||
<p
|
||
className="muted"
|
||
style={{
|
||
fontSize: "var(--font-size-xs)",
|
||
marginBottom: "0.25rem",
|
||
}}
|
||
>
|
||
Couldn’t 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>
|
||
);
|
||
}
|