Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
5.5 KiB
TypeScript
177 lines
5.5 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: { stopPropagation(): void }) => 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', 'common'])
|
|
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-accent-green' : 'text-accent-green/70'}`}
|
|
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
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
tabIndex={0}
|
|
draggable
|
|
onClick={onActivate}
|
|
onAuxClick={(e) => {
|
|
if (e.button === 1) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
onClose(e)
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
onActivate()
|
|
}
|
|
if (e.key === 'Delete') {
|
|
e.preventDefault()
|
|
onClose(e)
|
|
}
|
|
}}
|
|
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
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t('common:tab.closeAria', { title: displayTitle })}
|
|
className={`
|
|
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
|
|
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible: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>
|
|
)
|
|
}
|