Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 2 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.vscode/**
.vscode-test/**
scripts/
src/**
out/test/**
.gitignore
.yarnrc
.prettierrc
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ Use the command `Semantic Models: Select SAMM CLI Executable` to choose either:
- Manual validation command:
- `Semantic Models: Validate Document Now`

## Graphical View

Use `Semantic Models: Open Graphical View` (`semantic-models.openGraphicalView`) from the Turtle editor-title button, the editor context menu, or the Command Palette to open a read-only SVG snapshot in a separate panel. One panel is reused per Turtle document.

The view renders on initial open, manual Refresh, and Save while the main Turtle document is visible. It includes unsaved text from the main document but uses the persisted versions of imported files. Typing, import saves, hidden-document saves, and revealing an existing panel do not trigger a render.

Element headers can navigate to definitions in local files. Eligible attribute rows whose owner is a named model element navigate as a whole row; language-qualified and wrapped rows retain their source mapping, and aggregated rows navigate to the start of the corresponding predicate. Rows with anonymous or otherwise non-deterministic owners remain inert. Navigation to remote URIs and arbitrary referenced values is not supported.

If rendering fails, the last successful diagram remains visible with an error or warning. Rendering is limited to 1,000 boxes and a 30-second request timeout. The graphical view is not an editor, does not update live while typing, and does not claim visual parity with the Aspect Model Editor.

## Running the Server and Extension Together

1. In this extension project, install the dependencies using `npm install`.
Expand Down
Binary file added media/aspect-model-editor-targetsize-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 27 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@
"command": "semantic-models.selectSammCliExecutable",
"title": "Select SAMM CLI Executable",
"category": "Semantic Models"
},
{
"command": "semantic-models.openGraphicalView",
"title": "Open Graphical View",
"category": "Semantic Models",
"enablement": "editorLangId == turtle",
"icon": {
"light": "media/aspect-model-editor-targetsize-192.png",
"dark": "media/aspect-model-editor-targetsize-192.png"
}
}
],
"languages": [
Expand Down Expand Up @@ -150,11 +160,23 @@
}
},
"menus": {
"editor/title": [
{
"command": "semantic-models.openGraphicalView",
"when": "resourceLangId == turtle",
"group": "navigation@10"
}
],
"editor/context": [
{
"command": "semantic-models.validateDocumentNow",
"when": "resourceLangId == turtle",
"group": "1_modification"
},
{
"command": "semantic-models.openGraphicalView",
"when": "resourceLangId == turtle",
"group": "1_modification@2"
}
]
},
Expand All @@ -167,7 +189,7 @@
{
"id": "select-samm-cli",
"title": "Select a SAMM CLI Executable",
"description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)",
"description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:semantic-models.selectSammCliExecutable)",
"media": {
"image": "media/walkthrough_select_samm_cli.png",
"altText": "Example of selecting a SAMM CLI executable"
Expand All @@ -190,14 +212,15 @@
"vscode:prepublish": "npm run build",
"watch": "tsc -watch -p ./",
"pretest": "npm run build && npm run lint",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && node scripts/copy-webview-assets.mjs",
"build-watch": "tsc -p tsconfig.json --watch",
"prettier": "prettier --config .prettierrc --write './src/**/*{.ts,.js,.json}'",
"test": "vscode-test --config .vscode-test.mjs",
"test:prettier": "prettier --config .prettierrc --list-different './src/**/*{.ts,.js,.json}'",
"test:coverage": "vscode-test --config .vscode-test.mjs --coverage --coverage-reporter text --coverage-reporter lcov",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix"
"lint:fix": "eslint src --ext .ts --fix",
"test:webview-assets": "node scripts/verify-webview-assets.mjs"
},
"devDependencies": {
"@types/jest": "^30.0.0",
Expand All @@ -210,6 +233,7 @@
"typescript-eslint": "^8.56.1"
},
"dependencies": {
"dompurify": "3.4.13",
"extract-zip": "^2.0.1",
"tar": "^7.5.15",
"vscode-languageclient": "^9.0.1"
Expand Down
26 changes: 26 additions & 0 deletions scripts/copy-webview-assets.mjs
Original file line number Diff line number Diff line change
@@ -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.');
}
34 changes: 34 additions & 0 deletions scripts/verify-webview-assets.mjs
Original file line number Diff line number Diff line change
@@ -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`);
43 changes: 43 additions & 0 deletions scripts/webview-assets.mjs
Original file line number Diff line number Diff line change
@@ -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} : {}),
});
}
2 changes: 1 addition & 1 deletion src/aspectValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface DiagnosticReport {
}

export interface RequestClient {
sendRequest<R>(method: string, params?: unknown): Thenable<R>;
sendRequest<R>(method: string, params?: unknown, token?: vscode.CancellationToken): Thenable<R>;
}

export interface ValidationWindow {
Expand Down
41 changes: 41 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { TurtleExtensionSettings } from './settings';
import { TurtleLanguageClient } from './languageClient';
import { GitHubRepositoryValidator } from './githubRepositoryValidator';
import type { ExtensionLogger } from './outputChannel';
import { GraphicalViewController } from './graphicalView';
import {LspGraphicalViewClient} from './graphicalViewClient';
import { VscodeGraphicalViewPanelFactory } from './graphicalViewPanel';

const SELECT_EXECUTABLE_COMMAND = 'semantic-models.selectSammCliExecutable';
const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable';
Expand All @@ -29,6 +32,7 @@ let settings: TurtleExtensionSettings;
let languageServer: TurtleLanguageServer | undefined;
let languageClient: TurtleLanguageClient;
let aspectValidationController: AspectValidationController;
let graphicalViewController: GraphicalViewController;
let sammCliDownloader: SammCliDownloader;
let gitHubRepositoryValidator: GitHubRepositoryValidator;

Expand All @@ -48,6 +52,40 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel());
aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel);
aspectValidationController.register(context);
graphicalViewController = new GraphicalViewController(
undefined,
new VscodeGraphicalViewPanelFactory(context.extensionUri),
vscode.commands,
vscode.window,
{
onDidSaveTextDocument: listener => vscode.workspace.onDidSaveTextDocument(listener),
onDidChangeDocumentAvailability: listener =>
vscode.Disposable.from(
vscode.workspace.onDidOpenTextDocument(document => listener(document.uri.toString(), true)),
vscode.workspace.onDidCloseTextDocument(document => listener(document.uri.toString(), false)),
vscode.window.tabGroups.onDidChangeTabs(event => {
for (const tab of event.closed) {
if (tab.input instanceof vscode.TabInputText) {
const sourceUri = tab.input.uri.toString();
const remainsOpen = vscode.window.tabGroups.all.some(group =>
group.tabs.some(
candidate =>
candidate.input instanceof vscode.TabInputText &&
candidate.input.uri.toString() === sourceUri,
),
);
if (!remainsOpen) {
listener(sourceUri, false);
}
}
}
}),
),
isDocumentAvailable: uri => vscode.workspace.textDocuments.some(document => document.uri.toString() === uri),
},
outputChannel,
);
graphicalViewController.register(context);

context.subscriptions.push(
vscode.languages.setLanguageConfiguration('turtle', {
Expand Down Expand Up @@ -153,6 +191,7 @@ async function restartLanguageServices(reason: string): Promise<void> {
outputChannel.info(`Restarting language services (${reason}).`);

aspectValidationController.setClient(createUnavailableClient());
graphicalViewController.setClient(undefined);
await languageClient.disconnect();
await stopLanguageServer();

Expand All @@ -165,6 +204,7 @@ async function restartLanguageServices(reason: string): Promise<void> {
} catch (error) {
await stopLanguageServer().catch(() => undefined);
aspectValidationController.setClient(createUnavailableClient());
graphicalViewController.setClient(undefined);

throw error;
}
Expand All @@ -173,6 +213,7 @@ async function restartLanguageServices(reason: string): Promise<void> {
await nextClient.connect();
languageClient = nextClient;
aspectValidationController.setClient(nextClient);
graphicalViewController.setClient(new LspGraphicalViewClient(nextClient));
}

type SammCliQuickPickItem = vscode.QuickPickItem & {
Expand Down
Loading
Loading