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

View File

@@ -2,6 +2,15 @@
<div class="setting-group">
<Divider v-if="divider" />
<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)
}}
@@ -27,6 +36,7 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
group: {
label: string
category?: string
settings: SettingParams[]
}
divider?: boolean

View File

@@ -299,10 +299,12 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'Advanced',
category: 'Category',
settings: [mockSettings['Category.Setting2']]
}
])
@@ -332,15 +334,50 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'SubCategory',
category: 'Other',
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', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = []
@@ -372,6 +409,7 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [
mockSettings['Category.Setting1'],
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', () => {
it('handles empty settings store', () => {
mockSettingStore.settingsById = {}

View File

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

View File

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