| 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 |