/** * `useGit` — view-model hook for the Git feature (L8). * * Owns the git feature state for a given project and exposes the actions the * UI triggers. Consumes {@link GitGateway} exclusively through `useGateways().git`; * never touches `invoke()` or `@tauri-apps/api`. */ import { useCallback, useEffect, useState } from "react"; import type { GitBranches, GitCommit, GitFileStatus, GatewayError } from "@/domain"; import { useGateways } from "@/app/di"; /** What the Git UI needs from this hook. */ export interface GitViewModel { /** Files in the working tree (staged + unstaged). */ files: GitFileStatus[]; /** Branch info: list + current. */ branches: GitBranches; /** Recent commit log. */ log: GitCommit[]; /** Whether a request is in flight. */ busy: boolean; /** Last error message, or `null`. */ error: string | null; /** Refresh all git state. */ refresh: () => Promise; /** Stage a file. */ stage: (path: string) => Promise; /** Unstage a file. */ unstage: (path: string) => Promise; /** Commit staged files with the given message. */ commit: (message: string) => Promise; /** Checkout (or create) a branch. */ checkout: (branch: string) => Promise; } function describeError(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } const EMPTY_BRANCHES: GitBranches = { branches: [], current: null }; const LOG_LIMIT = 20; export function useGit(projectId: string): GitViewModel { const { git } = useGateways(); const [files, setFiles] = useState([]); const [branches, setBranches] = useState(EMPTY_BRANCHES); const [log, setLog] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(async () => { setBusy(true); setError(null); try { const [fileList, branchInfo, commitLog] = await Promise.all([ git.status(projectId), git.branches(projectId), git.log(projectId, LOG_LIMIT), ]); setFiles(fileList); setBranches(branchInfo); setLog(commitLog); } catch (e) { setError(describeError(e)); } finally { setBusy(false); } }, [git, projectId]); useEffect(() => { void refresh(); }, [refresh]); const stage = useCallback( async (path: string) => { setBusy(true); setError(null); try { await git.stage(projectId, path); setFiles((prev) => prev.map((f) => (f.path === path ? { ...f, staged: true } : f)), ); } catch (e) { setError(describeError(e)); } finally { setBusy(false); } }, [git, projectId], ); const unstage = useCallback( async (path: string) => { setBusy(true); setError(null); try { await git.unstage(projectId, path); setFiles((prev) => prev.map((f) => (f.path === path ? { ...f, staged: false } : f)), ); } catch (e) { setError(describeError(e)); } finally { setBusy(false); } }, [git, projectId], ); const commit = useCallback( async (message: string) => { setBusy(true); setError(null); try { const newCommit = await git.commit(projectId, message); // Remove committed (staged) files from the list. setFiles((prev) => prev.filter((f) => !f.staged)); setLog((prev) => [newCommit, ...prev].slice(0, LOG_LIMIT)); } catch (e) { setError(describeError(e)); } finally { setBusy(false); } }, [git, projectId], ); const checkout = useCallback( async (branch: string) => { setBusy(true); setError(null); try { await git.checkout(projectId, branch); setBranches((prev) => ({ branches: prev.branches.includes(branch) ? prev.branches : [...prev.branches, branch], current: branch, })); } catch (e) { setError(describeError(e)); } finally { setBusy(false); } }, [git, projectId], ); return { files, branches, log, busy, error, refresh, stage, unstage, commit, checkout, }; }