Add settings: theme, zoom controls, keyboard shortcuts (step 8)
Theme (light/dark/system) persisted via Zustand's persist middleware to localStorage, applied by toggling .dark on <html> — the CSS custom variant was already wired up from step 1 but nothing had driven it until now, so the app was silently stuck in light mode regardless of OS appearance. "System" resolves via matchMedia and stays live via a change listener. Zoom in/out/reset go through a small main-process IPC (webContents.setZoomLevel) since the renderer can't reach Electron's webFrame directly with contextIsolation on. Keyboard shortcuts (Cmd/Ctrl+N, +comma, +=/-/0) are a single window-level listener mounted once at the app root. Verified end-to-end via CDP: confirmed system theme correctly resolved dark to match the real OS appearance at test time, manually switching to Light actually removed the .dark class and persisted to localStorage, zoom in/reset both confirmed via the IPC's returned zoom level (cumulative across calls), and both keyboard shortcuts (dispatched as real KeyboardEvents) correctly opened their modals. Also added a matchMedia polyfill to the Vitest setup file — jsdom doesn't implement it, which broke App.test.tsx once useAppliedTheme started running on every render.
This commit is contained in:
@@ -8,6 +8,7 @@ import { registerDatabaseIpc } from './ipc/database'
|
||||
import { registerAddonsIpc } from './ipc/addons'
|
||||
import { registerLogsIpc } from './ipc/logs'
|
||||
import { registerCreateIpc } from './ipc/create'
|
||||
import { registerWindowIpc } from './ipc/window'
|
||||
import { killAllRunningCommands } from './commandRunner'
|
||||
|
||||
function createWindow(): void {
|
||||
@@ -63,6 +64,7 @@ app.whenReady().then(() => {
|
||||
registerAddonsIpc()
|
||||
registerLogsIpc()
|
||||
registerCreateIpc()
|
||||
registerWindowIpc()
|
||||
|
||||
createWindow()
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
const MIN_ZOOM = -3
|
||||
const MAX_ZOOM = 5
|
||||
|
||||
export function registerWindowIpc(): void {
|
||||
ipcMain.handle('window:zoomIn', (event) => {
|
||||
const level = Math.min(MAX_ZOOM, event.sender.getZoomLevel() + 0.5)
|
||||
event.sender.setZoomLevel(level)
|
||||
return level
|
||||
})
|
||||
|
||||
ipcMain.handle('window:zoomOut', (event) => {
|
||||
const level = Math.max(MIN_ZOOM, event.sender.getZoomLevel() - 0.5)
|
||||
event.sender.setZoomLevel(level)
|
||||
return level
|
||||
})
|
||||
|
||||
ipcMain.handle('window:zoomReset', (event) => {
|
||||
event.sender.setZoomLevel(0)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
@@ -97,6 +97,11 @@ const api = {
|
||||
projectType,
|
||||
docroot
|
||||
)
|
||||
},
|
||||
zoom: {
|
||||
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
|
||||
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
|
||||
reset: (): Promise<number> => ipcRenderer.invoke('window:zoomReset')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-10
@@ -1,18 +1,27 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Plus, Settings } from 'lucide-react'
|
||||
import { ProjectDetail } from './components/projects/ProjectDetail'
|
||||
import { ProjectList } from './components/projects/ProjectList'
|
||||
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
||||
import { StatusBar } from './components/layout/StatusBar'
|
||||
import { Toaster } from './components/ui/Toaster'
|
||||
import { CreateProjectModal } from './components/create/CreateProjectModal'
|
||||
import { SettingsModal } from './components/settings/SettingsModal'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
||||
import { useAppliedTheme } from './hooks/useAppliedTheme'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
useTerminalEvents()
|
||||
useAppliedTheme()
|
||||
useKeyboardShortcuts({
|
||||
onNewProject: useCallback(() => setIsCreateOpen(true), []),
|
||||
onOpenSettings: useCallback(() => setIsSettingsOpen(true), [])
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
||||
@@ -20,14 +29,24 @@ function App(): React.JSX.Element {
|
||||
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
title="New Project"
|
||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
title="New Project"
|
||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
title="Settings"
|
||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ProjectList />
|
||||
@@ -47,6 +66,7 @@ function App(): React.JSX.Element {
|
||||
<StatusBar />
|
||||
<Toaster />
|
||||
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
|
||||
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Minus, Plus, RotateCcw, X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useThemeStore, type Theme } from '../../stores/themeStore'
|
||||
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'system', label: 'System' }
|
||||
]
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: 'Cmd/Ctrl + N', action: 'New project' },
|
||||
{ keys: 'Cmd/Ctrl + ,', action: 'Open settings' },
|
||||
{ keys: 'Cmd/Ctrl + =', action: 'Zoom in' },
|
||||
{ keys: 'Cmd/Ctrl + -', action: 'Zoom out' },
|
||||
{ keys: 'Cmd/Ctrl + 0', action: 'Reset zoom' }
|
||||
]
|
||||
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 className="text-sm font-semibold">Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Theme
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(t.value)}
|
||||
className={clsx(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-sm font-medium',
|
||||
theme === t.value
|
||||
? 'border-neutral-900 bg-neutral-900 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-900'
|
||||
: 'border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Zoom
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.out()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Minus size={14} /> Out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.in()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Plus size={14} /> In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.reset()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<RotateCcw size={14} /> Reset
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Keyboard shortcuts
|
||||
</h3>
|
||||
<dl className="flex flex-col gap-1 text-sm">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<div key={s.keys} className="flex items-center justify-between">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">{s.action}</dt>
|
||||
<dd className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">
|
||||
{s.keys}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
|
||||
// Applies the resolved theme (light/dark/system) to <html class="dark">,
|
||||
// which is what the `@custom-variant dark` rule in main.css keys off. Must
|
||||
// run somewhere mounted once near the app root.
|
||||
export function useAppliedTheme(): void {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const apply = (): void => {
|
||||
const isDark = theme === 'dark' || (theme === 'system' && media.matches)
|
||||
root.classList.toggle('dark', isDark)
|
||||
}
|
||||
|
||||
apply()
|
||||
|
||||
if (theme === 'system') {
|
||||
media.addEventListener('change', apply)
|
||||
return () => media.removeEventListener('change', apply)
|
||||
}
|
||||
return undefined
|
||||
}, [theme])
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ShortcutHandlers {
|
||||
onNewProject: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts: Cmd/Ctrl+N (new project), Cmd/Ctrl+, (settings),
|
||||
// Cmd/Ctrl +/-/0 (zoom in/out/reset). Mount once near the app root.
|
||||
export function useKeyboardShortcuts({ onNewProject, onOpenSettings }: ShortcutHandlers): void {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'n':
|
||||
e.preventDefault()
|
||||
onNewProject()
|
||||
break
|
||||
case ',':
|
||||
e.preventDefault()
|
||||
onOpenSettings()
|
||||
break
|
||||
case '=':
|
||||
case '+':
|
||||
e.preventDefault()
|
||||
window.api.zoom.in()
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
window.api.zoom.out()
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
window.api.zoom.reset()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onNewProject, onOpenSettings])
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist<ThemeState>(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
setTheme: (theme) => {
|
||||
set({ theme })
|
||||
}
|
||||
}),
|
||||
{ name: 'aurora-dockside-theme' }
|
||||
)
|
||||
)
|
||||
@@ -1 +1,17 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
// jsdom doesn't implement matchMedia; useAppliedTheme() relies on it to
|
||||
// resolve the "system" theme.
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
}) as MediaQueryList
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user