import { useQuery } from "@tanstack/react-query"; import { Channel, invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation"; import { queryClient } from "@yaakapp/yaak-client/lib/queryClient"; import { useMemo } from "react"; import { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, } from "./bindings/gen_git"; import { showToast } from "@yaakapp/yaak-client/lib/toast"; export * from "./bindings/gen_git"; export * from "./bindings/gen_models"; export interface GitCredentials { username: string; password: string; } export type DivergedStrategy = "force_reset" | "merge" | "cancel"; export type UncommittedChangesStrategy = "reset" | "cancel"; interface GitWatchResult { unlistenEvent: string; } export interface GitCallbacks { addRemote: () => Promise; promptCredentials: ( result: Extract, ) => Promise; promptDiverged: (result: Extract) => Promise; promptUncommittedChanges: () => Promise; forceSync: () => Promise; } const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] }); function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) { return refreshKey == null ? (["git", "worktree_status", dir] as const) : (["git", "worktree_status", dir, refreshKey] as const); } export function invalidateGitWorktreeStatus(dir?: string) { return queryClient.invalidateQueries({ queryKey: gitWorktreeStatusQueryKey(dir) }); } export function useGitWorktreeStatus(dir: string, refreshKey?: string) { return useQuery({ queryKey: gitWorktreeStatusQueryKey(dir, refreshKey), queryFn: () => invoke("cmd_git_worktree_status", { dir }), placeholderData: (prev) => prev, }); } export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktreeStatus) => void) { const channel = new Channel(); channel.onmessage = callback; const unlistenPromise = invoke("cmd_git_watch_worktree_status", { dir, channel, }); void unlistenPromise .then(({ unlistenEvent }) => { addGitWatchKey(unlistenEvent); }) .catch(console.debug); return () => unlistenPromise .then(async ({ unlistenEvent }) => { unlistenGitWatcher(unlistenEvent); }) .catch(console.error); } function useGitFetchAll(dir: string, refreshKey?: string) { return useQuery({ queryKey: ["git", "fetch_all", dir, refreshKey], queryFn: () => invoke("cmd_git_fetch_all", { dir }), refetchInterval: 10 * 60_000, }); } function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) { return useQuery({ refetchOnMount: true, queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt], queryFn: () => invoke("cmd_git_branch_info", { dir }), placeholderData: (prev) => prev, }); } export function useGitBranchInfo(dir: string, refreshKey?: string) { const fetchAll = useGitFetchAll(dir, refreshKey); return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt); } export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) { return useQuery({ queryKey: ["git", "log", dir, refreshKey, relaPath], queryFn: () => relaPath == null ? invoke("cmd_git_log", { dir }) : invoke("cmd_git_log_for_file", { dir, relaPath }), placeholderData: (prev) => prev, }); } export function useGitFileDiffForCommit( dir: string, relaPath: string, commitOid: string | null | undefined, ) { return useQuery({ enabled: commitOid != null, queryKey: ["git", "file_diff_for_commit", dir, relaPath, commitOid], queryFn: () => { if (commitOid == null) throw new Error("Missing commit oid"); return invoke("cmd_git_file_diff_for_commit", { dir, relaPath, commitOid }); }, }); } export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) { const mutations = useGitMutations(dir, callbacks); const fetchAll = useGitFetchAll(dir, refreshKey); return [ { remotes: useQuery({ queryKey: ["git", "remotes", dir, refreshKey], queryFn: () => getRemotes(dir), placeholderData: (prev) => prev, }), log: useGitLog(dir, refreshKey), status: useQuery({ refetchOnMount: true, queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt], queryFn: () => invoke("cmd_git_status", { dir }), placeholderData: (prev) => prev, }), }, mutations, ] as const; } export function useGitMutations(dir: string, callbacks: GitCallbacks) { return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]); } export const gitMutations = (dir: string, callbacks: GitCallbacks) => { const push = async () => { const remotes = await getRemotes(dir); if (remotes.length === 0) { const remote = await callbacks.addRemote(); if (remote == null) throw new Error("No remote found"); } const result = await invoke("cmd_git_push", { dir }); if (result.type !== "needs_credentials") return result; // Needs credentials, prompt for them const creds = await callbacks.promptCredentials(result); if (creds == null) throw new Error("Canceled"); await invoke("cmd_git_add_credential", { remoteUrl: result.url, username: creds.username, password: creds.password, }); // Push again return invoke("cmd_git_push", { dir }); }; const handleError = (err: unknown) => { showToast({ id: String(err), message: String(err), color: "danger", timeout: 5000, }); }; return { init: createFastMutation({ mutationKey: ["git", "init"], mutationFn: () => invoke("cmd_git_initialize", { dir }), onSuccess, }), add: createFastMutation({ mutationKey: ["git", "add", dir], mutationFn: (args) => invoke("cmd_git_add", { dir, ...args }), onSuccess, }), addRemote: createFastMutation({ mutationKey: ["git", "add-remote"], mutationFn: (args) => invoke("cmd_git_add_remote", { dir, ...args }), onSuccess, }), rmRemote: createFastMutation({ mutationKey: ["git", "rm-remote", dir], mutationFn: (args) => invoke("cmd_git_rm_remote", { dir, ...args }), onSuccess, }), createBranch: createFastMutation({ mutationKey: ["git", "branch", dir], mutationFn: (args) => invoke("cmd_git_branch", { dir, ...args }), onSuccess, }), mergeBranch: createFastMutation({ mutationKey: ["git", "merge", dir], mutationFn: (args) => invoke("cmd_git_merge_branch", { dir, ...args }), onSuccess, }), deleteBranch: createFastMutation< BranchDeleteResult, string, { branch: string; force?: boolean } >({ mutationKey: ["git", "delete-branch", dir], mutationFn: (args) => invoke("cmd_git_delete_branch", { dir, ...args }), onSuccess, }), deleteRemoteBranch: createFastMutation({ mutationKey: ["git", "delete-remote-branch", dir], mutationFn: (args) => invoke("cmd_git_delete_remote_branch", { dir, ...args }), onSuccess, }), renameBranch: createFastMutation({ mutationKey: ["git", "rename-branch", dir], mutationFn: (args) => invoke("cmd_git_rename_branch", { dir, ...args }), onSuccess, }), checkout: createFastMutation({ mutationKey: ["git", "checkout", dir], mutationFn: (args) => invoke("cmd_git_checkout", { dir, ...args }), onSuccess, }), commit: createFastMutation({ mutationKey: ["git", "commit", dir], mutationFn: (args) => invoke("cmd_git_commit", { dir, ...args }), onSuccess, }), commitAndPush: createFastMutation({ mutationKey: ["git", "commit_push", dir], mutationFn: async (args) => { await invoke("cmd_git_commit", { dir, ...args }); return push(); }, onSuccess, }), push: createFastMutation({ mutationKey: ["git", "push", dir], mutationFn: push, onSuccess, }), pull: createFastMutation({ mutationKey: ["git", "pull", dir], async mutationFn() { const result = await invoke("cmd_git_pull", { dir }); if (result.type === "needs_credentials") { const creds = await callbacks.promptCredentials(result); if (creds == null) throw new Error("Canceled"); await invoke("cmd_git_add_credential", { remoteUrl: result.url, username: creds.username, password: creds.password, }); // Pull again after credentials return invoke("cmd_git_pull", { dir }); } if (result.type === "uncommitted_changes") { void callbacks .promptUncommittedChanges() .then(async (strategy) => { if (strategy === "cancel") return; await invoke("cmd_git_reset_changes", { dir }); return invoke("cmd_git_pull", { dir }); }) .then(async () => { await onSuccess(); await callbacks.forceSync(); }, handleError); } if (result.type === "diverged") { void callbacks .promptDiverged(result) .then((strategy) => { if (strategy === "cancel") return; if (strategy === "force_reset") { return invoke("cmd_git_pull_force_reset", { dir, remote: result.remote, branch: result.branch, }); } return invoke("cmd_git_pull_merge", { dir, remote: result.remote, branch: result.branch, }); }) .then(async () => { await onSuccess(); await callbacks.forceSync(); }, handleError); } return result; }, onSuccess, }), unstage: createFastMutation({ mutationKey: ["git", "unstage", dir], mutationFn: (args) => invoke("cmd_git_unstage", { dir, ...args }), onSuccess, }), resetChanges: createFastMutation({ mutationKey: ["git", "reset-changes", dir], mutationFn: () => invoke("cmd_git_reset_changes", { dir }), onSuccess, }), restore: createFastMutation({ mutationKey: ["git", "restore", dir], mutationFn: (args) => invoke("cmd_git_restore_files", { dir, ...args }), onSuccess, }), restoreFileFromCommit: createFastMutation< void, string, { commitOid: string; relaPath: string } >({ mutationKey: ["git", "restore-file-from-commit", dir], mutationFn: (args) => invoke("cmd_git_restore_file_from_commit", { dir, ...args }), onSuccess, }), } as const; }; async function getRemotes(dir: string) { return invoke("cmd_git_remotes", { dir }); } function unlistenGitWatcher(unlistenEvent: string) { void emit(unlistenEvent).then(() => { removeGitWatchKey(unlistenEvent); }); } function getGitWatchKeys() { return sessionStorage.getItem("git-worktree-watchers")?.split(",").filter(Boolean) ?? []; } function setGitWatchKeys(keys: string[]) { sessionStorage.setItem("git-worktree-watchers", keys.join(",")); } function addGitWatchKey(key: string) { const keys = getGitWatchKeys(); setGitWatchKeys([...keys, key]); } function removeGitWatchKey(key: string) { const keys = getGitWatchKeys(); setGitWatchKeys(keys.filter((k) => k !== key)); } const gitWatchKeys = getGitWatchKeys(); if (gitWatchKeys.length > 0) { gitWatchKeys.forEach(unlistenGitWatcher); } /** * Clone a git repository, prompting for credentials if needed. */ export async function gitClone( url: string, dir: string, promptCredentials: (args: { url: string; error: string | null; }) => Promise, ): Promise { const result = await invoke("cmd_git_clone", { url, dir }); if (result.type !== "needs_credentials") return result; // Prompt for credentials const creds = await promptCredentials({ url: result.url, error: result.error, }); if (creds == null) return { type: "cancelled" }; // Store credentials and retry await invoke("cmd_git_add_credential", { remoteUrl: result.url, username: creds.username, password: creds.password, }); return invoke("cmd_git_clone", { url, dir }); }