diff --git a/.vscode-test.mjs b/.vscode-test.mjs index d320b89..b36d3f4 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,7 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; +const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE_PATH; + export default defineConfig({ files: 'out/test/**/*.test.js', + ...(vscodeExecutablePath ? { useInstallation: { fromPath: vscodeExecutablePath } } : {}), launchArgs: [ '--user-data-dir=/tmp/extension-vscode-test-user-data', '--extensions-dir=/tmp/extension-vscode-test-extensions' diff --git a/.vscodeignore b/.vscodeignore index 3c7d9d9..8f65c1e 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,6 +1,8 @@ .vscode/** .vscode-test/** +scripts/ src/** +out/test/** .gitignore .yarnrc .prettierrc diff --git a/README.md b/README.md index a5fff77..f0b1757 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ Use the command `Semantic Models: Select SAMM CLI Executable` to choose either: - Manual validation command: - `Semantic Models: Validate Document Now` +## Graphical View + +Use `Semantic Models: Open Graphical View` (`semantic-models.openGraphicalView`) from the Turtle editor-title button, the editor context menu, or the Command Palette to open a read-only SVG snapshot in a separate panel. One panel is reused per Turtle document. + +The view renders on initial open, manual Refresh, and Save while the main Turtle document is visible. It includes unsaved text from the main document but uses the persisted versions of imported files. Typing, import saves, hidden-document saves, and revealing an existing panel do not trigger a render. + +Element headers can navigate to definitions in local files. Eligible attribute rows whose owner is a named model element navigate as a whole row; language-qualified and wrapped rows retain their source mapping, and aggregated rows navigate to the start of the corresponding predicate. Rows with anonymous or otherwise non-deterministic owners remain inert. Navigation to remote URIs and arbitrary referenced values is not supported. + +If rendering fails, the last successful diagram remains visible with an error or warning. Rendering is limited to 1,000 boxes and a 30-second request timeout. The graphical view is not an editor, does not update live while typing, and does not claim visual parity with the Aspect Model Editor. + ## Running the Server and Extension Together 1. In this extension project, install the dependencies using `npm install`. diff --git a/media/aspect-model-editor-targetsize-192.png b/media/aspect-model-editor-targetsize-192.png new file mode 100644 index 0000000..a8f811b Binary files /dev/null and b/media/aspect-model-editor-targetsize-192.png differ diff --git a/package-lock.json b/package-lock.json index 04c2e22..3500327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MPL-2.0", "dependencies": { + "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" @@ -561,6 +562,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/vscode": { "version": "1.120.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", @@ -1422,6 +1430,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 3183f41..3465f8c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,16 @@ "command": "semantic-models.selectSammCliExecutable", "title": "Select SAMM CLI Executable", "category": "Semantic Models" + }, + { + "command": "semantic-models.openGraphicalView", + "title": "Open Graphical View", + "category": "Semantic Models", + "enablement": "editorLangId == turtle", + "icon": { + "light": "media/aspect-model-editor-targetsize-192.png", + "dark": "media/aspect-model-editor-targetsize-192.png" + } } ], "languages": [ @@ -150,11 +160,23 @@ } }, "menus": { + "editor/title": [ + { + "command": "semantic-models.openGraphicalView", + "when": "resourceLangId == turtle", + "group": "navigation@10" + } + ], "editor/context": [ { "command": "semantic-models.validateDocumentNow", "when": "resourceLangId == turtle", "group": "1_modification" + }, + { + "command": "semantic-models.openGraphicalView", + "when": "resourceLangId == turtle", + "group": "1_modification@2" } ] }, @@ -167,7 +189,7 @@ { "id": "select-samm-cli", "title": "Select a SAMM CLI Executable", - "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)", + "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:semantic-models.selectSammCliExecutable)", "media": { "image": "media/walkthrough_select_samm_cli.png", "altText": "Example of selecting a SAMM CLI executable" @@ -190,14 +212,15 @@ "vscode:prepublish": "npm run build", "watch": "tsc -watch -p ./", "pretest": "npm run build && npm run lint", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node scripts/copy-webview-assets.mjs", "build-watch": "tsc -p tsconfig.json --watch", "prettier": "prettier --config .prettierrc --write './src/**/*{.ts,.js,.json}'", "test": "vscode-test --config .vscode-test.mjs", "test:prettier": "prettier --config .prettierrc --list-different './src/**/*{.ts,.js,.json}'", "test:coverage": "vscode-test --config .vscode-test.mjs --coverage --coverage-reporter text --coverage-reporter lcov", "lint": "eslint src --ext .ts", - "lint:fix": "eslint src --ext .ts --fix" + "lint:fix": "eslint src --ext .ts --fix", + "test:webview-assets": "node scripts/verify-webview-assets.mjs" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -210,6 +233,7 @@ "typescript-eslint": "^8.56.1" }, "dependencies": { + "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" diff --git a/scripts/copy-webview-assets.mjs b/scripts/copy-webview-assets.mjs new file mode 100644 index 0000000..8be932c --- /dev/null +++ b/scripts/copy-webview-assets.mjs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {copyFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +for (const asset of webviewAssets) { + if (asset.expectedSha256 && sha256(asset.source) !== asset.expectedSha256) { + throw new Error(`Pinned webview asset hash mismatch: ${asset.destination}`); + } +} + +rmSync(outputDirectory, {recursive: true, force: true}); +mkdirSync(outputDirectory, {recursive: true}); +for (const asset of webviewAssets) { + copyFileSync(asset.source, join(outputDirectory, asset.destination)); +} + +const actual = readdirSync(outputDirectory).sort(); +const expected = webviewAssets.map(asset => asset.destination).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Webview asset copy produced an unexpected inventory.'); +} diff --git a/scripts/verify-webview-assets.mjs b/scripts/verify-webview-assets.mjs new file mode 100644 index 0000000..db1355c --- /dev/null +++ b/scripts/verify-webview-assets.mjs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {readFileSync, readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; +import {extensionRoot, outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +const packageJson = JSON.parse(readFileSync(join(extensionRoot, 'package.json'), 'utf8')); +if (packageJson.dependencies?.dompurify !== '3.4.13') { + throw new Error('DOMPurify must remain pinned exactly to 3.4.13.'); +} + +const actual = readdirSync(outputDirectory).sort(); +const expected = webviewAssets.map(asset => asset.destination).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected out/webview inventory: ${actual.join(', ')}`); +} + +for (const asset of webviewAssets) { + const output = join(outputDirectory, asset.destination); + if (!statSync(output).isFile() || statSync(output).size === 0) { + throw new Error(`Missing or empty webview asset: ${asset.destination}`); + } + if (sha256(asset.source) !== sha256(output)) { + throw new Error(`Copied webview asset differs from its source: ${asset.destination}`); + } + if (asset.expectedSha256 && sha256(output) !== asset.expectedSha256) { + throw new Error(`Pinned webview asset hash mismatch: ${asset.destination}`); + } +} + +process.stdout.write(`Verified ${webviewAssets.length} deterministic webview assets.\n`); diff --git a/scripts/webview-assets.mjs b/scripts/webview-assets.mjs new file mode 100644 index 0000000..f2b8b7a --- /dev/null +++ b/scripts/webview-assets.mjs @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {createHash} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const outputDirectory = resolve(extensionRoot, 'out', 'webview'); + +export const webviewAssets = Object.freeze([ + asset('node_modules/dompurify/LICENSE', 'DOMPurify-LICENSE-Apache-2.0.txt'), + asset('node_modules/dompurify/LICENSE-MPL', 'DOMPurify-LICENSE-MPL-2.0.txt'), + asset( + 'node_modules/dompurify/dist/purify.min.js', + 'purify.min.js', + '9ab3d44d73c3e3947f9ab72e0f0bc15c7f1931d60b365ba261fc85fe59013c56', + ), + asset('src/webview/RobotoCondensed-NOTICE.txt', 'RobotoCondensed-NOTICE.txt'), + asset( + 'src/webview/RobotoCondensed-Regular.ttf', + 'RobotoCondensed-Regular.ttf', + '4a7c36df4318fee50a8159c3a0ebde4572abab65447ae4a651c2fe87212302b5', + ), + asset('src/webview/sanitizer-contract.js', 'sanitizer-contract.js'), + asset('src/webview/webview.css', 'webview.css'), + asset('src/webview/webview.js', 'webview.js'), +]); + +export function sha256(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function asset(source, destination, expectedSha256) { + return Object.freeze({ + source: resolve(extensionRoot, source), + destination, + ...(expectedSha256 ? {expectedSha256} : {}), + }); +} diff --git a/src/aspectValidation.ts b/src/aspectValidation.ts index 377c354..59aea41 100644 --- a/src/aspectValidation.ts +++ b/src/aspectValidation.ts @@ -30,7 +30,7 @@ export interface DiagnosticReport { } export interface RequestClient { - sendRequest(method: string, params?: unknown): Thenable; + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Thenable; } export interface ValidationWindow { diff --git a/src/extension.ts b/src/extension.ts index 34f28fc..7780f22 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,9 @@ import { TurtleExtensionSettings } from './settings'; import { TurtleLanguageClient } from './languageClient'; import { GitHubRepositoryValidator } from './githubRepositoryValidator'; import type { ExtensionLogger } from './outputChannel'; +import { GraphicalViewController } from './graphicalView'; +import {LspGraphicalViewClient} from './graphicalViewClient'; +import { VscodeGraphicalViewPanelFactory } from './graphicalViewPanel'; const SELECT_EXECUTABLE_COMMAND = 'semantic-models.selectSammCliExecutable'; const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable'; @@ -29,6 +32,7 @@ let settings: TurtleExtensionSettings; let languageServer: TurtleLanguageServer | undefined; let languageClient: TurtleLanguageClient; let aspectValidationController: AspectValidationController; +let graphicalViewController: GraphicalViewController; let sammCliDownloader: SammCliDownloader; let gitHubRepositoryValidator: GitHubRepositoryValidator; @@ -48,6 +52,40 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel()); aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel); aspectValidationController.register(context); + graphicalViewController = new GraphicalViewController( + undefined, + new VscodeGraphicalViewPanelFactory(context.extensionUri), + vscode.commands, + vscode.window, + { + onDidSaveTextDocument: listener => vscode.workspace.onDidSaveTextDocument(listener), + onDidChangeDocumentAvailability: listener => + vscode.Disposable.from( + vscode.workspace.onDidOpenTextDocument(document => listener(document.uri.toString(), true)), + vscode.workspace.onDidCloseTextDocument(document => listener(document.uri.toString(), false)), + vscode.window.tabGroups.onDidChangeTabs(event => { + for (const tab of event.closed) { + if (tab.input instanceof vscode.TabInputText) { + const sourceUri = tab.input.uri.toString(); + const remainsOpen = vscode.window.tabGroups.all.some(group => + group.tabs.some( + candidate => + candidate.input instanceof vscode.TabInputText && + candidate.input.uri.toString() === sourceUri, + ), + ); + if (!remainsOpen) { + listener(sourceUri, false); + } + } + } + }), + ), + isDocumentAvailable: uri => vscode.workspace.textDocuments.some(document => document.uri.toString() === uri), + }, + outputChannel, + ); + graphicalViewController.register(context); context.subscriptions.push( vscode.languages.setLanguageConfiguration('turtle', { @@ -153,6 +191,7 @@ async function restartLanguageServices(reason: string): Promise { outputChannel.info(`Restarting language services (${reason}).`); aspectValidationController.setClient(createUnavailableClient()); + graphicalViewController.setClient(undefined); await languageClient.disconnect(); await stopLanguageServer(); @@ -165,6 +204,7 @@ async function restartLanguageServices(reason: string): Promise { } catch (error) { await stopLanguageServer().catch(() => undefined); aspectValidationController.setClient(createUnavailableClient()); + graphicalViewController.setClient(undefined); throw error; } @@ -173,6 +213,7 @@ async function restartLanguageServices(reason: string): Promise { await nextClient.connect(); languageClient = nextClient; aspectValidationController.setClient(nextClient); + graphicalViewController.setClient(new LspGraphicalViewClient(nextClient)); } type SammCliQuickPickItem = vscode.QuickPickItem & { diff --git a/src/graphicalView.ts b/src/graphicalView.ts new file mode 100644 index 0000000..753f8b1 --- /dev/null +++ b/src/graphicalView.ts @@ -0,0 +1,618 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import type {ExtensionLogger} from './outputChannel'; +import type {GraphicalViewClient} from './graphicalViewClient'; +import {resolveGraphicalViewNavigation} from './graphicalViewNavigation'; +import { + GraphicalViewPanel, + GraphicalViewPanelFactory, + GraphicalViewStatus, + parseGraphicalViewPanelMessage, +} from './graphicalViewPanel'; +import type {GraphicalViewRenderResult, GraphicalViewRenderWarning, GraphicalViewTarget} from './graphicalViewProtocol'; +import {AcceptedGraphicalViewResult, acceptGraphicalViewResult, isGraphicalViewRenderResult} from './graphicalViewResult'; + +export const OPEN_GRAPHICAL_VIEW_COMMAND = 'semantic-models.openGraphicalView'; + +export interface GraphicalViewDocument { + readonly languageId: string; + readonly uri: vscode.Uri; +} + +export interface GraphicalViewWindow { + readonly activeTextEditor: {readonly document: GraphicalViewDocument} | undefined; + showWarningMessage(message: string): Thenable; + showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable; +} + +export interface GraphicalViewWorkspace { + onDidSaveTextDocument(listener: (document: GraphicalViewDocument) => void): vscode.Disposable; + onDidChangeDocumentAvailability(listener: (sourceUri: string, available: boolean) => void): vscode.Disposable; + isDocumentAvailable(uri: string): boolean; +} + +export interface GraphicalViewCommands { + registerCommand(command: string, callback: () => unknown): vscode.Disposable; +} + +interface PanelState { + readonly sourceUri: string; + readonly panel: GraphicalViewPanel; + readonly subscriptions: vscode.Disposable[]; + sequence: number; + sourceAvailable: boolean; + visible: boolean; + disposed: boolean; + cancellation: vscode.CancellationTokenSource | undefined; + navigationCancellation: vscode.CancellationTokenSource | undefined; + nextDisplayVersion: number; + status: GraphicalViewStatus; + lastSuccess: AcceptedGraphicalViewResult | undefined; + pendingDelivery: PendingDelivery | undefined; +} + +interface PendingDelivery { + readonly accepted: AcceptedGraphicalViewResult; + readonly requestSequence: number; +} + +const DISCONNECTED_STATUS: GraphicalViewStatus = Object.freeze({ + kind: 'disconnected', + message: 'The Turtle language server is disconnected. Reconnect and use Refresh to try again.', +}); + +export class GraphicalViewController implements vscode.Disposable { + private readonly panels = new Map(); + private readonly subscriptions: vscode.Disposable[] = []; + private clientSubscription: vscode.Disposable | undefined; + private registered = false; + private disposed = false; + + constructor( + private client: GraphicalViewClient | undefined, + private readonly panelFactory: GraphicalViewPanelFactory, + private readonly commands: GraphicalViewCommands, + private readonly window: GraphicalViewWindow, + private readonly workspace: GraphicalViewWorkspace, + private readonly outputChannel: ExtensionLogger, + ) { + this.subscribeToClient(); + } + + register(context: Pick): void { + if (this.registered || this.disposed) { + return; + } + this.registered = true; + this.subscriptions.push( + this.commands.registerCommand(OPEN_GRAPHICAL_VIEW_COMMAND, () => this.openActiveDocument()), + this.workspace.onDidSaveTextDocument(document => this.handleSave(document)), + this.workspace.onDidChangeDocumentAvailability((sourceUri, available) => + this.handleDocumentAvailability(sourceUri, available), + ), + ); + context.subscriptions.push(this); + } + + setClient(client: GraphicalViewClient | undefined): void { + if (this.disposed) { + return; + } + this.clientSubscription?.dispose(); + this.clientSubscription = undefined; + this.client = client; + for (const state of this.panels.values()) { + this.invalidate(state); + this.setStatus(state, client?.isAvailable() ? availableAgainStatus(state) : DISCONNECTED_STATUS); + } + this.subscribeToClient(); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.clientSubscription?.dispose(); + this.clientSubscription = undefined; + for (const subscription of this.subscriptions.splice(0)) { + subscription.dispose(); + } + for (const state of [...this.panels.values()]) { + this.disposePanelState(state, true); + } + } + + private async openActiveDocument(): Promise { + const document = this.window.activeTextEditor?.document; + if (!document || document.languageId !== 'turtle') { + await this.window.showWarningMessage('Open a Turtle file before opening the graphical view.'); + return; + } + + const sourceUri = document.uri.toString(); + const existing = this.panels.get(sourceUri); + if (existing && !existing.disposed) { + existing.panel.reveal(); + return; + } + + const panel = this.panelFactory.create(sourceUri); + const state: PanelState = { + sourceUri, + panel, + subscriptions: [], + sequence: 0, + sourceAvailable: this.workspace.isDocumentAvailable(sourceUri), + visible: panel.visible, + disposed: false, + cancellation: undefined, + navigationCancellation: undefined, + nextDisplayVersion: 0, + status: Object.freeze({kind: 'loading', message: 'Preparing graphical view...'}), + lastSuccess: undefined, + pendingDelivery: undefined, + }; + this.panels.set(sourceUri, state); + state.subscriptions.push( + panel.onDidDispose(() => this.disposePanelState(state, false)), + panel.onDidChangeVisibility(visible => this.handleVisibilityChange(state, visible)), + panel.onDidReceiveMessage(message => this.handlePanelMessage(state, message)), + ); + void this.requestRender(state, 'initial'); + } + + private subscribeToClient(): void { + if (!this.client || this.disposed) { + return; + } + this.clientSubscription = this.client.onDidChangeAvailability(available => this.handleClientAvailability(available)); + } + + private handleClientAvailability(available: boolean): void { + for (const state of this.panels.values()) { + if (!available) { + this.invalidate(state); + this.setStatus(state, DISCONNECTED_STATUS); + } else { + this.setStatus(state, availableAgainStatus(state)); + } + } + } + + private handleSave(document: GraphicalViewDocument): void { + const state = this.panels.get(document.uri.toString()); + if (!state || state.disposed) { + return; + } + if (!state.visible) { + this.invalidate(state); + this.setStatus(state, retainedAfterHiddenSaveStatus(state)); + return; + } + void this.requestRender(state, 'save'); + } + + private handleDocumentAvailability(sourceUri: string, available: boolean): void { + const state = this.panels.get(sourceUri); + if (!state || state.disposed || state.sourceAvailable === available) { + return; + } + state.sourceAvailable = available; + if (available) { + return; + } + this.invalidate(state); + this.setStatus(state, retainedAfterSourceLossStatus(state)); + } + + private handleVisibilityChange(state: PanelState, visible: boolean): void { + if (!this.isCurrent(state)) { + return; + } + state.visible = visible; + if (visible) { + this.deliverStatus(state); + } + } + + private handlePanelMessage(state: PanelState, value: unknown): void { + if (!this.isCurrent(state)) { + return; + } + const message = parseGraphicalViewPanelMessage(value); + if (!message) { + return; + } + switch (message.type) { + case 'ready': + this.deliverCurrentState(state); + return; + case 'refresh': + if (state.visible) { + void this.requestRender(state, 'manual'); + } + return; + case 'rendered': + this.handleRendered(state, message.version); + return; + case 'renderError': + this.handleRenderError(state, message.version); + return; + case 'navigate': + void this.navigateToTarget(state, message.version, message.targetId); + } + } + + private async requestRender(state: PanelState, trigger: 'initial' | 'manual' | 'save'): Promise { + if (!this.isCurrent(state)) { + return; + } + this.cancelRender(state); + const sequence = ++state.sequence; + const sourceUri = state.sourceUri; + state.sourceAvailable = this.workspace.isDocumentAvailable(sourceUri); + if (!state.sourceAvailable) { + this.setStale( + state, + 'sourceUnavailable', + 'The source document is not available to the language server. Reopen it and use Refresh.', + ); + return; + } + + const client = this.client; + if (!client?.isAvailable()) { + this.setStatus(state, DISCONNECTED_STATUS); + return; + } + + const cancellation = new vscode.CancellationTokenSource(); + state.cancellation = cancellation; + this.setStatus(state, Object.freeze({kind: 'loading', message: `Rendering graphical view (${trigger})...`})); + try { + let attributeRowsAvailable = true; + let result: GraphicalViewRenderResult; + try { + result = await client.render({uri: sourceUri, includeAttributeRows: true}, cancellation.token); + } catch (error) { + if (!isInvalidParams(error) || !this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { + throw error; + } + attributeRowsAvailable = false; + result = await client.render({uri: sourceUri}, cancellation.token); + } + if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { + return; + } + state.cancellation = undefined; + cancellation.dispose(); + this.handleRenderResult(state, result, sequence, attributeRowsAvailable); + } catch (error) { + if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { + return; + } + state.cancellation = undefined; + cancellation.dispose(); + this.handleRenderFailure(state, error); + } + } + + private handleRenderResult( + state: PanelState, + result: unknown, + requestSequence: number, + attributeRowsAvailable: boolean, + ): void { + if (!isGraphicalViewRenderResult(result)) { + this.setStale(state, 'invalidResponse', 'The language server returned an invalid graphical-view response.'); + return; + } + if (result.uri !== state.sourceUri) { + this.setStale(state, 'uriMismatch', 'The language server returned a graphical view for a different source document.'); + return; + } + if (!attributeRowsAvailable && result.targets.some(target => target.kind !== 'elementHeader')) { + this.setStale(state, 'invalidResponse', 'The legacy language server returned an invalid graphical-view response.'); + return; + } + if (result.svg === undefined || result.svg === null) { + this.handleWarningResult(state, result.warnings); + return; + } + + const accepted = acceptGraphicalViewResult(result, ++state.nextDisplayVersion, attributeRowsAvailable); + state.pendingDelivery = Object.freeze({accepted, requestSequence}); + state.panel.deliver(Object.freeze({type: 'render', version: accepted.version, svg: accepted.svg})); + } + + private handleRendered(state: PanelState, version: number): void { + const pending = state.pendingDelivery; + if (pending?.accepted.version !== version) { + return; + } + this.cancelNavigation(state); + state.lastSuccess = pending.accepted; + state.pendingDelivery = undefined; + if (state.sequence === pending.requestSequence) { + this.setStatus(state, Object.freeze({ + kind: 'ready', + message: pending.accepted.attributeRowsAvailable + ? 'Graphical view is up to date.' + : 'The server supports header navigation only; attribute-row navigation is unavailable.', + })); + } + } + + private handleRenderError(state: PanelState, version: number): void { + if (state.pendingDelivery?.accepted.version === version) { + state.pendingDelivery = undefined; + } else if (state.lastSuccess?.version !== version) { + return; + } + this.outputChannel.warn('Graphical view rejected an SVG payload at the secure rendering boundary.'); + this.setStale( + state, + 'sanitizationFailed', + 'The new diagram could not be displayed safely. The last successful diagram is retained.', + ); + } + + private async navigateToTarget(state: PanelState, version: number, targetId: string): Promise { + const accepted = state.lastSuccess; + const target = accepted?.version === version ? accepted.targetById.get(targetId) : undefined; + if (!accepted || !target) { + return; + } + + const client = this.client; + if (!client?.isAvailable()) { + await this.window.showWarningMessage( + 'The graphical target is temporarily unavailable because the language server is disconnected.', + ); + return; + } + + this.cancelNavigation(state); + const cancellation = new vscode.CancellationTokenSource(); + state.navigationCancellation = cancellation; + try { + const resolution = await resolveGraphicalViewNavigation(client, state.sourceUri, target, cancellation.token); + if (!this.isCurrentNavigation(state, accepted, target, cancellation)) { + return; + } + state.navigationCancellation = undefined; + cancellation.dispose(); + if (resolution.kind === 'warning') { + await this.window.showWarningMessage(resolution.message); + return; + } + + try { + const editor = await this.window.showTextDocument(resolution.uri, {preview: false}); + if (!this.isCurrentNavigationResult(state, accepted, target)) { + return; + } + editor.selection = new vscode.Selection(resolution.range.start, resolution.range.end); + editor.revealRange(resolution.range, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + } catch (_error) { + await this.window.showWarningMessage('The graphical target could not be opened in an editor.'); + } + } catch (_error) { + if (this.isCurrentNavigation(state, accepted, target, cancellation)) { + state.navigationCancellation = undefined; + cancellation.dispose(); + await this.window.showWarningMessage('The graphical target is temporarily unavailable.'); + } + } + } + + private isCurrentNavigation( + state: PanelState, + accepted: AcceptedGraphicalViewResult, + target: Readonly, + cancellation: vscode.CancellationTokenSource, + ): boolean { + return state.navigationCancellation === cancellation && this.isCurrentNavigationResult(state, accepted, target); + } + + private isCurrentNavigationResult( + state: PanelState, + accepted: AcceptedGraphicalViewResult, + target: Readonly, + ): boolean { + return this.isCurrent(state) + && state.lastSuccess === accepted + && accepted.targetById.get(target.id) === target; + } + + private handleWarningResult(state: PanelState, warnings: readonly GraphicalViewRenderWarning[]): void { + const warning = warnings[0]; + switch (warning) { + case 'timeout': + this.setStale(state, warning, 'Graphical rendering timed out. The last successful diagram is retained.'); + return; + case 'modelTooLarge': + this.setStale(state, warning, 'The model is too large for graphical rendering. The last successful diagram is retained.'); + return; + case 'missingDocument': + this.setStale(state, warning, 'The source document is unavailable. The last successful diagram is retained.'); + return; + case 'unsupportedUri': + this.setStale(state, warning, 'The source URI is not supported for graphical rendering.'); + return; + case 'temporarilyUnresolvable': + default: + this.setStale(state, warning ?? 'renderFailure', 'The model could not be loaded or parsed. The last successful diagram is retained.'); + } + } + + private handleRenderFailure(state: PanelState, error: unknown): void { + if (isMethodNotFound(error)) { + this.setStatus(state, Object.freeze({ + kind: 'unsupported', + message: 'Graphical view is not supported by the current server build. The last successful diagram is retained.', + })); + return; + } + const reason = classifyFailure(error instanceof Error ? error.message : String(error)); + this.outputChannel.warn(`Graphical view render request failed (${reason}).`); + this.setStale(state, reason, 'Graphical rendering failed. The last successful diagram is retained.'); + } + + private setStale(state: PanelState, reason: string, message: string): void { + this.setStatus(state, Object.freeze({kind: 'stale', reason, message})); + } + + private setStatus(state: PanelState, status: GraphicalViewStatus): void { + if (!this.isCurrent(state)) { + return; + } + state.status = status; + this.deliverStatus(state); + } + + private deliverCurrentState(state: PanelState): void { + this.deliverStatus(state); + const accepted = state.pendingDelivery?.accepted ?? state.lastSuccess; + if (accepted) { + state.panel.deliver(Object.freeze({type: 'render', version: accepted.version, svg: accepted.svg})); + } + } + + private deliverStatus(state: PanelState): void { + state.panel.deliver(Object.freeze({type: 'status', status: state.status})); + } + + private invalidate(state: PanelState): void { + if (!this.isCurrent(state)) { + return; + } + this.cancelRender(state); + this.cancelNavigation(state); + state.pendingDelivery = undefined; + state.sequence += 1; + } + + private cancelRender(state: PanelState): void { + const cancellation = state.cancellation; + state.cancellation = undefined; + if (cancellation) { + cancellation.cancel(); + cancellation.dispose(); + } + } + + private cancelNavigation(state: PanelState): void { + const cancellation = state.navigationCancellation; + state.navigationCancellation = undefined; + if (cancellation) { + cancellation.cancel(); + cancellation.dispose(); + } + } + + private isCurrentRequest( + state: PanelState, + sourceUri: string, + sequence: number, + cancellation: vscode.CancellationTokenSource, + ): boolean { + return this.isCurrent(state) + && state.sourceUri === sourceUri + && state.sequence === sequence + && state.cancellation === cancellation; + } + + private isCurrent(state: PanelState): boolean { + return !this.disposed && !state.disposed && this.panels.get(state.sourceUri) === state; + } + + private disposePanelState(state: PanelState, disposePanel: boolean): void { + if (state.disposed) { + return; + } + this.cancelRender(state); + this.cancelNavigation(state); + state.sequence += 1; + state.disposed = true; + this.panels.delete(state.sourceUri); + for (const subscription of state.subscriptions.splice(0)) { + subscription.dispose(); + } + if (disposePanel) { + state.panel.dispose(); + } + } +} + +function availableAgainStatus(state: PanelState): GraphicalViewStatus { + return Object.freeze({ + kind: 'stale', + reason: 'clientAvailable', + message: state.lastSuccess + ? 'The language server is available again. The retained diagram remains visible; use Refresh to update it.' + : 'The language server is available. Use Refresh to render the graphical view.', + }); +} + +function retainedAfterHiddenSaveStatus(state: PanelState): GraphicalViewStatus { + return state.lastSuccess + ? Object.freeze({kind: 'ready', message: 'Showing the retained graphical-view snapshot.'}) + : Object.freeze({ + kind: 'stale', + reason: 'noSnapshot', + message: 'No graphical-view snapshot is available. Reveal the panel and use Refresh to render one.', + }); +} + +function retainedAfterSourceLossStatus(state: PanelState): GraphicalViewStatus { + return state.lastSuccess + ? Object.freeze({kind: 'ready', message: 'Showing the retained graphical-view snapshot.'}) + : Object.freeze({ + kind: 'stale', + reason: 'sourceUnavailable', + message: 'The source document is no longer open. Reopen it and use Refresh to render the graphical view.', + }); +} + +function isMethodNotFound(error: unknown): boolean { + return (isRecord(error) && error.code === -32601) + || (error instanceof Error && /method\s+not\s+found/i.test(error.message)); +} + +function isInvalidParams(error: unknown): boolean { + return isRecord(error) && error.code === -32602; +} + +function classifyFailure(message: string): string { + if (/timeout|timed out/i.test(message)) { + return 'timeout'; + } + if (/parse|syntax/i.test(message)) { + return 'parse'; + } + if (/load/i.test(message)) { + return 'loading'; + } + if (/transport|connection|socket/i.test(message)) { + return 'transport'; + } + return 'error'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/graphicalViewClient.ts b/src/graphicalViewClient.ts new file mode 100644 index 0000000..fa76a94 --- /dev/null +++ b/src/graphicalViewClient.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import type * as vscode from 'vscode'; +import type {RequestClient} from './aspectValidation'; +import { + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GraphicalViewRenderParams, + GraphicalViewRenderResult, + GraphicalViewResolveAttributeTargetParams, + GraphicalViewResolveAttributeTargetResult, + GraphicalViewResolveTargetParams, + GraphicalViewResolveTargetResult, +} from './graphicalViewProtocol'; + +export interface GraphicalViewClient { + isAvailable(): boolean; + onDidChangeAvailability(listener: (available: boolean) => void): vscode.Disposable; + render(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Thenable; + resolveElement( + params: GraphicalViewResolveTargetParams, + token: vscode.CancellationToken, + ): Thenable; + resolveAttribute( + params: GraphicalViewResolveAttributeTargetParams, + token: vscode.CancellationToken, + ): Thenable; +} + +export interface GraphicalViewRequestTransport extends RequestClient { + isAvailable(): boolean; + onDidChangeAvailability(listener: (available: boolean) => void): vscode.Disposable; +} + +export class LspGraphicalViewClient implements GraphicalViewClient { + constructor(private readonly transport: GraphicalViewRequestTransport) {} + + isAvailable(): boolean { + return this.transport.isAvailable(); + } + + onDidChangeAvailability(listener: (available: boolean) => void): vscode.Disposable { + return this.transport.onDidChangeAvailability(listener); + } + + render(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Thenable { + return this.transport.sendRequest(GRAPHICAL_VIEW_RENDER_REQUEST, params, token); + } + + resolveElement( + params: GraphicalViewResolveTargetParams, + token: vscode.CancellationToken, + ): Thenable { + return this.transport.sendRequest( + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + params, + token, + ); + } + + resolveAttribute( + params: GraphicalViewResolveAttributeTargetParams, + token: vscode.CancellationToken, + ): Thenable { + return this.transport.sendRequest( + GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, + params, + token, + ); + } +} diff --git a/src/graphicalViewNavigation.ts b/src/graphicalViewNavigation.ts new file mode 100644 index 0000000..3ea9305 --- /dev/null +++ b/src/graphicalViewNavigation.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import type {GraphicalViewClient} from './graphicalViewClient'; +import type {GraphicalViewResolveTargetWarning, GraphicalViewTarget} from './graphicalViewProtocol'; + +export type GraphicalViewNavigationResolution = + | Readonly<{kind: 'location'; uri: vscode.Uri; range: vscode.Range}> + | Readonly<{kind: 'warning'; message: string}>; + +const RESOLVE_WARNINGS: ReadonlySet = new Set([ + 'notFound', + 'ambiguous', + 'unsupportedUri', + 'temporarilyUnresolvable', +]); + +export async function resolveGraphicalViewNavigation( + client: GraphicalViewClient, + sourceUri: string, + target: Readonly, + token: vscode.CancellationToken, +): Promise { + const response = target.kind === 'elementHeader' + ? await client.resolveElement({sourceUri, elementUrn: target.elementUrn}, token) + : await client.resolveAttribute( + { + sourceUri, + ownerUrn: target.ownerUrn, + predicateUrn: target.predicateUrn, + selection: target.selection, + ...(target.language === undefined ? {} : {language: target.language}), + }, + token, + ); + return validateGraphicalViewResolveResult(response); +} + +export function validateGraphicalViewResolveResult(value: unknown): GraphicalViewNavigationResolution { + if (!isRecord(value) || !hasOnlyKeys(value, ['location', 'warning'])) { + return invalidLocation(); + } + + const warning = value.warning; + if (warning !== undefined && warning !== null && (typeof warning !== 'string' || !RESOLVE_WARNINGS.has(warning))) { + return invalidLocation(); + } + const location = value.location; + if (location === undefined || location === null) { + return typeof warning === 'string' + ? {kind: 'warning', message: resolveWarningMessage(warning as GraphicalViewResolveTargetWarning)} + : invalidLocation(); + } + if (warning !== undefined && warning !== null) { + return invalidLocation(); + } + if (!isRecord(location) || !hasExactKeys(location, ['range', 'uri']) || typeof location.uri !== 'string') { + return invalidLocation(); + } + const range = location.range; + if (!isRecord(range) || !hasExactKeys(range, ['end', 'start'])) { + return invalidLocation(); + } + const start = validatePosition(range.start); + const end = validatePosition(range.end); + if (!start || !end || start.isAfter(end)) { + return invalidLocation(); + } + + try { + const uri = vscode.Uri.parse(location.uri, true); + if (uri.scheme !== 'file' + || uri.authority !== '' + || !uri.path.startsWith('/') + || uri.query !== '' + || uri.fragment !== '' + || uri.fsPath.includes('\0')) { + return {kind: 'warning', message: resolveWarningMessage('unsupportedUri')}; + } + return {kind: 'location', uri, range: new vscode.Range(start, end)}; + } catch (_error) { + return invalidLocation(); + } +} + +function validatePosition(value: unknown): vscode.Position | undefined { + if (!isRecord(value) + || !hasExactKeys(value, ['character', 'line']) + || !Number.isSafeInteger(value.line) + || !Number.isSafeInteger(value.character) + || (value.line as number) < 0 + || (value.character as number) < 0) { + return undefined; + } + return new vscode.Position(value.line as number, value.character as number); +} + +function invalidLocation(): GraphicalViewNavigationResolution { + return {kind: 'warning', message: 'The language server returned an invalid graphical target location.'}; +} + +function resolveWarningMessage(warning: GraphicalViewResolveTargetWarning): string { + switch (warning) { + case 'notFound': + return 'The graphical target no longer exists in the current model.'; + case 'ambiguous': + return 'The graphical target is ambiguous in the current model.'; + case 'unsupportedUri': + return 'The graphical target is not a local file and cannot be opened.'; + case 'temporarilyUnresolvable': + return 'The graphical target is temporarily unavailable. Fix any model syntax errors and try again.'; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Object.keys(value).every(key => allowed.has(key)); +} + +function hasExactKeys(value: Record, expectedKeys: readonly string[]): boolean { + const actualKeys = Object.keys(value).sort(); + return actualKeys.length === expectedKeys.length && actualKeys.every((key, index) => key === expectedKeys[index]); +} diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts new file mode 100644 index 0000000..9af26c5 --- /dev/null +++ b/src/graphicalViewPanel.ts @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import {randomBytes} from 'node:crypto'; +import * as vscode from 'vscode'; +import {GRAPHICAL_VIEW_MARKER_PATTERN} from './graphicalViewProtocol'; + +const VIEW_TYPE = 'semantic-models.graphicalView'; +export const WEBVIEW_ASSET_DIRECTORY = Object.freeze(['out', 'webview'] as const); +export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['purify.min.js', 'sanitizer-contract.js', 'webview.js'] as const); + +export type GraphicalViewStatus = + | Readonly<{kind: 'loading'; message: string}> + | Readonly<{kind: 'ready'; message: string}> + | Readonly<{kind: 'stale'; reason: string; message: string}> + | Readonly<{kind: 'unsupported'; message: string}> + | Readonly<{kind: 'disconnected'; message: string}>; + +export type GraphicalViewDelivery = + | Readonly<{type: 'status'; status: GraphicalViewStatus}> + | Readonly<{type: 'render'; version: number; svg: string}>; + +export type GraphicalViewPanelMessage = + | Readonly<{type: 'ready'}> + | Readonly<{type: 'refresh'}> + | Readonly<{type: 'rendered'; version: number}> + | Readonly<{type: 'renderError'; version: number; reason: 'sanitizationFailed'}> + | Readonly<{type: 'navigate'; version: number; targetId: string}>; + +export interface GraphicalViewPanel extends vscode.Disposable { + readonly visible: boolean; + reveal(): void; + deliver(delivery: GraphicalViewDelivery): void; + onDidDispose(listener: () => void): vscode.Disposable; + onDidChangeVisibility(listener: (visible: boolean) => void): vscode.Disposable; + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable; +} + +export interface GraphicalViewPanelFactory { + create(sourceUri: string): GraphicalViewPanel; +} + +export class VscodeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + constructor(private readonly extensionUri: vscode.Uri) {} + + create(sourceUri: string): GraphicalViewPanel { + const uri = vscode.Uri.parse(sourceUri, true); + const name = uri.path.split('/').filter(Boolean).at(-1) ?? 'Aspect Model'; + const panel = vscode.window.createWebviewPanel( + VIEW_TYPE, + `Graphical View: ${name}`, + vscode.ViewColumn.Beside, + createGraphicalViewPanelOptions(this.extensionUri), + ); + return new VscodeGraphicalViewPanel(panel, this.extensionUri); + } +} + +class VscodeGraphicalViewPanel implements GraphicalViewPanel { + constructor( + private readonly panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + ) { + panel.webview.html = createGraphicalViewShell(panel.webview, extensionUri); + } + + get visible(): boolean { + return this.panel.visible; + } + + reveal(): void { + this.panel.reveal(undefined, false); + } + + deliver(delivery: GraphicalViewDelivery): void { + void this.panel.webview.postMessage(delivery); + } + + onDidDispose(listener: () => void): vscode.Disposable { + return this.panel.onDidDispose(listener); + } + + onDidChangeVisibility(listener: (visible: boolean) => void): vscode.Disposable { + return this.panel.onDidChangeViewState(event => listener(event.webviewPanel.visible)); + } + + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable { + return this.panel.webview.onDidReceiveMessage(listener); + } + + dispose(): void { + this.panel.dispose(); + } +} + +export function webviewAssetDirectory(extensionUri: vscode.Uri): vscode.Uri { + return vscode.Uri.joinPath(extensionUri, ...WEBVIEW_ASSET_DIRECTORY); +} + +export function createGraphicalViewPanelOptions(extensionUri: vscode.Uri): vscode.WebviewPanelOptions & vscode.WebviewOptions { + return { + enableScripts: true, + enableForms: false, + enableCommandUris: false, + localResourceRoots: [webviewAssetDirectory(extensionUri)], + }; +} + +export function createGraphicalViewShell( + webview: Pick, + extensionUri: vscode.Uri, +): string { + const nonce = randomBytes(18).toString('base64'); + const assetDirectory = webviewAssetDirectory(extensionUri); + const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(assetDirectory, 'webview.css')); + const scriptUris = WEBVIEW_SCRIPT_ORDER.map(asset => webview.asWebviewUri(vscode.Uri.joinPath(assetDirectory, asset))); + const csp = [ + "default-src 'none'", + `script-src 'nonce-${nonce}'`, + `style-src ${webview.cspSource}`, + `font-src ${webview.cspSource}`, + "img-src 'none'", + "connect-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join('; '); + + return ` + + + + + + + Graphical View + + + +
+
+
+ + + + +`; +} + +export function parseGraphicalViewPanelMessage(value: unknown): GraphicalViewPanelMessage | undefined { + if (!isRecord(value)) { + return undefined; + } + const keys = Object.keys(value).sort(); + if ((value.type === 'ready' || value.type === 'refresh') && keys.length === 1) { + return value as {type: 'ready'} | {type: 'refresh'}; + } + if (value.type === 'rendered' + && hasExactKeys(keys, ['type', 'version']) + && isDisplayedVersion(value.version)) { + return value as {type: 'rendered'; version: number}; + } + if (value.type === 'renderError' + && hasExactKeys(keys, ['reason', 'type', 'version']) + && value.reason === 'sanitizationFailed' + && isDisplayedVersion(value.version)) { + return value as {type: 'renderError'; version: number; reason: 'sanitizationFailed'}; + } + if (value.type === 'navigate' + && hasExactKeys(keys, ['targetId', 'type', 'version']) + && isDisplayedVersion(value.version) + && typeof value.targetId === 'string' + && GRAPHICAL_VIEW_MARKER_PATTERN.test(value.targetId)) { + return value as {type: 'navigate'; version: number; targetId: string}; + } + return undefined; +} + +function isDisplayedVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasExactKeys(actualKeys: readonly string[], expectedKeys: readonly string[]): boolean { + return actualKeys.length === expectedKeys.length && actualKeys.every((key, index) => key === expectedKeys[index]); +} diff --git a/src/graphicalViewProtocol.ts b/src/graphicalViewProtocol.ts new file mode 100644 index 0000000..cb547ec --- /dev/null +++ b/src/graphicalViewProtocol.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +export const GRAPHICAL_VIEW_RENDER_REQUEST = 'turtle/graphicalView/render'; +export const GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST = 'turtle/graphicalView/resolveTarget'; +export const GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST = 'turtle/graphicalView/resolveAttributeTarget'; +export const GRAPHICAL_VIEW_HEADER_MARKER_PATTERN = /^gv-header-[a-z0-9]{16,32}$/; +export const GRAPHICAL_VIEW_ATTRIBUTE_MARKER_PATTERN = /^gv-attribute-[a-z0-9]{16,32}$/; +export const GRAPHICAL_VIEW_MARKER_PATTERN = /^gv-(?:header|attribute)-[a-z0-9]{16,32}$/; + +export type GraphicalViewRenderWarning = + | 'unsupportedUri' + | 'missingDocument' + | 'modelTooLarge' + | 'timeout' + | 'temporarilyUnresolvable'; + +export type GraphicalViewResolveTargetWarning = 'notFound' | 'ambiguous' | 'unsupportedUri' | 'temporarilyUnresolvable'; + +export interface GraphicalViewRenderParams { + uri: string; + includeAttributeRows?: boolean; +} + +export interface GraphicalViewElementHeaderTarget { + id: string; + kind: 'elementHeader'; + elementUrn: string; +} + +export interface GraphicalViewAttributeTarget { + id: string; + kind: 'attributeRow'; + ownerUrn: string; + predicateUrn: string; + selection: 'singleOccurrence' | 'predicateStart'; + language?: string; +} + +export type GraphicalViewTarget = GraphicalViewElementHeaderTarget | GraphicalViewAttributeTarget; + +export interface GraphicalViewRenderResult { + uri: string; + svg?: string | null; + targets: GraphicalViewTarget[]; + warnings: GraphicalViewRenderWarning[]; +} + +export interface GraphicalViewResolveTargetParams { + sourceUri: string; + elementUrn: string; +} + +export interface GraphicalViewPosition { + line: number; + character: number; +} + +export interface GraphicalViewLocation { + uri: string; + range: { + start: GraphicalViewPosition; + end: GraphicalViewPosition; + }; +} + +export interface GraphicalViewResolveTargetResult { + location?: GraphicalViewLocation | null; + warning?: GraphicalViewResolveTargetWarning | null; +} + +export interface GraphicalViewResolveAttributeTargetParams { + sourceUri: string; + ownerUrn: string; + predicateUrn: string; + selection: 'singleOccurrence' | 'predicateStart'; + language?: string; +} + +export type GraphicalViewResolveAttributeTargetResult = GraphicalViewResolveTargetResult; diff --git a/src/graphicalViewResult.ts b/src/graphicalViewResult.ts new file mode 100644 index 0000000..4c25d86 --- /dev/null +++ b/src/graphicalViewResult.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import { + GRAPHICAL_VIEW_ATTRIBUTE_MARKER_PATTERN, + GRAPHICAL_VIEW_HEADER_MARKER_PATTERN, + GRAPHICAL_VIEW_MARKER_PATTERN, + GraphicalViewRenderResult, + GraphicalViewRenderWarning, + GraphicalViewTarget, +} from './graphicalViewProtocol'; + +export interface AcceptedGraphicalViewResult { + readonly version: number; + readonly uri: string; + readonly svg: string; + readonly targets: readonly Readonly[]; + readonly warnings: readonly GraphicalViewRenderWarning[]; + readonly targetById: ReadonlyMap>; + readonly attributeRowsAvailable: boolean; +} + +const RENDER_WARNINGS: ReadonlySet = new Set([ + 'unsupportedUri', + 'missingDocument', + 'modelTooLarge', + 'timeout', + 'temporarilyUnresolvable', +]); +const ASPECT_MODEL_URN_PATTERN = /^urn:samm:[^\s#]+#[^\s#]+$/; +const LANGUAGE_PATTERN = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/; +const SVG_ID_PATTERN = /\bid\s*=\s*(["'])([^"']+)\1/g; +const GRAPHPER_DESCENDANT_PATTERN = /^gv-(?:header|attribute)-[a-z0-9]{16,32}_(?:polygon|text_0)$/; + +export function isGraphicalViewRenderResult(value: unknown): value is GraphicalViewRenderResult { + if (!isRecord(value) || typeof value.uri !== 'string' || !Array.isArray(value.targets) || !Array.isArray(value.warnings)) { + return false; + } + + const allowedResultKeys = value.svg === undefined ? ['targets', 'uri', 'warnings'] : ['svg', 'targets', 'uri', 'warnings']; + if (!hasExactKeys(value, allowedResultKeys)) { + return false; + } + if (value.svg !== undefined && value.svg !== null && typeof value.svg !== 'string') { + return false; + } + if (!value.warnings.every(warning => typeof warning === 'string' && RENDER_WARNINGS.has(warning))) { + return false; + } + if (!value.targets.every(isGraphicalViewTarget)) { + return false; + } + if (value.svg === undefined || value.svg === null) { + return value.targets.length === 0 && value.warnings.length > 0; + } + return value.svg.trim().length > 0 && hasConsistentSidecar(value.svg, value.targets); +} + +export function acceptGraphicalViewResult( + result: GraphicalViewRenderResult, + version: number, + attributeRowsAvailable: boolean, +): AcceptedGraphicalViewResult { + if (result.svg === undefined || result.svg === null) { + throw new Error('A successful graphical-view result requires SVG content.'); + } + const targets = Object.freeze(result.targets.map(target => Object.freeze({...target}))); + const warnings = Object.freeze([...result.warnings]); + const targetById = new ImmutableMap(targets.map(target => [target.id, target] as const)); + return Object.freeze({ + version, + uri: result.uri, + svg: result.svg, + targets, + warnings, + targetById, + attributeRowsAvailable, + }); +} + +class ImmutableMap implements ReadonlyMap { + private readonly valuesByKey: Map; + + constructor(entries: readonly (readonly [K, V])[]) { + this.valuesByKey = new Map(entries); + Object.freeze(this); + } + + get size(): number { + return this.valuesByKey.size; + } + + get(key: K): V | undefined { + return this.valuesByKey.get(key); + } + + has(key: K): boolean { + return this.valuesByKey.has(key); + } + + forEach(callback: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: unknown): void { + this.valuesByKey.forEach((value, key) => callback.call(thisArg, value, key, this)); + } + + entries(): MapIterator<[K, V]> { + return this.valuesByKey.entries(); + } + + keys(): MapIterator { + return this.valuesByKey.keys(); + } + + values(): MapIterator { + return this.valuesByKey.values(); + } + + [Symbol.iterator](): MapIterator<[K, V]> { + return this.entries(); + } + + get [Symbol.toStringTag](): string { + return 'ImmutableMap'; + } +} + +function isGraphicalViewTarget(value: unknown): value is GraphicalViewTarget { + if (!isRecord(value) || typeof value.id !== 'string') { + return false; + } + if (value.kind === 'elementHeader') { + return hasExactKeys(value, ['elementUrn', 'id', 'kind']) + && GRAPHICAL_VIEW_HEADER_MARKER_PATTERN.test(value.id) + && typeof value.elementUrn === 'string' + && ASPECT_MODEL_URN_PATTERN.test(value.elementUrn); + } + if (value.kind !== 'attributeRow') { + return false; + } + const expectedKeys = value.language === undefined + ? ['id', 'kind', 'ownerUrn', 'predicateUrn', 'selection'] + : ['id', 'kind', 'language', 'ownerUrn', 'predicateUrn', 'selection']; + return hasExactKeys(value, expectedKeys) + && GRAPHICAL_VIEW_ATTRIBUTE_MARKER_PATTERN.test(value.id) + && typeof value.ownerUrn === 'string' + && ASPECT_MODEL_URN_PATTERN.test(value.ownerUrn) + && typeof value.predicateUrn === 'string' + && ASPECT_MODEL_URN_PATTERN.test(value.predicateUrn) + && (value.selection === 'singleOccurrence' || value.selection === 'predicateStart') + && (value.language === undefined + || (value.selection === 'singleOccurrence' + && typeof value.language === 'string' + && LANGUAGE_PATTERN.test(value.language))); +} + +function hasConsistentSidecar(svg: string, targets: GraphicalViewTarget[]): boolean { + const targetIds = new Set(); + for (const target of targets) { + if (targetIds.has(target.id)) { + return false; + } + targetIds.add(target.id); + } + + const svgIds = new Set(); + for (const match of svg.matchAll(SVG_ID_PATTERN)) { + const id = match[2]; + if (GRAPHPER_DESCENDANT_PATTERN.test(id)) { + continue; + } + if (!id.startsWith('gv-header-') && !id.startsWith('gv-attribute-')) { + continue; + } + if (!GRAPHICAL_VIEW_MARKER_PATTERN.test(id) || svgIds.has(id)) { + return false; + } + svgIds.add(id); + } + + return targetIds.size === svgIds.size && [...targetIds].every(id => svgIds.has(id)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasExactKeys(value: Record, expectedKeys: readonly string[]): boolean { + const actualKeys = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + return actualKeys.length === expected.length && actualKeys.every((key, index) => key === expected[index]); +} diff --git a/src/languageClient.ts b/src/languageClient.ts index 615e10f..a45d90d 100644 --- a/src/languageClient.ts +++ b/src/languageClient.ts @@ -14,21 +14,31 @@ import { Trace } from 'vscode-jsonrpc'; import * as net from 'node:net'; import * as vscode from 'vscode'; -import { LanguageClient, LanguageClientOptions, State, StreamInfo } from 'vscode-languageclient/node'; +import {LanguageClient, LanguageClientOptions, State, StreamInfo} from 'vscode-languageclient/node'; import type { RequestClient } from './aspectValidation'; +import type {GraphicalViewRequestTransport} from './graphicalViewClient'; import type { ExtensionLogger } from './outputChannel'; const CLIENT_START_TIMEOUT_MS = 60_000; -export class TurtleLanguageClient implements RequestClient { - private client: LanguageClient; +export class TurtleLanguageClient implements RequestClient, GraphicalViewRequestTransport { + private readonly client: LanguageClient; + private readonly availability = new vscode.EventEmitter(); + private lastAvailability = false; constructor( private outputChannel: ExtensionLogger, private readonly serverPort: number, - private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off' + private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off', ) { this.client = this.initLanguageClient(this.serverPort); + this.client.onDidChangeState(event => { + const available = event.newState === State.Running; + if (available !== this.lastAvailability) { + this.lastAvailability = available; + this.availability.fire(available); + } + }); } private toTrace(level: 'off' | 'messages' | 'verbose'): Trace { @@ -101,12 +111,20 @@ export class TurtleLanguageClient implements RequestClient { await this.client.stop(); } - sendRequest(method: string, params?: unknown): Promise { + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise { if (this.client.state === State.Stopped) { return Promise.reject(new Error('The Turtle language client is not connected.')); } - return this.client.sendRequest(method, params) as Promise; + return this.client.sendRequest(method, params, token) as Promise; + } + + isAvailable(): boolean { + return this.client.state === State.Running; + } + + onDidChangeAvailability(listener: (available: boolean) => void): vscode.Disposable { + return this.availability.event(listener); } } diff --git a/src/test/graphicalViewController.test.ts b/src/test/graphicalViewController.test.ts new file mode 100644 index 0000000..91b5c65 --- /dev/null +++ b/src/test/graphicalViewController.test.ts @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import {OPEN_GRAPHICAL_VIEW_COMMAND} from '../graphicalView'; +import { + FakeGraphicalViewClient, + createGraphicalViewDocument, + createGraphicalViewHarness, + flushPromises, + lastStatus, + openGraphicalView, + renderDeliveries, + successfulResult, +} from './graphicalViewTestHarness'; + +suite('GraphicalViewController lifecycle', () => { + test('guards absent and non-Turtle active editors without creating a panel or request', async () => { + const harness = createGraphicalViewHarness(); + await harness.commands.execute(OPEN_GRAPHICAL_VIEW_COMMAND); + harness.window.activeTextEditor = {document: createGraphicalViewDocument('/tmp/not-turtle.txt', 'plaintext')}; + await harness.commands.execute(OPEN_GRAPHICAL_VIEW_COMMAND); + + assert.equal(harness.window.warnings.length, 2); + assert.equal(harness.panels.panels.length, 0); + assert.equal(harness.client.requests.length, 0); + harness.controller.dispose(); + }); + + test('creates one panel per canonical URI and renders only on first creation', async () => { + const harness = createGraphicalViewHarness(); + const first = track(harness, '/tmp/first.ttl'); + const second = track(harness, '/tmp/second.ttl'); + + await openGraphicalView(harness, first); + await openGraphicalView(harness, first); + await openGraphicalView(harness, second); + + assert.equal(harness.panels.panels.length, 2); + assert.equal(harness.panels.panels[0].revealCount, 1); + assert.equal(harness.client.requests.length, 2); + assert.deepEqual(harness.client.requests.map(request => request.params), [ + {uri: first.uri.toString(), includeAttributeRows: true}, + {uri: second.uri.toString(), includeAttributeRows: true}, + ]); + harness.controller.dispose(); + }); + + test('renders on visible Refresh and bound-main Save but not imported Save or reveal', async () => { + const harness = createGraphicalViewHarness(); + const main = track(harness, '/tmp/main.ttl'); + const imported = track(harness, '/tmp/import.ttl'); + await openGraphicalView(harness, main); + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + harness.workspace.fireSave(imported); + await openGraphicalView(harness, main); + harness.workspace.fireSave(main); + + assert.equal(harness.client.requests.length, 3); + assert.equal(harness.client.requests[0].token.isCancellationRequested, true); + assert.equal(harness.client.requests[1].token.isCancellationRequested, true); + harness.controller.dispose(); + }); + + test('hidden main Save invalidates pending work and ready rehydrates without rendering', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/hidden.ttl'); + await openGraphicalView(harness, document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + + const panel = harness.panels.panels[0]; + panel.emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + panel.setVisible(false); + harness.workspace.fireSave(document); + panel.emitMessage({type: 'refresh'}); + panel.setVisible(true); + const deliveriesBeforeReady = renderDeliveries(panel).length; + panel.emitMessage({type: 'ready'}); + + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(renderDeliveries(panel).length, deliveriesBeforeReady + 1); + assert.equal(renderDeliveries(panel).at(-1)?.svg, successfulResult(document).svg); + assert.equal(lastStatus(panel)?.kind, 'ready'); + harness.controller.dispose(); + }); + + test('source closure preserves the panel and last result while invalidating late work', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/closed.ttl'); + await openGraphicalView(harness, document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const panel = harness.panels.panels[0]; + panel.emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + + harness.workspace.closeSourceEditor(document); + pending.deferred.reject(new Error('late transport failure')); + await flushPromises(); + panel.emitMessage({type: 'ready'}); + + assert.equal(harness.panels.panels.length, 1); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(renderDeliveries(panel).at(-1)?.svg, successfulResult(document).svg); + assert.equal(lastStatus(panel)?.kind, 'ready'); + harness.controller.dispose(); + }); + + test('accepts only the newest request and discards obsolete success and failure completions', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/latest.ttl'); + await openGraphicalView(harness, document); + const first = harness.client.requests[0]; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const second = harness.client.requests[1]; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const third = harness.client.requests[2]; + + second.deferred.reject(new Error('obsolete failure')); + first.deferred.resolve(successfulResult(document, '1111111111111111')); + third.deferred.resolve(successfulResult(document, '3333333333333333')); + await flushPromises(); + + const renders = renderDeliveries(harness.panels.panels[0]); + assert.equal(renders.length, 1); + assert.match(renders[0].svg, /3333333333333333/); + assert.equal(lastStatus(harness.panels.panels[0])?.kind, 'ready'); + harness.controller.dispose(); + }); + + test('rejects a URI mismatch without replacing the retained result', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/uri-mismatch.ttl'); + await openGraphicalView(harness, document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + harness.panels.panels[0].emitMessage({type: 'refresh'}); + harness.client.requests[1].deferred.resolve({...successfulResult(document), uri: 'file:///tmp/other.ttl'}); + await flushPromises(); + harness.panels.panels[0].emitMessage({type: 'ready'}); + + assert.equal(renderDeliveries(harness.panels.panels[0]).at(-1)?.svg, successfulResult(document).svg); + const status = lastStatus(harness.panels.panels[0]); + assert.equal(status?.kind, 'stale'); + assert.equal(status?.kind === 'stale' && status.reason, 'uriMismatch'); + harness.controller.dispose(); + }); + + test('commits SVG and sidecar only after the secure webview acknowledges it', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/ack.ttl'); + await openGraphicalView(harness, document); + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'none'; + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + + panel.emitMessage({type: 'navigate', version: 1, targetId: successfulResult(document).targets[0].id}); + assert.equal(harness.client.resolveRequests.length, 0); + panel.emitMessage({type: 'rendered', version: 1}); + panel.emitMessage({type: 'navigate', version: 1, targetId: successfulResult(document).targets[0].id}); + await flushPromises(); + assert.equal(harness.client.resolveRequests.length, 1); + harness.controller.dispose(); + }); + + test('retains the last accepted diagram for warning, transport, and sanitizer failures', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/retention.ttl'); + await openGraphicalView(harness, document); + const retained = successfulResult(document); + harness.client.requests[0].deferred.resolve(retained); + await flushPromises(); + const panel = harness.panels.panels[0]; + + panel.emitMessage({type: 'refresh'}); + harness.client.requests[1].deferred.resolve({uri: document.uri.toString(), svg: null, targets: [], warnings: ['timeout']}); + await flushPromises(); + panel.emitMessage({type: 'refresh'}); + harness.client.requests[2].deferred.reject(new Error('transport connection failed')); + await flushPromises(); + panel.renderOutcome = 'failure'; + panel.emitMessage({type: 'refresh'}); + harness.client.requests[3].deferred.resolve(successfulResult(document, 'aaaaaaaaaaaaaaaa')); + await flushPromises(); + panel.renderOutcome = 'success'; + panel.emitMessage({type: 'ready'}); + + assert.equal(renderDeliveries(panel).at(-1)?.svg, retained.svg); + assert.equal(lastStatus(panel)?.kind, 'stale'); + assert.ok(harness.outputChannel.lines.some(line => line.includes('secure rendering boundary'))); + harness.controller.dispose(); + }); + + test('falls back once on InvalidParams and exposes header-only compatibility', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/legacy.ttl'); + await openGraphicalView(harness, document); + harness.client.requests[0].deferred.reject({code: -32602, message: 'Invalid params'}); + await flushPromises(); + harness.client.requests[1].deferred.resolve(successfulResult(document)); + await flushPromises(); + + assert.deepEqual(harness.client.requests.map(request => request.params), [ + {uri: document.uri.toString(), includeAttributeRows: true}, + {uri: document.uri.toString()}, + ]); + assert.match(lastStatus(harness.panels.panels[0])?.message ?? '', /header navigation only/i); + harness.controller.dispose(); + }); + + test('handles MethodNotFound, client replacement, disconnect, and reconnect without automatic rendering', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/lifecycle.ttl'); + await openGraphicalView(harness, document); + harness.client.requests[0].deferred.reject({code: -32601, message: 'localized'}); + await flushPromises(); + assert.equal(lastStatus(harness.panels.panels[0])?.kind, 'unsupported'); + + const replacement = new FakeGraphicalViewClient(); + harness.controller.setClient(replacement); + replacement.setAvailable(false); + replacement.setAvailable(true); + assert.equal(replacement.requests.length, 0); + assert.equal(lastStatus(harness.panels.panels[0])?.kind, 'stale'); + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + assert.equal(replacement.requests.length, 1); + harness.controller.dispose(); + }); + + test('panel disposal cancels work, removes ownership, and rejects late results', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/dispose.ttl'); + await openGraphicalView(harness, document); + const firstPanel = harness.panels.panels[0]; + const request = harness.client.requests[0]; + firstPanel.dispose(); + request.deferred.resolve(successfulResult(document)); + await flushPromises(); + await openGraphicalView(harness, document); + + assert.equal(request.token.isCancellationRequested, true); + assert.equal(firstPanel.disposedListenerCount, 3); + assert.equal(renderDeliveries(firstPanel).length, 0); + assert.equal(harness.panels.panels.length, 2); + harness.controller.dispose(); + }); + + test('independent panels do not cross-deliver results and controller disposal closes both', async () => { + const harness = createGraphicalViewHarness(); + const first = track(harness, '/tmp/multi-one.ttl'); + const second = track(harness, '/tmp/multi-two.ttl'); + await openGraphicalView(harness, first); + await openGraphicalView(harness, second); + harness.client.requests[1].deferred.resolve(successfulResult(second, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + + assert.equal(renderDeliveries(harness.panels.panels[0]).length, 0); + assert.match(renderDeliveries(harness.panels.panels[1])[0].svg, /bbbbbbbbbbbbbbbb/); + harness.controller.dispose(); + assert.equal(harness.panels.panels[0].disposeCount, 1); + assert.equal(harness.panels.panels[1].disposeCount, 1); + }); +}); + +function track(harness: ReturnType, filePath: string) { + const document = createGraphicalViewDocument(filePath); + harness.workspace.available.add(document.uri.toString()); + return document; +} diff --git a/src/test/graphicalViewManifest.test.ts b/src/test/graphicalViewManifest.test.ts new file mode 100644 index 0000000..cc6dc60 --- /dev/null +++ b/src/test/graphicalViewManifest.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {existsSync, readFileSync, statSync} from 'node:fs'; +import {isAbsolute, relative, resolve} from 'node:path'; +import {OPEN_GRAPHICAL_VIEW_COMMAND} from '../graphicalView'; + +const ICON_PATH = 'media/aspect-model-editor-targetsize-192.png'; +const ICON_SHA_256 = 'd4e288af96113cd31e0f3fea5ea6f6629ab568230dff967ed4c12da12c4cbe5b'; + +type ManifestCommand = { + command: string; + title?: string; + category?: string; + enablement?: string; + icon?: {light?: string; dark?: string}; +}; + +type ManifestMenuItem = { + command?: string; + when?: string; + group?: string; + alt?: string; +}; + +type ExtensionManifest = { + contributes: { + commands: ManifestCommand[]; + menus: Record; + }; +}; + +suite('Graphical View editor-title manifest contract', () => { + const extensionRoot = resolve(__dirname, '..', '..'); + const manifest = JSON.parse(readFileSync(resolve(extensionRoot, 'package.json'), 'utf8')) as ExtensionManifest; + + test('reuses the single existing command with its approved local light and dark icon', () => { + const commands = manifest.contributes.commands.filter(command => command.command === OPEN_GRAPHICAL_VIEW_COMMAND); + assert.equal(commands.length, 1); + assert.deepEqual(commands[0], { + command: OPEN_GRAPHICAL_VIEW_COMMAND, + title: 'Open Graphical View', + category: 'Semantic Models', + enablement: 'editorLangId == turtle', + icon: {light: ICON_PATH, dark: ICON_PATH}, + }); + + for (const iconPath of Object.values(commands[0].icon ?? {})) { + assertLocalAsset(extensionRoot, iconPath); + } + assert.equal(sha256(resolve(extensionRoot, ICON_PATH)), ICON_SHA_256); + }); + + test('contributes exactly one Turtle-scoped primary editor-title action in stable order', () => { + const titleItems = (manifest.contributes.menus['editor/title'] ?? []).filter(item => item.command === OPEN_GRAPHICAL_VIEW_COMMAND); + assert.deepEqual(titleItems, [ + { + command: OPEN_GRAPHICAL_VIEW_COMMAND, + when: 'resourceLangId == turtle', + group: 'navigation@10', + }, + ]); + assert.equal('alt' in titleItems[0], false); + }); + + test('preserves editor context and unrestricted Command Palette behavior without duplicate surfaces', () => { + const contextItems = (manifest.contributes.menus['editor/context'] ?? []).filter( + item => item.command === OPEN_GRAPHICAL_VIEW_COMMAND, + ); + assert.deepEqual(contextItems, [ + { + command: OPEN_GRAPHICAL_VIEW_COMMAND, + when: 'resourceLangId == turtle', + group: '1_modification@2', + }, + ]); + + const paletteItems = (manifest.contributes.menus.commandPalette ?? []).filter(item => item.command === OPEN_GRAPHICAL_VIEW_COMMAND); + assert.deepEqual(paletteItems, []); + + const graphicalCommands = manifest.contributes.commands.filter(command => command.command.toLowerCase().includes('graphicalview')); + assert.deepEqual( + graphicalCommands.map(command => command.command), + [OPEN_GRAPHICAL_VIEW_COMMAND], + ); + }); +}); + +function assertLocalAsset(extensionRoot: string, assetPath: string | undefined): asserts assetPath is string { + assert.ok(assetPath, 'The command icon path must be present'); + assert.equal(isAbsolute(assetPath), false, 'The command icon path must be extension-relative'); + assert.equal(/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(assetPath), false, 'The command icon path must not be a URL'); + + const resolvedPath = resolve(extensionRoot, assetPath); + const relativePath = relative(extensionRoot, resolvedPath); + assert.equal(relativePath.startsWith('..') || isAbsolute(relativePath), false, 'The command icon must stay inside the extension'); + assert.equal(existsSync(resolvedPath), true, `The command icon does not exist: ${assetPath}`); + assert.equal(statSync(resolvedPath).isFile(), true, `The command icon is not a file: ${assetPath}`); + assert.ok(statSync(resolvedPath).size > 0, `The command icon is empty: ${assetPath}`); +} + +function sha256(file: string): string { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} diff --git a/src/test/graphicalViewNavigation.test.ts b/src/test/graphicalViewNavigation.test.ts new file mode 100644 index 0000000..c1227bb --- /dev/null +++ b/src/test/graphicalViewNavigation.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import {resolveGraphicalViewNavigation, validateGraphicalViewResolveResult} from '../graphicalViewNavigation'; +import type {GraphicalViewRenderResult} from '../graphicalViewProtocol'; +import { + createGraphicalViewDocument, + createGraphicalViewHarness, + flushPromises, + openGraphicalView, +} from './graphicalViewTestHarness'; + +const OWNER = 'urn:samm:example.graphical:1.0.0#Aspect'; +const PREDICATE = 'urn:samm:org.eclipse.esmf.samm:meta-model:2.2.0#description'; + +suite('Graphical View source navigation', () => { + test('dispatches trusted element and language-qualified attribute locators to distinct LSP methods', async () => { + const harness = createGraphicalViewHarness(); + const cancellation = new vscode.CancellationTokenSource(); + harness.client.resolveResult = {location: null, warning: 'notFound'}; + + await resolveGraphicalViewNavigation( + harness.client, + 'file:///tmp/source.ttl', + {id: 'gv-header-aaaaaaaaaaaaaaaa', kind: 'elementHeader', elementUrn: OWNER}, + cancellation.token, + ); + await resolveGraphicalViewNavigation( + harness.client, + 'file:///tmp/source.ttl', + { + id: 'gv-attribute-bbbbbbbbbbbbbbbb', + kind: 'attributeRow', + ownerUrn: OWNER, + predicateUrn: PREDICATE, + selection: 'singleOccurrence', + language: 'de', + }, + cancellation.token, + ); + + assert.deepEqual(harness.client.resolveRequests[0].params, { + sourceUri: 'file:///tmp/source.ttl', + elementUrn: OWNER, + }); + assert.deepEqual(harness.client.attributeResolveRequests[0].params, { + sourceUri: 'file:///tmp/source.ttl', + ownerUrn: OWNER, + predicateUrn: PREDICATE, + selection: 'singleOccurrence', + language: 'de', + }); + cancellation.dispose(); + harness.controller.dispose(); + }); + + test('validates local file locations and rejects invalid ranges, remote URIs, and ambiguous result shapes', () => { + const valid = validateGraphicalViewResolveResult({ + location: { + uri: vscode.Uri.file('/tmp/target.ttl').toString(), + range: {start: {line: 3, character: 4}, end: {line: 5, character: 6}}, + }, + }); + assert.equal(valid.kind, 'location'); + assert.deepEqual(valid.kind === 'location' && valid.range, new vscode.Range(3, 4, 5, 6)); + + const invalid = [ + {location: {uri: 'https://example.invalid/model.ttl', range: positions(0, 0, 0, 1)}}, + {location: {uri: vscode.Uri.file('/tmp/model.ttl').toString(), range: positions(2, 0, 1, 0)}}, + {location: null, warning: null}, + {location: {uri: vscode.Uri.file('/tmp/model.ttl').toString(), range: positions(0, 0, 0, 1)}, warning: 'notFound'}, + {location: null, warning: 'unknown'}, + ]; + assert.ok(invalid.every(candidate => validateGraphicalViewResolveResult(candidate).kind === 'warning')); + }); + + test('opens current multilingual and wrapped attribute targets using only the accepted sidecar', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/navigation.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const ids = [ + 'gv-attribute-aaaaaaaaaaaaaaaa', + 'gv-attribute-bbbbbbbbbbbbbbbb', + 'gv-attribute-cccccccccccccccc', + ]; + const result: GraphicalViewRenderResult = { + uri: document.uri.toString(), + svg: `${ids.map(id => `row`).join('')}`, + targets: [ + {id: ids[0], kind: 'attributeRow', ownerUrn: OWNER, predicateUrn: PREDICATE, selection: 'singleOccurrence', language: 'de'}, + {id: ids[1], kind: 'attributeRow', ownerUrn: OWNER, predicateUrn: PREDICATE, selection: 'singleOccurrence', language: 'en'}, + {id: ids[2], kind: 'attributeRow', ownerUrn: OWNER, predicateUrn: PREDICATE, selection: 'predicateStart'}, + ], + warnings: [], + }; + harness.client.requests[0].deferred.resolve(result); + await flushPromises(); + harness.client.resolveResult = { + location: {uri: document.uri.toString(), range: positions(8, 3, 8, 11)}, + }; + + for (const id of ids) { + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + await flushPromises(); + } + + assert.deepEqual(harness.client.attributeResolveRequests.map(request => request.params.language), ['de', 'en', undefined]); + assert.deepEqual(harness.client.attributeResolveRequests.map(request => request.params.selection), [ + 'singleOccurrence', + 'singleOccurrence', + 'predicateStart', + ]); + assert.equal(harness.window.openedEditors.length, 3); + assert.ok(harness.window.openedEditors.every(opened => opened.editor.selection.isEqual(new vscode.Selection(8, 3, 8, 11)))); + harness.controller.dispose(); + }); + + test('rejects stale, fake, malformed, extra-field, warning, non-file, and editor-open paths', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/rejected.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = 'gv-header-aaaaaaaaaaaaaaaa'; + harness.client.requests[0].deferred.resolve({ + uri: document.uri.toString(), + svg: ``, + targets: [{id, kind: 'elementHeader', elementUrn: OWNER}], + warnings: [], + }); + await flushPromises(); + + for (const message of [ + {type: 'navigate', targetId: id}, + {type: 'navigate', version: 2, targetId: id}, + {type: 'navigate', version: 1, targetId: 'gv-header-bbbbbbbbbbbbbbbb'}, + {type: 'navigate', version: 1, targetId: id, elementUrn: OWNER}, + ]) { + harness.panels.panels[0].emitMessage(message); + } + assert.equal(harness.client.resolveRequests.length, 0); + + harness.client.resolveResult = {location: null, warning: 'ambiguous'}; + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + harness.client.resolveResult = {location: {uri: 'https://example.invalid/model.ttl', range: positions(0, 0, 0, 1)}}; + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + harness.client.resolveResult = {location: {uri: document.uri.toString(), range: positions(0, 0, 0, 1)}}; + harness.window.showTextDocumentFailure = new Error('hostile detail'); + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + await flushPromises(); + + assert.equal(harness.window.openedEditors.length, 0); + assert.equal(harness.window.warnings.length, 3); + assert.ok(harness.window.warnings.every(message => !message.includes('hostile'))); + harness.controller.dispose(); + }); +}); + +function positions(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { + return { + start: {line: startLine, character: startCharacter}, + end: {line: endLine, character: endCharacter}, + }; +} diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts new file mode 100644 index 0000000..460198e --- /dev/null +++ b/src/test/graphicalViewPanel.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readdirSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import * as vscode from 'vscode'; +import { + WEBVIEW_SCRIPT_ORDER, + createGraphicalViewPanelOptions, + createGraphicalViewShell, + parseGraphicalViewPanelMessage, + webviewAssetDirectory, +} from '../graphicalViewPanel'; + +suite('GraphicalView secure panel contract', () => { + test('uses exact script/forms/command options and one out/webview resource root without retention', () => { + const extensionUri = vscode.Uri.file('/tmp/graphical-view-extension'); + const options = createGraphicalViewPanelOptions(extensionUri); + + assert.equal(options.enableScripts, true); + assert.equal(options.enableForms, false); + assert.equal(options.enableCommandUris, false); + assert.equal(options.localResourceRoots?.length, 1); + assert.equal(options.localResourceRoots?.[0].toString(), webviewAssetDirectory(extensionUri).toString()); + assert.equal('retainContextWhenHidden' in options, false); + }); + + test('creates the exact CSP, fresh nonce, local resource URIs, and fixed external script order', () => { + const extensionUri = vscode.Uri.file('/tmp/graphical-view-extension'); + const webview = { + cspSource: 'vscode-webview-resource:', + asWebviewUri: (uri: vscode.Uri) => uri.with({scheme: 'vscode-webview-resource'}), + }; + const first = createGraphicalViewShell(webview, extensionUri); + const second = createGraphicalViewShell(webview, extensionUri); + const firstNonce = first.match(/script-src 'nonce-([^']+)'/)?.[1]; + const secondNonce = second.match(/script-src 'nonce-([^']+)'/)?.[1]; + assert.ok(firstNonce); + assert.ok(secondNonce); + assert.notEqual(firstNonce, secondNonce); + + const expectedCsp = + `default-src 'none'; script-src 'nonce-${firstNonce}'; style-src vscode-webview-resource:; ` + + "font-src vscode-webview-resource:; img-src 'none'; connect-src 'none'; object-src 'none'; " + + "base-uri 'none'; form-action 'none'"; + assert.ok(first.includes(`content="${expectedCsp}"`)); + assert.equal((first.match(/'}); + await waitFor(() => hasMessage(messages, 'renderError', 3), 'fail-closed hostile render'); + } finally { + subscription.dispose(); + panel.dispose(); + } + }); +}); + +function graphperSvg(marker: string): string { + return ` + + + + +Aspect +Aspect + + +`; +} + +function multilingualSvg(germanMarker: string, englishMarker: string): string { + return ` + +description [de]: Beschreibung +description [en]: Description + +`; +} + +async function waitFor(probe: () => boolean, description: string, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (probe()) { + return; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for ${description}`); +} + +function countMessages(messages: readonly unknown[], type: string): number { + return messages.filter(message => isRecord(message) && message.type === type).length; +} + +function hasMessage(messages: readonly unknown[], type: string, version: number): boolean { + return messages.some(message => isRecord(message) && message.type === type && message.version === version); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/test/languageClientGraphicalView.test.ts b/src/test/languageClientGraphicalView.test.ts new file mode 100644 index 0000000..4b9273c --- /dev/null +++ b/src/test/languageClientGraphicalView.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import {LspGraphicalViewClient, GraphicalViewRequestTransport} from '../graphicalViewClient'; +import { + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GraphicalViewRenderResult, + GraphicalViewResolveTargetResult, +} from '../graphicalViewProtocol'; + +suite('Graphical View typed LSP client', () => { + test('forwards exact render and resolve methods, parameters, and cancellation token', async () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); + const cancellation = new vscode.CancellationTokenSource(); + + await client.render({uri: 'file:///model.ttl', includeAttributeRows: true}, cancellation.token); + await client.resolveElement( + {sourceUri: 'file:///model.ttl', elementUrn: 'urn:samm:example:1.0.0#Aspect'}, + cancellation.token, + ); + await client.resolveAttribute( + { + sourceUri: 'file:///model.ttl', + ownerUrn: 'urn:samm:example:1.0.0#Aspect', + predicateUrn: 'urn:samm:org.eclipse.esmf.samm:meta-model:2.2.0#description', + selection: 'singleOccurrence', + language: 'en', + }, + cancellation.token, + ); + + assert.deepEqual(transport.requests.map(request => request.method), [ + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, + ]); + assert.ok(transport.requests.every(request => request.token === cancellation.token)); + assert.deepEqual(transport.requests[0].params, {uri: 'file:///model.ttl', includeAttributeRows: true}); + cancellation.dispose(); + }); + + test('exposes transport availability without a second lifecycle state', () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); + const events: boolean[] = []; + const subscription = client.onDidChangeAvailability(available => events.push(available)); + + transport.setAvailable(false); + transport.setAvailable(true); + assert.equal(client.isAvailable(), true); + assert.deepEqual(events, [false, true]); + subscription.dispose(); + }); + + test('preserves omitted and explicit-null JSON wire fields', async () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); + const cancellation = new vscode.CancellationTokenSource(); + transport.renderResult = JSON.parse('{"uri":"file:///model.ttl","svg":null,"targets":[],"warnings":["timeout"]}'); + const render = await client.render({uri: 'file:///model.ttl'}, cancellation.token); + transport.resolveResult = JSON.parse('{"location":null,"warning":null}'); + const resolve = await client.resolveElement( + {sourceUri: 'file:///model.ttl', elementUrn: 'urn:samm:example:1.0.0#Aspect'}, + cancellation.token, + ); + + assert.equal(render.svg, null); + assert.equal(resolve.location, null); + assert.equal(resolve.warning, null); + cancellation.dispose(); + }); +}); + +class FakeTransport implements GraphicalViewRequestTransport { + readonly requests: Array<{method: string; params: unknown; token: vscode.CancellationToken | undefined}> = []; + renderResult: GraphicalViewRenderResult = {uri: 'file:///model.ttl', svg: '', targets: [], warnings: []}; + resolveResult: GraphicalViewResolveTargetResult = {location: null, warning: 'notFound'}; + private available = true; + private readonly listeners = new Set<(available: boolean) => void>(); + + isAvailable(): boolean { + return this.available; + } + + onDidChangeAvailability(listener: (available: boolean) => void): vscode.Disposable { + this.listeners.add(listener); + return new vscode.Disposable(() => this.listeners.delete(listener)); + } + + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise { + this.requests.push({method, params, token}); + return Promise.resolve((method === GRAPHICAL_VIEW_RENDER_REQUEST ? this.renderResult : this.resolveResult) as R); + } + + setAvailable(available: boolean): void { + this.available = available; + for (const listener of [...this.listeners]) { + listener(available); + } + } +} diff --git a/src/webview/RobotoCondensed-NOTICE.txt b/src/webview/RobotoCondensed-NOTICE.txt new file mode 100644 index 0000000..fb40b42 --- /dev/null +++ b/src/webview/RobotoCondensed-NOTICE.txt @@ -0,0 +1,19 @@ +RobotoCondensed-Regular +======================= + +Copyright (C) Christian Robertson + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +The complete Apache License 2.0 text is included in this extension as +DOMPurify-LICENSE-Apache-2.0.txt and is also available at the URL above. diff --git a/src/webview/RobotoCondensed-Regular.ttf b/src/webview/RobotoCondensed-Regular.ttf new file mode 100644 index 0000000..65bf32a Binary files /dev/null and b/src/webview/RobotoCondensed-Regular.ttf differ diff --git a/src/webview/sanitizer-contract.js b/src/webview/sanitizer-contract.js new file mode 100644 index 0000000..0e4e621 --- /dev/null +++ b/src/webview/sanitizer-contract.js @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +(function () { + 'use strict'; + + const ALLOWED_TAGS = Object.freeze(['svg', 'g', 'path', 'polygon', 'text', 'title']); + const TAG_ATTRIBUTES = Object.freeze({ + svg: new Set(['xmlns', 'height', 'width', 'viewBox']), + g: new Set(['id', 'transform']), + path: new Set(['d', 'fill', 'stroke']), + polygon: new Set(['points', 'fill', 'stroke', 'stroke-width', 'cx', 'cy', 'rx', 'ry']), + text: new Set(['x', 'y', 'fill', 'font-family', 'font-size', 'text-anchor']), + title: new Set(), + }); + const ALLOWED_ATTRIBUTES = Object.freeze( + Array.from(new Set(Object.values(TAG_ATTRIBUTES).flatMap(attributes => Array.from(attributes)))), + ); + const MARKER_PATTERN = /^gv-(?:header|attribute)-[a-z0-9]{16,32}$/; + const COLOR_PATTERN = /^(?:none|#[0-9a-f]{6})$/i; + const FONT_FAMILIES = new Set(['Arial', 'Roboto Condensed']); + const NUMBER = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?'; + const NUMBER_PATTERN = new RegExp(`^${NUMBER}$`); + const LENGTH_PATTERN = new RegExp(`^${NUMBER}(?:pt)?$`); + const VIEW_BOX_PATTERN = new RegExp(`^${NUMBER}\\s+${NUMBER}\\s+${NUMBER}\\s+${NUMBER}$`); + const TRANSFORM_PATTERN = new RegExp(`^scale\\(${NUMBER}\\s+${NUMBER}\\)\\s+rotate\\(${NUMBER}\\)$`); + const PATH_PATTERN = /^[MmLlHhVvCcSsQqTtAaZzEe0-9.,+\-\s]+$/; + const POINTS_PATTERN = /^[0-9eE.,+\-\s]+$/; + const FORBIDDEN_SCHEME_PATTERN = /^(?:https?|file|command|javascript|data):/i; + + const DOMPURIFY_CONFIG = Object.freeze({ + ALLOWED_TAGS, + ALLOWED_ATTR: ALLOWED_ATTRIBUTES, + ALLOW_ARIA_ATTR: false, + ALLOW_DATA_ATTR: false, + ALLOW_UNKNOWN_PROTOCOLS: false, + FORBID_TAGS: ['a', 'style', 'script', 'foreignObject', 'defs', 'clipPath', 'tspan', 'image', 'use'], + FORBID_ATTR: ['href', 'xlink:href', 'style'], + RETURN_DOM_FRAGMENT: true, + SANITIZE_DOM: true, + SANITIZE_NAMED_PROPS: false, + }); + + function sanitizeSvg(svgText) { + if (typeof svgText !== 'string') { + throw new TypeError('SVG payload must be a string'); + } + + const fragment = DOMPurify.sanitize(svgText, DOMPURIFY_CONFIG); + validatePurifierRemovals(DOMPurify.removed); + const roots = Array.from(fragment.children); + if (roots.length !== 1 || roots[0].localName !== 'svg') { + throw new Error('Sanitized payload must contain exactly one SVG root'); + } + + const svg = roots[0]; + const markerIds = new Set(); + for (const element of [svg, ...svg.querySelectorAll('*')]) { + const permittedAttributes = TAG_ATTRIBUTES[element.localName]; + if (!permittedAttributes) { + throw new Error('Sanitized payload contains an unsupported SVG element'); + } + + if (element.hasAttribute('id')) { + const id = element.getAttribute('id'); + if (element.localName !== 'g' || !MARKER_PATTERN.test(id)) { + element.removeAttribute('id'); + } else if (markerIds.has(id)) { + throw new Error('Sanitized payload contains a duplicate navigation marker'); + } else { + markerIds.add(id); + } + } + + for (const attribute of Array.from(element.attributes)) { + if (!permittedAttributes.has(attribute.name)) { + throw new Error('Sanitized payload contains an attribute on an unsupported element'); + } + } + + for (const attribute of Array.from(element.attributes)) { + const value = attribute.value.trim(); + if (attribute.name !== 'xmlns' && (/url\s*\(/i.test(value) || FORBIDDEN_SCHEME_PATTERN.test(value))) { + throw new Error('Sanitized payload contains a forbidden resource value'); + } + } + + validateOptionalValue(element, 'fill', COLOR_PATTERN); + validateOptionalValue(element, 'stroke', COLOR_PATTERN); + validateOptionalSetValue(element, 'font-family', FONT_FAMILIES); + validateOptionalExactValue(element, 'xmlns', 'http://www.w3.org/2000/svg'); + validateOptionalExactValue(element, 'text-anchor', 'middle'); + validateOptionalValue(element, 'height', LENGTH_PATTERN); + validateOptionalValue(element, 'width', LENGTH_PATTERN); + validateOptionalValue(element, 'viewBox', VIEW_BOX_PATTERN); + validateOptionalValue(element, 'transform', TRANSFORM_PATTERN); + validateOptionalValue(element, 'd', PATH_PATTERN); + validateOptionalValue(element, 'points', POINTS_PATTERN); + for (const name of ['cx', 'cy', 'rx', 'ry', 'stroke-width', 'font-size', 'x', 'y']) { + validateOptionalValue(element, name, NUMBER_PATTERN); + } + } + + return {fragment, svg, markerIds: Object.freeze(Array.from(markerIds))}; + } + + function validatePurifierRemovals(removals) { + for (const removal of removals) { + if (removal.element?.localName === 'style' || removal.element?.localName === 'body') { + continue; + } + const attributeName = removal.attribute?.name; + if (attributeName === 'class' || attributeName === 'xmlns:xlink') { + continue; + } + const constructKind = removal.element ? 'element' : 'attribute'; + const constructName = boundedConstructName(removal.element?.localName ?? attributeName); + throw new Error(`DOMPurify removed an unsupported SVG ${constructKind}: ${constructName}`); + } + } + + function boundedConstructName(value) { + return typeof value === 'string' && /^[a-z0-9:_-]{1,40}$/i.test(value) ? value : 'unknown'; + } + + function validateOptionalValue(element, attributeName, pattern) { + if (element.hasAttribute(attributeName) && !pattern.test(element.getAttribute(attributeName).trim())) { + throw new Error('Sanitized payload contains an invalid SVG attribute value'); + } + } + + function validateOptionalSetValue(element, attributeName, values) { + if (element.hasAttribute(attributeName) && !values.has(element.getAttribute(attributeName))) { + throw new Error('Sanitized payload contains an unsupported SVG attribute value'); + } + } + + function validateOptionalExactValue(element, attributeName, expected) { + if (element.hasAttribute(attributeName) && element.getAttribute(attributeName) !== expected) { + throw new Error('Sanitized payload contains an unsupported SVG attribute value'); + } + } + + globalThis.SanitizerContract = Object.freeze({MARKER_PATTERN, sanitizeSvg}); +})(); diff --git a/src/webview/webview.css b/src/webview/webview.css new file mode 100644 index 0000000..92d72c8 --- /dev/null +++ b/src/webview/webview.css @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: "Roboto Condensed"; + src: url("./RobotoCondensed-Regular.ttf") format("truetype"); + font-style: normal; + font-weight: 400; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + font-family: var(--vscode-font-family); +} + +#toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.35rem; + padding: 0.45rem; + border-bottom: 1px solid var(--vscode-panel-border); +} + +#toolbar button { + min-width: 2rem; + min-height: 1.75rem; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: 1px solid transparent; + border-radius: 2px; +} + +#toolbar button:hover { + background: var(--vscode-button-hoverBackground); +} + +#toolbar button:focus-visible, +#viewport:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +#status { + min-width: 12rem; + margin: 0 0 0 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#zoom-value { + min-width: 3.5rem; + text-align: center; +} + +#viewport { + box-sizing: border-box; + flex: 1 1 auto; + width: 100%; + min-height: 0; + overflow: auto; + padding: 0.75rem; +} + +#diagram svg { + display: block; +} + +#diagram svg g[id^="gv-header-"], +#diagram svg g[id^="gv-attribute-"] { + cursor: pointer; + pointer-events: all; +} + +#diagram svg g[id^="gv-header-"]:hover, +#diagram svg g[id^="gv-attribute-"]:hover { + filter: brightness(0.94); +} + +#diagram svg g[id^="gv-header-"]:focus-visible, +#diagram svg g[id^="gv-attribute-"]:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} diff --git a/src/webview/webview.js b/src/webview/webview.js new file mode 100644 index 0000000..8005907 --- /dev/null +++ b/src/webview/webview.js @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +(function () { + 'use strict'; + + const MIN_ZOOM = 0.25; + const MAX_ZOOM = 4; + const ZOOM_FACTOR = 1.2; + const vscode = acquireVsCodeApi(); + const viewport = document.querySelector('#viewport'); + const diagram = document.querySelector('#diagram'); + const status = document.querySelector('#status'); + const zoomValue = document.querySelector('#zoom-value'); + let currentVersion = null; + let currentSvg = null; + let baseWidth = 0; + let baseHeight = 0; + let restoreGeneration = 0; + let state = normalizeState(vscode.getState()); + + function normalizeState(candidate) { + if (!candidate || candidate.schemaVersion !== 1) { + return {schemaVersion: 1, zoom: 1, scrollLeft: 0, scrollTop: 0}; + } + return { + schemaVersion: 1, + zoom: Number.isFinite(candidate.zoom) ? clamp(candidate.zoom, MIN_ZOOM, MAX_ZOOM) : 1, + scrollLeft: Number.isFinite(candidate.scrollLeft) ? Math.max(0, candidate.scrollLeft) : 0, + scrollTop: Number.isFinite(candidate.scrollTop) ? Math.max(0, candidate.scrollTop) : 0, + }; + } + + function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); + } + + function persistState() { + state = { + schemaVersion: 1, + zoom: state.zoom, + scrollLeft: Math.max(0, viewport.scrollLeft), + scrollTop: Math.max(0, viewport.scrollTop), + }; + vscode.setState(state); + updateZoomLabel(); + } + + function updateZoomLabel() { + zoomValue.textContent = `${Math.round(state.zoom * 100)}%`; + } + + function captureViewport() { + state = {...state, scrollLeft: Math.max(0, viewport.scrollLeft), scrollTop: Math.max(0, viewport.scrollTop)}; + } + + function dimensions(svg) { + const width = Number.parseFloat(svg.getAttribute('width')); + const height = Number.parseFloat(svg.getAttribute('height')); + const viewBox = svg.viewBox?.baseVal; + const resolvedWidth = Number.isFinite(width) && width > 0 ? width : viewBox?.width; + const resolvedHeight = Number.isFinite(height) && height > 0 ? height : viewBox?.height; + if (!Number.isFinite(resolvedWidth) || resolvedWidth <= 0 || !Number.isFinite(resolvedHeight) || resolvedHeight <= 0) { + throw new Error('SVG dimensions are unavailable'); + } + return {width: resolvedWidth, height: resolvedHeight}; + } + + function applyZoom() { + if (!currentSvg) { + updateZoomLabel(); + return; + } + currentSvg.setAttribute('width', String(baseWidth * state.zoom)); + currentSvg.setAttribute('height', String(baseHeight * state.zoom)); + updateZoomLabel(); + } + + function restoreViewport(version, notifyRendered) { + const generation = ++restoreGeneration; + afterLayout(() => { + if (generation !== restoreGeneration || version !== currentVersion) { + return; + } + const maximumLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth); + const maximumTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + viewport.scrollLeft = Math.min(state.scrollLeft, maximumLeft); + viewport.scrollTop = Math.min(state.scrollTop, maximumTop); + persistState(); + if (notifyRendered) { + vscode.postMessage({type: 'rendered', version}); + } + }); + } + + function afterLayout(callback) { + let completed = false; + const complete = () => { + if (!completed) { + completed = true; + callback(); + } + }; + requestAnimationFrame(() => requestAnimationFrame(complete)); + setTimeout(complete, 100); + } + + function setZoom(zoom) { + captureViewport(); + state = {...state, zoom: clamp(zoom, MIN_ZOOM, MAX_ZOOM)}; + applyZoom(); + restoreViewport(currentVersion, false); + } + + function fitDiagram() { + if (!currentSvg || baseWidth <= 0 || baseHeight <= 0) { + return; + } + const availableWidth = Math.max(1, viewport.clientWidth - 24); + const availableHeight = Math.max(1, viewport.clientHeight - 24); + setZoom(Math.min(availableWidth / baseWidth, availableHeight / baseHeight)); + state = {...state, scrollLeft: 0, scrollTop: 0}; + restoreViewport(currentVersion, false); + } + + function isExactMessage(message, keys) { + if (!message || typeof message !== 'object' || Array.isArray(message)) { + return false; + } + const actualKeys = Object.keys(message).sort(); + return actualKeys.length === keys.length && actualKeys.every((key, index) => key === keys[index]); + } + + function render(message) { + if ( + !isExactMessage(message, ['svg', 'type', 'version']) || + message.type !== 'render' || + !Number.isInteger(message.version) || + message.version < 1 || + typeof message.svg !== 'string' + ) { + return; + } + + try { + if (currentSvg) { + captureViewport(); + } + const sanitized = SanitizerContract.sanitizeSvg(message.svg); + const size = dimensions(sanitized.svg); + makeNavigationMarkersInteractive(sanitized.svg); + diagram.replaceChildren(sanitized.fragment); + currentSvg = sanitized.svg; + currentVersion = message.version; + baseWidth = size.width; + baseHeight = size.height; + applyZoom(); + restoreViewport(currentVersion, true); + } catch (error) { + status.textContent = 'The new diagram could not be displayed safely. The last valid diagram is retained.'; + vscode.postMessage({type: 'renderError', version: message.version, reason: 'sanitizationFailed'}); + } + } + + viewport.addEventListener('scroll', persistState, {passive: true}); + diagram.addEventListener('click', event => { + activateNavigationTarget(event.target); + }); + diagram.addEventListener('keydown', event => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + if (activateNavigationTarget(event.target)) { + event.preventDefault(); + } + }); + + function makeNavigationMarkersInteractive(svg) { + for (const group of svg.querySelectorAll('g[id]')) { + if (SanitizerContract.MARKER_PATTERN.test(group.id)) { + group.setAttribute('tabindex', '0'); + group.setAttribute('role', 'link'); + group.setAttribute('aria-label', group.id.startsWith('gv-attribute-') + ? 'Navigate to attribute source statement' + : 'Navigate to element definition'); + } + } + } + + function activateNavigationTarget(target) { + const group = target instanceof Element ? target.closest('g[id]') : null; + if (!group || !diagram.contains(group) || !SanitizerContract.MARKER_PATTERN.test(group.id) || !Number.isInteger(currentVersion)) { + return false; + } + vscode.postMessage({type: 'navigate', version: currentVersion, targetId: group.id}); + return true; + } + + document.querySelector('#refresh').addEventListener('click', () => vscode.postMessage({type: 'refresh'})); + document.querySelector('#zoom-in').addEventListener('click', () => setZoom(state.zoom * ZOOM_FACTOR)); + document.querySelector('#zoom-out').addEventListener('click', () => setZoom(state.zoom / ZOOM_FACTOR)); + document.querySelector('#zoom-reset').addEventListener('click', () => setZoom(1)); + document.querySelector('#zoom-fit').addEventListener('click', fitDiagram); + + window.addEventListener('message', event => { + const message = event.data; + if (message?.type === 'render') { + render(message); + return; + } + if ( + isExactMessage(message, ['status', 'type']) && + message.type === 'status' && + message.status && + typeof message.status.message === 'string' + ) { + status.textContent = message.status.message; + return; + } + }); + + updateZoomLabel(); + vscode.setState(state); + vscode.postMessage({type: 'ready'}); +})();