diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 6ac11c0..77592d9 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,14 +1,13 @@ -import { defineConfig } from '@vscode/test-cli'; +import {defineConfig} from '@vscode/test-cli'; -const runId = process.env.VSCODE_TEST_RUN_ID ?? 'default'; +const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE_PATH; +const runId = (process.env.VSCODE_TEST_RUN_ID ?? 'default').replace(/[^a-zA-Z0-9_-]/g, '-'); export default defineConfig({ - files: 'out/test/**/*.test.js', - useInstallation: process.env.VSCODE_EXECUTABLE_PATH - ? { fromPath: process.env.VSCODE_EXECUTABLE_PATH } - : undefined, - launchArgs: [ - `--user-data-dir=/tmp/extension-vscode-test-user-data-${runId}`, - `--extensions-dir=/tmp/extension-vscode-test-extensions-${runId}` - ], + files: 'out/test/**/*.test.js', + ...(vscodeExecutablePath ? {useInstallation: {fromPath: vscodeExecutablePath}} : {}), + launchArgs: [ + `--user-data-dir=/tmp/extension-vscode-test-user-data-${runId}`, + `--extensions-dir=/tmp/extension-vscode-test-extensions-${runId}`, + ], }); 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/documentation/vs-code-plugin/modules/ROOT/pages/features.adoc b/documentation/vs-code-plugin/modules/ROOT/pages/features.adoc index 59010e1..497fe72 100644 --- a/documentation/vs-code-plugin/modules/ROOT/pages/features.adoc +++ b/documentation/vs-code-plugin/modules/ROOT/pages/features.adoc @@ -6,6 +6,17 @@ * Document outline for navigating the current file. * Automatic syntax validation while typing, with diagnostics in the editor and the *Problems* view. +== Graphical View + +When editing a SAMM Aspect Model, you can right-click the document and select "Open Graphical View" +to open a graphical representation of the model. Alternatively, you can select "Semantic Models: +Open Graphical View" from the Command Palette. + +The graphical view shows a read-only diagram representation of the model. It is updated when the +document is saved, or when you click the "Refresh" button in the panel. You can click element +headers in the graphical view to directly jump to the definition of the respective element in the +corresponding `.ttl` file. + == Working with Aspect Models * *Go to Definition* for referenced elements in the current file, workspace, and SAMM vocabulary. 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.json b/package.json index 0c2528b..d0538dd 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": [ @@ -143,11 +153,23 @@ } }, "menus": { + "editor/title": [ + { + "command": "semantic-models.openGraphicalView", + "when": "resourceLangId == turtle", + "group": "navigation@10" + } + ], "editor/context": [ { "command": "semantic-models.validateDocumentNow", "when": "resourceLangId == turtle && (resourceScheme == file || resourceScheme == untitled)", "group": "1_modification" + }, + { + "command": "semantic-models.openGraphicalView", + "when": "resourceLangId == turtle", + "group": "1_modification@2" } ] }, @@ -183,14 +205,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/mocha": "^10.0.10", diff --git a/scripts/copy-webview-assets.mjs b/scripts/copy-webview-assets.mjs new file mode 100644 index 0000000..dd017b6 --- /dev/null +++ b/scripts/copy-webview-assets.mjs @@ -0,0 +1,34 @@ +/* + * 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 {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..c679697 --- /dev/null +++ b/scripts/verify-webview-assets.mjs @@ -0,0 +1,37 @@ +/* + * 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 {readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; +import {outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +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..9421180 --- /dev/null +++ b/scripts/webview-assets.mjs @@ -0,0 +1,48 @@ +/* + * 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 {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( + 'src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt', + 'RobotoCondensed-LICENSE-Apache-2.0.txt', + '8173d5c29b4f956d532781d2b86e4e30f83e6b7878dce18c919451d6ba707c90', + ), + asset('src/webview/RobotoCondensed-NOTICE.txt', 'RobotoCondensed-NOTICE.txt'), + asset( + 'src/webview/RobotoCondensed-Regular.ttf', + 'RobotoCondensed-Regular.ttf', + '4a7c36df4318fee50a8159c3a0ebde4572abab65447ae4a651c2fe87212302b5', + ), + 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 c122d6a..a10fc2c 100644 --- a/src/aspectValidation.ts +++ b/src/aspectValidation.ts @@ -24,7 +24,7 @@ export type AspectValidationTrigger = 'manual' | 'save'; export type ValidationResult = 'validation_successful' | 'validation_failed'; export interface RequestClient { - sendRequest(method: string, params?: unknown): Thenable; + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Thenable; } type RequestClientBinding = { diff --git a/src/extension.ts b/src/extension.ts index ff6d5c9..0a3195c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,13 +13,16 @@ import * as vscode from 'vscode'; import {AspectValidationController, RequestClient} from './aspectValidation'; +import {GraphicalViewController} from './graphicalView'; +import {LspGraphicalViewClient} from './graphicalViewClient'; +import {VscodeGraphicalViewPanelFactory} from './graphicalViewPanel'; +import {GitHubRepositoryValidator} from './githubRepositoryValidator'; +import {TurtleLanguageClient} from './languageClient'; import {TurtleLanguageServer} from './languageServer'; +import {LanguageServicesMode, LanguageServicesSupervisor, TerminalAction} from './languageServicesSupervisor'; +import type {ExtensionLogger} from './outputChannel'; import {SammCliDownloader} from './sammCliDownloader'; import {TurtleExtensionSettings} from './settings'; -import {TurtleLanguageClient} from './languageClient'; -import {GitHubRepositoryValidator} from './githubRepositoryValidator'; -import type {ExtensionLogger} from './outputChannel'; -import {LanguageServicesSupervisor, LanguageServicesMode, TerminalAction} from './languageServicesSupervisor'; const SELECT_EXECUTABLE_COMMAND = 'semantic-models.selectSammCliExecutable'; const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable'; @@ -28,6 +31,7 @@ const GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS = 2000; let settings: TurtleExtensionSettings; let aspectValidationController: AspectValidationController; +let graphicalViewController: GraphicalViewController; let sammCliDownloader: SammCliDownloader; let gitHubRepositoryValidator: GitHubRepositoryValidator; @@ -47,6 +51,40 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { gitHubRepositoryValidator = new GitHubRepositoryValidator(outputChannel); aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, 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); languageServicesSupervisor = createLanguageServicesSupervisor(); context.subscriptions.push( @@ -137,7 +175,12 @@ function createLanguageServicesSupervisor(): LanguageServicesSupervisor { logOutputChannel.logLevel, ), createClient: configuration => new TurtleLanguageClient(outputChannel, configuration.port, logOutputChannel.logLevel), - setRequestClient: (client, generation) => aspectValidationController.setClient(client, generation), + setRequestClient: (client, generation) => { + aspectValidationController.setClient(client, generation); + graphicalViewController.setClient( + client instanceof TurtleLanguageClient ? new LspGraphicalViewClient(client) : undefined, + ); + }, unavailableClient: createUnavailableClient, logger: outputChannel, notifyTerminal: notifyTerminalRecoveryFailure, diff --git a/src/graphicalView.ts b/src/graphicalView.ts new file mode 100644 index 0000000..cb28938 --- /dev/null +++ b/src/graphicalView.ts @@ -0,0 +1,653 @@ +/* + * 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; + readonly tabGroups: { + readonly all: readonly GraphicalViewTabGroup[]; + }; + showWarningMessage(message: string): Thenable; + showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable; +} + +export interface GraphicalViewTabGroup { + readonly viewColumn: vscode.ViewColumn; + readonly tabs: readonly { + readonly input: unknown; + readonly isActive: boolean; + }[]; +} + +export function findReusableTextEditorViewColumn( + uri: vscode.Uri, + groups: readonly GraphicalViewTabGroup[], +): vscode.ViewColumn | undefined { + const canonicalUri = uri.toString(); + let firstMatchingColumn: vscode.ViewColumn | undefined; + for (const group of groups) { + for (const tab of group.tabs) { + if (!(tab.input instanceof vscode.TabInputText) || tab.input.uri.toString() !== canonicalUri) { + continue; + } + if (tab.isActive) { + return group.viewColumn; + } + firstMatchingColumn ??= group.viewColumn; + } + } + return firstMatchingColumn; +} + +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 strict XML rendering boundary.'); + this.setStale( + state, + 'xmlParsingFailed', + 'The new diagram is not a usable SVG document. 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 viewColumn = findReusableTextEditorViewColumn(resolution.uri, this.window.tabGroups.all); + const options: vscode.TextDocumentShowOptions = viewColumn === undefined + ? {preview: false} + : {preview: false, viewColumn}; + const editor = await this.window.showTextDocument(resolution.uri, options); + 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 'unresolvable': + 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..af69a6d --- /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', + 'unresolvable', +]); + +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 'unresolvable': + return 'The graphical target is 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..91c3e14 --- /dev/null +++ b/src/graphicalViewPanel.ts @@ -0,0 +1,213 @@ +/* + * 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(['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: 'xmlParsingFailed'}> + | 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 === 'xmlParsingFailed' + && isDisplayedVersion(value.version)) { + return value as {type: 'renderError'; version: number; reason: 'xmlParsingFailed'}; + } + 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..9aa42a8 --- /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' + | 'unresolvable'; + +export type GraphicalViewResolveTargetWarning = 'notFound' | 'ambiguous' | 'unsupportedUri' | 'unresolvable'; + +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..49d00bb --- /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', + 'unresolvable', +]); +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 64c45ad..57ffc9e 100644 --- a/src/languageClient.ts +++ b/src/languageClient.ts @@ -16,6 +16,7 @@ import * as net from 'node:net'; import * as vscode from 'vscode'; import {CloseAction, DocumentFilter, ErrorAction, ErrorHandler, LanguageClient, LanguageClientOptions, State, StreamInfo} from 'vscode-languageclient/node'; import type {RequestClient} from './aspectValidation'; +import type {GraphicalViewRequestTransport} from './graphicalViewClient'; import type {ExtensionLogger} from './outputChannel'; import type {DisposableLike} from './languageServicesSupervisor'; @@ -40,11 +41,13 @@ export function createDoNotRestartErrorHandler(onUnexpectedClose: () => void): E }; } -export class TurtleLanguageClient implements RequestClient { +export class TurtleLanguageClient implements RequestClient, GraphicalViewRequestTransport { private client: LanguageClient; private readonly closeListeners = new Set<() => void>(); + private readonly availability = new vscode.EventEmitter(); private disconnecting = false; private closeReported = false; + private lastAvailability = false; constructor( private outputChannel: ExtensionLogger, @@ -52,6 +55,13 @@ export class TurtleLanguageClient implements RequestClient { private readonly logLevel: vscode.LogLevel ) { 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); + } + }); } onUnexpectedClose(listener: () => void): DisposableLike { @@ -147,11 +157,19 @@ export class TurtleLanguageClient implements RequestClient { } } - 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..3f3e734 --- /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 XML parsing 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('strict XML 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/graphicalViewEditorReuse.acceptance.test.ts b/src/test/graphicalViewEditorReuse.acceptance.test.ts new file mode 100644 index 0000000..0587d0d --- /dev/null +++ b/src/test/graphicalViewEditorReuse.acceptance.test.ts @@ -0,0 +1,183 @@ +/* + * 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 * as vscode from 'vscode'; +import type {GraphicalViewRenderResult} from '../graphicalViewProtocol'; +import {createGraphicalViewHarnessWithWindow, FakeGraphicalViewClient, flushPromises} from './graphicalViewTestHarness'; + +const OWNER = 'urn:samm:example.graphical:1.0.0#Aspect'; +const TARGET_ID = 'gv-header-aaaaaaaaaaaaaaaa'; +const createdUris: vscode.Uri[] = []; + +suite('Graphical View editor reuse acceptance', () => { + setup(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + }); + + teardown(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + for (const uri of createdUris.splice(0)) { + try { + await vscode.workspace.fs.delete(uri); + } catch (_error) { + // The test owns these unique temporary files; a missing file is already clean. + } + } + }); + + test('reuses the original visible source tab and does not create a second instance', async () => { + const source = await createTurtleFile('visible-source', 'line 0\nline 1 target\n'); + const distracting = await createTurtleFile('visible-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + + clickTarget(harness, source, 1, 7, 1, 13); + await waitForNavigation(source, new vscode.Selection(1, 7, 1, 13)); + + assert.equal(textTabs(source).length, 1); + assert.equal(activeTextTabGroup(source)?.viewColumn, vscode.ViewColumn.One); + assert.ok(vscode.window.activeTextEditor?.selection.isEqual(new vscode.Selection(1, 7, 1, 13))); + harness.controller.dispose(); + }); + + test('finds and reuses a source tab hidden behind another tab in its group', async () => { + const source = await createTurtleFile('hidden-source', 'line 0\nline 1 target\n'); + const cover = await createTurtleFile('hidden-cover', 'cover\n'); + const distracting = await createTurtleFile('hidden-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(cover, {viewColumn: vscode.ViewColumn.One, preview: false}); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + assert.equal(textTabs(source)[0]?.isActive, false); + + clickTarget(harness, source, 1, 7, 1, 13); + await waitForNavigation(source, new vscode.Selection(1, 7, 1, 13)); + + assert.equal(textTabs(source).length, 1); + assert.equal(activeTextTabGroup(source)?.viewColumn, vscode.ViewColumn.One); + assert.ok(vscode.window.activeTextEditor?.selection.isEqual(new vscode.Selection(1, 7, 1, 13))); + harness.controller.dispose(); + }); + + test('opens an unopened target once through the existing fallback placement', async () => { + const source = await createTurtleFile('fallback-source', 'source\n'); + const target = await createTurtleFile('fallback-target', 'line 0\nline 1 target\n'); + const distracting = await createTurtleFile('fallback-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + assert.equal(textTabs(target).length, 0); + + clickTarget(harness, target, 1, 7, 1, 13); + await waitForNavigation(target, new vscode.Selection(1, 7, 1, 13)); + + assert.equal(textTabs(target).length, 1); + assert.equal(activeTextTabGroup(target)?.viewColumn, vscode.ViewColumn.Two); + assert.ok(vscode.window.activeTextEditor?.selection.isEqual(new vscode.Selection(1, 7, 1, 13))); + harness.controller.dispose(); + }); + + test('retained target resolving to notFound does not open or focus another tab', async () => { + const source = await createTurtleFile('not-found-source', 'source\n'); + const distracting = await createTurtleFile('not-found-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + const activeBefore = vscode.window.activeTextEditor?.document.uri.toString(); + harness.client.resolveResult = {location: null, warning: 'notFound'}; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: TARGET_ID}); + await flushPromises(); + + assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), activeBefore); + assert.equal(textTabs(source).length, 1); + harness.controller.dispose(); + }); +}); + +async function createAcceptedHarness(source: vscode.Uri) { + const client = new FakeGraphicalViewClient(); + const harness = createGraphicalViewHarnessWithWindow(vscode.window, client); + harness.workspace.available.add(source.toString()); + await harness.commands.execute('semantic-models.openGraphicalView'); + client.requests[0].deferred.resolve(renderResult(source)); + await flushPromises(); + return harness; +} + +function clickTarget( + harness: ReturnType>, + target: vscode.Uri, + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, +): void { + harness.client.resolveResult = { + location: { + uri: target.toString(), + range: { + start: {line: startLine, character: startCharacter}, + end: {line: endLine, character: endCharacter}, + }, + }, + }; + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: TARGET_ID}); +} + +function renderResult(source: vscode.Uri): GraphicalViewRenderResult { + return { + uri: source.toString(), + svg: ``, + targets: [{id: TARGET_ID, kind: 'elementHeader', elementUrn: OWNER}], + warnings: [], + }; +} + +function textTabs(uri: vscode.Uri): vscode.Tab[] { + return vscode.window.tabGroups.all + .flatMap(group => group.tabs) + .filter(tab => tab.input instanceof vscode.TabInputText && tab.input.uri.toString() === uri.toString()); +} + +function activeTextTabGroup(uri: vscode.Uri): vscode.TabGroup | undefined { + return vscode.window.tabGroups.all.find(group => + group.tabs.some(tab => tab.isActive && tab.input instanceof vscode.TabInputText && tab.input.uri.toString() === uri.toString()), + ); +} + +async function createTurtleFile(name: string, contents: string): Promise { + const uri = vscode.Uri.file(`/tmp/esmf-task7-${process.pid}-${name}.ttl`); + createdUris.push(uri); + await vscode.workspace.fs.writeFile(uri, Buffer.from(contents)); + return uri; +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() >= deadline) { + assert.fail('Timed out waiting for the VS Code editor state.'); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +async function waitForNavigation(uri: vscode.Uri, selection: vscode.Selection): Promise { + await waitFor(() => { + const editor = vscode.window.activeTextEditor; + return editor?.document.uri.toString() === uri.toString() && editor.selection.isEqual(selection); + }); +} 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..2cf83c1 --- /dev/null +++ b/src/test/graphicalViewNavigation.test.ts @@ -0,0 +1,335 @@ +/* + * 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 * as vscode from 'vscode'; +import {findReusableTextEditorViewColumn} from '../graphicalView'; +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('finds visible and hidden regular text tabs while ignoring diff and custom inputs', () => { + const target = vscode.Uri.file('/tmp/reused.ttl'); + const groups = [ + tabGroup(vscode.ViewColumn.One, [ + tab(new vscode.TabInputTextDiff(vscode.Uri.file('/tmp/original.ttl'), target), true), + tab(new vscode.TabInputCustom(target, 'test.customEditor'), false), + ]), + tabGroup(vscode.ViewColumn.Two, [ + tab(new vscode.TabInputText(target), false), + tab(new vscode.TabInputText(vscode.Uri.file('/tmp/visible.ttl')), true), + ]), + ]; + + assert.equal(findReusableTextEditorViewColumn(target, groups), vscode.ViewColumn.Two); + }); + + test('uses complete canonical URI identity and rejects similar resource identifiers', () => { + const target = vscode.Uri.parse('file://server/tmp/a%20model.ttl?revision=1#definition'); + const groups = [ + tabGroup(vscode.ViewColumn.One, [ + tab(new vscode.TabInputText(vscode.Uri.parse('untitled://server/tmp/a%20model.ttl?revision=1#definition')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://other/tmp/a%20model.ttl?revision=1#definition')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://server/tmp/a%20model.ttl?revision=2#definition')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://server/tmp/a%20model.ttl?revision=1#other')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://server/tmp/a%2520model.ttl?revision=1#definition')), false), + ]), + tabGroup(vscode.ViewColumn.Three, [tab(new vscode.TabInputText(vscode.Uri.parse(target.toString())), false)]), + ]; + + assert.equal(findReusableTextEditorViewColumn(target, groups), vscode.ViewColumn.Three); + }); + + test('prefers an active duplicate match and otherwise keeps stable group order', () => { + const target = vscode.Uri.file('/tmp/duplicate.ttl'); + assert.equal( + findReusableTextEditorViewColumn(target, [ + tabGroup(vscode.ViewColumn.One, [tab(new vscode.TabInputText(target), false)]), + tabGroup(vscode.ViewColumn.Two, [tab(new vscode.TabInputText(target), true)]), + tabGroup(vscode.ViewColumn.Three, [tab(new vscode.TabInputText(target), true)]), + ]), + vscode.ViewColumn.Two, + ); + assert.equal( + findReusableTextEditorViewColumn(target, [ + tabGroup(vscode.ViewColumn.One, [tab(new vscode.TabInputText(target), false)]), + tabGroup(vscode.ViewColumn.Two, [tab(new vscode.TabInputText(target), false)]), + ]), + vscode.ViewColumn.One, + ); + }); + + test('reuses a matching tab group and preserves fallback, selection, and centered reveal', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/reuse-controller.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = successfulHeaderId(); + harness.client.requests[0].deferred.resolve(headerResult(document, id)); + await flushPromises(); + harness.client.resolveResult = { + location: {uri: document.uri.toString(), range: positions(4, 2, 4, 9)}, + }; + harness.window.tabGroups.all = [ + tabGroup(vscode.ViewColumn.Three, [tab(new vscode.TabInputText(document.uri), false)]) as vscode.TabGroup, + ]; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + assert.deepEqual(harness.window.openedEditors[0].options, {preview: false, viewColumn: vscode.ViewColumn.Three}); + assert.ok(harness.window.openedEditors[0].editor.selection.isEqual(new vscode.Selection(4, 2, 4, 9))); + assert.deepEqual(harness.window.openedEditors[0].revealedRanges, [new vscode.Range(4, 2, 4, 9)]); + assert.deepEqual(harness.window.openedEditors[0].revealTypes, [vscode.TextEditorRevealType.InCenterIfOutsideViewport]); + + harness.window.tabGroups.all = []; + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + assert.deepEqual(harness.window.openedEditors[1].options, {preview: false}); + harness.controller.dispose(); + }); + + test('does not select or reveal when navigation becomes stale while editor reveal is pending', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/stale-reuse.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = successfulHeaderId(); + harness.client.requests[0].deferred.resolve(headerResult(document, id)); + await flushPromises(); + harness.client.resolveResult = { + location: {uri: document.uri.toString(), range: positions(1, 2, 1, 7)}, + }; + let resolveEditor!: (editor: vscode.TextEditor) => void; + harness.window.showTextDocumentPromise = new Promise(resolve => { + resolveEditor = resolve; + }); + const revealed: vscode.Range[] = []; + const editor = { + selection: new vscode.Selection(0, 0, 0, 0), + revealRange: (range: vscode.Range) => revealed.push(range), + } as unknown as vscode.TextEditor; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + harness.panels.panels[0].dispose(); + resolveEditor(editor); + await flushPromises(); + + assert.ok(editor.selection.isEqual(new vscode.Selection(0, 0, 0, 0))); + assert.deepEqual(revealed, []); + harness.controller.dispose(); + }); + + test('resolver warnings do not inspect tabs or open an editor', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/not-found-before-tabs.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = successfulHeaderId(); + harness.client.requests[0].deferred.resolve(headerResult(document, id)); + await flushPromises(); + harness.client.resolveResult = {location: null, warning: 'notFound'}; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + + assert.deepEqual(harness.window.warnings, ['The graphical target no longer exists in the current model.']); + assert.deepEqual(harness.window.openedEditors, []); + harness.controller.dispose(); + }); + + 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}, + }; +} + +function tab(input: unknown, isActive: boolean): Pick { + return {input, isActive}; +} + +function tabGroup( + viewColumn: vscode.ViewColumn, + tabs: Array>, +): Pick { + return {viewColumn, tabs: tabs as vscode.Tab[]}; +} + +function successfulHeaderId(): string { + return 'gv-header-aaaaaaaaaaaaaaaa'; +} + +function headerResult(document: ReturnType, id: string): GraphicalViewRenderResult { + return { + uri: document.uri.toString(), + svg: ``, + targets: [{id, kind: 'elementHeader', elementUrn: OWNER}], + warnings: [], + }; +} diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts new file mode 100644 index 0000000..960c380 --- /dev/null +++ b/src/test/graphicalViewPanel.test.ts @@ -0,0 +1,174 @@ +/* + * 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(/`; + const probeScript = ``; + return shell.replace(controllerScript, `${bootstrap}${controllerScript}${probeScript}`); +} + +let probeId = 0; + +async function probe(panel: vscode.WebviewPanel, messages: readonly unknown[], action: string): Promise { + const id = ++probeId; + const attempts = ['snapshot', 'fit', 'clearResize'].includes(action) ? 3 : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + assert.equal(await panel.webview.postMessage({type: 'acceptanceProbe', id, action}), true); + try { + await waitFor( + () => messages.some(message => isRecord(message) && message.type === 'acceptanceResult' && message.id === id), + `viewport probe ${action}`, + 5_000, + ); + return messages.find( + (message): message is ViewportProbeResult => isRecord(message) && message.type === 'acceptanceResult' && message.id === id, + ) as ViewportProbeResult; + } catch (error) { + if (attempt === attempts - 1) { + throw error; + } + } + } + throw new Error(`Viewport probe ${action} did not complete.`); +} + +function assertCenteredFreeAxes(result: ViewportProbeResult): void { + const horizontalFreeSpace = result.viewport.clientWidth - 24 - result.svg.width; + if (horizontalFreeSpace > 0.5) { + assert.ok(Math.abs(result.svg.left - (result.viewport.left + 12 + horizontalFreeSpace / 2)) < 1); + } + const verticalFreeSpace = result.viewport.clientHeight - 24 - result.svg.height; + if (verticalFreeSpace > 0.5) { + assert.ok(Math.abs(result.svg.top - (result.viewport.top + 12 + verticalFreeSpace / 2)) < 1); + } +} + +interface ViewportProbeResult { + type: 'acceptanceResult'; + id: number; + state: {zoom: number; scrollLeft: number; scrollTop: number; viewportInitialized: boolean}; + viewport: { + left: number; + top: number; + right: number; + bottom: number; + clientWidth: number; + clientHeight: number; + maximumLeft: number; + maximumTop: number; + }; + svg: {left: number; top: number; right: number; bottom: number; width: number; height: number}; + styles: {overflow: string; buttonDisplay: string; diagramDisplay: string; bodyColor: string; bodyBackground: string}; + prevented: boolean; + beforeZoom: number; + afterZoom: number; + beforePoint: {x: number; y: number}; + afterPoint: {x: number; y: number}; +} + +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 latestMessage(messages: readonly unknown[], type: string): unknown { + return [...messages].reverse().find(message => isRecord(message) && message.type === type); +} + +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..33ab973 --- /dev/null +++ b/src/test/languageClientGraphicalView.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 * 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-LICENSE-Apache-2.0.txt b/src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt new file mode 100644 index 0000000..11069ed --- /dev/null +++ b/src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +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 + + http://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. diff --git a/src/webview/RobotoCondensed-NOTICE.txt b/src/webview/RobotoCondensed-NOTICE.txt new file mode 100644 index 0000000..8fc3164 --- /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 full Apache License 2.0 text is distributed alongside this notice as +RobotoCondensed-LICENSE-Apache-2.0.txt. 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/webview.css b/src/webview/webview.css new file mode 100644 index 0000000..8c10d1d --- /dev/null +++ b/src/webview/webview.css @@ -0,0 +1,262 @@ +/* + * 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; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + box-sizing: border-box; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--vscode-panel-border); +} + +#toolbar-controls, +#zoom-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + min-width: 0; +} + +#toolbar-controls { + flex: 0 1 auto; + gap: 0.4rem; +} + +#zoom-controls { + gap: 0.15rem; +} + +#toolbar button { + min-height: 1.65rem; + padding: 0 0.55rem; + font: inherit; + cursor: pointer; + border-radius: 2px; +} + +#toolbar .primary-action { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: 1px solid transparent; +} + +#toolbar .primary-action:hover { + background: var(--vscode-button-hoverBackground); +} + +#toolbar .toolbar-action { + color: var(--vscode-foreground); + background: transparent; + border: 1px solid transparent; +} + +#toolbar .toolbar-action:hover { + background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); +} + +#toolbar .icon-action { + width: 1.75rem; + min-width: 1.75rem; + padding: 0; + font-size: 1.1rem; +} + +.toolbar-separator { + align-self: stretch; + width: 1px; + min-height: 1.25rem; + background: var(--vscode-panel-border); +} + +#toolbar button:focus-visible, +#viewport:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +#status { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; + max-width: 50%; + margin: 0 0 0 auto; + padding: 0.2rem 0; +} + +#status-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#status-indicator { + flex: 0 0 auto; + box-sizing: border-box; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; +} + +#status[data-kind='ready'] #status-indicator { + background: var(--vscode-testing-iconPassed, var(--vscode-foreground)); +} + +#status[data-kind='loading'] #status-indicator { + width: 0.8rem; + height: 0.8rem; + border: 2px solid var(--vscode-panel-border); + border-top-color: var(--vscode-progressBar-background, var(--vscode-foreground)); + animation: status-spin 0.8s linear infinite; +} + +#status[data-kind='stale'], +#status[data-kind='unsupported'], +#status[data-kind='disconnected'] { + flex: 1 0 100%; + order: 2; + box-sizing: border-box; + width: 100%; + max-width: none; + margin: 0; + padding: 0.4rem 0.55rem; + border: 1px solid var(--vscode-panel-border); + border-left-width: 3px; +} + +#status[data-kind='stale'] #status-indicator, +#status[data-kind='unsupported'] #status-indicator, +#status[data-kind='disconnected'] #status-indicator { + width: auto; + height: auto; + border-radius: 0; +} + +#status[data-kind='stale'], +#status[data-kind='unsupported'] { + border-left-color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-foreground)); +} + +#status[data-kind='stale'] #status-indicator::before, +#status[data-kind='unsupported'] #status-indicator::before { + color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-foreground)); + content: '⚠'; +} + +#status[data-kind='disconnected'] { + border-left-color: var(--vscode-notificationsErrorIcon-foreground, var(--vscode-foreground)); +} + +#status[data-kind='disconnected'] #status-indicator::before { + color: var(--vscode-notificationsErrorIcon-foreground, var(--vscode-foreground)); + content: '×'; + font-weight: 700; +} + +#zoom-value { + min-width: 3rem; + text-align: center; +} + +@keyframes status-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + #status[data-kind='loading'] #status-indicator { + animation: none; + } +} + +@media (max-width: 520px) { + #toolbar-controls { + flex: 1 1 100%; + justify-content: space-between; + } + + #status[data-kind='ready'], + #status[data-kind='loading'] { + flex: 1 1 100%; + max-width: none; + margin-left: 0; + } +} + +#viewport { + box-sizing: border-box; + flex: 1 1 auto; + width: 100%; + min-height: 0; + overflow: auto; + padding: 0.75rem; +} + +#diagram { + display: flex; + box-sizing: border-box; + width: max-content; + min-width: 100%; + min-height: 100%; +} + +#diagram svg { + display: block; + flex: none; + margin: auto; +} + +#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..11928b6 --- /dev/null +++ b/src/webview/webview.js @@ -0,0 +1,449 @@ +/* + * 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 VIEWPORT_PADDING = 24; + const WHEEL_LINE_PIXELS = 16; + const MAX_WHEEL_DELTA = 100; + const WHEEL_ZOOM_SENSITIVITY = 0.002; + const WHEEL_GESTURE_IDLE_MS = 160; + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const MARKER_PATTERN = /^gv-(?:header|attribute)-[a-z0-9]{16,32}$/; + const POSITIVE_DIMENSION_PATTERN = /^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:pt)?$/; + const vscode = acquireVsCodeApi(); + const viewport = document.querySelector('#viewport'); + const diagram = document.querySelector('#diagram'); + const status = document.querySelector('#status'); + const statusText = document.querySelector('#status-text'); + const zoomValue = document.querySelector('#zoom-value'); + const STATUS_KINDS = Object.freeze(['loading', 'ready', 'stale', 'unsupported', 'disconnected']); + let currentVersion = null; + let currentSvg = null; + let baseWidth = 0; + let baseHeight = 0; + let layoutGeneration = 0; + let wheelFrame = null; + let pendingWheelDelta = 0; + let pendingWheelAnchor = null; + let wheelGeneration = 0; + let wheelGestureAnchor = null; + let wheelGestureTimeout = null; + let pendingRenderedVersion = null; + let pendingViewportInitialization = false; + let viewportLayoutPending = false; + let pendingAnchorCorrection = null; + let state = normalizeState(vscode.getState()); + + function normalizeState(candidate) { + if (!candidate || candidate.schemaVersion !== 1) { + return {schemaVersion: 1, zoom: 1, scrollLeft: 0, scrollTop: 0, viewportInitialized: false}; + } + const legacyStateIsValid = + !Object.hasOwn(candidate, 'viewportInitialized') && + Number.isFinite(candidate.zoom) && + candidate.zoom >= MIN_ZOOM && + candidate.zoom <= MAX_ZOOM && + Number.isFinite(candidate.scrollLeft) && + candidate.scrollLeft >= 0 && + Number.isFinite(candidate.scrollTop) && + candidate.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, + viewportInitialized: typeof candidate.viewportInitialized === 'boolean' ? candidate.viewportInitialized : legacyStateIsValid, + }; + } + + 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), + viewportInitialized: state.viewportInitialized, + }; + vscode.setState(state); + updateZoomLabel(); + } + + function updateZoomLabel() { + zoomValue.textContent = `${Math.round(state.zoom * 100)}%`; + } + + function updateStatus(candidate) { + if ( + !candidate || + typeof candidate !== 'object' || + Array.isArray(candidate) || + !STATUS_KINDS.includes(candidate.kind) || + typeof candidate.message !== 'string' + ) { + return; + } + status.setAttribute('data-kind', candidate.kind); + status.setAttribute('title', candidate.message); + statusText.textContent = candidate.message; + } + + function captureViewport() { + state = {...state, scrollLeft: Math.max(0, viewport.scrollLeft), scrollTop: Math.max(0, viewport.scrollTop)}; + } + + function dimensions(svg) { + const width = parseDimension(svg.getAttribute('width')); + const height = parseDimension(svg.getAttribute('height')); + if (width === undefined || height === undefined) { + throw new Error('SVG dimensions are unavailable'); + } + return {width, height}; + } + + function parseDimension(value) { + if (typeof value !== 'string' || !POSITIVE_DIMENSION_PATTERN.test(value)) { + return undefined; + } + const parsed = Number(value.endsWith('pt') ? value.slice(0, -2) : value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + } + + function parseTrustedSvg(svgText) { + const parsed = new DOMParser().parseFromString(svgText, 'image/svg+xml'); + if (parsed.getElementsByTagName('parsererror').length > 0) { + throw new Error('SVG is not well-formed XML'); + } + const svg = parsed.documentElement; + if (!svg || svg.localName !== 'svg' || svg.namespaceURI !== SVG_NAMESPACE) { + throw new Error('SVG document root is invalid'); + } + dimensions(svg); + const imported = document.importNode(svg, true); + if (!(imported instanceof Element) || imported.localName !== 'svg' || imported.namespaceURI !== SVG_NAMESPACE) { + throw new Error('SVG document root could not be imported'); + } + return imported; + } + + function applyZoom() { + if (!currentSvg) { + updateZoomLabel(); + return; + } + currentSvg.setAttribute('width', String(baseWidth * state.zoom)); + currentSvg.setAttribute('height', String(baseHeight * state.zoom)); + updateZoomLabel(); + } + + function restoreViewport(version, fitMeasurement) { + viewportLayoutPending = true; + pendingAnchorCorrection = null; + afterLayout(version, () => { + if (fitMeasurement && (fitMeasurement.width !== viewport.clientWidth || fitMeasurement.height !== viewport.clientHeight)) { + applyFit(version); + 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); + viewportLayoutPending = false; + completeLayout(version); + }); + } + + function completeLayout(version) { + if (pendingRenderedVersion === version && pendingViewportInitialization) { + state = {...state, viewportInitialized: true}; + } + persistState(); + if (pendingRenderedVersion === version) { + pendingRenderedVersion = null; + pendingViewportInitialization = false; + vscode.postMessage({type: 'rendered', version}); + } + } + + function afterLayout(version, callback) { + const generation = ++layoutGeneration; + let completed = false; + const complete = () => { + if (completed) { + return; + } + completed = true; + if (generation === layoutGeneration && version === currentVersion) { + callback(); + } + }; + requestAnimationFrame(complete); + setTimeout(complete, 100); + } + + function viewportCenter() { + const bounds = viewport.getBoundingClientRect(); + return {clientX: bounds.left + viewport.clientWidth / 2, clientY: bounds.top + viewport.clientHeight / 2}; + } + + function zoomAround(zoom, anchor) { + if (!currentSvg) { + return false; + } + const nextZoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM); + if (nextZoom === state.zoom) { + return false; + } + const version = currentVersion; + applyPendingAnchorCorrection(version); + layoutGeneration++; + const svgBounds = currentSvg.getBoundingClientRect(); + const anchorRatioX = svgBounds.width > 0 ? (anchor.clientX - svgBounds.left) / svgBounds.width : 0; + const anchorRatioY = svgBounds.height > 0 ? (anchor.clientY - svgBounds.top) / svgBounds.height : 0; + state = {...state, zoom: nextZoom}; + applyZoom(); + viewportLayoutPending = true; + pendingAnchorCorrection = {version, anchor, anchorRatioX, anchorRatioY}; + applyPendingAnchorCorrection(version); + completeLayout(version); + return true; + } + + function applyPendingAnchorCorrection(version) { + const correction = pendingAnchorCorrection; + if (!correction || correction.version !== version || version !== currentVersion || !currentSvg) { + return; + } + const updatedBounds = currentSvg.getBoundingClientRect(); + viewport.scrollLeft += updatedBounds.left + correction.anchorRatioX * updatedBounds.width - correction.anchor.clientX; + viewport.scrollTop += updatedBounds.top + correction.anchorRatioY * updatedBounds.height - correction.anchor.clientY; + captureViewport(); + pendingAnchorCorrection = null; + viewportLayoutPending = false; + } + + function fitDiagram() { + if (!currentSvg || baseWidth <= 0 || baseHeight <= 0) { + return; + } + cancelPendingWheel(); + applyFit(currentVersion); + } + + function applyFit(version) { + const fitMeasurement = {width: viewport.clientWidth, height: viewport.clientHeight}; + const availableWidth = Math.max(1, fitMeasurement.width - VIEWPORT_PADDING); + const availableHeight = Math.max(1, fitMeasurement.height - VIEWPORT_PADDING); + state = { + ...state, + zoom: clamp(Math.min(availableWidth / baseWidth, availableHeight / baseHeight), MIN_ZOOM, MAX_ZOOM), + scrollLeft: 0, + scrollTop: 0, + }; + applyZoom(); + restoreViewport(version, fitMeasurement); + } + + function normalizeWheelDelta(event) { + const unit = event.deltaMode === 1 ? WHEEL_LINE_PIXELS : event.deltaMode === 2 ? Math.max(1, viewport.clientHeight) : 1; + return clamp(event.deltaY * unit, -MAX_WHEEL_DELTA, MAX_WHEEL_DELTA); + } + + function handleWheel(event) { + if (!currentSvg || (!event.ctrlKey && !event.metaKey)) { + return; + } + event.preventDefault(); + const delta = normalizeWheelDelta(event); + const proposedZoom = clamp(state.zoom * Math.exp(-delta * WHEEL_ZOOM_SENSITIVITY), MIN_ZOOM, MAX_ZOOM); + if (proposedZoom === state.zoom && pendingWheelDelta === 0) { + return; + } + if (!wheelGestureAnchor) { + wheelGestureAnchor = {clientX: event.clientX, clientY: event.clientY}; + } + if (wheelGestureTimeout !== null) { + clearTimeout(wheelGestureTimeout); + } + wheelGestureTimeout = setTimeout(() => { + wheelGestureAnchor = null; + wheelGestureTimeout = null; + }, WHEEL_GESTURE_IDLE_MS); + pendingWheelDelta = clamp(pendingWheelDelta + delta, -MAX_WHEEL_DELTA, MAX_WHEEL_DELTA); + pendingWheelAnchor = wheelGestureAnchor; + if (wheelFrame !== null) { + return; + } + const generation = ++wheelGeneration; + let completed = false; + const completeWheel = () => { + if (completed) { + return; + } + completed = true; + if (generation !== wheelGeneration) { + return; + } + wheelFrame = null; + const accumulatedDelta = pendingWheelDelta; + const anchor = pendingWheelAnchor; + pendingWheelDelta = 0; + pendingWheelAnchor = null; + if (!anchor || accumulatedDelta === 0) { + return; + } + zoomAround(state.zoom * Math.exp(-accumulatedDelta * WHEEL_ZOOM_SENSITIVITY), anchor); + }; + wheelFrame = requestAnimationFrame(completeWheel); + setTimeout(completeWheel, 100); + } + + function cancelPendingWheel() { + wheelGeneration++; + if (wheelGestureTimeout !== null) { + clearTimeout(wheelGestureTimeout); + } + wheelFrame = null; + pendingWheelDelta = 0; + pendingWheelAnchor = null; + wheelGestureAnchor = null; + wheelGestureTimeout = null; + } + + function toolbarZoom(zoom) { + cancelPendingWheel(); + zoomAround(zoom, viewportCenter()); + } + + 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 { + const parsedSvg = parseTrustedSvg(message.svg); + const size = dimensions(parsedSvg); + if (currentSvg) { + applyPendingAnchorCorrection(currentVersion); + if (!viewportLayoutPending) { + captureViewport(); + } + } + makeNavigationMarkersInteractive(parsedSvg); + cancelPendingWheel(); + diagram.replaceChildren(parsedSvg); + currentSvg = parsedSvg; + currentVersion = message.version; + baseWidth = size.width; + baseHeight = size.height; + pendingRenderedVersion = currentVersion; + pendingViewportInitialization = !state.viewportInitialized; + if (state.viewportInitialized) { + applyZoom(); + restoreViewport(currentVersion); + } else { + applyFit(currentVersion); + } + } catch (error) { + updateStatus({ + kind: 'stale', + message: 'The new diagram is not a usable SVG document. The last valid diagram is retained.', + }); + vscode.postMessage({type: 'renderError', version: message.version, reason: 'xmlParsingFailed'}); + } + } + + viewport.addEventListener('scroll', persistState, {passive: true}); + viewport.addEventListener('wheel', handleWheel, {passive: false}); + 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 (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) || !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', () => toolbarZoom(state.zoom * ZOOM_FACTOR)); + document.querySelector('#zoom-out').addEventListener('click', () => toolbarZoom(state.zoom / ZOOM_FACTOR)); + document.querySelector('#zoom-reset').addEventListener('click', () => toolbarZoom(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 === 'object' + ) { + updateStatus(message.status); + } + }); + + updateZoomLabel(); + vscode.setState(state); + vscode.postMessage({type: 'ready'}); +})();