Skip to content

Commit 8c51eec

Browse files
ousugo0xfullex
andauthored
feat(composer): improve quick panel search aliases (#16730)
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: ousugo <dkzyxh@gmail.com>
1 parent ea382e8 commit 8c51eec

21 files changed

Lines changed: 153 additions & 42 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { defaultFilterFn } from '../defaultStrategies'
4+
import type { QuickPanelListItem } from '../types'
5+
6+
const fuzzyRegex = /unused/i
7+
8+
function matches(item: QuickPanelListItem, searchText: string) {
9+
return defaultFilterFn(item, searchText, fuzzyRegex, new WeakMap())
10+
}
11+
12+
describe('default QuickPanel filtering', () => {
13+
it('keeps filterText additive with label, description, and search aliases', () => {
14+
const item: QuickPanelListItem = {
15+
label: 'Visible label',
16+
description: 'Readable description',
17+
filterText: 'explicit-field',
18+
searchAliases: ['Hidden alias'],
19+
icon: 'icon'
20+
}
21+
22+
expect(matches(item, 'explicit')).toBe(true)
23+
expect(matches(item, 'visible')).toBe(true)
24+
expect(matches(item, 'readable')).toBe(true)
25+
expect(matches(item, 'hidden')).toBe(true)
26+
})
27+
})

src/renderer/components/QuickPanel/defaultStrategies.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,28 @@ import * as tinyPinyin from 'tiny-pinyin'
22

33
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
44

5+
/**
6+
* Concatenates all textual search fields into a single haystack for substring /
7+
* pinyin matching. `filterText` is additive here so the shared QuickPanel
8+
* strategy keeps matching the visible label and description by default.
9+
*/
10+
function buildItemHaystack(item: QuickPanelListItem): string {
11+
const parts: string[] = []
12+
if (item.filterText) parts.push(item.filterText)
13+
if (typeof item.label === 'string') parts.push(item.label)
14+
if (typeof item.description === 'string') parts.push(item.description)
15+
if (item.searchAliases) parts.push(...item.searchAliases)
16+
return parts.join(' ')
17+
}
18+
519
/**
620
* Default filter function
721
* Implements standard filtering logic with pinyin support
822
*/
923
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
1024
if (!searchText) return true
1125

12-
let filterText = item.filterText || ''
13-
if (typeof item.label === 'string') {
14-
filterText += item.label
15-
}
16-
if (typeof item.description === 'string') {
17-
filterText += item.description
18-
}
19-
26+
const filterText = buildItemHaystack(item)
2027
const lowerFilterText = filterText.toLowerCase()
2128
const lowerSearchText = searchText.toLowerCase()
2229

@@ -47,14 +54,7 @@ export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex
4754
* Higher score = better match
4855
*/
4956
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
50-
let filterText = item.filterText || ''
51-
if (typeof item.label === 'string') {
52-
filterText += item.label
53-
}
54-
if (typeof item.description === 'string') {
55-
filterText += item.description
56-
}
57-
57+
const filterText = buildItemHaystack(item)
5858
const lowerFilterText = filterText.toLowerCase()
5959
const lowerSearchText = searchText.toLowerCase()
6060

src/renderer/components/QuickPanel/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,14 @@ export type QuickPanelListItem = {
101101
label: React.ReactNode | string
102102
description?: React.ReactNode | string
103103
/**
104-
* Since title and description can be ReactNode values, provide separate text
105-
* for search filtering. This can combine the title and description strings.
104+
* Extra searchable text for items whose visible label/description are not
105+
* enough or are not plain strings. The default filter treats this as additive
106+
* with string labels and descriptions; custom filters may choose narrower
107+
* semantics.
106108
*/
107109
filterText?: string
110+
/** Extra searchable aliases that are not rendered in the panel. */
111+
searchAliases?: readonly string[]
108112
icon: React.ReactNode | string
109113
suffix?: React.ReactNode | string
110114
isSelected?: boolean

src/renderer/components/composer/quickPanel/__tests__/unifiedPanel.test.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -129,30 +129,54 @@ describe('createUnifiedQuickPanelOpenOptions', () => {
129129
expect(options.sortFn!(reversedItems, '')).toEqual(reversedItems)
130130
})
131131

132-
it('filters by filterText substring and pinyin, without loose fuzzy subsequence', () => {
133-
const options = createUnifiedQuickPanelOpenOptions([], {
134-
quickPanel,
135-
additionalItems: [
136-
{ id: 'skill:pdf', label: 'pdf', description: 'Read and analyze PDFs', filterText: 'pdf', icon: 'skill' }
132+
it('filters by explicit text, search aliases, pinyin, and pinyin initials without loose fuzzy subsequence', () => {
133+
const options = createUnifiedQuickPanelOpenOptions(
134+
[
135+
{
136+
id: 'web-search',
137+
kind: 'command',
138+
label: '网络搜索',
139+
icon: 'search',
140+
sources: ['root-panel'],
141+
searchAliases: ['Web Search', 'Online Search']
142+
}
137143
],
138-
resourceItems: [{ id: 'quick-phrases', label: '提示词管理', icon: 'phrase' }]
139-
})
144+
{
145+
quickPanel,
146+
additionalItems: [
147+
{
148+
id: 'skill:pdf',
149+
label: 'pdf',
150+
description: 'Read and analyze PDFs',
151+
filterText: 'pdf',
152+
icon: 'skill'
153+
}
154+
],
155+
resourceItems: [{ id: 'quick-phrases', label: '提示词管理', icon: 'phrase' }]
156+
}
157+
)
140158

141159
const filterFn = options.filterFn!
142160
const fuzzyRegex = /s.*l/i
143161
const pinyinCache = new WeakMap<QuickPanelListItem, string>()
144162
const skill = options.list.find((item) => item.label === 'pdf')!
145163
const quickPhrases = options.list.find((item) => item.label === '提示词管理')!
164+
const webSearch = options.list.find((item) => item.label === '网络搜索')!
146165

147-
// Skill matches its name...
166+
// Skills keep their explicit root-panel search field and do not match descriptions.
148167
expect(filterFn(skill, 'pdf', fuzzyRegex, pinyinCache)).toBe(true)
149-
// ...but not its description (name-only).
150168
expect(filterFn(skill, 'analyze', fuzzyRegex, pinyinCache)).toBe(false)
151169

152-
// Chinese row matches by substring and pinyin substring...
170+
// Chinese row matches by substring, pinyin substring, and pinyin initial substring...
153171
expect(filterFn(quickPhrases, '提示词', fuzzyRegex, pinyinCache)).toBe(true)
154172
expect(filterFn(quickPhrases, 'tishi', fuzzyRegex, pinyinCache)).toBe(true)
155-
// ...but not by a loose fuzzy subsequence of its pinyin (tiShiCiGuanLi).
173+
expect(filterFn(quickPhrases, 'tscgl', fuzzyRegex, pinyinCache)).toBe(true)
174+
175+
// Launcher rows with filterText still match hidden English aliases and visible Chinese labels by initials.
176+
expect(filterFn(webSearch, 'web', fuzzyRegex, pinyinCache)).toBe(true)
177+
expect(filterFn(webSearch, 'online', fuzzyRegex, pinyinCache)).toBe(true)
178+
expect(filterFn(webSearch, 'wlss', fuzzyRegex, pinyinCache)).toBe(true)
179+
// ...but not by a loose fuzzy subsequence of its pinyin or initials.
156180
expect(filterFn(quickPhrases, 'sl', fuzzyRegex, pinyinCache)).toBe(false)
157181
})
158182

src/renderer/components/composer/quickPanel/bridge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export function createComposerSuggestionQuickPanelItem(
8484
icon: item.icon,
8585
suffix: item.suffix,
8686
filterText: item.filterText,
87+
searchAliases: item.searchAliases,
8788
isSelected: item.selected,
8889
isMenu: item.isMenu,
8990
disabled: item.disabled,

src/renderer/components/composer/quickPanel/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
hasComposerQuickPanelTriggerBoundary,
1010
ROOT_QUICK_PANEL_ALLOWED_PREFIXES
1111
} from './bridge'
12+
export { getQuickPanelSearchAliases } from './searchAliases'
1213
export {
1314
COMPOSER_SUPPRESS_SUGGESTION_META,
1415
type ComposerSuggestionActiveChangeOptions,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { TFunction } from 'i18next'
2+
3+
export function getQuickPanelSearchAliases(t: TFunction, key: string, extraAliases: readonly string[] = []): string[] {
4+
const aliases = new Set<string>()
5+
const englishText = t(key, { lng: 'en-US', defaultValue: '' })
6+
7+
for (const value of [englishText, ...extraAliases]) {
8+
if (typeof value !== 'string') continue
9+
const alias = value.trim()
10+
if (!alias) continue
11+
aliases.add(alias)
12+
}
13+
14+
return Array.from(aliases)
15+
}

src/renderer/components/composer/quickPanel/suggestionExtension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ComposerSuggestionItem {
2121
isMenu?: boolean
2222
suffix?: ReactNode | string
2323
query?: string
24+
searchAliases?: readonly string[]
2425
command: (options: { editor: Editor; range: Range; item: ComposerSuggestionItem; query: string }) => void
2526
}
2627

src/renderer/components/composer/quickPanel/unifiedPanel.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,20 +142,29 @@ const sortUnifiedQuickPanelItems: QuickPanelSortFn = (items, searchText) => {
142142

143143
function getUnifiedQuickPanelMatchText(item: QuickPanelListItem) {
144144
// `filterText`, when set, is the authoritative search field for the item
145-
// (e.g. skills set it to their name only). Otherwise fall back to the visible
146-
// label + description so items without an explicit search field stay searchable.
147-
if (item.filterText) return item.filterText
145+
// (e.g. skills set it to their name only). Hidden aliases may still expand search.
146+
if (item.filterText) {
147+
return item.searchAliases ? `${item.filterText} ${item.searchAliases.join(' ')}` : item.filterText
148+
}
148149

149150
const parts: string[] = []
150151
if (typeof item.label === 'string') parts.push(item.label)
151152
if (typeof item.description === 'string') parts.push(item.description)
153+
if (item.searchAliases) parts.push(...item.searchAliases)
152154
return parts.join(' ')
153155
}
154156

157+
function getPinyinSearchText(matchText: string) {
158+
const pinyinWords = tinyPinyin.convertToPinyin(matchText, ' ', true).toLowerCase().split(/\s+/).filter(Boolean)
159+
const pinyinText = pinyinWords.join('')
160+
const pinyinInitials = pinyinWords.map((word) => word[0] ?? '').join('')
161+
return `${pinyinText} ${pinyinInitials}`
162+
}
163+
155164
/**
156-
* Root panel filter: substring match, plus pinyin substring for Chinese text.
157-
* Intentionally avoids the default loose fuzzy subsequence matching so unrelated
158-
* rows (e.g. Quick Phrases) don't surface for a query typed for another item.
165+
* Root panel filter: substring match, plus pinyin and pinyin-initial substring
166+
* matching for Chinese text. Intentionally avoids loose fuzzy subsequence matching
167+
* so unrelated rows (e.g. Quick Phrases) don't surface for another item's query.
159168
*/
160169
const filterUnifiedQuickPanelItems: QuickPanelFilterFn = (item, searchText, _fuzzyRegex, pinyinCache) => {
161170
if (!searchText) return true
@@ -169,7 +178,7 @@ const filterUnifiedQuickPanelItems: QuickPanelFilterFn = (item, searchText, _fuz
169178
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(matchText)) {
170179
let pinyinText = pinyinCache.get(item)
171180
if (pinyinText === undefined) {
172-
pinyinText = tinyPinyin.convertToPinyin(matchText, '', true).toLowerCase()
181+
pinyinText = getPinyinSearchText(matchText)
173182
pinyinCache.set(item, pinyinText)
174183
}
175184
return pinyinText.includes(query)
@@ -251,6 +260,7 @@ function createUnifiedPanelListItem(
251260
description: getLauncherDescription(launcher),
252261
icon: launcher.icon,
253262
suffix: launcher.suffix,
263+
searchAliases: launcher.searchAliases,
254264
isSelected: launcher.active,
255265
isMenu: launcher.kind === 'panel' || launcher.kind === 'group' || children.length > 0,
256266
disabled: launcher.disabled,

src/renderer/components/composer/toolLauncher.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface ComposerToolLauncher {
3939
description?: ReactNode | string
4040
tooltip?: ReactNode | string
4141
disabledReason?: ReactNode | string
42+
searchAliases?: readonly string[]
4243
icon: ReactNode | string
4344
suffix?: ReactNode | string
4445
active?: boolean

0 commit comments

Comments
 (0)