arrayview 0.49.0__tar.gz → 0.49.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.0 → arrayview-0.49.1}/PKG-INFO +1 -1
  2. {arrayview-0.49.0 → arrayview-0.49.1}/pyproject.toml +1 -1
  3. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_instance_registry.py +4 -1
  4. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_io.py +25 -16
  5. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_launch_trace.py +2 -1
  6. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_launcher.py +40 -4
  7. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_export.py +9 -3
  8. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_loading.py +12 -0
  9. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_websocket.py +30 -0
  10. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_viewer.html +432 -521
  11. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode_extension.py +14 -14
  12. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode_signal.py +57 -12
  13. arrayview-0.49.1/src/arrayview/arrayview-opener.vsix +0 -0
  14. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_api.py +41 -3
  15. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_browser.py +175 -0
  16. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_instance_registry.py +12 -0
  17. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_io.py +24 -0
  18. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_nifti_series.py +20 -0
  19. arrayview-0.49.0/src/arrayview/arrayview-opener.vsix +0 -0
  20. {arrayview-0.49.0 → arrayview-0.49.1}/.gitignore +0 -0
  21. {arrayview-0.49.0 → arrayview-0.49.1}/LICENSE +0 -0
  22. {arrayview-0.49.0 → arrayview-0.49.1}/README.md +0 -0
  23. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/ARCHITECTURE.md +0 -0
  24. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/__init__.py +0 -0
  25. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/__main__.py +0 -0
  26. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_analysis.py +0 -0
  27. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_app.py +0 -0
  28. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_bootstrap_app.py +0 -0
  29. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_config.py +0 -0
  30. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_diagnostics.py +0 -0
  31. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_dicom.py +0 -0
  32. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_diff.py +0 -0
  33. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_extra_ports.py +0 -0
  34. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_icon.png +0 -0
  35. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_imaging.py +0 -0
  36. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_launch_plan.py +0 -0
  37. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_lifecycle.py +0 -0
  38. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_overlays.py +0 -0
  39. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_platform.py +0 -0
  40. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_render.py +0 -0
  41. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_analysis.py +0 -0
  42. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_drop.py +0 -0
  43. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_persistence.py +0 -0
  44. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_preferences.py +0 -0
  45. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_preload.py +0 -0
  46. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_query.py +0 -0
  47. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_rendering.py +0 -0
  48. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_segmentation.py +0 -0
  49. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_state.py +0 -0
  50. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_vectorfield.py +0 -0
  51. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_segmentation.py +0 -0
  52. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_server.py +0 -0
  53. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_session.py +0 -0
  54. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_session_spec.py +0 -0
  55. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_shell.html +0 -0
  56. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_source_safety.py +0 -0
  57. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_synthetic_mri.py +0 -0
  58. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_torch.py +0 -0
  59. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_tutorial.py +0 -0
  60. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vectorfield.py +0 -0
  61. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_view_memory.py +0 -0
  62. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode.py +0 -0
  63. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode_browser.py +0 -0
  64. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_watch.py +0 -0
  65. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/gsap.min.js +0 -0
  66. {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/todo.md +0 -0
  67. {arrayview-0.49.0 → arrayview-0.49.1}/tests/capture_patient_loading.py +0 -0
  68. {arrayview-0.49.0 → arrayview-0.49.1}/tests/capture_v_animation.py +0 -0
  69. {arrayview-0.49.0 → arrayview-0.49.1}/tests/conftest.py +0 -0
  70. {arrayview-0.49.0 → arrayview-0.49.1}/tests/make_vectorfield_test_arrays.py +0 -0
  71. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_1d_arrays.py +0 -0
  72. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_backend_shared.py +0 -0
  73. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_cli.py +0 -0
  74. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_config.py +0 -0
  75. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_dicom.py +0 -0
  76. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_drop_import.py +0 -0
  77. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_large_arrays.py +0 -0
  78. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_launch_blackbox.py +0 -0
  79. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_mode_roundtrip.py +0 -0
  80. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_nifti_meta.py +0 -0
  81. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_rgb_pixel_art.py +0 -0
  82. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_safe_server_stop.py +0 -0
  83. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_source_safety.py +0 -0
  84. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_torch.py +0 -0
  85. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_tutorial.py +0 -0
  86. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_view_memory.py +0 -0
  87. {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_vsix_bundle.py +0 -0
  88. {arrayview-0.49.0 → arrayview-0.49.1}/tests/ui_audit.py +0 -0
  89. {arrayview-0.49.0 → arrayview-0.49.1}/tests/v_anim_frames/.gitkeep +0 -0
  90. {arrayview-0.49.0 → arrayview-0.49.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.0
3
+ Version: 0.49.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.0"
7
+ version = "0.49.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"
@@ -70,7 +70,10 @@ def runtime_directory() -> Path:
70
70
  return Path(base) / "arrayview"
71
71
  if hasattr(os, "getuid"):
72
72
  return Path(tempfile.gettempdir()) / f"arrayview-{os.getuid()}"
73
- return Path(tempfile.gettempdir()) / f"arrayview-{os.getpid()}"
73
+ # Windows: no uid, but the temp folder is already per user. One shared
74
+ # name lets every command see the servers the others started.
75
+ user = "".join(c for c in os.environ.get("USERNAME", "") if c.isalnum() or c in "-_.")
76
+ return Path(tempfile.gettempdir()) / f"arrayview-{user or 'user'}"
74
77
 
75
78
 
76
79
  def process_start_identity(pid: int) -> str | None:
@@ -503,7 +503,7 @@ def read_gzip_file_parallel(filepath):
503
503
  """
504
504
  try:
505
505
  size = os.path.getsize(filepath)
506
- fd = os.open(filepath, os.O_RDONLY)
506
+ fd = os.open(filepath, _O_RDONLY_BINARY)
507
507
  except OSError:
508
508
  return None
509
509
  buf = bytearray(size)
@@ -518,7 +518,7 @@ def read_gzip_file_parallel(filepath):
518
518
  pos = start
519
519
  try:
520
520
  while pos < stop:
521
- n = os.preadv(fd, [view[pos:stop]], pos)
521
+ n = _pread_into(fd, view[pos:stop], pos)
522
522
  if n <= 0:
523
523
  raise OSError("unexpected end of file")
524
524
  pos += n
@@ -624,10 +624,21 @@ def _mapped_slice_is_scattered(view) -> bool:
624
624
  return nbytes > 4 * view.nbytes and nbytes > _REGION_READ_CHUNK_BYTES
625
625
 
626
626
 
627
+ # Windows has neither pread nor preadv: reads there seek and read under one
628
+ # lock, so they are correct but no longer run in parallel.
629
+ _SEEK_READ_LOCK = threading.Lock()
630
+ _O_RDONLY_BINARY = os.O_RDONLY | getattr(os, "O_BINARY", 0)
631
+
632
+
627
633
  def _pread_into(fd, buf, offset):
628
634
  if hasattr(os, "preadv"):
629
635
  return os.preadv(fd, [buf], offset)
630
- data = os.pread(fd, len(buf), offset)
636
+ if hasattr(os, "pread"):
637
+ data = os.pread(fd, len(buf), offset)
638
+ else:
639
+ with _SEEK_READ_LOCK:
640
+ os.lseek(fd, offset, os.SEEK_SET)
641
+ data = os.read(fd, len(buf))
631
642
  buf[: len(data)] = data
632
643
  return len(data)
633
644
 
@@ -646,7 +657,7 @@ def read_mapped_region(view, progress=None):
646
657
  mv = memoryview(buf)
647
658
  lock = threading.Lock()
648
659
  done = [0]
649
- fd = os.open(filename, os.O_RDONLY)
660
+ fd = os.open(filename, _O_RDONLY_BINARY)
650
661
  try:
651
662
 
652
663
  def _read(start):
@@ -681,9 +692,9 @@ class _ScatteredRegionMixin:
681
692
  map of a 4D one. When a requested slice would be scattered across its
682
693
  region, the region is read in one go (see ``read_mapped_region``) and kept;
683
694
  every later slice of it, in any direction, is then served from memory.
684
- Afterwards the case's other regions and the neighbouring case are read in
685
- the background, so the next step — another map, the next patient — is
686
- usually already there.
695
+ Whichever way the case is first viewed, its regions and the neighbouring
696
+ case's are then read in the background, so the next step — scrolling,
697
+ another map, a crossing view, the next patient — is usually already there.
687
698
  """
688
699
 
689
700
  def _init_region_cache(self):
@@ -793,6 +804,9 @@ class _ScatteredRegionMixin:
793
804
  if region is None:
794
805
  try:
795
806
  if not _mapped_slice_is_scattered(mapped[tuple(spatial_key)]):
807
+ # Cheap to read now, but the next scroll steps and any
808
+ # switch to a crossing view are not: load the case anyway.
809
+ self._schedule_region_warm(p_idx, m_idx, rest, spatial_key)
796
810
  return None
797
811
  view = self._region_view(mapped, rest)
798
812
  if view.nbytes > self._region_cache_max_bytes // 2:
@@ -825,11 +839,6 @@ class _ScatteredRegionMixin:
825
839
  else:
826
840
  step = -1 if last is not None and last[0] > p_idx else 1
827
841
  neighbours = [(p_idx + step, m_idx), (p_idx - step, m_idx)]
828
- # Which axes are on screen is all that carries over to another case;
829
- # the indices along the others may not fit a differently-sized one.
830
- probe = tuple(
831
- slice(None) if isinstance(k, slice) else 0 for k in spatial_key[: min(3, len(spatial_key))]
832
- )
833
842
  plan = [(p_idx, m_idx)] + [
834
843
  (p, m) for p, m in neighbours if 0 <= p < n_p and 0 <= m < n_m
835
844
  ]
@@ -845,7 +854,7 @@ class _ScatteredRegionMixin:
845
854
  if self._file_matrix[p][m] is None:
846
855
  continue
847
856
  mapped = self._mapped_volume(p, m, self._get_volume(p, m))
848
- if mapped is None or not _mapped_slice_is_scattered(mapped[probe + first_rest]):
857
+ if mapped is None:
849
858
  continue
850
859
  n_sp = min(3, mapped.ndim)
851
860
  rests = list(np.ndindex(*mapped.shape[n_sp:])) or [()]
@@ -2213,7 +2222,7 @@ def prefetch_slice_pages(mapped, filepath, dims, index, progress=None) -> bool:
2213
2222
  return False
2214
2223
 
2215
2224
  try:
2216
- fd = os.open(filepath, os.O_RDONLY)
2225
+ fd = os.open(filepath, _O_RDONLY_BINARY)
2217
2226
  except OSError:
2218
2227
  return False
2219
2228
  try:
@@ -2323,13 +2332,13 @@ def read_npy_into_ram(mapped, progress=None):
2323
2332
  lock = threading.Lock()
2324
2333
  done = [0]
2325
2334
 
2326
- fd = os.open(mapped.filename, os.O_RDONLY)
2335
+ fd = os.open(mapped.filename, _O_RDONLY_BINARY)
2327
2336
  try:
2328
2337
  def _read(start):
2329
2338
  stop = min(start + _NPY_READ_CHUNK_BYTES, total)
2330
2339
  pos = start
2331
2340
  while pos < stop:
2332
- n = os.preadv(fd, [dst[pos:stop]], offset + pos)
2341
+ n = _pread_into(fd, dst[pos:stop], offset + pos)
2333
2342
  if n <= 0:
2334
2343
  raise OSError(f"unexpected end of file reading {mapped.filename!r}")
2335
2344
  pos += n
@@ -151,7 +151,8 @@ def emit_launch_event(event: str, **attrs: object) -> None:
151
151
  encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
152
152
  fd = os.open(_path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
153
153
  try:
154
- os.fchmod(fd, 0o600)
154
+ if hasattr(os, "fchmod"): # Windows has none before Python 3.13
155
+ os.fchmod(fd, 0o600)
155
156
  os.write(fd, encoded)
156
157
  finally:
157
158
  os.close(fd)
@@ -74,10 +74,15 @@ def _detached_child_kwargs() -> dict[str, object]:
74
74
  control event raised for that group reaches *every* process attached to it,
75
75
  so a child being shut down can deliver a KeyboardInterrupt to the process
76
76
  that spawned it — under pytest, to the test runner itself. Both branches
77
- state the same intent: the child gets its own group.
77
+ state the same intent: the child gets its own group. On Windows it also
78
+ gets no console of its own: started from a notebook or a GUI app, a
79
+ background server would otherwise pop up an empty black console window.
78
80
  """
79
81
  if sys.platform == "win32":
80
- return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
82
+ return {
83
+ "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
84
+ | subprocess.CREATE_NO_WINDOW
85
+ }
81
86
  return {"start_new_session": True}
82
87
 
83
88
 
@@ -391,6 +396,19 @@ def _build_inline_shell_html(url: str, shell_port: int) -> str | None:
391
396
  return None
392
397
 
393
398
 
399
+ def _set_listen_reuse(sock: "socket.socket") -> None:
400
+ """Let a restarted server rebind its port at once, without sharing it.
401
+
402
+ On Windows ``SO_REUSEADDR`` lets a second process bind a port another
403
+ listener already holds, so two servers could answer on one port; the
404
+ exclusive option there is what the POSIX flag means elsewhere.
405
+ """
406
+ if sys.platform == "win32":
407
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
408
+ else:
409
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
410
+
411
+
394
412
  def _make_loopback_socket(port: int) -> "socket.socket":
395
413
  """Bind a single TCP listener on the same loopback host the viewer URLs use."""
396
414
  for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
@@ -401,7 +419,7 @@ def _make_loopback_socket(port: int) -> "socket.socket":
401
419
  sock = None
402
420
  try:
403
421
  sock = socket.socket(family, socktype, proto)
404
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
422
+ _set_listen_reuse(sock)
405
423
  sock.bind(sockaddr)
406
424
  sock.listen(128)
407
425
  sock.set_inheritable(True)
@@ -439,7 +457,7 @@ def _make_loopback_sockets(port: int) -> list["socket.socket"]:
439
457
  sock = None
440
458
  try:
441
459
  sock = socket.socket(family, socktype, proto)
442
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
460
+ _set_listen_reuse(sock)
443
461
  # On Linux, binding ::1 first then 127.0.0.1 on the same port is
444
462
  # fine (different addresses). IPV6_V6ONLY stays default (off on
445
463
  # Linux) which is harmless since we bind explicit addresses.
@@ -5652,8 +5670,26 @@ def _handle_management_command(argv: list[str]) -> bool:
5652
5670
  return True
5653
5671
 
5654
5672
 
5673
+ def _replace_unprintable_output() -> None:
5674
+ """Keep messages with arrows or ticks from crashing a Windows console.
5675
+
5676
+ Output redirected to a file or pipe on Windows uses an old code page that
5677
+ cannot encode such characters, so printing them raised instead of showing
5678
+ the message. The servers this command starts inherit the same setting.
5679
+ """
5680
+ if os.name != "nt":
5681
+ return
5682
+ os.environ.setdefault("PYTHONIOENCODING", ":replace")
5683
+ for stream in (sys.stdout, sys.stderr):
5684
+ try:
5685
+ stream.reconfigure(errors="replace")
5686
+ except (AttributeError, ValueError):
5687
+ pass
5688
+
5689
+
5655
5690
  def arrayview():
5656
5691
  """Command Line Interface Entry Point."""
5692
+ _replace_unprintable_output()
5657
5693
  # Handle "arrayview config ..." subcommand before argparse
5658
5694
  if len(sys.argv) > 1 and sys.argv[1] == "config":
5659
5695
  _handle_config_command(sys.argv[2:])
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import io
3
+ import re
3
4
 
4
5
  import numpy as np
5
6
  from fastapi import Body, Depends, Request, Response
@@ -8,6 +9,12 @@ from fastapi.responses import JSONResponse
8
9
  from arrayview._render import render_rgba
9
10
 
10
11
 
12
+ def _safe_filename(name: str, default: str) -> str:
13
+ """A bare file name every OS accepts: no folders, no reserved characters."""
14
+ name = re.sub(r'[\\/:*?"<>|\x00-\x1f\s]+', "_", str(name or "")).strip("._")
15
+ return name or default
16
+
17
+
11
18
  def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
12
19
  @app.get("/export_array/{sid}")
13
20
  def export_array(
@@ -20,8 +27,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
20
27
  buf = io.BytesIO()
21
28
  np.save(buf, data)
22
29
  buf.seek(0)
23
- name_stem = (session.name or "array").replace(" ", "_").replace("/", "_")
24
- filename = f"{name_stem}.npy"
30
+ filename = f"{_safe_filename(session.name, 'array')}.npy"
25
31
  if save_to_downloads:
26
32
  import pathlib
27
33
 
@@ -39,7 +45,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
39
45
  async def save_file(request: Request):
40
46
  """Save a client-generated file to the Downloads folder."""
41
47
  body = await request.json()
42
- filename = body.get("filename", "arrayview_export")
48
+ filename = _safe_filename(body.get("filename"), "arrayview_export")
43
49
  data_b64 = body.get("data", "")
44
50
  if "," in data_b64:
45
51
  data_b64 = data_b64.split(",", 1)[1]
@@ -618,6 +618,18 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
618
618
  )
619
619
  return {"sid": session.sid, "name": name, "notified": notified}
620
620
 
621
+ @app.post("/reveal/{sid}")
622
+ async def reveal_session_tab(sid: str):
623
+ """Bring the VS Code browser tab already showing *sid* forward."""
624
+ from arrayview._routes_websocket import viewer_tab_reveal_target
625
+ from arrayview._vscode_signal import _request_vscode_tab_reveal
626
+
627
+ target = viewer_tab_reveal_target(sid)
628
+ if target is None:
629
+ return {"sid": sid, "revealed": False}
630
+ revealed = await asyncio.to_thread(_request_vscode_tab_reveal, *target)
631
+ return {"sid": sid, "revealed": revealed}
632
+
621
633
  @app.post("/release/{sid}")
622
634
  def release_viewer_session(sid: str, request: Request):
623
635
  """Release a session when its owning viewer tab/window closes."""
@@ -289,6 +289,32 @@ async def _send_json_quietly(ws, payload):
289
289
  # a small socket budget, so linking must not open connections of its own.
290
290
  _LINKED_VIEWER_SOCKETS: set = set()
291
291
 
292
+ # Where each live VS Code browser tab currently is: ws → (sid, path, window_id).
293
+ # The file picker uses it to bring an already-open array's tab forward.
294
+ _VIEWER_TAB_ADDRESSES: dict = {}
295
+
296
+
297
+ def _handle_tab_address_message(ws, sid: str, msg: dict) -> None:
298
+ path = msg.get("path")
299
+ window_id = msg.get("window_id")
300
+ if not isinstance(path, str) or not path.startswith("/") or len(path) > 2048:
301
+ return
302
+ if not isinstance(window_id, str) or not window_id or len(window_id) > 128:
303
+ return
304
+ _VIEWER_TAB_ADDRESSES[ws] = (sid, path, window_id)
305
+
306
+
307
+ def viewer_tab_reveal_target(sid: str) -> tuple[str, str] | None:
308
+ """(window_id, VS Code reuseUrlFilter) of a live tab showing *sid*."""
309
+ from urllib.parse import urlsplit
310
+
311
+ for tab_sid, path, window_id in reversed(list(_VIEWER_TAB_ADDRESSES.values())):
312
+ parts = urlsplit(path)
313
+ # A bare "/" is shared by every tab; only a query tells them apart.
314
+ if tab_sid == sid and parts.query:
315
+ return window_id, f"{parts.path}?{parts.query}"
316
+ return None
317
+
292
318
 
293
319
  def _handle_link_message(ws, msg: dict) -> None:
294
320
  action = msg.get("action")
@@ -531,6 +557,9 @@ def register_websocket_routes(app) -> None:
531
557
  # displaces (or is displaced by) a pending render.
532
558
  _handle_link_message(ws, msg)
533
559
  continue
560
+ if isinstance(msg, dict) and msg.get("type") == "tab-address":
561
+ _handle_tab_address_message(ws, sid, msg)
562
+ continue
534
563
  try:
535
564
  _pending.get_nowait()
536
565
  except asyncio.QueueEmpty:
@@ -933,6 +962,7 @@ def register_websocket_routes(app) -> None:
933
962
  except ValueError:
934
963
  pass
935
964
  _LINKED_VIEWER_SOCKETS.discard(ws)
965
+ _VIEWER_TAB_ADDRESSES.pop(ws, None)
936
966
  try:
937
967
  from arrayview import _extra_ports
938
968