From 6e60293323360e6ee4bf5f706059d2cd89f57331 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 6 May 2026 10:25:22 -0500 Subject: [PATCH 1/4] fix: show setting names in settings search --- .../app-core/src/components/SettingsModal.tsx | 358 +++++++++++++++++- .../app-core/src/lib/settings-search.test.ts | 63 +++ packages/app-core/src/lib/settings-search.ts | 100 +++++ 3 files changed, 503 insertions(+), 18 deletions(-) create mode 100644 packages/app-core/src/lib/settings-search.test.ts create mode 100644 packages/app-core/src/lib/settings-search.ts diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 83bff43f..e780dee6 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -37,6 +37,10 @@ import { getSystemFolderLabel } from '../lib/system-folder-labels' import { normalizeDailyNotesDirectory } from '../lib/vault-layout' +import { + getSettingsSearchResults, + type SettingsSearchCategory +} from '../lib/settings-search' import { getZenBridge } from '@zennotes/bridge-contract/bridge' import companyLogo from '../assets/lumary-labs-logo.svg' import { confirmApp } from './ConfirmHost' @@ -54,7 +58,7 @@ type SettingsCategoryId = type ResolvedVaultTextSearchBackend = 'builtin' | 'ripgrep' | 'fzf' -interface SettingsCategory { +interface SettingsCategory extends SettingsSearchCategory { id: SettingsCategoryId title: string description: string @@ -514,6 +518,7 @@ export function SettingsModal(): JSX.Element { const ref = useRef(null) const [activeCategory, setActiveCategory] = useState('appearance') + const [activeSearchResultId, setActiveSearchResultId] = useState(null) const [navQuery, setNavQuery] = useState('') const availableVaultTextSearchTools = [ vaultTextSearchCapabilities?.ripgrep ? 'ripgrep' : null, @@ -565,6 +570,37 @@ export function SettingsModal(): JSX.Element { title: 'Appearance', description: 'Theme family, mode, and chrome surface styling.', keywords: ['theme', 'mode', 'variant', 'dark sidebar', 'surface', 'look'], + searchItems: [ + { + id: 'theme-family', + title: 'Theme family', + description: 'Pick the visual system ZenNotes uses across the app.', + keywords: ['theme', 'family', 'apple', 'gruvbox', 'catppuccin', 'github', 'solarized', 'nord', 'tokyo night'] + }, + { + id: 'theme-mode', + title: 'Theme mode', + description: 'Choose light, dark, or automatic theme mode.', + keywords: ['light', 'dark', 'auto', 'mode'] + }, + { + id: 'theme-variant', + title: 'Theme variant', + description: 'Choose a family-specific contrast, flavor, or variant.', + keywords: ['variant', 'contrast', 'flavor'] + }, + { + id: 'dark-sidebar', + title: 'Dark sidebar', + description: 'Tint the sidebar one step darker than the canvas so the chrome reads as a separate surface.' + }, + { + id: 'sidebar-arrows', + title: 'Sidebar arrows', + description: 'Show disclosure arrows for collapsible folders and sidebar sections.', + keywords: ['chevrons', 'disclosure'] + } + ], content: (
}, { @@ -1248,6 +1522,26 @@ export function SettingsModal(): JSX.Element { 'capture', 'developer' ], + searchItems: [ + { + id: 'zen-command-line-tool', + title: 'zen command-line tool', + description: 'Install the `zen` shell command for terminal-based note workflows.', + keywords: ['cli', 'command line', 'terminal', 'shell', 'zen', 'install', 'path'] + }, + { + id: 'cli-quick-reference', + title: 'CLI quick reference', + description: 'A handful of the most useful `zen` commands.', + keywords: ['cli', 'help', 'commands', 'reference'] + }, + { + id: 'raycast-extension', + title: 'Raycast Extension', + description: 'Install the ZenNotes Raycast extension locally from this app.', + keywords: ['raycast', 'launcher', 'extension', 'install'] + } + ], content: }, { @@ -1255,6 +1549,26 @@ export function SettingsModal(): JSX.Element { title: 'About', description: 'App identity, version, updater status, and company information.', keywords: ['version', 'company', 'lumary', 'about', 'logo', 'updates'], + searchItems: [ + { + id: 'zen-notes-version', + title: 'ZenNotes version', + description: 'App identity, current version, and product details.', + keywords: ['about', 'version', 'identity'] + }, + { + id: 'updates', + title: 'Updates', + description: 'Check GitHub releases for a newer ZenNotes build.', + keywords: ['release', 'download', 'install', 'updater'] + }, + { + id: 'lumary-labs', + title: 'Lumary Labs', + description: 'Company and product details.', + keywords: ['company', 'lumary', 'logo'] + } + ], content: (
@@ -1412,16 +1726,14 @@ export function SettingsModal(): JSX.Element { ] const query = navQuery.trim().toLowerCase() - const filteredCategories = query - ? categories.filter((category) => - [category.title, category.description, ...category.keywords].some((value) => - value.toLowerCase().includes(query) - ) - ) - : categories + const searchResults = getSettingsSearchResults(categories, query) + const visibleSearchResult = + searchResults.find((result) => result.id === activeSearchResultId) ?? + searchResults.find((result) => result.category.id === activeCategory) ?? + searchResults[0] ?? + null const visibleCategory = - filteredCategories.find((category) => category.id === activeCategory) ?? - filteredCategories[0] ?? + visibleSearchResult?.category ?? null return ( @@ -1470,13 +1782,16 @@ export function SettingsModal(): JSX.Element {
diff --git a/packages/app-core/src/lib/settings-search.test.ts b/packages/app-core/src/lib/settings-search.test.ts new file mode 100644 index 00000000..c8aff835 --- /dev/null +++ b/packages/app-core/src/lib/settings-search.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { getSettingsSearchResults } from './settings-search' + +const categories = [ + { + id: 'appearance', + title: 'Appearance', + description: 'Theme family, mode, and chrome surface styling.', + keywords: ['theme', 'mode'], + searchItems: [ + { + id: 'dark-sidebar', + title: 'Dark sidebar', + description: 'Tint the sidebar one step darker than the canvas.' + } + ] + }, + { + id: 'editor', + title: 'Editor', + description: 'Vim, leader hints, live preview, tabs, and writing behavior.', + keywords: ['vim', 'leader', 'preview', 'tabs', 'wrap'], + searchItems: [ + { + id: 'word-wrap', + title: 'Word wrap', + description: 'Wrap long lines to the editor width.' + } + ] + } +] + +describe('getSettingsSearchResults', () => { + it('surfaces the matching setting title instead of only the category title', () => { + const results = getSettingsSearchResults(categories, 'wrap') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'editor:word-wrap', + type: 'setting', + title: 'Word wrap', + category: { id: 'editor', title: 'Editor' } + }) + }) + + it('falls back to category results when a section itself matches', () => { + const results = getSettingsSearchResults(categories, 'appearance') + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + id: 'appearance:category', + type: 'category', + title: 'Appearance' + }) + }) + + it('returns category navigation when the query is empty', () => { + const results = getSettingsSearchResults(categories, '') + + expect(results.map((result) => result.title)).toEqual(['Appearance', 'Editor']) + expect(results.every((result) => result.type === 'category')).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/settings-search.ts b/packages/app-core/src/lib/settings-search.ts new file mode 100644 index 00000000..525e51c3 --- /dev/null +++ b/packages/app-core/src/lib/settings-search.ts @@ -0,0 +1,100 @@ +export interface SettingsSearchItem { + id: string + title: string + description?: string + keywords?: string[] +} + +export interface SettingsSearchCategory { + id: TCategoryId + title: string + description: string + keywords: string[] + searchItems?: SettingsSearchItem[] +} + +export type SettingsSearchResult< + TCategory extends SettingsSearchCategory = SettingsSearchCategory +> = + | { + id: string + type: 'category' + title: string + description: string + category: TCategory + } + | { + id: string + type: 'setting' + title: string + description: string + category: TCategory + item: SettingsSearchItem + } + +function includesQuery(value: string | undefined, query: string): boolean { + return value?.toLowerCase().includes(query) ?? false +} + +function itemMatches(item: SettingsSearchItem, query: string): boolean { + return ( + includesQuery(item.title, query) || + includesQuery(item.description, query) || + (item.keywords ?? []).some((keyword) => includesQuery(keyword, query)) + ) +} + +function categoryMatches( + category: SettingsSearchCategory, + query: string +): boolean { + return ( + includesQuery(category.title, query) || + includesQuery(category.description, query) || + category.keywords.some((keyword) => includesQuery(keyword, query)) + ) +} + +function categoryResult( + category: TCategory +): SettingsSearchResult { + return { + id: `${category.id}:category`, + type: 'category', + title: category.title, + description: category.description, + category + } +} + +function settingResult( + category: TCategory, + item: SettingsSearchItem +): SettingsSearchResult { + return { + id: `${category.id}:${item.id}`, + type: 'setting', + title: item.title, + description: item.description ?? category.description, + category, + item + } +} + +export function getSettingsSearchResults( + categories: TCategory[], + query: string +): SettingsSearchResult[] { + const normalized = query.trim().toLowerCase() + if (!normalized) return categories.map(categoryResult) + + return categories.flatMap((category) => { + const matchedItems = (category.searchItems ?? []).filter((item) => + itemMatches(item, normalized) + ) + if (matchedItems.length > 0) { + return matchedItems.map((item) => settingResult(category, item)) + } + return categoryMatches(category, normalized) ? [categoryResult(category)] : [] + }) +} From 06d87b840cec87837c6a6b55fa75348c4e875481 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 6 May 2026 10:34:18 -0500 Subject: [PATCH 2/4] fix: jump to settings search targets --- .../app-core/src/components/SettingsModal.tsx | 196 ++++++++++++++++-- .../app-core/src/lib/settings-search.test.ts | 1 + packages/app-core/src/lib/settings-search.ts | 2 + packages/app-core/src/styles/index.css | 13 ++ 4 files changed, 191 insertions(+), 21 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index e780dee6..73f432bf 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -66,6 +66,27 @@ interface SettingsCategory extends SettingsSearchCategory { content: JSX.Element } +function settingsSearchTargetProps( + settingId: string | undefined +): { 'data-settings-search-id'?: string } { + return settingId ? { 'data-settings-search-id': settingId } : {} +} + +function findSettingsSearchTarget(root: HTMLElement, targetId: string): HTMLElement | null { + for (const element of root.querySelectorAll('[data-settings-search-id]')) { + if (element.dataset.settingsSearchId === targetId) return element + } + return null +} + +function clearSettingsSearchHighlights(root: HTMLElement): void { + root + .querySelectorAll('[data-settings-search-highlight="true"]') + .forEach((element) => { + delete element.dataset.settingsSearchHighlight + }) +} + function resolveVaultTextSearchBackend( preferred: VaultTextSearchBackendPreference, capabilities: VaultTextSearchCapabilities | null @@ -517,6 +538,7 @@ export function SettingsModal(): JSX.Element { } const ref = useRef(null) + const settingsSearchHighlightTimerRef = useRef(null) const [activeCategory, setActiveCategory] = useState('appearance') const [activeSearchResultId, setActiveSearchResultId] = useState(null) const [navQuery, setNavQuery] = useState('') @@ -564,6 +586,40 @@ export function SettingsModal(): JSX.Element { return () => window.removeEventListener('keydown', onKey) }, [setSettingsOpen]) + useEffect(() => { + return () => { + if (settingsSearchHighlightTimerRef.current != null) { + window.clearTimeout(settingsSearchHighlightTimerRef.current) + } + } + }, []) + + const jumpToSettingsSearchTarget = useCallback((targetId: string): void => { + if (settingsSearchHighlightTimerRef.current != null) { + window.clearTimeout(settingsSearchHighlightTimerRef.current) + settingsSearchHighlightTimerRef.current = null + } + + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + const root = ref.current + if (!root) return + + const target = findSettingsSearchTarget(root, targetId) + if (!target) return + + clearSettingsSearchHighlights(root) + target.dataset.settingsSearchHighlight = 'true' + target.scrollIntoView({ block: 'center', behavior: 'smooth' }) + + settingsSearchHighlightTimerRef.current = window.setTimeout(() => { + delete target.dataset.settingsSearchHighlight + settingsSearchHighlightTimerRef.current = null + }, 2000) + }) + }) + }, []) + const categories: SettingsCategory[] = [ { id: 'appearance', @@ -608,7 +664,7 @@ export function SettingsModal(): JSX.Element { description="Pick the visual system ZenNotes uses across the app." >
-
+
Family
@@ -630,7 +686,7 @@ export function SettingsModal(): JSX.Element {
-
+
Mode
@@ -653,7 +709,7 @@ export function SettingsModal(): JSX.Element {
{visibleVariants.length > 1 && ( -
+
{themeFamily === 'gruvbox' ? 'Contrast' @@ -690,12 +746,14 @@ export function SettingsModal(): JSX.Element { label="Dark sidebar" description="Tint the sidebar one step darker than the canvas so the chrome reads as a separate surface." value={darkSidebar} + settingId="dark-sidebar" onChange={setDarkSidebar} />
@@ -809,6 +867,7 @@ export function SettingsModal(): JSX.Element { label="Vim mode" description="First-class Vim motions in the markdown editor." value={vimMode} + settingId="vim-mode" onChange={setVimMode} /> {vimMode ? ( @@ -817,6 +876,7 @@ export function SettingsModal(): JSX.Element { label="Leader key hints" description="Show a which-key style guide after pressing the Leader key so the next available actions stay visible." value={whichKeyHints} + settingId="leader-key-hints" onChange={setWhichKeyHints} /> {whichKeyHints && ( @@ -825,6 +885,7 @@ export function SettingsModal(): JSX.Element { label="Leader hint behavior" description="Timed auto-hides after a short delay. Sticky keeps the leader overlay open until you dismiss it." value={whichKeyHintMode} + settingId="leader-hint-behavior" options={[ { value: 'timed', label: 'Timed' }, { value: 'sticky', label: 'Sticky' } @@ -840,6 +901,7 @@ export function SettingsModal(): JSX.Element { max={3000} step={100} format={(v) => `${(v / 1000).toFixed(1)}s`} + settingId="leader-hint-duration" onChange={setWhichKeyHintTimeoutMs} /> )} @@ -861,6 +923,7 @@ export function SettingsModal(): JSX.Element { label="Vault text search backend" description="Auto prefers fzf when available, then ripgrep, and falls back to the built-in searcher." value={vaultTextSearchBackend} + settingId="vault-text-search-backend" options={[ { value: 'auto', label: 'Auto' }, { value: 'builtin', label: 'Built-in' }, @@ -874,6 +937,7 @@ export function SettingsModal(): JSX.Element { description="Optional. Leave blank to use `rg` from your PATH." value={ripgrepBinaryPath ?? ''} placeholder="/custom/bin/rg" + settingId="ripgrep-binary-path" onChange={(next) => setRipgrepBinaryPath(next)} /> setFzfBinaryPath(next)} /> @@ -906,30 +971,35 @@ export function SettingsModal(): JSX.Element { label="Live preview" description="Hide markdown syntax on lines you're not editing. Turn off to always see raw #, **, [[…]], and other source text." value={livePreview} + settingId="live-preview" onChange={setLivePreview} />
@@ -955,7 +1027,7 @@ export function SettingsModal(): JSX.Element { title="Quick capture" description="Floating capture window for thoughts you want in the vault without leaving whatever you're doing." > - +
) @@ -974,7 +1046,7 @@ export function SettingsModal(): JSX.Element { } ], content: ( -
+
@@ -1086,6 +1161,7 @@ export function SettingsModal(): JSX.Element { max={32} step={1} unit="px" + settingId="font-size" onChange={setEditorFontSize} /> v.toFixed(2)} /> @@ -1106,6 +1183,7 @@ export function SettingsModal(): JSX.Element { max={1400} step={20} unit="px" + settingId="reading-width" onChange={setPreviewMaxWidth} /> -
+
{workspaceMode === 'remote' ? 'Remote workspace' : 'Vault location'} @@ -1271,6 +1355,7 @@ export function SettingsModal(): JSX.Element {
@@ -1356,6 +1441,7 @@ export function SettingsModal(): JSX.Element { label="Primary notes location" description="`Inbox` keeps ZenNotes' original lifecycle structure. `Vault root` surfaces top-level markdown files and folders directly." value={vaultSettings.primaryNotesLocation} + settingId="primary-notes-location" options={[ { value: 'inbox', label: 'Inbox' }, { value: 'root', label: 'Vault root' } @@ -1377,6 +1463,7 @@ export function SettingsModal(): JSX.Element { label="Enable daily notes" description="Adds a dedicated daily-notes workflow without changing ordinary note creation." value={vaultSettings.dailyNotes.enabled} + settingId="enable-daily-notes" onChange={(enabled) => void persistVaultSettings({ ...vaultSettings, @@ -1392,6 +1479,7 @@ export function SettingsModal(): JSX.Element { description="Stored inside your primary notes area. The default is `Daily Notes`." value={vaultSettings.dailyNotes.directory} placeholder={DEFAULT_DAILY_NOTES_DIRECTORY} + settingId="daily-notes-directory" onChange={(next) => void persistVaultSettings({ ...vaultSettings, @@ -1402,7 +1490,10 @@ export function SettingsModal(): JSX.Element { }) } /> -
+
Open today's daily note
@@ -1434,6 +1525,7 @@ export function SettingsModal(): JSX.Element { description="Shown in the sidebar, breadcrumbs, commands, and note actions." value={systemFolderLabels.inbox ?? ''} placeholder={DEFAULT_SYSTEM_FOLDER_LABELS.inbox} + settingId="inbox-label" onChange={(next) => setSystemFolderLabel('inbox', next)} /> setSystemFolderLabel('quick', next)} /> setSystemFolderLabel('archive', next)} /> setSystemFolderLabel('trash', next)} /> @@ -1570,14 +1665,17 @@ export function SettingsModal(): JSX.Element { } ], content: ( -
+
ZenNotes v{appInfo.version}
-
+
@@ -1692,7 +1790,10 @@ export function SettingsModal(): JSX.Element { {' '} for company and product details.

-
+
Built by @@ -1791,6 +1892,9 @@ export function SettingsModal(): JSX.Element { onClick={() => { setActiveCategory(result.category.id) setActiveSearchResultId(result.id) + if (result.type === 'setting') { + jumpToSettingsSearchTarget(result.targetId) + } }} className={[ 'w-full rounded-xl px-3 py-2.5 text-left transition-colors', @@ -2177,14 +2281,16 @@ function KeymapRecorderModal({ function Section({ title, description, + settingId, children }: { title: string description?: string + settingId?: string children: React.ReactNode }): JSX.Element { return ( -
+
{title} @@ -2230,7 +2336,7 @@ function formatAcceleratorForDisplay(accelerator: string): string { .join(mac ? '' : '+') } -function QuickCaptureHotkeyRow(): JSX.Element { +function QuickCaptureHotkeyRow({ settingId }: { settingId?: string } = {}): JSX.Element { const [current, setCurrent] = useState('') const [recording, setRecording] = useState(false) const [draft, setDraft] = useState('') @@ -2287,7 +2393,10 @@ function QuickCaptureHotkeyRow(): JSX.Element { const display = draft || current return ( -
+
Quick capture hotkey
@@ -2374,16 +2483,21 @@ function TextInputRow({ description, value, placeholder, + settingId, onChange }: { label: string description?: string value: string placeholder?: string + settingId?: string onChange: (next: string | null) => void }): JSX.Element { return ( -
+
{label}
{description &&
{description}
} @@ -2406,12 +2520,14 @@ function FontRow({ description, value, options, + settingId, onChange }: { label: string description?: string value: string | null options: string[] + settingId?: string onChange: (next: string | null) => void }): JSX.Element { const [open, setOpen] = useState(false) @@ -2528,7 +2644,10 @@ function FontRow({ } return ( -
+
{label}
{description &&
{description}
} @@ -2645,6 +2764,7 @@ function SliderRow({ step, unit, format, + settingId, onChange }: { label: string @@ -2655,11 +2775,15 @@ function SliderRow({ step: number unit?: string format?: (v: number) => string + settingId?: string onChange: (next: number) => void }): JSX.Element { const display = (format ? format(value) : String(value)) + (unit && !format ? unit : '') return ( -
+
{label}
{description &&
{description}
} @@ -2691,6 +2815,7 @@ function NumberRow({ step, unit, format, + settingId, onChange }: { label: string @@ -2701,12 +2826,16 @@ function NumberRow({ step: number unit?: string format?: (v: number) => string + settingId?: string onChange: (next: number) => void }): JSX.Element { const display = (format ? format(value) : String(value)) + (unit ?? '') const clamp = (n: number): number => Math.min(max, Math.max(min, n)) return ( -
+
{label}
{description &&
{description}
} @@ -2738,15 +2867,20 @@ function ToggleRow({ label, description, value, + settingId, onChange }: { label: string description?: string value: boolean + settingId?: string onChange: (next: boolean) => void }): JSX.Element { return ( -