feat: scroll to specific setting when opening settings dialog (#8761)

## Summary

- Adds `settingId` parameter to `showSettingsDialog` that auto-navigates
to the correct category tab, scrolls to the setting, and briefly
highlights it with a CSS pulse animation
- Adds `data-setting-id` attributes to setting items for stable DOM
targeting
- Adds "Don't show this again" checkbox with "Re-enable in Settings"
deep-link to the missing nodes dialog
- Adds "Re-enable in Settings" deep-link to missing models and blueprint
overwrite "Don't show this again" checkboxes

- Fixes #3437

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass (59/59 including 5 new tests for `useSettingUI`)



https://github.com/user-attachments/assets/a9e80aea-7b69-4686-b030-55a2e0570ff0



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8761-feat-scroll-to-specific-setting-when-opening-settings-dialog-3036d73d365081d18d9afe9f9ed41ebc)
by [Unito](https://www.unito.io)
This commit is contained in:
Johnpaul Chiwetelu
2026-02-10 23:00:46 +01:00
committed by GitHub
parent 19a724710c
commit e411a104f4
9 changed files with 388 additions and 72 deletions

View File

@@ -112,7 +112,7 @@ import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
@@ -129,7 +129,7 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { flattenTree } from '@/utils/treeUtil'
const { defaultPanel } = defineProps<{
const { defaultPanel, scrollToSettingId } = defineProps<{
defaultPanel?:
| 'about'
| 'keybinding'
@@ -140,6 +140,7 @@ const { defaultPanel } = defineProps<{
| 'subscription'
| 'workspace'
| 'secrets'
scrollToSettingId?: string
}>()
const { flags } = useFeatureFlags()
@@ -153,7 +154,7 @@ const {
settingCategories,
groupedMenuTreeNodes,
panels
} = useSettingUI(defaultPanel)
} = useSettingUI(defaultPanel, scrollToSettingId)
const {
searchQuery,
@@ -202,6 +203,31 @@ const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : (activeCategory.value?.label ?? '')
)
// Scroll to and highlight the target setting once the correct tab renders.
if (scrollToSettingId) {
const stopScrollWatch = watch(
tabValue,
() => {
void nextTick(() => {
const el = document.querySelector(
`[data-setting-id="${CSS.escape(scrollToSettingId)}"]`
)
if (!el) return
stopScrollWatch()
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('setting-highlight')
el.addEventListener(
'animationend',
() => el.classList.remove('setting-highlight'),
{ once: true }
)
})
},
{ immediate: true }
)
onBeforeUnmount(stopScrollWatch)
}
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
@@ -218,6 +244,26 @@ watch(activeCategory, (_, oldValue) => {
.settings-tab-panels {
padding-top: 0 !important;
}
@media (prefers-reduced-motion: no-preference) {
.setting-highlight {
animation: setting-highlight-pulse 1.5s ease-in-out;
}
}
@keyframes setting-highlight-pulse {
0%,
100% {
background-color: transparent;
}
30% {
background-color: color-mix(
in srgb,
var(--p-primary-color) 15%,
transparent
);
}
}
</style>
<style scoped>

View File

@@ -9,6 +9,7 @@
<div
v-for="setting in group.settings.filter((s) => !s.deprecated)"
:key="setting.id"
:data-setting-id="setting.id"
class="setting-item mb-4"
>
<SettingItem :setting="setting" />

View File

@@ -0,0 +1,140 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(),
getSettingInfo: vi.fn()
}))
interface MockSettingParams {
id: string
name: string
type: string
defaultValue: unknown
category?: string[]
}
describe('useSettingUI', () => {
const mockSettings: Record<string, MockSettingParams> = {
'Comfy.Locale': {
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
defaultValue: 'en'
},
'LiteGraph.Zoom': {
id: 'LiteGraph.Zoom',
name: 'Zoom',
type: 'slider',
defaultValue: 1
},
'Appearance.Theme': {
id: 'Appearance.Theme',
name: 'Theme',
type: 'combo',
defaultValue: 'dark'
}
}
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
const parts = setting.category || setting.id.split('.')
return {
category: parts[0] ?? 'Other',
subCategory: parts[1] ?? 'Other'
}
})
})
function findCategory(
categories: SettingTreeNode[],
label: string
): SettingTreeNode | undefined {
return categories.find((c) => c.label === label)
}
it('defaults to first category when no params are given', () => {
const { defaultCategory, settingCategories } = useSettingUI()
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('resolves category from scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
undefined,
'Comfy.Locale'
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
})
it('resolves different category from scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
undefined,
'Appearance.Theme'
)
const appearanceCategory = findCategory(
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
it('falls back to first category for unknown scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
undefined,
'NonExistent.Setting'
)
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('gives defaultPanel precedence over scrollToSettingId', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
})

View File

@@ -7,10 +7,12 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -30,7 +32,8 @@ export function useSettingUI(
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets'
| 'secrets',
scrollToSettingId?: string
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
@@ -238,12 +241,23 @@ export function useSettingUI(
* The default category to show when the dialog is opened.
*/
const defaultCategory = computed<SettingTreeNode>(() => {
if (!defaultPanel) return settingCategories.value[0]
// Search through all groups in groupedMenuTreeNodes
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === defaultPanel)
if (found) return found
if (defaultPanel) {
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === defaultPanel)
if (found) return found
}
return settingCategories.value[0]
}
if (scrollToSettingId) {
const setting = settingStore.settingsById[scrollToSettingId]
if (setting) {
const { category } = getSettingInfo(setting)
const found = settingCategories.value.find((c) => c.label === category)
if (found) return found
}
}
return settingCategories.value[0]
})