Files
orca/src/shared/plugin-theme-settings.ts
T
Neil d10ab00f3c feat(themes): Match Terminal becomes a real app theme for the whole window (#36567)
* feat(themes): make Match Terminal a real app theme painted at :root

Match Terminal used to be an inline override on three left-column
elements, so the tab bar, right sidebar, status bar, titlebar, menus and
editors kept the built-in palette, and its fixed color mixes became
unreadable on low-contrast palettes.

It is now a light/dark app theme slot value synthesized from the active
terminal palette (read through resolveTerminalPalette, so each mode's
color overrides apply) and painted through the plugin theme pipeline
(derivation, contrast gate, startup cache, Monaco theme). The document
mode follows the terminal background, including onboarding's restore.
The legacy leftSidebarAppearanceMode='match-terminal' value fills the
empty slots on load and on writes (desktop and web), and it is kept
mirrored while a slot matches the terminal so older Orca still matches
its sidebar. The pickers mark the option with a muted "(dynamic)" label.

* fix(onboarding): sync the painted-theme ref in an effect, not during render

* feat(themes): use a lightning icon for Match Terminal's dynamic label

* perf(themes): reuse the Match Terminal theme when its content is unchanged
2026-11-01 01:19:38 -07:00

234 lines
8.3 KiB
TypeScript

import type { GlobalSettings } from './global-settings-types'
import type {
AppThemeSlotSelection,
CodeEditorThemeChoice
} from './plugin-appearance-settings-types'
import type { TerminalPluginPalette } from './terminal-color-overrides'
import {
hasUsableTerminalThemeColors,
normalizeTerminalColorOverrides
} from './terminal-custom-themes'
type PluginThemeSettings = Pick<
GlobalSettings,
| 'pluginAppTheme'
| 'pluginAppThemeLight'
| 'pluginAppThemeDark'
| 'editorThemeLight'
| 'editorThemeDark'
| 'pluginIconTheme'
| 'themeBeforePlugin'
| 'terminalPluginPalettes'
>
/** The app theme slot value that paints Orca from the active terminal theme's colors. */
export const MATCH_TERMINAL_APP_THEME = 'match-terminal' satisfies AppThemeSlotSelection
/** The setting that holds the app theme for one light/dark mode. */
export function pluginAppThemeSlotUpdate(
base: 'light' | 'dark',
id: AppThemeSlotSelection | null
): Pick<GlobalSettings, 'pluginAppThemeLight'> | Pick<GlobalSettings, 'pluginAppThemeDark'> {
return base === 'dark' ? { pluginAppThemeDark: id } : { pluginAppThemeLight: id }
}
/** The setting that holds the code editor theme for one light/dark mode. */
export function editorThemeSlotUpdate(
base: 'light' | 'dark',
choice: CodeEditorThemeChoice | null
): Pick<GlobalSettings, 'editorThemeLight'> | Pick<GlobalSettings, 'editorThemeDark'> {
return base === 'dark' ? { editorThemeDark: choice } : { editorThemeLight: choice }
}
// Two terminal slots (dark, light) can each hold one plugin palette.
const MAX_TERMINAL_PLUGIN_PALETTES = 2
const PLUGIN_CONTENT_ID =
/^plugin:[a-z0-9][a-z0-9-]{0,63}\.[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9-]{0,63}$/
function contentId(value: unknown): `plugin:${string}` | null {
return typeof value === 'string' && PLUGIN_CONTENT_ID.test(value)
? `plugin:${value.slice('plugin:'.length)}`
: null
}
function editorThemeChoice(value: unknown): CodeEditorThemeChoice | null {
return value === 'light' || value === 'dark' ? value : contentId(value)
}
function palettes(value: unknown): TerminalPluginPalette[] {
if (!Array.isArray(value)) {
return []
}
const result: TerminalPluginPalette[] = []
for (const entry of value) {
const id =
typeof entry === 'object' && entry !== null && 'id' in entry ? contentId(entry.id) : null
const colors = normalizeTerminalColorOverrides(
typeof entry === 'object' && entry !== null && 'colors' in entry ? entry.colors : undefined
)
if (id && hasUsableTerminalThemeColors(colors) && !result.some((p) => p.id === id)) {
result.push({ id, colors })
}
}
return result.slice(0, MAX_TERMINAL_PLUGIN_PALETTES)
}
function appThemeSlot(value: unknown): AppThemeSlotSelection | null {
return value === MATCH_TERMINAL_APP_THEME ? MATCH_TERMINAL_APP_THEME : contentId(value)
}
/** Unvalidated plugin theme keys as a settings write or load carries them. */
export type PluginThemeSettingsInput = { [Key in keyof PluginThemeSettings]?: unknown }
/** Bounds plugin theme settings on every write and load; only keys present in `input` return. */
export function normalizePluginThemeSettings(
input: PluginThemeSettingsInput
): Partial<PluginThemeSettings> {
const result: Partial<PluginThemeSettings> = {}
if ('pluginAppTheme' in input) {
result.pluginAppTheme = contentId(input.pluginAppTheme)
}
if ('pluginAppThemeLight' in input) {
result.pluginAppThemeLight = appThemeSlot(input.pluginAppThemeLight)
}
if ('pluginAppThemeDark' in input) {
result.pluginAppThemeDark = appThemeSlot(input.pluginAppThemeDark)
}
if ('editorThemeLight' in input) {
result.editorThemeLight = editorThemeChoice(input.editorThemeLight)
}
if ('editorThemeDark' in input) {
result.editorThemeDark = editorThemeChoice(input.editorThemeDark)
}
if ('pluginIconTheme' in input) {
result.pluginIconTheme = contentId(input.pluginIconTheme)
}
if ('themeBeforePlugin' in input) {
const theme = input.themeBeforePlugin
result.themeBeforePlugin =
theme === 'system' || theme === 'dark' || theme === 'light' ? theme : null
}
if ('terminalPluginPalettes' in input) {
result.terminalPluginPalettes = palettes(input.terminalPluginPalettes)
}
return result
}
type MatchTerminalSidebarSettings = Partial<
Pick<GlobalSettings, 'pluginAppThemeLight' | 'pluginAppThemeDark' | 'leftSidebarAppearanceMode'>
>
/** Whether stored settings carry the sidebar value from before the app theme slots existed (or
* from an older Orca that cleared the slots it didn't understand). */
export function hasLegacyMatchTerminalSidebar(settings: MatchTerminalSidebarSettings): boolean {
return (
settings.leftSidebarAppearanceMode === 'match-terminal' &&
settings.pluginAppThemeLight !== MATCH_TERMINAL_APP_THEME &&
settings.pluginAppThemeDark !== MATCH_TERMINAL_APP_THEME
)
}
/**
* Keeps the sidebar's "Match Terminal" value in step with the app theme slots. Older Orca only
* reads the sidebar value, so it is written while a slot matches the terminal; a legacy write of
* it fills the empty slots, and a slot holding a plugin theme keeps it.
*/
export function syncLegacyMatchTerminalSidebar(
settings: MatchTerminalSidebarSettings,
legacyWrite: boolean
): Required<MatchTerminalSidebarSettings> {
const slot = (value: AppThemeSlotSelection | null | undefined): AppThemeSlotSelection | null =>
value ?? (legacyWrite ? MATCH_TERMINAL_APP_THEME : null)
const pluginAppThemeLight = slot(settings.pluginAppThemeLight)
const pluginAppThemeDark = slot(settings.pluginAppThemeDark)
const matches =
pluginAppThemeLight === MATCH_TERMINAL_APP_THEME ||
pluginAppThemeDark === MATCH_TERMINAL_APP_THEME
return {
pluginAppThemeLight,
pluginAppThemeDark,
leftSidebarAppearanceMode:
settings.leftSidebarAppearanceMode === 'tinted'
? 'tinted'
: matches
? 'match-terminal'
: 'default'
}
}
/** The settings write that keeps the sidebar value in step; empty when it touches neither. */
export function legacyMatchTerminalSidebarWrite(
current: MatchTerminalSidebarSettings,
updates: Partial<GlobalSettings>
): Partial<GlobalSettings> {
if (
!('pluginAppThemeLight' in updates) &&
!('pluginAppThemeDark' in updates) &&
!('leftSidebarAppearanceMode' in updates)
) {
return {}
}
// Why slots absent: a full-settings write carries the mirror next to the slots it chose.
const legacyWrite =
updates.leftSidebarAppearanceMode === 'match-terminal' &&
!('pluginAppThemeLight' in updates) &&
!('pluginAppThemeDark' in updates)
return syncLegacyMatchTerminalSidebar({ ...current, ...updates }, legacyWrite)
}
function ownedBy(id: string | null | undefined, pluginKey: string): boolean {
return typeof id === 'string' && id.startsWith(`plugin:${pluginKey}/`)
}
/** Forgets an uninstalled plugin's theme choices so a reinstall does not quietly reapply them. */
export function pluginThemeSettingsAfterRemoval(
settings: Pick<
GlobalSettings,
| 'theme'
| 'terminalThemeDark'
| 'terminalThemeLight'
| 'pluginAppTheme'
| 'pluginAppThemeLight'
| 'pluginAppThemeDark'
| 'editorThemeLight'
| 'editorThemeDark'
| 'pluginIconTheme'
| 'themeBeforePlugin'
| 'terminalPluginPalettes'
>,
pluginKey: string
): Partial<GlobalSettings> {
const update: Partial<GlobalSettings> = {}
if (ownedBy(settings.pluginAppTheme, pluginKey)) {
update.pluginAppTheme = null
update.themeBeforePlugin = null
update.theme = settings.themeBeforePlugin ?? settings.theme
}
if (ownedBy(settings.pluginAppThemeLight, pluginKey)) {
update.pluginAppThemeLight = null
}
if (ownedBy(settings.pluginAppThemeDark, pluginKey)) {
update.pluginAppThemeDark = null
}
if (ownedBy(settings.editorThemeLight, pluginKey)) {
update.editorThemeLight = null
}
if (ownedBy(settings.editorThemeDark, pluginKey)) {
update.editorThemeDark = null
}
if (ownedBy(settings.pluginIconTheme, pluginKey)) {
update.pluginIconTheme = null
}
if (ownedBy(settings.terminalThemeDark, pluginKey)) {
update.terminalThemeDark = ''
}
if (ownedBy(settings.terminalThemeLight, pluginKey)) {
update.terminalThemeLight = ''
}
const kept = (settings.terminalPluginPalettes ?? []).filter((p) => !ownedBy(p.id, pluginKey))
if (kept.length !== (settings.terminalPluginPalettes ?? []).length) {
update.terminalPluginPalettes = kept
}
return update
}