From 22d5aea5e61be09ecb2c675cc5eb905f1e2343f0 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 25 Oct 2026 12:36:21 -0400 Subject: [PATCH] feat(settings): search across all settings (#463) A search box at the top of the settings sidebar (Ctrl/Cmd+F while settings are open). Results replace the page list while typing, showing the page and section; picking one (or Enter for the top result) opens that page, scrolls to the setting and briefly outlines it. Esc clears. The index is built from the settings source itself (SettingTile titles and descriptions, section headers), loaded as raw text in a lazy chunk on first search, so options added later are found without a hand-kept list. Desktop-only options only show in the desktop app. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/settings-search.spec.ts | 51 +++++ src/app/features/settings/Settings.tsx | 55 ++++- .../settings/search/SettingsSearch.tsx | 129 ++++++++++++ .../settings/search/loadSettingsIndex.ts | 20 ++ .../settings/search/settingsIndex.test.ts | 65 ++++++ .../features/settings/search/settingsIndex.ts | 190 ++++++++++++++++++ 6 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 e2e/settings-search.spec.ts create mode 100644 src/app/features/settings/search/SettingsSearch.tsx create mode 100644 src/app/features/settings/search/loadSettingsIndex.ts create mode 100644 src/app/features/settings/search/settingsIndex.test.ts create mode 100644 src/app/features/settings/search/settingsIndex.ts diff --git a/e2e/settings-search.spec.ts b/e2e/settings-search.spec.ts new file mode 100644 index 000000000..d2a87f847 --- /dev/null +++ b/e2e/settings-search.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { ensureUser, hsReachable, loginUI, uniq } from './localHs'; + +// [Gitea #463] Search across all settings. +test.describe('settings search (#463)', () => { + test.beforeAll(async () => { + test.skip(!(await hsReachable()), 'local homeserver not running'); + }); + + test('finds a setting, opens its page and shows it', async ({ page }) => { + test.setTimeout(120_000); + const user = await ensureUser(uniq('ss')); + await loginUI(page, user); + await page.getByRole('button', { name: 'User Settings' }).click(); + const dialog = page.getByRole('dialog').first(); + const box = dialog.getByRole('textbox', { name: 'Search settings' }); + + // Ctrl+F focuses the box while settings are open (after the dialog has + // placed its own initial focus). + await expect(box).toBeVisible(); + await page.waitForTimeout(800); + await page.keyboard.press('ControlOrMeta+f'); + await expect(box).toBeFocused(); + + await box.fill('deafen'); + const results = dialog.getByRole('list', { name: 'Matching settings' }); + const first = results.getByRole('listitem').first(); + await expect(first).toContainText('Push to deafen'); + await expect(first).toContainText('Calls & audio'); + await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeHidden(); + await first.click(); + await expect(dialog.getByText('Calls & audio', { exact: true }).last()).toBeVisible(); + await expect(dialog.getByText('Push to deafen', { exact: true })).toBeInViewport(); + await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeVisible(); + + // A description word finds it too; Enter picks the top result. + await box.fill('utm'); + await expect(results.getByRole('listitem').first()).toContainText( + 'Strip tracking parameters from links', + ); + await box.press('Enter'); + await expect(dialog.getByText('Strip tracking parameters from links')).toBeInViewport(); + + // No match, then Esc clears. + await box.fill('zzzz-nothing'); + await expect(dialog.getByText(/No settings match/)).toBeVisible(); + await box.press('Escape'); + await expect(box).toHaveValue(''); + await expect(dialog).toBeVisible(); + }); +}); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 07b90d317..8c665f514 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Avatar, Box, @@ -13,6 +13,7 @@ import { OverlayBackdrop, OverlayCenter, Text, + color, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { @@ -43,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; import { SettingsPages } from './pages'; +import { SettingsSearch } from './search/SettingsSearch'; +import type { SettingsEntry } from './search/settingsIndex'; export { SettingsPages }; @@ -94,6 +97,49 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage; }); const menuItems = useSettingsMenuItems(); + // [Gitea #463] Settings search: results replace the page list while typing; + // picking one opens its page and briefly highlights the setting. + const pageNames = useMemo( + () => new Map(menuItems.map((item) => [item.page, item.name])), + [menuItems], + ); + const [searching, setSearching] = useState(false); + const [highlight, setHighlight] = useState(); + const pickResult = useCallback((entry: SettingsEntry) => { + setActivePage(entry.page); + setHighlight(entry.title); + }, []); + useEffect(() => { + if (!highlight) return undefined; + let tries = 0; + let flashTimer: number | undefined; + const find = () => { + const match = [...document.querySelectorAll('p, span, label, h1, h2, h3')].find( + (el) => + !el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null, + ); + if (!match) { + tries += 1; + if (tries < 20) poll = window.setTimeout(find, 100); + else setHighlight(undefined); + return; + } + const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match; + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + const prev = target.style.boxShadow; + target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`; + target.style.transition = 'box-shadow 0.3s'; + flashTimer = window.setTimeout(() => { + target.style.boxShadow = prev; + setHighlight(undefined); + }, 1800); + }; + let poll = window.setTimeout(find, 50); + return () => { + window.clearTimeout(poll); + window.clearTimeout(flashTimer); + }; + }, [highlight, activePage]); const handlePageRequestClose = () => { if (screenSize === ScreenSize.Mobile) { @@ -134,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { + -