返回 DeepSeek-Reasonix
useBranchSwitcher.ts
根目录 / desktop / frontend / src / lib / useBranchSwitcher.ts
1 // Branch list, checkout and create for the launcher's branch row. The list is
2 // fetched lazily the first time the menu opens; the active branch is mirrored
3 // locally so a checkout flips the row immediately while the workspace meta
4 // catches up on its own (cached) cadence.
5 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6 import type { RefObject } from "react";
7 import { app } from "./bridge";
8 import { markWorkspaceRefresh } from "./workspaceRefreshStore";
9
10 // Mirrors the backend's validGitBranchName so the create action is only
11 // enabled for names git would accept.
12 export function isValidBranchName(name: string): boolean {
13 const trimmed = name.trim();
14 if (!trimmed || trimmed.startsWith("-") || trimmed.includes("..") ||
15 trimmed.endsWith("/") || trimmed.endsWith(".") || trimmed.includes("@{")) {
16 return false;
17 }
18 return !/[\s~^:?*[\\\t\n]/.test(trimmed);
19 }
20
21 interface BranchSwitcherInput {
22 tabId: string;
23 scopeKey: string;
24 workspaceRoot: string;
25 /** Current git branch for the active workspace; undefined when unknown. */
26 gitBranch: string | undefined;
27 /** The launcher card, used to dismiss the menu on an outside click. */
28 rootRef: RefObject<HTMLElement | null>;
29 /** Fired after a checkout/create so the caller can refresh derived data. */
30 onBranchChanged: () => void;
31 }
32
33 export function useBranchSwitcher({ tabId, scopeKey, workspaceRoot, gitBranch, rootRef, onBranchChanged }: BranchSwitcherInput) {
34 const identity = useMemo(() => ({}), [tabId, scopeKey, workspaceRoot]);
35 const currentIdentity = useRef<object | null>(null);
36 const listRequest = useRef(0);
37 const operation = useRef<object | null>(null);
38 const [branchMenuOpen, setBranchMenuOpen] = useState(false);
39 const [branches, setBranches] = useState<string[]>([]);
40 const [branchesLoading, setBranchesLoading] = useState(false);
41 const [branchesErr, setBranchesErr] = useState("");
42 const [branchQuery, setBranchQuery] = useState("");
43 const [switchingBranch, setSwitchingBranch] = useState("");
44 const [branchSwitchErr, setBranchSwitchErr] = useState("");
45 // Local mirror of the active branch: the workspace meta refreshes on a
46 // cached cadence, so a checkout flips this immediately and the prop catches
47 // up (the prop wins whenever it diverges from local optimism).
48 const [activeBranch, setActiveBranch] = useState(gitBranch);
49 const branchSearchRef = useRef<HTMLInputElement | null>(null);
50 const onBranchChangedRef = useRef(onBranchChanged);
51 onBranchChangedRef.current = onBranchChanged;
52
53 useLayoutEffect(() => {
54 currentIdentity.current = identity;
55 listRequest.current++;
56 operation.current = null;
57 setBranches([]);
58 setBranchesLoading(false);
59 setBranchesErr("");
60 setBranchQuery("");
61 setBranchMenuOpen(false);
62 setSwitchingBranch("");
63 setBranchSwitchErr("");
64 return () => { currentIdentity.current = null; };
65 }, [identity]);
66
67 useEffect(() => {
68 setActiveBranch(gitBranch);
69 }, [gitBranch, identity]);
70
71 // Dismiss the branch switcher on any click outside the launcher card, plus
72 // Escape — baseline popover behavior.
73 useEffect(() => {
74 if (!branchMenuOpen) return;
75 const onPointerDown = (event: PointerEvent) => {
76 if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) {
77 setBranchMenuOpen(false);
78 }
79 };
80 const onKeyDown = (event: KeyboardEvent) => {
81 if (event.key === "Escape") setBranchMenuOpen(false);
82 };
83 window.addEventListener("pointerdown", onPointerDown, true);
84 window.addEventListener("keydown", onKeyDown);
85 return () => {
86 window.removeEventListener("pointerdown", onPointerDown, true);
87 window.removeEventListener("keydown", onKeyDown);
88 };
89 }, [branchMenuOpen, rootRef]);
90
91 const loadBranches = useCallback(async () => {
92 if (!tabId || currentIdentity.current !== identity) return;
93 const request = ++listRequest.current;
94 const current = () => currentIdentity.current === identity && request === listRequest.current;
95 setBranchesLoading(true);
96 setBranchesErr("");
97 try {
98 const list = await app.GitBranchesForTab(tabId, workspaceRoot);
99 if (current()) setBranches(Array.isArray(list) ? list : []);
100 } catch (error) {
101 if (current()) setBranchesErr(error instanceof Error ? error.message : String(error));
102 } finally {
103 if (current()) setBranchesLoading(false);
104 }
105 }, [identity, tabId, workspaceRoot]);
106
107 const toggleBranchMenu = useCallback(() => {
108 const next = !branchMenuOpen;
109 setBranchMenuOpen(next);
110 setBranchSwitchErr("");
111 setBranchQuery("");
112 if (next && branches.length === 0 && !branchesLoading) void loadBranches();
113 if (next) window.setTimeout(() => {
114 if (currentIdentity.current === identity) branchSearchRef.current?.focus();
115 }, 0);
116 }, [branchMenuOpen, branches.length, branchesLoading, loadBranches, identity]);
117
118 const finishBranchSwitch = useCallback((branch: string) => {
119 if (currentIdentity.current !== identity) return;
120 setActiveBranch(branch);
121 setBranchMenuOpen(false);
122 setBranchQuery("");
123 // The working tree just changed under the diff badge.
124 markWorkspaceRefresh(tabId, scopeKey);
125 onBranchChangedRef.current();
126 void loadBranches();
127 }, [identity, loadBranches, tabId, scopeKey]);
128
129 const checkoutBranch = useCallback(async (branch: string) => {
130 if (!tabId || operation.current || currentIdentity.current !== identity) return;
131 const request = {};
132 operation.current = request;
133 setSwitchingBranch(branch);
134 setBranchSwitchErr("");
135 try {
136 await app.GitCheckoutForTab(tabId, workspaceRoot, branch);
137 if (operation.current === request) finishBranchSwitch(branch);
138 } catch (error) {
139 if (currentIdentity.current === identity && operation.current === request) setBranchSwitchErr(error instanceof Error ? error.message : String(error));
140 } finally {
141 if (currentIdentity.current === identity && operation.current === request) {
142 operation.current = null;
143 setSwitchingBranch("");
144 }
145 }
146 }, [finishBranchSwitch, identity, tabId, workspaceRoot]);
147
148 const createBranch = useCallback(async (name: string) => {
149 if (!tabId || operation.current || currentIdentity.current !== identity) return;
150 const request = {};
151 operation.current = request;
152 setSwitchingBranch(name);
153 setBranchSwitchErr("");
154 try {
155 await app.GitCreateBranchForTab(tabId, workspaceRoot, name);
156 if (operation.current === request) finishBranchSwitch(name);
157 } catch (error) {
158 if (currentIdentity.current === identity && operation.current === request) setBranchSwitchErr(error instanceof Error ? error.message : String(error));
159 } finally {
160 if (currentIdentity.current === identity && operation.current === request) {
161 operation.current = null;
162 setSwitchingBranch("");
163 }
164 }
165 }, [finishBranchSwitch, identity, tabId, workspaceRoot]);
166
167 const trimmedQuery = branchQuery.trim();
168 const filteredBranches = useMemo(() => {
169 const q = trimmedQuery.toLowerCase();
170 const available = activeBranch && !branches.includes(activeBranch) ? [activeBranch, ...branches] : branches;
171 return q ? available.filter((branch) => branch.toLowerCase().includes(q)) : available;
172 }, [activeBranch, branches, trimmedQuery]);
173 const exactMatch = filteredBranches.some((branch) => branch === trimmedQuery);
174 const canCreate = trimmedQuery !== "" && !exactMatch && isValidBranchName(trimmedQuery);
175
176 return {
177 activeBranch,
178 branchMenuOpen,
179 branchSearchRef,
180 branches,
181 branchesLoading,
182 branchesErr,
183 branchQuery,
184 setBranchQuery,
185 branchSwitchErr,
186 setBranchSwitchErr,
187 switchingBranch,
188 filteredBranches,
189 exactMatch,
190 canCreate,
191 trimmedQuery,
192 toggleBranchMenu,
193 checkoutBranch,
194 createBranch,
195 };
196 }
197
197 lines TYPESCRIPT