Add tab navigation dropdown menu for better multi-tab management

Implement Issue #130 suggestion: Add dropdown menu in top tab bar to help
users quickly navigate between open hosts/tabs when many are open.

Features:
- Smart visibility: Only shows when multiple tabs are open
- Visual identification: Each tab type has corresponding icon (Home, Terminal, Server, etc.)
- Active tab indicator: Current tab highlighted with blue dot
- Quick switching: Click any tab to switch instantly
- Seamless integration: Positioned in top navbar without disrupting existing UI

New Components:
- src/components/ui/dropdown-menu.tsx: Radix UI based dropdown component
- src/ui/Desktop/Navigation/Tabs/TabDropdown.tsx: Tab navigation dropdown logic
- Updated TopNavbar.tsx with integrated dropdown

UI/UX Improvements:
- Consistent dark theme styling (#18181b background, #303032 borders)
- 30x30px button size matching existing toolbar buttons
- Right-aligned dropdown to avoid screen boundary issues
- Hover effects and smooth transitions
- Responsive design for different screen sizes

i18n Support:
- Added "tabNavigation" key in both English and Chinese translations
- Accessible tooltips and labels

Technical Implementation:
- Zero breaking changes: Pure incremental feature
- Reuses existing tab context and switching logic
- TypeScript safe with proper type definitions
- Performance optimized: Only renders when needed
- Follows existing code patterns and conventions

This enhancement significantly improves the user experience when managing
multiple host connections, providing quick access to any tab without scrolling.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-07 16:35:09 +08:00
parent bfd441e1f3
commit d1635c35f3
5 changed files with 316 additions and 2 deletions
+198
View File
@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
@@ -0,0 +1,111 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
ChevronDown,
Home,
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon,
Shield as AdminIcon,
Network as SshManagerIcon
} from "lucide-react";
import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
export function TabDropdown(): React.ReactElement {
const { tabs, currentTab, setCurrentTab } = useTabs();
const { t } = useTranslation();
const getTabIcon = (tabType: Tab['type']) => {
switch (tabType) {
case 'home':
return <Home className="h-4 w-4" />;
case 'terminal':
return <TerminalIcon className="h-4 w-4" />;
case 'server':
return <ServerIcon className="h-4 w-4" />;
case 'file_manager':
return <FolderIcon className="h-4 w-4" />;
case 'ssh_manager':
return <SshManagerIcon className="h-4 w-4" />;
case 'admin':
return <AdminIcon className="h-4 w-4" />;
default:
return <TerminalIcon className="h-4 w-4" />;
}
};
const getTabDisplayTitle = (tab: Tab) => {
switch (tab.type) {
case 'home':
return t('nav.home');
case 'server':
return tab.title || t('nav.serverStats');
case 'file_manager':
return tab.title || t('nav.fileManager');
case 'ssh_manager':
return tab.title || t('nav.sshManager');
case 'admin':
return tab.title || t('nav.admin');
case 'terminal':
default:
return tab.title || t('nav.terminal');
}
};
const handleTabSwitch = (tabId: number) => {
setCurrentTab(tabId);
};
// If only one tab (home), don't show dropdown
if (tabs.length <= 1) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[30px] h-[30px] border-[#303032]"
title={t('nav.tabNavigation', { defaultValue: 'Tab Navigation' })}
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 bg-[#18181b] border-[#303032] text-white"
>
{tabs.map((tab) => {
const isActive = tab.id === currentTab;
return (
<DropdownMenuItem
key={tab.id}
onClick={() => handleTabSwitch(tab.id)}
className={`flex items-center gap-2 cursor-pointer px-3 py-2 ${
isActive
? 'bg-[#1d1d1f] text-white'
: 'hover:bg-[#2d2d30] text-gray-300'
}`}
>
{getTabIcon(tab.type)}
<span className="flex-1 truncate">
{getTabDisplayTitle(tab)}
</span>
{isActive && (
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+3
View File
@@ -14,6 +14,7 @@ import {Input} from "@/components/ui/input.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {useTranslation} from "react-i18next";
import {TabDropdown} from "@/ui/Desktop/Navigation/Tabs/TabDropdown.tsx";
interface TopNavbarProps {
isTopbarOpen: boolean;
@@ -262,6 +263,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div>
<div className="flex items-center justify-center gap-2 flex-1 px-2">
<TabDropdown />
<Button
variant="outline"
className="w-[30px] h-[30px]"