/** * 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>(new Set(['default'])) const [selectedSessionId, setSelectedSessionId] = useState(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>(new Set()) const requirePro = useEntitlementStore((s) => s.requirePro) const contextMenuRef = useRef(null) const sidebarRef = useRef(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() 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 ( <>
{/* Header */}
OpsXShell
OpsXShell
{t('common:sidebar.brandSub')}
{onCollapse && ( )}
<>
{batchMode && (
{t('sidebar.batchSelected', { count: batchSelection.size })} {sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
)}
onNewSession()} multiSelect={batchMode} selectedSessionIds={batchSelection} onToggleSelection={toggleBatchSelection} /> {batchMode && (
)} {/* Footer */}
{/* Context Menu */} {contextMenu && (
{(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) => ( ))}
)}
{/* Resize Handle — 双击也可折叠 */}
{ 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)]' : ''} `} />
{ConfirmDialogComponent} {PromptDialogComponent} ) }