Add app icon switcher to appearance settings (#4600)

This commit is contained in:
Neil 2026-06-03 17:14:50 -07:00 committed by GitHub
parent eb96ef94de
commit 6b87b9a32c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 323 additions and 17 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

79
src/main/app-icon.test.ts Normal file
View File

@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
browserWindowGetAllWindowsMock,
createFromPathMock,
dockSetIconMock,
isMock,
windowSetIconMock
} = vi.hoisted(() => ({
browserWindowGetAllWindowsMock: vi.fn(),
createFromPathMock: vi.fn(),
dockSetIconMock: vi.fn(),
isMock: { dev: false },
windowSetIconMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { dock: { setIcon: dockSetIconMock } },
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
nativeImage: { createFromPath: createFromPathMock }
}))
vi.mock('@electron-toolkit/utils', () => ({
is: isMock
}))
vi.mock('../../resources/icon.png?asset', () => ({
default: 'classic-icon'
}))
vi.mock('../../resources/icon-dev.png?asset', () => ({
default: 'classic-dev-icon'
}))
vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({
default: 'watercolor-icon'
}))
vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({
default: 'blue-icon'
}))
import { applyAppIcon, getAppIconPath } from './app-icon'
describe('app icon selection', () => {
beforeEach(() => {
browserWindowGetAllWindowsMock.mockReset()
createFromPathMock.mockReset()
dockSetIconMock.mockReset()
windowSetIconMock.mockReset()
isMock.dev = false
})
it('resolves classic, watercolor, blue, and invalid icon ids', () => {
expect(getAppIconPath('classic')).toBe('classic-icon')
expect(getAppIconPath('watercolor')).toBe('watercolor-icon')
expect(getAppIconPath('blue')).toBe('blue-icon')
expect(getAppIconPath('missing')).toBe('classic-icon')
})
it('applies the selected icon to the dock and live windows', () => {
const image = { isEmpty: () => false }
createFromPathMock.mockReturnValue(image)
browserWindowGetAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, setIcon: windowSetIconMock },
{ isDestroyed: () => true, setIcon: vi.fn() }
])
applyAppIcon('watercolor')
expect(createFromPathMock).toHaveBeenCalledWith('watercolor-icon')
if (process.platform === 'darwin') {
expect(dockSetIconMock).toHaveBeenCalledWith(image)
} else {
expect(dockSetIconMock).not.toHaveBeenCalled()
}
expect(windowSetIconMock).toHaveBeenCalledWith(image)
})
})

36
src/main/app-icon.ts Normal file
View File

@ -0,0 +1,36 @@
import { app, BrowserWindow, nativeImage } from 'electron'
import { is } from '@electron-toolkit/utils'
import classicIcon from '../../resources/icon.png?asset'
import classicDevIcon from '../../resources/icon-dev.png?asset'
import watercolorIcon from '../../resources/app-icons/orca-watercolor.png?asset'
import blueIcon from '../../resources/app-icons/orca-blue.png?asset'
import { normalizeAppIconId, type AppIconId } from '../shared/app-icon'
const APP_ICON_PATHS = {
classic: is.dev ? classicDevIcon : classicIcon,
watercolor: watercolorIcon,
blue: blueIcon
} satisfies Record<AppIconId, string>
export function getAppIconPath(value: unknown): string {
return APP_ICON_PATHS[normalizeAppIconId(value)]
}
export function createAppIconImage(value: unknown): Electron.NativeImage {
return nativeImage.createFromPath(getAppIconPath(value))
}
export function applyAppIcon(value: unknown): void {
const image = createAppIconImage(value)
if (image.isEmpty()) {
return
}
if (process.platform === 'darwin') {
app.dock?.setIcon(image)
}
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.setIcon(image)
}
}
}

View File

@ -49,6 +49,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
branchPrefix: 'git-username',
branchPrefixCustom: '',
theme: 'system',
appIcon: overrides.appIcon ?? 'classic',
editorAutoSave: false,
editorAutoSaveDelayMs: 1000,
editorMinimapEnabled: false,

View File

@ -53,6 +53,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
branchPrefix: 'git-username',
branchPrefixCustom: '',
theme: 'system',
appIcon: overrides.appIcon ?? 'classic',
editorAutoSave: false,
editorAutoSaveDelayMs: 1000,
editorMinimapEnabled: false,

View File

@ -6,11 +6,11 @@ import { grantDirAcl } from './win32-utils'
import { existsSync } from 'fs'
import { join } from 'path'
import os from 'node:os'
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { app, BrowserWindow, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import * as QRCode from 'qrcode'
import devIcon from '../../resources/icon-dev.png?asset'
import { Store, initDataPath } from './persistence'
import { applyAppIcon } from './app-icon'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
@ -1052,12 +1052,8 @@ app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
if (process.platform === 'darwin' && is.dev) {
const dockIcon = nativeImage.createFromPath(devIcon)
app.dock?.setIcon(dockIcon)
}
store = new Store()
applyAppIcon(store.getSettings().appIcon)
if (shouldSuppressDevEducation({ isDev: is.dev })) {
suppressDevEducationForStore(store)
}

View File

@ -1,11 +1,13 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
const {
applyAppIconMock,
applyElectronProxySettingsMock,
browserWindowGetAllWindowsMock,
handleMock,
previewGhosttyImportMock
} = vi.hoisted(() => ({
applyAppIconMock: vi.fn(),
applyElectronProxySettingsMock: vi.fn(),
browserWindowGetAllWindowsMock: vi.fn(),
handleMock: vi.fn(),
@ -26,6 +28,10 @@ vi.mock('../network/proxy-settings', () => ({
applyElectronProxySettings: applyElectronProxySettingsMock
}))
vi.mock('../app-icon', () => ({
applyAppIcon: applyAppIconMock
}))
import { registerSettingsHandlers } from './settings'
const settingsInvokeEvent = { sender: { id: 1 } }
@ -46,6 +52,7 @@ const store = {
describe('registerSettingsHandlers', () => {
beforeEach(() => {
handleMock.mockClear()
applyAppIconMock.mockClear()
applyElectronProxySettingsMock.mockClear()
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
previewGhosttyImportMock.mockClear()
@ -216,4 +223,42 @@ describe('registerSettingsHandlers', () => {
)
expect(applyElectronProxySettingsMock).toHaveBeenCalledWith({ httpProxyUrl: '' })
})
it('normalizes and applies app icon changes from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ appIcon: 'classic' })
store.updateSettings.mockReturnValue({ appIcon: 'watercolor' })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(settingsInvokeEvent, { appIcon: 'watercolor' })
expect(store.updateSettings).toHaveBeenCalledWith(
{ appIcon: 'watercolor' },
{ notifyListeners: true, originWebContentsId: 1 }
)
expect(applyAppIconMock).toHaveBeenCalledWith('watercolor')
})
it('falls back to the classic app icon for invalid renderer settings IPC values', async () => {
store.getSettings.mockReturnValue({ appIcon: 'watercolor' })
store.updateSettings.mockReturnValue({ appIcon: 'classic' })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(settingsInvokeEvent, { appIcon: 'not-real' })
expect(store.updateSettings).toHaveBeenCalledWith(
{ appIcon: 'classic' },
{ notifyListeners: true, originWebContentsId: 1 }
)
expect(applyAppIconMock).toHaveBeenCalledWith('classic')
})
})

View File

@ -11,6 +11,8 @@ import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { applyElectronProxySettings } from '../network/proxy-settings'
import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy'
import { normalizeAppIconId } from '../../shared/app-icon'
import { applyAppIcon } from '../app-icon'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@ -63,6 +65,9 @@ export function registerSettingsHandlers(
if ('httpProxyBypassRules' in args) {
sanitizedArgs.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules)
}
if ('appIcon' in args) {
sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon)
}
if (args.theme) {
nativeTheme.themeSource = args.theme
}
@ -98,6 +103,9 @@ export function registerSettingsHandlers(
console.warn('[settings] failed to apply network proxy settings')
}
}
if ('appIcon' in sanitizedArgs && before.appIcon !== result.appIcon) {
applyAppIcon(result.appIcon)
}
// Why: telemetry-plan.md§Settings — fire `settings_changed` only for
// whitelisted keys, with `value_kind` distinguishing booleans from

View File

@ -257,6 +257,7 @@ describe('Store', () => {
expect(settings.branchPrefix).toBe('git-username')
expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false)
expect(settings.theme).toBe('system')
expect(settings.appIcon).toBe('classic')
expect(settings.appFontFamily).toBe('Geist')
expect(settings.editorAutoSave).toBe(false)
expect(settings.editorAutoSaveDelayMs).toBe(1000)
@ -2269,6 +2270,24 @@ describe('Store', () => {
expect(updated.disabledTuiAgents).toEqual(['gemini', 'opencode'])
})
it('normalizes app icon on load and update', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({
settings: {
appIcon: 'not-real'
}
})
)
const store = await createStore()
expect(store.getSettings().appIcon).toBe('classic')
expect(store.updateSettings({ appIcon: 'watercolor' }).appIcon).toBe('watercolor')
expect(store.updateSettings({ appIcon: 'blue' }).appIcon).toBe('blue')
expect(store.updateSettings({ appIcon: 'not-real' as never }).appIcon).toBe('classic')
})
it('updateSettings keeps the legacy commit-message AI projection in sync', async () => {
const store = await createStore()
const current = store.getSettings().sourceControlAi!

View File

@ -90,6 +90,7 @@ import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-command
import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeAppIconId } from '../shared/app-icon'
import {
normalizeFeatureInteractions,
type FeatureInteractionId
@ -1852,6 +1853,7 @@ export class Store {
terminalQuickCommands: normalizeTerminalQuickCommands(
parsed.settings?.terminalQuickCommands
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
visibleTaskProvidersDefaultedForJira: true,
@ -2983,6 +2985,9 @@ export class Store {
updates.terminalShortcutPolicy
)
}
if ('appIcon' in updates) {
sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon)
}
const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate(
this.state.settings,
sanitizedUpdates

View File

@ -36,12 +36,8 @@ vi.mock('@electron-toolkit/utils', () => ({
is: isMock
}))
vi.mock('../../../resources/icon.png?asset', () => ({
default: 'icon'
}))
vi.mock('../../../resources/icon-dev.png?asset', () => ({
default: 'icon-dev'
vi.mock('../app-icon', () => ({
getAppIconPath: vi.fn(() => 'icon')
}))
vi.mock('../browser/browser-manager', () => ({

View File

@ -2,9 +2,8 @@
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, screen, shell } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import icon from '../../../resources/icon.png?asset'
import devIcon from '../../../resources/icon-dev.png?asset'
import type { Store } from '../persistence'
import { getAppIconPath } from '../app-icon'
import { browserManager } from '../browser/browser-manager'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import {
@ -264,7 +263,7 @@ export function createMainWindow(
}
}
: {}),
icon: is.dev ? devIcon : icon,
icon: getAppIconPath(settings?.appIcon),
...platformBlurOptions,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),

View File

@ -0,0 +1,71 @@
import type React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import classicIconUrl from '../../../../../resources/icon.png?url'
import watercolorIconUrl from '../../../../../resources/app-icons/orca-watercolor.png?url'
import blueIconUrl from '../../../../../resources/app-icons/orca-blue.png?url'
import { APP_ICON_OPTIONS, normalizeAppIconId, type AppIconId } from '../../../../shared/app-icon'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
const APP_ICON_URLS = {
classic: classicIconUrl,
watercolor: watercolorIconUrl,
blue: blueIconUrl
} satisfies Record<AppIconId, string>
type AppIconSelectorProps = {
value: AppIconId
onChange: (value: AppIconId) => void
}
function getAppIconOptionIndex(value: AppIconId): number {
const index = APP_ICON_OPTIONS.findIndex((option) => option.id === value)
return Math.max(index, 0)
}
function getOffsetIcon(value: AppIconId, offset: -1 | 1): AppIconId {
const index = getAppIconOptionIndex(value)
const next = (index + offset + APP_ICON_OPTIONS.length) % APP_ICON_OPTIONS.length
return APP_ICON_OPTIONS[next].id
}
type IconCycleButtonProps = {
label: string
onClick: () => void
children: React.ReactNode
}
function IconCycleButton({ label, onClick, children }: IconCycleButtonProps): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={label} onClick={onClick}>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
export function AppIconSelector({ value, onChange }: AppIconSelectorProps): React.JSX.Element {
const selected = normalizeAppIconId(value)
return (
<div className="flex items-center justify-center gap-2">
<IconCycleButton label="Previous icon" onClick={() => onChange(getOffsetIcon(selected, -1))}>
<ChevronLeft className="size-4" />
</IconCycleButton>
<img
src={APP_ICON_URLS[selected]}
alt="Selected app icon"
className="size-24 rounded-2xl object-contain"
/>
<IconCycleButton label="Next icon" onClick={() => onChange(getOffsetIcon(selected, 1))}>
<ChevronRight className="size-4" />
</IconCycleButton>
</div>
)
}

View File

@ -17,8 +17,10 @@ import {
SettingsSwitchRow
} from './SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { normalizeAppIconId } from '../../../../shared/app-icon'
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
import {
APP_ICON_ENTRIES,
APPEARANCE_PANE_SEARCH_ENTRIES,
LAYOUT_ENTRIES,
SIDEBAR_ENTRIES,
@ -32,6 +34,7 @@ import {
import { TERMINAL_APPEARANCE_SEARCH_ENTRIES } from './terminal-search'
import { TerminalAppearanceSection } from './TerminalAppearanceSection'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import { AppIconSelector } from './AppIconSelector'
export { APPEARANCE_PANE_SEARCH_ENTRIES }
type AppearancePaneProps = {
@ -295,6 +298,25 @@ export function AppearancePane({
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, APP_ICON_ENTRIES) ? (
<section key="app-icon" className="space-y-3">
<SearchableSetting
title="App Icon"
description="Choose the app icon shown in the Dock and window switcher."
keywords={APP_ICON_ENTRIES.flatMap((entry) => [
entry.title,
entry.description ?? '',
...(entry.keywords ?? [])
])}
className="max-w-none py-2"
>
<AppIconSelector
value={normalizeAppIconId(settings.appIcon)}
onChange={(appIcon) => updateSettings({ appIcon })}
/>
</SearchableSetting>
</section>
) : null
].filter(Boolean)

View File

@ -120,6 +120,14 @@ export const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [
}
]
export const APP_ICON_ENTRIES: SettingsSearchEntry[] = [
{
title: 'App Icon',
description: 'Choose the app icon shown in the Dock and window switcher.',
keywords: ['app icon', 'orca', 'dock', 'window', 'switcher', 'blue', 'watercolor']
}
]
export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...THEME_ENTRIES,
...TYPOGRAPHY_ENTRIES,
@ -128,5 +136,6 @@ export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...LAYOUT_ENTRIES,
...TITLEBAR_ENTRIES,
...STATUS_BAR_ENTRIES,
...SIDEBAR_ENTRIES
...SIDEBAR_ENTRIES,
...APP_ICON_ENTRIES
]

15
src/shared/app-icon.ts Normal file
View File

@ -0,0 +1,15 @@
export const APP_ICON_OPTIONS = [
{ id: 'classic', label: 'Classic Orca' },
{ id: 'watercolor', label: 'Watercolor Orca' },
{ id: 'blue', label: 'Blue Orca' }
] as const
export type AppIconId = (typeof APP_ICON_OPTIONS)[number]['id']
export const DEFAULT_APP_ICON_ID: AppIconId = 'classic'
export function normalizeAppIconId(value: unknown): AppIconId {
return APP_ICON_OPTIONS.some((option) => option.id === value)
? (value as AppIconId)
: DEFAULT_APP_ICON_ID
}

View File

@ -18,6 +18,7 @@ import { cloneDefaultWorkspaceStatuses } from './workspace-statuses'
import { TASK_PROVIDERS } from './task-providers'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties'
import { getDefaultSourceControlAiSettings } from './source-control-ai'
import { DEFAULT_APP_ICON_ID } from './app-icon'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
@ -173,6 +174,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
branchPrefixCustom: '',
enableGitHubAttribution: false,
theme: 'system',
appIcon: DEFAULT_APP_ICON_ID,
appFontFamily: DEFAULT_APP_FONT_FAMILY,
editorAutoSave: false,
editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS,

View File

@ -18,6 +18,7 @@ import type { FeatureInteractionState } from './feature-interactions'
import type { GitBranchChangeStatus } from './git-status-types'
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
import type { RepoIcon } from './repo-icon'
import type { AppIconId } from './app-icon'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
@ -1952,6 +1953,7 @@ export type GlobalSettings = {
branchPrefixCustom: string
enableGitHubAttribution: boolean
theme: 'system' | 'dark' | 'light'
appIcon: AppIconId
appFontFamily: string
editorAutoSave: boolean
editorAutoSaveDelayMs: number