| 1 | // TabAddMenu is the dropdown opened by the + button in the dock's tab bar. |
| 2 | // It lists the addable tab types (files / changed / overview / instructions); |
| 3 | // selecting one appends that tab and activates it. Every type can be added |
| 4 | // repeatedly, so the list shows no current-selection checkmark. Other panel |
| 5 | // types (terminal, browser, remote) are not exposed yet. |
| 6 | |
| 7 | import { useEffect, useLayoutEffect, useRef } from "react"; |
| 8 | import type { RefObject } from "react"; |
| 9 | import { useT } from "../../lib/i18n"; |
| 10 | import { Activity, Compass, FileDiff, FileText } from "lucide-react"; |
| 11 | import type { ComponentType } from "react"; |
| 12 | import { desktopHost } from "../../lib/desktopHost"; |
| 13 | import type { TabType } from "../../store/activityBar"; |
| 14 | |
| 15 | interface AddableTab { |
| 16 | type: TabType; |
| 17 | labelKey: string; |
| 18 | icon: ComponentType<{ size?: number | string; className?: string }>; |
| 19 | } |
| 20 | |
| 21 | const ADDABLE_TABS: AddableTab[] = [ |
| 22 | { type: "file", labelKey: "workspace.filesTab", icon: FileText }, |
| 23 | { type: "changed", labelKey: "workspace.changedTab", icon: FileDiff }, |
| 24 | { type: "context", labelKey: "rightDock.overview", icon: Activity }, |
| 25 | ]; |
| 26 | |
| 27 | const BROWSER_TAB: AddableTab = { type: "browser", labelKey: "rightDock.browser", icon: Compass }; |
| 28 | |
| 29 | interface TabAddMenuProps { |
| 30 | onPick: (type: TabType, label: string) => void; |
| 31 | onClose: () => void; |
| 32 | /** The + button this menu anchors to (opens under it, left-aligned). */ |
| 33 | anchorRef: RefObject<HTMLButtonElement | null>; |
| 34 | } |
| 35 | |
| 36 | export function TabAddMenu({ onPick, onClose, anchorRef }: TabAddMenuProps) { |
| 37 | const t = useT(); |
| 38 | const menuRef = useRef<HTMLDivElement>(null); |
| 39 | // Anchor the menu under the + button, its left edge aligned to the button's. |
| 40 | // Coordinates are relative to the dock; when the menu would overflow the |
| 41 | // dock (narrow dock / window near the screen edge), flip the alignment |
| 42 | // inward so it stays fully visible. |
| 43 | useLayoutEffect(() => { |
| 44 | const el = menuRef.current; |
| 45 | const anchor = anchorRef.current; |
| 46 | const dock = el?.closest(".tab-container"); |
| 47 | if (!el || !anchor || !dock) return; |
| 48 | const menuRect = el.getBoundingClientRect(); |
| 49 | const anchorRect = anchor.getBoundingClientRect(); |
| 50 | const dockRect = dock.getBoundingClientRect(); |
| 51 | const gap = 4; |
| 52 | let left = anchorRect.left - dockRect.left; |
| 53 | let top = anchorRect.bottom - dockRect.top + gap; |
| 54 | if (left + menuRect.width > dockRect.width - gap) { |
| 55 | left = dockRect.width - menuRect.width - gap; |
| 56 | } |
| 57 | if (top + menuRect.height > dockRect.height - gap) { |
| 58 | top = Math.max(gap, anchorRect.top - dockRect.top - menuRect.height - gap); |
| 59 | } |
| 60 | el.style.left = `${left}px`; |
| 61 | el.style.top = `${top}px`; |
| 62 | }, [anchorRef]); |
| 63 | |
| 64 | // Close on outside click (anywhere except the menu itself or the + button) |
| 65 | // or Escape. The menu is only mounted while open, so listeners die with it. |
| 66 | useEffect(() => { |
| 67 | const onPointerDown = (event: PointerEvent) => { |
| 68 | const target = event.target; |
| 69 | if (!(target instanceof Node)) return; |
| 70 | if (menuRef.current?.contains(target)) return; |
| 71 | if (anchorRef.current?.contains(target)) return; |
| 72 | onClose(); |
| 73 | }; |
| 74 | const onKeyDown = (event: KeyboardEvent) => { |
| 75 | if (event.key === "Escape") onClose(); |
| 76 | }; |
| 77 | window.addEventListener("pointerdown", onPointerDown); |
| 78 | window.addEventListener("keydown", onKeyDown); |
| 79 | return () => { |
| 80 | window.removeEventListener("pointerdown", onPointerDown); |
| 81 | window.removeEventListener("keydown", onKeyDown); |
| 82 | }; |
| 83 | }, [anchorRef, onClose]); |
| 84 | |
| 85 | return ( |
| 86 | <div className="tab-add-menu" ref={menuRef} role="menu" aria-label={t("rightDock.addTab")}> |
| 87 | {(desktopHost().browser !== undefined ? [...ADDABLE_TABS, BROWSER_TAB] : ADDABLE_TABS).map((item) => ( |
| 88 | <button |
| 89 | key={item.type} |
| 90 | type="button" |
| 91 | role="menuitem" |
| 92 | className="tab-add-menu__item" |
| 93 | onClick={() => { |
| 94 | onPick(item.type, t(item.labelKey as never)); |
| 95 | onClose(); |
| 96 | }} |
| 97 | > |
| 98 | <item.icon size={14} /> |
| 99 | <span>{t(item.labelKey as never)}</span> |
| 100 | </button> |
| 101 | ))} |
| 102 | </div> |
| 103 | ); |
| 104 | } |
| 105 |