feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现

核心功能:
- SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮
- SFTP管理: 双栏文件管理、上传下载、传输队列
- 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理

架构改进:
- 拆分大型组件 (SFTPView/TabBar/Sidebar)
- 添加统一日志和错误处理
- 修复React严格模式下的终端黑屏问题

UI更新:
- 新增端口转发视图
- 更新UI预览文档
- 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
Logan.Li committed 2026-04-06 01:20:47 +08:00
1 parent cf1c054381
commit 484d850abd
105 files changed
+25330 -64

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
/**
* SFTP Utils Tests - SFTP 工具函数测试
*/
import { describe, it, expect } from 'vitest'
import { formatSize, formatSpeed, formatDate, getFileExtension, sortFiles } from '../../../src/components/sftp/utils'
import type { FileItem } from '../../../src/components/sftp/types'
describe('SFTP Utils', () => {
describe('formatSize', () => {
it('应该正确格式化字节', () => {
expect(formatSize(0)).toBe('--')
expect(formatSize(512)).toBe('512 B')
expect(formatSize(1024)).toBe('1 KB')
expect(formatSize(1536)).toBe('1.5 KB')
expect(formatSize(1048576)).toBe('1 MB')
expect(formatSize(1073741824)).toBe('1 GB')
})
it('应该保留一位小数', () => {
expect(formatSize(1500)).toBe('1.5 KB')
expect(formatSize(1572864)).toBe('1.5 MB')
})
})
describe('formatSpeed', () => {
it('应该正确格式化传输速度', () => {
expect(formatSpeed(0)).toBe('0 B/s')
expect(formatSpeed(1024)).toBe('1 KB/s')
expect(formatSpeed(1048576)).toBe('1 MB/s')
})
it('应该保留一位小数', () => {
expect(formatSpeed(1536)).toBe('1.5 KB/s')
})
})
describe('formatDate', () => {
it('应该显示今天', () => {
const today = Date.now() / 1000
expect(formatDate(today)).toBe('今天')
})
it('应该显示昨天', () => {
const yesterday = Date.now() / 1000 - 86400
expect(formatDate(yesterday)).toBe('昨天')
})
it('应该显示天数', () => {
const threeDaysAgo = Date.now() / 1000 - 3 * 86400
expect(formatDate(threeDaysAgo)).toBe('3 天前')
})
})
describe('getFileExtension', () => {
it('应该返回正确的文件类型描述', () => {
expect(getFileExtension('test.pdf')).toBe('PDF')
expect(getFileExtension('test.json')).toBe('JSON')
expect(getFileExtension('test.ts')).toBe('TypeScript')
expect(getFileExtension('test.MD')).toBe('Markdown')
})
it('应该处理无扩展名的文件', () => {
expect(getFileExtension('Makefile')).toBe('文件')
})
it('应该返回大写扩展名', () => {
expect(getFileExtension('test.PNG')).toBe('PNG')
})
})
describe('sortFiles', () => {
const mockFiles: FileItem[] = [
{ name: 'test.txt', path: '/test.txt', type: 'file', size: 100, modified: 1000, permissions: '-rw-r--r--' },
{ name: 'docs', path: '/docs', type: 'directory', size: 0, modified: 2000, permissions: 'drwxr-xr-x' },
{ name: 'image.png', path: '/image.png', type: 'file', size: 500, modified: 500, permissions: '-rw-r--r--' },
{ name: 'backup.zip', path: '/backup.zip', type: 'file', size: 1000, modified: 1500, permissions: '-rw-r--r--' },
]
it('应该按名称排序且目录优先', () => {
const sorted = sortFiles(mockFiles, 'name', 'asc')
expect(sorted[0].name).toBe('docs') // 目录优先
expect(sorted[1].name).toBe('backup.zip')
})
it('应该按大小排序且目录优先', () => {
const sorted = sortFiles(mockFiles, 'size', 'asc')
expect(sorted[0].name).toBe('docs') // 目录优先
expect(sorted[sorted.length - 1].name).toBe('backup.zip') // 最大
})
it('应该按修改时间排序且目录优先', () => {
const sorted = sortFiles(mockFiles, 'modified', 'asc')
expect(sorted[0].name).toBe('docs') // 目录优先
expect(sorted[1].name).toBe('image.png') // 第二新
})
it('应该反转排序顺序', () => {
const sorted = sortFiles(mockFiles, 'name', 'desc')
expect(sorted[0].name).toBe('docs') // 目录优先,即使降序
})
it('应该总是目录优先', () => {
const sorted = sortFiles(mockFiles, 'size', 'desc')
expect(sorted[0].type).toBe('directory')
})
it('不应该修改原始数组', () => {
const original = [...mockFiles]
sortFiles(mockFiles, 'name', 'asc')
expect(mockFiles).toEqual(original)
})
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* Vitest Setup - 测试环境配置
*/
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Mock Electron API
const mockElectronAPI = {
// SSH
sshConnect: vi.fn().mockResolvedValue({ success: true, connectionId: 'test-conn-1' }),
sshDisconnect: vi.fn().mockResolvedValue({ success: true }),
sshExec: vi.fn().mockResolvedValue({ success: true, stdout: '', stderr: '' }),
sshCreateShell: vi.fn().mockResolvedValue({ success: true, shellId: 'test-shell-1' }),
sshShellWrite: vi.fn().mockResolvedValue({ success: true }),
sshShellResize: vi.fn().mockResolvedValue({ success: true }),
sshForwardLocal: vi.fn().mockResolvedValue({ success: true }),
sshForwardRemote: vi.fn().mockResolvedValue({ success: true }),
// SFTP
sftpConnect: vi.fn().mockResolvedValue({ success: true, sftpId: 'test-sftp-1' }),
sftpListDirectory: vi.fn().mockResolvedValue({ success: true, files: [] }),
sftpUpload: vi.fn().mockResolvedValue({ success: true }),
sftpDownload: vi.fn().mockResolvedValue({ success: true }),
sftpRename: vi.fn().mockResolvedValue({ success: true }),
sftpDelete: vi.fn().mockResolvedValue({ success: true }),
sftpMkdir: vi.fn().mockResolvedValue({ success: true }),
onSFTPProgress: vi.fn(),
// Session
getSessions: vi.fn().mockResolvedValue({ success: true, sessions: [] }),
getSession: vi.fn().mockResolvedValue({ success: true, session: null }),
createSession: vi.fn().mockResolvedValue({ success: true, id: 'test-session-1' }),
updateSession: vi.fn().mockResolvedValue({ success: true }),
deleteSession: vi.fn().mockResolvedValue({ success: true }),
// Settings
getSettings: vi.fn().mockResolvedValue({ success: true, settings: {} }),
setSettings: vi.fn().mockResolvedValue({ success: true }),
// Dialog
showSaveDialog: vi.fn().mockResolvedValue({ success: true, canceled: false, filePath: '/tmp/test' }),
showOpenDialog: vi.fn().mockResolvedValue({ success: true, canceled: false, filePaths: ['/tmp/test'] }),
// File
localReadDir: vi.fn().mockResolvedValue({ success: true, files: [] }),
localReadFile: vi.fn().mockResolvedValue({ success: true, content: '' }),
localWriteFile: vi.fn().mockResolvedValue({ success: true }),
localRename: vi.fn().mockResolvedValue({ success: true }),
localDelete: vi.fn().mockResolvedValue({ success: true }),
localMkdir: vi.fn().mockResolvedValue({ success: true }),
getHomePath: vi.fn().mockResolvedValue({ success: true, path: '/Users/test' }),
getDownloadsPath: vi.fn().mockResolvedValue({ success: true, path: '/Users/test/Downloads' }),
// Zmodem
onZmodemSend: vi.fn(),
// App
getVersion: vi.fn().mockResolvedValue('1.0.0'),
getPlatform: vi.fn().mockReturnValue('darwin'),
// Event listeners
onShellData: vi.fn(),
removeListener: vi.fn(),
}
window.electronAPI = mockElectronAPI as any
// Mock navigator
Object.defineProperty(window, 'navigator', {
value: { platform: 'MacIntel' },
writable: true
})
// Mock ResizeObserver
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverMock
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Suppress console errors in tests
const originalError = console.error
beforeAll(() => {
console.error = (...args: any[]) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))
) {
return
}
originalError.call(console, ...args)
}
})
afterAll(() => {
console.error = originalError
})
+70
View File
@@ -0,0 +1,70 @@
/**
* ConnectionPool Store Tests - 连接池状态管理测试
*
* 注意: 这些测试验证 store 接口存在且可用
* 实际的连接功能需要集成测试环境
*/
import { describe, it, expect } from 'vitest'
import { useConnectionPool, useConnectionStore, getConnectionStore } from '../../src/stores/connectionPool'
describe('ConnectionPool Store', () => {
describe('store 初始化', () => {
it('useConnectionStore hook 应该存在', () => {
expect(typeof useConnectionStore).toBe('function')
})
it('useConnectionPool hook 应该存在 (向后兼容别名)', () => {
expect(typeof useConnectionPool).toBe('function')
})
it('getConnectionStore 函数应该存在', () => {
expect(typeof getConnectionStore).toBe('function')
})
it('getConnectionStore 应该返回正确的接口', () => {
const store = getConnectionStore()
expect(store).toHaveProperty('createConnection')
expect(store).toHaveProperty('getConnection')
expect(store).toHaveProperty('releaseConnection')
expect(store).toHaveProperty('disconnectConnection')
expect(store).toHaveProperty('getStats')
})
})
describe('store 方法签名', () => {
it('createConnection 应该是异步函数', () => {
const store = getConnectionStore()
expect(store.createConnection).toBeInstanceOf(Function)
// 验证是 async function (通过返回 Promise)
const result = store.createConnection('test-tab', {
host: 'localhost',
port: 22,
username: 'test',
})
expect(result).toBeInstanceOf(Promise)
})
it('getStats 应该返回连接统计', () => {
const store = getConnectionStore()
const stats = store.getStats()
expect(stats).toHaveProperty('total')
expect(stats).toHaveProperty('connected')
expect(stats).toHaveProperty('background')
expect(stats).toHaveProperty('suspended')
})
})
describe('配置', () => {
it('应该有默认最大连接数配置', () => {
const store = getConnectionStore()
expect(store.config).toBeDefined()
expect(store.config.maxConnections).toBeGreaterThan(0)
})
it('应该有默认后台连接配置', () => {
const store = getConnectionStore()
expect(store.config.maxBackgroundConnections).toBeGreaterThan(0)
})
})
})
+177
View File
@@ -0,0 +1,177 @@
/**
* TabStore Tests - 标签页状态管理测试
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { useTabStore } from '../../src/stores/tabStore'
describe('TabStore', () => {
beforeEach(() => {
// Reset store to initial state
useTabStore.setState({
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
activeTabId: 'welcome',
})
})
describe('初始状态', () => {
it('应该包含默认的欢迎标签', () => {
const { tabs, activeTabId } = useTabStore.getState()
expect(tabs).toHaveLength(1)
expect(tabs[0].id).toBe('welcome')
expect(tabs[0].type).toBe('welcome')
expect(activeTabId).toBe('welcome')
})
})
describe('addTab', () => {
it('应该添加新的终端标签', () => {
const { addTab, tabs } = useTabStore.getState()
addTab({ title: 'Terminal', type: 'terminal' })
const newTabs = useTabStore.getState().tabs
expect(newTabs).toHaveLength(tabs.length + 1)
expect(newTabs.find(t => t.type === 'terminal')).toBeDefined()
})
it('添加新标签后应该激活它', () => {
const { addTab } = useTabStore.getState()
addTab({ title: 'Terminal', type: 'terminal' })
const { activeTabId, tabs } = useTabStore.getState()
const activeTab = tabs.find(t => t.id === activeTabId)
expect(activeTab?.type).toBe('terminal')
})
it('添加 settings 类型时如果已存在则激活而不是创建新的', () => {
const { addTab, tabs } = useTabStore.getState()
addTab({ title: 'Settings', type: 'settings' })
addTab({ title: 'Settings 2', type: 'settings' })
const newTabs = useTabStore.getState().tabs
expect(newTabs).toHaveLength(2) // welcome + settings
})
})
describe('updateTab', () => {
it('应该更新指定标签的标题', () => {
const { updateTab } = useTabStore.getState()
updateTab('welcome', { title: '新的标题' })
const { tabs } = useTabStore.getState()
expect(tabs[0].title).toBe('新的标题')
})
it('应该更新标签的连接状态', () => {
const { updateTab } = useTabStore.getState()
updateTab('welcome', { connectionStatus: 'connected' })
const { tabs } = useTabStore.getState()
expect(tabs[0].connectionStatus).toBe('connected')
})
})
describe('closeTab', () => {
it('不应该关闭最后一个标签', () => {
const { closeTab } = useTabStore.getState()
const closed = closeTab('welcome')
const { tabs } = useTabStore.getState()
expect(tabs).toHaveLength(1)
expect(closed).toBeNull()
})
it('关闭标签后应该激活另一个标签', () => {
const { addTab, closeTab } = useTabStore.getState()
addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' })
const { tabs, activeTabId } = useTabStore.getState()
const tabToClose = tabs.find(t => t.title === 'Tab 1')
if (tabToClose) {
closeTab(tabToClose.id)
const { tabs: newTabs, activeTabId: newActiveId } = useTabStore.getState()
expect(newTabs.find(t => t.isActive)?.title).toBe('Tab 2')
}
})
it('应该返回被关闭的标签', () => {
const { addTab, closeTab } = useTabStore.getState()
addTab({ title: 'Tab 1', type: 'terminal' })
const { tabs } = useTabStore.getState()
const tabToClose = tabs.find(t => t.title === 'Tab 1')
if (tabToClose) {
const closed = closeTab(tabToClose.id)
expect(closed?.title).toBe('Tab 1')
}
})
})
describe('closeOtherTabs', () => {
it('应该关闭除指定标签外的所有标签', () => {
const { addTab, closeOtherTabs, tabs } = useTabStore.getState()
addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' })
const tabToKeep = useTabStore.getState().tabs.find(t => t.title === 'Tab 1')
if (tabToKeep) {
closeOtherTabs(tabToKeep.id)
const { tabs: newTabs } = useTabStore.getState()
expect(newTabs).toHaveLength(1)
expect(newTabs[0].title).toBe('Tab 1')
}
})
})
describe('activateTab', () => {
it('应该激活指定的标签', () => {
const { addTab, activateTab } = useTabStore.getState()
addTab({ title: 'Tab 1', type: 'terminal' })
addTab({ title: 'Tab 2', type: 'sftp' })
const { tabs } = useTabStore.getState()
const tabToActivate = tabs.find(t => t.title === 'Tab 1')
if (tabToActivate) {
activateTab(tabToActivate.id)
const { tabs: newTabs, activeTabId } = useTabStore.getState()
const activeTab = newTabs.find(t => t.id === activeTabId)
expect(activeTab?.title).toBe('Tab 1')
}
})
})
describe('getTabById', () => {
it('应该返回指定 ID 的标签', () => {
const { addTab, getTabById } = useTabStore.getState()
addTab({ title: 'Test Tab', type: 'terminal' })
const { tabs } = useTabStore.getState()
const testTab = tabs.find(t => t.title === 'Test Tab')
if (testTab) {
const found = getTabById(testTab.id)
expect(found?.title).toBe('Test Tab')
}
})
it('找不到时应该返回 undefined', () => {
const { getTabById } = useTabStore.getState()
const found = getTabById('non-existent-id')
expect(found).toBeUndefined()
})
})
describe('updateTabTitle', () => {
it('应该更新标签标题', () => {
const { updateTabTitle } = useTabStore.getState()
updateTabTitle('welcome', 'Updated Welcome')
const { tabs } = useTabStore.getState()
expect(tabs[0].title).toBe('Updated Welcome')
})
})
})