feat(preferences): enhance selection management with type safety and refactor preferences structure

- Introduced new types for selection management: `SelectionActionItem`, `SelectionFilterMode`, and `SelectionTriggerMode` to improve type safety across the application.
- Updated `PreferencesType` to utilize these new types, ensuring better consistency and clarity in preference definitions.
- Refactored `SelectionService` and related components to adopt the new types, enhancing the handling of selection actions and preferences.
- Cleaned up deprecated types and improved the overall structure of preference management, aligning with recent updates in the preference system.
This commit is contained in:
fullex 2025-08-16 08:57:48 +08:00
parent 087e825086
commit b15778b16b
11 changed files with 80 additions and 123 deletions

View File

@ -1,3 +1,5 @@
import type { SelectionActionItem, SelectionFilterMode, SelectionTriggerMode } from './types'
/**
* Auto-generated preferences configuration
* Generated at: 2025-08-15T03:23:46.568Z
@ -13,7 +15,6 @@
"interfaces": { "order": "alphabetically" },
"typeLiterals": { "order": "alphabetically" }
}] */
export interface PreferencesType {
default: {
// redux/settings/enableDeveloperMode
@ -289,7 +290,7 @@ export interface PreferencesType {
// redux/settings/readClipboardAtStartup
'feature.quick_assistant.read_clipboard_at_startup': boolean
// redux/selectionStore/actionItems
'feature.selection.action_items': unknown[]
'feature.selection.action_items': SelectionActionItem[]
// redux/selectionStore/actionWindowOpacity
'feature.selection.action_window_opacity': number
// redux/selectionStore/isAutoClose
@ -303,13 +304,13 @@ export interface PreferencesType {
// redux/selectionStore/filterList
'feature.selection.filter_list': string[]
// redux/selectionStore/filterMode
'feature.selection.filter_mode': string
'feature.selection.filter_mode': SelectionFilterMode
// redux/selectionStore/isFollowToolbar
'feature.selection.follow_toolbar': boolean
// redux/selectionStore/isRemeberWinSize
'feature.selection.remember_win_size': boolean
// redux/selectionStore/triggerMode
'feature.selection.trigger_mode': string
'feature.selection.trigger_mode': SelectionTriggerMode
// redux/shortcuts/shortcuts.clear_topic
'shortcut.chat.clear': Record<string, unknown>
// redux/shortcuts/shortcuts.copy_last_message

View File

@ -3,6 +3,10 @@ import { PreferencesType } from './preferences'
export type PreferenceDefaultScopeType = PreferencesType['default']
export type PreferenceKeyType = keyof PreferenceDefaultScopeType
export interface PreferenceUpdateOptions {
optimistic: boolean
}
export type PreferenceShortcutType = {
key: string[]
editable: boolean
@ -10,6 +14,16 @@ export type PreferenceShortcutType = {
system: boolean
}
export interface PreferenceUpdateOptions {
optimistic: boolean
export type SelectionTriggerMode = 'selected' | 'ctrlkey' | 'shortcut'
export type SelectionFilterMode = 'default' | 'whitelist' | 'blacklist'
export interface SelectionActionItem {
id: string
name: string
enabled: boolean
isBuiltIn: boolean
icon?: string
prompt?: string
assistantId?: string
selectedText?: string
searchEngine?: string
}

View File

@ -159,16 +159,16 @@ if (!app.requestSingleInstanceLock()) {
await preferenceService.initialize()
// Create two test windows for cross-window preference sync testing
logger.info('Creating test windows for PreferenceService cross-window sync testing')
const testWindow1 = dataRefactorMigrateService.createTestWindow()
const testWindow2 = dataRefactorMigrateService.createTestWindow()
// // Create two test windows for cross-window preference sync testing
// logger.info('Creating test windows for PreferenceService cross-window sync testing')
// const testWindow1 = dataRefactorMigrateService.createTestWindow()
// const testWindow2 = dataRefactorMigrateService.createTestWindow()
// Position windows to avoid overlap
testWindow1.once('ready-to-show', () => {
const [x, y] = testWindow1.getPosition()
testWindow2.setPosition(x + 50, y + 50)
})
// // Position windows to avoid overlap
// testWindow1.once('ready-to-show', () => {
// const [x, y] = testWindow1.getPosition()
// testWindow2.setPosition(x + 50, y + 50)
// })
/************FOR TESTING ONLY END****************/
// Set app user model id for windows

View File

@ -2,6 +2,7 @@ import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { isDev, isMac, isWin } from '@main/constant'
import type { SelectionActionItem, SelectionFilterMode, SelectionTriggerMode } from '@shared/data/types'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import { join } from 'path'
@ -13,8 +14,6 @@ import type {
TextSelectionData
} from 'selection-hook'
import type { ActionItem } from '../../renderer/src/types/selectionTypes'
const logger = loggerService.withContext('SelectionService')
const isSupportedOS = isWin || isMac
@ -1248,7 +1247,7 @@ export class SelectionService {
* @param actionItem Action item to process
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
public processAction(actionItem: SelectionActionItem, isFullScreen: boolean = false): void {
const actionWindow = this.popActionWindow()
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
@ -1476,7 +1475,7 @@ export class SelectionService {
preferenceService.set('feature.selection.enabled', enabled)
})
ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: string) => {
ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: SelectionTriggerMode) => {
preferenceService.set('feature.selection.trigger_mode', triggerMode)
})
@ -1488,7 +1487,7 @@ export class SelectionService {
preferenceService.set('feature.selection.remember_win_size', isRemeberWinSize)
})
ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: string) => {
ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: SelectionFilterMode) => {
preferenceService.set('feature.selection.filter_mode', filterMode)
})
@ -1497,9 +1496,12 @@ export class SelectionService {
})
// [macOS] only macOS has the available isFullscreen mode
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
ipcMain.handle(
IpcChannel.Selection_ProcessAction,
(_, actionItem: SelectionActionItem, isFullScreen: boolean = false) => {
selectionService?.processAction(actionItem, isFullScreen)
})
}
)
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {
const actionWindow = BrowserWindow.fromWebContents(event.sender)

View File

@ -1,5 +1,6 @@
import '@renderer/databases'
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import store, { persistor } from '@renderer/store'
import { Provider } from 'react-redux'
@ -15,6 +16,8 @@ import Router from './Router'
const logger = loggerService.withContext('App.tsx')
preferenceService.loadAll()
function App(): React.ReactElement {
logger.info('App initialized')

View File

@ -109,7 +109,7 @@ export class PreferenceService {
this.notifyChangeListeners(key)
// Auto-subscribe to this key for future updates
await this.subscribeToKeyInternal(key)
await this.subscribeToKeyInternal([key])
return value
} catch (error) {
@ -230,7 +230,7 @@ export class PreferenceService {
this.notifyChangeListeners(key)
await this.subscribeToKeyInternal(key as PreferenceKeyType)
await this.subscribeToKeyInternal([key as PreferenceKeyType])
}
return { ...cachedResults, ...uncachedResults }
@ -343,15 +343,16 @@ export class PreferenceService {
/**
* Subscribe to a specific key for change notifications
*/
private async subscribeToKeyInternal(key: PreferenceKeyType): Promise<void> {
if (this.subscribedKeys.has(key)) return
private async subscribeToKeyInternal(keys: PreferenceKeyType[]): Promise<void> {
const keysToSubscribe = keys.filter((key) => !this.subscribedKeys.has(key))
if (keysToSubscribe.length === 0) return
try {
await window.api.preference.subscribe([key])
this.subscribedKeys.add(key)
logger.debug(`Subscribed to preference key: ${key}`)
await window.api.preference.subscribe(keysToSubscribe)
keysToSubscribe.forEach((key) => this.subscribedKeys.add(key))
logger.debug(`Subscribed to preference keys: ${keysToSubscribe.join(', ')}`)
} catch (error) {
logger.error(`Failed to subscribe to preference key ${key}:`, error as Error)
logger.error(`Failed to subscribe to preference keys ${keysToSubscribe.join(', ')}:`, error as Error)
}
}
@ -379,7 +380,7 @@ export class PreferenceService {
keyListeners.add(callback)
// Auto-subscribe to this key for updates
this.subscribeToKeyInternal(key)
this.subscribeToKeyInternal([key])
return () => {
keyListeners.delete(callback)
@ -417,11 +418,10 @@ export class PreferenceService {
// Notify change listeners for the loaded value
this.notifyChangeListeners(key)
// Auto-subscribe to this key if not already subscribed
await this.subscribeToKeyInternal(key as PreferenceKeyType)
}
await this.subscribeToKeyInternal(Object.keys(allPreferences) as PreferenceKeyType[])
this.fullCacheLoaded = true
logger.info(`Loaded all ${Object.keys(allPreferences).length} preferences into cache`)

View File

@ -1,11 +1,9 @@
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { isMac, isWin } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { getSelectionDescriptionLabel } from '@renderer/i18n/label'
import { FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
import { ActionItem } from '@renderer/types/selectionTypes'
import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar'
import type { SelectionFilterMode, SelectionTriggerMode } from '@shared/data/types'
import { Button, Radio, Row, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp, Edit2 } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
@ -26,35 +24,9 @@ import MacProcessTrustHintModal from './components/MacProcessTrustHintModal'
import SelectionActionsList from './components/SelectionActionsList'
import SelectionFilterListModal from './components/SelectionFilterListModal'
const logger = loggerService.withContext('Settings:SelectionAssistant')
const SelectionAssistantSettings: FC = () => {
const { theme } = useTheme()
const { t } = useTranslation()
// const {
// selectionEnabled,
// triggerMode,
// isCompact,
// isAutoClose,
// isAutoPin,
// isFollowToolbar,
// isRemeberWinSize,
// actionItems,
// actionWindowOpacity,
// filterMode,
// filterList,
// setSelectionEnabled,
// setTriggerMode,
// setIsCompact,
// setIsAutoClose,
// setIsAutoPin,
// setIsFollowToolbar,
// setIsRemeberWinSize,
// setActionWindowOpacity,
// setActionItems,
// setFilterMode,
// setFilterList
// } = useSelectionAssistant()
const [selectionEnabled, setSelectionEnabled] = usePreference('feature.selection.enabled')
const [triggerMode, setTriggerMode] = usePreference('feature.selection.trigger_mode')
@ -68,18 +40,6 @@ const SelectionAssistantSettings: FC = () => {
const [filterList, setFilterList] = usePreference('feature.selection.filter_list')
const [actionItems, setActionItems] = usePreference('feature.selection.action_items')
logger.debug(`selectionEnabled: ${selectionEnabled}`)
logger.debug(`triggerMode: ${triggerMode}`)
logger.debug(`isCompact: ${isCompact}`)
logger.debug(`isAutoClose: ${isAutoClose}`)
logger.debug(`isAutoPin: ${isAutoPin}`)
logger.debug(`isFollowToolbar: ${isFollowToolbar}`)
logger.debug(`isRemeberWinSize: ${isRemeberWinSize}`)
logger.debug(`actionWindowOpacity: ${actionWindowOpacity}`)
logger.debug(`filterMode: ${filterMode}`)
logger.debug(`filterList: ${filterList}`)
logger.debug(`actionItems: ${actionItems}`)
const isSupportedOS = isWin || isMac
const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false)
@ -168,7 +128,7 @@ const SelectionAssistantSettings: FC = () => {
</SettingLabel>
<Radio.Group
value={triggerMode}
onChange={(e) => setTriggerMode(e.target.value as TriggerMode)}
onChange={(e) => setTriggerMode(e.target.value as SelectionTriggerMode)}
buttonStyle="solid">
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.selected_note')} arrow>
<Radio.Button value="selected">{t('selection.settings.toolbar.trigger_mode.selected')}</Radio.Button>
@ -257,7 +217,7 @@ const SelectionAssistantSettings: FC = () => {
</SettingRow>
</SettingGroup>
<SelectionActionsList actionItems={actionItems as ActionItem[]} setActionItems={setActionItems} />
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
<SettingGroup theme={theme}>
<SettingTitle>{t('selection.settings.advanced.title')}</SettingTitle>
@ -269,7 +229,7 @@ const SelectionAssistantSettings: FC = () => {
</SettingLabel>
<Radio.Group
value={filterMode ?? 'default'}
onChange={(e) => setFilterMode(e.target.value as FilterMode)}
onChange={(e) => setFilterMode(e.target.value as SelectionFilterMode)}
buttonStyle="solid">
<Radio.Button value="default">{t('selection.settings.advanced.filter_mode.default')}</Radio.Button>
<Radio.Button value="whitelist">{t('selection.settings.advanced.filter_mode.whitelist')}</Radio.Button>

View File

@ -1,27 +0,0 @@
export type TriggerMode = 'selected' | 'ctrlkey' | 'shortcut'
export type FilterMode = 'default' | 'whitelist' | 'blacklist'
export interface ActionItem {
id: string
name: string
enabled: boolean
isBuiltIn: boolean
icon?: string
prompt?: string
assistantId?: string
selectedText?: string
searchEngine?: string
}
export interface SelectionState {
selectionEnabled: boolean
triggerMode: TriggerMode
isCompact: boolean
isAutoClose: boolean
isAutoPin: boolean
isFollowToolbar: boolean
isRemeberWinSize: boolean
filterMode: FilterMode
filterList: string[]
actionWindowOpacity: number
actionItems: ActionItem[]
}

View File

@ -1,9 +1,9 @@
import { usePreference } from '@data/hooks/usePreference'
import { isMac } from '@renderer/config/constant'
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { defaultLanguage } from '@shared/config/constant'
import type { SelectionActionItem } from '@shared/data/types'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Slider, Tooltip } from 'antd'
import { Droplet, Minus, Pin, X } from 'lucide-react'
@ -20,10 +20,13 @@ const SelectionActionApp: FC = () => {
const { t } = useTranslation()
const [action, setAction] = useState<ActionItem | null>(null)
const [action, setAction] = useState<SelectionActionItem | null>(null)
const isActionLoaded = useRef(false)
const { isAutoClose, isAutoPin, actionWindowOpacity } = useSelectionAssistant()
const [isAutoClose] = usePreference('feature.selection.auto_close')
const [isAutoPin] = usePreference('feature.selection.auto_pin')
const [actionWindowOpacity] = usePreference('feature.selection.action_window_opacity')
const [isPinned, setIsPinned] = useState(isAutoPin)
const [isWindowFocus, setIsWindowFocus] = useState(true)
@ -38,7 +41,7 @@ const SelectionActionApp: FC = () => {
useEffect(() => {
const actionListenRemover = window.electron?.ipcRenderer.on(
IpcChannel.Selection_UpdateActionData,
(_, actionItem: ActionItem) => {
(_, actionItem: SelectionActionItem) => {
setAction(actionItem)
isActionLoaded.current = true
}

View File

@ -11,8 +11,8 @@ import {
} from '@renderer/services/AssistantService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import { Assistant, Topic } from '@renderer/types'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController'
import type { SelectionActionItem } from '@shared/data/types'
import { ChevronDown } from 'lucide-react'
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,7 +22,7 @@ import { processMessages } from './ActionUtils'
import WindowFooter from './WindowFooter'
interface Props {
action: ActionItem
action: SelectionActionItem
scrollToBottom?: () => void
}

View File

@ -1,12 +1,12 @@
import '@renderer/assets/styles/selection-toolbar.scss'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { AppLogo } from '@renderer/config/env'
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { defaultLanguage } from '@shared/config/constant'
import type { SelectionActionItem } from '@shared/data/types'
import { IpcChannel } from '@shared/IpcChannel'
import { Avatar } from 'antd'
import { ClipboardCheck, ClipboardCopy, ClipboardX, MessageSquareHeart } from 'lucide-react'
@ -32,9 +32,9 @@ const updateWindowSize = () => {
* ActionIcons is a component that renders the action icons
*/
const ActionIcons: FC<{
actionItems: ActionItem[]
actionItems: SelectionActionItem[]
isCompact: boolean
handleAction: (action: ActionItem) => void
handleAction: (action: SelectionActionItem) => void
copyIconStatus: 'normal' | 'success' | 'fail'
copyIconAnimation: 'none' | 'enter' | 'exit'
}> = memo(({ actionItems, isCompact, handleAction, copyIconStatus, copyIconAnimation }) => {
@ -67,7 +67,7 @@ const ActionIcons: FC<{
}, [copyIconStatus, copyIconAnimation])
const renderActionButton = useCallback(
(action: ActionItem) => {
(action: SelectionActionItem) => {
const displayName = action.isBuiltIn ? t(action.name) : action.name
return (
@ -99,7 +99,8 @@ const ActionIcons: FC<{
*/
const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
const { language, customCss } = useSettings()
const { isCompact, actionItems } = useSelectionAssistant()
const [isCompact] = usePreference('feature.selection.compact')
const [actionItems] = usePreference('feature.selection.action_items')
const [animateKey, setAnimateKey] = useState(0)
const [copyIconStatus, setCopyIconStatus] = useState<'normal' | 'success' | 'fail'>('normal')
const [copyIconAnimation, setCopyIconAnimation] = useState<'none' | 'enter' | 'exit'>('none')
@ -179,7 +180,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
}
const handleAction = useCallback(
(action: ActionItem) => {
(action: SelectionActionItem) => {
if (demo) return
/** avoid mutating the original action, it will cause syncing issue */
@ -216,7 +217,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
}
}
const handleSearch = (action: ActionItem) => {
const handleSearch = (action: SelectionActionItem) => {
if (!action.searchEngine) return
const customUrl = action.searchEngine.split('|')[1]
@ -230,14 +231,14 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
/**
* Quote the selected text to the inputbar of the main window
*/
const handleQuote = (action: ActionItem) => {
const handleQuote = (action: SelectionActionItem) => {
if (action.selectedText) {
window.api?.quoteToMainWindow(action.selectedText)
window.api?.selection.hideToolbar()
}
}
const handleDefaultAction = (action: ActionItem) => {
const handleDefaultAction = (action: SelectionActionItem) => {
// [macOS] only macOS has the available isFullscreen mode
window.api?.selection.processAction(action, isFullScreen.current)
window.api?.selection.hideToolbar()