返回 DeepSeek-Reasonix
TabBar.tsx
根目录 / desktop / frontend / src / components / TabBar.tsx
1 // TabBar renders the browser-like workspace tab strip. Each tab represents one
2 // open project/global topic, so switching tabs switches the active conversation.
3 import { useEffect, useRef, useState } from "react";
4 import type { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
5 import { FileText, Plus, Search, X } from "lucide-react";
6 import { normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode, type Mode, type TabMeta } from "../lib/types";
7 import { projectColorValue } from "../lib/projectColors";
8 import { useT } from "../lib/i18n";
9 import { Tooltip } from "./Tooltip";
10 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
11 import { WorktreeBadge } from "./WorktreeBadge";
12
13 interface TabBarProps {
14 tabs: TabMeta[];
15 activeTabId?: string;
16 onTabChange: (tabId: string) => void;
17 onTabClose: (tabId: string) => void;
18 onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void;
19 onTabsReorder: (tabIds: string[]) => void;
20 onNewTab: () => void;
21 onOpenPalette?: () => void;
22 commandCompact?: boolean;
23 revealActiveSignal?: number;
24 }
25
26 type DropSide = "before" | "after";
27
28 function tabDisplayTitle(tab: TabMeta): string {
29 if (tab.tabType === "file" || tab.scope === "file") return tab.topicTitle?.trim() || tab.filePath?.split("/").filter(Boolean).pop() || "File";
30 const title = tab.topicTitle?.trim();
31 if (tab.scope === "global") return title || "Global";
32 return title || "Untitled";
33 }
34
35 function tabFullTitle(tab: TabMeta): string {
36 if (tab.tabType === "file" || tab.scope === "file") return tab.filePath || tabDisplayTitle(tab);
37 if (tab.scope === "global") {
38 const title = tabDisplayTitle(tab);
39 const workspaceName = tab.workspaceName?.trim() || "Global";
40 return title === workspaceName ? workspaceName : `${workspaceName} / ${title}`;
41 }
42 const workspaceName = tab.workspaceName?.trim() || "Project";
43 return `${workspaceName} / ${tabDisplayTitle(tab)}`;
44 }
45
46 function tabMode(tab: TabMeta): Mode {
47 return normalizeMode(tab.mode);
48 }
49
50 function projectAccentStyle(color?: string): CSSProperties | undefined {
51 const value = projectColorValue(color);
52 if (!value) return undefined;
53 return { "--project-accent": value } as CSSProperties;
54 }
55
56 export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, onOpenPalette, commandCompact = false, revealActiveSignal = 0 }: TabBarProps) {
57 const t = useT();
58 const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
59 const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null);
60 const [menuTabId, setMenuTabId] = useState<string | null>(null);
61 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
62 const suppressClickRef = useRef(false);
63 const tabRefs = useRef(new Map<string, HTMLButtonElement>());
64 const backendActiveTabId = tabs.find((tab) => tab.active)?.id;
65 const activeTabIdExists = Boolean(activeTabId && tabs.some((tab) => tab.id === activeTabId));
66 const resolvedActiveTabId = activeTabIdExists ? activeTabId : backendActiveTabId;
67 const tabOrderKey = tabs.map((tab) => tab.id).join("\u0000");
68
69 useEffect(() => {
70 if (!resolvedActiveTabId) return;
71 const frame = window.requestAnimationFrame(() => {
72 tabRefs.current.get(resolvedActiveTabId)?.scrollIntoView({
73 block: "nearest",
74 inline: "nearest",
75 });
76 });
77 return () => window.cancelAnimationFrame(frame);
78 }, [backendActiveTabId, resolvedActiveTabId, revealActiveSignal, tabOrderKey]);
79
80 const handleClose = (tabId: string) => {
81 onTabClose(tabId);
82 };
83
84 const clearDragState = () => {
85 setDraggingTabId(null);
86 setDropTarget(null);
87 };
88
89 const dropSideForEvent = (event: DragEvent<HTMLButtonElement>): DropSide => {
90 const rect = event.currentTarget.getBoundingClientRect();
91 return event.clientX > rect.left + rect.width / 2 ? "after" : "before";
92 };
93
94 const reorderTabIds = (draggedId: string, targetId: string, side: DropSide): string[] => {
95 const ids = tabs.map((tab) => tab.id);
96 const from = ids.indexOf(draggedId);
97 const target = ids.indexOf(targetId);
98 if (from < 0 || target < 0 || draggedId === targetId) return ids;
99 const next = ids.filter((id) => id !== draggedId);
100 const targetAfterRemoval = next.indexOf(targetId);
101 const insertAt = side === "after" ? targetAfterRemoval + 1 : targetAfterRemoval;
102 next.splice(insertAt, 0, draggedId);
103 return next;
104 };
105
106 const handleDragStart = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
107 setDraggingTabId(tabId);
108 setDropTarget(null);
109 event.dataTransfer.effectAllowed = "move";
110 event.dataTransfer.setData("text/plain", tabId);
111 };
112
113 const handleDragOver = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
114 if (!draggingTabId || draggingTabId === tabId) return;
115 event.preventDefault();
116 event.dataTransfer.dropEffect = "move";
117 setDropTarget({ id: tabId, side: dropSideForEvent(event) });
118 };
119
120 const handleDrop = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
121 event.preventDefault();
122 const draggedId = draggingTabId || event.dataTransfer.getData("text/plain");
123 const side = dropTarget?.id === tabId ? dropTarget.side : dropSideForEvent(event);
124 clearDragState();
125 if (!draggedId || draggedId === tabId) return;
126 const next = reorderTabIds(draggedId, tabId, side);
127 if (next.join("\u0000") !== tabs.map((tab) => tab.id).join("\u0000")) {
128 suppressClickRef.current = true;
129 onTabsReorder(next);
130 }
131 };
132
133 const handleTabClick = (tabId: string) => {
134 if (suppressClickRef.current) {
135 suppressClickRef.current = false;
136 return;
137 }
138 onTabChange(tabId);
139 };
140
141 const handleTabAuxClick = (event: ReactMouseEvent<HTMLButtonElement>, tabId: string) => {
142 // DOM MouseEvent.button: 1 is the auxiliary button, normally the middle/wheel button.
143 if (event.button !== 1) return;
144 event.preventDefault();
145 event.stopPropagation();
146 handleClose(tabId);
147 };
148
149 const openTabMenu = (event: ReactMouseEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => {
150 event.preventDefault();
151 event.stopPropagation();
152 setMenuTabId(tabId);
153 setMenuPoint(contextMenuPointFromEvent(event));
154 };
155
156 const closeTabMenu = () => {
157 setMenuTabId(null);
158 setMenuPoint(null);
159 };
160
161 const closeTabsFromMenu = (tabIds: string[], nextActiveTabId?: string) => {
162 closeTabMenu();
163 onTabsClose(tabIds, nextActiveTabId);
164 };
165
166 const menuTabIndex = menuTabId ? tabs.findIndex((tab) => tab.id === menuTabId) : -1;
167 const tabMenuItems: ContextMenuItem[] = menuTabId && menuTabIndex >= 0
168 ? [
169 {
170 key: "close-current",
171 label: t("tabBar.closeTab"),
172 disabled: tabs.length <= 1,
173 onSelect: () => closeTabsFromMenu([menuTabId]),
174 },
175 {
176 key: "close-other",
177 label: t("tabBar.closeOtherTabs"),
178 disabled: tabs.length <= 1,
179 onSelect: () => closeTabsFromMenu(tabs.filter((tab) => tab.id !== menuTabId).map((tab) => tab.id), menuTabId),
180 },
181 {
182 key: "close-right",
183 label: t("tabBar.closeTabsToRight"),
184 disabled: menuTabIndex >= tabs.length - 1,
185 onSelect: () => {
186 const rightTabIds = tabs.slice(menuTabIndex + 1).map((tab) => tab.id);
187 const nextActiveTabId = resolvedActiveTabId && rightTabIds.includes(resolvedActiveTabId) ? menuTabId : undefined;
188 closeTabsFromMenu(rightTabIds, nextActiveTabId);
189 },
190 },
191 ]
192 : [];
193
194 return (
195 <div className="tabbar">
196 <div className="tabbar__tabs">
197 {tabs.map((tab) => {
198 const displayTitle = tabDisplayTitle(tab);
199 const fullTitle = tabFullTitle(tab);
200 const mode = tabMode(tab);
201 const collaborationMode = normalizeCollaborationMode(tab.collaborationMode, tab.goal, mode);
202 const planMode = collaborationMode === "plan";
203 const goalMode = collaborationMode === "goal";
204 const toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode, mode);
205 const stateTitle = [
206 tab.running ? "Running" : "",
207 planMode ? "Plan" : "",
208 goalMode ? "Goal" : "",
209 toolApprovalMode === "auto" ? "Auto approve" : "",
210 toolApprovalMode === "yolo" ? "YOLO approval" : "",
211 ].filter(Boolean).join(" · ");
212 const annotatedTitle = stateTitle ? `${stateTitle} · ${fullTitle}` : fullTitle;
213 return (
214 <button
215 key={tab.id}
216 ref={(node) => {
217 if (node) {
218 tabRefs.current.set(tab.id, node);
219 } else {
220 tabRefs.current.delete(tab.id);
221 }
222 }}
223 draggable
224 className={[
225 "tabbar__tab",
226 tab.id === resolvedActiveTabId ? "tabbar__tab--active" : "",
227 tab.running ? "tabbar__tab--running" : "",
228 toolApprovalMode === "yolo" ? "tabbar__tab--yolo" : "",
229 draggingTabId === tab.id ? "tabbar__tab--dragging" : "",
230 dropTarget?.id === tab.id ? `tabbar__tab--drop-${dropTarget.side}` : "",
231 ].filter(Boolean).join(" ")}
232 title={annotatedTitle}
233 aria-label={annotatedTitle}
234 style={projectAccentStyle(tab.projectColor)}
235 onClick={() => handleTabClick(tab.id)}
236 onAuxClick={(event) => handleTabAuxClick(event, tab.id)}
237 onMouseDown={(event) => {
238 // Prevent the browser/webview middle-click auto-scroll before auxclick fires.
239 if (event.button === 1) event.preventDefault();
240 }}
241 onContextMenu={(event) => openTabMenu(event, tab.id)}
242 onKeyDown={(event) => {
243 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
244 openTabMenu(event, tab.id);
245 }
246 }}
247 onDragStart={(event) => handleDragStart(event, tab.id)}
248 onDragOver={(event) => handleDragOver(event, tab.id)}
249 onDrop={(event) => handleDrop(event, tab.id)}
250 onDragEnd={clearDragState}
251 >
252 {tab.tabType === "file" || tab.scope === "file" ? (
253 <FileText size={12} className="tabbar__file-icon" />
254 ) : (
255 <span
256 className={[
257 "tabbar__status",
258 tab.running ? "tabbar__status--running" : "",
259 ].filter(Boolean).join(" ")}
260 />
261 )}
262 <span className="tabbar__tab-label">{displayTitle}</span>
263 {tab.isolatedWorktree && <WorktreeBadge size={11} />}
264 {planMode && <span className="tabbar__mode-badge tabbar__mode-badge--plan">plan</span>}
265 {goalMode && <span className="tabbar__mode-badge tabbar__mode-badge--plan">goal</span>}
266 {toolApprovalMode === "auto" && <span className="tabbar__mode-badge tabbar__mode-badge--plan">auto</span>}
267 {toolApprovalMode === "yolo" && <span className="tabbar__mode-badge tabbar__mode-badge--yolo">yolo</span>}
268 <span
269 className="tabbar__tab-close"
270 onClick={(e) => {
271 e.stopPropagation();
272 handleClose(tab.id);
273 }}
274 >
275 <X size={10} />
276 </span>
277 </button>
278 );
279 })}
280 </div>
281 <Tooltip label={t("tabBar.newSession")}>
282 <button className="tabbar__new" type="button" aria-label={t("tabBar.newSession")} onClick={onNewTab}>
283 <Plus size={13} />
284 </button>
285 </Tooltip>
286 {onOpenPalette && <span className="tabbar__spacer" aria-hidden="true" />}
287 {onOpenPalette && (
288 <button
289 className={["tabbar__command", commandCompact ? "tabbar__command--compact" : ""].filter(Boolean).join(" ")}
290 type="button"
291 onClick={onOpenPalette}
292 aria-label={t("palette.placeholder")}
293 title={t("palette.placeholder")}
294 >
295 <Search size={commandCompact ? 16 : 13} className="tabbar__command-icon" />
296 {!commandCompact && (
297 <>
298 <span className="tabbar__command-text tabbar__command-text--full">{t("tabBar.commandSearch")}</span>
299 <span className="tabbar__command-text tabbar__command-text--compact">{t("tabBar.commandSearchCompact")}</span>
300 <kbd className="tabbar__command-kbd">⌘K</kbd>
301 </>
302 )}
303 </button>
304 )}
305 <ContextMenu
306 open={Boolean(menuTabId)}
307 point={menuPoint}
308 items={tabMenuItems}
309 minWidth={170}
310 ariaLabel={t("tabBar.tabActions")}
311 onClose={closeTabMenu}
312 />
313 </div>
314 );
315 }
316
316 lines Plain Text