feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user