arrayview 0.40.0__tar.gz → 0.41.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {arrayview-0.40.0 → arrayview-0.41.0}/PKG-INFO +1 -1
  2. {arrayview-0.40.0 → arrayview-0.41.0}/pyproject.toml +1 -1
  3. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/ARCHITECTURE.md +1 -1
  4. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_launch_plan.py +9 -1
  5. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_launcher.py +103 -13
  6. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_lifecycle.py +4 -0
  7. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_query.py +70 -0
  8. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_server.py +107 -21
  9. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_session.py +4 -0
  10. arrayview-0.41.0/src/arrayview/_tutorial.py +194 -0
  11. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_viewer.html +848 -134
  12. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode_browser.py +38 -23
  13. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode_extension.py +48 -20
  14. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode_signal.py +3 -0
  15. arrayview-0.41.0/src/arrayview/arrayview-opener.vsix +0 -0
  16. {arrayview-0.40.0 → arrayview-0.41.0}/tests/lifecycle_matrix.py +1 -1
  17. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_browser.py +44 -69
  18. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_display_opener_results.py +40 -1
  19. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_plan.py +36 -3
  20. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_lifecycle_contract.py +141 -17
  21. arrayview-0.41.0/tests/test_tutorial_browser.py +402 -0
  22. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_vscode_ack_protocol.py +20 -0
  23. {arrayview-0.40.0 → arrayview-0.41.0}/tests/visual_smoke.py +23 -11
  24. arrayview-0.40.0/src/arrayview/_tutorial.py +0 -106
  25. arrayview-0.40.0/src/arrayview/arrayview-opener.vsix +0 -0
  26. arrayview-0.40.0/tests/test_command_reachability.py +0 -140
  27. arrayview-0.40.0/tests/test_cross_mode_parametrized.py +0 -214
  28. arrayview-0.40.0/tests/test_interactions.py +0 -2132
  29. arrayview-0.40.0/tests/test_tutorial_browser.py +0 -197
  30. arrayview-0.40.0/tests/test_view_component_integration.py +0 -1675
  31. arrayview-0.40.0/tests/test_view_component_unit.py +0 -165
  32. {arrayview-0.40.0 → arrayview-0.41.0}/.gitignore +0 -0
  33. {arrayview-0.40.0 → arrayview-0.41.0}/LICENSE +0 -0
  34. {arrayview-0.40.0 → arrayview-0.41.0}/README.md +0 -0
  35. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/__init__.py +0 -0
  36. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/__main__.py +0 -0
  37. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_analysis.py +0 -0
  38. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_app.py +0 -0
  39. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_config.py +0 -0
  40. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_diagnostics.py +0 -0
  41. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_dicom.py +0 -0
  42. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_diff.py +0 -0
  43. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_icon.png +0 -0
  44. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_imaging.py +0 -0
  45. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_instance_registry.py +0 -0
  46. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_io.py +0 -0
  47. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_launch_trace.py +0 -0
  48. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_overlays.py +0 -0
  49. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_platform.py +0 -0
  50. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_render.py +0 -0
  51. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_analysis.py +0 -0
  52. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_drop.py +0 -0
  53. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_export.py +0 -0
  54. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_loading.py +0 -0
  55. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_persistence.py +0 -0
  56. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_preferences.py +0 -0
  57. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_preload.py +0 -0
  58. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_rendering.py +0 -0
  59. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_segmentation.py +0 -0
  60. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_state.py +0 -0
  61. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_vectorfield.py +0 -0
  62. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_websocket.py +0 -0
  63. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_segmentation.py +0 -0
  64. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_session_spec.py +0 -0
  65. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_shell.html +0 -0
  66. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_synthetic_mri.py +0 -0
  67. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_torch.py +0 -0
  68. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vectorfield.py +0 -0
  69. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode.py +0 -0
  70. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/gsap.min.js +0 -0
  71. {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/todo.md +0 -0
  72. {arrayview-0.40.0 → arrayview-0.41.0}/tests/capture_patient_loading.py +0 -0
  73. {arrayview-0.40.0 → arrayview-0.41.0}/tests/capture_v_animation.py +0 -0
  74. {arrayview-0.40.0 → arrayview-0.41.0}/tests/conftest.py +0 -0
  75. {arrayview-0.40.0 → arrayview-0.41.0}/tests/make_vectorfield_test_arrays.py +0 -0
  76. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_1d_arrays.py +0 -0
  77. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_api.py +0 -0
  78. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_backend_shared.py +0 -0
  79. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_cli.py +0 -0
  80. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_colorbar_hover_highlight.py +0 -0
  81. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_config.py +0 -0
  82. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_dicom.py +0 -0
  83. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_doctor_commands.py +0 -0
  84. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_drop_import.py +0 -0
  85. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_instance_registry.py +0 -0
  86. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_invocation_contract_matrix.py +0 -0
  87. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_io.py +0 -0
  88. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_large_arrays.py +0 -0
  89. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_blackbox.py +0 -0
  90. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_diagnostics.py +0 -0
  91. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_execution_ownership.py +0 -0
  92. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_trace.py +0 -0
  93. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_loading_server.py +0 -0
  94. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_mode_consistency.py +0 -0
  95. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_mode_matrix.py +0 -0
  96. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_mode_roundtrip.py +0 -0
  97. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_nifti_meta.py +0 -0
  98. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_nifti_series.py +0 -0
  99. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_preferences_ui.py +0 -0
  100. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_public_invocation_regressions.py +0 -0
  101. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_render_failure_reporting.py +0 -0
  102. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_render_progress.py +0 -0
  103. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_rgb_pixel_art.py +0 -0
  104. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_safe_server_stop.py +0 -0
  105. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_server_identity_status.py +0 -0
  106. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_session_spec.py +0 -0
  107. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_spawn_ownership.py +0 -0
  108. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_torch.py +0 -0
  109. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_tutorial.py +0 -0
  110. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_value_formatting.py +0 -0
  111. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_view_handle_lifecycle.py +0 -0
  112. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_view_launch_execution.py +0 -0
  113. {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_window_level_preview.py +0 -0
  114. {arrayview-0.40.0 → arrayview-0.41.0}/tests/ui_audit.py +0 -0
  115. {arrayview-0.40.0 → arrayview-0.41.0}/tests/v_anim_frames/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.40.0
3
+ Version: 0.41.0
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.40.0"
7
+ version = "0.41.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -169,7 +169,7 @@ A refactoring in progress that introduces a View/Layer/Slicer/LayoutStrategy/Mod
169
169
  **Status:** Phases 1–15 complete. Phase 12 (keybind collapse) and Phase 17 (final cleanup) pending completion of render-pipeline migration from legacy globals to `displayState`.
170
170
 
171
171
  ### Command Registry
172
- All keybinds flow through a VS Code-style command registry in `_viewer.html`. Three tables: `commands` (id → `{title, when, run}`), `keybinds` (key+modifiers → command id), and `makeContext(state)` (mode/state flag bag). `dispatchCommand(e)` is wired as a prefix to the keydown handler; on a match it evaluates `when` against the context and runs the command, otherwise falls through. The help overlay is rendered at runtime from the `GUIDE_TABS` static data structure in `_viewer.html` — when adding or changing a keybind, update `GUIDE_TABS` manually. A `/`-triggered command palette fuzzy-searches all commands. Cross-mode enablement is guarded by `tests/test_command_reachability.py`.
172
+ All keybinds flow through a VS Code-style command registry in `_viewer.html`. Three tables: `commands` (id → `{title, when, run}`), `keybinds` (key+modifiers → command id), and `makeContext(state)` (mode/state flag bag). `dispatchCommand(e)` is wired as a prefix to the keydown handler; on a match it evaluates `when` against the context and runs the command, otherwise falls through. The help overlay is rendered at runtime from the `GUIDE_TABS` static data structure in `_viewer.html` — when adding or changing a keybind, update `GUIDE_TABS` manually. A `/`-triggered command palette fuzzy-searches all commands.
173
173
 
174
174
  ### Reconcilers
175
175
  Functions in the "UI Validation and Reconciliation" section (~line 13666) that enforce consistent UI state. When mode changes happen, reconcilers update visibility of containers, colorbars, dynamic islands, and compare sub-mode UI. There are four:
@@ -267,6 +267,8 @@ def _completion_target(
267
267
  if plan.display is Display.BROWSER:
268
268
  if _placement(evidence) is Placement.SSH:
269
269
  return CompletionTarget.GUIDANCE_PRINTED
270
+ if _placement(evidence) is Placement.VSCODE_REMOTE:
271
+ return CompletionTarget.FRAME_READY
270
272
  return CompletionTarget.DISPATCH_ACCEPTED
271
273
  if invocation in {Invocation.CLI, Invocation.VSCODE_EXPLORER}:
272
274
  return CompletionTarget.FRAME_READY
@@ -412,7 +414,9 @@ def plan_launch(
412
414
  window=None if remote_jupyter else window,
413
415
  inline=False if remote_jupyter else intent.inline,
414
416
  environment=environment,
415
- native_available=facts.native_backend is not None, reasons=reasons,
417
+ native_available=facts.native_backend is not None,
418
+ in_vscode_tunnel=facts.in_vscode_tunnel,
419
+ reasons=reasons,
416
420
  )
417
421
  if display is Display.VSCODE and environment not in {
418
422
  Environment.VSCODE_LOCAL, Environment.VSCODE_REMOTE
@@ -436,6 +440,7 @@ def _display_policy(
436
440
  inline: bool | None,
437
441
  environment: Environment,
438
442
  native_available: bool,
443
+ in_vscode_tunnel: bool,
439
444
  reasons: list[str],
440
445
  ) -> tuple[Display, Display | None, bool]:
441
446
  if window == "none":
@@ -461,6 +466,9 @@ def _display_policy(
461
466
  return Display.VSCODE, None, False
462
467
  return Display.VSCODE, Display.BROWSER, True
463
468
  if window == "browser":
469
+ if environment is Environment.VSCODE_REMOTE and in_vscode_tunnel:
470
+ reasons.append("remote_external_browser")
471
+ return Display.BROWSER, None, False
464
472
  if environment is Environment.VSCODE_REMOTE:
465
473
  reasons.append("remote_browser_redirected_to_vscode")
466
474
  return Display.VSCODE, None, False
@@ -1813,6 +1813,9 @@ def _handle_cli_existing_server(
1813
1813
  expected_server_id=expected_server_id,
1814
1814
  native_request_id=native_request_id,
1815
1815
  )
1816
+ # Inside this try on purpose: the generated bundle is removed in the
1817
+ # finally below, and these sessions read from it.
1818
+ _register_tutorial_extras(port, expected_server_id)
1816
1819
  except Exception as e:
1817
1820
  err = str(e)
1818
1821
  stale_stack_server = (
@@ -2065,7 +2068,14 @@ def _handle_cli_spawned_daemon(
2065
2068
  and is_remote
2066
2069
  ) or (launch_context is None and not use_native_shell):
2067
2070
  _configure_vscode_port_preview(
2068
- port, in_vscode=not is_remote, is_remote=is_remote
2071
+ port,
2072
+ in_vscode=not is_remote,
2073
+ is_remote=is_remote,
2074
+ is_tunnel=(
2075
+ launch_context.evidence.in_vscode_tunnel
2076
+ if launch_context is not None
2077
+ else None
2078
+ ),
2069
2079
  )
2070
2080
 
2071
2081
  daemon_connect_timeout = (
@@ -2201,6 +2211,9 @@ def _handle_cli_spawned_daemon(
2201
2211
  print(f"Error while loading compare array: {e}")
2202
2212
  sys.exit(1)
2203
2213
 
2214
+ # Before the bundle is cleaned up below: these sessions read from it.
2215
+ _register_tutorial_extras(port, daemon_server_id)
2216
+
2204
2217
  try:
2205
2218
  _open_cli_spawned_view(
2206
2219
  port=port,
@@ -2709,6 +2722,77 @@ _FAILED_LOAD_EXIT_GRACE_SECONDS = float(
2709
2722
  )
2710
2723
 
2711
2724
 
2725
+ # The tutorial's extra sessions. Two of its sections cannot share the main
2726
+ # session — a vector field disables statistical projections for whatever
2727
+ # session it is attached to, and a ragged collection is a single session made
2728
+ # of differently shaped files — so they are registered separately and the tour
2729
+ # navigates to them by sid. Set once per CLI invocation, read when the viewer
2730
+ # URL is built; empty for every launch that is not the tutorial, in which case
2731
+ # the tour simply does not offer those sections.
2732
+ _TUTORIAL_BUNDLE = None
2733
+ _TUTORIAL_EXTRAS: dict[str, str] = {}
2734
+
2735
+
2736
+ def _register_tutorial_extras(port: int, expected_server_id: str | None = None) -> None:
2737
+ """Register the tutorial's vector-field and ragged-collection sessions.
2738
+
2739
+ Best effort: a failure here costs two sections, not the tour, so it is
2740
+ traced and swallowed rather than allowed to fail a launch that has already
2741
+ loaded the main array.
2742
+
2743
+ The collection loads eagerly on purpose. The generated bundle is deleted as
2744
+ soon as registration returns, and a lazy collection would still be holding
2745
+ paths into it.
2746
+ """
2747
+ bundle = _TUTORIAL_BUNDLE
2748
+ if bundle is None:
2749
+ return
2750
+ try:
2751
+ _register_tutorial_extras_impl(port, bundle, expected_server_id)
2752
+ finally:
2753
+ from arrayview._tutorial import cleanup_tutorial_bundle
2754
+
2755
+ cleanup_tutorial_bundle(bundle.extras_directory)
2756
+
2757
+
2758
+ def _register_tutorial_extras_impl(port, bundle, expected_server_id) -> None:
2759
+ try:
2760
+ flow = _load_session_from_filepath(
2761
+ port,
2762
+ bundle.flow_file,
2763
+ "flow",
2764
+ expected_server_id=expected_server_id,
2765
+ )
2766
+ flow_sid = flow.get("sid")
2767
+ if flow_sid and "error" not in flow:
2768
+ attached = _attach_vectorfield_to_session(
2769
+ port,
2770
+ str(flow_sid),
2771
+ bundle.flow_field_file,
2772
+ expected_server_id=expected_server_id,
2773
+ )
2774
+ if "error" not in attached:
2775
+ _TUTORIAL_EXTRAS["flow"] = str(flow_sid)
2776
+ except Exception as exc: # pragma: no cover - best effort
2777
+ _trace_launch_event("tutorial.extra_failed", extra="flow", error=str(exc))
2778
+
2779
+ try:
2780
+ stack = _load_session_from_filepath(
2781
+ port,
2782
+ "",
2783
+ "cases",
2784
+ dir_patterns=[bundle.stack_pattern],
2785
+ collection_stack="ragged",
2786
+ collection_load="eager",
2787
+ expected_server_id=expected_server_id,
2788
+ )
2789
+ stack_sid = stack.get("sid")
2790
+ if stack_sid and "error" not in stack:
2791
+ _TUTORIAL_EXTRAS["stack"] = str(stack_sid)
2792
+ except Exception as exc: # pragma: no cover - best effort
2793
+ _trace_launch_event("tutorial.extra_failed", extra="stack", error=str(exc))
2794
+
2795
+
2712
2796
  def _join_query_values(values: _CSVValues) -> str:
2713
2797
  if isinstance(values, str):
2714
2798
  return values
@@ -2743,6 +2827,11 @@ def _viewer_query(
2743
2827
  parts.append(f"dim_y={dims[1]}")
2744
2828
  if inline:
2745
2829
  parts.append("inline=1")
2830
+ if _TUTORIAL_EXTRAS:
2831
+ # The tour needs these to survive the navigations it makes between
2832
+ # its own sessions, so they ride in the URL rather than in memory.
2833
+ for key, extra_sid in sorted(_TUTORIAL_EXTRAS.items()):
2834
+ parts.append(f"tutorial_{key}_sid={extra_sid}")
2746
2835
  return "?" + "&".join(parts)
2747
2836
 
2748
2837
 
@@ -3397,9 +3486,8 @@ def view(
3397
3486
  launch_context=_launch_context,
3398
3487
  )
3399
3488
 
3400
- # VS Code tunnel/remote: use the server + WebSocket path.
3401
- # WS works through the devtunnel when the port is public — the extension
3402
- # calls ensurePortPublic() before asExternalUri.
3489
+ # VS Code tunnel/remote: use the server + WebSocket path. The opener owns
3490
+ # private client-side delivery for tunnel sessions.
3403
3491
  # Fall through to the server-based path below (don't return early).
3404
3492
 
3405
3493
  # With a planned existing server: register arrays via /load. Keep display
@@ -3644,7 +3732,10 @@ def view(
3644
3732
  and _launch_plan.display is Display.VSCODE
3645
3733
  ):
3646
3734
  _configure_vscode_port_preview(
3647
- port, in_vscode=False, is_remote=True
3735
+ port,
3736
+ in_vscode=False,
3737
+ is_remote=True,
3738
+ is_tunnel=_launch_context.evidence.in_vscode_tunnel,
3648
3739
  )
3649
3740
  _platform_mod._jupyter_server_port = port
3650
3741
  except Exception:
@@ -4935,6 +5026,8 @@ def _configure_tutorial_args(args):
4935
5026
  """Turn a file-less CLI request into the generated tutorial bundle."""
4936
5027
  from arrayview._tutorial import create_tutorial_bundle
4937
5028
 
5029
+ global _TUTORIAL_BUNDLE
5030
+
4938
5031
  tutorial = create_tutorial_bundle()
4939
5032
  args.files = [tutorial.base_file, tutorial.compare_file]
4940
5033
  args.overlay = [f"Regions={tutorial.overlay_file}"]
@@ -4942,6 +5035,8 @@ def _configure_tutorial_args(args):
4942
5035
  args._demo_cleanup = False
4943
5036
  args._tutorial_cleanup_dir = tutorial.directory
4944
5037
  args.rgb = False
5038
+ _TUTORIAL_BUNDLE = tutorial
5039
+ _TUTORIAL_EXTRAS.clear()
4945
5040
  return tutorial
4946
5041
 
4947
5042
 
@@ -5123,8 +5218,7 @@ def arrayview():
5123
5218
  action="store_true",
5124
5219
  help=(
5125
5220
  "Start a persistent server on the given port without loading any data. "
5126
- "Useful on VS Code remote tunnel: run this first, set the port to Public "
5127
- "in the Ports tab, then use 'arrayview FILE' freely."
5221
+ "Useful for reusing one ArrayView server across repeated launches."
5128
5222
  ),
5129
5223
  )
5130
5224
  parser.add_argument(
@@ -5522,8 +5616,7 @@ def arrayview():
5522
5616
  _revalidate_launch_server(serve_context, args.port)
5523
5617
  print(
5524
5618
  f"[ArrayView] Server already running on port {args.port}. "
5525
- "Set port to Public in VS Code Ports tab if not done yet, "
5526
- "then run: arrayview your_file.npy"
5619
+ "Run: arrayview your_file.npy"
5527
5620
  )
5528
5621
  return
5529
5622
 
@@ -5553,6 +5646,7 @@ def arrayview():
5553
5646
  args.port,
5554
5647
  in_vscode=serve_context.evidence.in_vscode_terminal,
5555
5648
  is_remote=serve_context.evidence.is_vscode_remote,
5649
+ is_tunnel=serve_context.evidence.in_vscode_tunnel,
5556
5650
  )
5557
5651
  script = (
5558
5652
  "from arrayview._launcher import _serve_empty; "
@@ -5874,10 +5968,6 @@ def arrayview():
5874
5968
  "--window native is not available over a VS Code remote/tunnel "
5875
5969
  "session; opening a VS Code tab instead."
5876
5970
  ),
5877
- "remote_browser_redirected_to_vscode": (
5878
- "--window browser cannot reach a browser on the remote host; "
5879
- "opening a VS Code tab instead."
5880
- ),
5881
5971
  "native_unavailable": (
5882
5972
  "--window native found no usable GUI backend (no display server "
5883
5973
  "or no Qt/GTK for pywebview); opening a browser instead."
@@ -13,6 +13,7 @@ from arrayview._session import (
13
13
  SESSIONS,
14
14
  VIEWER_CONNECTION_EPOCHS,
15
15
  VIEWER_PHASE_JOURNALS,
16
+ VIEWER_LAUNCH_ROUTES,
16
17
  VIEWER_RELEASE_TASKS,
17
18
  )
18
19
 
@@ -88,6 +89,9 @@ def release_session(sid: str, *, cancel_if_missing: bool = False) -> bool:
88
89
  if retire_journals:
89
90
  journals = VIEWER_PHASE_JOURNALS.pop(sid, {})
90
91
  for journal in journals.values():
92
+ navigation_key = journal.get("navigation_key")
93
+ if navigation_key:
94
+ VIEWER_LAUNCH_ROUTES.pop(navigation_key, None)
91
95
  journal_task = journal.get("release_task")
92
96
  if journal_task is not None and not journal_task.done():
93
97
  try:
@@ -1,5 +1,7 @@
1
1
  import asyncio
2
2
  import io
3
+ import re
4
+ from urllib.parse import parse_qs
3
5
 
4
6
  import numpy as np
5
7
  from fastapi import Depends, HTTPException, Request, Response
@@ -73,6 +75,52 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
73
75
  journals = _session_mod.VIEWER_PHASE_JOURNALS.setdefault(sid, {})
74
76
  if phase == "launch-prepared":
75
77
  previous_journal = journals.get(request_id)
78
+ viewer_query = body.get("viewer_query")
79
+ tab_key = body.get("tab_key")
80
+ navigation_key = body.get("navigation_key")
81
+ navigation_attempt = body.get("navigation_attempt")
82
+ short_route = any(
83
+ value is not None
84
+ for value in (
85
+ viewer_query,
86
+ tab_key,
87
+ navigation_key,
88
+ navigation_attempt,
89
+ )
90
+ )
91
+ if short_route:
92
+ if (
93
+ not isinstance(viewer_query, str)
94
+ or not viewer_query.startswith("?")
95
+ or len(viewer_query) > 65536
96
+ ):
97
+ raise HTTPException(status_code=400, detail="Invalid viewer query")
98
+ parsed_query = parse_qs(
99
+ viewer_query.removeprefix("?"),
100
+ keep_blank_values=True,
101
+ )
102
+ if parsed_query.get("sid") != [sid]:
103
+ raise HTTPException(
104
+ status_code=409,
105
+ detail="Viewer primary session changed",
106
+ )
107
+ if any(key.startswith("_av_") for key in parsed_query):
108
+ raise HTTPException(
109
+ status_code=400,
110
+ detail="Reserved viewer query field",
111
+ )
112
+ if (
113
+ not isinstance(tab_key, str)
114
+ or re.fullmatch(r"[A-Za-z0-9_-]{16,64}", tab_key) is None
115
+ or not isinstance(navigation_key, str)
116
+ or re.fullmatch(r"[A-Za-z0-9_-]{16,64}", navigation_key) is None
117
+ or not isinstance(navigation_attempt, int)
118
+ or not 0 <= navigation_attempt <= 32
119
+ ):
120
+ raise HTTPException(
121
+ status_code=400,
122
+ detail="Invalid short viewer route identity",
123
+ )
76
124
  previous_release_task = (
77
125
  previous_journal.get("release_task")
78
126
  if previous_journal is not None
@@ -83,9 +131,23 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
83
131
  and not previous_release_task.done()
84
132
  ):
85
133
  previous_release_task.cancel()
134
+ previous_navigation_key = (
135
+ previous_journal.get("navigation_key")
136
+ if previous_journal is not None
137
+ else None
138
+ )
139
+ if previous_navigation_key:
140
+ _session_mod.VIEWER_LAUNCH_ROUTES.pop(
141
+ previous_navigation_key,
142
+ None,
143
+ )
86
144
  journal = journals[request_id] = {
87
145
  "token": token,
88
146
  "window_id": window_id,
147
+ "viewer_query": viewer_query,
148
+ "tab_key": tab_key,
149
+ "navigation_key": navigation_key,
150
+ "navigation_attempt": navigation_attempt,
89
151
  "viewer_instance_ids": [],
90
152
  "phases": [],
91
153
  "related_sids": None,
@@ -93,12 +155,20 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
93
155
  "release_task": None,
94
156
  "disconnect_release_grace_seconds": 30.0,
95
157
  }
158
+ if navigation_key:
159
+ _session_mod.VIEWER_LAUNCH_ROUTES[navigation_key] = (
160
+ sid,
161
+ request_id,
162
+ )
96
163
  return {
97
164
  "sid": sid,
98
165
  "request_id": request_id,
99
166
  "window_id": window_id,
100
167
  "server_id": _session_mod.SERVER_RUNTIME.instance_id,
101
168
  "token": token,
169
+ "tab_key": tab_key,
170
+ "navigation_key": navigation_key,
171
+ "navigation_attempt": navigation_attempt,
102
172
  "phases": [],
103
173
  "viewer_instance_ids": [],
104
174
  "related_sids": [],
@@ -10,6 +10,7 @@ import os
10
10
  import time
11
11
  import uuid
12
12
  from dataclasses import dataclass, replace
13
+ from urllib.parse import urlencode
13
14
  from fastapi import FastAPI, HTTPException, Request
14
15
  from fastapi.responses import HTMLResponse, JSONResponse, Response
15
16
  from importlib.resources import files as _pkg_files
@@ -338,25 +339,45 @@ def status():
338
339
 
339
340
  # ── Root UI Route ─────────────────────────────────────────────────
340
341
 
341
- @app.get("/")
342
- def get_ui(request: Request, sid: str = None):
343
- """Viewer page."""
344
- # VS Code's asExternalUri() strips query parameters, so ?sid= is often lost
345
- # before the page loads. Embed the SID directly in the HTML so the viewer
346
- # JS can find it regardless of the URL.
347
- if not sid:
348
- # No sid in URL — VS Code strips the query string before loading the
349
- # page, so ?sid= is lost. Inject the latest valid session
350
- # server-side so the viewer JS can find it regardless of the URL.
351
- if SESSIONS:
352
- latest_sid = list(SESSIONS.keys())[-1]
353
- query_val = json.dumps(f"?sid={latest_sid}")
354
- else:
355
- query_val = "null" # viewer will show "Session not found or expired"
356
- else:
357
- # sid is present in the URL (valid or not) — let the JS fetch /metadata/{sid}
358
- # and handle errors itself (shows "Session not found or expired" on 404).
359
- query_val = "null"
342
+ def _trace_page_request(
343
+ request: Request,
344
+ *,
345
+ request_id: str | None = None,
346
+ navigation_attempt: str | None = None,
347
+ ) -> None:
348
+ """Record that a viewer page fetch reached this backend.
349
+
350
+ A tunnel-delivered launch that never logs ``script-loaded`` is ambiguous
351
+ from the opener's side alone: the relay may have dropped the page request,
352
+ or the page may have arrived and the script stalled. This distinguishes
353
+ the two. Inert unless ``ARRAYVIEW_LAUNCH_TRACE`` is set.
354
+ """
355
+ if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
356
+ return
357
+ try:
358
+ from arrayview._launch_trace import emit_launch_event
359
+
360
+ params = request.query_params
361
+ emit_launch_event(
362
+ "page.requested",
363
+ request_id=request_id or params.get("_av_launch_request_id"),
364
+ navigation_attempt=(
365
+ navigation_attempt
366
+ or params.get("_av_navigation_attempt")
367
+ or "0"
368
+ ),
369
+ )
370
+ except Exception:
371
+ pass
372
+
373
+
374
+ def _viewer_ui_response(
375
+ request: Request,
376
+ *,
377
+ sid: str | None,
378
+ query_val: str,
379
+ ):
380
+ """Render one viewer page with its launch query already chosen."""
360
381
  _init_luts()
361
382
  _cfg_colormaps = get_viewer_colormaps()
362
383
  _valid_cfg_colormaps = (
@@ -390,7 +411,72 @@ def get_ui(request: Request, sid: str = None):
390
411
  html,
391
412
  request=request,
392
413
  media_type="text/html; charset=utf-8",
393
- # no-store stays: when the URL carries no ?sid=, the body embeds the
394
- # latest session id, so a cached copy would resurrect a dead session.
395
414
  headers={"Cache-Control": "no-store"},
396
415
  )
416
+
417
+
418
+ @app.get("/_av/{tab_key}/{navigation_key}")
419
+ def get_short_viewer_ui(
420
+ request: Request,
421
+ tab_key: str,
422
+ navigation_key: str,
423
+ ):
424
+ """Resolve a private integrated-browser launch without exposing its query."""
425
+ owner = _session_mod.VIEWER_LAUNCH_ROUTES.get(navigation_key)
426
+ if owner is None:
427
+ raise HTTPException(status_code=404, detail="Viewer launch route expired")
428
+ sid, request_id = owner
429
+ journal = (
430
+ _session_mod.VIEWER_PHASE_JOURNALS
431
+ .get(sid, {})
432
+ .get(request_id)
433
+ )
434
+ if (
435
+ journal is None
436
+ or journal.get("tab_key") != tab_key
437
+ or journal.get("navigation_key") != navigation_key
438
+ ):
439
+ raise HTTPException(status_code=409, detail="Viewer launch route changed")
440
+ bootstrap_query = journal["viewer_query"] + "&" + urlencode(
441
+ {
442
+ "_av_integrated_browser": "1",
443
+ "_av_launch_request_id": request_id,
444
+ "_av_launch_server_id": _session_mod.SERVER_RUNTIME.instance_id,
445
+ "_av_launch_window_id": journal["window_id"],
446
+ "_av_launch_token": journal["token"],
447
+ "_av_navigation_attempt": journal["navigation_attempt"],
448
+ }
449
+ )
450
+ _trace_page_request(
451
+ request,
452
+ request_id=request_id,
453
+ navigation_attempt=str(journal["navigation_attempt"]),
454
+ )
455
+ return _viewer_ui_response(
456
+ request,
457
+ sid=sid,
458
+ query_val=json.dumps(bootstrap_query),
459
+ )
460
+
461
+
462
+ @app.get("/")
463
+ def get_ui(request: Request, sid: str = None):
464
+ """Viewer page."""
465
+ _trace_page_request(request)
466
+ # VS Code's asExternalUri() strips query parameters, so ?sid= is often lost
467
+ # before the page loads. Embed the SID directly in the HTML so the viewer
468
+ # JS can find it regardless of the URL.
469
+ if not sid:
470
+ # No sid in URL — VS Code strips the query string before loading the
471
+ # page, so ?sid= is lost. Inject the latest valid session
472
+ # server-side so the viewer JS can find it regardless of the URL.
473
+ if SESSIONS:
474
+ latest_sid = list(SESSIONS.keys())[-1]
475
+ query_val = json.dumps(f"?sid={latest_sid}")
476
+ else:
477
+ query_val = "null" # viewer will show "Session not found or expired"
478
+ else:
479
+ # sid is present in the URL (valid or not) — let the JS fetch /metadata/{sid}
480
+ # and handle errors itself (shows "Session not found or expired" on 404).
481
+ query_val = "null"
482
+ return _viewer_ui_response(request, sid=sid, query_val=query_val)
@@ -40,6 +40,9 @@ NATIVE_READY_REQUESTS: set[tuple[str, str]] = set()
40
40
  # Launch readiness is control-plane state and must exist before a large array
41
41
  # finishes loading. Keyed by SID, then request ID; release_session owns cleanup.
42
42
  VIEWER_PHASE_JOURNALS: dict[str, dict[str, dict]] = {}
43
+ # Short integrated-browser navigation key -> (primary SID, request ID).
44
+ # The full launch query stays in the fenced journal instead of the visible URL.
45
+ VIEWER_LAUNCH_ROUTES: dict[str, tuple[str, str]] = {}
43
46
  _window_process = None
44
47
  PENDING_SESSIONS: set = set() # sids whose data is still loading in a background thread
45
48
  PENDING_SESSION_EVENTS: dict[str, threading.Event] = {}
@@ -630,6 +633,7 @@ __all__ = [
630
633
  "VIEWER_SIDS",
631
634
  "VIEWER_CONNECTIONS_SEEN",
632
635
  "VIEWER_PHASE_JOURNALS",
636
+ "VIEWER_LAUNCH_ROUTES",
633
637
  "SHELL_SOCKETS",
634
638
  "_window_process",
635
639
  "PENDING_SESSIONS",