diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 8548917a..c1957a3e 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App' import { sanitizeProps } from '../electron/telemetry' @@ -52,6 +52,16 @@ function setVisibility(state: 'visible' | 'hidden') { Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' }) } +// The shortcut code (lib/platform.ts) reads `window.codeburn.platform` at call +// time; stub it per test and always restore so no test leaks platform state. +function setPlatform(platform: string): void { + ;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform } +} + +function clearPlatform(): void { + delete (window as unknown as { codeburn?: { platform?: string } }).codeburn +} + function overviewPayload(): MenubarPayload { const now = new Date() return { @@ -181,6 +191,11 @@ describe('App shortcuts', () => { // the app-wide default ('today'); tests that exercise the default set it. localStorage.setItem('codeburn.defaultPeriod', '30days') document.documentElement.removeAttribute('data-theme') + setPlatform('darwin') + }) + + afterEach(() => { + clearPlatform() }) it('applies the persisted theme on app boot before Settings mounts', async () => { @@ -211,46 +226,59 @@ describe('App shortcuts', () => { expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() }) - it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => { + it.each([ + ['darwin', { metaKey: true }, '⌘'], + ['win32', { ctrlKey: true }, 'Ctrl+'], + ] as const)('keeps %s navigation, settings, and refresh shortcuts active without stale hints', async (platform, chord, mod) => { + setPlatform(platform) render() expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() - expect(screen.getByText('⌘1-8')).toBeInTheDocument() - expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0) - expect(screen.getByText('⌘R')).toBeInTheDocument() + expect(screen.getByText(`${mod}1-8`)).toBeInTheDocument() + expect(screen.getAllByText(`${mod},`).length).toBeGreaterThan(0) + expect(screen.getByText(`${mod}R`)).toBeInTheDocument() expect(screen.queryByText('Command')).not.toBeInTheDocument() expect(screen.queryByText('Export view')).not.toBeInTheDocument() - fireEvent.keyDown(document, { key: '2', metaKey: true }) + fireEvent.keyDown(document, { key: '2', ...chord }) expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '3', metaKey: true }) + fireEvent.keyDown(document, { key: '3', ...chord }) expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument() - fireEvent.keyDown(document, { key: '4', metaKey: true }) + fireEvent.keyDown(document, { key: '4', ...chord }) expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '5', metaKey: true }) + fireEvent.keyDown(document, { key: '5', ...chord }) expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '6', metaKey: true }) + fireEvent.keyDown(document, { key: '6', ...chord }) expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '7', metaKey: true }) + fireEvent.keyDown(document, { key: '7', ...chord }) expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '8', metaKey: true }) + fireEvent.keyDown(document, { key: '8', ...chord }) expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: ',', metaKey: true }) + fireEvent.keyDown(document, { key: ',', ...chord }) expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0) expect(screen.queryByText('Back')).not.toBeInTheDocument() const overviewCalls = mocks.getOverview.mock.calls.length - fireEvent.keyDown(document, { key: 'r', metaKey: true }) + fireEvent.keyDown(document, { key: 'r', ...chord }) await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls)) }) + it('ignores Ctrl+2 on mac', async () => { + render() + + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', ctrlKey: true }) + expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument() + }) + it('re-polls visible section data when period or provider changes', async () => { render() @@ -481,6 +509,48 @@ describe('App shortcuts', () => { }) }) +describe('win32 shortcut chords', () => { + beforeEach(() => { + installDefaultMocks() + localStorage.clear() + localStorage.setItem('codeburn.defaultPeriod', '30days') + document.documentElement.removeAttribute('data-theme') + setPlatform('win32') + }) + + afterEach(() => { + clearPlatform() + }) + + it('navigates with Ctrl+2 and refreshes with Ctrl+R', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', ctrlKey: true }) + expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() + + const overviewCalls = mocks.getOverview.mock.calls.length + fireEvent.keyDown(document, { key: 'r', ctrlKey: true }) + await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls)) + }) + + it('ignores Meta+2 on win32', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', metaKey: true }) + expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument() + }) + + it('ignores Ctrl+Alt+2 (the AltGr shape) on win32', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', ctrlKey: true, altKey: true }) + expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument() + }) +}) + describe('provider prefetch storm', () => { const PROVIDERS = [ 'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid', @@ -618,6 +688,11 @@ describe('currency correctness', () => { // independent of the app-wide default ('today'). localStorage.setItem('codeburn.defaultPeriod', '30days') __resetPolledMemo() + setPlatform('darwin') + }) + + afterEach(() => { + clearPlatform() }) it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => { diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index a3110a21..48295d6b 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -16,6 +16,7 @@ import { readDailyBudget } from './lib/budget' import { formatCompact, formatUsd, setActiveCurrency } from './lib/format' import { motionClass } from './lib/motion' import { codeburn } from './lib/ipc' +import { isModifierChord, shortcutLabel } from './lib/platform' import { localDateKey } from './lib/period' import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence' import { OverviewContent } from './sections/Overview' @@ -440,7 +441,7 @@ function AppMain() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + if (!isModifierChord(event)) return const key = event.key.toLowerCase() if (key === '1') navigate('overview') else if (key === '2') navigate('sessions') @@ -577,9 +578,9 @@ function AppMain() { {section !== 'settings' && ( diff --git a/app/renderer/components/Sidebar.test.tsx b/app/renderer/components/Sidebar.test.tsx index d87b058b..64454053 100644 --- a/app/renderer/components/Sidebar.test.tsx +++ b/app/renderer/components/Sidebar.test.tsx @@ -1,18 +1,31 @@ // @vitest-environment jsdom -import { describe, it, expect, vi } from 'vitest' +import { afterEach, describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { Sidebar } from './Sidebar' +function setPlatform(platform: string): void { + ;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform } +} + describe('Sidebar', () => { - it('renders all nine nav items in the desktop order', () => { + afterEach(() => { + delete (window as unknown as { codeburn?: { platform?: string } }).codeburn + }) + + it.each([ + ['darwin', '⌘'], + ['win32', 'Ctrl+'], + ] as const)('renders all nine nav items in the desktop order with %s keycaps', (platform, mod) => { + setPlatform(platform) render( {}} />) - const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, '')) + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,]/, '')) expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings']) - expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Pull requests.*⌘3/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Compare.*⌘7/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Plans.*⌘8/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Sessions.*${esc(mod)}2`) })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Pull requests.*${esc(mod)}3`) })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Compare.*${esc(mod)}7`) })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Plans.*${esc(mod)}8`) })).toBeInTheDocument() }) it('calls onNavigate with the section id when a nav item is clicked', () => { diff --git a/app/renderer/components/Sidebar.tsx b/app/renderer/components/Sidebar.tsx index ab4c5530..ebbd5eed 100644 --- a/app/renderer/components/Sidebar.tsx +++ b/app/renderer/components/Sidebar.tsx @@ -1,37 +1,38 @@ import { useState, type ReactNode } from 'react' import { codeburn } from '../lib/ipc' +import { shortcutLabel } from '../lib/platform' import { AboutModal, type SocialLink } from './AboutModal' import { FlameMark } from './FlameMark' export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings' export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [ - { id: 'overview', label: 'Overview', key: '⌘1', icon: ( + { id: 'overview', label: 'Overview', key: '1', icon: ( ) }, - { id: 'sessions', label: 'Sessions', key: '⌘2', icon: ( + { id: 'sessions', label: 'Sessions', key: '2', icon: ( ) }, - { id: 'pullRequests', label: 'Pull requests', key: '⌘3', icon: ( + { id: 'pullRequests', label: 'Pull requests', key: '3', icon: ( ) }, - { id: 'spend', label: 'Spend', key: '⌘4', icon: ( + { id: 'spend', label: 'Spend', key: '4', icon: ( ) }, - { id: 'optimize', label: 'Optimize', key: '⌘5', icon: ( + { id: 'optimize', label: 'Optimize', key: '5', icon: ( ) }, - { id: 'models', label: 'Models', key: '⌘6', icon: ( + { id: 'models', label: 'Models', key: '6', icon: ( ) }, - { id: 'compare', label: 'Compare', key: '⌘7', icon: ( + { id: 'compare', label: 'Compare', key: '7', icon: ( ) }, - { id: 'plans', label: 'Plans', key: '⌘8', icon: ( + { id: 'plans', label: 'Plans', key: '8', icon: ( ) }, - { id: 'settings', label: 'Settings', key: '⌘,', icon: ( + { id: 'settings', label: 'Settings', key: ',', icon: ( ) }, ] @@ -75,7 +76,7 @@ export function Sidebar({ > {item.icon} {item.label} - {item.key} + {shortcutLabel(item.key)} ))}
diff --git a/app/renderer/lib/platform.ts b/app/renderer/lib/platform.ts new file mode 100644 index 00000000..f0c62b88 --- /dev/null +++ b/app/renderer/lib/platform.ts @@ -0,0 +1,46 @@ +// Single source of truth for platform-aware shortcut behaviour. The preload +// exposes `window.codeburn.platform` (process.platform); when the bridge is +// absent (unit tests, vite in a plain browser) fall back to the user agent. +// All functions read platform state at call time, never at module load, so +// the preload bridge may appear after this module is imported. + +function bridgePlatform(): string | undefined { + if (typeof window === 'undefined') return undefined + return (window as unknown as { codeburn?: { platform?: string } }).codeburn?.platform +} + +function userAgentPlatform(): string | undefined { + if (typeof navigator === 'undefined') return undefined + if (/mac/i.test(navigator.userAgent)) return 'darwin' + const platform = navigator.platform + if (typeof platform === 'string' && /mac/i.test(platform)) return 'darwin' + return undefined +} + +/** True when the Electron preload reports darwin (or the UA matches a Mac). */ +export function isMacPlatform(): boolean { + const platform = bridgePlatform() + if (platform) return platform === 'darwin' + return userAgentPlatform() === 'darwin' +} + +/** The modifier keycap label: '⌘' on mac, 'Ctrl+' elsewhere. */ +export function modKeyLabel(): string { + return isMacPlatform() ? '⌘' : 'Ctrl+' +} + +/** A full shortcut label, e.g. '⌘R' on mac, 'Ctrl+R' on Windows. */ +export function shortcutLabel(key: string): string { + return modKeyLabel() + key +} + +/** + * True when the event is the platform's modifier chord and no other modifier + * is held. On mac: Meta (Cmd) without Ctrl. Elsewhere: Ctrl without Meta. + * altKey stays rejected on every platform: AltGr on European layouts arrives + * as Ctrl+Alt, and Ctrl+Alt+ must not hijack a typed character. + */ +export function isModifierChord(event: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean { + if (event.altKey || event.shiftKey) return false + return isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey +} diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index f3af6fed..41ddcf46 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -13,6 +13,7 @@ import { version as appVersion } from '../../package.json' import { readDailyBudget } from '../lib/budget' import { formatConverted, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { shortcutLabel } from '../lib/platform' import { motionClass } from '../lib/motion' import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence' import { showToast } from '../lib/toast' @@ -123,7 +124,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl {pane === 'privacy' && }
- + ) } @@ -203,7 +204,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
onScopeChange?.(value)} width={110} />
-
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
+
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
{budgetError &&

{budgetError}

}