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 { registerAddonsIpc } from './ipc/addons'
|
||||||
import { registerLogsIpc } from './ipc/logs'
|
import { registerLogsIpc } from './ipc/logs'
|
||||||
import { registerCreateIpc } from './ipc/create'
|
import { registerCreateIpc } from './ipc/create'
|
||||||
|
import { registerWindowIpc } from './ipc/window'
|
||||||
import { killAllRunningCommands } from './commandRunner'
|
import { killAllRunningCommands } from './commandRunner'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
@@ -63,6 +64,7 @@ app.whenReady().then(() => {
|
|||||||
registerAddonsIpc()
|
registerAddonsIpc()
|
||||||
registerLogsIpc()
|
registerLogsIpc()
|
||||||
registerCreateIpc()
|
registerCreateIpc()
|
||||||
|
registerWindowIpc()
|
||||||
|
|
||||||
createWindow()
|
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,
|
projectType,
|
||||||
docroot
|
docroot
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
|
||||||
|
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
|
||||||
|
reset: (): Promise<number> => ipcRenderer.invoke('window:zoomReset')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, Settings } from 'lucide-react'
|
||||||
import { ProjectDetail } from './components/projects/ProjectDetail'
|
import { ProjectDetail } from './components/projects/ProjectDetail'
|
||||||
import { ProjectList } from './components/projects/ProjectList'
|
import { ProjectList } from './components/projects/ProjectList'
|
||||||
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
||||||
import { StatusBar } from './components/layout/StatusBar'
|
import { StatusBar } from './components/layout/StatusBar'
|
||||||
import { Toaster } from './components/ui/Toaster'
|
import { Toaster } from './components/ui/Toaster'
|
||||||
import { CreateProjectModal } from './components/create/CreateProjectModal'
|
import { CreateProjectModal } from './components/create/CreateProjectModal'
|
||||||
|
import { SettingsModal } from './components/settings/SettingsModal'
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
||||||
|
import { useAppliedTheme } from './hooks/useAppliedTheme'
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||||
useTerminalEvents()
|
useTerminalEvents()
|
||||||
|
useAppliedTheme()
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onNewProject: useCallback(() => setIsCreateOpen(true), []),
|
||||||
|
onOpenSettings: useCallback(() => setIsSettingsOpen(true), [])
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
||||||
@@ -20,6 +29,7 @@ function App(): React.JSX.Element {
|
|||||||
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
|
<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">
|
<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>
|
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCreateOpen(true)}
|
onClick={() => setIsCreateOpen(true)}
|
||||||
@@ -28,6 +38,15 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</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>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ProjectList />
|
<ProjectList />
|
||||||
@@ -47,6 +66,7 @@ function App(): React.JSX.Element {
|
|||||||
<StatusBar />
|
<StatusBar />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
|
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
|
||||||
|
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
|
||||||
</div>
|
</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'
|
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