Files
opsxshell/src/components/layout/tabbar/TabItem.tsx
T
Logan.Li b0d0e3a5f3 feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru
- Auto-detect system language via navigator.languages; fallback to English
- Add BCP47 locale mapping for date formatting and sorting
- Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each)
- Update language selector with native language names
- Extend locale key parity test to cover all 7 languages (70 assertions)
- Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features
- Add credential encryption, host metrics, session backup crypto, and zmodem bridge
- Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
2026-08-12 00:07:48 +08:00

154 lines
4.9 KiB
TypeScript

/**
* TabItem — local shell uses the same Terminal icon as the launcher (+)
*/
import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Tab } from '../../../stores/tabStore'
import { useSessionStore } from '../../../stores/sessionStore'
import HostDistroIcon from '../../common/HostDistroIcon'
import { resolveTabTitle } from '../../../utils/tabTitle'
interface TabItemProps {
tab: Tab
index: number
isActive: boolean
isDragging: boolean
dragOverIndex: number | null
onActivate: () => void
onClose: (e: React.MouseEvent) => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent, index: number) => void
onDragOver: (e: React.DragEvent, index: number) => void
onDrop: (e: React.DragEvent, index: number) => void
onDragEnd: () => void
}
export function TabItem({
tab,
index,
isActive,
isDragging,
dragOverIndex,
onActivate,
onClose,
onContextMenu,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}: TabItemProps) {
const { t } = useTranslation('terminal')
const displayTitle = resolveTabTitle(tab)
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
const session = useSessionStore((state) =>
tab.sessionId ? state.sessions.find((item) => item.id === tab.sessionId) : undefined
)
const getIcon = () => {
if (isLocalTerm) {
return (
<Terminal
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-emerald-400' : 'text-emerald-500/80'}`}
aria-label={t('toolbar.labelLocal')}
/>
)
}
if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) {
return (
<HostDistroIcon
distro={
session?.distro ??
(tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)
}
protocol={
tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : session?.protocol ?? 'ssh'
}
size="xs"
/>
)
}
switch (tab.type) {
case 'terminal':
return <Terminal className={`h-3.5 w-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
case 'sftp':
return <FolderSync className={`h-3.5 w-3.5 ${isActive ? 'text-accent-purple' : ''}`} />
case 'serial':
return <Cable className={`h-3.5 w-3.5 ${isActive ? 'text-accent-green' : ''}`} />
case 'upgrade':
return <Crown className={`h-3.5 w-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
case 'batch':
return <TerminalSquare className={`h-3.5 w-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
default:
return null
}
}
const showLeftIndicator = dragOverIndex === index && !isDragging
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
const statusDot =
tab.connectionStatus === 'connecting' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-yellow-400" />
) : tab.connectionStatus === 'connected' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" />
) : tab.connectionStatus === 'error' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-red-500" />
) : null
return (
<div
data-tab-item
draggable
onClick={onActivate}
onContextMenu={onContextMenu}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
className={`
group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2
border-r border-dark-700/20 px-3 py-2 titlebar-no-drag transition-all duration-150
${
isActive
? 'bg-dark-900 text-content-primary'
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
}
${isDragging ? 'opacity-50' : 'opacity-100'}
`}
>
{showLeftIndicator && (
<div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{showRightIndicator && (
<div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{statusDot}
{getIcon()}
<span className="flex-1 truncate text-sm font-medium" title={displayTitle}>
{displayTitle}
</span>
<button
onClick={onClose}
className={`
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover:opacity-100
${isActive ? 'opacity-100' : ''}
`}
>
<X className="h-3.5 w-3.5" />
</button>
<div
className={`
absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue
transition-[opacity,transform] duration-150
${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'}
`}
/>
</div>
)
}