From f3119e690f7bae994cacb865dd325400bef5b5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sat, 29 Aug 2026 16:24:57 +0200 Subject: [PATCH] feat(standalone): floating sheet navigator panel backed by a C++ hierarchy export (sheet-panel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CtN6ASBvMGNbjPqY5boycg --- kicad | 2 +- tests/fixtures/demo/hier/hier.kicad_sch | 49 ++++ tests/fixtures/demo/hier/io.kicad_sch | 31 +++ tests/fixtures/demo/hier/io_sub.kicad_sch | 16 ++ tests/fixtures/demo/hier/power.kicad_sch | 16 ++ tests/web/sheet-panel.spec.ts | 149 ++++++++++ wasm/bindings/eeschema_embind.cpp | 127 +++++++++ .../src/components/SheetPanel.test.ts | 44 +++ web/standalone/src/components/SheetPanel.tsx | 259 ++++++++++++++++++ web/standalone/src/components/WasmTool.tsx | 49 ++++ .../src/components/wasm-tool/SessionMenu.tsx | 20 ++ .../src/components/wasm-tool/ui-helpers.ts | 1 + 12 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/demo/hier/hier.kicad_sch create mode 100644 tests/fixtures/demo/hier/io.kicad_sch create mode 100644 tests/fixtures/demo/hier/io_sub.kicad_sch create mode 100644 tests/fixtures/demo/hier/power.kicad_sch create mode 100644 tests/web/sheet-panel.spec.ts create mode 100644 web/standalone/src/components/SheetPanel.test.ts create mode 100644 web/standalone/src/components/SheetPanel.tsx diff --git a/kicad b/kicad index d0afbc4..9ab93b8 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit d0afbc4bd35e19bb98685ceecd53f848914cff9d +Subproject commit 9ab93b838e86bedab0ca649a36a3104f30d01e34 diff --git a/tests/fixtures/demo/hier/hier.kicad_sch b/tests/fixtures/demo/hier/hier.kicad_sch new file mode 100644 index 0000000..1554801 --- /dev/null +++ b/tests/fixtures/demo/hier/hier.kicad_sch @@ -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")) + ) +) diff --git a/tests/fixtures/demo/hier/io.kicad_sch b/tests/fixtures/demo/hier/io.kicad_sch new file mode 100644 index 0000000..2bdba83 --- /dev/null +++ b/tests/fixtures/demo/hier/io.kicad_sch @@ -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")))) + ) +) diff --git a/tests/fixtures/demo/hier/io_sub.kicad_sch b/tests/fixtures/demo/hier/io_sub.kicad_sch new file mode 100644 index 0000000..985fe1f --- /dev/null +++ b/tests/fixtures/demo/hier/io_sub.kicad_sch @@ -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") + ) +) diff --git a/tests/fixtures/demo/hier/power.kicad_sch b/tests/fixtures/demo/hier/power.kicad_sch new file mode 100644 index 0000000..b899e97 --- /dev/null +++ b/tests/fixtures/demo/hier/power.kicad_sch @@ -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") + ) +) diff --git a/tests/web/sheet-panel.spec.ts b/tests/web/sheet-panel.spec.ts new file mode 100644 index 0000000..a089414 --- /dev/null +++ b/tests/web/sheet-panel.spec.ts @@ -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; +}; +type SheetsState = { current: string; sheets: { path: string; name: string; depth: number; page: string }[] }; + +async function bootSchematic(page: Page, file: string, params = ''): Promise { + 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 { + 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'); + }); +}); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 9d82c40..6c52737 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -57,6 +57,7 @@ #include #include #include +#include #include #include #include "collab_common.h" @@ -390,6 +391,68 @@ void emitSheetChanged() }, 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( 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 // 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 @@ -693,6 +756,9 @@ public: { rebaseline(); emitSheetChanged(); + // The floating sheet panel follows wx-driven navigation too (double- + // clicking a sheet symbol, Alt+Backspace, the toolbar arrows). + emitSheetsState( schFrame() ); } 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_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() { return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty(); @@ -2289,6 +2413,9 @@ EMSCRIPTEN_BINDINGS(eeschema) { function("kicadCollabTestMoveSchItem", &schCollabTestMoveSchItem); function("kicadCollabTestMirrorSchItem", &schCollabTestMirrorSchItem); function("kicadCollabTestDuplicateSchItem", &schCollabTestDuplicateSchItem); + // Sheet navigator bridge (sheet-panel): hierarchy tree + navigate. + function("kicadSheetsGetTree", &schSheetsGetTree); + function("kicadSheetsEnter", &schSheetsEnter); #ifndef KICAD_MERGED_EMBIND // JS names ALSO registered by pcbnew_embind.cpp — in the merged image these are diff --git a/web/standalone/src/components/SheetPanel.test.ts b/web/standalone/src/components/SheetPanel.test.ts new file mode 100644 index 0000000..93af2c6 --- /dev/null +++ b/web/standalone/src/components/SheetPanel.test.ts @@ -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); + }); +}); diff --git a/web/standalone/src/components/SheetPanel.tsx b/web/standalone/src/components/SheetPanel.tsx new file mode 100644 index 0000000..b866de0 --- /dev/null +++ b/web/standalone/src/components/SheetPanel.tsx @@ -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; +} + +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 | 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 | 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(null); + const drag = useDraggablePanel({ + storageKey: PANEL_POS_KEY, + handleWidth: PANEL_W, + handleHeight: PANEL_HEADER_H, + }); + const [collapsed, setCollapsedState] = React.useState(() => { + 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(() => + 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 ( +
+ {/* Header = drag handle. Interactive children stop pointerdown so they + don't start a drag. */} +
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())} + onPointerMove={(e) => void drag.onPointerMove(e)} + onPointerUp={() => void drag.onPointerUp()} + > + + Sheets + {state && state.sheets.length > 1 && ( + + {state.sheets.length} + + )} + e.stopPropagation()}> + + +
+ + {!collapsed && ( +
+ {!state && ( +

+ Sheet hierarchy isn't available yet. +

+ )} + {state && single && ( +

+ This schematic has no sub-sheets. +

+ )} + {state?.sheets.map((s) => { + const active = state.current === s.path; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 35c6ead..78b57b8 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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(() => { + 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(() => { + 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 && ( + setSheetsOpen(false)} + /> + )} {ready && effectiveChromeHidden && inspectorOpen && diff --git a/web/standalone/src/components/wasm-tool/SessionMenu.tsx b/web/standalone/src/components/wasm-tool/SessionMenu.tsx index 41d1e6f..0b15b15 100644 --- a/web/standalone/src/components/wasm-tool/SessionMenu.tsx +++ b/web/standalone/src/components/wasm-tool/SessionMenu.tsx @@ -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({ {layersOpen ? "Hide layers" : "Layers"} )} + {effectiveChromeHidden && hasSheets && ( + + )} {effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (