diff --git a/packages/desktop/__tests__/navigation.test.ts b/packages/desktop/__tests__/navigation.test.ts new file mode 100644 index 0000000..4082a68 --- /dev/null +++ b/packages/desktop/__tests__/navigation.test.ts @@ -0,0 +1,46 @@ +import { + isRoute, + NAV_GROUPS, + ROUTE_GROUP, + ROUTE_LABEL, + ROUTES, + UNLISTED_ROUTES +} from '../src/renderer/lib/navigation'; + +describe('sidebar navigation', () => { + it('places every route in a group or names it explicitly unlisted', () => { + const grouped = NAV_GROUPS.flatMap((g) => g.routes); + const orphans = ROUTES.filter((r) => !grouped.includes(r) && !UNLISTED_ROUTES.includes(r)); + expect(orphans).toEqual([]); + }); + + it('never lists a route twice', () => { + const grouped = NAV_GROUPS.flatMap((g) => g.routes); + expect(grouped.length).toBe(new Set(grouped).size); + }); + + it('keeps running a show to two items, ahead of everything else', () => { + expect(NAV_GROUPS[0]).toEqual({ id: 'run', label: 'Run', routes: ['show', 'status'] }); + }); + + it('hides the admin vocabulary under Advanced', () => { + const advanced = NAV_GROUPS.find((g) => g.id === 'advanced')?.routes ?? []; + expect(advanced).toContain('access'); + expect(advanced).toContain('settings'); + expect(advanced).toContain('devices'); + }); + + it('labels every route', () => { + expect(ROUTES.filter((r) => !ROUTE_LABEL[r])).toEqual([]); + }); + + it('breadcrumbs a grouped route, but not one reached from the switcher', () => { + expect(ROUTE_GROUP.lights).toBe('Set up'); + expect(ROUTE_GROUP.projects).toBeUndefined(); + }); + + it('only accepts known hash routes', () => { + expect(isRoute('lights')).toBe(true); + expect(isRoute('nope')).toBe(false); + }); +}); diff --git a/packages/desktop/src/components/ui/app-shell.tsx b/packages/desktop/src/components/ui/app-shell.tsx index ecd8610..f994dcb 100644 --- a/packages/desktop/src/components/ui/app-shell.tsx +++ b/packages/desktop/src/components/ui/app-shell.tsx @@ -135,6 +135,8 @@ type AppShellProps = Omit & { /** CSS length for the expanded desktop sidebar (default upstream token). */ sidebarWidth?: string; sidebarProps?: Omit; + /** Rendered in the sidebar header under the brand (e.g. a workspace switcher). */ + sidebarHeader?: React.ReactNode; sidebarFooter?: React.ReactNode; /** Optional sticky footer below main content (e.g. mobile feature tabs). */ contentFooter?: React.ReactNode; @@ -423,6 +425,7 @@ function AppShell({ headerHeight = '3.5rem', sidebarWidth, sidebarProps, + sidebarHeader, sidebarFooter, contentFooter, defaultSidebarOpen = true, @@ -463,9 +466,10 @@ function AppShell({ sidebarClassName, )} > - {brand && ( + {(brand || sidebarHeader) && ( - + {brand && } + {sidebarHeader} )} diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 9ae84b3..f33f94c 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -5,6 +5,7 @@ import { type AppLinkRenderer } from '@/components/ui/app-bar'; import { type AppNavigationGroup,AppShell } from '@/components/ui/app-shell'; import { AppSplash } from '@/components/ui/app-splash'; import { ConstructiveIcon } from '@/components/ui/constructive-icon'; +import { isRoute, NAV_GROUPS, type Route,ROUTE_GROUP, ROUTE_LABEL } from '@/renderer/lib/navigation'; import { useAccessKeys, useBrainStatus, @@ -28,46 +29,27 @@ import { ConfigRoute } from '@/renderer/routes/config-route'; import { DevicesRoute } from '@/renderer/routes/devices-route'; import { LightsRoute } from '@/renderer/routes/lights-route'; import { OutputRoute } from '@/renderer/routes/output-route'; +import { ProjectSwitcher } from '@/renderer/routes/project-switcher'; import { ProjectsRoute } from '@/renderer/routes/projects-route'; import { SettingsRoute } from '@/renderer/routes/settings-route'; import { ShowRoute } from '@/renderer/routes/show-route'; import { StatusRoute } from '@/renderer/routes/status-route'; +import { SwitchProjectDialog } from '@/renderer/routes/switch-project-dialog'; -type Route = - | 'show' - | 'status' - | 'projects' - | 'config' - | 'access' - | 'lights' - | 'output' - | 'devices' - | 'settings'; +type AppIcon = React.ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>; -const ROUTE_LABEL: Record = { - show: 'Show', - status: 'Status', - projects: 'Projects', - config: 'Config', - access: 'Users & Secrets', - lights: 'Lights', - output: 'Output', - devices: 'Devices', - settings: 'Settings' +const ROUTE_ICON: Record = { + show: MonitorPlay, + status: Activity, + projects: FolderKanban, + config: SlidersHorizontal, + access: ShieldCheck, + lights: Lightbulb, + output: Radio, + devices: Cpu, + settings: Cog }; -const ROUTES: Route[] = [ - 'show', - 'status', - 'projects', - 'config', - 'access', - 'lights', - 'output', - 'devices', - 'settings' -]; - export function App() { const [route, setRoute] = React.useState('show'); const [busy, setBusy] = React.useState(false); @@ -103,10 +85,10 @@ export function App() { const { devices, refresh: refreshDevices, rename: renameDevice, assignShard } = useDevices(activeScope); - // The project whose config the editor is bound to — defaults to the active one, - // overridden when the operator clicks "Config" on a specific project row. - const [configProject, setConfigProject] = React.useState(null); - const editingProject = configProject ?? activeProject; + // There is exactly one current project: the one in use. Panels used to be + // able to pin a *different* project for editing, which meant the screen and + // the stage could silently disagree about what you were changing. + const editingProject = activeProject; const editingScope = React.useMemo( () => ({ project: editingProject, rev: dataRev }), [editingProject, dataRev] @@ -202,10 +184,6 @@ export function App() { setBusy(true); try { await use(name); - // Drop any project pinned by "Config" on a row, so every project-scoped - // panel (config, lights, devices, access) follows the project in use - // instead of the one last inspected. - setConfigProject(null); invalidateProjectData(); if (status.running && status.project !== name) { await window.wavegrid.brain.start(name).catch(() => undefined); @@ -217,6 +195,24 @@ export function App() { [use, invalidateProjectData, status.running, status.project] ); + /** + * Every path that changes the current project goes through here, so none of + * them can skip the warning: a switch mid-show restarts the brain, which + * darkens the lasers for a moment. + */ + const [pendingProject, setPendingProject] = React.useState(null); + const requestProjectSwitch = React.useCallback( + (name: string) => { + if (name === activeProject) return; + if (status.running) { + setPendingProject(name); + return; + } + void onUse(name); + }, + [activeProject, status.running, onUse] + ); + const onCreate = React.useCallback( async (input: Parameters[0]) => { setBusy(true); @@ -235,7 +231,6 @@ export function App() { setBusy(true); try { await remove(name); - setConfigProject((cur) => (cur === name ? null : cur)); invalidateProjectData(); } finally { setBusy(false); @@ -244,10 +239,14 @@ export function App() { [remove, invalidateProjectData] ); - const onEditConfig = React.useCallback((name: string) => { - setConfigProject(name); - setRoute('config'); - }, []); + /** Editing a project's layout means working *in* it — switch, then open it. */ + const onEditConfig = React.useCallback( + (name: string) => { + requestProjectSwitch(name); + setRoute('config'); + }, + [requestProjectSwitch] + ); /** * Run a store write with the busy flag held, surfacing a refusal instead of @@ -302,86 +301,26 @@ export function App() { href={href} onClick={(e) => { e.preventDefault(); - const next = href.replace(/^#/, '') as Route; - if (ROUTES.includes(next)) setRoute(next); + const next = href.replace(/^#/, ''); + if (isRoute(next)) setRoute(next); onClick?.(e); }} {...props} /> ); - const navigation: AppNavigationGroup[] = [ - { - id: 'main', - items: [ - { - id: 'show', - label: 'Show', - href: '#show', - icon: MonitorPlay, - isActive: route === 'show' - }, - { - id: 'status', - label: 'Status', - href: '#status', - icon: Activity, - isActive: route === 'status' - }, - { - id: 'projects', - label: 'Projects', - href: '#projects', - icon: FolderKanban, - isActive: route === 'projects', - badge: projects.length || undefined - }, - { - id: 'config', - label: 'Config', - href: '#config', - icon: SlidersHorizontal, - isActive: route === 'config' - }, - { - id: 'access', - label: 'Users & Secrets', - href: '#access', - icon: ShieldCheck, - isActive: route === 'access' - }, - { - id: 'lights', - label: 'Lights', - href: '#lights', - icon: Lightbulb, - isActive: route === 'lights' - }, - { - id: 'output', - label: 'Output', - href: '#output', - icon: Radio, - isActive: route === 'output' - }, - { - id: 'devices', - label: 'Devices', - href: '#devices', - icon: Cpu, - isActive: route === 'devices', - badge: devices.length || undefined - }, - { - id: 'settings', - label: 'Settings', - href: '#settings', - icon: Cog, - isActive: route === 'settings' - } - ] - } - ]; + const navigation: AppNavigationGroup[] = NAV_GROUPS.map((group) => ({ + id: group.id, + label: group.id === 'setup' && activeProject ? `Set up · ${activeProject}` : group.label, + items: group.routes.map((r) => ({ + id: r, + label: ROUTE_LABEL[r], + href: `#${r}`, + icon: ROUTE_ICON[r], + isActive: route === r, + badge: r === 'devices' ? devices.length || undefined : undefined + })) + })); React.useEffect(() => { if (route === 'projects') void refresh(); @@ -406,9 +345,22 @@ export function App() { brand={{ name: 'Wavegrid', logo: , - description: activeProject ? `Project · ${activeProject}` : 'No active project' + description: status.running ? 'Show running' : 'Show stopped' }} - breadcrumbs={[{ id: route, label: ROUTE_LABEL[route], current: true }]} + sidebarHeader={ + setRoute('projects')} + /> + } + breadcrumbs={[ + ...(ROUTE_GROUP[route] + ? [{ id: `group-${route}`, label: ROUTE_GROUP[route] as string }] + : []), + { id: route, label: ROUTE_LABEL[route], current: true } + ]} > {actionError && (
@@ -462,7 +414,7 @@ export function App() { void onRemove(name)} onEditConfig={onEditConfig} @@ -550,7 +502,6 @@ export function App() { const result = await clearStore(keepDevice); // Everything the other screens mirror just vanished — re-read it all // so no route keeps showing a project that no longer exists. - setConfigProject(null); await refresh(); invalidateProjectData(); return result; @@ -561,6 +512,15 @@ export function App() { busy={busy} /> )} + setPendingProject(null)} + onConfirm={() => { + if (pendingProject) void onUse(pendingProject); + setPendingProject(null); + }} + /> {showSplash && } ); diff --git a/packages/desktop/src/renderer/lib/navigation.ts b/packages/desktop/src/renderer/lib/navigation.ts new file mode 100644 index 0000000..8e4b887 --- /dev/null +++ b/packages/desktop/src/renderer/lib/navigation.ts @@ -0,0 +1,55 @@ +/** + * The sidebar's shape, grouped by *when* you reach for something rather than by + * what it is: running a show is two items, everything a project needs is under + * "Set up", and the vocabulary that scares a non-technical operator lives under + * "Advanced". Projects is not a destination — the switcher in the sidebar + * header owns choosing one and links to the manage screen. + */ +export type Route = + | 'show' + | 'status' + | 'projects' + | 'config' + | 'access' + | 'lights' + | 'output' + | 'devices' + | 'settings'; + +export const ROUTE_LABEL: Record = { + show: 'Show', + status: 'Status', + projects: 'Projects', + config: 'Layout', + access: 'People & Keys', + lights: 'Lights', + output: 'Output', + devices: 'Devices', + settings: 'Settings' +}; + +export const ROUTES = Object.keys(ROUTE_LABEL) as Route[]; + +export interface NavGroup { + id: string; + label: string; + routes: Route[]; +} + +export const NAV_GROUPS: NavGroup[] = [ + { id: 'run', label: 'Run', routes: ['show', 'status'] }, + { id: 'setup', label: 'Set up', routes: ['config', 'lights', 'output'] }, + { id: 'advanced', label: 'Advanced', routes: ['devices', 'access', 'settings'] } +]; + +/** Routes reachable other than from a sidebar group. */ +export const UNLISTED_ROUTES: Route[] = ['projects']; + +/** Which group a route sits in, for the breadcrumb. */ +export const ROUTE_GROUP: Partial> = Object.fromEntries( + NAV_GROUPS.flatMap((g) => g.routes.map((r) => [r, g.label])) +); + +export function isRoute(value: string): value is Route { + return (ROUTES as string[]).includes(value); +} diff --git a/packages/desktop/src/renderer/routes/project-switcher.tsx b/packages/desktop/src/renderer/routes/project-switcher.tsx new file mode 100644 index 0000000..4b371eb --- /dev/null +++ b/packages/desktop/src/renderer/routes/project-switcher.tsx @@ -0,0 +1,70 @@ +import { Check, ChevronsUpDown, FolderKanban, FolderOpen } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; +import type { ProjectSummary } from '@/types/ipc'; + +interface ProjectSwitcherProps { + projects: ProjectSummary[]; + current: string | null; + onSelect: (name: string) => void; + onManage: () => void; +} + +/** + * The one place the current project is chosen. Everything project-scoped — + * Layout, Lights, Output, People — follows it, so there is no second notion of + * "the project I'm editing" to drift from the one on stage. + */ +export function ProjectSwitcher({ projects, current, onSelect, onManage }: ProjectSwitcherProps) { + return ( + + + + + } + > +
+ +
+
+ Project + + {current ?? 'None yet'} + +
+ +
+ + Switch project + {projects.map((p) => ( + p.name !== current && onSelect(p.name)}> + {p.name} + {p.name === current && } + + ))} + {projects.length === 0 && No projects yet} + + + + +
+
+
+ ); +} diff --git a/packages/desktop/src/renderer/routes/projects-route.tsx b/packages/desktop/src/renderer/routes/projects-route.tsx index 2501886..b2e3e1a 100644 --- a/packages/desktop/src/renderer/routes/projects-route.tsx +++ b/packages/desktop/src/renderer/routes/projects-route.tsx @@ -109,10 +109,10 @@ export function ProjectsRoute({ size='sm' disabled={busy} onClick={() => onEditConfig(p.name)} - title='Edit config' + title='Switch to this project and edit its layout' > - Config + Layout