diff --git a/src/main/data/PreferenceService.ts b/src/main/data/PreferenceService.ts index 0c2f52294d..ca59adc50c 100644 --- a/src/main/data/PreferenceService.ts +++ b/src/main/data/PreferenceService.ts @@ -97,9 +97,8 @@ export class PreferenceService { throw new Error(`Preference ${key} not found in cache`) } - const db = dbService.getDb() - - await db + await dbService + .getDb() .update(preferenceTable) .set({ value: value as any diff --git a/src/renderer/src/data/PreferenceService.ts b/src/renderer/src/data/PreferenceService.ts index 95f328589a..77d15cbd1e 100644 --- a/src/renderer/src/data/PreferenceService.ts +++ b/src/renderer/src/data/PreferenceService.ts @@ -10,7 +10,7 @@ const logger = loggerService.withContext('PreferenceService') */ export class PreferenceService { private static instance: PreferenceService - private cache: Partial = {} + private cache: Record = {} private listeners = new Set<() => void>() private keyListeners = new Map void>>() private changeListenerCleanup: (() => void) | null = null @@ -24,7 +24,7 @@ export class PreferenceService { /** * Get the singleton instance of PreferenceService */ - static getInstance(): PreferenceService { + public static getInstance(): PreferenceService { if (!PreferenceService.instance) { PreferenceService.instance = new PreferenceService() } @@ -68,7 +68,7 @@ export class PreferenceService { /** * Get a single preference value with caching */ - async get(key: K): Promise { + public async get(key: K): Promise { // Check cache first if (key in this.cache && this.cache[key] !== undefined) { return this.cache[key] as PreferenceDefaultScopeType[K] @@ -95,7 +95,7 @@ export class PreferenceService { /** * Set a single preference value */ - async set(key: K, value: PreferenceDefaultScopeType[K]): Promise { + public async set(key: K, value: PreferenceDefaultScopeType[K]): Promise { try { await window.api.preference.set(key, value) @@ -113,14 +113,14 @@ export class PreferenceService { /** * Get multiple preferences at once */ - async getMultiple(keys: PreferenceKeyType[]): Promise> { + public async getMultiple(keys: PreferenceKeyType[]): Promise> { // Check which keys are already cached const cachedResults: Partial = {} const uncachedKeys: PreferenceKeyType[] = [] for (const key of keys) { if (key in this.cache && this.cache[key] !== undefined) { - ;(cachedResults as any)[key] = this.cache[key] + cachedResults[key] = this.cache[key] } else { uncachedKeys.push(key) } @@ -133,7 +133,7 @@ export class PreferenceService { // Update cache with new results for (const [key, value] of Object.entries(uncachedResults)) { - ;(this.cache as any)[key] = value + this.cache[key as PreferenceKeyType] = value } // Auto-subscribe to new keys @@ -165,13 +165,13 @@ export class PreferenceService { /** * Set multiple preferences at once */ - async setMultiple(updates: Partial): Promise { + public async setMultiple(updates: Partial): Promise { try { await window.api.preference.setMultiple(updates) // Update local cache for all updated values for (const [key, value] of Object.entries(updates)) { - ;(this.cache as any)[key] = value + this.cache[key as PreferenceKeyType] = value this.notifyListeners(key) } @@ -200,7 +200,7 @@ export class PreferenceService { /** * Subscribe to global preference changes (for useSyncExternalStore) */ - subscribe = (callback: () => void): (() => void) => { + public subscribe = (callback: () => void): (() => void) => { this.listeners.add(callback) return () => { this.listeners.delete(callback) @@ -210,7 +210,7 @@ export class PreferenceService { /** * Subscribe to specific key changes (for useSyncExternalStore) */ - subscribeToKey = + public subscribeToKey = (key: PreferenceKeyType) => (callback: () => void): (() => void) => { if (!this.keyListeners.has(key)) { @@ -234,7 +234,7 @@ export class PreferenceService { /** * Get snapshot for useSyncExternalStore */ - getSnapshot = + public getSnapshot = (key: K) => (): PreferenceDefaultScopeType[K] | undefined => { return this.cache[key] @@ -243,14 +243,14 @@ export class PreferenceService { /** * Get cached value without async fetch */ - getCachedValue(key: K): PreferenceDefaultScopeType[K] | undefined { + public getCachedValue(key: K): PreferenceDefaultScopeType[K] | undefined { return this.cache[key] } /** * Check if a preference is cached */ - isCached(key: PreferenceKeyType): boolean { + public isCached(key: PreferenceKeyType): boolean { return key in this.cache && this.cache[key] !== undefined } @@ -258,13 +258,13 @@ export class PreferenceService { * Load all preferences from main process at once * Provides optimal performance by loading complete preference set into memory */ - async loadAll(): Promise { + public async loadAll(): Promise { try { const allPreferences = await window.api.preference.getAll() // Update local cache with all preferences for (const [key, value] of Object.entries(allPreferences)) { - ;(this.cache as any)[key] = value + this.cache[key as PreferenceKeyType] = value // Auto-subscribe to this key if not already subscribed if (!this.subscribedKeys.has(key)) { @@ -285,7 +285,7 @@ export class PreferenceService { /** * Check if all preferences are loaded in cache */ - isFullyCached(): boolean { + public isFullyCached(): boolean { return this.fullCacheLoaded } @@ -308,7 +308,7 @@ export class PreferenceService { /** * Clear all cached preferences (for testing/debugging) */ - clearCache(): void { + public clearCache(): void { this.cache = {} this.fullCacheLoaded = false logger.debug('Preference cache cleared') @@ -317,7 +317,7 @@ export class PreferenceService { /** * Cleanup service (call when shutting down) */ - cleanup(): void { + public cleanup(): void { if (this.changeListenerCleanup) { this.changeListenerCleanup() this.changeListenerCleanup = null diff --git a/src/renderer/src/data/hooks/usePreference.ts b/src/renderer/src/data/hooks/usePreference.ts index 1324099053..743663d68a 100644 --- a/src/renderer/src/data/hooks/usePreference.ts +++ b/src/renderer/src/data/hooks/usePreference.ts @@ -1,17 +1,69 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types' import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' -import { preferenceService } from '../PreferenceService' - const logger = loggerService.withContext('usePreference') /** - * React hook for managing a single preference value - * Uses useSyncExternalStore for optimal React 18 integration + * React hook for managing a single preference value with automatic synchronization + * Uses useSyncExternalStore for optimal React 18 integration and real-time updates * - * @param key - The preference key to manage - * @returns [value, setValue] - Current value and setter function + * @param key - The preference key to manage (must be a valid PreferenceKeyType) + * @returns A tuple [value, setValue] where: + * - value: Current preference value or undefined if not loaded/cached + * - setValue: Async function to update the preference value + * + * @example + * ```typescript + * // Basic usage - managing theme preference + * const [theme, setTheme] = usePreference('app.theme.mode') + * + * // Conditional rendering based on preference value + * if (theme === undefined) { + * return + * } + * + * // Updating preference value + * const handleThemeChange = async (newTheme: string) => { + * try { + * await setTheme(newTheme) + * } catch (error) { + * console.error('Failed to update theme:', error) + * } + * } + * + * return ( + * + * ) + * ``` + * + * @example + * ```typescript + * // Advanced usage with form handling for message font size + * const [fontSize, setFontSize] = usePreference('chat.message.font_size') + * + * const handleFontSizeChange = useCallback(async (size: number) => { + * if (size < 8 || size > 72) { + * throw new Error('Font size must be between 8 and 72') + * } + * await setFontSize(size) + * }, [setFontSize]) + * + * return ( + * handleFontSizeChange(Number(e.target.value))} + * min={8} + * max={72} + * /> + * ) + * ``` */ export function usePreference( key: K @@ -49,13 +101,126 @@ export function usePreference( } /** - * React hook for managing multiple preference values - * Efficiently batches operations and provides type-safe interface + * React hook for managing multiple preference values with efficient batch operations + * Automatically synchronizes all specified preferences and provides type-safe access * - * @param keys - Object mapping local names to preference keys - * @returns [values, updateValues] - Current values and batch update function + * @param keys - Object mapping local names to preference keys. Keys are your custom names, + * values must be valid PreferenceKeyType identifiers + * @returns A tuple [values, updateValues] where: + * - values: Object with your local keys mapped to current preference values (undefined if not loaded) + * - updateValues: Async function to batch update multiple preferences at once + * + * @example + * ```typescript + * // Basic usage - managing related UI preferences + * const [uiSettings, setUISettings] = useMultiplePreferences({ + * theme: 'app.theme.mode', + * fontSize: 'chat.message.font_size', + * showLineNumbers: 'chat.code.show_line_numbers' + * }) + * + * // Accessing individual values with type safety + * const currentTheme = uiSettings.theme // string | undefined + * const currentFontSize = uiSettings.fontSize // number | undefined + * const showLines = uiSettings.showLineNumbers // boolean | undefined + * + * // Batch updating multiple preferences + * const resetToDefaults = async () => { + * await setUISettings({ + * theme: 'ThemeMode.light', + * fontSize: 14, + * showLineNumbers: true + * }) + * } + * + * // Partial updates (only specified keys will be updated) + * const toggleTheme = async () => { + * await setUISettings({ + * theme: currentTheme === 'ThemeMode.light' ? 'ThemeMode.dark' : 'ThemeMode.light' + * }) + * } + * ``` + * + * @example + * ```typescript + * // Advanced usage - backup settings form with validation + * const [settings, updateSettings] = useMultiplePreferences({ + * autoSync: 'data.backup.local.auto_sync', + * backupDir: 'data.backup.local.dir', + * maxBackups: 'data.backup.local.max_backups', + * syncInterval: 'data.backup.local.sync_interval' + * }) + * + * // Form submission with error handling + * const handleSubmit = async (formData: Partial) => { + * try { + * // Validate before saving + * if (formData.maxBackups && formData.maxBackups < 0) { + * throw new Error('Max backups must be non-negative') + * } + * + * await updateSettings(formData) + * showSuccessMessage('Backup settings saved successfully') + * } catch (error) { + * showErrorMessage(`Failed to save settings: ${error.message}`) + * } + * } + * + * // Conditional rendering based on loading state + * if (Object.values(settings).every(val => val === undefined)) { + * return + * } + * + * return ( + *
{ + * e.preventDefault() + * handleSubmit({ + * maxBackups: parseInt(e.target.maxBackups.value), + * syncInterval: parseInt(e.target.syncInterval.value) + * }) + * }}> + * + * + * + *
+ * ) + * ``` + * + * @example + * ```typescript + * // Performance optimization - grouping related chat code preferences + * const [codePrefs] = useMultiplePreferences({ + * showLineNumbers: 'chat.code.show_line_numbers', + * wrappable: 'chat.code.wrappable', + * collapsible: 'chat.code.collapsible', + * autocompletion: 'chat.code.editor.autocompletion', + * foldGutter: 'chat.code.editor.fold_gutter' + * }) + * + * // Single subscription handles all code preferences + * // More efficient than 5 separate usePreference calls + * const codeConfig = useMemo(() => ({ + * showLineNumbers: codePrefs.showLineNumbers ?? false, + * wrappable: codePrefs.wrappable ?? false, + * collapsible: codePrefs.collapsible ?? false, + * autocompletion: codePrefs.autocompletion ?? true, + * foldGutter: codePrefs.foldGutter ?? false + * }), [codePrefs]) + * + * return + * ``` */ -export function usePreferences>( +export function useMultiplePreferences>( keys: T ): [ { [P in keyof T]: PreferenceDefaultScopeType[T[P]] | undefined }, @@ -63,7 +228,6 @@ export function usePreferences>( ] { // Create stable key dependencies const keyList = useMemo(() => Object.values(keys), [keys]) - const keysStringified = useMemo(() => JSON.stringify(keys), [keys]) // Cache the last snapshot to avoid infinite loops const lastSnapshotRef = useRef>({}) @@ -78,7 +242,7 @@ export function usePreferences>( unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()) } }, - [keysStringified] + [keyList] ), useCallback(() => { @@ -101,9 +265,9 @@ export function usePreferences>( } return lastSnapshotRef.current - }, [keysStringified]), + }, [keys]), - () => ({}) // SSR snapshot + () => ({}) // No SSR snapshot ) // Load initial values asynchronously if not cached @@ -115,7 +279,7 @@ export function usePreferences>( logger.error('Failed to load initial preferences:', error as Error) }) } - }, [keysStringified]) + }, [keyList]) // Memoized batch update function const updateValues = useCallback( @@ -136,7 +300,7 @@ export function usePreferences>( throw error } }, - [keysStringified] + [keys] ) // Type-cast the values to the expected shape @@ -144,26 +308,3 @@ export function usePreferences>( return [typedValues, updateValues] } - -/** - * Hook for preloading preferences to improve performance - * Useful for components that will use many preferences - * - * @param keys - Array of preference keys to preload - */ -export function usePreferencePreload(keys: PreferenceKeyType[]): void { - const keysString = useMemo(() => keys.join(','), [keys]) - useEffect(() => { - preferenceService.preload(keys).catch((error) => { - logger.error('Failed to preload preferences:', error as Error) - }) - }, [keysString]) -} - -/** - * Hook for getting the preference service instance - * Useful for non-reactive operations or advanced usage - */ -export function usePreferenceService() { - return preferenceService -} diff --git a/src/renderer/src/windows/dataRefactorTest/components/PreferenceHookTests.tsx b/src/renderer/src/windows/dataRefactorTest/components/PreferenceHookTests.tsx index a7235a3235..e862006af1 100644 --- a/src/renderer/src/windows/dataRefactorTest/components/PreferenceHookTests.tsx +++ b/src/renderer/src/windows/dataRefactorTest/components/PreferenceHookTests.tsx @@ -29,11 +29,11 @@ const PreferenceHookTests: React.FC = () => { const testSubscriptions = () => { // Test subscription behavior const unsubscribe = preferenceService.subscribeToKey('app.theme.mode')(() => { - setSubscriptionCount(prev => prev + 1) + setSubscriptionCount((prev) => prev + 1) }) message.info('已添加订阅,修改app.theme.mode将触发计数') - + // Clean up after 10 seconds setTimeout(() => { unsubscribe() @@ -45,14 +45,14 @@ const PreferenceHookTests: React.FC = () => { try { const keys: PreferenceKeyType[] = ['app.theme.mode', 'app.language', 'app.zoom_factor', 'app.spell_check.enabled'] await preferenceService.preload(keys) - - const cachedStates = keys.map(key => ({ + + const cachedStates = keys.map((key) => ({ key, isCached: preferenceService.isCached(key), value: preferenceService.getCachedValue(key) })) - - message.success(`预加载完成。缓存状态: ${cachedStates.filter(s => s.isCached).length}/${keys.length}`) + + message.success(`预加载完成。缓存状态: ${cachedStates.filter((s) => s.isCached).length}/${keys.length}`) console.log('Cache states:', cachedStates) } catch (error) { message.error(`预加载失败: ${(error as Error).message}`) @@ -63,16 +63,16 @@ const PreferenceHookTests: React.FC = () => { try { const keys: PreferenceKeyType[] = ['app.theme.mode', 'app.language'] const result = await preferenceService.getMultiple(keys) - + message.success(`批量获取成功: ${Object.keys(result).length} 项`) console.log('Batch get result:', result) - + // Test batch set await preferenceService.setMultiple({ 'app.theme.mode': theme1 === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark', 'app.language': language === 'zh-CN' ? 'en-US' : 'zh-CN' }) - + message.success('批量设置成功') } catch (error) { message.error(`批量操作失败: ${(error as Error).message}`) @@ -82,23 +82,25 @@ const PreferenceHookTests: React.FC = () => { const performanceTest = async () => { const start = performance.now() const iterations = 100 - + try { // Test rapid reads for (let i = 0; i < iterations; i++) { preferenceService.getCachedValue('app.theme.mode') } - + const readTime = performance.now() - start - + // Test rapid writes const writeStart = performance.now() for (let i = 0; i < 10; i++) { await preferenceService.set('app.theme.mode', `ThemeMode.test_${i}`) } const writeTime = performance.now() - writeStart - - message.success(`性能测试完成: 读取${iterations}次耗时${readTime.toFixed(2)}ms, 写入10次耗时${writeTime.toFixed(2)}ms`) + + message.success( + `性能测试完成: 读取${iterations}次耗时${readTime.toFixed(2)}ms, 写入10次耗时${writeTime.toFixed(2)}ms` + ) } catch (error) { message.error(`性能测试失败: ${(error as Error).message}`) } @@ -110,11 +112,21 @@ const PreferenceHookTests: React.FC = () => { {/* Hook State Display */} - 组件渲染次数: {renderCountRef.current} - 订阅触发次数: {subscriptionCount} - Theme Hook 1: {String(theme1)} - Theme Hook 2: {String(theme2)} - Language Hook: {String(language)} + + 组件渲染次数: {renderCountRef.current} + + + 订阅触发次数: {subscriptionCount} + + + Theme Hook 1: {String(theme1)} + + + Theme Hook 2: {String(theme2)} + + + Language Hook: {String(language)} + 注意: 相同key的多个hook应该返回相同值 @@ -123,24 +135,18 @@ const PreferenceHookTests: React.FC = () => { {/* Test Actions */} - - - - + + + + {/* Service Information */} - Service实例: {preferenceService ? '已连接' : '未连接'} + + Service实例: {preferenceService ? '已连接' : '未连接'} + 预加载Keys: app.theme.mode, app.language, app.zoom_factor usePreferenceService() 返回的是同一个单例实例 @@ -152,15 +158,9 @@ const PreferenceHookTests: React.FC = () => { 实时同步测试: - - 1. 在其他测试组件中修改 app.theme.mode 或 app.language - - - 2. 观察此组件中的值是否实时更新 - - - 3. 检查订阅触发次数是否增加 - + 1. 在其他测试组件中修改 app.theme.mode 或 app.language + 2. 观察此组件中的值是否实时更新 + 3. 检查订阅触发次数是否增加 @@ -174,4 +174,4 @@ const TestContainer = styled.div` border-radius: 8px; ` -export default PreferenceHookTests \ No newline at end of file +export default PreferenceHookTests diff --git a/src/renderer/src/windows/dataRefactorTest/components/PreferenceMultipleTests.tsx b/src/renderer/src/windows/dataRefactorTest/components/PreferenceMultipleTests.tsx index cb65eb6951..d92f254949 100644 --- a/src/renderer/src/windows/dataRefactorTest/components/PreferenceMultipleTests.tsx +++ b/src/renderer/src/windows/dataRefactorTest/components/PreferenceMultipleTests.tsx @@ -1,4 +1,4 @@ -import { usePreferences } from '@renderer/data/hooks/usePreference' +import { useMultiplePreferences } from '@renderer/data/hooks/usePreference' import { Button, Card, Input, message, Select, Space, Table, Typography } from 'antd' import { ColumnType } from 'antd/es/table' import React, { useState } from 'react' @@ -40,7 +40,7 @@ const PreferenceMultipleTests: React.FC = () => { } as const const currentKeys = scenarios[scenario as keyof typeof scenarios] - const [values, updateValues] = usePreferences(currentKeys) + const [values, updateValues] = useMultiplePreferences(currentKeys) const [batchInput, setBatchInput] = useState('') @@ -72,7 +72,7 @@ const PreferenceMultipleTests: React.FC = () => { const sampleUpdates: Record = {} Object.keys(currentKeys).forEach((localKey, index) => { const prefKey = currentKeys[localKey as keyof typeof currentKeys] - + switch (prefKey) { case 'app.theme.mode': sampleUpdates[localKey] = values[localKey] === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark' @@ -81,7 +81,7 @@ const PreferenceMultipleTests: React.FC = () => { sampleUpdates[localKey] = values[localKey] === 'zh-CN' ? 'en-US' : 'zh-CN' break case 'app.zoom_factor': - sampleUpdates[localKey] = 1.0 + (index * 0.1) + sampleUpdates[localKey] = 1.0 + index * 0.1 break case 'app.spell_check.enabled': sampleUpdates[localKey] = !values[localKey] @@ -90,7 +90,7 @@ const PreferenceMultipleTests: React.FC = () => { sampleUpdates[localKey] = `sample_value_${index}` } }) - + setBatchInput(JSON.stringify(sampleUpdates, null, 2)) } @@ -115,7 +115,11 @@ const PreferenceMultipleTests: React.FC = () => { render: (value: any) => ( {value !== undefined ? ( - typeof value === 'object' ? JSON.stringify(value) : String(value) + typeof value === 'object' ? ( + JSON.stringify(value) + ) : ( + String(value) + ) ) : ( undefined )} @@ -136,7 +140,14 @@ const PreferenceMultipleTests: React.FC = () => { render: (_, record) => ( {record.prefKey === 'app.theme.mode' && ( - )} @@ -146,7 +157,9 @@ const PreferenceMultipleTests: React.FC = () => { )} {record.prefKey === 'app.language' && ( - )} @@ -183,35 +196,27 @@ const PreferenceMultipleTests: React.FC = () => { {/* Current Values Table */} - +
{/* Batch Update */} - + - + setBatchInput(e.target.value)} - placeholder="输入JSON格式的批量更新数据,例如: {"theme": "dark", "language": "en-US"}" + placeholder='输入JSON格式的批量更新数据,例如: {"theme": "dark", "language": "en-US"}' rows={6} style={{ fontFamily: 'monospace' }} /> - + 格式: {"localKey": "newValue", ...} - 使用本地键名,不是完整的偏好设置键名 @@ -221,15 +226,17 @@ const PreferenceMultipleTests: React.FC = () => { {/* Quick Actions */} - - - - - + + @@ -156,7 +164,9 @@ const PreferenceServiceTests: React.FC = () => { {getResult !== null && ( Result: - {typeof getResult === 'object' ? JSON.stringify(getResult, null, 2) : String(getResult)} + + {typeof getResult === 'object' ? JSON.stringify(getResult, null, 2) : String(getResult)} + )} @@ -215,4 +225,4 @@ const ResultText = styled.pre` word-break: break-all; ` -export default PreferenceServiceTests \ No newline at end of file +export default PreferenceServiceTests diff --git a/src/renderer/src/windows/dataRefactorTest/entryPoint.tsx b/src/renderer/src/windows/dataRefactorTest/entryPoint.tsx index ac164184e8..504f6a2337 100644 --- a/src/renderer/src/windows/dataRefactorTest/entryPoint.tsx +++ b/src/renderer/src/windows/dataRefactorTest/entryPoint.tsx @@ -10,4 +10,4 @@ loggerService.initWindowSource('DataRefactorTestWindow') const root = createRoot(document.getElementById('root') as HTMLElement) -root.render() \ No newline at end of file +root.render()