diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 4fc4c917..41a98b15 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -43,6 +43,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx index a1ec0821..d206d440 100644 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx @@ -1,13 +1,18 @@ import { useEffect, useMemo } from 'react'; +import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; -import { WorkspaceView } from 'officerdev'; +import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; // /headscale uses the Workspace/Panel system (like /soulseek and /music): a section nav (headscale-nav) on -// the left and a section view (headscale-view) on the right, coordinating via the 'headscale:section' -// channel. Both talk to the officer-headscale sidecar through the /api/headscale auth proxy, which holds no -// Headscale credentials of its own — the registered servers and their keys live in the sidecar. +// the left and a section view (headscale-view) on the right. Both talk to the officer-headscale sidecar +// through the /api/headscale auth proxy, which holds no Headscale credentials of its own — the registered +// servers and their keys live in the sidecar. +// +// The open section is :section in the URL, so both panels read it with useParams instead of passing it +// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the +// single place that decides what an absent or bogus section means. const ALLOWED_APP_TYPES = new Set(['headscale-nav', 'headscale-view', null]); @@ -24,6 +29,7 @@ function normalizeLayout(node: LayoutNode): LayoutNode { } export const HeadscaleScreen = () => { + const { section } = useParams(); const rawWorkspace = useDashboardState('screens/headscale', defaultLayout); const workspace = useMemo(() => { @@ -38,6 +44,13 @@ export const HeadscaleScreen = () => { } }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + // Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a + // default while the address bar says something else — the nav highlight is derived from the URL, so a URL + // that names nothing would leave nothing highlighted. + if (!isHeadscaleSection(section)) { + return ; + } + return (
diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx index e613b675..871d5f7b 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx @@ -1,14 +1,17 @@ import type { LucideIcon } from 'lucide-react'; +import { NavLink } from 'react-router'; import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared'; +import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared'; import { useHeadscaleServers } from './useHeadscaleServers'; -// Left panel of the /headscale workspace: the active-server switcher on top, sections below. Publishes the -// selected section on 'headscale:section'; HeadscaleView (right) renders the matching UI. +// Left panel of the /headscale workspace: the active-server switcher on top, sections below. // -// Switching servers is the primary action here rather than a buried setting — the owner runs several -// control servers and every other section is scoped to whichever is active. +// Sections are real links to /headscale/
, not channel writes — so they cmd-click into a new tab, +// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than +// being derived in JS, per the navigation audit's Phase 4. +// +// The server switcher stays a button on purpose: activating a server is a mutation (a DB write that changes +// which server every other section acts on), not navigation. It has no URL of its own and shouldn't. const ICONS: Record = { servers: Server, @@ -17,8 +20,21 @@ const ICONS: Record = { keys: KeyRound, }; +const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors'; + +type SectionBodyProps = { icon: LucideIcon; label: string; selected: boolean }; + +const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => ( + <> + {selected && } + + {label} + +); + export const HeadscaleNav = () => { - const [section, setSection] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers'); const { servers, active, activate } = useHeadscaleServers(); return ( @@ -63,30 +79,30 @@ export const HeadscaleNav = () => { diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx index bf3b4c4d..e7daf29f 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx @@ -1,17 +1,16 @@ -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { HEADSCALE_SECTION_CHANNEL, type HeadscaleSectionId } from './shared'; +import { useHeadscaleSection } from './useHeadscaleSection'; import { ServersView } from './ServersView'; import { NodesView } from './NodesView'; import { UsersView } from './UsersView'; import { KeysView } from './KeysView'; -// Right panel of the /headscale workspace — renders the section the nav selected. +// Right panel of the /headscale workspace — renders the section named by the URL. // // Every section except `servers` acts on whichever server is active; each handles the "none selected" case // itself through ViewShell, so there is no gating to do here. export const HeadscaleView = () => { - const [section] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers'); + const section = useHeadscaleSection(); switch (section) { case 'nodes': diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx index ac57c2a2..0a03c196 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx @@ -1,13 +1,13 @@ import { Network } from 'lucide-react'; import { useHeadscaleServers } from './useHeadscaleServers'; -import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { HEADSCALE_SECTIONS } from './shared'; +import { useHeadscaleSection } from './useHeadscaleSection'; // Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is // acting on — with several registered, "delete this node" is only safe if the target is unambiguous. export const HeadscaleViewHeader = () => { - const [section] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers'); + const section = useHeadscaleSection(); const { active } = useHeadscaleServers(); const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale'; diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/src/workspaces/officerdev/src/apps/Headscale/shared.ts index 2e138d5d..acfd69e2 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/shared.ts +++ b/src/workspaces/officerdev/src/apps/Headscale/shared.ts @@ -4,9 +4,6 @@ // floor), so these types are stable across Headscale releases and the browser never learns the upstream // version. See src/servers/sidecar/headscale/routes.ts. -/** Selected section, published by HeadscaleNav and consumed by HeadscaleView. */ -export const HEADSCALE_SECTION_CHANNEL = 'headscale:section'; - export const HEADSCALE_SECTIONS = [ { id: 'servers', label: 'Servers' }, { id: 'nodes', label: 'Nodes' }, @@ -16,6 +13,15 @@ export const HEADSCALE_SECTIONS = [ export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id']; +/** Where /headscale lands, and where an unrecognised section redirects to. */ +export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers'; + +export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId => + HEADSCALE_SECTIONS.some((s) => s.id === value); + +/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */ +export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`; + /** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */ export type HeadscaleServer = { id: number; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts new file mode 100644 index 00000000..3bb65cad --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts @@ -0,0 +1,12 @@ +import { useParams } from 'react-router'; +import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared'; + +// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md: +// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to, +// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the +// fallback here is only for the instant before that lands. + +export function useHeadscaleSection(): HeadscaleSectionId { + const { section } = useParams(); + return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION; +} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index abfc8abb..f15eeaf6 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -27,6 +27,9 @@ export * from './apps/Chat/types'; export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory'; export type { SelectedSession } from './apps/ChatHistory'; export { CodeEditorView } from './apps/CodeEditor'; +// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL. +export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared'; +export type { HeadscaleSectionId } from './apps/Headscale/shared'; export { useFilesAPI, useTasks,