870 lines
34 KiB
TypeScript
870 lines
34 KiB
TypeScript
import { Copy, FileCode, FileText, LayoutGrid, Minus, Server, Square, TerminalSquare, Usb, X } from 'lucide-react';
|
|
import React, { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
import { activeTabStore, useActiveTabId, useIsTabActive } from '../../application/state/activeTabStore';
|
|
import type { EditorTab } from '../../application/state/editorTabStore';
|
|
import type { LogView } from '../../application/state/logViewState';
|
|
import { useWindowControls } from '../../application/state/useWindowControls';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import { getEffectiveHostDistro } from '../../domain/host';
|
|
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon';
|
|
import { cn } from '../../lib/utils';
|
|
import { Host, TerminalSession, Workspace } from '../../types';
|
|
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
|
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
|
|
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
|
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
|
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
|
import { renderHostIconGlyph } from '../hostIconRenderer';
|
|
|
|
// File extensions that render the code-file icon instead of the plain text icon.
|
|
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
|
|
|
export function activateLogViewTab(logViewId: string): void {
|
|
activeTabStore.setActiveTabId(logViewId);
|
|
}
|
|
|
|
const localOsId = (() => {
|
|
if (typeof navigator === 'undefined') return 'linux';
|
|
const ua = navigator.userAgent;
|
|
if (/Mac/i.test(ua)) return 'macos';
|
|
if (/Win/i.test(ua)) return 'windows';
|
|
return 'linux';
|
|
})();
|
|
|
|
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
|
|
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string; shellIcon?: string }> = memo(({ host, isActive, protocol, shellIcon }) => {
|
|
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
|
const iconSize = "h-2.5 w-2.5";
|
|
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
|
|
|
// Serial protocol → USB icon
|
|
if (protocol === 'serial' || host?.protocol === 'serial') {
|
|
return (
|
|
<div className={cn(boxBase, "bg-amber-500/15 text-amber-500")}>
|
|
<Usb className={iconSize} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Local protocol → shell-specific icon if available, else OS-specific icon
|
|
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
|
// Use shell icon from discovery when available
|
|
const iconId = shellIcon || host?.localShellIcon;
|
|
if (iconId) {
|
|
return (
|
|
<img
|
|
src={getShellIconPath(iconId)}
|
|
alt={iconId}
|
|
className={cn("shrink-0 h-4 w-4 object-contain", isMonochromeShellIcon(iconId) && "dark:invert")}
|
|
/>
|
|
);
|
|
}
|
|
const logo = DISTRO_LOGOS[localOsId];
|
|
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
|
|
if (logo) {
|
|
return (
|
|
<div className={cn(boxBase, bg)}>
|
|
<img
|
|
src={logo}
|
|
alt={localOsId}
|
|
className={cn(iconSize, "object-contain invert brightness-0")}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
|
<TerminalSquare className={iconSize} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (host) {
|
|
const customAppearance = resolveHostIconAppearance(host);
|
|
if (customAppearance) {
|
|
return (
|
|
<div className={cn(boxBase, "text-white")} style={{ backgroundColor: customAppearance.colorHex }}>
|
|
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Try distro logo with brand background color
|
|
if (host) {
|
|
const distro = getEffectiveHostDistro(host);
|
|
const logo = DISTRO_LOGOS[distro];
|
|
if (logo) {
|
|
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
|
const customColor = resolveHostIconColorAppearance(host);
|
|
return (
|
|
<div className={cn(boxBase, !customColor && bg)} style={customColor ? { backgroundColor: customColor.colorHex } : undefined}>
|
|
<img
|
|
src={logo}
|
|
alt={distro || host.os}
|
|
className={cn(iconSize, "object-contain invert brightness-0")}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Fallback: generic server icon for remote, terminal for unknown
|
|
if (host && host.protocol !== 'local') {
|
|
return (
|
|
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
|
<Server className={iconSize} />
|
|
</div>
|
|
);
|
|
}
|
|
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
|
|
});
|
|
SessionTabIcon.displayName = 'SessionTabIcon';
|
|
|
|
export const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
|
|
const tone = status === 'connected'
|
|
? "bg-emerald-400"
|
|
: status === 'connecting'
|
|
? "bg-amber-400"
|
|
: "bg-rose-500";
|
|
return (
|
|
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
|
<span
|
|
className={cn(
|
|
"relative inline-block h-2 w-2 rounded-full ring-2",
|
|
tone,
|
|
hasActivity && "session-activity-dot",
|
|
)}
|
|
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
|
|
/>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Custom window controls for Windows/Linux (frameless window)
|
|
export 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 (debounced to avoid IPC storm)
|
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const handleResize = () => {
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
|
}, 200);
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
};
|
|
}, [fetchIsMaximized]);
|
|
|
|
const handleMinimize = () => {
|
|
minimize();
|
|
};
|
|
|
|
const handleMaximize = async () => {
|
|
const result = await maximize();
|
|
setIsMaximized(!!result);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
close();
|
|
};
|
|
|
|
const controlClassName = 'window-control-btn app-no-drag';
|
|
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
|
|
|
|
return (
|
|
<div className="ml-2 flex items-center h-7 overflow-visible app-no-drag">
|
|
<button type="button" className={controlClassName} onClick={handleMinimize}>
|
|
<Minus size={16} />
|
|
</button>
|
|
<button type="button" className={controlClassName} onClick={handleMaximize}>
|
|
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
|
|
</button>
|
|
<button type="button" className={closeControlClassName} onClick={handleClose}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
WindowControls.displayName = 'WindowControls';
|
|
|
|
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
|
type RenderBulkCloseItems = (anchorId: string) => React.ReactNode;
|
|
|
|
const TOP_TAB_COMFORT_EDGE_RATIO = 0.22;
|
|
const TOP_TAB_COMFORT_EDGE_MIN = 72;
|
|
const TOP_TAB_COMFORT_EDGE_MAX = 160;
|
|
|
|
export function scrollTopTabIntoComfortView(
|
|
container: HTMLDivElement | null,
|
|
tab: HTMLElement | null,
|
|
behavior: ScrollBehavior = 'smooth',
|
|
) {
|
|
if (!container || !tab) return;
|
|
if (container.scrollWidth <= container.clientWidth) return;
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const tabRect = tab.getBoundingClientRect();
|
|
const edgeBuffer = Math.min(
|
|
TOP_TAB_COMFORT_EDGE_MAX,
|
|
Math.max(TOP_TAB_COMFORT_EDGE_MIN, containerRect.width * TOP_TAB_COMFORT_EDGE_RATIO),
|
|
);
|
|
const isNearLeft = tabRect.left < containerRect.left + edgeBuffer;
|
|
const isNearRight = tabRect.right > containerRect.right - edgeBuffer;
|
|
|
|
if (!isNearLeft && !isNearRight) return;
|
|
|
|
const tabCenter =
|
|
tabRect.left - containerRect.left + container.scrollLeft + tabRect.width / 2;
|
|
const maxScrollLeft = container.scrollWidth - container.clientWidth;
|
|
const targetLeft = Math.max(
|
|
0,
|
|
Math.min(maxScrollLeft, tabCenter - container.clientWidth / 2),
|
|
);
|
|
|
|
if (Math.abs(container.scrollLeft - targetLeft) < 1) return;
|
|
container.scrollTo({ left: targetLeft, behavior });
|
|
}
|
|
|
|
interface ActiveTabAutoScrollerProps {
|
|
tabsContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
updateScrollState: () => void;
|
|
}
|
|
|
|
export const ActiveTabAutoScroller: React.FC<ActiveTabAutoScrollerProps> = memo(({
|
|
tabsContainerRef,
|
|
updateScrollState,
|
|
}) => {
|
|
const activeTabId = useActiveTabId();
|
|
|
|
useLayoutEffect(() => {
|
|
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') return;
|
|
const container = tabsContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const activeTabElement = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement | null;
|
|
scrollTopTabIntoComfortView(container, activeTabElement, 'smooth');
|
|
|
|
setTimeout(updateScrollState, 260);
|
|
}, [activeTabId, tabsContainerRef, updateScrollState]);
|
|
|
|
return null;
|
|
});
|
|
ActiveTabAutoScroller.displayName = 'ActiveTabAutoScroller';
|
|
|
|
interface RootTopTabProps {
|
|
tabId: 'vault' | 'sftp';
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
className?: string;
|
|
compact?: boolean;
|
|
}
|
|
|
|
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false }) => {
|
|
const isActive = useIsTabActive(tabId);
|
|
// The Vaults tab is the app's persistent "home", so keep its selected state
|
|
// visually flat — no active background fill (the label/icon still brighten to
|
|
// the active foreground for subtle feedback). Other root tabs (SFTP) keep the
|
|
// normal filled active state.
|
|
const suppressActiveBg = tabId === 'vault';
|
|
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
// Flat tabs never change their React-managed backgroundColor (transparent
|
|
// when inactive AND active), so React can't diff transparent → transparent
|
|
// to clear the hover fill that onMouseEnter wrote imperatively. Clicking
|
|
// straight from a hover would otherwise leave a stuck highlight, so reset
|
|
// it here before activating.
|
|
if (suppressActiveBg) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
}
|
|
activeTabStore.setActiveTabId(tabId);
|
|
}, [tabId, suppressActiveBg]);
|
|
|
|
return (
|
|
<div
|
|
data-tab-id={tabId}
|
|
data-tab-type="root"
|
|
data-state={isActive ? 'active' : 'inactive'}
|
|
onClick={handleClick}
|
|
className={cn(
|
|
"netcatty-tab relative h-7 overflow-hidden text-xs font-semibold cursor-pointer flex items-center app-no-drag transition-[padding,gap] duration-300 ease-out",
|
|
compact ? "px-2 gap-0" : "px-3 gap-2",
|
|
className,
|
|
)}
|
|
style={{
|
|
backgroundColor: isActive && !suppressActiveBg
|
|
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
|
: 'transparent',
|
|
color: isActive
|
|
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
|
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
|
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
|
}
|
|
}}
|
|
>
|
|
{icon}
|
|
<span className={cn('top-tab-root-label', compact && 'top-tab-root-label-compact')}>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
);
|
|
});
|
|
RootTopTab.displayName = 'RootTopTab';
|
|
|
|
interface EditorTopTabProps {
|
|
tabId: string;
|
|
editorTab: EditorTab;
|
|
host: Host | undefined;
|
|
suffix: string;
|
|
onRequestCloseEditorTab: (editorTabId: string) => void;
|
|
isBeingDragged: boolean;
|
|
isDraggingForReorder: boolean;
|
|
shiftStyle: React.CSSProperties;
|
|
showDropIndicatorBefore: boolean;
|
|
showDropIndicatorAfter: boolean;
|
|
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragEnd: () => void;
|
|
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragLeave: (e: React.DragEvent) => void;
|
|
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
|
tabAnimationClass?: string;
|
|
}
|
|
|
|
export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
|
tabId,
|
|
editorTab,
|
|
host,
|
|
suffix,
|
|
onRequestCloseEditorTab,
|
|
isBeingDragged,
|
|
isDraggingForReorder,
|
|
shiftStyle,
|
|
showDropIndicatorBefore,
|
|
showDropIndicatorAfter,
|
|
onTabDragStart,
|
|
onTabDragEnd,
|
|
onTabDragOver,
|
|
onTabDragLeave,
|
|
onTabDrop,
|
|
tabAnimationClass,
|
|
}) => {
|
|
const isActive = useIsTabActive(tabId);
|
|
const dirty = editorTab.content !== editorTab.baselineContent;
|
|
const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`;
|
|
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
|
|
const handleClick = useCallback(() => {
|
|
activeTabStore.setActiveTabId(tabId);
|
|
}, [tabId]);
|
|
const handleClose = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onRequestCloseEditorTab(editorTab.id);
|
|
}, [editorTab.id, onRequestCloseEditorTab]);
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
data-tab-id={tabId}
|
|
data-tab-type="editor"
|
|
data-state={isActive ? 'active' : 'inactive'}
|
|
onClick={handleClick}
|
|
onMouseDown={handleTabMiddleMouseDown}
|
|
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
|
draggable
|
|
onDragStart={(e) => onTabDragStart(e, tabId)}
|
|
onDragEnd={onTabDragEnd}
|
|
onDragOver={(e) => onTabDragOver(e, tabId)}
|
|
onDragLeave={onTabDragLeave}
|
|
onDrop={(e) => onTabDrop(e, tabId)}
|
|
className={cn(
|
|
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
|
"transition-transform duration-150",
|
|
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
|
tabAnimationClass,
|
|
)}
|
|
style={{
|
|
...shiftStyle,
|
|
backgroundColor: isActive
|
|
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
|
: 'transparent',
|
|
color: isActive
|
|
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
|
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
|
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
|
}
|
|
}}
|
|
>
|
|
{showDropIndicatorBefore && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
{showDropIndicatorAfter && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<FileIcon
|
|
size={14}
|
|
className="shrink-0"
|
|
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
|
/>
|
|
<span className="truncate flex items-center gap-0.5">
|
|
{dirty && <span className="text-primary mr-0.5">●</span>}
|
|
{editorTab.fileName}
|
|
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
|
aria-label="Close editor tab"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
});
|
|
EditorTopTab.displayName = 'EditorTopTab';
|
|
|
|
interface SessionTopTabProps {
|
|
session: TerminalSession;
|
|
host: Host | undefined;
|
|
hasActivity: boolean;
|
|
isBeingDragged: boolean;
|
|
isDraggingForReorder: boolean;
|
|
shiftStyle: React.CSSProperties;
|
|
showDropIndicatorBefore: boolean;
|
|
showDropIndicatorAfter: boolean;
|
|
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragEnd: () => void;
|
|
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragLeave: (e: React.DragEvent) => void;
|
|
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
|
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
|
onRenameSession: (sessionId: string) => void;
|
|
onCopySession: (sessionId: string) => void;
|
|
onCopySessionToNewWindow: (sessionId: string) => void;
|
|
renderBulkCloseItems: RenderBulkCloseItems;
|
|
t: TranslateFn;
|
|
tabAnimationClass?: string;
|
|
}
|
|
|
|
export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
|
session,
|
|
host,
|
|
hasActivity,
|
|
isBeingDragged,
|
|
isDraggingForReorder,
|
|
shiftStyle,
|
|
showDropIndicatorBefore,
|
|
showDropIndicatorAfter,
|
|
onTabDragStart,
|
|
onTabDragEnd,
|
|
onTabDragOver,
|
|
onTabDragLeave,
|
|
onTabDrop,
|
|
onCloseSession,
|
|
onRenameSession,
|
|
onCopySession,
|
|
onCopySessionToNewWindow,
|
|
renderBulkCloseItems,
|
|
t,
|
|
tabAnimationClass,
|
|
}) => {
|
|
const isActive = useIsTabActive(session.id);
|
|
const handleClick = useCallback(() => {
|
|
activeTabStore.setActiveTabId(session.id);
|
|
}, [session.id]);
|
|
|
|
return (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
data-tab-id={session.id}
|
|
data-tab-type="session"
|
|
data-state={isActive ? 'active' : 'inactive'}
|
|
onClick={handleClick}
|
|
onMouseDown={handleTabMiddleMouseDown}
|
|
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
|
draggable
|
|
onDragStart={(e) => onTabDragStart(e, session.id)}
|
|
onDragEnd={onTabDragEnd}
|
|
onDragOver={(e) => onTabDragOver(e, session.id)}
|
|
onDragLeave={onTabDragLeave}
|
|
onDrop={(e) => onTabDrop(e, session.id)}
|
|
className={cn(
|
|
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
|
"transition-transform duration-150",
|
|
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
|
tabAnimationClass,
|
|
)}
|
|
style={{
|
|
...shiftStyle,
|
|
backgroundColor: isActive
|
|
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
|
: 'transparent',
|
|
color: isActive
|
|
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
|
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
|
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
|
}
|
|
}}
|
|
>
|
|
{showDropIndicatorBefore && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
{showDropIndicatorAfter && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<SessionTabIcon host={host} isActive={isActive} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
|
<span className="truncate">{session.customName || session.hostLabel}</span>
|
|
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</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>
|
|
<SessionTabContextMenuContent
|
|
sessionId={session.id}
|
|
onCloseSession={onCloseSession}
|
|
onCopySession={onCopySession}
|
|
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
|
onRenameSession={onRenameSession}
|
|
renderBulkCloseItems={renderBulkCloseItems}
|
|
t={t}
|
|
/>
|
|
</ContextMenu>
|
|
);
|
|
});
|
|
SessionTopTab.displayName = 'SessionTopTab';
|
|
|
|
interface WorkspaceTopTabProps {
|
|
workspace: Workspace;
|
|
paneCount: number;
|
|
hasActivity: boolean;
|
|
isBeingDragged: boolean;
|
|
isDraggingForReorder: boolean;
|
|
shiftStyle: React.CSSProperties;
|
|
showDropIndicatorBefore: boolean;
|
|
showDropIndicatorAfter: boolean;
|
|
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragEnd: () => void;
|
|
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragLeave: (e: React.DragEvent) => void;
|
|
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
|
onRenameWorkspace: (workspaceId: string) => void;
|
|
onCloseWorkspace: (workspaceId: string) => void;
|
|
onDetachSessionFromWorkspace?: (workspaceId: string, sessionId: string) => void;
|
|
workspaceSessionLabels?: Record<string, string>;
|
|
renderBulkCloseItems: RenderBulkCloseItems;
|
|
t: TranslateFn;
|
|
tabAnimationClass?: string;
|
|
}
|
|
|
|
export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
|
workspace,
|
|
paneCount,
|
|
hasActivity,
|
|
isBeingDragged,
|
|
isDraggingForReorder,
|
|
shiftStyle,
|
|
showDropIndicatorBefore,
|
|
showDropIndicatorAfter,
|
|
onTabDragStart,
|
|
onTabDragEnd,
|
|
onTabDragOver,
|
|
onTabDragLeave,
|
|
onTabDrop,
|
|
onRenameWorkspace,
|
|
onCloseWorkspace,
|
|
onDetachSessionFromWorkspace,
|
|
workspaceSessionLabels,
|
|
renderBulkCloseItems,
|
|
t,
|
|
tabAnimationClass,
|
|
}) => {
|
|
const isActive = useIsTabActive(workspace.id);
|
|
const handleClick = useCallback(() => {
|
|
activeTabStore.setActiveTabId(workspace.id);
|
|
}, [workspace.id]);
|
|
|
|
return (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
data-tab-id={workspace.id}
|
|
data-tab-type="workspace"
|
|
data-state={isActive ? 'active' : 'inactive'}
|
|
onClick={handleClick}
|
|
onMouseDown={handleTabMiddleMouseDown}
|
|
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
|
draggable
|
|
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
|
onDragEnd={onTabDragEnd}
|
|
onDragOver={(e) => onTabDragOver(e, workspace.id)}
|
|
onDragLeave={onTabDragLeave}
|
|
onDrop={(e) => onTabDrop(e, workspace.id)}
|
|
className={cn(
|
|
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
|
"transition-transform duration-150",
|
|
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
|
tabAnimationClass,
|
|
)}
|
|
style={{
|
|
...shiftStyle,
|
|
backgroundColor: isActive
|
|
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
|
: 'transparent',
|
|
color: isActive
|
|
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
|
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
|
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
|
}
|
|
}}
|
|
>
|
|
{showDropIndicatorBefore && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
{showDropIndicatorAfter && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-2 truncate">
|
|
<LayoutGrid
|
|
size={14}
|
|
className="shrink-0"
|
|
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
|
/>
|
|
<span className="truncate">{workspace.title}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{hasActivity && sessionStatusDot('connected', true)}
|
|
<div
|
|
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
|
|
style={{
|
|
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
|
|
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
|
|
}}
|
|
>
|
|
{paneCount}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onClick={() => onRenameWorkspace(workspace.id)}>
|
|
{t('common.rename')}
|
|
</ContextMenuItem>
|
|
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.entries(workspaceSessionLabels).map(([sessionId, label]) => (
|
|
<ContextMenuItem
|
|
key={sessionId}
|
|
onClick={() => onDetachSessionFromWorkspace(workspace.id, sessionId)}
|
|
>
|
|
{t('terminal.menu.detachSession', { name: label })}
|
|
</ContextMenuItem>
|
|
))}
|
|
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.keys(workspaceSessionLabels).length > 0 && (
|
|
<ContextMenuSeparator />
|
|
)}
|
|
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
|
{t('common.close')}
|
|
</ContextMenuItem>
|
|
{renderBulkCloseItems(workspace.id)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
});
|
|
WorkspaceTopTab.displayName = 'WorkspaceTopTab';
|
|
|
|
interface LogViewTopTabProps {
|
|
logView: LogView;
|
|
onCloseLogView: (logViewId: string) => void;
|
|
isBeingDragged: boolean;
|
|
isDraggingForReorder: boolean;
|
|
shiftStyle: React.CSSProperties;
|
|
showDropIndicatorBefore: boolean;
|
|
showDropIndicatorAfter: boolean;
|
|
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragEnd: () => void;
|
|
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
|
|
onTabDragLeave: (e: React.DragEvent) => void;
|
|
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
|
t: TranslateFn;
|
|
tabAnimationClass?: string;
|
|
}
|
|
|
|
export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
|
logView,
|
|
onCloseLogView,
|
|
isBeingDragged,
|
|
isDraggingForReorder,
|
|
shiftStyle,
|
|
showDropIndicatorBefore,
|
|
showDropIndicatorAfter,
|
|
onTabDragStart,
|
|
onTabDragEnd,
|
|
onTabDragOver,
|
|
onTabDragLeave,
|
|
onTabDrop,
|
|
t,
|
|
tabAnimationClass,
|
|
}) => {
|
|
const isActive = useIsTabActive(logView.id);
|
|
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
|
|
const handleClick = useCallback(() => {
|
|
activateLogViewTab(logView.id);
|
|
}, [logView.id]);
|
|
const handleClose = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onCloseLogView(logView.id);
|
|
}, [logView.id, onCloseLogView]);
|
|
|
|
return (
|
|
<div
|
|
data-tab-id={logView.id}
|
|
data-tab-type="logView"
|
|
data-state={isActive ? 'active' : 'inactive'}
|
|
onClick={handleClick}
|
|
onMouseDown={handleTabMiddleMouseDown}
|
|
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
|
draggable
|
|
onDragStart={(e) => onTabDragStart(e, logView.id)}
|
|
onDragEnd={onTabDragEnd}
|
|
onDragOver={(e) => onTabDragOver(e, logView.id)}
|
|
onDragLeave={onTabDragLeave}
|
|
onDrop={(e) => onTabDrop(e, logView.id)}
|
|
className={cn(
|
|
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
|
"transition-transform duration-150",
|
|
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
|
tabAnimationClass,
|
|
)}
|
|
style={{
|
|
...shiftStyle,
|
|
backgroundColor: isActive
|
|
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
|
: 'transparent',
|
|
color: isActive
|
|
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
|
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
|
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
|
}
|
|
}}
|
|
>
|
|
{showDropIndicatorBefore && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
{showDropIndicatorAfter && isDraggingForReorder && (
|
|
<div
|
|
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<FileText
|
|
size={14}
|
|
className="shrink-0"
|
|
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
|
/>
|
|
<span className="truncate">
|
|
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
|
aria-label={t('tabs.closeLogViewAria')}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
LogViewTopTab.displayName = 'LogViewTopTab';
|