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

2
kicad

@ -1 +1 @@
Subproject commit d0afbc4bd35e19bb98685ceecd53f848914cff9d Subproject commit 9ab93b838e86bedab0ca649a36a3104f30d01e34

49
tests/fixtures/demo/hier/hier.kicad_sch vendored Normal file
View file

@ -0,0 +1,49 @@
(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "a0000000-0000-4000-8000-000000000001")
(paper "A4")
(title_block
(title "Hierarchy demo — root")
)
(lib_symbols)
(wire
(pts (xy 50.8 101.6) (xy 101.6 101.6))
(stroke (width 0) (type default))
(uuid "c0000000-0000-4000-8000-000000000011")
)
(sheet
(at 50.8 50.8)
(size 25.4 20.32)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(fields_autoplaced yes)
(stroke (width 0.1524) (type solid))
(fill (color 0 0 0 0.0000))
(uuid "a0000000-0000-4000-8000-0000000000a1")
(property "Sheetname" "Power" (at 50.8 50.0876 0) (effects (font (size 1.27 1.27)) (justify left bottom)))
(property "Sheetfile" "power.kicad_sch" (at 50.8 71.7046 0) (effects (font (size 1.27 1.27)) (justify left top)))
(instances (project "hier" (path "/a0000000-0000-4000-8000-000000000001" (page "2"))))
)
(sheet
(at 101.6 50.8)
(size 25.4 20.32)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(fields_autoplaced yes)
(stroke (width 0.1524) (type solid))
(fill (color 0 0 0 0.0000))
(uuid "a0000000-0000-4000-8000-0000000000a2")
(property "Sheetname" "IO" (at 101.6 50.0876 0) (effects (font (size 1.27 1.27)) (justify left bottom)))
(property "Sheetfile" "io.kicad_sch" (at 101.6 71.7046 0) (effects (font (size 1.27 1.27)) (justify left top)))
(instances (project "hier" (path "/a0000000-0000-4000-8000-000000000001" (page "3"))))
)
(sheet_instances
(path "/" (page "1"))
)
)

31
tests/fixtures/demo/hier/io.kicad_sch vendored Normal file
View file

@ -0,0 +1,31 @@
(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "b0000000-0000-4000-8000-000000000003")
(paper "A4")
(title_block
(title "Hierarchy demo — io")
)
(lib_symbols)
(wire
(pts (xy 50.8 101.6) (xy 101.6 101.6))
(stroke (width 0) (type default))
(uuid "c0000000-0000-4000-8000-000000000013")
)
(sheet
(at 50.8 50.8)
(size 25.4 20.32)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(fields_autoplaced yes)
(stroke (width 0.1524) (type solid))
(fill (color 0 0 0 0.0000))
(uuid "a0000000-0000-4000-8000-0000000000a3")
(property "Sheetname" "Sub" (at 50.8 50.0876 0) (effects (font (size 1.27 1.27)) (justify left bottom)))
(property "Sheetfile" "io_sub.kicad_sch" (at 50.8 71.7046 0) (effects (font (size 1.27 1.27)) (justify left top)))
(instances (project "hier" (path "/a0000000-0000-4000-8000-000000000001/a0000000-0000-4000-8000-0000000000a2" (page "4"))))
)
)

View file

@ -0,0 +1,16 @@
(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "b0000000-0000-4000-8000-000000000004")
(paper "A4")
(title_block
(title "Hierarchy demo — io sub")
)
(lib_symbols)
(wire
(pts (xy 50.8 101.6) (xy 101.6 101.6))
(stroke (width 0) (type default))
(uuid "c0000000-0000-4000-8000-000000000014")
)
)

View file

@ -0,0 +1,16 @@
(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "b0000000-0000-4000-8000-000000000002")
(paper "A4")
(title_block
(title "Hierarchy demo — power")
)
(lib_symbols)
(wire
(pts (xy 50.8 101.6) (xy 101.6 101.6))
(stroke (width 0) (type default))
(uuid "c0000000-0000-4000-8000-000000000012")
)
)

View file

@ -0,0 +1,149 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
import { shotPath } from '../e2e/utils/element-tracker';
/**
* Sheet navigator panel e2e (sheet-panel): the floating React stand-in for
* the wx hierarchy pane that canvas-only sessions can't reach. Opens the
* hierarchical fixture (tests/fixtures/demo/hier: root Power, IO Sub) and
* asserts
* - the panel lists every sheet instance, page-ordered and depth-indented,
* from the C++ tree export (kicadSheetsGetTree names come from the
* Sheetname properties, not filenames);
* - clicking a row navigates (kicadSheetsEnter SCH_ACTIONS::changeSheet)
* and the panel follows through the onSheetsState push; the collab sheet
* manager rebinds to the now-active .kicad_sch (the `[sheet]` log);
* - this works for the READ-ONLY viewer (`?readonly=1`): the navigate
* actions are on the PCBJAM_READ_ONLY allowlist;
* - a flat schematic (demo.kicad_sch) gets no panel and no menu row.
*
* Needs a wasm build exporting kicadSheetsGetTree/kicadSheetsEnter an
* older bundle hides the panel (feature-detected), which fails the first
* assertion; rebuild + `npm run setup:kicad`.
*/
const SCOPE = 'default';
type Mod = {
kicadSheetsGetTree(): string;
kicadSheetsEnter(path: string): boolean | Promise<boolean>;
};
type SheetsState = { current: string; sheets: { path: string; name: string; depth: number; page: string }[] };
async function bootSchematic(page: Page, file: string, params = ''): Promise<string[]> {
const logs: string[] = [];
page.on('console', (m) => logs.push(m.text()));
await page.goto(`/${SCOPE}/projects/demo/${file}${params}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(/Schematic Editor/i);
await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 });
return logs;
}
async function tree(page: Page): Promise<SheetsState> {
return page.evaluate(() => {
const mod = (window as unknown as { Module: Mod }).Module;
return JSON.parse(mod.kicadSheetsGetTree()) as SheetsState;
});
}
test.describe('sheet panel', () => {
test('read-only viewer: lists the hierarchy and navigates between sheets', async ({ page }) => {
test.setTimeout(300000); // Firefox cold boot alone is ~2 min; three navigations follow.
const logs = await bootSchematic(page, 'hier/hier.kicad_sch', '?readonly=1');
// Viewer boot: the panel is up as a collapsed header (viewer-panels default).
const panel = page.getByTestId('sheet-panel');
await expect(panel).toBeVisible({ timeout: 30000 });
await expect(page.getByTestId('sheet-panel-list')).toHaveCount(0);
await page.getByTestId('sheet-panel-collapse').click();
// Every instance, page-ordered, names from Sheetname, depth-indented.
const rows = page.getByTestId('sheet-row');
await expect(rows).toHaveCount(4);
await expect(rows).toHaveText([/hier/, /Power/, /IO/, /Sub/]);
const state = await tree(page);
expect(state.sheets.map((s) => [s.name, s.depth, s.page])).toEqual([
['hier', 0, '1'],
['Power', 1, '2'],
['IO', 1, '3'],
['Sub', 2, '4'],
]);
expect(state.current).toBe(state.sheets[0]!.path);
await expect(rows.nth(0)).toHaveAttribute('data-active', 'true');
await page.screenshot({ path: shotPath(page, 'web-sheet-panel-root.png'), scale: 'css' });
// Navigate to the nested sheet: the row activates via the C++ push, the
// frame's current sheet moved, and collab rebound its room to io_sub.
await rows.nth(3).click();
await expect(rows.nth(3)).toHaveAttribute('data-active', 'true', { timeout: 30000 });
await expect(rows.nth(0)).not.toHaveAttribute('data-active', 'true');
await expect.poll(async () => (await tree(page)).current).toBe(state.sheets[3]!.path);
await expect
.poll(() => logs.some((l) => /\[sheet\].*io_sub\.kicad_sch/.test(l)), { timeout: 30000 })
.toBe(true);
await page.screenshot({ path: shotPath(page, 'web-sheet-panel-sub.png'), scale: 'css' });
// And back to the root.
await rows.nth(0).click();
await expect(rows.nth(0)).toHaveAttribute('data-active', 'true', { timeout: 30000 });
await expect.poll(async () => (await tree(page)).current).toBe(state.sheets[0]!.path);
// Menu row toggles it off/on; close button removes it.
await openOverlayMenu(page);
await page.getByTestId('sheet-panel-toggle').click();
await expect(panel).toHaveCount(0);
await openOverlayMenu(page);
await page.getByTestId('sheet-panel-toggle').click();
await expect(panel).toBeVisible();
// The open menu (z-50) sits over the panel's right-anchored header —
// close it (FAB toggles) before the close button is clickable.
await page.getByTestId('overlay-menu-fab').click();
await expect(page.getByTestId('overlay-menu-panel')).toHaveCount(0);
await page.getByTestId('sheet-panel-close').click();
await expect(panel).toHaveCount(0);
});
test('a flat schematic shows no sheet panel and no menu row', async ({ page }) => {
await bootSchematic(page, 'demo.kicad_sch', '?readonly=1');
// The other viewer panel proves the chrome-hidden panel layer is up.
await expect(page.getByTestId('inspector-panel')).toBeVisible({ timeout: 30000 });
await expect(page.getByTestId('sheet-panel')).toHaveCount(0);
await openOverlayMenu(page);
await expect(page.getByTestId('inspector-panel-toggle')).toBeVisible();
await expect(page.getByTestId('sheet-panel-toggle')).toHaveCount(0);
const state = await tree(page);
expect(state.sheets).toHaveLength(1);
});
test('wx-driven navigation updates the panel (kicadSheetsEnter without the panel open)', async ({
page,
}) => {
test.setTimeout(300000);
await bootSchematic(page, 'hier/hier.kicad_sch', '?readonly=1');
const panel = page.getByTestId('sheet-panel');
await expect(panel).toBeVisible({ timeout: 30000 });
await page.getByTestId('sheet-panel-collapse').click();
const rows = page.getByTestId('sheet-row');
await expect(rows).toHaveCount(4);
const state = await tree(page);
// Drive the bridge directly (stands in for a double-clicked sheet symbol /
// toolbar arrow: both end in DisplayCurrentSheet → OnSchSheetChanged push).
const ok = await page.evaluate(
(p) => (window as unknown as { Module: Mod }).Module.kicadSheetsEnter(p),
state.sheets[1]!.path,
);
expect(ok).toBe(true);
await expect(rows.nth(1)).toHaveAttribute('data-active', 'true', { timeout: 30000 });
// An unknown path is rejected synchronously and changes nothing.
const bad = await page.evaluate(
() => (window as unknown as { Module: Mod }).Module.kicadSheetsEnter('/not-a-sheet'),
);
expect(bad).toBe(false);
await expect(rows.nth(1)).toHaveAttribute('data-active', 'true');
});
});

View file

@ -57,6 +57,7 @@
#include <sch_sheet_path.h> #include <sch_sheet_path.h>
#include <schematic_settings.h> #include <schematic_settings.h>
#include <tool/actions.h> #include <tool/actions.h>
#include <tools/sch_actions.h>
#include <tool/coroutine.h> #include <tool/coroutine.h>
#include <pcbjam_remote_lock.h> #include <pcbjam_remote_lock.h>
#include "collab_common.h" #include "collab_common.h"
@ -390,6 +391,68 @@ void emitSheetChanged()
}, s.c_str() ); }, s.c_str() );
} }
// ───────────────────────── sheet navigator bridge (sheet-panel) ─────────────────────────
//
// The React stand-in for the docked wx HIERARCHY_PANE that kicadSetChrome(false)
// hides: kicadSheetsGetTree() returns the loaded hierarchy (every SCH_SHEET_PATH
// instance — a sheet file placed twice is two rows, like the wx pane) plus the
// current path; kicadSheetsEnter(path) navigates. Same shape as pcbnew's layer
// bridge: validate synchronously, apply on the frame's coroutine, then push the
// fresh state to window.kicadCollab.onSheetsState so the panel is event-driven.
// Rows are page-number ordered (the wx pane's order); `parent` is the KIID path
// of the enclosing instance ("" for the root) so JS can rebuild the tree.
json sheetsStateJson( SCH_EDIT_FRAME* aFrame )
{
SCH_SHEET_LIST list = aFrame->Schematic().Hierarchy();
list.SortByPageNumbers( /* aUpdateVirtualPageNums */ false );
json sheets = json::array();
for( const SCH_SHEET_PATH& path : list )
{
SCH_SHEET_PATH parent = path;
if( parent.size() > 1 )
parent.pop_back();
SCH_SCREEN* screen = path.LastScreen();
SCH_SHEET* sheet = path.Last();
wxString name = sheet ? sheet->GetName() : wxString();
// The root sheet carries no Sheetname — show its file like the wx pane does.
if( name.IsEmpty() && screen )
name = wxFileName( screen->GetFileName() ).GetName();
sheets.push_back( {
{ "path", toUtf8( path.PathAsString() ) },
{ "parent", path.size() > 1 ? toUtf8( parent.PathAsString() ) : std::string() },
{ "name", toUtf8( name ) },
{ "file", screen ? toUtf8( screen->GetFileName() ) : std::string() },
{ "page", toUtf8( path.GetPageNumber() ) },
{ "depth", static_cast<int>( path.size() ) - 1 },
} );
}
return { { "current", toUtf8( aFrame->GetCurrentSheet().PathAsString() ) },
{ "sheets", sheets } };
}
void emitSheetsState( SCH_EDIT_FRAME* aFrame )
{
if( !aFrame )
return;
std::string s = sheetsStateJson( aFrame ).dump();
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onSheetsState )
{
// Never let a throwing listener unwind the wasm frame (findings P-1).
try { window.kicadCollab.onSheetsState( UTF8ToString( $0 ) ); }
catch( e ) { console.error( '[pcbjam sheets] onSheetsState listener threw', e ); }
}
}, s.c_str() );
}
// Serialize one live schematic item to its native s-expr via a one-item // Serialize one live schematic item to its native s-expr via a one-item
// SCH_SELECTION through SCH_IO_KICAD_SEXPR::Format. For a symbol the output also // SCH_SELECTION through SCH_IO_KICAD_SEXPR::Format. For a symbol the output also
// carries its (lib_symbols …) definition (that prelude is emitted for any symbol // carries its (lib_symbols …) definition (that prelude is emitted for any symbol
@ -693,6 +756,9 @@ public:
{ {
rebaseline(); rebaseline();
emitSheetChanged(); emitSheetChanged();
// The floating sheet panel follows wx-driven navigation too (double-
// clicking a sheet symbol, Alt+Backspace, the toolbar arrows).
emitSheetsState( schFrame() );
} }
private: private:
@ -2270,6 +2336,64 @@ void kicadSaveSchematic( std::string path )
} }
// Sheet navigator bridge (sheet-panel) — see sheetsStateJson. ensureBridge()
// so the OnSchSheetChanged push is wired even for sessions that never bind
// presence (read-only viewers).
std::string schSheetsGetTree()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "";
ensureBridge();
return sheetsStateJson( fr ).dump();
}
// Navigate to the sheet instance whose KIID path is aPath (as reported by
// kicadSheetsGetTree). Validated synchronously against the live hierarchy; the
// switch itself runs on the coroutine through the navigate tool's changeSheet
// action — the same route the wx hierarchy pane takes, so back/forward history
// stays consistent. DisplayCurrentSheet fires OnSchSheetChanged → the collab
// sheet-manager rebinds its room and the panel gets its onSheetsState push.
// The action is on the read-only allowlist (view-only), so viewers can navigate.
bool schSheetsEnter( const std::string& aPath )
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return false;
wxString wanted = wxString::FromUTF8( aPath.c_str() );
SCH_SHEET_LIST list = fr->Schematic().Hierarchy();
auto it = std::find_if( list.begin(), list.end(),
[&]( const SCH_SHEET_PATH& p )
{
return p.PathAsString() == wanted;
} );
if( it == list.end() )
return false;
if( *it == fr->GetCurrentSheet() )
{
emitSheetsState( fr );
return true;
}
SCH_SHEET_PATH target = *it;
pcbjam_collab::runOnCoroutine( fr, [fr, target]() {
SCH_SHEET_PATH path = target;
fr->GetToolManager()->RunAction<SCH_SHEET_PATH*>( SCH_ACTIONS::changeSheet, &path );
// changeSheet already pushed via OnSchSheetChanged; a no-op path
// (already there / rejected) still leaves the panel consistent.
emitSheetsState( fr );
} );
return true;
}
static bool kicadCollabBusyProbe() static bool kicadCollabBusyProbe()
{ {
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty(); return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
@ -2289,6 +2413,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabTestMoveSchItem", &schCollabTestMoveSchItem); function("kicadCollabTestMoveSchItem", &schCollabTestMoveSchItem);
function("kicadCollabTestMirrorSchItem", &schCollabTestMirrorSchItem); function("kicadCollabTestMirrorSchItem", &schCollabTestMirrorSchItem);
function("kicadCollabTestDuplicateSchItem", &schCollabTestDuplicateSchItem); function("kicadCollabTestDuplicateSchItem", &schCollabTestDuplicateSchItem);
// Sheet navigator bridge (sheet-panel): hierarchy tree + navigate.
function("kicadSheetsGetTree", &schSheetsGetTree);
function("kicadSheetsEnter", &schSheetsEnter);
#ifndef KICAD_MERGED_EMBIND #ifndef KICAD_MERGED_EMBIND
// JS names ALSO registered by pcbnew_embind.cpp — in the merged image these are // JS names ALSO registered by pcbnew_embind.cpp — in the merged image these are

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 { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel"; import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel";
import { SelectionInspector } from "@/components/SelectionInspector"; import { SelectionInspector } from "@/components/SelectionInspector";
import { hasSheetsBridge, SheetPanel, type SheetsModule } from "@/components/SheetPanel";
import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection"; import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection";
import { import {
type SheetCollabManager, type SheetCollabManager,
@ -127,6 +128,7 @@ import {
INSPECTOR_OPEN_KEY, INSPECTOR_OPEN_KEY,
LAYERS_OPEN_KEY, LAYERS_OPEN_KEY,
LIB_KIND_FOR_TOOL, LIB_KIND_FOR_TOOL,
SHEETS_OPEN_KEY,
} from "@/components/wasm-tool/ui-helpers"; } from "@/components/wasm-tool/ui-helpers";
/** /**
@ -382,6 +384,27 @@ export function WasmTool({
/* private mode */ /* 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 // 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 // (collab handle / sheet manager). Owned here so a boot failure, an
// open-never-settled degrade, or unmount can destroy it instead of leaking // open-never-settled degrade, or unmount can destroy it instead of leaking
@ -1510,6 +1533,22 @@ export function WasmTool({
return hasLayersBridge(mod) ? mod : null; return hasLayersBridge(mod) ? mod : null;
}, [ready, tool]); }, [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 // Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with
// a synchronous first attempt: `ready` unmounts the opaque boot overlay in // 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 // this same commit, and a passive effect would let one frame of full chrome
@ -1629,6 +1668,9 @@ export function WasmTool({
hasLayers={layersMod !== null} hasLayers={layersMod !== null}
layersOpen={layersOpen} layersOpen={layersOpen}
setLayersOpen={setLayersOpen} setLayersOpen={setLayersOpen}
hasSheets={sheetsMod !== null}
sheetsOpen={sheetsOpen}
setSheetsOpen={setSheetsOpen}
inspectorOpen={inspectorOpen} inspectorOpen={inspectorOpen}
setInspectorOpen={setInspectorOpen} setInspectorOpen={setInspectorOpen}
canToggleChrome={setChromeFn !== null} canToggleChrome={setChromeFn !== null}
@ -1665,6 +1707,13 @@ export function WasmTool({
onClose={() => setLayersOpen(false)} onClose={() => setLayersOpen(false)}
/> />
)} )}
{ready && effectiveChromeHidden && sheetsOpen && sheetsMod && (
<SheetPanel
mod={sheetsMod}
defaultCollapsed={readOnly}
onClose={() => setSheetsOpen(false)}
/>
)}
{ready && {ready &&
effectiveChromeHidden && effectiveChromeHidden &&
inspectorOpen && inspectorOpen &&

View file

@ -6,6 +6,7 @@ import {
Crosshair, Crosshair,
EyeOff, EyeOff,
Layers, Layers,
ListTree,
Moon, Moon,
PanelsTopLeft, PanelsTopLeft,
RefreshCw, RefreshCw,
@ -151,6 +152,9 @@ export function SessionMenu({
hasLayers, hasLayers,
layersOpen, layersOpen,
setLayersOpen, setLayersOpen,
hasSheets,
sheetsOpen,
setSheetsOpen,
inspectorOpen, inspectorOpen,
setInspectorOpen, setInspectorOpen,
canToggleChrome, canToggleChrome,
@ -180,6 +184,10 @@ export function SessionMenu({
hasLayers: boolean; hasLayers: boolean;
layersOpen: boolean; layersOpen: boolean;
setLayersOpen: (v: boolean) => void; 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; inspectorOpen: boolean;
setInspectorOpen: (v: boolean) => void; setInspectorOpen: (v: boolean) => void;
/** The loaded bundle exports kicadSetChrome. */ /** The loaded bundle exports kicadSetChrome. */
@ -272,6 +280,18 @@ export function SessionMenu({
<span>{layersOpen ? "Hide layers" : "Layers"}</span> <span>{layersOpen ? "Hide layers" : "Layers"}</span>
</button> </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") && ( {effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (
<button <button
data-testid="inspector-panel-toggle" 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 // Viewer panels (viewer-panels): floating layer selector + selection
// inspector open-state persistence, mirroring the comments panel's keys. // inspector open-state persistence, mirroring the comments panel's keys.
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open"; 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"; export const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open";
// Tooltip only — the matcher accepts both chords on any platform. // Tooltip only — the matcher accepts both chords on any platform.