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
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
+226
-121
@@ -3,45 +3,57 @@
|
||||
* 支持宽度拖拽调整
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Plus, Zap, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare, PanelLeftClose } from 'lucide-react'
|
||||
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 { useTerminalBroadcastStore } from '../../stores/terminalBroadcastStore'
|
||||
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: () => void
|
||||
onNewSession: (group?: string) => void
|
||||
onQuickConnect: () => void
|
||||
onEditSession: (session: Session) => void
|
||||
onCollapse?: () => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
|
||||
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
|
||||
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<{ x: number; y: number; session: Session } | 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 setBroadcastSelectedSessionIds = useTerminalBroadcastStore((s) => s.setSelectedSessionIds)
|
||||
const setBroadcastScope = useTerminalBroadcastStore((s) => s.setScope)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
@@ -125,14 +137,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
|
||||
const handleConnect = (session: Session) => {
|
||||
setSelectedSessionId(session.id)
|
||||
addTab({
|
||||
title: session.name,
|
||||
type: 'terminal',
|
||||
sessionId: 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}`,
|
||||
@@ -142,6 +153,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -150,7 +162,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
})
|
||||
}, [])
|
||||
|
||||
const visibleSessions = sessions.filter((session) => {
|
||||
/** 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) ||
|
||||
@@ -170,64 +188,148 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
})
|
||||
}, [visibleSessions])
|
||||
|
||||
const selectedSessions = sessions.filter((session) => batchSelection.has(session.id))
|
||||
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())
|
||||
setBroadcastSelectedSessionIds([])
|
||||
}, [setBroadcastSelectedSessionIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchMode) return
|
||||
setBroadcastSelectedSessionIds(Array.from(batchSelection))
|
||||
setBroadcastScope('selected-sessions')
|
||||
}, [batchMode, batchSelection, setBroadcastScope, setBroadcastSelectedSessionIds])
|
||||
|
||||
const handleBatchTerminal = useCallback(() => {
|
||||
for (const session of selectedSessions) {
|
||||
addTab({
|
||||
title: session.name,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
connectOnMount: true,
|
||||
})
|
||||
}
|
||||
// Keep selection for sync-input / further batch actions
|
||||
}, [addTab, selectedSessions])
|
||||
|
||||
const handleBatchSftp = useCallback(() => {
|
||||
for (const session of selectedSessions) {
|
||||
addTab({ title: `SFTP: ${session.name}`, type: 'sftp', sessionId: session.id })
|
||||
}
|
||||
}, [addTab, selectedSessions])
|
||||
}, [])
|
||||
|
||||
const handleBatchCommand = useCallback(() => {
|
||||
if (!requirePro('batchExec')) return
|
||||
addTab({
|
||||
title: '批量执行',
|
||||
title: t('sessions:batch.title'),
|
||||
type: 'batch',
|
||||
batchSessionIds: selectedSessions.map((session) => session.id),
|
||||
})
|
||||
}, [addTab, requirePro, selectedSessions])
|
||||
|
||||
const handleBatchSyncInput = useCallback(() => {
|
||||
if (!requirePro('broadcast')) return
|
||||
setBroadcastSelectedSessionIds(selectedSessions.map((s) => s.id))
|
||||
setBroadcastScope('selected-sessions')
|
||||
useTerminalBroadcastStore.getState().setEnabled(true)
|
||||
}, [requirePro, selectedSessions, setBroadcastScope, setBroadcastSelectedSessionIds])
|
||||
}, [addTab, requirePro, selectedSessions, t])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
|
||||
e.preventDefault()
|
||||
setSelectedSessionId(session.id)
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, session })
|
||||
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
|
||||
const session = contextMenu.session
|
||||
|
||||
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)
|
||||
@@ -242,7 +344,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
const { id: _id, ...sessionWithoutId } = session
|
||||
window.opsxApi?.sessionCreate({
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (副本)`,
|
||||
name: `${session.name} (${t('common:action.clone')})`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
addSession(result.session as Session)
|
||||
@@ -252,10 +354,10 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
}
|
||||
case 'delete': {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
message: `确定删除会话 "${session.name}"?`,
|
||||
title: t('sessions:deleteSession.title'),
|
||||
message: t('sessions:deleteSession.message', { name: session.name }),
|
||||
danger: true,
|
||||
confirmText: '删除',
|
||||
confirmText: t('sessions:deleteSession.confirm'),
|
||||
})
|
||||
if (confirmed) {
|
||||
removeSession(session.id)
|
||||
@@ -290,35 +392,39 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
|
||||
OpsXShell
|
||||
</div>
|
||||
<div className="text-[10px] text-txt-faint mt-1">SSH / SFTP 终端</div>
|
||||
<div className="text-[10px] text-txt-faint mt-1">{t('common:sidebar.brandSub')}</div>
|
||||
</div>
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title="折叠侧栏 (Ctrl/Cmd+B)"
|
||||
aria-label="折叠侧栏"
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
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"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
<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}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
快速连接
|
||||
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
|
||||
{t('sidebar.quickConnectShort')}
|
||||
</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"
|
||||
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="w-4 h-4" />
|
||||
<Plus className="h-3.5 w-3.5 text-accent-green" />
|
||||
{t('sidebar.newSessionShort')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,13 +436,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索会话..."
|
||||
placeholder={t('sidebar.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title={batchMode ? '退出批量操作' : '批量操作'}
|
||||
aria-label={batchMode ? '退出批量操作' : '批量操作'}
|
||||
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
|
||||
@@ -348,67 +454,47 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
</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">
|
||||
<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={sessions}
|
||||
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}
|
||||
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">
|
||||
<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="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"
|
||||
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" />
|
||||
批量执行 · Pro
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchSyncInput}
|
||||
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded border border-accent-orange/40 bg-accent-orange/10 text-xs font-medium text-accent-orange disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
同步输入 · Pro
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchTerminal}
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded bg-dark-700 text-xs font-medium text-content-primary 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
|
||||
{t('sidebar.batchExec')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -417,11 +503,11 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700/20">
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
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>设置</span>
|
||||
<span>{t('sidebar.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -430,23 +516,41 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
<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]"
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
|
||||
>
|
||||
{[
|
||||
{ label: '打开终端', action: 'connect' },
|
||||
{ label: 'SFTP 文件', action: 'sftp' },
|
||||
{ label: '编辑', action: 'edit' },
|
||||
{ label: '克隆', action: 'duplicate' },
|
||||
{ label: '删除', action: 'delete', danger: true },
|
||||
].map((item) => (
|
||||
{(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.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -464,7 +568,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
e.preventDefault()
|
||||
onCollapse?.()
|
||||
}}
|
||||
title="拖拽调整宽度,双击折叠"
|
||||
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
|
||||
@@ -475,6 +579,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
/>
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
{PromptDialogComponent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user