add fuzzy search to instance settings (#8000)

* feat: add fuzzy search to instance settings sidebar

Adds a search input at the top of the superadmin settings sidebar that
uses uFuzzy for fuzzy matching against all setting labels, descriptions,
and categories. Selecting a result navigates to the correct tab and
scrolls to the specific setting card with a brief highlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: improve settings search display and description matching

- Extract only the label portion from uFuzzy highlighted text for
  cleaner dropdown display
- Show description only when the match is in the description and NOT
  in the label
- Truncate descriptions to 80 chars in searchable items
- Add maxHeight prop to SelectDropdown for configurable height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: fix search description truncation and handle undefined marked values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove description from settings search dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add smooth outline transition for setting highlight animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: highlight first search result by default for enter-to-select

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* nit

* clean code

* fix: address review feedback - sanitize html, remove max-w-40, document description field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: constrain search dropdown width to prevent long title overflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* perf: add 150ms debounce to settings search filter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: clean up timeouts on destroy and re-invocation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract settings search into reusable SettingsSearchInput component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use twMerge for class prop in SettingsSearchInput

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wrap debounced state write in untrack to prevent re-triggering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-02-18 17:12:27 +00:00
committed by GitHub
parent acf991ff00
commit cfb0829f81
8 changed files with 338 additions and 41 deletions
+32 -13
View File
@@ -4,25 +4,34 @@
@tailwind utilities;
@media (min-width: 1760px) {
:root {
font-size: 18px;
}
:root {
font-size: 18px;
}
}
@layer base {
/* Light mode: default border color */
.border, .border-t, .border-r, .border-b, .border-l,
.border-x, .border-y,
.divide-x > :not([hidden]) ~ :not([hidden]),
.border,
.border-t,
.border-r,
.border-b,
.border-l,
.border-x,
.border-y,
.divide-x > :not([hidden]) ~ :not([hidden]),
.divide-y > :not([hidden]) ~ :not([hidden]) {
border-color: rgb(var(--color-border-light));
}
/* Dark mode: change border color */
.dark .border, .dark .border-t, .dark .border-r, .dark .border-b, .dark .border-l,
.dark .border-x, .dark .border-y,
.dark .divide-x > :not([hidden]) ~ :not([hidden]),
.dark .border,
.dark .border-t,
.dark .border-r,
.dark .border-b,
.dark .border-l,
.dark .border-x,
.dark .border-y,
.dark .divide-x > :not([hidden]) ~ :not([hidden]),
.dark .divide-y > :not([hidden]) ~ :not([hidden]) {
border-color: rgb(var(--color-border-light));
}
@@ -205,11 +214,21 @@ svelte-virtual-list-contents > * + * {
/* Prevent clock icon in input[type="time"] making the input taller */
/* Chrome, Safari, Edge, Opera */
input[type="time"]::-webkit-calendar-picker-indicator {
input[type='time']::-webkit-calendar-picker-indicator {
margin: 0;
padding: 0;
padding: 0;
}
/* Settings search highlight (used by both the drawer and the setup page) */
[data-setting-key] {
transition: outline 0.8s ease;
outline: 2px solid transparent;
outline-offset: -2px;
}
[data-setting-key].setting-highlight {
outline: 2px solid rgb(var(--color-border-accent, 59 130 246));
}
.svelte-flow__edges {
z-index: -10;
}
}
@@ -38,7 +38,15 @@
warning?: string
}
let { setting, version, values, loading = true, openSmtpSettings, oauths, warning }: Props = $props()
let {
setting,
version,
values,
loading = true,
openSmtpSettings,
oauths,
warning
}: Props = $props()
const dispatch = createEventDispatcher()
let latestKeyRenewalAttempt: {
@@ -174,7 +182,12 @@
<!-- {JSON.stringify($values, null, 2)} -->
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key]) && !(setting.hiddenInEe && $enterpriseLicense)}
{#if setting.fieldType == 'select'}
<SettingCard label={setting.label} description={setting.description} ee_only={setting.ee_only}>
<SettingCard
label={setting.label}
description={setting.description}
ee_only={setting.ee_only}
settingKey={setting.key}
>
<ToggleButtonGroup bind:selected={$values[setting.key]}>
{#snippet children({ item: toggleButton })}
{#each setting.select_items ?? [] as item}
@@ -189,7 +202,12 @@
</ToggleButtonGroup>
</SettingCard>
{:else if setting.fieldType == 'select_python'}
<SettingCard label={setting.label} description={setting.description} ee_only={setting.ee_only}>
<SettingCard
label={setting.label}
description={setting.description}
ee_only={setting.ee_only}
settingKey={setting.key}
>
<ToggleButtonGroup bind:selected={$values[setting.key]}>
{#snippet children({ item: toggleButton })}
{#each setting.select_items ?? [] as item}
@@ -234,6 +252,7 @@
label="Memory"
description="Configure the memory budget for the indexer and manage index clearing."
ee_only=""
settingKey="indexer_settings_memory"
>
<div class="p-4 rounded-md border mt-2">
<IndexerMemorySettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
@@ -243,6 +262,7 @@
label="Completed Job Index"
description="Configure indexing parameters for completed jobs."
ee_only=""
settingKey="indexer_settings_jobs"
>
<div class="p-4 rounded-md border mt-2">
<IndexerJobIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
@@ -252,6 +272,7 @@
label="Service Logs Index"
description="Configure indexing parameters for service logs."
ee_only=""
settingKey="indexer_settings_logs"
>
<div class="p-4 rounded-md border mt-2">
<IndexerLogIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
@@ -264,6 +285,7 @@
description={setting.description}
ee_only={setting.ee_only}
tooltip={setting.tooltip}
settingKey={setting.key}
actionButton={setting.actionButton}
values={$values}
>
@@ -26,16 +26,19 @@
import InstanceNameEditor from './InstanceNameEditor.svelte'
import Toggle from './Toggle.svelte'
import { instanceSettingsSelectedTab } from '$lib/stores'
import { onDestroy } from 'svelte'
import { onDestroy, tick } from 'svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
import {
instanceSettingsNavigationGroups,
tabToCategoryMap,
tabToAuthSubTab,
categoryToTabMap
categoryToTabMap,
buildSearchableSettingItems,
type SearchableSettingItem
} from './instanceSettings'
import TextInput from './text_input/TextInput.svelte'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
let filter = $state('')
@@ -138,6 +141,35 @@
export function syncBeforeDiff(): boolean {
return instanceSettings?.syncBeforeDiff() ?? true
}
// --- Settings search ---
const searchableItems = buildSearchableSettingItems()
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
async function handleSearchSelect(item: SearchableSettingItem) {
handleNavigate(item.tabId)
if (item.settingKey) {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
await tick()
// Wait for the tab content to render before scrolling
scrollTimeout = setTimeout(() => {
const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('setting-highlight')
highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500)
}
}, 100)
}
}
onDestroy(() => {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
})
</script>
<SearchItems
@@ -174,13 +206,14 @@
{#if !yamlMode && !diffMode}
<!-- Sidebar Navigation -->
<div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface flex flex-col">
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
<SidebarNavigation
groups={instanceSettingsNavigationGroups}
selectedId={tab}
onNavigate={handleNavigate}
/>
{#if $workspaceStore !== 'admins'}
<div class="mt-auto pt-4 border-t border-surface-hover">
<div class="mt-4 pt-2 border-t border-surface-hover">
<a
href="{base}/?workspace=admins"
target="_blank"
@@ -510,3 +543,4 @@
<span>Are you sure you want to remove <b>{deleteUserEmail}</b>?</span>
</div>
</ConfirmationModal>
@@ -321,7 +321,7 @@ export const settings: Record<string, Setting[]> = {
],
SMTP: [
{
label: 'SMTP',
label: 'SMTP configuration',
key: 'smtp_settings',
fieldType: 'smtp_connect',
storage: 'setting',
@@ -782,3 +782,84 @@ export const categoryToTabMap: Record<string, string> = {
Jobs: 'jobs',
'Private Hub': 'private_hub'
}
export interface SearchableSettingItem {
label: string
tabId: string
settingKey?: string
category: string
/** Full description text (HTML stripped), used for search matching only — not displayed */
description?: string
}
/**
* Extract the label portion from a uFuzzy marked/highlighted string.
* Only allows `<mark>` and `</mark>` tags through (sanitizes everything else).
*/
export function extractMarkedLabel(marked: string | undefined, labelLength: number): string {
if (!marked) return ''
let plainIdx = 0
let markedIdx = 0
while (plainIdx < labelLength && markedIdx < marked.length) {
if (marked[markedIdx] === '<') {
while (markedIdx < marked.length && marked[markedIdx] !== '>') markedIdx++
markedIdx++
} else {
plainIdx++
markedIdx++
}
}
// Include any closing </mark> right after
if (marked.startsWith('</mark>', markedIdx)) {
markedIdx += '</mark>'.length
}
// Sanitize: only allow <mark> and </mark> tags from uFuzzy highlight
return marked.slice(0, markedIdx).replace(/<(?!\/?mark>)[^>]*>/g, '')
}
export function buildSearchableSettingItems(
navigationGroups: typeof instanceSettingsNavigationGroups = instanceSettingsNavigationGroups
): SearchableSettingItem[] {
const items: SearchableSettingItem[] = []
// Add sidebar navigation items (tab-level)
for (const group of navigationGroups) {
for (const navItem of group.items) {
items.push({
label: navItem.label,
tabId: navItem.id,
category: group.title
})
}
}
// Add individual settings from each category
for (const [category, categorySettings] of Object.entries(settings)) {
const tabId = categoryToTabMap[category]
if (!tabId) continue
for (const setting of categorySettings) {
if (!setting.label) continue
items.push({
label: setting.label,
tabId,
settingKey: setting.key,
category,
description: setting.description?.replace(/<[^>]*>/g, '') ?? ''
})
}
}
// Add SCIM/SAML settings
for (const setting of scimSamlSetting) {
if (!setting.label) continue
items.push({
label: setting.label,
tabId: 'scim_saml',
settingKey: setting.key,
category: 'SCIM/SAML',
description: setting.description?.replace(/<[^>]*>/g, '') ?? ''
})
}
return items
}
@@ -11,6 +11,7 @@
description?: string
ee_only?: string
tooltip?: string
settingKey?: string
actionButton?: {
label: string
onclick: (values: Record<string, any>) => Promise<void>
@@ -26,6 +27,7 @@
description,
ee_only,
tooltip,
settingKey,
actionButton,
values,
children,
@@ -33,7 +35,10 @@
}: Props = $props()
</script>
<div class={twMerge('p-4 rounded-md bg-surface-tertiary shadow-sm flex flex-col gap-1', clazz)}>
<div
data-setting-key={settingKey}
class={twMerge('p-4 rounded-md bg-surface-tertiary shadow-sm flex flex-col gap-1', clazz)}
>
{#if label}
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex gap-1 items-baseline">
@@ -0,0 +1,89 @@
<script lang="ts">
import SearchItems from '../SearchItems.svelte'
import SelectDropdown from '../select/SelectDropdown.svelte'
import type { ProcessedItem } from '../select/utils.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { Search } from 'lucide-svelte'
import {
extractMarkedLabel,
type SearchableSettingItem
} from '../instanceSettings'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
let {
searchableItems,
onSelect,
class: className = ''
}: {
searchableItems: SearchableSettingItem[]
onSelect: (item: SearchableSettingItem) => void
class?: string
} = $props()
let settingsSearchFilter = $state('')
let debouncedSearchFilter = $state('')
let filteredSearchItems: (SearchableSettingItem & { marked: string })[] = $state([])
let searchInputEl: HTMLDivElement | undefined = $state()
// Debounce search to avoid running uFuzzy on every keystroke
$effect(() => {
const val = settingsSearchFilter
const timeout = setTimeout(() => untrack(() => (debouncedSearchFilter = val)), 150)
return () => clearTimeout(timeout)
})
const searchDropdownOpen = $derived(
settingsSearchFilter.trim().length > 0 && filteredSearchItems.length > 0
)
let searchProcessedItems: ProcessedItem<SearchableSettingItem & { marked: string }>[] = $derived(
filteredSearchItems.map((item) => ({
label: item.label,
value: item,
subtitle: item.category
}))
)
function handleSelect(item: SearchableSettingItem) {
settingsSearchFilter = ''
onSelect(item)
}
</script>
<SearchItems
filter={debouncedSearchFilter}
items={searchableItems}
bind:filteredItems={filteredSearchItems}
f={(x) => x.label + ' ' + (x.description ?? '') + ' ' + x.category}
/>
<div class={twMerge('relative', className)}>
<div bind:this={searchInputEl} class="relative w-full">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
<TextInput
inputProps={{ placeholder: 'Search settings...' }}
bind:value={settingsSearchFilter}
class="pl-7 text-xs w-full"
/>
</div>
<SelectDropdown
processedItems={searchProcessedItems}
value={undefined}
open={searchDropdownOpen}
disablePortal
class="max-w-full"
itemLabelWrapperClasses="hidden"
itemButtonWrapperClasses="overflow-hidden"
getInputRect={searchInputEl ? () => searchInputEl!.getBoundingClientRect() : undefined}
onSelectValue={(item) => handleSelect(item.value)}
highlightFirstOnOpen
maxHeight={400}
>
{#snippet startSnippet({ item })}
<div class="text-xs truncate w-full min-w-0"
>{@html extractMarkedLabel(item.value.marked, item.value.label.length)}</div
>
{/snippet}
</SelectDropdown>
</div>
@@ -21,12 +21,14 @@
ulClass = '',
itemLabelWrapperClasses = '',
itemButtonWrapperClasses = '',
maxHeight = 256,
header,
getInputRect,
onSelectValue,
startSnippet,
endSnippet,
bottomSnippet
bottomSnippet,
highlightFirstOnOpen = false
}: {
processedItems?: ProcessedItem<T>[]
value: T | undefined
@@ -40,12 +42,15 @@
ulClass?: string
itemLabelWrapperClasses?: string
itemButtonWrapperClasses?: string
maxHeight?: number
header?: Snippet
getInputRect?: () => DOMRect
onSelectValue: (item: ProcessedItem<T>) => void
startSnippet?: Snippet<[{ item: ProcessedItem<T>; close: () => void }]>
endSnippet?: Snippet<[{ item: ProcessedItem<T>; close: () => void }]>
bottomSnippet?: Snippet<[{ close: () => void }]>
/** When true, the first item is highlighted when the dropdown opens (even without filterText) */
highlightFirstOnOpen?: boolean
} = $props()
let processedItems = $derived(
@@ -92,7 +97,7 @@
$effect(() => {
;[open, processedItems]
untrack(() => (keyArrowPos = open && filterText ? 0 : undefined))
untrack(() => (keyArrowPos = open && (filterText || highlightFirstOnOpen) ? 0 : undefined))
})
// We do not want to render the dropdown when it is closed for performance reasons
@@ -185,7 +190,11 @@
)}
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
>
<div bind:this={listEl} class="flex flex-col max-h-64 rounded-md bg-surface-input">
<div
bind:this={listEl}
class="flex flex-col rounded-md bg-surface-input"
style="max-height: {maxHeight}px;"
>
{@render header?.()}
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
@@ -10,11 +10,15 @@
setupNavigationGroups,
tabToCategoryMap,
tabToAuthSubTab,
categoryToTabMap
categoryToTabMap,
buildSearchableSettingItems,
type SearchableSettingItem
} from '$lib/components/instanceSettings'
import SettingsSearchInput from '$lib/components/instanceSettings/SettingsSearchInput.svelte'
import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte'
import { ChevronRight, ArrowLeft } from 'lucide-svelte'
import { superadmin } from '$lib/stores'
import { onDestroy, tick } from 'svelte'
import { UserService, JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import TextInput from '$lib/components/text_input/TextInput.svelte'
@@ -30,7 +34,10 @@
const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types']
const initialMode = $page.url.searchParams.get('mode') === 'full' ? 'full' : 'wizard'
const initialStep = Math.max(0, Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1))
const initialStep = Math.max(
0,
Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1)
)
let mode: 'wizard' | 'full' = $state(initialMode)
let wizardStep = $state(initialStep)
@@ -106,7 +113,12 @@
hubSyncStatus = 'success'
hubSyncMessage = 'Resource types synced from hub successfully'
} catch (e: any) {
hubSyncMessage = e?.body?.error?.message || e?.body?.message || (typeof e?.body === 'string' ? e.body : null) || e?.message || 'Failed to sync from hub'
hubSyncMessage =
e?.body?.error?.message ||
e?.body?.message ||
(typeof e?.body === 'string' ? e.body : null) ||
e?.message ||
'Failed to sync from hub'
hubSyncStatus = 'error'
}
}
@@ -151,7 +163,33 @@
}
}
// --- Settings search (full mode) ---
const searchableItems = buildSearchableSettingItems(setupNavigationGroups)
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
async function handleSearchSelect(item: SearchableSettingItem) {
handleNavigate(item.tabId)
if (item.settingKey) {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
await tick()
scrollTimeout = setTimeout(() => {
const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('setting-highlight')
highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500)
}
}, 100)
}
}
onDestroy(() => {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
})
/** Check if we need to warn about missing EE license key before proceeding */
function proceedFromCore(callback: () => void) {
@@ -312,9 +350,7 @@
{/key}
{:else}
<!-- Account setup step -->
<SettingsPageHeader
title="Root login & Resource Types"
/>
<SettingsPageHeader title="Root login & Resource Types" />
<div class="flex flex-col gap-6 pb-6">
<SettingCard
@@ -411,17 +447,14 @@
{:else}
<!-- Action bar (full mode) -->
<div class="flex items-center justify-end gap-2 pb-2 border-b shrink-0">
<Toggle
bind:checked={yamlMode}
options={{ right: 'YAML' }}
size="sm"
/>
<Toggle bind:checked={yamlMode} options={{ right: 'YAML' }} size="sm" />
</div>
<!-- Sidebar + Content -->
<div class="flex flex-1 min-h-0">
<div class="flex flex-1 min-h-0 pt-2">
{#if !yamlMode}
<div class="w-44 shrink-0 overflow-auto pb-4 pr-4">
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
<SidebarNavigation
groups={setupNavigationGroups}
selectedId={fullTab}
@@ -503,11 +536,16 @@
>
Quick setup
</Button>
<Button variant="accent" unifiedSize="md" onClick={() => saveAndProceed(() => {
yamlMode = false
wizardStep = wizardStepLabels.length - 1
mode = 'wizard'
})}>Continue</Button>
<Button
variant="accent"
unifiedSize="md"
onClick={() =>
saveAndProceed(() => {
yamlMode = false
wizardStep = wizardStepLabels.length - 1
mode = 'wizard'
})}>Continue</Button
>
{/if}
</div>