diff --git a/src/renderer/components/BrowserPreviewSettingsCard.tsx b/src/renderer/components/BrowserPreviewSettingsCard.tsx index 218884a03..fb3f74666 100644 --- a/src/renderer/components/BrowserPreviewSettingsCard.tsx +++ b/src/renderer/components/BrowserPreviewSettingsCard.tsx @@ -6,7 +6,7 @@ export default function BrowserPreviewSettingsCard() { const { settings, updateSettings, isLoading, isSaving } = useAppSettings(); return ( -
+
Show localhost links in browser diff --git a/src/renderer/components/DefaultAgentSettingsCard.tsx b/src/renderer/components/DefaultAgentSettingsCard.tsx index bb1fab9f0..c623b725d 100644 --- a/src/renderer/components/DefaultAgentSettingsCard.tsx +++ b/src/renderer/components/DefaultAgentSettingsCard.tsx @@ -21,7 +21,7 @@ const DefaultAgentSettingsCard: React.FC = () => { }; return ( -
+

Default agent

diff --git a/src/renderer/components/HiddenToolsSettingsCard.tsx b/src/renderer/components/HiddenToolsSettingsCard.tsx index 83ea76de9..f998a3120 100644 --- a/src/renderer/components/HiddenToolsSettingsCard.tsx +++ b/src/renderer/components/HiddenToolsSettingsCard.tsx @@ -64,7 +64,10 @@ export default function HiddenToolsSettingsCard() { }, [availability, labels]); return ( -

+
{sortedApps.map((app) => { const isDetected = availability[app.id] ?? app.alwaysAvailable ?? false; diff --git a/src/renderer/components/IntegrationsCard.tsx b/src/renderer/components/IntegrationsCard.tsx index 7e509e7b1..90c088017 100644 --- a/src/renderer/components/IntegrationsCard.tsx +++ b/src/renderer/components/IntegrationsCard.tsx @@ -26,6 +26,7 @@ import { useModalContext } from '../contexts/ModalProvider'; import GitLabSetupForm from './integrations/GitLabSetupForm'; import ForgejoSetupForm from './integrations/ForgejoSetupForm'; import { GithubDeviceFlowModal } from './GithubDeviceFlowModal'; +import { INTEGRATION_REGISTRY, type IntegrationId } from './integrations/registry'; /** Light mode: original SVG colors. Dark / dark-black: primary colour. */ const SvgLogo = ({ raw }: { raw: string }) => { @@ -472,21 +473,27 @@ const IntegrationsCard: React.FC = () => { } }, []); - const integrations = [ + // Runtime state per integration. Static metadata (name/description/order) + // lives in INTEGRATION_REGISTRY so it can be shared with the settings + // search index. + const integrationRuntime: Record< + IntegrationId, { - id: 'github', - name: 'GitHub', - description: 'Connect your repositories', + logoSvg: string; + connected: boolean; + loading: boolean; + onConnect: () => void; + onDisconnect: () => void; + } + > = { + github: { logoSvg: githubSvg, connected: authenticated, loading: isLoading, onConnect: handleGithubConnect, onDisconnect: handleGithubDisconnect, }, - { - id: 'linear', - name: 'Linear', - description: 'Work on Linear tickets', + linear: { logoSvg: linearSvg, connected: linearConnected, loading: linearLoading, @@ -496,10 +503,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleLinearDisconnect, }, - { - id: 'jira', - name: 'Jira', - description: 'Work on Jira tickets', + jira: { logoSvg: jiraSvg, connected: jiraConnected, loading: jiraLoading, @@ -509,10 +513,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleJiraDisconnect, }, - { - id: 'gitlab', - name: 'GitLab', - description: 'Work on GitLab issues', + gitlab: { logoSvg: gitlabSvg, connected: gitlabConnected, loading: gitlabLoading, @@ -522,10 +523,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleGitlabDisconnect, }, - { - id: 'plain', - name: 'Plain', - description: 'Work on support threads', + plain: { logoSvg: plainSvg, connected: plainConnected, loading: plainLoading, @@ -535,10 +533,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handlePlainDisconnect, }, - { - id: 'forgejo', - name: 'Forgejo', - description: 'Work on Forgejo issues', + forgejo: { logoSvg: forgejoSvg, connected: forgejoConnected, loading: forgejoLoading, @@ -548,10 +543,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleForgejoDisconnect, }, - { - id: 'sentry', - name: 'Sentry', - description: 'Fix errors from Sentry', + sentry: { logoSvg: sentrySvg, connected: sentryConnected, loading: sentryLoading, @@ -561,11 +553,17 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleSentryDisconnect, }, - ]; + }; + + const integrations = INTEGRATION_REGISTRY.map((entry) => ({ + ...entry, + ...integrationRuntime[entry.id as IntegrationId], + })); return ( <>
diff --git a/src/renderer/components/KeyboardSettingsCard.tsx b/src/renderer/components/KeyboardSettingsCard.tsx index 05dde88f7..9cac53316 100644 --- a/src/renderer/components/KeyboardSettingsCard.tsx +++ b/src/renderer/components/KeyboardSettingsCard.tsx @@ -284,7 +284,7 @@ const KeyboardSettingsCard: React.FC = () => { }; return ( -
+
{CONFIGURABLE_SHORTCUTS.map((shortcut) => (
diff --git a/src/renderer/components/NotificationSettingsCard.tsx b/src/renderer/components/NotificationSettingsCard.tsx index 8a07cbf6e..7c61d7ec2 100644 --- a/src/renderer/components/NotificationSettingsCard.tsx +++ b/src/renderer/components/NotificationSettingsCard.tsx @@ -22,7 +22,7 @@ const NotificationSettingsCard: React.FC = () => { }; return ( -
+
{/* Master toggle */}
diff --git a/src/renderer/components/RepositorySettingsCard.tsx b/src/renderer/components/RepositorySettingsCard.tsx index 4a3eeadc1..338f40bc4 100644 --- a/src/renderer/components/RepositorySettingsCard.tsx +++ b/src/renderer/components/RepositorySettingsCard.tsx @@ -26,7 +26,7 @@ const RepositorySettingsCard: React.FC = () => { }, [repository?.branchPrefix]); return ( -
+
{ const showResourceMonitor = settings?.interface?.showResourceMonitor ?? false; return ( -
+
Show resource monitor in titlebar diff --git a/src/renderer/components/ReviewAgentSettingsCard.tsx b/src/renderer/components/ReviewAgentSettingsCard.tsx index 8834776a0..9d4b48a44 100644 --- a/src/renderer/components/ReviewAgentSettingsCard.tsx +++ b/src/renderer/components/ReviewAgentSettingsCard.tsx @@ -54,7 +54,10 @@ const ReviewAgentSettingsCard: React.FC = () => { }; return ( -
+

Review preset

diff --git a/src/renderer/components/RightSidebarSettingsCard.tsx b/src/renderer/components/RightSidebarSettingsCard.tsx index b98064842..8b7cd3146 100644 --- a/src/renderer/components/RightSidebarSettingsCard.tsx +++ b/src/renderer/components/RightSidebarSettingsCard.tsx @@ -10,7 +10,7 @@ const RightSidebarSettingsCard: React.FC = () => { const autoRightSidebarBehavior = interfaceSettings?.autoRightSidebarBehavior ?? false; return ( -
+ @@ -330,13 +378,7 @@ export const SettingsPage: React.FC = ({ initialTab, onClose setActiveTab(tab.id as SettingsPageTab); } }} - className={`flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${ - isActive - ? 'bg-muted text-foreground' - : tab.isExternal - ? 'text-muted-foreground hover:bg-muted/60' - : 'text-foreground hover:bg-muted/60' - }`} + className={getTabButtonClasses(isActive, !!tab.isExternal)} > {tab.label} {tab.isExternal && } @@ -344,35 +386,45 @@ export const SettingsPage: React.FC = ({ initialTab, onClose ); })} +
+
+ {searchQuery.trim() ? ( + + ) : ( + currentContent && ( + <> + {/* Page title */} +
+
+

{currentContent.title}

+

+ {currentContent.description} +

+
+ +
- {/* Content container */} - {currentContent && ( -
-
- {/* Page title */} -
-
-

{currentContent.title}

-

{currentContent.description}

-
- -
- - {/* Sections */} - {currentContent.sections.map((section, index) => ( -
- {section.title && ( -
-

{section.title}

- {section.action &&
{section.action}
} + {/* Sections */} + {currentContent.sections.map((section, index) => ( +
+ {section.title && ( +
+

{section.title}

+ {section.action &&
{section.action}
} +
+ )} + {section.component}
- )} - {section.component} -
- ))} -
+ ))} + + ) + )}
- )} +
diff --git a/src/renderer/components/SettingsSearchInput.tsx b/src/renderer/components/SettingsSearchInput.tsx new file mode 100644 index 000000000..5245840a7 --- /dev/null +++ b/src/renderer/components/SettingsSearchInput.tsx @@ -0,0 +1,55 @@ +import React, { forwardRef } from 'react'; +import { Search, X } from 'lucide-react'; + +const isMac = typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform); +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +interface SettingsSearchInputProps { + query: string; + onQueryChange: (query: string) => void; +} + +export const SettingsSearchInput = forwardRef( + ({ query, onQueryChange }, ref) => { + return ( +
+ + onQueryChange(e.target.value)} + className="h-9 w-72 rounded-lg border-border/60 bg-muted/40 pl-9 pr-16 text-sm placeholder:text-muted-foreground/60 hover:border-border hover:bg-muted focus:border-primary/50 focus:bg-background" + /> +
+ {query ? ( + + ) : ( + + {isMac ? '⌘' : 'Ctrl'} + F + + )} +
+
+ ); + } +); + +SettingsSearchInput.displayName = 'SettingsSearchInput'; + +export default SettingsSearchInput; diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx new file mode 100644 index 000000000..0e34d7927 --- /dev/null +++ b/src/renderer/components/SettingsSearchResults.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { motion } from 'motion/react'; +import { ChevronRight } from 'lucide-react'; +import type { SearchResult } from '@/hooks/useSettingsSearch'; +import { groupResultsByTab } from '@/hooks/useSettingsSearch'; +import type { SettingsPageTab } from './SettingsPage'; + +const TAB_LABELS: Record = { + general: 'General', + 'clis-models': 'Agents', + integrations: 'Integrations', + repository: 'Repository', + interface: 'Interface', + account: 'Account', + docs: 'Docs', +}; + +interface SettingsSearchResultsProps { + results: SearchResult[]; + query: string; + onResultClick: (tabId: SettingsPageTab, elementId: string) => void; +} + +function highlightMatches(text: string, query: string) { + if (!query.trim()) return text; + + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + let index = textLower.indexOf(queryLower); + while (index !== -1) { + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + parts.push( + + {text.slice(index, index + query.length)} + + ); + lastIndex = index + query.length; + index = textLower.indexOf(queryLower, lastIndex); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : text; +} + +export const SettingsSearchResults: React.FC = ({ + results, + query, + onResultClick, +}) => { + const grouped = React.useMemo(() => groupResultsByTab(results), [results]); + + if (results.length === 0) { + return ( +
+

No settings found for "{query}"

+
+ ); + } + + return ( +
+

+ {results.length} {results.length === 1 ? 'result' : 'results'} +

+ + {Array.from(grouped.entries()).map(([tabId, tabResults]) => ( + +

+ {TAB_LABELS[tabId as SettingsPageTab] || tabId} +

+
+ {tabResults.map((result) => ( + + ))} +
+
+ ))} +
+ ); +}; + +export default SettingsSearchResults; diff --git a/src/renderer/components/TaskHoverActionCard.tsx b/src/renderer/components/TaskHoverActionCard.tsx index 82a899050..411686fe7 100644 --- a/src/renderer/components/TaskHoverActionCard.tsx +++ b/src/renderer/components/TaskHoverActionCard.tsx @@ -13,7 +13,7 @@ const TaskHoverActionCard: React.FC = () => { }; return ( -
+

Task hover action

diff --git a/src/renderer/components/TaskSettingsRows.tsx b/src/renderer/components/TaskSettingsRows.tsx index 95dd7d854..8866cdfc3 100644 --- a/src/renderer/components/TaskSettingsRows.tsx +++ b/src/renderer/components/TaskSettingsRows.tsx @@ -21,7 +21,7 @@ export const AutoGenerateTaskNamesRow: React.FC = ({ taskSettings }) = (taskSettings.errorScope === 'autoGenerateName' || taskSettings.errorScope === 'load'); return ( -

+

Auto-generate task names

@@ -47,7 +47,7 @@ export const AutoInferTaskNamesRow: React.FC = ({ taskSettings }) => { const isDisabled = taskSettings.loading || taskSettings.saving || !taskSettings.autoGenerateName; return ( -
+

@@ -78,7 +78,7 @@ export const AutoApproveByDefaultRow: React.FC = ({ taskSettings }) => Boolean(taskSettings.error) && taskSettings.errorScope === 'autoApproveByDefault'; return ( -

+
@@ -120,7 +120,7 @@ export const CreateWorktreeByDefaultRow: React.FC = ({ taskSettings }) Boolean(taskSettings.error) && taskSettings.errorScope === 'createWorktreeByDefault'; return ( -
+

Create worktree by default

@@ -143,7 +143,7 @@ export const AutoTrustWorktreesRow: React.FC = ({ taskSettings }) => { const showError = Boolean(taskSettings.error) && taskSettings.errorScope === 'autoTrustWorktrees'; return ( -
+
diff --git a/src/renderer/components/TelemetryCard.tsx b/src/renderer/components/TelemetryCard.tsx index 9ed96f4a4..e42c8097c 100644 --- a/src/renderer/components/TelemetryCard.tsx +++ b/src/renderer/components/TelemetryCard.tsx @@ -8,7 +8,7 @@ const TelemetryCard: React.FC = () => { useTelemetryConsent(); return ( -
+

Privacy & Telemetry

diff --git a/src/renderer/components/TerminalSettingsCard.tsx b/src/renderer/components/TerminalSettingsCard.tsx index 511335ba7..03bb536e5 100644 --- a/src/renderer/components/TerminalSettingsCard.tsx +++ b/src/renderer/components/TerminalSettingsCard.tsx @@ -177,7 +177,7 @@ const TerminalSettingsCard: React.FC = () => { const hasAnyResults = filteredPopularOptions.length > 0 || filteredInstalledOptions.length > 0; return ( -
+

Terminal font

diff --git a/src/renderer/components/ThemeCard.tsx b/src/renderer/components/ThemeCard.tsx index a41da76f1..1d07aaa76 100644 --- a/src/renderer/components/ThemeCard.tsx +++ b/src/renderer/components/ThemeCard.tsx @@ -13,7 +13,7 @@ const ThemeCard: React.FC = () => { ]; return ( -
+
Color mode
Choose how Emdash looks.
diff --git a/src/renderer/components/WorkspaceProviderInfoCard.tsx b/src/renderer/components/WorkspaceProviderInfoCard.tsx index 4dd88b0e8..671b5f338 100644 --- a/src/renderer/components/WorkspaceProviderInfoCard.tsx +++ b/src/renderer/components/WorkspaceProviderInfoCard.tsx @@ -261,7 +261,10 @@ export const WorkspaceProviderInfoCard: React.FC = () => { return ( <> -
+

Workspace Provider

Run tasks on your own infrastructure. Configure provision and teardown scripts per-project diff --git a/src/renderer/components/integrations/registry.ts b/src/renderer/components/integrations/registry.ts new file mode 100644 index 000000000..1aa60a606 --- /dev/null +++ b/src/renderer/components/integrations/registry.ts @@ -0,0 +1,25 @@ +// Static metadata for all integrations rendered by IntegrationsCard. +// +// This is the source of truth for which integrations exist. Both the +// IntegrationsCard UI and the settings search index are validated against +// this list (see src/test/renderer/settingsSearchIndex.test.ts) so that +// adding a new integration here will fail tests until it is also added to +// SETTINGS_INDEX. + +export interface IntegrationRegistryEntry { + id: string; + name: string; + description: string; +} + +export const INTEGRATION_REGISTRY: readonly IntegrationRegistryEntry[] = [ + { id: 'github', name: 'GitHub', description: 'Connect your repositories' }, + { id: 'linear', name: 'Linear', description: 'Work on Linear tickets' }, + { id: 'jira', name: 'Jira', description: 'Work on Jira tickets' }, + { id: 'gitlab', name: 'GitLab', description: 'Work on GitLab issues' }, + { id: 'plain', name: 'Plain', description: 'Work on support threads' }, + { id: 'forgejo', name: 'Forgejo', description: 'Work on Forgejo issues' }, + { id: 'sentry', name: 'Sentry', description: 'Fix errors from Sentry' }, +] as const; + +export type IntegrationId = (typeof INTEGRATION_REGISTRY)[number]['id']; diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts new file mode 100644 index 000000000..de44d4f23 --- /dev/null +++ b/src/renderer/hooks/useSettingsSearch.ts @@ -0,0 +1,379 @@ +export interface SearchableSetting { + id: string; + label: string; + description: string; + aliases: string[]; + tabId: string; + /** DOM element id to scroll to when this result is selected. */ + elementId: string; +} + +export interface SearchResult { + setting: SearchableSetting; + score: number; +} + +// Hand-curated index of searchable settings. Order does not matter; results +// are ranked by score. When adding a new setting, prefer adding a few +// natural-language aliases over adding more weight tiers. +export const SETTINGS_INDEX: SearchableSetting[] = [ + // General Tab + { + id: 'telemetry', + label: 'Telemetry', + description: 'Help improve Emdash by sending anonymous usage data', + aliases: ['analytics', 'tracking', 'data collection', 'usage', 'metrics', 'monitoring'], + tabId: 'general', + elementId: 'telemetry-card', + }, + { + id: 'auto-generate-task-names', + label: 'Auto-generate task names', + description: 'Automatically generate task names from context', + aliases: ['naming', 'titles', 'automatic names', 'task titles'], + tabId: 'general', + elementId: 'auto-generate-task-names-row', + }, + { + id: 'auto-infer-task-names', + label: 'Auto-infer task names', + description: 'Infer task names from the first message', + aliases: ['detect names', 'smart naming', 'guess names'], + tabId: 'general', + elementId: 'auto-infer-task-names-row', + }, + { + id: 'auto-approve-by-default', + label: 'Auto-approve by default', + description: 'Automatically approve tool operations without asking', + aliases: ['permissions', 'tool approval', 'skip prompts', 'approve automatically'], + tabId: 'general', + elementId: 'auto-approve-by-default-row', + }, + { + id: 'create-worktree-by-default', + label: 'Create worktree by default', + description: 'Create a git worktree for each new task', + aliases: ['worktree', 'git worktree', 'branch isolation', 'isolated workspace'], + tabId: 'general', + elementId: 'create-worktree-by-default-row', + }, + { + id: 'auto-trust-worktrees', + label: 'Auto-trust worktrees', + description: 'Automatically trust repositories in new worktrees', + aliases: ['trust', 'repository trust', 'trusted repos'], + tabId: 'general', + elementId: 'auto-trust-worktrees-row', + }, + { + id: 'notifications-enabled', + label: 'Enable notifications', + description: 'Show notification messages', + aliases: ['alerts', 'banner', 'popups', 'notify'], + tabId: 'general', + elementId: 'notification-settings-card', + }, + { + id: 'notification-sound', + label: 'Notification sound', + description: 'Play a sound when notifications appear', + aliases: ['audio', 'beep', 'chime', 'ding', 'alert sound'], + tabId: 'general', + elementId: 'notification-settings-card', + }, + { + id: 'os-notifications', + label: 'OS notifications', + description: 'Show native operating system notifications', + aliases: ['native notifications', 'system notifications', 'desktop alerts'], + tabId: 'general', + elementId: 'notification-settings-card', + }, + { + id: 'sound-focus-mode', + label: 'Sound focus mode', + description: 'When to play notification sounds', + aliases: ['focus mode', 'quiet mode', 'do not disturb'], + tabId: 'general', + elementId: 'notification-settings-card', + }, + { + id: 'sound-profile', + label: 'Sound profile', + description: 'Choose the notification sound style', + aliases: ['audio theme', 'sound theme', 'notification tone'], + tabId: 'general', + elementId: 'notification-settings-card', + }, + + // Agents Tab + { + id: 'default-agent', + label: 'Default agent', + description: 'Choose the default AI agent for new tasks', + aliases: ['default model', 'preferred agent', 'ai provider', 'default llm'], + tabId: 'clis-models', + elementId: 'default-agent-settings-card', + }, + { + id: 'review-agent', + label: 'Review agent', + description: 'Enable code review with an AI agent', + aliases: ['code review', 'pr review', 'review bot', 'code analysis'], + tabId: 'clis-models', + elementId: 'review-agent-settings-card', + }, + { + id: 'review-prompt', + label: 'Review prompt', + description: 'Custom instructions for the review agent', + aliases: ['custom instructions', 'review instructions', 'review guidelines'], + tabId: 'clis-models', + elementId: 'review-agent-settings-card', + }, + { + id: 'cli-agents', + label: 'CLI agents', + description: 'Manage installed CLI agents and their status', + aliases: ['command line', 'installed agents', 'terminal agents', 'cli tools'], + tabId: 'clis-models', + elementId: 'cli-agents-section', + }, + + // Integrations Tab + { + id: 'integrations', + label: 'Integrations', + description: 'Connect external services like GitHub, Linear, Jira', + aliases: ['connections', 'external services', 'third party', 'apps'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'github', + label: 'GitHub', + description: 'Connect your GitHub repositories', + aliases: ['gh', 'git', 'repository', 'pull request', 'pr', 'octocat', 'source control'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'gitlab', + label: 'GitLab', + description: 'Work on GitLab issues', + aliases: ['gl', 'git', 'repository', 'merge request', 'mr', 'source control'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'forgejo', + label: 'Forgejo', + description: 'Work on Forgejo issues', + aliases: ['gitea', 'self-hosted', 'self hosted git', 'codeberg', 'repository'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'linear', + label: 'Linear', + description: 'Work on Linear tickets', + aliases: ['tickets', 'issues', 'project management', 'issue tracker'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'jira', + label: 'Jira', + description: 'Work on Jira tickets', + aliases: ['atlassian', 'tickets', 'issues', 'project management'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'plain', + label: 'Plain', + description: 'Work on support threads', + aliases: ['support', 'customer support', 'helpdesk', 'support inbox'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'sentry', + label: 'Sentry', + description: 'Fix errors from Sentry', + aliases: ['errors', 'exceptions', 'monitoring', 'error tracking', 'observability'], + tabId: 'integrations', + elementId: 'integrations-card', + }, + { + id: 'workspace-provider', + label: 'Workspace provider', + description: 'Configure workspace provisioning settings', + aliases: ['provisioning', 'cloud workspace', 'remote workspace'], + tabId: 'integrations', + elementId: 'workspace-provider-card', + }, + + // Repository Tab + { + id: 'branch-prefix', + label: 'Branch prefix', + description: 'Prefix for automatically generated branch names', + aliases: ['branch naming', 'git branches', 'naming convention'], + tabId: 'repository', + elementId: 'repository-settings-card', + }, + { + id: 'push-on-create', + label: 'Push on create', + description: 'Automatically push branches when creating a PR', + aliases: ['auto push', 'git push', 'auto publish'], + tabId: 'repository', + elementId: 'repository-settings-card', + }, + { + id: 'auto-close-issues', + label: 'Auto-close linked issues', + description: 'Close linked issues when PR is created', + aliases: ['close tickets', 'issue automation', 'auto resolve'], + tabId: 'repository', + elementId: 'repository-settings-card', + }, + + // Interface Tab + { + id: 'theme', + label: 'Theme', + description: 'Choose your preferred color theme', + aliases: ['color scheme', 'dark mode', 'light mode', 'appearance', 'colors', 'ui theme'], + tabId: 'interface', + elementId: 'theme-card', + }, + { + id: 'terminal-font-family', + label: 'Terminal font family', + description: 'Custom font for the integrated terminal', + aliases: ['terminal font', 'monospace', 'console font', 'typeface'], + tabId: 'interface', + elementId: 'terminal-settings-card', + }, + { + id: 'terminal-font-size', + label: 'Terminal font size', + description: 'Font size for the integrated terminal', + aliases: ['text size', 'terminal zoom', 'font scaling'], + tabId: 'interface', + elementId: 'terminal-settings-card', + }, + { + id: 'auto-copy-selection', + label: 'Auto-copy on selection', + description: 'Automatically copy text when selected in terminal', + aliases: ['clipboard', 'select to copy', 'terminal copy'], + tabId: 'interface', + elementId: 'terminal-settings-card', + }, + { + id: 'mac-option-is-meta', + label: 'Mac Option is Meta', + description: 'Use Option key as Meta key in terminal', + aliases: ['option key', 'meta key', 'alt key', 'modifier keys'], + tabId: 'interface', + elementId: 'terminal-settings-card', + }, + { + id: 'keyboard-shortcuts', + label: 'Keyboard shortcuts', + description: 'Customize keyboard shortcuts for common actions', + aliases: ['keybindings', 'hotkeys', 'shortcut keys', 'accelerators'], + tabId: 'interface', + elementId: 'keyboard-settings-card', + }, + { + id: 'auto-right-sidebar', + label: 'Auto right sidebar', + description: 'Automatically show/hide right sidebar based on context', + aliases: ['side panel', 'right panel', 'panel behavior'], + tabId: 'interface', + elementId: 'right-sidebar-settings-card', + }, + { + id: 'resource-monitor', + label: 'Resource monitor', + description: 'Show system resource usage in the sidebar', + aliases: ['cpu', 'memory', 'system stats', 'performance', 'usage stats'], + tabId: 'interface', + elementId: 'resource-monitor-settings-card', + }, + { + id: 'browser-preview', + label: 'Browser preview', + description: 'Enable built-in browser preview for web projects', + aliases: ['web preview', 'live preview', 'web view', 'live reload'], + tabId: 'interface', + elementId: 'browser-preview-settings-card', + }, + { + id: 'task-hover-action', + label: 'Task hover action', + description: 'Action to show when hovering over tasks', + aliases: ['hover behavior', 'task actions', 'mouse hover'], + tabId: 'interface', + elementId: 'task-hover-action-card', + }, + { + id: 'hidden-tools', + label: 'Hidden tools', + description: 'Manage which tools are hidden from the interface', + aliases: ['tool visibility', 'show tools', 'hide tools', 'tool preferences'], + tabId: 'interface', + elementId: 'hidden-tools-settings-card', + }, +]; + +// Score a single field. Higher is better; 0 means no match. +function scoreField(query: string, text: string): number { + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (t === q) return 1; + if (t.startsWith(q)) return 0.8; + if (t.includes(q)) return 0.5; + return 0; +} + +export function searchSettings(query: string): SearchResult[] { + const trimmed = query.trim(); + if (!trimmed) return []; + + const results: SearchResult[] = []; + + for (const setting of SETTINGS_INDEX) { + let score = 0; + score += scoreField(trimmed, setting.label) * 4; + score += scoreField(trimmed, setting.description) * 2; + for (const alias of setting.aliases) { + score += scoreField(trimmed, alias) * 1.5; + } + + if (score > 0) { + results.push({ setting, score }); + } + } + + return results.sort((a, b) => b.score - a.score); +} + +export function groupResultsByTab(results: SearchResult[]): Map { + const grouped = new Map(); + + for (const result of results) { + const tabId = result.setting.tabId; + if (!grouped.has(tabId)) { + grouped.set(tabId, []); + } + grouped.get(tabId)!.push(result); + } + + return grouped; +} diff --git a/src/test/renderer/settingsSearchIndex.test.ts b/src/test/renderer/settingsSearchIndex.test.ts new file mode 100644 index 000000000..98e808475 --- /dev/null +++ b/src/test/renderer/settingsSearchIndex.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { SETTINGS_INDEX } from '../../renderer/hooks/useSettingsSearch'; +import { INTEGRATION_REGISTRY } from '../../renderer/components/integrations/registry'; + +describe('settings search index', () => { + it('has an entry for every integration in the registry', () => { + const indexedIds = new Set(SETTINGS_INDEX.map((entry) => entry.id)); + const missing = INTEGRATION_REGISTRY.filter((entry) => !indexedIds.has(entry.id)).map( + (entry) => entry.id + ); + + expect( + missing, + `INTEGRATION_REGISTRY contains ids that are not present in SETTINGS_INDEX: ${missing.join( + ', ' + )}. Add an entry to SETTINGS_INDEX in src/renderer/hooks/useSettingsSearch.ts so the new integration is searchable.` + ).toEqual([]); + }); + + it('routes every integration entry to the integrations tab', () => { + const integrationIds = new Set(INTEGRATION_REGISTRY.map((entry) => entry.id)); + const misrouted = SETTINGS_INDEX.filter( + (entry) => integrationIds.has(entry.id) && entry.tabId !== 'integrations' + ); + + expect(misrouted).toEqual([]); + }); +});