From 3a357075aa1f987b7d33d58c4d2d2fab684b1ae6 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Wed, 26 Aug 2026 15:28:04 +0300 Subject: [PATCH 1/7] Add graphical view functionality and enhance language client integration --- .vscode-test.mjs | 3 + package.json | 13 +- src/aspectValidation.ts | 2 +- src/extension.ts | 41 ++ src/graphicalView.ts | 611 +++++++++++++++++++ src/graphicalViewPanel.ts | 101 +++ src/graphicalViewProtocol.ts | 147 +++++ src/languageClient.ts | 63 +- src/test/graphicalViewController.test.ts | 346 +++++++++++ src/test/graphicalViewTestHarness.ts | 286 +++++++++ src/test/languageClientGraphicalView.test.ts | 141 +++++ 11 files changed, 1744 insertions(+), 10 deletions(-) create mode 100644 src/graphicalView.ts create mode 100644 src/graphicalViewPanel.ts create mode 100644 src/graphicalViewProtocol.ts create mode 100644 src/test/graphicalViewController.test.ts create mode 100644 src/test/graphicalViewTestHarness.ts create mode 100644 src/test/languageClientGraphicalView.test.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index d320b89..b36d3f4 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,7 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; +const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE_PATH; + export default defineConfig({ files: 'out/test/**/*.test.js', + ...(vscodeExecutablePath ? { useInstallation: { fromPath: vscodeExecutablePath } } : {}), launchArgs: [ '--user-data-dir=/tmp/extension-vscode-test-user-data', '--extensions-dir=/tmp/extension-vscode-test-extensions' diff --git a/package.json b/package.json index 5c65ebf..87ade76 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,12 @@ "command": "turtle.selectSammCliExecutable", "title": "Select SAMM CLI Executable", "category": "Turtle" + }, + { + "command": "turtle.openGraphicalView", + "title": "Open Graphical View", + "category": "Turtle", + "enablement": "editorLangId == turtle" } ], "languages": [ @@ -113,6 +119,11 @@ "command": "turtle.validateDocumentNow", "when": "resourceLangId == turtle", "group": "1_modification" + }, + { + "command": "turtle.openGraphicalView", + "when": "resourceLangId == turtle", + "group": "1_modification@2" } ] }, @@ -172,4 +183,4 @@ "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" } -} \ No newline at end of file +} diff --git a/src/aspectValidation.ts b/src/aspectValidation.ts index 9dbf802..125d94b 100644 --- a/src/aspectValidation.ts +++ b/src/aspectValidation.ts @@ -30,7 +30,7 @@ export interface DiagnosticReport { } export interface RequestClient { - sendRequest(method: string, params?: unknown): Thenable; + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Thenable; } export interface ValidationWindow { diff --git a/src/extension.ts b/src/extension.ts index 8bc4388..f396302 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,8 @@ import { SammCliDownloader } from './sammCliDownloader'; import { TurtleExtensionSettings } from './settings'; import { TurtleLanguageClient } from './languageClient'; import type { ExtensionLogger } from './outputChannel'; +import { GraphicalViewController } from './graphicalView'; +import { VscodeGraphicalViewPanelFactory } from './graphicalViewPanel'; const SELECT_EXECUTABLE_COMMAND = 'turtle.selectSammCliExecutable'; const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable'; @@ -27,6 +29,7 @@ let settings: TurtleExtensionSettings; let languageServer: TurtleLanguageServer | undefined; let languageClient: TurtleLanguageClient; let aspectValidationController: AspectValidationController; +let graphicalViewController: GraphicalViewController; let sammCliDownloader: SammCliDownloader; let outputChannel: ExtensionLogger; @@ -43,6 +46,40 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel()); aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel); aspectValidationController.register(context); + graphicalViewController = new GraphicalViewController( + undefined, + new VscodeGraphicalViewPanelFactory(), + vscode.commands, + vscode.window, + { + onDidSaveTextDocument: listener => vscode.workspace.onDidSaveTextDocument(listener), + onDidChangeDocumentAvailability: listener => + vscode.Disposable.from( + vscode.workspace.onDidOpenTextDocument(document => listener(document.uri.toString(), true)), + vscode.workspace.onDidCloseTextDocument(document => listener(document.uri.toString(), false)), + vscode.window.tabGroups.onDidChangeTabs(event => { + for (const tab of event.closed) { + if (tab.input instanceof vscode.TabInputText) { + const sourceUri = tab.input.uri.toString(); + const remainsOpen = vscode.window.tabGroups.all.some(group => + group.tabs.some( + candidate => + candidate.input instanceof vscode.TabInputText && + candidate.input.uri.toString() === sourceUri, + ), + ); + if (!remainsOpen) { + listener(sourceUri, false); + } + } + } + }), + ), + isDocumentAvailable: uri => vscode.workspace.textDocuments.some(document => document.uri.toString() === uri), + }, + outputChannel, + ); + graphicalViewController.register(context); context.subscriptions.push( vscode.commands.registerCommand(SELECT_EXECUTABLE_COMMAND, async () => { @@ -112,6 +149,7 @@ async function restartLanguageServices(reason: string): Promise { outputChannel.info(`Restarting language services (${reason}).`); aspectValidationController.setClient(createUnavailableClient()); + graphicalViewController.setClient(undefined); await languageClient.disconnect(); await stopLanguageServer(); @@ -124,6 +162,7 @@ async function restartLanguageServices(reason: string): Promise { } catch (error) { await stopLanguageServer().catch(() => undefined); aspectValidationController.setClient(createUnavailableClient()); + graphicalViewController.setClient(undefined); throw error; } @@ -132,6 +171,7 @@ async function restartLanguageServices(reason: string): Promise { await nextClient.connect(); languageClient = nextClient; aspectValidationController.setClient(nextClient); + graphicalViewController.setClient(nextClient); } type SammCliQuickPickItem = vscode.QuickPickItem & { @@ -257,6 +297,7 @@ function createUnavailableClient(): RequestClient { } export async function deactivate(): Promise { + graphicalViewController?.dispose(); await restartChain; await languageClient.disconnect(); await stopLanguageServer(); diff --git a/src/graphicalView.ts b/src/graphicalView.ts new file mode 100644 index 0000000..f412aa1 --- /dev/null +++ b/src/graphicalView.ts @@ -0,0 +1,611 @@ +/* + * 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 { + GraphicalViewRenderResult, + GraphicalViewRenderWarning, + GraphicalViewRequestClient, + GraphicalViewTarget, + isGraphicalViewRenderResult, +} from './graphicalViewProtocol'; + +export const OPEN_GRAPHICAL_VIEW_COMMAND = 'turtle.openGraphicalView'; + +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 interface GraphicalViewAcceptedResult { + readonly uri: string; + readonly svg: string; + readonly targets: readonly Readonly[]; + readonly warnings: readonly GraphicalViewRenderWarning[]; + readonly targetById: ReadonlyMap>; +} + +export type GraphicalViewDelivery = + | Readonly<{type: 'status'; uri: string; status: GraphicalViewStatus}> + | Readonly<{type: 'render'; uri: string; svg: string; warnings: readonly GraphicalViewRenderWarning[]}>; + +export type GraphicalViewPanelMessage = Readonly<{type: 'ready'}> | Readonly<{type: 'refresh'}> | Readonly<{type: 'navigate'; targetId: string}>; + +export interface GraphicalViewPanelAdapter 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): GraphicalViewPanelAdapter; +} + +export interface GraphicalViewDocument { + readonly languageId: string; + readonly uri: vscode.Uri; +} + +export interface GraphicalViewWindow { + readonly activeTextEditor: {readonly document: GraphicalViewDocument} | undefined; + showWarningMessage(message: string): Thenable; +} + +export interface GraphicalViewWorkspace { + onDidSaveTextDocument(listener: (document: GraphicalViewDocument) => void): vscode.Disposable; + onDidChangeDocumentAvailability(listener: (sourceUri: string, available: boolean) => void): vscode.Disposable; + isDocumentAvailable(uri: string): boolean; +} + +export interface GraphicalViewCommands { + registerCommand(command: string, callback: () => unknown): vscode.Disposable; +} + +export interface GraphicalViewControllerContext { + subscriptions: vscode.Disposable[]; +} + +export interface GraphicalViewPanelSnapshot { + readonly sourceUri: string; + readonly sequence: number; + readonly visible: boolean; + readonly disposed: boolean; + readonly status: GraphicalViewStatus; + readonly lastSuccess: GraphicalViewAcceptedResult | undefined; +} + +interface PanelState { + readonly sourceUri: string; + readonly panel: GraphicalViewPanelAdapter; + readonly subscriptions: vscode.Disposable[]; + sequence: number; + sourceAvailable: boolean; + visible: boolean; + disposed: boolean; + cancellation: vscode.CancellationTokenSource | undefined; + status: GraphicalViewStatus; + lastSuccess: GraphicalViewAcceptedResult | undefined; +} + +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: GraphicalViewRequestClient | undefined, + private readonly panelFactory: GraphicalViewPanelFactory, + private readonly commands: GraphicalViewCommands, + private readonly window: GraphicalViewWindow, + private readonly workspace: GraphicalViewWorkspace, + private readonly outputChannel: ExtensionLogger, + private readonly createCancellationSource: () => vscode.CancellationTokenSource = () => new vscode.CancellationTokenSource(), + ) { + this.subscribeToClient(); + } + + register(context: GraphicalViewControllerContext): void { + if (this.registered || this.disposed) { + return; + } + this.registered = true; + + this.subscriptions.push( + this.commands.registerCommand(OPEN_GRAPHICAL_VIEW_COMMAND, () => this.openGraphicalView(this.window.activeTextEditor?.document)), + this.workspace.onDidSaveTextDocument(document => this.handleSave(document)), + this.workspace.onDidChangeDocumentAvailability((sourceUri, available) => + this.handleDocumentAvailability(sourceUri, available), + ), + ); + context.subscriptions.push(this); + } + + async openGraphicalView(document: GraphicalViewDocument | undefined): Promise { + if (!document || document.languageId !== 'turtle') { + await this.window.showWarningMessage('Open a Turtle file before opening the graphical view.'); + return undefined; + } + + const sourceUri = document.uri.toString(); + const existing = this.panels.get(sourceUri); + if (existing && !existing.disposed) { + existing.panel.reveal(); + return this.snapshot(existing); + } + + 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, + status: Object.freeze({kind: 'loading', message: 'Preparing graphical view...'}), + lastSuccess: 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'); + return this.snapshot(state); + } + + setClient(client: GraphicalViewRequestClient | 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?.isGraphicalViewAvailable() ? availableAgainStatus(state) : DISCONNECTED_STATUS); + } + this.subscribeToClient(); + } + + getPanelCount(): number { + return this.panels.size; + } + + getPanelState(sourceUri: string): GraphicalViewPanelSnapshot | undefined { + const state = this.panels.get(sourceUri); + return state ? this.snapshot(state) : undefined; + } + + 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 subscribeToClient(): void { + if (!this.client || this.disposed) { + return; + } + this.clientSubscription = this.client.onDidChangeGraphicalViewAvailability(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.deliverCurrentState(state); + } + } + + private handlePanelMessage(state: PanelState, message: unknown): void { + if (!this.isCurrent(state) || !isPanelMessage(message)) { + return; + } + + switch (message.type) { + case 'ready': + this.deliverCurrentState(state); + return; + case 'refresh': + if (state.visible) { + void this.requestRender(state, 'manual'); + } + return; + case 'navigate': + // Task 4 validates current-sidecar membership and resolves navigation. + return; + } + } + + private async requestRender(state: PanelState, trigger: 'initial' | 'manual' | 'save'): Promise { + if (!this.isCurrent(state)) { + return; + } + + this.cancelCurrent(state); + const sequence = ++state.sequence; + const sourceUri = state.sourceUri; + + state.sourceAvailable = this.workspace.isDocumentAvailable(sourceUri); + if (!state.sourceAvailable) { + this.setStatus( + state, + Object.freeze({ + kind: 'stale', + reason: 'sourceUnavailable', + message: 'The source document is not available to the language server. Reopen it and use Refresh.', + }), + ); + return; + } + + const client = this.client; + if (!client?.isGraphicalViewAvailable()) { + this.setStatus(state, DISCONNECTED_STATUS); + return; + } + + const cancellation = this.createCancellationSource(); + state.cancellation = cancellation; + this.setStatus(state, Object.freeze({kind: 'loading', message: `Rendering graphical view (${trigger})...`})); + + try { + const result = await client.renderGraphicalView({uri: sourceUri}, cancellation.token); + if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { + return; + } + state.cancellation = undefined; + cancellation.dispose(); + this.handleRenderResult(state, result); + } 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): 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 (result.svg === undefined || result.svg === null) { + this.handleWarningResult(state, result.warnings); + return; + } + + const accepted = createAcceptedResult(result, result.svg); + state.lastSuccess = accepted; + state.status = Object.freeze({kind: 'ready', message: 'Graphical view is up to date.'}); + state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); + this.deliverStatus(state); + } + + private handleWarningResult(state: PanelState, warnings: readonly GraphicalViewRenderWarning[]): void { + const warning = warnings[0]; + switch (warning) { + case 'timeout': + this.setStale(state, warning, 'Graphical rendering timed out. The last successful diagram is retained.'); + return; + case 'modelTooLarge': + this.setStale(state, warning, 'The model is too large for graphical rendering. The last successful diagram is retained.'); + return; + case 'missingDocument': + this.setStale(state, warning, 'The source document is unavailable. The last successful diagram is retained.'); + return; + case 'unsupportedUri': + this.setStale(state, warning, 'The source URI is not supported for graphical rendering.'); + return; + case 'temporarilyUnresolvable': + default: + this.setStale(state, warning ?? 'renderFailure', 'The model could not be loaded or parsed. The last successful diagram is retained.'); + } + } + + private handleRenderFailure(state: PanelState, error: unknown): void { + if (isMethodNotFound(error)) { + this.setStatus( + state, + Object.freeze({ + kind: 'unsupported', + message: 'Graphical view is not supported by the current server build. The last successful diagram is retained.', + }), + ); + return; + } + + const detail = error instanceof Error ? error.message : String(error); + this.setStale(state, classifyFailure(detail), `Graphical rendering failed: ${detail}. 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.lastSuccess; + if (accepted) { + state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); + } + } + + private deliverStatus(state: PanelState): void { + state.panel.deliver(Object.freeze({type: 'status', uri: state.sourceUri, status: state.status})); + } + + private invalidate(state: PanelState): void { + if (!this.isCurrent(state)) { + return; + } + this.cancelCurrent(state); + state.sequence += 1; + } + + private cancelCurrent(state: PanelState): void { + const cancellation = state.cancellation; + state.cancellation = 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.cancelCurrent(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(); + } + } + + private snapshot(state: PanelState): GraphicalViewPanelSnapshot { + return Object.freeze({ + sourceUri: state.sourceUri, + sequence: state.sequence, + visible: state.visible, + disposed: state.disposed, + status: state.status, + lastSuccess: state.lastSuccess, + }); + } +} + +function createAcceptedResult(result: GraphicalViewRenderResult, svg: string): GraphicalViewAcceptedResult { + const targets = Object.freeze(result.targets.map(target => Object.freeze({...target}))); + const warnings = Object.freeze([...result.warnings]); + const targetById = new ImmutableTargetMap(targets.map(target => [target.id, target])); + return Object.freeze({uri: result.uri, svg, targets, warnings, targetById}); +} + +class ImmutableTargetMap implements ReadonlyMap { + private readonly map: Map; + + constructor(entries: readonly (readonly [K, V])[]) { + this.map = new Map(entries); + Object.freeze(this); + } + + get size(): number { + return this.map.size; + } + + get(key: K): V | undefined { + return this.map.get(key); + } + + has(key: K): boolean { + return this.map.has(key); + } + + forEach(callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: unknown): void { + this.map.forEach((value, key) => callbackfn.call(thisArg, value, key, this)); + } + + entries(): MapIterator<[K, V]> { + return this.map.entries(); + } + + keys(): MapIterator { + return this.map.keys(); + } + + values(): MapIterator { + return this.map.values(); + } + + [Symbol.iterator](): MapIterator<[K, V]> { + return this.entries(); + } + + get [Symbol.toStringTag](): string { + return 'ImmutableTargetMap'; + } +} + +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 { + if (state.lastSuccess) { + return Object.freeze({kind: 'ready', message: 'Showing the retained graphical-view snapshot.'}); + } + return 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 { + if (state.lastSuccess) { + return Object.freeze({kind: 'ready', message: 'Showing the retained graphical-view snapshot.'}); + } + return 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 isPanelMessage(value: unknown): value is GraphicalViewPanelMessage { + if (!isRecord(value)) { + return false; + } + const keys = Object.keys(value); + if ((value.type === 'ready' || value.type === 'refresh') && keys.length === 1) { + return true; + } + return value.type === 'navigate' && keys.length === 2 && typeof value.targetId === 'string'; +} + +function isMethodNotFound(error: unknown): boolean { + if (isRecord(error) && error.code === -32601) { + return true; + } + return error instanceof Error && /method\s+not\s+found/i.test(error.message); +} + +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/graphicalViewPanel.ts b/src/graphicalViewPanel.ts new file mode 100644 index 0000000..0bbda48 --- /dev/null +++ b/src/graphicalViewPanel.ts @@ -0,0 +1,101 @@ +/* + * 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 {GraphicalViewDelivery, GraphicalViewPanelAdapter, GraphicalViewPanelFactory} from './graphicalView'; + +const VIEW_TYPE = 'turtle.graphicalView'; + +export class VscodeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + create(sourceUri: string): GraphicalViewPanelAdapter { + 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, { + enableScripts: true, + enableForms: false, + enableCommandUris: false, + localResourceRoots: [], + }); + return new VscodeGraphicalViewPanel(panel); + } +} + +class VscodeGraphicalViewPanel implements GraphicalViewPanelAdapter { + constructor(private readonly panel: vscode.WebviewPanel) { + panel.webview.html = createShellHtml(panel.webview); + } + + 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(); + } +} + +function createShellHtml(webview: vscode.Webview): string { + const nonce = randomBytes(16).toString('hex'); + return ` + + + + + + Graphical View + + + +

Preparing graphical view...

+

Diagram rendering is prepared. Secure SVG display is completed in Task 4.

+ + +`; +} diff --git a/src/graphicalViewProtocol.ts b/src/graphicalViewProtocol.ts new file mode 100644 index 0000000..a86a838 --- /dev/null +++ b/src/graphicalViewProtocol.ts @@ -0,0 +1,147 @@ +/* + * 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'; + +export const GRAPHICAL_VIEW_RENDER_REQUEST = 'turtle/graphicalView/render'; +export const GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST = 'turtle/graphicalView/resolveTarget'; +export const GRAPHICAL_VIEW_MARKER_PATTERN = /^gv-header-[a-z0-9]{16,32}$/; + +export type GraphicalViewRenderWarning = + | 'unsupportedUri' + | 'missingDocument' + | 'modelTooLarge' + | 'timeout' + | 'temporarilyUnresolvable'; + +export type GraphicalViewResolveTargetWarning = 'notFound' | 'ambiguous' | 'unsupportedUri' | 'temporarilyUnresolvable'; + +export interface GraphicalViewRenderParams { + uri: string; +} + +export interface GraphicalViewTarget { + id: string; + kind: 'elementHeader'; + elementUrn: string; +} + +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 GraphicalViewRequestClient { + isGraphicalViewAvailable(): boolean; + onDidChangeGraphicalViewAvailability(listener: (available: boolean) => void): vscode.Disposable; + renderGraphicalView(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Thenable; + resolveGraphicalViewTarget( + params: GraphicalViewResolveTargetParams, + token?: vscode.CancellationToken, + ): Thenable; +} + +const RENDER_WARNINGS: ReadonlySet = new Set([ + 'unsupportedUri', + 'missingDocument', + 'modelTooLarge', + 'timeout', + 'temporarilyUnresolvable', +]); +const ASPECT_MODEL_URN_PATTERN = /^urn:samm:[^\s#]+#[^\s#]+$/; +const SVG_MARKER_PATTERN = /\bid\s*=\s*(["'])(gv-header-[a-z0-9]{16,32})\1/g; + +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; + } + + 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); +} + +function isGraphicalViewTarget(value: unknown): value is GraphicalViewTarget { + return ( + isRecord(value) && + typeof value.id === 'string' && + GRAPHICAL_VIEW_MARKER_PATTERN.test(value.id) && + value.kind === 'elementHeader' && + typeof value.elementUrn === 'string' && + ASPECT_MODEL_URN_PATTERN.test(value.elementUrn) + ); +} + +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_MARKER_PATTERN)) { + const id = match[2]; + if (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; +} diff --git a/src/languageClient.ts b/src/languageClient.ts index 2e12733..2858351 100644 --- a/src/languageClient.ts +++ b/src/languageClient.ts @@ -14,21 +14,49 @@ import { Trace } from 'vscode-jsonrpc'; import * as net from 'node:net'; import * as vscode from 'vscode'; -import { LanguageClient, LanguageClientOptions, State, StreamInfo } from 'vscode-languageclient/node'; +import {LanguageClient, LanguageClientOptions, State, StateChangeEvent, StreamInfo} from 'vscode-languageclient/node'; import type { RequestClient } from './aspectValidation'; +import { + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GraphicalViewRenderParams, + GraphicalViewRenderResult, + GraphicalViewRequestClient, + GraphicalViewResolveTargetParams, + GraphicalViewResolveTargetResult, +} from './graphicalViewProtocol'; import type { ExtensionLogger } from './outputChannel'; const CLIENT_START_TIMEOUT_MS = 5000; -export class TurtleLanguageClient implements RequestClient { - private client: LanguageClient; +export interface LanguageClientAdapter { + readonly state: State; + setTrace(value: Trace): void; + start(): Promise; + stop(): Promise; + onDidChangeState(listener: (event: StateChangeEvent) => void): vscode.Disposable; + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise; +} + +export class TurtleLanguageClient implements RequestClient, GraphicalViewRequestClient { + private client: LanguageClientAdapter; + private readonly graphicalViewAvailability = new vscode.EventEmitter(); + private lastGraphicalViewAvailability = false; constructor( private outputChannel: ExtensionLogger, private readonly serverPort: number, - private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off' + private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off', + clientFactory?: () => LanguageClientAdapter, ) { - this.client = this.initLanguageClient(this.serverPort); + this.client = clientFactory ? clientFactory() : this.initLanguageClient(this.serverPort); + this.client.onDidChangeState(event => { + const available = event.newState === State.Running; + if (available !== this.lastGraphicalViewAvailability) { + this.lastGraphicalViewAvailability = available; + this.graphicalViewAvailability.fire(available); + } + }); } private toTrace(level: 'off' | 'messages' | 'verbose'): Trace { @@ -39,7 +67,7 @@ export class TurtleLanguageClient implements RequestClient { } } - private initLanguageClient(serverPort: number): LanguageClient { + private initLanguageClient(serverPort: number): LanguageClientAdapter { const serverOptions = async (): Promise => new Promise((resolve, reject) => { const socket = net.connect({ host: '127.0.0.1', port: serverPort }, () => { resolve({ reader: socket, writer: socket }); @@ -100,12 +128,31 @@ export class TurtleLanguageClient implements RequestClient { await this.client.stop(); } - sendRequest(method: string, params?: unknown): Promise { + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise { if (this.client.state === State.Stopped) { return Promise.reject(new Error('The Turtle language client is not connected.')); } - return this.client.sendRequest(method, params) as Promise; + return this.client.sendRequest(method, params, token) as Promise; + } + + isGraphicalViewAvailable(): boolean { + return this.client.state === State.Running; + } + + onDidChangeGraphicalViewAvailability(listener: (available: boolean) => void): vscode.Disposable { + return this.graphicalViewAvailability.event(listener); + } + + renderGraphicalView(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Promise { + return this.sendRequest(GRAPHICAL_VIEW_RENDER_REQUEST, params, token); + } + + resolveGraphicalViewTarget( + params: GraphicalViewResolveTargetParams, + token?: vscode.CancellationToken, + ): Promise { + return this.sendRequest(GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, params, token); } } diff --git a/src/test/graphicalViewController.test.ts b/src/test/graphicalViewController.test.ts new file mode 100644 index 0000000..7604c56 --- /dev/null +++ b/src/test/graphicalViewController.test.ts @@ -0,0 +1,346 @@ +/* + * 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 {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {OPEN_GRAPHICAL_VIEW_COMMAND} from '../graphicalView'; +import {GraphicalViewRenderResult} from '../graphicalViewProtocol'; +import { + FakeGraphicalViewClient, + createGraphicalViewDocument, + createGraphicalViewHarness, + flushPromises, + successfulResult, +} from './graphicalViewTestHarness'; + +suite('GraphicalViewController', () => { + test('contributes the guarded Turtle editor command', () => { + const manifest = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')) as { + contributes: {commands: Array>; menus: {'editor/context': Array>}}; + }; + const command = manifest.contributes.commands.find(candidate => candidate.command === OPEN_GRAPHICAL_VIEW_COMMAND); + const menu = manifest.contributes.menus['editor/context'].find(candidate => candidate.command === OPEN_GRAPHICAL_VIEW_COMMAND); + assert.equal(command?.enablement, 'editorLangId == turtle'); + assert.equal(menu?.when, 'resourceLangId == turtle'); + }); + + test('guards absent and non-Turtle active editors without 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 and performs the initial typed render', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/initial.ttl'); + harness.window.activeTextEditor = {document}; + await harness.commands.execute(OPEN_GRAPHICAL_VIEW_COMMAND); + assert.equal(harness.panels.panels.length, 1); + assert.deepEqual(harness.client.requests[0].params, {uri: document.uri.toString()}); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.sequence, 1); + + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'ready'); + assert.equal(state?.lastSuccess?.svg, successfulResult(document).svg); + assert.equal(state?.lastSuccess?.targetById.size, 1); + harness.controller.dispose(); + }); + + test('reveals one panel per URI and keeps different URI panels independent', async () => { + const harness = createGraphicalViewHarness(); + const first = track(harness, '/tmp/first.ttl'); + const second = track(harness, '/tmp/second.ttl'); + await harness.controller.openGraphicalView(first); + await harness.controller.openGraphicalView(first); + assert.equal(harness.panels.panels.length, 1); + assert.equal(harness.panels.panels[0].revealCount, 1); + assert.equal(harness.client.requests.length, 1); + + await harness.controller.openGraphicalView(second); + assert.equal(harness.controller.getPanelCount(), 2); + assert.equal(harness.client.requests.length, 2); + assert.equal(harness.client.requests[1].params.uri, second.uri.toString()); + harness.controller.dispose(); + }); + + test('renders on manual Refresh and visible bound-main Save only', async () => { + const harness = createGraphicalViewHarness(); + const main = track(harness, '/tmp/main.ttl'); + const imported = track(harness, '/tmp/import.ttl'); + await harness.controller.openGraphicalView(main); + harness.panels.panels[0].emitMessage({type: 'refresh'}); + assert.equal(harness.client.requests.length, 2); + assert.equal(harness.client.requests[0].token.isCancellationRequested, true); + + harness.workspace.fireSave(imported); + assert.equal(harness.client.requests.length, 2); + harness.workspace.fireSave(main); + assert.equal(harness.client.requests.length, 3); + assert.equal(harness.client.requests[1].token.isCancellationRequested, true); + harness.controller.dispose(); + }); + + test('hidden Save invalidates pending work; typing and reveal do not render', async () => { + const harness = createGraphicalViewHarness(); + const main = track(harness, '/tmp/hidden.ttl'); + await harness.controller.openGraphicalView(main); + harness.client.requests[0].deferred.resolve(successfulResult(main)); + await flushPromises(); + const panel = harness.panels.panels[0]; + panel.emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + panel.setVisible(false); + panel.emitMessage({type: 'refresh'}); + assert.equal(harness.client.requests.length, 2); + const before = harness.controller.getPanelState(main.uri.toString())?.sequence; + harness.workspace.fireSave(main); + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(main.uri.toString())?.sequence, (before ?? 0) + 1); + assert.equal(harness.controller.getPanelState(main.uri.toString())?.status.kind, 'ready'); + assert.ok(harness.controller.getPanelState(main.uri.toString())?.lastSuccess); + + panel.setVisible(true); + panel.emitMessage({type: 'ready'}); + panel.emitMessage({type: 'navigate', targetId: 'gv-header-0123456789abcdef'}); + assert.equal(harness.client.requests.length, 2); + harness.controller.dispose(); + }); + + test('source editor closure invalidates pending work and ignores its late failure', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/closed.ttl'); + await harness.controller.openGraphicalView(document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const retained = harness.controller.getPanelState(document.uri.toString())?.lastSuccess; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + const sequence = harness.controller.getPanelState(document.uri.toString())?.sequence ?? 0; + + harness.workspace.closeSourceEditor(document); + assert.equal(harness.controller.getPanelCount(), 1); + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.sequence, sequence + 1); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess, retained); + + pending.deferred.reject(new Error('late transport failure')); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'ready'); + assert.equal(state?.lastSuccess, retained); + harness.controller.dispose(); + }); + + test('bound-source loss invalidates pending work and ignores its late success', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/lost.ttl'); + await harness.controller.openGraphicalView(document); + const retainedResult = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(retainedResult); + await flushPromises(); + const retained = harness.controller.getPanelState(document.uri.toString())?.lastSuccess; + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + const sequence = harness.controller.getPanelState(document.uri.toString())?.sequence ?? 0; + harness.workspace.loseDocument(document); + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.sequence, sequence + 1); + + pending.deferred.resolve(successfulResult(document, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'ready'); + assert.equal(state?.lastSuccess, retained); + assert.equal(state?.lastSuccess?.svg, retainedResult.svg); + harness.controller.dispose(); + }); + + test('cancels superseded work and rejects obsolete success and failure completions', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/sequence.ttl'); + await harness.controller.openGraphicalView(document); + const first = harness.client.requests[0]; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const second = harness.client.requests[1]; + assert.equal(first.token.isCancellationRequested, true); + + second.deferred.resolve(successfulResult(document, '1111111111111111')); + await flushPromises(); + first.deferred.resolve(successfulResult(document, '2222222222222222')); + await flushPromises(); + assert.match(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.svg ?? '', /1111111111111111/); + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const obsoleteFailure = harness.client.requests[2]; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const newest = harness.client.requests[3]; + obsoleteFailure.deferred.reject(new Error('obsolete transport failure')); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'loading'); + newest.deferred.resolve(successfulResult(document, '3333333333333333')); + await flushPromises(); + assert.match(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.svg ?? '', /3333333333333333/); + harness.controller.dispose(); + }); + + test('rejects URI mismatches and malformed, duplicate, invalid-marker, and inconsistent sidecars', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/validation.ttl'); + await harness.controller.openGraphicalView(document); + const good = successfulResult(document); + harness.client.requests[0].deferred.resolve(good); + await flushPromises(); + + const invalidResults: GraphicalViewRenderResult[] = [ + {...good, uri: 'file:///tmp/other.ttl'}, + {...good, targets: [...good.targets, good.targets[0]]}, + {...good, targets: [{...good.targets[0], id: 'bad-marker'}]}, + {...good, targets: [{...good.targets[0], kind: 'notHeader' as 'elementHeader'}]}, + {...good, targets: [{...good.targets[0], elementUrn: 'not-a-urn'}]}, + {...good, svg: ''}, + ]; + + for (const invalid of invalidResults) { + harness.panels.panels[0].emitMessage({type: 'refresh'}); + harness.client.requests.at(-1)?.deferred.resolve(invalid); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'stale'); + assert.equal(state?.lastSuccess?.svg, good.svg); + assert.equal(state?.lastSuccess?.targetById.get(good.targets[0].id)?.elementUrn, good.targets[0].elementUrn); + } + harness.controller.dispose(); + }); + + test('installs SVG and sidecar atomically and retains them for all failure classes', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/retention.ttl'); + await harness.controller.openGraphicalView(document); + const accepted = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(accepted); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.targetById.has(accepted.targets[0].id), true); + + const failures: Array = [ + {uri: document.uri.toString(), svg: null, targets: [], warnings: ['temporarilyUnresolvable']}, + {uri: document.uri.toString(), targets: [], warnings: ['timeout']}, + {uri: document.uri.toString(), svg: null, targets: [], warnings: ['modelTooLarge']}, + new Error('transport socket closed'), + new Error('generic failure'), + ]; + for (const failure of failures) { + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const request = harness.client.requests.at(-1); + if (failure instanceof Error) { + request?.deferred.reject(failure); + } else { + request?.deferred.resolve(failure); + } + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'stale'); + assert.equal(state?.lastSuccess?.svg, accepted.svg); + assert.equal(state?.lastSuccess?.targetById.has(accepted.targets[0].id), true); + } + harness.controller.dispose(); + }); + + test('detects MethodNotFound by code and recovers compatibility state after client replacement', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/unsupported.ttl'); + await harness.controller.openGraphicalView(document); + harness.client.requests[0].deferred.reject(Object.assign(new Error('localized message'), {code: -32601})); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'unsupported'); + + const replacement = new FakeGraphicalViewClient(); + harness.controller.setClient(replacement); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + assert.equal(replacement.requests.length, 0); + harness.panels.panels[0].emitMessage({type: 'refresh'}); + assert.equal(replacement.requests.length, 1); + harness.controller.dispose(); + }); + + test('handles explicit replacement and unexpected disconnect/reconnect without automatic render', async () => { + const client = new FakeGraphicalViewClient(); + const harness = createGraphicalViewHarness(client); + const document = track(harness, '/tmp/lifecycle.ttl'); + await harness.controller.openGraphicalView(document); + const pending = client.requests[0]; + harness.controller.setClient(undefined); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'disconnected'); + + const replacement = new FakeGraphicalViewClient(); + harness.controller.setClient(replacement); + assert.equal(replacement.requests.length, 0); + replacement.setAvailable(false); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'disconnected'); + replacement.setAvailable(true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + assert.equal(replacement.requests.length, 0); + harness.controller.dispose(); + }); + + test('disposal removes ownership, cancels work, disposes listeners, and rejects late results', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/dispose.ttl'); + await harness.controller.openGraphicalView(document); + const request = harness.client.requests[0]; + const panel = harness.panels.panels[0]; + panel.dispose(); + assert.equal(harness.controller.getPanelCount(), 0); + assert.equal(request.token.isCancellationRequested, true); + assert.equal(panel.disposedListenerCount, 3); + request.deferred.resolve(successfulResult(document)); + await flushPromises(); + assert.equal(harness.controller.getPanelCount(), 0); + harness.controller.dispose(); + }); + + test('multiple panels neither leak nor cross-deliver results or status', async () => { + const harness = createGraphicalViewHarness(); + const first = track(harness, '/tmp/multi-one.ttl'); + const second = track(harness, '/tmp/multi-two.ttl'); + await harness.controller.openGraphicalView(first); + await harness.controller.openGraphicalView(second); + const firstDeliveryCount = harness.panels.panels[0].deliveries.length; + harness.client.requests[1].deferred.resolve(successfulResult(second, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + assert.equal(harness.panels.panels[0].deliveries.length, firstDeliveryCount); + assert.match(harness.controller.getPanelState(second.uri.toString())?.lastSuccess?.svg ?? '', /bbbbbbbbbbbbbbbb/); + assert.equal(harness.controller.getPanelState(first.uri.toString())?.lastSuccess, undefined); + 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/graphicalViewTestHarness.ts b/src/test/graphicalViewTestHarness.ts new file mode 100644 index 0000000..6ea0459 --- /dev/null +++ b/src/test/graphicalViewTestHarness.ts @@ -0,0 +1,286 @@ +/* + * 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 { + GraphicalViewCommands, + GraphicalViewController, + GraphicalViewDelivery, + GraphicalViewDocument, + GraphicalViewPanelAdapter, + GraphicalViewPanelFactory, + GraphicalViewWindow, + GraphicalViewWorkspace, +} from '../graphicalView'; +import { + GraphicalViewRenderParams, + GraphicalViewRenderResult, + GraphicalViewRequestClient, + GraphicalViewResolveTargetParams, + GraphicalViewResolveTargetResult, +} from '../graphicalViewProtocol'; +import type {ExtensionLogger} from '../outputChannel'; + +export interface RecordedRenderRequest { + readonly params: GraphicalViewRenderParams; + readonly token: vscode.CancellationToken; + readonly deferred: Deferred; +} + +export class FakeGraphicalViewClient implements GraphicalViewRequestClient { + readonly requests: RecordedRenderRequest[] = []; + private readonly listeners = new Set<(available: boolean) => void>(); + + constructor(private available = true) {} + + isGraphicalViewAvailable(): boolean { + return this.available; + } + + onDidChangeGraphicalViewAvailability(listener: (available: boolean) => void): vscode.Disposable { + this.listeners.add(listener); + return new vscode.Disposable(() => this.listeners.delete(listener)); + } + + renderGraphicalView(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Promise { + const deferred = new Deferred(); + this.requests.push({params, token, deferred}); + return deferred.promise; + } + + resolveGraphicalViewTarget( + _params: GraphicalViewResolveTargetParams, + _token?: vscode.CancellationToken, + ): Promise { + return Promise.resolve({location: null, warning: 'temporarilyUnresolvable'}); + } + + setAvailable(available: boolean): void { + this.available = available; + for (const listener of [...this.listeners]) { + listener(available); + } + } +} + +export class FakeGraphicalViewPanel implements GraphicalViewPanelAdapter { + readonly deliveries: GraphicalViewDelivery[] = []; + revealCount = 0; + disposeCount = 0; + disposedListenerCount = 0; + private readonly disposeListeners = new Set<() => void>(); + private readonly visibilityListeners = new Set<(visible: boolean) => void>(); + private readonly messageListeners = new Set<(message: unknown) => void>(); + + constructor(public visible = true) {} + + reveal(): void { + this.revealCount += 1; + this.setVisible(true); + } + + deliver(delivery: GraphicalViewDelivery): void { + this.deliveries.push(delivery); + } + + onDidDispose(listener: () => void): vscode.Disposable { + this.disposeListeners.add(listener); + return this.listenerDisposable(this.disposeListeners, listener); + } + + onDidChangeVisibility(listener: (visible: boolean) => void): vscode.Disposable { + this.visibilityListeners.add(listener); + return this.listenerDisposable(this.visibilityListeners, listener); + } + + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable { + this.messageListeners.add(listener); + return this.listenerDisposable(this.messageListeners, listener); + } + + setVisible(visible: boolean): void { + if (this.visible === visible) { + return; + } + this.visible = visible; + for (const listener of [...this.visibilityListeners]) { + listener(visible); + } + } + + emitMessage(message: unknown): void { + for (const listener of [...this.messageListeners]) { + listener(message); + } + } + + dispose(): void { + if (this.disposeCount > 0) { + return; + } + this.disposeCount += 1; + for (const listener of [...this.disposeListeners]) { + listener(); + } + } + + private listenerDisposable(listeners: Set, listener: T): vscode.Disposable { + return new vscode.Disposable(() => { + if (listeners.delete(listener)) { + this.disposedListenerCount += 1; + } + }); + } +} + +export class FakeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + readonly panels: FakeGraphicalViewPanel[] = []; + + create(_sourceUri: string): FakeGraphicalViewPanel { + const panel = new FakeGraphicalViewPanel(); + this.panels.push(panel); + return panel; + } +} + +export function createGraphicalViewHarness(client = new FakeGraphicalViewClient()) { + const panels = new FakeGraphicalViewPanelFactory(); + const commands = new FakeCommands(); + const window = new FakeWindow(); + const workspace = new FakeWorkspace(); + const outputChannel = new FakeOutputChannel(); + const controller = new GraphicalViewController(client, panels, commands, window, workspace, outputChannel); + const context = {subscriptions: [] as vscode.Disposable[]}; + controller.register(context); + return {client, commands, context, controller, outputChannel, panels, window, workspace}; +} + +export function createGraphicalViewDocument(filePath: string, languageId = 'turtle'): GraphicalViewDocument { + return {languageId, uri: vscode.Uri.file(filePath)}; +} + +export function successfulResult(document: GraphicalViewDocument, suffix = '0123456789abcdef'): GraphicalViewRenderResult { + const id = `gv-header-${suffix}`; + return { + uri: document.uri.toString(), + svg: `Aspect`, + targets: [{id, kind: 'elementHeader', elementUrn: 'urn:samm:example.graphical:1.0.0#Aspect'}], + warnings: [], + }; +} + +export async function flushPromises(): Promise { + await new Promise(resolve => setImmediate(resolve)); +} + +class FakeCommands implements GraphicalViewCommands { + private readonly callbacks = new Map unknown>(); + + registerCommand(command: string, callback: () => unknown): vscode.Disposable { + this.callbacks.set(command, callback); + return new vscode.Disposable(() => this.callbacks.delete(command)); + } + + async execute(command: string): Promise { + return this.callbacks.get(command)?.(); + } +} + +class FakeWindow implements GraphicalViewWindow { + activeTextEditor: {document: GraphicalViewDocument} | undefined; + readonly warnings: string[] = []; + + showWarningMessage(message: string): Promise { + this.warnings.push(message); + return Promise.resolve(undefined); + } +} + +class FakeWorkspace implements GraphicalViewWorkspace { + readonly available = new Set(); + private saveListener: ((document: GraphicalViewDocument) => void) | undefined; + private availabilityListener: ((sourceUri: string, available: boolean) => void) | undefined; + + onDidSaveTextDocument(listener: (document: GraphicalViewDocument) => void): vscode.Disposable { + this.saveListener = listener; + return new vscode.Disposable(() => { + if (this.saveListener === listener) { + this.saveListener = undefined; + } + }); + } + + onDidChangeDocumentAvailability(listener: (sourceUri: string, available: boolean) => void): vscode.Disposable { + this.availabilityListener = listener; + return new vscode.Disposable(() => { + if (this.availabilityListener === listener) { + this.availabilityListener = undefined; + } + }); + } + + isDocumentAvailable(uri: string): boolean { + return this.available.has(uri); + } + + fireSave(document: GraphicalViewDocument): void { + this.saveListener?.(document); + } + + loseDocument(document: GraphicalViewDocument): void { + this.available.delete(document.uri.toString()); + this.availabilityListener?.(document.uri.toString(), false); + } + + closeSourceEditor(document: GraphicalViewDocument): void { + this.available.delete(document.uri.toString()); + this.availabilityListener?.(document.uri.toString(), false); + } +} + +class FakeOutputChannel implements ExtensionLogger { + readonly lines: string[] = []; + trace(message: string): void { + this.lines.push(`[trace] ${message}`); + } + info(message: string): void { + this.lines.push(`[info] ${message}`); + } + warn(message: string): void { + this.lines.push(`[warn] ${message}`); + } + error(message: string | Error): void { + this.lines.push(`[error] ${message instanceof Error ? message.message : message}`); + } +} + +class Deferred { + readonly promise: Promise; + private resolvePromise!: (value: T) => void; + private rejectPromise!: (reason: unknown) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + } + + resolve(value: T): void { + this.resolvePromise(value); + } + + reject(reason: unknown): void { + this.rejectPromise(reason); + } +} diff --git a/src/test/languageClientGraphicalView.test.ts b/src/test/languageClientGraphicalView.test.ts new file mode 100644 index 0000000..e3691ac --- /dev/null +++ b/src/test/languageClientGraphicalView.test.ts @@ -0,0 +1,141 @@ +/* + * 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 {Trace} from 'vscode-jsonrpc'; +import {State, StateChangeEvent} from 'vscode-languageclient/node'; +import { + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GraphicalViewRenderResult, + GraphicalViewResolveTargetResult, + isGraphicalViewRenderResult, +} from '../graphicalViewProtocol'; +import {LanguageClientAdapter, TurtleLanguageClient} from '../languageClient'; +import type {ExtensionLogger} from '../outputChannel'; + +suite('TurtleLanguageClient graphical-view integration', () => { + test('forwards exact typed methods and render cancellation token', async () => { + const adapter = new FakeLanguageClientAdapter(); + const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); + await client.connect(); + const cancellation = new vscode.CancellationTokenSource(); + + const render = await client.renderGraphicalView({uri: 'file:///model.ttl'}, cancellation.token); + const resolve = await client.resolveGraphicalViewTarget({ + sourceUri: 'file:///model.ttl', + elementUrn: 'urn:samm:example:1.0.0#Aspect', + }); + + assert.equal(render.uri, 'file:///model.ttl'); + assert.equal(resolve.warning, 'notFound'); + assert.equal(adapter.requests[0].method, GRAPHICAL_VIEW_RENDER_REQUEST); + assert.equal(adapter.requests[0].token, cancellation.token); + assert.equal(adapter.requests[1].method, GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST); + cancellation.dispose(); + }); + + test('exposes disconnect and reconnect transitions once per availability change', async () => { + const adapter = new FakeLanguageClientAdapter(); + const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); + const events: boolean[] = []; + const subscription = client.onDidChangeGraphicalViewAvailability(available => events.push(available)); + + await client.connect(); + adapter.transition(State.Starting); + adapter.transition(State.Stopped); + adapter.transition(State.Running); + assert.deepEqual(events, [true, false, true]); + subscription.dispose(); + }); + + test('accepts Task 2 render warning JSON with omitted or explicit-null svg', () => { + const omittedSvg = JSON.parse( + '{"uri":"file:///model.ttl","targets":[],"warnings":["timeout"]}', + ) as unknown; + const nullSvg = JSON.parse( + '{"uri":"file:///model.ttl","svg":null,"targets":[],"warnings":["modelTooLarge"]}', + ) as unknown; + const missingSuccessfulSvg = JSON.parse( + '{"uri":"file:///model.ttl","targets":[],"warnings":[]}', + ) as unknown; + + assert.equal(isGraphicalViewRenderResult(omittedSvg), true); + assert.equal(isGraphicalViewRenderResult(nullSvg), true); + assert.equal(isGraphicalViewRenderResult(missingSuccessfulSvg), false); + }); + + test('models omitted and explicit-null resolve result fields from Task 2 JSON', () => { + const omittedLocation = JSON.parse('{"warning":"notFound"}') as GraphicalViewResolveTargetResult; + const omittedWarning = JSON.parse( + '{"location":{"uri":"file:///model.ttl","range":{"start":{"line":1,"character":2},"end":{"line":1,"character":3}}}}', + ) as GraphicalViewResolveTargetResult; + const explicitNulls = JSON.parse('{"location":null,"warning":null}') as GraphicalViewResolveTargetResult; + + assert.equal(omittedLocation.location, undefined); + assert.equal(omittedLocation.warning, 'notFound'); + assert.equal(omittedWarning.warning, undefined); + assert.equal(omittedWarning.location?.uri, 'file:///model.ttl'); + assert.equal(explicitNulls.location, null); + assert.equal(explicitNulls.warning, null); + }); +}); + +class FakeLanguageClientAdapter implements LanguageClientAdapter { + state = State.Stopped; + readonly requests: Array<{method: string; params: unknown; token: vscode.CancellationToken | undefined}> = []; + private readonly listeners = new Set<(event: StateChangeEvent) => void>(); + + setTrace(_value: Trace): void {} + + start(): Promise { + this.transition(State.Running); + return Promise.resolve(); + } + + stop(): Promise { + this.transition(State.Stopped); + return Promise.resolve(); + } + + onDidChangeState(listener: (event: StateChangeEvent) => 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}); + if (method === GRAPHICAL_VIEW_RENDER_REQUEST) { + const result: GraphicalViewRenderResult = {uri: 'file:///model.ttl', svg: '', targets: [], warnings: []}; + return Promise.resolve(result as R); + } + const result: GraphicalViewResolveTargetResult = {location: null, warning: 'notFound'}; + return Promise.resolve(result as R); + } + + transition(newState: State): void { + const oldState = this.state; + this.state = newState; + for (const listener of [...this.listeners]) { + listener({oldState, newState}); + } + } +} + +class FakeLogger implements ExtensionLogger { + trace(_message: string): void {} + info(_message: string): void {} + warn(_message: string): void {} + error(_message: string | Error): void {} +} From 4438a2f2f9121d6e6eb7186b2c7f7dbffe572c60 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 27 Aug 2026 15:21:49 +0300 Subject: [PATCH 2/7] Enhance graphical view functionality and add webview assets --- package-lock.json | 17 + package.json | 6 +- src/extension.ts | 2 +- src/graphicalView.ts | 313 ++++++++++++++++-- src/graphicalViewPanel.ts | 101 ++++-- src/test/graphicalViewController.test.ts | 210 ++++++++++++ src/test/graphicalViewPanel.test.ts | 100 ++++++ src/test/graphicalViewTestHarness.ts | 43 ++- .../graphicalViewWebviewLifecycle.test.ts | 197 +++++++++++ src/webview/RobotoCondensed-NOTICE.txt | 19 ++ src/webview/RobotoCondensed-Regular.ttf | Bin 0 -> 140396 bytes src/webview/sanitizer-contract.js | 155 +++++++++ src/webview/webview.css | 95 ++++++ src/webview/webview.js | 229 +++++++++++++ 14 files changed, 1428 insertions(+), 59 deletions(-) create mode 100644 src/test/graphicalViewPanel.test.ts create mode 100644 src/test/graphicalViewWebviewLifecycle.test.ts create mode 100644 src/webview/RobotoCondensed-NOTICE.txt create mode 100644 src/webview/RobotoCondensed-Regular.ttf create mode 100644 src/webview/sanitizer-contract.js create mode 100644 src/webview/webview.css create mode 100644 src/webview/webview.js diff --git a/package-lock.json b/package-lock.json index 6a2d563..591143d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MPL-2.0", "dependencies": { + "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" @@ -561,6 +562,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/vscode": { "version": "1.120.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", @@ -1422,6 +1430,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 87ade76..749b751 100644 --- a/package.json +++ b/package.json @@ -159,14 +159,15 @@ "vscode:prepublish": "npm run build", "watch": "tsc -watch -p ./", "pretest": "npm run build && npm run lint", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node scripts/copy-webview-assets.mjs", "build-watch": "tsc -p tsconfig.json --watch", "prettier": "prettier --config .prettierrc --write './src/**/*{.ts,.js,.json}'", "test": "vscode-test --config .vscode-test.mjs", "test:prettier": "prettier --config .prettierrc --list-different './src/**/*{.ts,.js,.json}'", "test:coverage": "vscode-test --config .vscode-test.mjs --coverage --coverage-reporter text --coverage-reporter lcov", "lint": "eslint src --ext .ts", - "lint:fix": "eslint src --ext .ts --fix" + "lint:fix": "eslint src --ext .ts --fix", + "test:webview-assets": "node scripts/verify-webview-assets.mjs" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -179,6 +180,7 @@ "typescript-eslint": "^8.56.1" }, "dependencies": { + "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" diff --git a/src/extension.ts b/src/extension.ts index f396302..f573ad9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,7 +48,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { aspectValidationController.register(context); graphicalViewController = new GraphicalViewController( undefined, - new VscodeGraphicalViewPanelFactory(), + new VscodeGraphicalViewPanelFactory(context.extensionUri), vscode.commands, vscode.window, { diff --git a/src/graphicalView.ts b/src/graphicalView.ts index f412aa1..5c4c09e 100644 --- a/src/graphicalView.ts +++ b/src/graphicalView.ts @@ -14,6 +14,7 @@ import * as vscode from 'vscode'; import type {ExtensionLogger} from './outputChannel'; import { + GRAPHICAL_VIEW_MARKER_PATTERN, GraphicalViewRenderResult, GraphicalViewRenderWarning, GraphicalViewRequestClient, @@ -31,6 +32,7 @@ export type GraphicalViewStatus = | Readonly<{kind: 'disconnected'; message: string}>; export interface GraphicalViewAcceptedResult { + readonly version: number; readonly uri: string; readonly svg: string; readonly targets: readonly Readonly[]; @@ -39,10 +41,15 @@ export interface GraphicalViewAcceptedResult { } export type GraphicalViewDelivery = - | Readonly<{type: 'status'; uri: string; status: GraphicalViewStatus}> - | Readonly<{type: 'render'; uri: string; svg: string; warnings: readonly GraphicalViewRenderWarning[]}>; + | Readonly<{type: 'status'; status: GraphicalViewStatus}> + | Readonly<{type: 'render'; version: number; svg: string}>; -export type GraphicalViewPanelMessage = Readonly<{type: 'ready'}> | Readonly<{type: 'refresh'}> | Readonly<{type: 'navigate'; targetId: string}>; +export type GraphicalViewPanelMessage = + | Readonly<{type: 'ready'}> + | Readonly<{type: 'refresh'}> + | Readonly<{type: 'rendered'; version: number}> + | Readonly<{type: 'renderError'; version: number; reason: 'sanitizationFailed'}> + | Readonly<{type: 'navigate'; version: number; targetId: string}>; export interface GraphicalViewPanelAdapter extends vscode.Disposable { readonly visible: boolean; @@ -65,6 +72,7 @@ export interface GraphicalViewDocument { export interface GraphicalViewWindow { readonly activeTextEditor: {readonly document: GraphicalViewDocument} | undefined; showWarningMessage(message: string): Thenable; + showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable; } export interface GraphicalViewWorkspace { @@ -99,8 +107,16 @@ interface PanelState { visible: boolean; disposed: boolean; cancellation: vscode.CancellationTokenSource | undefined; + navigationCancellation: vscode.CancellationTokenSource | undefined; + nextDisplayVersion: number; status: GraphicalViewStatus; lastSuccess: GraphicalViewAcceptedResult | undefined; + pendingDelivery: PendingDelivery | undefined; +} + +interface PendingDelivery { + readonly accepted: GraphicalViewAcceptedResult; + readonly requestSequence: number; } const DISCONNECTED_STATUS: GraphicalViewStatus = Object.freeze({ @@ -166,8 +182,11 @@ export class GraphicalViewController implements vscode.Disposable { 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( @@ -272,7 +291,7 @@ export class GraphicalViewController implements vscode.Disposable { } state.visible = visible; if (visible) { - this.deliverCurrentState(state); + this.deliverStatus(state); } } @@ -290,8 +309,14 @@ export class GraphicalViewController implements vscode.Disposable { void this.requestRender(state, 'manual'); } return; + case 'rendered': + this.handleRendered(state, message.version); + return; + case 'renderError': + this.handleRenderError(state, message.version); + return; case 'navigate': - // Task 4 validates current-sidecar membership and resolves navigation. + void this.navigateToTarget(state, message); return; } } @@ -335,7 +360,7 @@ export class GraphicalViewController implements vscode.Disposable { } state.cancellation = undefined; cancellation.dispose(); - this.handleRenderResult(state, result); + this.handleRenderResult(state, result, sequence); } catch (error) { if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { return; @@ -346,7 +371,7 @@ export class GraphicalViewController implements vscode.Disposable { } } - private handleRenderResult(state: PanelState, result: unknown): void { + private handleRenderResult(state: PanelState, result: unknown, requestSequence: number): void { if (!isGraphicalViewRenderResult(result)) { this.setStale(state, 'invalidResponse', 'The language server returned an invalid graphical-view response.'); return; @@ -362,11 +387,127 @@ export class GraphicalViewController implements vscode.Disposable { return; } - const accepted = createAcceptedResult(result, result.svg); - state.lastSuccess = accepted; - state.status = Object.freeze({kind: 'ready', message: 'Graphical view is up to date.'}); - state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); - this.deliverStatus(state); + const accepted = createAcceptedResult(result, result.svg, ++state.nextDisplayVersion); + 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) { + this.cancelNavigation(state); + state.lastSuccess = pending.accepted; + state.pendingDelivery = undefined; + if (state.sequence === pending.requestSequence) { + this.setStatus(state, Object.freeze({kind: 'ready', message: 'Graphical view is up to date.'})); + } + return; + } + + // An acknowledgement for a rehydrated last-successful snapshot must not + // clear a newer stale/error status. + } + + private handleRenderError(state: PanelState, version: number): void { + if (state.pendingDelivery?.accepted.version === version) { + state.pendingDelivery = undefined; + } else if (state.lastSuccess?.version !== version) { + return; + } + this.outputChannel.warn('Graphical view rejected an SVG payload at the secure rendering boundary.'); + this.setStale( + state, + 'sanitizationFailed', + 'The new diagram could not be displayed safely. The last successful diagram is retained.', + ); + } + + private async navigateToTarget( + state: PanelState, + message: Readonly<{type: 'navigate'; version: number; targetId: string}>, + ): Promise { + const accepted = state.lastSuccess; + const target = accepted?.version === message.version ? accepted.targetById.get(message.targetId) : undefined; + if (!accepted || !target || target.kind !== 'elementHeader' || !GRAPHICAL_VIEW_MARKER_PATTERN.test(message.targetId)) { + return; + } + + const client = this.client; + if (!client?.isGraphicalViewAvailable()) { + await this.warnNavigation('The graphical target is temporarily unavailable because the language server is disconnected.'); + return; + } + + this.cancelNavigation(state); + const cancellation = this.createCancellationSource(); + state.navigationCancellation = cancellation; + try { + const response = await client.resolveGraphicalViewTarget( + {sourceUri: state.sourceUri, elementUrn: target.elementUrn}, + cancellation.token, + ); + if (!this.isCurrentNavigation(state, accepted, target, cancellation)) { + return; + } + state.navigationCancellation = undefined; + cancellation.dispose(); + + const resolved = validateResolveTargetResult(response); + if (resolved.kind === 'warning') { + await this.warnNavigation(resolveWarningMessage(resolved.warning)); + return; + } + if (resolved.kind === 'invalid') { + await this.warnNavigation('The language server returned an invalid graphical target location.'); + return; + } + + try { + const editor = await this.window.showTextDocument(resolved.uri, {preview: false}); + if (!this.isCurrentNavigationResult(state, accepted, target)) { + return; + } + const range = new vscode.Range( + resolved.start.line, + resolved.start.character, + resolved.end.line, + resolved.end.character, + ); + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + } catch (_error) { + await this.warnNavigation('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.warnNavigation('The graphical target is temporarily unavailable.'); + } + } + } + + private isCurrentNavigation( + state: PanelState, + accepted: GraphicalViewAcceptedResult, + target: Readonly, + cancellation: vscode.CancellationTokenSource, + ): boolean { + return state.navigationCancellation === cancellation && this.isCurrentNavigationResult(state, accepted, target); + } + + private isCurrentNavigationResult( + state: PanelState, + accepted: GraphicalViewAcceptedResult, + target: Readonly, + ): boolean { + return this.isCurrent(state) + && state.lastSuccess === accepted + && accepted.targetById.get(target.id) === target; + } + + private async warnNavigation(message: string): Promise { + await this.window.showWarningMessage(message); } private handleWarningResult(state: PanelState, warnings: readonly GraphicalViewRenderWarning[]): void { @@ -403,7 +544,9 @@ export class GraphicalViewController implements vscode.Disposable { } const detail = error instanceof Error ? error.message : String(error); - this.setStale(state, classifyFailure(detail), `Graphical rendering failed: ${detail}. The last successful diagram is retained.`); + const reason = classifyFailure(detail); + 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 { @@ -420,14 +563,14 @@ export class GraphicalViewController implements vscode.Disposable { private deliverCurrentState(state: PanelState): void { this.deliverStatus(state); - const accepted = state.lastSuccess; + const accepted = state.pendingDelivery?.accepted ?? state.lastSuccess; if (accepted) { - state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); + 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', uri: state.sourceUri, status: state.status})); + state.panel.deliver(Object.freeze({type: 'status', status: state.status})); } private invalidate(state: PanelState): void { @@ -435,6 +578,8 @@ export class GraphicalViewController implements vscode.Disposable { return; } this.cancelCurrent(state); + this.cancelNavigation(state); + state.pendingDelivery = undefined; state.sequence += 1; } @@ -447,6 +592,15 @@ export class GraphicalViewController implements vscode.Disposable { } } + private cancelNavigation(state: PanelState): void { + const cancellation = state.navigationCancellation; + state.navigationCancellation = undefined; + if (cancellation) { + cancellation.cancel(); + cancellation.dispose(); + } + } + private isCurrentRequest( state: PanelState, sourceUri: string, @@ -465,6 +619,7 @@ export class GraphicalViewController implements vscode.Disposable { return; } this.cancelCurrent(state); + this.cancelNavigation(state); state.sequence += 1; state.disposed = true; this.panels.delete(state.sourceUri); @@ -488,11 +643,11 @@ export class GraphicalViewController implements vscode.Disposable { } } -function createAcceptedResult(result: GraphicalViewRenderResult, svg: string): GraphicalViewAcceptedResult { +function createAcceptedResult(result: GraphicalViewRenderResult, svg: string, version: number): GraphicalViewAcceptedResult { const targets = Object.freeze(result.targets.map(target => Object.freeze({...target}))); const warnings = Object.freeze([...result.warnings]); const targetById = new ImmutableTargetMap(targets.map(target => [target.id, target])); - return Object.freeze({uri: result.uri, svg, targets, warnings, targetById}); + return Object.freeze({version, uri: result.uri, svg, targets, warnings, targetById}); } class ImmutableTargetMap implements ReadonlyMap { @@ -576,11 +731,129 @@ function isPanelMessage(value: unknown): value is GraphicalViewPanelMessage { if (!isRecord(value)) { return false; } - const keys = Object.keys(value); + const keys = Object.keys(value).sort(); if ((value.type === 'ready' || value.type === 'refresh') && keys.length === 1) { return true; } - return value.type === 'navigate' && keys.length === 2 && typeof value.targetId === 'string'; + if (value.type === 'rendered') { + return keys.length === 2 && keys[0] === 'type' && keys[1] === 'version' && isDisplayedVersion(value.version); + } + if (value.type === 'renderError') { + return keys.length === 3 + && keys[0] === 'reason' + && keys[1] === 'type' + && keys[2] === 'version' + && value.reason === 'sanitizationFailed' + && isDisplayedVersion(value.version); + } + return value.type === 'navigate' + && keys.length === 3 + && keys[0] === 'targetId' + && keys[1] === 'type' + && keys[2] === 'version' + && isDisplayedVersion(value.version) + && typeof value.targetId === 'string' + && GRAPHICAL_VIEW_MARKER_PATTERN.test(value.targetId); +} + +function isDisplayedVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +type ValidatedResolveTarget = + | Readonly<{kind: 'location'; uri: vscode.Uri; start: vscode.Position; end: vscode.Position}> + | Readonly<{kind: 'warning'; warning: GraphicalViewResolveWarning}> + | Readonly<{kind: 'invalid'}>; + +type GraphicalViewResolveWarning = 'notFound' | 'ambiguous' | 'unsupportedUri' | 'temporarilyUnresolvable'; + +const RESOLVE_WARNINGS: ReadonlySet = new Set([ + 'notFound', + 'ambiguous', + 'unsupportedUri', + 'temporarilyUnresolvable', +]); + +function validateResolveTargetResult(value: unknown): ValidatedResolveTarget { + if (!isRecord(value) || !hasOnlyKeys(value, ['location', 'warning'])) { + return {kind: 'invalid'}; + } + + const warning = value.warning; + if (warning !== undefined && warning !== null && (typeof warning !== 'string' || !RESOLVE_WARNINGS.has(warning))) { + return {kind: 'invalid'}; + } + const location = value.location; + if (location === undefined || location === null) { + return typeof warning === 'string' + ? {kind: 'warning', warning: warning as GraphicalViewResolveWarning} + : {kind: 'invalid'}; + } + if (warning !== undefined && warning !== null) { + return {kind: 'invalid'}; + } + if (!isRecord(location) || !hasExactKeys(location, ['range', 'uri']) || typeof location.uri !== 'string') { + return {kind: 'invalid'}; + } + const range = location.range; + if (!isRecord(range) || !hasExactKeys(range, ['end', 'start'])) { + return {kind: 'invalid'}; + } + const start = validatePosition(range.start); + const end = validatePosition(range.end); + if (!start || !end || start.isAfter(end)) { + return {kind: 'invalid'}; + } + + 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', warning: 'unsupportedUri'}; + } + return {kind: 'location', uri, start, end}; + } catch (_error) { + return {kind: 'invalid'}; + } +} + +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 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]); +} + +function resolveWarningMessage(warning: GraphicalViewResolveWarning): string { + switch (warning) { + case 'notFound': + return 'The graphical target no longer exists in the current model.'; + case 'ambiguous': + return 'The graphical target is ambiguous in the current model.'; + case 'unsupportedUri': + return 'The graphical target is not a local file and cannot be opened.'; + case 'temporarilyUnresolvable': + return 'The graphical target is temporarily unavailable. Fix any model syntax errors and try again.'; + } } function isMethodNotFound(error: unknown): boolean { diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts index 0bbda48..e04b411 100644 --- a/src/graphicalViewPanel.ts +++ b/src/graphicalViewPanel.ts @@ -16,24 +16,31 @@ import * as vscode from 'vscode'; import {GraphicalViewDelivery, GraphicalViewPanelAdapter, GraphicalViewPanelFactory} from './graphicalView'; const VIEW_TYPE = 'turtle.graphicalView'; +export const WEBVIEW_ASSET_DIRECTORY = Object.freeze(['out', 'webview'] as const); +export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['purify.min.js', 'sanitizer-contract.js', 'webview.js'] as const); export class VscodeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + constructor(private readonly extensionUri: vscode.Uri) {} + create(sourceUri: string): GraphicalViewPanelAdapter { 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, { - enableScripts: true, - enableForms: false, - enableCommandUris: false, - localResourceRoots: [], - }); - return new VscodeGraphicalViewPanel(panel); + 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 GraphicalViewPanelAdapter { - constructor(private readonly panel: vscode.WebviewPanel) { - panel.webview.html = createShellHtml(panel.webview); + constructor( + private readonly panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + ) { + panel.webview.html = createShellHtml(panel.webview, extensionUri); } get visible(): boolean { @@ -65,37 +72,65 @@ class VscodeGraphicalViewPanel implements GraphicalViewPanelAdapter { } } -function createShellHtml(webview: vscode.Webview): string { - const nonce = randomBytes(16).toString('hex'); +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 createShellHtml( + webview: Pick, + extensionUri: vscode.Uri, + nonce = randomBytes(18).toString('base64'), + testMode = false, +): string { + 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 - -

Preparing graphical view...

-

Diagram rendering is prepared. Secure SVG display is completed in Task 4.

- + +
+
+
+ + + `; } diff --git a/src/test/graphicalViewController.test.ts b/src/test/graphicalViewController.test.ts index 7604c56..9e72414 100644 --- a/src/test/graphicalViewController.test.ts +++ b/src/test/graphicalViewController.test.ts @@ -14,6 +14,7 @@ import * as assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {join} from 'node:path'; +import * as vscode from 'vscode'; import {OPEN_GRAPHICAL_VIEW_COMMAND} from '../graphicalView'; import {GraphicalViewRenderResult} from '../graphicalViewProtocol'; import { @@ -267,6 +268,215 @@ suite('GraphicalViewController', () => { harness.controller.dispose(); }); + test('commits a sidecar only after secure render confirmation and retains last-good state on render rejection', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/secure-render.ttl'); + await harness.controller.openGraphicalView(document); + const first = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(first); + await flushPromises(); + const retained = harness.controller.getPanelState(document.uri.toString())?.lastSuccess; + assert.equal(retained?.version, 1); + + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'failure'; + panel.emitMessage({type: 'refresh'}); + const rejected = successfulResult(document, 'bbbbbbbbbbbbbbbb'); + harness.client.requests[1].deferred.resolve(rejected); + await flushPromises(); + + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'stale'); + assert.equal(state?.lastSuccess, retained); + assert.equal(state?.lastSuccess?.targetById.has(first.targets[0].id), true); + assert.equal(state?.lastSuccess?.targetById.has(rejected.targets[0].id), false); + harness.controller.dispose(); + }); + + test('late acknowledgement for delivered A establishes its sidecar without overwriting loading state for B', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/delayed-render-ack-loading.ttl'); + await harness.controller.openGraphicalView(document); + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'none'; + + harness.client.requests[0].deferred.resolve(successfulResult(document, 'aaaaaaaaaaaaaaaa')); + await flushPromises(); + panel.emitMessage({type: 'refresh'}); + const loadingB = harness.controller.getPanelState(document.uri.toString())?.status; + + panel.emitMessage({type: 'rendered', version: 1}); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.lastSuccess?.version, 1); + assert.equal(state?.status, loadingB); + assert.equal(state?.status.kind, 'loading'); + harness.controller.dispose(); + }); + + test('late acknowledgement for delivered A establishes its sidecar without overwriting failed state for B', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/delayed-render-ack-stale.ttl'); + await harness.controller.openGraphicalView(document); + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'none'; + + harness.client.requests[0].deferred.resolve(successfulResult(document, 'aaaaaaaaaaaaaaaa')); + await flushPromises(); + panel.emitMessage({type: 'refresh'}); + harness.client.requests[1].deferred.reject(new Error('render B failed')); + await flushPromises(); + const failedB = harness.controller.getPanelState(document.uri.toString())?.status; + + panel.emitMessage({type: 'rendered', version: 1}); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.lastSuccess?.version, 1); + assert.equal(state?.status, failedB); + assert.equal(state?.status.kind, 'stale'); + harness.controller.dispose(); + }); + + test('rehydrates the current version through ready without an LSP render or shell reset', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/rehydrate.ttl'); + await harness.controller.openGraphicalView(document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const panel = harness.panels.panels[0]; + const renderCount = panel.deliveries.filter(delivery => delivery.type === 'render').length; + + panel.emitMessage({type: 'ready'}); + assert.equal(harness.client.requests.length, 1); + assert.equal(panel.deliveries.filter(delivery => delivery.type === 'render').length, renderCount + 1); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.version, 1); + + panel.renderOutcome = 'failure'; + panel.emitMessage({type: 'refresh'}); + harness.client.requests[1].deferred.resolve(successfulResult(document, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + + panel.renderOutcome = 'success'; + panel.emitMessage({type: 'ready'}); + assert.equal(harness.client.requests.length, 2); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.version, 1); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + harness.controller.dispose(); + }); + + test('resolves a current sidecar-backed marker and opens, selects, and reveals only its local file location', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/navigation-source.ttl'); + await harness.controller.openGraphicalView(document); + const result = successfulResult(document); + harness.client.requests[0].deferred.resolve(result); + await flushPromises(); + const targetUri = vscode.Uri.file('/tmp/navigation-target.ttl'); + harness.client.resolveResult = { + location: { + uri: targetUri.toString(), + range: {start: {line: 3, character: 4}, end: {line: 5, character: 6}}, + }, + }; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: result.targets[0].id}); + await flushPromises(); + await flushPromises(); + + assert.deepEqual(harness.client.resolveRequests[0].params, { + sourceUri: document.uri.toString(), + elementUrn: result.targets[0].elementUrn, + }); + assert.equal(harness.client.resolveRequests[0].token?.isCancellationRequested, false); + assert.equal(harness.window.openedEditors[0].uri.toString(), targetUri.toString()); + assert.equal(harness.window.openedEditors[0].options?.preview, false); + assert.deepEqual(harness.window.openedEditors[0].editor.selection.start, new vscode.Position(3, 4)); + assert.deepEqual(harness.window.openedEditors[0].editor.selection.end, new vscode.Position(5, 6)); + assert.deepEqual(harness.window.openedEditors[0].revealedRanges[0], new vscode.Range(3, 4, 5, 6)); + harness.controller.dispose(); + }); + + test('rejects malformed, stale, fake, extra-field, and non-sidecar navigation before LSP resolution', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/rejected-navigation.ttl'); + await harness.controller.openGraphicalView(document); + const first = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(first); + await flushPromises(); + + const rejectedMessages: unknown[] = [ + {type: 'navigate', targetId: first.targets[0].id}, + {type: 'navigate', version: 1, targetId: first.targets[0].id, uri: 'file:///tmp/evil.ttl'}, + {type: 'navigate', version: 1, targetId: 'gv-header-bbbbbbbbbbbbbbbb'}, + {type: 'navigate', version: 1, targetId: 'bad-marker'}, + {type: 'navigate', version: 1.5, targetId: first.targets[0].id}, + {type: 'navigate', version: '1', targetId: first.targets[0].id}, + {type: 'navigate', version: 1, targetId: first.targets[0].id, elementUrn: first.targets[0].elementUrn}, + ]; + for (const message of rejectedMessages) { + harness.panels.panels[0].emitMessage(message); + } + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const second = successfulResult(document, 'cccccccccccccccc'); + harness.client.requests[1].deferred.resolve(second); + await flushPromises(); + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: first.targets[0].id}); + assert.equal(harness.client.resolveRequests.length, 0); + assert.equal(harness.window.openedEditors.length, 0); + harness.controller.dispose(); + }); + + test('blocks warning, malformed, invalid-range, non-file, resolver-failure, and editor-open navigation paths', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/navigation-failures.ttl'); + await harness.controller.openGraphicalView(document); + const result = successfulResult(document); + harness.client.requests[0].deferred.resolve(result); + await flushPromises(); + const navigate = async () => { + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: result.targets[0].id}); + await flushPromises(); + await flushPromises(); + }; + + for (const warning of ['notFound', 'ambiguous', 'unsupportedUri', 'temporarilyUnresolvable'] as const) { + harness.client.resolveResult = {location: null, warning}; + await navigate(); + } + harness.client.resolveResult = { + location: { + uri: 'https://example.invalid/model.ttl', + range: {start: {line: 0, character: 0}, end: {line: 0, character: 1}}, + }, + }; + await navigate(); + harness.client.resolveResult = { + location: { + uri: vscode.Uri.file('/tmp/invalid-range.ttl').toString(), + range: {start: {line: 2, character: 0}, end: {line: 1, character: 0}}, + }, + }; + await navigate(); + harness.client.resolveResult = {location: null, warning: null}; + await navigate(); + harness.client.resolveFailure = new Error('hostile resolver detail'); + await navigate(); + harness.client.resolveFailure = undefined; + harness.client.resolveResult = { + location: { + uri: vscode.Uri.file('/tmp/open-failure.ttl').toString(), + range: {start: {line: 0, character: 0}, end: {line: 0, character: 1}}, + }, + }; + harness.window.showTextDocumentFailure = new Error('hostile editor detail'); + await navigate(); + + assert.equal(harness.window.openedEditors.length, 0); + assert.equal(harness.window.warnings.length, 9); + assert.ok(harness.window.warnings.every(message => !message.includes('hostile'))); + harness.controller.dispose(); + }); + test('detects MethodNotFound by code and recovers compatibility state after client replacement', async () => { const harness = createGraphicalViewHarness(); const document = track(harness, '/tmp/unsupported.ttl'); diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts new file mode 100644 index 0000000..f2c8f98 --- /dev/null +++ b/src/test/graphicalViewPanel.test.ts @@ -0,0 +1,100 @@ +/* + * 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, createShellHtml, 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 = createShellHtml(webview, extensionUri, 'first-nonce'); + const second = createShellHtml(webview, extensionUri, 'second-nonce'); + + const expectedCsp = + "default-src 'none'; script-src 'nonce-first-nonce'; 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.includes('second-nonce'), false); + assert.equal(second.includes('second-nonce'), true); + assert.equal((first.match(/'}); + await waitFor(() => hasMessage(messages, 'renderError', 3), 'fail-closed hostile render'); } finally { subscription.dispose(); panel.dispose(); @@ -151,11 +77,11 @@ function graphperSvg(marker: string): string { `; } -function wrappedSeeSvg(firstMarker: string, continuationMarker: string): string { +function multilingualSvg(germanMarker: string, englishMarker: string): string { return ` -see: https://example.test/reference/with/a/long/path, -urn:irdi:0173:1:02:AAO677:003 +description [de]: Beschreibung +description [en]: Description `; } @@ -171,21 +97,6 @@ async function waitFor(probe: () => boolean, description: string, timeoutMs = 20 throw new Error(`Timed out waiting for ${description}`); } -async function waitForMessage( - messages: readonly unknown[], - predicate: (message: unknown) => message is T, - description: string, - skip = 0, -): Promise { - let result: T | undefined; - await waitFor(() => { - const matches = messages.filter(predicate); - result = matches.at(skip); - return result !== undefined; - }, description); - return result as T; -} - function countMessages(messages: readonly unknown[], type: string): number { return messages.filter(message => isRecord(message) && message.type === type).length; } @@ -194,38 +105,6 @@ function hasMessage(messages: readonly unknown[], type: string, version: number) return messages.some(message => isRecord(message) && message.type === type && message.version === version); } -function navigationMessages(messages: readonly unknown[]): unknown[] { - return messages.filter(message => isRecord(message) && message.type === 'navigate'); -} - -function isTestState( - message: unknown, -): message is {type: 'testState'; state: {schemaVersion: number; zoom: number; scrollLeft: number; scrollTop: number}} { - return ( - isRecord(message) && - message.type === 'testState' && - isRecord(message.state) && - typeof message.state.schemaVersion === 'number' && - typeof message.state.zoom === 'number' && - typeof message.state.scrollLeft === 'number' && - typeof message.state.scrollTop === 'number' - ); -} - -function isViewportState( - message: unknown, - zoom: number, - scrollLeft: number, - scrollTop: number, -): message is {type: 'testState'; state: {schemaVersion: number; zoom: number; scrollLeft: number; scrollTop: number}} { - return ( - isTestState(message) && - message.state.zoom === zoom && - message.state.scrollLeft === scrollLeft && - message.state.scrollTop === scrollTop - ); -} - 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 index d9b042c..4b9273c 100644 --- a/src/test/languageClientGraphicalView.test.ts +++ b/src/test/languageClientGraphicalView.test.ts @@ -1,151 +1,108 @@ /* * 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 {Trace} from 'vscode-jsonrpc'; -import {State, StateChangeEvent} from 'vscode-languageclient/node'; +import {LspGraphicalViewClient, GraphicalViewRequestTransport} from '../graphicalViewClient'; import { GRAPHICAL_VIEW_RENDER_REQUEST, GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, GraphicalViewRenderResult, GraphicalViewResolveTargetResult, - isGraphicalViewRenderResult, } from '../graphicalViewProtocol'; -import {LanguageClientAdapter, TurtleLanguageClient} from '../languageClient'; -import type {ExtensionLogger} from '../outputChannel'; -suite('TurtleLanguageClient graphical-view integration', () => { - test('forwards exact typed methods and render cancellation token', async () => { - const adapter = new FakeLanguageClientAdapter(); - const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); - await client.connect(); +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(); - const render = await client.renderGraphicalView({uri: 'file:///model.ttl'}, cancellation.token); - const resolve = await client.resolveGraphicalViewTarget({ - sourceUri: 'file:///model.ttl', - elementUrn: 'urn:samm:example:1.0.0#Aspect', - }); - const resolveAttribute = await client.resolveGraphicalViewAttributeTarget({ - 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', - }); - - assert.equal(render.uri, 'file:///model.ttl'); - assert.equal(resolve.warning, 'notFound'); - assert.equal(resolveAttribute.warning, 'notFound'); - assert.equal(adapter.requests[0].method, GRAPHICAL_VIEW_RENDER_REQUEST); - assert.equal(adapter.requests[0].token, cancellation.token); - assert.equal(adapter.requests[1].method, GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST); - assert.equal(adapter.requests[2].method, GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST); + 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 disconnect and reconnect transitions once per availability change', async () => { - const adapter = new FakeLanguageClientAdapter(); - const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); + test('exposes transport availability without a second lifecycle state', () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); const events: boolean[] = []; - const subscription = client.onDidChangeGraphicalViewAvailability(available => events.push(available)); + const subscription = client.onDidChangeAvailability(available => events.push(available)); - await client.connect(); - adapter.transition(State.Starting); - adapter.transition(State.Stopped); - adapter.transition(State.Running); - assert.deepEqual(events, [true, false, true]); + transport.setAvailable(false); + transport.setAvailable(true); + assert.equal(client.isAvailable(), true); + assert.deepEqual(events, [false, true]); subscription.dispose(); }); - test('accepts Task 2 render warning JSON with omitted or explicit-null svg', () => { - const omittedSvg = JSON.parse( - '{"uri":"file:///model.ttl","targets":[],"warnings":["timeout"]}', - ) as unknown; - const nullSvg = JSON.parse( - '{"uri":"file:///model.ttl","svg":null,"targets":[],"warnings":["modelTooLarge"]}', - ) as unknown; - const missingSuccessfulSvg = JSON.parse( - '{"uri":"file:///model.ttl","targets":[],"warnings":[]}', - ) as unknown; - - assert.equal(isGraphicalViewRenderResult(omittedSvg), true); - assert.equal(isGraphicalViewRenderResult(nullSvg), true); - assert.equal(isGraphicalViewRenderResult(missingSuccessfulSvg), false); - }); - - test('models omitted and explicit-null resolve result fields from Task 2 JSON', () => { - const omittedLocation = JSON.parse('{"warning":"notFound"}') as GraphicalViewResolveTargetResult; - const omittedWarning = JSON.parse( - '{"location":{"uri":"file:///model.ttl","range":{"start":{"line":1,"character":2},"end":{"line":1,"character":3}}}}', - ) as GraphicalViewResolveTargetResult; - const explicitNulls = JSON.parse('{"location":null,"warning":null}') as GraphicalViewResolveTargetResult; - - assert.equal(omittedLocation.location, undefined); - assert.equal(omittedLocation.warning, 'notFound'); - assert.equal(omittedWarning.warning, undefined); - assert.equal(omittedWarning.location?.uri, 'file:///model.ttl'); - assert.equal(explicitNulls.location, null); - assert.equal(explicitNulls.warning, null); + 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 FakeLanguageClientAdapter implements LanguageClientAdapter { - state = State.Stopped; +class FakeTransport implements GraphicalViewRequestTransport { readonly requests: Array<{method: string; params: unknown; token: vscode.CancellationToken | undefined}> = []; - private readonly listeners = new Set<(event: StateChangeEvent) => void>(); + 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>(); - setTrace(_value: Trace): void {} - - start(): Promise { - this.transition(State.Running); - return Promise.resolve(); - } - - stop(): Promise { - this.transition(State.Stopped); - return Promise.resolve(); + isAvailable(): boolean { + return this.available; } - onDidChangeState(listener: (event: StateChangeEvent) => void): vscode.Disposable { + 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}); - if (method === GRAPHICAL_VIEW_RENDER_REQUEST) { - const result: GraphicalViewRenderResult = {uri: 'file:///model.ttl', svg: '', targets: [], warnings: []}; - return Promise.resolve(result as R); - } - const result: GraphicalViewResolveTargetResult = {location: null, warning: 'notFound'}; - return Promise.resolve(result as R); + return Promise.resolve((method === GRAPHICAL_VIEW_RENDER_REQUEST ? this.renderResult : this.resolveResult) as R); } - transition(newState: State): void { - const oldState = this.state; - this.state = newState; + setAvailable(available: boolean): void { + this.available = available; for (const listener of [...this.listeners]) { - listener({oldState, newState}); + listener(available); } } } - -class FakeLogger implements ExtensionLogger { - trace(_message: string): void {} - info(_message: string): void {} - warn(_message: string): void {} - error(_message: string | Error): void {} -} diff --git a/src/webview/webview.js b/src/webview/webview.js index cf7d599..8005907 100644 --- a/src/webview/webview.js +++ b/src/webview/webview.js @@ -22,7 +22,6 @@ const diagram = document.querySelector('#diagram'); const status = document.querySelector('#status'); const zoomValue = document.querySelector('#zoom-value'); - const testMode = document.documentElement.dataset.testMode === 'true'; let currentVersion = null; let currentSvg = null; let baseWidth = 0; @@ -101,9 +100,6 @@ if (notifyRendered) { vscode.postMessage({type: 'rendered', version}); } - if (testMode) { - vscode.postMessage({type: 'testState', state}); - } }); } @@ -173,12 +169,6 @@ } catch (error) { status.textContent = 'The new diagram could not be displayed safely. The last valid diagram is retained.'; vscode.postMessage({type: 'renderError', version: message.version, reason: 'sanitizationFailed'}); - if (testMode) { - vscode.postMessage({ - type: 'testRenderDiagnostic', - message: error instanceof Error ? error.message : 'Unknown render failure', - }); - } } } @@ -237,22 +227,6 @@ status.textContent = message.status.message; return; } - if (testMode && isExactMessage(message, ['scrollLeft', 'scrollTop', 'type', 'zoom']) && message.type === 'testSetViewport') { - state = normalizeState({schemaVersion: 1, zoom: message.zoom, scrollLeft: message.scrollLeft, scrollTop: message.scrollTop}); - applyZoom(); - restoreViewport(currentVersion, false); - return; - } - if (testMode && isExactMessage(message, ['targetId', 'type']) && message.type === 'testClickMarker') { - const marker = diagram.querySelector(`g[id="${CSS.escape(message.targetId)}"]`); - const hitTarget = marker?.querySelector('polygon') ?? marker; - hitTarget?.dispatchEvent(new MouseEvent('click', {bubbles: true})); - return; - } - if (testMode && isExactMessage(message, ['key', 'targetId', 'type']) && message.type === 'testKeyMarker') { - const marker = diagram.querySelector(`g[id="${CSS.escape(message.targetId)}"]`); - marker?.dispatchEvent(new KeyboardEvent('keydown', {key: message.key, bubbles: true})); - } }); updateZoomLabel(); From 9555968026afded5f39fc1fdf7dac5e5d9303352 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Wed, 2 Sep 2026 20:02:31 +0300 Subject: [PATCH 6/7] refactoring --- .vscodeignore | 1 + README.md | 10 +++++++ scripts/copy-webview-assets.mjs | 26 +++++++++++++++++++ scripts/verify-webview-assets.mjs | 34 ++++++++++++++++++++++++ scripts/webview-assets.mjs | 43 +++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 scripts/copy-webview-assets.mjs create mode 100644 scripts/verify-webview-assets.mjs create mode 100644 scripts/webview-assets.mjs diff --git a/.vscodeignore b/.vscodeignore index 3d26b57..b015481 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,5 +1,6 @@ .vscode/** .vscode-test/** +scripts/ src/** out/test/** .gitignore diff --git a/README.md b/README.md index 5d8fb00..65389b3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,16 @@ Use the command `Turtle: Select SAMM CLI Executable` to choose either: - Manual validation command: - `Turtle: Validate document now` +## Graphical View + +Use `Turtle: Open Graphical View` (`turtle.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. + ## Run The Server And Extension Together 1. In this extension project, install dependencies with `npm install`. diff --git a/scripts/copy-webview-assets.mjs b/scripts/copy-webview-assets.mjs new file mode 100644 index 0000000..8be932c --- /dev/null +++ b/scripts/copy-webview-assets.mjs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {copyFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +for (const asset of webviewAssets) { + if (asset.expectedSha256 && sha256(asset.source) !== asset.expectedSha256) { + throw new Error(`Pinned webview asset hash mismatch: ${asset.destination}`); + } +} + +rmSync(outputDirectory, {recursive: true, force: true}); +mkdirSync(outputDirectory, {recursive: true}); +for (const asset of webviewAssets) { + copyFileSync(asset.source, join(outputDirectory, asset.destination)); +} + +const actual = readdirSync(outputDirectory).sort(); +const expected = webviewAssets.map(asset => asset.destination).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Webview asset copy produced an unexpected inventory.'); +} diff --git a/scripts/verify-webview-assets.mjs b/scripts/verify-webview-assets.mjs new file mode 100644 index 0000000..db1355c --- /dev/null +++ b/scripts/verify-webview-assets.mjs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {readFileSync, readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; +import {extensionRoot, outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +const packageJson = JSON.parse(readFileSync(join(extensionRoot, 'package.json'), 'utf8')); +if (packageJson.dependencies?.dompurify !== '3.4.13') { + throw new Error('DOMPurify must remain pinned exactly to 3.4.13.'); +} + +const actual = readdirSync(outputDirectory).sort(); +const expected = webviewAssets.map(asset => asset.destination).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected out/webview inventory: ${actual.join(', ')}`); +} + +for (const asset of webviewAssets) { + const output = join(outputDirectory, asset.destination); + if (!statSync(output).isFile() || statSync(output).size === 0) { + throw new Error(`Missing or empty webview asset: ${asset.destination}`); + } + if (sha256(asset.source) !== sha256(output)) { + throw new Error(`Copied webview asset differs from its source: ${asset.destination}`); + } + if (asset.expectedSha256 && sha256(output) !== asset.expectedSha256) { + throw new Error(`Pinned webview asset hash mismatch: ${asset.destination}`); + } +} + +process.stdout.write(`Verified ${webviewAssets.length} deterministic webview assets.\n`); diff --git a/scripts/webview-assets.mjs b/scripts/webview-assets.mjs new file mode 100644 index 0000000..f2b8b7a --- /dev/null +++ b/scripts/webview-assets.mjs @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {createHash} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const outputDirectory = resolve(extensionRoot, 'out', 'webview'); + +export const webviewAssets = Object.freeze([ + asset('node_modules/dompurify/LICENSE', 'DOMPurify-LICENSE-Apache-2.0.txt'), + asset('node_modules/dompurify/LICENSE-MPL', 'DOMPurify-LICENSE-MPL-2.0.txt'), + asset( + 'node_modules/dompurify/dist/purify.min.js', + 'purify.min.js', + '9ab3d44d73c3e3947f9ab72e0f0bc15c7f1931d60b365ba261fc85fe59013c56', + ), + asset('src/webview/RobotoCondensed-NOTICE.txt', 'RobotoCondensed-NOTICE.txt'), + asset( + 'src/webview/RobotoCondensed-Regular.ttf', + 'RobotoCondensed-Regular.ttf', + '4a7c36df4318fee50a8159c3a0ebde4572abab65447ae4a651c2fe87212302b5', + ), + asset('src/webview/sanitizer-contract.js', 'sanitizer-contract.js'), + asset('src/webview/webview.css', 'webview.css'), + asset('src/webview/webview.js', 'webview.js'), +]); + +export function sha256(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function asset(source, destination, expectedSha256) { + return Object.freeze({ + source: resolve(extensionRoot, source), + destination, + ...(expectedSha256 ? {expectedSha256} : {}), + }); +} From 0d3691daf6ae1c328f1b60dac17e2a20b844afc7 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 3 Sep 2026 14:35:55 +0300 Subject: [PATCH 7/7] resolve merge conflicts --- README.md | 2 +- package.json | 2 +- src/graphicalView.ts | 2 +- src/graphicalViewPanel.ts | 2 +- src/test/graphicalViewManifest.test.ts | 2 +- src/test/graphicalViewTestHarness.ts | 2 +- src/test/graphicalViewWebviewLifecycle.test.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a6d1a0f..f0b1757 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Use the command `Semantic Models: Select SAMM CLI Executable` to choose either: ## Graphical View -Use `Turtle: Open Graphical View` (`turtle.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. +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. diff --git a/package.json b/package.json index 12c8870..3465f8c 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,7 @@ { "id": "select-samm-cli", "title": "Select a SAMM CLI Executable", - "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)", + "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:semantic-models.selectSammCliExecutable)", "media": { "image": "media/walkthrough_select_samm_cli.png", "altText": "Example of selecting a SAMM CLI executable" diff --git a/src/graphicalView.ts b/src/graphicalView.ts index 744b70b..753f8b1 100644 --- a/src/graphicalView.ts +++ b/src/graphicalView.ts @@ -24,7 +24,7 @@ import { import type {GraphicalViewRenderResult, GraphicalViewRenderWarning, GraphicalViewTarget} from './graphicalViewProtocol'; import {AcceptedGraphicalViewResult, acceptGraphicalViewResult, isGraphicalViewRenderResult} from './graphicalViewResult'; -export const OPEN_GRAPHICAL_VIEW_COMMAND = 'turtle.openGraphicalView'; +export const OPEN_GRAPHICAL_VIEW_COMMAND = 'semantic-models.openGraphicalView'; export interface GraphicalViewDocument { readonly languageId: string; diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts index b2d4959..9af26c5 100644 --- a/src/graphicalViewPanel.ts +++ b/src/graphicalViewPanel.ts @@ -15,7 +15,7 @@ import {randomBytes} from 'node:crypto'; import * as vscode from 'vscode'; import {GRAPHICAL_VIEW_MARKER_PATTERN} from './graphicalViewProtocol'; -const VIEW_TYPE = 'turtle.graphicalView'; +const VIEW_TYPE = 'semantic-models.graphicalView'; export const WEBVIEW_ASSET_DIRECTORY = Object.freeze(['out', 'webview'] as const); export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['purify.min.js', 'sanitizer-contract.js', 'webview.js'] as const); diff --git a/src/test/graphicalViewManifest.test.ts b/src/test/graphicalViewManifest.test.ts index 1245c58..cc6dc60 100644 --- a/src/test/graphicalViewManifest.test.ts +++ b/src/test/graphicalViewManifest.test.ts @@ -52,7 +52,7 @@ suite('Graphical View editor-title manifest contract', () => { assert.deepEqual(commands[0], { command: OPEN_GRAPHICAL_VIEW_COMMAND, title: 'Open Graphical View', - category: 'Turtle', + category: 'Semantic Models', enablement: 'editorLangId == turtle', icon: {light: ICON_PATH, dark: ICON_PATH}, }); diff --git a/src/test/graphicalViewTestHarness.ts b/src/test/graphicalViewTestHarness.ts index c13157a..1333d04 100644 --- a/src/test/graphicalViewTestHarness.ts +++ b/src/test/graphicalViewTestHarness.ts @@ -224,7 +224,7 @@ export async function openGraphicalView( document: GraphicalViewDocument, ): Promise { harness.window.activeTextEditor = {document}; - await harness.commands.execute('turtle.openGraphicalView'); + await harness.commands.execute('semantic-models.openGraphicalView'); } export function lastStatus(panel: FakeGraphicalViewPanel): GraphicalViewStatus | undefined { diff --git a/src/test/graphicalViewWebviewLifecycle.test.ts b/src/test/graphicalViewWebviewLifecycle.test.ts index d3563ee..5414746 100644 --- a/src/test/graphicalViewWebviewLifecycle.test.ts +++ b/src/test/graphicalViewWebviewLifecycle.test.ts @@ -19,7 +19,7 @@ suite('GraphicalView real webview lifecycle', function () { this.timeout(30_000); const extensionUri = vscode.Uri.file(join(__dirname, '..', '..')); const panel = vscode.window.createWebviewPanel( - 'turtle.graphicalViewLifecycleTest', + 'semantic-models.graphicalViewLifecycleTest', 'Graphical View Lifecycle Test', vscode.ViewColumn.One, createGraphicalViewPanelOptions(extensionUri),