fix: address review findings (#315)
This commit is contained in:
parent
3ec7fcb05e
commit
171a2052df
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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 "{settingsSearchQuery.trim()}"
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue