diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx index f25b962..7d62a00 100644 --- a/apps/web/src/components/workspace-create-form.tsx +++ b/apps/web/src/components/workspace-create-form.tsx @@ -3,8 +3,9 @@ import { useState, useEffect, useCallback } from "react"; import { Icon } from "./icon"; import { listProjects } from "../api/projects"; -import { listRepositories, listRepositoryBranches } from "../api/git_repositories"; +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"; @@ -29,17 +30,36 @@ export function WorkspaceCreateForm({ const [projects, setProjects] = useState([]); const [repos, setRepos] = useState([]); - const [branches, setBranches] = useState([]); - const [selectedProject, setSelectedProject] = useState(defaultProjectId ?? ""); + 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 [fetching, setFetching] = useState(!isContextual); + const [fetchingProjects, setFetchingProjects] = useState(!isContextual); const [error, setError] = useState(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) ── */ const loadProjects = useCallback(async () => { if (isContextual) return; @@ -52,7 +72,7 @@ export function WorkspaceCreateForm({ } catch { setError("Failed to load projects"); } finally { - setFetching(false); + setFetchingProjects(false); } }, [isContextual, defaultProjectId]); @@ -81,42 +101,15 @@ export function WorkspaceCreateForm({ 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 if (value === "__manual__") { + setIsNewBranch(true); + setSelectedBranch("__manual__"); + setNewBranchName(""); } else { setIsNewBranch(false); setSelectedBranch(value); @@ -156,7 +149,10 @@ export function WorkspaceCreateForm({ } }; - if (fetching) { + /* Show single combined error */ + const displayError = error || git.error; + + if (fetchingProjects) { return (

Loading projects...

@@ -164,6 +160,9 @@ export function WorkspaceCreateForm({ ); } + const branchSelectDisabled = + !selectedRepo || submitting || (git.loading && git.branches.length === 0); + return (

@@ -176,7 +175,10 @@ export function WorkspaceCreateForm({ setSelectedRepo(e.target.value)} + onChange={(e) => { + setSelectedRepo(e.target.value); + setSelectedBranch(""); + }} required disabled={!selectedProject || repos.length === 0} > @@ -231,53 +236,52 @@ export function WorkspaceCreateForm({ - {branches.length > 0 ? ( - <> - - {isNewBranch && ( - setNewBranchName(e.target.value)} - placeholder="new-branch-name" - required - style={{ marginTop: "0.5rem" }} - disabled={submitting} - /> - )} - - ) : ( + + + {/* New branch text input */} + {isNewBranch && ( { - setIsNewBranch(true); - setNewBranchName(e.target.value); - setSelectedBranch("__new__"); - }} - placeholder="main" + value={newBranchName} + onChange={(e) => setNewBranchName(e.target.value)} + placeholder="new-branch-name" required - disabled={!selectedRepo || submitting} + style={{ marginTop: "0.5rem" }} + disabled={submitting} /> )}

- {error && ( + {displayError && (
- {error} + {displayError}
)} diff --git a/apps/web/src/hooks/use-git-repo.ts b/apps/web/src/hooks/use-git-repo.ts new file mode 100644 index 0000000..e24900c --- /dev/null +++ b/apps/web/src/hooks/use-git-repo.ts @@ -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; + /** Refresh working-directory status. */ + refreshStatus: () => Promise; + /** Refresh commit history. */ + refreshHistory: (branch?: string, limit?: number) => Promise; + /** Fetch a single commit's details. */ + loadCommitDetail: (hash: string) => Promise; + /** Stage + commit changes. */ + commit: (message: string, files?: string[]) => Promise; + /** Push current branch (or named branch) to remote. */ + push: (branch?: string) => Promise; + /** Pull from remote. */ + pull: (branch?: string) => Promise; + /** Fetch from remote. */ + fetch: () => Promise; + /** Checkout an existing branch. */ + checkout: (branch: string) => Promise; + /** Create and checkout a new branch. */ + createBranch: (name: string, baseBranch?: string) => Promise; + /** Delete a branch. */ + deleteBranch: (name: string, force?: boolean) => Promise; + /** Merge source into current (or target) branch. */ + merge: (sourceBranch: string, targetBranch?: string, message?: string) => Promise; + /** Clear the current error. */ + clearError: () => void; +} + +export function useGitRepo( + projectId: string | undefined, + repoId: string | undefined, +): UseGitRepoResult { + const [branches, setBranches] = useState([]); + const [defaultBranch, setDefaultBranch] = useState(""); + const [status, setStatus] = useState(null); + const [history, setHistory] = useState(null); + const [commitDetail, setCommitDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const withLoading = useCallback( + async (fn: () => Promise): Promise => { + 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), + }; +} diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 2eb3762..103d510 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -10,10 +10,7 @@ import { type ProjectCreateInput, type ProjectUpdateInput, } from "../api/projects"; -import { - deleteWorkspace, - syncWorkspace, -} from "../api/workspaces"; +import { deleteWorkspace, syncWorkspace } from "../api/workspaces"; import { EmptyState, ErrorState, @@ -111,8 +108,6 @@ export const ProjectsPage = () => { } }; - - const handleSyncWorkspace = async ( projectId: string, repoId: string,