From 1535a69ca58a967115a22683be7fdef2925791d8 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 23 Feb 2026 22:44:42 +0800 Subject: [PATCH] feat: add MoreMenu component for toolbar actions - Introduced MoreMenu component to consolidate less-frequent actions (Search, Export, Analyze, Settings) behind a dropdown menu. - Removed individual action buttons from TabBar for a cleaner interface. - Implemented functionality to close the menu on outside clicks and Escape key press. --- src/renderer/components/layout/MoreMenu.tsx | 216 ++++++++++++++++++++ src/renderer/components/layout/TabBar.tsx | 71 +------ 2 files changed, 224 insertions(+), 63 deletions(-) create mode 100644 src/renderer/components/layout/MoreMenu.tsx diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx new file mode 100644 index 00000000..4b30a736 --- /dev/null +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -0,0 +1,216 @@ +/** + * MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions. + * + * Groups: Search, Export (session-only), Analyze (session-only), Settings. + * Closes on outside click or Escape. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { triggerDownload } from '@renderer/utils/sessionExporter'; +import { formatShortcut } from '@renderer/utils/stringUtils'; +import { Activity, Braces, FileText, MoreHorizontal, Search, Settings, Type } from 'lucide-react'; + +import type { SessionDetail } from '@renderer/types/data'; +import type { Tab } from '@renderer/types/tabs'; +import type { ExportFormat } from '@renderer/utils/sessionExporter'; + +interface MoreMenuProps { + activeTab: Tab | undefined; + activeTabSessionDetail: SessionDetail | null; + activeTabId: string | null; +} + +interface MenuItem { + id: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; + onClick: () => void; +} + +export const MoreMenu = ({ + activeTab, + activeTabSessionDetail, + activeTabId, +}: Readonly): React.JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const [buttonHover, setButtonHover] = useState(false); + const [hoveredId, setHoveredId] = useState(null); + const containerRef = useRef(null); + + const openCommandPalette = useStore((s) => s.openCommandPalette); + const openSettingsTab = useStore((s) => s.openSettingsTab); + const openSessionReport = useStore((s) => s.openSessionReport); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen]); + + const handleExport = useCallback( + (format: ExportFormat) => { + if (activeTabSessionDetail) { + triggerDownload(activeTabSessionDetail, format); + } + setIsOpen(false); + }, + [activeTabSessionDetail] + ); + + const isSessionWithData = activeTab?.type === 'session' && activeTabSessionDetail != null; + + // Build menu sections + const topItems: MenuItem[] = [ + { + id: 'search', + label: 'Search', + icon: Search, + shortcut: formatShortcut('K'), + onClick: () => { + openCommandPalette(); + setIsOpen(false); + }, + }, + ]; + + const sessionItems: MenuItem[] = isSessionWithData + ? [ + { + id: 'export-md', + label: 'Export as Markdown', + icon: FileText, + shortcut: '.md', + onClick: () => handleExport('markdown'), + }, + { + id: 'export-json', + label: 'Export as JSON', + icon: Braces, + shortcut: '.json', + onClick: () => handleExport('json'), + }, + { + id: 'export-txt', + label: 'Export as Plain Text', + icon: Type, + shortcut: '.txt', + onClick: () => handleExport('plaintext'), + }, + { + id: 'analyze', + label: 'Analyze Session', + icon: Activity, + onClick: () => { + if (activeTabId) openSessionReport(activeTabId); + setIsOpen(false); + }, + }, + ] + : []; + + const bottomItems: MenuItem[] = [ + { + id: 'settings', + label: 'Settings', + icon: Settings, + shortcut: formatShortcut(','), + onClick: () => { + openSettingsTab(); + setIsOpen(false); + }, + }, + ]; + + const renderItem = (item: MenuItem): React.JSX.Element => ( + + ); + + const separator = ( +
+ ); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown menu */} + {isOpen && ( +
+ {topItems.map(renderItem)} + + {sessionItems.length > 0 && ( + <> + {separator} + {sessionItems.map(renderItem)} + + )} + + {separator} + {bottomItems.map(renderItem)} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index b4874b5f..52e0b099 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -15,11 +15,10 @@ import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; +import { Bell, PanelLeft, Plus, RefreshCw } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { ExportDropdown } from '../common/ExportDropdown'; - +import { MoreMenu } from './MoreMenu'; import { SortableTab } from './SortableTab'; import { TabContextMenu } from './TabContextMenu'; @@ -42,11 +41,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { openDashboard, fetchSessionDetail, fetchSessions, - openCommandPalette, unreadCount, openNotificationsTab, - openSettingsTab, - openSessionReport, sidebarCollapsed, toggleSidebar, splitPane, @@ -70,11 +66,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { openDashboard: s.openDashboard, fetchSessionDetail: s.fetchSessionDetail, fetchSessions: s.fetchSessions, - openCommandPalette: s.openCommandPalette, unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, - openSettingsTab: s.openSettingsTab, - openSessionReport: s.openSessionReport, sidebarCollapsed: s.sidebarCollapsed, toggleSidebar: s.toggleSidebar, splitPane: s.splitPane, @@ -105,10 +98,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { 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); - const [analyzeHover, setAnalyzeHover] = useState(false); // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>( @@ -383,43 +373,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { - {/* Search button (icon only) */} - - - {/* Export dropdown - show only for session tabs with loaded data */} - {activeTab?.type === 'session' && activeTabSessionDetail && ( - - )} - - {/* Analyze button - show only for session tabs with loaded data */} - {activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && ( - - )} - {/* Notifications bell icon */} - {/* Settings gear icon */} - + {/* More menu (Search, Export, Analyze, Settings) */} +
{/* Context menu */}