From 50a46ee5822bf728e672af9b934015cf04c92792 Mon Sep 17 00:00:00 2001 From: gaboolic <23441099@qq.com> Date: Sun, 24 May 2026 20:42:53 +0800 Subject: [PATCH 1/2] feat: add settings search with filtering and highlighting --- src/features/settings/SettingsDialog.tsx | 298 +++++++++++++++++- .../settings/components/SettingsUI.tsx | 4 +- .../settings/components/WorkspaceSettings.tsx | 2 + src/locales/en/settings.json | 1 + src/locales/zh-CN/settings.json | 1 + 5 files changed, 290 insertions(+), 16 deletions(-) diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index 90368cc2..089fbd97 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from 'react' import { useTranslation } from 'react-i18next' import { Dialog } from '../../components/ui/Dialog' import { @@ -13,6 +13,8 @@ import { MessageSquareIcon, LayersIcon, QuestionIcon, + SearchIcon, + ChevronRightIcon, } from '../../components/Icons' import { useIsMobile } from '../../hooks' import { isTauri } from '../../utils/tauri' @@ -110,6 +112,99 @@ const GROUP_DEFS: { labelKey: string; tabs: SettingsTab[] }[] = [ { labelKey: 'groups.advanced', tabs: ['service', 'keybindings', 'about'] }, ] +// ============================================ +// Settings Search Index +// ============================================ + +interface SettingsSearchItem { + tab: SettingsTab + sectionKey?: string + itemKey: string + descKey?: string + anchorId?: string +} + +const SETTINGS_SEARCH_INDEX: SettingsSearchItem[] = [ + // Servers + { tab: 'servers', sectionKey: 'servers.connections', itemKey: 'servers.connections', descKey: 'servers.connectionsDesc' }, + // Models + { tab: 'models', sectionKey: 'models.visibility', itemKey: 'models.visibility', descKey: 'models.visibilityDesc' }, + // Agent + { tab: 'agent', sectionKey: 'agent.behavior', itemKey: 'agent.behavior', descKey: 'agent.behaviorDesc' }, + { tab: 'agent', sectionKey: 'agent.toolInteraction', itemKey: 'agent.toolInteraction', descKey: 'agent.toolInteractionDesc' }, + // Chat + { tab: 'chat', sectionKey: 'chat.agentBehavior', itemKey: 'chat.autoApprove', descKey: 'chat.autoApproveDesc' }, + { tab: 'chat', sectionKey: 'chat.agentBehavior', itemKey: 'chat.queueFollowupMessages', descKey: 'chat.queueFollowupMessagesDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.conversationExperience', descKey: 'chat.conversationExperienceDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.collapseLongMessages', descKey: 'chat.collapseLongMessagesDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.descriptiveToolSteps', descKey: 'chat.descriptiveToolStepsDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.inlineToolRequests', descKey: 'chat.inlineToolRequestsDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.compactInlinePermission', descKey: 'chat.compactInlinePermissionDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.toolCardStyle', descKey: 'chat.toolCardStyleDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.immersiveMode', descKey: 'chat.immersiveModeDesc' }, + { tab: 'chat', sectionKey: 'chat.conversationExperience', itemKey: 'chat.thinkingDisplay', descKey: 'chat.thinkingDisplayDesc' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.stepFinishInfo' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showAgent' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showModel' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showTokenUsage' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showCacheHit' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showApiCost' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showResponseTime' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showTurnElapsed' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.showCompletedAt' }, + { tab: 'chat', sectionKey: 'chat.stepFinishInfo', itemKey: 'chat.completedAtFormat', descKey: 'chat.completedAtFormatDesc' }, + { tab: 'chat', sectionKey: 'chat.sidebarRecents', itemKey: 'chat.sidebarRecents', descKey: 'chat.sidebarRecentsDesc' }, + { tab: 'chat', sectionKey: 'chat.sidebarRecents', itemKey: 'chat.folderStyleRecents', descKey: 'chat.folderStyleRecentsDesc' }, + // Workspace + { tab: 'workspace', sectionKey: 'workspace.layout', itemKey: 'appearance.wideMode', descKey: 'appearance.wideModeDesc' }, + { tab: 'workspace', sectionKey: 'workspace.layout', itemKey: 'appearance.wakeLock', descKey: 'appearance.wakeLockDesc', anchorId: 'setting-wakeLock' }, + { tab: 'workspace', sectionKey: 'workspace.layout', itemKey: 'appearance.codeWordWrap', descKey: 'appearance.codeWordWrapDesc', anchorId: 'setting-codeWordWrap' }, + { tab: 'workspace', sectionKey: 'workspace.layout', itemKey: 'workspace.manualTerminalTitles', descKey: 'workspace.manualTerminalTitlesDesc' }, + { tab: 'workspace', sectionKey: 'workspace.layout', itemKey: 'appearance.diffStyle', descKey: 'appearance.diffStyleDesc' }, + { tab: 'workspace', sectionKey: 'workspace.terminal', itemKey: 'workspace.terminalCopyOnSelect', descKey: 'workspace.terminalCopyOnSelectDesc' }, + { tab: 'workspace', sectionKey: 'workspace.terminal', itemKey: 'workspace.terminalRightClickPaste', descKey: 'workspace.terminalRightClickPasteDesc' }, + { tab: 'workspace', sectionKey: 'workspace.sidebar', itemKey: 'appearance.folderStyleRecents', descKey: 'appearance.folderStyleRecentsDesc' }, + { tab: 'workspace', sectionKey: 'workspace.sidebar', itemKey: 'appearance.folderStyleRecentsShowDiff', descKey: 'appearance.folderStyleRecentsShowDiffDesc' }, + { tab: 'workspace', sectionKey: 'workspace.sidebar', itemKey: 'appearance.showChildSessions', descKey: 'appearance.showChildSessionsDesc' }, + // Appearance + { tab: 'appearance', sectionKey: 'appearance.themePresets', itemKey: 'appearance.themePresets', descKey: 'appearance.themePresetsDesc' }, + { tab: 'appearance', sectionKey: 'appearance.customCss', itemKey: 'appearance.customCss', descKey: 'appearance.customCssDesc' }, + { tab: 'appearance', sectionKey: 'appearance.display', itemKey: 'appearance.colorMode' }, + { tab: 'appearance', sectionKey: 'appearance.display', itemKey: 'appearance.glassEffect', descKey: 'appearance.glassEffectDesc' }, + { tab: 'appearance', sectionKey: 'appearance.display', itemKey: 'appearance.uiFontScale', descKey: 'appearance.uiFontScaleDesc' }, + { tab: 'appearance', sectionKey: 'appearance.display', itemKey: 'appearance.codeFontScale', descKey: 'appearance.codeFontScaleDesc' }, + { tab: 'appearance', sectionKey: 'appearance.display', itemKey: 'appearance.language', descKey: 'appearance.languageDesc' }, + // Notifications + { tab: 'notifications', sectionKey: 'notifications.systemNotifications', itemKey: 'notifications.notificationsLabel', descKey: 'notifications.systemNotificationsDesc' }, + { tab: 'notifications', sectionKey: 'notifications.systemNotifications', itemKey: 'notifications.notifyWhenComplete' }, + { tab: 'notifications', sectionKey: 'notifications.inAppAlerts', itemKey: 'notifications.toastNotifications', descKey: 'notifications.toastDesc' }, + { tab: 'notifications', sectionKey: 'notifications.soundSettings', itemKey: 'notifications.soundEnabled', descKey: 'notifications.soundEnabledDesc' }, + { tab: 'notifications', sectionKey: 'notifications.soundSettings', itemKey: 'notifications.currentSessionSound', descKey: 'notifications.currentSessionSoundDesc' }, + { tab: 'notifications', sectionKey: 'notifications.soundSettings', itemKey: 'notifications.volume', descKey: 'notifications.volumeDesc' }, + // Service + { tab: 'service', sectionKey: 'service.localService', itemKey: 'service.autoStart', descKey: 'service.autoStartDesc' }, + { tab: 'service', sectionKey: 'service.localService', itemKey: 'service.serviceStatus' }, + // About + { tab: 'about', sectionKey: 'about.versionCardTitle', itemKey: 'about.checkNow' }, + { tab: 'about', sectionKey: 'about.backupCardTitle', itemKey: 'about.exportBackup' }, + { tab: 'about', sectionKey: 'about.backupCardTitle', itemKey: 'about.importBackup' }, +] + +function highlightText(text: string, query: string): React.ReactNode { + if (!query.trim()) return text + const lower = text.toLowerCase() + const q = query.toLowerCase() + const idx = lower.indexOf(q) + if (idx === -1) return text + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ) +} + // ============================================ // Tab Content Router // ============================================ @@ -141,6 +236,83 @@ function TabContent({ tab }: { tab: SettingsTab }) { } } +// ============================================ +// Search Results +// ============================================ + +function SettingsSearchResults({ + query, + results, + t, + tabIcons, + tabLabels, + onSelect, +}: { + query: string + results: SettingsSearchItem[] + t: (key: string) => string + tabIcons: Record + tabLabels: { id: SettingsTab; label: string }[] + onSelect: (item: SettingsSearchItem) => void +}) { + const groups = useMemo(() => { + const map = new Map() + for (const item of results) { + const list = map.get(item.tab) || [] + list.push(item) + map.set(item.tab, list) + } + return [...map.entries()] + }, [results]) + + if (results.length === 0) { + return ( +
+ {t('models.noResults')} +
+ ) + } + + return ( +
+ {groups.map(([tabId, items]) => { + const tabLabel = tabLabels.find(vt => vt.id === tabId) + return ( +
+
+ {tabIcons[tabId]} + + {tabLabel?.label ?? tabId} + +
+
+ {items.map((item, idx) => ( + + ))} +
+
+ ) + })} +
+ ) +} + // ============================================ // Main Settings Dialog // ============================================ @@ -155,10 +327,38 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett return next }, []) const [tab, setTab] = useState(normalizeTab(initialTab)) + const [searchQuery, setSearchQuery] = useState('') + + const searchResults = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return null + const matchedTabs = new Set() + const items: SettingsSearchItem[] = [] + for (const item of SETTINGS_SEARCH_INDEX) { + const tabLabel = t(TAB_LABEL_KEYS[item.tab]).toLowerCase() + const tabDesc = t(TAB_DESC_KEYS[item.tab]).toLowerCase() + const itemLabel = t(item.itemKey).toLowerCase() + const itemDesc = item.descKey ? t(item.descKey).toLowerCase() : '' + const sectionLabel = item.sectionKey ? t(item.sectionKey).toLowerCase() : '' + if ( + tabLabel.includes(q) || tabDesc.includes(q) || + itemLabel.includes(q) || itemDesc.includes(q) || + sectionLabel.includes(q) + ) { + matchedTabs.add(item.tab) + items.push(item) + } + } + return { items, matchedTabs } + }, [searchQuery, t]) const visibleTabIds = useMemo( - () => (isTauriDesktop ? TAB_IDS : TAB_IDS.filter(id => id !== 'service')), - [isTauriDesktop], + () => { + const ids = isTauriDesktop ? TAB_IDS : TAB_IDS.filter(id => id !== 'service') + if (!searchResults) return ids + return ids.filter(id => searchResults.matchedTabs.has(id)) + }, + [isTauriDesktop, searchResults], ) const visibleTabs = useMemo( @@ -301,6 +501,29 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t('searchPlaceholder')} + className="w-full h-8 pl-8 pr-7 bg-bg-100 text-text-200 text-[length:var(--fs-sm)] rounded-md outline-none placeholder:text-text-400/50 border border-border-100/40 focus:border-border-100/70" + /> + {searchQuery && ( + + )} +
+
{/* Content - single scroll container */} @@ -311,8 +534,21 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett ref={scrollRef} className="flex-1 min-h-0 py-4 px-4 overflow-y-auto custom-scrollbar overscroll-contain" > - - + {searchQuery.trim() ? ( + { + switchTab(item.tab) + setSearchQuery('') + }} + /> + ) : ( + + )} ) @@ -385,23 +621,36 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett {/* Right Content */}
{/* Content Header - sticky at top */} -
-
-
{activeTabMeta.label}
-
- {activeTabMeta.description} -
+
+
+ + setSearchQuery(e.target.value)} + placeholder={t('searchPlaceholder')} + className="w-full h-8 pl-7 pr-7 bg-bg-100 text-text-200 text-[length:var(--fs-sm)] rounded-md outline-none placeholder:text-text-400/50 border border-border-100/40 focus:border-border-100/70" + /> + {searchQuery && ( + + )}
- {/* Scroll area - single scroll container for all tab content */} + {/* Scroll area */}
- + {searchQuery.trim() ? ( + { + switchTab(item.tab) + setSearchQuery('') + if (item.anchorId) { + requestAnimationFrame(() => { + document.getElementById(item.anchorId)?.scrollIntoView({ block: 'center' }) + }) + } + }} + /> + ) : ( + + )}
diff --git a/src/features/settings/components/SettingsUI.tsx b/src/features/settings/components/SettingsUI.tsx index b5b1eeb5..0dbca75d 100644 --- a/src/features/settings/components/SettingsUI.tsx +++ b/src/features/settings/components/SettingsUI.tsx @@ -110,11 +110,13 @@ export interface SettingRowProps { children: React.ReactNode onClick?: () => void className?: string + id?: string } -export function SettingRow({ label, description, icon, children, onClick, className }: SettingRowProps) { +export function SettingRow({ label, description, icon, children, onClick, className, id }: SettingRowProps) { return (
layoutStore.setWakeLock(!wakeLock)} @@ -46,6 +47,7 @@ export function WorkspaceSettings() { setCodeWordWrap(!codeWordWrap)} diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 57d0a879..c94b8aad 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -3,6 +3,7 @@ "subtitle": "Customize UI, behavior, and server setup", "closeSettings": "Close settings", "version": "OpenCodeUI v{{version}}", + "searchPlaceholder": "Search settings...", "tabs": { "servers": "Servers", "serversDesc": "Backend connections and fast active endpoint switching", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 556adf7b..01847afd 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -3,6 +3,7 @@ "subtitle": "自定义界面、行为和服务器配置", "closeSettings": "关闭设置", "version": "OpenCodeUI v{{version}}", + "searchPlaceholder": "搜索设置...", "tabs": { "servers": "服务器", "serversDesc": "后端连接和快速切换活跃端点", From 6cb0a1fffb1538ad8f36e37ba5d1b0fcab6ff408 Mon Sep 17 00:00:00 2001 From: gaboolic <23441099@qq.com> Date: Sun, 24 May 2026 20:53:25 +0800 Subject: [PATCH 2/2] feat: persist search highlight after clicking result via SettingsHighlightProvider context --- local_url.txt | 1 + src/features/settings/SettingsDialog.tsx | 40 ++++++++----------- .../settings/components/SettingsUI.tsx | 36 ++++++++++++++--- 3 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 local_url.txt diff --git a/local_url.txt b/local_url.txt new file mode 100644 index 00000000..f907df52 --- /dev/null +++ b/local_url.txt @@ -0,0 +1 @@ +curl -u "gbl:OpenCode.gbl" "http://192.168.1.114:4096/agent?directory=/home/gbl/doc-source/my-novel-gpt" \ No newline at end of file diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index 089fbd97..c9abda50 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Dialog } from '../../components/ui/Dialog' import { @@ -28,6 +28,7 @@ import { NotificationSettings } from './components/NotificationSettings' import { ServiceSettings } from './components/ServiceSettings' import { ServersSettings } from './components/ServersSettings' import { WorkspaceSettings } from './components/WorkspaceSettings' +import { SettingsHighlightProvider, highlightText } from './components/SettingsUI' // ============================================ // Types @@ -190,21 +191,6 @@ const SETTINGS_SEARCH_INDEX: SettingsSearchItem[] = [ { tab: 'about', sectionKey: 'about.backupCardTitle', itemKey: 'about.importBackup' }, ] -function highlightText(text: string, query: string): React.ReactNode { - if (!query.trim()) return text - const lower = text.toLowerCase() - const q = query.toLowerCase() - const idx = lower.indexOf(q) - if (idx === -1) return text - return ( - <> - {text.slice(0, idx)} - {text.slice(idx, idx + q.length)} - {text.slice(idx + q.length)} - - ) -} - // ============================================ // Tab Content Router // ============================================ @@ -328,6 +314,7 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett }, []) const [tab, setTab] = useState(normalizeTab(initialTab)) const [searchQuery, setSearchQuery] = useState('') + const [showSearchResults, setShowSearchResults] = useState(false) const searchResults = useMemo(() => { const q = searchQuery.trim().toLowerCase() @@ -509,14 +496,14 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett setSearchQuery(e.target.value)} + onChange={e => { setSearchQuery(e.target.value); setShowSearchResults(true) }} placeholder={t('searchPlaceholder')} className="w-full h-8 pl-8 pr-7 bg-bg-100 text-text-200 text-[length:var(--fs-sm)] rounded-md outline-none placeholder:text-text-400/50 border border-border-100/40 focus:border-border-100/70" /> {searchQuery && (
) @@ -658,7 +648,7 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett ref={scrollRef} className="flex-1 min-h-0 py-5 px-5 xl:px-6 overflow-y-auto custom-scrollbar" > - {searchQuery.trim() ? ( + {showSearchResults && searchQuery.trim() ? ( { switchTab(item.tab) - setSearchQuery('') + setShowSearchResults(false) if (item.anchorId) { requestAnimationFrame(() => { document.getElementById(item.anchorId)?.scrollIntoView({ block: 'center' }) @@ -676,7 +666,9 @@ export function SettingsDialog({ isOpen, onClose, initialTab = 'servers' }: Sett }} /> ) : ( - + + + )} diff --git a/src/features/settings/components/SettingsUI.tsx b/src/features/settings/components/SettingsUI.tsx index 0dbca75d..61a5573e 100644 --- a/src/features/settings/components/SettingsUI.tsx +++ b/src/features/settings/components/SettingsUI.tsx @@ -1,11 +1,35 @@ import type React from 'react' +import { createContext, useContext, Fragment } from 'react' // ============================================ // Shared Settings UI Primitives -// section + border-bottom 分隔, -// 标题左+描述左+控件右,无装饰图标,干净利落。 // ============================================ +const SettingsHighlightContext = createContext('') + +export function SettingsHighlightProvider({ query, children }: { query: string; children: React.ReactNode }) { + return {children} +} + +function useHighlight(): string { + return useContext(SettingsHighlightContext) +} + +export function highlightText(text: string, query: string): React.ReactNode { + if (!query.trim()) return text + const lower = text.toLowerCase() + const q = query.toLowerCase() + const idx = lower.indexOf(q) + if (idx === -1) return text + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ) +} + /** * Toggle switch — 36×20,即时生效。 * 圆角 full,hover 有 ring 反馈,checked 时 accent 色。 @@ -114,6 +138,7 @@ export interface SettingRowProps { } export function SettingRow({ label, description, icon, children, onClick, className, id }: SettingRowProps) { + const hq = useHighlight() return (
{icon && {icon}}
-

{label}

- {description &&

{description}

} +

{highlightText(label, hq)}

+ {description &&

{highlightText(description, hq)}

}
{children}
@@ -139,9 +164,10 @@ export function SettingRow({ label, description, icon, children, onClick, classN * h2 标题 + 内容 + 底部 border 分隔。最后一个 section 无 border。 */ export function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { + const hq = useHighlight() return (
-

{title}

+

{highlightText(title, hq)}

{children}
)