Files
opsxshell/src/components/layout/Sidebar.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

586 lines
22 KiB
TypeScript

/**
* Sidebar - 侧边栏组件
* 支持宽度拖拽调整
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Plus, LogIn, Settings, ListChecks, X, TerminalSquare, ChevronsLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { SessionTree } from './sidebar/SessionTree'
import { SearchBox } from './sidebar/SearchBox'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { usePromptDialog } from '../common/PromptDialog'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { buildOpenTabPayload, isSshSession } from '../../utils/sessionProtocol'
import {
DEFAULT_SESSION_GROUP,
groupDisplayName,
normalizeGroupId,
} from '../../utils/sessionGroups'
const MIN_SIDEBAR_WIDTH = 180
const MAX_SIDEBAR_WIDTH = 400
interface SidebarProps {
onNewSession: (group?: string) => void
onQuickConnect: () => void
onEditSession: (session: Session) => void
onCollapse?: () => void
}
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
const { t } = useTranslation(['sessions', 'common'])
const { sessions, setSessions, addSession, removeSession, updateSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const { prompt, PromptDialogComponent } = usePromptDialog()
const [searchTerm, setSearchTerm] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [contextMenu, setContextMenu] = useState<
| { kind: 'session'; x: number; y: number; session: Session }
| { kind: 'group'; x: number; y: number; group: string }
| { kind: 'blank'; x: number; y: number }
| null
>(null)
const [isResizing, setIsResizing] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(settings.sidebarWidth || 256)
const [sessionsLoading, setSessionsLoading] = useState(true)
const [batchMode, setBatchMode] = useState(false)
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
const requirePro = useEntitlementStore((s) => s.requirePro)
const contextMenuRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
// Sync width from settings on load
useEffect(() => {
if (settings.sidebarWidth) {
setSidebarWidth(settings.sidebarWidth)
}
}, [settings.sidebarWidth])
// Load sessions
useEffect(() => {
window.opsxApi?.sessionGetAll()
.then((result) => {
if (result.success && result.sessions) setSessions(result.sessions)
})
.catch(() => undefined)
.finally(() => setSessionsLoading(false))
}, [setSessions])
// Click outside to close context menu
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Resize handler
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
const startX = e.clientX
const startWidth = sidebarWidth
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
setSidebarWidth(newWidth)
}
const handleMouseUp = () => {
setIsResizing(false)
document.body.style.userSelect = ''
document.body.style.cursor = ''
// Save to settings on drag end
setSetting('sidebarWidth', sidebarWidth)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// Prevent text selection during drag
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [sidebarWidth, setSetting])
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
useEffect(() => {
if (!isResizing && sidebarWidth !== settings.sidebarWidth) {
setSetting('sidebarWidth', sidebarWidth)
}
}, [isResizing, sidebarWidth, settings.sidebarWidth, setSetting])
const toggleGroup = (group: string) => {
const newExpanded = new Set(expandedGroups)
if (newExpanded.has(group)) newExpanded.delete(group)
else newExpanded.add(group)
setExpandedGroups(newExpanded)
}
const handleSelect = (session: Session) => {
setSelectedSessionId(session.id)
}
const handleConnect = (session: Session) => {
setSelectedSessionId(session.id)
const protocol = session.protocol ?? 'ssh'
if ((protocol === 'rdp' || protocol === 'vnc') && !requirePro(protocol)) return
addTab(buildOpenTabPayload(session))
}
const handleOpenSftp = (session: Session) => {
if ((session.protocol ?? 'ssh') !== 'ssh') return
setSelectedSessionId(session.id)
addTab({
title: `SFTP: ${session.name}`,
type: 'sftp',
sessionId: session.id,
})
}
const toggleBatchSelection = useCallback((session: Session) => {
if (!isSshSession(session)) return
setBatchSelection((current) => {
const next = new Set(current)
if (next.has(session.id)) next.delete(session.id)
else next.add(session.id)
return next
})
}, [])
/** Batch mode only lists SSH targets (RDP / VNC are unsupported). */
const batchableSessions = useMemo(
() => sessions.filter((session) => isSshSession(session)),
[sessions]
)
const visibleSessions = batchableSessions.filter((session) => {
const query = searchTerm.trim().toLowerCase()
return !query || session.name.toLowerCase().includes(query) ||
session.host.toLowerCase().includes(query) ||
session.username?.toLowerCase().includes(query)
})
const toggleSelectVisible = useCallback(() => {
setBatchSelection((current) => {
const visibleIds = visibleSessions.map((session) => session.id)
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
const next = new Set(current)
for (const id of visibleIds) {
if (allSelected) next.delete(id)
else next.add(id)
}
return next
})
}, [visibleSessions])
const selectedSessions = batchableSessions.filter((session) => batchSelection.has(session.id))
useEffect(() => {
if (!batchMode) return
setBatchSelection((current) => {
const allowed = new Set(batchableSessions.map((session) => session.id))
let changed = false
const next = new Set<string>()
for (const id of current) {
if (allowed.has(id)) next.add(id)
else changed = true
}
return changed ? next : current
})
}, [batchMode, batchableSessions])
const finishBatchMode = useCallback(() => {
setBatchMode(false)
setBatchSelection(new Set())
}, [])
const handleBatchCommand = useCallback(() => {
if (!requirePro('batchExec')) return
addTab({
title: t('sessions:batch.title'),
type: 'batch',
batchSessionIds: selectedSessions.map((session) => session.id),
})
}, [addTab, requirePro, selectedSessions, t])
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
e.preventDefault()
setSelectedSessionId(session.id)
setContextMenu({ kind: 'session', x: e.clientX, y: e.clientY, session })
}
const handleGroupContextMenu = (e: React.MouseEvent, group: string) => {
e.preventDefault()
setContextMenu({ kind: 'group', x: e.clientX, y: e.clientY, group })
}
const handleBlankContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenu({ kind: 'blank', x: e.clientX, y: e.clientY })
}
const persistSessionGroups = useCallback(
async (next: string[]) => {
const cleaned = next
.map((item) => normalizeGroupId(item))
.filter((item) => item !== DEFAULT_SESSION_GROUP)
const unique = Array.from(new Set(cleaned))
await setSetting('sessionGroups', unique)
},
[setSetting]
)
const createGroup = useCallback(async () => {
const raw = await prompt({
title: t('sessions:groups.new.title'),
message: t('sessions:groups.new.message'),
placeholder: t('sessions:groups.new.placeholder'),
confirmText: t('sessions:groups.new.confirm'),
})
if (raw == null) return
const group = normalizeGroupId(raw)
if (group === DEFAULT_SESSION_GROUP) return
const extras = settings.sessionGroups || []
if (!extras.includes(group)) {
await persistSessionGroups([...extras, group])
}
setExpandedGroups((current) => new Set(current).add(group))
}, [persistSessionGroups, prompt, settings.sessionGroups, t])
const renameGroup = useCallback(
async (group: string) => {
if (group === DEFAULT_SESSION_GROUP) return
const raw = await prompt({
title: t('sessions:groups.rename.title'),
message: t('sessions:groups.rename.message', { name: groupDisplayName(group) }),
defaultValue: groupDisplayName(group),
placeholder: t('sessions:groups.rename.placeholder'),
confirmText: t('sessions:groups.rename.confirm'),
})
if (raw == null) return
const next = normalizeGroupId(raw)
if (!next || next === group) return
const targets = sessions.filter((session) => normalizeGroupId(session.group) === group)
for (const session of targets) {
await updateSession(session.id, { group: next })
}
const extras = (settings.sessionGroups || []).filter((item) => item !== group)
if (next !== DEFAULT_SESSION_GROUP && !extras.includes(next)) extras.push(next)
await persistSessionGroups(extras)
setExpandedGroups((current) => {
const copy = new Set(current)
copy.delete(group)
copy.add(next)
return copy
})
},
[persistSessionGroups, prompt, sessions, settings.sessionGroups, updateSession, t]
)
const deleteEmptyGroup = useCallback(
async (group: string) => {
if (group === DEFAULT_SESSION_GROUP) return
const hasSessions = sessions.some((session) => normalizeGroupId(session.group) === group)
if (hasSessions) {
await confirm({
title: t('sessions:groups.deleteBlocked.title'),
message: t('sessions:groups.deleteBlocked.message', { name: groupDisplayName(group) }),
confirmText: t('sessions:groups.deleteBlocked.confirm'),
})
return
}
await persistSessionGroups((settings.sessionGroups || []).filter((item) => item !== group))
},
[confirm, persistSessionGroups, sessions, settings.sessionGroups, t]
)
const handleContextMenuAction = async (action: string) => {
if (!contextMenu) return
if (contextMenu.kind === 'blank') {
if (action === 'new-group') await createGroup()
if (action === 'new-session') onNewSession()
setContextMenu(null)
return
}
if (contextMenu.kind === 'group') {
const group = contextMenu.group
if (action === 'new-session') onNewSession(group)
if (action === 'new-group') await createGroup()
if (action === 'rename-group') await renameGroup(group)
if (action === 'delete-group') await deleteEmptyGroup(group)
setContextMenu(null)
return
}
const session = contextMenu.session
switch (action) {
case 'connect':
handleConnect(session)
break
case 'sftp':
handleOpenSftp(session)
break
case 'edit':
onEditSession(session)
break
case 'duplicate': {
const { id: _id, ...sessionWithoutId } = session
window.opsxApi?.sessionCreate({
...sessionWithoutId,
name: `${session.name} (${t('common:action.clone')})`,
}).then((result) => {
if (result?.success && result.session) {
addSession(result.session as Session)
}
})
break
}
case 'delete': {
const confirmed = await confirm({
title: t('sessions:deleteSession.title'),
message: t('sessions:deleteSession.message', { name: session.name }),
danger: true,
confirmText: t('sessions:deleteSession.confirm'),
})
if (confirmed) {
removeSession(session.id)
window.opsxApi?.sessionDelete(session.id)
}
break
}
}
setContextMenu(null)
}
return (
<>
<div
ref={sidebarRef}
className="relative flex flex-col h-full bg-dark-800/95 border-r border-dark-700/40"
style={{ width: sidebarWidth }}
>
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Header */}
<div className="p-3.5 border-b border-dark-700/30">
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
<img
src={appIconUrl}
alt="OpsXShell"
width={32}
height={32}
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
draggable={false}
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
OpsXShell
</div>
<div className="text-[10px] text-txt-faint mt-1">{t('common:sidebar.brandSub')}</div>
</div>
{onCollapse && (
<button
type="button"
onClick={onCollapse}
title={t('common:sidebar.collapse')}
aria-label={t('common:sidebar.collapseAria')}
className="group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:bg-dark-700/70 hover:text-txt-muted"
>
<ChevronsLeft className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" strokeWidth={1.75} />
</button>
)}
</div>
<div className="flex gap-1.5">
<button
type="button"
onClick={onQuickConnect}
title={t('sidebar.quickConnect')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
>
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
{t('sidebar.quickConnectShort')}
</button>
<button
type="button"
onClick={() => onNewSession()}
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 text-accent-green" />
{t('sidebar.newSessionShort')}
</button>
</div>
</div>
<>
<div className="p-3 border-b border-dark-700/20 space-y-2">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
placeholder={t('sidebar.searchPlaceholder')}
/>
</div>
<button
type="button"
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
aria-label={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
onClick={() => batchMode ? finishBatchMode() : setBatchMode(true)}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
batchMode
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-700 bg-dark-900 text-txt-muted hover:text-content-primary'
}`}
>
{batchMode ? <X className="h-4 w-4" /> : <ListChecks className="h-4 w-4" />}
</button>
</div>
{batchMode && (
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="text-txt-muted truncate">
{t('sidebar.batchSelected', { count: batchSelection.size })}
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
</span>
<button type="button" onClick={toggleSelectVisible} className="flex-shrink-0 text-accent-blue hover:underline">
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
? t('sidebar.deselectAllVisible')
: t('sidebar.selectAllVisible')}
</button>
</div>
)}
</div>
<SessionTree
sessions={batchMode ? batchableSessions : sessions}
extraGroups={settings.sessionGroups}
expandedGroups={expandedGroups}
selectedSessionId={selectedSessionId}
onToggleGroup={toggleGroup}
onSelectSession={handleSelect}
onConnectSession={handleConnect}
onContextMenu={handleContextMenu}
onGroupContextMenu={handleGroupContextMenu}
onBlankContextMenu={handleBlankContextMenu}
searchTerm={searchTerm}
loading={sessionsLoading}
onNewSession={() => onNewSession()}
multiSelect={batchMode}
selectedSessionIds={batchSelection}
onToggleSelection={toggleBatchSelection}
/>
{batchMode && (
<div className="grid grid-cols-1 gap-2 border-t border-dark-700/30 p-3">
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchCommand}
className="flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
>
<TerminalSquare className="h-3.5 w-3.5" />
{t('sidebar.batchExec')}
</button>
</div>
)}
</>
{/* Footer */}
<div className="p-3 border-t border-dark-700/20">
<button
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted hover:bg-dark-700 rounded transition-colors"
>
<Settings className="w-4 h-4" />
<span>{t('sidebar.settings')}</span>
</button>
</div>
{/* Context Menu */}
{contextMenu && (
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
>
{(contextMenu.kind === 'blank'
? [
{ label: t('contextMenu.blank.newGroup'), action: 'new-group' },
{ label: t('contextMenu.blank.newSession'), action: 'new-session' },
]
: contextMenu.kind === 'group'
? [
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
...(contextMenu.group !== DEFAULT_SESSION_GROUP
? [
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
{ label: t('contextMenu.group.deleteEmptyGroup'), action: 'delete-group', danger: true },
]
: []),
]
: [
{ label: t('contextMenu.session.open'), action: 'connect' },
{ label: t('common:tab.kind.sftp'), action: 'sftp' },
{ label: t('contextMenu.session.edit'), action: 'edit' },
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
]
).map((item) => (
<button
key={item.action}
onClick={() => handleContextMenuAction(item.action)}
className={`
w-full px-4 py-2 text-left text-sm transition-colors
${
item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-content-secondary hover:bg-dark-700'
}
`}
>
{item.label}
</button>
))}
</div>
)}
</div>
{/* Resize Handle — 双击也可折叠 */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={(e) => {
e.preventDefault()
onCollapse?.()
}}
title={t('sidebar.resizeHint')}
className={`
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
hover:bg-accent-blue/50 active:bg-accent-blue/70
hover:w-1.5 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)]
transition-all duration-150
${isResizing ? 'bg-accent-blue/70 w-1.5 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''}
`}
/>
</div>
{ConfirmDialogComponent}
{PromptDialogComponent}
</>
)
}