/** * Local git work for pull requests: checkout (`gh pr checkout`), fetching what a diff * needs (`ghPrPrepareDiff`, cached per head / base commit) and opening a changed file in * the diff viewer (`vcs.openDiff`; `diff/` shows its review threads). Long operations run * one at a time and are shown in the tool window's banner (`useGhBusy`), cancellable. */ import { Channel } from "@tauri-apps/api/core"; import { create } from "zustand"; import { withChain, type DiffData } from "@/features/vcs/diffInput"; import { executeAction } from "@/platform/actions"; import type { PrDiffRevisions, RequestId } from "@/platform/ipc"; import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; import { openExternalUrl } from "@/platform/openUrl"; import { tx } from "@/platform/i18n"; import { notifications } from "@/workbench/contributions"; import { errorMessage } from "../api/client"; import type { GHCommitFile, GHPullRequest } from "../api/types"; import { loadPullRequestData } from "./pullRequest"; import { connectedRoot, nameWithOwner, requireConnection } from "./repository"; /** The running operation (checkout, fetch for a diff) with its last output line. */ export const useGhBusy = create<{ busy: { title: string; line: string; request: RequestId } | null }>(() => ({ busy: null, })); /** Runs a cancellable, streaming gh / git operation; null when it failed, was cancelled or another one runs. */ export async function withBusy( title: string, run: (request: RequestId, out: Channel) => Promise, ): Promise { if (useGhBusy.getState().busy) { notifications().notify({ group: "vcs", level: "info", title: "Another GitHub operation is still running", key: "github-busy", }); return null; } const request = nextRequestId(); const out = new Channel(); out.onmessage = (line) => useGhBusy.setState((s) => (s.busy ? { busy: { ...s.busy, line } } : s)); useGhBusy.setState({ busy: { title, line: "", request } }); try { return await run(request, out); } catch (e) { if (!isCancelled(e)) notifications().notify({ group: "vcs", level: "error", title: `${title.replace(/…$/, "")} failed`, body: errorMessage(e), key: "github-op", }); return null; } finally { useGhBusy.setState({ busy: null }); } } export function cancelBusy() { const b = useGhBusy.getState().busy; if (b) void commands.cancelRequest(b.request); } /** "Checkout Branch": fetches and checks out the pull request's branch (gh's local name). */ export async function checkoutPullRequest(number: number): Promise { // The command resolves with nothing (`null`), which `withBusy` reports as a failure. const done = await withBusy(`Checking out #${number}…`, async (r, out) => { await call(commands.ghPrCheckout(connectedRoot(), r, number, out)); return true; }); if (done === null) return false; notifications().notify({ group: "vcs", level: "info", title: `Checked out pull request #${number}`, key: "github-checkout", }); return true; } const diffCache = new Map(); /** Fetches what the local repository lacks to diff `pr` (once per head / base commit). */ export async function prepareDiff(pr: GHPullRequest): Promise { const key = `${pr.number}:${pr.headRefOid}:${pr.baseRefOid}`; const hit = diffCache.get(key); if (hit) return hit; const { coordinates, root } = requireConnection(); const repo = nameWithOwner(coordinates); const revs = await withBusy(`Fetching #${pr.number}…`, (r, out) => call(commands.ghPrPrepareDiff(root, r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)), ); if (revs) diffCache.set(key, revs); return revs; } export function clearDiffCache() { diffCache.clear(); } /** The diff context of a pull request's file (`diff/` shows its review there); `commit` for one commit's diff. */ export const PULL_REQUEST_DIFF = "github.pullRequest"; /** * `DiffData` for a changed file of `pr`: merge base ↔ head, its context (`PULL_REQUEST_DIFF`) * naming the pull request and the file. */ export function fileDiffData( pr: Pick, file: Pick, revs: PrDiffRevisions, ): DiffData { const root = revs.root; const changed = revs.files.find((f) => f.path === file.filename); const added = file.status === "added" || changed?.kind === "added"; const deleted = file.status === "removed" || changed?.kind === "deleted"; const oldPath = file.previousFilename ?? changed?.oldPath ?? null; const n = pr.number; const name = file.filename.slice(file.filename.lastIndexOf("/") + 1); return { path: `${root}/${file.filename}`, left: added ? { type: "text", text: "", label: tx("(does not exist)") } : { type: "rev", rev: revs.base, path: oldPath ? `${root}/${oldPath}` : undefined, label: `${pr.baseRefName} (${revs.base.slice(0, 8)})`, }, right: deleted ? { type: "text", text: "", label: tx("(deleted)") } : { type: "rev", rev: revs.head, label: `#${n} ${pr.headRefName}` }, title: `#${n}: ${name}`, context: { kind: PULL_REQUEST_DIFF, number: n, path: file.filename, commit: null }, }; } /** * Opens `path` of pull request `number` in the diff viewer (fetching what it needs first), * revealing the 0-based `line` of `side` (default right) when given (a review thread's line). * The pull request's other files follow in the chain (F7 past the last change). */ export async function openPullRequestFileDiff( number: number, path: string, opts: { line?: number; side?: "left" | "right" } = {}, ): Promise { const [pr, files] = await Promise.all([ loadPullRequestData(number, "details"), loadPullRequestData(number, "changes"), ]); const revs = await prepareDiff(pr); if (!revs) return; const file = files.find((f) => f.filename === path) ?? { filename: path, status: "modified" as const, previousFilename: null, }; const all = files.some((f) => f.filename === path) ? files : [...files, file]; const data = withChain( all.map((f) => fileDiffData(pr, f, revs)), all.findIndex((f) => f.filename === path), ); await executeAction("vcs.openDiff", opts.line == null ? data : { ...data, line: opts.line, lineSide: opts.side }); } /** "View Pull Request in Browser" and other links. */ export function openInBrowser(url: string): Promise { return openExternalUrl(url); } /** "Copy Pull Request URL". */ export async function copyUrl(url: string): Promise { await navigator.clipboard?.writeText(url); } /** * "Open in Worktree": fetches the pull request's head, adds the worktree * `_PR_` on it (detached) and opens it in a new window. */ export async function openInWorktree(number: number): Promise { const pr = await loadPullRequestData(number, "details"); const revs = await prepareDiff(pr); if (!revs) return; const path = `${String(revs.root).replace(/[\\/]+$/, "")}_PR_${number}`; const added = await withBusy(`Creating worktree for #${number}…`, async () => { await call(commands.vcsAddWorktree(revs.root, path, pr.headRefOid, null)); return true; }); if (added) await executeAction("file.openInNewWindow", path); }