446 lines · 15.8 KB · TypeScript
RawDownload
/** * TabBar - Displays open tabs with close buttons and action buttons. * Accepts a paneId prop to scope to a specific pane's tabs. * Supports tab switching, closing, horizontal scrolling on overflow, * right-click context menu, middle-click to close, Shift/Ctrl+click multi-select, * and drag-and-drop reordering/cross-pane movement via @dnd-kit. * When sidebar is collapsed, shows expand button on the left with macOS traffic light spacing. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDroppable } from '@dnd-kit/core';import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';import { isElectronMode } from '@renderer/api';import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';import { useStore } from '@renderer/store';import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react';import { useShallow } from 'zustand/react/shallow'; import { SortableTab } from './SortableTab';import { TabContextMenu } from './TabContextMenu'; interface TabBarProps {  paneId: string;} export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {  const {    pane,    isFocused,    paneCount,    setActiveTab,    closeTab,    closeOtherTabs,    closeAllTabs,    closeTabs,    setSelectedTabIds,    clearTabSelection,    openDashboard,    fetchSessionDetail,    fetchSessions,    openCommandPalette,    unreadCount,    openNotificationsTab,    openSettingsTab,    sidebarCollapsed,    toggleSidebar,    splitPane,    togglePinSession,    pinnedSessionIds,    toggleHideSession,    hiddenSessionIds,  } = useStore(    useShallow((s) => ({      pane: s.paneLayout.panes.find((p) => p.id === paneId),      isFocused: s.paneLayout.focusedPaneId === paneId,      paneCount: s.paneLayout.panes.length,      setActiveTab: s.setActiveTab,      closeTab: s.closeTab,      closeOtherTabs: s.closeOtherTabs,      closeAllTabs: s.closeAllTabs,      closeTabs: s.closeTabs,      setSelectedTabIds: s.setSelectedTabIds,      clearTabSelection: s.clearTabSelection,      openDashboard: s.openDashboard,      fetchSessionDetail: s.fetchSessionDetail,      fetchSessions: s.fetchSessions,      openCommandPalette: s.openCommandPalette,      unreadCount: s.unreadCount,      openNotificationsTab: s.openNotificationsTab,      openSettingsTab: s.openSettingsTab,      sidebarCollapsed: s.sidebarCollapsed,      toggleSidebar: s.toggleSidebar,      splitPane: s.splitPane,      togglePinSession: s.togglePinSession,      pinnedSessionIds: s.pinnedSessionIds,      toggleHideSession: s.toggleHideSession,      hiddenSessionIds: s.hiddenSessionIds,    }))  );   const openTabs = useMemo(() => pane?.tabs ?? [], [pane?.tabs]);  const activeTabId = pane?.activeTabId ?? null;  const selectedTabIds = useMemo(() => pane?.selectedTabIds ?? [], [pane?.selectedTabIds]);   // Derive Set for O(1) lookups  const selectedSet = useMemo(() => new Set(selectedTabIds), [selectedTabIds]);   // Derive stable tab IDs array for SortableContext  const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]);   // Hover states for buttons  const [expandHover, setExpandHover] = useState(false);  const [refreshHover, setRefreshHover] = useState(false);  const [newTabHover, setNewTabHover] = useState(false);  const [searchHover, setSearchHover] = useState(false);  const [notificationsHover, setNotificationsHover] = useState(false);  const [settingsHover, setSettingsHover] = useState(false);   // Context menu state  const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(    null  );   // Track last clicked tab for Shift range selection  const lastClickedTabIdRef = useRef<string | null>(null);   // Get the active tab  const activeTab = openTabs.find((tab) => tab.id === activeTabId);   // Refs for auto-scrolling to active tab  const tabRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());  const scrollContainerRef = useRef<HTMLDivElement | null>(null);   // Make the tab bar area droppable for cross-pane drops  const { setNodeRef: setDroppableRef, isOver: isDroppableOver } = useDroppable({    id: `tabbar-${paneId}`,    data: {      type: 'tabbar',      paneId,    },  });   // Auto-scroll to active tab when it changes  useEffect(() => {    if (!activeTabId) return;     const tabElement = tabRefsMap.current.get(activeTabId);    if (tabElement && scrollContainerRef.current) {      tabElement.scrollIntoView({        behavior: 'smooth',        block: 'nearest',        inline: 'nearest',      });    }  }, [activeTabId]);   // Clear selection on Escape  useEffect(() => {    const handleKeyDown = (e: KeyboardEvent): void => {      if (e.key === 'Escape' && selectedTabIds.length > 0) {        clearTabSelection();      }    };    document.addEventListener('keydown', handleKeyDown);    return () => document.removeEventListener('keydown', handleKeyDown);  }, [selectedTabIds.length, clearTabSelection]);   // Handle tab click with multi-select support  const handleTabClick = useCallback(    (tabId: string, e: React.MouseEvent) => {      const isMeta = e.metaKey || e.ctrlKey;      const isShift = e.shiftKey;       if (isMeta) {        // Ctrl/Cmd+click: toggle tab in selection        if (selectedSet.has(tabId)) {          setSelectedTabIds(selectedTabIds.filter((id) => id !== tabId));        } else {          setSelectedTabIds([...selectedTabIds, tabId]);        }        lastClickedTabIdRef.current = tabId;        return;      }       if (isShift && lastClickedTabIdRef.current) {        // Shift+click: range selection from last clicked to current        const lastIndex = openTabs.findIndex((t) => t.id === lastClickedTabIdRef.current);        const currentIndex = openTabs.findIndex((t) => t.id === tabId);        if (lastIndex !== -1 && currentIndex !== -1) {          const start = Math.min(lastIndex, currentIndex);          const end = Math.max(lastIndex, currentIndex);          const rangeIds = openTabs.slice(start, end + 1).map((t) => t.id);          // Merge with existing selection          const merged = new Set([...selectedTabIds, ...rangeIds]);          setSelectedTabIds([...merged]);        }        return;      }       // Plain click: clear selection, switch tab      clearTabSelection();      lastClickedTabIdRef.current = tabId;      setActiveTab(tabId);    },    [openTabs, selectedTabIds, selectedSet, setActiveTab, setSelectedTabIds, clearTabSelection]  );   // Middle-click to close + prevent text selection on Shift/Cmd click  const handleMouseDown = useCallback(    (tabId: string, e: React.MouseEvent) => {      if (e.button === 1) {        e.preventDefault();        closeTab(tabId);        return;      }      // Prevent native text selection when Shift or Cmd/Ctrl clicking tabs      if (e.button === 0 && (e.shiftKey || e.metaKey || e.ctrlKey)) {        e.preventDefault();      }    },    [closeTab]  );   // Right-click context menu  const handleContextMenu = useCallback((tabId: string, e: React.MouseEvent) => {    e.preventDefault();    setContextMenu({ x: e.clientX, y: e.clientY, tabId });  }, []);   // Handle refresh for active session tab  const handleRefresh = async (): Promise<void> => {    if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) {      await Promise.all([        fetchSessionDetail(activeTab.projectId, activeTab.sessionId, activeTabId ?? undefined),        fetchSessions(activeTab.projectId),      ]);    }  };   // Ref setter for SortableTab  const setTabRef = useCallback((tabId: string, el: HTMLDivElement | null) => {    if (el) {      tabRefsMap.current.set(tabId, el);    } else {      tabRefsMap.current.delete(tabId);    }  }, []);   // Context menu helpers  const contextMenuTabId = contextMenu?.tabId ?? null;  const effectiveSelectedCount =    contextMenuTabId && selectedSet.has(contextMenuTabId) ? selectedTabIds.length : 0;   // Pin state for context menu tab  const contextMenuTab = contextMenuTabId ? openTabs.find((t) => t.id === contextMenuTabId) : null;  const isContextMenuTabSession = contextMenuTab?.type === 'session';  const isContextMenuTabPinned =    isContextMenuTabSession && contextMenuTab?.sessionId      ? pinnedSessionIds.includes(contextMenuTab.sessionId)      : false;  const isContextMenuTabHidden =    isContextMenuTabSession && contextMenuTab?.sessionId      ? hiddenSessionIds.includes(contextMenuTab.sessionId)      : false;   // Show sidebar expand button only in the leftmost pane  const isLeftmostPane = useStore(    (s) => s.paneLayout.panes.length === 0 || s.paneLayout.panes[0]?.id === paneId  );   return (    <div      className="flex items-center justify-between pr-2"      style={        {          height: `${HEADER_ROW1_HEIGHT}px`,          paddingLeft:            sidebarCollapsed && isLeftmostPane              ? 'var(--macos-traffic-light-padding-left, 72px)'              : '8px',          WebkitAppRegion:            isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined,          backgroundColor: 'var(--color-surface)',          borderBottom: '1px solid var(--color-border)',          opacity: isFocused || paneCount === 1 ? 1 : 0.7,        } as React.CSSProperties      }    >      {/* Expand sidebar button - show when collapsed (only in leftmost pane) */}      {sidebarCollapsed && isLeftmostPane && (        <button          onClick={toggleSidebar}          onMouseEnter={() => setExpandHover(true)}          onMouseLeave={() => setExpandHover(false)}          className="mr-2 shrink-0 rounded-md p-1.5 transition-colors"          style={            {              WebkitAppRegion: 'no-drag',              color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',              backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',            } as React.CSSProperties          }          title="Expand sidebar"        >          <PanelLeft className="size-4" />        </button>      )}       {/* Tab list with horizontal scroll, sortable DnD, and droppable area */}      <div        ref={(el) => {          scrollContainerRef.current = el;          setDroppableRef(el);        }}        className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"        style={          {            WebkitAppRegion: 'no-drag',            outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',            outlineOffset: '-1px',          } as React.CSSProperties        }      >        <SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>          {openTabs.map((tab) => (            <SortableTab              key={tab.id}              tab={tab}              paneId={paneId}              isActive={tab.id === activeTabId}              isSelected={selectedSet.has(tab.id)}              onTabClick={handleTabClick}              onMouseDown={handleMouseDown}              onContextMenu={handleContextMenu}              onClose={closeTab}              setRef={setTabRef}            />          ))}        </SortableContext>         {/* Refresh button - show only for session tabs */}        {activeTab?.type === 'session' && (          <button            className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"            style={{              color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',              backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',            }}            onMouseEnter={() => setRefreshHover(true)}            onMouseLeave={() => setRefreshHover(false)}            onClick={handleRefresh}            title="Refresh Session (Cmd+R)"          >            <RefreshCw className="size-4" />          </button>        )}      </div>       {/* Right side actions */}      <div        className="ml-2 flex shrink-0 items-center gap-1"        style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}      >        {/* New tab button */}        <button          onClick={openDashboard}          onMouseEnter={() => setNewTabHover(true)}          onMouseLeave={() => setNewTabHover(false)}          className="rounded-md p-2 transition-colors"          style={{            color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',            backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',          }}          title="New tab (Dashboard)"        >          <Plus className="size-4" />        </button>         {/* Search button (icon only) */}        <button          onClick={openCommandPalette}          onMouseEnter={() => setSearchHover(true)}          onMouseLeave={() => setSearchHover(false)}          className="rounded-md p-2 transition-colors"          style={{            color: searchHover ? 'var(--color-text)' : 'var(--color-text-muted)',            backgroundColor: searchHover ? 'var(--color-surface-raised)' : 'transparent',          }}          title="Search (Cmd+K)"        >          <Search className="size-4" />        </button>         {/* Notifications bell icon */}        <button          onClick={openNotificationsTab}          onMouseEnter={() => setNotificationsHover(true)}          onMouseLeave={() => setNotificationsHover(false)}          className="relative rounded-md p-2 transition-colors"          style={{            color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',            backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',          }}          title="Notifications"        >          <Bell className="size-4" />          {unreadCount > 0 && (            <span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">              {unreadCount > 99 ? '99+' : unreadCount}            </span>          )}        </button>         {/* Settings gear icon */}        <button          onClick={() => openSettingsTab()}          onMouseEnter={() => setSettingsHover(true)}          onMouseLeave={() => setSettingsHover(false)}          className="rounded-md p-2 transition-colors"          style={{            color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',            backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',          }}          title="Settings"        >          <Settings className="size-4" />        </button>      </div>       {/* Context menu */}      {contextMenu && contextMenuTabId && (        <TabContextMenu          x={contextMenu.x}          y={contextMenu.y}          tabId={contextMenuTabId}          paneId={paneId}          selectedCount={effectiveSelectedCount}          onClose={() => setContextMenu(null)}          onCloseTab={() => closeTab(contextMenuTabId)}          onCloseOtherTabs={() => closeOtherTabs(contextMenuTabId)}          onCloseAllTabs={() => closeAllTabs()}          onCloseSelectedTabs={            effectiveSelectedCount > 1 ? () => closeTabs([...selectedTabIds]) : undefined          }          onSplitRight={() => splitPane(paneId, contextMenuTabId, 'right')}          onSplitLeft={() => splitPane(paneId, contextMenuTabId, 'left')}          disableSplit={paneCount >= 4}          isSessionTab={isContextMenuTabSession}          isPinned={isContextMenuTabPinned}          onTogglePin={            isContextMenuTabSession && contextMenuTab?.sessionId              ? () => togglePinSession(contextMenuTab.sessionId!)              : undefined          }          isHidden={isContextMenuTabHidden}          onToggleHide={            isContextMenuTabSession && contextMenuTab?.sessionId              ? () => toggleHideSession(contextMenuTab.sessionId!)              : undefined          }        />      )}    </div>  );};