Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 89 additions & 14 deletions app/renderer/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(<App />)

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(<App />)

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(<App />)

Expand Down Expand Up @@ -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(<App />)
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(<App />)
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(<App />)
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',
Expand Down Expand Up @@ -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 () => {
Expand Down
9 changes: 5 additions & 4 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -577,9 +578,9 @@ function AppMain() {
{section !== 'settings' && (
<Hint
items={[
{ k: '⌘1-8', label: 'Navigate' },
{ k: '⌘,', label: 'Settings' },
{ k: '⌘R', label: 'Refresh' },
{ k: shortcutLabel('1-8'), label: 'Navigate' },
{ k: shortcutLabel(','), label: 'Settings' },
{ k: shortcutLabel('R'), label: 'Refresh' },
]}
right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)}
/>
Expand Down
27 changes: 20 additions & 7 deletions app/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Sidebar active="overview" onNavigate={() => {}} />)
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', () => {
Expand Down
21 changes: 11 additions & 10 deletions app/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1" /><rect x="14" y="3" width="7" height="5" rx="1" /><rect x="14" y="12" width="7" height="9" rx="1" /><rect x="3" y="16" width="7" height="5" rx="1" /></svg>
) },
{ id: 'sessions', label: 'Sessions', key: '2', icon: (
{ id: 'sessions', label: 'Sessions', key: '2', icon: (
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
) },
{ id: 'pullRequests', label: 'Pull requests', key: '3', icon: (
{ id: 'pullRequests', label: 'Pull requests', key: '3', icon: (
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
) },
{ id: 'spend', label: 'Spend', key: '4', icon: (
{ id: 'spend', label: 'Spend', key: '4', icon: (
<svg viewBox="0 0 24 24"><line x1="6" y1="20" x2="6" y2="13" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="18" y1="20" x2="18" y2="9" /></svg>
) },
{ id: 'optimize', label: 'Optimize', key: '5', icon: (
{ id: 'optimize', label: 'Optimize', key: '5', icon: (
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="3.4"/><path d="M10.5 3v1.7M10.5 16.3V18M3 10.5h1.7M16.3 10.5H18M5.3 5.3l1.2 1.2M14.5 14.5l1.2 1.2M15.7 5.3l-1.2 1.2M6.5 14.5l-1.2 1.2"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg>
) },
{ id: 'models', label: 'Models', key: '6', icon: (
{ id: 'models', label: 'Models', key: '6', icon: (
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><path d="M3.3 7 12 12l8.7-5M12 22V12" /></svg>
) },
{ id: 'compare', label: 'Compare', key: '7', icon: (
{ id: 'compare', label: 'Compare', key: '7', icon: (
<svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="M16 21l4-4-4-4"/><path d="M20 17H4"/></svg>
) },
{ id: 'plans', label: 'Plans', key: '8', icon: (
{ id: 'plans', label: 'Plans', key: '8', icon: (
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></svg>
) },
{ id: 'settings', label: 'Settings', key: ',', icon: (
{ id: 'settings', label: 'Settings', key: ',', icon: (
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
) },
]
Expand Down Expand Up @@ -75,7 +76,7 @@ export function Sidebar({
>
{item.icon}
{item.label}
<span className="k">{item.key}</span>
<span className="k">{shortcutLabel(item.key)}</span>
</div>
))}
<div className="push" />
Expand Down
46 changes: 46 additions & 0 deletions app/renderer/lib/platform.ts
Original file line number Diff line number Diff line change
@@ -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+<key> 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
}
Loading
Loading