319 lines · 13.6 KB · TypeScript
RawDownload
import type { GitBranch } from "@t3tools/contracts";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { useEffect, useRef, useState } from "react"; import { useStore } from "../store";import { useNativeApi } from "../hooks/useNativeApi"; interface BranchToolbarProps {  envMode: "local" | "worktree";  onEnvModeChange: (mode: "local" | "worktree") => void;  envLocked: boolean;} export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {  const { state, dispatch } = useStore();  const api = useNativeApi();  const queryClient = useQueryClient();   const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);  const [isCreatingBranch, setIsCreatingBranch] = useState(false);  const [newBranchName, setNewBranchName] = useState("");  const branchMenuRef = useRef<HTMLDivElement>(null);   const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);  const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);  const activeThreadId = activeThread?.id;  const activeThreadBranch = activeThread?.branch ?? null;  const activeWorktreePath = activeThread?.worktreePath ?? null;  const branchCwd = activeWorktreePath ?? activeProject?.cwd;   // ── Queries ───────────────────────────────────────────────────────────   const branchesQuery = useQuery({    queryKey: ["git", "branches", branchCwd],    queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),    enabled: !!api && !!branchCwd,  });   const branches = branchesQuery.data?.branches ?? [];  // Default to true while loading — showing "Initialize git" during a fetch is wrong,  // and worktrees are inherently git repos.  const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading;   // ── Mutations ─────────────────────────────────────────────────────────   const checkoutMutation = useMutation({    mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),    onError: (error) =>      setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."),  });   const createBranchMutation = useMutation({    mutationFn: (branch: string) =>      api!.git        .createBranch({ cwd: branchCwd!, branch })        .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),    onError: (error) =>      setThreadError(error instanceof Error ? error.message : "Failed to create branch."),  });   const initMutation = useMutation({    mutationFn: () => api!.git.init({ cwd: branchCwd! }),    onSuccess: () => {      setThreadError(null);      queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });    },    onError: (error) =>      setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."),  });   // ── Effects ───────────────────────────────────────────────────────────   // Keep thread branch synced to git current branch for local threads.  const queryBranches = branchesQuery.data?.branches;  useEffect(() => {    if (!activeThreadId || activeWorktreePath) return;    const current = queryBranches?.find((branch) => branch.current);    if (!current) return;    if (current.name === activeThreadBranch) return;    dispatch({      type: "SET_THREAD_BRANCH",      threadId: activeThreadId,      branch: current.name,      worktreePath: null,    });  }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]);   useEffect(() => {    if (!isBranchMenuOpen) return;    const handleClickOutside = (event: MouseEvent) => {      if (!branchMenuRef.current) return;      if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) {        setIsBranchMenuOpen(false);      }    };    window.addEventListener("mousedown", handleClickOutside);    return () => {      window.removeEventListener("mousedown", handleClickOutside);    };  }, [isBranchMenuOpen]);   // ── Helpers ───────────────────────────────────────────────────────────   const setThreadError = (error: string | null) => {    if (!activeThreadId) return;    dispatch({ type: "SET_ERROR", threadId: activeThreadId, error });  };   const setThreadBranch = (branch: string | null, worktreePath: string | null) => {    if (!activeThreadId) return;    // If the effective cwd is about to change, stop the running session so the    // next message creates a new one with the correct cwd.    const sessionId = activeThread?.session?.sessionId;    if (sessionId && worktreePath !== activeWorktreePath) {      void api?.providers.stopSession({ sessionId }).catch(() => undefined);    }    dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });  };   const selectBranch = (branch: GitBranch) => {    if (!api || !activeThreadId || !branchCwd) return;     // For new worktree mode, selecting a branch picks the base branch.    if (envMode === "worktree" && !envLocked && !activeWorktreePath) {      setThreadError(null);      setThreadBranch(branch.name, null);      setIsBranchMenuOpen(false);      return;    }     // If the branch already lives in a worktree, redirect there instead of    // trying to checkout (which git would reject with "already used by worktree").    if (branch.worktreePath) {      const isMainWorktree = branch.worktreePath === activeProject?.cwd;      setThreadError(null);      // Main worktree → switch back to local (project cwd, worktreePath=null).      // Secondary worktree → point the thread at that worktree path.      setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);      setIsBranchMenuOpen(false);      return;    }     checkoutMutation.mutate(branch.name, {      onSuccess: () => {        setThreadError(null);        setThreadBranch(branch.name, activeWorktreePath);        setIsBranchMenuOpen(false);      },    });  };   const createBranch = () => {    const name = newBranchName.trim();    if (!api || !activeThreadId || !branchCwd || !name) return;    createBranchMutation.mutate(name, {      onSuccess: () => {        setThreadError(null);        setThreadBranch(name, activeWorktreePath);        setNewBranchName("");        setIsCreatingBranch(false);        setIsBranchMenuOpen(false);      },    });  };   if (!activeThread || !activeProject) return null;   return (    <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">      <div className="flex items-center gap-2">        {envLocked || activeWorktreePath ? (          <span className="inline-flex items-center gap-1.5 px-1 text-[12px] text-muted-foreground/55">            {activeWorktreePath ? "Worktree" : "Local"}          </span>        ) : (          <button            type="button"            className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] transition-colors duration-150 ${              envMode === "worktree"                ? "bg-accent text-foreground/80"                : "text-muted-foreground/60 hover:bg-accent/50 hover:text-muted-foreground/80"            }`}            onClick={() => onEnvModeChange(envMode === "local" ? "worktree" : "local")}          >            {envMode === "worktree" ? "New worktree" : "Local"}          </button>        )}      </div>       {!isRepo ? (        <button          type="button"          className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[12px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-50"          disabled={!branchCwd || initMutation.isPending}          onClick={() => initMutation.mutate()}        >          {initMutation.isPending ? "Initializing\u2026" : "Initialize git"}        </button>      ) : (        <div className="relative" ref={branchMenuRef}>          <button            type="button"            className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] text-muted-foreground/60 transition-colors duration-150 hover:bg-accent/50 hover:text-muted-foreground/80"            onClick={() => setIsBranchMenuOpen((open) => !open)}            disabled={branchesQuery.isLoading}          >            <span className="max-w-[240px] truncate font-mono">              {activeThread.branch                ? envMode === "worktree" && !activeWorktreePath                  ? `From ${activeThread.branch}`                  : activeThread.branch                : "Select branch"}            </span>            <svg              width="10"              height="10"              viewBox="0 0 10 10"              fill="none"              className="opacity-40"              aria-hidden="true"            >              <path                d="M2.5 4L5 6.5L7.5 4"                stroke="currentColor"                strokeWidth="1.2"                strokeLinecap="round"                strokeLinejoin="round"              />            </svg>          </button>          {isBranchMenuOpen && (            <div className="absolute bottom-full right-0 z-20 mb-2 w-[320px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">              <p className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55">                Branch              </p>              <div className="max-h-64 overflow-y-auto">                {branches.map((branch) => {                  const isSelected = branch.name === activeThread.branch;                  const hasSecondaryWorktree =                    branch.worktreePath && branch.worktreePath !== activeProject.cwd;                  return (                    <button                      key={branch.name}                      type="button"                      className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-1.5 text-left font-mono text-xs transition-colors duration-150 ${                        isSelected                          ? "bg-accent text-foreground"                          : "text-foreground/90 hover:bg-accent/50"                      }`}                      onClick={() => selectBranch(branch)}                    >                      <span className="truncate">{branch.name}</span>                      {(branch.current || branch.isDefault || hasSecondaryWorktree) && (                        <span className="shrink-0 text-[10px] text-muted-foreground/45">                          {branch.current                            ? "current"                            : hasSecondaryWorktree                              ? "worktree"                              : "default"}                        </span>                      )}                    </button>                  );                })}              </div>              {envMode === "local" && (                <>                  <div className="mx-1 my-1 h-px bg-border" />                  {isCreatingBranch ? (                    <form                      className="flex items-center gap-1 px-1"                      onSubmit={(event) => {                        event.preventDefault();                        createBranch();                      }}                    >                      <input                        type="text"                        className="flex-1 rounded-lg border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/30 focus:border-ring focus:outline-none"                        placeholder="branch-name"                        value={newBranchName}                        onChange={(event) => setNewBranchName(event.target.value)}                        onKeyDown={(event) => {                          if (event.key === "Escape") {                            setIsCreatingBranch(false);                            setNewBranchName("");                          }                        }}                        // biome-ignore lint/a11y/noAutofocus: branch name input should focus when shown                        autoFocus                      />                      <button                        type="submit"                        disabled={!newBranchName.trim() || createBranchMutation.isPending}                        className="rounded-lg px-2 py-1.5 text-xs text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground/80 disabled:opacity-30"                      >                        Create                      </button>                    </form>                  ) : (                    <button                      type="button"                      className="w-full rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80"                      onClick={() => setIsCreatingBranch(true)}                    >                      + New branch                    </button>                  )}                </>              )}            </div>          )}        </div>      )}    </div>  );}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t