返回 DeepSeek-Reasonix
TabOverviewMenu.tsx
根目录 / desktop / frontend / src / components / TabContainer / TabOverviewMenu.tsx
1 // TabOverviewMenu is the dock strip's leading dropdown: every open tab with
2 // when it was opened, every recently closed tab with when it was closed (click
3 // to reopen), a filter over both lists, and the close-current/others/all
4 // actions. Relative times refresh while the menu is open so a tab that was
5 // just closed does not keep reading "just now".
6 import { useEffect, useMemo, useRef, useState } from "react";
7 import { ChevronDown, Search } from "lucide-react";
8 import { useT } from "../../lib/i18n";
9 import { useActivityBarStore, type TabItem } from "../../store/activityBar";
10 import { DOCK_ENTRY_ICONS } from "../dockEntryIcons";
11
12 function relativeTime(timestamp: number, now: number, t: ReturnType<typeof useT>): string {
13 const minutes = Math.floor(Math.max(0, now - timestamp) / 60000);
14 if (minutes < 1) return t("rightDock.time.justNow");
15 if (minutes < 60) return t("rightDock.time.minutesAgo", { count: String(minutes) });
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return t("rightDock.time.hoursAgo", { count: String(hours) });
18 return t("rightDock.time.daysAgo", { count: String(Math.floor(hours / 24)) });
19 }
20
21 function matches(tab: TabItem, query: string): boolean {
22 if (!query) return true;
23 return tab.label.toLowerCase().includes(query);
24 }
25
26 export function TabOverviewMenu() {
27 const t = useT();
28 const tabs = useActivityBarStore((state) => state.tabs);
29 const activeTabId = useActivityBarStore((state) => state.activeTabId);
30 const recentlyClosed = useActivityBarStore((state) => state.recentlyClosed);
31 const activateTab = useActivityBarStore((state) => state.activateTab);
32 const closeTab = useActivityBarStore((state) => state.closeTab);
33 const reopenTab = useActivityBarStore((state) => state.reopenTab);
34 const [open, setOpen] = useState(false);
35 const [query, setQuery] = useState("");
36 const [now, setNow] = useState(() => Date.now());
37 const rootRef = useRef<HTMLDivElement | null>(null);
38 const searchRef = useRef<HTMLInputElement | null>(null);
39
40 useEffect(() => {
41 if (!open) return;
42 setNow(Date.now());
43 const timer = window.setInterval(() => setNow(Date.now()), 60000);
44 return () => window.clearInterval(timer);
45 }, [open]);
46
47 useEffect(() => {
48 if (!open) return;
49 const onPointerDown = (event: PointerEvent) => {
50 if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) setOpen(false);
51 };
52 const onKeyDown = (event: KeyboardEvent) => {
53 if (event.key === "Escape") setOpen(false);
54 };
55 window.addEventListener("pointerdown", onPointerDown, true);
56 window.addEventListener("keydown", onKeyDown);
57 return () => {
58 window.removeEventListener("pointerdown", onPointerDown, true);
59 window.removeEventListener("keydown", onKeyDown);
60 };
61 }, [open]);
62
63 const needle = query.trim().toLowerCase();
64 const visibleTabs = useMemo(() => tabs.filter((tab) => matches(tab, needle)), [needle, tabs]);
65 const visibleClosed = useMemo(
66 () => recentlyClosed.filter((record) => matches(record.tab, needle)),
67 [needle, recentlyClosed],
68 );
69
70 const toggle = () => {
71 const next = !open;
72 setOpen(next);
73 setQuery("");
74 if (next) window.setTimeout(() => searchRef.current?.focus(), 0);
75 };
76
77 const row = (tab: TabItem, active: boolean, trailing: string, onSelect: () => void) => {
78 const Icon = DOCK_ENTRY_ICONS[tab.type];
79 return (
80 <button
81 key={tab.id}
82 type="button"
83 role="menuitem"
84 className={`tab-overview__row${active ? " tab-overview__row--active" : ""}`}
85 onClick={onSelect}
86 >
87 <Icon size={14} className="tab-overview__row-icon" />
88 <span className="tab-overview__row-label">{tab.label}</span>
89 <span className="tab-overview__row-time">{trailing}</span>
90 </button>
91 );
92 };
93
94 return (
95 <div className="tab-overview" ref={rootRef}>
96 <button
97 type="button"
98 className={`workbench-dock__tab-overview${open ? " workbench-dock__tab-overview--open" : ""}`}
99 aria-label={t("rightDock.tabOverview")}
100 aria-expanded={open}
101 onClick={toggle}
102 >
103 <ChevronDown size={14} />
104 </button>
105 {open && (
106 <div className="tab-overview__menu" role="menu" aria-label={t("rightDock.tabOverview")}>
107 <div className="tab-overview__search">
108 <Search size={13} />
109 <input
110 ref={searchRef}
111 type="text"
112 value={query}
113 placeholder={t("rightDock.searchTabs")}
114 onChange={(event) => setQuery(event.target.value)}
115 />
116 </div>
117 {visibleTabs.length === 0 && visibleClosed.length === 0 ? (
118 <div className="tab-overview__note">{t("rightDock.noTabsFound")}</div>
119 ) : null}
120 {visibleTabs.length > 0 ? (
121 <>
122 <div className="tab-overview__section">{t("rightDock.openTabs")}</div>
123 {visibleTabs.map((tab) => row(tab, tab.id === activeTabId,
124 tab.openedAt ? t("rightDock.openedRelativeTime", { time: relativeTime(tab.openedAt, now, t) }) : "",
125 () => { activateTab(tab.id); setOpen(false); }))}
126 </>
127 ) : null}
128 {visibleClosed.length > 0 ? (
129 <>
130 <div className="tab-overview__section">{t("rightDock.recentlyClosedTabs")}</div>
131 {visibleClosed.map((record) => row(record.tab, false,
132 t("rightDock.closedRelativeTime", { time: relativeTime(record.closedAt, now, t) }),
133 () => { reopenTab(record.tab.id); setOpen(false); }))}
134 </>
135 ) : null}
136 <div className="tab-overview__actions">
137 <button type="button" role="menuitem" disabled={!activeTabId}
138 onClick={() => { if (activeTabId) closeTab(activeTabId); setOpen(false); }}>
139 {t("rightDock.closeCurrentTab")}
140 </button>
141 <button type="button" role="menuitem" disabled={tabs.length <= 1}
142 onClick={() => {
143 tabs.filter((tab) => tab.id !== activeTabId).forEach((tab) => closeTab(tab.id));
144 setOpen(false);
145 }}>
146 {t("rightDock.closeOtherTabs")}
147 </button>
148 <button type="button" role="menuitem" disabled={tabs.length === 0}
149 onClick={() => { tabs.forEach((tab) => closeTab(tab.id)); setOpen(false); }}>
150 {t("rightDock.closeAllTabs")}
151 </button>
152 </div>
153 </div>
154 )}
155 </div>
156 );
157 }
158
158 lines Plain Text