diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx index a034b3084..5ec81dc01 100644 --- a/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx @@ -1,8 +1,16 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Command } from 'cmdk'; -import { Activity, FolderOpen, GitBranch, MessageSquare, type LucideIcon } from 'lucide-react'; +import { + Activity, + FolderOpen, + GitBranch, + MessageSquare, + Settings, + type LucideIcon, +} from 'lucide-react'; import { useObserver } from 'mobx-react-lite'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { searchSettings } from '@renderer/features/settings/components/settings-search'; import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; import { getTaskStore, getTaskView } from '@renderer/features/tasks/stores/task-selectors'; @@ -223,6 +231,23 @@ export function CommandPaletteModal({ const rankedDb = applyContextAffinity(dbResults, { projectId }); const actionResults = actions; + const settingsResults = useMemo( + () => + searchSettings(debouncedQuery) + .slice(0, 6) + .map((result) => ({ + kind: 'action', + id: result.id, + title: result.title, + subtitle: result.subtitle, + icon: Settings, + execute: () => { + handleClose(); + navigate('settings', { tab: result.tab }); + }, + })), + [debouncedQuery, handleClose, navigate] + ); const q = debouncedQuery.toLowerCase(); const matchedResourceMonitor = @@ -323,6 +348,13 @@ export function CommandPaletteModal({ onSelect={matchedResourceMonitor.execute} /> )} + {settingsResults.length > 0 && ( + + {settingsResults.map((item) => ( + + ))} + + )} {rankedDb.map((item) => { if (item.kind === 'command') { const live = commandRegistry.findById(item.id); diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx index 78d135307..abd1436e5 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx @@ -1,47 +1,29 @@ import { ExternalLink } from 'lucide-react'; -import React, { useCallback } from 'react'; +import { useCallback, useRef, useState, type ReactNode } from 'react'; import { PageHeader } from '@renderer/lib/components/page-header'; import { rpc } from '@renderer/lib/ipc'; +import { SearchInput } from '@renderer/lib/ui/search-input'; import { cn } from '@renderer/utils/utils'; -import { AccountTab } from './AccountTab'; -import { CliAgentsList } from './CliAgentsList'; -import DefaultAgentSettingsCard from './DefaultAgentSettingsCard'; -import HiddenToolsSettingsCard from './HiddenToolsSettingsCard'; -import IntegrationsCard from './IntegrationsCard'; -import InterfaceSettingsCard from './InterfaceSettingsCard'; -import KeyboardSettingsCard from './KeyboardSettingsCard'; -import NotificationSettingsCard from './NotificationSettingsCard'; -import RepositorySettingsCard from './RepositorySettingsCard'; -import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard'; -import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard'; -import { SshConnectionsSettingsCard } from './SshConnectionsSettingsCard'; -import { - AutoGenerateTaskNamesRow, - AutoTrustWorktreesRow, - CreateBranchAndWorktreeRow, - EnableTmuxRow, - IncludeIssueContextByDefaultRow, - PreserveTaskNameCapitalizationRow, -} from './TaskSettingsRows'; -import TelemetryCard from './TelemetryCard'; -import TerminalSettingsCard from './TerminalSettingsCard'; -import ThemeCard from './ThemeCard'; -import { UpdateCard } from './UpdateCard'; +import type { SettingsPageTab } from './settings-page-config'; +import { type SectionConfig, settingsTabContent } from './settings-page-content'; +import { getSettingsSearchView } from './settings-search'; -export type SettingsPageTab = - | 'general' - | 'account' - | 'clis-models' - | 'integrations' - | 'connections' - | 'repository' - | 'interface' - | 'docs'; +export type { SettingsPageTab } from './settings-page-config'; -interface SectionConfig { - title?: string; - action?: React.ReactNode; - component: React.ReactNode; +/** Wrap the matched substring of `text` in a highlight; plain text when the match is keyword-only. */ +function highlightMatch(text: string, normalizedQuery: string): ReactNode { + if (!normalizedQuery) return text; + const index = text.toLowerCase().indexOf(normalizedQuery); + if (index === -1) return text; + return ( + <> + {text.slice(0, index)} + + {text.slice(index, index + normalizedQuery.length)} + + {text.slice(index + normalizedQuery.length)} + + ); } export function SettingsPage({ @@ -51,173 +33,151 @@ export function SettingsPage({ tab: SettingsPageTab; onTabChange: (tab: SettingsPageTab) => void; }) { + const [searchQuery, setSearchQuery] = useState(''); + const searchView = getSettingsSearchView(activeTab, searchQuery); + const { normalizedQuery, displayedTab, resultGroups, totalMatches } = searchView; + const displayedContent = displayedTab ? settingsTabContent[displayedTab.id] : null; + const groupRefs = useRef>({}); + const handleDocsClick = useCallback(() => { void rpc.app.openExternal('https://docs.emdash.sh'); }, []); - const tabs: Array<{ - id: SettingsPageTab; - label: string; - isExternal?: boolean; - }> = [ - { id: 'general', label: 'General' }, - { id: 'account', label: 'Account' }, - { id: 'clis-models', label: 'Agents' }, - { id: 'integrations', label: 'Integrations' }, - { id: 'connections', label: 'Connections' }, - { id: 'repository', label: 'Repository' }, - { id: 'interface', label: 'Interface' }, - { id: 'docs', label: 'Docs', isExternal: true }, - ]; - - const tabContent: Record< - string, - { title: string; description: string; sections: SectionConfig[] } - > = { - general: { - title: 'General', - description: 'Manage your account, privacy settings, notifications, and app updates.', - sections: [ - { - component: , - }, - { - component: , - }, - { - component: , - }, - { - component: , - }, - { - component: , - }, - { - component: , - }, - { - component: , - }, - { - component: , - }, - { - component: , - }, - ], - }, - account: { - title: 'Account', - description: 'Manage your Emdash account.', - sections: [{ component: }], - }, - 'clis-models': { - title: 'Agents', - description: 'Manage CLI agents and model configurations.', - sections: [ - { component: }, - { - title: 'CLI agents', - component: ( -
- -
- ), - }, - ], - }, - integrations: { - title: 'Integrations', - description: 'Connect external services and tools.', - sections: [{ component: }], - }, - connections: { - title: 'Connections', - description: 'Manage reusable SSH connections for remote projects.', - sections: [{ component: }], - }, - repository: { - title: 'Repository', - description: 'Configure repository and branch settings.', - sections: [{ title: 'Branch prefix', component: }], - }, - interface: { - title: 'Interface', - description: 'Customize the appearance and behavior of the app.', - sections: [ - { component: }, - { component: }, - { component: }, - { component: }, - { component: }, - { title: 'Keyboard shortcuts', component: }, - { - title: 'Tools', - component: , - }, - ], - }, - }; - - const currentContent = tabContent[activeTab as keyof typeof tabContent]; + const renderSection = (section: SectionConfig) => ( +
+ {section.title && ( +
+

+ {normalizedQuery ? highlightMatch(section.title, normalizedQuery) : section.title} +

+ {section.action &&
{section.action}
} +
+ )} + {section.component} +
+ ); return (
- +
+ setSearchQuery(event.currentTarget.value)} + onClear={() => setSearchQuery('')} + placeholder="Search settings" + aria-label="Search settings" + clearLabel="Clear settings search" + selectOnHotkey + containerClassName="mx-0.5" + className="h-9" + /> +
+ {normalizedQuery + ? totalMatches > 0 + ? `${totalMatches} settings match ${searchQuery.trim()}` + : `No settings match ${searchQuery.trim()}` + : ''} +
+ +
- {/* Content container */} - {currentContent && ( -
-
- - {currentContent.sections.map((section) => ( -
- {section.title && ( -
-

{section.title}

- {section.action &&
{section.action}
} -
- )} - {section.component} +
+
+ {normalizedQuery ? ( + resultGroups.length > 0 ? ( +
+ {resultGroups.map((group) => ( +
{ + groupRefs.current[group.tab.id] = el; + }} + className="scroll-mt-4 space-y-6" + > +
+

+ {highlightMatch(group.title, normalizedQuery)} +

+
+ {group.sections.map((section) => { + const contentSection = settingsTabContent[group.tab.id].sections.find( + (candidate) => candidate.id === section.id + ); + return contentSection ? renderSection(contentSection) : null; + })} +
+ ))}
- ))} -
+ ) : ( +
+ No settings match “{searchQuery.trim()}”. +
+ ) + ) : ( + displayedContent && ( +
+ + {displayedContent.sections.map(renderSection)} +
+ ) + )}
- )} +
diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/settings-page-config.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/settings-page-config.tsx new file mode 100644 index 000000000..a045ee1e4 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/settings-page-config.tsx @@ -0,0 +1,197 @@ +import { AGENT_PROVIDERS } from '@shared/core/agents/agent-provider-registry'; + +export type SettingsPageTab = + | 'general' + | 'account' + | 'clis-models' + | 'integrations' + | 'connections' + | 'repository' + | 'interface' + | 'docs'; + +export type SettingsContentTab = Exclude; + +export interface SectionSearchConfig { + id: string; + title?: string; + searchText: string; +} + +export interface TabSearchConfig { + title: string; + description: string; + sections: SectionSearchConfig[]; +} + +export type SettingsNavTab = + | { + id: SettingsContentTab; + label: string; + isExternal?: false; + } + | { + id: 'docs'; + label: string; + isExternal: true; + }; + +const agentProviderSearchText = AGENT_PROVIDERS.flatMap((provider) => [ + provider.id, + provider.name, + provider.alt, + provider.cli, + provider.description, + ...(provider.commands ?? []), +]) + .filter(Boolean) + .join(' '); + +export const settingsTabs: SettingsNavTab[] = [ + { id: 'general', label: 'General' }, + { id: 'account', label: 'Account' }, + { id: 'clis-models', label: 'Agents' }, + { id: 'integrations', label: 'Integrations' }, + { id: 'connections', label: 'Connections' }, + { id: 'repository', label: 'Repository' }, + { id: 'interface', label: 'Interface' }, + { id: 'docs', label: 'Docs', isExternal: true }, +]; + +export const settingsSearchContent: Record = { + general: { + title: 'General', + description: 'Manage your account, privacy settings, notifications, and app updates.', + sections: [ + { + id: 'telemetry', + searchText: 'telemetry analytics privacy tracking product usage', + }, + { + id: 'auto-generate-task-names', + searchText: 'auto generate task names tasks naming ai summary', + }, + { + id: 'auto-trust-worktrees', + searchText: 'auto trust worktrees permissions security approvals sandbox', + }, + { + id: 'create-branch-and-worktree', + searchText: 'create branch worktree tasks git checkout', + }, + { + id: 'preserve-task-name-capitalization', + searchText: 'preserve task name capitalization case title names', + }, + { + id: 'include-issue-context-by-default', + searchText: 'include issue context default linear github jira tickets tasks', + }, + { + id: 'enable-tmux', + searchText: 'tmux terminal sessions panes multiplexing', + }, + { + id: 'notifications', + searchText: + 'notifications alerts sound sounds custom sound custom sounds audio file cue desktop updates task completion', + }, + { + id: 'updates', + searchText: 'updates version app update release download', + }, + ], + }, + account: { + title: 'Account', + description: 'Manage your Emdash account.', + sections: [ + { + id: 'account', + searchText: 'account sign in login user profile', + }, + ], + }, + 'clis-models': { + title: 'Agents', + description: 'Manage CLI agents and model configurations.', + sections: [ + { + id: 'default-agent', + searchText: `default agent cli model provider claude code cloud code amp ${agentProviderSearchText}`, + }, + { + id: 'cli-agents', + title: 'CLI agents', + searchText: `cli agents models providers codex claude code cloud code gemini opencode amp install detected missing command ${agentProviderSearchText}`, + }, + ], + }, + integrations: { + title: 'Integrations', + description: 'Connect external services and tools.', + sections: [ + { + id: 'integrations', + searchText: 'integrations github gitlab linear jira connect services accounts tokens', + }, + ], + }, + connections: { + title: 'Connections', + description: 'Manage reusable SSH connections for remote projects.', + sections: [ + { + id: 'ssh-connections', + searchText: 'ssh connections remote hosts keys servers reusable projects', + }, + ], + }, + repository: { + title: 'Repository', + description: 'Configure repository and branch settings.', + sections: [ + { + id: 'branch-prefix', + title: 'Branch prefix', + searchText: 'branch prefix repository git default branch remote worktree', + }, + ], + }, + interface: { + title: 'Interface', + description: 'Customize the appearance and behavior of the app.', + sections: [ + { + id: 'theme', + searchText: 'theme appearance light dark system color emdash light emdash dark', + }, + { + id: 'terminal', + searchText: 'terminal font size family shell copy selection option meta cursor', + }, + { + id: 'sidebar-metadata', + searchText: 'sidebar metadata task list badges details density', + }, + { + id: 'resource-monitor', + searchText: 'resource monitor cpu memory usage performance status', + }, + { + id: 'interface', + searchText: 'interface appearance behavior layout animations density', + }, + { + id: 'keyboard-shortcuts', + title: 'Keyboard shortcuts', + searchText: 'keyboard shortcuts hotkeys keybindings commands', + }, + { + id: 'tools', + title: 'Tools', + searchText: 'tools hidden disabled agent tools permissions', + }, + ], + }, +}; diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/settings-page-content.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/settings-page-content.tsx new file mode 100644 index 000000000..1f204f8c5 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/settings-page-content.tsx @@ -0,0 +1,170 @@ +import type { ReactNode } from 'react'; +import { AccountTab } from './AccountTab'; +import { CliAgentsList } from './CliAgentsList'; +import DefaultAgentSettingsCard from './DefaultAgentSettingsCard'; +import HiddenToolsSettingsCard from './HiddenToolsSettingsCard'; +import IntegrationsCard from './IntegrationsCard'; +import InterfaceSettingsCard from './InterfaceSettingsCard'; +import KeyboardSettingsCard from './KeyboardSettingsCard'; +import NotificationSettingsCard from './NotificationSettingsCard'; +import RepositorySettingsCard from './RepositorySettingsCard'; +import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard'; +import { + type SectionSearchConfig, + type SettingsContentTab, + type TabSearchConfig, + settingsSearchContent, +} from './settings-page-config'; +import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard'; +import { SshConnectionsSettingsCard } from './SshConnectionsSettingsCard'; +import { + AutoGenerateTaskNamesRow, + AutoTrustWorktreesRow, + CreateBranchAndWorktreeRow, + EnableTmuxRow, + IncludeIssueContextByDefaultRow, + PreserveTaskNameCapitalizationRow, +} from './TaskSettingsRows'; +import TelemetryCard from './TelemetryCard'; +import TerminalSettingsCard from './TerminalSettingsCard'; +import ThemeCard from './ThemeCard'; +import { UpdateCard } from './UpdateCard'; + +export interface SectionConfig extends SectionSearchConfig { + action?: ReactNode; + component: ReactNode; +} + +export interface TabContent extends Omit { + sections: SectionConfig[]; +} + +export const settingsTabContent: Record = { + general: { + ...settingsSearchContent.general, + sections: [ + { + ...settingsSearchContent.general.sections[0], + component: , + }, + { + ...settingsSearchContent.general.sections[1], + component: , + }, + { + ...settingsSearchContent.general.sections[2], + component: , + }, + { + ...settingsSearchContent.general.sections[3], + component: , + }, + { + ...settingsSearchContent.general.sections[4], + component: , + }, + { + ...settingsSearchContent.general.sections[5], + component: , + }, + { + ...settingsSearchContent.general.sections[6], + component: , + }, + { + ...settingsSearchContent.general.sections[7], + component: , + }, + { + ...settingsSearchContent.general.sections[8], + component: , + }, + ], + }, + account: { + ...settingsSearchContent.account, + sections: [ + { + ...settingsSearchContent.account.sections[0], + component: , + }, + ], + }, + 'clis-models': { + ...settingsSearchContent['clis-models'], + sections: [ + { + ...settingsSearchContent['clis-models'].sections[0], + component: , + }, + { + ...settingsSearchContent['clis-models'].sections[1], + component: ( +
+ +
+ ), + }, + ], + }, + integrations: { + ...settingsSearchContent.integrations, + sections: [ + { + ...settingsSearchContent.integrations.sections[0], + component: , + }, + ], + }, + connections: { + ...settingsSearchContent.connections, + sections: [ + { + ...settingsSearchContent.connections.sections[0], + component: , + }, + ], + }, + repository: { + ...settingsSearchContent.repository, + sections: [ + { + ...settingsSearchContent.repository.sections[0], + component: , + }, + ], + }, + interface: { + ...settingsSearchContent.interface, + sections: [ + { + ...settingsSearchContent.interface.sections[0], + component: , + }, + { + ...settingsSearchContent.interface.sections[1], + component: , + }, + { + ...settingsSearchContent.interface.sections[2], + component: , + }, + { + ...settingsSearchContent.interface.sections[3], + component: , + }, + { + ...settingsSearchContent.interface.sections[4], + component: , + }, + { + ...settingsSearchContent.interface.sections[5], + component: , + }, + { + ...settingsSearchContent.interface.sections[6], + component: , + }, + ], + }, +}; diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/settings-search.ts b/apps/emdash-desktop/src/renderer/features/settings/components/settings-search.ts new file mode 100644 index 000000000..ba7497eb0 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/settings-search.ts @@ -0,0 +1,152 @@ +import { + type SectionSearchConfig, + type SettingsContentTab, + type SettingsNavTab, + type SettingsPageTab, + settingsSearchContent, + settingsTabs, +} from './settings-page-config'; + +type ContentNavTab = { + id: SettingsContentTab; + label: string; + isExternal?: false; +}; + +export interface SettingsTabMatch { + tab: SettingsNavTab; + /** Sections this tab would show under the current query (0 = no hit). */ + count: number; +} + +export interface SettingsSearchResult { + id: string; + tab: SettingsContentTab; + title: string; + subtitle: string; + score: number; +} + +export interface SettingsResultGroup { + tab: ContentNavTab; + title: string; + description: string; + sections: SectionSearchConfig[]; +} + +function matchesSearch(parts: Array, normalizedQuery: string) { + return parts.filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery); +} + +function tabHeaderMatchesSearch(tab: SettingsNavTab, normalizedQuery: string) { + if (!normalizedQuery || tab.isExternal) return false; + + const content = settingsSearchContent[tab.id]; + return matchesSearch([tab.label, content.title, content.description], normalizedQuery); +} + +function sectionMatchesSearch(section: SectionSearchConfig, normalizedQuery: string) { + return matchesSearch([section.title, section.searchText], normalizedQuery); +} + +function sectionLabel(section: SectionSearchConfig) { + if (section.title) return section.title; + return section.id + .split('-') + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(' '); +} + +/** Count of sections a tab would show under the current query (0 = no hit). */ +function tabMatchCount(tab: SettingsNavTab, normalizedQuery: string) { + if (!normalizedQuery || tab.isExternal) return 0; + const content = settingsSearchContent[tab.id]; + if (tabHeaderMatchesSearch(tab, normalizedQuery)) return content.sections.length; + return content.sections.filter((section) => sectionMatchesSearch(section, normalizedQuery)) + .length; +} + +function isContentTab(tab: SettingsNavTab): tab is ContentNavTab { + return !tab.isExternal; +} + +export function getSettingsSearchView(activeTab: SettingsPageTab, query: string) { + const normalizedQuery = query.trim().toLowerCase(); + const activeContentTab: SettingsContentTab = activeTab === 'docs' ? 'general' : activeTab; + + // Show the active tab's matches; if it has none but another tab does, jump to the first match + // so results are visible immediately instead of forcing a sidebar click. + const activeNavTab = settingsTabs.find( + (tab): tab is ContentNavTab => isContentTab(tab) && tab.id === activeContentTab + ); + const activeTabMatches = activeNavTab ? tabMatchCount(activeNavTab, normalizedQuery) > 0 : false; + const firstMatchingTab = normalizedQuery + ? settingsTabs.find( + (tab): tab is ContentNavTab => isContentTab(tab) && tabMatchCount(tab, normalizedQuery) > 0 + ) + : undefined; + const displayedTab = + normalizedQuery && !activeTabMatches && firstMatchingTab ? firstMatchingTab : activeNavTab; + + const tabMatches: SettingsTabMatch[] = settingsTabs.map((tab) => ({ + tab, + count: tabMatchCount(tab, normalizedQuery), + })); + const totalMatches = tabMatches.reduce((sum, match) => sum + match.count, 0); + + // Aggregated results across every tab so all findings are visible at once while searching. + const resultGroups: SettingsResultGroup[] = normalizedQuery + ? settingsTabs.filter(isContentTab).flatMap((tab) => { + const content = settingsSearchContent[tab.id]; + const sections = tabHeaderMatchesSearch(tab, normalizedQuery) + ? content.sections + : content.sections.filter((section) => sectionMatchesSearch(section, normalizedQuery)); + return sections.length > 0 + ? [{ tab, title: content.title, description: content.description, sections }] + : []; + }) + : []; + + return { + normalizedQuery, + tabMatches, + totalMatches, + displayedTab, + resultGroups, + }; +} + +export function searchSettings(query: string): SettingsSearchResult[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + + const results: SettingsSearchResult[] = []; + + for (const tab of settingsTabs) { + if (tab.isExternal) continue; + + const content = settingsSearchContent[tab.id]; + if (tabHeaderMatchesSearch(tab, normalizedQuery)) { + results.push({ + id: `settings:${tab.id}`, + tab: tab.id, + title: `${content.title} Settings`, + subtitle: content.description, + score: 2, + }); + } + + for (const section of content.sections) { + if (!sectionMatchesSearch(section, normalizedQuery)) continue; + results.push({ + id: `settings:${tab.id}:${section.id}`, + tab: tab.id, + title: sectionLabel(section), + subtitle: `${content.title} Settings`, + score: section.title?.toLowerCase().includes(normalizedQuery) ? 3 : 1, + }); + } + } + + return results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)); +} diff --git a/apps/emdash-desktop/src/renderer/lib/ui/search-input.tsx b/apps/emdash-desktop/src/renderer/lib/ui/search-input.tsx index dd584e78b..d91d8bab1 100644 --- a/apps/emdash-desktop/src/renderer/lib/ui/search-input.tsx +++ b/apps/emdash-desktop/src/renderer/lib/ui/search-input.tsx @@ -1,18 +1,44 @@ import { useHotkey } from '@tanstack/react-hotkeys'; -import { Search } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import * as React from 'react'; import { Input } from '@renderer/lib/ui/input'; import { cn } from '@renderer/utils/utils'; type SearchInputProps = React.ComponentProps<'input'> & { containerClassName?: string; + clearLabel?: string; + onClear?: () => void; + selectOnHotkey?: boolean; }; const SearchInput = React.forwardRef(function SearchInput( - { className, containerClassName, ...props }, + { + className, + clearLabel = 'Clear search', + containerClassName, + onClear, + onKeyDown, + selectOnHotkey = false, + value, + ...props + }, forwardedRef ) { const inputRef = React.useRef(null); + const showClearButton = Boolean(value) && onClear; + + // Escape clears a non-empty search before falling back to blurring the field. + const handleKeyDown = (event: React.KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.key !== 'Escape') return; + if (value && onClear) { + event.preventDefault(); + event.stopPropagation(); + onClear(); + } else { + event.currentTarget.blur(); + } + }; React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement); @@ -20,13 +46,32 @@ const SearchInput = React.forwardRef(functio 'Mod+F', () => { inputRef.current?.focus(); + if (selectOnHotkey) { + inputRef.current?.select(); + } }, { enabled: true } ); return (
- + + {showClearButton && ( + + )}
); });