Files
opsxshell/src/components/layout/Sidebar.tsx
T

438 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Sidebar - 侧边栏组件
* 支持宽度拖拽调整
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { Plus, Zap, Server, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare } from 'lucide-react'
import { useSessionStore, Session } 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 BatchCommandDialog from '../sessions/BatchCommandDialog'
const MIN_SIDEBAR_WIDTH = 180
const MAX_SIDEBAR_WIDTH = 400
interface SidebarProps {
onNewSession: () => void
onQuickConnect: () => void
onEditSession: (session: Session) => void
}
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const [searchTerm, setSearchTerm] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; session: Session } | 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 [batchCommandSessions, setBatchCommandSessions] = useState<Session[]>([])
const [batchCommandOpen, setBatchCommandOpen] = useState(false)
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)
addTab({
title: session.name,
type: 'terminal',
sessionId: session.id,
})
}
const handleOpenSftp = (session: Session) => {
setSelectedSessionId(session.id)
addTab({
title: `SFTP: ${session.name}`,
type: 'sftp',
sessionId: session.id,
})
}
const toggleBatchSelection = useCallback((session: Session) => {
setBatchSelection((current) => {
const next = new Set(current)
if (next.has(session.id)) next.delete(session.id)
else next.add(session.id)
return next
})
}, [])
const visibleSessions = sessions.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 = sessions.filter((session) => batchSelection.has(session.id))
const finishBatchMode = useCallback(() => {
setBatchMode(false)
setBatchSelection(new Set())
}, [])
const handleBatchTerminal = useCallback(() => {
for (const session of selectedSessions) {
addTab({
title: session.name,
type: 'terminal',
sessionId: session.id,
connectOnMount: true,
})
}
finishBatchMode()
}, [addTab, finishBatchMode, selectedSessions])
const handleBatchSftp = useCallback(() => {
for (const session of selectedSessions) {
addTab({ title: `SFTP: ${session.name}`, type: 'sftp', sessionId: session.id })
}
finishBatchMode()
}, [addTab, finishBatchMode, selectedSessions])
const handleBatchCommand = useCallback(() => {
setBatchCommandSessions(selectedSessions)
setBatchCommandOpen(true)
finishBatchMode()
}, [finishBatchMode, selectedSessions])
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
e.preventDefault()
setSelectedSessionId(session.id)
setContextMenu({ x: e.clientX, y: e.clientY, session })
}
const handleContextMenuAction = async (action: string) => {
if (!contextMenu) 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} (副本)`,
}).then((result) => {
if (result?.success && result.session) {
addSession(result.session as Session)
}
})
break
}
case 'delete': {
const confirmed = await confirm({
title: '确认删除',
message: `确定删除会话 "${session.name}"?`,
danger: true,
confirmText: '删除',
})
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">
<div className="w-8 h-8 bg-gradient-to-br from-accent-blue to-accent-blue/70 rounded-lg flex items-center justify-center shadow-sm shadow-accent-blue/25">
<Server className="w-4 h-4 text-white" />
</div>
<div className="min-w-0">
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
OpsXShell
</div>
<div className="text-[10px] text-txt-faint mt-1">远程运维工作台</div>
</div>
</div>
<div className="flex gap-1.5">
<button
onClick={onQuickConnect}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue hover:bg-accent-blue-hover rounded-lg text-xs font-medium text-white transition-colors shadow-sm shadow-accent-blue/20"
>
<Zap className="w-3.5 h-3.5" />
快速连接
</button>
<button
onClick={onNewSession}
title="新建会话"
className="flex items-center justify-center px-2.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-lg text-sm font-medium transition-colors text-content-primary"
>
<Plus className="w-4 h-4" />
</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="搜索会话..."
/>
</div>
<button
type="button"
title={batchMode ? '退出批量操作' : '批量操作'}
aria-label={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 text-[11px]">
<span className="text-txt-muted">已选 {batchSelection.size} 个会话</span>
<button type="button" onClick={toggleSelectVisible} className="text-accent-blue hover:underline">
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
? '取消当前结果'
: '全选当前结果'}
</button>
</div>
)}
</div>
<SessionTree
sessions={sessions}
expandedGroups={expandedGroups}
selectedSessionId={selectedSessionId}
onToggleGroup={toggleGroup}
onSelectSession={handleSelect}
onConnectSession={handleConnect}
onContextMenu={handleContextMenu}
searchTerm={searchTerm}
loading={sessionsLoading}
onNewSession={onNewSession}
multiSelect={batchMode}
selectedSessionIds={batchSelection}
onToggleSelection={toggleBatchSelection}
/>
{batchMode && (
<div className="grid grid-cols-2 gap-2 border-t border-dark-700/30 p-3">
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchCommand}
className="col-span-2 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" />
批量命令
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchTerminal}
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"
>
<Terminal className="h-3.5 w-3.5" />
打开终端
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchSftp}
className="flex h-9 items-center justify-center gap-1.5 rounded border border-dark-600 bg-dark-700 text-xs font-medium text-content-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<FolderSync className="h-3.5 w-3.5" />
打开 SFTP
</button>
</div>
)}
</>
{/* Footer */}
<div className="p-3 border-t border-dark-700/20">
<button
onClick={() => addTab({ title: '设置', 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>设置</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-[140px]"
>
{[
{ label: '打开终端', action: 'connect' },
{ label: 'SFTP 文件', action: 'sftp' },
{ label: '编辑', action: 'edit' },
{ label: '克隆', action: 'duplicate' },
{ label: '删除', 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}
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}
<BatchCommandDialog
isOpen={batchCommandOpen}
sessions={batchCommandSessions}
onClose={() => setBatchCommandOpen(false)}
/>
</>
)
}