feat: unified git repo hook + fix branch dropdown in workspace creation

- New useGitRepo hook: centralizes all git operations (branches, status,
  history, commit, push, pull, fetch, checkout, create/delete branch, merge)
  for a given project+repo. Auto-refreshes after mutating ops.
- Fix WorkspaceCreateForm branch dropdown:
  - Always renders <select> (never input fallback)
  - Uses useGitRepo for branch fetching with loading/error states
  - Shows 'Loading branches...' while fetching
  - Shows 'Enter branch name manually...' if API fails
  - '+ Create new branch...' option with text input reveal
  - Auto-selects default branch on load
- ProjectsPage uses updated form props (defaultProjectId/defaultRepoId)

Quality gates: tsc --noEmit clean, eslint clean
This commit is contained in:
2026-06-01 18:45:53 +02:00
parent e956d7c30d
commit b02cd978c3
3 changed files with 350 additions and 84 deletions
@@ -3,8 +3,9 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import { listRepositories, listRepositoryBranches } from "../api/git_repositories"; import { listRepositories } from "../api/git_repositories";
import { createWorkspaceTopLevel } from "../api/workspaces"; import { createWorkspaceTopLevel } from "../api/workspaces";
import { useGitRepo } from "../hooks/use-git-repo";
import type { ProjectWithRepos } from "../types"; import type { ProjectWithRepos } from "../types";
import type { GitRepository } from "../api/git_repositories"; import type { GitRepository } from "../api/git_repositories";
@@ -29,17 +30,36 @@ export function WorkspaceCreateForm({
const [projects, setProjects] = useState<ProjectWithRepos[]>([]); const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repos, setRepos] = useState<GitRepository[]>([]); const [repos, setRepos] = useState<GitRepository[]>([]);
const [branches, setBranches] = useState<string[]>([]); const [selectedProject, setSelectedProject] = useState(
const [selectedProject, setSelectedProject] = useState(defaultProjectId ?? ""); defaultProjectId ?? "",
);
const [selectedRepo, setSelectedRepo] = useState(defaultRepoId ?? ""); const [selectedRepo, setSelectedRepo] = useState(defaultRepoId ?? "");
const [selectedBranch, setSelectedBranch] = useState(""); const [selectedBranch, setSelectedBranch] = useState("");
const [newBranchName, setNewBranchName] = useState(""); const [newBranchName, setNewBranchName] = useState("");
const [isNewBranch, setIsNewBranch] = useState(false); const [isNewBranch, setIsNewBranch] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [fetching, setFetching] = useState(!isContextual); const [fetchingProjects, setFetchingProjects] = useState(!isContextual);
const [error, setError] = useState<string | null>(null); 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);
}
}, [git.branches, git.defaultBranch, selectedBranch]);
/* ── Load projects (standalone mode only) ── */ /* ── Load projects (standalone mode only) ── */
const loadProjects = useCallback(async () => { const loadProjects = useCallback(async () => {
if (isContextual) return; if (isContextual) return;
@@ -52,7 +72,7 @@ export function WorkspaceCreateForm({
} catch { } catch {
setError("Failed to load projects"); setError("Failed to load projects");
} finally { } finally {
setFetching(false); setFetchingProjects(false);
} }
}, [isContextual, defaultProjectId]); }, [isContextual, defaultProjectId]);
@@ -81,42 +101,15 @@ export function WorkspaceCreateForm({
void loadRepos(); void loadRepos();
}, [selectedProject, defaultRepoId]); }, [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) => { const handleBranchChange = (value: string) => {
if (value === "__new__") { if (value === "__new__") {
setIsNewBranch(true); setIsNewBranch(true);
setSelectedBranch("__new__"); setSelectedBranch("__new__");
setNewBranchName(""); setNewBranchName("");
} else if (value === "__manual__") {
setIsNewBranch(true);
setSelectedBranch("__manual__");
setNewBranchName("");
} else { } else {
setIsNewBranch(false); setIsNewBranch(false);
setSelectedBranch(value); setSelectedBranch(value);
@@ -156,7 +149,10 @@ export function WorkspaceCreateForm({
} }
}; };
if (fetching) { /* Show single combined error */
const displayError = error || git.error;
if (fetchingProjects) {
return ( return (
<div className="card workspace-create-inline"> <div className="card workspace-create-inline">
<p className="muted">Loading projects...</p> <p className="muted">Loading projects...</p>
@@ -164,6 +160,9 @@ export function WorkspaceCreateForm({
); );
} }
const branchSelectDisabled =
!selectedRepo || submitting || (git.loading && git.branches.length === 0);
return ( return (
<div className="card workspace-create-inline"> <div className="card workspace-create-inline">
<h3> <h3>
@@ -176,7 +175,10 @@ export function WorkspaceCreateForm({
<label>Project</label> <label>Project</label>
<select <select
value={selectedProject} value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)} onChange={(e) => {
setSelectedProject(e.target.value);
setSelectedBranch("");
}}
required required
> >
<option value="">Select project...</option> <option value="">Select project...</option>
@@ -195,7 +197,10 @@ export function WorkspaceCreateForm({
<label>Repository</label> <label>Repository</label>
<select <select
value={selectedRepo} value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)} onChange={(e) => {
setSelectedRepo(e.target.value);
setSelectedBranch("");
}}
required required
disabled={!selectedProject || repos.length === 0} disabled={!selectedProject || repos.length === 0}
> >
@@ -231,53 +236,52 @@ export function WorkspaceCreateForm({
<label> <label>
<Icon name="branch" size="sm" /> Branch <Icon name="branch" size="sm" /> Branch
</label> </label>
{branches.length > 0 ? ( <select
<> value={selectedBranch}
<select onChange={(e) => handleBranchChange(e.target.value)}
value={selectedBranch} required
onChange={(e) => handleBranchChange(e.target.value)} disabled={branchSelectDisabled}
required >
disabled={!selectedRepo || submitting} <option value="">
> {git.loading && git.branches.length === 0
<option value="">Select branch...</option> ? "Loading branches..."
{branches.map((b) => ( : !selectedRepo
<option key={b} value={b}> ? "Select a repository first"
{b} : "Select branch..."}
</option> </option>
))}
<option value="__new__">+ Create new branch...</option> {git.branches.map((b) => (
</select> <option key={b} value={b}>
{isNewBranch && ( {b}
<input {b === git.defaultBranch ? " (default)" : ""}
type="text" </option>
value={newBranchName} ))}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="new-branch-name" <option value="__new__">+ Create new branch...</option>
required
style={{ marginTop: "0.5rem" }} {/* If branch fetch failed, offer manual entry */}
disabled={submitting} {git.error && git.branches.length === 0 && (
/> <option value="__manual__"> Enter branch name manually...</option>
)} )}
</> </select>
) : (
{/* New branch text input */}
{isNewBranch && (
<input <input
type="text" type="text"
value={isNewBranch ? newBranchName : selectedBranch} value={newBranchName}
onChange={(e) => { onChange={(e) => setNewBranchName(e.target.value)}
setIsNewBranch(true); placeholder="new-branch-name"
setNewBranchName(e.target.value);
setSelectedBranch("__new__");
}}
placeholder="main"
required required
disabled={!selectedRepo || submitting} style={{ marginTop: "0.5rem" }}
disabled={submitting}
/> />
)} )}
</div> </div>
{error && ( {displayError && (
<div className="form-error" style={{ gridColumn: "1 / -1" }}> <div className="form-error" style={{ gridColumn: "1 / -1" }}>
{error} {displayError}
</div> </div>
)} )}
+267
View File
@@ -0,0 +1,267 @@
/** Unified hook for git repository operations.
*
* Centralizes branch fetching, status, history, and git actions
* so components don't duplicate this logic.
*/
import { useState, useEffect, useCallback } from "react";
import {
listRepositoryBranches,
getRepositoryStatus,
getRepositoryHistory,
getCommitDetail,
commitChanges,
pushRepository,
pullRepository,
fetchRepository,
checkoutBranch,
createBranch,
deleteBranch,
mergeBranches,
type Branch,
type CommitHistoryResponse,
type CommitDetail,
} from "../api/git_repositories";
export interface GitStatus {
branch: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
renamed: string[];
ahead: number;
behind: number;
}
export interface UseGitRepoResult {
/** Available branch names. */
branches: string[];
/** The repo's default branch. */
defaultBranch: string;
/** Current working-directory status. */
status: GitStatus | null;
/** Commit history. */
history: CommitHistoryResponse | null;
/** Selected commit detail. */
commitDetail: CommitDetail | null;
/** True while any async operation is in flight. */
loading: boolean;
/** Error message from the last failed operation. */
error: string | null;
/** Refresh branches list. */
refreshBranches: () => Promise<void>;
/** Refresh working-directory status. */
refreshStatus: () => Promise<void>;
/** Refresh commit history. */
refreshHistory: (branch?: string, limit?: number) => Promise<void>;
/** Fetch a single commit's details. */
loadCommitDetail: (hash: string) => Promise<void>;
/** Stage + commit changes. */
commit: (message: string, files?: string[]) => Promise<void>;
/** Push current branch (or named branch) to remote. */
push: (branch?: string) => Promise<void>;
/** Pull from remote. */
pull: (branch?: string) => Promise<void>;
/** Fetch from remote. */
fetch: () => Promise<void>;
/** Checkout an existing branch. */
checkout: (branch: string) => Promise<void>;
/** Create and checkout a new branch. */
createBranch: (name: string, baseBranch?: string) => Promise<void>;
/** Delete a branch. */
deleteBranch: (name: string, force?: boolean) => Promise<void>;
/** Merge source into current (or target) branch. */
merge: (sourceBranch: string, targetBranch?: string, message?: string) => Promise<void>;
/** Clear the current error. */
clearError: () => void;
}
export function useGitRepo(
projectId: string | undefined,
repoId: string | undefined,
): UseGitRepoResult {
const [branches, setBranches] = useState<string[]>([]);
const [defaultBranch, setDefaultBranch] = useState("");
const [status, setStatus] = useState<GitStatus | null>(null);
const [history, setHistory] = useState<CommitHistoryResponse | null>(null);
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const withLoading = useCallback(
async <T,>(fn: () => Promise<T>): Promise<T> => {
setLoading(true);
setError(null);
try {
return await fn();
} catch (err) {
const msg = err instanceof Error ? err.message : "Git operation failed";
setError(msg);
throw err;
} finally {
setLoading(false);
}
},
[],
);
const refreshBranches = useCallback(async () => {
if (!projectId || !repoId) return;
const data = await withLoading(() =>
listRepositoryBranches(projectId, repoId),
);
setBranches(data.branches.map((b: Branch) => b.name));
setDefaultBranch(data.default_branch ?? "");
}, [projectId, repoId, withLoading]);
const refreshStatus = useCallback(async () => {
if (!projectId || !repoId) return;
const data = await withLoading(() =>
getRepositoryStatus(projectId, repoId),
);
setStatus({
branch: data.branch,
modified: data.modified,
added: data.added,
deleted: data.deleted,
untracked: data.untracked,
renamed: data.renamed ?? [],
ahead: data.ahead,
behind: data.behind,
});
}, [projectId, repoId, withLoading]);
const refreshHistory = useCallback(
async (branch?: string, limit = 50) => {
if (!projectId || !repoId) return;
const data = await withLoading(() =>
getRepositoryHistory(projectId, repoId, branch, limit),
);
setHistory(data);
},
[projectId, repoId, withLoading],
);
const loadCommitDetail = useCallback(
async (hash: string) => {
if (!projectId || !repoId) return;
const data = await withLoading(() =>
getCommitDetail(projectId, repoId, hash),
);
setCommitDetail(data);
},
[projectId, repoId, withLoading],
);
const commit = useCallback(
async (message: string, files?: string[]) => {
if (!projectId || !repoId) return;
await withLoading(() =>
commitChanges(projectId, repoId, message, files),
);
await refreshStatus();
},
[projectId, repoId, withLoading, refreshStatus],
);
const push = useCallback(
async (branch?: string) => {
if (!projectId || !repoId) return;
await withLoading(() => pushRepository(projectId, repoId, branch));
await refreshStatus();
},
[projectId, repoId, withLoading, refreshStatus],
);
const pull = useCallback(
async (branch?: string) => {
if (!projectId || !repoId) return;
await withLoading(() => pullRepository(projectId, repoId, branch));
await refreshStatus();
},
[projectId, repoId, withLoading, refreshStatus],
);
const fetch = useCallback(async () => {
if (!projectId || !repoId) return;
await withLoading(() => fetchRepository(projectId, repoId));
await refreshStatus();
}, [projectId, repoId, withLoading, refreshStatus]);
const checkout = useCallback(
async (branch: string) => {
if (!projectId || !repoId) return;
await withLoading(() => checkoutBranch(projectId, repoId, branch));
await refreshStatus();
await refreshBranches();
},
[projectId, repoId, withLoading, refreshStatus, refreshBranches],
);
const createBranchFn = useCallback(
async (name: string, baseBranch = "HEAD") => {
if (!projectId || !repoId) return;
await withLoading(() =>
createBranch(projectId, repoId, name, baseBranch),
);
await refreshBranches();
await refreshStatus();
},
[projectId, repoId, withLoading, refreshBranches, refreshStatus],
);
const deleteBranchFn = useCallback(
async (name: string, force = false) => {
if (!projectId || !repoId) return;
await withLoading(() => deleteBranch(projectId, repoId, name, force));
await refreshBranches();
},
[projectId, repoId, withLoading, refreshBranches],
);
const merge = useCallback(
async (sourceBranch: string, targetBranch?: string, message?: string) => {
if (!projectId || !repoId) return;
await withLoading(() =>
mergeBranches(projectId, repoId, sourceBranch, targetBranch, message),
);
await refreshStatus();
await refreshHistory();
},
[projectId, repoId, withLoading, refreshStatus, refreshHistory],
);
// Auto-refresh branches when projectId/repoId become valid
useEffect(() => {
if (projectId && repoId) {
void refreshBranches();
} else {
setBranches([]);
setDefaultBranch("");
}
}, [projectId, repoId, refreshBranches]);
return {
branches,
defaultBranch,
status,
history,
commitDetail,
loading,
error,
refreshBranches,
refreshStatus,
refreshHistory,
loadCommitDetail,
commit,
push,
pull,
fetch,
checkout,
createBranch: createBranchFn,
deleteBranch: deleteBranchFn,
merge,
clearError: () => setError(null),
};
}
+1 -6
View File
@@ -10,10 +10,7 @@ import {
type ProjectCreateInput, type ProjectCreateInput,
type ProjectUpdateInput, type ProjectUpdateInput,
} from "../api/projects"; } from "../api/projects";
import { import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
deleteWorkspace,
syncWorkspace,
} from "../api/workspaces";
import { import {
EmptyState, EmptyState,
ErrorState, ErrorState,
@@ -111,8 +108,6 @@ export const ProjectsPage = () => {
} }
}; };
const handleSyncWorkspace = async ( const handleSyncWorkspace = async (
projectId: string, projectId: string,
repoId: string, repoId: string,