diff --git a/.gitignore b/.gitignore index ac1b7945..37d53f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,13 @@ cpp/build/ .vscode/ *.ipynb +# Local dev-container data landing zone (e.g. azcopy pulls of large .mrd files) +mrd-viz/data/ + *.bin *.png +*.vsix + +mrd-viz/extension/mrd-viz/media/backend/ + +*.pyc diff --git a/mrd-viz/backend/packaging/testfile.txt b/mrd-viz/backend/packaging/testfile.txt new file mode 100644 index 00000000..30d74d25 --- /dev/null +++ b/mrd-viz/backend/packaging/testfile.txt @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/mrd-viz/docs/RESEARCHER_ONBOARDING_AND_ROADMAP.md b/mrd-viz/docs/RESEARCHER_ONBOARDING_AND_ROADMAP.md new file mode 100644 index 00000000..b88a31cd --- /dev/null +++ b/mrd-viz/docs/RESEARCHER_ONBOARDING_AND_ROADMAP.md @@ -0,0 +1,25 @@ +# MRD Viz researcher onboarding and roadmap + +This plan keeps the first delivery focused on the smallest experience that already feels useful to researchers: + +- D1 delivers a file-first viewer that opens `.mrd` files directly in VS Code. +- Release packages that experience into a `.vsix` that can be installed from GitHub Releases. +- The later milestones can continue in parallel, but they do not block the first researcher-facing release. + +## Functionality table + +| Milestone | Before UX | After UX | Functions created | Why it matters | +| --- | --- | --- | --- | --- | +| D1 | Researchers need a source checkout, manual backend setup, and a dev-host workflow to inspect `.mrd` files. | Researchers can install the extension, point it at a Python backend, and open a local `.mrd` file in a normal VS Code window. | Custom editor for `.mrd`, open-file command, backend interpreter selection, thumbnail mosaic preview, on-demand full-resolution image loading, metadata panels. | This is the first “real” researcher UX: inspect MRD data without a custom dev environment. | +| Release | There is no simple installable artifact for non-developers. | Researchers can install a packaged `.vsix` from a GitHub Release with a single command such as `code --install-extension mrd-viz-*.vsix`. | VSIX packaging workflow, release automation, packaging runbook, installation guidance. | This removes the biggest onboarding friction for early adopters. | +| D2 | A single file is viewable, but metadata navigation is still coarse. | Researchers can inspect acquisition, waveform, and raw metadata with clearer grouping and search. | Structured metadata summaries, improved panels, richer navigation between preview and metadata. | Makes it easier to understand what a file contains before deeper analysis. | +| D3 | Users can inspect one file at a time, which makes comparison work awkward. | Researchers can compare multiple MRD files side by side or in a multi-file review flow. | Multi-file selection, comparison view, synchronized thumbnail and metadata inspection. | Reduces manual effort when comparing reconstructions or runs. | +| Marketplace | Distribution is limited to local installs and GitHub Releases. | Researchers can discover and install the extension from the VS Code Marketplace. | Publisher setup, Marketplace publishing flow, marketplace metadata and branding. | This broadens reach once the core experience is stable. | +| F10 | Backend setup is still a manual step and the first-run experience is uneven. | Researchers get guided backend bootstrap, clearer first-run messages, and fewer setup failures. | Backend auto-detection, setup wizard, robust setup diagnostics, clearer error pages. | This closes the remaining onboarding gap for less technical users. | +| Future ops / support | Researchers can open files, but support and diagnostics are ad hoc. | Teams can collect clear logs, report failures, and troubleshoot the backend more easily. | Better diagnostics, telemetry-safe logging, support docs, known-issue guidance. | Keeps the workflow sustainable as adoption grows. | + +## Recommended shipping order + +1. Ship D1 + Release together as the first researcher-facing milestone. +2. Keep D2 and D3 moving in parallel behind that milestone so feedback can guide the next iteration. +3. Treat Marketplace and F10 as follow-on work that improves discoverability and onboarding after the core flow is proven. diff --git a/mrd-viz/extension/mrd-viz/CHANGELOG.md b/mrd-viz/extension/mrd-viz/CHANGELOG.md new file mode 100644 index 00000000..6b1ea0fa --- /dev/null +++ b/mrd-viz/extension/mrd-viz/CHANGELOG.md @@ -0,0 +1,11 @@ +# 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] + +- Added researcher-facing quick-start guidance for VSIX installation and backend setup. +- Documented the D1 + release roadmap and the broader functionality sequence for future milestones. +- Added an initial D3 comparison scaffold command that lets users select multiple `.mrd` files and open a placeholder compare view. \ No newline at end of file diff --git a/mrd-viz/extension/mrd-viz/README.md b/mrd-viz/extension/mrd-viz/README.md new file mode 100644 index 00000000..ee991429 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/README.md @@ -0,0 +1,54 @@ +# 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. +- Start the D3 comparison scaffold by selecting multiple `.mrd` files and opening the new compare view. + +## Quick start for researchers + +The first researcher-friendly path is a packaged VSIX from a GitHub Release: + +1. Download the latest `mrd-viz-*.vsix` from the MRD Viz GitHub Release assets. +2. Install it with `code --install-extension mrd-viz-*.vsix`. +3. Create or point MRD Viz at a Python 3.12 environment that has the `mrd_viz` backend installed. +4. Open a local `.mrd` file (or run `MRD Viz: Open File`) to view it in a regular VS Code window. + +For the backend setup, the extension looks for the interpreter configured in `mrdViz.pythonPath`; a local development checkout can also auto-detect `mrd-viz/backend/.venv`. + +See [mrd-viz/docs/RESEARCHER_ONBOARDING_AND_ROADMAP.md](../../docs/RESEARCHER_ONBOARDING_AND_ROADMAP.md) for the phased D1/release roadmap and the broader functionality plan. + +## Requirements + +MRD Viz relies on a Python backend (the `mrd_viz` package) to read `.mrd` files. You need: + +- Python 3.12 +- The `mrd_viz` backend installed into an environment the extension can find. + +Point the extension at the interpreter with the `mrdViz.pythonPath` setting (see below). During local development the extension also auto-detects a virtual environment at `mrd-viz/backend/.venv`. + +## Extension Settings + +This extension contributes the following settings: + +- `mrdViz.pythonPath`: Python executable used to run `python -m mrd_viz.cli`. Set this to the backend environment's interpreter. +- `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. + +## 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/package.json b/mrd-viz/extension/mrd-viz/package.json new file mode 100644 index 00000000..b8c0cbf3 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/package.json @@ -0,0 +1,114 @@ +{ + "name": "mrd-viz", + "displayName": "MRD Viz", + "description": "MRD file viewer for VS Code", + "version": "0.0.1", + "publisher": "ismrmrd", + "license": "MIT", + "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" + }, + { + "command": "mrd-viz.compareFiles", + "title": "Compare Files (D3 scaffolding)", + "category": "MRD Viz" + } + ], + "customEditors": [ + { + "viewType": "mrd-viz.mrdFile", + "displayName": "MRD Viz", + "selector": [ + { + "filenamePattern": "*.mrd" + } + ], + "priority": "default" + } + ], + "configuration": { + "title": "MRD Viz", + "properties": { + "mrdViz.pythonPath": { + "type": "string", + "default": "python", + "markdownDescription": "Python executable used to run `python -m mrd_viz.cli`. Set this to the backend virtual environment interpreter when needed." + }, + "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" + }, + { + "command": "mrd-viz.compareFiles", + "when": "resourceExtname == .mrd", + "group": "navigation" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "pretest": "npm run compile && npm run lint", + "lint": "eslint src", + "test": "vscode-test" + }, + "devDependencies": { + "@types/vscode": "^1.120.0", + "@types/mocha": "^10.0.10", + "@types/node": "22.x", + "typescript-eslint": "^8.56.1", + "eslint": "^9.39.3", + "typescript": "^5.9.3", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2" + }, + "overrides": { + "diff": "^8.0.3", + "serialize-javascript": "^7.0.5" + } +} diff --git a/mrd-viz/extension/mrd-viz/src/d3Scaffold.ts b/mrd-viz/extension/mrd-viz/src/d3Scaffold.ts new file mode 100644 index 00000000..34044ae2 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/d3Scaffold.ts @@ -0,0 +1,44 @@ +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +export function getD3ScaffoldHtml(webview: vscode.Webview, files: readonly vscode.Uri[]): string { + const cspSource = webview.cspSource; + const fileItems = files.map(file => { + const label = path.basename(file.fsPath); + return `
  • ${escapeHtml(label)}
  • `; + }).join(''); + + return ` + + + + + + MRD Viz Comparison Scaffold + + + +
    +

    MRD Viz D3 scaffolding

    +

    This placeholder view is the first step for the upcoming multi-file comparison experience.

    +

    Selected files:

    + +

    Next steps for D3: add synchronized thumbnails, side-by-side metadata, and a shared selection model.

    +
    + + `; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} 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..175fbaa4 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/extension.ts @@ -0,0 +1,172 @@ +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { invalidateBackendCache } from './backendResolver'; +import { getD3ScaffoldHtml } from './d3Scaffold'; +import { MrdEditorProvider, MRD_VIEW_TYPE } from './mrdEditorProvider'; + +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()), + vscode.commands.registerCommand('mrd-viz.selectInterpreter', () => selectInterpreter()), + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('mrdViz.pythonPath')) { + invalidateBackendCache(); + } + }), + ); + + const openFileDisposable = 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}`); + } + }); + + const compareFilesDisposable = vscode.commands.registerCommand('mrd-viz.compareFiles', async (resource?: vscode.Uri, selectedResources?: vscode.Uri[]) => { + const targetUris = await resolveCompareTargetUris(resource, selectedResources); + if (targetUris.length === 0) { + void vscode.window.showInformationMessage('Select one or more .mrd files to start the D3 comparison scaffold.'); + return; + } + + const panel = vscode.window.createWebviewPanel( + 'mrd-viz.compareScaffold', + 'MRD Viz Compare (D3 scaffolding)', + { viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, + { enableScripts: true }, + ); + panel.webview.html = getD3ScaffoldHtml(panel.webview, targetUris); + }); + + context.subscriptions.push(openFileDisposable, compareFilesDisposable); +} + +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 resolveCompareTargetUris(resource?: vscode.Uri, selectedResources?: vscode.Uri[]): Promise { + if (resource?.scheme === 'file' && isMrdFile(resource.fsPath)) { + return [resource]; + } + + const selectedMrdFiles = selectedResources?.filter(item => item.scheme === 'file' && isMrdFile(item.fsPath)) ?? []; + if (selectedMrdFiles.length > 0) { + return selectedMrdFiles; + } + + const activeUri = vscode.window.activeTextEditor?.document.uri; + if (activeUri?.scheme === 'file' && isMrdFile(activeUri.fsPath)) { + return [activeUri]; + } + + const pickedFiles = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + filters: { + 'MRD files': ['mrd'], + }, + openLabel: 'Compare MRD Files', + }); + + return pickedFiles?.filter(item => item.scheme === 'file' && isMrdFile(item.fsPath)) ?? []; +} + +async function setUpBackend(): Promise { + const action = await vscode.window.showInformationMessage( + 'MRD Viz needs a Python 3.12 environment with the "mrd_viz" package installed. Point MRD Viz at that interpreter, or open this workspace in the MRD Viz dev container.', + 'Select Python Interpreter\u2026', + ); + if (action) { + await selectInterpreter(); + } +} + +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; + } + + await vscode.workspace.getConfiguration('mrdViz').update('pythonPath', picked[0].fsPath, vscode.ConfigurationTarget.Global); + invalidateBackendCache(); + void vscode.window.showInformationMessage('MRD Viz Python interpreter updated. Re-open the .mrd file to load it.'); +} + +function isMrdFile(filePath: string): boolean { + return filePath.toLowerCase().endsWith('.mrd'); +} + 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..acb78df5 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/src/test/extension.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +import { getOpenWithMrdEditorArgs } from '../extension'; +import { MRD_VIEW_TYPE } from '../mrdEditorProvider'; +import { getMrdBackendMissingHtml, getMrdErrorHtml } from '../webviewHtml'; + +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')); + assert.ok(commands.includes('mrd-viz.compareFiles')); + }); + + 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('