feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}))
|
||||
@@ -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
@@ -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(运行时状态)
|
||||
|
||||
Reference in new issue
Block a user