|
35 | 35 |
|
36 | 36 | T = TypeVar("T", bound="ConsolePage") |
37 | 37 |
|
| 38 | +# SY-3670 — Shared JS snippet that walks the React fiber tree from #root |
| 39 | +# to find the Redux store. Used by import_page and export_workspace as a |
| 40 | +# browser-mode workaround for features that normally rely on Tauri APIs. |
| 41 | +_FIND_REDUX_STORE_JS = """ |
| 42 | +const rootEl = document.getElementById('root'); |
| 43 | +if (!rootEl) throw new Error('Root element not found'); |
| 44 | +const containerKey = Object.keys(rootEl).find( |
| 45 | + k => k.startsWith('__reactContainer$') |
| 46 | +); |
| 47 | +if (!containerKey) throw new Error('React container not found'); |
| 48 | +let store = null; |
| 49 | +const stack = [rootEl[containerKey]]; |
| 50 | +while (stack.length > 0) { |
| 51 | + const fiber = stack.pop(); |
| 52 | + if (!fiber) continue; |
| 53 | + if (fiber.memoizedProps?.store?.dispatch) { |
| 54 | + store = fiber.memoizedProps.store; |
| 55 | + break; |
| 56 | + } |
| 57 | + if (fiber.child) stack.push(fiber.child); |
| 58 | + if (fiber.sibling) stack.push(fiber.sibling); |
| 59 | +} |
| 60 | +if (!store) throw new Error('Redux store not found'); |
| 61 | +""" |
| 62 | + |
38 | 63 |
|
39 | 64 | class WorkspaceClient: |
40 | 65 | """Workspace management for Console UI automation.""" |
@@ -449,6 +474,180 @@ def export_page(self, name: str) -> dict[str, Any]: |
449 | 474 | result: dict[str, Any] = json.load(f) |
450 | 475 | return result |
451 | 476 |
|
| 477 | + def _evaluate_with_redux(self, js_body: str, args: Any = None) -> Any: |
| 478 | + """Execute JS with the Redux store available as ``store``. |
| 479 | +
|
| 480 | + # SY-3670 — Uses React fiber walking to locate the Redux store |
| 481 | + # from the React root. This is a browser-mode workaround for |
| 482 | + # features that normally rely on Tauri APIs. |
| 483 | +
|
| 484 | + Args: |
| 485 | + js_body: JS code to execute. ``store`` and ``args`` are in scope. |
| 486 | + args: Optional arguments forwarded to the JS function. |
| 487 | + """ |
| 488 | + js = "(args) => {" + _FIND_REDUX_STORE_JS + js_body + "}" |
| 489 | + return self.layout.page.evaluate(js, args) |
| 490 | + |
| 491 | + def import_page(self, json_path: str, name: str) -> None: |
| 492 | + """Import a page from a JSON file via direct JS injection. |
| 493 | +
|
| 494 | + Since the console uses Tauri's native file dialog for imports |
| 495 | + (unavailable in browser mode), this method bypasses the dialog |
| 496 | + by reading the JSON file and dispatching Redux actions directly. |
| 497 | +
|
| 498 | + # SY-3670 will address this issue. |
| 499 | +
|
| 500 | + Args: |
| 501 | + json_path: Path to the JSON file to import. |
| 502 | + name: Display name for the imported page tab. |
| 503 | + """ |
| 504 | + with open(json_path, "r") as f: |
| 505 | + data: dict[str, Any] = json.load(f) |
| 506 | + |
| 507 | + resource_type = data.get("type") |
| 508 | + if resource_type is None: |
| 509 | + raise ValueError(f"JSON file missing 'type' field: {json_path}") |
| 510 | + |
| 511 | + type_config: dict[str, dict[str, str]] = { |
| 512 | + "lineplot": {"slice": "line", "icon": "Visualize"}, |
| 513 | + "schematic": {"slice": "schematic", "icon": "Schematic"}, |
| 514 | + "log": {"slice": "log", "icon": "Log"}, |
| 515 | + "table": {"slice": "table", "icon": "Table"}, |
| 516 | + } |
| 517 | + |
| 518 | + config = type_config.get(resource_type) |
| 519 | + if config is None: |
| 520 | + raise ValueError(f"Unsupported resource type: {resource_type}") |
| 521 | + |
| 522 | + self._evaluate_with_redux( |
| 523 | + """ |
| 524 | + const [data, name, sliceName, icon] = args; |
| 525 | + const key = crypto.randomUUID(); |
| 526 | + store.dispatch({ |
| 527 | + type: sliceName + '/create', |
| 528 | + payload: { ...data, key } |
| 529 | + }); |
| 530 | + store.dispatch({ |
| 531 | + type: 'layout/place', |
| 532 | + payload: { |
| 533 | + key, name, |
| 534 | + location: 'mosaic', |
| 535 | + type: data.type, |
| 536 | + icon, |
| 537 | + windowKey: 'main', |
| 538 | + } |
| 539 | + }); |
| 540 | + """, |
| 541 | + [data, name, config["slice"], config["icon"]], |
| 542 | + ) |
| 543 | + |
| 544 | + self.layout.get_tab(name).wait_for(state="visible", timeout=10000) |
| 545 | + |
| 546 | + def import_workspace(self, name: str, data: dict[str, Any]) -> None: |
| 547 | + """Import a workspace via command palette with JS injection fallback. |
| 548 | +
|
| 549 | + Triggers "Import a workspace" from the command palette. Since the |
| 550 | + console uses Tauri's native directory dialog (unavailable in browser |
| 551 | + mode), the actual import falls back to dispatching Redux actions |
| 552 | + via JS injection. |
| 553 | +
|
| 554 | + # SY-3670 will address the browser-mode limitation. |
| 555 | +
|
| 556 | + Args: |
| 557 | + name: Name for the imported workspace. |
| 558 | + data: Export data dict with 'layout' and 'components' keys |
| 559 | + (as returned by export_workspace). |
| 560 | + """ |
| 561 | + self.layout.command_palette("Import a workspace") |
| 562 | + |
| 563 | + sliceMap: dict[str, str] = { |
| 564 | + "lineplot": "line", |
| 565 | + "schematic": "schematic", |
| 566 | + "log": "log", |
| 567 | + "table": "table", |
| 568 | + } |
| 569 | + |
| 570 | + self._evaluate_with_redux( |
| 571 | + """ |
| 572 | + const [name, layoutData, components, sliceMap] = args; |
| 573 | + const wsKey = crypto.randomUUID(); |
| 574 | + store.dispatch({ |
| 575 | + type: 'workspace/setActive', |
| 576 | + payload: { key: wsKey, name, layout: layoutData }, |
| 577 | + }); |
| 578 | + store.dispatch({ |
| 579 | + type: 'layout/setWorkspace', |
| 580 | + payload: { slice: layoutData, keepNav: false }, |
| 581 | + }); |
| 582 | + for (const [key, component] of Object.entries(components)) { |
| 583 | + const sliceName = sliceMap[component.type]; |
| 584 | + if (!sliceName) continue; |
| 585 | + store.dispatch({ |
| 586 | + type: sliceName + '/create', |
| 587 | + payload: { ...component, key }, |
| 588 | + }); |
| 589 | + } |
| 590 | + """, |
| 591 | + [name, data["layout"], data["components"], sliceMap], |
| 592 | + ) |
| 593 | + |
| 594 | + self.layout.page.get_by_role("button").filter(has_text=name).wait_for( |
| 595 | + state="visible", timeout=10000 |
| 596 | + ) |
| 597 | + |
| 598 | + def export_workspace(self, name: str) -> dict[str, Any]: |
| 599 | + """Export a workspace via context menu with JS injection fallback. |
| 600 | +
|
| 601 | + Opens the context menu on the workspace and clicks Export. Since the |
| 602 | + console uses Tauri's native directory dialog and file system APIs |
| 603 | + (unavailable in browser mode), the actual data extraction falls back |
| 604 | + to reading Redux state via JS injection. |
| 605 | +
|
| 606 | + # SY-3670 will address the browser-mode limitation. |
| 607 | +
|
| 608 | + Args: |
| 609 | + name: Name of the workspace to export. |
| 610 | +
|
| 611 | + Returns: |
| 612 | + Dict with 'layout' (the layout state) and 'components' |
| 613 | + (dict of component key -> exported state with type field). |
| 614 | + """ |
| 615 | + self.layout.show_resource_toolbar("workspace") |
| 616 | + workspace_item = self.get_item(name) |
| 617 | + workspace_item.wait_for(state="visible", timeout=5000) |
| 618 | + self.ctx_menu.action(workspace_item, "Export") |
| 619 | + self.notifications.close_all() |
| 620 | + |
| 621 | + result: dict[str, Any] = self._evaluate_with_redux(""" |
| 622 | + const state = store.getState(); |
| 623 | + const layoutState = state.layout; |
| 624 | + const sliceMap = { |
| 625 | + lineplot: { slice: 'line', collection: 'plots' }, |
| 626 | + schematic: { slice: 'schematic', collection: 'schematics' }, |
| 627 | + log: { slice: 'log', collection: 'logs' }, |
| 628 | + table: { slice: 'table', collection: 'tables' }, |
| 629 | + }; |
| 630 | + const components = {}; |
| 631 | + const layouts = {}; |
| 632 | + for (const [key, layout] of Object.entries(layoutState.layouts)) { |
| 633 | + if (layout.excludeFromWorkspace || layout.location === 'modal') |
| 634 | + continue; |
| 635 | + layouts[key] = layout; |
| 636 | + const mapping = sliceMap[layout.type]; |
| 637 | + if (!mapping) continue; |
| 638 | + const cs = state[mapping.slice]?.[mapping.collection]?.[key]; |
| 639 | + if (cs) components[key] = { ...cs, type: layout.type }; |
| 640 | + } |
| 641 | + return { layout: { ...layoutState, layouts }, components }; |
| 642 | + """) |
| 643 | + |
| 644 | + save_path = get_results_path(f"{name}_export.json") |
| 645 | + with open(save_path, "w") as f: |
| 646 | + json.dump(result, f, indent=2) |
| 647 | + |
| 648 | + self.layout.close_left_toolbar() |
| 649 | + return result |
| 650 | + |
452 | 651 | def snapshot_page_to_active_range(self, name: str, range_name: str) -> None: |
453 | 652 | """Snapshot a page to the active range via context menu. |
454 | 653 |
|
|
0 commit comments