From d3b7d9dfeb23d42df55c226925007f7c10f0e201 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:18:52 -0500 Subject: [PATCH] feat: add session export (Markdown, JSON, Plain Text) Add an export button to the TabBar header that lets users export the current session as Markdown, JSON, or Plain Text. The button appears between Search and Notifications, only for session tabs. - sessionExporter.ts: formatters for all three formats + download trigger - ExportDropdown.tsx: dropdown UI component with format selection - TabBar.tsx: integration with conditional rendering for session tabs - 51 new tests covering all formatters, edge cases, and download Co-Authored-By: Claude Opus 4.6 --- .../components/common/ExportDropdown.tsx | 142 ++++ src/renderer/components/layout/TabBar.tsx | 14 + src/renderer/utils/sessionExporter.ts | 427 +++++++++++ test/renderer/utils/sessionExporter.test.ts | 716 ++++++++++++++++++ 4 files changed, 1299 insertions(+) create mode 100644 src/renderer/components/common/ExportDropdown.tsx create mode 100644 src/renderer/utils/sessionExporter.ts create mode 100644 test/renderer/utils/sessionExporter.test.ts diff --git a/src/renderer/components/common/ExportDropdown.tsx b/src/renderer/components/common/ExportDropdown.tsx new file mode 100644 index 00000000..c35f71d9 --- /dev/null +++ b/src/renderer/components/common/ExportDropdown.tsx @@ -0,0 +1,142 @@ +/** + * ExportDropdown - Download icon button with dropdown for exporting session data. + * + * Supports three formats: Markdown (.md), JSON (.json), Plain Text (.txt). + * Follows the same close-on-outside-click / Escape patterns as RepositoryDropdown. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { triggerDownload } from '@renderer/utils/sessionExporter'; +import { Braces, Download, FileText, Type } from 'lucide-react'; + +import type { SessionDetail } from '@renderer/types/data'; +import type { ExportFormat } from '@renderer/utils/sessionExporter'; + +interface ExportDropdownProps { + sessionDetail: SessionDetail; +} + +interface FormatOption { + format: ExportFormat; + label: string; + icon: React.ComponentType<{ className?: string }>; + ext: string; +} + +const FORMAT_OPTIONS: FormatOption[] = [ + { format: 'markdown', label: 'Markdown', icon: FileText, ext: '.md' }, + { format: 'json', label: 'JSON', icon: Braces, ext: '.json' }, + { format: 'plaintext', label: 'Plain Text', icon: Type, ext: '.txt' }, +]; + +export const ExportDropdown = ({ + sessionDetail, +}: Readonly): React.JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const [buttonHover, setButtonHover] = useState(false); + const [hoveredFormat, setHoveredFormat] = useState(null); + const containerRef = useRef(null); + + // 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) => { + triggerDownload(sessionDetail, format); + setIsOpen(false); + }, + [sessionDetail] + ); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown menu */} + {isOpen && ( +
+ {/* Header */} +
+ Export Session +
+ + {/* Format options */} + {FORMAT_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 6748a0a5..61915d35 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -17,6 +17,8 @@ import { useStore } from '@renderer/store'; import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { ExportDropdown } from '../common/ExportDropdown'; + import { SortableTab } from './SortableTab'; import { TabContextMenu } from './TabContextMenu'; @@ -50,6 +52,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { pinnedSessionIds, toggleHideSession, hiddenSessionIds, + tabSessionData, } = useStore( useShallow((s) => ({ pane: s.paneLayout.panes.find((p) => p.id === paneId), @@ -76,6 +79,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { pinnedSessionIds: s.pinnedSessionIds, toggleHideSession: s.toggleHideSession, hiddenSessionIds: s.hiddenSessionIds, + tabSessionData: s.tabSessionData, })) ); @@ -89,6 +93,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { // Derive stable tab IDs array for SortableContext const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]); + // Derive session detail for the active tab (used by export dropdown) + const activeTabSessionDetail = activeTabId + ? (tabSessionData[activeTabId]?.sessionDetail ?? null) + : null; + // Hover states for buttons const [expandHover, setExpandHover] = useState(false); const [refreshHover, setRefreshHover] = useState(false); @@ -372,6 +381,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { + {/* Export dropdown - show only for session tabs with loaded data */} + {activeTab?.type === 'session' && activeTabSessionDetail && ( + + )} + {/* Notifications bell icon */}