fix: address review findings (#315)

This commit is contained in:
Jinjing 2026-04-05 16:17:07 -07:00 committed by GitHub
parent 3ec7fcb05e
commit 171a2052df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1911 additions and 796 deletions

View File

@ -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 `<GeneralPane />`, `<AppearancePane />`, `<TerminalPane />`, 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 `<SearchableSetting>` 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 (
<div className="setting-row">
{/* Title, description, and children (the actual input control) */}
</div>
)
}
```
### 4. Section Visibility & Empty States
If all `<SearchableSetting>` components inside `<TerminalPane />` 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 `<h2>` 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 <SearchableSetting>
AppearancePane.tsx — Wrap items in <SearchableSetting>
TerminalPane.tsx — Wrap items in <SearchableSetting>
ShortcutsPane.tsx — Wrap items in <SearchableSetting>
RepositoryPane.tsx — Wrap items in <SearchableSetting>
```
### 6. Workflow for Adding New Settings
When a developer adds a new setting, they simply wrap it in `<SearchableSetting title="..." keywords={['...']}>`.
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.

View File

@ -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 (
<div className="space-y-8">
<section className="space-y-4">
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) ? (
<section key="theme" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Theme</h2>
<h3 className="text-sm font-semibold">Theme</h3>
<p className="text-xs text-muted-foreground">Choose how Orca looks in the app window.</p>
</div>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['system', 'dark', 'light'] as const).map((option) => (
<button
key={option}
onClick={() => {
updateSettings({ theme: option })
applyTheme(option)
}}
className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
settings.theme === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option}
</button>
))}
</div>
<SearchableSetting
title="Theme"
description="Choose how Orca looks in the app window."
keywords={['dark', 'light', 'system']}
>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['system', 'dark', 'light'] as const).map((option) => (
<button
key={option}
onClick={() => {
updateSettings({ theme: option })
applyTheme(option)
}}
className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
settings.theme === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option}
</button>
))}
</div>
</SearchableSetting>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, zoomEntries) ? (
<section key="zoom" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">UI Zoom</h2>
<h3 className="text-sm font-semibold">UI Zoom</h3>
<p className="text-xs text-muted-foreground">
Scale the entire application interface. Use{' '}
<kbd className="rounded border px-1 py-0.5 text-[10px]">+</kbd> /{' '}
<kbd className="rounded border px-1 py-0.5 text-[10px]">-</kbd> when not in a terminal
pane.
<kbd className="rounded border px-1 py-0.5 text-[10px]">{zoomInLabel}</kbd> /{' '}
<kbd className="rounded border px-1 py-0.5 text-[10px]">{zoomOutLabel}</kbd> when not in
a terminal pane.
</p>
</div>
<UIZoomControl />
<SearchableSetting
title="UI Zoom"
description="Scale the entire application interface."
keywords={['zoom', 'scale', 'shortcut']}
>
<UIZoomControl />
</SearchableSetting>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, layoutEntries) ? (
<section key="layout" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Layout</h2>
<h3 className="text-sm font-semibold">Layout</h3>
<p className="text-xs text-muted-foreground">
Default layout when creating new worktrees.
</p>
</div>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<SearchableSetting
title="Open Right Sidebar by Default"
description="Automatically expand the file explorer panel when creating a new worktree."
keywords={['layout', 'file explorer', 'sidebar']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Open Right Sidebar by Default</Label>
<p className="text-xs text-muted-foreground">
@ -93,8 +137,19 @@ export function AppearancePane({
}`}
/>
</button>
</div>
</SearchableSetting>
</section>
) : null
].filter(Boolean)
return (
<div className="space-y-8">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-8">
{index > 0 ? <Separator /> : null}
{section}
</div>
))}
</div>
)
}

View File

@ -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<string | null>(null)
const [autoSaveDelayDraft, setAutoSaveDelayDraft] = useState(
@ -68,17 +81,22 @@ export function GeneralPane({
setAutoSaveDelayDraft(String(next))
}
return (
<div className="space-y-8">
<section className="space-y-4">
const visibleSections = [
matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? (
<section key="workspace" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Workspace</h2>
<h3 className="text-sm font-semibold">Workspace</h3>
<p className="text-xs text-muted-foreground">
Configure where new worktrees are created.
</p>
</div>
<div className="space-y-2">
<SearchableSetting
title="Workspace Directory"
description="Root directory where worktree folders are created."
keywords={['workspace', 'folder', 'path', 'worktree']}
className="space-y-2"
>
<Label>Workspace Directory</Label>
<div className="flex gap-2">
<Input
@ -99,9 +117,14 @@ export function GeneralPane({
<p className="text-xs text-muted-foreground">
Root directory where worktree folders are created.
</p>
</div>
</SearchableSetting>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<SearchableSetting
title="Nest Workspaces"
description="Create worktrees inside a repo-named subfolder."
keywords={['nested', 'subfolder', 'directory']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Nest Workspaces</Label>
<p className="text-xs text-muted-foreground">
@ -126,18 +149,22 @@ export function GeneralPane({
}`}
/>
</button>
</div>
</SearchableSetting>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? (
<section key="editor" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Editor</h2>
<h3 className="text-sm font-semibold">Editor</h3>
<p className="text-xs text-muted-foreground">Configure how Orca persists file edits.</p>
</div>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<SearchableSetting
title="Auto Save Files"
description="Save editor and editable diff changes automatically after a short pause."
keywords={['autosave', 'save']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Auto Save Files</Label>
<p className="text-xs text-muted-foreground">
@ -162,9 +189,14 @@ export function GeneralPane({
}`}
/>
</button>
</div>
</SearchableSetting>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<SearchableSetting
title="Auto Save Delay"
description="How long Orca waits after your last edit before saving automatically."
keywords={['autosave', 'delay', 'milliseconds']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Auto Save Delay</Label>
<p className="text-xs text-muted-foreground">
@ -190,150 +222,179 @@ export function GeneralPane({
/>
<span className="text-xs text-muted-foreground">ms</span>
</div>
</div>
</SearchableSetting>
</section>
<Separator />
<CliSection currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'} />
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, GENERAL_CLI_SEARCH_ENTRIES) ? (
<CliSection
key="cli"
currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'}
/>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_BRANCH_SEARCH_ENTRIES) ? (
<section key="branch-prefix" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Branch Naming</h2>
<h3 className="text-sm font-semibold">Branch Naming</h3>
<p className="text-xs text-muted-foreground">
Prefix added to branch names when creating worktrees.
</p>
</div>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['git-username', 'custom', 'none'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ branchPrefix: option })}
className={`rounded-sm px-3 py-1 text-sm transition-colors ${
settings.branchPrefix === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option === 'git-username' ? 'Git Username' : option === 'custom' ? 'Custom' : 'None'}
</button>
))}
</div>
{(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && (
<Input
value={
settings.branchPrefix === 'git-username'
? displayedGitUsername
: settings.branchPrefixCustom
}
onChange={(e) => 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'}
/>
)}
<SearchableSetting
title="Branch Prefix"
description="Prefix added to branch names when creating worktrees."
keywords={['branch naming', 'git username', 'custom']}
className="space-y-3"
>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['git-username', 'custom', 'none'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ branchPrefix: option })}
className={`rounded-sm px-3 py-1 text-sm transition-colors ${
settings.branchPrefix === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option === 'git-username'
? 'Git Username'
: option === 'custom'
? 'Custom'
: 'None'}
</button>
))}
</div>
{(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && (
<Input
value={
settings.branchPrefix === 'git-username'
? displayedGitUsername
: settings.branchPrefixCustom
}
onChange={(e) => 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'}
/>
)}
</SearchableSetting>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, GENERAL_UPDATE_SEARCH_ENTRIES) ? (
<section key="updates" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Updates</h2>
<h3 className="text-sm font-semibold">Updates</h3>
<p className="text-xs text-muted-foreground">Current version: {appVersion ?? '…'}</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => window.api.updater.check()}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
<SearchableSetting
title="Check for Updates"
description="Check for app updates and install a newer Orca version."
keywords={['update', 'version', 'release notes', 'download']}
className="space-y-3"
>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => window.api.updater.check()}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Check for Updates
</Button>
{updateStatus.state === 'available' ? (
<Button
variant="default"
size="sm"
onClick={() => window.api.updater.download()}
className="gap-2"
>
<Download className="size-3.5" />
{updateStatus.manualDownloadUrl
? `Download Update (${updateStatus.version})`
: `Install Update (${updateStatus.version})`}
</Button>
) : updateStatus.state === 'downloaded' ? (
<Button
variant="default"
size="sm"
onClick={() => window.api.updater.quitAndInstall()}
className="gap-2"
>
<Download className="size-3.5" />
Restart to Update ({updateStatus.version})
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{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.'}{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Release notes
</a>
</>
)}
Check for Updates
</Button>
{updateStatus.state === 'available' ? (
<Button
variant="default"
size="sm"
onClick={() => window.api.updater.download()}
className="gap-2"
>
<Download className="size-3.5" />
{updateStatus.manualDownloadUrl
? `Download Update (${updateStatus.version})`
: `Install Update (${updateStatus.version})`}
</Button>
) : updateStatus.state === 'downloaded' ? (
<Button
variant="default"
size="sm"
onClick={() => window.api.updater.quitAndInstall()}
className="gap-2"
>
<Download className="size-3.5" />
Restart to Update ({updateStatus.version})
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{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.'}{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Release notes
</a>
</>
)}
{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.{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Release notes
</a>
</>
)}
{updateStatus.state === 'error' && `Update error: ${updateStatus.message}`}
</p>
{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.{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Release notes
</a>
</>
)}
{updateStatus.state === 'error' && `Update error: ${updateStatus.message}`}
</p>
</SearchableSetting>
</section>
) : null
].filter(Boolean)
return (
<div className="space-y-8">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-8">
{index > 0 ? <Separator /> : null}
{section}
</div>
))}
</div>
)
}

View File

@ -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({
</p>
</div>
<div
className={`space-y-3 rounded-xl border p-4 ${
yamlState === 'loaded'
? 'border-emerald-500/20 bg-emerald-500/5'
: yamlState === 'invalid'
? 'border-amber-500/20 bg-amber-500/5'
: 'border-border/50 bg-muted/20'
}`}
<SearchableSetting
title="orca.yaml hooks"
description="Shared setup and archive hook commands for this repository."
keywords={['hooks', 'setup', 'archive', 'yaml']}
>
<div className="space-y-1">
<p
className={`text-sm font-medium ${
yamlState === 'loaded'
? 'text-emerald-700 dark:text-emerald-300'
<div
className={`space-y-3 rounded-xl border p-4 ${
yamlState === 'loaded'
? 'border-emerald-500/20 bg-emerald-500/5'
: yamlState === 'invalid'
? 'border-amber-500/20 bg-amber-500/5'
: 'border-border/50 bg-muted/20'
}`}
>
<div className="space-y-1">
<p
className={`text-sm font-medium ${
yamlState === 'loaded'
? 'text-emerald-700 dark:text-emerald-300'
: yamlState === 'invalid'
? 'text-amber-700 dark:text-amber-300'
: 'text-foreground'
}`}
>
{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'}
</p>
<p className="text-xs text-muted-foreground">
{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:'}
</p>
</div>
{yamlState === 'loaded' ? (
<div className="space-y-2">
<div className="rounded-lg border border-border/50 bg-background/70">
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-5 text-foreground">
{renderYamlScriptPreview(yamlHooks)}
</pre>
</div>
? '`orca.yaml` could not be parsed'
: 'No `orca.yaml` detected'}
</p>
<p className="text-xs text-muted-foreground">
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:'}
</p>
</div>
) : yamlState === 'invalid' ? (
<p className="text-[10px] text-muted-foreground">
Fix the file format in `orca.yaml` to restore shared hook behavior.
</p>
) : (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Example `orca.yaml` template
</p>
<div className="rounded-lg border border-border/50 bg-background/70">
<div className="flex items-center justify-end border-b border-border/40 px-2 py-1.5">
<Button
type="button"
variant={copiedTemplate ? 'secondary' : 'ghost'}
size="sm"
className={`h-6 px-2 text-[11px] ${
copiedTemplate
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={onCopyTemplate}
>
{copiedTemplate ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-5 text-muted-foreground">
{EXAMPLE_TEMPLATE}
</pre>
</div>
</div>
)}
</div>
{legacyHookEntries.length > 0 ? (
<div className="space-y-4 rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h5 className="text-sm font-semibold text-amber-700 dark:text-amber-300">
Legacy Repo-Local Hooks
</h5>
{yamlState === 'loaded' ? (
<div className="space-y-2">
<div className="rounded-lg border border-border/50 bg-background/70">
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-5 text-foreground">
{renderYamlScriptPreview(yamlHooks)}
</pre>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onClearLegacyHooks}>
Clear Legacy Hooks
</Button>
</div>
{legacyHookEntries.map(([hookName, script]) => (
<div
key={hookName}
className="space-y-2 rounded-xl border border-amber-500/20 bg-background/70 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium capitalize text-foreground">{hookName}</p>
<span className="text-[10px] text-muted-foreground">Compatibility fallback</span>
) : yamlState === 'invalid' ? (
<p className="text-[10px] text-muted-foreground">
Fix the file format in `orca.yaml` to restore shared hook behavior.
</p>
) : (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Example `orca.yaml` template
</p>
<div className="rounded-lg border border-border/50 bg-background/70">
<div className="flex items-center justify-end border-b border-border/40 px-2 py-1.5">
<Button
type="button"
variant={copiedTemplate ? 'secondary' : 'ghost'}
size="sm"
className={`h-6 px-2 text-[11px] ${
copiedTemplate
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={onCopyTemplate}
>
{copiedTemplate ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-5 text-muted-foreground">
{EXAMPLE_TEMPLATE}
</pre>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background p-3 font-mono text-[11px] leading-5 text-foreground">
{script}
</pre>
</div>
))}
)}
</div>
</SearchableSetting>
{legacyHookEntries.length > 0 ? (
<SearchableSetting
title="Legacy Repo-Local Hooks"
description="Older setup and archive hook scripts stored in local repo settings."
keywords={['legacy', 'fallback', 'setup', 'archive']}
>
<div className="space-y-4 rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h5 className="text-sm font-semibold text-amber-700 dark:text-amber-300">
Legacy Repo-Local Hooks
</h5>
<p className="text-xs text-muted-foreground">
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`.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onClearLegacyHooks}>
Clear Legacy Hooks
</Button>
</div>
{legacyHookEntries.map(([hookName, script]) => (
<div
key={hookName}
className="space-y-2 rounded-xl border border-amber-500/20 bg-background/70 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium capitalize text-foreground">{hookName}</p>
<span className="text-[10px] text-muted-foreground">Compatibility fallback</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background p-3 font-mono text-[11px] leading-5 text-foreground">
{script}
</pre>
</div>
))}
</div>
</SearchableSetting>
) : null}
<div className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="space-y-1">
<h5 className="text-sm font-semibold">When to Run Setup</h5>
<p className="text-xs text-muted-foreground">
Choose the default behavior when a setup command is available.
</p>
</div>
<SearchableSetting
title="When to Run Setup"
description="Choose the default behavior when a setup command is available."
keywords={['setup run policy', 'ask', 'run by default', 'skip by default']}
>
<div className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="space-y-1">
<h5 className="text-sm font-semibold">When to Run Setup</h5>
<p className="text-xs text-muted-foreground">
Choose the default behavior when a setup command is available.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
{SETUP_RUN_POLICY_OPTIONS.map(({ policy, label, description }) => {
const selected = selectedSetupRunPolicy === policy
<div className="grid gap-2 md:grid-cols-3">
{SETUP_RUN_POLICY_OPTIONS.map(({ policy, label, description }) => {
const selected = selectedSetupRunPolicy === policy
return (
<button
key={policy}
onClick={() => onUpdateSetupRunPolicy(policy)}
className={`rounded-xl border px-3 py-2.5 text-center transition-colors ${
selected
? 'border-foreground/15 bg-accent text-accent-foreground'
: 'border-border/60 bg-background text-foreground hover:border-border hover:bg-muted/40'
}`}
>
<span className={`block text-sm ${selected ? 'font-semibold' : 'font-medium'}`}>
{label}
</span>
<p
className={`mt-1 text-[11px] leading-4 ${
selected ? 'text-accent-foreground/80' : 'text-muted-foreground'
return (
<button
key={policy}
onClick={() => onUpdateSetupRunPolicy(policy)}
className={`rounded-xl border px-3 py-2.5 text-center transition-colors ${
selected
? 'border-foreground/15 bg-accent text-accent-foreground'
: 'border-border/60 bg-background text-foreground hover:border-border hover:bg-muted/40'
}`}
>
{description}
</p>
</button>
)
})}
<span className={`block text-sm ${selected ? 'font-semibold' : 'font-medium'}`}>
{label}
</span>
<p
className={`mt-1 text-[11px] leading-4 ${
selected ? 'text-accent-foreground/80' : 'text-muted-foreground'
}`}
>
{description}
</p>
</button>
)
})}
</div>
</div>
</div>
</SearchableSetting>
</section>
)
}

View File

@ -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<string | null>(null)
const [copiedTemplate, setCopiedTemplate] = useState(false)
@ -83,30 +127,45 @@ export function RepositoryPane({
})
}
return (
<div className="space-y-8">
<section className="space-y-6">
const allEntries = getRepositoryPaneSearchEntries(repo)
const identityEntries = allEntries.slice(0, 4)
const hooksEntries = allEntries.slice(4)
const visibleSections = [
matchesSettingsSearch(searchQuery, identityEntries) ? (
<section key="identity" className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Identity</h2>
<h3 className="text-sm font-semibold">Identity</h3>
<p className="text-xs text-muted-foreground">
Repo-specific display details for the sidebar and tabs.
</p>
</div>
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleRemoveRepo(repo.id)}
onBlur={() => setConfirmingRemove(null)}
className="gap-2"
<SearchableSetting
title="Remove Repo"
description="Remove this repository from Orca."
keywords={[repo.displayName, 'delete', 'repository']}
>
<Trash2 className="size-3.5" />
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Repo'}
</Button>
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleRemoveRepo(repo.id)}
onBlur={() => setConfirmingRemove(null)}
className="gap-2"
>
<Trash2 className="size-3.5" />
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Repo'}
</Button>
</SearchableSetting>
</div>
<div className="space-y-2">
<SearchableSetting
title="Display Name"
description="Repo-specific display details for the sidebar and tabs."
keywords={[repo.displayName, repo.path, 'repository name']}
className="space-y-2"
>
<Label>Display Name</Label>
<Input
value={repo.displayName}
@ -117,9 +176,14 @@ export function RepositoryPane({
}
className="h-9 text-sm"
/>
</div>
</SearchableSetting>
<div className="space-y-2">
<SearchableSetting
title="Badge Color"
description="Repo color used in the sidebar and tabs."
keywords={[repo.displayName, 'color', 'badge']}
className="space-y-2"
>
<Label>Badge Color</Label>
<div className="flex flex-wrap gap-2">
{REPO_COLORS.map((color) => (
@ -136,9 +200,14 @@ export function RepositoryPane({
/>
))}
</div>
</div>
</SearchableSetting>
<div className="space-y-3">
<SearchableSetting
title="Default Worktree Base"
description="Default base branch or ref when creating worktrees."
keywords={[repo.displayName, 'base ref', 'branch']}
className="space-y-3"
>
<Label>Default Worktree Base</Label>
<BaseRefPicker
repoId={repo.id}
@ -146,12 +215,12 @@ export function RepositoryPane({
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
/>
</div>
</SearchableSetting>
</section>
<Separator />
) : null,
matchesSettingsSearch(searchQuery, hooksEntries) ? (
<RepositoryHooksSection
key="hooks"
repo={repo}
yamlHooks={yamlHooks}
hasHooksFile={hasHooksFile}
@ -162,6 +231,17 @@ export function RepositoryPane({
updateSelectedRepoHookSettings({ setupRunPolicy: policy as SetupRunPolicy })
}
/>
) : null
].filter(Boolean)
return (
<div className="space-y-8">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-8">
{index > 0 ? <Separator /> : null}
{section}
</div>
))}
</div>
)
}

View File

@ -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 <div className={className}>{children}</div>
}

View File

@ -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<string | null>(null)
const [repoHooksMap, setRepoHooksMap] = useState<
Record<string, { hasHooks: boolean; hooks: OrcaHooks | null }>
>({})
@ -36,23 +56,36 @@ function Settings(): React.JSX.Element {
const [terminalFontSuggestions, setTerminalFontSuggestions] = useState<string[]>(
getFallbackTerminalFonts()
)
const [activeSectionId, setActiveSectionId] = useState('general')
const contentScrollRef = useRef<HTMLDivElement | null>(null)
const terminalFontsLoadedRef = useRef(false)
const pendingScrollTargetRef = useRef<string | null>(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<void> => {
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<SettingsNavSection[]>(
() => [
{
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<HTMLElement>('[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 ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">General</h1>
<p className="text-sm text-muted-foreground">Workspace, editor, naming, and updates.</p>
</div>
) : showAppearancePane ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Appearance</h1>
<p className="text-sm text-muted-foreground">Theme and UI scaling.</p>
</div>
) : showTerminalPane ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Terminal</h1>
<p className="text-sm text-muted-foreground">
Terminal appearance, previews, and defaults for new panes.
</p>
</div>
) : showShortcutsPane ? (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Shortcuts</h1>
<p className="text-sm text-muted-foreground">Keyboard shortcuts for common actions.</p>
</div>
) : selectedRepo ? (
<div className="space-y-1">
<div className="flex items-center gap-3">
<span
className="size-3 rounded-full"
style={{ backgroundColor: selectedRepo.badgeColor }}
/>
<h1 className="text-2xl font-semibold">{selectedRepo.displayName}</h1>
</div>
<p className="text-xs text-muted-foreground">{selectedRepo.path}</p>
</div>
) : (
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Repository Settings</h1>
<p className="text-sm text-muted-foreground">Select a repository to edit its settings.</p>
</div>
)
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 (
<div className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background">
<aside className="flex w-[260px] shrink-0 flex-col border-r border-border/50 bg-card/40">
<div className="border-b border-border/50 px-3 py-3">
<Button
variant="ghost"
size="sm"
onClick={() => setActiveView('terminal')}
className="w-full justify-start gap-2 text-muted-foreground"
>
<ArrowLeft className="size-4" />
Back to app
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 px-3 py-4">
<div className="space-y-1">
<button
onClick={() => setSelectedPane('general')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showGeneralPane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<SlidersHorizontal className="mr-2 size-4" />
General
</button>
<button
onClick={() => setSelectedPane('appearance')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showAppearancePane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Palette className="mr-2 size-4" />
Appearance
</button>
<button
onClick={() => setSelectedPane('terminal')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showTerminalPane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<SquareTerminal className="mr-2 size-4" />
Terminal
</button>
<button
onClick={() => setSelectedPane('shortcuts')}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showShortcutsPane
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Keyboard className="mr-2 size-4" />
Shortcuts
</button>
</div>
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Repositories
</p>
{repos.length === 0 ? (
<p className="px-3 text-xs text-muted-foreground">No repositories added yet.</p>
) : (
<div className="space-y-1">
{repos.map((repo) => (
<button
key={repo.id}
onClick={() => {
setSelectedRepoId(repo.id)
setSelectedPane('repo')
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
showRepoPane && selectedRepoId === repo.id
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: repo.badgeColor }}
/>
<span className="truncate">{repo.displayName}</span>
</button>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</aside>
<SettingsSidebar
activeSectionId={activeSectionId}
generalSections={generalNavSections}
repoSections={repoNavSections}
hasRepos={repos.length > 0}
searchQuery={settingsSearchQuery}
onBack={() => setActiveView('terminal')}
onSearchChange={setSettingsSearchQuery}
onSelectSection={scrollToSection}
/>
<div className="flex min-h-0 flex-1 flex-col">
<div className="sticky top-0 z-10 border-b border-border/50 bg-background/95 py-6 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className={contentClassName}>{pageHeader}</div>
<div className="border-b border-border/50 bg-background/95 px-8 py-6 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground">
Search across every settings section without leaving the page.
</p>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className={`${contentClassName} py-8`}>
{showGeneralPane ? (
<GeneralPane
settings={settings}
updateSettings={updateSettings}
displayedGitUsername={displayedGitUsername}
/>
) : showAppearancePane ? (
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
/>
) : showTerminalPane ? (
<TerminalPane
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
scrollbackMode={scrollbackMode}
setScrollbackMode={setScrollbackMode}
/>
) : showShortcutsPane ? (
<ShortcutsPane />
) : selectedRepo ? (
<RepositoryPane
repo={selectedRepo}
yamlHooks={selectedYamlHooks}
hasHooksFile={selectedRepoHooksState?.hasHooks ?? false}
updateRepo={updateRepo}
removeRepo={removeRepo}
/>
) : (
<div className="flex min-h-[24rem] items-center justify-center text-sm text-muted-foreground">
Select a repository to edit its settings.
<div ref={contentScrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex w-full max-w-5xl flex-col gap-10 px-8 py-8">
{visibleNavSections.length === 0 ? (
<div className="flex min-h-[24rem] items-center justify-center rounded-2xl border border-dashed border-border/60 bg-card/30 text-sm text-muted-foreground">
No settings found for &quot;{settingsSearchQuery.trim()}&quot;
</div>
) : (
<>
<SettingsSection
id="general"
title="General"
description="Workspace, editor, naming, and updates."
searchEntries={GENERAL_PANE_SEARCH_ENTRIES}
>
<GeneralPane
settings={settings}
updateSettings={updateSettings}
displayedGitUsername={displayedGitUsername}
/>
</SettingsSection>
<SettingsSection
id="appearance"
title="Appearance"
description="Theme and UI scaling."
searchEntries={APPEARANCE_PANE_SEARCH_ENTRIES}
>
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
/>
</SettingsSection>
<SettingsSection
id="terminal"
title="Terminal"
description="Terminal appearance, previews, and defaults for new panes."
searchEntries={TERMINAL_PANE_SEARCH_ENTRIES}
>
<TerminalPane
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
scrollbackMode={scrollbackMode}
setScrollbackMode={setScrollbackMode}
/>
</SettingsSection>
<SettingsSection
id="shortcuts"
title="Shortcuts"
description="Keyboard shortcuts for common actions."
searchEntries={SHORTCUTS_PANE_SEARCH_ENTRIES}
>
<ShortcutsPane />
</SettingsSection>
{repos.map((repo) => {
const repoSectionId = `repo-${repo.id}`
const repoHooksState = repoHooksMap[repo.id]
return (
<SettingsSection
key={repo.id}
id={repoSectionId}
title={repo.displayName}
description={repo.path}
searchEntries={getRepositoryPaneSearchEntries(repo)}
>
<RepositoryPane
repo={repo}
yamlHooks={repoHooksState?.hooks ?? null}
hasHooksFile={repoHooksState?.hasHooks ?? false}
updateRepo={updateRepo}
removeRepo={removeRepo}
/>
</SettingsSection>
)
})}
</>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
)

View File

@ -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 (
<section id={id} data-settings-section={id} className={className ?? 'space-y-6 scroll-mt-6'}>
<div className="space-y-1">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</section>
)
}

View File

@ -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 (
<aside className="flex w-[280px] shrink-0 flex-col border-r border-border/50 bg-card/40">
<div className="border-b border-border/50 px-3 py-3">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="w-full justify-start gap-2 text-muted-foreground"
>
<ArrowLeft className="size-4" />
Back to app
</Button>
</div>
<div className="border-b border-border/50 px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search settings"
className="pl-9"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
<div className="space-y-5">
<div className="space-y-1">
{generalSections.map((section) => {
const Icon = section.icon
const isActive = activeSectionId === section.id
return (
<button
key={section.id}
onClick={() => onSelectSection(section.id)}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Icon className="mr-2 size-4" />
{section.title}
</button>
)
})}
</div>
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Repositories
</p>
{repoSections.length > 0 ? (
<div className="space-y-1">
{repoSections.map((section) => {
const isActive = activeSectionId === section.id
return (
<button
key={section.id}
onClick={() => onSelectSection(section.id)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: section.badgeColor ?? '#6b7280' }}
/>
<span className="truncate">{section.title}</span>
</button>
)
})}
</div>
) : (
<p className="px-3 text-xs text-muted-foreground">
{hasRepos ? 'No matching repository settings.' : 'No repositories added yet.'}
</p>
)}
</div>
</div>
</div>
</aside>
)
}

View File

@ -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<ShortcutGroup[]>(
() => [
{
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<Record<string, SettingsSearchEntry[]>>(
() =>
Object.fromEntries(
SHORTCUT_GROUP_DEFINITIONS.map((groupDef) => [
groupDef.title,
groupDef.items.map((defItem) => ({
title: defItem.action,
description: `${groupDef.title} shortcut`,
keywords: defItem.searchKeywords
}))
])
),
[]
)
return (
<div className="space-y-8">
<section className="space-y-4">
@ -68,32 +198,48 @@ export function ShortcutsPane(): React.JSX.Element {
</div>
<div className="grid gap-8">
{groups.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="text-sm font-medium border-b border-border/50 pb-2 text-muted-foreground">
{group.title}
</h3>
<div className="grid gap-2">
{group.items.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-1">
<span className="text-sm text-foreground">{item.action}</span>
<div className="flex items-center gap-1">
{item.keys.map((key, kIdx) => (
<React.Fragment key={kIdx}>
<span className="inline-flex min-w-6 items-center justify-center rounded border border-border/80 bg-secondary/70 px-1.5 py-0.5 text-xs font-medium text-muted-foreground shadow-sm">
{key}
</span>
{!isMac && kIdx < item.keys.length - 1 && (
<span className="text-muted-foreground text-xs mx-0.5">+</span>
)}
</React.Fragment>
))}
</div>
</div>
))}
{groups
.filter((group) => matchesSettingsSearch(searchQuery, groupEntries[group.title] ?? []))
.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="border-b border-border/50 pb-2 text-sm font-medium text-muted-foreground">
{group.title}
</h3>
<div className="grid gap-2">
{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 (
<SearchableSetting
key={idx}
title={item.action}
description={`${group.title} shortcut`}
keywords={keywords}
className="flex items-center justify-between py-1"
>
<span className="text-sm text-foreground">{item.action}</span>
<div className="flex items-center gap-1">
{item.keys.map((key, kIdx) => (
<React.Fragment key={kIdx}>
<span className="inline-flex min-w-6 items-center justify-center rounded border border-border/80 bg-secondary/70 px-1.5 py-0.5 text-xs font-medium text-muted-foreground shadow-sm">
{key}
</span>
{!isMac && kIdx < item.keys.length - 1 ? (
<span className="mx-0.5 text-xs text-muted-foreground">+</span>
) : null}
</React.Fragment>
))}
</div>
</SearchableSetting>
)
})}
</div>
</div>
</div>
))}
))}
</div>
</section>
</div>

View File

@ -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 (
<div className="space-y-8">
<section className="space-y-4">
const visibleSections = [
matchesSettingsSearch(searchQuery, TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES) ? (
<section key="typography" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Typography</h2>
<h3 className="text-sm font-semibold">Typography</h3>
<p className="text-xs text-muted-foreground">
Default terminal typography for new panes and live updates.
</p>
</div>
<div className="space-y-2">
<SearchableSetting
title="Font Size"
description="Default terminal font size for new panes and live updates."
keywords={['terminal', 'typography', 'text size']}
className="space-y-2"
>
<Label>Font Size</Label>
<div className="flex items-center gap-2">
<Button
@ -108,46 +128,61 @@ export function TerminalPane({
</Button>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
</SearchableSetting>
<div className="space-y-2">
<SearchableSetting
title="Font Family"
description="Default terminal font family for new panes and live updates."
keywords={['terminal', 'typography', 'font']}
className="space-y-2"
>
<Label>Font Family</Label>
<FontAutocomplete
value={settings.terminalFontFamily}
suggestions={terminalFontSuggestions}
onChange={(value) => updateSettings({ terminalFontFamily: value })}
/>
</div>
</SearchableSetting>
<NumberField
label="Font Weight"
<SearchableSetting
title="Font Weight"
description="Controls the terminal text font weight."
value={normalizeTerminalFontWeight(settings.terminalFontWeight)}
defaultValue={DEFAULT_TERMINAL_FONT_WEIGHT}
min={TERMINAL_FONT_WEIGHT_MIN}
max={TERMINAL_FONT_WEIGHT_MAX}
step={TERMINAL_FONT_WEIGHT_STEP}
suffix="100 to 900"
onChange={(value) =>
updateSettings({
terminalFontWeight: normalizeTerminalFontWeight(value)
})
}
/>
keywords={['terminal', 'typography', 'weight']}
>
<NumberField
label="Font Weight"
description="Controls the terminal text font weight."
value={normalizeTerminalFontWeight(settings.terminalFontWeight)}
defaultValue={DEFAULT_TERMINAL_FONT_WEIGHT}
min={TERMINAL_FONT_WEIGHT_MIN}
max={TERMINAL_FONT_WEIGHT_MAX}
step={TERMINAL_FONT_WEIGHT_STEP}
suffix="100 to 900"
onChange={(value) =>
updateSettings({
terminalFontWeight: normalizeTerminalFontWeight(value)
})
}
/>
</SearchableSetting>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_CURSOR_SEARCH_ENTRIES) ? (
<section key="cursor" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Cursor</h2>
<h3 className="text-sm font-semibold">Cursor</h3>
<p className="text-xs text-muted-foreground">
Default cursor appearance for Orca terminal panes.
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<SearchableSetting
title="Cursor Shape"
description="Default cursor appearance for Orca terminal panes."
keywords={['terminal', 'cursor', 'bar', 'block', 'underline']}
className="space-y-2"
>
<Label>Cursor Shape</Label>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['bar', 'block', 'underline'] as const).map((option) => (
@ -164,9 +199,14 @@ export function TerminalPane({
</button>
))}
</div>
</div>
</SearchableSetting>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<SearchableSetting
title="Blinking Cursor"
description="Uses the blinking variant of the selected cursor shape."
keywords={['terminal', 'cursor', 'blink']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Blinking Cursor</Label>
<p className="text-xs text-muted-foreground">
@ -191,172 +231,103 @@ export function TerminalPane({
}`}
/>
</button>
</div>
</SearchableSetting>
</div>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_PANE_STYLE_SEARCH_ENTRIES) ? (
<section key="pane-styling" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Pane Styling</h2>
<h3 className="text-sm font-semibold">Pane Styling</h3>
<p className="text-xs text-muted-foreground">
Control inactive pane dimming, divider thickness, and transition timing.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<NumberField
label="Inactive Pane Opacity"
<SearchableSetting
title="Inactive Pane Opacity"
description="Opacity applied to panes that are not currently active."
value={paneStyleOptions.inactivePaneOpacity}
defaultValue={0.8}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({
terminalInactivePaneOpacity: clampNumber(value, 0, 1)
})
}
/>
<NumberField
label="Divider Thickness"
description="Thickness of the pane divider line."
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}
max={32}
step={1}
suffix="px"
onChange={(value) =>
updateSettings({
terminalDividerThicknessPx: clampNumber(value, 1, 32)
})
}
/>
</div>
</section>
<Separator />
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<ThemePicker
label="Dark Theme"
description="Choose the terminal theme used in dark mode."
selectedTheme={settings.terminalThemeDark}
query={themeSearchDark}
onQueryChange={setThemeSearchDark}
onSelectTheme={(theme) => updateSettings({ terminalThemeDark: theme })}
/>
<ColorField
label="Dark Divider Color"
description="Controls the split divider line between panes in dark mode."
value={settings.terminalDividerColorDark}
fallback="#3f3f46"
onChange={(value) => updateSettings({ terminalDividerColorDark: value })}
/>
</div>
<TerminalThemePreview
title="Dark Mode Preview"
description={
settings.theme === 'system'
? `System mode is currently ${systemPrefersDark ? 'Dark' : 'Light'}.`
: `Orca is currently in ${settings.theme} mode.`
}
appearance={darkPreviewAppearance}
dividerThicknessPx={paneStyleOptions.dividerThicknessPx}
inactivePaneOpacity={paneStyleOptions.inactivePaneOpacity}
activePaneOpacity={paneStyleOptions.activePaneOpacity}
/>
</section>
<Separator />
<section className="space-y-4">
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<Label>Use Separate Theme In Light Mode</Label>
<p className="text-xs text-muted-foreground">
When disabled, light mode reuses the dark terminal theme.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalUseSeparateLightTheme}
onClick={() =>
updateSettings({
terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalUseSeparateLightTheme ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
keywords={['pane', 'opacity', 'dimming']}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalUseSeparateLightTheme ? 'translate-x-4' : 'translate-x-0.5'
}`}
<NumberField
label="Inactive Pane Opacity"
description="Opacity applied to panes that are not currently active."
value={paneStyleOptions.inactivePaneOpacity}
defaultValue={0.8}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({
terminalInactivePaneOpacity: clampNumber(value, 0, 1)
})
}
/>
</button>
</div>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
settings.terminalUseSeparateLightTheme
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="min-h-0">
<div className="grid gap-6 pt-2 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<ThemePicker
label="Light Theme"
description="Choose the theme used when Orca is in light mode."
selectedTheme={settings.terminalThemeLight}
query={themeSearchLight}
onQueryChange={setThemeSearchLight}
onSelectTheme={(theme) => updateSettings({ terminalThemeLight: theme })}
/>
<ColorField
label="Light Divider Color"
description="Controls the split divider line between panes in light mode."
value={settings.terminalDividerColorLight}
fallback="#d4d4d8"
onChange={(value) => updateSettings({ terminalDividerColorLight: value })}
/>
</div>
<TerminalThemePreview
title="Light Mode Preview"
description="Updates live as you change the light theme or divider color."
appearance={lightPreviewAppearance}
dividerThicknessPx={paneStyleOptions.dividerThicknessPx}
inactivePaneOpacity={paneStyleOptions.inactivePaneOpacity}
activePaneOpacity={paneStyleOptions.activePaneOpacity}
/>
</div>
</div>
</SearchableSetting>
<SearchableSetting
title="Divider Thickness"
description="Thickness of the pane divider line."
keywords={['pane', 'divider', 'thickness']}
>
<NumberField
label="Divider Thickness"
description="Thickness of the pane divider line."
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}
max={32}
step={1}
suffix="px"
onChange={(value) =>
updateSettings({
terminalDividerThicknessPx: clampNumber(value, 1, 32)
})
}
/>
</SearchableSetting>
</div>
</section>
<Separator />
<section className="space-y-4">
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_DARK_THEME_SEARCH_ENTRIES) ? (
<DarkTerminalThemeSection
key="dark-theme"
settings={settings}
systemPrefersDark={systemPrefersDark}
themeSearchDark={themeSearchDark}
setThemeSearchDark={setThemeSearchDark}
updateSettings={updateSettings}
previewProps={paneStyleOptions}
darkPreviewAppearance={darkPreviewAppearance}
/>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_LIGHT_THEME_SEARCH_ENTRIES) ? (
<LightTerminalThemeSection
key="light-theme"
settings={settings}
themeSearchLight={themeSearchLight}
setThemeSearchLight={setThemeSearchLight}
updateSettings={updateSettings}
previewProps={paneStyleOptions}
lightPreviewAppearance={lightPreviewAppearance}
/>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ? (
<section key="advanced" className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Advanced</h2>
<h3 className="text-sm font-semibold">Advanced</h3>
<p className="text-xs text-muted-foreground">
Scrollback is bounded for stability. This setting applies to new terminal panes.
</p>
</div>
<div className="space-y-3">
<SearchableSetting
title="Scrollback Size"
description="Maximum terminal scrollback buffer size."
keywords={['terminal', 'scrollback', 'buffer', 'memory']}
className="space-y-3"
>
<Label>Scrollback Size</Label>
<ToggleGroup
type="single"
@ -411,8 +382,19 @@ export function TerminalPane({
}
/>
) : null}
</div>
</SearchableSetting>
</section>
) : null
].filter(Boolean)
return (
<div className="space-y-8">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-8">
{index > 0 ? <Separator /> : null}
{section}
</div>
))}
</div>
)
}

View File

@ -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<SetStateAction<string>>
updateSettings: (updates: Partial<GlobalSettings>) => void
previewProps: ThemePreviewProps
darkPreviewAppearance: EffectiveTerminalAppearance
}
type LightTerminalThemeSectionProps = {
settings: GlobalSettings
themeSearchLight: string
setThemeSearchLight: Dispatch<SetStateAction<string>>
updateSettings: (updates: Partial<GlobalSettings>) => void
previewProps: ThemePreviewProps
lightPreviewAppearance: EffectiveTerminalAppearance
}
export function DarkTerminalThemeSection({
settings,
systemPrefersDark,
themeSearchDark,
setThemeSearchDark,
updateSettings,
previewProps,
darkPreviewAppearance
}: DarkTerminalThemeSectionProps): React.JSX.Element {
return (
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Dark Theme</h3>
<p className="text-xs text-muted-foreground">
Choose the theme used for terminal panes in dark mode.
</p>
</div>
<SearchableSetting
title="Dark Theme"
description="Choose the terminal theme used in dark mode."
keywords={['terminal', 'theme', 'dark', 'preview']}
>
<ThemePicker
label="Dark Theme"
description="Choose the terminal theme used in dark mode."
selectedTheme={settings.terminalThemeDark}
query={themeSearchDark}
onQueryChange={setThemeSearchDark}
onSelectTheme={(theme) => updateSettings({ terminalThemeDark: theme })}
/>
</SearchableSetting>
<SearchableSetting
title="Dark Divider Color"
description="Controls the split divider line between panes in dark mode."
keywords={['terminal', 'divider', 'dark', 'color']}
>
<ColorField
label="Dark Divider Color"
description="Controls the split divider line between panes in dark mode."
value={settings.terminalDividerColorDark}
fallback="#3f3f46"
onChange={(value) => updateSettings({ terminalDividerColorDark: value })}
/>
</SearchableSetting>
</div>
<TerminalThemePreview
title="Dark Mode Preview"
description={
settings.theme === 'system'
? `System mode is currently ${systemPrefersDark ? 'Dark' : 'Light'}.`
: `Orca is currently in ${settings.theme} mode.`
}
appearance={darkPreviewAppearance}
dividerThicknessPx={previewProps.dividerThicknessPx}
inactivePaneOpacity={previewProps.inactivePaneOpacity}
activePaneOpacity={previewProps.activePaneOpacity}
/>
</section>
)
}
export function LightTerminalThemeSection({
settings,
themeSearchLight,
setThemeSearchLight,
updateSettings,
previewProps,
lightPreviewAppearance
}: LightTerminalThemeSectionProps): React.JSX.Element {
return (
<section className="space-y-4">
<SearchableSetting
title="Use Separate Theme In Light Mode"
description="When disabled, light mode reuses the dark terminal theme."
keywords={['terminal', 'light mode', 'theme']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<p className="text-sm font-medium">Use Separate Theme In Light Mode</p>
<p className="text-xs text-muted-foreground">
When disabled, light mode reuses the dark terminal theme.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalUseSeparateLightTheme}
onClick={() =>
updateSettings({
terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalUseSeparateLightTheme ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalUseSeparateLightTheme ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
settings.terminalUseSeparateLightTheme
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="min-h-0">
<div className="grid gap-6 pt-2 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Light Theme</h3>
<p className="text-xs text-muted-foreground">
Configure the optional light-mode terminal appearance.
</p>
</div>
<SearchableSetting
title="Light Theme"
description="Choose the theme used when Orca is in light mode."
keywords={['terminal', 'theme', 'light', 'preview']}
>
<ThemePicker
label="Light Theme"
description="Choose the theme used when Orca is in light mode."
selectedTheme={settings.terminalThemeLight}
query={themeSearchLight}
onQueryChange={setThemeSearchLight}
onSelectTheme={(theme) => updateSettings({ terminalThemeLight: theme })}
/>
</SearchableSetting>
<SearchableSetting
title="Light Divider Color"
description="Controls the split divider line between panes in light mode."
keywords={['terminal', 'divider', 'light', 'color']}
>
<ColorField
label="Light Divider Color"
description="Controls the split divider line between panes in light mode."
value={settings.terminalDividerColorLight}
fallback="#d4d4d8"
onChange={(value) => updateSettings({ terminalDividerColorLight: value })}
/>
</SearchableSetting>
</div>
<TerminalThemePreview
title="Light Mode Preview"
description="Updates live as you change the light theme or divider color."
appearance={lightPreviewAppearance}
dividerThicknessPx={previewProps.dividerThicknessPx}
inactivePaneOpacity={previewProps.inactivePaneOpacity}
activePaneOpacity={previewProps.activePaneOpacity}
/>
</div>
</div>
</div>
</section>
)
}

View File

@ -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
]

View File

@ -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))
})
}

View File

@ -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
]

View File

@ -4,12 +4,16 @@ import type { GlobalSettings } from '../../../../shared/types'
export type SettingsSlice = {
settings: GlobalSettings | null
settingsSearchQuery: string
setSettingsSearchQuery: (q: string) => void
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
}
export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> = (set) => ({
settings: null,
settingsSearchQuery: '',
setSettingsSearchQuery: (q) => set({ settingsSearchQuery: q }),
fetchSettings: async () => {
try {