From 171a2052dfcf2c5946aa97c149cc65cfe2b78c36 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:17:07 -0700 Subject: [PATCH] fix: address review findings (#315) --- docs/settings-search-design.md | 126 +++++ .../components/settings/AppearancePane.tsx | 131 +++-- .../src/components/settings/GeneralPane.tsx | 353 +++++++----- .../settings/RepositoryHooksSection.tsx | 279 +++++----- .../components/settings/RepositoryPane.tsx | 124 ++++- .../components/settings/SearchableSetting.tsx | 23 + .../src/components/settings/Settings.tsx | 516 ++++++++++-------- .../components/settings/SettingsSection.tsx | 36 ++ .../components/settings/SettingsSidebar.tsx | 125 +++++ .../src/components/settings/ShortcutsPane.tsx | 268 ++++++--- .../src/components/settings/TerminalPane.tsx | 340 ++++++------ .../settings/TerminalThemeSections.tsx | 200 +++++++ .../src/components/settings/general-search.ts | 64 +++ .../components/settings/settings-search.ts | 25 + .../components/settings/terminal-search.ts | 93 ++++ src/renderer/src/store/slices/settings.ts | 4 + 16 files changed, 1911 insertions(+), 796 deletions(-) create mode 100644 docs/settings-search-design.md create mode 100644 src/renderer/src/components/settings/SearchableSetting.tsx create mode 100644 src/renderer/src/components/settings/SettingsSection.tsx create mode 100644 src/renderer/src/components/settings/SettingsSidebar.tsx create mode 100644 src/renderer/src/components/settings/TerminalThemeSections.tsx create mode 100644 src/renderer/src/components/settings/general-search.ts create mode 100644 src/renderer/src/components/settings/settings-search.ts create mode 100644 src/renderer/src/components/settings/terminal-search.ts diff --git a/docs/settings-search-design.md b/docs/settings-search-design.md new file mode 100644 index 000000000..3fb1644a7 --- /dev/null +++ b/docs/settings-search-design.md @@ -0,0 +1,126 @@ +# Settings Search — Design Document + +## Problem + +Orca's settings UI currently has no search capability. Users must manually browse through separate panes (General, Appearance, Terminal, Shortcuts, Repository) to find the setting they want. As the number of settings grows, this becomes increasingly painful. + +## Research & Alternatives + +### 1. The VS Code Approach (Heavyweight) + +VS Code uses a multi-provider architecture (TF-IDF, Embeddings, Local Search) with complex scoring, fuzzy matching, and metadata filtering. +_Pros_: Scales to thousands of settings. _Cons_: Massive over-engineering for an app with ~25 settings. + +### 2. The Flat Registry + Pane Auto-Navigation (Original Proposal) + +Maintain a separate JSON/JS array of all settings. When the user types, the UI auto-navigates to the first pane containing a match. +_Pros_: Simple substring search. _Cons_: **Jarring UX**. The entire screen changes underneath the user mid-keystroke as the "first match" shifts from the General pane to the Terminal pane. Also suffers from **Data Drift**—developers must remember to update a separate registry file when they rename a UI label. + +### 3. Single-Page Continuous Scroll + Component-Level Filtering (The Winner) + +Instead of distinct pages that replace each other, all settings are rendered in a single continuously scrolling list, grouped by section. The sidebar acts as a Table of Contents (anchor links). +Search is handled locally at the component level. If a setting doesn't match the query, it hides itself. If a section has no visible settings, the section header hides itself. +_Pros_: Silky smooth UX (no page jumping), native feel (like Discord, Linear, or macOS Settings), and zero data drift. + +## Decision + +We will implement **Alternative 3: Single-Page Continuous Scroll with Component-Level Filtering**. + +At our scale (~25 settings), this provides the absolute best user experience. It avoids jarring layout shifts during search, natively supports a global empty state, and keeps the codebase highly maintainable. + +## Design + +### 1. Layout Architecture (Single-Page) + +We will refactor the Settings layout from a "Router/Tab" model to a "ScrollSpy" model: + +- The right-hand content area renders ``, ``, ``, etc., all stacked vertically in a single `overflow-y-auto` container. +- The left-hand sidebar contains the Search Input at the top, and a list of anchor links below it. +- Clicking a sidebar link smoothly scrolls the right-hand container to that section. + +### 2. State Management + +We only need to track the search query in the Zustand store. + +```typescript +type SettingsSlice = { + settings: GlobalSettings | null + settingsSearchQuery: string // NEW + setSettingsSearchQuery: (q: string) => void // NEW + // ... existing methods +} +``` + +### 3. Component-Level Filtering (No separate registry) + +To prevent the search index from drifting away from the UI, search metadata is colocated with the UI component itself. + +We introduce a `` wrapper component. Every setting control in the UI is wrapped in this. + +```tsx +interface SearchableSettingProps { + title: string + description?: string + keywords?: string[] + children: React.ReactNode +} + +export function SearchableSetting({ + title, + description, + keywords, + children +}: SearchableSettingProps) { + const query = useSettingsStore((s) => s.settingsSearchQuery).toLowerCase() + + if (query) { + const matchesTitle = title.toLowerCase().includes(query) + const matchesDesc = description?.toLowerCase().includes(query) + const matchesKw = keywords?.some((k) => k.toLowerCase().includes(query)) + + if (!matchesTitle && !matchesDesc && !matchesKw) { + return null // Hide this setting if it doesn't match + } + } + + return ( +
+ {/* Title, description, and children (the actual input control) */} +
+ ) +} +``` + +### 4. Section Visibility & Empty States + +If all `` components inside `` return `null`, the Terminal pane will be empty. + +To handle this cleanly: + +- We can track section matches via a lightweight Context, OR +- Since React renders top-down, we can simply apply CSS: `div:empty { display: none }` or use a `useMemo` to check visibility of children arrays if data-driven. +- For the easiest React implementation: a `SettingsSection` wrapper that reads the query, knows its children's search metadata, and hides its own `

` header if no children match. + +**Global Empty State:** +If the overall `searchQuery` yields 0 matches across the entire settings page, we display a clear centered message in the main content area: +`No settings found for "{query}"` + +### 5. File Changes + +```text +src/renderer/src/components/settings/ + Settings.tsx — Add search input, change layout to stacked scroll + SearchableSetting.tsx — NEW: Wrapper component for filtering + SettingsSection.tsx — NEW: Wrapper for sections to hide headers + panes/ + GeneralPane.tsx — Wrap items in + AppearancePane.tsx — Wrap items in + TerminalPane.tsx — Wrap items in + ShortcutsPane.tsx — Wrap items in + RepositoryPane.tsx — Wrap items in +``` + +### 6. Workflow for Adding New Settings + +When a developer adds a new setting, they simply wrap it in ``. +Because the UI component _is_ the search index, it is impossible for the setting to exist in the UI but be missing from the search logic, guaranteeing long-term maintainability. diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 8bea294fb..5df3d70b3 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -2,6 +2,9 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' import { Separator } from '../ui/separator' import { UIZoomControl } from './UIZoomControl' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { useAppStore } from '../../store' type AppearancePaneProps = { settings: GlobalSettings @@ -9,66 +12,107 @@ type AppearancePaneProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void } +export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Theme', + description: 'Choose how Orca looks in the app window.', + keywords: ['dark', 'light', 'system'] + }, + { + title: 'UI Zoom', + description: 'Scale the entire application interface.', + keywords: ['zoom', 'scale', 'shortcut'] + }, + { + title: 'Open Right Sidebar by Default', + description: 'Automatically expand the file explorer panel when creating a new worktree.', + keywords: ['layout', 'file explorer', 'sidebar'] + } +] + export function AppearancePane({ settings, updateSettings, applyTheme }: AppearancePaneProps): React.JSX.Element { - return ( -
-
+ const searchQuery = useAppStore((state) => state.settingsSearchQuery) + const isMac = navigator.userAgent.includes('Mac') + const zoomInLabel = isMac ? '⌘+' : 'Ctrl +' + const zoomOutLabel = isMac ? '⌘-' : 'Ctrl -' + const themeEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(0, 1) + const zoomEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(1, 2) + const layoutEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(2) + + const visibleSections = [ + matchesSettingsSearch(searchQuery, themeEntries) ? ( +
-

Theme

+

Theme

Choose how Orca looks in the app window.

-
- {(['system', 'dark', 'light'] as const).map((option) => ( - - ))} -
+ +
+ {(['system', 'dark', 'light'] as const).map((option) => ( + + ))} +
+
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, zoomEntries) ? ( +
-

UI Zoom

+

UI Zoom

Scale the entire application interface. Use{' '} - ⌘+ /{' '} - ⌘- when not in a terminal - pane. + {zoomInLabel} /{' '} + {zoomOutLabel} when not in + a terminal pane.

- + + +
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, layoutEntries) ? ( +
-

Layout

+

Layout

Default layout when creating new worktrees.

-
+

@@ -93,8 +137,19 @@ export function AppearancePane({ }`} /> -

+
+ ) : null + ].filter(Boolean) + + return ( +
+ {visibleSections.map((section, index) => ( +
+ {index > 0 ? : null} + {section} +
+ ))}
) } diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 7cba63e71..b6e1c9884 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -13,6 +13,18 @@ import { MIN_EDITOR_AUTO_SAVE_DELAY_MS } from '../../../../shared/constants' import { clampNumber } from '@/lib/terminal-theme' +import { + GENERAL_BRANCH_SEARCH_ENTRIES, + GENERAL_CLI_SEARCH_ENTRIES, + GENERAL_EDITOR_SEARCH_ENTRIES, + GENERAL_PANE_SEARCH_ENTRIES, + GENERAL_UPDATE_SEARCH_ENTRIES, + GENERAL_WORKSPACE_SEARCH_ENTRIES +} from './general-search' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch } from './settings-search' + +export { GENERAL_PANE_SEARCH_ENTRIES } type GeneralPaneProps = { settings: GlobalSettings @@ -25,6 +37,7 @@ export function GeneralPane({ updateSettings, displayedGitUsername }: GeneralPaneProps): React.JSX.Element { + const searchQuery = useAppStore((s) => s.settingsSearchQuery) const updateStatus = useAppStore((s) => s.updateStatus) const [appVersion, setAppVersion] = useState(null) const [autoSaveDelayDraft, setAutoSaveDelayDraft] = useState( @@ -68,17 +81,22 @@ export function GeneralPane({ setAutoSaveDelayDraft(String(next)) } - return ( -
-
+ const visibleSections = [ + matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? ( +
-

Workspace

+

Workspace

Configure where new worktrees are created.

-
+
Root directory where worktree folders are created.

-
+
-
+

@@ -126,18 +149,22 @@ export function GeneralPane({ }`} /> -

+
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? ( +
-

Editor

+

Editor

Configure how Orca persists file edits.

-
+

@@ -162,9 +189,14 @@ export function GeneralPane({ }`} /> -

+
-
+

@@ -190,150 +222,179 @@ export function GeneralPane({ /> ms

-
+
- - - - - - - -
+ ) : null, + matchesSettingsSearch(searchQuery, GENERAL_CLI_SEARCH_ENTRIES) ? ( + + ) : null, + matchesSettingsSearch(searchQuery, GENERAL_BRANCH_SEARCH_ENTRIES) ? ( +
-

Branch Naming

+

Branch Naming

Prefix added to branch names when creating worktrees.

-
- {(['git-username', 'custom', 'none'] as const).map((option) => ( - - ))} -
- {(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && ( - updateSettings({ branchPrefixCustom: e.target.value })} - placeholder={ - settings.branchPrefix === 'git-username' - ? 'No git username configured' - : 'e.g. feature' - } - className="max-w-xs" - readOnly={settings.branchPrefix === 'git-username'} - /> - )} + +
+ {(['git-username', 'custom', 'none'] as const).map((option) => ( + + ))} +
+ {(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && ( + updateSettings({ branchPrefixCustom: e.target.value })} + placeholder={ + settings.branchPrefix === 'git-username' + ? 'No git username configured' + : 'e.g. feature' + } + className="max-w-xs" + readOnly={settings.branchPrefix === 'git-username'} + /> + )} +
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, GENERAL_UPDATE_SEARCH_ENTRIES) ? ( +
-

Updates

+

Updates

Current version: {appVersion ?? '…'}

-
- + + {updateStatus.state === 'available' ? ( + + ) : updateStatus.state === 'downloaded' ? ( + + ) : null} +
+ +

+ {updateStatus.state === 'idle' && 'Updates are checked automatically on launch.'} + {updateStatus.state === 'checking' && 'Checking for updates...'} + {updateStatus.state === 'available' && ( + <> + Version {updateStatus.version} is available.{' '} + {updateStatus.manualDownloadUrl + ? 'Open the download to install it manually.' + : 'Click "Install Update" to download it.'}{' '} + + Release notes + + )} - Check for Updates - - - {updateStatus.state === 'available' ? ( - - ) : updateStatus.state === 'downloaded' ? ( - - ) : null} -

- -

- {updateStatus.state === 'idle' && 'Updates are checked automatically on launch.'} - {updateStatus.state === 'checking' && 'Checking for updates...'} - {updateStatus.state === 'available' && ( - <> - Version {updateStatus.version} is available.{' '} - {updateStatus.manualDownloadUrl - ? 'Open the download to install it manually.' - : 'Click "Install Update" to download it.'}{' '} - - Release notes - - - )} - {updateStatus.state === 'not-available' && 'You\u2019re on the latest version.'} - {updateStatus.state === 'downloading' && - `Downloading v${updateStatus.version}... ${updateStatus.percent}%`} - {updateStatus.state === 'downloaded' && ( - <> - Version {updateStatus.version} is ready to install.{' '} - - Release notes - - - )} - {updateStatus.state === 'error' && `Update error: ${updateStatus.message}`} -

+ {updateStatus.state === 'not-available' && 'You\u2019re on the latest version.'} + {updateStatus.state === 'downloading' && + `Downloading v${updateStatus.version}... ${updateStatus.percent}%`} + {updateStatus.state === 'downloaded' && ( + <> + Version {updateStatus.version} is ready to install.{' '} + + Release notes + + + )} + {updateStatus.state === 'error' && `Update error: ${updateStatus.message}`} +

+
+ ) : null + ].filter(Boolean) + + return ( +
+ {visibleSections.map((section, index) => ( +
+ {index > 0 ? : null} + {section} +
+ ))}
) } diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index b5299dbd4..8793838e2 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -1,6 +1,7 @@ import type { OrcaHooks, Repo, SetupRunPolicy } from '../../../../shared/types' import { Button } from '../ui/button' import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants' +import { SearchableSetting } from './SearchableSetting' type RepositoryHooksSectionProps = { repo: Repo @@ -66,155 +67,173 @@ export function RepositoryHooksSection({

-
-
-

+

+

+ {yamlState === 'loaded' + ? 'Using `orca.yaml`' : yamlState === 'invalid' - ? 'text-amber-700 dark:text-amber-300' - : 'text-foreground' - }`} - > - {yamlState === 'loaded' - ? 'Using `orca.yaml`' - : yamlState === 'invalid' - ? '`orca.yaml` could not be parsed' - : 'No `orca.yaml` detected'} -

-

- {yamlState === 'loaded' - ? 'Hook commands are defined in the repo and shared with everyone who uses it.' - : yamlState === 'invalid' - ? 'The file exists, but Orca could not read valid setup or archive commands from it yet.' - : 'Add an `orca.yaml` file to enable setup or archive hooks for this repo. Example template:'} -

-
- - {yamlState === 'loaded' ? ( -
-
-
-                {renderYamlScriptPreview(yamlHooks)}
-              
-
+ ? '`orca.yaml` could not be parsed' + : 'No `orca.yaml` detected'} +

- Edit `orca.yaml` in the repository if you need to change these commands. + {yamlState === 'loaded' + ? 'Hook commands are defined in the repo and shared with everyone who uses it.' + : yamlState === 'invalid' + ? 'The file exists, but Orca could not read valid setup or archive commands from it yet.' + : 'Add an `orca.yaml` file to enable setup or archive hooks for this repo. Example template:'}

- ) : yamlState === 'invalid' ? ( -

- Fix the file format in `orca.yaml` to restore shared hook behavior. -

- ) : ( -
-

- Example `orca.yaml` template -

-
-
- -
-
-                {EXAMPLE_TEMPLATE}
-              
-
-
- )} -
- {legacyHookEntries.length > 0 ? ( -
-
-
-
- Legacy Repo-Local Hooks -
+ {yamlState === 'loaded' ? ( +
+
+
+                  {renderYamlScriptPreview(yamlHooks)}
+                
+

- These older commands still run as a fallback when `orca.yaml` does not provide a - hook. Clear them after you migrate the behavior into `orca.yaml`. + Edit `orca.yaml` in the repository if you need to change these commands.

- -
- - {legacyHookEntries.map(([hookName, script]) => ( -
-
-

{hookName}

- Compatibility fallback + ) : yamlState === 'invalid' ? ( +

+ Fix the file format in `orca.yaml` to restore shared hook behavior. +

+ ) : ( +
+

+ Example `orca.yaml` template +

+
+
+ +
+
+                  {EXAMPLE_TEMPLATE}
+                
-
-                {script}
-              
- ))} + )}
+ + + {legacyHookEntries.length > 0 ? ( + +
+
+
+
+ Legacy Repo-Local Hooks +
+

+ These older commands still run as a fallback when `orca.yaml` does not provide a + hook. Clear them after you migrate the behavior into `orca.yaml`. +

+
+ +
+ + {legacyHookEntries.map(([hookName, script]) => ( +
+
+

{hookName}

+ Compatibility fallback +
+
+                  {script}
+                
+
+ ))} +
+
) : null} -
-
-
When to Run Setup
-

- Choose the default behavior when a setup command is available. -

-
+ +
+
+
When to Run Setup
+

+ Choose the default behavior when a setup command is available. +

+
-
- {SETUP_RUN_POLICY_OPTIONS.map(({ policy, label, description }) => { - const selected = selectedSetupRunPolicy === policy +
+ {SETUP_RUN_POLICY_OPTIONS.map(({ policy, label, description }) => { + const selected = selectedSetupRunPolicy === policy - return ( - - ) - })} + + {label} + +

+ {description} +

+ + ) + })} +
-
+
) } diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index f34d78a44..4e491a4e7 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -9,6 +9,9 @@ import { Trash2 } from 'lucide-react' import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants' import { BaseRefPicker } from './BaseRefPicker' import { RepositoryHooksSection } from './RepositoryHooksSection' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { useAppStore } from '../../store' type RepositoryPaneProps = { repo: Repo @@ -18,6 +21,46 @@ type RepositoryPaneProps = { removeRepo: (repoId: string) => void } +export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[] { + return [ + { + title: 'Display Name', + description: 'Repo-specific display details for the sidebar and tabs.', + keywords: [repo.displayName, repo.path, 'repository name'] + }, + { + title: 'Badge Color', + description: 'Repo color used in the sidebar and tabs.', + keywords: [repo.displayName, 'color', 'badge'] + }, + { + title: 'Default Worktree Base', + description: 'Default base branch or ref when creating worktrees.', + keywords: [repo.displayName, 'base ref', 'branch'] + }, + { + title: 'Remove Repo', + description: 'Remove this repository from Orca.', + keywords: [repo.displayName, 'delete', 'repository'] + }, + { + title: 'orca.yaml hooks', + description: 'Shared setup and archive hook commands for this repository.', + keywords: [repo.displayName, 'hooks', 'setup', 'archive', 'yaml'] + }, + { + title: 'Legacy Repo-Local Hooks', + description: 'Older setup and archive hook scripts stored in local repo settings.', + keywords: [repo.displayName, 'legacy', 'fallback', 'hooks'] + }, + { + title: 'When to Run Setup', + description: 'Choose the default behavior when a setup command is available.', + keywords: [repo.displayName, 'setup run policy', 'ask', 'run by default', 'skip by default'] + } + ] +} + export function RepositoryPane({ repo, yamlHooks, @@ -25,6 +68,7 @@ export function RepositoryPane({ updateRepo, removeRepo }: RepositoryPaneProps): React.JSX.Element { + const searchQuery = useAppStore((state) => state.settingsSearchQuery) const [confirmingRemove, setConfirmingRemove] = useState(null) const [copiedTemplate, setCopiedTemplate] = useState(false) @@ -83,30 +127,45 @@ export function RepositoryPane({ }) } - return ( -
-
+ const allEntries = getRepositoryPaneSearchEntries(repo) + const identityEntries = allEntries.slice(0, 4) + const hooksEntries = allEntries.slice(4) + + const visibleSections = [ + matchesSettingsSearch(searchQuery, identityEntries) ? ( +
-

Identity

+

Identity

Repo-specific display details for the sidebar and tabs.

- + +
-
+ -
+ -
+
{REPO_COLORS.map((color) => ( @@ -136,9 +200,14 @@ export function RepositoryPane({ /> ))}
-
+ -
+ updateRepo(repo.id, { worktreeBaseRef: ref })} onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })} /> -
+
- - - + ) : null, + matchesSettingsSearch(searchQuery, hooksEntries) ? ( + ) : null + ].filter(Boolean) + + return ( +
+ {visibleSections.map((section, index) => ( +
+ {index > 0 ? : null} + {section} +
+ ))}
) } diff --git a/src/renderer/src/components/settings/SearchableSetting.tsx b/src/renderer/src/components/settings/SearchableSetting.tsx new file mode 100644 index 000000000..61c4b17bc --- /dev/null +++ b/src/renderer/src/components/settings/SearchableSetting.tsx @@ -0,0 +1,23 @@ +import type React from 'react' +import { useAppStore } from '../../store' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' + +type SearchableSettingProps = SettingsSearchEntry & { + children: React.ReactNode + className?: string +} + +export function SearchableSetting({ + title, + description, + keywords, + children, + className +}: SearchableSettingProps): React.JSX.Element | null { + const query = useAppStore((state) => state.settingsSearchQuery) + if (!matchesSettingsSearch(query, { title, description, keywords })) { + return null + } + + return
{children}
+} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 48624d065..1577f0dc5 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1,16 +1,38 @@ -import { useEffect, useState, useCallback, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Keyboard, Palette, SlidersHorizontal, SquareTerminal } from 'lucide-react' import type { OrcaHooks } from '../../../../shared/types' import { useAppStore } from '../../store' -import { ScrollArea } from '../ui/scroll-area' -import { Button } from '../ui/button' -import { ArrowLeft, Palette, SlidersHorizontal, SquareTerminal, Keyboard } from 'lucide-react' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' -import { GeneralPane } from './GeneralPane' -import { AppearancePane } from './AppearancePane' -import { ShortcutsPane } from './ShortcutsPane' -import { TerminalPane } from './TerminalPane' -import { RepositoryPane } from './RepositoryPane' +import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane' +import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane' +import { ShortcutsPane, SHORTCUTS_PANE_SEARCH_ENTRIES } from './ShortcutsPane' +import { TerminalPane, TERMINAL_PANE_SEARCH_ENTRIES } from './TerminalPane' +import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane' +import { SettingsSidebar } from './SettingsSidebar' +import { SettingsSection } from './SettingsSection' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' + +type SettingsNavTarget = 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'repo' + +type SettingsNavSection = { + id: string + title: string + description: string + icon: typeof SlidersHorizontal + searchEntries: SettingsSearchEntry[] +} + +function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string { + if (pane === 'repo' && repoId) { + return `repo-${repoId}` + } + return pane +} + +function getFallbackVisibleSection(sections: SettingsNavSection[]): SettingsNavSection | undefined { + return sections.at(0) +} function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) @@ -22,11 +44,9 @@ function Settings(): React.JSX.Element { const removeRepo = useAppStore((s) => s.removeRepo) const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget) const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget) + const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery) + const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) - const [selectedPane, setSelectedPane] = useState< - 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'repo' - >('general') - const [selectedRepoId, setSelectedRepoId] = useState(null) const [repoHooksMap, setRepoHooksMap] = useState< Record >({}) @@ -36,23 +56,36 @@ function Settings(): React.JSX.Element { const [terminalFontSuggestions, setTerminalFontSuggestions] = useState( getFallbackTerminalFonts() ) + const [activeSectionId, setActiveSectionId] = useState('general') + const contentScrollRef = useRef(null) const terminalFontsLoadedRef = useRef(false) + const pendingScrollTargetRef = useRef(null) useEffect(() => { fetchSettings() }, [fetchSettings]) + useEffect( + () => () => { + // Why: the settings search is a transient in-page filter. Leaving it behind makes the next + // visit look partially broken because whole sections stay hidden before the user types again. + setSettingsSearchQuery('') + }, + [setSettingsSearchQuery] + ) + useEffect(() => { if (!settingsNavigationTarget) { return } - // Why: the create-worktree dialog links here so setup configuration stays - // out of the dialog until the user explicitly asks to edit it. - setSelectedPane(settingsNavigationTarget.pane) - if (settingsNavigationTarget.repoId) { - setSelectedRepoId(settingsNavigationTarget.repoId) - } + // Why: settings entry points elsewhere in the app target a section, not a + // transient tab, so the scroll-based settings page needs an explicit anchor + // handoff to land the user on the intended configuration block. + pendingScrollTargetRef.current = getSettingsSectionId( + settingsNavigationTarget.pane, + settingsNavigationTarget.repoId + ) clearSettingsTarget() }, [clearSettingsTarget, settingsNavigationTarget]) @@ -67,7 +100,7 @@ function Settings(): React.JSX.Element { }, []) useEffect(() => { - if (selectedPane !== 'terminal' || terminalFontsLoadedRef.current) { + if (terminalFontsLoadedRef.current) { return } @@ -91,7 +124,7 @@ function Settings(): React.JSX.Element { return () => { stale = true } - }, [selectedPane]) + }, []) if (settings !== prevSettings) { setPrevSettings(settings) @@ -107,7 +140,8 @@ function Settings(): React.JSX.Element { useEffect(() => { let stale = false - const checkHooks = async () => { + + const checkHooks = async (): Promise => { const results = await Promise.all( repos.map(async (repo) => { try { @@ -125,7 +159,7 @@ function Settings(): React.JSX.Element { } if (repos.length > 0) { - checkHooks() + void checkHooks() } else { setRepoHooksMap({}) } @@ -135,18 +169,6 @@ function Settings(): React.JSX.Element { } }, [repos]) - // Validate selectedRepoId against current repos (adjusting state during render) - if (repos.length === 0) { - if (selectedRepoId !== null) { - setSelectedRepoId(null) - if (selectedPane === 'repo') { - setSelectedPane('general') - } - } - } else if (!selectedRepoId || !repos.some((repo) => repo.id === selectedRepoId)) { - setSelectedRepoId(repos[0].id) - } - const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => { const root = document.documentElement if (theme === 'dark') { @@ -163,15 +185,139 @@ function Settings(): React.JSX.Element { } }, []) - const selectedRepo = repos.find((repo) => repo.id === selectedRepoId) ?? null - const selectedRepoHooksState = selectedRepo ? repoHooksMap[selectedRepo.id] : undefined - const selectedYamlHooks = selectedRepoHooksState?.hooks ?? null - const showGeneralPane = selectedPane === 'general' - const showAppearancePane = selectedPane === 'appearance' - const showTerminalPane = selectedPane === 'terminal' - const showShortcutsPane = selectedPane === 'shortcuts' - const showRepoPane = selectedPane === 'repo' && !!selectedRepo - const displayedGitUsername = (selectedRepo ?? repos[0])?.gitUsername ?? '' + const displayedGitUsername = repos[0]?.gitUsername ?? '' + + const navSections = useMemo( + () => [ + { + id: 'general', + title: 'General', + description: 'Workspace, editor, naming, and updates.', + icon: SlidersHorizontal, + searchEntries: GENERAL_PANE_SEARCH_ENTRIES + }, + { + id: 'appearance', + title: 'Appearance', + description: 'Theme and UI scaling.', + icon: Palette, + searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES + }, + { + id: 'terminal', + title: 'Terminal', + description: 'Terminal appearance, previews, and defaults for new panes.', + icon: SquareTerminal, + searchEntries: TERMINAL_PANE_SEARCH_ENTRIES + }, + { + id: 'shortcuts', + title: 'Shortcuts', + description: 'Keyboard shortcuts for common actions.', + icon: Keyboard, + searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES + }, + ...repos.map((repo) => ({ + id: `repo-${repo.id}`, + title: repo.displayName, + description: repo.path, + icon: SlidersHorizontal, + searchEntries: getRepositoryPaneSearchEntries(repo) + })) + ], + [repos] + ) + + const visibleNavSections = useMemo( + () => + navSections.filter((section) => + matchesSettingsSearch(settingsSearchQuery, section.searchEntries) + ), + [navSections, settingsSearchQuery] + ) + + useEffect(() => { + const scrollTargetId = pendingScrollTargetRef.current + const visibleIds = new Set(visibleNavSections.map((section) => section.id)) + + if (scrollTargetId && visibleIds.has(scrollTargetId)) { + const target = document.getElementById(scrollTargetId) + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + setActiveSectionId(scrollTargetId) + pendingScrollTargetRef.current = null + return + } + + if (scrollTargetId && settingsSearchQuery.trim() !== '') { + // Why: keep the ref set so the *next* effect cycle (after the search clears and + // sections become visible) can scroll to the target via the branch above. + // The loop concern is mitigated because once the search clears, the target becomes + // visible, the branch above consumes and clears the ref, and the cycle stops. + setSettingsSearchQuery('') + return + } + + if (!visibleIds.has(activeSectionId) && visibleNavSections.length > 0) { + setActiveSectionId(getFallbackVisibleSection(visibleNavSections)?.id ?? activeSectionId) + } + }, [activeSectionId, setSettingsSearchQuery, settingsSearchQuery, visibleNavSections]) + + useEffect(() => { + const container = contentScrollRef.current + if (!container) { + return + } + + const updateActiveSection = (): void => { + const sections = Array.from( + container.querySelectorAll('[data-settings-section]') + ) + if (sections.length === 0) { + return + } + + const containerTop = container.getBoundingClientRect().top + const candidate = + sections.find((section) => section.getBoundingClientRect().top - containerTop >= -24) ?? + sections.at(-1) + if (!candidate) { + return + } + setActiveSectionId(candidate.dataset.settingsSection ?? candidate.id) + } + + // Why: the scroll handler runs querySelectorAll + getBoundingClientRect for every + // section on each scroll event (60+ fps). Wrapping it in a requestAnimationFrame + // throttle limits it to once per frame, avoiding layout-thrashing jank. + let rafId: number | null = null + const throttledUpdateActiveSection = (): void => { + if (rafId !== null) { + return + } + rafId = requestAnimationFrame(() => { + rafId = null + updateActiveSection() + }) + } + + updateActiveSection() + container.addEventListener('scroll', throttledUpdateActiveSection, { passive: true }) + return () => { + container.removeEventListener('scroll', throttledUpdateActiveSection) + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + } + }, [visibleNavSections]) + + const scrollToSection = useCallback((sectionId: string) => { + const target = document.getElementById(sectionId) + if (!target) { + return + } + target.scrollIntoView({ behavior: 'smooth', block: 'start' }) + setActiveSectionId(sectionId) + }, []) if (!settings) { return ( @@ -181,192 +327,122 @@ function Settings(): React.JSX.Element { ) } - const contentClassName = 'w-full max-w-5xl px-8' - const pageHeader = showGeneralPane ? ( -
-

General

-

Workspace, editor, naming, and updates.

-
- ) : showAppearancePane ? ( -
-

Appearance

-

Theme and UI scaling.

-
- ) : showTerminalPane ? ( -
-

Terminal

-

- Terminal appearance, previews, and defaults for new panes. -

-
- ) : showShortcutsPane ? ( -
-

Shortcuts

-

Keyboard shortcuts for common actions.

-
- ) : selectedRepo ? ( -
-
- -

{selectedRepo.displayName}

-
-

{selectedRepo.path}

-
- ) : ( -
-

Repository Settings

-

Select a repository to edit its settings.

-
- ) + const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-')) + const repoNavSections = visibleNavSections + .filter((section) => section.id.startsWith('repo-')) + .map((section) => { + const repo = repos.find((entry) => entry.id === section.id.replace('repo-', '')) + return { ...section, badgeColor: repo?.badgeColor } + }) return (
- + 0} + searchQuery={settingsSearchQuery} + onBack={() => setActiveView('terminal')} + onSearchChange={setSettingsSearchQuery} + onSelectSection={scrollToSection} + />
-
-
{pageHeader}
+
+
+

Settings

+

+ Search across every settings section without leaving the page. +

+
- -
- {showGeneralPane ? ( - - ) : showAppearancePane ? ( - - ) : showTerminalPane ? ( - - ) : showShortcutsPane ? ( - - ) : selectedRepo ? ( - - ) : ( -
- Select a repository to edit its settings. +
+
+ {visibleNavSections.length === 0 ? ( +
+ No settings found for "{settingsSearchQuery.trim()}"
+ ) : ( + <> + + + + + + + + + + + + + + + + + {repos.map((repo) => { + const repoSectionId = `repo-${repo.id}` + const repoHooksState = repoHooksMap[repo.id] + + return ( + + + + ) + })} + )}
- +
) diff --git a/src/renderer/src/components/settings/SettingsSection.tsx b/src/renderer/src/components/settings/SettingsSection.tsx new file mode 100644 index 000000000..ca85978aa --- /dev/null +++ b/src/renderer/src/components/settings/SettingsSection.tsx @@ -0,0 +1,36 @@ +import type React from 'react' +import { useAppStore } from '../../store' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' + +type SettingsSectionProps = { + id: string + title: string + description: string + searchEntries: SettingsSearchEntry[] + children: React.ReactNode + className?: string +} + +export function SettingsSection({ + id, + title, + description, + searchEntries, + children, + className +}: SettingsSectionProps): React.JSX.Element | null { + const query = useAppStore((state) => state.settingsSearchQuery) + if (!matchesSettingsSearch(query, searchEntries)) { + return null + } + + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx new file mode 100644 index 000000000..3f6399fa5 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -0,0 +1,125 @@ +import { ArrowLeft, Search, type LucideIcon, type LucideProps } from 'lucide-react' +import { Button } from '../ui/button' +import { Input } from '../ui/input' + +type NavSection = { + id: string + title: string + icon: LucideIcon | ((props: LucideProps) => React.JSX.Element) +} + +type RepoNavSection = NavSection & { + badgeColor?: string +} + +type SettingsSidebarProps = { + activeSectionId: string + generalSections: NavSection[] + repoSections: RepoNavSection[] + hasRepos: boolean + searchQuery: string + onBack: () => void + onSearchChange: (query: string) => void + onSelectSection: (sectionId: string) => void +} + +export function SettingsSidebar({ + activeSectionId, + generalSections, + repoSections, + hasRepos, + searchQuery, + onBack, + onSearchChange, + onSelectSection +}: SettingsSidebarProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index 5e11cf1fb..fffedc9fe 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -1,4 +1,7 @@ import React, { useMemo } from 'react' +import { useAppStore } from '../../store' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' type ShortcutItem = { action: string @@ -10,52 +13,179 @@ type ShortcutGroup = { items: ShortcutItem[] } +type ShortcutDefinition = { + action: string + searchKeywords: string[] + keys: (labels: { mod: string; shift: string; enter: string }) => string[] +} + +type ShortcutGroupDefinition = { + title: string + items: ShortcutDefinition[] +} + +const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [ + { + title: 'Global', + items: [ + { + action: 'Go to File', + searchKeywords: ['shortcut', 'global', 'file'], + keys: ({ mod }) => [mod, 'P'] + }, + { + action: 'Create worktree', + searchKeywords: ['shortcut', 'global', 'worktree'], + keys: ({ mod }) => [mod, 'N'] + }, + { + action: 'Toggle Sidebar', + searchKeywords: ['shortcut', 'sidebar'], + keys: ({ mod }) => [mod, 'B'] + }, + { + action: 'Move up worktree', + searchKeywords: ['shortcut', 'global', 'worktree', 'move'], + keys: ({ mod, shift }) => [mod, shift, '↑'] + }, + { + action: 'Move down worktree', + searchKeywords: ['shortcut', 'global', 'worktree', 'move'], + keys: ({ mod, shift }) => [mod, shift, '↓'] + }, + { + action: 'Toggle File Explorer', + searchKeywords: ['shortcut', 'file explorer'], + keys: ({ mod, shift }) => [mod, shift, 'E'] + }, + { + action: 'Toggle Search', + searchKeywords: ['shortcut', 'search'], + keys: ({ mod, shift }) => [mod, shift, 'F'] + }, + { + action: 'Toggle Source Control', + searchKeywords: ['shortcut', 'source control'], + keys: ({ mod, shift }) => [mod, shift, 'G'] + } + ] + }, + { + title: 'Terminal Tabs', + items: [ + { + action: 'New tab', + searchKeywords: ['shortcut', 'tab'], + keys: ({ mod }) => [mod, 'T'] + }, + { + action: 'Close active tab / pane', + searchKeywords: ['shortcut', 'close', 'tab', 'pane'], + keys: ({ mod }) => [mod, 'W'] + }, + { + action: 'Next tab', + searchKeywords: ['shortcut', 'tab', 'next'], + keys: ({ mod, shift }) => [mod, shift, ']'] + }, + { + action: 'Previous tab', + searchKeywords: ['shortcut', 'tab', 'previous'], + keys: ({ mod, shift }) => [mod, shift, '['] + } + ] + }, + { + title: 'Terminal Panes', + items: [ + { + action: 'Split pane right', + searchKeywords: ['shortcut', 'pane', 'split'], + keys: ({ mod }) => [mod, 'D'] + }, + { + action: 'Split pane down', + searchKeywords: ['shortcut', 'pane', 'split'], + keys: ({ mod, shift }) => [mod, shift, 'D'] + }, + { + action: 'Close pane (EOF)', + searchKeywords: ['shortcut', 'pane', 'close', 'eof'], + keys: () => ['Ctrl', 'D'] + }, + { + action: 'Focus next pane', + searchKeywords: ['shortcut', 'pane', 'focus', 'next'], + keys: ({ mod }) => [mod, ']'] + }, + { + action: 'Focus previous pane', + searchKeywords: ['shortcut', 'pane', 'focus', 'previous'], + keys: ({ mod }) => [mod, '['] + }, + { + action: 'Clear active pane', + searchKeywords: ['shortcut', 'pane', 'clear'], + keys: ({ mod }) => [mod, 'K'] + }, + { + action: 'Expand / collapse pane', + searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'], + keys: ({ mod, shift, enter }) => [mod, shift, enter] + } + ] + } +] + +// Why: search is supposed to stay in lockstep with the rendered shortcuts. Deriving +// both from one definition prevents the registry drift regression this branch introduced. +export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = + SHORTCUT_GROUP_DEFINITIONS.flatMap((group) => + group.items.map((item) => ({ + title: item.action, + description: `${group.title} shortcut`, + keywords: item.searchKeywords + })) + ) + export function ShortcutsPane(): React.JSX.Element { + const searchQuery = useAppStore((state) => state.settingsSearchQuery) const isMac = navigator.userAgent.includes('Mac') const mod = isMac ? '⌘' : 'Ctrl' const shift = isMac ? '⇧' : 'Shift' const enter = isMac ? '↵' : 'Enter' const groups = useMemo( - () => [ - { - title: 'Global', - items: [ - { action: 'Go to File', keys: [mod, 'P'] }, - { action: 'Create worktree', keys: [mod, 'N'] }, - { action: 'Toggle Sidebar', keys: [mod, 'B'] }, - { action: 'Move up worktree', keys: [mod, shift, '↑'] }, - { action: 'Move down worktree', keys: [mod, shift, '↓'] }, - { action: 'Toggle File Explorer', keys: [mod, shift, 'E'] }, - { action: 'Toggle Search', keys: [mod, shift, 'F'] }, - { action: 'Toggle Source Control', keys: [mod, shift, 'G'] } - ] - }, - { - title: 'Terminal Tabs', - items: [ - { action: 'New tab', keys: [mod, 'T'] }, - { action: 'Close active tab / pane', keys: [mod, 'W'] }, - { action: 'Next tab', keys: [mod, shift, ']'] }, - { action: 'Previous tab', keys: [mod, shift, '['] } - ] - }, - { - title: 'Terminal Panes', - items: [ - { action: 'Split pane right', keys: [mod, 'D'] }, - { action: 'Split pane down', keys: [mod, shift, 'D'] }, - { action: 'Close pane (EOF)', keys: ['Ctrl', 'D'] }, - { action: 'Focus next pane', keys: [mod, ']'] }, - { action: 'Focus previous pane', keys: [mod, '['] }, - { action: 'Clear active pane', keys: [mod, 'K'] }, - { action: 'Expand / collapse pane', keys: [mod, shift, enter] } - ] - } - ], + () => + SHORTCUT_GROUP_DEFINITIONS.map((group) => ({ + title: group.title, + items: group.items.map((item) => ({ + action: item.action, + keys: item.keys({ mod, shift, enter }) + })) + })), [mod, shift, enter] ) + // Why: keywords here must match the ones used by SHORTCUTS_PANE_SEARCH_ENTRIES + // (which uses searchKeywords from SHORTCUT_GROUP_DEFINITIONS). Using item.keys + // (rendered key labels like ['Cmd', 'P']) would cause a mismatch where sidebar-level + // search finds a shortcut but the inner SearchableSetting hides it. + const groupEntries = useMemo>( + () => + Object.fromEntries( + SHORTCUT_GROUP_DEFINITIONS.map((groupDef) => [ + groupDef.title, + groupDef.items.map((defItem) => ({ + title: defItem.action, + description: `${groupDef.title} shortcut`, + keywords: defItem.searchKeywords + })) + ]) + ), + [] + ) + return (
@@ -68,32 +198,48 @@ export function ShortcutsPane(): React.JSX.Element {
- {groups.map((group) => ( -
-

- {group.title} -

-
- {group.items.map((item, idx) => ( -
- {item.action} -
- {item.keys.map((key, kIdx) => ( - - - {key} - - {!isMac && kIdx < item.keys.length - 1 && ( - + - )} - - ))} -
-
- ))} + {groups + .filter((group) => matchesSettingsSearch(searchQuery, groupEntries[group.title] ?? [])) + .map((group) => ( +
+

+ {group.title} +

+
+ {group.items.map((item, idx) => { + // Why: look up the definition's searchKeywords so the inner + // SearchableSetting matches the same terms as the sidebar search. + const defGroup = SHORTCUT_GROUP_DEFINITIONS.find((g) => g.title === group.title) + const defItem = defGroup?.items.find((d) => d.action === item.action) + const keywords = defItem?.searchKeywords ?? item.keys + + return ( + + {item.action} +
+ {item.keys.map((key, kIdx) => ( + + + {key} + + {!isMac && kIdx < item.keys.length - 1 ? ( + + + ) : null} + + ))} +
+
+ ) + })} +
-
- ))} + ))}
diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index 57c0a7331..6d603ebe6 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -12,15 +12,29 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' -import { TerminalThemePreview } from './TerminalThemePreview' import { Minus, Plus } from 'lucide-react' import { clampNumber, resolveEffectiveTerminalAppearance, resolvePaneStyleOptions } from '@/lib/terminal-theme' -import { ThemePicker, ColorField, NumberField, FontAutocomplete } from './SettingsFormControls' +import { NumberField, FontAutocomplete } from './SettingsFormControls' import { SCROLLBACK_PRESETS_MB } from './SettingsConstants' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch } from './settings-search' +import { useAppStore } from '../../store' +import { + TERMINAL_ADVANCED_SEARCH_ENTRIES, + TERMINAL_CURSOR_SEARCH_ENTRIES, + TERMINAL_DARK_THEME_SEARCH_ENTRIES, + TERMINAL_LIGHT_THEME_SEARCH_ENTRIES, + TERMINAL_PANE_SEARCH_ENTRIES, + TERMINAL_PANE_STYLE_SEARCH_ENTRIES, + TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES +} from './terminal-search' +import { DarkTerminalThemeSection, LightTerminalThemeSection } from './TerminalThemeSections' + +export { TERMINAL_PANE_SEARCH_ENTRIES } type TerminalPaneProps = { settings: GlobalSettings @@ -39,6 +53,7 @@ export function TerminalPane({ scrollbackMode, setScrollbackMode }: TerminalPaneProps): React.JSX.Element { + const searchQuery = useAppStore((state) => state.settingsSearchQuery) const [themeSearchDark, setThemeSearchDark] = useState('') const [themeSearchLight, setThemeSearchLight] = useState('') @@ -58,17 +73,22 @@ export function TerminalPane({ const scrollbackToggleValue = scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom' - return ( -
-
+ const visibleSections = [ + matchesSettingsSearch(searchQuery, TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES) ? ( +
-

Typography

+

Typography

Default terminal typography for new panes and live updates.

-
+
-
+ -
+ updateSettings({ terminalFontFamily: value })} /> -
+ - - updateSettings({ - terminalFontWeight: normalizeTerminalFontWeight(value) - }) - } - /> + keywords={['terminal', 'typography', 'weight']} + > + + updateSettings({ + terminalFontWeight: normalizeTerminalFontWeight(value) + }) + } + /> +
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, TERMINAL_CURSOR_SEARCH_ENTRIES) ? ( +
-

Cursor

+

Cursor

Default cursor appearance for Orca terminal panes.

-
+
{(['bar', 'block', 'underline'] as const).map((option) => ( @@ -164,9 +199,14 @@ export function TerminalPane({ ))}
-
+ -
+

@@ -191,172 +231,103 @@ export function TerminalPane({ }`} /> -

+
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, TERMINAL_PANE_STYLE_SEARCH_ENTRIES) ? ( +
-

Pane Styling

+

Pane Styling

Control inactive pane dimming, divider thickness, and transition timing.

- - updateSettings({ - terminalInactivePaneOpacity: clampNumber(value, 0, 1) - }) - } - /> - - updateSettings({ - terminalDividerThicknessPx: clampNumber(value, 1, 32) - }) - } - /> -
-
- - - -
-
- updateSettings({ terminalThemeDark: theme })} - /> - - updateSettings({ terminalDividerColorDark: value })} - /> -
- - -
- - - -
-
-
- -

- When disabled, light mode reuses the dark terminal theme. -

-
- -
- -
-
-
-
- updateSettings({ terminalThemeLight: theme })} - /> - - updateSettings({ terminalDividerColorLight: value })} - /> -
- - -
-
+ + + + updateSettings({ + terminalDividerThicknessPx: clampNumber(value, 1, 32) + }) + } + /> +
- - - -
+ ) : null, + matchesSettingsSearch(searchQuery, TERMINAL_DARK_THEME_SEARCH_ENTRIES) ? ( + + ) : null, + matchesSettingsSearch(searchQuery, TERMINAL_LIGHT_THEME_SEARCH_ENTRIES) ? ( + + ) : null, + matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ? ( +
-

Advanced

+

Advanced

Scrollback is bounded for stability. This setting applies to new terminal panes.

-
+ ) : null} -
+
+ ) : null + ].filter(Boolean) + + return ( +
+ {visibleSections.map((section, index) => ( +
+ {index > 0 ? : null} + {section} +
+ ))}
) } diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx new file mode 100644 index 000000000..6e75e6a9e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -0,0 +1,200 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { GlobalSettings } from '../../../../shared/types' +import type { EffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { ColorField, ThemePicker } from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { TerminalThemePreview } from './TerminalThemePreview' + +type ThemePreviewProps = { + dividerThicknessPx: number + inactivePaneOpacity: number + activePaneOpacity: number +} + +type DarkTerminalThemeSectionProps = { + settings: GlobalSettings + systemPrefersDark: boolean + themeSearchDark: string + setThemeSearchDark: Dispatch> + updateSettings: (updates: Partial) => void + previewProps: ThemePreviewProps + darkPreviewAppearance: EffectiveTerminalAppearance +} + +type LightTerminalThemeSectionProps = { + settings: GlobalSettings + themeSearchLight: string + setThemeSearchLight: Dispatch> + updateSettings: (updates: Partial) => void + previewProps: ThemePreviewProps + lightPreviewAppearance: EffectiveTerminalAppearance +} + +export function DarkTerminalThemeSection({ + settings, + systemPrefersDark, + themeSearchDark, + setThemeSearchDark, + updateSettings, + previewProps, + darkPreviewAppearance +}: DarkTerminalThemeSectionProps): React.JSX.Element { + return ( +
+
+
+

Dark Theme

+

+ Choose the theme used for terminal panes in dark mode. +

+
+ + + updateSettings({ terminalThemeDark: theme })} + /> + + + + updateSettings({ terminalDividerColorDark: value })} + /> + +
+ + +
+ ) +} + +export function LightTerminalThemeSection({ + settings, + themeSearchLight, + setThemeSearchLight, + updateSettings, + previewProps, + lightPreviewAppearance +}: LightTerminalThemeSectionProps): React.JSX.Element { + return ( +
+ +
+

Use Separate Theme In Light Mode

+

+ When disabled, light mode reuses the dark terminal theme. +

+
+ +
+ +
+
+
+
+
+

Light Theme

+

+ Configure the optional light-mode terminal appearance. +

+
+ + + updateSettings({ terminalThemeLight: theme })} + /> + + + + updateSettings({ terminalDividerColorLight: value })} + /> + +
+ + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts new file mode 100644 index 000000000..fc17b4d5f --- /dev/null +++ b/src/renderer/src/components/settings/general-search.ts @@ -0,0 +1,64 @@ +import type { SettingsSearchEntry } from './settings-search' + +export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Workspace Directory', + description: 'Root directory where worktree folders are created.', + keywords: ['workspace', 'folder', 'path', 'worktree'] + }, + { + title: 'Nest Workspaces', + description: 'Create worktrees inside a repo-named subfolder.', + keywords: ['nested', 'subfolder', 'directory'] + } +] + +export const GENERAL_EDITOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Auto Save Files', + description: 'Save editor and editable diff changes automatically after a short pause.', + keywords: ['autosave', 'save'] + }, + { + title: 'Auto Save Delay', + description: 'How long Orca waits after your last edit before saving automatically.', + keywords: ['autosave', 'delay', 'milliseconds'] + } +] + +export const GENERAL_CLI_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Shell command', + description: 'Register or remove the orca shell command.', + keywords: ['cli', 'path', 'terminal', 'command'] + }, + { + title: 'Agent skill', + description: 'Install the Orca skill so agents know to use the orca CLI.', + keywords: ['skill', 'agents', 'npx'] + } +] + +export const GENERAL_BRANCH_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Branch Prefix', + description: 'Prefix added to branch names when creating worktrees.', + keywords: ['branch naming', 'git username', 'custom'] + } +] + +export const GENERAL_UPDATE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Check for Updates', + description: 'Check for app updates and install a newer Orca version.', + keywords: ['update', 'version', 'release notes', 'download'] + } +] + +export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + ...GENERAL_WORKSPACE_SEARCH_ENTRIES, + ...GENERAL_EDITOR_SEARCH_ENTRIES, + ...GENERAL_CLI_SEARCH_ENTRIES, + ...GENERAL_BRANCH_SEARCH_ENTRIES, + ...GENERAL_UPDATE_SEARCH_ENTRIES +] diff --git a/src/renderer/src/components/settings/settings-search.ts b/src/renderer/src/components/settings/settings-search.ts new file mode 100644 index 000000000..c72750bf5 --- /dev/null +++ b/src/renderer/src/components/settings/settings-search.ts @@ -0,0 +1,25 @@ +export type SettingsSearchEntry = { + title: string + description?: string + keywords?: string[] +} + +export function normalizeSettingsSearchQuery(query: string): string { + return query.trim().toLowerCase() +} + +export function matchesSettingsSearch( + query: string, + entries: SettingsSearchEntry | SettingsSearchEntry[] +): boolean { + const normalizedQuery = normalizeSettingsSearchQuery(query) + if (!normalizedQuery) { + return true + } + + const values = Array.isArray(entries) ? entries : [entries] + return values.some((entry) => { + const haystack = [entry.title, entry.description ?? '', ...(entry.keywords ?? [])] + return haystack.some((value) => value.toLowerCase().includes(normalizedQuery)) + }) +} diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts new file mode 100644 index 000000000..409854903 --- /dev/null +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -0,0 +1,93 @@ +import type { SettingsSearchEntry } from './settings-search' + +export const TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Font Size', + description: 'Default terminal font size for new panes and live updates.', + keywords: ['terminal', 'typography', 'text size'] + }, + { + title: 'Font Family', + description: 'Default terminal font family for new panes and live updates.', + keywords: ['terminal', 'typography', 'font'] + }, + { + title: 'Font Weight', + description: 'Controls the terminal text font weight.', + keywords: ['terminal', 'typography', 'weight'] + } +] + +export const TERMINAL_CURSOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Cursor Shape', + description: 'Default cursor appearance for Orca terminal panes.', + keywords: ['terminal', 'cursor', 'bar', 'block', 'underline'] + }, + { + title: 'Blinking Cursor', + description: 'Uses the blinking variant of the selected cursor shape.', + keywords: ['terminal', 'cursor', 'blink'] + } +] + +export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Inactive Pane Opacity', + description: 'Opacity applied to panes that are not currently active.', + keywords: ['pane', 'opacity', 'dimming'] + }, + { + title: 'Divider Thickness', + description: 'Thickness of the pane divider line.', + keywords: ['pane', 'divider', 'thickness'] + } +] + +export const TERMINAL_DARK_THEME_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Dark Theme', + description: 'Choose the terminal theme used in dark mode.', + keywords: ['terminal', 'theme', 'dark', 'preview'] + }, + { + title: 'Dark Divider Color', + description: 'Controls the split divider line between panes in dark mode.', + keywords: ['terminal', 'divider', 'dark', 'color'] + } +] + +export const TERMINAL_LIGHT_THEME_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Use Separate Theme In Light Mode', + description: 'When disabled, light mode reuses the dark terminal theme.', + keywords: ['terminal', 'light mode', 'theme'] + }, + { + title: 'Light Theme', + description: 'Choose the theme used when Orca is in light mode.', + keywords: ['terminal', 'theme', 'light', 'preview'] + }, + { + title: 'Light Divider Color', + description: 'Controls the split divider line between panes in light mode.', + keywords: ['terminal', 'divider', 'light', 'color'] + } +] + +export const TERMINAL_ADVANCED_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Scrollback Size', + description: 'Maximum terminal scrollback buffer size.', + keywords: ['terminal', 'scrollback', 'buffer', 'memory'] + } +] + +export const TERMINAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + ...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES, + ...TERMINAL_CURSOR_SEARCH_ENTRIES, + ...TERMINAL_PANE_STYLE_SEARCH_ENTRIES, + ...TERMINAL_DARK_THEME_SEARCH_ENTRIES, + ...TERMINAL_LIGHT_THEME_SEARCH_ENTRIES, + ...TERMINAL_ADVANCED_SEARCH_ENTRIES +] diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 31bdc33b5..7e01ca81b 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -4,12 +4,16 @@ import type { GlobalSettings } from '../../../../shared/types' export type SettingsSlice = { settings: GlobalSettings | null + settingsSearchQuery: string + setSettingsSearchQuery: (q: string) => void fetchSettings: () => Promise updateSettings: (updates: Partial) => Promise } export const createSettingsSlice: StateCreator = (set) => ({ settings: null, + settingsSearchQuery: '', + setSettingsSearchQuery: (q) => set({ settingsSearchQuery: q }), fetchSettings: async () => { try {