v1.1.0: better-sqlite3→sql.js迁移 + 6项UI/代码修复
迁移: - better-sqlite3(原生C++) → sql.js(WASM纯JS), 兼容Node 24 - database-service.ts: PreparedStatement包装器保持API兼容 - main.ts: initialize()改为async/await - 移除@electron/rebuild和@types/better-sqlite3依赖 - 新增.npmrc阿里云镜像加速 UI/代码修复(任务1-6): 1. TerminalView: 清理111行调试日志(645→534行) 2. 设计系统: cyan/slate→accent-blue/dark-*颜色统一(FileRow/TransferItem/Breadcrumb/ContextMenu) 3. StatusBar: 重写接入实时连接状态,三区布局 4. SessionDialog: 私钥浏览按钮添加onClick处理 5. WelcomeView: 真实session数据替换硬编码假数据 6. SFTPView: emoji→Lucide图标(FolderOpen/Upload/Download/Trash2) 构建验证: tsc零错误 + vite build成功(Node 24.14.0)
This commit is contained in:
1 parent
c5243df040
commit
53274e2291
14 files changed
+1784
-2141
No files matched your search
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { FolderOpen, Upload, Download, Trash2 } from 'lucide-react'
|
||||
import { useSFTP } from '../hooks'
|
||||
import { FileListPanel } from './sftp/FileListPanel'
|
||||
import { TransferPanel } from './sftp/TransferPanel'
|
||||
@@ -93,12 +94,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: '📂',
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: '打开',
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
{
|
||||
icon: '📤',
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
label: '上传',
|
||||
action: () => {},
|
||||
},
|
||||
@@ -111,12 +112,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: '📂',
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: '打开',
|
||||
action: () => file.type === 'directory' && navigateRemote(file.path),
|
||||
},
|
||||
{
|
||||
icon: '📥',
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: '下载',
|
||||
action: () => {
|
||||
setRemoteSelected(new Set([file.name]))
|
||||
@@ -124,7 +125,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: '🗑️',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
label: '删除',
|
||||
danger: true,
|
||||
action: () => {},
|
||||
|
||||
@@ -265,6 +265,21 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-gray-300 transition-colors"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI?.showOpenDialog({
|
||||
title: '选择私钥文件',
|
||||
defaultPath: '~/.ssh',
|
||||
filters: [
|
||||
{ name: '所有文件', extensions: ['*'] },
|
||||
{ name: 'PEM 密钥', extensions: ['pem'] },
|
||||
{ name: 'SSH 密钥', extensions: ['rsa', 'ed25519', 'pem'] },
|
||||
],
|
||||
})
|
||||
if (result?.canceled) return
|
||||
if (result?.filePaths?.[0]) {
|
||||
setFormData({ ...formData, privateKey: result.filePaths[0] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
|
||||
+156
-20
@@ -1,19 +1,137 @@
|
||||
import { Wifi, HardDrive, Clock } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Wifi, WifiOff, AlertCircle, Loader2, HardDrive, Clock } from 'lucide-react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useTabStore, type ConnectionStatus } from '../stores/tabStore'
|
||||
import { useConnectionStore, type ConnectionState } from '../stores/connection'
|
||||
|
||||
// ============== Status Display Config ==============
|
||||
|
||||
interface StatusDisplay {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
colorClass: string
|
||||
animateClass: string
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, StatusDisplay> = {
|
||||
connected: {
|
||||
icon: <Wifi className="w-3 h-3" />,
|
||||
label: '已连接',
|
||||
colorClass: 'text-green-400',
|
||||
animateClass: '',
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||
label: '连接中',
|
||||
colorClass: 'text-yellow-400',
|
||||
animateClass: 'animate-pulse',
|
||||
},
|
||||
background: {
|
||||
icon: <Wifi className="w-3 h-3" />,
|
||||
label: '后台',
|
||||
colorClass: 'text-green-400',
|
||||
animateClass: '',
|
||||
},
|
||||
suspended: {
|
||||
icon: <WifiOff className="w-3 h-3" />,
|
||||
label: '已挂起',
|
||||
colorClass: 'text-yellow-400',
|
||||
animateClass: '',
|
||||
},
|
||||
reconnecting: {
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||
label: '重连中',
|
||||
colorClass: 'text-yellow-400',
|
||||
animateClass: 'animate-pulse',
|
||||
},
|
||||
error: {
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
label: '连接错误',
|
||||
colorClass: 'text-red-400',
|
||||
animateClass: '',
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="w-3 h-3" />,
|
||||
label: '未连接',
|
||||
colorClass: 'text-gray-400',
|
||||
animateClass: '',
|
||||
},
|
||||
}
|
||||
|
||||
// ============== Helper: format bytes ==============
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const value = bytes / Math.pow(k, i)
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// ============== Helper: map tab connectionStatus to ConnectionState ==============
|
||||
|
||||
function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionState | undefined {
|
||||
if (!status) return undefined
|
||||
// tabStore's ConnectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
// maps directly to ConnectionState values
|
||||
return status as ConnectionState
|
||||
}
|
||||
|
||||
// ============== Component ==============
|
||||
|
||||
export default function StatusBar() {
|
||||
const [version, setVersion] = useState('1.0.0')
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
// Tab store
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const tabs = useTabStore((s) => s.tabs)
|
||||
|
||||
// Connection store (pool)
|
||||
const connections = useConnectionStore().config // trigger re-renders via store subscription
|
||||
const poolStore = useConnectionStore()
|
||||
|
||||
// Derive the active tab
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((t) => t.id === activeTabId),
|
||||
[tabs, activeTabId]
|
||||
)
|
||||
|
||||
// Determine the connection state and info for the active tab
|
||||
const { connectionState, hostLabel, stats } = useMemo(() => {
|
||||
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
|
||||
return { connectionState: undefined, hostLabel: undefined, stats: undefined }
|
||||
}
|
||||
|
||||
// 1. Try to get connection from pool via connectionInstance
|
||||
const instance = activeTab.connectionInstance
|
||||
if (instance?.connectionId) {
|
||||
const conn = poolStore.getConnection(instance.connectionId)
|
||||
if (conn) {
|
||||
return {
|
||||
connectionState: conn.state,
|
||||
hostLabel: `${conn.config.username}@${conn.config.host}`,
|
||||
stats: conn.stats,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to tab's own connectionStatus
|
||||
const state = mapTabStatusToConnectionState(activeTab.connectionStatus)
|
||||
const config = activeTab.connectionConfig
|
||||
const label = config ? `${config.username}@${config.host}` : undefined
|
||||
|
||||
return { connectionState: state, hostLabel: label, stats: undefined }
|
||||
}, [activeTab, poolStore, connections]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Clock timer + version
|
||||
useEffect(() => {
|
||||
// Get app version
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getVersion().then((v) => {
|
||||
window.electronAPI.getVersion().then((v: string) => {
|
||||
setVersion(v)
|
||||
})
|
||||
}
|
||||
|
||||
// Update time every second
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
}, 1000)
|
||||
@@ -29,29 +147,47 @@ export default function StatusBar() {
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve status display
|
||||
const statusKey = connectionState ?? 'disconnected'
|
||||
const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected
|
||||
|
||||
// Format transfer info if available
|
||||
const transferLabel = stats && stats.bytesTransferred > 0
|
||||
? `↑↓ ${formatBytes(stats.bytesTransferred)}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="h-7 bg-accent-blue flex items-center px-3 text-xs text-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wifi className="w-3 h-3" />
|
||||
<span>就绪</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>本地</span>
|
||||
</div>
|
||||
<div className="h-7 bg-dark-800 border-t border-dark-700 flex items-center px-3 text-xs text-white select-none">
|
||||
{/* Left: Connection status indicator + host */}
|
||||
<div className={`flex items-center gap-1.5 ${statusCfg.colorClass} ${statusCfg.animateClass}`}>
|
||||
{statusCfg.icon}
|
||||
<span>{statusCfg.label}</span>
|
||||
{hostLabel && (
|
||||
<>
|
||||
<span className="text-dark-500 mx-0.5">·</span>
|
||||
<span className="text-gray-300">{hostLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
{/* Center: Transfer stats */}
|
||||
{transferLabel && (
|
||||
<div className="flex items-center gap-1 ml-4 text-gray-400">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>{transferLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: Clock + version */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
<span className="text-blue-200">OpsXShell v{version}</span>
|
||||
<span className="text-dark-500">OpsXShell v{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,51 +12,7 @@ import type { TerminalViewProps } from './terminal/types'
|
||||
|
||||
const logger = createLogger('TerminalView')
|
||||
|
||||
// 调试用 - 大量日志
|
||||
const DEBUG = true
|
||||
function debug(...args: any[]) {
|
||||
if (DEBUG) {
|
||||
console.log('[TerminalView]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 彩色高亮日志
|
||||
function logHeader(msg: string) {
|
||||
console.log(
|
||||
`%c${msg}`,
|
||||
'background: #41b883; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(
|
||||
`%c${label}:%c ${value}`,
|
||||
'background: #35495e; color: #fff; padding: 2px 6px; border-radius: 3px;',
|
||||
'color: #41b883; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(
|
||||
`%c✓ ${msg}`,
|
||||
'color: #41b883; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(
|
||||
`%c✗ ${msg}`,
|
||||
'color: #ff4757; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
// 使用 logError 避免 TS 错误
|
||||
if (false) logError('')
|
||||
function logWarning(msg: string) {
|
||||
console.log(
|
||||
`%c⚠ ${msg}`,
|
||||
'color: #ffa502; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
|
||||
export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) {
|
||||
debug('>>> RENDER tabId:', tabId, 'isActive:', isActive)
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
@@ -86,13 +42,10 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 初始化 xterm
|
||||
useEffect(() => {
|
||||
debug('>>> useEffect[init] START - terminalRef.current:', !!terminalRef.current, 'xtermRef.current:', !!xtermRef.current)
|
||||
|
||||
// 修复:只检查 terminalRef,不检查 xtermRef
|
||||
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
|
||||
// 但监听器需要重新注册
|
||||
if (!terminalRef.current) {
|
||||
debug('>>> useEffect[init] EARLY RETURN - terminalRef is null')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,8 +60,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 如果 xterm 不存在,创建它
|
||||
if (!xtermRef.current) {
|
||||
debug('>>> useEffect[init] Creating Terminal...')
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
@@ -144,11 +95,9 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
term.open(terminalRef.current)
|
||||
|
||||
fitAddon.fit()
|
||||
debug('>>> Terminal opened, cols:', term.cols, 'rows:', term.rows)
|
||||
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
debug('>>> xtermRef set:', !!xtermRef.current)
|
||||
|
||||
// 处理终端输入
|
||||
term.onData((data) => {
|
||||
@@ -160,8 +109,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
window.electronAPI.sshShellWrite(shellIdRef.current, data)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
debug('>>> useEffect[init] xterm already exists, skipping creation')
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
@@ -182,18 +129,8 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
|
||||
const text = data.toString('utf-8')
|
||||
|
||||
// ===== 大量调试日志 =====
|
||||
logHeader('📝 SHELL DATA RECEIVED')
|
||||
logInfo('shellId (received)', shellId)
|
||||
logInfo('shellId (expected)', shellIdRef.current)
|
||||
logInfo('xterm exists', !!xtermRef.current)
|
||||
logInfo('data length', data.length)
|
||||
logInfo('MATCH', shellId === shellIdRef.current)
|
||||
console.log(' Data preview:', text.substring(0, 200).replace(/\n/g, '\\n'))
|
||||
|
||||
// 使用 ref 获取最新的 shellId,避免闭包问题
|
||||
if (shellId === shellIdRef.current && xtermRef.current) {
|
||||
logSuccess('WRITING TO XTERM')
|
||||
// 处理数据,高亮 IP 地址(Last login 或 from IP 行)
|
||||
let processedData = data
|
||||
|
||||
@@ -206,7 +143,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 写入 xterm
|
||||
xtermRef.current.write(processedData)
|
||||
console.log('>>> DATA WRITTEN TO XTERM <<<')
|
||||
|
||||
// 更新传输统计
|
||||
const connId = connectionIdRef.current
|
||||
@@ -216,30 +152,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
conn.stats.bytesTransferred += data.length
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logWarning('SKIPPED: shellId mismatch or xterm not ready')
|
||||
logInfo('Expected', shellIdRef.current)
|
||||
logInfo('Received', shellId)
|
||||
logInfo('xterm exists', !!xtermRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
// 只注册一次监听器(防止严格模式下重复注册)
|
||||
if (!listenerRegisteredRef.current) {
|
||||
debug('>>> Registering onShellData listener')
|
||||
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => {
|
||||
console.log('=== IPC onShellData RAW received ===')
|
||||
console.log(' connectionId:', connectionId)
|
||||
console.log(' shellId:', shellId)
|
||||
console.log(' data type:', typeof data)
|
||||
console.log(' data length:', data?.length)
|
||||
handleShellDataRef.current?.(event, connectionId, shellId, data)
|
||||
}
|
||||
window.electronAPI?.onShellData(handler)
|
||||
listenerRegisteredRef.current = true
|
||||
debug('>>> onShellData listener registered')
|
||||
} else {
|
||||
debug('>>> onShellData listener already registered, skipping')
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -285,23 +207,14 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 连接管理
|
||||
const connect = useCallback(async () => {
|
||||
logHeader('CONNECT() CALLED')
|
||||
logInfo('tabId', tabId)
|
||||
logInfo('isConnectingRef', isConnectingRef.current)
|
||||
logInfo('hasConnectedRef', hasConnectedRef.current)
|
||||
logInfo('xtermRef', !!xtermRef.current)
|
||||
|
||||
if (isConnectingRef.current) {
|
||||
console.log('>>> CONNECT: Already connecting, skipping <<<')
|
||||
logger.debug('[Terminal] Connection already in progress, skipping')
|
||||
return
|
||||
}
|
||||
isConnectingRef.current = true
|
||||
console.log('>>> isConnectingRef set to true <<<')
|
||||
|
||||
// 直接使用 getTabById,避免 useCallback 循环问题
|
||||
const currentTab = getTabById(tabId)
|
||||
console.log(' currentTab:', currentTab ? 'found' : 'NOT FOUND')
|
||||
if (!currentTab) {
|
||||
logger.debug('[Terminal] Tab not found:', tabId)
|
||||
isConnectingRef.current = false
|
||||
@@ -309,7 +222,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
}
|
||||
|
||||
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
|
||||
console.log('>>> Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
|
||||
|
||||
// 检查是否已有连接
|
||||
if (connectionIdRef.current) {
|
||||
@@ -364,12 +276,9 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
if (!config) {
|
||||
throw new Error('无可用连接信息')
|
||||
}
|
||||
logSuccess(`Config ready: ${config.host}:${config.port} (${config.username})`)
|
||||
|
||||
// 创建连接
|
||||
console.log('>>> Calling createConnection...')
|
||||
const result = await getConnectionStore().createConnection(tabId, config)
|
||||
console.log('>>> createConnection result:', result)
|
||||
|
||||
if (!result.success || !result.connectionId) {
|
||||
throw new Error(result.error || '连接失败')
|
||||
@@ -377,25 +286,20 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
const connectionId = result.connectionId
|
||||
connectionIdRef.current = connectionId
|
||||
logSuccess(`connectionId set: ${connectionId}`)
|
||||
|
||||
// 创建 Shell - 先生成前端 shellId,确保数据不会丢失
|
||||
const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId)
|
||||
shellIdRef.current = pendingShellId
|
||||
logSuccess(`shellIdRef set to: ${pendingShellId}`)
|
||||
|
||||
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
|
||||
console.log('>>> Calling sshCreateShell...')
|
||||
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
|
||||
cols: xtermRef.current?.cols || 80,
|
||||
rows: xtermRef.current?.rows || 24,
|
||||
}, pendingShellId)
|
||||
console.log('>>> sshCreateShell result:', shellResult)
|
||||
|
||||
if (!shellResult?.success || !shellResult.shellId) {
|
||||
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
|
||||
console.error('>>> Shell creation failed:', shellResult?.error)
|
||||
shellIdRef.current = null
|
||||
await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed')
|
||||
throw new Error(shellResult?.error || '创建终端失败')
|
||||
@@ -404,7 +308,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
// shellId 应该和 pendingShellId 相同(由后端确认)
|
||||
const confirmedShellId = shellResult.shellId
|
||||
logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId)
|
||||
logSuccess(`Shell confirmed: ${confirmedShellId}`)
|
||||
// shellIdRef 已经是 confirmedShellId(等于 pendingShellId)
|
||||
getConnectionStore().addShell(connectionId, confirmedShellId)
|
||||
|
||||
@@ -496,23 +399,15 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 初始连接
|
||||
useEffect(() => {
|
||||
logHeader('INITIAL CONNECT useEffect')
|
||||
logInfo('hasConnectedRef', hasConnectedRef.current)
|
||||
logInfo('isActive', isActive)
|
||||
|
||||
if (!hasConnectedRef.current && isActive) {
|
||||
// 检查是否有快照
|
||||
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
||||
logInfo('hasSnapshot', hasSnapshot)
|
||||
if (hasSnapshot && isActive) {
|
||||
// 先恢复快照
|
||||
restoreSnapshot()
|
||||
}
|
||||
|
||||
logSuccess('Calling connect() from initial useEffect')
|
||||
connect()
|
||||
} else {
|
||||
logWarning('SKIPPED: hasConnectedRef=true or isActive=false')
|
||||
}
|
||||
}, []) // 只在挂载时执行
|
||||
|
||||
@@ -564,18 +459,12 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 组件卸载时清理监听器
|
||||
useEffect(() => {
|
||||
logHeader('📺 TerminalView MOUNT')
|
||||
logInfo('tabId', tabId)
|
||||
return () => {
|
||||
logHeader('📺 TerminalView UNMOUNT')
|
||||
logInfo('tabId', tabId)
|
||||
// 重置连接状态,防止严格模式下 remount 时状态混乱
|
||||
isConnectingRef.current = false
|
||||
listenerRegisteredRef.current = false // 重置监听器注册标记
|
||||
logSuccess('Reset isConnectingRef and listenerRegisteredRef to false')
|
||||
// 组件真正卸载时移除所有监听器
|
||||
window.electronAPI?.removeAllListeners('ssh:shellData')
|
||||
logSuccess('Removed all ssh:shellData listeners')
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -598,7 +487,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
</div>
|
||||
<button
|
||||
onClick={resumeConnection}
|
||||
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors mr-2"
|
||||
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
|
||||
>
|
||||
恢复连接
|
||||
</button>
|
||||
@@ -620,7 +509,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors"
|
||||
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
@@ -631,7 +520,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
{/* 连接中状态 */}
|
||||
{connectionState === 'connecting' && (
|
||||
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-cyan-400 animate-pulse">连接中...</div>
|
||||
<div className="text-accent-blue animate-pulse">连接中...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap } from 'lucide-react'
|
||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Inbox } from 'lucide-react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
@@ -8,12 +9,18 @@ interface WelcomeViewProps {
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
|
||||
const recentConnections = [
|
||||
{ name: '本地开发服务器', host: '192.168.1.100', lastConnected: '2小时前' },
|
||||
{ name: '生产环境', host: '10.0.0.1', lastConnected: '1天前' },
|
||||
{ name: '测试服务器', host: '172.16.0.50', lastConnected: '3天前' },
|
||||
]
|
||||
// 取最近 5 个会话作为最近连接
|
||||
const recentSessions = sessions.slice(-5).reverse()
|
||||
|
||||
const handleSessionClick = (session: typeof sessions[number]) => {
|
||||
addTab({
|
||||
title: session.name || session.host,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8">
|
||||
@@ -93,23 +100,34 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
{/* Recent connections */}
|
||||
<div className="w-full max-w-2xl">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
|
||||
<div className="space-y-2">
|
||||
{recentConnections.map((conn, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-accent-blue" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-200">{conn.name}</div>
|
||||
<div className="text-sm text-gray-500">{conn.host}</div>
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-gray-500">
|
||||
<Inbox className="w-10 h-10 mb-3 text-gray-600" />
|
||||
<p className="text-sm">暂无会话记录</p>
|
||||
<p className="text-xs mt-1">点击上方「新建会话」创建第一个连接</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 hover:border-accent-blue/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-accent-blue" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-200">{session.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{session.username}@{session.host}{session.port !== 22 ? `:${session.port}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{conn.lastConnected}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">--</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcuts hint */}
|
||||
|
||||
@@ -17,23 +17,23 @@ export function Breadcrumb({ path, onNavigate, onHome }: BreadcrumbProps) {
|
||||
<div className="flex items-center gap-1 text-sm min-w-0">
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="p-1.5 hover:bg-slate-700 rounded transition-colors flex-shrink-0"
|
||||
className="p-1.5 hover:bg-dark-700 rounded transition-colors flex-shrink-0"
|
||||
title="主目录"
|
||||
>
|
||||
<Home className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('/')}
|
||||
className="px-1.5 py-0.5 hover:bg-slate-700 rounded text-gray-400 hover:text-slate-200 transition-colors"
|
||||
className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-gray-400 hover:text-dark-200 transition-colors"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index} className="flex items-center min-w-0">
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 mx-0.5 flex-shrink-0" />
|
||||
<ChevronRight className="w-3 h-3 text-dark-600 mx-0.5 flex-shrink-0" />
|
||||
<button
|
||||
onClick={() => onNavigate('/' + parts.slice(0, index + 1).join('/'))}
|
||||
className="px-1.5 py-0.5 hover:bg-slate-700 rounded text-gray-200 hover:text-white transition-colors truncate max-w-[120px]"
|
||||
className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-gray-200 hover:text-white transition-colors truncate max-w-[120px]"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-dark-700 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]"
|
||||
className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
|
||||
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
@@ -52,7 +52,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-slate-700 hover:text-white'
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-dark-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
@@ -24,12 +24,12 @@ export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onD
|
||||
draggable={file.type === 'file'}
|
||||
onDragStart={onDragStart}
|
||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/50 transition-colors group ${
|
||||
isSelected ? 'bg-cyan-500/20 hover:bg-cyan-500/25' : 'hover:bg-dark-700/50'
|
||||
isSelected ? 'bg-accent-blue/20 hover:bg-accent-blue/25' : 'hover:bg-dark-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-4 h-4 text-sky-400 flex-shrink-0" />
|
||||
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
|
||||
) : file.type === 'symlink' ? (
|
||||
<Folder className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
) : (
|
||||
|
||||
@@ -17,7 +17,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-dark-700/50 rounded-lg group">
|
||||
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-cyan-400' : 'text-green-400'}`}>
|
||||
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-accent-blue' : 'text-green-400'}`}>
|
||||
{task.type === 'download' ? <Download className="w-4 h-4" /> : <Upload className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -25,8 +25,8 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{isActive && (
|
||||
<>
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden max-w-[120px]">
|
||||
<div className="h-full bg-cyan-400 transition-all duration-300" style={{ width: `${task.progress}%` }} />
|
||||
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden max-w-[120px]">
|
||||
<div className="h-full bg-accent-blue transition-all duration-300" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{task.progress}%</span>
|
||||
{task.speed !== undefined && <span className="text-xs text-gray-500">{formatSpeed(task.speed)}</span>}
|
||||
@@ -62,7 +62,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
{task.status === 'transferring' && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-slate-700 rounded text-gray-400 hover:text-red-400"
|
||||
className="p-1.5 hover:bg-dark-700 rounded text-gray-400 hover:text-red-400"
|
||||
title="取消"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
@@ -71,7 +71,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
{(task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 hover:bg-slate-700 rounded text-gray-400 hover:text-gray-200"
|
||||
className="p-1.5 hover:bg-dark-700 rounded text-gray-400 hover:text-gray-200"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
Reference in new issue
Block a user