v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨
核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'
UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线
架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化
质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
This commit is contained in:
1 parent
37e65be823
commit
514d62644e
77 files changed
+2563
-931
No files matched your search
@@ -281,11 +281,11 @@ export default function ConnectionPanel() {
|
||||
{/* Panel */}
|
||||
<div
|
||||
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
|
||||
bg-dark-800/95 backdrop-blur border-l border-dark-700
|
||||
bg-dark-800/95 backdrop-blur border-l border-dark-700/30
|
||||
flex flex-col shadow-xl animate-slide-in-right"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700 shrink-0">
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-accent-blue" />
|
||||
连接管理
|
||||
@@ -300,7 +300,7 @@ export default function ConnectionPanel() {
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="px-4 py-3 border-b border-dark-700 shrink-0">
|
||||
<div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
|
||||
<StatsOverview
|
||||
total={stats.total}
|
||||
connected={stats.connected}
|
||||
|
||||
@@ -132,8 +132,8 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
|
||||
} else {
|
||||
setError(result?.error || '启动失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '启动失败')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.type}
|
||||
onClick={() => setNewRule({ ...newRule, type: t.type as any })}
|
||||
onClick={() => setNewRule({ ...newRule, type: t.type as 'local' | 'remote' | 'dynamic' })}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
newRule.type === t.type
|
||||
? 'bg-accent-blue/20 border-accent-blue text-white'
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function QuickConnectDialog({ isOpen, onClose }: QuickConnectDial
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message || '连接失败')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 使用子组件拆分后的简化版本
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
FolderOpen, Upload, Download, Trash2,
|
||||
Edit3, Copy, FileText, ShieldCheck,
|
||||
@@ -28,9 +28,10 @@ import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.typ
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPViewProps) {
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
|
||||
const {
|
||||
sessions,
|
||||
localPath,
|
||||
@@ -83,6 +84,21 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
|
||||
const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
|
||||
|
||||
// Auto-connect when sessionId is provided and not connected
|
||||
const autoConnectAttempted = useRef(false)
|
||||
const connectRef = useRef(connect)
|
||||
connectRef.current = connect
|
||||
const isConnectedRef = useRef(isConnected)
|
||||
isConnectedRef.current = isConnected
|
||||
const isConnectingRef = useRef(isConnecting)
|
||||
isConnectingRef.current = isConnecting
|
||||
useEffect(() => {
|
||||
if (sessionId && !isConnectedRef.current && !isConnectingRef.current && !autoConnectAttempted.current) {
|
||||
autoConnectAttempted.current = true
|
||||
connectRef.current(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Sort handler
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
|
||||
@@ -18,10 +18,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
username: 'root',
|
||||
authType: 'password' as AuthType,
|
||||
password: '',
|
||||
privateKey: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
@@ -49,10 +49,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
@@ -73,10 +73,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
username: formData.username.trim() || 'root',
|
||||
authType: formData.authType,
|
||||
password: formData.password,
|
||||
privateKey: formData.privateKey,
|
||||
privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
|
||||
passphrase: formData.passphrase,
|
||||
group: formData.group,
|
||||
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
|
||||
@@ -148,11 +148,11 @@ export default function SettingsView() {
|
||||
].map((theme) => (
|
||||
<button
|
||||
key={theme.value}
|
||||
onClick={() => setSetting('theme', theme.value as any)}
|
||||
onClick={() => setSetting('theme', theme.value as 'dark' | 'light' | 'system')}
|
||||
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||
settings.theme === theme.value
|
||||
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-gray-300'
|
||||
: 'border-dark-700/30 hover:border-dark-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{theme.label}
|
||||
@@ -165,8 +165,8 @@ export default function SettingsView() {
|
||||
<SettingSectionTitle>语言</SettingSectionTitle>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSetting('language', e.target.value as any)}
|
||||
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
onChange={(e) => setSetting('language', e.target.value as 'zh-CN' | 'en-US')}
|
||||
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
@@ -187,7 +187,7 @@ export default function SettingsView() {
|
||||
<select
|
||||
value={settings.fontFamily}
|
||||
onChange={(e) => setSetting('fontFamily', e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option>JetBrains Mono</option>
|
||||
<option>Fira Code</option>
|
||||
@@ -200,7 +200,7 @@ export default function SettingsView() {
|
||||
<select
|
||||
value={settings.fontSize}
|
||||
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
|
||||
<option key={size} value={size}>{size}px</option>
|
||||
@@ -216,11 +216,11 @@ export default function SettingsView() {
|
||||
{Object.entries(themes).map(([key, theme]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSetting('terminalTheme', key as any)}
|
||||
onClick={() => setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')}
|
||||
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||
settings.terminalTheme === key
|
||||
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-gray-300'
|
||||
: 'border-dark-700/30 hover:border-dark-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -240,7 +240,7 @@ export default function SettingsView() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<div className="overflow-hidden rounded-lg border border-dark-700">
|
||||
<div className="overflow-hidden rounded-lg border border-dark-700/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-dark-800/60 text-gray-400 text-left">
|
||||
@@ -253,7 +253,7 @@ export default function SettingsView() {
|
||||
{SHORTCUTS.map((s, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-t border-dark-700/60 hover:bg-dark-800/40 transition-colors"
|
||||
className="border-t border-dark-700/20 hover:bg-dark-800/40 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-gray-200">{s.action}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
@@ -293,7 +293,7 @@ export default function SettingsView() {
|
||||
type="number"
|
||||
value={settings.defaultPort}
|
||||
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
|
||||
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function SettingsView() {
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
|
||||
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function SettingsView() {
|
||||
max={50}
|
||||
value={settings.maxConnections || 10}
|
||||
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
|
||||
</div>
|
||||
@@ -337,7 +337,7 @@ export default function SettingsView() {
|
||||
max={20}
|
||||
value={settings.maxBackgroundConnections || 5}
|
||||
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
|
||||
</div>
|
||||
@@ -352,7 +352,7 @@ export default function SettingsView() {
|
||||
max={120}
|
||||
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
|
||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
|
||||
</div>
|
||||
@@ -364,7 +364,7 @@ export default function SettingsView() {
|
||||
max={240}
|
||||
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
|
||||
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
|
||||
</div>
|
||||
@@ -442,7 +442,7 @@ export default function SettingsView() {
|
||||
['Tailwind CSS', '3.3+'],
|
||||
['Lucide React', '0.294+'],
|
||||
].map(([name, ver]) => (
|
||||
<div key={name} className="flex justify-between py-1 border-b border-dark-700/40">
|
||||
<div key={name} className="flex justify-between py-1 border-b border-dark-700/20">
|
||||
<span className="text-gray-400">{name}</span>
|
||||
<span className="text-gray-500 font-mono">{ver}</span>
|
||||
</div>
|
||||
@@ -475,9 +475,9 @@ export default function SettingsView() {
|
||||
return (
|
||||
<div className="h-full flex bg-dark-900">
|
||||
{/* ── 左侧导航 ── */}
|
||||
<div className="w-56 flex-shrink-0 bg-dark-800/50 border-r border-dark-700 flex flex-col">
|
||||
<div className="w-56 flex-shrink-0 bg-dark-800 flex flex-col">
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<div className="p-3 border-b border-dark-700/20">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
|
||||
<input
|
||||
@@ -485,7 +485,7 @@ export default function SettingsView() {
|
||||
placeholder="搜索设置..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700 rounded-lg text-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60"
|
||||
className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -507,16 +507,16 @@ export default function SettingsView() {
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
|
||||
active
|
||||
? 'bg-accent-blue/15 text-white'
|
||||
: 'text-gray-400 hover:bg-dark-700/60 hover:text-gray-200'
|
||||
? 'bg-accent-blue text-white shadow-sm'
|
||||
: 'text-gray-400 hover:bg-dark-700/50 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-white' : ''}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{tab.label}</div>
|
||||
<div className={`text-xs truncate ${active ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
<div className={`text-xs truncate ${active ? 'text-white/70' : 'text-gray-600'}`}>
|
||||
{tab.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,7 +529,7 @@ export default function SettingsView() {
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header bar */}
|
||||
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/50">
|
||||
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/20">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{currentMeta.label}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{currentMeta.description}</p>
|
||||
</div>
|
||||
@@ -558,7 +558,7 @@ function SettingCard({
|
||||
className={`rounded-xl p-5 ${
|
||||
highlight
|
||||
? 'bg-dark-800/80 border border-accent-blue/20'
|
||||
: 'bg-dark-800/50 border border-dark-700/50'
|
||||
: 'bg-dark-800/50 border border-dark-700/30'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
+12
-11
@@ -22,7 +22,7 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
||||
const { sessions, setSessions, removeSession } = useSessionStore()
|
||||
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
|
||||
const { settings, setSetting } = useSettingsStore()
|
||||
const { addTab } = useTabStore()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
@@ -40,7 +40,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
|
||||
// Sync width from settings on load
|
||||
useEffect(() => {
|
||||
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) {
|
||||
if (settings.sidebarWidth) {
|
||||
setSidebarWidth(settings.sidebarWidth)
|
||||
}
|
||||
}, [settings.sidebarWidth])
|
||||
@@ -144,18 +144,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
case 'edit':
|
||||
onEditSession(session)
|
||||
break
|
||||
case 'duplicate':
|
||||
case 'duplicate': {
|
||||
// Duplicate session
|
||||
const { id: _id, ...sessionWithoutId } = session
|
||||
window.electronAPI?.sessionCreate({
|
||||
...session,
|
||||
id: undefined,
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (副本)`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
setSessions([...sessions, result.session])
|
||||
addSession(result.session as Session)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
@@ -177,12 +178,12 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="relative flex flex-shrink-0 bg-dark-800 border-r border-dark-700"
|
||||
className="relative flex flex-col h-full bg-dark-800"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<div className="p-4 border-b border-dark-700/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
@@ -209,7 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<div className="p-3 border-b border-dark-700/20">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
@@ -231,7 +232,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<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-gray-400 hover:bg-dark-700 rounded transition-colors"
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function StatusBar() {
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="h-7 bg-dark-800 border-t border-dark-700 flex items-center px-3 text-xs text-white select-none">
|
||||
<div className="h-7 bg-dark-800 flex items-center px-3 text-xs text-white select-none">
|
||||
{/* Left: Connection status area — clickable to toggle ConnectionPanel */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useConnectionPool } from '../stores/connection'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
|
||||
export default function TabBar() {
|
||||
function TabBar() {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -58,7 +58,6 @@ export default function TabBar() {
|
||||
setDragFromIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Set a transparent drag image so we rely on the ghost
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (e.currentTarget instanceof HTMLDivElement) {
|
||||
dragNodeRef.current = e.currentTarget
|
||||
}
|
||||
@@ -112,7 +111,7 @@ export default function TabBar() {
|
||||
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
|
||||
<div className="flex items-center bg-dark-800 overflow-x-auto">
|
||||
{/* Tab List */}
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
@@ -167,3 +166,5 @@ export default function TabBar() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabBar)
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import WelcomeView from './WelcomeView'
|
||||
import TerminalView from './TerminalView'
|
||||
@@ -11,8 +12,8 @@ interface TabContentProps {
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
|
||||
const { tabs } = useTabStore()
|
||||
function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
|
||||
const tabs = useTabStore((state) => state.tabs)
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
@@ -27,7 +28,7 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
|
||||
content = <TerminalView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'sftp':
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} />
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
|
||||
break
|
||||
case 'portforward':
|
||||
content = <PortForwardView tabId={tab.id} isActive={isActive} />
|
||||
@@ -54,3 +55,5 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabContent)
|
||||
@@ -182,7 +182,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// 只注册一次监听器(防止严格模式下重复注册)
|
||||
if (!listenerRegisteredRef.current) {
|
||||
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => {
|
||||
const handler = (event: unknown, connectionId: string, shellId: string, data: Buffer) => {
|
||||
handleShellDataRef.current?.(event, connectionId, shellId, data)
|
||||
}
|
||||
window.electronAPI?.onShellData(handler)
|
||||
@@ -192,6 +192,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // 只在挂载时运行
|
||||
|
||||
// 初始连接
|
||||
@@ -210,6 +211,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
connect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // 只在挂载时执行
|
||||
|
||||
// 处理活跃状态变化
|
||||
@@ -234,15 +236,19 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
saveSnapshot()
|
||||
}
|
||||
}
|
||||
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay])
|
||||
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay, connectionIdRef, hasConnectedRef])
|
||||
|
||||
// 组件卸载时清理监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isConnectingRef.current = false
|
||||
listenerRegisteredRef.current = false
|
||||
window.electronAPI?.removeAllListeners('ssh:shellData')
|
||||
// 精确移除当前组件注册的监听器,避免影响其他终端
|
||||
if (handleShellDataRef.current) {
|
||||
window.electronAPI?.removeListener('ssh:shellData', handleShellDataRef.current as (...args: unknown[]) => void)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const currentTab = getTabById(tabId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Clock } from 'lucide-react'
|
||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Plus, Zap, Clock } from 'lucide-react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import EmptyState from './common/EmptyState'
|
||||
@@ -24,17 +24,17 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8">
|
||||
<div className="text-center mb-12">
|
||||
<div className="h-full flex flex-col items-center justify-start bg-dark-900 p-8 overflow-y-auto">
|
||||
<div className="text-center mb-12 pt-8">
|
||||
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto shadow-lg">
|
||||
<Terminal className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full max-w-2xl mb-12">
|
||||
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl mb-12">
|
||||
<button
|
||||
onClick={onQuickConnect}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group"
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-blue transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent-blue/20 rounded-lg flex items-center justify-center group-hover:bg-accent-blue/30 transition-colors">
|
||||
<Zap className="w-6 h-6 text-accent-blue" />
|
||||
@@ -47,7 +47,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-green transition-all group"
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-green transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent-green/20 rounded-lg flex items-center justify-center group-hover:bg-accent-green/30 transition-colors">
|
||||
<Plus className="w-6 h-6 text-white" />
|
||||
@@ -60,7 +60,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
|
||||
<button
|
||||
onClick={() => addTab({ title: 'SFTP', type: 'sftp' })}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-purple transition-all group"
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-purple transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent-purple/20 rounded-lg flex items-center justify-center group-hover:bg-accent-purple/30 transition-colors">
|
||||
<FolderSync className="w-6 h-6 text-accent-purple" />
|
||||
@@ -73,7 +73,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
|
||||
<button
|
||||
onClick={() => addTab({ title: '端口转发', type: 'portforward' })}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-orange transition-all group"
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-orange transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center group-hover:bg-orange-500/30 transition-colors">
|
||||
<ArrowRightLeft className="w-6 h-6 text-orange-400" />
|
||||
@@ -83,19 +83,6 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<div className="text-sm text-gray-500">本地/远程/SOCKS5 代理</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-gray-500 transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gray-700/50 rounded-lg flex items-center justify-center group-hover:bg-gray-700 transition-colors">
|
||||
<Settings className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-gray-200">设置</div>
|
||||
<div className="text-sm text-gray-500">配置应用偏好</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent connections */}
|
||||
@@ -113,7 +100,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<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"
|
||||
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700/30 hover:border-accent-blue/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-accent-blue" />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { handleError } from '../../utils/error-handler'
|
||||
|
||||
interface DefaultFallbackProps {
|
||||
error: Error
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) {
|
||||
const appError = handleError(error)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
|
||||
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
|
||||
<p className="text-sm text-gray-500">应用遇到意外问题</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-900 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
|
||||
{appError.recoverable && (
|
||||
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
重启应用
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-600 text-center">
|
||||
如果问题持续存在,请检查日志或联系支持
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { handleError } from '../../utils/error-handler'
|
||||
import DefaultFallback from './DefaultFallback'
|
||||
|
||||
const logger = createLogger('ErrorBoundary')
|
||||
|
||||
@@ -22,57 +22,6 @@ interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认错误_fallback UI
|
||||
*/
|
||||
function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
|
||||
const appError = handleError(error)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
|
||||
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
|
||||
<p className="text-sm text-gray-500">应用遇到意外问题</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-900 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
|
||||
{appError.recoverable && (
|
||||
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
重启应用
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-600 text-center">
|
||||
如果问题持续存在,请检查日志或联系支持
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
|
||||
@@ -202,7 +202,7 @@ export function SessionTreeSkeleton({
|
||||
{Array.from({ length: groups }).map((_, gIdx) => {
|
||||
const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1))
|
||||
return (
|
||||
<div key={gIdx} className="border-b border-dark-700 last:border-b-0">
|
||||
<div key={gIdx} className="border-b border-dark-700/30 last:border-b-0">
|
||||
{/* 分组头 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<Skeleton width={14} height={14} className="rounded-sm" />
|
||||
|
||||
@@ -67,19 +67,19 @@ function ToastItemView({ toast, onClose }: ToastItemProps) {
|
||||
}, [])
|
||||
|
||||
// 自动关闭
|
||||
const handleClose = useCallback(() => {
|
||||
setVisible(false)
|
||||
// 等待退场动画结束后再移除
|
||||
setTimeout(() => onClose(toast.id), 200)
|
||||
}, [onClose, toast.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration <= 0) return
|
||||
const timer = setTimeout(() => {
|
||||
handleClose()
|
||||
}, toast.duration)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.duration])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setVisible(false)
|
||||
// 等待退场动画结束后再移除
|
||||
setTimeout(() => onClose(toast.id), 200)
|
||||
}, [onClose, toast.id])
|
||||
}, [toast.duration, handleClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function ChmodDialog({
|
||||
>
|
||||
<div className="space-y-4 min-w-[360px]">
|
||||
{/* File info */}
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-dark-600/30">
|
||||
<Shield className="w-5 h-5 text-accent-blue" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ConnectionPanel({
|
||||
// Disconnected State
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
|
||||
<span className="text-sm text-gray-400">未连接</span>
|
||||
<button
|
||||
onClick={onShowSelector}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
|
||||
return (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700/30">
|
||||
<h3 className="text-lg font-medium text-white">选择服务器</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{item.separator && index > 0 && (
|
||||
<div className="my-1 border-t border-dark-600" />
|
||||
<div className="my-1 border-t border-dark-600/30" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -121,7 +121,7 @@ export function FileListPanel({
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue" /> : <Folder className="w-4 h-4 text-accent-green" />}
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
@@ -151,12 +151,12 @@ export function FileListPanel({
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="px-4 py-2 border-b border-dark-700">
|
||||
<div className="px-4 py-2 border-b border-dark-700/20">
|
||||
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="flex items-center px-4 py-2 border-b border-dark-700 text-xs text-gray-400">
|
||||
<div className="flex items-center px-4 py-2 border-b border-dark-700/20 text-xs text-gray-400">
|
||||
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
|
||||
</div>
|
||||
|
||||
@@ -195,7 +195,7 @@ export function FileListPanel({
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400">
|
||||
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-gray-400">
|
||||
{loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function FilePropertiesDialog({
|
||||
>
|
||||
<div className="space-y-4 min-w-[320px]">
|
||||
{/* File icon + name header */}
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-dark-600/30">
|
||||
<div className="p-2 bg-dark-700 rounded-lg">
|
||||
<TypeIcon className="w-8 h-8 text-accent-blue" />
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onD
|
||||
onContextMenu={onContextMenu}
|
||||
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 ${
|
||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group ${
|
||||
isSelected ? 'bg-accent-blue/20 hover:bg-accent-blue/25' : 'hover:bg-dark-700/50'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700 select-none">
|
||||
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700/30 select-none">
|
||||
{fields.map(({ key, label, width, align }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ToolbarProps {
|
||||
|
||||
export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onUpload}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border-t border-dark-700">
|
||||
<div className="bg-dark-800 border-t border-dark-700/30">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
@@ -48,7 +48,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
|
||||
|
||||
{/* Transfer List */}
|
||||
{isOpen && tasks.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto border-t border-dark-700">
|
||||
<div className="max-h-48 overflow-y-auto border-t border-dark-700/30">
|
||||
{tasks.map((task) => (
|
||||
<TransferItem
|
||||
key={task.id}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SessionTree({
|
||||
{Object.entries(grouped).map(([group, groupSessions]) => {
|
||||
const isExpanded = expandedGroups.has(group)
|
||||
return (
|
||||
<div key={group} className="border-b border-dark-700 last:border-b-0">
|
||||
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
onClick={() => onToggleGroup(group)}
|
||||
|
||||
@@ -201,13 +201,13 @@ export default function SnippetPanel() {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-dark-800">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700/30">
|
||||
<Code2 className="w-5 h-5 text-accent-blue flex-shrink-0" />
|
||||
<h2 className="text-sm font-semibold text-gray-200">命令片段</h2>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<div className="p-3 border-b border-dark-700/30">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
@@ -260,7 +260,7 @@ export default function SnippetPanel() {
|
||||
</div>
|
||||
|
||||
{/* 底部新建按钮 */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<div className="p-3 border-t border-dark-700/30">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
||||
@@ -60,11 +60,11 @@ export function TabItem({
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
|
||||
border-r border-dark-700 cursor-pointer select-none
|
||||
border-r border-dark-700/20 cursor-pointer select-none
|
||||
transition-all duration-150 relative
|
||||
${isActive
|
||||
? 'bg-dark-800 text-gray-200'
|
||||
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
|
||||
? 'bg-dark-900 text-gray-200'
|
||||
: 'bg-dark-800 text-gray-500 hover:bg-dark-700 hover:text-gray-400'
|
||||
}
|
||||
${isDragging ? 'opacity-50' : 'opacity-100'}
|
||||
`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
|
||||
@@ -10,7 +11,7 @@ export interface TerminalOverlayProps {
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
export default function TerminalOverlay({
|
||||
function TerminalOverlay({
|
||||
connectionState,
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
@@ -96,3 +97,5 @@ export default function TerminalOverlay({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TerminalOverlay)
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { memo } from 'react'
|
||||
import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
export interface TerminalSearchBarProps {
|
||||
@@ -12,7 +12,7 @@ export interface TerminalSearchBarProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function TerminalSearchBar({
|
||||
function TerminalSearchBar({
|
||||
searchQuery,
|
||||
searchResultCount,
|
||||
searchResultIndex,
|
||||
@@ -72,3 +72,5 @@ export default function TerminalSearchBar({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TerminalSearchBar)
|
||||
Reference in new issue
Block a user