diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 83bff43f..227acd99 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 @@ -62,6 +66,27 @@ interface SettingsCategory { 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 @@ -160,7 +185,10 @@ function formatReleaseNotesForDisplay(notes: string | null): string | null { } export function SettingsModal(): JSX.Element { - const appInfo = getZenBridge().getAppInfo() + const zenBridge = getZenBridge() + const appInfo = zenBridge.getAppInfo() + const supportsRemoteWorkspace = + appInfo.runtime === 'desktop' && zenBridge.getCapabilities().supportsRemoteWorkspace const setSettingsOpen = useStore((s) => s.setSettingsOpen) const vimMode = useStore((s) => s.vimMode) const setVimMode = useStore((s) => s.setVimMode) @@ -513,7 +541,9 @@ 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('') const availableVaultTextSearchTools = [ vaultTextSearchCapabilities?.ripgrep ? 'ripgrep' : null, @@ -559,12 +589,86 @@ 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 leaderKeyHintsTargetId = vimMode ? 'leader-key-hints' : 'vim-mode' + const leaderHintBehaviorTargetId = + vimMode && whichKeyHints ? 'leader-hint-behavior' : leaderKeyHintsTargetId + const leaderHintDurationTargetId = + vimMode && whichKeyHints && whichKeyHintMode === 'timed' + ? 'leader-hint-duration' + : leaderHintBehaviorTargetId + const categories: SettingsCategory[] = [ { id: 'appearance', 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'], + available: visibleVariants.length > 1 + }, + { + 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: (
-
+
Family
@@ -594,7 +698,7 @@ export function SettingsModal(): JSX.Element {
-
+
Mode
@@ -617,7 +721,7 @@ export function SettingsModal(): JSX.Element {
{visibleVariants.length > 1 && ( -
+
{themeFamily === 'gruvbox' ? 'Contrast' @@ -654,12 +758,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} />
@@ -671,6 +777,101 @@ export function SettingsModal(): JSX.Element { title: 'Editor', description: 'Vim, leader hints, live preview, tabs, and writing behavior.', keywords: ['vim', 'leader', 'preview', 'tabs', 'wrap', 'pdf', 'quick note', 'quick capture', 'hotkey', 'shortcut', 'task', 'tasks'], + searchItems: [ + { + id: 'vim-mode', + title: 'Vim mode', + description: 'First-class Vim motions in the markdown editor.', + keywords: ['vim', 'motions'] + }, + { + id: 'leader-key-hints', + title: 'Leader key hints', + description: 'Show a which-key style guide after pressing the Leader key so the next available actions stay visible.', + keywords: ['leader', 'which-key'], + targetId: leaderKeyHintsTargetId + }, + { + id: 'leader-hint-behavior', + title: 'Leader hint behavior', + description: 'Timed auto-hides after a short delay. Sticky keeps the leader overlay open until you dismiss it.', + keywords: ['leader', 'sticky', 'timed'], + targetId: leaderHintBehaviorTargetId + }, + { + id: 'leader-hint-duration', + title: 'Leader hint duration', + description: 'How long the leader overlay stays visible, and how long the pending leader sequence remains armed.', + keywords: ['leader', 'timeout', 'delay'], + targetId: leaderHintDurationTargetId + }, + { + id: 'vault-text-search-backend', + title: 'Vault text search backend', + description: 'Auto prefers fzf when available, then ripgrep, and falls back to the built-in searcher.', + keywords: ['search', 'backend', 'ripgrep', 'rg', 'fzf', 'built-in'] + }, + { + id: 'ripgrep-binary-path', + title: 'ripgrep binary path', + description: 'Optional. Leave blank to use `rg` from your PATH.', + keywords: ['search', 'rg', 'path'] + }, + { + id: 'fzf-binary-path', + title: 'fzf binary path', + description: 'Optional. Leave blank to use `fzf` from your PATH.', + keywords: ['search', 'path'] + }, + { + id: 'live-preview', + title: 'Live preview', + description: 'Hide markdown syntax on lines you are not editing.', + keywords: ['preview', 'markdown'] + }, + { + id: 'note-tabs', + title: 'Note tabs', + description: 'Open notes in tabs and allow split-friendly tab workflows.', + keywords: ['tabs'] + }, + { + id: 'word-wrap', + title: 'Word wrap', + description: 'Wrap long lines to the editor width. Turn off to scroll horizontally instead.', + keywords: ['wrap', 'line wrap'] + }, + { + id: 'smooth-preview-scroll', + title: 'Smooth preview scroll', + description: 'Animate Ctrl+D / Ctrl+U half-page jumps in preview mode.', + keywords: ['preview', 'scroll'] + }, + { + id: 'pdfs-in-edit-mode', + title: 'PDFs in edit mode', + description: 'Compact keeps the editor focused. Full inlines the PDF viewer under your cursor.', + keywords: ['pdf', 'embed'] + }, + { + id: 'date-titled-quick-notes', + title: 'Date-titled Quick Notes', + description: 'New Quick Notes use YYYY-MM-DD instead of timestamp-style titles.', + keywords: ['quick note', 'date', 'title'] + }, + { + id: 'quick-note-prefix', + title: 'Quick Note prefix', + description: 'Used when naming new Quick Notes.', + keywords: ['quick note', 'prefix'] + }, + { + id: 'quick-capture-hotkey', + title: 'Quick capture hotkey', + description: 'System-wide shortcut to open the floating capture window.', + keywords: ['quick capture', 'hotkey', 'shortcut'] + } + ], content: (
{vimMode ? ( @@ -689,6 +891,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 && ( @@ -697,6 +900,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' } @@ -712,6 +916,7 @@ export function SettingsModal(): JSX.Element { max={3000} step={100} format={(v) => `${(v / 1000).toFixed(1)}s`} + settingId="leader-hint-duration" onChange={setWhichKeyHintTimeoutMs} /> )} @@ -733,6 +938,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' }, @@ -746,6 +952,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)} /> @@ -778,30 +986,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} />
@@ -827,7 +1042,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." > - +
) @@ -837,8 +1052,16 @@ export function SettingsModal(): JSX.Element { title: 'Keymap', description: 'Remap global shortcuts, Vim bindings, and view navigation.', keywords: ['shortcuts', 'bindings', 'leader', 'vim', 'remap', 'keyboard'], + searchItems: [ + { + id: 'shortcut-editor', + title: 'Shortcut editor', + description: 'Record a new key or sequence for the app’s keyboard-first actions.', + keywords: ['shortcuts', 'bindings', 'leader', 'vim', 'remap', 'keyboard'] + } + ], content: ( -
+
@@ -894,6 +1176,7 @@ export function SettingsModal(): JSX.Element { max={32} step={1} unit="px" + settingId="font-size" onChange={setEditorFontSize} /> v.toFixed(2)} /> @@ -914,6 +1198,7 @@ export function SettingsModal(): JSX.Element { max={1400} step={20} unit="px" + settingId="reading-width" onChange={setPreviewMaxWidth} />
-
+
{workspaceMode === 'remote' ? 'Remote workspace' : 'Vault location'} @@ -1002,7 +1356,7 @@ export function SettingsModal(): JSX.Element { Open Local Vault… )} - {appInfo.runtime === 'desktop' && getZenBridge().getCapabilities().supportsRemoteWorkspace && ( + {supportsRemoteWorkspace && (
- {appInfo.runtime === 'desktop' && getZenBridge().getCapabilities().supportsRemoteWorkspace && ( + {supportsRemoteWorkspace && (
@@ -1102,6 +1457,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' } @@ -1123,6 +1479,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, @@ -1138,6 +1495,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, @@ -1148,7 +1506,10 @@ export function SettingsModal(): JSX.Element { }) } /> -
+
Open today's daily note
@@ -1180,6 +1541,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)} /> @@ -1227,6 +1592,26 @@ export function SettingsModal(): JSX.Element { 'agent', 'model context protocol' ], + searchItems: [ + { + id: 'mcp-server', + title: 'MCP server', + description: 'ZenNotes bundles a local MCP server that connected clients use.', + keywords: ['mcp', 'server', 'runtime', 'command'] + }, + { + id: 'mcp-integrations', + title: 'MCP integrations', + description: 'Pick the clients you want connected to this vault.', + keywords: ['mcp', 'claude', 'codex', 'client', 'install', 'uninstall'] + }, + { + id: 'mcp-instructions', + title: 'MCP instructions', + description: 'Edit the system prompt ZenNotes ships to any connected MCP client.', + keywords: ['mcp', 'prompt', 'instructions', 'system prompt'] + } + ], content: }, { @@ -1248,6 +1633,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,15 +1660,38 @@ 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: ( -
+
ZenNotes v{appInfo.version}
-
+
@@ -1378,7 +1806,10 @@ export function SettingsModal(): JSX.Element { {' '} for company and product details.

-
+
Built by @@ -1412,16 +1843,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 +1899,19 @@ export function SettingsModal(): JSX.Element {
@@ -1855,14 +2297,16 @@ function KeymapRecorderModal({ function Section({ title, description, + settingId, children }: { title: string description?: string + settingId?: string children: React.ReactNode }): JSX.Element { return ( -
+
{title} @@ -1908,7 +2352,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('') @@ -1965,7 +2409,10 @@ function QuickCaptureHotkeyRow(): JSX.Element { const display = draft || current return ( -
+
Quick capture hotkey
@@ -2052,16 +2499,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}
} @@ -2084,12 +2536,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) @@ -2206,7 +2660,10 @@ function FontRow({ } return ( -
+
{label}
{description &&
{description}
} @@ -2323,6 +2780,7 @@ function SliderRow({ step, unit, format, + settingId, onChange }: { label: string @@ -2333,11 +2791,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}
} @@ -2369,6 +2831,7 @@ function NumberRow({ step, unit, format, + settingId, onChange }: { label: string @@ -2379,12 +2842,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}
} @@ -2416,15 +2883,20 @@ function ToggleRow({ label, description, value, + settingId, onChange }: { label: string description?: string value: boolean + settingId?: string onChange: (next: boolean) => void }): JSX.Element { return ( -