feat(settings): improve search to include nav items and show all results (#9195)

## Summary
- Settings search now matches sidebar navigation items (Keybinding,
About, Extension, etc.) and navigates to the corresponding panel
- Search results show all matching settings across all categories
instead of filtering to only the first matching category
- Search result group headers display parent category prefix (e.g.
"LiteGraph › Node") for clarity

## Test plan
- [x] Search "Keybinding" → sidebar highlights and navigates to
Keybinding panel
- [x] Search "badge" → shows all 4 badge settings (3 LiteGraph + 1
Comfy)
- [x] Search "canvas" → shows results from all categories
- [x] Clear search → returns to default category
- [x] Unit tests pass (`pnpm test:unit`)
<img width="1425" height="682" alt="스크린샷 2026-02-25 오후 3 01 05"
src="https://github.com/user-attachments/assets/956c4635-b140-4dff-8145-db312d295160"
/>



🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9195-feat-settings-improve-search-to-include-nav-items-and-show-all-results-3126d73d3650814dbf3ce1d59ad962cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Dante
2026-02-26 10:14:37 +09:00
committed by GitHub
parent 1ab48b42a7
commit e9bf113686
5 changed files with 196 additions and 38 deletions

View File

@@ -44,17 +44,7 @@
<template #header /> <template #header />
<template #content> <template #content>
<template v-if="inSearch"> <template v-if="activePanel">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
<ColorPaletteMessage
v-if="activeSettingCategory.label === 'Appearance'"
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
<template v-else-if="activePanel">
<Suspense> <Suspense>
<component :is="activePanel.component" v-bind="activePanel.props" /> <component :is="activePanel.component" v-bind="activePanel.props" />
<template #fallback> <template #fallback>
@@ -64,6 +54,16 @@
</template> </template>
</Suspense> </Suspense>
</template> </template>
<template v-else-if="inSearch">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
<ColorPaletteMessage
v-if="activeSettingCategory.label === 'Appearance'"
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
</template> </template>
</BaseModalLayout> </BaseModalLayout>
</template> </template>
@@ -110,6 +110,7 @@ const {
searchQuery, searchQuery,
inSearch, inSearch,
searchResultsCategories, searchResultsCategories,
matchedNavItemKeys,
handleSearch: handleSearchBase, handleSearch: handleSearchBase,
getSearchResults getSearchResults
} = useSettingSearch() } = useSettingSearch()
@@ -119,16 +120,29 @@ const authActions = useFirebaseAuthActions()
const navRef = ref<HTMLElement | null>(null) const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null) const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
watch(searchResultsCategories, (categories) => { const searchableNavItems = computed(() =>
if (!inSearch.value || categories.size === 0) return navGroups.value.flatMap((g) =>
const firstMatch = navGroups.value g.items.map((item) => ({
.flatMap((g) => g.items) key: item.id,
.find((item) => { label: item.label
const node = findCategoryByKey(item.id) }))
return node && categories.has(node.label) )
}) )
activeCategoryKey.value = firstMatch?.id ?? null
}) watch(
[searchResultsCategories, matchedNavItemKeys],
([categories, navKeys]) => {
if (!inSearch.value || (categories.size === 0 && navKeys.size === 0)) return
const firstMatch = navGroups.value
.flatMap((g) => g.items)
.find((item) => {
if (navKeys.has(item.id)) return true
const node = findCategoryByKey(item.id)
return node && categories.has(node.label)
})
activeCategoryKey.value = firstMatch?.id ?? null
}
)
const activeSettingCategory = computed<SettingTreeNode | null>(() => { const activeSettingCategory = computed<SettingTreeNode | null>(() => {
if (!activeCategoryKey.value) return null if (!activeCategoryKey.value) return null
@@ -163,7 +177,7 @@ function sortedGroups(category: SettingTreeNode): ISettingGroup[] {
} }
function handleSearch(query: string) { function handleSearch(query: string) {
handleSearchBase(query.trim()) handleSearchBase(query.trim(), searchableNavItems.value)
if (query) { if (query) {
activeCategoryKey.value = null activeCategoryKey.value = null
} else if (!activeCategoryKey.value) { } else if (!activeCategoryKey.value) {
@@ -175,12 +189,7 @@ function onNavItemClick(id: string) {
activeCategoryKey.value = id activeCategoryKey.value = id
} }
const searchResults = computed<ISettingGroup[]>(() => { const searchResults = computed<ISettingGroup[]>(() => getSearchResults(null))
const category = activeCategoryKey.value
? findCategoryByKey(activeCategoryKey.value)
: null
return getSearchResults(category)
})
// Scroll to and highlight the target setting once the correct category renders. // Scroll to and highlight the target setting once the correct category renders.
if (scrollToSettingId) { if (scrollToSettingId) {

View File

@@ -2,6 +2,15 @@
<div class="setting-group"> <div class="setting-group">
<Divider v-if="divider" /> <Divider v-if="divider" />
<h3> <h3>
<span v-if="group.category" class="text-muted">
{{
$t(
`settingsCategories.${normalizeI18nKey(group.category)}`,
group.category
)
}}
&#8250;
</span>
{{ {{
$t(`settingsCategories.${normalizeI18nKey(group.label)}`, group.label) $t(`settingsCategories.${normalizeI18nKey(group.label)}`, group.label)
}} }}
@@ -27,6 +36,7 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{ defineProps<{
group: { group: {
label: string label: string
category?: string
settings: SettingParams[] settings: SettingParams[]
} }
divider?: boolean divider?: boolean

View File

@@ -299,10 +299,12 @@ describe('useSettingSearch', () => {
expect(results).toEqual([ expect(results).toEqual([
{ {
label: 'Basic', label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']] settings: [mockSettings['Category.Setting1']]
}, },
{ {
label: 'Advanced', label: 'Advanced',
category: 'Category',
settings: [mockSettings['Category.Setting2']] settings: [mockSettings['Category.Setting2']]
} }
]) ])
@@ -332,15 +334,50 @@ describe('useSettingSearch', () => {
expect(results).toEqual([ expect(results).toEqual([
{ {
label: 'Basic', label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']] settings: [mockSettings['Category.Setting1']]
}, },
{ {
label: 'SubCategory', label: 'SubCategory',
category: 'Other',
settings: [mockSettings['Other.Setting3']] settings: [mockSettings['Other.Setting3']]
} }
]) ])
}) })
it('returns results from all categories when searching cross-category term', () => {
// Simulates the "badge" scenario: same term matches settings in
// multiple categories (e.g. LiteGraph and Comfy)
mockSettings['LiteGraph.BadgeSetting'] = {
id: 'LiteGraph.BadgeSetting',
name: 'Node source badge mode',
type: 'combo',
defaultValue: 'default',
category: ['LiteGraph', 'Node']
}
mockSettings['Comfy.BadgeSetting'] = {
id: 'Comfy.BadgeSetting',
name: 'Show API node pricing badge',
type: 'boolean',
defaultValue: true,
category: ['Comfy', 'API Nodes']
}
const search = useSettingSearch()
search.handleSearch('badge')
expect(search.filteredSettingIds.value).toContain(
'LiteGraph.BadgeSetting'
)
expect(search.filteredSettingIds.value).toContain('Comfy.BadgeSetting')
// getSearchResults(null) should return both categories' results
const results = search.getSearchResults(null)
const labels = results.map((g) => g.label)
expect(labels).toContain('Node')
expect(labels).toContain('API Nodes')
})
it('returns empty array when no filtered results', () => { it('returns empty array when no filtered results', () => {
const search = useSettingSearch() const search = useSettingSearch()
search.filteredSettingIds.value = [] search.filteredSettingIds.value = []
@@ -372,6 +409,7 @@ describe('useSettingSearch', () => {
expect(results).toEqual([ expect(results).toEqual([
{ {
label: 'Basic', label: 'Basic',
category: 'Category',
settings: [ settings: [
mockSettings['Category.Setting1'], mockSettings['Category.Setting1'],
mockSettings['Category.Setting4'] mockSettings['Category.Setting4']
@@ -381,6 +419,75 @@ describe('useSettingSearch', () => {
}) })
}) })
describe('nav item matching', () => {
const navItems = [
{ key: 'keybinding', label: 'Keybinding' },
{ key: 'about', label: 'About' },
{ key: 'extension', label: 'Extension' },
{ key: 'Comfy', label: 'Comfy' }
]
it('matches nav items by key', () => {
const search = useSettingSearch()
search.handleSearch('keybinding', navItems)
expect(search.matchedNavItemKeys.value.has('keybinding')).toBe(true)
})
it('matches nav items by translated label (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('ABOUT', navItems)
expect(search.matchedNavItemKeys.value.has('about')).toBe(true)
})
it('matches partial nav item labels', () => {
const search = useSettingSearch()
search.handleSearch('ext', navItems)
expect(search.matchedNavItemKeys.value.has('extension')).toBe(true)
})
it('clears matched nav item keys on empty query', () => {
const search = useSettingSearch()
search.handleSearch('keybinding', navItems)
expect(search.matchedNavItemKeys.value.size).toBeGreaterThan(0)
search.handleSearch('', navItems)
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
it('can match both settings and nav items simultaneously', () => {
const search = useSettingSearch()
search.handleSearch('other', navItems)
expect(search.filteredSettingIds.value).toContain('Other.Setting3')
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
it('matches nav items with translated labels different from key', () => {
const translatedNavItems = [{ key: 'keybinding', label: '키 바인딩' }]
const search = useSettingSearch()
search.handleSearch('키 바인딩', translatedNavItems)
expect(search.matchedNavItemKeys.value.has('keybinding')).toBe(true)
})
it('does not match nav items when no nav items provided', () => {
const search = useSettingSearch()
search.handleSearch('keybinding')
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
})
describe('edge cases', () => { describe('edge cases', () => {
it('handles empty settings store', () => { it('handles empty settings store', () => {
mockSettingStore.settingsById = {} mockSettingStore.settingsById = {}

View File

@@ -10,12 +10,18 @@ import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
interface SearchableNavItem {
key: string
label: string
}
export function useSettingSearch() { export function useSettingSearch() {
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { shouldRenderVueNodes } = useVueFeatureFlags() const { shouldRenderVueNodes } = useVueFeatureFlags()
const searchQuery = ref<string>('') const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([]) const filteredSettingIds = ref<string[]>([])
const matchedNavItemKeys = ref<Set<string>>(new Set())
const searchInProgress = ref<boolean>(false) const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true)) watch(searchQuery, () => (searchInProgress.value = true))
@@ -46,7 +52,9 @@ export function useSettingSearch() {
/** /**
* Handle search functionality * Handle search functionality
*/ */
const handleSearch = (query: string) => { const handleSearch = (query: string, navItems?: SearchableNavItem[]) => {
matchedNavItemKeys.value = new Set()
if (!query) { if (!query) {
filteredSettingIds.value = [] filteredSettingIds.value = []
return return
@@ -89,6 +97,17 @@ export function useSettingSearch() {
) )
}) })
if (navItems) {
for (const item of navItems) {
if (
item.key.toLocaleLowerCase().includes(queryLower) ||
item.label.toLocaleLowerCase().includes(queryLower)
) {
matchedNavItemKeys.value.add(item.key)
}
}
}
filteredSettingIds.value = filteredSettings.map((x) => x.id) filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false searchInProgress.value = false
} }
@@ -99,30 +118,42 @@ export function useSettingSearch() {
const getSearchResults = ( const getSearchResults = (
activeCategory: SettingTreeNode | null activeCategory: SettingTreeNode | null
): ISettingGroup[] => { ): ISettingGroup[] => {
const groupedSettings: { [key: string]: SettingParams[] } = {} const groupedSettings: {
[key: string]: { category: string; settings: SettingParams[] }
} = {}
filteredSettingIds.value.forEach((id) => { filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id] const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting) const info = getSettingInfo(setting)
const groupLabel = info.subCategory const groupKey =
activeCategory === null
? `${info.category}/${info.subCategory}`
: info.subCategory
if (activeCategory === null || activeCategory.label === info.category) { if (activeCategory === null || activeCategory.label === info.category) {
if (!groupedSettings[groupLabel]) { if (!groupedSettings[groupKey]) {
groupedSettings[groupLabel] = [] groupedSettings[groupKey] = {
category: info.category,
settings: []
}
} }
groupedSettings[groupLabel].push(setting) groupedSettings[groupKey].settings.push(setting)
} }
}) })
return Object.entries(groupedSettings).map(([label, settings]) => ({ return Object.entries(groupedSettings).map(
label, ([key, { category, settings }]) => ({
settings label: activeCategory === null ? key.split('/')[1] : key,
})) ...(activeCategory === null ? { category } : {}),
settings
})
)
} }
return { return {
searchQuery, searchQuery,
filteredSettingIds, filteredSettingIds,
matchedNavItemKeys,
searchInProgress, searchInProgress,
searchResultsCategories, searchResultsCategories,
queryIsEmpty, queryIsEmpty,

View File

@@ -62,6 +62,7 @@ export interface FormItem {
export interface ISettingGroup { export interface ISettingGroup {
label: string label: string
category?: string
settings: SettingParams[] settings: SettingParams[]
} }