feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
import { create } from 'zustand'
import type { AiModelConfig, AiProviderConfig } from '../../shared/types/ai.types'
import { createCustomProvider, createProviderFromPreset } from '../ai/providerPresets'
import { normalizeModelsDevCatalog, type CatalogProvider } from '../ai/modelCatalog'
export const AI_PROVIDER_SETTINGS_KEY = 'ai-providers-v1'
interface StoredAiProviders {
providers: AiProviderConfig[]
activeProviderId: string
}
interface AiProviderState extends StoredAiProviders {
isLoaded: boolean
catalogProviders: CatalogProvider[]
catalogUpdatedAt: number | null
load: () => Promise<void>
addPreset: (presetId: string) => Promise<string>
addCustom: () => Promise<string>
saveProvider: (provider: AiProviderConfig, apiKey?: string) => Promise<void>
deleteProvider: (providerId: string) => Promise<void>
setActiveProvider: (providerId: string) => Promise<void>
refreshProviderModels: (providerId: string) => Promise<string[]>
refreshCatalog: () => Promise<{ providers: number; errors: string[] }>
importCatalogProvider: (provider: CatalogProvider) => Promise<string>
}
function normalizeProvider(raw: AiProviderConfig): AiProviderConfig {
const models = Array.isArray(raw.models) ? raw.models.filter((item) => item?.id && item?.model) : []
return {
...raw,
protocol: ['openai-chat', 'openai-responses', 'anthropic'].includes(raw.protocol) ? raw.protocol : 'openai-chat',
authStyle: ['bearer', 'x-api-key', 'both', 'none'].includes(raw.authStyle) ? raw.authStyle : 'bearer',
allowInsecureHttp: raw.allowInsecureHttp === true,
models,
defaultModelId: models.some((item) => item.id === raw.defaultModelId) ? raw.defaultModelId : models[0]?.id || '',
enabled: raw.enabled !== false,
hasApiKey: raw.authStyle === 'none' ? true : raw.hasApiKey === true,
}
}
function serializable(providers: AiProviderConfig[]): AiProviderConfig[] {
return providers.map(({ hasApiKey: _hasApiKey, ...provider }) => provider)
}
async function persist(providers: AiProviderConfig[], activeProviderId: string): Promise<void> {
await window.opsxApi.settingsSet(AI_PROVIDER_SETTINGS_KEY, {
providers: serializable(providers),
activeProviderId,
})
}
function newModel(upstreamId: string): AiModelConfig {
return { id: upstreamId, model: upstreamId, label: upstreamId, capability: 'chat', billing: 'payg' }
}
export const useAiProviderStore = create<AiProviderState>((set, get) => ({
providers: [],
activeProviderId: '',
isLoaded: false,
catalogProviders: [],
catalogUpdatedAt: null,
load: async () => {
if (get().isLoaded) return
const result = await window.opsxApi.settingsGet(AI_PROVIDER_SETTINGS_KEY)
const value = result.value && typeof result.value === 'object' ? (result.value as Partial<StoredAiProviders>) : {}
const providers = Array.isArray(value.providers) ? value.providers.map(normalizeProvider) : []
await Promise.all(
providers.map(async (provider) => {
provider.hasApiKey = provider.authStyle === 'none' || (await window.opsxApi.aiProviderHasKey(provider.id))
})
)
const activeProviderId = providers.some((item) => item.id === value.activeProviderId)
? value.activeProviderId || ''
: providers[0]?.id || ''
set({ providers, activeProviderId, isLoaded: true })
},
addPreset: async (presetId) => {
const provider = createProviderFromPreset(presetId)
const providers = [...get().providers, provider]
const activeProviderId = get().activeProviderId || provider.id
set({ providers, activeProviderId })
await persist(providers, activeProviderId)
return provider.id
},
addCustom: async () => {
const provider = createCustomProvider()
const providers = [...get().providers, provider]
const activeProviderId = get().activeProviderId || provider.id
set({ providers, activeProviderId })
await persist(providers, activeProviderId)
return provider.id
},
saveProvider: async (input, apiKey) => {
const provider = normalizeProvider(input)
if (apiKey?.trim()) {
const result = await window.opsxApi.aiProviderSaveKey(provider.id, apiKey.trim())
if (!result.success) throw new Error(result.error || 'API Key 保存失败')
provider.hasApiKey = true
}
const exists = get().providers.some((item) => item.id === provider.id)
const providers = exists
? get().providers.map((item) => (item.id === provider.id ? provider : item))
: [...get().providers, provider]
set({ providers })
await persist(providers, get().activeProviderId)
},
deleteProvider: async (providerId) => {
await window.opsxApi.aiProviderDeleteKey(providerId)
const providers = get().providers.filter((item) => item.id !== providerId)
const activeProviderId = get().activeProviderId === providerId ? providers[0]?.id || '' : get().activeProviderId
set({ providers, activeProviderId })
await persist(providers, activeProviderId)
},
setActiveProvider: async (providerId) => {
if (!get().providers.some((item) => item.id === providerId)) return
set({ activeProviderId: providerId })
await persist(get().providers, providerId)
},
refreshProviderModels: async (providerId) => {
const provider = get().providers.find((item) => item.id === providerId)
if (!provider) throw new Error('Provider 不存在')
const result = await window.opsxApi.aiListModels(provider)
if (!result.success) throw new Error(result.error || '模型目录读取失败')
const current = new Map(provider.models.map((item) => [item.model, item]))
for (const id of result.models || []) if (!current.has(id)) current.set(id, newModel(id))
const models = Array.from(current.values())
await get().saveProvider({ ...provider, models, defaultModelId: provider.defaultModelId || models[0]?.id || '' })
return result.models || []
},
refreshCatalog: async () => {
const result = await window.opsxApi.aiCatalogFetch()
const catalogProviders = normalizeModelsDevCatalog(result.documents || [])
if (catalogProviders.length === 0) throw new Error(result.error || result.errors?.[0] || 'models.dev 暂不可用')
set({ catalogProviders, catalogUpdatedAt: Date.now() })
return { providers: catalogProviders.length, errors: result.errors || [] }
},
importCatalogProvider: async (catalog) => {
const provider = createCustomProvider()
provider.name = catalog.name
provider.presetId = `models-dev:${catalog.id}`
if (catalog.baseUrl?.startsWith('http')) provider.baseUrl = catalog.baseUrl
provider.models = catalog.models.map((item) => ({ ...item }))
provider.defaultModelId = provider.models[0]?.id || ''
const providers = [...get().providers, provider]
const activeProviderId = get().activeProviderId || provider.id
set({ providers, activeProviderId })
await persist(providers, activeProviderId)
return provider.id
},
}))
export function getActiveAiRoute(): { provider: AiProviderConfig; model: AiModelConfig } | null {
const state = useAiProviderStore.getState()
const provider = state.providers.find((item) => item.id === state.activeProviderId && item.enabled)
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
return provider && model ? { provider, model } : null
}
+6 -4
View File
@@ -138,7 +138,7 @@ export const useLifecycleStore = create<LifecycleState>((_set, get) => ({
// 尝试重新连接
try {
const result = await window.electronAPI?.sshConnect(conn.config)
const result = await window.opsxApi?.sshConnect(conn.config)
if (!result?.success) return false
const newConnectionId = result.connectionId
@@ -148,9 +148,11 @@ export const useLifecycleStore = create<LifecycleState>((_set, get) => ({
newConnections.delete(connectionId)
const now = Date.now()
const keepaliveInterval = window.setInterval(() => {
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
}, poolStore.config.keepaliveIntervalMs)
const keepaliveInterval = conn.config.keepAlive === false
? undefined
: window.setInterval(() => {
void window.opsxApi?.sshKeepalive(newConnectionId).catch(() => {})
}, poolStore.config.keepaliveIntervalMs)
newConnections.set(newConnectionId, {
...conn,
+56 -16
View File
@@ -11,6 +11,7 @@ import type {
PoolClientConfig,
} from './types'
import type { CreateConnectionOptions, CreateConnectionResult, PoolStats } from './types'
import { useSettingsStore } from '../settingsStore'
// ============== Pool State ==============
@@ -41,6 +42,10 @@ interface PoolState {
disconnectConnection: (connectionId: string, reason?: string) => Promise<void>
// The native service has already torn this connection down. Do not issue a
// second disconnect command, but release every renderer-side resource.
forgetNativeConnection: (connectionId: string) => void
releaseConnection: (connectionId: string, tabId: string) => Promise<void>
// 连接查询
@@ -133,9 +138,10 @@ export const usePoolStore = create<PoolState>((set, get) => ({
}
// 3. 创建新连接
console.log('>>> Creating new SSH connection via electronAPI...')
console.log('>>> Creating new SSH connection via opsxApi...')
try {
const result = await window.electronAPI?.sshConnect({
const sshSettings = useSettingsStore.getState().settings
const result = await window.opsxApi?.sshConnect({
host: config.host,
port: config.port || 22,
username: config.username,
@@ -143,6 +149,10 @@ export const usePoolStore = create<PoolState>((set, get) => ({
privateKey: config.privateKey,
passphrase: config.passphrase,
proxy: config.proxy,
jumpHost: config.jumpHost,
jumpHosts: config.jumpHosts,
timeoutSeconds: config.timeoutSeconds ?? sshSettings.timeout,
keepAlive: config.keepAlive ?? sshSettings.keepAlive,
})
console.log('>>> sshConnect result:', result)
@@ -158,14 +168,22 @@ export const usePoolStore = create<PoolState>((set, get) => ({
console.log('>>> Connection created, connectionId:', connectionId)
// 设置保活定时器
const keepaliveInterval = window.setInterval(() => {
const conn = get().connections.get(connectionId)
if (conn && (conn.state === 'connected' || conn.state === 'background')) {
window.electronAPI?.sshExec(connectionId, 'echo -n ""').catch(() => {
get().disconnectConnection(connectionId, 'keepalive failed')
})
}
}, poolConfig.keepaliveIntervalMs)
const keepaliveInterval = (config.keepAlive ?? sshSettings.keepAlive)
? window.setInterval(() => {
const conn = get().connections.get(connectionId)
if (conn && (conn.state === 'connected' || conn.state === 'background')) {
void window.opsxApi?.sshKeepalive(connectionId)
.then((result) => {
if (!result.success) {
void get().disconnectConnection(connectionId, result.error || 'keepalive failed')
}
})
.catch(() => {
void get().disconnectConnection(connectionId, 'keepalive failed')
})
}
}, poolConfig.keepaliveIntervalMs)
: undefined
// 4. 创建连接池项
const pooledConnection: PooledConnection = {
@@ -177,6 +195,12 @@ export const usePoolStore = create<PoolState>((set, get) => ({
password: config.password,
privateKey: config.privateKey,
passphrase: config.passphrase,
proxy: config.proxy,
jumpHost: config.jumpHost,
jumpHosts: config.jumpHosts,
timeoutSeconds: config.timeoutSeconds ?? sshSettings.timeout,
keepAlive: config.keepAlive ?? sshSettings.keepAlive,
startupCommand: config.startupCommand,
},
state: 'connected',
refCount: 1,
@@ -220,7 +244,7 @@ export const usePoolStore = create<PoolState>((set, get) => ({
}
try {
const result = await window.electronAPI?.sshConnect(conn.config)
const result = await window.opsxApi?.sshConnect(conn.config)
if (!result?.success) return false
const newConnectionId = result.connectionId
@@ -231,9 +255,11 @@ export const usePoolStore = create<PoolState>((set, get) => ({
newConnections.delete(connectionId)
const now = Date.now()
const keepaliveInterval = window.setInterval(() => {
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
}, state.config.keepaliveIntervalMs)
const keepaliveInterval = conn.config.keepAlive === false
? undefined
: window.setInterval(() => {
void window.opsxApi?.sshKeepalive(newConnectionId).catch(() => {})
}, state.config.keepaliveIntervalMs)
newConnections.set(newConnectionId, {
...conn,
@@ -289,7 +315,7 @@ export const usePoolStore = create<PoolState>((set, get) => ({
}
try {
await window.electronAPI?.sshDisconnect(connectionId)
await window.opsxApi?.sshDisconnect(connectionId)
} catch {
// 忽略断开错误
}
@@ -301,6 +327,20 @@ export const usePoolStore = create<PoolState>((set, get) => ({
})
},
forgetNativeConnection: (connectionId) => {
const conn = get().connections.get(connectionId)
if (!conn) return
if (conn.keepaliveInterval) clearInterval(conn.keepaliveInterval)
if (conn.idleTimeout) clearTimeout(conn.idleTimeout)
set((state) => {
const newConnections = new Map(state.connections)
newConnections.delete(connectionId)
return { connections: newConnections }
})
},
releaseConnection: async (connectionId, tabId) => {
const conn = get().connections.get(connectionId)
if (!conn) return
@@ -391,7 +431,7 @@ export const usePoolStore = create<PoolState>((set, get) => ({
}
promises.push(
window.electronAPI?.sshDisconnect(conn.connectionId)
window.opsxApi?.sshDisconnect(conn.connectionId)
.then(() => {})
.catch(() => {}) as Promise<void>
)
+56 -2
View File
@@ -1,24 +1,73 @@
/**
* Session Helper - 会话配置辅助函数
* 从 sessionId 获取连接配置
* 从 sessionId 获取连接配置(含多级跳板展开)
*/
import type { ConnectionConfig } from './types'
import type { JumpHostConfig } from '../../../shared/types/session.types'
import { createLogger } from '../../utils/logger'
const logger = createLogger('SessionHelper')
async function resolveJumpSession(sessionId: string): Promise<JumpHostConfig | null> {
const jumpResult = await window.opsxApi?.sessionGetById(sessionId)
if (!jumpResult?.success || !jumpResult.session) {
logger.warn('Jump session not found:', sessionId)
return null
}
const j = jumpResult.session
return {
host: j.host,
port: j.port || 22,
username: j.username,
password: j.password,
privateKey: j.privateKey,
passphrase: j.passphrase,
authType: j.authType,
proxy: j.proxy,
}
}
/**
* 从 sessionId 获取连接配置
* 支持 jumpSessionIds 多级链 / jumpSessionId 单跳 / 内联 jumpHosts
*/
export async function getSessionConfig(sessionId: string): Promise<ConnectionConfig | null> {
try {
const result = await window.electronAPI?.sessionGetById(sessionId)
const result = await window.opsxApi?.sessionGetById(sessionId)
if (!result?.success || !result.session) {
return null
}
const session = result.session
let jumpHosts: JumpHostConfig[] | undefined
const chainIds =
session.jumpSessionIds && session.jumpSessionIds.length > 0
? session.jumpSessionIds
: session.jumpSessionId
? [session.jumpSessionId]
: []
if (chainIds.length > 0) {
jumpHosts = []
const visited = new Set<string>([sessionId])
for (const jid of chainIds) {
if (visited.has(jid)) {
logger.warn('Cycle detected in jump chain, stopping at', jid)
break
}
visited.add(jid)
const hop = await resolveJumpSession(jid)
if (hop) jumpHosts.push(hop)
}
if (jumpHosts.length === 0) jumpHosts = undefined
} else if (session.jumpHosts && session.jumpHosts.length > 0) {
jumpHosts = session.jumpHosts
} else if (session.jumpHost) {
jumpHosts = [session.jumpHost]
}
return {
host: session.host,
port: session.port || 22,
@@ -28,6 +77,11 @@ export async function getSessionConfig(sessionId: string): Promise<ConnectionCon
passphrase: session.passphrase,
authType: session.authType,
proxy: session.proxy,
startupCommand: session.startupCommand,
jumpHosts,
jumpHost: jumpHosts?.[0],
jumpSessionId: chainIds[0],
jumpSessionIds: chainIds.length ? chainIds : undefined,
}
} catch (error) {
logger.error('Failed to get session config:', error)
+2
View File
@@ -21,6 +21,7 @@ export function useConnectionStore() {
createConnection: pool.createConnection,
acquireConnection: pool.acquireConnection,
disconnectConnection: pool.disconnectConnection,
forgetNativeConnection: pool.forgetNativeConnection,
releaseConnection: pool.releaseConnection,
// 状态管理
@@ -74,6 +75,7 @@ export function getConnectionStore() {
createConnection: pool.createConnection,
acquireConnection: pool.acquireConnection,
disconnectConnection: pool.disconnectConnection,
forgetNativeConnection: pool.forgetNativeConnection,
releaseConnection: pool.releaseConnection,
setActiveTab: lifecycle.setActiveTab,
setBackgroundTab: lifecycle.setBackgroundTab,
+10 -10
View File
@@ -5,7 +5,7 @@
* - customKeybindings: { actionId: customKeys } — 仅存储用户修改的
* - getEffectiveKeys(): 合并默认值和自定义值返回实际生效的键
* - 冲突检测: 检测新键绑定是否与现有绑定冲突
* - 持久化: 通过 electronAPI.settingsSet 保存到 SQLite
* - 持久化: 通过 opsxApi.settingsSet 保存到原生应用数据文件
*
* @module stores/keybindingStore
*/
@@ -24,7 +24,7 @@ const logger = createLogger('KeybindingStore')
// ─── 类型 ──────────────────────────────────────────────
/** 自定义键绑定映射 (仅存储用户修改的) */
export type CustomKeybindings = Record<KeybindingActionId, string>
export type CustomKeybindings = Partial<Record<KeybindingActionId, string>>
interface KeybindingState {
/** 自定义键绑定 (仅存储与默认不同的) */
@@ -33,7 +33,7 @@ interface KeybindingState {
isLoaded: boolean
// Actions
/** 从 electronAPI 加载自定义快捷键 */
/** 从 opsxApi 加载自定义快捷键 */
loadFromSettings: () => Promise<void>
/** 获取操作的有效键绑定 (自定义优先,否则默认) */
getEffectiveKeys: (actionId: KeybindingActionId) => string
@@ -54,12 +54,12 @@ interface KeybindingState {
// ─── Store ─────────────────────────────────────────────
export const useKeybindingStore = create<KeybindingState>((set, get) => ({
customKeybindings: {} as CustomKeybindings,
customKeybindings: {},
isLoaded: false,
loadFromSettings: async () => {
try {
const result = await window.electronAPI?.settingsGet('keybindings')
const result = await window.opsxApi?.settingsGet('keybindings')
if (result?.success && result.value && typeof result.value === 'object') {
set({ customKeybindings: result.value as CustomKeybindings, isLoaded: true })
} else {
@@ -93,7 +93,7 @@ export const useKeybindingStore = create<KeybindingState>((set, get) => ({
const newCustoms = { ...get().customKeybindings, [actionId]: keys }
set({ customKeybindings: newCustoms })
try {
await window.electronAPI?.settingsSet('keybindings', newCustoms)
await window.opsxApi?.settingsSet('keybindings', newCustoms)
} catch (error) {
logger.error('Failed to persist keybinding:', error)
}
@@ -101,18 +101,18 @@ export const useKeybindingStore = create<KeybindingState>((set, get) => ({
resetKeybinding: async (actionId: KeybindingActionId) => {
const { [actionId]: _, ...rest } = get().customKeybindings
set({ customKeybindings: rest as CustomKeybindings })
set({ customKeybindings: rest })
try {
await window.electronAPI?.settingsSet('keybindings', rest)
await window.opsxApi?.settingsSet('keybindings', rest)
} catch (error) {
logger.error('Failed to persist keybinding reset:', error)
}
},
resetAll: async () => {
set({ customKeybindings: {} as CustomKeybindings })
set({ customKeybindings: {} })
try {
await window.electronAPI?.settingsSet('keybindings', {})
await window.opsxApi?.settingsSet('keybindings', {})
} catch (error) {
logger.error('Failed to persist keybinding resetAll:', error)
}
+9 -3
View File
@@ -24,6 +24,12 @@ export interface Session {
username?: string
password?: string
}
/** 连接成功后自动发送的启动命令 */
startupCommand?: string
/** 跳板机会话 ID */
jumpSessionId?: string
/** 多级跳板会话 ID 链 */
jumpSessionIds?: string[]
group?: string
tags?: string[]
}
@@ -43,7 +49,7 @@ export const useSessionStore = create<SessionState>((set) => ({
addSession: async (session) => {
// 同步到数据库
const result = await window.electronAPI?.sessionCreate(session)
const result = await window.opsxApi?.sessionCreate(session)
if (result?.success && result.session) {
set((state) => ({
sessions: [...state.sessions, result.session!],
@@ -61,7 +67,7 @@ export const useSessionStore = create<SessionState>((set) => ({
sessions: state.sessions.filter((s) => s.id !== id),
}))
// 同步删除数据库中的会话
window.electronAPI?.sessionDelete(id)
window.opsxApi?.sessionDelete(id)
},
updateSession: async (id, updates) => {
@@ -71,7 +77,7 @@ export const useSessionStore = create<SessionState>((set) => ({
),
}))
// 同步更新数据库中的会话
await window.electronAPI?.sessionUpdate(id, updates)
await window.opsxApi?.sessionUpdate(id, updates)
},
}))
+43 -24
View File
@@ -1,17 +1,15 @@
import { create } from 'zustand'
import { createLogger } from '../utils/logger'
import {
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
type KeywordHighlightRule,
} from '../utils/keywordHighlight'
const logger = createLogger('SettingsStore')
export interface AppSettings {
// 常规
restoreOnStart: boolean
autoSave: boolean
showNotifications: boolean
// 外观
theme: 'dark' | 'light' | 'system'
language: 'zh-CN' | 'en-US'
sidebarWidth: number
// 终端
@@ -21,7 +19,6 @@ export interface AppSettings {
// SSH
keepAlive: boolean
defaultPort: number
timeout: number
// 专业连接管理
@@ -29,36 +26,51 @@ export interface AppSettings {
maxBackgroundConnections: number
idleTimeoutMs: number
suspendTimeoutMs: number
enableSnapshots: boolean
autoReconnect: boolean
maxReconnectAttempts: number
reconnectBaseDelayMs: number
// 安全
encryptPasswords: boolean
lockOnSleep: boolean
// 终端增强
scrollback: number
keywordHighlightEnabled: boolean
keywordHighlightRules: KeywordHighlightRule[]
}
const defaultSettings: AppSettings = {
restoreOnStart: true,
autoSave: true,
showNotifications: false,
theme: 'dark',
language: 'zh-CN',
sidebarWidth: 256,
fontFamily: 'JetBrains Mono',
// Prefer Nerd Font so local zsh powerline prompts render correctly
fontFamily: 'MesloLGS NF',
fontSize: 14,
terminalTheme: 'dracula',
keepAlive: true,
defaultPort: 22,
timeout: 30,
// 专业连接管理默认值
maxConnections: 10,
maxBackgroundConnections: 5,
idleTimeoutMs: 5 * 60 * 1000, // 5 分钟
suspendTimeoutMs: 30 * 60 * 1000, // 30 分钟
enableSnapshots: true,
autoReconnect: true,
encryptPasswords: true,
lockOnSleep: false,
maxReconnectAttempts: 5,
reconnectBaseDelayMs: 1500,
scrollback: 10000,
keywordHighlightEnabled: true,
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
}
export function normalizeAppSettings(value: unknown): AppSettings {
const source = value && typeof value === 'object'
? value as Record<string, unknown>
: {}
const normalized: Record<string, unknown> = {}
for (const key of Object.keys(defaultSettings) as Array<keyof AppSettings>) {
const candidate = source[key]
const fallback = defaultSettings[key]
normalized[key] = typeof candidate === typeof fallback ? candidate : fallback
}
return normalized as unknown as AppSettings
}
interface SettingsState {
@@ -75,9 +87,16 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
loadSettings: async () => {
try {
const result = await window.electronAPI?.settingsGet('app-settings')
const result = await window.opsxApi?.settingsGet('app-settings')
if (result?.success && result.value) {
set({ settings: { ...defaultSettings, ...result.value }, isLoaded: true })
const settings = normalizeAppSettings(result.value)
set({ settings, isLoaded: true })
const storedKeys = Object.keys(result.value as Record<string, unknown>)
const currentKeys = new Set(Object.keys(defaultSettings))
if (storedKeys.some((key) => !currentKeys.has(key))) {
await window.opsxApi?.settingsSet('app-settings', settings)
}
} else {
set({ isLoaded: true })
}
@@ -90,12 +109,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setSetting: async (key, value) => {
const newSettings = { ...get().settings, [key]: value }
set({ settings: newSettings })
await window.electronAPI?.settingsSet('app-settings', newSettings)
await window.opsxApi?.settingsSet('app-settings', newSettings)
},
saveSettings: async (newSettings) => {
const merged = { ...get().settings, ...newSettings }
set({ settings: merged })
await window.electronAPI?.settingsSet('app-settings', merged)
await window.opsxApi?.settingsSet('app-settings', merged)
},
}))
+3 -3
View File
@@ -2,7 +2,7 @@
* SnippetStore — 命令片段库状态管理
*
* 管理用户保存的常用命令片段,支持 CRUD 和搜索
* 数据通过 electronAPI.settingsGet/settingsSet 持久化
* 数据通过 opsxApi.settingsGet/settingsSet 持久化
*/
import { create } from 'zustand'
@@ -42,7 +42,7 @@ function generateId(): string {
/** 将片段数据保存到 electron settings */
async function persistSnippets(snippets: Snippet[]): Promise<void> {
try {
await window.electronAPI?.settingsSet(STORAGE_KEY, snippets)
await window.opsxApi?.settingsSet(STORAGE_KEY, snippets)
} catch (error) {
logger.error('Failed to persist snippets', error)
}
@@ -54,7 +54,7 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
loadSnippets: async () => {
try {
const result = await window.electronAPI?.settingsGet(STORAGE_KEY)
const result = await window.opsxApi?.settingsGet(STORAGE_KEY)
if (result?.success && Array.isArray(result.value)) {
set({ snippets: result.value as Snippet[], isLoaded: true })
} else {
+30 -2
View File
@@ -2,10 +2,11 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { createLogger } from '../utils/logger'
import type { SSHConnectionConfig } from '../../shared/types/session.types'
import type { SerialConfig } from '../../shared/types/serial.types'
const logger = createLogger('TabStore')
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
export type TabType = 'welcome' | 'terminal' | 'serial' | 'sftp' | 'portforward' | 'settings'
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
// 连接实例信息
@@ -15,6 +16,15 @@ export interface ConnectionInstance {
sftpId?: string // SFTP 会话 ID(仅 sftp 类型)
}
/** Terminal split layout (persisted without shell ids) */
export interface SplitLayout {
direction: 'horizontal' | 'vertical'
/** Number of panes 1–4 */
paneCount: number
/** Ratio used when paneCount === 2 (first pane share 0.2–0.8) */
ratio?: number
}
export interface Tab {
id: string
title: string
@@ -25,6 +35,18 @@ export interface Tab {
isActive: boolean
connectionStatus?: ConnectionStatus
needsReconnect?: boolean // 从持久化恢复后需要重新连接
/** Batch actions can establish inactive terminal tabs immediately. */
connectOnMount?: boolean
/** Terminal multi-pane layout */
splitLayout?: SplitLayout
/** Host local shell (node-pty), not SSH */
isLocal?: boolean
/** Local shell kind for tab badge: zsh | bash | fish | ... */
localShellKind?: string
/** Explicit shell binary path e.g. /bin/zsh */
localShellPath?: string
/** Native serial terminal configuration. */
serialConfig?: SerialConfig
}
// 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态)
@@ -33,6 +55,9 @@ interface PersistedTab {
title: string
type: TabType
sessionId?: string
splitLayout?: SplitLayout
isLocal?: boolean
serialConfig?: SerialConfig
// 注意:不持久化 connectionConfig(可能包含密码/密钥)
// 注意:不持久化 connectionInstance(运行时状态)
// 注意:不持久化 connectionStatus(运行时状态)
@@ -295,13 +320,16 @@ export const useTabStore = create<TabState>()(
const tabsToPersist = state.tabs
.filter(tab => {
// 持久化有 sessionId 的标签或非 welcome/settings 类型
return tab.type === 'terminal' || tab.type === 'sftp' || tab.type === 'portforward'
return tab.type === 'terminal' || tab.type === 'serial' || tab.type === 'sftp' || tab.type === 'portforward'
})
.map(tab => ({
id: tab.id,
title: tab.title,
type: tab.type,
sessionId: tab.sessionId,
splitLayout: tab.splitLayout,
isLocal: tab.isLocal,
serialConfig: tab.serialConfig,
// 不保存 connectionConfig(含密码等敏感信息)
// 不保存 connectionInstance(运行时状态)
// 不保存 connectionStatus(运行时状态)