codeburn/app/renderer/components/Sidebar.test.tsx
ozymandiashh c10ae84e4a fix(desktop): make keyboard shortcuts work on Windows and Linux
Closes #918.

The renderer's keydown handler required event.metaKey and explicitly
rejected event.ctrlKey, so every shortcut was dead outside macOS: on
Windows and Linux metaKey is the Super key, which the OS shell takes.
Navigation (1-8), Settings (,) and Refresh (R) all did nothing. The
sidebar and footer hints also hardcoded the Cmd glyph, so a Windows
user was shown chords that could not fire.

Add app/renderer/lib/platform.ts as the single source of truth for
platform-aware shortcuts, reading the platform the preload already
exposes (window.codeburn.platform) with a user-agent fallback for the
non-Electron cases. isModifierChord accepts Cmd-without-Ctrl on darwin
and Ctrl-without-Cmd elsewhere; altKey stays rejected on both, because
AltGr on European Windows layouts arrives as Ctrl+Alt and must not
hijack a typed character. Every visible shortcut label now resolves
through shortcutLabel() at render time, so the sidebar shows Ctrl+1
where macOS shows the Cmd glyph.

The mac chord condition is unchanged: the old guard admitted
metaKey && !altKey && !ctrlKey && !shiftKey, and the new one admits
exactly the same set on darwin.

The Electron application menu is deliberately left alone. It ships no
reload/forceReload role and no CmdOrCtrl+R accelerator, which is what
leaves Ctrl+R free for the renderer to handle on Windows.

Also corrects the Settings navigation hint, which read 1-7 while the
sidebar has eight numbered destinations.

Tests cover both platforms for labels and dispatch, including the
negatives: Meta on win32, Ctrl on darwin, and the Ctrl+Alt AltGr shape.
2026-08-04 22:48:39 +03:00

51 lines
2.4 KiB
TypeScript

// @vitest-environment jsdom
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', () => {
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 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: 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', () => {
const onNavigate = vi.fn()
render(<Sidebar active="overview" onNavigate={onNavigate} />)
fireEvent.click(screen.getByRole('button', { name: /Spend/ }))
expect(onNavigate).toHaveBeenCalledWith('spend')
})
it('marks the active item with the "on" class', () => {
render(<Sidebar active="models" onNavigate={() => {}} />)
expect(screen.getByRole('button', { name: /Models/ })).toHaveClass('on')
expect(screen.getByRole('button', { name: /Overview/ })).not.toHaveClass('on')
})
it('renders the brand flame mark, static under the closed motion gate', () => {
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
const flame = container.querySelector('.app .flamemark')
expect(flame?.tagName.toLowerCase()).toBe('img')
// motionEnabled() is off under vitest, so the idle flicker never attaches.
expect(container.querySelector('.fm-flicker')).toBeNull()
})
})