arrayview 0.49.1__tar.gz → 0.50.1__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 (90) hide show
  1. {arrayview-0.49.1 → arrayview-0.50.1}/PKG-INFO +1 -1
  2. {arrayview-0.49.1 → arrayview-0.50.1}/pyproject.toml +1 -1
  3. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_loading.py +20 -0
  4. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_websocket.py +72 -11
  5. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_viewer.html +302 -31
  6. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode_extension.py +1 -1
  7. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode_signal.py +6 -2
  8. arrayview-0.50.1/src/arrayview/arrayview-opener.vsix +0 -0
  9. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_api.py +24 -0
  10. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_browser.py +5 -3
  11. arrayview-0.49.1/src/arrayview/arrayview-opener.vsix +0 -0
  12. {arrayview-0.49.1 → arrayview-0.50.1}/.gitignore +0 -0
  13. {arrayview-0.49.1 → arrayview-0.50.1}/LICENSE +0 -0
  14. {arrayview-0.49.1 → arrayview-0.50.1}/README.md +0 -0
  15. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/ARCHITECTURE.md +0 -0
  16. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/__init__.py +0 -0
  17. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/__main__.py +0 -0
  18. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_analysis.py +0 -0
  19. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_app.py +0 -0
  20. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_bootstrap_app.py +0 -0
  21. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_config.py +0 -0
  22. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_diagnostics.py +0 -0
  23. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_dicom.py +0 -0
  24. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_diff.py +0 -0
  25. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_extra_ports.py +0 -0
  26. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_icon.png +0 -0
  27. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_imaging.py +0 -0
  28. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_instance_registry.py +0 -0
  29. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_io.py +0 -0
  30. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_launch_plan.py +0 -0
  31. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_launch_trace.py +0 -0
  32. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_launcher.py +0 -0
  33. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_lifecycle.py +0 -0
  34. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_overlays.py +0 -0
  35. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_platform.py +0 -0
  36. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_render.py +0 -0
  37. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_analysis.py +0 -0
  38. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_drop.py +0 -0
  39. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_export.py +0 -0
  40. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_persistence.py +0 -0
  41. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_preferences.py +0 -0
  42. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_preload.py +0 -0
  43. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_query.py +0 -0
  44. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_rendering.py +0 -0
  45. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_segmentation.py +0 -0
  46. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_state.py +0 -0
  47. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_vectorfield.py +0 -0
  48. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_segmentation.py +0 -0
  49. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_server.py +0 -0
  50. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_session.py +0 -0
  51. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_session_spec.py +0 -0
  52. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_shell.html +0 -0
  53. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_source_safety.py +0 -0
  54. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_synthetic_mri.py +0 -0
  55. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_torch.py +0 -0
  56. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_tutorial.py +0 -0
  57. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vectorfield.py +0 -0
  58. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_view_memory.py +0 -0
  59. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode.py +0 -0
  60. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode_browser.py +0 -0
  61. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_watch.py +0 -0
  62. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/gsap.min.js +0 -0
  63. {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/todo.md +0 -0
  64. {arrayview-0.49.1 → arrayview-0.50.1}/tests/capture_patient_loading.py +0 -0
  65. {arrayview-0.49.1 → arrayview-0.50.1}/tests/capture_v_animation.py +0 -0
  66. {arrayview-0.49.1 → arrayview-0.50.1}/tests/conftest.py +0 -0
  67. {arrayview-0.49.1 → arrayview-0.50.1}/tests/make_vectorfield_test_arrays.py +0 -0
  68. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_1d_arrays.py +0 -0
  69. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_backend_shared.py +0 -0
  70. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_cli.py +0 -0
  71. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_config.py +0 -0
  72. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_dicom.py +0 -0
  73. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_drop_import.py +0 -0
  74. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_instance_registry.py +0 -0
  75. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_io.py +0 -0
  76. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_large_arrays.py +0 -0
  77. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_launch_blackbox.py +0 -0
  78. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_mode_roundtrip.py +0 -0
  79. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_nifti_meta.py +0 -0
  80. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_nifti_series.py +0 -0
  81. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_rgb_pixel_art.py +0 -0
  82. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_safe_server_stop.py +0 -0
  83. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_source_safety.py +0 -0
  84. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_torch.py +0 -0
  85. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_tutorial.py +0 -0
  86. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_view_memory.py +0 -0
  87. {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_vsix_bundle.py +0 -0
  88. {arrayview-0.49.1 → arrayview-0.50.1}/tests/ui_audit.py +0 -0
  89. {arrayview-0.49.1 → arrayview-0.50.1}/tests/v_anim_frames/.gitkeep +0 -0
  90. {arrayview-0.49.1 → arrayview-0.50.1}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.49.1
3
+ Version: 0.50.1
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.49.1"
7
+ version = "0.50.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -630,6 +630,26 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
630
630
  revealed = await asyncio.to_thread(_request_vscode_tab_reveal, *target)
631
631
  return {"sid": sid, "revealed": revealed}
632
632
 
633
+ @app.post("/popout/{sid}")
634
+ async def toggle_session_tab_window(sid: str, request: Request):
635
+ """Move this VS Code browser tab into its own window, or back."""
636
+ from arrayview._routes_websocket import viewer_tab_window_id
637
+ from arrayview._vscode_signal import _request_vscode_tab_reveal
638
+
639
+ try:
640
+ body = await request.json()
641
+ except Exception:
642
+ body = {}
643
+ path = body.get("path") if isinstance(body, dict) else None
644
+ window_id = viewer_tab_window_id(sid, path) if isinstance(path, str) else None
645
+ # A bare "/" matches every tab; only the query singles this one out.
646
+ if window_id is None or not urllib.parse.urlsplit(path).query:
647
+ return {"sid": sid, "moved": False}
648
+ moved = await asyncio.to_thread(
649
+ _request_vscode_tab_reveal, window_id, path, 5.0, "toggle-window"
650
+ )
651
+ return {"sid": sid, "moved": moved}
652
+
633
653
  @app.post("/release/{sid}")
634
654
  def release_viewer_session(sid: str, request: Request):
635
655
  """Release a session when its owning viewer tab/window closes."""
@@ -103,12 +103,22 @@ def _cancel_pending_viewer_release(sid: str) -> None:
103
103
  task.cancel()
104
104
 
105
105
 
106
- def _trace_viewer_event(event: str, sid: str) -> None:
107
- if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
108
- return
109
- from arrayview._launch_trace import emit_launch_event, trace_tag
106
+ def _trace_viewer_event(event: str, sid: str, **attrs: object) -> None:
107
+ # Same opt-in as the page-route events (env or ~/.arrayview marker), so a
108
+ # server started by an Explorer click logs its sockets too: a launch that
109
+ # loads its page but never shows a frame needs both halves to diagnose.
110
+ try:
111
+ from arrayview._launch_trace import (
112
+ configure_route_launch_trace,
113
+ emit_launch_event,
114
+ trace_tag,
115
+ )
110
116
 
111
- emit_launch_event(event, sid_tag=trace_tag(sid))
117
+ if not configure_route_launch_trace():
118
+ return
119
+ emit_launch_event(event, sid_tag=trace_tag(sid), **attrs)
120
+ except Exception:
121
+ return
112
122
 
113
123
  def _render_progress_reporter(
114
124
  loop, ws, label="Loading", quiet_for_s=0.6, min_interval_s=0.2
@@ -316,6 +326,14 @@ def viewer_tab_reveal_target(sid: str) -> tuple[str, str] | None:
316
326
  return None
317
327
 
318
328
 
329
+ def viewer_tab_window_id(sid: str, path: str) -> str | None:
330
+ """VS Code window that owns the live tab at *path* showing *sid*."""
331
+ for tab_sid, tab_path, window_id in _VIEWER_TAB_ADDRESSES.values():
332
+ if tab_sid == sid and tab_path == path:
333
+ return window_id
334
+ return None
335
+
336
+
319
337
  def _handle_link_message(ws, msg: dict) -> None:
320
338
  action = msg.get("action")
321
339
  if action == "join":
@@ -469,11 +487,21 @@ def register_websocket_routes(app) -> None:
469
487
  # stops that clock. The viewer keeps its splash up until the metadata
470
488
  # below arrives, so an early accept shows nothing half-rendered.
471
489
  await ws.accept()
490
+ accepted_at = time.perf_counter()
491
+ arrived_on = (ws.scope.get("server") or (None, None))[1]
492
+ _trace_viewer_event(
493
+ "viewer.socket_accepted", sid,
494
+ request_id=launch_request_id, port=arrived_on,
495
+ session_ready=sid in _session_mod.SESSIONS,
496
+ )
472
497
 
473
498
  session = _session_mod.SESSIONS.get(sid)
474
499
  if session is None:
475
500
  session = await _await_session_reporting_read_progress(ws, sid)
476
501
  if not session:
502
+ _trace_viewer_event(
503
+ "viewer.session_missing", sid, request_id=launch_request_id
504
+ )
477
505
  # The socket has already opened, and the viewer treats a drop after
478
506
  # a successful open as a recoverable reconnect — it would retry
479
507
  # forever. Say the load failed so it shows an error instead.
@@ -497,6 +525,11 @@ def register_websocket_routes(app) -> None:
497
525
  or launch_journal is None
498
526
  or launch_journal.get("token") != launch_token
499
527
  ):
528
+ _trace_viewer_event(
529
+ "viewer.launch_rejected", sid,
530
+ request_id=launch_request_id,
531
+ journal_found=launch_journal is not None,
532
+ )
500
533
  await ws.close(code=1008)
501
534
  return
502
535
 
@@ -525,10 +558,9 @@ def register_websocket_routes(app) -> None:
525
558
  _session_mod.VIEWER_CONNECTIONS_SEEN += 1
526
559
  _session_mod.LIVE_VIEWER_SOCKETS.append(ws)
527
560
  _ensure_path_nudge_task()
528
- # Which port this viewer arrived on, so a cold-start port can be
529
- # released the moment its own viewer goes away rather than waiting for
530
- # every viewer everywhere to close.
531
- arrived_on = (ws.scope.get("server") or (None, None))[1]
561
+ # arrived_on (above) is the port this viewer came in on, so a
562
+ # cold-start port can be released the moment its own viewer goes away
563
+ # rather than waiting for every viewer everywhere to close.
532
564
  try:
533
565
  from arrayview import _extra_ports
534
566
 
@@ -544,7 +576,11 @@ def register_websocket_routes(app) -> None:
544
576
  _session_mod.VIEWER_REQUEST_COUNTS[request_key] = (
545
577
  _session_mod.VIEWER_REQUEST_COUNTS.get(request_key, 0) + 1
546
578
  )
547
- _trace_viewer_event("viewer.connected", sid)
579
+ _trace_viewer_event(
580
+ "viewer.connected", sid,
581
+ request_id=launch_request_id,
582
+ wait_ms=round((time.perf_counter() - accepted_at) * 1000),
583
+ )
548
584
  loop = asyncio.get_running_loop()
549
585
  _pending: asyncio.Queue[dict | None] = asyncio.Queue()
550
586
 
@@ -572,11 +608,16 @@ def register_websocket_routes(app) -> None:
572
608
 
573
609
  recv_task = asyncio.create_task(_receiver())
574
610
  previous_indices = None
611
+ frames_sent = 0
575
612
  try:
576
613
  while True:
577
614
  msg = await _pending.get()
578
615
  if msg is None:
579
616
  break
617
+ if frames_sent == 0:
618
+ _trace_viewer_event(
619
+ "viewer.first_request", sid, request_id=launch_request_id
620
+ )
580
621
 
581
622
  seq = int(msg.get("seq", 0))
582
623
 
@@ -858,6 +899,14 @@ def register_websocket_routes(app) -> None:
858
899
  raw_payload = payload
859
900
  payload = await _render(loop, lambda: _pack_frame(raw_payload))
860
901
  await ws.send_bytes(payload)
902
+ frames_sent += 1
903
+ if frames_sent == 1:
904
+ _trace_viewer_event(
905
+ "viewer.first_frame_sent", sid,
906
+ request_id=launch_request_id,
907
+ ms=round((time.perf_counter() - accepted_at) * 1000),
908
+ payload_bytes=len(payload),
909
+ )
861
910
  if perf:
862
911
  total_ms = (time.perf_counter() - total_t0) * 1000.0
863
912
  await ws.send_json(
@@ -939,6 +988,13 @@ def register_websocket_routes(app) -> None:
939
988
 
940
989
  _vprint(f"[ArrayView] WS/{sid[:8]}: {_ws_exc}", flush=True)
941
990
  traceback.print_exc()
991
+ _trace_viewer_event(
992
+ "viewer.render_failed", sid,
993
+ request_id=launch_request_id,
994
+ error=type(_ws_exc).__name__,
995
+ message=str(_ws_exc)[:200],
996
+ frames_sent=frames_sent,
997
+ )
942
998
  # Without this the viewer never learns the frame it is waiting for
943
999
  # will not arrive: the socket just closes and the panel sits on the
944
1000
  # loading spinner until the opener's timeout fails the request
@@ -1122,4 +1178,9 @@ def register_websocket_routes(app) -> None:
1122
1178
  _session_mod.VIEWER_REQUEST_COUNTS[request_key] = request_count
1123
1179
  else:
1124
1180
  _session_mod.VIEWER_REQUEST_COUNTS.pop(request_key, None)
1125
- _trace_viewer_event("viewer.disconnected", sid)
1181
+ _trace_viewer_event(
1182
+ "viewer.disconnected", sid,
1183
+ request_id=launch_request_id,
1184
+ frames_sent=frames_sent,
1185
+ open_s=round(time.perf_counter() - accepted_at, 1),
1186
+ )
@@ -3924,6 +3924,30 @@
3924
3924
  .compare-title.drag-over { outline: 1px dashed var(--accent, #888); border-radius: 2px; }
3925
3925
  .compare-title.compare-center-title { cursor: default; max-width: 100%; overflow: visible; text-overflow: clip; }
3926
3926
  .compare-title.compare-side-title { gap: 0; }
3927
+ /* Array names: the start and end every compared name shares fade back
3928
+ and give way first when the slot is narrow (_fitArrayName). */
3929
+ .av-name { min-width: 0; overflow: hidden; white-space: nowrap; }
3930
+ .av-name-shared { opacity: 0.5; }
3931
+ #compare-view-wrap:not(.compare-titles-inset) .compare-title .av-name { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
3932
+ /* A name beside its image: plain text in the empty margin, wrapping
3933
+ at separators (_placeRowName / _placeCompareTitle). */
3934
+ .av-name.av-name-side {
3935
+ display: block; white-space: normal; overflow-wrap: anywhere;
3936
+ font-size: 12px; line-height: 16px; letter-spacing: 0.02em; text-transform: none;
3937
+ color: var(--text);
3938
+ }
3939
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-left,
3940
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-right {
3941
+ top: 50%; transform: translateY(-50%); max-width: none; -webkit-text-stroke: 0; text-shadow: none;
3942
+ }
3943
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-left { left: auto; right: 100%; margin-right: 14px; justify-content: flex-end; text-align: right; }
3944
+ #compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-right { left: 100%; margin-left: 14px; text-align: left; }
3945
+ .compare-title.compare-title-out-left svg,
3946
+ .compare-title.compare-title-out-right svg { display: none; }
3947
+ .av-name-br { display: none; }
3948
+ /* Chrome breaks at <wbr> even under nowrap: only side names wrap */
3949
+ .av-name:not(.av-name-side) wbr { display: none; }
3950
+ .av-name.av-name-paths .av-name-br { display: block; }
3927
3951
  .compare-diff-stats { display: inline-flex; gap: 12px; font-variant-numeric: tabular-nums; cursor: default; }
3928
3952
  .compare-diff-stats b { font-weight: normal; color: color-mix(in srgb, var(--text) 75%, var(--muted)); text-transform: none; }
3929
3953
  .detached-index-hint {
@@ -4212,6 +4236,8 @@
4212
4236
  /* compare-qMRI: row label (array name) at left of each row */
4213
4237
  .cq-row-label { font-size: 12px; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; writing-mode: vertical-rl; transform: rotate(180deg); width: 24px; flex-shrink: 0; align-self: center; text-align: center; white-space: nowrap; }
4214
4238
  .qv-row.cq-row > .cq-row-label { position: absolute; right: 100%; margin-right: 14px; top: 50%; transform: translateY(-50%) rotate(180deg); }
4239
+ /* compare-qMRI / compare-3plane: array name left of its row */
4240
+ .qv-row.cq-row > .cq-row-name { position: absolute; right: 100%; margin-right: 14px; top: 50%; transform: translateY(-50%); text-align: right; cursor: default; user-select: none; }
4215
4241
  /* compare-qMRI: column header row for map names */
4216
4242
  .cq-header-row { display: flex; flex-direction: row; gap: 24px; justify-content: center; align-items: flex-end; padding-left: 0; }
4217
4243
  .cq-header-label { font-size: 16px; color: var(--fg); letter-spacing: 0.05em; text-align: center; }
@@ -4754,7 +4780,8 @@
4754
4780
  /* qMRI maps touching (Preferences): no gaps; with two rows the
4755
4781
  colour bars float inside the maps like the immersive islands. */
4756
4782
  body.qmri-gapless #qmri-view-wrap.active { gap: 0 !important; }
4757
- body.qmri-gapless #qmri-view-wrap > .qv-row:not(.qmri-mosaic-row) { gap: 0 !important; }
4783
+ body.qmri-gapless #qmri-view-wrap > .qv-row:not(.qmri-mosaic-row),
4784
+ body.qmri-gapless #qmri-view-wrap > .cq-header-row { gap: 0 !important; }
4758
4785
  body.qmri-gapless #qmri-view-wrap .qv-pane { gap: 0; }
4759
4786
  body.qmri-gapless #qmri-view-wrap .qv-canvas-clip { border-radius: 0 !important; }
4760
4787
  /* Touching panes read as one block: only its outer corners stay
@@ -9977,6 +10004,231 @@
9977
10004
  return sidVal.slice(0, 8);
9978
10005
  }
9979
10006
 
10007
+ // Compared names often share most of their text (one file name in
10008
+ // several run folders). Split each into the start and end that every
10009
+ // name shares and the middle that tells it apart. Splits only at
10010
+ // separators, and every name keeps a word of its own.
10011
+ const _NAME_SEP_RE = /^[_\-.\/\\\s()]+$/;
10012
+ function _arrayNameParts(names) {
10013
+ if (!names.length) return [];
10014
+ const toks = names.map(n => String(n).split(/([_\-.\/\\\s()]+)/).filter(Boolean));
10015
+ const hasWord = ts => ts.some(t => !_NAME_SEP_RE.test(t));
10016
+ let h = 0, t = 0;
10017
+ if (toks.length > 1) {
10018
+ const minLen = Math.min(...toks.map(ts => ts.length));
10019
+ while (h < minLen && toks.every(ts => ts[h] === toks[0][h])) h++;
10020
+ while (h > 0 && !toks.every(ts => hasWord(ts.slice(h)))) h--;
10021
+ const rest = toks.map(ts => ts.slice(h));
10022
+ const minRest = Math.min(...rest.map(ts => ts.length));
10023
+ const fromEnd = (ts, i) => ts[ts.length - 1 - i];
10024
+ while (t < minRest && rest.every(ts => fromEnd(ts, t) === fromEnd(rest[0], t))) t++;
10025
+ while (t > 0 && !rest.every(ts => hasWord(ts.slice(0, ts.length - t)))) t--;
10026
+ }
10027
+ // `near`: how much of each key's start it shares with its closest
10028
+ // sibling; in a tight slot that goes before the rest of the key.
10029
+ let keys = toks.map(ts => ts.slice(h, ts.length - t));
10030
+ // Shared folders and a shared file name or extension are left out
10031
+ // (the full path is on hover), but never part of a word group
10032
+ // (pat_a stays pat_a), nor when what remains says nothing on its
10033
+ // own (t1.npy (1) would become "1").
10034
+ const isPath = tok => /[\/\\]/.test(tok);
10035
+ let hd = h;
10036
+ while (hd > 0 && !isPath(toks[0][hd - 1])) hd--;
10037
+ const tailToks = toks[0].slice(toks[0].length - t);
10038
+ const cut = tailToks.findIndex(tok => isPath(tok) || tok.startsWith('.'));
10039
+ const td = cut < 0 ? 0 : t - cut;
10040
+ const shortKeys = toks.map(ts => ts.slice(hd, ts.length - td));
10041
+ const dropShared = shortKeys.every(kt => kt.some(tok => /[a-z]/i.test(tok)));
10042
+ if (dropShared) {
10043
+ keys = shortKeys.map(kt => {
10044
+ let a = 0, b = kt.length;
10045
+ const loose = tok => /^[_\-.\/\\\s]+$/.test(tok);
10046
+ while (a < b && loose(kt[a])) a++;
10047
+ while (b > a && loose(kt[b - 1])) b--;
10048
+ return kt.slice(a, b);
10049
+ });
10050
+ }
10051
+ return toks.map((ts, i) => {
10052
+ let near = 0;
10053
+ keys.forEach((other, j) => {
10054
+ if (j === i) return;
10055
+ let n = 0;
10056
+ while (n < keys[i].length && keys[i][n] === other[n]) n++;
10057
+ while (n > 0 && !hasWord(keys[i].slice(n))) n--;
10058
+ near = Math.max(near, n);
10059
+ });
10060
+ return {
10061
+ head: dropShared ? '' : ts.slice(0, h).join(''),
10062
+ key: keys[i].join(''),
10063
+ tail: dropShared ? '' : ts.slice(ts.length - t).join(''),
10064
+ near: keys[i].slice(0, near).join('').length,
10065
+ };
10066
+ });
10067
+ }
10068
+
10069
+ // Last `n` characters of `s`, moved up to the next word start when the
10070
+ // cut would leave a word fragment (\u2026on_ reads as noise).
10071
+ function _nameTailChars(s, n) {
10072
+ const out = s.slice(s.length - n);
10073
+ if (n >= s.length || /[_\-.\/\\\s()]/.test(s[s.length - n - 1])) return out;
10074
+ const m = out.match(/[_\-.\/\\\s()]+/);
10075
+ return m ? out.slice(m.index + m[0].length) : '';
10076
+ }
10077
+
10078
+ // Name squeezed to `cap` characters: the shared end gives way first,
10079
+ // then the shared start, then the start the name shares with its
10080
+ // closest sibling, and only then the middle of what is its own.
10081
+ // Returns [text, shared] runs.
10082
+ function _arrayNameRuns(p, cap) {
10083
+ const E = '\u2026';
10084
+ const { head, key, tail, near } = p;
10085
+ if (head.length + key.length + tail.length <= cap) return [[head, true], [key, false], [tail, true]];
10086
+ const tailRoom = cap - head.length - key.length - 1;
10087
+ if (tail && tailRoom >= 0) return [[head, true], [key, false], [tail.slice(0, tailRoom) + E, true]];
10088
+ const tailE = tail ? E : '';
10089
+ const headRoom = cap - key.length - tailE.length - 1;
10090
+ if (head && headRoom >= 0) return [[E + _nameTailChars(head, headRoom), true], [key, false], [tailE, true]];
10091
+ if (!head && key.length + tailE.length <= cap) return [[key, false], [tailE, true]];
10092
+ const keyRoom = cap - 1 - tailE.length;
10093
+ if (keyRoom > 0 && key.length - keyRoom <= near) {
10094
+ const cut = _nameTailChars(key, keyRoom);
10095
+ return [[E, true], [cut || key.slice(key.length - keyRoom), false], [tailE, true]];
10096
+ }
10097
+ const rest = key.slice(near).replace(/^[_\-.\/\\\s()]+/, '');
10098
+ const lead = (head || near) ? E : '';
10099
+ const room = cap - lead.length - tailE.length - 1;
10100
+ if (room < 2) return [[rest.slice(0, Math.max(1, cap - 1)), false], [E, true]];
10101
+ const a = Math.floor(room / 2), b = room - a;
10102
+ return [[lead, true], [rest.slice(0, a), false], [E, false], [rest.slice(rest.length - b), false], [tailE, true]];
10103
+ }
10104
+
10105
+ function _arrayNameHTML(runs) {
10106
+ // A name beside its image wraps after separators (not dots: they
10107
+ // sit inside numbers), and after folders when av-name-paths is on.
10108
+ const esc = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;')
10109
+ .replace(/[_\-\s()]+/g, '$&<wbr>')
10110
+ .replace(/[\/\\]+/g, '$&<span class="av-name-br"></span>');
10111
+ return runs.filter(([t]) => t)
10112
+ .map(([t, shared]) => shared ? `<span class="av-name-shared">${esc(t)}</span>` : esc(t))
10113
+ .join('');
10114
+ }
10115
+
10116
+ // Names sit beside their image, in the empty margin, when it has room
10117
+ // for a column at least NAME_SIDE_MIN_W wide; otherwise inside it.
10118
+ // NAME_SIDE_GAP is also the CSS margin between name and image.
10119
+ const NAME_SIDE_MIN_W = 96;
10120
+ const NAME_SIDE_MAX_W = 280;
10121
+ const NAME_SIDE_GAP = 14;
10122
+ const NAME_SIDE_EDGE = 12;
10123
+ // Margin a row layout keeps free on each side for its row names,
10124
+ // counted from the window edge. Capped so a narrow window keeps its
10125
+ // images and the names get a narrower column instead.
10126
+ function _rowNameReserve() {
10127
+ const view = document.getElementById('qmri-view-wrap');
10128
+ const viewL = view ? Math.max(0, view.getBoundingClientRect().left) : 0;
10129
+ return Math.min(viewL + NAME_SIDE_MIN_W + NAME_SIDE_GAP + NAME_SIDE_EDGE, window.innerWidth * 0.17);
10130
+ }
10131
+
10132
+ function _fitArrayName(el) {
10133
+ const p = el && el._nameParts;
10134
+ if (!p) return;
10135
+ if (el.isConnected && el._namePlace) el._namePlace(el);
10136
+ const full = p.head.length + p.key.length + p.tail.length;
10137
+ const show = cap => { el.innerHTML = _arrayNameHTML(_arrayNameRuns(p, cap)); };
10138
+ el.classList.remove('av-name-paths');
10139
+ show(full);
10140
+ if (!el.isConnected) return;
10141
+ const fits = () => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight;
10142
+ // A name that needs several lines starts each folder on a new one,
10143
+ // unless that alone makes it too tall.
10144
+ if (el.classList.contains('av-name-side') && el.scrollHeight > 1.5 * parseFloat(getComputedStyle(el).lineHeight)) {
10145
+ el.classList.add('av-name-paths');
10146
+ if (!fits()) el.classList.remove('av-name-paths');
10147
+ }
10148
+ if (fits() || el.clientWidth <= 0) return;
10149
+ let lo = 1, hi = full - 1;
10150
+ while (lo < hi) {
10151
+ const mid = Math.ceil((lo + hi) / 2);
10152
+ show(mid);
10153
+ if (fits()) lo = mid; else hi = mid - 1;
10154
+ }
10155
+ show(lo);
10156
+ }
10157
+
10158
+ // Refit names when their pane, row or view changes size.
10159
+ const _arrayNameRO = typeof ResizeObserver === 'function'
10160
+ ? new ResizeObserver(entries => {
10161
+ const names = new Set();
10162
+ for (const e of entries) {
10163
+ if (!e.target.isConnected) { _arrayNameRO.unobserve(e.target); continue; }
10164
+ e.target.querySelectorAll('.av-name').forEach(n => names.add(n));
10165
+ }
10166
+ names.forEach(_fitArrayName);
10167
+ })
10168
+ : null;
10169
+
10170
+ // Hover text for a shortened name: the file's full path when known.
10171
+ function _arrayNameHover(sidVal, label) {
10172
+ return _compareFilepathBySid[sidVal] || label;
10173
+ }
10174
+
10175
+ function _setArrayName(el, parts, fullName, slots, place) {
10176
+ el.classList.add('av-name');
10177
+ el._nameParts = parts;
10178
+ el._namePlace = place || null;
10179
+ el.title = fullName;
10180
+ _fitArrayName(el);
10181
+ if (_arrayNameRO) slots.forEach(slot => slot && _arrayNameRO.observe(slot));
10182
+ }
10183
+
10184
+ // compare-qMRI / compare-3plane: the name sits left of its row, in
10185
+ // the margin the layout keeps free (_rowNameReserve). The view
10186
+ // clips at its own edge, not the window's.
10187
+ function _placeRowName(el) {
10188
+ const r = el.parentElement.getBoundingClientRect();
10189
+ const viewL = document.getElementById('qmri-view-wrap').getBoundingClientRect().left;
10190
+ const w = Math.min(NAME_SIDE_MAX_W, r.left - viewL - NAME_SIDE_EDGE - NAME_SIDE_GAP);
10191
+ el.style.width = Math.max(40, w) + 'px';
10192
+ el.style.maxHeight = Math.max(16, r.height) + 'px';
10193
+ }
10194
+
10195
+ // Compare panes: names move into the margin beside the panes when
10196
+ // every pane has one (one or two columns) with room; otherwise they
10197
+ // all stay inside their images, never a mix.
10198
+ function _compareTitleSides() {
10199
+ const wrap = document.getElementById('compare-view-wrap');
10200
+ if (!wrap.classList.contains('compare-titles-inset')) return null;
10201
+ const panes = [...wrap.querySelectorAll('.compare-pane.active')]
10202
+ .filter(q => q.getBoundingClientRect().width > 0);
10203
+ if (!panes.length) return null;
10204
+ const rects = panes.map(q => q.getBoundingClientRect());
10205
+ const minL = Math.min(...rects.map(r => r.left));
10206
+ const maxR = Math.max(...rects.map(r => r.right));
10207
+ const roomL = minL - NAME_SIDE_EDGE - NAME_SIDE_GAP;
10208
+ const roomR = window.innerWidth - maxR - NAME_SIDE_EDGE - NAME_SIDE_GAP;
10209
+ const sides = new Map();
10210
+ for (let i = 0; i < panes.length; i++) {
10211
+ const r = rects[i];
10212
+ if (r.left <= minL + 1 && roomL >= NAME_SIDE_MIN_W) sides.set(panes[i], { side: 'left', w: roomL, h: r.height });
10213
+ else if (r.right >= maxR - 1 && roomR >= NAME_SIDE_MIN_W) sides.set(panes[i], { side: 'right', w: roomR, h: r.height });
10214
+ else return null;
10215
+ }
10216
+ return sides;
10217
+ }
10218
+
10219
+ function _placeCompareTitle(el) {
10220
+ const title = el.closest('.compare-title');
10221
+ const pane = title && title.closest('.compare-pane');
10222
+ const sides = pane && !title.classList.contains('compare-side-title') ? _compareTitleSides() : null;
10223
+ const spot = sides && sides.get(pane);
10224
+ const side = spot ? spot.side : null;
10225
+ title.classList.toggle('compare-title-out-left', side === 'left');
10226
+ title.classList.toggle('compare-title-out-right', side === 'right');
10227
+ el.classList.toggle('av-name-side', !!side);
10228
+ el.style.width = side ? Math.min(NAME_SIDE_MAX_W, spot.w) + 'px' : '';
10229
+ el.style.maxHeight = side ? spot.h + 'px' : '';
10230
+ }
10231
+
9980
10232
  function _resetDetachedDimState() {
9981
10233
  detachedDimMode = false;
9982
10234
  detachedDim = -1;
@@ -10110,14 +10362,17 @@
10110
10362
  drawRegBlendCb();
10111
10363
  }
10112
10364
  const _logoSvg = '<svg width="14" height="14" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0"><rect width="16" height="16" rx="2.5" fill="#0c0c0c"/><rect x="2" y="2" width="3" height="3" fill="#3a0ca3"/><rect x="6.5" y="2" width="3" height="3" fill="#560bad"/><rect x="11" y="2" width="3" height="3" fill="#c77dff"/><rect x="2" y="6.5" width="3" height="3" fill="#4361ee"/><rect x="6.5" y="6.5" width="3" height="3" fill="#4cc9f0"/><rect x="11" y="6.5" width="3" height="3" fill="#f5c842"/><rect x="2" y="11" width="3" height="3" fill="#4895ef"/><rect x="6.5" y="11" width="3" height="3" fill="#80ed99"/><rect x="11" y="11" width="3" height="3" fill="#f8961e"/></svg>';
10365
+ const nameParts = detachedDimMode ? null : _arrayNameParts(labels);
10113
10366
  compareTitles.forEach((el, idx) => {
10114
10367
  const text = labels[idx] || '';
10115
10368
  const sideTitle = _isCompareBigLeftLayout() && idx < 2;
10116
10369
  el.classList.toggle('compare-side-title', sideTitle);
10117
- if (text) {
10118
- const prefix = (!detachedDimMode && !sideTitle) ? _logoSvg : '';
10119
- const titleHtml = detachedDimMode ? _detachedDimPaneTitleHTML(idx, text) : text.replace(/</g, '&lt;');
10120
- el.innerHTML = prefix + '<span>' + titleHtml + '</span>';
10370
+ if (text && detachedDimMode) {
10371
+ el.innerHTML = '<span>' + _detachedDimPaneTitleHTML(idx, text) + '</span>';
10372
+ } else if (text) {
10373
+ el.innerHTML = (sideTitle ? '' : _logoSvg) + '<span></span>';
10374
+ _setArrayName(el.lastElementChild, nameParts[idx], _arrayNameHover(effectiveSids[idx], text),
10375
+ [el.closest('.compare-pane'), document.getElementById('compare-view-wrap')], _placeCompareTitle);
10121
10376
  } else {
10122
10377
  el.textContent = '';
10123
10378
  }
@@ -28366,6 +28621,25 @@
28366
28621
  }
28367
28622
  },
28368
28623
  },
28624
+ 'window.togglePopout': {
28625
+ title: 'Pop this tab out into its own window / put it back',
28626
+ when: [],
28627
+ run: async () => {
28628
+ if (!_vscodeTabWindowId) {
28629
+ showStatus('pop out: only in a VS Code tab');
28630
+ return;
28631
+ }
28632
+ try {
28633
+ const r = await proxyFetch(`/popout/${sid}`, {
28634
+ method: 'POST',
28635
+ headers: { 'Content-Type': 'application/json' },
28636
+ body: JSON.stringify({ path: location.pathname + location.search }),
28637
+ });
28638
+ if (r.ok && (await r.json()).moved) return;
28639
+ } catch (_) {}
28640
+ showStatus('pop out: VS Code did not respond');
28641
+ },
28642
+ },
28369
28643
  'immersive.toggle': {
28370
28644
  title: 'Toggle immersive mode',
28371
28645
  when: [],
@@ -30226,6 +30500,7 @@
30226
30500
  { key: '-', command: 'zoom.out' },
30227
30501
  { key: '0', command: 'zoom.reset' },
30228
30502
  { key: 'F', command: 'immersive.toggle' },
30503
+ { key: 'W', command: 'window.togglePopout' },
30229
30504
  { key: 'n', command: 'compare.cycleTarget' },
30230
30505
  { key: '<', command: 'playback.adjustFps' },
30231
30506
  { key: '>', command: 'playback.adjustFps' },
@@ -30399,6 +30674,7 @@
30399
30674
  { key: 'Drag', action: 'Pan the image (when zoomed in)' },
30400
30675
  { key: 'Shift+F', action: 'Toggle immersive mode', hint: 'hides chrome and fills the viewport with the array' },
30401
30676
  { key: 'Z', action: 'Zen mode — hide UI chrome', hint: 'in compare + center active, hides the two source panes instead' },
30677
+ { key: 'Shift+W', action: 'Pop tab out into its own window / put it back', hint: 'VS Code tabs only' },
30402
30678
  ]
30403
30679
  },
30404
30680
  {
@@ -37735,12 +38011,6 @@
37735
38011
  // Column header row with map names
37736
38012
  const headerRow = document.createElement('div');
37737
38013
  headerRow.className = 'cq-header-row';
37738
- // Spacer matching row label width
37739
- const headerSpacer = document.createElement('div');
37740
- headerSpacer.className = 'cq-row-label';
37741
- headerSpacer.style.visibility = 'hidden';
37742
- headerSpacer.textContent = '\u00A0';
37743
- headerRow.appendChild(headerSpacer);
37744
38014
  for (let mi = 0; mi < nMaps; mi++) {
37745
38015
  const hLabel = document.createElement('div');
37746
38016
  hLabel.className = 'cq-header-label qv-label';
@@ -37750,16 +38020,18 @@
37750
38020
  wrap.appendChild(headerRow);
37751
38021
 
37752
38022
  compareQmriViews = [];
38023
+ const rowNames = compareQmriSids.map(comparePaneLabelForSid);
38024
+ const rowNameParts = _arrayNameParts(rowNames);
37753
38025
  // One row per compared array (SID)
37754
38026
  for (let si = 0; si < compareQmriSids.length; si++) {
37755
38027
  const sidVal = compareQmriSids[si];
37756
38028
  const rowDiv = document.createElement('div');
37757
38029
  rowDiv.className = 'qv-row cq-row';
37758
- // Row label: array name
37759
- const rowLabel = document.createElement('div');
37760
- rowLabel.className = 'cq-row-label';
37761
- rowLabel.textContent = comparePaneLabelForSid(sidVal);
37762
- rowDiv.appendChild(rowLabel);
38030
+ // Array name, left of its row
38031
+ const rowName = document.createElement('div');
38032
+ rowName.className = 'cq-row-name av-name-side';
38033
+ rowDiv.appendChild(rowName);
38034
+ _setArrayName(rowName, rowNameParts[si], _arrayNameHover(sidVal, rowNames[si]), [rowDiv, wrap], _placeRowName);
37763
38035
  for (let mi = 0; mi < nMaps; mi++) {
37764
38036
  const actualIdx = mapIndices[mi];
37765
38037
  const cmap = cmaps[mi];
@@ -38057,7 +38329,7 @@
38057
38329
  const rows = nRows;
38058
38330
  const colGap = rows >= 3 ? 10 : 24;
38059
38331
  const rowGap = rows >= 3 ? 4 : 14;
38060
- const sidePad = rows >= 3 ? 80 : 120;
38332
+ const sidePad = Math.max(rows >= 3 ? 80 : 120, 2 * _rowNameReserve()); // room for row names
38061
38333
  const labelH = 24; // column headers
38062
38334
  const anyExpanded = ready.some(v => v._colorBar && v._colorBar._expanded);
38063
38335
  const cbH = anyExpanded ? 33 + (CB_DEFAULT_EXPANDED_H - CB_COLLAPSED_H) : 33;
@@ -38286,23 +38558,18 @@
38286
38558
  }
38287
38559
 
38288
38560
  compareMvViews = [];
38561
+ const rowNames = compareMvSids.map(comparePaneLabelForSid);
38562
+ const rowNameParts = _arrayNameParts(rowNames);
38289
38563
  // One row per compared array (SID)
38290
38564
  for (let si = 0; si < compareMvSids.length; si++) {
38291
38565
  const sidVal = compareMvSids[si];
38292
38566
  const rowDiv = document.createElement('div');
38293
38567
  rowDiv.className = 'qv-row cq-row';
38294
- // Row label: array name (basename only)
38295
- const rowLabel = document.createElement('div');
38296
- rowLabel.className = 'cq-row-label';
38297
- const _rawLabel = comparePaneLabelForSid(sidVal);
38298
- const _shortLabel = l => l.split('/').pop().split('\\').pop().replace(/\.[^.]+$/, '') || l;
38299
- // Basenames alone, unless that would make two rows look alike;
38300
- // then keep the folders the pane titles use to tell them apart.
38301
- const _shorts = compareMvSids.map(x => _shortLabel(comparePaneLabelForSid(x)));
38302
- rowLabel.textContent = _shorts.filter(x => x === _shorts[si]).length > 1
38303
- ? _rawLabel.replace(/\.[^./\\\s]+(\s\(\d+\))?$/, '$1') : _shorts[si];
38304
- rowLabel.title = _rawLabel; // full name on hover
38305
- rowDiv.appendChild(rowLabel);
38568
+ // Array name, left of its row
38569
+ const rowName = document.createElement('div');
38570
+ rowName.className = 'cq-row-name av-name-side';
38571
+ rowDiv.appendChild(rowName);
38572
+ _setArrayName(rowName, rowNameParts[si], _arrayNameHover(sidVal, rowNames[si]), [rowDiv, wrap], _placeRowName);
38306
38573
  // 3 planes per row
38307
38574
  for (let pi = 0; pi < planeDefs.length; pi++) {
38308
38575
  const def = planeDefs[pi];
@@ -38684,7 +38951,7 @@
38684
38951
  const nCols = 3; // always 3 planes
38685
38952
  const colGap = nRows >= 3 ? 12 : 24;
38686
38953
  const rowGap = nRows >= 3 ? 6 : 14;
38687
- const sidePad = nRows >= 3 ? 80 : 120; // tighter for multi-array
38954
+ const sidePad = Math.max(nRows >= 3 ? 80 : 120, 2 * _rowNameReserve()); // room for row names
38688
38955
  const labelH = 0; // no column headers in compare-multiview
38689
38956
  const cbWrap = document.getElementById('mv-cb-wrap');
38690
38957
  const cbH = cbWrap && colorbarVisible() ? (cbWrap.offsetHeight || 33) : 0;
@@ -43044,7 +43311,11 @@
43044
43311
 
43045
43312
  function _reconcileCompareTitlesInset() {
43046
43313
  compareWrap.classList.toggle('compare-touching', _compareTouching());
43047
- compareWrap.classList.toggle('compare-titles-inset', _compareTitlesInset());
43314
+ const inset = _compareTitlesInset();
43315
+ if (inset !== compareWrap.classList.contains('compare-titles-inset')) {
43316
+ compareWrap.classList.toggle('compare-titles-inset', inset);
43317
+ compareWrap.querySelectorAll('.av-name').forEach(_fitArrayName);
43318
+ }
43048
43319
  }
43049
43320
 
43050
43321
  // ── Centralized CB / island visibility reconciler ────────────
@@ -58,7 +58,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
58
58
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
59
59
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
60
60
  _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
61
- _VSCODE_EXT_VERSION = "0.15.71" # current bundled extension version
61
+ _VSCODE_EXT_VERSION = "0.15.74" # current bundled extension version
62
62
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
63
63
  # Version-skew notices already printed, so a long-lived process (Jupyter, a
64
64
  # script opening several arrays) states the mismatch once instead of per call.
@@ -694,10 +694,13 @@ _VSCODE_REVEAL_ACK_PREFIX = "reveal-tab-ack-"
694
694
 
695
695
 
696
696
  def _request_vscode_tab_reveal(
697
- window_id: str, url_filter: str, timeout: float = 2.5
697
+ window_id: str, url_filter: str, timeout: float = 2.5, action: str = "reveal"
698
698
  ) -> bool:
699
699
  """Ask the opener in *window_id* to bring the browser tab matching
700
- *url_filter* forward. True only when that window confirms it did."""
700
+ *url_filter* forward. True only when that window confirms it did.
701
+
702
+ ``action="toggle-window"`` also moves that tab into its own window, or
703
+ back into the main window when it was popped out before."""
701
704
  signal_dir = Path(os.path.expanduser("~/.arrayview"))
702
705
  request_id = uuid.uuid4().hex
703
706
  request_path = signal_dir / f"{_VSCODE_REVEAL_REQUEST_PREFIX}{request_id}.json"
@@ -706,6 +709,7 @@ def _request_vscode_tab_reveal(
706
709
  "requestId": request_id,
707
710
  "windowId": window_id,
708
711
  "urlFilter": url_filter,
712
+ "action": action,
709
713
  "sentAtMs": int(time.time() * 1000),
710
714
  "maxAgeMs": int(timeout * 1000),
711
715
  }
@@ -4536,3 +4536,27 @@ class TestRevealTab:
4536
4536
  assert c.post(f"/reveal/{sid}").json()["revealed"] is True
4537
4537
  assert asked == [("w1", "/?t=ab12c")]
4538
4538
  assert c.post(f"/reveal/{sid}").json()["revealed"] is False
4539
+
4540
+ def test_popout_asks_the_window_owning_this_tab(self, tmp_path, monkeypatch):
4541
+ """Shift+W asks the VS Code window holding this exact tab to move it."""
4542
+ from arrayview._app import app
4543
+ import arrayview._vscode_signal as signal_mod
4544
+
4545
+ asked = []
4546
+ monkeypatch.setattr(
4547
+ signal_mod, "_request_vscode_tab_reveal",
4548
+ lambda *args: asked.append(args) or True,
4549
+ )
4550
+ np.save(tmp_path / "pop.npy", np.zeros((4, 4), dtype=np.float32))
4551
+ with TestClient(app) as c:
4552
+ sid = c.post("/load", json={"filepath": str(tmp_path / "pop.npy")}).json()["sid"]
4553
+ pop = lambda path: c.post(f"/popout/{sid}", json={"path": path}).json()["moved"]
4554
+ assert pop("/?t=ab12c") is False
4555
+ with c.websocket_connect(f"/ws/{sid}") as ws:
4556
+ ws.receive_json()
4557
+ ws.send_json({"type": "tab-address", "path": "/?t=ab12c", "window_id": "w1"})
4558
+ ws.send_json({"type": "link", "action": "leave"}) # round-trip barrier
4559
+ import time; time.sleep(0.2)
4560
+ assert pop("/?t=other") is False
4561
+ assert pop("/?t=ab12c") is True
4562
+ assert asked == [("w1", "/?t=ab12c", 5.0, "toggle-window")]
@@ -1234,7 +1234,7 @@ class TestModeCombinations:
1234
1234
  page.wait_for_function("() => compareActive && compareMvActive", timeout=15_000)
1235
1235
  # Panes are titled with the file names, not session ids.
1236
1236
  page.wait_for_function(
1237
- "() => [...document.querySelectorAll('.cq-row-label')].map(e => e.textContent).join(' ').includes('cmportho0')",
1237
+ "() => [...document.querySelectorAll('.cq-row-name')].map(e => e.textContent).join(' ').includes('cmportho0')",
1238
1238
  timeout=5_000,
1239
1239
  )
1240
1240
 
@@ -1341,8 +1341,10 @@ class TestModeCombinations:
1341
1341
  np.save(path, arr_3d)
1342
1342
  sids.append(client.post("/load", json={"filepath": str(path), "name": "t1.npy"}).json()["sid"])
1343
1343
  page.goto(f"{server_url}/?sid={sids[0]}&compare_sid={sids[1]}&compare_sids={sids[1]}")
1344
+ # Only the part that differs is shown; the full path is on hover.
1344
1345
  page.wait_for_function(
1345
- """() => { const t = document.getElementById('compare-left-title')?.textContent || '';
1346
- return t.includes('pat_a/t1.npy'); }""",
1346
+ """() => { const n = document.querySelector('#compare-left-title .av-name');
1347
+ return n && n.textContent === 'pat_a'
1348
+ && n.title.endsWith('pat_a/t1.npy'); }""",
1347
1349
  timeout=15_000,
1348
1350
  )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes