返回 DeepSeek-Reasonix
TabBar.tsx
1 import { useCallback, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { Plus, X } from "lucide-react";
4 import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
5 import { DOCK_ENTRY_ICONS } from "../dockEntryIcons";
6 import { useT } from "../../lib/i18n";
7 import type { TabItem } from "../../store/activityBar";
8 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "../ContextMenu";
9 import { useDockTabDrag } from "../../lib/useDockTabDrag";
10 import { TabOverviewMenu } from "./TabOverviewMenu";
11
12 interface TabBarProps {
13 tabs: TabItem[];
14 activeTabId: string | null;
15 onActivate: (tabId: string) => void;
16 onClose: (tabId: string) => void;
17 onMoveTab: (fromId: string, toId: string, side: "left" | "right") => void;
18 onAdd: () => void;
19 addButtonRef: RefObject<HTMLButtonElement | null>;
20 }
21
22 export function TabBar({ tabs, activeTabId, onActivate, onClose, onMoveTab, onAdd, addButtonRef }: TabBarProps) {
23 const t = useT();
24 const [menuTabId, setMenuTabId] = useState<string | null>(null);
25 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
26 const {
27 draggingTabId, dragElRefs, floatingRef, tabsRef, tabsOverflow, suppressClickRef, startTabDrag, clearDragState, ghost, slotWidthFor,
28 } = useDockTabDrag({ tabs, onActivate, onMoveTab });
29
30 const closeMenu = useCallback(() => {
31 setMenuTabId(null);
32 setMenuPoint(null);
33 }, []);
34
35 const openTabMenu = useCallback((event: ReactMouseEvent<HTMLDivElement>, tab: TabItem) => {
36 event.preventDefault();
37 event.stopPropagation();
38 setMenuTabId(tab.id);
39 setMenuPoint(contextMenuPointFromEvent(event));
40 }, []);
41
42 const menuTabIndex = menuTabId ? tabs.findIndex((tab) => tab.id === menuTabId) : -1;
43
44 const closeThen = useCallback((action: () => void) => {
45 closeMenu();
46 action();
47 }, [closeMenu]);
48
49 const menuItems = useCallback((): ContextMenuItem[] => {
50 const items: ContextMenuItem[] = [];
51 items.push(
52 {
53 key: "close-current",
54 label: t("tabBar.closeTab"),
55 disabled: menuTabIndex < 0,
56 onSelect: () => closeThen(() => { if (menuTabId) onClose(menuTabId); }),
57 },
58 {
59 key: "close-others",
60 label: t("tabBar.closeOtherTabs"),
61 disabled: menuTabIndex < 0 || tabs.length <= 1,
62 onSelect: () => closeThen(() => {
63 if (!menuTabId) return;
64 tabs.filter((tab) => tab.id !== menuTabId).forEach((tab) => onClose(tab.id));
65 onActivate(menuTabId);
66 }),
67 },
68 {
69 key: "close-right",
70 label: t("tabBar.closeTabsToRight"),
71 disabled: menuTabIndex < 0 || menuTabIndex >= tabs.length - 1,
72 onSelect: () => closeThen(() => {
73 if (menuTabIndex < 0) return;
74 tabs.slice(menuTabIndex + 1).forEach((tab) => onClose(tab.id));
75 onActivate(menuTabId ?? tabs[menuTabIndex].id);
76 }),
77 },
78 );
79 return items;
80 }, [closeThen, menuTabId, menuTabIndex, onActivate, onClose, t, tabs]);
81
82 return (
83 <div className="workbench-dock__tools">
84 <TabOverviewMenu />
85 <div
86 ref={tabsRef}
87 className={["workbench-dock__tabs", tabsOverflow ? "workbench-dock__tabs--overflow" : ""].filter(Boolean).join(" ")}
88 role="tablist"
89 aria-label={t("rightDock.views")}
90 >
91 {tabs.map((tab) => {
92 const TabIcon = DOCK_ENTRY_ICONS[tab.type];
93 const active = tab.id === activeTabId;
94 const dragging = draggingTabId === tab.id;
95 if (dragging) {
96 const slotWidth = slotWidthFor(tab.id);
97 return (
98 <div
99 key={tab.id}
100 className="workbench-dock__tab-slot"
101 style={{ width: slotWidth }}
102 aria-hidden="true"
103 />
104 );
105 }
106 return (
107 <div
108 key={tab.id}
109 ref={(node) => {
110 if (node) dragElRefs.current.set(tab.id, node);
111 else dragElRefs.current.delete(tab.id);
112 }}
113 role="tab"
114 aria-selected={active}
115 aria-label={tab.label}
116 className={[
117 "workbench-dock__tab",
118 active ? "workbench-dock__tab--active" : "",
119 ].filter(Boolean).join(" ")}
120 onClick={() => {
121 if (suppressClickRef.current) {
122 suppressClickRef.current = false;
123 return;
124 }
125 onActivate(tab.id);
126 }}
127 tabIndex={active ? 0 : -1}
128 onContextMenu={(event) => openTabMenu(event, tab)}
129 onKeyDown={(event) => {
130 if (event.target !== event.currentTarget) return;
131 if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onActivate(tab.id); }
132 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
133 event.preventDefault();
134 const rect = event.currentTarget.getBoundingClientRect();
135 setMenuTabId(tab.id);
136 setMenuPoint({ left: rect.left, top: rect.bottom, keyboardTarget: event.currentTarget });
137 }
138 const index = tabs.indexOf(tab);
139 const next = event.key === "ArrowRight" ? tabs[(index + 1) % tabs.length]
140 : event.key === "ArrowLeft" ? tabs[(index + tabs.length - 1) % tabs.length] : null;
141 if (next) { event.preventDefault(); onActivate(next.id); dragElRefs.current.get(next.id)?.focus(); }
142 }}
143 onPointerDown={(event) => startTabDrag(event, tab.id)}
144 >
145 <TabIcon size={13} />
146 <span className="workbench-dock__tab-label">{tab.label}</span>
147 <button
148 type="button"
149 className="workbench-dock__tab-close"
150 aria-label={t("rightDock.closeTab")}
151 onPointerDown={(event) => event.stopPropagation()}
152 onClick={(event) => {
153 event.stopPropagation();
154 onClose(tab.id);
155 }}
156 >
157 <X size={12} />
158 </button>
159 </div>
160 );
161 })}
162 </div>
163 <button
164 ref={addButtonRef}
165 type="button"
166 className="workbench-dock__tab-add"
167 aria-label={t("rightDock.addTab")}
168 onClick={onAdd}
169 >
170 <Plus size={14} />
171 </button>
172 {draggingTabId !== null && (() => {
173 const draggedTab = tabs.find((tab) => tab.id === draggingTabId);
174 if (!draggedTab) return null;
175 const FloatIcon = DOCK_ENTRY_ICONS[draggedTab.type];
176 return createPortal(
177 <div
178 ref={floatingRef}
179 className={[
180 "workbench-dock__tab",
181 "workbench-dock__tab--floating",
182 draggedTab.id === activeTabId ? "workbench-dock__tab--active" : "",
183 ].filter(Boolean).join(" ")}
184 role="presentation"
185 style={{ left: ghost.left, top: ghost.top, width: ghost.width }}
186 >
187 <FloatIcon size={13} />
188 <span className="workbench-dock__tab-label">{draggedTab.label}</span>
189 {/* Keep the whole tab (including its close button) in the drag
190 ghost so it looks like the tab itself is being dragged. */}
191 <button
192 type="button"
193 className="workbench-dock__tab-close"
194 aria-label={t("rightDock.closeTab")}
195 onClick={(event) => {
196 event.stopPropagation();
197 clearDragState();
198 onClose(draggedTab.id);
199 }}
200 >
201 <X size={12} />
202 </button>
203 </div>,
204 document.body,
205 );
206 })()}
207 <ContextMenu
208 open={menuPoint !== null}
209 point={menuPoint}
210 items={menuItems()}
211 onClose={closeMenu}
212 ariaLabel={t("rightDock.views")}
213 />
214 </div>
215 );
216 }
217
217 lines Plain Text