Files
Netcatty/components/TopTabs.tsx
T
bincxz e8be0d72de feat: add Copy Tab option to SSH session context menu
Add the ability to duplicate an SSH session by right-clicking on a tab
and selecting "Copy Tab". This creates a new session with the same
connection parameters (host, port, protocol, etc.).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:41:11 +08:00

681 lines
28 KiB
TypeScript

import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { TerminalSession, Workspace } from '../types';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
interface TopTabsProps {
theme: 'dark' | 'light';
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
workspaces: Workspace[];
logViews: LogView[];
orderedTabs: string[];
draggingSessionId: string | null;
isMacClient: boolean;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
onOpenQuickSwitcher: () => void;
onToggleTheme: () => void;
onOpenSettings: () => void;
onSyncNow?: () => Promise<void>;
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
}
const sessionStatusDot = (status: TerminalSession['status']) => {
const tone = status === 'connected'
? "bg-emerald-400"
: status === 'connecting'
? "bg-amber-400"
: "bg-rose-500";
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
};
// Custom window controls for Windows/Linux (frameless window)
const WindowControls: React.FC = memo(() => {
const { minimize, maximize, close, isMaximized: fetchIsMaximized } = useWindowControls();
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
// Check initial maximized state
fetchIsMaximized().then(v => setIsMaximized(!!v));
// Listen for window resize to update maximized state
const handleResize = () => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [fetchIsMaximized]);
const handleMinimize = () => {
minimize();
};
const handleMaximize = async () => {
const result = await maximize();
setIsMaximized(!!result);
};
const handleClose = () => {
close();
};
return (
<div className="flex items-center app-drag">
<button
onClick={handleMinimize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
// Restore icon (two overlapping squares)
<Copy size={14} />
) : (
// Maximize icon (single square)
<Square size={14} />
)}
</button>
<button
onClick={handleClose}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
title="Close"
>
<X size={16} />
</button>
</div>
);
});
WindowControls.displayName = 'WindowControls';
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
sessions,
orphanSessions,
workspaces,
logViews,
orderedTabs,
draggingSessionId,
isMacClient,
onCloseSession,
onRenameSession,
onCopySession,
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
onOpenQuickSwitcher,
onToggleTheme,
onOpenSettings,
onSyncNow,
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const activeTabId = useActiveTabId();
const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp';
const onSelectTab = activeTabStore.setActiveTabId;
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
const draggedTabIdRef = useRef<string | null>(null);
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
useEffect(() => {
if (!isMacClient) return;
let cancelled = false;
isFullscreen().then((value) => {
if (!cancelled) setIsWindowFullscreen(!!value);
});
const unsubscribe = onFullscreenChanged((value) => setIsWindowFullscreen(!!value));
return () => {
cancelled = true;
unsubscribe();
};
}, [isFullscreen, isMacClient, onFullscreenChanged]);
// Refs for scrollable tab container
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
// Check scroll state
const updateScrollState = useCallback(() => {
const container = tabsContainerRef.current;
if (container) {
const hasScroll = container.scrollWidth > container.clientWidth;
setHasOverflow(hasScroll);
setCanScrollLeft(container.scrollLeft > 0);
setCanScrollRight(container.scrollLeft < container.scrollWidth - container.clientWidth - 1);
}
}, []);
// Update scroll state on mount and resize
useEffect(() => {
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
container.addEventListener('scroll', updateScrollState);
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener('scroll', updateScrollState);
resizeObserver.disconnect();
};
}
}, [updateScrollState, orderedTabs]);
// Scroll to active tab when it changes
useLayoutEffect(() => {
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') return;
const container = tabsContainerRef.current;
if (!container) return;
// Find the active tab element
const activeTabElement = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement | null;
if (activeTabElement) {
const containerRect = container.getBoundingClientRect();
const tabRect = activeTabElement.getBoundingClientRect();
// Check if tab is outside visible area
if (tabRect.left < containerRect.left) {
container.scrollLeft -= (containerRect.left - tabRect.left + 8);
} else if (tabRect.right > containerRect.right) {
container.scrollLeft += (tabRect.right - containerRect.right + 8);
}
}
// Update scroll indicators after scroll
setTimeout(updateScrollState, 100);
}, [activeTabId, updateScrollState]);
// Pre-compute lookup maps for O(1) access instead of O(n) find operations
const orphanSessionMap = useMemo(() => {
const map = new Map<string, TerminalSession>();
for (const s of orphanSessions) map.set(s.id, s);
return map;
}, [orphanSessions]);
const workspaceMap = useMemo(() => {
const map = new Map<string, Workspace>();
for (const w of workspaces) map.set(w.id, w);
return map;
}, [workspaces]);
const logViewMap = useMemo(() => {
const map = new Map<string, LogView>();
for (const lv of logViews) map.set(lv.id, lv);
return map;
}, [logViews]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const s of sessions) {
if (s.workspaceId) {
counts.set(s.workspaceId, (counts.get(s.workspaceId) || 0) + 1);
}
}
return counts;
}, [sessions]);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
// Also set session-id for backward compatibility with workspace split functionality
// Only orphan sessions can be dragged to create workspaces
const isOrphanSession = orphanSessionMap.has(tabId);
if (isOrphanSession) {
e.dataTransfer.setData('session-id', tabId);
}
draggedTabIdRef.current = tabId;
// Use setTimeout to allow the drag image to be captured before we change styles
setTimeout(() => {
setIsDraggingForReorder(true);
}, 0);
onStartSessionDrag(tabId);
}, [orphanSessionMap, onStartSessionDrag]);
const handleTabDragEnd = useCallback(() => {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDraggingForReorder(false);
onEndSessionDrag();
}, [onEndSessionDrag]);
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
return;
}
// Determine if we're on the left or right half of the target tab
const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const position: 'before' | 'after' = e.clientX < midpoint ? 'before' : 'after';
// Always update drop indicator on drag over to ensure it doesn't get stuck
setDropIndicator({ tabId, position });
}, []);
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
// Don't clear drop indicator on drag leave - let onDragOver manage it
// This prevents the indicator from flickering/disappearing during fast drags
// The indicator will be cleared when drag ends or on drop
}, []);
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
e.preventDefault();
const draggedId = e.dataTransfer.getData('tab-reorder-id') || draggedTabIdRef.current;
if (draggedId && draggedId !== targetTabId && dropIndicator) {
onReorderTabs(draggedId, targetTabId, dropIndicator.position);
}
setDropIndicator(null);
setIsDraggingForReorder(false);
}, [dropIndicator, onReorderTabs]);
// Pre-compute tab shift styles for all tabs to avoid recalculation during render
const tabShiftStyles = useMemo(() => {
if (!dropIndicator || !isDraggingForReorder || !draggedTabIdRef.current) {
return {};
}
const styles: Record<string, React.CSSProperties> = {};
const draggedIndex = orderedTabs.indexOf(draggedTabIdRef.current);
const targetIndex = orderedTabs.indexOf(dropIndicator.tabId);
const dropIndex = dropIndicator.position === 'before' ? targetIndex : targetIndex + 1;
for (let i = 0; i < orderedTabs.length; i++) {
const tabId = orderedTabs[i];
if (tabId === draggedTabIdRef.current) continue;
if (draggedIndex < dropIndex) {
if (i > draggedIndex && i < dropIndex) {
styles[tabId] = { transform: 'translateX(-8px)' };
}
} else {
if (i >= dropIndex && i < draggedIndex) {
styles[tabId] = { transform: 'translateX(8px)' };
}
}
}
return styles;
}, [dropIndicator, isDraggingForReorder, orderedTabs]);
// Build ordered tab items using pre-computed maps for O(1) lookups
const orderedTabItems = useMemo(() => {
return orderedTabs.map((tabId) => {
const session = orphanSessionMap.get(tabId);
const workspace = workspaceMap.get(tabId);
const logView = logViewMap.get(tabId);
if (session) {
return { type: 'session' as const, id: tabId, session };
}
if (workspace) {
return { type: 'workspace' as const, id: tabId, workspace, paneCount: workspacePaneCounts.get(tabId) || 0 };
}
if (logView) {
return { type: 'logView' as const, id: tabId, logView };
}
return null;
}).filter(Boolean);
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
// Render the tabs
const renderOrderedTabs = () => {
return orderedTabItems.map((item) => {
if (!item) return null;
if (item.type === 'session') {
const session = item.session;
const isBeingDragged = draggingSessionId === session.id;
const shiftStyle = tabShiftStyles[session.id] || {};
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === session.id && dropIndicator.position === 'after';
return (
<ContextMenu key={session.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={session.id}
onClick={() => onSelectTab(session.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, session.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, session.id)}
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
activeTabId === session.id ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(activeTabId === session.id ? { borderColor: 'hsl(var(--accent))' } : {})
}}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-accent" : "text-muted-foreground")} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
</div>
<button
onClick={(e) => onCloseSession(session.id, e)}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label={t('tabs.closeSessionAria')}
>
<X size={12} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
if (item.type === 'workspace') {
const workspace = item.workspace;
const paneCount = item.paneCount;
const isActive = activeTabId === workspace.id;
const isBeingDragged = draggingSessionId === workspace.id;
const shiftStyle = tabShiftStyles[workspace.id] || {};
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
return (
<ContextMenu key={workspace.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={workspace.id}
onClick={() => onSelectTab(workspace.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, workspace.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, workspace.id)}
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(isActive ? { borderColor: 'hsl(var(--accent))' } : {})
}}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
<span className="truncate">{workspace.title}</span>
</div>
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
{paneCount}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onRenameWorkspace(workspace.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
{t('common.close')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
if (item.type === 'logView') {
const logView = item.logView;
const isActive = activeTabId === logView.id;
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return (
<div
key={logView.id}
data-tab-id={logView.id}
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
)}
style={isActive ? { borderColor: 'hsl(var(--accent))' } : {}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
<span className="truncate">
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onCloseLogView(logView.id);
}}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label={t('tabs.closeLogViewAria')}
>
<X size={12} />
</button>
</div>
);
}
return null;
});
};
// Handle double-click on titlebar to maximize/restore window (Windows/Linux)
const handleTitleBarDoubleClick = useCallback((e: React.MouseEvent) => {
// Only handle double-click on the drag region itself, not on buttons/tabs
if ((e.target as HTMLElement).closest('.app-no-drag')) return;
if (!isMacClient) {
maximize();
}
}, [isMacClient, maximize]);
return (
<div
className="relative w-full bg-secondary border-b border-border/60 app-drag"
style={dragRegionNoSelect}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-8 px-3 flex items-center gap-2 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
<div
onClick={() => onSelectTab('vault')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isVaultActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
)}
style={isVaultActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
<Shield size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isSftpActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
)}
style={isSftpActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
<Folder size={14} /> SFTP
</div>
</div>
{/* Scrollable tabs container with fade masks */}
<div
className="relative min-w-0 flex-1 flex app-drag"
style={dragRegionStyle}
// Add container-level drag handlers to prevent indicator loss
onDragOver={(e) => {
// Keep drop indicator active while dragging over the container
if (draggedTabIdRef.current && isDraggingForReorder && !dropIndicator) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
}}
>
{/* Left fade mask */}
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
/>
)}
{/* Scrollable container */}
<div
ref={tabsContainerRef}
className="flex items-center gap-2 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{renderOrderedTabs()}
{/* Add new tab button - follows last tab when not overflowing */}
{!hasOverflow && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
<Plus size={14} />
</Button>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-6 app-drag flex-shrink-0" style={dragRegionStyle} />
</div>
{/* Right fade mask */}
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
/>
)}
</div>
{/* More tabs button - only when overflowing */}
{hasOverflow && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
onClick={onOpenQuickSwitcher}
title="More tabs"
>
<MoreHorizontal size={14} />
</Button>
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag" style={dragRegionStyle}>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Bell size={16} />
</Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
onClick={onToggleTheme}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
{/* Small drag shim to the right edge */}
<div className="w-2 h-8 app-drag flex-shrink-0" />
</div>
</div>
);
};
// Custom comparison: only re-render when data props change - activeTabId is now managed internally via store subscription
const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
return (
prev.theme === next.theme &&
prev.sessions === next.sessions &&
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&
prev.orderedTabs === next.orderedTabs &&
prev.draggingSessionId === next.draggingSessionId &&
prev.isMacClient === next.isMacClient &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow
);
};
export const TopTabs = memo(TopTabsInner, topTabsAreEqual);
TopTabs.displayName = 'TopTabs';