import React, { useRef, useState, useEffect, useCallback } from 'react'; import { useAppStore, Tab } from '../../store'; import './TabBar.css'; const MAX_CHAT_TITLE_LENGTH = 18; function getGitDiffResource(tabId: string): string { return tabId.startsWith('git-diff:') ? tabId.slice('git-diff:'.length) : tabId; } function getCommitHashFromGitDiffTabId(tabId: string): string | null { const resource = getGitDiffResource(tabId); if (!resource.startsWith('commit:')) { return null; } return resource.slice('commit:'.length); } const getTabTitle = ( tab: Tab, postTitles: Map, media: { id: string; originalName: string }[], chatTitles: Map, importDefTitles: Map, commitTitles: Map ): string => { if (tab.type === 'git-diff') { const filePath = getGitDiffResource(tab.id); const commitHash = getCommitHashFromGitDiffTabId(tab.id); if (commitHash) { const commitTitle = commitTitles.get(commitHash); if (commitTitle) { return commitTitle; } return `Commit ${commitHash.slice(0, 7)}`; } const filename = filePath.split('/').pop(); return filename || filePath; } if (tab.type === 'settings') { return 'Settings'; } if (tab.type === 'tags') { return 'Tags'; } if (tab.type === 'post') { return postTitles.get(tab.id) || 'Loading...'; } if (tab.type === 'media') { const mediaItem = media.find(m => m.id === tab.id); return mediaItem?.originalName || 'Media'; } if (tab.type === 'chat') { const title = chatTitles.get(tab.id); if (title && title !== 'New Chat') { // Truncate long titles for display return title.length > MAX_CHAT_TITLE_LENGTH ? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…' : title; } return 'New Chat'; } if (tab.type === 'import') { return importDefTitles.get(tab.id) || 'Import'; } if (tab.type === 'metadata-diff') { return 'Metadata Diff'; } return 'Unknown'; }; const getTabIcon = (tab: Tab): React.ReactNode => { switch (tab.type) { case 'git-diff': return ( ); case 'post': return ( ); case 'media': return ( ); case 'settings': return ( ); case 'tags': return ( ); case 'chat': return ( ); case 'import': return ( ); case 'metadata-diff': return ( ); default: return ( ); } }; const CloseIcon: React.FC = () => ( ); const ChevronLeftIcon: React.FC = () => ( ); const ChevronRightIcon: React.FC = () => ( ); export const TabBar: React.FC = () => { const { tabs, activeTabId, media, activeProject, dirtyPosts, toggleSidebar, setActiveTab, closeTab, pinTab, } = useAppStore(); const tabsContainerRef = useRef(null); const [showLeftArrow, setShowLeftArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(false); const [postTitles, setPostTitles] = useState>(new Map()); const [chatTitles, setChatTitles] = useState>(new Map()); const [importDefTitles, setImportDefTitles] = useState>(new Map()); const [commitTitles, setCommitTitles] = useState>(new Map()); // Fetch post titles from database for post tabs useEffect(() => { const postTabs = tabs.filter(t => t.type === 'post'); if (postTabs.length === 0) return; const fetchTitles = async () => { const newTitles = new Map(postTitles); let changed = false; for (const tab of postTabs) { if (!postTitles.has(tab.id)) { try { const post = await window.electronAPI?.posts.get(tab.id); if (post) { newTitles.set(tab.id, post.title || 'Untitled'); changed = true; } } catch (error) { console.error('Failed to fetch post title:', error); } } } if (changed) { setPostTitles(newTitles); } }; fetchTitles(); }, [tabs]); // Note: intentionally not including postTitles to avoid infinite loops // Listen for post updates to refresh titles useEffect(() => { const unsub = window.electronAPI?.on('post-updated', (...args: unknown[]) => { const post = args[0] as { id: string; title: string } | undefined; if (post) { setPostTitles(prev => { const newTitles = new Map(prev); newTitles.set(post.id, post.title || 'Untitled'); return newTitles; }); } }); return () => { unsub?.(); }; }, []); // Fetch chat titles for chat tabs useEffect(() => { const chatTabs = tabs.filter(t => t.type === 'chat'); if (chatTabs.length === 0) return; // Fetch titles for chat tabs that don't have a title yet const fetchTitles = async () => { const newTitles = new Map(chatTitles); for (const tab of chatTabs) { if (!chatTitles.has(tab.id)) { try { const conversation = await window.electronAPI?.chat.getConversation(tab.id); if (conversation) { newTitles.set(tab.id, conversation.title); } } catch (error) { console.error('Failed to fetch chat title:', error); } } } if (newTitles.size !== chatTitles.size) { setChatTitles(newTitles); } }; fetchTitles(); }, [tabs]); // Note: intentionally not including chatTitles to avoid infinite loops // Listen for chat title updates useEffect(() => { const unsub = window.electronAPI?.chat.onTitleUpdated((data) => { setChatTitles(prev => { const newTitles = new Map(prev); newTitles.set(data.conversationId, data.title); return newTitles; }); }); return () => { unsub?.(); }; }, []); // Fetch import definition titles for import tabs useEffect(() => { const importTabs = tabs.filter(t => t.type === 'import'); if (importTabs.length === 0) return; const fetchTitles = async () => { const newTitles = new Map(importDefTitles); for (const tab of importTabs) { if (!importDefTitles.has(tab.id)) { try { const def = await window.electronAPI?.importDefinitions.get(tab.id); if (def) { newTitles.set(tab.id, def.name); } } catch (error) { console.error('Failed to fetch import definition title:', error); } } } if (newTitles.size !== importDefTitles.size) { setImportDefTitles(newTitles); } }; fetchTitles(); }, [tabs]); // Note: intentionally not including importDefTitles to avoid infinite loops // Listen for import definition name updates useEffect(() => { const unsub = window.electronAPI?.importDefinitions.onNameUpdated((data) => { setImportDefTitles(prev => { const newTitles = new Map(prev); newTitles.set(data.definitionId, data.name); return newTitles; }); }); return () => { unsub?.(); }; }, []); // Fetch commit subjects for commit-based git-diff tabs useEffect(() => { const commitHashes = tabs .filter((tab) => tab.type === 'git-diff') .map((tab) => getCommitHashFromGitDiffTabId(tab.id)) .filter((hash): hash is string => Boolean(hash)); if (commitHashes.length === 0 || !activeProject) { return; } const missingHashes = commitHashes.filter((hash) => !commitTitles.has(hash)); if (missingHashes.length === 0) { return; } let cancelled = false; const fetchCommitTitles = async () => { try { const projectPath = activeProject.dataPath ? activeProject.dataPath : await window.electronAPI?.app.getDefaultProjectPath(activeProject.id); if (!projectPath) { return; } const history = await window.electronAPI?.git.getHistory(projectPath, 200); if (!history || cancelled) { return; } setCommitTitles((previous) => { const updated = new Map(previous); let changed = false; for (const hash of missingHashes) { const match = history.find((entry) => entry.hash === hash); if (match) { updated.set(hash, `${match.shortHash} ${match.subject}`); changed = true; } } return changed ? updated : previous; }); } catch (error) { console.error('Failed to fetch commit titles:', error); } }; void fetchCommitTitles(); return () => { cancelled = true; }; }, [tabs, activeProject]); // Check if arrows are needed based on scroll position const updateArrowVisibility = useCallback(() => { const container = tabsContainerRef.current; if (!container) return; const { scrollLeft, scrollWidth, clientWidth } = container; setShowLeftArrow(scrollLeft > 0); setShowRightArrow(scrollLeft + clientWidth < scrollWidth - 1); }, []); // Update arrow visibility on scroll or resize useEffect(() => { const container = tabsContainerRef.current; if (!container) return; updateArrowVisibility(); container.addEventListener('scroll', updateArrowVisibility); const resizeObserver = new ResizeObserver(updateArrowVisibility); resizeObserver.observe(container); return () => { container.removeEventListener('scroll', updateArrowVisibility); resizeObserver.disconnect(); }; }, [updateArrowVisibility, tabs]); // Scroll to active tab when it changes useEffect(() => { if (!activeTabId || !tabsContainerRef.current) return; const container = tabsContainerRef.current; const activeTab = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement; if (activeTab) { const containerRect = container.getBoundingClientRect(); const tabRect = activeTab.getBoundingClientRect(); if (tabRect.left < containerRect.left) { container.scrollLeft -= containerRect.left - tabRect.left + 10; } else if (tabRect.right > containerRect.right) { container.scrollLeft += tabRect.right - containerRect.right + 10; } } }, [activeTabId]); // Keyboard shortcut handler (Ctrl+W to close active tab, Ctrl+B to toggle sidebar) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'w') { e.preventDefault(); if (activeTabId) { closeTab(activeTabId); } } if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault(); toggleSidebar(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeTabId, closeTab, toggleSidebar]); const handleTabClick = (tabId: string) => { setActiveTab(tabId); }; const handleTabDoubleClick = (tab: Tab) => { // Double-click on transient tab pins it if (tab.isTransient) { pinTab(tab.id); } }; const handleTabClose = (e: React.MouseEvent, tabId: string) => { e.stopPropagation(); closeTab(tabId); }; const handleMiddleClick = (e: React.MouseEvent, tabId: string) => { // Middle-click closes the tab if (e.button === 1) { e.preventDefault(); closeTab(tabId); } }; const scrollLeft = () => { const container = tabsContainerRef.current; if (container) { container.scrollBy({ left: -150, behavior: 'smooth' }); } }; const scrollRight = () => { const container = tabsContainerRef.current; if (container) { container.scrollBy({ left: 150, behavior: 'smooth' }); } }; return (
{showLeftArrow && ( )}
{tabs.map((tab) => { const isActive = tab.id === activeTabId; const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id); const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles); const icon = getTabIcon(tab); return (
handleTabClick(tab.id)} onDoubleClick={() => handleTabDoubleClick(tab)} onMouseDown={(e) => handleMiddleClick(e, tab.id)} title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`} > {icon} {title}
{isDirty && }
); })}
{showRightArrow && ( )}
); }; export default TabBar;