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/README.md b/README.md index c9eddf1..e8af606 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,18 @@ Use the command `Semantic Models: Select SAMM CLI Executable` to choose either: - Manual validation command: - `Semantic Models: Validate Document Now` +## Graphical View + +Use `Semantic Models: Open Graphical View` (`semantic-models.openGraphicalView`) from the Turtle editor-title button, the editor context menu, or the Command Palette to open a read-only SVG snapshot in a separate panel. One panel is reused per Turtle document. + +The view renders on initial open, manual Refresh, and Save while the main Turtle document is visible. It includes unsaved text from the main document but uses the persisted versions of imported files. Typing, import saves, hidden-document saves, and revealing an existing panel do not trigger a render. + +Element headers can navigate to definitions in local files. Eligible attribute rows whose owner is a named model element navigate as a whole row; language-qualified and wrapped rows retain their source mapping, and aggregated rows navigate to the start of the corresponding predicate. Rows with anonymous or otherwise non-deterministic owners remain inert. Navigation to remote URIs and arbitrary referenced values is not supported. + +If rendering fails, the last successful diagram remains visible with an error or warning. Rendering is limited to 1,000 boxes and a 30-second request timeout. The graphical view is not an editor, does not update live while typing, and does not claim visual parity with the Aspect Model Editor. + +Graphical View supports SVG only from the shipped/current trusted SAMM language server and generator contract. The extension strict-parses each SVG as XML but does not sanitize or filter its elements and attributes. Its restrictive webview CSP and extension-side message, sidecar, and local-file navigation validation remain enforced. External, substituted, incompatible, or compromised language servers are outside the supported security model; future Graphper or generator changes must preserve the tested style-free passive-output invariant. + ## Running the Server and Extension Together 1. In this extension project, install the dependencies using `npm install`. diff --git a/media/aspect-model-editor-targetsize-192.png b/media/aspect-model-editor-targetsize-192.png new file mode 100644 index 0000000..a8f811b Binary files /dev/null and b/media/aspect-model-editor-targetsize-192.png differ diff --git a/package.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 2f7bf5d..5f6155e 100644 --- a/src/aspectValidation.ts +++ b/src/aspectValidation.ts @@ -31,7 +31,7 @@ export interface DiagnosticReport { } 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'}); +})();