diff --git a/src/main/utils/gitStatusParser.ts b/src/main/utils/gitStatusParser.ts index 9f060ccc9..b53b67601 100644 --- a/src/main/utils/gitStatusParser.ts +++ b/src/main/utils/gitStatusParser.ts @@ -17,6 +17,11 @@ function normalizeStatusCode(statusCode: string): string { return statusCode.padEnd(2, '.').slice(0, 2); } +function isStagedFromStatusCode(statusCode: string): boolean { + const indexStatus = normalizeStatusCode(statusCode)[0]; + return indexStatus !== '.' && indexStatus !== ' ' && indexStatus !== '?'; +} + function mapStatusCodeToStatus( statusCode: string, rawEntryType?: '1' | '2' | 'u' | '?' @@ -25,6 +30,7 @@ function mapStatusCodeToStatus( const [indexStatus, worktreeStatus] = normalizeStatusCode(statusCode); + // Renamed or copied if ( rawEntryType === '2' || indexStatus === 'R' || @@ -41,12 +47,6 @@ function mapStatusCodeToStatus( return 'modified'; } -function isStagedFromStatusCode(statusCode: string): boolean { - const normalized = normalizeStatusCode(statusCode); - const indexStatus = normalized[0]; - return indexStatus !== '.' && indexStatus !== ' ' && indexStatus !== '?'; -} - function parsePorcelainV1Line(line: string): ParsedGitStatusEntry | null { if (line.length < 3) return null; @@ -75,97 +75,93 @@ function parsePorcelainV1Line(line: string): ParsedGitStatusEntry | null { }; } -/** - * Parse `git status --porcelain=v2 -z` output. - * - * For fallback support, if the payload does not look like porcelain v2 records - * this parser falls back to porcelain v1 line parsing. - */ -export function parseGitStatusOutput(output: string): ParsedGitStatusEntry[] { - const tokens = output.split('\0').filter((token) => token.length > 0); - const entries: ParsedGitStatusEntry[] = []; +function parseV1Entries(output: string): ParsedGitStatusEntry[] { + return output + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0) + .map(parsePorcelainV1Line) + .filter((entry): entry is ParsedGitStatusEntry => entry !== null); +} - const looksLikePorcelainV2 = tokens.some((token) => /^(?:1|2|u|\?|!|#)\s/.test(token)); +function createStatusEntry( + path: string, + statusCode: string, + entryType: '1' | '2' | 'u' | '?', + oldPath?: string +): ParsedGitStatusEntry { + return { + path, + statusCode, + status: mapStatusCodeToStatus(statusCode, entryType), + isStaged: isStagedFromStatusCode(statusCode), + ...(oldPath ? { oldPath } : {}), + }; +} - if (!looksLikePorcelainV2) { - return output - .split('\n') - .map((line) => line.replace(/\r$/, '')) - .filter((line) => line.length > 0) - .map(parsePorcelainV1Line) - .filter((entry): entry is ParsedGitStatusEntry => entry !== null); - } +function parseV2Entries(tokens: string[]): ParsedGitStatusEntry[] { + const entries: ParsedGitStatusEntry[] = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const entryType = token[0]; - if (entryType === '#' || entryType === '!') { - continue; - } + // Skip headers and ignored entries + if (entryType === '#' || entryType === '!') continue; + // Untracked file if (entryType === '?') { - const path = token.slice(2); - entries.push({ - path, - statusCode: '??', - status: 'added', - isStaged: false, - }); + entries.push(createStatusEntry(token.slice(2), '??', '?')); continue; } + // Regular entry: 1 if (entryType === '1') { - // 1 const fields = token.split(' '); if (fields.length < 9) continue; - const statusCode = fields[1]; - const path = fields.slice(8).join(' '); - entries.push({ - path, - statusCode, - status: mapStatusCodeToStatus(statusCode, '1'), - isStaged: isStagedFromStatusCode(statusCode), - }); + entries.push(createStatusEntry(fields.slice(8).join(' '), fields[1], '1')); continue; } + // Renamed/copied entry: 2 \0 if (entryType === '2') { - // 2 \0 const fields = token.split(' '); if (fields.length < 10) continue; - const statusCode = fields[1]; - const path = fields.slice(9).join(' '); const oldPath = tokens[i + 1]; if (oldPath !== undefined) i += 1; - entries.push({ - path, - oldPath, - statusCode, - status: mapStatusCodeToStatus(statusCode, '2'), - isStaged: isStagedFromStatusCode(statusCode), - }); + entries.push(createStatusEntry(fields.slice(9).join(' '), fields[1], '2', oldPath)); continue; } + // Unmerged entry: u

if (entryType === 'u') { - // u

const fields = token.split(' '); if (fields.length < 11) continue; - const statusCode = fields[1]; - const path = fields.slice(10).join(' '); - entries.push({ - path, - statusCode, - status: mapStatusCodeToStatus(statusCode, 'u'), - isStaged: isStagedFromStatusCode(statusCode), - }); + entries.push(createStatusEntry(fields.slice(10).join(' '), fields[1], 'u')); } } return entries; } +/** + * Parse `git status --porcelain=v2 -z` output. + * + * For fallback support, if the payload does not look like porcelain v2 records + * this parser falls back to porcelain v1 line parsing. + */ +export function parseGitStatusOutput(output: string): ParsedGitStatusEntry[] { + const tokens = output.split('\0').filter((token) => token.length > 0); + + const looksLikePorcelainV2 = tokens.some((token) => /^(?:1|2|u|\?|!|#)\s/.test(token)); + + if (!looksLikePorcelainV2) { + return parseV1Entries(output); + } + + return parseV2Entries(tokens); +} + function resolveRenamedNumstatPath(filePath: string): string { if (!filePath.includes(' => ')) return filePath; @@ -184,9 +180,9 @@ function parseNumstatValue(value: string): number | null { return Number.isFinite(parsed) ? parsed : 0; } -function mergeNumstatValues(left: number | null | undefined, right: number | null): number | null { +function mergeNumstatValues(left: number | null, right: number | null): number | null { if (left === null || right === null) return null; - return (left ?? 0) + right; + return left + right; } /** @@ -208,19 +204,15 @@ export function parseNumstatOutput(stdout: string): Map { const parts = line.split('\t'); if (parts.length < 3) continue; - const additions = parseNumstatValue(parts[0]); - const deletions = parseNumstatValue(parts[1]); const filePath = resolveRenamedNumstatPath(parts.slice(2).join('\t')); const current = map.get(filePath); - if (!current) { - map.set(filePath, { additions, deletions }); - continue; - } + const additions = parseNumstatValue(parts[0]); + const deletions = parseNumstatValue(parts[1]); map.set(filePath, { - additions: mergeNumstatValues(current.additions, additions), - deletions: mergeNumstatValues(current.deletions, deletions), + additions: current ? mergeNumstatValues(current.additions, additions) : additions, + deletions: current ? mergeNumstatValues(current.deletions, deletions) : deletions, }); } 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 +377,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 && } @@ -345,34 +386,45 @@ export const SettingsPage: React.FC = ({ initialTab, onClose })} - {/* Content container */} - {currentContent && ( -
-
- {/* Page title */} -
-
-

{currentContent.title}

-

{currentContent.description}

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

{section.title}

- {section.action &&
{section.action}
} +
+
+ {trimmedSearchQuery ? ( + + ) : ( + currentContent && ( + <> + {/* Page title */} +
+
+

{currentContent.title}

+

+ {currentContent.description} +

+
+ +
+ + {/* 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..bb11b1342 --- /dev/null +++ b/src/renderer/components/SettingsSearchInput.tsx @@ -0,0 +1,56 @@ +import React, { forwardRef } from 'react'; +import { Search, X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +const isMac = typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform); + +interface SettingsSearchInputProps { + query: string; + onQueryChange: (query: string) => void; +} + +export const SettingsSearchInput = forwardRef( + function SettingsSearchInput({ query, onQueryChange }, ref) { + const handleClear = () => { + onQueryChange(''); + if (ref && typeof ref === 'object') { + ref.current?.focus(); + ref.current?.select(); + } + }; + + 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 + + )} +
+
+ ); + } +); + +export default SettingsSearchInput; diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx new file mode 100644 index 000000000..21016958e --- /dev/null +++ b/src/renderer/components/SettingsSearchResults.tsx @@ -0,0 +1,115 @@ +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): React.ReactNode { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return text; + + const queryLower = trimmedQuery.toLowerCase(); + const queryLength = trimmedQuery.length; + const textLower = text.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + for ( + let index = textLower.indexOf(queryLower); + index !== -1; + index = textLower.indexOf(queryLower, lastIndex) + ) { + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + parts.push( + + {text.slice(index, index + queryLength)} + + ); + lastIndex = index + queryLength; + } + + if (parts.length === 0) return text; + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; +} + +export function SettingsSearchResults({ + results, + query, + onResultClick, +}: SettingsSearchResultsProps) { + 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'} +

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

+ {TAB_LABELS[tabId] || 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..82b9203c7 --- /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 = [ + { 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 satisfies readonly IntegrationRegistryEntry[]; + +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..dc3c4678f --- /dev/null +++ b/src/renderer/hooks/useSettingsSearch.ts @@ -0,0 +1,383 @@ +import type { SettingsPageTab } from '@/components/SettingsPage'; + +export interface SearchableSetting { + id: string; + label: string; + description: string; + aliases: string[]; + tabId: SettingsPageTab; + /** 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 (clis-models) 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; + const bucket = grouped.get(tabId); + if (bucket) { + bucket.push(result); + } else { + grouped.set(tabId, [result]); + } + } + + return grouped; +} diff --git a/src/renderer/terminal/TerminalInputBuffer.ts b/src/renderer/terminal/TerminalInputBuffer.ts index 57ade7847..fb94e60d0 100644 --- a/src/renderer/terminal/TerminalInputBuffer.ts +++ b/src/renderer/terminal/TerminalInputBuffer.ts @@ -43,25 +43,42 @@ export class TerminalInputBuffer { this.onCapture = onCapture; } + private processInputCharacter(ch: string): void { + if (ch === '\r' || ch === '\n') { + // Enter pressed — snapshot the buffer as a pending message + if (this.buffer.trim()) { + this.pendingMessage = this.buffer.trim(); + } + this.buffer = ''; + return; + } + + if (ch === '\x7f' || ch === '\b') { + // Backspace + this.buffer = this.buffer.slice(0, -1); + return; + } + + if (ch === '\x15') { + // Ctrl+U — line kill: discard current input and any pending message + this.buffer = ''; + this.pendingMessage = null; + return; + } + + if (ch.charCodeAt(0) >= 32) { + // Printable character + this.buffer += ch; + } + } + /** Feed raw terminal input data (keystrokes). */ feed(data: string): void { if (this.captured) return; const clean = stripAnsi(data, { includePrivateCsiParams: true, stripOscSt: true }); for (const ch of clean) { - if (ch === '\r' || ch === '\n') { - // Enter pressed — snapshot the buffer as a pending message - if (this.buffer.trim()) { - this.pendingMessage = this.buffer.trim(); - } - this.buffer = ''; - } else if (ch === '\x7f' || ch === '\b') { - // Backspace - this.buffer = this.buffer.slice(0, -1); - } else if (ch.charCodeAt(0) >= 32) { - // Printable character - this.buffer += ch; - } + this.processInputCharacter(ch); } } @@ -70,19 +87,19 @@ export class TerminalInputBuffer { * If we have a pending message that passes validation, fire the callback. */ confirmSubmit(): void { - if (this.captured) return; - if (!this.pendingMessage) return; + if (this.captured || !this.pendingMessage) return; - if (isRealTaskInput(this.pendingMessage)) { - this.captured = true; - const message = this.pendingMessage; - this.pendingMessage = null; - this.buffer = ''; - this.onCapture(message); - } else { + if (!isRealTaskInput(this.pendingMessage)) { // Not a real task input — discard and keep listening this.pendingMessage = null; + return; } + + this.captured = true; + const message = this.pendingMessage; + this.pendingMessage = null; + this.buffer = ''; + this.onCapture(message); } /** Whether the buffer has already fired its callback. */ diff --git a/src/renderer/terminal/submitCapture.ts b/src/renderer/terminal/submitCapture.ts index 50a71b1b8..126cad67a 100644 --- a/src/renderer/terminal/submitCapture.ts +++ b/src/renderer/terminal/submitCapture.ts @@ -1,41 +1,49 @@ import { stripAnsi } from '@shared/text/stripAnsi'; -export function consumeSubmittedInputChunk(args: { +interface SubmittedInputState { currentInput: string; - data: string; - isNewlineInsert: boolean; -}): { currentInput: string; submittedText: string | null } { - const clean = stripAnsi(args.data, { includePrivateCsiParams: true, stripOscSt: true }); - let currentInput = args.currentInput; - let submittedText: string | null = null; - - for (const ch of clean) { - if (ch === '\r' || ch === '\n') { - if (args.isNewlineInsert) { - currentInput += '\n'; - continue; - } - if (submittedText === null) { - submittedText = currentInput.trim() || null; - } - currentInput = ''; - continue; - } + submittedText: string | null; +} - if (ch === '\x15') { - currentInput = ''; - continue; +function processCharacter( + state: SubmittedInputState, + ch: string, + isNewlineInsert: boolean +): SubmittedInputState { + if (ch === '\r' || ch === '\n') { + if (isNewlineInsert) { + return { ...state, currentInput: state.currentInput + '\n' }; } + return { + currentInput: '', + submittedText: state.submittedText ?? (state.currentInput.trim() || null), + }; + } - if (ch === '\x7f' || ch === '\b') { - currentInput = currentInput.slice(0, -1); - continue; - } + if (ch === '\x15') { + return { ...state, currentInput: '' }; + } - if (ch.charCodeAt(0) >= 32) { - currentInput += ch; - } + if (ch === '\x7f' || ch === '\b') { + return { ...state, currentInput: state.currentInput.slice(0, -1) }; } - return { currentInput, submittedText }; + if (ch.charCodeAt(0) >= 32) { + return { ...state, currentInput: state.currentInput + ch }; + } + + return state; +} + +export function consumeSubmittedInputChunk(args: { + currentInput: string; + data: string; + isNewlineInsert: boolean; +}): SubmittedInputState { + const clean = stripAnsi(args.data, { includePrivateCsiParams: true, stripOscSt: true }); + + return clean.split('').reduce((state, ch) => processCharacter(state, ch, args.isNewlineInsert), { + currentInput: args.currentInput, + submittedText: null, + } as SubmittedInputState); } diff --git a/src/renderer/terminal/writeDrainScheduler.ts b/src/renderer/terminal/writeDrainScheduler.ts index dacad51a4..54a0120c0 100644 --- a/src/renderer/terminal/writeDrainScheduler.ts +++ b/src/renderer/terminal/writeDrainScheduler.ts @@ -6,13 +6,21 @@ function getVisibilityState(): DocumentVisibilityState { } export function scheduleTerminalWriteDrain(run: () => void): () => void { - let finished = false; let frameId: number | null = null; let timeoutId: ReturnType | null = null; + let isCancelled = false; + let hasRun = false; - const cancelPending = () => { - if (frameId !== null && typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(frameId); + const canUseAnimationFrame = + getVisibilityState() === 'visible' && + typeof requestAnimationFrame === 'function' && + typeof cancelAnimationFrame === 'function'; + + const cleanup = () => { + if (frameId !== null) { + if (canUseAnimationFrame) { + cancelAnimationFrame(frameId); + } frameId = null; } if (timeoutId !== null) { @@ -21,34 +29,22 @@ export function scheduleTerminalWriteDrain(run: () => void): () => void { } }; - const finish = () => { - if (finished) return; - finished = true; - cancelPending(); + const execute = () => { + if (isCancelled || hasRun) return; + hasRun = true; + cleanup(); run(); }; - const canUseAnimationFrame = - getVisibilityState() === 'visible' && - typeof requestAnimationFrame === 'function' && - typeof cancelAnimationFrame === 'function'; - if (canUseAnimationFrame) { - frameId = requestAnimationFrame(() => { - finish(); - }); - timeoutId = setTimeout(() => { - finish(); - }, VISIBLE_DRAIN_FALLBACK_MS); + frameId = requestAnimationFrame(execute); + timeoutId = setTimeout(execute, VISIBLE_DRAIN_FALLBACK_MS); } else { - timeoutId = setTimeout(() => { - finish(); - }, 0); + timeoutId = setTimeout(execute, 0); } return () => { - if (finished) return; - finished = true; - cancelPending(); + isCancelled = true; + cleanup(); }; } diff --git a/src/test/renderer/settingsSearchIndex.test.ts b/src/test/renderer/settingsSearchIndex.test.ts new file mode 100644 index 000000000..89435cc56 --- /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([]); + }); +});