This commit is contained in:
Neil
2026-03-18 22:58:50 -07:00
parent 520679478a
commit a84b8a4168
13 changed files with 1086 additions and 27 deletions
+9
View File
@@ -13,6 +13,7 @@ import { registerSettingsHandlers } from './ipc/settings'
import { registerShellHandlers } from './ipc/shell'
import { registerSessionHandlers } from './ipc/session'
import { registerUIHandlers } from './ipc/ui'
import { warmSystemFontFamilies } from './system-fonts'
let mainWindow: BrowserWindow | null = null
let store: Store | null = null
@@ -85,6 +86,13 @@ app.whenReady().then(() => {
label: app.name,
submenu: [
{ role: 'about' },
{
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
click: () => {
mainWindow?.webContents.send('ui:openSettings')
}
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
@@ -143,6 +151,7 @@ app.whenReady().then(() => {
registerShellHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
warmSystemFontFamilies()
// macOS re-activate
app.on('activate', function () {
+5
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import { listSystemFontFamilies } from '../system-fonts'
export function registerSettingsHandlers(store: Store): void {
ipcMain.handle('settings:get', () => {
@@ -11,6 +12,10 @@ export function registerSettingsHandlers(store: Store): void {
return store.updateSettings(args)
})
ipcMain.handle('settings:listFonts', () => {
return listSystemFontFamilies()
})
ipcMain.handle('cache:getGitHub', () => {
return store.getGitHubCache()
})
+126
View File
@@ -0,0 +1,126 @@
import { execFile } from 'child_process'
let cachedFonts: string[] | null = null
let fontsPromise: Promise<string[]> | null = null
export async function listSystemFontFamilies(): Promise<string[]> {
if (cachedFonts) return cachedFonts
if (fontsPromise) return fontsPromise
fontsPromise = loadSystemFontFamilies()
.then((fonts) => {
cachedFonts = fonts.length > 0 ? fonts : fallbackFonts()
return cachedFonts
})
.catch(() => {
cachedFonts = fallbackFonts()
return cachedFonts
})
.finally(() => {
fontsPromise = null
})
return fontsPromise
}
export function warmSystemFontFamilies(): void {
void listSystemFontFamilies()
}
function loadSystemFontFamilies(): Promise<string[]> {
if (process.platform === 'darwin') return listMacFonts()
if (process.platform === 'win32') return listWindowsFonts()
return listLinuxFonts()
}
function listMacFonts(): Promise<string[]> {
return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then(
(output) => {
const parsed = JSON.parse(output) as {
SPFontsDataType?: Array<{
typefaces?: Array<{
family?: string
}>
}>
}
return uniqueSorted(
(parsed.SPFontsDataType ?? []).flatMap((font) =>
(font.typefaces ?? []).map((typeface) => typeface.family)
)
)
}
)
}
function listLinuxFonts(): Promise<string[]> {
return execFileText('fc-list', [':', 'family'], 8 * 1024 * 1024).then((output) =>
uniqueSorted(
output
.split('\n')
.flatMap((line) => line.split(','))
.map((name) => name.trim())
.filter(Boolean)
)
)
}
function listWindowsFonts(): Promise<string[]> {
const script = `
Add-Type -AssemblyName System.Drawing
$fonts = New-Object System.Drawing.Text.InstalledFontCollection
$fonts.Families | ForEach-Object { $_.Name }
`
return execFileText(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
8 * 1024 * 1024
).then((output) =>
uniqueSorted(
output
.split('\n')
.map((name) => name.trim())
.filter(Boolean)
)
)
}
function execFileText(command: string, args: string[], maxBuffer: number): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf8', maxBuffer }, (error, stdout) => {
if (error) {
reject(error)
return
}
resolve(stdout)
})
})
}
function uniqueSorted(values: Array<string | undefined>): string[] {
return Array.from(
new Set(
values
.map((value) => value?.trim() ?? '')
.filter((value) => value.length > 0 && !value.startsWith('.'))
)
).sort((a, b) => a.localeCompare(b))
}
function fallbackFonts(): string[] {
if (process.platform === 'darwin') {
return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code']
}
if (process.platform === 'win32') {
return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code']
}
return [
'JetBrains Mono',
'Fira Code',
'DejaVu Sans Mono',
'Liberation Mono',
'Ubuntu Mono',
'Noto Sans Mono'
]
}
+2
View File
@@ -53,6 +53,7 @@ interface GhApi {
interface SettingsApi {
get: () => Promise<GlobalSettings>
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
}
interface ShellApi {
@@ -85,6 +86,7 @@ interface SessionApi {
interface UIApi {
get: () => Promise<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
}
interface Api {
+9 -2
View File
@@ -100,7 +100,9 @@ const api = {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
set: (args: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('settings:set', args)
ipcRenderer.invoke('settings:set', args),
listFonts: (): Promise<string[]> => ipcRenderer.invoke('settings:listFonts')
},
shell: {
@@ -126,7 +128,12 @@ const api = {
ui: {
get: (): Promise<unknown> => ipcRenderer.invoke('ui:get'),
set: (args: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('ui:set', args)
set: (args: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('ui:set', args),
onOpenSettings: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openSettings', listener)
return () => ipcRenderer.removeListener('ui:openSettings', listener)
}
}
}
+25
View File
@@ -403,6 +403,31 @@
height: 100% !important;
}
.restty-pane-root .pane-divider.is-vertical,
.restty-pane-root .pane-divider.is-horizontal {
background: var(--orca-terminal-divider-color, transparent) !important;
}
.restty-pane-root .pane-divider.is-vertical:hover,
.restty-pane-root .pane-divider.is-vertical.is-dragging,
.restty-pane-root .pane-divider.is-horizontal:hover,
.restty-pane-root .pane-divider.is-horizontal.is-dragging {
background: var(
--orca-terminal-divider-color-strong,
var(--orca-terminal-divider-color, transparent)
) !important;
}
.number-input-clean {
appearance: textfield;
}
.number-input-clean::-webkit-outer-spin-button,
.number-input-clean::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ── Landing ─────────────────────────────────────────── */
.landing {
+545 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../shared/types'
import { REPO_COLORS, getDefaultRepoHookSettings } from '../../../shared/constants'
import { useAppStore } from '../store'
@@ -7,8 +7,20 @@ import { Button } from './ui/button'
import { Input } from './ui/input'
import { Label } from './ui/label'
import { Separator } from './ui/separator'
import { TerminalThemePreview } from './settings/TerminalThemePreview'
import {
BUILTIN_TERMINAL_THEME_NAMES,
clampNumber,
getSystemPrefersDark,
normalizeColor,
resolvePaneStyleOptions,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import {
ArrowLeft,
Check,
ChevronsUpDown,
CircleX,
FolderOpen,
Minus,
Plus,
@@ -19,6 +31,324 @@ import {
type HookName = keyof OrcaHooks['scripts']
const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings()
const MAX_THEME_RESULTS = 80
const MAX_FONT_RESULTS = 12
function getFallbackTerminalFonts(): string[] {
const nav =
typeof navigator !== 'undefined'
? (navigator as Navigator & { userAgentData?: { platform?: string } })
: null
const platform = nav ? (nav.userAgentData?.platform ?? nav.platform ?? '') : ''
const normalizedPlatform = platform.toLowerCase()
if (normalizedPlatform.includes('mac')) {
return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code']
}
if (normalizedPlatform.includes('win')) {
return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code']
}
return [
'JetBrains Mono',
'Fira Code',
'DejaVu Sans Mono',
'Liberation Mono',
'Ubuntu Mono',
'Noto Sans Mono'
]
}
type ThemePickerProps = {
label: string
description: string
selectedTheme: string
query: string
onQueryChange: (value: string) => void
onSelectTheme: (theme: string) => void
}
type ColorFieldProps = {
label: string
description: string
value: string
fallback: string
onChange: (value: string) => void
}
function ThemePicker({
label,
description,
selectedTheme,
query,
onQueryChange,
onSelectTheme
}: ThemePickerProps): React.JSX.Element {
const normalizedQuery = query.trim().toLowerCase()
const filteredThemes = BUILTIN_TERMINAL_THEME_NAMES.filter((theme) =>
theme.toLowerCase().includes(normalizedQuery)
).slice(0, MAX_THEME_RESULTS)
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search builtin themes"
/>
<div className="rounded-lg border">
<div className="flex items-center justify-between border-b px-3 py-2 text-xs text-muted-foreground">
<span>Selected: {selectedTheme}</span>
<span>
Showing {filteredThemes.length}
{normalizedQuery
? ` matching "${query.trim()}"`
: ` of ${BUILTIN_TERMINAL_THEME_NAMES.length}`}
</span>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 p-2">
{filteredThemes.map((theme) => (
<button
key={theme}
onClick={() => onSelectTheme(theme)}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
selectedTheme === theme
? 'bg-accent font-medium text-accent-foreground'
: 'hover:bg-muted/60'
}`}
>
<span className="truncate">{theme}</span>
{selectedTheme === theme ? (
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
Current
</span>
) : null}
</button>
))}
{filteredThemes.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">No themes found.</div>
) : null}
</div>
</ScrollArea>
</div>
</div>
)
}
function ColorField({
label,
description,
value,
fallback,
onChange
}: ColorFieldProps): React.JSX.Element {
const normalized = normalizeColor(value, fallback)
return (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3">
<input
type="color"
value={normalized}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 rounded-md border border-input bg-transparent p-1"
/>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={fallback}
className="max-w-xs font-mono text-xs"
/>
</div>
</div>
)
}
type NumberFieldProps = {
label: string
description: string
value: number
defaultValue?: number
min: number
max: number
step?: number
onChange: (value: number) => void
suffix?: string
}
type FontAutocompleteProps = {
value: string
suggestions: string[]
onChange: (value: string) => void
}
function NumberField({
label,
description,
value,
defaultValue,
min,
max,
step = 1,
onChange,
suffix
}: NumberFieldProps): React.JSX.Element {
return (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3">
<Input
type="number"
min={min}
max={max}
step={step}
value={Number.isFinite(value) ? String(value) : ''}
onChange={(e) => {
const next = Number(e.target.value)
if (!Number.isFinite(next)) return
onChange(next)
}}
className="number-input-clean w-28 tabular-nums"
/>
{suffix ? <span className="text-xs text-muted-foreground">{suffix}</span> : null}
</div>
<p className="text-[11px] text-muted-foreground">
Current: {value}
{defaultValue !== undefined ? ` · Default: ${defaultValue}` : ''}
</p>
</div>
)
}
function FontAutocomplete({
value,
suggestions,
onChange
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
setQuery(value)
}, [value])
useEffect(() => {
if (!open) return
const handlePointerDown = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
const normalizedQuery = query.trim().toLowerCase()
const filteredSuggestions = useMemo(() => {
const startsWith = suggestions.filter((font) => font.toLowerCase().startsWith(normalizedQuery))
const includes = suggestions.filter(
(font) =>
!font.toLowerCase().startsWith(normalizedQuery) &&
font.toLowerCase().includes(normalizedQuery)
)
const ordered = normalizedQuery ? [...startsWith, ...includes] : suggestions
return ordered.slice(0, MAX_FONT_RESULTS)
}, [suggestions, normalizedQuery])
const commitValue = (nextValue: string): void => {
setQuery(nextValue)
onChange(nextValue)
setOpen(false)
}
return (
<div ref={rootRef} className="relative max-w-sm">
<div className="relative">
<Input
value={query}
onChange={(e) => {
const next = e.target.value
setQuery(next)
onChange(next)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder="SF Mono"
className="pr-18"
/>
<div className="absolute inset-y-0 right-2 flex items-center gap-1">
{query ? (
<button
type="button"
onClick={() => {
setQuery('')
onChange('')
setOpen(true)
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Clear font selection"
title="Clear"
>
<CircleX className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Toggle font suggestions"
title="Fonts"
>
<ChevronsUpDown className="size-3.5" />
</button>
</div>
</div>
{open ? (
<div className="absolute top-full z-20 mt-2 w-full overflow-hidden rounded-md border bg-popover shadow-md">
<ScrollArea className="max-h-64">
<div className="p-1">
{filteredSuggestions.length > 0 ? (
filteredSuggestions.map((font) => (
<button
key={font}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => commitValue(font)}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
font === value ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
}`}
>
<span className="truncate">{font}</span>
{font === value ? <Check className="ml-3 size-4 shrink-0" /> : null}
</button>
))
) : (
<div className="px-3 py-3 text-sm text-muted-foreground">No matching fonts.</div>
)}
</div>
</ScrollArea>
</div>
) : null}
</div>
)
}
function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
@@ -39,11 +369,51 @@ function Settings(): React.JSX.Element {
const [baseRefQuery, setBaseRefQuery] = useState('')
const [baseRefResults, setBaseRefResults] = useState<string[]>([])
const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
const [themeSearchDark, setThemeSearchDark] = useState('')
const [themeSearchLight, setThemeSearchLight] = useState('')
const [systemPrefersDark, setSystemPrefersDark] = useState(getSystemPrefersDark())
const [terminalFontSuggestions, setTerminalFontSuggestions] = useState<string[]>(
getFallbackTerminalFonts()
)
const terminalFontsLoadedRef = useRef(false)
useEffect(() => {
fetchSettings()
}, [fetchSettings])
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent): void => {
setSystemPrefersDark(event.matches)
}
setSystemPrefersDark(media.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
if (selectedPane !== 'terminal' || terminalFontsLoadedRef.current) return
let stale = false
const loadFontSuggestions = async (): Promise<void> => {
try {
const fonts = await window.api.settings.listFonts()
if (stale || fonts.length === 0) return
terminalFontsLoadedRef.current = true
setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320))
} catch {
// Fall back to curated cross-platform suggestions.
}
}
void loadFontSuggestions()
return () => {
stale = true
}
}, [selectedPane])
useEffect(() => {
let stale = false
const checkHooks = async () => {
@@ -229,6 +599,22 @@ function Settings(): React.JSX.Element {
)
}
const darkPreviewAppearance = resolveEffectiveTerminalAppearance(
{
...settings,
theme: 'dark'
},
systemPrefersDark
)
const lightPreviewAppearance = resolveEffectiveTerminalAppearance(
{
...settings,
theme: 'light'
},
systemPrefersDark
)
const paneStyleOptions = resolvePaneStyleOptions(settings)
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 bg-card/40">
@@ -461,7 +847,7 @@ function Settings(): React.JSX.Element {
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Terminal</h1>
<p className="text-sm text-muted-foreground">
Default terminal typography for new panes.
Terminal appearance, previews, and defaults for new panes.
</p>
</div>
@@ -469,7 +855,7 @@ function Settings(): React.JSX.Element {
<div className="space-y-1">
<h2 className="text-sm font-semibold">Typography</h2>
<p className="text-xs text-muted-foreground">
Default terminal typography for new panes.
Default terminal typography for new panes and live updates.
</p>
</div>
@@ -517,14 +903,166 @@ function Settings(): React.JSX.Element {
<div className="space-y-2">
<Label className="text-sm">Font Family</Label>
<Input
<FontAutocomplete
value={settings.terminalFontFamily}
onChange={(e) => updateSettings({ terminalFontFamily: e.target.value })}
placeholder="SF Mono"
className="max-w-xs"
suggestions={terminalFontSuggestions}
onChange={(value) => updateSettings({ terminalFontFamily: value })}
/>
</div>
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Pane Styling</h2>
<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"
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 className="text-sm">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'
}`}
>
<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>
</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>
</div>
</section>
</div>
) : selectedRepo ? (
<div className="space-y-8">
+99 -17
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Restty, getBuiltinTheme } from 'restty'
import type { CSSProperties } from 'react'
import {
Clipboard,
Copy,
@@ -25,6 +26,14 @@ import type {
TerminalPaneSplitDirection
} from '../../../shared/types'
import { useAppStore } from '../store'
import {
DEFAULT_TERMINAL_DIVIDER_DARK,
buildTerminalFontMatchers,
colorToCss,
normalizeColor,
resolvePaneStyleOptions,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
type PtyTransport = {
connect: (options: {
@@ -223,6 +232,22 @@ function paneLeafId(paneId: number): string {
return `pane:${paneId}`
}
function buildTerminalFontSources(fontFamily: string) {
return [
{
type: 'local' as const,
label: fontFamily || 'Preferred terminal font',
matchers: buildTerminalFontMatchers(fontFamily),
required: true
},
{
type: 'local' as const,
label: 'Menlo',
matchers: ['menlo', 'menlo regular']
}
]
}
function getLayoutChildNodes(split: HTMLElement): HTMLElement[] {
return Array.from(split.children).filter(
(child): child is HTMLElement =>
@@ -485,11 +510,54 @@ export default function TerminalPane({
const updateTabPtyId = useAppStore((s) => s.updateTabPtyId)
const clearTabPtyId = useAppStore((s) => s.clearTabPtyId)
const markWorktreeUnreadFromBell = useAppStore((s) => s.markWorktreeUnreadFromBell)
const settings = useAppStore((s) => s.settings)
const [systemPrefersDark, setSystemPrefersDark] = useState(() =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: true
)
const settingsRef = useRef(settings)
settingsRef.current = settings
// Use a ref so the Restty closure always calls the latest onPtyExit
const onPtyExitRef = useRef(onPtyExit)
onPtyExitRef.current = onPtyExit
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent): void => {
setSystemPrefersDark(event.matches)
}
setSystemPrefersDark(media.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
const applyTerminalAppearance = (restty: Restty): void => {
const currentSettings = settingsRef.current
if (!currentSettings) return
const appearance = resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDark)
const paneStyles = resolvePaneStyleOptions(currentSettings)
const theme = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const paneBackground = colorToCss(theme?.colors.background, '#000000')
if (theme) {
for (const pane of restty.getPanes()) {
pane.app.applyTheme(theme, appearance.themeName)
pane.app.setFontSize(currentSettings.terminalFontSize)
}
}
restty.setPaneStyleOptions({
splitBackground: paneBackground,
paneBackground,
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
activePaneOpacity: paneStyles.activePaneOpacity,
opacityTransitionMs: paneStyles.opacityTransitionMs,
dividerThicknessPx: paneStyles.dividerThicknessPx
})
}
// Initialize Restty instance once
useEffect(() => {
const container = containerRef.current
@@ -529,6 +597,7 @@ export default function TerminalPane({
shortcuts: { enabled: false },
defaultContextMenu: false,
appOptions: ({ id }) => {
const currentSettings = settingsRef.current
const onExit = (ptyId: string): void => {
// Schedule close via parent
const panes = restty.getPanes()
@@ -541,7 +610,7 @@ export default function TerminalPane({
}
return {
renderer: 'webgpu',
fontSize: 14,
fontSize: currentSettings?.terminalFontSize ?? 14,
fontSizeMode: 'em',
alphaBlending: 'native',
ptyTransport: createIpcPtyTransport(
@@ -551,25 +620,12 @@ export default function TerminalPane({
onPtySpawn,
onBell
) as never,
fontSources: [
{
type: 'local' as const,
label: 'SF Mono',
matchers: ['sf mono', 'sfmono-regular'],
required: true
},
{
type: 'local' as const,
label: 'Menlo',
matchers: ['menlo', 'menlo regular']
}
]
fontSources: buildTerminalFontSources(currentSettings?.terminalFontFamily ?? 'SF Mono')
}
},
onPaneCreated: async (pane) => {
await pane.app.init()
const theme = getBuiltinTheme('Aizen Dark')
if (theme) pane.app.applyTheme(theme, 'Aizen Dark')
applyTerminalAppearance(restty)
pane.app.updateSize(true)
pane.app.connectPty('')
pane.canvas.focus({ preventScroll: true })
@@ -610,6 +666,7 @@ export default function TerminalPane({
}
shouldPersistLayout = true
syncCanExpandState()
applyTerminalAppearance(restty)
queueResizeAll(isActive)
persistLayoutSnapshot()
@@ -624,6 +681,19 @@ export default function TerminalPane({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId, cwd])
useEffect(() => {
const restty = resttyRef.current
if (!restty || !settings) return
applyTerminalAppearance(restty)
void Promise.all(
restty
.getPanes()
.map((pane) =>
pane.app.setFontSources(buildTerminalFontSources(settings.terminalFontFamily))
)
)
}, [settings, systemPrefersDark])
// Handle focus and resize when tab becomes active
useEffect(() => {
const restty = resttyRef.current
@@ -824,13 +894,25 @@ export default function TerminalPane({
const canExpandPane = paneCount > 1
const menuPaneId = resolveMenuPane()?.id ?? null
const menuPaneIsExpanded = menuPaneId !== null && menuPaneId === expandedPaneId
const effectiveAppearance = settings
? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
: null
const terminalContainerStyle: CSSProperties = {
display: isActive ? 'flex' : 'none',
['--orca-terminal-divider-color' as string]:
effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK,
['--orca-terminal-divider-color-strong' as string]: normalizeColor(
effectiveAppearance?.dividerColor,
DEFAULT_TERMINAL_DIVIDER_DARK
)
}
return (
<>
<div
ref={containerRef}
className="absolute inset-0 min-h-0 min-w-0"
style={{ display: isActive ? 'flex' : 'none' }}
style={terminalContainerStyle}
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
@@ -0,0 +1,125 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
colorToCss,
terminalPalettePreview,
type EffectiveTerminalAppearance
} from '@/lib/terminal-theme'
type TerminalThemePreviewProps = {
title: string
description: string
appearance: EffectiveTerminalAppearance
dividerThicknessPx?: number
inactivePaneOpacity?: number
activePaneOpacity?: number
}
export function TerminalThemePreview({
title,
description,
appearance,
dividerThicknessPx = 1,
inactivePaneOpacity = 0.9,
activePaneOpacity = 1
}: TerminalThemePreviewProps): React.JSX.Element {
const background = colorToCss(
appearance.theme?.colors.background,
appearance.mode === 'light' ? '#f8fafc' : '#09090b'
)
const foreground = colorToCss(
appearance.theme?.colors.foreground,
appearance.mode === 'light' ? '#111827' : '#f4f4f5'
)
const cursor = colorToCss(appearance.theme?.colors.cursor, foreground)
const selection = colorToCss(
appearance.theme?.colors.selectionBackground,
appearance.mode === 'light' ? 'rgba(59, 130, 246, 0.18)' : 'rgba(148, 163, 184, 0.22)'
)
const palette = terminalPalettePreview(appearance.theme)
return (
<Card className="gap-4 overflow-hidden py-0">
<CardHeader className="gap-1 border-b py-4">
<CardTitle className="text-sm">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2 text-xs">
<div className="min-w-0">
<p className="font-medium text-foreground">{appearance.themeName}</p>
<p className="text-muted-foreground">
{appearance.sourceTheme === 'system'
? `System mode, currently ${appearance.systemPrefersDark ? 'Dark' : 'Light'}`
: `${appearance.mode === 'dark' ? 'Dark' : 'Light'} mode`}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Divider</span>
<span
className="size-4 rounded-sm border"
style={{ backgroundColor: appearance.dividerColor }}
/>
</div>
</div>
<div
className="overflow-hidden rounded-lg border"
style={{ backgroundColor: appearance.dividerColor }}
>
<div className="flex min-h-[220px]">
<div
className="flex-1 p-3 font-mono text-[12px] leading-6"
style={{ backgroundColor: background, color: foreground, opacity: activePaneOpacity }}
>
<div className="flex items-center gap-2 text-[11px] opacity-70">
<span className="size-2 rounded-full bg-emerald-500" />
<span>orca preview</span>
</div>
<div className="mt-3">$ git status --short</div>
<div style={{ color: palette[1] ?? foreground }}>
M src/renderer/src/components/Settings.tsx
</div>
<div style={{ color: palette[2] ?? foreground }}>
A src/renderer/src/lib/terminal-theme.ts
</div>
<div className="mt-3">
<span style={{ backgroundColor: selection }}>theme preview selected text</span>
</div>
<div className="mt-3 flex items-center gap-2">
<span>$</span>
<span>echo &quot;cursor&quot;</span>
<span
className="inline-block h-[1.1em] w-[0.6ch] align-middle"
style={{ backgroundColor: cursor }}
/>
</div>
</div>
<div
className="shrink-0"
style={{ width: `${dividerThicknessPx}px`, backgroundColor: appearance.dividerColor }}
/>
<div
className="w-[38%] p-3 font-mono text-[12px] leading-6"
style={{
backgroundColor: background,
color: foreground,
opacity: inactivePaneOpacity
}}
>
<div className="opacity-70">palette</div>
<div className="mt-3 grid grid-cols-4 gap-2">
{(palette.length ? palette : [foreground]).map((swatch, index) => (
<div
key={`${swatch}-${index}`}
className="h-6 rounded-sm border border-black/10"
style={{ backgroundColor: swatch }}
/>
))}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
+6
View File
@@ -17,6 +17,12 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onOpenSettings(() => {
useAppStore.getState().setActiveView('settings')
})
)
return () => unsubs.forEach((fn) => fn())
}, [])
}
+116
View File
@@ -0,0 +1,116 @@
import { getBuiltinTheme, listBuiltinThemeNames, type GhosttyTheme } from 'restty'
import type { GlobalSettings } from '../../../shared/types'
export const BUILTIN_TERMINAL_THEME_NAMES = listBuiltinThemeNames()
export const DEFAULT_TERMINAL_THEME_DARK = 'Ghostty Default Style Dark'
export const DEFAULT_TERMINAL_THEME_LIGHT = 'Builtin Tango Light'
export const DEFAULT_TERMINAL_DIVIDER_DARK = '#3f3f46'
export const DEFAULT_TERMINAL_DIVIDER_LIGHT = '#d4d4d8'
export type EffectiveTerminalAppearance = {
mode: 'dark' | 'light'
sourceTheme: 'system' | 'dark' | 'light'
themeName: string
dividerColor: string
theme: GhosttyTheme | null
systemPrefersDark: boolean
}
export function getSystemPrefersDark(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
export function getTerminalThemePreview(name: string): GhosttyTheme | null {
const theme = getBuiltinTheme(name)
if (theme) return theme
return getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK)
}
export function resolveEffectiveTerminalAppearance(
settings: Pick<
GlobalSettings,
| 'theme'
| 'terminalThemeDark'
| 'terminalDividerColorDark'
| 'terminalUseSeparateLightTheme'
| 'terminalThemeLight'
| 'terminalDividerColorLight'
>,
systemPrefersDark = getSystemPrefersDark()
): EffectiveTerminalAppearance {
const sourceTheme =
settings.theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : settings.theme
const useLightVariant = sourceTheme === 'light' && settings.terminalUseSeparateLightTheme
const themeName = useLightVariant
? settings.terminalThemeLight || DEFAULT_TERMINAL_THEME_LIGHT
: settings.terminalThemeDark || DEFAULT_TERMINAL_THEME_DARK
const dividerColor = useLightVariant
? normalizeColor(settings.terminalDividerColorLight, DEFAULT_TERMINAL_DIVIDER_LIGHT)
: normalizeColor(settings.terminalDividerColorDark, DEFAULT_TERMINAL_DIVIDER_DARK)
return {
mode: sourceTheme,
sourceTheme: settings.theme,
themeName,
dividerColor,
theme: getTerminalThemePreview(themeName),
systemPrefersDark
}
}
export function normalizeColor(value: string | undefined, fallback: string): string {
const trimmed = value?.trim()
if (!trimmed) return fallback
return trimmed
}
export function buildTerminalFontMatchers(fontFamily: string): string[] {
const trimmed = fontFamily.trim()
const normalized = trimmed.toLowerCase()
const matchers = trimmed ? [trimmed, normalized] : []
return Array.from(new Set([...matchers, 'sf mono', 'sfmono-regular', 'menlo', 'menlo regular']))
}
export function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function resolvePaneStyleOptions(
settings: Pick<
GlobalSettings,
| 'terminalInactivePaneOpacity'
| 'terminalActivePaneOpacity'
| 'terminalPaneOpacityTransitionMs'
| 'terminalDividerThicknessPx'
>
) {
return {
inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1),
activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1),
opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000),
dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32)
}
}
export function colorToCss(
color: { r: number; g: number; b: number; a?: number } | string | undefined,
fallback: string
): string {
if (!color || typeof color === 'string') return fallback
const alpha = typeof color.a === 'number' ? color.a / 255 : 1
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`
}
export function terminalPalettePreview(theme: GhosttyTheme | null): string[] {
if (!theme) return []
const colors = theme.colors.palette
const swatches: string[] = []
for (let i = 0; i < 16; i += 1) {
const color = colors[i]
if (!color) continue
swatches.push(colorToCss(color, '#000000'))
}
return swatches
}
+10 -1
View File
@@ -27,7 +27,16 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
branchPrefixCustom: '',
theme: 'system',
terminalFontSize: 14,
terminalFontFamily: 'SF Mono'
terminalFontFamily: 'SF Mono',
terminalThemeDark: 'Ghostty Default Style Dark',
terminalDividerColorDark: '#3f3f46',
terminalUseSeparateLightTheme: false,
terminalThemeLight: 'Builtin Tango Light',
terminalDividerColorLight: '#d4d4d8',
terminalInactivePaneOpacity: 0.8,
terminalActivePaneOpacity: 1,
terminalPaneOpacityTransitionMs: 140,
terminalDividerThicknessPx: 1
}
}
+9
View File
@@ -129,6 +129,15 @@ export interface GlobalSettings {
theme: 'system' | 'dark' | 'light'
terminalFontSize: number
terminalFontFamily: string
terminalThemeDark: string
terminalDividerColorDark: string
terminalUseSeparateLightTheme: boolean
terminalThemeLight: string
terminalDividerColorLight: string
terminalInactivePaneOpacity: number
terminalActivePaneOpacity: number
terminalPaneOpacityTransitionMs: number
terminalDividerThicknessPx: number
}
export interface PersistedUIState {