From 2860935e5b6a953a85fa09c14da95054523f8207 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Tue, 12 Aug 2025 18:14:39 +0800 Subject: [PATCH] refactor(preferences): enhance PreferenceService with public access modifiers and improve caching logic This commit updates the PreferenceService by adding public access modifiers to several methods, improving code clarity and consistency. It also refines the caching logic to eliminate unnecessary type assertions and streamline the handling of preference values. Additionally, minor formatting adjustments are made for better readability across the service and related hooks. --- src/main/data/PreferenceService.ts | 5 +- src/renderer/src/data/PreferenceService.ts | 38 +-- src/renderer/src/data/hooks/usePreference.ts | 221 ++++++++++++++---- .../components/PreferenceHookTests.tsx | 84 +++---- .../components/PreferenceMultipleTests.tsx | 63 ++--- .../components/PreferenceServiceTests.tsx | 34 ++- .../windows/dataRefactorTest/entryPoint.tsx | 2 +- 7 files changed, 302 insertions(+), 145 deletions(-) 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()