diff --git a/.devcontainer/mrd-viz/devcontainer.json b/.devcontainer/mrd-viz/devcontainer.json new file mode 100644 index 00000000..4484cca2 --- /dev/null +++ b/.devcontainer/mrd-viz/devcontainer.json @@ -0,0 +1,42 @@ +{ + // Lightweight, extension-specific dev container for MRD Viz (Scenario B). + // The other config, ".devcontainer/devcontainer.json" ("mrd"), is the full + // repo toolchain (conda + MATLAB + C++) and is a poor fit for just viewing + // .mrd files, so this pins Python 3.12 (matching the backend) and stays minimal. + "name": "MRD Viz extension", + "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm", + "features": { + "ghcr.io/devcontainers/features/azure-cli:1": {} + }, + // postCreate installs Node.js (from nodejs.org), plus the `just` and `azcopy` + // CLIs. Node is installed here rather than via the devcontainer `node` feature + // because that feature pulls pnpm from the public npm registry at build time, + // which is blocked on some corporate networks. Project provisioning (backend + + // the MRD Viz extension) is a one-time `just container-setup` you run afterwards. + "postCreateCommand": "bash .devcontainer/mrd-viz/postCreate.sh", + // Ordered package-index candidates (Microsoft-internal mirror first, public + // registry as fallback). `.devcontainer/mrd-viz/select-pkg-index.sh` picks the + // first reachable one and exports PIP_INDEX_URL / npm_config_registry, so the + // internal feed lives here — not in a hidden dotfile — and external contributors + // fall back to the public registry automatically. Override either list, or set + // PIP_INDEX_URL / npm_config_registry directly, to force a specific feed. + "remoteEnv": { + "MRD_PIP_INDEX_URLS": "https://packagefeedproxy.microsoft.io/pypi/simple/ https://pypi.org/simple/", + "MRD_NPM_REGISTRIES": "https://packagefeedproxy.microsoft.io/npm/ https://registry.npmjs.org/" + }, + "customizations": { + "vscode": { + "settings": { + // `just container-setup` creates this venv; pointing the extension at it + // explicitly (via the developer override) avoids the installed-VSIX discovery + // gap and the python/python3 PATH ambiguity. backendPath is machine-scoped, so + // this applies as a container remote setting and never leaks back to the host. + "mrdViz.backendPath": "/home/vscode/.venvs/mrd-viz/bin/python", + "python.defaultInterpreterPath": "/home/vscode/.venvs/mrd-viz/bin/python" + }, + "extensions": [ + "ms-python.python" + ] + } + } +} diff --git a/.devcontainer/mrd-viz/postCreate.sh b/.devcontainer/mrd-viz/postCreate.sh new file mode 100644 index 00000000..e9f4d2ad --- /dev/null +++ b/.devcontainer/mrd-viz/postCreate.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# postCreate for the MRD Viz dev container. +# Installs the generic CLI tools the workflow needs, then best-effort provisions +# the backend virtualenv so the container is turnkey for a first run. Building and +# installing the extension VSIX stays a one-time `just mrd-viz-container-setup` +# step (it needs the npm registry, which is what fails on restricted networks). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Resolve the container architecture so the Node and azcopy downloads match it. +arch="$(uname -m)" +case "$arch" in + x86_64) node_arch="x64"; azcopy_url="https://aka.ms/downloadazcopy-v10-linux"; azcopy_glob="azcopy_linux_amd64_*" ;; + aarch64|arm64) node_arch="arm64"; azcopy_url="https://aka.ms/downloadazcopy-v10-linux-arm64"; azcopy_glob="azcopy_linux_arm64_*" ;; + *) echo ">> Unsupported architecture: $arch" >&2; exit 1 ;; +esac + +# Node.js (from nodejs.org, which is reachable even where the public npm registry +# is blocked). Installed here instead of the devcontainer `node` feature, which +# pulls pnpm from the public npm registry at build time. Node bundles npm. +if ! command -v node >/dev/null 2>&1; then + echo ">> Installing Node.js" + node_version="v24.18.0" + curl -fsSL "https://nodejs.org/dist/${node_version}/node-${node_version}-linux-${node_arch}.tar.xz" -o /tmp/node.tar.xz + sudo tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 + rm -f /tmp/node.tar.xz +fi + +# just: task runner used by `just container-setup`. +if ! command -v just >/dev/null 2>&1; then + echo ">> Installing just" + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh -o /tmp/just-install.sh + sudo bash /tmp/just-install.sh --to /usr/local/bin + rm -f /tmp/just-install.sh +fi + +# azcopy: used to pull .mrd data from Azure storage. +if ! command -v azcopy >/dev/null 2>&1; then + echo ">> Installing azcopy" + curl -sSL "$azcopy_url" -o /tmp/azcopy.tar.gz + tar -xzf /tmp/azcopy.tar.gz -C /tmp + sudo cp /tmp/${azcopy_glob}/azcopy /usr/local/bin/azcopy + sudo chmod +x /usr/local/bin/azcopy + rm -rf /tmp/azcopy.tar.gz /tmp/${azcopy_glob} +fi + +# Backend provisioning (best-effort, non-fatal). The dev container points +# mrdViz.backendPath at this venv, so create it and install the mrd_viz backend +# now for a turnkey first run. This only needs PyPI (mrd-python/numpy/pillow), +# which is typically reachable even where the npm registry is blocked; the guard +# keeps a failure from aborting container creation and falls back to the manual +# step in the banner below. +venv="$HOME/.venvs/mrd-viz" +backend_dir="$repo_root/mrd-viz/backend" +backend_ready=0 +if [ -x "$venv/bin/python" ] && "$venv/bin/python" -m mrd_viz.cli --version >/dev/null 2>&1; then + backend_ready=1 +elif [ -d "$backend_dir" ]; then + # Point pip at the first reachable index (internal mirror, else public PyPI). + # shellcheck source=./select-pkg-index.sh + source "$repo_root/.devcontainer/mrd-viz/select-pkg-index.sh" + echo ">> Provisioning MRD Viz backend virtualenv: $venv" + if python3 -m venv "$venv" \ + && "$venv/bin/python" -m pip install --upgrade pip \ + && "$venv/bin/python" -m pip install -e "$backend_dir"; then + backend_ready=1 + else + echo ">> WARNING: automatic backend setup failed (often a blocked/restricted network)." >&2 + rm -rf "$venv" + fi +fi + +if [ "$backend_ready" -eq 1 ]; then + cat <<'EOF' + +============================================================ + MRD Viz dev container is ready. Backend is installed and + mrdViz.backendPath points at ~/.venvs/mrd-viz, so opening a + .mrd file should work out of the box. + + If the MRD Viz extension itself is not installed yet, run: + just mrd-viz-container-setup + (builds + installs the extension VSIX; needs npm registry + access - see mrd-viz/docs/DEVCONTAINER.md for restricted + networks). +============================================================ +EOF +else + cat <<'EOF' + +============================================================ + MRD Viz dev container is ready, but automatic backend setup + did not complete (often a blocked/restricted network). + + Finish setup by running: + just mrd-viz-container-setup + + That creates the backend virtualenv, installs mrd_viz, and + builds + installs the MRD Viz extension in this window. + See mrd-viz/docs/DEVCONTAINER.md for restricted-network tips. +============================================================ +EOF +fi diff --git a/.devcontainer/mrd-viz/select-pkg-index.sh b/.devcontainer/mrd-viz/select-pkg-index.sh new file mode 100644 index 00000000..6d571cfa --- /dev/null +++ b/.devcontainer/mrd-viz/select-pkg-index.sh @@ -0,0 +1,59 @@ +# shellcheck shell=bash +# Pick the first reachable package index/registry from an ordered, space-separated +# candidate list and export the variables pip and npm read natively +# (PIP_INDEX_URL, npm_config_registry). Source this before running pip/npm. +# +# Candidate ladder (same idea as the backend resolver): the Microsoft-internal +# mirror is tried first, the public registry second. Microsoft-internal devs on a +# restricted network get the mirror; external devs fall back to the public +# registry automatically. The lists come from devcontainer.json (remoteEnv): +# MRD_PIP_INDEX_URLS e.g. "https://internal/pypi/simple/ https://pypi.org/simple/" +# MRD_NPM_REGISTRIES e.g. "https://internal/npm/ https://registry.npmjs.org/" +# When unset (running outside the dev container), they default to public only. +# +# Override the final choice by exporting PIP_INDEX_URL / npm_config_registry +# yourself before sourcing; an explicit value is always respected. + +: "${MRD_PIP_INDEX_URLS:=https://pypi.org/simple/}" +: "${MRD_NPM_REGISTRIES:=https://registry.npmjs.org/}" + +# Echo the first URL in $1 (space-separated) whose host completes an HTTPS request +# within a short timeout. Any HTTP response (even 404) counts as reachable; only a +# TLS handshake / connection failure — the restricted-network symptom — is a miss. +_mrd_first_reachable() { + local url + for url in $1; do + if curl -sS --max-time 6 -o /dev/null -I "$url" >/dev/null 2>&1; then + printf '%s' "$url" + return 0 + fi + done + return 1 +} + +# pip +if [ -n "${PIP_INDEX_URL:-}" ]; then + echo ">> Using PyPI index (from PIP_INDEX_URL): $PIP_INDEX_URL" +elif _pip_url="$(_mrd_first_reachable "$MRD_PIP_INDEX_URLS")"; then + export PIP_INDEX_URL="$_pip_url" + echo ">> Using PyPI index: $PIP_INDEX_URL" +else + echo ">> WARNING: no reachable PyPI index among: $MRD_PIP_INDEX_URLS" >&2 + echo ">> If you are on a restricted/corporate network, run this and retry:" >&2 + echo ">> export PIP_INDEX_URL=\"${MRD_PIP_INDEX_URLS%% *}\"" >&2 +fi + +# npm +if [ -n "${npm_config_registry:-}" ]; then + echo ">> Using npm registry (from npm_config_registry): $npm_config_registry" +elif _npm_url="$(_mrd_first_reachable "$MRD_NPM_REGISTRIES")"; then + export npm_config_registry="$_npm_url" + echo ">> Using npm registry: $npm_config_registry" +else + echo ">> WARNING: no reachable npm registry among: $MRD_NPM_REGISTRIES" >&2 + echo ">> If you are on a restricted/corporate network, run this and retry:" >&2 + echo ">> export npm_config_registry=\"${MRD_NPM_REGISTRIES%% *}\"" >&2 +fi + +unset -f _mrd_first_reachable +unset _pip_url _npm_url diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fcd75fb8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts and justfiles run inside Linux dev containers; keep them LF +# so bash/just don't choke on CR characters even when edited on Windows. +*.sh text eol=lf +justfile text eol=lf diff --git a/.github/workflows/mrd_viz.yml b/.github/workflows/mrd_viz.yml new file mode 100644 index 00000000..2545301b --- /dev/null +++ b/.github/workflows/mrd_viz.yml @@ -0,0 +1,42 @@ +name: MRD Viz + +on: + pull_request: + branches: [main] + paths: + - "mrd-viz/**" + - ".github/workflows/mrd_viz.yml" + push: + branches: [main] + paths: + - "mrd-viz/**" + - ".github/workflows/mrd_viz.yml" + workflow_dispatch: + +jobs: + ci: + name: Backend and Extension Checks + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: mrd-viz/extension/mrd-viz/package-lock.json + + - name: Set up just + uses: extractions/setup-just@v4 + + - name: Run MRD Viz CI + working-directory: mrd-viz + run: just ci \ No newline at end of file diff --git a/.github/workflows/mrd_viz_release.yml b/.github/workflows/mrd_viz_release.yml new file mode 100644 index 00000000..dde21a25 --- /dev/null +++ b/.github/workflows/mrd_viz_release.yml @@ -0,0 +1,181 @@ +name: MRD Viz Release + +on: + pull_request: + branches: [main] + paths: + - "mrd-viz/extension/**" + - "mrd-viz/backend/**" + - ".github/workflows/mrd_viz_release.yml" + push: + tags: + - "mrd-viz-v*" + workflow_dispatch: + +# Least privilege by default; only the release job needs write access to publish. +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + target: linux-x64 + - os: windows-2022 + target: win32-x64 + - os: macos-14 + target: darwin-arm64 + # NOTE: darwin-x64 (Intel macOS on the macos-13 runner) is intentionally + # dropped. GitHub is winding down the Intel macOS runner pool, so the job + # sat in the queue for hours without ever being assigned a runner (0 steps, + # cancelled at GitHub's ~24h queue limit). timeout-minutes does not help + # because it only counts execution time, not queue time. We do not currently + # ship to Intel Macs; those users can still fall back to the managed-venv / + # "Select Python Interpreter" path. Re-add this leg (or use a paid macos-13-large + # / self-hosted Intel runner) if Intel-Mac bundled-binary support is needed. + runs-on: ${{ matrix.os }} + # Cap runtime so a stalled leg fails fast instead of hanging for hours. + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: mrd-viz/extension/mrd-viz/package-lock.json + + - name: Build standalone backend binary + working-directory: mrd-viz/backend + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install ".[package]" + # Retry the PyInstaller build once to absorb transient CI-runner flakes. + for attempt in 1 2; do + if pyinstaller --clean --noconfirm packaging/mrd-viz.spec; then + exit 0 + fi + echo "::warning::PyInstaller build attempt ${attempt} failed; retrying..." + sleep 5 + done + echo "::error::PyInstaller build failed after 2 attempts." + exit 1 + + - name: Stage binary into the extension + shell: bash + run: | + dest="mrd-viz/extension/mrd-viz/media/backend" + mkdir -p "$dest" + cp -r mrd-viz/backend/dist/mrd-viz/. "$dest/" + if [ "${{ runner.os }}" = "Windows" ]; then + "$dest/mrd-viz.exe" --version + else + chmod +x "$dest/mrd-viz" + "$dest/mrd-viz" --version + fi + + - name: Package platform VSIX + working-directory: mrd-viz/extension/mrd-viz + run: | + npm ci + # Pin vsce to an exact version so packaging is reproducible and an upstream + # compromise cannot silently execute in this artifact-producing job. + npx --yes @vscode/vsce@3.9.2 package --target ${{ matrix.target }} + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: vsix-${{ matrix.target }} + path: mrd-viz/extension/mrd-viz/*.vsix + + release: + name: Publish GitHub Release + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Download platform VSIXs + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Publish release + # Pinned to a full commit SHA rather than the mutable v2 tag: this job holds + # contents: write and publishes release artifacts. + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + files: dist/**/*.vsix + generate_release_notes: true + + publish-marketplace: + name: Publish to VS Code Marketplace + needs: [build, release] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-24.04 + # No repo write needed; this job only talks to the Marketplace via the PAT. + permissions: + contents: read + + steps: + - name: Download platform VSIXs + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Publish pre-built VSIXs + # vsce reads the PAT from the VSCE_PAT env var. If the secret is not + # configured yet, skip gracefully so tagging never hard-fails while the + # publisher identity / credential is still being set up. + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + shell: bash + run: | + if [ -z "${VSCE_PAT}" ]; then + echo "::warning::VSCE_PAT secret not set; skipping Marketplace publish." + exit 0 + fi + # Publish each pre-built platform VSIX as-is. Do NOT rebuild here — + # the bundled backend binary was staged during the build job. + shopt -s globstar nullglob + published=0 + for vsix in dist/**/*.vsix; do + echo "Publishing ${vsix}" + npx --yes @vscode/vsce@3.9.2 publish --packagePath "${vsix}" + published=$((published + 1)) + done + if [ "${published}" -eq 0 ]; then + echo "::error::No VSIX artifacts found under dist/ to publish." + exit 1 + fi + echo "Published ${published} VSIX(es) to the Marketplace." + +# TODO: optionally mirror the same pre-built VSIXs to Open VSX (VSCodium / Cursor / +# code-server) with `ovsx publish -p $OVSX_PAT --packagePath `. Needs a +# separate Open VSX account + token; add as an OVSX_PAT secret. +# TODO: optionally sign / notarize the binaries. Not required for the one-dir +# build to run under Windows Application Control, but signing removes +# SmartScreen / Gatekeeper prompts on first launch. +# TODO: optionally also publish a generic (no-binary) VSIX as a fallback for +# unsupported platforms (uses the resolver's python-interpreter path). + diff --git a/.gitignore b/.gitignore index ac1b7945..1845980a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,14 @@ cpp/build/ .vscode/ *.ipynb +# Local dev-container data landing zone (e.g. azcopy pulls of large .mrd files) +mrd-viz/data/ + *.bin *.png + +# MRD Viz extension icon assets are intentionally tracked despite the *.png rule. +!mrd-viz/extension/mrd-viz/media/icon.png +!mrd-viz/extension/mrd-viz/media/icon-src.png + +*.pyc diff --git a/justfile b/justfile index 66bf165c..cefe21f4 100644 --- a/justfile +++ b/justfile @@ -105,3 +105,8 @@ validate-with-no-changes: test @test-docker-images: ./docker/test-docker-images.sh + +# Provision the MRD Viz dev container (delegates into mrd-viz/justfile so you +# don't have to `cd mrd-viz` first). +@mrd-viz-container-setup: + cd mrd-viz && just container-setup diff --git a/mrd-viz/.gitignore b/mrd-viz/.gitignore new file mode 100644 index 00000000..5c6d7b11 --- /dev/null +++ b/mrd-viz/.gitignore @@ -0,0 +1,21 @@ +# Local/generated files +backend/.venv/ +backend/src/*.egg-info/ +backend/src/**/__pycache__/ +backend/build/ +backend/dist/ +extension/mrd-viz/node_modules/ +extension/mrd-viz/out/ +extension/mrd-viz/.vscode-test/ +# Local npm registry override (e.g. a corporate feed on restricted networks). +extension/mrd-viz/.npmrc +# CI-staged standalone backend binary (built by the release workflow). +extension/mrd-viz/media/backend/ +*.vsix + +# WIP internal docs kept untracked until ready for review. +docs/BACKEND_INSTALL_MODES.md +docs/BACKEND_RESOLUTION_IMPLEMENTATION_PLAN.md +docs/EXTENSION_RELEASE_RUNBOOK.md +docs/OFFICIAL_EXT_DEV_RUNBOOK.md +docs/PACKAGING_AND_INSTALL_RUNBOOK.md \ No newline at end of file diff --git a/mrd-viz/README.md b/mrd-viz/README.md new file mode 100644 index 00000000..f50208d4 --- /dev/null +++ b/mrd-viz/README.md @@ -0,0 +1,23 @@ +# mrd-viz + +Lightweight MRD inspection and preview VS Code extension. + +The current focus is direct inspection of existing `.mrd` files rather than reconstruction orchestration. See `docs/TECHNICAL_DESIGN.md` for detailed project context and implementation plan. + +## Researcher functionality and onboarding + +| Delivery | Before | After | State | +| --- | --- | --- | --- | +| PR #78 dev container | Researchers assembled Python, Node, Azure tools, backend, and extension separately. | One reproducible container and setup command provide a test environment. | Delivered | +| D1 backend resolution | A missing or wrong Python could produce a raw process error or select an unintended fallback. | MRD Viz validates a machine-local override or the bundled backend and provides guided recovery. | Delivered | +| GitHub Release | Researchers built from source or stayed inside a development container. | A version tag builds platform VSIXs and publishes one reviewable GitHub Release. | Ready in this PR | +| D3 bundled backend | Normal VS Code installs required Python and manual backend configuration. | Linux x64, Windows x64, and Apple Silicon VSIXs include a validated standalone backend. | Delivered | +| D2 managed fallback | Unsupported platforms have no bundled binary. | Guided provisioning creates an extension-owned Python environment with cleanup and actionable errors. | Delivered fallback; PyPI publication pending | +| Marketplace | Researchers download and update a VSIX manually. | The release workflow can publish the same reviewed platform artifacts when `VSCE_PAT` is configured. | Prepared; credentials pending | +| Multi-file comparison | Inspection is file-first and one editor at a time. | Compare related files, slices, image types, and metadata in one workflow. | Planned | +| F10 maintainability | Viewer HTML, styles, and behavior shared one large module. | Focused webview modules reduce change risk while preserving the researcher UX. | Delivered | +| Feedback-driven onboarding | Setup assumptions come primarily from developer testing. | Researcher feedback determines defaults, diagnostics, and which fallback paths stay visible. | Starts after release | + +## Install the researcher preview + +Download the VSIX for your platform from the [latest GitHub Release](https://github.com/ismrmrd/mrd/releases/latest), install it with **Extensions: Install from VSIX...**, and open a `.mrd` file. See the [release guide](docs/RELEASE.md) for publishing and fallback details. \ No newline at end of file diff --git a/mrd-viz/backend/README.md b/mrd-viz/backend/README.md new file mode 100644 index 00000000..1a733711 --- /dev/null +++ b/mrd-viz/backend/README.md @@ -0,0 +1,5 @@ +# mrd-viz backend + +Python backend for the MRD Viz VS Code extension. + +This package provides the `mrd-viz` command used by the extension to inspect MRD files and return preview metadata. \ No newline at end of file diff --git a/mrd-viz/backend/packaging/entry.py b/mrd-viz/backend/packaging/entry.py new file mode 100644 index 00000000..708cd7ba --- /dev/null +++ b/mrd-viz/backend/packaging/entry.py @@ -0,0 +1,10 @@ +"""PyInstaller entry point for the standalone mrd-viz backend binary. + +Building this into a single self-contained executable lets the VS Code extension +ship a backend that needs no Python or PyPI on the user's machine. +""" + +from mrd_viz.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mrd-viz/backend/packaging/mrd-viz.spec b/mrd-viz/backend/packaging/mrd-viz.spec new file mode 100644 index 00000000..fa621da8 --- /dev/null +++ b/mrd-viz/backend/packaging/mrd-viz.spec @@ -0,0 +1,62 @@ +# PyInstaller spec for the standalone mrd-viz backend binary. +# +# Build (from mrd-viz/backend): +# python -m pip install ".[package]" +# pyinstaller --clean --noconfirm packaging/mrd-viz.spec +# +# Produces a one-dir bundle at dist/mrd-viz/ (dist/mrd-viz/mrd-viz[.exe] plus an +# _internal/ folder of libraries). A one-DIR (not one-file) build is deliberate: +# one-file extracts python3xx.dll to a temp dir at runtime, which Windows +# Application Control blocks on managed machines. Keeping the DLLs next to the +# executable runs without code signing. The extension's resolver looks for the +# executable at media/backend/mrd-viz[.exe] inside the VSIX. +# +# NOTE: numpy and pillow are covered by PyInstaller's bundled hooks. mrd-python +# (imported as `mrd`) is collected explicitly below; if a submodule is missed at +# runtime, add it to `hiddenimports`. + +from PyInstaller.utils.hooks import collect_submodules + +hidden_imports = collect_submodules("mrd") + collect_submodules("mrd_viz") + +analysis = Analysis( + ["entry.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=hidden_imports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(analysis.pure) + +exe = EXE( + pyz, + analysis.scripts, + [], + exclude_binaries=True, + name="mrd-viz", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + analysis.binaries, + analysis.datas, + strip=False, + upx=False, + upx_exclude=[], + name="mrd-viz", +) diff --git a/mrd-viz/backend/pyproject.toml b/mrd-viz/backend/pyproject.toml new file mode 100644 index 00000000..2a8d951e --- /dev/null +++ b/mrd-viz/backend/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "mrd-viz" +version = "0.1.0" +description = "MRD inspection and preview tooling" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "mrd-python==2.2.1", + "numpy>=2.2.6", + "pillow>=11.1.0", +] + +[project.optional-dependencies] +plots = [ + "matplotlib>=3.10.6", +] +test = [ + "pytest>=8.3", +] +package = [ + "pyinstaller>=6.10", +] + +[project.scripts] +mrd-viz = "mrd_viz.cli:main" + +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/mrd-viz/backend/src/mrd_viz/__init__.py b/mrd-viz/backend/src/mrd_viz/__init__.py new file mode 100644 index 00000000..5d7bcb4d --- /dev/null +++ b/mrd-viz/backend/src/mrd_viz/__init__.py @@ -0,0 +1,16 @@ +"""Python backend for the MRD Viz extension.""" + +from .html_harness import write_mosaic_html +from .main import DEFAULT_OPTIONS, PAYLOAD_SCHEMA_VERSION, DisplayMode, MrdFileClass, PreviewOptions, classify_file, extract_image, open_file + +__all__ = [ + "DEFAULT_OPTIONS", + "DisplayMode", + "MrdFileClass", + "PAYLOAD_SCHEMA_VERSION", + "PreviewOptions", + "classify_file", + "extract_image", + "open_file", + "write_mosaic_html", +] diff --git a/mrd-viz/backend/src/mrd_viz/cli.py b/mrd-viz/backend/src/mrd_viz/cli.py new file mode 100644 index 00000000..aab08ce5 --- /dev/null +++ b/mrd-viz/backend/src/mrd_viz/cli.py @@ -0,0 +1,146 @@ +"""CLI entry point for the MRD Viz backend contract.""" + +from __future__ import annotations + +import argparse +import json +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +from .html_harness import write_mosaic_html +from .main import DEFAULT_OPTIONS, PreviewOptions, classify_file, extract_image, open_file + + +def _package_version() -> str: + try: + return version("mrd-viz") + except PackageNotFoundError: # running from a source tree without install metadata + return "unknown" + + +# _coords_from_pairs builds a dense tuple spanning up to the largest supplied axis, so an +# out-of-range AXIS (e.g. --slice 1000000000:0) would attempt an enormous allocation before +# any JSON error handling runs. MRD arrays are low-dimensional; cap AXIS well above that. +_MAX_SLICE_AXIS = 31 + + +def _slice_pair(value: str) -> tuple[int, int]: + axis_str, sep, index_str = value.partition(":") + if not sep: + raise argparse.ArgumentTypeError(f"--slice expects AXIS:INDEX, got {value!r}") + try: + axis = int(axis_str) + index = int(index_str) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"--slice expects integer AXIS:INDEX, got {value!r}") from exc + if axis < 0 or index < 0: + raise argparse.ArgumentTypeError(f"--slice AXIS and INDEX must be non-negative, got {value!r}") + if axis > _MAX_SLICE_AXIS: + raise argparse.ArgumentTypeError(f"--slice AXIS must be <= {_MAX_SLICE_AXIS}, got {value!r}") + return axis, index + + +def _coords_from_pairs(pairs: list[tuple[int, int]] | None) -> tuple[int, ...]: + if not pairs: + return () + mapping = dict(pairs) + return tuple(mapping.get(axis, 0) for axis in range(max(mapping) + 1)) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mrd-viz") + parser.add_argument("--version", action="version", version=f"mrd-viz {_package_version()}") + subparsers = parser.add_subparsers(dest="command", required=True) + + classify_parser = subparsers.add_parser("classify", help="Return the stream classification for an MRD file") + classify_parser.add_argument("path", type=Path) + + open_parser = subparsers.add_parser("open", aliases=["inspect"], help="Return the open-file payload") + open_parser.add_argument("path", type=Path) + open_parser.add_argument("--max-thumbnails", type=int, default=DEFAULT_OPTIONS.max_thumbnails) + open_parser.add_argument("--thumbnail-size", type=int, default=DEFAULT_OPTIONS.thumbnail_size) + open_parser.add_argument( + "--explode-slices", + dest="explode_slices", + action="store_true", + help="Emit one mosaic thumbnail per z slice instead of one per image", + ) + + image_parser = subparsers.add_parser("image", help="Return one full-resolution image payload by mosaic image index") + image_parser.add_argument("path", type=Path) + image_parser.add_argument("--index", type=int, required=True) + image_parser.add_argument( + "--slice", + dest="slice", + action="append", + type=_slice_pair, + metavar="AXIS:INDEX", + help="Select a leading-axis slice index (repeatable, e.g. --slice 0:2 --slice 1:5)", + ) + + html_parser = subparsers.add_parser("html", help="Write a static HTML mosaic harness for one MRD file") + html_parser.add_argument("path", type=Path) + html_parser.add_argument("--output", type=Path, required=True) + html_parser.add_argument("--max-thumbnails", type=int, default=DEFAULT_OPTIONS.max_thumbnails) + html_parser.add_argument("--thumbnail-size", type=int, default=DEFAULT_OPTIONS.thumbnail_size) + html_parser.add_argument("--preload-full-images", type=int, default=DEFAULT_OPTIONS.preload_full_images) + + return parser + + +def _emit_payload(payload: dict) -> int: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if payload.get("ok", False) else 1 + + +def _run_classify(path: Path) -> int: + return _emit_payload(classify_file(path)) + + +def _run_inspect(path: Path, max_thumbnails: int, thumbnail_size: int, explode_slices: bool) -> int: + options = PreviewOptions( + max_thumbnails=max_thumbnails, + thumbnail_size=thumbnail_size, + explode_slices=explode_slices, + ) + return _emit_payload(open_file(path, options)) + + +def _run_image(path: Path, index: int, slice_pairs: list[tuple[int, int]] | None) -> int: + return _emit_payload(extract_image(path, index, _coords_from_pairs(slice_pairs))) + + +def _run_html(path: Path, output: Path, max_thumbnails: int, thumbnail_size: int, preload_full_images: int) -> int: + preflight = open_file(path, PreviewOptions(max_thumbnails=0, thumbnail_size=thumbnail_size)) + if not preflight.get("ok", False): + return _emit_payload(preflight) + + written_path = write_mosaic_html( + path, + output, + max_thumbnails=max_thumbnails, + thumbnail_size=thumbnail_size, + preload_full_images=preload_full_images, + ) + return _emit_payload({"ok": True, "output": str(written_path)}) + + +def main(argv: list[str] | None = None) -> int: + """Run the mrd-viz command-line interface.""" + + parser = _build_parser() + args = parser.parse_args(argv) + if args.command == "classify": + return _run_classify(args.path) + if args.command in {"open", "inspect"}: + return _run_inspect(args.path, args.max_thumbnails, args.thumbnail_size, args.explode_slices) + if args.command == "image": + return _run_image(args.path, args.index, args.slice) + if args.command == "html": + return _run_html(args.path, args.output, args.max_thumbnails, args.thumbnail_size, args.preload_full_images) + + parser.error(f"Unknown command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/mrd-viz/backend/src/mrd_viz/html_harness.py b/mrd-viz/backend/src/mrd_viz/html_harness.py new file mode 100644 index 00000000..ca1929d7 --- /dev/null +++ b/mrd-viz/backend/src/mrd_viz/html_harness.py @@ -0,0 +1,307 @@ +"""Static HTML harness for iterating on the MRD Viz mosaic UI. + +This file is not referenced directly by the current VS Code extension code. It +is kept as a useful reference for the webview shape and as a standalone way to +exercise the CLI/backend contract while testing MRD files locally. +""" + +from __future__ import annotations + +import html +import json +from pathlib import Path +from typing import Any + +from .main import DEFAULT_OPTIONS, PreviewOptions, extract_image, open_file + + +def write_mosaic_html( + path: Path, + output_path: Path, + *, + max_thumbnails: int = DEFAULT_OPTIONS.max_thumbnails, + thumbnail_size: int = DEFAULT_OPTIONS.thumbnail_size, + preload_full_images: int = DEFAULT_OPTIONS.preload_full_images, +) -> Path: + """Write a standalone HTML mosaic harness for one MRD file.""" + + path = Path(path) + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + payload = open_file(path, PreviewOptions(max_thumbnails=max_thumbnails, thumbnail_size=thumbnail_size)) + full_images = _preload_full_images(path, payload, preload_full_images) + + output_path.write_text(_build_html(payload, full_images), encoding="utf-8") + return output_path + + +def _preload_full_images(path: Path, payload: dict[str, Any], count: int) -> dict[str, Any]: + if count <= 0 or not payload.get("ok"): + return {} + + tiles = payload.get("mosaic", {}).get("thumbnails", []) + full_images: dict[str, Any] = {} + for tile in tiles[:count]: + image_index = tile.get("image_index") + if image_index is None: + continue + full_images[str(image_index)] = extract_image(path, int(image_index)) + return full_images + + +def _build_html(payload: dict[str, Any], full_images: dict[str, Any]) -> str: + payload_json = json.dumps(payload, ensure_ascii=True).replace("<", "\\u003c") + full_images_json = json.dumps(full_images, ensure_ascii=True).replace("<", "\\u003c") + title = html.escape(str(payload.get("filename", "MRD Mosaic"))) + + return f""" + + + + + {title} - MRD Mosaic Harness + + + +
+

+
+
+
+
+

Mosaic

+
+
+ +
+ + + + + +""" \ No newline at end of file diff --git a/mrd-viz/backend/src/mrd_viz/main.py b/mrd-viz/backend/src/mrd_viz/main.py new file mode 100644 index 00000000..f9c6c045 --- /dev/null +++ b/mrd-viz/backend/src/mrd_viz/main.py @@ -0,0 +1,572 @@ +"""Backend contract for the MRD Viz CLI and VS Code extension. + +The extension calls this module through the ``mrd-viz`` CLI. The backend +classifies the stream, summarizes the file, builds thumbnail tiles for +reconstructed MRD image items, and returns one full-resolution image on demand. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace +from enum import StrEnum +from io import BytesIO +from pathlib import Path +from typing import Any, Sequence + +import mrd +import numpy as np +from PIL import Image + + +class MrdFileClass(StrEnum): + """High-level stream categories used by the viewer UI.""" + + RAW = "raw" + RECONSTRUCTED = "reconstructed" + MIXED = "mixed" + UNKNOWN = "unknown" + INVALID = "invalid" + + +class DisplayMode(StrEnum): + """Rendering mode the extension should use for a file.""" + + MOSAIC = "mosaic" + METADATA_ONLY = "metadata_only" + ERROR = "error" + + +@dataclass(slots=True) +class PreviewOptions: + """Controls for initial file payloads and static preview generation.""" + + max_thumbnails: int = 256 + thumbnail_size: int = 128 + max_acquisition_examples: int = 8 + preload_full_images: int = 1 + read_full_stream: bool = False + explode_slices: bool = False + + +DEFAULT_OPTIONS = PreviewOptions() +# Version of the local backend-to-extension CLI JSON contract, not the Python package or MRD file format. +PAYLOAD_SCHEMA_VERSION = 1 + + +def open_file(path: Path, options: PreviewOptions | None = None) -> dict[str, Any]: + """Return the payload for opening one MRD file. + + The payload is designed for the VS Code extension, but it is plain JSON so + the CLI can also be used as a regression and debugging surface. + """ + + options = options or DEFAULT_OPTIONS + path = Path(path) + if not path.exists(): + return _error_payload(path, f"File not found: {path}") + + try: + with mrd.BinaryMrdReader(str(path), skip_completed_check=not options.read_full_stream) as reader: + header = reader.read_header() + if header is None: + return _error_payload(path, "Missing MRD header") + + state = _new_state(path, header) + for stream_index, item in enumerate(reader.read_data()): + should_stop = _consume_stream_item(state, item, stream_index, options) + if should_stop and not options.read_full_stream: + state["stream"]["partial"] = True + break + + _finalize_state(state, options) + return state + except Exception as exc: + return _error_payload(path, str(exc)) + + +def extract_image(path: Path, image_index: int, slice_coords: Sequence[int] | None = None) -> dict[str, Any]: + """Return one full-resolution image payload for lazy tile expansion.""" + + path = Path(path) + if not path.exists(): + return _error_payload(path, f"File not found: {path}") + if image_index < 0: + return _error_payload(path, "Image index must be non-negative") + try: + seen_images = 0 + selected_image: dict[str, Any] | None = None + with mrd.BinaryMrdReader(str(path), skip_completed_check=True) as reader: + header = reader.read_header() + if header is None: + return _error_payload(path, "Missing MRD header") + + for stream_index, item in enumerate(reader.read_data()): + image = _image_value(item) + if image is None: + continue + if seen_images == image_index and selected_image is None: + selected_image = _image_payload( + image, + _stream_item_type_name(item), + stream_index, + seen_images, + thumbnail=False, + slice_coords=slice_coords, + ) + break + seen_images += 1 + + if selected_image is not None: + return { + "ok": True, + "path": str(path), + "image": selected_image, + } + except Exception as exc: + return _error_payload(path, str(exc)) + + return _error_payload(path, f"Image index {image_index} not found") + + +def classify_file(path: Path) -> dict[str, Any]: + """Return only the classification subset of the open-file payload.""" + + payload = open_file(path, replace(DEFAULT_OPTIONS, max_thumbnails=0, read_full_stream=True)) + return { + "ok": payload["ok"], + "path": payload["path"], + "file_class": payload["file_class"], + "file_class_reliable": payload["file_class_reliable"], + "display_mode": payload["display_mode"], + "item_counts": payload["stream"]["item_counts"], + "warnings": payload["warnings"], + "error": payload.get("error"), + } + + +def _new_state(path: Path, header: mrd.Header) -> dict[str, Any]: + return { + "ok": True, + "schema_version": PAYLOAD_SCHEMA_VERSION, + "path": str(path), + "filename": path.name, + "file_size_bytes": path.stat().st_size, + "file_class": MrdFileClass.UNKNOWN.value, + "file_class_reliable": True, + "display_mode": DisplayMode.METADATA_ONLY.value, + "summary": _header_summary(header), + "stream": { + "item_counts": {}, + "image_count": 0, + "acquisition_count": 0, + "waveform_count": 0, + "other_count": 0, + "partial": False, + }, + "mosaic": { + "tile_unit": "mrd_image_item", + "thumbnails": [], + "truncated": False, + }, + "metadata": { + "images": [], + "acquisitions": [], + "waveforms": [], + "other_items": [], + }, + "warnings": [], + } + + +def _consume_stream_item(state: dict[str, Any], item: Any, stream_index: int, options: PreviewOptions) -> bool: + item_type = _stream_item_type_name(item) + item_counts: dict[str, int] = state["stream"]["item_counts"] + item_counts[item_type] = int(item_counts.get(item_type, 0)) + 1 + + image = _image_value(item) + if image is not None: + image_index = state["stream"]["image_count"] + state["stream"]["image_count"] += 1 + state["metadata"]["images"].append(_image_metadata(image, item_type, stream_index, image_index)) + + thumbnails = state["mosaic"]["thumbnails"] + remaining = options.max_thumbnails - len(thumbnails) + if remaining <= 0: + state["mosaic"]["truncated"] = True + return True + + tiles, hit_limit = _image_mosaic_tiles(image, item_type, stream_index, image_index, options, remaining) + thumbnails.extend(tiles) + if hit_limit: + state["mosaic"]["truncated"] = True + return True + return False + + acquisition = _acquisition_value(item) + if acquisition is not None: + state["stream"]["acquisition_count"] += 1 + if len(state["metadata"]["acquisitions"]) < options.max_acquisition_examples: + state["metadata"]["acquisitions"].append(_acquisition_metadata(acquisition, stream_index)) + return False + + waveform = _waveform_value(item) + if waveform is not None: + state["stream"]["waveform_count"] += 1 + state["metadata"]["waveforms"].append({"stream_index": stream_index, "type": item_type}) + return False + + state["stream"]["other_count"] += 1 + state["metadata"]["other_items"].append({"stream_index": stream_index, "type": item_type}) + return False + + +def _finalize_state(state: dict[str, Any], options: PreviewOptions) -> None: + image_count = state["stream"]["image_count"] + acquisition_count = state["stream"]["acquisition_count"] + + if image_count and acquisition_count: + state["file_class"] = MrdFileClass.MIXED.value + state["display_mode"] = DisplayMode.MOSAIC.value + state["warnings"].append("Mixed MRD file: showing image mosaic and summarizing acquisitions.") + elif image_count: + state["file_class"] = MrdFileClass.RECONSTRUCTED.value + state["display_mode"] = DisplayMode.MOSAIC.value + elif acquisition_count: + state["file_class"] = MrdFileClass.RAW.value + state["display_mode"] = DisplayMode.METADATA_ONLY.value + state["warnings"].append("Raw-only MRD files are summarized but not visualized.") + else: + state["file_class"] = MrdFileClass.UNKNOWN.value + state["display_mode"] = DisplayMode.METADATA_ONLY.value + state["warnings"].append("No acquisition or image stream items were found.") + + if state["mosaic"]["truncated"] and options.max_thumbnails > 0: + if options.read_full_stream: + state["warnings"].append(f"Showing first {options.max_thumbnails} thumbnails; load individual images on demand.") + else: + state["file_class_reliable"] = False + state["warnings"].append( + f"Stopped reading after reaching the thumbnail limit of {options.max_thumbnails}; file_class and stream counts may be partial." + ) + + +def _image_payload( + image: mrd.Image, + item_type: str, + stream_index: int, + image_index: int, + *, + thumbnail: bool, + max_size: int = 192, + slice_coords: Sequence[int] | None = None, +) -> dict[str, Any]: + data = np.asarray(image.data) + payload = _image_metadata(image, item_type, stream_index, image_index) + slice_dims = payload["slice_dims"] + coords = _clamp_coords(slice_dims, slice_coords) + plane = _display_plane(data, coords) + png_base64, rendered_shape = _plane_to_png_base64(plane, thumbnail=thumbnail, max_size=max_size) + payload.update( + { + "png_base64": png_base64, + "rendered_shape": rendered_shape, + "thumbnail": thumbnail, + "renderable": True, + "render_error": None, + "source_plane": _source_plane(slice_dims, coords), + } + ) + return payload + + +def _image_tile( + image: mrd.Image, + item_type: str, + stream_index: int, + image_index: int, + options: PreviewOptions, + *, + slice_coords: Sequence[int] | None = None, + title: str | None = None, +) -> dict[str, Any]: + try: + payload = _image_payload( + image, + item_type, + stream_index, + image_index, + thumbnail=True, + max_size=options.thumbnail_size, + slice_coords=slice_coords, + ) + except Exception as exc: + payload = _image_metadata(image, item_type, stream_index, image_index) + payload.update( + { + "png_base64": None, + "rendered_shape": None, + "thumbnail": True, + "renderable": False, + "render_error": str(exc), + "source_plane": None, + } + ) + if title is not None: + payload["tile_title"] = title + return payload + + +def _image_mosaic_tiles( + image: mrd.Image, + item_type: str, + stream_index: int, + image_index: int, + options: PreviewOptions, + limit: int, +) -> tuple[list[dict[str, Any]], bool]: + """Return (tiles, hit_limit) for one image, exploding z slices when requested.""" + + if not options.explode_slices: + return [_image_tile(image, item_type, stream_index, image_index, options)], False + + slice_dims = _slice_dims(np.asarray(image.data).shape) + z_dim = slice_dims[-1] if slice_dims else None + if z_dim is None or int(z_dim["size"]) <= 1: + return [_image_tile(image, item_type, stream_index, image_index, options)], False + + z_axis = int(z_dim["axis"]) + z_size = int(z_dim["size"]) + tiles: list[dict[str, Any]] = [] + for z in range(z_size): + if len(tiles) >= limit: + return tiles, True + coords = [0] * len(slice_dims) + coords[z_axis] = z + tiles.append( + _image_tile( + image, + item_type, + stream_index, + image_index, + options, + slice_coords=coords, + title=f"Image {image_index} \u00b7 z {z}", + ) + ) + return tiles, False + + +def _slice_dims(shape: Sequence[int]) -> list[dict[str, Any]]: + """Describe the steppable leading axes (everything before the trailing y/x plane). + + MRD image data is canonically ``[channel, z, y, x]``; only 3D and 4D arrays + expose steppable axes. + """ + + dims = [int(d) for d in shape] + if len(dims) not in (3, 4): + return [] + leading = dims[:-2] + names = ["channel", "z"] if len(leading) == 2 else ["z"] + return [{"axis": i, "name": names[i], "size": size} for i, size in enumerate(leading)] + + +def _clamp_coords(slice_dims: Sequence[dict[str, Any]], coords: Sequence[int] | None) -> list[int]: + """Clamp requested slice indices to each axis, defaulting missing axes to 0.""" + + requested = list(coords or []) + clamped: list[int] = [] + for dim in slice_dims: + axis = int(dim["axis"]) + value = requested[axis] if axis < len(requested) else 0 + clamped.append(max(0, min(int(value), int(dim["size"]) - 1))) + return clamped + + +def _source_plane(slice_dims: Sequence[dict[str, Any]], coords: Sequence[int]) -> dict[str, int]: + plane: dict[str, int] = {} + for dim in slice_dims: + axis = int(dim["axis"]) + plane[str(dim["name"])] = int(coords[axis]) if axis < len(coords) else 0 + return plane + + +def _display_plane(data: np.ndarray, coords: Sequence[int] | None = None) -> np.ndarray: + if data.ndim not in (2, 3, 4): + raise ValueError(f"Unsupported image data dimensions: {list(data.shape)}") + leading = data.ndim - 2 + if leading == 0: + return data + requested = list(coords or []) + index: list[int] = [] + for axis in range(leading): + size = int(data.shape[axis]) + value = requested[axis] if axis < len(requested) else 0 + index.append(max(0, min(int(value), size - 1))) + return data[tuple(index)] + + +def _plane_to_png_base64(plane: np.ndarray, *, thumbnail: bool, max_size: int) -> tuple[str, list[int]]: + pixels = _normalize_to_uint8(plane) + image = Image.fromarray(pixels, mode="L") + if thumbnail: + resampling = getattr(Image, "Resampling", Image).LANCZOS + image.thumbnail((max_size, max_size), resampling) + buffer = BytesIO() + image.save(buffer, format="PNG") + return base64.b64encode(buffer.getvalue()).decode("ascii"), [image.height, image.width] + + +def _normalize_to_uint8(array: np.ndarray) -> np.ndarray: + values = np.asarray(array) + if values.size == 0: + raise ValueError("Cannot render empty image data") + if np.iscomplexobj(values): + values = np.abs(values) + values = np.nan_to_num(values.astype(np.float32), copy=False) + minimum = float(values.min()) + maximum = float(values.max()) + if maximum == minimum: + return np.zeros(values.shape, dtype=np.uint8) + scaled = (values - minimum) / (maximum - minimum) + return np.clip(scaled * 255.0, 0, 255).astype(np.uint8) + + +def _header_summary(header: mrd.Header) -> dict[str, Any]: + result: dict[str, Any] = {"encoding_count": len(getattr(header, "encoding", []) or [])} + if not header.encoding: + return result + + encoding = header.encoding[0] + result.update( + { + "encoded_matrix": _matrix(getattr(encoding, "encoded_space", None)), + "recon_matrix": _matrix(getattr(encoding, "recon_space", None)), + "encoded_fov_mm": _field_of_view(getattr(encoding, "encoded_space", None)), + "recon_fov_mm": _field_of_view(getattr(encoding, "recon_space", None)), + } + ) + return result + + +def _matrix(space: Any) -> list[int] | None: + matrix_size = getattr(space, "matrix_size", None) + if matrix_size is None: + return None + return [_safe_int(getattr(matrix_size, axis, None)) for axis in ("x", "y", "z")] + + +def _field_of_view(space: Any) -> list[float] | None: + field_of_view = getattr(space, "field_of_view_mm", None) + if field_of_view is None: + return None + return [_safe_float(getattr(field_of_view, axis, None)) for axis in ("x", "y", "z")] + + +def _image_metadata(image: mrd.Image, item_type: str, stream_index: int, image_index: int) -> dict[str, Any]: + data = np.asarray(image.data) + head = image.head + return { + "image_index": image_index, + "stream_index": stream_index, + "stream_item_type": item_type, + "data_shape": list(data.shape), + "slice_dims": _slice_dims(data.shape), + "dtype": str(data.dtype), + "head": { + "image_type": _safe_int(getattr(head, "image_type", None)), + "image_series_index": _safe_int(getattr(head, "image_series_index", None)), + "slice": _safe_int(getattr(head, "slice", None)), + "phase": _safe_int(getattr(head, "phase", None)), + "contrast": _safe_int(getattr(head, "contrast", None)), + "repetition": _safe_int(getattr(head, "repetition", None)), + "field_of_view": _safe_float_list(getattr(head, "field_of_view", None)), + }, + } + + +def _acquisition_metadata(acquisition: mrd.Acquisition, stream_index: int) -> dict[str, Any]: + head = acquisition.head + return { + "stream_index": stream_index, + "data_shape": list(np.asarray(acquisition.data).shape), + "dtype": str(np.asarray(acquisition.data).dtype), + "flags": _safe_int(getattr(head, "flags", None)), + "scan_counter": _safe_int(getattr(head, "scan_counter", None)), + "idx": { + "slice": _safe_int(getattr(head.idx, "slice", None)), + "phase": _safe_int(getattr(head.idx, "phase", None)), + "contrast": _safe_int(getattr(head.idx, "contrast", None)), + "repetition": _safe_int(getattr(head.idx, "repetition", None)), + "kspace_encode_step_1": _safe_int(getattr(head.idx, "kspace_encode_step_1", None)), + "kspace_encode_step_2": _safe_int(getattr(head.idx, "kspace_encode_step_2", None)), + }, + } + + +def _stream_item_type_name(item: Any) -> str: + return type(item).__name__.replace("StreamItem.", "") + + +def _image_value(item: Any) -> mrd.Image | None: + value = getattr(item, "value", None) + return value if isinstance(value, mrd.Image) else None + + +def _acquisition_value(item: Any) -> mrd.Acquisition | None: + value = getattr(item, "value", None) + return value if isinstance(value, mrd.Acquisition) else None + + +def _waveform_value(item: Any) -> mrd.Waveform | None: + value = getattr(item, "value", None) + return value if isinstance(value, mrd.Waveform) else None + + +def _safe_int(value: Any) -> int | None: + if value is None: + return None + raw_value = getattr(value, "value", value) + try: + return int(raw_value) + except (TypeError, ValueError): + return None + + +def _safe_float(value: Any) -> float | None: + if value is None: + return None + raw_value = getattr(value, "value", value) + try: + return float(raw_value) + except (TypeError, ValueError): + return None + + +def _safe_float_list(value: Any) -> list[float] | None: + if value is None: + return None + try: + return [float(item) for item in value] + except (TypeError, ValueError): + return None + + +def _error_payload(path: Path, message: str) -> dict[str, Any]: + return { + "ok": False, + "schema_version": PAYLOAD_SCHEMA_VERSION, + "path": str(path), + "filename": path.name, + "file_size_bytes": path.stat().st_size if path.exists() else None, + "file_class": MrdFileClass.INVALID.value, + "file_class_reliable": True, + "display_mode": DisplayMode.ERROR.value, + "summary": {}, + "stream": {"item_counts": {}, "image_count": 0, "acquisition_count": 0, "waveform_count": 0, "other_count": 0, "partial": False}, + "mosaic": {"tile_unit": "mrd_image_item", "thumbnails": [], "truncated": False}, + "metadata": {"images": [], "acquisitions": [], "waveforms": [], "other_items": []}, + "warnings": [], + "error": message, + } diff --git a/mrd-viz/backend/tests/__init__.py b/mrd-viz/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mrd-viz/backend/tests/conftest.py b/mrd-viz/backend/tests/conftest.py new file mode 100644 index 00000000..a6d78648 --- /dev/null +++ b/mrd-viz/backend/tests/conftest.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from mrd.tools.phantom import generate_cartesian_phantom +from mrd.tools.stream_recon import reconstruct_mrd_stream + + +@pytest.fixture(scope="session") +def generated_mrd_pair(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: + root = tmp_path_factory.mktemp("generated_mrds") + raw_path = root / "phantom_raw.mrd" + recon_path = root / "phantom_recon.mrd" + + generate_cartesian_phantom( + str(raw_path), + ncoils=4, + matrix_size=16, + repetitions=2, + oversampling=2, + noise_level=0.0, + ) + with raw_path.open("rb") as input_stream, recon_path.open("wb") as output_stream: + reconstruct_mrd_stream(input_stream, output_stream) + + return raw_path, recon_path \ No newline at end of file diff --git a/mrd-viz/backend/tests/helpers.py b/mrd-viz/backend/tests/helpers.py new file mode 100644 index 00000000..74b93bb5 --- /dev/null +++ b/mrd-viz/backend/tests/helpers.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path + +import mrd +import numpy as np + + +def write_image_mrd(path: Path, arrays: list[np.ndarray]) -> None: + with mrd.BinaryMrdWriter(str(path)) as writer: + writer.write_header(mrd.Header()) + writer.write_data([_image_item(array, image_index) for image_index, array in enumerate(arrays)]) + + +def write_images_then_acquisition_mrd(path: Path, arrays: list[np.ndarray]) -> None: + with mrd.BinaryMrdWriter(str(path)) as writer: + writer.write_header(mrd.Header()) + writer.write_data([*_image_items(arrays), _acquisition_item()]) + + +def write_header_only_mrd(path: Path) -> None: + with mrd.BinaryMrdWriter(str(path)) as writer: + writer.write_header(mrd.Header()) + writer.write_data([]) + + +def _image_items(arrays: list[np.ndarray]) -> list[mrd.StreamItem.ImageFloat]: + return [_image_item(array, image_index) for image_index, array in enumerate(arrays)] + + +def _image_item(array: np.ndarray, image_index: int) -> mrd.StreamItem.ImageFloat: + data = np.asarray(array, dtype=np.float32) + head = mrd.ImageHeader(image_type=mrd.ImageType.MAGNITUDE) + head.image_index = image_index + head.field_of_view[:] = [float(data.shape[-1]), float(data.shape[-2]), 1.0] + image = mrd.Image[np.float32](head=head, data=data) + return mrd.StreamItem.ImageFloat(image) + + +def _acquisition_item() -> mrd.StreamItem.Acquisition: + head = mrd.AcquisitionHeader() + head.scan_counter = 1 + head.channel_order = [0] + head.center_sample = 2 + head.idx.kspace_encode_step_1 = 0 + head.idx.slice = 0 + data = np.zeros((1, 4), dtype=np.complex64) + return mrd.StreamItem.Acquisition(mrd.Acquisition(head=head, data=data)) diff --git a/mrd-viz/backend/tests/test_arrays.py b/mrd-viz/backend/tests/test_arrays.py new file mode 100644 index 00000000..e1e2a48f --- /dev/null +++ b/mrd-viz/backend/tests/test_arrays.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +# Synthetic array tests are the fastest guardrail for rendering semantics. They +# keep normalization, representative-plane selection, and unsupported-shape +# behavior pinned without paying the cost or fragility of writing MRD files. + +import mrd +import numpy as np +import pytest + +from mrd_viz.main import ( + PreviewOptions, + _display_plane, + _image_mosaic_tiles, + _image_tile, + _normalize_to_uint8, + _plane_to_png_base64, + _slice_dims, +) + + +def test_display_plane_selects_representative_plane() -> None: + assert _display_plane(np.ones((3, 4))).shape == (3, 4) + assert _display_plane(np.ones((2, 3, 4))).shape == (3, 4) + assert _display_plane(np.ones((5, 2, 3, 4))).shape == (3, 4) + + +def test_display_plane_honors_and_clamps_slice_coords() -> None: + data = np.arange(2 * 3 * 2 * 2, dtype=np.float32).reshape(2, 3, 2, 2) + assert np.array_equal(_display_plane(data, [1, 2]), data[1, 2]) + # Missing trailing coords default to axis 0. + assert np.array_equal(_display_plane(data, [1]), data[1, 0]) + # Out-of-range coords clamp to the last valid index on each axis. + assert np.array_equal(_display_plane(data, [9, 9]), data[1, 2]) + # 3D data exposes a single leading (z) axis. + data3 = np.arange(3 * 2 * 2, dtype=np.float32).reshape(3, 2, 2) + assert np.array_equal(_display_plane(data3, [2]), data3[2]) + + +def test_slice_dims_describes_leading_axes() -> None: + assert _slice_dims((3, 4)) == [] + assert _slice_dims((5, 3, 4)) == [{"axis": 0, "name": "z", "size": 5}] + assert _slice_dims((2, 5, 3, 4)) == [ + {"axis": 0, "name": "channel", "size": 2}, + {"axis": 1, "name": "z", "size": 5}, + ] + # Shapes outside the canonical 3D/4D image layout expose no steppable axes. + assert _slice_dims((1, 1, 1, 3, 4)) == [] + + +def test_normalize_handles_constant_complex_and_range() -> None: + assert np.all(_normalize_to_uint8(np.ones((2, 2), dtype=np.float32)) == 0) + + complex_pixels = _normalize_to_uint8(np.array([[0 + 0j, 3 + 4j]], dtype=np.complex64)) + assert complex_pixels.tolist() == [[0, 255]] + + ranged_pixels = _normalize_to_uint8(np.array([[-1.0, 1.0]], dtype=np.float32)) + assert ranged_pixels.tolist() == [[0, 255]] + + +def test_png_thumbnail_is_bounded() -> None: + payload, shape = _plane_to_png_base64(np.arange(64, dtype=np.float32).reshape(8, 8), thumbnail=True, max_size=4) + + assert payload.startswith("iVBOR") + assert shape == [4, 4] + + +def test_unsupported_image_shape_becomes_nonrenderable_tile() -> None: + image = mrd.Image[np.float32]( + head=mrd.ImageHeader(image_type=mrd.ImageType.MAGNITUDE), + data=np.zeros((1, 1, 1, 2, 2), dtype=np.float32), + ) + + tile = _image_tile(image, "ImageFloat", stream_index=0, image_index=0, options=PreviewOptions(max_thumbnails=1)) + + assert tile["renderable"] is False + assert tile["png_base64"] is None + assert "Unsupported image data dimensions" in tile["render_error"] + + +def test_image_tile_renders_requested_slice() -> None: + data = np.zeros((1, 2, 2, 2), dtype=np.float32) + data[0, 1] = np.array([[0.0, 1.0], [2.0, 3.0]], dtype=np.float32) + image = mrd.Image[np.float32]( + head=mrd.ImageHeader(image_type=mrd.ImageType.MAGNITUDE), + data=data, + ) + + tile = _image_tile( + image, + "ImageFloat", + stream_index=0, + image_index=0, + options=PreviewOptions(max_thumbnails=1), + slice_coords=(0, 1), + ) + + assert tile["renderable"] is True + assert tile["source_plane"] == {"channel": 0, "z": 1} + assert tile["slice_dims"] == [ + {"axis": 0, "name": "channel", "size": 1}, + {"axis": 1, "name": "z", "size": 2}, + ] + + +def test_image_mosaic_tiles_explodes_z_slices() -> None: + data = np.arange(1 * 3 * 2 * 2, dtype=np.float32).reshape(1, 3, 2, 2) + image = mrd.Image[np.float32]( + head=mrd.ImageHeader(image_type=mrd.ImageType.MAGNITUDE), + data=data, + ) + + # Without explode mode a single volume yields one tile. + single, hit_limit = _image_mosaic_tiles(image, "ImageFloat", 0, 4, PreviewOptions(), limit=16) + assert len(single) == 1 + assert hit_limit is False + assert "tile_title" not in single[0] + + # Explode mode yields one tile per z slice, labeled and tagged with source_plane. + exploded, hit_limit = _image_mosaic_tiles(image, "ImageFloat", 0, 4, PreviewOptions(explode_slices=True), limit=16) + assert len(exploded) == 3 + assert hit_limit is False + assert [tile["source_plane"]["z"] for tile in exploded] == [0, 1, 2] + assert exploded[2]["tile_title"] == "Image 4 \u00b7 z 2" + + # The per-image limit truncates the exploded tiles. + limited, hit_limit = _image_mosaic_tiles(image, "ImageFloat", 0, 4, PreviewOptions(explode_slices=True), limit=2) + assert len(limited) == 2 + assert hit_limit is True + + +def test_display_plane_rejects_empty_or_unsupported_arrays() -> None: + with pytest.raises(ValueError, match="Unsupported image data dimensions"): + _display_plane(np.ones((1, 1, 1, 1, 1))) + + with pytest.raises(ValueError, match="Cannot render empty image data"): + _normalize_to_uint8(np.array([], dtype=np.float32)) \ No newline at end of file diff --git a/mrd-viz/backend/tests/test_cli.py b/mrd-viz/backend/tests/test_cli.py new file mode 100644 index 00000000..ff5a25b5 --- /dev/null +++ b/mrd-viz/backend/tests/test_cli.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +# CLI contract tests run the backend the same way the VS Code extension will: +# as a short-lived subprocess whose stdout is JSON. They verify both successful +# payloads and clean expected errors, including the new exit-code behavior. + +import json +import os +import subprocess +import sys +from pathlib import Path + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = BACKEND_ROOT / "src" + + +def run_cli(*args: object) -> tuple[subprocess.CompletedProcess[str], dict]: + env = os.environ.copy() + env["PYTHONPATH"] = str(SRC_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, "-m", "mrd_viz.cli", *[str(arg) for arg in args]], + cwd=BACKEND_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + return result, json.loads(result.stdout) + + +def test_cli_open_classify_image_and_inspect_emit_json(generated_mrd_pair: tuple[Path, Path]) -> None: + raw_path, recon_path = generated_mrd_pair + + open_result, open_payload = run_cli("open", recon_path, "--max-thumbnails", 1) + classify_result, classify_payload = run_cli("classify", raw_path) + image_result, image_payload = run_cli("image", recon_path, "--index", 0) + inspect_result, inspect_payload = run_cli("inspect", recon_path, "--max-thumbnails", 1) + + assert open_result.returncode == 0 + assert open_payload["file_class"] == "reconstructed" + assert open_payload["file_class_reliable"] is False + assert open_payload["stream"]["partial"] is True + assert classify_result.returncode == 0 + assert classify_payload["file_class"] == "raw" + assert classify_payload["file_class_reliable"] is True + assert image_result.returncode == 0 + assert image_payload["image"]["renderable"] is True + assert inspect_result.returncode == 0 + assert inspect_payload["schema_version"] == open_payload["schema_version"] + assert inspect_payload["file_class_reliable"] is False + + +def test_cli_html_writes_output_for_valid_mrd(generated_mrd_pair: tuple[Path, Path], tmp_path: Path) -> None: + _, recon_path = generated_mrd_pair + output_path = tmp_path / "preview.html" + + result, payload = run_cli("html", recon_path, "--output", output_path, "--max-thumbnails", 1) + + assert result.returncode == 0 + assert payload == {"ok": True, "output": str(output_path)} + assert output_path.exists() + + +def test_cli_clean_file_errors_exit_one(tmp_path: Path) -> None: + missing_path = tmp_path / "missing.mrd" + output_path = tmp_path / "missing.html" + + for args in [("open", missing_path), ("classify", missing_path), ("html", missing_path, "--output", output_path)]: + result, payload = run_cli(*args) + assert result.returncode == 1 + assert payload["ok"] is False + assert payload["file_class"] == "invalid" + assert payload["file_class_reliable"] is True + assert "File not found" in payload["error"] + + +def test_cli_clean_image_error_exits_one(generated_mrd_pair: tuple[Path, Path]) -> None: + _, recon_path = generated_mrd_pair + + result, payload = run_cli("image", recon_path, "--index", 99) + + assert result.returncode == 1 + assert payload["ok"] is False + assert "not found" in payload["error"] + + +def test_cli_image_accepts_slice_options(generated_mrd_pair: tuple[Path, Path]) -> None: + _, recon_path = generated_mrd_pair + + result, payload = run_cli("image", recon_path, "--index", 0, "--slice", "0:0", "--slice", "1:0") + + assert result.returncode == 0 + assert payload["image"]["renderable"] is True + assert "slice_dims" in payload["image"] + assert isinstance(payload["image"]["source_plane"], dict) + + +def test_cli_image_rejects_malformed_slice(generated_mrd_pair: tuple[Path, Path]) -> None: + _, recon_path = generated_mrd_pair + + env = os.environ.copy() + env["PYTHONPATH"] = str(SRC_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + result = subprocess.run( + [sys.executable, "-m", "mrd_viz.cli", "image", str(recon_path), "--index", "0", "--slice", "bogus"], + cwd=BACKEND_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 2 + assert "AXIS:INDEX" in result.stderr + + +def test_cli_open_explode_slices_emits_tiles(generated_mrd_pair: tuple[Path, Path]) -> None: + _, recon_path = generated_mrd_pair + + default_result, default_payload = run_cli("open", recon_path, "--max-thumbnails", 64) + exploded_result, exploded_payload = run_cli("open", recon_path, "--max-thumbnails", 64, "--explode-slices") + + assert default_result.returncode == 0 + assert exploded_result.returncode == 0 + default_tiles = default_payload["mosaic"]["thumbnails"] + exploded_tiles = exploded_payload["mosaic"]["thumbnails"] + assert len(exploded_tiles) >= len(default_tiles) + diff --git a/mrd-viz/backend/tests/test_custom_mrds.py b/mrd-viz/backend/tests/test_custom_mrds.py new file mode 100644 index 00000000..fb0be3da --- /dev/null +++ b/mrd-viz/backend/tests/test_custom_mrds.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +# Custom MRD tests fill only the gaps the generator does not expose directly. +# They stay intentionally small: a multi-plane image, thumbnail truncation, and +# a header-only file are enough to exercise the backend's edge classifications. + +from pathlib import Path + +import numpy as np + +from mrd_viz.main import PreviewOptions, open_file + +from .helpers import write_header_only_mrd, write_image_mrd, write_images_then_acquisition_mrd + + +def test_custom_multiplane_image_reports_shape_and_renders_first_plane(tmp_path: Path) -> None: + path = tmp_path / "multiplane.mrd" + write_image_mrd(path, [np.arange(2 * 3 * 5 * 6, dtype=np.float32).reshape(2, 3, 5, 6)]) + + payload = open_file(path, PreviewOptions(max_thumbnails=1, thumbnail_size=4, read_full_stream=True)) + tile = payload["mosaic"]["thumbnails"][0] + + assert payload["file_class"] == "reconstructed" + assert tile["renderable"] is True + assert tile["data_shape"] == [2, 3, 5, 6] + assert tile["source_plane"] == {"channel": 0, "z": 0} + assert max(tile["rendered_shape"]) == 4 + + +def test_custom_image_thumbnail_limit_marks_truncation(tmp_path: Path) -> None: + path = tmp_path / "two_images.mrd" + write_image_mrd(path, [np.ones((1, 1, 4, 4)), np.ones((1, 1, 4, 4)) * 2]) + + payload = open_file(path, PreviewOptions(max_thumbnails=1, read_full_stream=True)) + + assert payload["stream"]["image_count"] == 2 + assert len(payload["mosaic"]["thumbnails"]) == 1 + assert payload["mosaic"]["truncated"] is True + assert payload["stream"]["partial"] is False + assert payload["file_class_reliable"] is True + + +def test_default_partial_read_marks_file_class_unreliable(tmp_path: Path) -> None: + path = tmp_path / "images_then_acquisition.mrd" + write_images_then_acquisition_mrd(path, [np.ones((1, 1, 4, 4)), np.ones((1, 1, 4, 4)) * 2]) + + payload = open_file(path, PreviewOptions(max_thumbnails=1)) + + full_payload = open_file(path, PreviewOptions(max_thumbnails=1, read_full_stream=True)) + + assert payload["file_class"] == "reconstructed" + assert payload["file_class_reliable"] is False + assert payload["stream"]["partial"] is True + assert payload["stream"]["acquisition_count"] == 0 + assert "file_class and stream counts may be partial" in payload["warnings"][-1] + assert full_payload["file_class"] == "mixed" + assert full_payload["file_class_reliable"] is True + assert full_payload["stream"]["acquisition_count"] == 1 + + +def test_custom_header_only_file_is_unknown(tmp_path: Path) -> None: + path = tmp_path / "header_only.mrd" + write_header_only_mrd(path) + + payload = open_file(path, PreviewOptions(read_full_stream=True)) + + assert payload["ok"] is True + assert payload["file_class"] == "unknown" + assert payload["display_mode"] == "metadata_only" + assert payload["warnings"] == ["No acquisition or image stream items were found."] \ No newline at end of file diff --git a/mrd-viz/backend/tests/test_generated_mrds.py b/mrd-viz/backend/tests/test_generated_mrds.py new file mode 100644 index 00000000..74eee0a1 --- /dev/null +++ b/mrd-viz/backend/tests/test_generated_mrds.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +# Generated MRD tests use the real phantom and stream reconstruction tools to +# cover realistic raw and reconstructed files. This gives broad end-to-end +# confidence while keeping fixtures reproducible and small enough for CI. + +from pathlib import Path + +from mrd.tools.phantom import generate_cartesian_phantom + +from mrd_viz.main import PreviewOptions, open_file + + +def test_generated_raw_phantom_is_metadata_only(generated_mrd_pair: tuple[Path, Path]) -> None: + raw_path, _ = generated_mrd_pair + + payload = open_file(raw_path, PreviewOptions(read_full_stream=True)) + + assert payload["ok"] is True + assert payload["file_class"] == "raw" + assert payload["display_mode"] == "metadata_only" + assert payload["stream"]["acquisition_count"] > 0 + assert payload["stream"]["image_count"] == 0 + assert payload["metadata"]["acquisitions"][0]["data_shape"] == [4, 32] + assert payload["warnings"] == ["Raw-only MRD files are summarized but not visualized."] + + +def test_generated_recon_phantom_renders_mosaic(generated_mrd_pair: tuple[Path, Path]) -> None: + _, recon_path = generated_mrd_pair + + payload = open_file(recon_path, PreviewOptions(max_thumbnails=4, thumbnail_size=8, read_full_stream=True)) + + first_tile = payload["mosaic"]["thumbnails"][0] + + assert payload["ok"] is True + assert payload["file_class"] == "reconstructed" + assert payload["display_mode"] == "mosaic" + assert payload["stream"]["image_count"] == 2 + assert first_tile["renderable"] is True + assert first_tile["data_shape"] == [1, 1, 16, 16] + assert first_tile["rendered_shape"] == [8, 8] + + +def test_generated_featureful_raw_phantom_summarizes_multicoil_acquisitions(tmp_path: Path) -> None: + raw_path = tmp_path / "featureful_raw.mrd" + generate_cartesian_phantom( + str(raw_path), + ncoils=3, + matrix_size=12, + repetitions=2, + acceleration=2, + oversampling=2, + calibration_width=4, + noise_calibration=True, + store_coordinates=True, + noise_level=0.0, + ) + + payload = open_file(raw_path, PreviewOptions(read_full_stream=True, max_acquisition_examples=4)) + + assert payload["ok"] is True + assert payload["file_class"] == "raw" + assert payload["summary"]["encoded_matrix"] == [24, 12, 1] + assert payload["stream"]["acquisition_count"] > 32 + assert payload["metadata"]["acquisitions"][0]["data_shape"] == [3, 24] \ No newline at end of file diff --git a/mrd-viz/docs/D3_BUNDLED_BACKEND.md b/mrd-viz/docs/D3_BUNDLED_BACKEND.md new file mode 100644 index 00000000..6f890ee7 --- /dev/null +++ b/mrd-viz/docs/D3_BUNDLED_BACKEND.md @@ -0,0 +1,28 @@ +# D3 Bundled Backend Verification + +D3 packages the Python backend as a PyInstaller one-dir bundle inside each platform VSIX. The implementation exists; this scaffold defines the artifact contract that release automation and onboarding can rely on. + +## Artifact contract + +Each staged `extension/mrd-viz/media/backend/` directory must contain: + +- `mrd-viz` (`mrd-viz.exe` on Windows); +- a non-empty `_internal/` PyInstaller runtime; +- an executable that returns `mrd-viz ` for `--version`; +- `backend-manifest.json`, generated from the verified artifact with target, version, size, and SHA-256. + +Verify a staged bundle from `mrd-viz/`: + +```bash +just verify-bundled-backend linux-x64 +``` + +Run the release-tool contract tests: + +```bash +just test-release-tools +``` + +## Next wiring + +The release matrix should invoke the verifier after staging and before `vsce package`. Because the manifest is written into `media/backend/`, it is included in the VSIX and can later support diagnostics, provenance display, and researcher bug reports without probing platform-specific file metadata. diff --git a/mrd-viz/docs/DEVCONTAINER.md b/mrd-viz/docs/DEVCONTAINER.md new file mode 100644 index 00000000..37943c41 --- /dev/null +++ b/mrd-viz/docs/DEVCONTAINER.md @@ -0,0 +1,85 @@ +# MRD Viz Dev Container (Scenario B) + +Run MRD Viz inside a reproducible dev container: Python 3.12 + the `mrd_viz` backend + the MRD Viz extension + Azure tooling (`az` / `azcopy`), all wired together. This is the recommended way for the research team to use MRD Viz against `.mrd` data stored in Azure. + +## Prerequisites + +- Docker +- The **Dev Containers** VS Code extension (`ms-vscode-remote.remote-containers`) + +## 1. Open in the container + +1. Open the `mrd` repo in VS Code. +2. Command Palette → **Dev Containers: Reopen in Container** → pick **"MRD Viz extension"**. + - The other option, **"mrd"**, is the full-repo toolchain container (conda + MATLAB + C++) and is not needed just to view `.mrd` files. +3. Wait for the build. `postCreate` installs the `just` and `azcopy` CLIs, then best-effort provisions the backend virtualenv at `~/.venvs/mrd-viz` (needs PyPI access; the banner it prints says whether it succeeded). + +## 2. One-time setup + +`postCreate` already provisions the backend virtualenv at `~/.venvs/mrd-viz` and `mrdViz.backendPath` points at it, so opening a `.mrd` file works out of the box. To build and install the **MRD Viz extension** itself — and to (re)run the backend install if the automatic step was skipped or failed on a restricted network — run from the container's integrated terminal: + +```bash +just mrd-viz-container-setup +``` + +This (re)creates the backend virtualenv, installs the `mrd_viz` backend into it, builds the extension `.vsix`, and installs it in this window. Reload the window if the extension does not activate immediately. + +> If the `postCreate` banner reported that automatic backend setup did **not** complete (e.g. a blocked network), running `just mrd-viz-container-setup` is required — see [Restricted / corporate networks](#restricted--corporate-networks) below. + +## 3. Pull `.mrd` data from Azure + +Authenticate and copy files into the `mrd-viz/data/` directory (git-ignored): + +```bash +az login # or use a SAS URL +azcopy copy "" /workspaces/mrd/mrd-viz/data --recursive +``` + +## 4. Open a file + +Double-click any `.mrd` file, or run **MRD Viz: Open File** from the Command Palette. + +## Restricted / corporate networks + +Some corporate networks block direct access to the **public npm registry** (`registry.npmjs.org`) **and the public PyPI** (`pypi.org` / `files.pythonhosted.org`), requiring an internal mirror instead. Symptoms: the container build, `postCreate`, or `just mrd-viz-container-setup` fails with an `npm` **TLS handshake failure**, or `pip` fails with `SSL: SSLV3_ALERT_HANDSHAKE_FAILURE` while resolving packages. + +### Automatic handling (default) + +Both blockers are handled for you by [`.devcontainer/mrd-viz/select-pkg-index.sh`](../../.devcontainer/mrd-viz/select-pkg-index.sh), which `postCreate` and `just mrd-viz-container-setup` source before any `pip`/`npm` call. It walks an ordered **candidate ladder** — the Microsoft-internal mirror first, the public registry as fallback — probes each, and exports `PIP_INDEX_URL` / `npm_config_registry` to the first that responds: + +- **Microsoft-internal devs** (on the corp network) get the internal mirror automatically. +- **External contributors** (internal mirror unreachable) fall back to the public registry automatically. + +The candidate lists live in [`.devcontainer/mrd-viz/devcontainer.json`](../../.devcontainer/mrd-viz/devcontainer.json) under `remoteEnv`, so the internal feed is discoverable in a committed file rather than a hidden dotfile: + +```jsonc +"remoteEnv": { + "MRD_PIP_INDEX_URLS": "https://packagefeedproxy.microsoft.io/pypi/simple/ https://pypi.org/simple/", + "MRD_NPM_REGISTRIES": "https://packagefeedproxy.microsoft.io/npm/ https://registry.npmjs.org/" +} +``` + +### Manual override + +If probing picks the wrong feed, or you run the setup outside the container, force a feed by exporting the vars pip and npm read natively, then re-run `just mrd-viz-container-setup`: + +```bash +export PIP_INDEX_URL="https://packagefeedproxy.microsoft.io/pypi/simple/" +export npm_config_registry="https://packagefeedproxy.microsoft.io/npm/" +``` + +The setup scripts print these exact `export` lines if no candidate is reachable. An explicit `PIP_INDEX_URL` / `npm_config_registry` always wins over the ladder. Find your own feed URLs on the host with `pip config get global.index-url` and `npm config get registry`. If a feed requires authentication, add the appropriate credential (e.g. a git-ignored `~/.netrc`, or `_authToken` in a git-ignored user `~/.npmrc`) — never commit credentials. + +> A git-ignored project `.npmrc` (`mrd-viz/extension/mrd-viz/.npmrc` with `registry=…`) still works and takes precedence for npm, but is no longer required now that the ladder exports `npm_config_registry` automatically. + + +## Backend error: a host path or `spawn ... ENOENT` + +If opening a `.mrd` file fails and the **Running:** line in the error shows a **host path** (e.g. a Windows `...\.venv\Scripts\python.exe`) instead of `/home/vscode/.venvs/mrd-viz/bin/python`, a host interpreter path is leaking into the container. `mrdViz.backendPath` is **machine-scoped**, so it cannot be set from a committed workspace `.vscode/settings.json` and cannot leak across the host/container boundary — a leak today comes only from a legacy, deprecated `mrdViz.pythonPath` left in workspace settings. + +Fix: remove any `mrdViz.pythonPath` from the workspace `.vscode/settings.json`, then reload the window. Point the container at its interpreter with `mrdViz.backendPath` in the container's remote (or your **User**) settings instead; because it is machine-scoped it stays per-machine and never leaks. + +## Notes + +- The extension is **built from source** in the container (needs Node). Planned follow-up: attach a prebuilt `.vsix` to a GitHub Release so the container can install it without building. +- For manual/local (non-container) setup, see [PACKAGING_AND_INSTALL_RUNBOOK.md](PACKAGING_AND_INSTALL_RUNBOOK.md) and [OFFICIAL_EXT_DEV_RUNBOOK.md](OFFICIAL_EXT_DEV_RUNBOOK.md). diff --git a/mrd-viz/docs/MARKETPLACE_RELEASE_TODO.md b/mrd-viz/docs/MARKETPLACE_RELEASE_TODO.md new file mode 100644 index 00000000..143e76ef --- /dev/null +++ b/mrd-viz/docs/MARKETPLACE_RELEASE_TODO.md @@ -0,0 +1,153 @@ +# Marketplace Release TODO + +> **Handoff note (2026-08-14):** Release prep to date was done on the `mrd-viz-release` branch. +> The mechanical/manifest work is complete and validated locally; what remains is (a) creating the +> `ismrmrd` Marketplace publisher + credential, (b) adding the `VSCE_PAT` secret, and (c) the +> compliance sign-off in §5. Once the secret exists, publishing is fully automated on a tag push +> — see [§6 "How the Marketplace publish job works"](#6-how-the-marketplace-publish-job-works) +> for the exact activation steps. Owner sections marked _(Owner: Carter)_ need a new owner. + +Tracking checklist for publishing the **MRD Viz** VS Code extension to the VS Code Marketplace +(Channel B). Worked on the `mrd-viz-release` branch. + +Context: the repo already ships **Channel A** (GitHub Release of platform VSIXs via +[`mrd_viz_release.yml`](../../.github/workflows/mrd_viz_release.yml)). This checklist covers only +what is still missing to publish to the Marketplace. See the +[Extension Release Runbook](EXTENSION_RELEASE_RUNBOOK.md) for background and the compliance +questions. + +Legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[blocked]` waiting on someone else. + +--- + +## 1. Identity & credentials + +- [x] **Publisher name** — `ismrmrd` confirmed as the publisher string (already set in + [`package.json`](../extension/mrd-viz/package.json)). Owner decision resolved: publish under + the community `ismrmrd` identity. +- [~] **Create the Marketplace publisher** `ismrmrd` at marketplace.visualstudio.com, backed by + an Azure DevOps org tied to the chosen Microsoft Entra tenant. _(Owner: Carter)_ +- [~] **Generate a publish credential** — Azure DevOps PAT (Marketplace → Manage scope) or + federated/OIDC. _(Owner: Carter)_ +- [ ] **Store the credential** as a GitHub Actions secret (`VSCE_PAT`) once the repo/org is + decided. Confirm approved storage per compliance Q7 for a non-Microsoft-org repo. + +## 2. Manifest completeness + +- [ ] **Add an `icon`** (128×128 PNG) to the manifest and bundle it in the VSIX. + - [x] Drop source art at `mrd-viz/extension/mrd-viz/media/icon-src.png`. + - [x] Resize to exactly 128×128 → `media/icon.png`. + - [x] Add `"icon": "media/icon.png"` to `package.json`. +- [ ] **Verify README renders standalone** — [`README.md`](../extension/mrd-viz/README.md) becomes + the Marketplace detail page; ensure any image links are absolute. +- [x] **Confirm LICENSE is included** in the VSIX — copied the repo MIT license to + `extension/mrd-viz/LICENSE.txt`; confirmed present in the packaged VSIX via `vsce` file list. +- [ ] _(Optional)_ Add `keywords`, refine `categories` (currently `["Other"]`), and a + `galleryBanner` for discoverability. + +## 3. Workflow wiring + +- [x] **Add a `publish` job** after `release` in + [`mrd_viz_release.yml`](../../.github/workflows/mrd_viz_release.yml) that runs + `vsce publish --packagePath` on each **pre-built** platform VSIX (do not rebuild). One publish + call per `--target`. +- [x] Guard the job so it is a no-op until the `VSCE_PAT` secret exists (skips with a warning if + the secret is empty). +- [ ] _(Optional)_ Mirror to **Open VSX** via `ovsx publish` (separate account/token) — TODO left + in the workflow. +- [ ] **Update PR trigger branch** — the workflow currently filters on `carter-mrd-viz`; point it + at `main` (or the release branch) once merged. + +## 4. Validation loop (local, no publish) + +- [x] `vsce package` — validated manifest + produced VSIX without publishing. Confirmed + `icon.png` and `LICENSE.txt` are included and `icon-src.png` is excluded. +- [x] Install the VSIX locally (`code --install-extension`) — installed as + `ismrmrd.mrd-viz@0.0.1`; verify listing appearance in the Extensions view. +- [ ] Smoke-test opening a `.mrd` file with a bundled backend binary (local package omits the + PyInstaller binary; requires a CI build or a local backend build). +- [ ] Only after the above: `vsce publish --packagePath .vsix` (real publish; gated on §1). + +## 5. Compliance sign-off (see runbook §5) + +- [blocked] Q1–Q3 — employee publishing under non-Microsoft `ismrmrd` publisher; Microsoft + name/branding usage. +- [blocked] Q4–Q5 — OSS release-approval / registration process. +- [blocked] Q6 — third-party dependency / SBOM / license review for the bundled PyInstaller + backend. +- [blocked] Q7 — approved credential storage for a non-Microsoft-org repo. +- [blocked] Q8 — code signing / notarization of native backend binaries. + +--- + +## 6. How the Marketplace publish job works + +This section documents the `publish-marketplace` job added to +[`mrd_viz_release.yml`](../../.github/workflows/mrd_viz_release.yml) so the next owner can activate +it without reverse-engineering the workflow. + +### What it is + +It is **a job inside the existing release workflow**, not a separate GitHub Action and not +something that runs on branch pushes. The whole release workflow only does the release/publish +path when a **git tag matching `mrd-viz-v*`** is pushed. + +### When it runs + +On a `mrd-viz-v*` tag push, the jobs run in this order: + +```text +tag mrd-viz-v* → build (linux-x64, win32-x64, darwin-arm64 VSIXs) + → release (attaches VSIXs to a GitHub Release) + → publish-marketplace (this job) +``` + +`publish-marketplace` has `needs: [build, release]` and `if: startsWith(github.ref, 'refs/tags/')`, +so it only runs on a tag and only after the GitHub Release succeeds. + +### What it does + +- Downloads the **already-built** platform VSIX artifacts (does **not** rebuild — this preserves + the PyInstaller backend binary that was staged during `build`). +- Runs `npx @vscode/vsce publish --packagePath ` once per platform VSIX. +- Reads the Marketplace credential from the `VSCE_PAT` environment variable, which is wired to the + `secrets.VSCE_PAT` GitHub Actions secret. + +### Guarded / fail-safe behavior + +If the `VSCE_PAT` secret is **not** set, the job prints a warning and exits successfully +(`exit 0`) instead of failing. This means tagging a release today is safe and will simply skip the +Marketplace step until the credential is in place. **No code change is required to activate it — +just add the secret.** + +### Steps to activate (for the next owner) + +1. Complete §1 (create the `ismrmrd` Marketplace publisher and generate an Azure DevOps PAT with + the Marketplace → Manage scope) and get compliance sign-off (§5, esp. Q7). +2. In the GitHub repo: **Settings → Secrets and variables → Actions → New repository secret**, + name it `VSCE_PAT`, paste the PAT value. +3. Bump `version` in [`package.json`](../extension/mrd-viz/package.json) and update the release + notes in [`README.md`](../extension/mrd-viz/README.md) / `CHANGELOG.md`. +4. Tag and push: `git tag mrd-viz-v0.0.1 && git push origin mrd-viz-v0.0.1`. +5. Watch the Actions run: `build` → `release` → `publish-marketplace`. The extension appears on the + Marketplace within a few minutes of the publish step succeeding. + +### Rollback / safety notes + +- A bad publish cannot be deleted, only superseded by a higher version or unpublished by the + publisher. Prefer a pre-release version (e.g. `0.0.1`) for the first real publish. +- To test the credential without a public publish, run `vsce publish` from a throwaway + publisher/PAT, or validate the packaging path locally with `vsce package` (see §4). +- Open VSX mirroring is intentionally **not** wired up (a TODO is left in the workflow); it needs a + separate account and `OVSX_PAT` secret. + +--- + +## Notes / decisions log + +- 2026-08-13 — Branch `mrd-viz-release` created for release prep. Publisher owner confirmed as + `ismrmrd` (community identity, not personal or `microsoft`). +- 2026-08-13 — Added 128×128 `icon.png`, wired into manifest, added `LICENSE.txt`. Local + `vsce package` + install validated (icon present, source art excluded). +- 2026-08-14 — Scaffolded guarded `publish-marketplace` job in the release workflow (runs on + `mrd-viz-v*` tag push after `release`; skips if `VSCE_PAT` unset). diff --git a/mrd-viz/docs/RELEASE.md b/mrd-viz/docs/RELEASE.md new file mode 100644 index 00000000..538495c7 --- /dev/null +++ b/mrd-viz/docs/RELEASE.md @@ -0,0 +1,26 @@ +# MRD Viz Release + +GitHub Releases are the first researcher distribution channel. Each release provides platform VSIXs containing the extension and standalone backend. + +## Publish + +1. Update `version` in `extension/mrd-viz/package.json` and `package-lock.json`. +2. Move the relevant extension changelog entries out of `Unreleased`. +3. Merge after the MRD Viz and MRD Viz Release checks pass. +4. Create and push an annotated tag matching the manifest exactly: + + ```bash + git tag -a mrd-viz-v0.0.1 -m "MRD Viz 0.0.1" + git push origin mrd-viz-v0.0.1 + ``` + +The release workflow rejects a mismatched tag, builds and smoke-tests the standalone backend on each supported platform, packages targeted VSIXs, and attaches them to a generated GitHub Release. A manual workflow run produces the same downloadable artifacts without publishing a release. + +## Researcher install + +1. Open . +2. Download the VSIX matching the researcher's platform. +3. In VS Code, run **Extensions: Install from VSIX...**. +4. Open a `.mrd` file. + +Linux x64, Windows x64, and Apple Silicon builds include the backend. Intel macOS and other unsupported platforms use **MRD Viz: Set Up Backend** or **MRD Viz: Select Python Interpreter**; the automatic fallback requires Python 3.12 and a published `mrd-viz` package source. diff --git a/mrd-viz/docs/TECHNICAL_DESIGN.md b/mrd-viz/docs/TECHNICAL_DESIGN.md new file mode 100644 index 00000000..82320965 --- /dev/null +++ b/mrd-viz/docs/TECHNICAL_DESIGN.md @@ -0,0 +1,582 @@ +# MRD Viz Technical Design + +Status: In Progress +Owner: Carter Capetz +Last updated: 2026-06-23 + +## Purpose + +This doc is the working technical design for `mrd-viz`, a VS Code extension and Python-backed inspection layer for MRD files. + +Use it to: + +- explain why the tool is needed +- define the smallest useful software shape +- cite the scripts, libraries, and local implementation patterns the project will build from +- log open questions and design decisions +- keep extension, webview, and Python backend boundaries clear + +## Background + +### Team overview and why an MRD viewer is needed + +The Monarch team is building the initial test rollout of low-field MRI machines. The goal is to make MRI more accessible by developing scanners that are lower cost and more portable than conventional high-field systems. That goal introduces engineering challenges across hardware control, acquisition workflow design, reconstruction, image quality, and autonomous operation. + +Low-field MRI produces lower-fidelity images, so the broader project depends on reliable experimentation and image-improvement workflows. Over time, the system should support increasingly autonomous operation: agents should be able to run acquisition and reconstruction workflows, optimize sequence choices, inspect outputs, and eventually help translate new MRI techniques from research papers onto the specific scanner hardware. + +MRD is the file format used in this project ecosystem to support typed, stream-oriented MRI data exchange. That matters for low-field workflows because the scanner itself should not be expected to run every image-processing step locally; MRD gives the system a way to hand off acquisition and reconstruction artifacts to local or cloud processing while preserving MRI-specific structure. + +The current tooling gap is inspection. Researchers and engineers can generate scripts or quick visualizers, but those tools are fragmented, ad hoc, and inconsistent. Some users need a quick view to decide whether a dataset is worth using as training input. Others need to classify many MRD files at once, compare slices, inspect acquisition patterns, or run lightweight image heuristics. `mrd-viz` should become the unified inspection surface for those workflows, starting with a dependable file-first viewer. + +### MRD file structure overview + +MRD files should be understood as one header followed by a stream of typed items. The important logical components are: + +- Header: acquisition and reconstruction context, especially encoding-space matrix size, reconstructed-space matrix size, field of view, encoding limits, and acquisition metadata needed to interpret k-space organization. +- Acquisitions: raw complex-valued k-space readouts. In the MRD Python model, acquisition data is shaped like `[coils, samples]` and carries flags plus encoding counters such as slice, phase, contrast, repetition, and k-space encode steps. +- Images: reconstructed image-domain arrays. In the MRD Python model, image data is shaped like `[channel, z, y, x]` and carries image metadata such as image type, series index, slice, phase, contrast, repetition, field of view, channels, and slices. +- Waveforms: physiological or external signal streams such as ECG, pulse oximetry, or triggers. These are not the Stage 1 visualization target, but the data model should count and preserve awareness of them. + +This structure means raw and reconstructed MRD files require different viewing modes. Raw MRD is not directly viewable like a normal image; it is better inspected through k-space magnitude, phase, sampling density, encoding counters, and acquisition flags. Reconstructed MRD is more directly renderable because it already contains image-domain data. Stage 1 will prioritize reconstructed MRD files while keeping raw-file classification and simple acquisition summaries in the backend contract. + +### Relationship to HDF5 and ISMRMRD + +HDF5, ISMRMRD, and MRD should not be treated as interchangeable terms. + +- HDF5 is a hierarchical storage container. It can store large arrays and metadata, but it does not define MRI semantics by itself. +- ISMRMRD v1 is the older MRI raw-data standard that uses HDF5 plus an XML header/schema and fixed binary-style acquisition and image headers. +- MRD is the newer typed, stream-oriented standard and SDK ecosystem. It keeps the same conceptual model of header, acquisitions, images, and waveforms while making binary and NDJSON streams first-class serializations, with optional HDF5 support where useful. + +Stage 1 should target MRD binary files through `mrd-python`. Legacy HDF5/ISMRMRD compatibility should be acknowledged as a future compatibility risk, but it should not expand the first build scope. The MRD repository includes `ismrmrd_to_mrd.py`, which can convert ISMRMRD streams or ISMRMRD HDF5 datasets into MRD streams; Stage 1 can document that companion workflow without embedding conversion in the viewer. + +## Scope + +In scope: + +- Stage 1 + - build a VS Code plugin so double clicking a `.mrd` file opens a readonly preview panel + - render reconstructed MRD image data as a mosaic of thumbnail tiles, where each tile represents one MRD image stream item + - support lazy loading of a selected tile at larger/full resolution for detailed inspection + - classify files as raw, reconstructed, mixed, unknown, or invalid based on stream contents rather than filename conventions + - show a compact metadata side panel with header summary, stream counts, image metadata, and acquisition metadata + - summarize raw-only MRD files without attempting raw acquisition visualization + - support the known reconstructed example files before broadening to less common variants +- Stage 2 + - support multiple images within one `.mrd` file with richer navigation + - support richer metadata display, including grouped metadata sections instead of only unstructured dumps + - support working with multiple `.mrd` files in one UI session + - support comparison-oriented views for slices, image types, and related datasets +- Stage 3 + - leave room for run-aware integration, batch classification, acquisition-pattern inspection, lightweight image heuristics, and workflow observability + - do not design Stage 1 around full scanner control or live workflow monitoring + +Out of scope for now: + +- direct scanner control +- live workflow monitoring in Stage 1 +- QC heuristics or classification models in Stage 1 and Stage 2 +- direct runtime dependency on `tinker/` or `monarch/` +- turning the extension into a scanner control surface +- legacy HDF5/ISMRMRD viewing in Stage 1 + +## Product Surface + +### Why a VS Code plugin + +`mrd-viz` will be a VS Code plugin because the initial users are technical researchers and operators who already inspect files, notebooks, scripts, and reconstruction outputs inside VS Code. A plugin keeps MRD inspection inside that existing development loop instead of forcing users to switch to a separate command-line-only workflow. + +The Stage 1 experience should be file-first: double click a `.mrd` file, or use an `Open in MRD Viewer` command, and get a readonly custom editor with image and metadata preview. This is a better first product surface than a standalone CLI because it makes inspection discoverable and repeatable while still allowing the Python backend to expose CLI commands for testing, debugging, and automation. + +The VS Code extension also leaves a path to richer workflow views later. Stage 3 can add panels that understand sessions, runs, artifacts, and stage outputs without changing the Stage 1 backend contract too aggressively. + +### Stage 1 user flow + +1. User opens a `.mrd` file in VS Code. +2. VS Code routes the file to the custom readonly editor registered for `*.mrd`. +3. The extension host starts a local Python process with the selected file path. +4. The Python backend reads the MRD header and stream items using `mrd-python`. +5. The backend returns JSON summary data plus thumbnail PNG payloads for each renderable MRD image item. +6. The main webview renders a mosaic of thumbnails so distant image items can be compared without stepping through a slider. +7. A separate adjacent metadata panel renders header, stream, image, acquisition, waveform, and warning details. +8. When the user selects a tile, the extension requests that image index from Python and displays the returned larger PNG file. +9. If the file is unsupported, malformed, raw-only, or has no renderable image, the extension shows an explicit state. Raw-only files still get a metadata summary. + +### Stage 1 architecture + +```mermaid +flowchart LR + A[double click .mrd] --> B[VS Code custom readonly editor] + B --> C[extension host TypeScript] + C --> D[local Python backend] + D --> E[mrd-python BinaryMrdReader] + D --> F[summary JSON] + D --> G[preview PNG data] + F --> H[webview UI] + G --> H +``` + +Node responsibilities: + +- VS Code custom readonly editor: owns the file-opening surface and one `CustomDocument` per opened `.mrd` file. +- Extension host TypeScript: registers the editor, manages webviews, starts short-lived `mrd-viz` CLI processes, enforces timeouts/cancellation, parses stdout JSON, captures stderr, and cleans up temp PNG files. +- Local Python backend: implements `mrd-viz open`, `image`, `classify`, and `html`; reads the MRD header before stream data; classifies file contents; emits bounded thumbnails and metadata. +- MRD reader: uses `mrd-python` as the only Stage 1 format parser. +- Summary JSON: carries schema version, file class, display mode, header summary, stream counts, warnings, raw-only states, and error envelopes. +- Preview PNG data: uses base64 PNGs for bounded thumbnails and temporary PNG paths for lazy full-size image requests. +- Webview UI: renders the mosaic, selected image, metadata panel, loading states, unsupported states, and controlled errors. + +## Extension Implementation References + +The implementation will start from the official VS Code extension path. Use the VS Code Extension Generator with the TypeScript template: + +```powershell +npx --package yo --package generator-code -- yo code +``` + +Select `New Extension (TypeScript)`, keep the generated `src/extension.ts` and `package.json` structure, and run the extension with F5 in an Extension Development Host. + +The Stage 1 editor should use VS Code's custom editor API: + +- contribute a `customEditors` entry in `package.json` for `*.mrd` +- use a unique `viewType`, for example `mrd-viz.mrdFile` +- register a `vscode.CustomReadonlyEditorProvider` during activation +- implement `openCustomDocument(...)`, `resolveCustomEditor(...)`, `WebviewPanel.onDidDispose`, and `CustomDocument.dispose` +- activate on demand through `onCustomEditor:`; VS Code 1.74+ can infer activation from the custom editor contribution, but the event is still the right lifecycle concept to document + +`alaramartin/dicom-viewer` remains a useful case study for custom editor UX, webview layout, metadata separation, TypeScript packaging, and `.vsix` distribution. It should not be used as the code template. Its DICOM parsing runs in TypeScript/Node and includes DICOM-specific metadata behavior, while `mrd-viz` should keep MRD parsing and PNG generation in Python and remain readonly in Stage 1. + +## Data and Technical Implementation Plan + +Stage 1 should use a local process boundary, not a network service boundary. + +Reason: the first product goal is file-first preview on one machine. A short-lived Python CLI process keeps setup and iteration simple, avoids early service and deployment overhead, and still preserves a clean separation if a service is needed later. + +### Languages and runtimes + +- VS Code extension host: TypeScript. The implementation target should follow the official `yo code` TypeScript template. +- Webview UI: HTML, CSS, and JavaScript running inside the VS Code webview sandbox. +- Backend: Python 3.12 package code under `src/mrd_viz/`. +- MRD parsing: `mrd-python==2.2.1`. +- Array processing: `numpy`. +- PNG generation: `Pillow` in the Python backend. +- Optional local plotting and notebook diagnostics: `matplotlib` through the `plots` optional dependency. This is not part of the Stage 1 extension contract. + +### Local setup and rollout + +Python backend setup: + +```powershell +py -3.12 --version +py -3.12 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e . +mrd-viz --help +``` + +Extension setup from the generated extension project root: + +```powershell +node --version +git --version +npx --package yo --package generator-code -- yo code +npm install +npm run compile +``` + +Local extension testing should use F5 to launch an Extension Development Host. Private rollout should use a `.vsix` package before marketplace publishing: + +```powershell +npm install -g @vscode/vsce +vsce package +code --install-extension .\mrd-viz-0.0.1.vsix +``` + +The packaged extension must not contain hardcoded local paths. It should expose a setting for the Python executable or backend command and fail clearly when the configured environment cannot import `mrd`. + +### Current backend state + +The original `src/mrd_viz/` package was rough generated scaffolding and should not be treated as ground truth. It has been simplified so the repo now contains one canonical backend module: + +- `src/mrd_viz/main.py`: canonical backend contract. +- `src/mrd_viz/cli.py`: subprocess entry point used by the extension and tests. +- `src/mrd_viz/__init__.py`: small public export surface. + +The existing VS Code extension prototype under `mrd-for-carter/mrd-for-carter/mrd-viewer/` remains useful as a loose logical reference: + +- `package.json` contributes a `customEditors` entry for `*.mrd`, a `mrd-viewer.openFile` command, and settings for `mrdViewer.pythonPath` and `mrdViewer.maxImages`. +- `extension.js` registers the editor provider and command. +- `mrdEditorProvider.js` implements a custom readonly editor, spawns a Python process, parses JSON from stdout, and sends the parsed payload to the webview. +- `scripts/mrd_extract.py` reads the MRD header and stream using `mrd.BinaryMrdReader`, converts selected image planes into base64 PNGs, and emits a single JSON payload for the webview. +- `media/viewer.js` renders header, encoding, acquisition, image, waveform, and other stream sections. +- `media/viewer.css` keeps the UI aligned with VS Code theme variables. + +This prototype should not be copied wholesale. The prototype proves that the extension-to-Python boundary works, but the implementation should be rebuilt around the cleaner `mrd-viz` CLI contract below. + +### Backend responsibilities + +The Python backend owns all MRD-specific logic: + +- read `.mrd` files with `mrd.BinaryMrdReader`, calling `read_header()` before iterating `read_data()` +- validate that the header is present and the stream can be consumed +- classify files by stream item type, not filename +- extract one representative display plane from each MRD image stream item for the Stage 1 mosaic +- normalize complex, integer, or floating image data into grayscale 8-bit PNG output +- generate thumbnail PNG payloads for the initial mosaic +- generate a larger/full-resolution PNG file for one selected mosaic tile on demand +- summarize header encoding fields, item counts, image dimensions, image metadata, acquisition dimensions, acquisition flags, and encoding counters +- produce explicit errors for invalid files and files with no renderable image +- return an unsupported-but-summarized state for raw-only MRD files + +The backend should not depend on `tinker` or `monarch` at runtime in Stage 1. It should duplicate only the small amount of image-plane extraction and normalization logic needed for viewing. Tinker remains conceptual context for reconstruction semantics, but it is not a Stage 1 dependency or adapter. + +The Stage 1 representative-plane policy is deliberately simple: + +- one mosaic tile equals one MRD image stream item +- if image data is shaped `[channel, z, y, x]`, render `channel=0`, `z=0` +- if image data is shaped `[z, y, x]`, render `z=0` +- if image data is shaped `[y, x]`, render it directly +- if the shape is unsupported, keep metadata for the tile and mark it as not renderable + +This policy avoids over-designing channel and slice navigation before more MRD examples are available. Stage 2 can expand a single MRD image item into internal channel/slice navigation if real files show that is needed. + +### Extension responsibilities + +The VS Code extension owns editor registration, process orchestration, and UI presentation: + +- register the custom readonly editor for `.mrd` files +- expose an explicit `Open in MRD Viewer` command +- resolve the configured backend through `mrdViz.backendPath`, or fall back to the bundled binary +- spawn the `mrd-viz` backend CLI with file path and requested operation +- enforce a stable JSON contract between extension and backend +- render loading, success, unsupported, and error states in the webview +- retain webview context when hidden where useful +- keep presentation logic out of the Python parser +- create a main image webview for the mosaic and a separate adjacent metadata webview panel +- request a full-size image lazily when a user selects a mosaic tile +- package as a normal VS Code extension that can be built into a `.vsix` and installed on another machine + +Stage 1 should keep visualization and metadata separate from the start. The main editor focuses on the mosaic and selected image. The adjacent metadata panel focuses on searchable/scannable file structure and can evolve independently. + +### Process lifecycle, concurrency, and reentrancy + +Stage 1 should use short-lived backend processes. The extension starts one `mrd-viz` process for each open, classify, or lazy image request, then treats stdout JSON as the only success channel. Stderr is captured for diagnostics and shown only in controlled error details. + +The extension owns lifecycle control: + +- apply a timeout to each backend process +- cancel in-flight backend work when the editor or document is disposed +- reject malformed, oversized, or schema-incompatible stdout payloads +- render timeout, cancellation, missing dependency, and invalid JSON as explicit error states +- clean up backend-created temporary PNG files when the owning document is disposed + +Concurrent opens should be independent. Each opened `.mrd` file gets its own `CustomDocument`; multiple editor panes for the same file can share document-level summary data while keeping per-webview UI state such as selected tile and scroll position separate. + +Lazy image requests should be reentrant. If a user selects tiles quickly, a newer request for the same document supersedes older pending requests for the selected-image view. Older results should be ignored if they arrive late, and duplicate requests for an already loaded tile may be served from extension-side memoization. + +Installability matters for Stage 1, even before marketplace release. The extension project should include: + +- `package.json` with publisher, repository, icon, categories, keywords, activation events, custom editor contribution, configuration properties, build scripts, and package file list +- `tsconfig.json` compiling `src/` TypeScript into `out/` +- a `vscode:prepublish` script that compiles the extension before packaging +- a `package` script using `vsce package` +- clear configuration for the Python executable used by the backend +- no hardcoded local paths in the packaged extension + +### Boundary contract + +The subprocess boundary should be treated as a product API even though it is local. `mrd-viz` is the Python backend command surface that the extension calls; it is also useful for tests and manual debugging. Stage 1 uses these commands: + +- `mrd-viz open --max-thumbnails 256`: return the initial open-file payload: classification, header summary, stream counts, metadata, warnings, and bounded mosaic thumbnails. +- `mrd-viz image --index `: return one larger/full-resolution temporary PNG path and metadata for a selected mosaic tile. +- `mrd-viz classify `: return a lightweight classification payload for batch workflows. +- `mrd-viz html --output `: write a static HTML mosaic harness for fast UI iteration before the VS Code frontend exists. +- `mrd-viz inspect `: supported as a temporary alias for `open`. + +The initial open payload should remain small and stable. Thumbnail PNGs may be base64 because they are bounded; the default maximum is 256 thumbnails and should be configurable. + +```json +{ + "ok": true, + "schema_version": 1, + "path": "...", + "filename": "example.mrd", + "file_class": "reconstructed", + "display_mode": "mosaic", + "summary": { + "encoding_count": 1, + "encoded_matrix": [256, 256, 1], + "recon_matrix": [256, 256, 1] + }, + "stream": { + "item_counts": { "ImageFloat": 20 }, + "image_count": 20, + "acquisition_count": 0, + "waveform_count": 0, + "other_count": 0 + }, + "mosaic": { + "tile_unit": "mrd_image_item", + "thumbnails": [ + { + "image_index": 0, + "stream_index": 1, + "data_shape": [1, 1, 256, 256], + "png_base64": "...", + "thumbnail": true, + "source_plane": { "channel": 0, "z": 0 } + } + ], + "truncated": false + }, + "metadata": { + "images": [], + "acquisitions": [], + "waveforms": [], + "other_items": [] + }, + "warnings": [] +} +``` + +The lazy image payload should be separate: + +```json +{ + "ok": true, + "path": "...", + "image": { + "image_index": 0, + "stream_index": 1, + "data_shape": [1, 1, 256, 256], + "png_path": "C:\\Users\\...\\Temp\\mrd-viz\\example-0.png", + "thumbnail": false, + "source_plane": { "channel": 0, "z": 0 } + } +} +``` + +Raw-only files should return metadata without pretending an image exists: + +```json +{ + "ok": true, + "schema_version": 1, + "file_class": "raw", + "display_mode": "metadata_only", + "stream": { "image_count": 0, "acquisition_count": 1200 }, + "mosaic": { "tile_unit": "mrd_image_item", "thumbnails": [], "truncated": false }, + "warnings": ["Raw-only MRD files are summarized but not visualized in Stage 1."] +} +``` + +Errors should use the same envelope across commands: + +```json +{ + "ok": false, + "schema_version": 1, + "error": { + "code": "invalid_mrd", + "message": "Could not read MRD header.", + "detail": "...", + "suggestion": "Confirm the file is an MRD binary stream readable by mrd-python." + } +} +``` + +### Data model + +The internal model should stay intentionally small: + +- File summary: path, size, file kind, item counts, image count, acquisition count, waveform count, and status. +- Header summary: encoding count, encoded matrix, reconstructed matrix, encoded field of view, reconstructed field of view, encoding limits, and acquisition system fields when available. +- Mosaic model: one tile per MRD image stream item, thumbnail PNG base64 payload, stream index, image index, original shape, dtype, image type, selected representative plane, and renderability status. +- Lazy image model: one full-size temporary PNG path for the selected mosaic tile, requested by image index. +- Acquisition summary: sample counts, channel counts, scan counter range, flags, and representative encoding counters. +- Error model: explicit user-facing reason, backend exception detail for debugging, and suggested next action when known. + +This keeps the frontend insulated from raw MRD object internals and gives the Python package room to adapt as MRD variants appear. + +### Leveraged logic and scripts + +Stage 1 should use external and existing logic carefully: + +- Use `mrd/python/mrd/tools/minimal_example.py` as the reference for basic MRD header and stream reading. +- Use `mrd/python/mrd/tools/export_png_images.py` as a conceptual reference for converting reconstructed image data into PNG output. +- Use `/mrd/python/mrd/tools/ismrmrd_to_mrd.py` as the reference for any documented ISMRMRD-to-MRD conversion workflow outside the Stage 1 viewer. +- Do not import Tinker or Monarch at runtime for Stage 1 viewing. +- Do not preserve rough scaffolding just because it exists. Keep the Stage 1 code path small enough that a prototype agent can reason about it in one pass. + +### Loading and caching + +- Read eagerly: header, image metadata, acquisition examples, and up to 128 thumbnail PNGs for the image-item mosaic. +- Read lazily: larger/full-resolution temporary PNG for the selected mosaic tile. +- Defer to later stages: raw acquisition visualization, channel/slice expansion inside one image item, batch comparison, and QC heuristics. +- Cache for Stage 1: in-memory webview state, optional extension-side memoization of already requested tile images, and explicit cleanup of temp PNGs on document disposal. +- Avoid caching raw MRD object graphs in the webview; send only JSON-friendly summaries and image references. + +### Static HTML mosaic harness + +Before building the full VS Code custom editor, use `mrd-viz html` as an experimental middle ground. It writes a standalone browser-viewable HTML file from the same backend contract the extension will use. + +The harness should support: + +- a responsive thumbnail mosaic +- a selected-tile detail pane +- compact payload/metadata summary +- embedded full-resolution payloads for the first N tiles through `--preload-full-images` +- a JavaScript loader hook (`window.MrdVizHarness.setTileLoader(...)`) so the future VS Code webview can replace the static embedded loader with `postMessage` calls to the extension host + +Example: + +```powershell +$env:PYTHONPATH = "src" +$py = "C:\Users\t-ccapetz\Documents\mrd-proj\tinker-clean\.venv\Scripts\python.exe" +$inputMrd = "exp_data\misc\fastmri_knee_gt_RECON.mrd" +$outputHtml = "notebooks\artifacts\fastmri_knee_gt_RECON_mosaic.html" +$maxThumbnails = 128 +$thumbnailSize = 112 +$preloadFullImages = 1 + +& $py -m mrd_viz.cli html $inputMrd ` + --output $outputHtml ` + --max-thumbnails $maxThumbnails ` + --thumbnail-size $thumbnailSize ` + --preload-full-images $preloadFullImages + +Start-Process $outputHtml +``` + +The `html` command requires the input path and `--output`. Optional flags such as `--max-thumbnails`, `--thumbnail-size`, and `--preload-full-images` use CLI defaults when omitted; passing an empty value after a flag should be treated as invalid input rather than a request for the default. + +## Testing Strategy + +Stage 1 needs tests at three levels: backend unit tests, backend sample-file integration tests, and extension smoke tests. + +### Backend unit tests + +Add `pytest` tests for the Python package: + +- `open_file` returns the backend schema with `schema_version`, `file_class`, `display_mode`, `summary`, `stream`, `mosaic`, `metadata`, and `warnings`. +- `open_file` classifies reconstructed, raw-only, mixed, unknown, and invalid files correctly. +- `open_file` returns one mosaic thumbnail per renderable MRD image item up to `max_thumbnails`. +- `open_file` returns `display_mode: metadata_only` plus a warning for raw-only files. +- `extract_image` returns a larger/full-resolution temporary PNG path for a selected image index. +- image normalization handles constant arrays, complex arrays, integer arrays, floating arrays, and expected min/max scaling. +- unsupported image shapes are represented as non-renderable tile metadata rather than crashing the whole file open operation. + +### Sample-file integration tests + +Use `exp_data/` as the primary local integration fixture set. The files are filtered examples from Tinker and are already labeled in their filenames as `RAW`, `RECON`, or `UNKNOWN`. + +Useful starter cases: + +- `exp_data/ulf_localizer_Localizer.2026.02.22.08.31.22.605_RECON.mrd`: small reconstructed file, useful for first mosaic and lazy-image smoke tests. +- `exp_data/ulf_localizer_Localizer.2026.02.22.08.31.22.605_RAW.mrd`: raw-only file, useful for unsupported-but-summarized behavior. +- `exp_data/phantom_R3_Perfusion_3slice_24rep_gt_RECON.mrd`: multi-image reconstructed file, useful for thumbnail truncation and mosaic layout testing. +- `exp_data/misc/lge_LGE_phantom_UNKNOWN.mrd`: filename-labeled unknown case; current stream-derived behavior may classify it as raw if it contains acquisitions but no images. + +Expected checks: + +- reconstructed MRD sample: expected to classify as reconstructed and produce at least one renderable preview. +- raw MRD sample: expected to classify as raw and produce acquisition metadata without pretending it is a normal image. +- malformed or missing path case: expected to return an explicit error. +- multi-image reconstructed sample: expected to render multiple mosaic tiles and support lazy image retrieval by index. + +Because sample data may be large or unavailable in some environments, these tests should be skippable when fixture paths are not configured. + +### CLI contract tests + +Add tests that invoke the installed CLI commands and parse stdout as JSON: + +- `mrd-viz open ` returns valid JSON with `schema_version`, `file_class`, `display_mode`, `stream`, `mosaic`, and `metadata`. +- `mrd-viz image --index 0` returns valid JSON with a full-size temporary PNG path for the selected tile. +- `mrd-viz classify ` returns valid JSON with `path`, `file_class`, `display_mode`, `item_counts`, and `warnings`. +- `mrd-viz inspect ` remains valid as a temporary alias for `open`. + +These tests matter because the VS Code extension depends on subprocess stdout as a local API. + +### Extension tests + +The extension should add a small VS Code extension test suite once the custom editor is integrated with the main package: + +- activation registers the explicit open command. +- `.mrd` files can be opened with the `mrd-viz.mrdFile` custom editor view type. +- `mrdViz.backendPath` is honored. +- backend process errors render the error view rather than crashing the extension host. +- backend process timeout and cancellation render controlled states. +- a mocked `open` payload renders a mosaic in the image webview and metadata in the adjacent metadata webview. +- selecting a mosaic tile sends an `image` request and updates the selected/full-size image view. +- raw-only payloads render the unsupported-but-summarized state. + +Stage 1 can begin with manual VS Code smoke tests, but the release candidate should have automated coverage for the subprocess contract and custom editor activation path. + +### Manual validation checklist + +- Open a known reconstructed `.mrd` file by double click. +- Confirm the mosaic renders one tile per MRD image item. +- Confirm selecting a tile loads a larger image from the returned temp PNG path. +- Confirm header matrix and FOV fields appear when present. +- Confirm image count and stream counts match CLI output. +- Open a known raw `.mrd` file and confirm it shows a raw/acquisition-oriented summary rather than a misleading blank image. +- Open an invalid file and confirm the error is explicit. +- Change `mrdViz.backendPath` to the project `.venv` interpreter and confirm the extension uses it. +- Package the extension as a `.vsix` and install it into a separate VS Code instance or profile without local hardcoded paths. + +## Backward Compatibility and Format Risk + +Stage 1 targets MRD binary files readable through `mrd-python==2.2.1`. This is a deliberate constraint. The viewer should fail clearly for unsupported formats instead of guessing. + +Compatibility risks: + +- MRD SDK updates may rename classes, alter stream item variants, or change field access patterns. +- MRD files produced by different pipeline stages may include acquisitions, images, waveforms, or mixed stream contents. +- Some reconstructed images may have different dtypes, complex values, dimensions, channels, slices, or image metadata availability. +- Raw-only files may be common even though Stage 1 focuses on reconstructed preview. +- Legacy ISMRMRD/HDF5 files may appear in datasets and should be identified as unsupported or routed to a future compatibility path. + +Mitigations: + +- Pin the backend dependency initially with `mrd-python==2.2.1`. +- Centralize all MRD SDK calls inside the Python backend, not the webview. +- Classify by stream item type and preserve unknown item counts. +- Keep sample-file regression tests for raw and reconstructed MRD examples. +- Return warnings and explicit unsupported states in the JSON contract. +- Add compatibility fixtures as new MRD variants are copied into the project. +- Keep the viewer contract stable even if backend parsing details change. + +## Questions + +### Open + +- Which team details should be included in the final background section, and how much of the long-term autonomous-agent vision belongs in this technical design versus a broader product proposal? +- What reconstructed MRD variants appear once more datasets are copied in, and do they force changes to the initial parser assumptions? +- What is the smallest structured metadata subset to promote from the initial unstructured dump into the main UI? +- What batch classification workflow should be included in Stage 2 or Stage 3? +- Which image heuristic analysis belongs in this project, and which should remain out of scope? + +### Closed + +- Stage 1 is file first. The first useful flow is double click a reconstructed `.mrd` file and open an image preview. +- Priority order is reconstructed image mosaic first, metadata side panel second, raw acquisition visualization later. +- Stage 2 expands into multi-image and multi-file inspection with a more intentional comparison UI. +- Live workflow monitoring and QC heuristics are out of scope for Stage 1 and Stage 2. +- The tool should stand on its own and duplicate needed logic from `tinker` instead of interfacing with that repo at runtime. +- The right initial architecture is not FastAPI. Use a local extension-to-Python process boundary first, and revisit a service only if remote, shared, or run-aware use cases become real. +- Stage 1 should render a mosaic rather than a slider so distant image items can be compared quickly. +- Stage 1 metadata should live in a separate adjacent panel so visualization and metadata features can evolve independently. +- Stage 1 metadata can start as a compact structured summary plus optional unstructured detail so repeated field patterns can guide a cleaner grouped presentation later. +- The Python side should return PNGs first, imitating the existing Tinker image export path. +- `alaramartin/dicom-viewer` is a good Stage 1 case study for the custom-editor UX, but not a direct code template for the backend because `mrd-viz` should keep Python-based parsing and image generation. +- Stage 1 targets MRD binary files through `mrd-python`, not legacy HDF5/ISMRMRD viewing. +- Raw-only MRD files are unsupported for visualization in Stage 1, but they should still produce a useful metadata summary. +- Stage 1 extension implementation should use TypeScript. +- Stage 1 should call the installed `mrd-viz` Python CLI rather than bundling a separate extraction script. +- Stage 1 should default to 256 thumbnails, with the limit configurable. +- Thumbnail images may be returned as base64 JSON; full-size lazy images should use temporary PNG paths. +- Stage 1 local setup should use a project `.venv`, with Windows commands documented first and cross-platform support preserved as a packaging goal. + +## AI Disclosure + +This design doc was iterated upon and drafted with the help of GitHub Copilot. This still remains a draft for the official design and functionality of the extension. \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/.gitignore b/mrd-viz/extension/mrd-viz/.gitignore new file mode 100644 index 00000000..18b77db0 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/.gitignore @@ -0,0 +1,9 @@ +# Generated esbuild webview bundle (built by `npm run build:webview`, +# and regenerated automatically on `vsce package` via the vscode:prepublish hook). +media/viewer.js +media/*.map + +# Local build / dependency output +node_modules/ +out/ +*.vsix diff --git a/mrd-viz/extension/mrd-viz/.vscode-test.mjs b/mrd-viz/extension/mrd-viz/.vscode-test.mjs new file mode 100644 index 00000000..b62ba25f --- /dev/null +++ b/mrd-viz/extension/mrd-viz/.vscode-test.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from '@vscode/test-cli'; + +export default defineConfig({ + files: 'out/test/**/*.test.js', +}); diff --git a/mrd-viz/extension/mrd-viz/.vscodeignore b/mrd-viz/extension/mrd-viz/.vscodeignore new file mode 100644 index 00000000..4cdebc85 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/.vscodeignore @@ -0,0 +1,14 @@ +.vscode/** +.vscode-test/** +src/** +out/test/** +.gitignore +.yarnrc +vsc-extension-quickstart.md +**/tsconfig.json +**/eslint.config.mjs +**/*.map +**/*.ts +**/.vscode-test.* +package-lock.json +media/icon-src.png diff --git a/mrd-viz/extension/mrd-viz/CHANGELOG.md b/mrd-viz/extension/mrd-viz/CHANGELOG.md new file mode 100644 index 00000000..a14e5840 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +All notable changes to the "mrd-viz" extension will be documented in this file. + +Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. + +## [Unreleased] + +- Initial release \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/LICENSE.txt b/mrd-viz/extension/mrd-viz/LICENSE.txt new file mode 100644 index 00000000..24b1a29f --- /dev/null +++ b/mrd-viz/extension/mrd-viz/LICENSE.txt @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2024 ISMRMRD Steering Committee + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/mrd-viz/extension/mrd-viz/README.md b/mrd-viz/extension/mrd-viz/README.md new file mode 100644 index 00000000..9a64ecb7 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/README.md @@ -0,0 +1,39 @@ +# MRD Viz + +A Visual Studio Code extension for inspecting [MRD](https://github.com/ismrmrd/mrd) (Magnetic Resonance Data) files directly in the editor. Open a `.mrd` file to view a thumbnail mosaic of its images alongside acquisition, waveform, and header metadata. + +## Features + +- Opens `.mrd` files in a custom editor (thumbnail mosaic + metadata panels). +- Select a tile to load its full-resolution image on demand. +- Browse image, acquisition, waveform, and raw-stream metadata, including a raw JSON view. + +## Install + +Download the VSIX for your platform from the [latest GitHub Release](https://github.com/ismrmrd/mrd/releases/latest), then run **Extensions: Install from VSIX...** in VS Code. Supported platform builds include the backend, so Python setup is not required. + +On a platform without a bundled build, run **MRD Viz: Set Up Backend** or point `mrdViz.backendPath` at a Python 3.12 environment containing `mrd_viz`. + +## Extension Settings + +This extension contributes the following settings: + +- `mrdViz.backendPath`: Path to a Python interpreter (runs `python -m mrd_viz.cli`) or a prebuilt `mrd-viz` binary. Leave unset to use the backend bundled with the extension. +- `mrdViz.maxThumbnails`: Maximum number of image thumbnails requested for the initial view (default `128`). +- `mrdViz.backendTimeoutMs`: Timeout in milliseconds for a single backend process (default `30000`). + +## Commands + +- `MRD Viz: Open File` — open the selected or picked `.mrd` file in MRD Viz. +- `MRD Viz: Set Up Backend` — provision the managed Python fallback. +- `MRD Viz: Select Python Interpreter` — select an existing backend environment. + +## Known Issues + +- Non-`.mrd` files and non-`file://` resources are rejected with a warning; only local `.mrd` files are supported. + +## Release Notes + +### 0.0.1 + +Initial preview: custom editor, thumbnail mosaic, on-demand full-resolution images, and metadata panels. diff --git a/mrd-viz/extension/mrd-viz/eslint.config.mjs b/mrd-viz/extension/mrd-viz/eslint.config.mjs new file mode 100644 index 00000000..7c51b0c0 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/eslint.config.mjs @@ -0,0 +1,27 @@ +import typescriptEslint from "typescript-eslint"; + +export default [{ + files: ["**/*.ts"], +}, { + plugins: { + "@typescript-eslint": typescriptEslint.plugin, + }, + + languageOptions: { + parser: typescriptEslint.parser, + ecmaVersion: 2022, + sourceType: "module", + }, + + rules: { + "@typescript-eslint/naming-convention": ["warn", { + selector: "import", + format: ["camelCase", "PascalCase"], + }], + + curly: "warn", + eqeqeq: "warn", + "no-throw-literal": "warn", + semi: "warn", + }, +}]; \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/media/icon-src.png b/mrd-viz/extension/mrd-viz/media/icon-src.png new file mode 100644 index 00000000..ffbde7e5 Binary files /dev/null and b/mrd-viz/extension/mrd-viz/media/icon-src.png differ diff --git a/mrd-viz/extension/mrd-viz/media/icon.png b/mrd-viz/extension/mrd-viz/media/icon.png new file mode 100644 index 00000000..47255e8c Binary files /dev/null and b/mrd-viz/extension/mrd-viz/media/icon.png differ diff --git a/mrd-viz/extension/mrd-viz/media/tsconfig.json b/mrd-viz/extension/mrd-viz/media/tsconfig.json new file mode 100644 index 00000000..93c82216 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ES2020", + "moduleResolution": "Bundler", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vscode-webview" + ], + "strict": true, + "noImplicitAny": false, + "strictNullChecks": false, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "viewer.ts", + "viewer/**/*.ts" + ] +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer.css b/mrd-viz/extension/mrd-viz/media/viewer.css new file mode 100644 index 00000000..3aa2f42a --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer.css @@ -0,0 +1,434 @@ +:root { + --mrd-panel: var(--vscode-sideBar-background); + --mrd-panel-strong: var(--vscode-editor-background); + --mrd-line: var(--vscode-panel-border); + --mrd-text: var(--vscode-foreground); + --mrd-muted: var(--vscode-descriptionForeground); + --mrd-accent: var(--vscode-focusBorder); + --mrd-warning-bg: var(--vscode-inputValidation-warningBackground); + --mrd-warning-border: var(--vscode-inputValidation-warningBorder); + --mrd-error-bg: var(--vscode-inputValidation-errorBackground); + --mrd-error-border: var(--vscode-inputValidation-errorBorder); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + color: var(--mrd-text); + background: var(--vscode-editor-background); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +button, pre { font-family: inherit; } + +.shell { min-height: 100vh; display: grid; grid-template-rows: auto minmax(0, 1fr); } + +.header { + position: sticky; + top: 0; + z-index: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 14px; + background: var(--mrd-panel-strong); + border-bottom: 1px solid var(--mrd-line); +} + +.title { min-width: 0; } + +.title h1 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subtitle { + color: var(--mrd-muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stats { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; } + +.stat { + padding: 3px 7px; + border: 1px solid var(--mrd-line); + background: var(--mrd-panel); + border-radius: 999px; + color: var(--mrd-muted); + font-size: 11px; + white-space: nowrap; +} + +.main { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(300px, 420px); + gap: 12px; + padding: 12px; + min-width: 0; +} + +.panel { + min-width: 0; + border: 1px solid var(--mrd-line); + background: var(--mrd-panel); + border-radius: 6px; + overflow: hidden; +} + +.panel-title { + margin: 0; + padding: 9px 11px; + border-bottom: 1px solid var(--mrd-line); + color: var(--mrd-muted); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.mosaic { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 10px; + padding: 11px; +} + +.tile { + display: grid; + gap: 6px; + width: 100%; + min-width: 0; + padding: 7px; + color: inherit; + background: var(--mrd-panel-strong); + border: 1px solid var(--mrd-line); + border-radius: 6px; + cursor: pointer; + text-align: left; +} + +.tile:hover, .tile:focus { border-color: var(--mrd-accent); outline: none; } +.tile[aria-selected="true"] { border-color: var(--mrd-accent); box-shadow: 0 0 0 1px var(--mrd-accent); } + +.tile img { + width: 100%; + aspect-ratio: 1; + object-fit: contain; + image-rendering: pixelated; + background: #000; +} + +.tile-placeholder { + display: grid; + place-items: center; + width: 100%; + aspect-ratio: 1; + padding: 8px; + background: var(--vscode-editor-background); + border: 1px dashed var(--mrd-line); + color: var(--mrd-muted); + font-size: 11px; + text-align: center; +} + +.tile-name { font-size: 12px; font-weight: 650; } +.tile-meta { color: var(--mrd-muted); font-size: 11px; overflow-wrap: anywhere; } + +.side { display: grid; gap: 12px; align-content: start; } +.detail { display: grid; gap: 10px; padding: 11px; } + +.selected-image { + display: grid; + place-items: center; + min-height: 220px; + background: var(--vscode-editor-background); + border: 1px solid var(--mrd-line); + border-radius: 6px; + color: var(--mrd-muted); +} + +.selected-image img { + max-width: 100%; + max-height: 52vh; + object-fit: contain; + image-rendering: pixelated; +} + +.mrd-viewport { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; +} + +.mrd-viewport-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.mrd-tool-button { + font: inherit; + font-size: 12px; + line-height: 1; + padding: 4px 8px; + color: var(--vscode-button-secondaryForeground, var(--mrd-text)); + background: var(--vscode-button-secondaryBackground, transparent); + border: 1px solid var(--mrd-line); + border-radius: 4px; + cursor: pointer; +} + +.mrd-tool-button:hover { + background: var(--vscode-button-secondaryHoverBackground, var(--mrd-line)); +} + +.mrd-zoom-label { + min-width: 44px; + text-align: center; + font-size: 12px; + color: var(--mrd-muted); + font-variant-numeric: tabular-nums; +} + +.mrd-viewport-frame { + position: relative; + overflow: hidden; + min-height: 220px; + height: 52vh; + background: var(--vscode-editor-background); + border: 1px solid var(--mrd-line); + border-radius: 6px; + touch-action: none; + cursor: default; +} + +.mrd-viewport-frame.is-pannable { + cursor: grab; +} + +.mrd-viewport-frame.is-panning { + cursor: grabbing; +} + +.mrd-viewport-image { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + image-rendering: pixelated; + user-select: none; + -webkit-user-drag: none; +} + +.mrd-tool-maximize { + margin-left: auto; + font-size: 15px; + line-height: 1; +} + +.mrd-viewport-resizer { + height: 10px; + margin-top: 2px; + cursor: ns-resize; + position: relative; + touch-action: none; +} + +.mrd-viewport-resizer::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 44px; + height: 3px; + transform: translate(-50%, -50%); + border-radius: 2px; + background: var(--mrd-line); +} + +.mrd-viewport-resizer:hover::after { + background: var(--vscode-focusBorder, var(--mrd-muted)); +} + +.mrd-slice-controls { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mrd-mosaic-mode { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.mrd-mosaic-mode-label { + font-size: 12px; + color: var(--mrd-muted); +} + +.mrd-segmented { + display: inline-flex; + border: 1px solid var(--mrd-line); + border-radius: 6px; + overflow: hidden; +} + +.mrd-segmented-button { + font: inherit; + font-size: 12px; + padding: 4px 12px; + border: none; + background: transparent; + color: var(--mrd-text); + cursor: pointer; +} + +.mrd-segmented-button + .mrd-segmented-button { + border-left: 1px solid var(--mrd-line); +} + +.mrd-segmented-button.is-active { + background: var(--vscode-button-background, var(--mrd-line)); + color: var(--vscode-button-foreground, var(--mrd-text)); +} + +.mrd-segmented-button:disabled { + opacity: 0.6; + cursor: default; +} + +.mrd-slice-row { + display: grid; + grid-template-columns: minmax(0, max-content) minmax(120px, 1fr); + align-items: center; + gap: 10px; +} + +.mrd-slice-name { + font-size: 12px; + color: var(--mrd-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.mrd-slice-row input[type="range"] { + width: 100%; + accent-color: var(--vscode-focusBorder, currentColor); +} + +body.mrd-maximized #mosaic-panel, +body.mrd-maximized #metadata-panel, +body.mrd-maximized .header { + display: none; +} + +body.mrd-maximized #selected-panel { + position: fixed; + inset: 0; + z-index: 20; + margin: 0; + border: none; + border-radius: 0; + display: flex; + flex-direction: column; + background: var(--mrd-panel, var(--vscode-editor-background)); +} + +body.mrd-maximized #selected-panel .detail { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +body.mrd-maximized .mrd-viewport-frame { + height: 70vh; + max-height: none; +} + +dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 10px; margin: 0; font-size: 12px; } +dt { color: var(--mrd-muted); } +dd { margin: 0; overflow-wrap: anywhere; } +ul { margin: 0; padding-left: 18px; } +li + li { margin-top: 4px; } + +.notice { + margin: 11px; + padding: 8px 9px; + border: 1px solid var(--mrd-warning-border); + background: var(--mrd-warning-bg); + border-radius: 6px; + font-size: 12px; +} + +.notice.error { border-color: var(--mrd-error-border); background: var(--mrd-error-bg); } +.empty { padding: 14px; color: var(--mrd-muted); } + +.tabs { + display: flex; + gap: 2px; + padding: 8px 8px 0; + border-bottom: 1px solid var(--mrd-line); + overflow-x: auto; +} + +.tab { + padding: 6px 8px; + border: 1px solid transparent; + border-bottom: none; + border-radius: 5px 5px 0 0; + color: var(--mrd-muted); + background: transparent; + cursor: pointer; + font-size: 12px; + white-space: nowrap; +} + +.tab:hover, .tab:focus { color: var(--mrd-text); outline: none; } +.tab[aria-selected="true"] { + color: var(--mrd-text); + background: var(--mrd-panel-strong); + border-color: var(--mrd-line); +} + +.tab-panel { display: none; padding: 11px; } +.tab-panel[aria-hidden="false"] { display: grid; gap: 12px; } + +.metadata-section { display: grid; gap: 8px; } +.metadata-section h3 { margin: 0; font-size: 12px; font-weight: 650; } +.metadata-note { color: var(--mrd-muted); font-size: 12px; line-height: 1.4; } +.metadata-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.metadata-table th, +.metadata-table td { padding: 4px 6px; border-bottom: 1px solid var(--mrd-line); text-align: left; vertical-align: top; } +.metadata-table th { color: var(--mrd-muted); font-weight: 500; } + +.json { + margin: 0; + max-height: 42vh; + overflow: auto; + padding: 11px; + background: var(--vscode-textCodeBlock-background); + font-family: var(--vscode-editor-font-family); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; +} + +@media (max-width: 900px) { + .header { grid-template-columns: 1fr; } + .stats { justify-content: flex-start; } + .main { grid-template-columns: 1fr; } +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer.ts b/mrd-viz/extension/mrd-viz/media/viewer.ts new file mode 100644 index 00000000..ea78e44e --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer.ts @@ -0,0 +1,50 @@ +// Client-side controller entry point for the MRD Viz webview. +// +// Server-injected values arrive exclusively through the `#mrd-payload` JSON script element (never +// string-interpolated into these modules), so this code can be type-checked, linted, and unit-tested +// independently of the extension host. esbuild bundles this module and its imports to `media/viewer.js`. + +import { state, persisted, isMaximized } from './viewer/state'; +import { renderShell } from './viewer/metadata'; +import { renderMosaic } from './viewer/mosaic'; +import { initMessaging } from './viewer/messaging'; +import { toggleMaximize } from './viewer/viewport'; + +initMessaging(); + +window.addEventListener('resize', function () { + if (state.activeViewport) { + state.activeViewport.refit(); + } +}); + +document.addEventListener('keydown', function (event) { + const target = event.target as HTMLElement; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { + return; + } + if (event.key === 'Escape' && isMaximized()) { + toggleMaximize(); + return; + } + if (!state.activeViewport) { + return; + } + if (event.key === '+' || event.key === '=') { + state.activeViewport.zoomIn(); + event.preventDefault(); + } else if (event.key === '-' || event.key === '_') { + state.activeViewport.zoomOut(); + event.preventDefault(); + } else if (event.key === '0') { + state.activeViewport.fit(); + event.preventDefault(); + } +}); + +if (persisted.maximized) { + document.body.classList.add('mrd-maximized'); +} + +renderShell(); +renderMosaic(); diff --git a/mrd-viz/extension/mrd-viz/media/viewer/dom.ts b/mrd-viz/extension/mrd-viz/media/viewer/dom.ts new file mode 100644 index 00000000..8a3c98db --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/dom.ts @@ -0,0 +1,40 @@ +// Small DOM primitives and value-formatting helpers shared across the viewer modules. + +export function valueOrUnknown(value) { + return value === undefined || value === null || value === '' ? 'unknown' : String(value); +} + +export function formatList(value) { + return Array.isArray(value) ? value.join('x') : ''; +} + +export function stat(label, value) { + const span = document.createElement('span'); + span.className = 'stat'; + span.textContent = label + ': ' + valueOrUnknown(value); + return span; +} + +export function notice(text, kind?) { + const div = document.createElement('div'); + div.className = kind === 'error' ? 'notice error' : 'notice'; + div.textContent = String(text); + return div; +} + +export function section(title) { + const root = document.createElement('section'); + root.className = 'metadata-section'; + const heading = document.createElement('h3'); + heading.textContent = title; + root.appendChild(heading); + return root; +} + +export function addField(root, key, value) { + const dt = document.createElement('dt'); + dt.textContent = key; + const dd = document.createElement('dd'); + dd.textContent = valueOrUnknown(value); + root.append(dt, dd); +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/messaging.ts b/mrd-viz/extension/mrd-viz/media/viewer/messaging.ts new file mode 100644 index 00000000..62b500ba --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/messaging.ts @@ -0,0 +1,29 @@ +// postMessage helpers, request-ID sequencing, and the window 'message' listener that routes backend +// responses to the selected-tile and mosaic handlers. + +import { vscode, state } from './state'; +import { handleImageLoaded, renderSelectedError } from './selectedTile'; +import { handleMosaicUpdated, handleMosaicError } from './mosaic'; + +export function nextRequestId() { + return String(++state.requestSequence); +} + +export function postMessage(message) { + vscode.postMessage(message); +} + +export function initMessaging() { + window.addEventListener('message', function (event) { + const message = event.data || {}; + if (message.type === 'imageLoaded') { + handleImageLoaded(message); + } else if (message.type === 'imageError' && message.requestId === state.pendingRequestId) { + renderSelectedError(message.error || 'Unable to load selected image.'); + } else if (message.type === 'mosaicUpdated') { + handleMosaicUpdated(message); + } else if (message.type === 'mosaicError' && message.requestId === state.mosaicRequestId) { + handleMosaicError(message.error || 'Unable to update the mosaic.'); + } + }); +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts b/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts new file mode 100644 index 00000000..1160643d --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts @@ -0,0 +1,285 @@ +// Header/metadata rendering: the shell (title, stats, notices), the metadata tabs, and the summary, +// organization, stream, and raw-JSON panels. + +import { payload } from './state'; +import { stat, notice, section, addField, valueOrUnknown, formatList } from './dom'; + +function metadata() { + return payload.metadata || {}; +} + +function images() { + return metadata().images || []; +} + +function acquisitions() { + return metadata().acquisitions || []; +} + +function waveforms() { + return metadata().waveforms || []; +} + +function otherItems() { + return metadata().other_items || []; +} + +function definitionList(entries) { + const list = document.createElement('dl'); + entries.forEach(function (entry) { + addField(list, entry[0], entry[1]); + }); + return list; +} + +function table(headers, rows) { + const tableRoot = document.createElement('table'); + tableRoot.className = 'metadata-table'; + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + headers.forEach(function (header) { + const th = document.createElement('th'); + th.textContent = header; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + tableRoot.appendChild(thead); + + const tbody = document.createElement('tbody'); + rows.forEach(function (row) { + const tr = document.createElement('tr'); + row.forEach(function (cell) { + const td = document.createElement('td'); + td.textContent = valueOrUnknown(cell); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + tableRoot.appendChild(tbody); + return tableRoot; +} + +function distribution(values) { + const counts = new Map(); + values.forEach(function (value) { + const key = valueOrUnknown(value); + counts.set(key, (counts.get(key) || 0) + 1); + }); + return Array.from(counts.entries()).sort(function (left, right) { + return String(left[0]).localeCompare(String(right[0]), undefined, { numeric: true }); + }); +} + +function headValue(image, key) { + return image && image.head ? image.head[key] : undefined; +} + +function shapeKey(image) { + return formatList(image && image.data_shape) || 'unknown'; +} + +function uniqueCount(values) { + return distribution(values).length; +} + +function appendEmpty(root, text) { + const empty = document.createElement('div'); + empty.className = 'metadata-note'; + empty.textContent = text; + root.appendChild(empty); +} + +function redactPayload(key, value) { + if (key === 'png_base64' && typeof value === 'string') { + return ''; + } + return value; +} + +export function renderShell() { + document.getElementById('title').textContent = payload.filename || 'MRD file'; + document.getElementById('subtitle').textContent = payload.path || ''; + + const stats = document.getElementById('stats'); + stats.textContent = ''; + stats.append( + stat('class', payload.file_class), + stat('mode', payload.display_mode), + stat('images', payload.stream && payload.stream.image_count), + stat('acq', payload.stream && payload.stream.acquisition_count), + stat('thumbs', payload.mosaic && payload.mosaic.thumbnails && payload.mosaic.thumbnails.length) + ); + + const notices = document.getElementById('notices'); + notices.textContent = ''; + if (!payload.ok) { + notices.appendChild(notice(payload.error || 'The backend reported an error.', 'error')); + } + (payload.warnings || []).forEach(function (warning) { + notices.appendChild(notice(warning, 'warning')); + }); + if (payload.mosaic && payload.mosaic.truncated) { + notices.appendChild(notice('Thumbnail payload is truncated by the configured maximum.')); + } + if (payload.file_class_reliable === false) { + notices.appendChild(notice('File classification is based on a partial stream read.', 'warning')); + } + + renderMetadata(); +} + +function renderMetadata() { + renderSummaryMetadata(); + renderOrganizationMetadata(); + renderStreamMetadata(); + renderRawJsonMetadata(); + document.querySelectorAll('.tab').forEach(function (tab) { + tab.addEventListener('click', function () { + activateTab(tab.dataset.tab); + }); + }); +} + +function activateTab(name) { + document.querySelectorAll('.tab').forEach(function (tab) { + tab.setAttribute('aria-selected', String(tab.dataset.tab === name)); + }); + document.querySelectorAll('.tab-panel').forEach(function (panel) { + panel.setAttribute('aria-hidden', String(panel.id !== 'metadata-' + name)); + }); +} + +function renderSummaryMetadata() { + const root = document.getElementById('metadata-summary'); + root.textContent = ''; + + const file = section('File'); + file.appendChild(definitionList([ + ['class', payload.file_class], + ['classification reliable', payload.file_class_reliable], + ['display mode', payload.display_mode], + ['schema version', payload.schema_version], + ['file size bytes', payload.file_size_bytes] + ])); + root.appendChild(file); + + const stream = payload.stream || {}; + const counts = section('Counts'); + counts.appendChild(definitionList([ + ['images', stream.image_count], + ['acquisitions', stream.acquisition_count], + ['waveforms', stream.waveform_count], + ['other items', stream.other_count], + ['returned thumbnails', payload.mosaic && payload.mosaic.thumbnails && payload.mosaic.thumbnails.length], + ['thumbnail payload truncated', payload.mosaic && payload.mosaic.truncated] + ])); + root.appendChild(counts); + + const summary = payload.summary || {}; + const header = section('Header Summary'); + header.appendChild(definitionList([ + ['encoding count', summary.encoding_count], + ['encoded matrix', formatList(summary.encoded_matrix)], + ['recon matrix', formatList(summary.recon_matrix)], + ['encoded FOV mm', formatList(summary.encoded_fov_mm)], + ['recon FOV mm', formatList(summary.recon_fov_mm)] + ])); + root.appendChild(header); + + if ((payload.warnings || []).length) { + const warnings = section('Warnings'); + const list = document.createElement('ul'); + payload.warnings.forEach(function (warning) { + const item = document.createElement('li'); + item.textContent = String(warning); + list.appendChild(item); + }); + warnings.appendChild(list); + root.appendChild(warnings); + } +} + +function renderOrganizationMetadata() { + const root = document.getElementById('metadata-organization'); + root.textContent = ''; + const imageItems = images(); + if (!imageItems.length) { + appendEmpty(root, 'No image metadata is available for this file.'); + return; + } + + const overview = section('Image Set'); + overview.appendChild(definitionList([ + ['images', imageItems.length], + ['unique slices', uniqueCount(imageItems.map(function (image) { return headValue(image, 'slice'); }))], + ['unique image types', uniqueCount(imageItems.map(function (image) { return headValue(image, 'image_type'); }))], + ['unique series', uniqueCount(imageItems.map(function (image) { return headValue(image, 'image_series_index'); }))], + ['unique shapes', uniqueCount(imageItems.map(shapeKey))], + ['unique dtypes', uniqueCount(imageItems.map(function (image) { return image.dtype; }))] + ])); + root.appendChild(overview); + + const sliceRows = distribution(imageItems.map(function (image) { return headValue(image, 'slice'); })); + const slices = section('Slice Distribution'); + slices.appendChild(table(['slice', 'image count'], sliceRows)); + root.appendChild(slices); + + const typeRows = distribution(imageItems.map(function (image) { return headValue(image, 'image_type'); })); + const types = section('Image Type Distribution'); + types.appendChild(table(['image type', 'image count'], typeRows)); + root.appendChild(types); + + const shapeRows = distribution(imageItems.map(function (image) { return shapeKey(image) + ' / ' + valueOrUnknown(image.dtype); })); + const shapes = section('Shape / Dtype Consistency'); + shapes.appendChild(table(['shape / dtype', 'image count'], shapeRows)); + root.appendChild(shapes); +} + +function renderStreamMetadata() { + const root = document.getElementById('metadata-stream'); + root.textContent = ''; + const stream = payload.stream || {}; + + const itemCounts = section('Stream Item Counts'); + const rows = Object.entries(stream.item_counts || {}).sort(function (left, right) { + return left[0].localeCompare(right[0]); + }); + if (rows.length) { + itemCounts.appendChild(table(['item type', 'count'], rows)); + } else { + appendEmpty(itemCounts, 'No stream item counts were returned.'); + } + root.appendChild(itemCounts); + + const examples = section('Metadata Examples'); + examples.appendChild(definitionList([ + ['image metadata entries', images().length], + ['acquisition examples', acquisitions().length], + ['waveform entries', waveforms().length], + ['other item entries', otherItems().length] + ])); + root.appendChild(examples); + + if (acquisitions().length) { + const firstAcquisition = acquisitions()[0]; + const acquisition = section('First Acquisition Example'); + acquisition.appendChild(definitionList([ + ['stream index', firstAcquisition.stream_index], + ['shape', formatList(firstAcquisition.data_shape)], + ['dtype', firstAcquisition.dtype], + ['flags', firstAcquisition.flags], + ['scan counter', firstAcquisition.scan_counter] + ])); + root.appendChild(acquisition); + } +} + +function renderRawJsonMetadata() { + document.getElementById('json').textContent = JSON.stringify({ + summary: payload.summary, + stream: payload.stream, + warnings: payload.warnings, + metadata: payload.metadata, + mosaic: payload.mosaic + }, redactPayload, 2); +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/mosaic.ts b/mrd-viz/extension/mrd-viz/media/viewer/mosaic.ts new file mode 100644 index 00000000..ef8c971d --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/mosaic.ts @@ -0,0 +1,194 @@ +// The thumbnail mosaic: tile buttons, the images/slices view toggle, and mosaic-update handling. + +import { state, payload } from './state'; +import { notice, formatList, valueOrUnknown } from './dom'; +import { selectTile, renderSelectedTile } from './selectedTile'; +import { nextRequestId, postMessage } from './messaging'; + +export function renderMosaic() { + const root = document.getElementById('mosaic'); + root.textContent = ''; + removeMosaicModeControls(); + const tiles = currentTiles(); + if (!tiles.length) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = payload.display_mode === 'metadata_only' + ? 'No renderable image thumbnails. Metadata is available in the payload summary.' + : 'No mosaic thumbnails were returned.'; + root.appendChild(empty); + renderSelectedTile(null); + return; + } + + tiles.forEach(function (tile, tileIndex) { + tile.mosaicKey = tileIndex; + const button = document.createElement('button'); + button.className = 'tile'; + button.type = 'button'; + button.dataset.imageIndex = String(tile.image_index); + button.dataset.tileKey = String(tileIndex); + button.setAttribute('aria-selected', 'false'); + + if (tile.png_base64) { + const img = document.createElement('img'); + img.src = 'data:image/png;base64,' + tile.png_base64; + img.alt = 'MRD image item ' + valueOrUnknown(tile.image_index); + button.appendChild(img); + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'tile-placeholder'; + placeholder.textContent = tile.render_error || 'Not renderable'; + button.appendChild(placeholder); + } + + const title = document.createElement('div'); + title.className = 'tile-name'; + title.textContent = tile.tile_title || ('Image ' + valueOrUnknown(tile.image_index)); + button.appendChild(title); + + const meta = document.createElement('div'); + meta.className = 'tile-meta'; + meta.textContent = formatList(tile.data_shape) + ' | stream ' + valueOrUnknown(tile.stream_index); + button.appendChild(meta); + + button.addEventListener('click', function () { + selectTile(tile); + }); + + root.appendChild(button); + }); + + renderMosaicModeControls(tiles); + selectTile(tiles[0]); +} + +function currentTiles() { + // The host forwards backend output; guard against a non-array `thumbnails` (e.g. from an + // incompatible or corrupted backend) so `.length`/`.forEach` below cannot throw and blank + // the editor. + const tiles = payload.mosaic && payload.mosaic.thumbnails; + return Array.isArray(tiles) ? tiles : []; +} + +function removeMosaicModeControls() { + const existing = document.getElementById('mosaic-mode'); + if (existing) { + existing.remove(); + } +} + +function mosaicCanExplode(tiles) { + return tiles.some(function (tile) { + const dims = tile && tile.slice_dims; + if (!Array.isArray(dims) || !dims.length) { + return false; + } + const z = dims[dims.length - 1]; + return Boolean(z) && (Number(z.size) || 1) > 1; + }); +} + +function setMosaicNotice(text, kind) { + const noticesEl = document.getElementById('notices'); + if (!noticesEl) { + return; + } + // Manage only our own status node so backend error/warning notices from renderShell() survive. + const existing = document.getElementById('mosaic-status'); + if (existing) { + existing.remove(); + } + if (text) { + const node = notice(text, kind); + node.id = 'mosaic-status'; + noticesEl.appendChild(node); + } +} + +function makeMosaicModeButton(mode, label, title) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'mrd-segmented-button'; + button.textContent = label; + button.title = title; + if (state.mosaicMode === mode) { + button.classList.add('is-active'); + } + button.setAttribute('aria-pressed', state.mosaicMode === mode ? 'true' : 'false'); + button.disabled = state.mosaicPending; + button.addEventListener('click', function () { + setMosaicMode(mode); + }); + return button; +} + +function renderMosaicModeControls(tiles) { + removeMosaicModeControls(); + if (!mosaicCanExplode(tiles)) { + return; + } + + const bar = document.createElement('div'); + bar.id = 'mosaic-mode'; + bar.className = 'mrd-mosaic-mode'; + + const label = document.createElement('span'); + label.className = 'mrd-mosaic-mode-label'; + label.textContent = 'View'; + bar.appendChild(label); + + const group = document.createElement('div'); + group.className = 'mrd-segmented'; + group.setAttribute('role', 'group'); + group.append( + makeMosaicModeButton('images', 'Images', 'One thumbnail per image'), + makeMosaicModeButton('slices', 'Slices', 'One thumbnail per z slice') + ); + bar.appendChild(group); + + const mosaicEl = document.getElementById('mosaic'); + mosaicEl.parentNode.insertBefore(bar, mosaicEl); +} + +function setMosaicMode(mode) { + if (state.mosaicPending || mode === state.mosaicMode) { + return; + } + state.mosaicRevertMode = state.mosaicMode; + state.mosaicMode = mode; + state.mosaicPending = true; + const requestId = nextRequestId(); + state.mosaicRequestId = requestId; + renderMosaicModeControls(currentTiles()); + setMosaicNotice(mode === 'slices' ? 'Rendering individual slices...' : 'Rebuilding image mosaic...', 'warning'); + postMessage({ + type: 'setMosaicMode', + requestId: requestId, + mode: mode + }); +} + +export function handleMosaicUpdated(message) { + if (message.requestId !== state.mosaicRequestId) { + return; + } + + state.mosaicPending = false; + const responsePayload = message.payload || {}; + if (responsePayload.ok !== true || !responsePayload.mosaic) { + handleMosaicError((responsePayload && responsePayload.error) || 'Unable to update the mosaic.'); + return; + } + + payload.mosaic = responsePayload.mosaic; + setMosaicNotice('', null); + renderMosaic(); +} + +export function handleMosaicError(error) { + state.mosaicPending = false; + state.mosaicMode = state.mosaicRevertMode; + setMosaicNotice(String(error), 'error'); + renderMosaicModeControls(currentTiles()); +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/selectedTile.ts b/mrd-viz/extension/mrd-viz/media/viewer/selectedTile.ts new file mode 100644 index 00000000..fa4014d3 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/selectedTile.ts @@ -0,0 +1,159 @@ +// The selected-tile detail panel: full-resolution image loading (with caching), per-slice controls, +// and the metadata field list. + +import { state, imageCache, cacheImage } from './state'; +import { notice, formatList, addField } from './dom'; +import { cacheKey, buildSliceSlider, defaultSliceCoords } from './slice'; +import { createImageViewport } from './viewport'; +import { nextRequestId, postMessage } from './messaging'; + +export function selectTile(tile) { + if (!tile) { + state.selectedImageIndex = null; + state.selectedTileThumb = null; + state.selectedTileKey = null; + state.selectedSliceDims = []; + state.selectedSliceCoords = []; + renderSelectedTile(null); + return; + } + + state.selectedImageIndex = Number(tile.image_index); + state.selectedTileThumb = tile; + state.selectedTileKey = tile.mosaicKey != null ? String(tile.mosaicKey) : null; + state.selectedSliceDims = Array.isArray(tile.slice_dims) ? tile.slice_dims : []; + state.selectedSliceCoords = defaultSliceCoords(state.selectedSliceDims, tile.source_plane); + loadSelectedImage(); +} + +function loadSelectedImage() { + const canLoad = Number.isInteger(state.selectedImageIndex) + && state.selectedImageIndex >= 0 + && state.selectedTileThumb + && Boolean(state.selectedTileThumb.renderable); + if (!canLoad) { + renderSelectedTile(state.selectedTileThumb); + return; + } + + const key = cacheKey(state.selectedImageIndex, state.selectedSliceCoords); + const cachedImage = imageCache.get(key); + if (cachedImage) { + renderSelectedTile(cachedImage, 'Loaded from selection cache.'); + return; + } + + renderSelectedTile(state.selectedTileThumb, 'Loading full-resolution image...'); + const requestId = nextRequestId(); + state.pendingRequestId = requestId; + state.pendingRequestIndex = state.selectedImageIndex; + state.pendingRequestCoords = state.selectedSliceCoords.slice(); + postMessage({ + type: 'loadImage', + requestId: requestId, + imageIndex: state.selectedImageIndex, + sliceCoords: state.selectedSliceCoords.slice() + }); +} + +export function renderSelectedTile(tile, statusText?) { + state.selectedIndex = tile ? tile.image_index : null; + document.querySelectorAll('.tile').forEach(function (node) { + node.setAttribute('aria-selected', String(state.selectedTileKey !== null && node.dataset.tileKey === state.selectedTileKey)); + }); + + const root = document.getElementById('detail'); + root.textContent = ''; + if (!tile) { + state.activeViewport = null; + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No tile selected.'; + root.appendChild(empty); + return; + } + + if (statusText) { + root.appendChild(notice(statusText, 'warning')); + } + + if (tile.png_base64) { + root.appendChild(createImageViewport(tile)); + } else { + state.activeViewport = null; + const frame = document.createElement('div'); + frame.className = 'selected-image'; + frame.textContent = tile.render_error || 'No image payload available.'; + root.appendChild(frame); + } + + const sliceControls = buildSelectedSliceControls(); + if (sliceControls) { + root.appendChild(sliceControls); + } + + const fields = document.createElement('dl'); + const head = tile.head || {}; + addField(fields, 'slice', head.slice); + addField(fields, 'phase', head.phase); + addField(fields, 'contrast', head.contrast); + addField(fields, 'repetition', head.repetition); + addField(fields, 'image type', head.image_type); + addField(fields, 'series', head.image_series_index); + addField(fields, 'field of view', formatList(head.field_of_view)); + addField(fields, 'image index', tile.image_index); + addField(fields, 'stream index', tile.stream_index); + addField(fields, 'stream item type', tile.stream_item_type); + addField(fields, 'data shape', formatList(tile.data_shape)); + addField(fields, 'rendered shape', formatList(tile.rendered_shape)); + addField(fields, 'dtype', tile.dtype); + addField(fields, 'source plane', JSON.stringify(tile.source_plane)); + root.appendChild(fields); +} + +export function handleImageLoaded(message) { + if (message.requestId !== state.pendingRequestId) { + return; + } + + const responsePayload = message.payload || {}; + if (!responsePayload.ok || !responsePayload.image) { + renderSelectedError(responsePayload.error || 'Unable to load selected image.'); + return; + } + + const image = responsePayload.image; + if (Number.isInteger(state.pendingRequestIndex)) { + cacheImage(cacheKey(state.pendingRequestIndex, state.pendingRequestCoords), image); + } + + renderSelectedTile(image); +} + +function buildSelectedSliceControls() { + if (!state.selectedSliceDims || !state.selectedSliceDims.length) { + return null; + } + + const wrap = document.createElement('div'); + wrap.className = 'mrd-slice-controls'; + let rendered = false; + state.selectedSliceDims.forEach(function (dim) { + if ((Number(dim.size) || 1) <= 1) { + return; + } + rendered = true; + wrap.appendChild(buildSliceSlider(dim, state.selectedSliceCoords[Number(dim.axis)], function (axis, value) { + state.selectedSliceCoords[axis] = value; + loadSelectedImage(); + })); + }); + return rendered ? wrap : null; +} + +export function renderSelectedError(error) { + state.activeViewport = null; + const root = document.getElementById('detail'); + root.textContent = ''; + root.appendChild(notice(error, 'error')); +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/slice.ts b/mrd-viz/extension/mrd-viz/media/viewer/slice.ts new file mode 100644 index 00000000..a66a677d --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/slice.ts @@ -0,0 +1,54 @@ +// Slice-coordinate math, image cache keys, and the per-axis slider control. + +export function clampIndex(value, size) { + const numeric = Number.isInteger(value) ? value : Number(value) || 0; + return Math.max(0, Math.min(numeric, size - 1)); +} + +export function defaultSliceCoords(dims, sourcePlane) { + const coords = []; + (dims || []).forEach(function (dim) { + const axis = Number(dim.axis); + let value = 0; + if (sourcePlane && typeof sourcePlane === 'object' && dim.name in sourcePlane) { + value = Number(sourcePlane[dim.name]) || 0; + } + coords[axis] = clampIndex(value, Number(dim.size) || 1); + }); + return coords; +} + +export function cacheKey(imageIndex, coords) { + return String(imageIndex) + '@' + (coords || []).join(','); +} + +export function buildSliceSlider(dim, currentValue, onCommit) { + const size = Number(dim.size) || 1; + const axis = Number(dim.axis); + const label = dim.name || ('axis ' + axis); + const current = clampIndex(currentValue, size); + + const row = document.createElement('div'); + row.className = 'mrd-slice-row'; + + const caption = document.createElement('span'); + caption.className = 'mrd-slice-name'; + caption.textContent = label + ' ' + current + ' / ' + (size - 1); + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = String(size - 1); + slider.step = '1'; + slider.value = String(current); + slider.setAttribute('aria-label', 'Step ' + label); + slider.addEventListener('input', function () { + caption.textContent = label + ' ' + slider.value + ' / ' + (size - 1); + }); + slider.addEventListener('change', function () { + onCommit(axis, Number(slider.value)); + }); + + row.append(caption, slider); + return row; +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/state.ts b/mrd-viz/extension/mrd-viz/media/viewer/state.ts new file mode 100644 index 00000000..a1b80300 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/state.ts @@ -0,0 +1,59 @@ +// Shared mutable state, the VS Code API handle, the server-injected payload/config bootstrap, and +// the selection image cache. +// +// Server-injected values arrive exclusively through the `#mrd-payload` JSON script element (never +// string-interpolated), so these modules can be type-checked, linted, and unit-tested independently +// of the extension host. + +export const vscode = acquireVsCodeApi(); + +const bootstrapElement = document.getElementById('mrd-payload'); +const bootstrap = JSON.parse((bootstrapElement && bootstrapElement.textContent) || '{}'); + +export const payload = bootstrap.payload || {}; +export const config = bootstrap.config || {}; + +export const persisted = (vscode.getState() || {}) as any; + +// All cross-module mutable state lives in this single object so modules can share and reassign it +// without relying on live bindings. +export const state = { + selectedIndex: null as any, + selectedTileKey: null as any, + requestSequence: 0, + pendingRequestId: null as any, + pendingRequestIndex: null as any, + pendingRequestCoords: [] as any[], + activeViewport: null as any, + selectedImageIndex: null as any, + selectedTileThumb: null as any, + selectedSliceDims: [] as any[], + selectedSliceCoords: [] as any[], + mosaicMode: 'images' as string, + mosaicRevertMode: 'images' as string, + mosaicPending: false, + mosaicRequestId: null as any, + viewportHeight: Number(persisted.viewportHeight) || 0, +}; + +const MAX_IMAGE_CACHE_ENTRIES = Number(config.maxImageCacheEntries) || 32; + +export const imageCache = new Map(); + +export function cacheImage(key, image) { + if (imageCache.has(key)) { + imageCache.delete(key); + } else if (imageCache.size >= MAX_IMAGE_CACHE_ENTRIES) { + const oldestKey = imageCache.keys().next().value; + imageCache.delete(oldestKey); + } + imageCache.set(key, image); +} + +export function isMaximized() { + return document.body.classList.contains('mrd-maximized'); +} + +export function persistState() { + vscode.setState({ viewportHeight: state.viewportHeight, maximized: isMaximized() }); +} diff --git a/mrd-viz/extension/mrd-viz/media/viewer/viewport.ts b/mrd-viz/extension/mrd-viz/media/viewer/viewport.ts new file mode 100644 index 00000000..cdcac31d --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/viewport.ts @@ -0,0 +1,227 @@ +// The interactive image surface: zoom/pan, resize, and the full-window (maximize) toggle. + +import { state, persistState, isMaximized } from './state'; +import { valueOrUnknown } from './dom'; + +function makeToolButton(label, title, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'mrd-tool-button'; + button.textContent = label; + button.title = title; + button.addEventListener('click', onClick); + return button; +} + +export function toggleMaximize() { + document.body.classList.toggle('mrd-maximized'); + persistState(); + if (state.activeViewport) { + if (state.activeViewport.syncControls) { + state.activeViewport.syncControls(); + } + requestAnimationFrame(state.activeViewport.refit); + } +} + +// Builds an interactive image surface (zoom/pan + full-window toggle) for the selected tile. +export function createImageViewport(tile) { + const container = document.createElement('div'); + container.className = 'mrd-viewport'; + + const frame = document.createElement('div'); + frame.className = 'mrd-viewport-frame'; + + const img = document.createElement('img'); + img.className = 'mrd-viewport-image'; + img.src = 'data:image/png;base64,' + tile.png_base64; + img.alt = 'Selected MRD image item ' + valueOrUnknown(tile.image_index); + img.draggable = false; + frame.appendChild(img); + + const MIN_SCALE = 0.05; + const MAX_SCALE = 40; + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let mode = 'fit'; + + const zoomLabel = document.createElement('span'); + zoomLabel.className = 'mrd-zoom-label'; + + function naturalSize() { + return { w: img.naturalWidth || 1, h: img.naturalHeight || 1 }; + } + + function fitScale() { + const n = naturalSize(); + const fw = frame.clientWidth || 1; + const fh = frame.clientHeight || 1; + return Math.min(fw / n.w, fh / n.h); + } + + function apply() { + img.style.transform = 'translate(' + offsetX + 'px, ' + offsetY + 'px) scale(' + scale + ')'; + zoomLabel.textContent = Math.round(scale * 100) + '%'; + frame.classList.toggle('is-pannable', scale > fitScale() + 0.0001); + } + + function fit() { + mode = 'fit'; + scale = fitScale(); + const n = naturalSize(); + offsetX = (frame.clientWidth - n.w * scale) / 2; + offsetY = (frame.clientHeight - n.h * scale) / 2; + apply(); + } + + function setScaleAbout(newScale, cx, cy) { + newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); + const ix = (cx - offsetX) / scale; + const iy = (cy - offsetY) / scale; + scale = newScale; + offsetX = cx - ix * scale; + offsetY = cy - iy * scale; + apply(); + } + + function zoomBy(factor, cx, cy) { + mode = 'free'; + setScaleAbout(scale * factor, cx, cy); + } + + function actualSize() { + mode = 'free'; + setScaleAbout(1, frame.clientWidth / 2, frame.clientHeight / 2); + } + + function refit() { + if (mode === 'fit') { + fit(); + } else { + apply(); + } + } + + frame.addEventListener('wheel', function (event) { + event.preventDefault(); + const rect = frame.getBoundingClientRect(); + const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1; + zoomBy(factor, event.clientX - rect.left, event.clientY - rect.top); + }, { passive: false }); + + let dragging = false; + let lastX = 0; + let lastY = 0; + frame.addEventListener('pointerdown', function (event) { + dragging = true; + lastX = event.clientX; + lastY = event.clientY; + frame.classList.add('is-panning'); + try { + frame.setPointerCapture(event.pointerId); + } catch (err) { /* pointer capture is best-effort */ } + }); + frame.addEventListener('pointermove', function (event) { + if (!dragging) { + return; + } + offsetX += event.clientX - lastX; + offsetY += event.clientY - lastY; + lastX = event.clientX; + lastY = event.clientY; + mode = 'free'; + apply(); + }); + function endDrag(event) { + if (!dragging) { + return; + } + dragging = false; + frame.classList.remove('is-panning'); + try { + frame.releasePointerCapture(event.pointerId); + } catch (err) { /* pointer capture is best-effort */ } + } + frame.addEventListener('pointerup', endDrag); + frame.addEventListener('pointercancel', endDrag); + frame.addEventListener('dblclick', toggleMaximize); + + function zoomIn() { + zoomBy(1.2, frame.clientWidth / 2, frame.clientHeight / 2); + } + + function zoomOut() { + zoomBy(1 / 1.2, frame.clientWidth / 2, frame.clientHeight / 2); + } + + const maximizeButton = makeToolButton('\u2922', 'Maximize', toggleMaximize); + maximizeButton.classList.add('mrd-tool-maximize'); + function syncMaximizeButton() { + maximizeButton.textContent = isMaximized() ? '\u2921' : '\u2922'; + maximizeButton.title = isMaximized() ? 'Restore view (Esc)' : 'Maximize'; + maximizeButton.setAttribute('aria-label', maximizeButton.title); + } + syncMaximizeButton(); + + const toolbar = document.createElement('div'); + toolbar.className = 'mrd-viewport-toolbar'; + toolbar.append( + makeToolButton('\u2212', 'Zoom out (-)', zoomOut), + zoomLabel, + makeToolButton('+', 'Zoom in (+)', zoomIn), + makeToolButton('Fit', 'Fit image to window (0)', fit), + makeToolButton('1:1', 'Actual size', actualSize), + maximizeButton + ); + + const resizer = document.createElement('div'); + resizer.className = 'mrd-viewport-resizer'; + resizer.title = 'Drag to resize the image area'; + resizer.setAttribute('aria-label', 'Resize image area'); + let resizing = false; + let resizeStartY = 0; + let resizeStartHeight = 0; + resizer.addEventListener('pointerdown', function (event) { + resizing = true; + resizeStartY = event.clientY; + resizeStartHeight = frame.getBoundingClientRect().height; + try { + resizer.setPointerCapture(event.pointerId); + } catch (err) { /* pointer capture is best-effort */ } + event.preventDefault(); + }); + resizer.addEventListener('pointermove', function (event) { + if (!resizing) { + return; + } + state.viewportHeight = Math.max(140, resizeStartHeight + (event.clientY - resizeStartY)); + frame.style.height = state.viewportHeight + 'px'; + refit(); + }); + function endResize(event) { + if (!resizing) { + return; + } + resizing = false; + try { + resizer.releasePointerCapture(event.pointerId); + } catch (err) { /* pointer capture is best-effort */ } + persistState(); + } + resizer.addEventListener('pointerup', endResize); + resizer.addEventListener('pointercancel', endResize); + + if (state.viewportHeight > 0) { + frame.style.height = state.viewportHeight + 'px'; + } + + img.addEventListener('load', refit); + if (img.complete && img.naturalWidth) { + requestAnimationFrame(fit); + } + + state.activeViewport = { refit: refit, zoomIn: zoomIn, zoomOut: zoomOut, fit: fit, syncControls: syncMaximizeButton }; + container.append(toolbar, frame, resizer); + return container; +} diff --git a/mrd-viz/extension/mrd-viz/package-lock.json b/mrd-viz/extension/mrd-viz/package-lock.json new file mode 100644 index 00000000..ef510cf0 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/package-lock.json @@ -0,0 +1,3745 @@ +{ + "name": "mrd-viz", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mrd-viz", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@types/mocha": "^10.0.10", + "@types/node": "22.x", + "@types/vscode": "^1.120.0", + "@types/vscode-webview": "^1.57.5", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", + "esbuild": "^0.25.12", + "eslint": "^9.39.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" + }, + "engines": { + "vscode": "^1.120.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode-webview": { + "version": "1.57.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/vscode-webview/-/vscode-webview-1.57.5.tgz", + "integrity": "sha1-W5EFJThsAjBesdB3LgGBxfGcV5s=", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vscode/test-cli": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.12.tgz", + "integrity": "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mocha": "^10.0.10", + "c8": "^10.1.3", + "chokidar": "^3.6.0", + "enhanced-resolve": "^5.18.3", + "glob": "^10.3.10", + "minimatch": "^9.0.3", + "mocha": "^11.7.4", + "supports-color": "^10.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", + "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.6.tgz", + "integrity": "sha512-ATTK5Q4gFVg0YDp1my2vqygyvhcklD/UV5GIlYHooGTn/NogJqIzpetkD6E5kmuVULqz/S9inUL25XcAgDRJQg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/mrd-viz/extension/mrd-viz/package.json b/mrd-viz/extension/mrd-viz/package.json new file mode 100644 index 00000000..3d7958ec --- /dev/null +++ b/mrd-viz/extension/mrd-viz/package.json @@ -0,0 +1,110 @@ +{ + "name": "mrd-viz", + "displayName": "MRD Viz", + "description": "MRD file viewer for VS Code", + "version": "0.0.1", + "publisher": "ismrmrd", + "license": "MIT", + "icon": "media/icon.png", + "repository": { + "type": "git", + "url": "https://github.com/ismrmrd/mrd.git" + }, + "engines": { + "vscode": "^1.120.0" + }, + "categories": [ + "Other" + ], + "main": "./out/extension.js", + "activationEvents": [], + "contributes": { + "commands": [ + { + "command": "mrd-viz.openFile", + "title": "Open File", + "category": "MRD Viz" + }, + { + "command": "mrd-viz.setUpBackend", + "title": "Set Up Backend", + "category": "MRD Viz" + }, + { + "command": "mrd-viz.selectInterpreter", + "title": "Select Python Interpreter", + "category": "MRD Viz" + } + ], + "customEditors": [ + { + "viewType": "mrd-viz.mrdFile", + "displayName": "MRD Viz", + "selector": [ + { + "filenamePattern": "*.mrd" + } + ], + "priority": "default" + } + ], + "configuration": { + "title": "MRD Viz", + "properties": { + "mrdViz.backendPath": { + "type": "string", + "scope": "machine", + "markdownDescription": "Developer override for the MRD Viz backend. Provide a path to a Python interpreter (runs `python -m mrd_viz.cli`) or to a prebuilt `mrd-viz` backend binary. **Leave unset to use the backend bundled with the extension.** Machine-scoped so an absolute path is never committed to a workspace or synced between machines." + }, + "mrdViz.maxThumbnails": { + "type": "number", + "default": 128, + "minimum": 0, + "markdownDescription": "Maximum number of MRD image thumbnails requested from the backend for the initial open-file payload." + }, + "mrdViz.backendTimeoutMs": { + "type": "number", + "default": 30000, + "minimum": 1000, + "markdownDescription": "Timeout in milliseconds for one short-lived MRD Viz backend process." + } + } + }, + "menus": { + "explorer/context": [ + { + "command": "mrd-viz.openFile", + "when": "resourceExtname == .mrd", + "group": "navigation" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./ && npm run check:webview && npm run build:webview", + "check:webview": "tsc --noEmit -p media", + "build:webview": "esbuild media/viewer.ts --bundle --format=iife --outfile=media/viewer.js --target=es2020", + "watch": "tsc -watch -p ./", + "watch:webview": "esbuild media/viewer.ts --bundle --format=iife --outfile=media/viewer.js --target=es2020 --watch", + "pretest": "npm run compile && npm run lint", + "lint": "eslint src", + "test": "vscode-test" + }, + "devDependencies": { + "@types/mocha": "^10.0.10", + "@types/node": "22.x", + "@types/vscode": "^1.120.0", + "@types/vscode-webview": "^1.57.5", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", + "esbuild": "^0.25.12", + "eslint": "^9.39.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" + }, + "overrides": { + "diff": "^8.0.3", + "serialize-javascript": "^7.0.5" + } +} diff --git a/mrd-viz/extension/mrd-viz/src/backendConstants.ts b/mrd-viz/extension/mrd-viz/src/backendConstants.ts new file mode 100644 index 00000000..563bd0a0 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/backendConstants.ts @@ -0,0 +1,37 @@ +// Centralized timeouts, buffer sizes, and backend invocation constants so the several subprocess +// call sites (resolver probe, backend run, Python-version probe, provisioning) don't drift. + +/** Default per-process backend timeout. Must match the `mrdViz.backendTimeoutMs` default in package.json. */ +export const BACKEND_TIMEOUT_MS_DEFAULT = 30_000; + +/** Bounds applied to the configured timeout when probing a candidate with `--version`. */ +export const PROBE_TIMEOUT_MS_MIN = 1_000; +export const PROBE_TIMEOUT_MS_MAX = 15_000; + +/** Timeout for the quick `sys.version_info` probe used to find a provisioning interpreter. */ +export const PYTHON_VERSION_PROBE_TIMEOUT_MS = 10_000; + +/** Timeout for one provisioning step (venv creation / pip install); pip installs can be slow. */ +export const PROVISIONING_STEP_TIMEOUT_MS = 10 * 60 * 1_000; + +/** stdout/stderr buffer for a backend payload response (base64 PNGs can be large). */ +export const BACKEND_RESPONSE_MAX_BUFFER_BYTES = 64 * 1024 * 1024; + +/** stdout/stderr buffer for a provisioning step's logs. */ +export const PROVISIONING_LOG_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +/** Leading args that invoke the backend CLI through a Python interpreter. */ +export const PYTHON_MODULE_ARGS = ['-m', 'mrd_viz.cli'] as const; + +/** The pip distribution name for the backend (not yet published to PyPI). */ +export const PYPI_PACKAGE_NAME = 'mrd-viz'; + +/** + * Base name of the standalone backend binary (the PyInstaller output bundled in the VSIX at + * `media/backend/`). Kept here so a rename of the backend/binary touches one place rather than + * scattered string literals in the resolver. + */ +export const BACKEND_BINARY_NAME = 'mrd-viz'; + +/** Interpreter commands tried, in order, when discovering a Python to build the managed venv. */ +export const PROVISIONING_PYTHON_CANDIDATES = ['python3.12', 'python3', 'python'] as const; diff --git a/mrd-viz/extension/mrd-viz/src/backendResolver.ts b/mrd-viz/extension/mrd-viz/src/backendResolver.ts new file mode 100644 index 00000000..3e8f7734 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/backendResolver.ts @@ -0,0 +1,246 @@ +import { type ExecFileException } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { BACKEND_BINARY_NAME, PROBE_TIMEOUT_MS_MAX, PROBE_TIMEOUT_MS_MIN, PYTHON_MODULE_ARGS } from './backendConstants'; +import { runProcess } from './subprocess'; + +/** How a backend candidate was selected. Drives tailored failure messaging in the webview. */ +export type BackendKind = 'override' | 'bundled' | 'development'; + +/** A validated way to invoke the MRD Viz backend: `command` plus fixed leading args. */ +export interface ResolvedBackend { + command: string; + baseArgs: string[]; + source: string; + kind: BackendKind; +} + +/** A candidate that was probed but rejected, plus the reason its `--version` probe failed. */ +export interface BackendAttempt { + source: string; + /** Which tier this candidate belonged to, so the UI can distinguish a broken override from a missing binary. */ + kind?: BackendKind; + /** Concise, webview-friendly failure reason (last lines of probe stderr, or the spawn error). */ + detail?: string; + /** Complete captured probe output, logged in full to the output channel (never truncated). */ + detailFull?: string; +} + +export type BackendResolution = + | { ok: true; backend: ResolvedBackend } + | { ok: false; tried: BackendAttempt[] }; + +let cachedBackend: ResolvedBackend | undefined; + +/** Drop the cached backend so the next resolve re-probes (e.g. after a settings change). */ +export function invalidateBackendCache(): void { + cachedBackend = undefined; +} + +/** + * Find the first candidate whose `--version` probe succeeds, in priority order. + * The result is cached for the session; call {@link invalidateBackendCache} to reset. + */ +export async function resolveBackend(context: vscode.ExtensionContext, validationTimeoutMs: number): Promise { + if (cachedBackend) { + return { ok: true, backend: cachedBackend }; + } + + const candidates = planBackendCandidates({ + configuredPath: getConfiguredBackendPath(), + bundledBinaryPath: bundledBinaryPath(context), + developmentVenvPath: developmentVenvPython(context), + isDevelopment: context.extensionMode === vscode.ExtensionMode.Development, + }); + + // No override and no bundled binary for this platform means there is nothing to probe and no way + // the extension can work. Surface that explicitly rather than returning an empty `tried` list the + // webview cannot explain. + if (candidates.length === 0) { + return { ok: false, tried: [noBackendAvailableAttempt()] }; + } + + const tried: BackendAttempt[] = []; + for (const candidate of candidates) { + const probe = await validateBackend(candidate, validationTimeoutMs); + if (probe.ok) { + cachedBackend = candidate; + return { ok: true, backend: candidate }; + } + tried.push({ source: candidate.source, kind: candidate.kind, detail: probe.detail, detailFull: probe.detailFull }); + } + + return { ok: false, tried }; +} + +const BACKEND_PATH_SETTING = 'mrdViz.backendPath'; + +/** + * The developer override, if set: the machine-scoped `mrdViz.backendPath`. Returns undefined when + * the user has configured nothing — the signal that this is an end-user install that should use the + * bundled backend. + */ +function getConfiguredBackendPath(): string | undefined { + return vscode.workspace.getConfiguration('mrdViz').get('backendPath')?.trim() || undefined; +} + +/** Inputs to {@link planBackendCandidates}; plain data so the ordering logic stays pure and testable. */ +export interface BackendCandidateInputs { + /** Developer override path (interpreter or binary), or undefined when unset. */ + configuredPath?: string; + /** Path to the bundled binary if present in the VSIX, else undefined. */ + bundledBinaryPath?: string; + /** Path to the repo `backend/.venv` interpreter if present, else undefined. */ + developmentVenvPath?: string; + /** True when the extension host is the F5 Development host (`ExtensionMode.Development`). */ + isDevelopment: boolean; +} + +/** + * Deterministic, two-tier candidate order (see docs/BACKEND_INSTALL_MODES.md): + * + * - If a developer override is set, it is the ONLY candidate — no silent fallback to the bundled + * binary, so a broken override fails loudly instead of masking a misconfiguration. + * - Otherwise (end user / unconfigured) use the bundled binary. In the Development host only, the + * repo `backend/.venv` is tried first so contributors run their live checkout. + */ +export function planBackendCandidates(inputs: BackendCandidateInputs): ResolvedBackend[] { + if (inputs.configuredPath) { + return [configuredBackend(inputs.configuredPath)]; + } + + const candidates: ResolvedBackend[] = []; + if (inputs.isDevelopment && inputs.developmentVenvPath) { + candidates.push({ + command: inputs.developmentVenvPath, + baseArgs: [...PYTHON_MODULE_ARGS], + source: `development environment (${inputs.developmentVenvPath})`, + kind: 'development', + }); + } + if (inputs.bundledBinaryPath) { + candidates.push({ + command: inputs.bundledBinaryPath, + baseArgs: [], + source: `bundled backend (${inputs.bundledBinaryPath})`, + kind: 'bundled', + }); + } + return candidates; +} + +/** + * The failure surfaced when {@link planBackendCandidates} yields nothing: no `mrdViz.backendPath` + * override and no bundled binary shipped for this platform. Gives the webview a concrete, actionable + * message (and its setup buttons) instead of an empty attempt list. + */ +function noBackendAvailableAttempt(): BackendAttempt { + return { + source: 'no backend available', + detail: `No ${BACKEND_PATH_SETTING} is set and no bundled backend was shipped for this platform (${process.platform}-${process.arch}). Use “Set Up Backend Automatically…” or “Select Python Interpreter…” below to provide one.`, + }; +} + +/** Build the override candidate, treating an `mrd-viz` executable as a binary and anything else as an interpreter. */ +function configuredBackend(configuredPath: string): ResolvedBackend { + const baseArgs = looksLikeBackendBinary(configuredPath) ? [] : [...PYTHON_MODULE_ARGS]; + return { command: configuredPath, baseArgs, source: `${BACKEND_PATH_SETTING} setting (${configuredPath})`, kind: 'override' }; +} + +/** Whether a configured path points at the standalone backend binary rather than a Python interpreter. */ +function looksLikeBackendBinary(configuredPath: string): boolean { + const base = path.basename(configuredPath).toLowerCase(); + return base === BACKEND_BINARY_NAME || base === `${BACKEND_BINARY_NAME}.exe`; +} + +interface ProbeResult { + ok: boolean; + /** Concise failure reason for the webview when `ok` is false. */ + detail?: string; + /** Complete captured output for the output channel when `ok` is false. */ + detailFull?: string; +} + +async function validateBackend(candidate: ResolvedBackend, timeoutMs: number): Promise { + const timeout = Math.min(Math.max(timeoutMs, PROBE_TIMEOUT_MS_MIN), PROBE_TIMEOUT_MS_MAX); + const { error, stderr } = await runProcess(candidate.command, [...candidate.baseArgs, '--version'], { timeoutMs: timeout }); + if (!error) { + return { ok: true }; + } + return { ok: false, detail: describeProbeFailure(error, stderr), detailFull: fullProbeFailure(error, stderr) }; +} + +/** + * Turn a failed `--version` probe into a concise, user-facing reason. The captured stderr is + * preferred because it carries the actionable message (e.g. a `GLIBC_2.38 not found` linker + * error, or a `ModuleNotFoundError: No module named 'mrd_viz'`); the spawn error is a fallback + * for cases with no output, such as a missing command (ENOENT) or a probe timeout. + */ +function describeProbeFailure(error: ExecFileException, stderr: string): string { + const trimmedStderr = stderr.trim(); + if (trimmedStderr) { + return truncateForDisplay(collapseToLastLines(trimmedStderr, 4)); + } + if (error.code === 'ENOENT') { + return 'command not found'; + } + if (error.killed) { + return 'timed out before responding'; + } + return truncateForDisplay(error.message.trim() || 'probe failed'); +} + +/** + * Like {@link describeProbeFailure} but without collapsing or truncation, so the complete probe + * output can be written to the output channel where a linker/module error may span many lines. + */ +function fullProbeFailure(error: ExecFileException, stderr: string): string { + const trimmedStderr = stderr.trim(); + if (trimmedStderr) { + return trimmedStderr; + } + if (error.code === 'ENOENT') { + return 'command not found'; + } + if (error.killed) { + return 'timed out before responding'; + } + return error.message.trim() || 'probe failed'; +} + +function collapseToLastLines(text: string, maxLines: number): string { + const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0); + return lines.slice(-maxLines).join('\n'); +} + +function truncateForDisplay(text: string): string { + const limit = 600; + return text.length > limit ? `${text.slice(0, limit)}\u2026` : text; +} + +function pythonExecutableRelative(): string { + return process.platform === 'win32' ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'); +} + +function bundledBinaryPath(context: vscode.ExtensionContext): string | undefined { + const name = process.platform === 'win32' ? `${BACKEND_BINARY_NAME}.exe` : BACKEND_BINARY_NAME; + const candidate = path.join(context.extensionUri.fsPath, 'media', 'backend', name); + return existsSync(candidate) ? candidate : undefined; +} + +/** Directory where the managed backend virtual environment is (or would be) provisioned. */ +export function managedVenvDirectory(context: vscode.ExtensionContext): string { + return path.join(context.globalStorageUri.fsPath, 'backend-venv'); +} + +/** Path to the interpreter inside the managed backend virtual environment (may not exist yet). */ +export function managedVenvPythonPath(context: vscode.ExtensionContext): string { + return path.join(managedVenvDirectory(context), pythonExecutableRelative()); +} + +function developmentVenvPython(context: vscode.ExtensionContext): string | undefined { + const candidate = path.resolve(context.extensionUri.fsPath, '..', '..', 'backend', '.venv', pythonExecutableRelative()); + return existsSync(candidate) ? candidate : undefined; +} diff --git a/mrd-viz/extension/mrd-viz/src/backendRunner.ts b/mrd-viz/extension/mrd-viz/src/backendRunner.ts new file mode 100644 index 00000000..b2042067 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/backendRunner.ts @@ -0,0 +1,114 @@ +import { isMrdImageResponsePayload, isMrdOpenPayload, type MrdImageResponsePayload, type MrdOpenPayload } from './contracts'; +import { BACKEND_RESPONSE_MAX_BUFFER_BYTES } from './backendConstants'; +import { runProcess } from './subprocess'; + +export interface BackendRunnerOptions { + command: string; + baseArgs: string[]; + maxThumbnails: number; + timeoutMs: number; +} + +export class MrdVizBackendError extends Error { + constructor( + message: string, + readonly stdout: string, + readonly stderr: string, + ) { + super(message); + this.name = 'MrdVizBackendError'; + } +} + +export interface OpenFileResult { + payload: MrdOpenPayload; + stderr: string; +} + +export async function runOpenFile(filePath: string, options: BackendRunnerOptions, signal?: AbortSignal, explodeSlices = false): Promise { + const commandArguments = [ + ...options.baseArgs, + 'open', + filePath, + '--max-thumbnails', + String(options.maxThumbnails), + ...(explodeSlices ? ['--explode-slices'] : []), + ]; + const result = await execBackend(options.command, commandArguments, options.timeoutMs, signal); + return { payload: parseOpenPayload(result.stdout, result.stderr), stderr: result.stderr }; +} + +export interface ImageResult { + payload: MrdImageResponsePayload; + stderr: string; +} + +export async function runImage(filePath: string, imageIndex: number, options: BackendRunnerOptions, signal?: AbortSignal, sliceCoords?: number[]): Promise { + const commandArguments = [ + ...options.baseArgs, + 'image', + filePath, + '--index', + String(imageIndex), + ...sliceArgs(sliceCoords), + ]; + const result = await execBackend(options.command, commandArguments, options.timeoutMs, signal); + return { payload: parseImagePayload(result.stdout, result.stderr), stderr: result.stderr }; +} + +function sliceArgs(sliceCoords?: number[]): string[] { + if (!sliceCoords || sliceCoords.length === 0) { + return []; + } + + const args: string[] = []; + sliceCoords.forEach((coord, axis) => { + if (Number.isInteger(coord) && coord >= 0) { + args.push('--slice', `${axis}:${coord}`); + } + }); + return args; +} + +async function execBackend(command: string, commandArguments: string[], timeoutMs: number, signal?: AbortSignal): Promise<{ stdout: string; stderr: string }> { + const { error, stdout, stderr } = await runProcess(command, commandArguments, { + timeoutMs, + maxBuffer: BACKEND_RESPONSE_MAX_BUFFER_BYTES, + signal, + }); + if (error && !stdout.trim()) { + throw new MrdVizBackendError(error.message, stdout, stderr); + } + return { stdout, stderr }; +} + +function parseOpenPayload(stdout: string, stderr: string): MrdOpenPayload { + return parsePayload(stdout, stderr, isMrdOpenPayload, 'open-file'); +} + +function parseImagePayload(stdout: string, stderr: string): MrdImageResponsePayload { + return parsePayload(stdout, stderr, isMrdImageResponsePayload, 'selected-image'); +} + +function parsePayload(stdout: string, stderr: string, predicate: (value: unknown) => value is T, label: string): T { + const trimmedStdout = stdout.trim(); + if (!trimmedStdout) { + throw new MrdVizBackendError('The MRD Viz backend returned empty stdout.', stdout, stderr); + } + + let payload: unknown; + try { + payload = JSON.parse(trimmedStdout); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new MrdVizBackendError(`The MRD Viz backend returned invalid JSON: ${message}`, stdout, stderr); + } + + if (!predicate(payload)) { + throw new MrdVizBackendError(`The MRD Viz backend returned JSON that does not match the ${label} payload shape.`, stdout, stderr); + } + + return payload; +} + +export type { MrdImageResponsePayload, MrdOpenPayload }; \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/src/contracts.ts b/mrd-viz/extension/mrd-viz/src/contracts.ts new file mode 100644 index 00000000..02990e83 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/contracts.ts @@ -0,0 +1,179 @@ +export interface MrdStreamSummary { + item_counts?: Record; + image_count?: number; + acquisition_count?: number; + waveform_count?: number; + other_count?: number; +} + +export interface MrdSliceDim { + axis: number; + name: string; + size: number; +} + +export interface MrdMosaicTile { + image_index?: number; + stream_index?: number; + stream_item_type?: string; + data_shape?: number[]; + slice_dims?: MrdSliceDim[]; + tile_title?: string; + dtype?: string; + png_base64?: string | null; + rendered_shape?: number[] | null; + thumbnail?: boolean; + renderable?: boolean; + render_error?: string | null; + source_plane?: unknown; + [key: string]: unknown; +} + +export interface MrdMosaicSummary { + tile_unit?: string; + thumbnails?: MrdMosaicTile[]; + truncated?: boolean; +} + +export interface MrdMetadataSummary { + images?: unknown[]; + acquisitions?: unknown[]; + waveforms?: unknown[]; + other_items?: unknown[]; + [key: string]: unknown; +} + +export interface MrdOpenPayload { + ok: boolean; + schema_version?: number; + path?: string; + filename?: string; + file_size_bytes?: number | null; + file_class?: string; + file_class_reliable?: boolean; + display_mode?: string; + summary?: Record; + stream?: MrdStreamSummary; + mosaic?: MrdMosaicSummary; + metadata?: MrdMetadataSummary; + warnings?: unknown[]; + error?: unknown; + [key: string]: unknown; +} + +export function isMrdOpenPayload(value: unknown): value is MrdOpenPayload { + return typeof value === 'object' + && value !== null + && 'ok' in value + && typeof (value as { ok: unknown }).ok === 'boolean'; +} + +export function redactingPayloadReplacer(key: string, value: unknown): unknown { + if (key === 'png_base64' && typeof value === 'string') { + return ``; + } + + return value; +} + +export type MrdImagePayload = MrdMosaicTile; + +export interface MrdImageResponsePayload { + ok: boolean; + path?: string; + filename?: string; + file_class?: string; + display_mode?: string; + image?: MrdImagePayload; + error?: unknown; + [key: string]: unknown; +} + +export type MrdMosaicMode = 'images' | 'slices'; + +export interface LoadImageRequestMessage { + type: 'loadImage'; + requestId: string; + imageIndex: number; + sliceCoords?: number[]; +} + +export interface SetMosaicModeRequestMessage { + type: 'setMosaicMode'; + requestId: string; + mode: MrdMosaicMode; +} + +export interface ImageLoadedMessage { + type: 'imageLoaded'; + requestId: string; + payload: MrdImageResponsePayload; +} + +export interface ImageErrorMessage { + type: 'imageError'; + requestId: string; + imageIndex: number; + error: string; +} + +export interface MosaicUpdatedMessage { + type: 'mosaicUpdated'; + requestId: string; + payload: MrdOpenPayload; +} + +export interface MosaicErrorMessage { + type: 'mosaicError'; + requestId: string; + error: string; +} + +export type ViewerToExtensionMessage = LoadImageRequestMessage | SetMosaicModeRequestMessage; + +export type ExtensionToViewerMessage = + | ImageLoadedMessage + | ImageErrorMessage + | MosaicUpdatedMessage + | MosaicErrorMessage; + +export function isMrdImageResponsePayload(value: unknown): value is MrdImageResponsePayload { + return typeof value === 'object' + && value !== null + && 'ok' in value + && typeof (value as { ok: unknown }).ok === 'boolean'; +} + +export function isViewerToExtensionMessage(value: unknown): value is ViewerToExtensionMessage { + if (typeof value !== 'object' || value === null) { + return false; + } + + const message = value as { type?: unknown; requestId?: unknown; imageIndex?: unknown; sliceCoords?: unknown; mode?: unknown }; + if (typeof message.requestId !== 'string') { + return false; + } + + if (message.type === 'loadImage') { + const imageIndex = message.imageIndex; + return typeof imageIndex === 'number' + && Number.isInteger(imageIndex) + && imageIndex >= 0 + && isOptionalSliceCoords(message.sliceCoords); + } + + if (message.type === 'setMosaicMode') { + return message.mode === 'images' || message.mode === 'slices'; + } + + return false; +} + +function isSliceCoords(value: unknown): value is number[] { + return Array.isArray(value) + && value.every(entry => typeof entry === 'number' && Number.isInteger(entry) && entry >= 0); +} + +function isOptionalSliceCoords(value: unknown): value is number[] | undefined { + return value === undefined || isSliceCoords(value); +} \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/src/extension.ts b/mrd-viz/extension/mrd-viz/src/extension.ts new file mode 100644 index 00000000..edb6c693 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/extension.ts @@ -0,0 +1,353 @@ +import { existsSync } from 'node:fs'; +import { mkdir, rename, rm } from 'node:fs/promises'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { + PROVISIONING_LOG_MAX_BUFFER_BYTES, + PROVISIONING_PYTHON_CANDIDATES, + PROVISIONING_STEP_TIMEOUT_MS, + PYPI_PACKAGE_NAME, + PYTHON_VERSION_PROBE_TIMEOUT_MS, +} from './backendConstants'; +import { invalidateBackendCache, managedVenvDirectory, managedVenvPythonPath } from './backendResolver'; +import { MrdEditorProvider, MRD_VIEW_TYPE } from './mrdEditorProvider'; +import { runProcess } from './subprocess'; + +export function activate(context: vscode.ExtensionContext) { + const outputChannel = vscode.window.createOutputChannel('MRD Viz'); + context.subscriptions.push(outputChannel); + + const editorProvider = new MrdEditorProvider(context, outputChannel); + context.subscriptions.push(vscode.window.registerCustomEditorProvider(MRD_VIEW_TYPE, editorProvider, { + webviewOptions: { + retainContextWhenHidden: true, + }, + })); + + context.subscriptions.push( + vscode.commands.registerCommand('mrd-viz.setUpBackend', () => setUpBackend(context, outputChannel)), + vscode.commands.registerCommand('mrd-viz.selectInterpreter', () => selectInterpreter()), + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('mrdViz.backendPath')) { + invalidateBackendCache(); + } + }), + ); + + const disposable = vscode.commands.registerCommand('mrd-viz.openFile', async (resource?: vscode.Uri, selectedResources?: vscode.Uri[]) => { + const targetUri = await resolveTargetUri(resource, selectedResources); + if (!targetUri) { + return; + } + + try { + await vscode.commands.executeCommand(...getOpenWithMrdEditorArgs(targetUri)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + outputChannel.show(true); + outputChannel.appendLine(`MRD Viz failed to open ${targetUri.fsPath}: ${message}`); + vscode.window.showErrorMessage(`MRD Viz failed to open ${path.basename(targetUri.fsPath)}: ${message}`); + } + }); + + context.subscriptions.push(disposable); +} + +export function deactivate() {} + +export function getOpenWithMrdEditorArgs(targetUri: vscode.Uri): [string, vscode.Uri, string, vscode.TextDocumentShowOptions] { + return ['vscode.openWith', targetUri, MRD_VIEW_TYPE, { + preview: false, + viewColumn: vscode.ViewColumn.Active, + }]; +} + +async function resolveTargetUri(resource?: vscode.Uri, selectedResources?: vscode.Uri[]): Promise { + const candidate = await pickTargetUri(resource, selectedResources); + if (!candidate) { + return undefined; + } + + if (candidate.scheme !== 'file' || !isMrdFile(candidate.fsPath)) { + void vscode.window.showWarningMessage(`MRD Viz can only open .mrd files: "${path.basename(candidate.fsPath)}" is not an MRD file.`); + return undefined; + } + + return candidate; +} + +async function pickTargetUri(resource?: vscode.Uri, selectedResources?: vscode.Uri[]): Promise { + if (resource?.scheme === 'file') { + return resource; + } + + const firstSelectedResource = selectedResources?.find(item => item.scheme === 'file'); + if (firstSelectedResource) { + return firstSelectedResource; + } + + const activeUri = vscode.window.activeTextEditor?.document.uri; + if (activeUri?.scheme === 'file') { + return activeUri; + } + + const selectedFiles = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'MRD files': ['mrd'], + }, + openLabel: 'Inspect MRD File', + }); + + return selectedFiles?.[0]; +} + +async function setUpBackend(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel): Promise { + // If a managed backend is already provisioned, this rebuilds it from scratch, so confirm the + // reinstall rather than silently clobbering a working environment. + const alreadyInstalled = existsSync(managedVenvPythonPath(context)); + const confirmLabel = alreadyInstalled ? 'Reinstall' : 'Install'; + const proceed = await vscode.window.showInformationMessage( + alreadyInstalled + ? 'An MRD Viz backend is already installed in the extension\u2019s storage. Reinstall it? This deletes and rebuilds the private Python virtual environment and re-installs the "mrd_viz" package with pip. It needs a Python 3.12+ interpreter on PATH and network access.' + : 'Set up the MRD Viz backend automatically? This creates a private Python virtual environment in the extension\u2019s storage and installs the "mrd_viz" package with pip. It needs a Python 3.12+ interpreter on PATH and network access.', + { modal: true }, + confirmLabel, + ); + if (proceed !== confirmLabel) { + return; + } + + const installed = await provisionManagedBackend(context, outputChannel); + if (installed) { + // Persist the managed venv as the configured backend (machine-scoped) so it becomes a + // first-class override the resolver reads directly, rather than an implicit candidate. + await vscode.workspace.getConfiguration('mrdViz').update( + 'backendPath', managedVenvPythonPath(context), vscode.ConfigurationTarget.Global, + ); + invalidateBackendCache(); + void vscode.window.showInformationMessage(alreadyInstalled ? 'MRD Viz backend reinstalled.' : 'MRD Viz backend installed.'); + } +} + +async function selectInterpreter(): Promise { + const picked = await vscode.window.showOpenDialog({ + canSelectMany: false, + canSelectFolders: false, + openLabel: 'Select interpreter', + title: 'Select the Python interpreter that has the mrd_viz backend', + }); + if (!picked || picked.length === 0) { + return; + } + + // backendPath is machine-scoped, so this writes to the context-appropriate machine settings + // (host user settings, or the dev container's remote settings) and cannot leak across that + // boundary or be committed to a workspace — no narrower-scope clearing needed. + const config = vscode.workspace.getConfiguration('mrdViz'); + await config.update('backendPath', picked[0].fsPath, vscode.ConfigurationTarget.Global); + invalidateBackendCache(); + void vscode.window.showInformationMessage('MRD Viz backend updated.'); +} + +/** A provisioning step that failed, tagged with the human-readable phase for clear reporting. */ +class BackendSetupError extends Error { + constructor(readonly step: string, detail: string) { + super(detail); + this.name = 'BackendSetupError'; + } +} + +/** Flags applied to every pip call: silence the version-check notice and never block on a prompt. */ +const PIP_FLAGS = ['--disable-pip-version-check', '--no-input']; + +/** + * Provision the managed backend virtual environment in global storage: create a venv from a + * discovered Python 3.12+ interpreter and `pip install` the backend. The caller persists the venv + * interpreter to `mrdViz.backendPath` on success. Returns true only if every step succeeds; on any + * failure the half-provisioned venv is removed (so a stale interpreter can't satisfy the resolver + * probe and then fail at `import mrd_viz`) and the cause is surfaced to the user and the output + * channel. + */ +async function provisionManagedBackend(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel): Promise { + const basePython = await findProvisioningPython(); + if (!basePython) { + void vscode.window.showErrorMessage( + 'MRD Viz could not find a Python 3.12+ interpreter on PATH to build the backend environment. Install Python 3.12 or newer, or use "Select Python Interpreter\u2026" to point at an existing environment.', + ); + return false; + } + + const venvDir = managedVenvDirectory(context); + const venvPython = managedVenvPythonPath(context); + const installTarget = repoBackendInstallTarget(context) ?? PYPI_PACKAGE_NAME; + + return vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Setting up MRD Viz backend', cancellable: false }, + async progress => { + try { + await mkdir(path.dirname(venvDir), { recursive: true }); + + progress.report({ message: 'Creating virtual environment\u2026' }); + await runProvisioningStep('creating the virtual environment', basePython, ['-m', 'venv', venvDir], outputChannel); + + progress.report({ message: 'Upgrading pip\u2026' }); + await runProvisioningStep('upgrading pip', venvPython, ['-m', 'pip', 'install', ...PIP_FLAGS, '--upgrade', 'pip'], outputChannel); + + progress.report({ message: 'Installing the mrd_viz backend\u2026' }); + // TODO(publish-pypi): the PYPI_PACKAGE_NAME branch only resolves once the backend is + // published to PyPI; until then setup succeeds only from a repo checkout (editable + // install) and otherwise fails loudly with the package-not-found hint. + const installArgs = installTarget === PYPI_PACKAGE_NAME + ? ['-m', 'pip', 'install', ...PIP_FLAGS, PYPI_PACKAGE_NAME] + : ['-m', 'pip', 'install', ...PIP_FLAGS, '-e', installTarget]; + await runProvisioningStep('installing the mrd_viz backend', venvPython, installArgs, outputChannel); + + return true; + } catch (error) { + await reportProvisioningFailure(error, venvDir, outputChannel); + return false; + } + }, + ); +} + +/** + * Log the failure, remove the incomplete venv, and show a user-facing message that names the + * step that failed and (when recognizable) hints at the cause. + */ +async function reportProvisioningFailure(error: unknown, venvDir: string, outputChannel: vscode.OutputChannel): Promise { + const step = error instanceof BackendSetupError ? error.step : undefined; + const detail = error instanceof Error ? error.message : String(error); + const where = step ? ` while ${step}` : ''; + + outputChannel.appendLine(`Backend setup failed${where}: ${detail}`); + await removeIncompleteVenv(venvDir, outputChannel); + // The removed (or quarantined) venv may already be the resolver's cached selection, so drop the + // cache defensively — otherwise a stale interpreter path could stay cached after we tore it down. + invalidateBackendCache(); + outputChannel.show(true); + + const hint = classifyProvisioningFailure(detail); + void vscode.window.showErrorMessage( + `MRD Viz backend setup failed${where}: ${detail}.${hint} See the MRD Viz output channel for details, or use "Select Python Interpreter\u2026" instead.`, + ); +} + +/** + * Remove a partially built venv so retries start clean and the resolver skips a broken candidate. + * `rm` can fail transiently (notably on Windows, where a just-exited pip may still hold a lock), so + * retry a few times; if the directory still can't be deleted, rename it out of the way so its + * interpreter path no longer exists (the resolver probes that path with `existsSync` and will skip + * the candidate), then make a best-effort delete of the renamed directory. + */ +export async function removeIncompleteVenv(venvDir: string, outputChannel: Pick): Promise { + if (await tryRemoveDirectory(venvDir)) { + outputChannel.appendLine(`Cleaned up incomplete backend environment (if present): ${venvDir}`); + return; + } + + const quarantineDir = `${venvDir}.broken-${Date.now()}`; + try { + await rename(venvDir, quarantineDir); + outputChannel.appendLine(`Could not delete the incomplete backend environment; moved it aside to ${quarantineDir} so it will be ignored.`); + void tryRemoveDirectory(quarantineDir); + } catch (moveError) { + const detail = moveError instanceof Error ? moveError.message : String(moveError); + outputChannel.appendLine(`Warning: could not remove or move the incomplete backend environment at ${venvDir}: ${detail}`); + } +} + +/** Delete a directory tree, retrying a few times to ride out transient locks. Returns whether it is gone. */ +async function tryRemoveDirectory(dir: string): Promise { + const attempts = 3; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await rm(dir, { recursive: true, force: true }); + return true; + } catch { + if (attempt === attempts) { + return false; + } + await delay(attempt * 100); + } + } + return false; +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** Map a raw failure detail to a short, actionable hint appended to the error notification. */ +export function classifyProvisioningFailure(detail: string): string { + if (/no matching distribution|could not find a version|404|not found on pypi/i.test(detail)) { + return ' The mrd-viz package could not be found in the configured package index.'; + } + if (/ssl|tls|proxy|connection|timed out|network|getaddrinfo|econn|temporary failure/i.test(detail)) { + return ' This looks like a network/proxy problem reaching the package index.'; + } + return ''; +} + +/** Locate a Python 3.12+ interpreter on PATH suitable for building the backend venv. */ +async function findProvisioningPython(): Promise { + for (const command of PROVISIONING_PYTHON_CANDIDATES) { + if (await isPython312OrNewer(command)) { + return command; + } + } + return undefined; +} + +async function isPython312OrNewer(command: string): Promise { + const { error, stdout } = await runProcess( + command, + ['-c', 'import sys; print(sys.version_info[0], sys.version_info[1])'], + { timeoutMs: PYTHON_VERSION_PROBE_TIMEOUT_MS }, + ); + if (error) { + return false; + } + const match = /^(\d+)\s+(\d+)/.exec(stdout.trim()); + if (!match) { + return false; + } + const [major, minor] = [Number(match[1]), Number(match[2])]; + return major === 3 && minor >= 12; +} + +/** When running from the repo checkout, prefer an editable install of the local backend. */ +function repoBackendInstallTarget(context: vscode.ExtensionContext): string | undefined { + const backendDir = path.resolve(context.extensionUri.fsPath, '..', '..', 'backend'); + return existsSync(path.join(backendDir, 'pyproject.toml')) ? backendDir : undefined; +} + +async function runProvisioningStep(step: string, command: string, args: string[], outputChannel: vscode.OutputChannel): Promise { + outputChannel.appendLine(`Running: ${command} ${args.join(' ')}`); + const { error, stdout, stderr } = await runProcess(command, args, { + timeoutMs: PROVISIONING_STEP_TIMEOUT_MS, + maxBuffer: PROVISIONING_LOG_MAX_BUFFER_BYTES, + }); + appendIfPresent(outputChannel, 'stdout', stdout); + appendIfPresent(outputChannel, 'stderr', stderr); + if (error) { + const detail = (stderr.trim().split(/\r?\n/).pop() || error.message).trim(); + throw new BackendSetupError(step, detail); + } +} + +function appendIfPresent(outputChannel: vscode.OutputChannel, label: string, text: string): void { + const trimmed = text.trim(); + if (trimmed) { + outputChannel.appendLine(`${label}: ${trimmed}`); + } +} + +function isMrdFile(filePath: string): boolean { + return filePath.toLowerCase().endsWith('.mrd'); +} + diff --git a/mrd-viz/extension/mrd-viz/src/htmlUtils.ts b/mrd-viz/extension/mrd-viz/src/htmlUtils.ts new file mode 100644 index 00000000..074543ab --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/htmlUtils.ts @@ -0,0 +1,47 @@ +/** Shared helpers for building webview HTML: escaping, nonces, and JSON script payloads. */ + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"]/g, character => { + switch (character) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + default: + return character; + } + }); +} + +export function getNonce(): string { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ''; + for (let index = 0; index < 32; index += 1) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + return text; +} + +export function jsonForScript(value: unknown): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, character => { + switch (character) { + case '<': + return '\\u003c'; + case '>': + return '\\u003e'; + case '&': + return '\\u0026'; + case '\u2028': + return '\\u2028'; + case '\u2029': + return '\\u2029'; + default: + return character; + } + }); +} diff --git a/mrd-viz/extension/mrd-viz/src/mrdEditorProvider.ts b/mrd-viz/extension/mrd-viz/src/mrdEditorProvider.ts new file mode 100644 index 00000000..f524a416 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/mrdEditorProvider.ts @@ -0,0 +1,170 @@ +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { MrdVizBackendError, runOpenFile, type BackendRunnerOptions } from './backendRunner'; +import { BACKEND_TIMEOUT_MS_DEFAULT } from './backendConstants'; +import { invalidateBackendCache, resolveBackend } from './backendResolver'; +import { redactingPayloadReplacer } from './contracts'; +import { getMrdBackendMissingHtml, getMrdViewerHtml } from './webviewHtml'; +import { getMrdErrorHtml, getMrdLoadingHtml } from './stateHtml'; +import { appendIfPresent, bindViewerMessageHandling } from './viewerController'; + +export const MRD_VIEW_TYPE = 'mrd-viz.mrdFile'; + +const BACKEND_SETUP_COMMANDS = new Set(['mrd-viz.setUpBackend', 'mrd-viz.selectInterpreter']); + +class MrdDocument implements vscode.CustomDocument { + constructor(readonly uri: vscode.Uri) {} + + dispose(): void {} +} + +export class MrdEditorProvider implements vscode.CustomReadonlyEditorProvider { + constructor( + private readonly context: vscode.ExtensionContext, + private readonly outputChannel: vscode.OutputChannel, + ) {} + + openCustomDocument(uri: vscode.Uri): MrdDocument { + return new MrdDocument(uri); + } + + async resolveCustomEditor( + document: MrdDocument, + webviewPanel: vscode.WebviewPanel, + token: vscode.CancellationToken, + ): Promise { + webviewPanel.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')], + }; + + if (document.uri.scheme !== 'file') { + webviewPanel.webview.html = getMrdErrorHtml( + webviewPanel.webview, + `Unable to open ${path.basename(document.uri.fsPath)}`, + `MRD Viz can only open files on disk, but this resource uses the "${document.uri.scheme}" scheme.`, + document.uri.fsPath, + ); + return; + } + + await this.renderDocument(document, webviewPanel, token); + } + + private async renderDocument( + document: MrdDocument, + webviewPanel: vscode.WebviewPanel, + token: vscode.CancellationToken, + ): Promise { + webviewPanel.webview.html = getMrdLoadingHtml(webviewPanel.webview, document.uri); + + const configuration = vscode.workspace.getConfiguration('mrdViz'); + const timeoutMs = configuration.get('backendTimeoutMs') ?? BACKEND_TIMEOUT_MS_DEFAULT; + const maxThumbnails = configuration.get('maxThumbnails') ?? 128; + + const resolution = await resolveBackend(this.context, timeoutMs); + if (token.isCancellationRequested) { + return; + } + if (!resolution.ok) { + this.outputChannel.appendLine(''); + this.outputChannel.appendLine('No MRD Viz backend found. Tried:'); + for (const attempt of resolution.tried) { + this.outputChannel.appendLine(` - ${attempt.source}`); + const detail = attempt.detailFull ?? attempt.detail; + if (detail) { + for (const line of detail.split('\n')) { + this.outputChannel.appendLine(` ${line}`); + } + } + } + this.bindBackendSetupCommands(document, webviewPanel, token); + webviewPanel.webview.html = getMrdBackendMissingHtml(webviewPanel.webview, resolution.tried); + return; + } + + const options: BackendRunnerOptions = { + command: resolution.backend.command, + baseArgs: resolution.backend.baseArgs, + maxThumbnails, + timeoutMs, + }; + + const abortController = new AbortController(); + const cancelSubscription = token.onCancellationRequested(() => abortController.abort()); + // Keep this listener for the panel's lifetime so in-flight image requests are aborted + // when the tab is closed; VS Code disposes onDidDispose listeners after they fire. + webviewPanel.onDidDispose(() => abortController.abort()); + + bindViewerMessageHandling(webviewPanel, document.uri, options, this.outputChannel, abortController.signal); + + this.outputChannel.appendLine(''); + this.outputChannel.appendLine(`Running: ${formatOpenCommand(document.uri.fsPath, options)}`); + + try { + const { payload, stderr } = await runOpenFile(document.uri.fsPath, options, abortController.signal); + if (token.isCancellationRequested || abortController.signal.aborted) { + return; + } + + this.outputChannel.appendLine(JSON.stringify(payload, redactingPayloadReplacer, 2)); + appendIfPresent(this.outputChannel, 'stderr', stderr); + webviewPanel.webview.html = getMrdViewerHtml(webviewPanel.webview, payload, this.context.extensionUri); + } catch (error) { + if (token.isCancellationRequested || abortController.signal.aborted) { + return; + } + + const message = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(`Backend failed: ${message}`); + if (error instanceof MrdVizBackendError) { + appendIfPresent(this.outputChannel, 'stdout', error.stdout); + appendIfPresent(this.outputChannel, 'stderr', error.stderr); + } + this.outputChannel.show(true); + webviewPanel.webview.html = getMrdErrorHtml( + webviewPanel.webview, + `Unable to open ${path.basename(document.uri.fsPath)}`, + message, + document.uri.fsPath, + ); + } finally { + cancelSubscription.dispose(); + } + } + + private bindBackendSetupCommands( + document: MrdDocument, + webviewPanel: vscode.WebviewPanel, + token: vscode.CancellationToken, + ): void { + const subscription = webviewPanel.webview.onDidReceiveMessage(async (message: unknown) => { + if (!isCommandMessage(message) || !BACKEND_SETUP_COMMANDS.has(message.command)) { + return; + } + // Handle the setup action once, then re-resolve: a successful interpreter selection + // or provisioning run should recover the viewer in place without a manual reload. + subscription.dispose(); + await vscode.commands.executeCommand(message.command); + if (token.isCancellationRequested) { + return; + } + invalidateBackendCache(); + await this.renderDocument(document, webviewPanel, token); + }); + webviewPanel.onDidDispose(() => subscription.dispose()); + } +} + +function isCommandMessage(value: unknown): value is { type: 'command'; command: string } { + return typeof value === 'object' + && value !== null + && (value as { type?: unknown }).type === 'command' + && typeof (value as { command?: unknown }).command === 'string'; +} + +function formatOpenCommand(filePath: string, options: BackendRunnerOptions): string { + const parts = [options.command, ...options.baseArgs, 'open', `"${filePath}"`, '--max-thumbnails', String(options.maxThumbnails)]; + return parts.join(' '); +} diff --git a/mrd-viz/extension/mrd-viz/src/stateHtml.ts b/mrd-viz/extension/mrd-viz/src/stateHtml.ts new file mode 100644 index 00000000..c811c781 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/stateHtml.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; + +import { escapeHtml, getNonce } from './htmlUtils'; + +export function getMrdLoadingHtml(webview: vscode.Webview, targetUri: vscode.Uri): string { + return getMrdStateHtml(webview, 'Opening MRD file', 'Inspecting file with the MRD Viz backend.', targetUri.fsPath, ''); +} + +export function getMrdErrorHtml(webview: vscode.Webview, title: string, detail: string, targetPath: string): string { + return getMrdStateHtml(webview, title, detail, targetPath, 'error'); +} + +function getMrdStateHtml(webview: vscode.Webview, title: string, detail: string, targetPath: string, stateClass: string): string { + const nonce = getNonce(); + const cspSource = webview.cspSource; + const className = stateClass ? `state ${stateClass}` : 'state'; + + return ` + + + + + + MRD Viz + + + +
+

${escapeHtml(title)}

+

${escapeHtml(detail)}

+

${escapeHtml(targetPath)}

+
+ +`; +} diff --git a/mrd-viz/extension/mrd-viz/src/subprocess.ts b/mrd-viz/extension/mrd-viz/src/subprocess.ts new file mode 100644 index 00000000..bbbaf4be --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/subprocess.ts @@ -0,0 +1,35 @@ +import { execFile, type ExecFileException } from 'node:child_process'; + +/** Options for {@link runProcess}. Each caller sets the timeout/buffer that suits its workload. */ +export interface RunProcessOptions { + timeoutMs: number; + /** Max stdout/stderr bytes; omit to use Node's default. */ + maxBuffer?: number; + /** Abort signal to cancel an in-flight process. */ + signal?: AbortSignal; +} + +/** Result of a finished process: its captured output plus any spawn/exit error. */ +export interface ProcessOutcome { + error?: ExecFileException; + stdout: string; + stderr: string; +} + +/** + * Thin wrapper over `execFile` for short-lived subprocesses: it standardizes the option plumbing + * (timeout, buffer, `windowsHide`, abort signal) and resolves with `{ error, stdout, stderr }` so + * each caller keeps its own error shaping rather than sharing one policy. + */ +export function runProcess(command: string, args: string[], options: RunProcessOptions): Promise { + return new Promise(resolve => { + execFile( + command, + args, + { timeout: options.timeoutMs, maxBuffer: options.maxBuffer, windowsHide: true, signal: options.signal }, + (error, stdout, stderr) => { + resolve({ error: error ?? undefined, stdout: stdout.toString(), stderr: stderr.toString() }); + }, + ); + }); +} diff --git a/mrd-viz/extension/mrd-viz/src/test/extension.test.ts b/mrd-viz/extension/mrd-viz/src/test/extension.test.ts new file mode 100644 index 00000000..cbc91c0f --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/test/extension.test.ts @@ -0,0 +1,227 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +import { classifyProvisioningFailure, getOpenWithMrdEditorArgs, removeIncompleteVenv } from '../extension'; +import { MRD_VIEW_TYPE } from '../mrdEditorProvider'; +import { planBackendCandidates } from '../backendResolver'; +import { getMrdBackendMissingHtml } from '../webviewHtml'; +import { getMrdErrorHtml } from '../stateHtml'; +import { isViewerToExtensionMessage } from '../contracts'; + +interface CommandContribution { + command: string; + title: string; + category?: string; +} + +interface CustomEditorContribution { + viewType: string; + displayName: string; + selector: Array<{ filenamePattern: string }>; + priority?: string; +} + +interface ExtensionPackageJson { + name: string; + contributes?: { + commands?: CommandContribution[]; + customEditors?: CustomEditorContribution[]; + }; +} + +suite('MRD Viz Extension', () => { + test('contributes MRD Viz as the default custom editor for .mrd files', () => { + const packageJson = readPackageJson(); + const customEditor = packageJson.contributes?.customEditors?.find(editor => editor.viewType === MRD_VIEW_TYPE); + + assert.deepStrictEqual(customEditor, { + viewType: MRD_VIEW_TYPE, + displayName: 'MRD Viz', + selector: [{ filenamePattern: '*.mrd' }], + priority: 'default', + }); + }); + + test('registers the command palette entry when the extension activates', async () => { + const extension = vscode.extensions.all.find(item => item.packageJSON.name === 'mrd-viz'); + if (!extension) { + assert.fail('MRD Viz extension was not loaded by the VS Code test host.'); + } + + await extension.activate(); + const commands = await vscode.commands.getCommands(true); + + assert.ok(commands.includes('mrd-viz.openFile')); + assert.ok(commands.includes('mrd-viz.setUpBackend')); + assert.ok(commands.includes('mrd-viz.selectInterpreter')); + }); + + test('routes command opens through the custom editor view type', () => { + const targetUri = vscode.Uri.file(path.join('sample data', 'scan.mrd')); + const [command, uri, viewType, options] = getOpenWithMrdEditorArgs(targetUri); + + assert.strictEqual(command, 'vscode.openWith'); + assert.strictEqual(uri, targetUri); + assert.strictEqual(viewType, MRD_VIEW_TYPE); + assert.deepStrictEqual(options, { + preview: false, + viewColumn: vscode.ViewColumn.Active, + }); + }); + + test('escapes backend error details rendered inside the editor', () => { + const webview = { cspSource: 'vscode-resource:' } as vscode.Webview; + const html = getMrdErrorHtml(webview, 'Unable to open scan.mrd', 'Bad & path', 'C:\\tmp\\scan.mrd'); + + assert.ok(html.includes('Bad <script>alert("x")</script> & path')); + assert.ok(!html.includes(' + +`; +} + +export function getMrdViewerHtml(webview: vscode.Webview, payload: MrdOpenPayload, extensionUri: vscode.Uri): string { + const nonce = getNonce(); + const bootstrapJson = jsonForScript({ payload, config: { maxImageCacheEntries: MAX_IMAGE_CACHE_ENTRIES } }); + const cspSource = webview.cspSource; + const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'media', 'viewer.css')); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'media', 'viewer.js')); + + return ` + + + + + + MRD Viz + + + +
+
+
+

MRD Viz

+
+
+
+
+
+
+

Mosaic

+
+
+
+ +
+
+ + + +`; +} \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/tsconfig.json b/mrd-viz/extension/mrd-viz/tsconfig.json new file mode 100644 index 00000000..2df9206f --- /dev/null +++ b/mrd-viz/extension/mrd-viz/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out", + "lib": [ + "ES2022" + ], + "types": [ + "node", + "mocha" + ], + "sourceMap": true, + "rootDir": "src", + "strict": true, /* enable all strict type-checking options */ + /* Additional Checks */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + }, + "include": [ + "src" + ] +} diff --git a/mrd-viz/justfile b/mrd-viz/justfile new file mode 100644 index 00000000..17805074 --- /dev/null +++ b/mrd-viz/justfile @@ -0,0 +1,59 @@ +@default: ci + +@backend-install: + cd backend && python -m pip install -e ".[test]" + +@backend-test: backend-install + cd backend && python -m pytest + +@extension-install: + cd extension/mrd-viz && npm ci + +@extension-lint: extension-install + cd extension/mrd-viz && npm run lint + +@extension-compile: extension-install + cd extension/mrd-viz && npm run compile + +@extension-test: extension-install + cd extension/mrd-viz && npm test + +@extension-check: extension-lint extension-compile + +@ci: backend-test extension-check + +@full-ci: backend-test extension-check extension-test + +@extension-package: extension-compile + cd extension/mrd-viz && cp ../../../LICENSE LICENSE && trap 'rm -f LICENSE' EXIT && npx --yes @vscode/vsce package + +# One-time provisioning inside the MRD Viz dev container: backend venv + extension install. +container-setup: + #!/usr/bin/env bash + set -euo pipefail + # Point pip and npm at the first reachable index (internal mirror, else public). + # shellcheck source=../.devcontainer/mrd-viz/select-pkg-index.sh + source ../.devcontainer/mrd-viz/select-pkg-index.sh + venv="$HOME/.venvs/mrd-viz" + echo ">> Creating backend virtualenv: $venv" + python -m venv "$venv" + "$venv/bin/python" -m pip install --upgrade pip + ( cd backend && "$venv/bin/python" -m pip install -e ".[test]" ) + echo ">> Building and installing the MRD Viz extension (VSIX)" + ( cd extension/mrd-viz && npm ci && cp ../../../LICENSE LICENSE && trap 'rm -f LICENSE' EXIT && npx --yes @vscode/vsce package ) + vsix="$(ls -t extension/mrd-viz/mrd-viz-*.vsix 2>/dev/null | head -1)" + if [ -z "$vsix" ]; then echo ">> ERROR: no VSIX found in extension/mrd-viz (packaging may have failed)." >&2; exit 1; fi + code --install-extension "$vsix" --force + echo ">> Setup complete. Backend interpreter: $venv/bin/python" + echo ">> Reload the window if the extension does not activate immediately." + +# Build the standalone backend binary (dist/mrd-viz[.exe]) via PyInstaller. +@build-binary: + cd backend && python -m pip install ".[package]" && pyinstaller --clean --noconfirm packaging/mrd-viz.spec + +# Verify a staged D3 one-dir backend and emit its release manifest. +verify-bundled-backend target: + node tools/verify-bundled-backend.mjs "{{target}}" + +@test-release-tools: + node --test tools/*.test.mjs \ No newline at end of file diff --git a/mrd-viz/tools/verify-bundled-backend.mjs b/mrd-viz/tools/verify-bundled-backend.mjs new file mode 100644 index 00000000..74a48584 --- /dev/null +++ b/mrd-viz/tools/verify-bundled-backend.mjs @@ -0,0 +1,74 @@ +import { createHash } from 'node:crypto'; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TARGETS = new Map([ + ['linux-x64', 'mrd-viz'], + ['win32-x64', 'mrd-viz.exe'], + ['darwin-arm64', 'mrd-viz'], + ['darwin-x64', 'mrd-viz'], +]); + +export async function verifyBundledBackend(target, backendDirectory, run = spawnSync) { + const executableName = TARGETS.get(target); + if (!executableName) { + throw new Error(`Unsupported VS Code target "${target}". Expected one of: ${[...TARGETS.keys()].join(', ')}`); + } + + const executablePath = path.join(backendDirectory, executableName); + const executableStat = await stat(executablePath); + if (!executableStat.isFile() || executableStat.size === 0) { + throw new Error(`Bundled backend executable is empty or not a file: ${executablePath}`); + } + + const internalDirectory = path.join(backendDirectory, '_internal'); + const internalEntries = await readdir(internalDirectory); + if (internalEntries.length === 0) { + throw new Error(`PyInstaller one-dir runtime is empty: ${internalDirectory}`); + } + + const result = run(executablePath, ['--version'], { encoding: 'utf8' }); + if (result.error) { + throw new Error(`Bundled backend version probe failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`Bundled backend version probe exited ${result.status}: ${(result.stderr || '').trim()}`); + } + + const versionOutput = (result.stdout || '').trim(); + const versionMatch = /^mrd-viz\s+(\S+)$/.exec(versionOutput); + if (!versionMatch) { + throw new Error(`Unexpected bundled backend version output: ${JSON.stringify(versionOutput)}`); + } + + const executableBytes = await readFile(executablePath); + return { + schemaVersion: 1, + target, + backendVersion: versionMatch[1], + executable: executableName, + executableBytes: executableStat.size, + executableSha256: createHash('sha256').update(executableBytes).digest('hex'), + }; +} + +async function main() { + const [target, backendDirectory = 'extension/mrd-viz/media/backend'] = process.argv.slice(2); + if (!target) { + throw new Error('Usage: node tools/verify-bundled-backend.mjs [backend-directory]'); + } + + const manifest = await verifyBundledBackend(target, backendDirectory); + const manifestPath = path.join(backendDirectory, 'backend-manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + console.log(`Verified ${target} bundled backend ${manifest.backendVersion}; wrote ${manifestPath}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/mrd-viz/tools/verify-bundled-backend.test.mjs b/mrd-viz/tools/verify-bundled-backend.test.mjs new file mode 100644 index 00000000..21d83799 --- /dev/null +++ b/mrd-viz/tools/verify-bundled-backend.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { verifyBundledBackend } from './verify-bundled-backend.mjs'; + +async function fixture(t) { + const directory = await mkdtemp(path.join(tmpdir(), 'mrd-viz-backend-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await mkdir(path.join(directory, '_internal'), { recursive: true }); + await writeFile(path.join(directory, 'mrd-viz'), 'binary'); + await writeFile(path.join(directory, '_internal', 'runtime'), 'runtime'); + return directory; +} + +test('returns release manifest data for a valid one-dir bundle', async t => { + const directory = await fixture(t); + const manifest = await verifyBundledBackend('linux-x64', directory, () => ({ + status: 0, + stdout: 'mrd-viz 0.1.0\n', + stderr: '', + })); + + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.target, 'linux-x64'); + assert.equal(manifest.backendVersion, '0.1.0'); + assert.equal(manifest.executable, 'mrd-viz'); + assert.equal(manifest.executableBytes, 6); + assert.match(manifest.executableSha256, /^[a-f0-9]{64}$/); +}); + +test('rejects a one-dir bundle without its runtime', async t => { + const directory = await fixture(t); + await rm(path.join(directory, '_internal'), { recursive: true }); + await assert.rejects( + () => verifyBundledBackend('linux-x64', directory), + /ENOENT/, + ); +}); + +test('rejects malformed version output', async t => { + const directory = await fixture(t); + await assert.rejects( + () => verifyBundledBackend('linux-x64', directory, () => ({ + status: 0, + stdout: 'unexpected\n', + stderr: '', + })), + /Unexpected bundled backend version output/, + ); +});