feat(standalone): floating sheet navigator panel backed by a C++ hierarchy export (sheet-panel)

Canvas-only eeschema sessions (read-only viewers, hide-UI editors) could not
reach the wx hierarchy pane, so a viewer of a hierarchical design was stuck
on the entry sheet. Adds the React stand-in (SheetPanel) + the bridge:

- eeschema_embind: kicadSheetsGetTree() (SCHEMATIC::Hierarchy(), page-number
  ordered: instance KIID path / parent / Sheetname / file / page / depth) and
  kicadSheetsEnter(path) (validated synchronously, applied on the coroutine
  via SCH_ACTIONS::changeSheet — same route as the wx pane, history intact).
  OnSchSheetChanged pushes window.kicadCollab.onSheetsState after EVERY
  navigation, wx-driven included, so the panel is event-driven.
- kicad → 9ab93b838e: navigate actions allowlisted for read-only viewers.
- SheetPanel.tsx: LayerPanel shell conventions; rendered + menu row only when
  the hierarchy has >1 sheet instance; viewers boot it collapsed.
- tests: fixtures/demo/hier (root → Power, IO → Sub) + web/sheet-panel.spec
  (list/order/depth, navigate + collab rebind, menu toggle/close, flat
  schematic has no panel, bridge-driven navigation updates the panel);
  SheetPanel.test.ts for the wire parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtN6ASBvMGNbjPqY5boycg
This commit is contained in:
Gergő Törcsvári 2026-08-29 16:24:57 +02:00
commit f3119e690f
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 762 additions and 1 deletions

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { hasSheetsBridge, parseSheetsState } from "./SheetPanel";
/** Sheet navigator bridge parsing (sheet-panel) the wire shape emitted by
* eeschema_embind.cpp's sheetsStateJson / kicadSheetsGetTree. */
describe("parseSheetsState", () => {
it("parses the C++ wire shape, keeping row order (page-number sorted)", () => {
const s = parseSheetsState(
JSON.stringify({
current: "/root",
sheets: [
{ path: "/root", parent: "", name: "Arduino Leonardo", file: "/p/root.kicad_sch", page: "1", depth: 0 },
{ path: "/root/aaa", parent: "/root", name: "Headers", file: "/p/Headers.kicad_sch", page: "2", depth: 1 },
{ path: "/root/bbb", parent: "/root", name: "Power", file: "/p/Power.kicad_sch", page: "3", depth: 1 },
],
}),
);
expect(s?.current).toBe("/root");
expect(s?.sheets.map((r) => r.name)).toEqual(["Arduino Leonardo", "Headers", "Power"]);
expect(s?.sheets[1]).toMatchObject({ parent: "/root", depth: 1, page: "2" });
});
it("tolerates missing optional fields and drops rows without a path", () => {
const s = parseSheetsState(
JSON.stringify({ current: "/r", sheets: [{ path: "/r" }, { name: "no path" }, null] }),
);
expect(s?.sheets).toEqual([{ path: "/r", parent: "", name: "", file: "", page: "", depth: 0 }]);
});
it("returns null for garbage / an empty bridge answer", () => {
expect(parseSheetsState("")).toBeNull();
expect(parseSheetsState("null")).toBeNull();
expect(parseSheetsState('{"sheets":[]}')).toBeNull();
expect(parseSheetsState("{not json")).toBeNull();
});
});
describe("hasSheetsBridge", () => {
it("needs both exports (older bundles / non-eeschema frames have neither)", () => {
expect(hasSheetsBridge({ kicadSheetsGetTree: () => "", kicadSheetsEnter: () => true })).toBe(true);
expect(hasSheetsBridge({ kicadSheetsGetTree: () => "" })).toBe(false);
expect(hasSheetsBridge(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,259 @@
import * as React from "react";
import { ChevronDown, ChevronRight, FileText, X } from "lucide-react";
import { useDraggablePanel } from "@/components/useDraggablePanel";
/**
* Floating sheet navigator (sheet-panel): the canvas-only stand-in for the
* docked wx HIERARCHY_PANE that kicadSetChrome(false) hides. Lists the loaded
* schematic hierarchy every sheet INSTANCE, page-number ordered, indented by
* depth, like the wx pane and navigates through the sheet bridge
* (eeschema builds only kicadSheetsGetTree / kicadSheetsEnter), updating
* event-driven from the C++ `window.kicadCollab.onSheetsState` push that
* follows every navigation (ours, or a wx-driven one: double-clicking a sheet
* symbol, the toolbar arrows).
*
* Same draggable-panel conventions as the layer panel (viewer-panels):
* header = drag handle, collapse-to-header, position/collapse persisted,
* always-onscreen restore via useDraggablePanel.
*/
const PANEL_POS_KEY = "pcbjam:sheet-panel-pos";
const PANEL_COLLAPSED_KEY = "pcbjam:sheet-panel-collapsed";
const PANEL_W = 256; // w-64
const PANEL_HEADER_H = 36;
export interface SheetRow {
/** KIID path of this sheet INSTANCE — the navigation key. */
path: string;
/** KIID path of the enclosing instance; "" for the root. */
parent: string;
name: string;
/** Absolute MEMFS path of the sheet's .kicad_sch. */
file: string;
page: string;
depth: number;
}
export interface SheetsState {
current: string;
sheets: SheetRow[];
}
export interface SheetsModule {
kicadSheetsGetTree(): string;
kicadSheetsEnter(path: string): boolean | Promise<boolean>;
}
interface SheetsWindow {
kicadCollab?: { onSheetsState?: (json: string) => void };
}
/** True when the loaded wasm exposes the sheet bridge (eeschema builds). */
export function hasSheetsBridge(mod: unknown): mod is SheetsModule {
const m = mod as Partial<SheetsModule> | undefined;
return (
typeof m?.kicadSheetsGetTree === "function" &&
typeof m?.kicadSheetsEnter === "function"
);
}
export function parseSheetsState(json: string): SheetsState | null {
try {
const v: unknown = JSON.parse(json);
if (!v || typeof v !== "object") return null;
const o = v as { current?: unknown; sheets?: unknown };
if (typeof o.current !== "string" || !Array.isArray(o.sheets)) return null;
const sheets: SheetRow[] = [];
for (const s of o.sheets) {
const r = s as Partial<SheetRow> | null;
if (!r || typeof r.path !== "string") continue;
sheets.push({
path: r.path,
parent: typeof r.parent === "string" ? r.parent : "",
name: typeof r.name === "string" ? r.name : "",
file: typeof r.file === "string" ? r.file : "",
page: typeof r.page === "string" ? r.page : "",
depth: typeof r.depth === "number" && r.depth >= 0 ? r.depth : 0,
});
}
return { current: o.current, sheets };
} catch {
return null;
}
}
/** Basename of a MEMFS/relative path, for the row tooltip. */
function fileLabel(file: string): string {
const i = file.lastIndexOf("/");
return i >= 0 ? file.slice(i + 1) : file;
}
export function SheetPanel({
mod,
defaultCollapsed,
onClose,
}: {
mod: SheetsModule;
/** Boot state when no per-browser choice is stored (read-only sessions
* start as a collapsed header viewer-panels). */
defaultCollapsed?: boolean;
onClose: () => void;
}) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const drag = useDraggablePanel({
storageKey: PANEL_POS_KEY,
handleWidth: PANEL_W,
handleHeight: PANEL_HEADER_H,
});
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
try {
const stored = localStorage.getItem(PANEL_COLLAPSED_KEY);
if (stored !== null) return stored === "1";
} catch {
/* private mode */
}
return defaultCollapsed === true;
});
const setCollapsed = (v: boolean) => {
setCollapsedState(v);
try {
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
} catch {
/* private mode */
}
};
const [state, setState] = React.useState<SheetsState | null>(() =>
parseSheetsState(mod.kicadSheetsGetTree() || "null"),
);
// Event-driven refresh: the C++ side pushes the fresh state after every
// navigation (the switch runs on the coroutine — a synchronous re-read
// right after the call would still see the old sheet). Spread-preserving
// install, same etiquette as the layer/presence bridges.
React.useEffect(() => {
const win = window as SheetsWindow;
win.kicadCollab = {
...win.kicadCollab,
onSheetsState: (json) => {
const s = parseSheetsState(json);
if (s) setState(s);
},
};
// Re-read on mount: the panel can open after a wx-driven navigation
// that happened while no listener was installed.
const s = parseSheetsState(mod.kicadSheetsGetTree() || "null");
if (s) setState(s);
return () => {
if (win.kicadCollab) delete win.kicadCollab.onSheetsState;
};
}, [mod]);
const enter = (path: string) => {
// Optimistic highlight for a snappy click; the onSheetsState push corrects.
setState((s) => (s ? { ...s, current: path } : s));
void mod.kicadSheetsEnter(path);
};
// Default anchor: below the layer panel's slot (right column) — a schematic
// session has no layer panel, so this sits where that would.
const style: React.CSSProperties = drag.pos
? { left: drag.pos.x, top: drag.pos.y }
: { right: 12, top: 56 };
const single = (state?.sheets.length ?? 0) <= 1;
return (
<div
ref={rootRef}
data-testid="sheet-panel"
className="absolute z-40 flex w-64 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:text-white dark:ring-white/15"
style={style}
>
{/* Header = drag handle. Interactive children stop pointerdown so they
don't start a drag. */}
<div
data-testid="sheet-panel-header"
className="flex cursor-grab select-none items-center gap-2 px-3 py-2 text-xs font-semibold active:cursor-grabbing"
style={{ touchAction: "none" }}
title="Sheets — drag to move"
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
onPointerMove={(e) => void drag.onPointerMove(e)}
onPointerUp={() => void drag.onPointerUp()}
>
<button
data-testid="sheet-panel-collapse"
aria-expanded={!collapsed}
title={collapsed ? "Expand" : "Collapse to header"}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => setCollapsed(!collapsed)}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
<span>Sheets</span>
{state && state.sheets.length > 1 && (
<span className="text-[10px] font-normal text-neutral-400 dark:text-white/40">
{state.sheets.length}
</span>
)}
<span className="ml-auto flex items-center gap-0.5" onPointerDown={(e) => e.stopPropagation()}>
<button
data-testid="sheet-panel-close"
title="Close"
onClick={onClose}
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
>
<X size={14} />
</button>
</span>
</div>
{!collapsed && (
<div data-testid="sheet-panel-list" className="max-h-[60vh] overflow-y-auto pb-1">
{!state && (
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
Sheet hierarchy isn't available yet.
</p>
)}
{state && single && (
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
This schematic has no sub-sheets.
</p>
)}
{state?.sheets.map((s) => {
const active = state.current === s.path;
return (
<button
key={s.path}
data-testid="sheet-row"
data-sheet-path={s.path}
data-active={active || undefined}
aria-current={active ? "page" : undefined}
title={`${fileLabel(s.file)} — page ${s.page}`}
onClick={() => enter(s.path)}
className={`flex w-full items-center gap-2 border-t border-black/5 px-3 py-1 text-left text-xs hover:text-sky-600 dark:border-white/5 dark:hover:text-sky-300 ${
active ? "bg-sky-500/10 dark:bg-sky-400/10" : ""
}`}
style={{ paddingLeft: 12 + Math.min(s.depth, 8) * 12 }}
>
<FileText
size={13}
className={`shrink-0 ${
active ? "text-sky-600 dark:text-sky-300" : "text-neutral-400 dark:text-white/40"
}`}
/>
<span className={`min-w-0 flex-1 truncate ${active ? "font-semibold" : ""}`}>
{s.name || fileLabel(s.file)}
</span>
<span className="shrink-0 tabular-nums text-[10px] text-neutral-400 dark:text-white/40">
{s.page}
</span>
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -83,6 +83,7 @@ import { CommentLayer } from "@/components/CommentLayer";
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel";
import { SelectionInspector } from "@/components/SelectionInspector";
import { hasSheetsBridge, SheetPanel, type SheetsModule } from "@/components/SheetPanel";
import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection";
import {
type SheetCollabManager,
@ -127,6 +128,7 @@ import {
INSPECTOR_OPEN_KEY,
LAYERS_OPEN_KEY,
LIB_KIND_FOR_TOOL,
SHEETS_OPEN_KEY,
} from "@/components/wasm-tool/ui-helpers";
/**
@ -382,6 +384,27 @@ export function WasmTool({
/* private mode */
}
}, []);
// Sheet navigator (sheet-panel): eeschema's stand-in for the wx hierarchy
// pane. Open by default like the other viewer panels, but only ever RENDERS
// when the hierarchy has sub-sheets (see sheetsMod) — a flat schematic
// shouldn't pay for an empty panel.
const [sheetsOpen, setSheetsOpenState] = React.useState<boolean>(() => {
try {
const stored = localStorage.getItem(SHEETS_OPEN_KEY);
if (stored !== null) return stored === "1";
} catch {
/* private mode */
}
return true;
});
const setSheetsOpen = React.useCallback((v: boolean) => {
setSheetsOpenState(v);
try {
localStorage.setItem(SHEETS_OPEN_KEY, v ? "1" : "0");
} catch {
/* private mode */
}
}, []);
// A doc session that connected but has not been ADOPTED by an owner yet
// (collab handle / sheet manager). Owned here so a boot failure, an
// open-never-settled degrade, or unmount can destroy it instead of leaking
@ -1510,6 +1533,22 @@ export function WasmTool({
return hasLayersBridge(mod) ? mod : null;
}, [ready, tool]);
// Sheet bridge (sheet-panel), eeschema sessions only. Re-evaluated on every
// sheet switch (activeSheetPath) so a hierarchy that only gains sub-sheets
// later ("Add Sheet") surfaces the panel; hidden for a flat schematic.
const sheetsMod = React.useMemo<SheetsModule | null>(() => {
if (!ready || tool !== "eeschema") return null;
const mod = (window as { Module?: unknown }).Module;
if (!hasSheetsBridge(mod)) return null;
try {
const raw = mod.kicadSheetsGetTree() || "null";
const parsed = JSON.parse(raw) as { sheets?: unknown[] } | null;
return (parsed?.sheets?.length ?? 0) > 1 ? mod : null;
} catch {
return null;
}
}, [ready, tool, activeSheetPath]);
// Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with
// a synchronous first attempt: `ready` unmounts the opaque boot overlay in
// this same commit, and a passive effect would let one frame of full chrome
@ -1629,6 +1668,9 @@ export function WasmTool({
hasLayers={layersMod !== null}
layersOpen={layersOpen}
setLayersOpen={setLayersOpen}
hasSheets={sheetsMod !== null}
sheetsOpen={sheetsOpen}
setSheetsOpen={setSheetsOpen}
inspectorOpen={inspectorOpen}
setInspectorOpen={setInspectorOpen}
canToggleChrome={setChromeFn !== null}
@ -1665,6 +1707,13 @@ export function WasmTool({
onClose={() => setLayersOpen(false)}
/>
)}
{ready && effectiveChromeHidden && sheetsOpen && sheetsMod && (
<SheetPanel
mod={sheetsMod}
defaultCollapsed={readOnly}
onClose={() => setSheetsOpen(false)}
/>
)}
{ready &&
effectiveChromeHidden &&
inspectorOpen &&

View file

@ -6,6 +6,7 @@ import {
Crosshair,
EyeOff,
Layers,
ListTree,
Moon,
PanelsTopLeft,
RefreshCw,
@ -151,6 +152,9 @@ export function SessionMenu({
hasLayers,
layersOpen,
setLayersOpen,
hasSheets,
sheetsOpen,
setSheetsOpen,
inspectorOpen,
setInspectorOpen,
canToggleChrome,
@ -180,6 +184,10 @@ export function SessionMenu({
hasLayers: boolean;
layersOpen: boolean;
setLayersOpen: (v: boolean) => void;
/** The sheet bridge is available AND the schematic has sub-sheets. */
hasSheets: boolean;
sheetsOpen: boolean;
setSheetsOpen: (v: boolean) => void;
inspectorOpen: boolean;
setInspectorOpen: (v: boolean) => void;
/** The loaded bundle exports kicadSetChrome. */
@ -272,6 +280,18 @@ export function SessionMenu({
<span>{layersOpen ? "Hide layers" : "Layers"}</span>
</button>
)}
{effectiveChromeHidden && hasSheets && (
<button
data-testid="sheet-panel-toggle"
aria-pressed={sheetsOpen}
className={overlayRowClass}
title="Schematic sheets — navigate the hierarchy"
onClick={() => setSheetsOpen(!sheetsOpen)}
>
<ListTree size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
<span>{sheetsOpen ? "Hide sheets" : "Sheets"}</span>
</button>
)}
{effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (
<button
data-testid="inspector-panel-toggle"

View file

@ -32,6 +32,7 @@ export function show3DOpener(win: Window): (() => boolean) | null {
// Viewer panels (viewer-panels): floating layer selector + selection
// inspector open-state persistence, mirroring the comments panel's keys.
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open";
export const SHEETS_OPEN_KEY = "pcbjam:sheet-panel-open";
export const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open";
// Tooltip only — the matcher accepts both chords on any platform.