diff --git a/.eslintrc.json b/.eslintrc.json index edc381d..520c948 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,8 +1,11 @@ { "root": true, - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended-type-checked"], "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], + "parserOptions": { + "project": true + }, "rules": { "@typescript-eslint/consistent-type-imports": [ "warn", @@ -11,6 +14,7 @@ "fixStyle": "inline-type-imports" } ], - "@typescript-eslint/no-unused-vars": ["warn", {"argsIgnorePattern": "^_"}] + "@typescript-eslint/no-unused-vars": ["warn", {"argsIgnorePattern": "^_"}], + "@typescript-eslint/require-await": "off" } } diff --git a/.prettierignore b/.prettierignore index ca87251..849ddff 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1 @@ dist/ - -# TODO: un-ignore this file once we find a solution to the jquery import reordering situation -views/digitaljs/src/main.ts diff --git a/package-lock.json b/package-lock.json index ab9945c..364fb1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "process": "^0.11.10", "ts-loader": "^9.5.1", "ts-node": "^10.9.1", - "typescript": "^5.3.2", + "typescript": "5.2.2", "webpack": "^5.89.0", "webpack-cli": "^5.1.4" }, @@ -5046,9 +5046,9 @@ } }, "node_modules/typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 9e0fe45..4e18509 100644 --- a/package.json +++ b/package.json @@ -280,7 +280,7 @@ "compile-web": "npm run build-views && tsc && webpack", "package-web": "npm run build-views && tsc && webpack --mode production --devtool hidden-source-map", "vscode:prepublish": "npm run package-web", - "lint": "eslint ./src ./workers/src --ext ts", + "lint": "eslint ./src --ext ts", "test": "vscode-test-web --browserType=chromium --extensionDevelopmentPath=. --extensionTestsPath=dist/extension/test/suite/index.js", "pretest": "npm run compile-web" }, @@ -301,7 +301,7 @@ "process": "^0.11.10", "ts-loader": "^9.5.1", "ts-node": "^10.9.1", - "typescript": "^5.3.2", + "typescript": "5.2.2", "webpack": "^5.89.0", "webpack-cli": "^5.1.4" }, diff --git a/scripts/run-each-view.ts b/scripts/run-each-view.ts index 4d3901b..d5f8a40 100644 --- a/scripts/run-each-view.ts +++ b/scripts/run-each-view.ts @@ -1,38 +1,40 @@ import {spawnSync} from 'child_process'; import {readdir} from 'fs/promises'; import path from 'path'; -import {fileURLToPath} from 'url'; -const command = process.argv.slice(2); -const commandName = ['npm'].concat(command).join(' '); +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const command = process.argv.slice(2); + const commandName = ['npm'].concat(command).join(' '); -const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); -const viewsDirectory = path.resolve(currentDirectory, '..', 'views'); -const failedDirectories: string[] = []; + const currentDirectory = path.dirname(__filename); + const viewsDirectory = path.resolve(currentDirectory, '..', 'views'); + const failedDirectories: string[] = []; -console.log(`Executing command "${commandName}" for all views.`); -console.log(); + console.log(`Executing command "${commandName}" for all views.`); + console.log(); -for (const directory of await readdir(viewsDirectory)) { - const directoryName = `./views/${directory}`; + for (const directory of await readdir(viewsDirectory)) { + const directoryName = `./views/${directory}`; - console.log(`Executing command "${commandName}" in views "${directoryName}"`); + console.log(`Executing command "${commandName}" in views "${directoryName}"`); - const {status} = spawnSync('npm', command, { - cwd: path.resolve(viewsDirectory, directory), - stdio: ['inherit', 'inherit', 'inherit'] - }); - console.log(); + const {status} = spawnSync('npm', command, { + cwd: path.resolve(viewsDirectory, directory), + stdio: ['inherit', 'inherit', 'inherit'] + }); + console.log(); + + if (status !== 0) { + failedDirectories.push(directoryName); + } + } - if (status !== 0) { - failedDirectories.push(directoryName); + if (failedDirectories.length === 0) { + console.log(`Executing command "${commandName}" was successful for all views.`); + } else { + console.log(`Executing command "${commandName}" failed for these views:`); + console.log(failedDirectories.map((directory) => ` - ${directory}`).join('\n')); + console.log('See the logs above for more details.'); } -} - -if (failedDirectories.length === 0) { - console.log(`Executing command "${commandName}" was successful for all views.`); -} else { - console.log(`Executing command "${commandName}" failed for these views:`); - console.log(failedDirectories.map((directory) => ` - ${directory}`).join('\n')); - console.log('See the logs above for more details.'); -} +})(); diff --git a/src/common/universal-worker.ts b/src/common/universal-worker.ts index 5304ded..b0108da 100644 --- a/src/common/universal-worker.ts +++ b/src/common/universal-worker.ts @@ -7,8 +7,10 @@ type WorkerThreadsModule = typeof import('worker_threads'); type WorkerThreadsWorker = import('worker_threads').Worker; /* eslint-enable @typescript-eslint/consistent-type-imports */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ const module: WorkerThreadsModule | undefined = typeof Worker === 'undefined' ? __non_webpack_require__('worker_threads') : undefined; +/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ type InternalNodeWorker = { type: 'node'; @@ -40,6 +42,7 @@ export const onEvent = (event: E, callback: Even if (module) { module.parentPort?.on(event, callback); } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument addEventListener(event, (event) => callback(extractData(event))); } }; @@ -77,6 +80,7 @@ export class UniversalWorker { public onEvent(event: E, callback: EventCallbacks[E]): void { if (this.worker.type === 'web') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.worker.worker.addEventListener(event, (event) => callback(extractData(event))); } else { this.worker.worker.on(event, callback); diff --git a/src/extension/commands/actions.ts b/src/extension/commands/actions.ts index 6229b84..437552c 100644 --- a/src/extension/commands/actions.ts +++ b/src/extension/commands/actions.ts @@ -13,7 +13,7 @@ export class OpenProjectConfigurationCommand extends CurrentProjectCommand { async executeForCurrentProject(project: Project) { // Open project file - vscode.commands.executeCommand('vscode.openWith', project.getUri(), ProjectEditor.getViewType()); + await vscode.commands.executeCommand('vscode.openWith', project.getUri(), ProjectEditor.getViewType()); } } @@ -31,14 +31,14 @@ abstract class RunTaskCommand extends CurrentProjectCommand { return false; } - const uri = vscode.Uri.joinPath(task.scope.uri, task.definition.project); + const uri = vscode.Uri.joinPath(task.scope.uri, task.definition.project as string); return uri.toString() === project.getUri().toString(); }); if (task) { - vscode.tasks.executeTask(task); + await vscode.tasks.executeTask(task); } else { - vscode.window.showErrorMessage('No task could be found for the current EDA project.'); + await vscode.window.showErrorMessage('No task could be found for the current EDA project.'); } } } diff --git a/src/extension/commands/base.ts b/src/extension/commands/base.ts index e74e0c0..ed71744 100644 --- a/src/extension/commands/base.ts +++ b/src/extension/commands/base.ts @@ -24,7 +24,7 @@ export abstract class CurrentProjectCommand extends BaseCommand { async execute(...args: unknown[]) { const project = this.projects.getCurrent(); if (!project) { - vscode.window.showWarningMessage('No EDA project selected.'); + await vscode.window.showWarningMessage('No EDA project selected.'); return; } diff --git a/src/extension/commands/project.ts b/src/extension/commands/project.ts index 9da73e3..137557e 100644 --- a/src/extension/commands/project.ts +++ b/src/extension/commands/project.ts @@ -112,10 +112,13 @@ export class NewProjectCommand extends BaseCommand { // Check if the project is within the workspace folder const [workspaceRelativePath] = getWorkspaceRelativePath(projectWorkspace, projectUri); if (!workspaceRelativePath) { - vscode.window.showErrorMessage('Selected project location must be within the selected workspace folder.', { - detail: `File "${projectUri.path}" is not in folder "${projectWorkspace.path}".`, - modal: true - }); + await vscode.window.showErrorMessage( + 'Selected project location must be within the selected workspace folder.', + { + detail: `File "${projectUri.path}" is not in folder "${projectWorkspace.path}".`, + modal: true + } + ); return; } @@ -126,7 +129,7 @@ export class NewProjectCommand extends BaseCommand { await this.projects.add(projectUri, true, true); // Open project file - vscode.commands.executeCommand('vscode.openWith', projectUri, ProjectEditor.getViewType()); + await vscode.commands.executeCommand('vscode.openWith', projectUri, ProjectEditor.getViewType()); } } @@ -187,10 +190,13 @@ export class OpenProjectCommand extends BaseCommand { // Check if the project is within the workspace folder const [workspaceRelativePath] = getWorkspaceRelativePath(projectWorkspace, projectUri); if (!workspaceRelativePath) { - vscode.window.showErrorMessage('Selected project location must be within the selected workspace folder.', { - detail: `File "${projectUri.path}" is not in folder "${projectWorkspace.path}".`, - modal: true - }); + await vscode.window.showErrorMessage( + 'Selected project location must be within the selected workspace folder.', + { + detail: `File "${projectUri.path}" is not in folder "${projectWorkspace.path}".`, + modal: true + } + ); return; } @@ -198,7 +204,7 @@ export class OpenProjectCommand extends BaseCommand { await this.projects.add(projectUri, true, false); // Open project file - vscode.commands.executeCommand('vscode.openWith', projectUri, ProjectEditor.getViewType()); + await vscode.commands.executeCommand('vscode.openWith', projectUri, ProjectEditor.getViewType()); } } @@ -218,6 +224,6 @@ export class SelectProject extends BaseCommand { } async execute(project: Project) { - await this.projects.setCurrent(project); + this.projects.setCurrent(project); } } diff --git a/src/extension/editors/base.ts b/src/extension/editors/base.ts index 2e5fc61..eaced3c 100644 --- a/src/extension/editors/base.ts +++ b/src/extension/editors/base.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import type {Projects} from '../projects/index.js'; -import {type ViewMessage} from '../types.js'; +import type {GlobalStoreMessage, ViewMessage} from '../types.js'; import {BaseWebview} from '../webview.js'; export interface EditorWebviewArgs { @@ -26,57 +26,89 @@ export abstract class BaseEditor extends BaseWebview implemen _token: vscode.CancellationToken ): void | Thenable { const disposables: vscode.Disposable[] = []; + const webview = webviewPanel.webview; // Render webview - webviewPanel.webview.options = { + webview.options = { enableScripts: true }; - webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview, {document}); + webview.html = this.getHtmlForWebview(webview, {document}); // Add message listener - webviewPanel.webview.onDidReceiveMessage(this.onDidReceiveMessage.bind(this, document, webviewPanel.webview)); + webview.onDidReceiveMessage(this.onDidReceiveMessage.bind(this, document, webview)); // Add text document listener disposables.push( vscode.workspace.onDidChangeTextDocument((event) => { if (event.document.uri.toString() === document.uri.toString()) { - this.update(document, webviewPanel.webview, true); + this.update(document, webview, true); } }) ); disposables.push( vscode.workspace.onDidSaveTextDocument((event) => { if (event.uri.toString() === document.uri.toString()) { - this.onSave(document, webviewPanel.webview); + this.onSave(document, webview); } }) ); // Create file system watcher const watcher = vscode.workspace.createFileSystemWatcher(document.uri.fsPath); - watcher.onDidCreate(() => this.update(document, webviewPanel.webview, true)); - watcher.onDidChange(() => this.update(document, webviewPanel.webview, true)); - watcher.onDidDelete(() => this.update(document, webviewPanel.webview, true)); + watcher.onDidCreate(() => this.update(document, webview, true)); + watcher.onDidChange(() => this.update(document, webview, true)); + watcher.onDidDelete(() => this.update(document, webview, true)); disposables.push(watcher); // Add dispose listener webviewPanel.onDidDispose(() => { + this.onClose(document, webview); + for (const disposable of disposables) { disposable.dispose(); } }); // Update document - this.update(document, webviewPanel.webview, false); + this.update(document, webview, false); } - protected abstract onDidReceiveMessage( - document: vscode.TextDocument, + protected async onDidReceiveMessage( + _document: vscode.TextDocument, webview: vscode.Webview, - message: ViewMessage - ): void; + message: ViewMessage | GlobalStoreMessage + ): Promise { + if (message.type === 'globalStore') { + if (message.action === 'set') { + await this.context.globalState.update(message.name, message.value); + const response: GlobalStoreMessage = { + type: 'globalStore', + action: 'result', + transaction: message.transaction + }; + await webview.postMessage(response); + + return true; + } else if (message.action === 'get') { + const value = this.context.globalState.get(message.name) || ({} as object); + const response: GlobalStoreMessage = { + type: 'globalStore', + action: 'result', + transaction: message.transaction, + result: value + }; + await webview.postMessage(response); + + return true; + } + } + + return false; + } protected abstract onSave(document: vscode.TextDocument, webview: vscode.Webview): void; + protected abstract onClose(document: vscode.TextDocument, webview: vscode.Webview): void; + protected abstract update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean): void; } diff --git a/src/extension/editors/index.ts b/src/extension/editors/index.ts index bc86727..c9ebd3e 100644 --- a/src/extension/editors/index.ts +++ b/src/extension/editors/index.ts @@ -1,3 +1,3 @@ -export * from './digitaljs.js'; +export * from './yosys.js'; export * from './nextpnr.js'; export * from './project.js'; diff --git a/src/extension/editors/nextpnr.ts b/src/extension/editors/nextpnr.ts index 205d7ce..880f1d6 100644 --- a/src/extension/editors/nextpnr.ts +++ b/src/extension/editors/nextpnr.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; -import {type ViewMessage} from '../types.js'; +import type {GlobalStoreMessage, ViewMessage} from '../types.js'; import {getWebviewUri} from '../util.js'; import {BaseEditor} from './base.js'; @@ -29,31 +29,45 @@ export class NextpnrEditor extends BaseEditor { @font-face { font-family: "codicon"; font-display: block; - src: url("${fontUri}") format("truetype"); + src: url("${fontUri.toString()}") format("truetype"); } `; } - protected onDidReceiveMessage(document: vscode.TextDocument, webview: vscode.Webview, message: ViewMessage): void { + protected async onDidReceiveMessage( + document: vscode.TextDocument, + webview: vscode.Webview, + message: ViewMessage | GlobalStoreMessage + ): Promise { + if (await super.onDidReceiveMessage(document, webview, message)) { + return true; + } if (message.type === 'ready') { - webview.postMessage({ + await webview.postMessage({ type: 'document', document: document.getText() }); + return true; } + + return false; } protected onSave(_document: vscode.TextDocument, _webview: vscode.Webview): void { // Do nothing } - protected update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean) { + protected onClose(_document: vscode.TextDocument, _webview: vscode.Webview): void { + // Do nothing + } + + protected async update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean) { if (!isDocumentChange) { - vscode.commands.executeCommand('edacation-projects.focus'); + await vscode.commands.executeCommand('edacation-projects.focus'); } - webview.postMessage({ + await webview.postMessage({ type: 'document', document: document.getText() }); diff --git a/src/extension/editors/project.ts b/src/extension/editors/project.ts index 710bfd2..c3551d8 100644 --- a/src/extension/editors/project.ts +++ b/src/extension/editors/project.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import {Project} from '../projects/index.js'; -import {type ViewMessage} from '../types.js'; +import type {GlobalStoreMessage, ViewMessage} from '../types.js'; import {BaseEditor, type EditorWebviewArgs} from './base.js'; @@ -34,51 +34,65 @@ export class ProjectEditor extends BaseEditor { } } - protected onDidReceiveMessage(document: vscode.TextDocument, webview: vscode.Webview, message: ViewMessage): void { - console.log(message); - + protected async onDidReceiveMessage( + document: vscode.TextDocument, + webview: vscode.Webview, + message: ViewMessage | GlobalStoreMessage + ): Promise { + if (await super.onDidReceiveMessage(document, webview, message)) { + return true; + } if (message.type === 'ready') { const project = this.projects.get(document.uri); console.log('ready project', document.uri); if (project) { - webview.postMessage({ + await webview.postMessage({ type: 'project', project: Project.serialize(project) }); } + return true; } else if (message.type === 'change') { if (document.getText() === message.document) { - return; + return true; } const edit = new vscode.WorkspaceEdit(); - edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), message.document as string); - vscode.workspace.applyEdit(edit); + edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), message.document); + await vscode.workspace.applyEdit(edit); + + return true; } + + return false; } protected async onSave(document: vscode.TextDocument, webview: vscode.Webview) { // Reload project await this.projects.reload(document.uri); - this.update(document, webview, false); + await this.update(document, webview, false); + } + + protected onClose(_document: vscode.TextDocument, _webview: vscode.Webview): void { + // Do nothing } - protected update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean) { + protected async update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean) { if (isDocumentChange) { return; } - vscode.commands.executeCommand('edacation-projects.focus'); + await vscode.commands.executeCommand('edacation-projects.focus'); const project = this.projects.get(document.uri); console.log('updating project', project); if (project) { - webview.postMessage({ + await webview.postMessage({ type: 'project', project: Project.serialize(project) }); diff --git a/src/extension/editors/digitaljs.ts b/src/extension/editors/yosys.ts similarity index 55% rename from src/extension/editors/digitaljs.ts rename to src/extension/editors/yosys.ts index 93ef2df..3c13af0 100644 --- a/src/extension/editors/digitaljs.ts +++ b/src/extension/editors/yosys.ts @@ -1,11 +1,13 @@ import * as vscode from 'vscode'; -import {type ViewMessage} from '../types.js'; +import type {GlobalStoreMessage, ViewMessage} from '../types.js'; import * as util from '../util.js'; import {BaseEditor} from './base.js'; -export class DigitalJSEditor extends BaseEditor { +export class YosysEditor extends BaseEditor { + private static activeViews: Set = new Set(); + public static getViewType() { return 'edacation.digitaljs'; } @@ -35,45 +37,75 @@ export class DigitalJSEditor extends BaseEditor { @font-face { font-family: "codicon"; font-display: block; - src: url("${fontUri}") format("truetype"); + src: url("${fontUri.toString()}") format("truetype"); } `; } - protected onDidReceiveMessage(document: vscode.TextDocument, webview: vscode.Webview, message: ViewMessage): void { + protected async onDidReceiveMessage( + document: vscode.TextDocument, + webview: vscode.Webview, + message: ViewMessage | GlobalStoreMessage + ): Promise { + if (await super.onDidReceiveMessage(document, webview, message)) { + return true; + } + if (message.type === 'ready') { - webview.postMessage({ + await webview.postMessage({ type: 'document', document: document.getText() }); + return true; + } else if (message.type === 'broadcast') { + if (YosysEditor.activeViews.size < 2) { + // There are no views to broadcast to... + await vscode.window.showErrorMessage('You must have more than one tab open for this feature to work!'); + } + + for (const view of YosysEditor.activeViews) { + if (view === webview) { + continue; + } + await view.postMessage(message); + } + return true; } else if (message.type === 'requestSave') { // Save to project root, or the parent dir of the current editor's file if we can't find it const projectRoot = util.findProjectRoot(document.uri) || util.getParentUri(document.uri); const path = vscode.Uri.joinPath(projectRoot, message.data.defaultPath || ''); - util.offerSaveFile(message.data.fileContents, { + const uri = await util.offerSaveFile(message.data.fileContents, { defaultUri: path, filters: message.data?.filters - }).then((path) => { - if (!path) { - return; - } - this.showSaveNotification(path); }); + if (uri) { + await this.showSaveNotification(uri); + } + + return true; } + + return false; } protected onSave(_document: vscode.TextDocument, _webview: vscode.Webview): void { // Do nothing } - protected update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean) { + protected onClose(_document: vscode.TextDocument, webview: vscode.Webview): void { + YosysEditor.activeViews.delete(webview); + } + + protected async update(document: vscode.TextDocument, webview: vscode.Webview, isDocumentChange: boolean) { + YosysEditor.activeViews.add(webview); + if (!isDocumentChange) { - vscode.commands.executeCommand('edacation-projects.focus'); + await vscode.commands.executeCommand('edacation-projects.focus'); } - webview.postMessage({ + await webview.postMessage({ type: 'document', document: document.getText() }); diff --git a/src/extension/index.ts b/src/extension/index.ts index 4ed2c7e..518ff78 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -42,12 +42,9 @@ export const activate = async (context: vscode.ExtensionContext) => { // Register webview providers for (const webviewType of Object.values(webviews)) { const webview = new webviewType(context, projects); - context.subscriptions.push( - vscode.window.registerWebviewViewProvider(webviewType.getViewID(), webview) - ); + context.subscriptions.push(vscode.window.registerWebviewViewProvider(webviewType.getViewID(), webview)); } - await projects.load(); }; diff --git a/src/extension/projects/project.ts b/src/extension/projects/project.ts index 4d93d08..7c847a7 100644 --- a/src/extension/projects/project.ts +++ b/src/extension/projects/project.ts @@ -1,4 +1,4 @@ -import {Project as BaseProject, DEFAULT_CONFIGURATION, type ProjectConfiguration} from 'edacation'; +import {Project as BaseProject, DEFAULT_CONFIGURATION, type ProjectConfiguration, type ProjectState} from 'edacation'; import path from 'path'; import * as vscode from 'vscode'; @@ -82,7 +82,7 @@ export class Project extends BaseProject { for (const fileUri of fileUris) { const [workspaceRelativePath, folderRelativePath] = getWorkspaceRelativePath(this.getRoot(), fileUri); if (!workspaceRelativePath) { - vscode.window.showErrorMessage(`File must be in the a subfolder of the EDA project root.`, { + await vscode.window.showErrorMessage(`File must be in the a subfolder of the EDA project root.`, { detail: `File "${fileUri.path}" is not in folder "${this.getRoot().path}".`, modal: true }); @@ -95,7 +95,7 @@ export class Project extends BaseProject { } } - await super.addInputFiles(filePaths); + super.addInputFiles(filePaths); this.projects.emitInputFileChange(); @@ -107,7 +107,7 @@ export class Project extends BaseProject { this.inputFileUris.delete(filePath); } - await super.removeInputFiles(filePaths); + super.removeInputFiles(filePaths); this.projects.emitInputFileChange(); @@ -119,7 +119,7 @@ export class Project extends BaseProject { for (const fileUri of fileUris) { const [workspaceRelativePath, folderRelativePath] = getWorkspaceRelativePath(this.getRoot(), fileUri); if (!workspaceRelativePath) { - vscode.window.showErrorMessage(`File must be in the a subfolder of the EDA project root.`, { + await vscode.window.showErrorMessage(`File must be in the a subfolder of the EDA project root.`, { detail: `File "${fileUri.path}" is not in folder "${this.getRoot().path}".`, modal: true }); @@ -132,7 +132,7 @@ export class Project extends BaseProject { } } - await super.addOutputFiles(filePaths); + super.addOutputFiles(filePaths); this.projects.emitOutputFileChange(); @@ -144,7 +144,7 @@ export class Project extends BaseProject { this.outputFileUris.delete(filePath); } - await super.removeOutputFiles(filePaths); + super.removeOutputFiles(filePaths); this.projects.emitOutputFileChange(); @@ -155,24 +155,22 @@ export class Project extends BaseProject { await Project.store(this); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static serialize(project: Project): any { + static serialize(project: Project): ProjectState { return BaseProject.serialize(project); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static deserialize(data: any, projects: Projects, uri: vscode.Uri): Project { - const name: string = data.name; - const inputFiles: string[] = data.inputFiles ?? []; - const outputFiles: string[] = data.outputFiles ?? []; - const configuration: ProjectConfiguration = data.configuration ?? {}; + static deserialize(data: ProjectState, projects: Projects, uri: vscode.Uri): Project { + const name = data.name; + const inputFiles = data.inputFiles ?? []; + const outputFiles = data.outputFiles ?? []; + const configuration = data.configuration ?? ({} as ProjectConfiguration); return new Project(projects, uri, name, inputFiles, outputFiles, configuration); } static async load(projects: Projects, uri: vscode.Uri): Promise { const data = decodeJSON(await vscode.workspace.fs.readFile(uri)); - const project = Project.deserialize(data, projects, uri); + const project = Project.deserialize(data as ProjectState, projects, uri); return project; } diff --git a/src/extension/projects/projects.ts b/src/extension/projects/projects.ts index 82c49f7..58b1743 100644 --- a/src/extension/projects/projects.ts +++ b/src/extension/projects/projects.ts @@ -120,7 +120,7 @@ export class Projects { this.projects.push(project); } } catch (err) { - this.context.workspaceState.update('projects', undefined); + await this.context.workspaceState.update('projects', undefined); throw err; } @@ -170,7 +170,7 @@ export class Projects { return this.currentProject; } - async setCurrent(project: Project) { + setCurrent(project: Project) { const previousProject = this.currentProject; this.currentProject = project; @@ -188,7 +188,7 @@ export class Projects { this.emitOutputFileChange(); } - handleTaskEnd(event: vscode.TaskEndEvent) { + async handleTaskEnd(event: vscode.TaskEndEvent) { const task = event.execution.task; if (['yosys-rtl', 'yosys-synth', 'nextpnr'].includes(task.definition.type)) { @@ -200,8 +200,8 @@ export class Projects { return; } - const uri = vscode.Uri.joinPath(task.scope.uri, task.definition.project); - this.reload(uri); + const uri = vscode.Uri.joinPath(task.scope.uri, task.definition.project as string); + await this.reload(uri); } } } diff --git a/src/extension/tasks/messaging.ts b/src/extension/tasks/messaging.ts index 049833a..6266028 100644 --- a/src/extension/tasks/messaging.ts +++ b/src/extension/tasks/messaging.ts @@ -1,3 +1,5 @@ +import type {Uri} from 'vscode'; + export enum AnsiModifier { RESET = '\x1b[0m', BOLD = '\x1b[1m', @@ -8,6 +10,7 @@ export enum AnsiModifier { export interface TaskOutputFile { path: string; data?: Uint8Array; + uri?: Uri; } export interface TerminalMessagePrintln { diff --git a/src/extension/tasks/nextpnr.ts b/src/extension/tasks/nextpnr.ts index 8c5ee74..fd679e0 100644 --- a/src/extension/tasks/nextpnr.ts +++ b/src/extension/tasks/nextpnr.ts @@ -91,7 +91,7 @@ class NextpnrTerminalTask extends TerminalTask { const pnrFile = outputFiles.find((file) => file.path === 'routed.nextpnr.json'); if (pnrFile) { const uri = vscode.Uri.joinPath(project.getRoot(), pnrFile.path); - vscode.commands.executeCommand('vscode.open', uri); + await vscode.commands.executeCommand('vscode.open', uri); } } } diff --git a/src/extension/tasks/rtl.ts b/src/extension/tasks/rtl.ts index b6b8c5c..51d8288 100644 --- a/src/extension/tasks/rtl.ts +++ b/src/extension/tasks/rtl.ts @@ -1,8 +1,11 @@ import {type YosysWorkerOptions, encodeText, generateYosysRTLCommands} from 'edacation'; +import {basename} from 'path-browserify'; import * as vscode from 'vscode'; +import type {Uri} from 'vscode'; import type {MessageFile} from '../../common/messages.js'; import type {Project} from '../projects/index.js'; +import {decodeJSON, encodeJSON} from '../util.js'; import {type TaskOutputFile} from './messaging.js'; import {getConfiguredRunner} from './runner.js'; @@ -43,17 +46,53 @@ class RTLTerminalTask extends BaseYosysTerminalTask { } getOutputFiles(_workerOptions: YosysWorkerOptions): string[] { - return ['rtl.digitaljs.json']; + return ['rtl.digitaljs.json', 'stats.digitaljs.json']; + } + + private async updateFile(uri: vscode.Uri) { + const fileName = basename(uri.path); + let fileType: string; + if (fileName === 'rtl.digitaljs.json') { + fileType = 'rtl'; + } else if (fileName === 'stats.digitaljs.json') { + fileType = 'stats'; + } else { + // TODO: print the message to stderr as a warning (red text). + // This is implemented in https://github.com/EDAcation/vscode-edacation/pull/14. + this.println( + `WARNING: Output file "${fileName}" not recognized. It might not be compatible with EDAcation.` + ); + return; + } + + // Actually update file + const oldContent = await vscode.workspace.fs.readFile(uri); + const newContent = encodeJSON({ + type: fileType, + data: decodeJSON(oldContent) + }); + await vscode.workspace.fs.writeFile(uri, newContent); } async handleEnd(project: Project, outputFiles: TaskOutputFile[]) { - super.handleEnd(project, outputFiles); + await super.handleEnd(project, outputFiles); + + // Update file contents so our custom editor can handle them + // TODO: print the message to stderr as an error (red text). + // This is implemented in https://github.com/EDAcation/vscode-edacation/pull/14. + await Promise.all( + outputFiles.map((file) => + this.updateFile(file.uri as Uri).catch((_err) => + this.println(`Error while updating "${file.path}"; the file might not be usable!`) + ) + ) + ); // Open RTL file in DigitalJS editor const rtlFile = outputFiles.find((file) => file.path.endsWith('rtl.digitaljs.json')); if (rtlFile) { const uri = vscode.Uri.joinPath(project.getRoot(), rtlFile.path); - vscode.commands.executeCommand('vscode.open', uri); + await vscode.commands.executeCommand('vscode.open', uri); } } } diff --git a/src/extension/tasks/runner.ts b/src/extension/tasks/runner.ts index c9f7bb1..be2bcba 100644 --- a/src/extension/tasks/runner.ts +++ b/src/extension/tasks/runner.ts @@ -159,14 +159,13 @@ export class NativeTaskRunner extends TaskRunner { proc.on('exit', this.onProcessExit.bind(this, ctx)); proc.on('error', this.onProcessError.bind(this)); - proc.stdout.on('data', (data) => this.onProcessData(data, 'stdout')); - proc.stderr.on('data', (data) => this.onProcessData(data, 'stderr')); + proc.stdout.on('data', (data) => this.onProcessData(data as string, 'stdout')); + proc.stderr.on('data', (data) => this.onProcessData(data as string, 'stderr')); } private onProcessError(error: unknown) { if (error instanceof Error) { - // ugly, but it's a nodejs error so there's no typings for the code attribute - if (Object(error)?.code === 'ENOENT') { + if ((error as Error & {code: string}).code === 'ENOENT') { this.error( 'Could not find native runner entrypoint. Are Yosys/Nextpnr installed on your system and available in PATH?' ); diff --git a/src/extension/tasks/terminal.ts b/src/extension/tasks/terminal.ts index 886051c..c064696 100644 --- a/src/extension/tasks/terminal.ts +++ b/src/extension/tasks/terminal.ts @@ -45,8 +45,8 @@ export abstract class TaskProvider extends BaseTaskProvider { return this.getTask( task.scope, - task.definition.uri, - task.definition.project, + task.definition.uri as vscode.Uri, + task.definition.project as string, task.definition as TaskDefinition ); } @@ -131,8 +131,8 @@ export class TaskTerminal implements vscode.Pseudoterminal { this.logMessages = []; } - open() { - this.execute(); + async open() { + await this.execute(); } close() { @@ -194,10 +194,11 @@ export class TaskTerminal implements vscode.Pseudoterminal { this.definition.uri || vscode.Uri.joinPath(this.folder.uri, this.definition.project) ); } catch (err) { - this.error(err); + await this.error(err); return; } + // eslint-disable-next-line @typescript-eslint/no-misused-promises this.task.onMessage(this.handleMessage.bind(this, project)); try { diff --git a/src/extension/tasks/yosys.ts b/src/extension/tasks/yosys.ts index aec6cdb..5ade02d 100644 --- a/src/extension/tasks/yosys.ts +++ b/src/extension/tasks/yosys.ts @@ -100,13 +100,13 @@ export class YosysTaskProvider extends TaskProvider { class YosysTerminalTask extends BaseYosysTerminalTask { async handleEnd(project: Project, outputFiles: TaskOutputFile[]) { - super.handleEnd(project, outputFiles); + await super.handleEnd(project, outputFiles); // Open LUT file in DigitalJS editor const lutFile = outputFiles.find((file) => file.path.endsWith('luts.digitaljs.json')); if (lutFile) { const uri = vscode.Uri.joinPath(project.getRoot(), lutFile.path); - vscode.commands.executeCommand('vscode.open', uri); + await vscode.commands.executeCommand('vscode.open', uri); } } } diff --git a/src/extension/test/suite/extension.test.ts b/src/extension/test/suite/extension.test.ts index feabf57..81d6a82 100644 --- a/src/extension/test/suite/extension.test.ts +++ b/src/extension/test/suite/extension.test.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; // import * as myExtension from '../../extension'; suite('Web Extension Test Suite', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises vscode.window.showInformationMessage('Start all tests.'); test('Sample test', () => { diff --git a/src/extension/trees/files.ts b/src/extension/trees/files.ts index 6daf94a..66e9b60 100644 --- a/src/extension/trees/files.ts +++ b/src/extension/trees/files.ts @@ -23,7 +23,7 @@ abstract class FilesProvider extends BaseTreeDataProvider { }; } - async getChildren(element?: ProjectFile): Promise { + getChildren(element?: ProjectFile): ProjectFile[] { const project = this.projects.getCurrent(); if (!project) { return []; diff --git a/src/extension/trees/projects.ts b/src/extension/trees/projects.ts index 10a0c2e..744d67e 100644 --- a/src/extension/trees/projects.ts +++ b/src/extension/trees/projects.ts @@ -28,7 +28,7 @@ export class ProjectProvider extends BaseTreeDataProvider { }; } - async getChildren(element?: Project): Promise { + getChildren(element?: Project): Project[] { if (!element) { return this.projects.getAll(); } diff --git a/src/extension/types.ts b/src/extension/types.ts index e31f678..dae6d3e 100644 --- a/src/extension/types.ts +++ b/src/extension/types.ts @@ -1,13 +1,32 @@ -export interface ViewMessageReady { +interface ForeignViewMessageModuleFocus { + type: 'moduleFocus'; + breadcrumbs: string[]; +} + +export type ForeignViewMessage = ForeignViewMessageModuleFocus; + +interface MessageBroadcast { + type: 'broadcast'; + message: ForeignViewMessage; +} + +interface EditorMessageDocument { + type: 'document'; + document: string; +} + +export type EditorMessage = EditorMessageDocument | MessageBroadcast; + +interface ViewMessageReady { type: 'ready'; } -export interface ViewMessageChange { +interface ViewMessageChange { type: 'change'; document: string; } -export interface ViewMessageRequestSave { +interface ViewMessageRequestSave { type: 'requestSave'; data: { fileContents: string; @@ -16,4 +35,29 @@ export interface ViewMessageRequestSave { }; } -export type ViewMessage = ViewMessageReady | ViewMessageChange | ViewMessageRequestSave; +export type ViewMessage = ViewMessageReady | ViewMessageChange | MessageBroadcast | ViewMessageRequestSave; + +interface GlobalStore { + type: 'globalStore'; +} + +interface GlobalStoreSet extends GlobalStore { + action: 'set'; + transaction: string; + name: string; + value: object; +} + +interface GlobalStoreGet extends GlobalStore { + action: 'get'; + transaction: string; + name: string; +} + +interface GlobalStoreResult extends GlobalStore { + action: 'result'; + transaction: string; + result?: object; +} + +export type GlobalStoreMessage = GlobalStoreSet | GlobalStoreGet | GlobalStoreResult; diff --git a/src/extension/util.ts b/src/extension/util.ts index 83f1126..e967217 100644 --- a/src/extension/util.ts +++ b/src/extension/util.ts @@ -29,7 +29,7 @@ export const ensureFile = async (uri: vscode.Uri, exists: boolean): Promise textEncoder.encode(input.endsWith(' export const encodeJSON = (input: unknown, pretty = false) => encodeText(JSON.stringify(input, undefined, pretty ? 4 : undefined)); -export const decodeText = (input: BufferSource) => textDecoder.decode(input); -export const decodeJSON = (input: BufferSource) => JSON.parse(decodeText(input)); +export const decodeText = (input: BufferSource): string => textDecoder.decode(input); +export const decodeJSON = (input: BufferSource): unknown => JSON.parse(decodeText(input)); export const FILE_EXTENSIONS_VERILOG = ['v', 'vh', 'sv', 'svh']; export const FILE_EXTENSIONS_VHDL = ['vhd']; diff --git a/src/extension/webview.ts b/src/extension/webview.ts index 81c9800..e0dc2e8 100644 --- a/src/extension/webview.ts +++ b/src/extension/webview.ts @@ -46,7 +46,7 @@ export abstract class BaseWebview> { const styleUris = stylePaths.map((stylePath) => getWebviewUri(webview, this.context, stylePath)); return styleUris - .map((styleUri) => /*html*/ ``) + .map((styleUri) => /*html*/ ``) .join('\n'); } @@ -55,7 +55,7 @@ export abstract class BaseWebview> { const scriptUris = scriptPaths.map((scriptPath) => getWebviewUri(webview, this.context, scriptPath)); return scriptUris - .map((scriptUri) => /*html*/ ``) + .map((scriptUri) => /*html*/ ``) .join('\n'); } diff --git a/src/extension/webviews/actions.ts b/src/extension/webviews/actions.ts index 68bdff0..764dd42 100644 --- a/src/extension/webviews/actions.ts +++ b/src/extension/webviews/actions.ts @@ -1,9 +1,9 @@ import type * as vscode from 'vscode'; +import {Project} from '../projects/index.js'; import type {ViewMessage} from '../types.js'; import {BaseWebviewViewProvider} from './base.js'; -import {Project} from '../projects/index.js'; export class ActionsProvider extends BaseWebviewViewProvider { public static getViewID() { @@ -26,26 +26,31 @@ export class ActionsProvider extends BaseWebviewViewProvider { }; } - public resolveWebviewView(webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken): void | Thenable { - super.resolveWebviewView(webviewView, context, token) + public resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + token: vscode.CancellationToken + ): void | Thenable { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + super.resolveWebviewView(webviewView, context, token); // TODO: subscribe to project change // this.projects.getProjectEmitter().event. } - protected onDidReceiveMessage(webview: vscode.Webview, message: ViewMessage): void { + protected async onDidReceiveMessage(webview: vscode.Webview, message: ViewMessage): Promise { console.log('[actions]', message); if (message.type === 'ready') { - const project = this.projects.getCurrent() + const project = this.projects.getCurrent(); if (!project) { - return + return; } console.log('[actions]', 'ready project', project.getUri()); if (project) { - webview.postMessage({ + await webview.postMessage({ type: 'project', project: Project.serialize(project) }); diff --git a/src/workers/worker.ts b/src/workers/worker.ts index 7cc0a85..6a3c425 100644 --- a/src/workers/worker.ts +++ b/src/workers/worker.ts @@ -29,6 +29,7 @@ export abstract class WorkerTool { constructor() { this.toolPromise = this.initialize(); + // eslint-disable-next-line @typescript-eslint/no-misused-promises onEvent('message', this.handleMessage.bind(this)); onEvent('messageerror', this.handleMessageError.bind(this)); } @@ -133,7 +134,9 @@ export abstract class WorkerTool { // TODO: create output directories // Execute Emscripten tool - // @ts-expect-error: TODO + + // @ts-expect-error TODO + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access tool.getModule().callMain(message.args); // Read output files diff --git a/tsconfig.json b/tsconfig.json index 0a9adba..985e81b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "exclude": ["node_modules", "scripts", "views"] + "exclude": ["node_modules", "views"] } diff --git a/views/digitaljs/package-lock.json b/views/digitaljs/package-lock.json index d38d968..485bd05 100644 --- a/views/digitaljs/package-lock.json +++ b/views/digitaljs/package-lock.json @@ -278,8 +278,8 @@ } }, "node_modules/digitaljs": { - "version": "0.14.0", - "resolved": "git+ssh://git@github.com/EDAcation/digitaljs.git#65605962911923d368c8e266705b7022fa0b2114", + "version": "0.14.1", + "resolved": "git+ssh://git@github.com/EDAcation/digitaljs.git#3fd56fa4c2c0f08c52aca8154f8368b5bbc411e1", "license": "BSD-2-Clause", "dependencies": { "3vl": "^1.0.1", diff --git a/views/digitaljs/src/globalStore.ts b/views/digitaljs/src/globalStore.ts new file mode 100644 index 0000000..df2cd49 --- /dev/null +++ b/views/digitaljs/src/globalStore.ts @@ -0,0 +1,78 @@ +import type {GlobalStoreMessage} from './messages'; +import {vscode} from './vscode'; + +interface TransactionSet { + type: 'set'; + resolve: (value: void) => unknown; +} + +interface TransactionGet { + type: 'get'; + resolve: (value: object) => unknown; +} + +type Transaction = TransactionSet | TransactionGet; + +export class GlobalStoreConnector { + private transactions: Record; + + constructor() { + this.transactions = {}; + } + + private sendMessage(message: GlobalStoreMessage) { + vscode.postMessage(message); + } + + public onMessage(message: GlobalStoreMessage) { + if (message.action !== 'result') { + return; + } + + const transaction = this.transactions[message.transaction]; + if (transaction === undefined) { + // Not a critical error, so do not throw + console.error(`GlobalState transaction ${transaction} not found!`); + return; + } + + if (transaction.type === 'set') { + transaction.resolve(); + } else if (transaction.type === 'get') { + transaction.resolve(message.result || {}); + } + } + + public set(name: string, value: object): Promise { + const transUuid = Math.floor(Math.random() * 100_000); + const promise = new Promise((resolve: (value: void) => unknown, _reject) => { + this.transactions[transUuid] = {type: 'set', resolve}; + }); + + this.sendMessage({ + type: 'globalStore', + transaction: transUuid, + action: 'set', + name: name, + value: value + }); + + return promise; + } + + public get(name: string): Promise { + const transUuid = Math.floor(Math.random() * 100_000); + const promise = new Promise((resolve: (value: object) => unknown, _reject) => { + this.transactions[transUuid] = {type: 'get', resolve}; + }); + + this.sendMessage({ + type: 'globalStore', + transaction: transUuid, + action: 'get', + name: name + }); + + return promise; + } +} diff --git a/views/digitaljs/src/main.ts b/views/digitaljs/src/main.ts index fcc0929..ace0faa 100644 --- a/views/digitaljs/src/main.ts +++ b/views/digitaljs/src/main.ts @@ -1,11 +1,12 @@ -import 'jquery-ui/dist/jquery-ui.min.js'; import '@vscode/codicons/dist/codicon.css'; import {allComponents} from '@vscode/webview-ui-toolkit/dist/toolkit.js'; -// @ts-expect-error: TODO: add module declaration (digitaljs.d.ts) -import {Circuit} from 'digitaljs'; -import {yosys2digitaljs} from 'yosys2digitaljs'; +import 'jquery-ui/dist/jquery-ui.min.js'; +import {GlobalStoreConnector} from './globalStore'; import './main.css'; +import type {EditorMessage, ForeignViewMessage, GlobalStoreMessage, ViewMessage} from './messages'; +import type {YosysFile} from './types'; +import {type BaseViewer, DiagramViewer, StatsViewer} from './viewers'; import {vscode} from './vscode'; // Force bundler to include VS Code Webview UI Toolkit @@ -15,83 +16,34 @@ interface State { document?: string; } -interface MessageDocument { - type: 'document'; - document: string; -} - -type Message = MessageDocument; +export class View { + public readonly root: HTMLDivElement; -// TODO: better typing - should be fixed from our digitaljs fork -interface ButtonCallback { - circuit: Circuit; - model: any // eslint-disable-line @typescript-eslint/no-explicit-any - paper: any // eslint-disable-line @typescript-eslint/no-explicit-any -} - -const getSvg = (svgElem: Element, width: number, height: number): string => { - // Filter conveniently labeled foreign objects from element - const foreignElems = svgElem.getElementsByTagName('foreignObject'); - for (const elem of Array.from(foreignElems)) { - elem.remove(); - } - - // Set correct XML namespace - svgElem.removeAttribute('xmlns:xlink'); - svgElem.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - - // Correctly specify width / height to prevent clipping - svgElem.setAttribute('width', `${width}px`); - svgElem.setAttribute('height', `${height}px`); - - // Add XML header - return '\n' + svgElem.outerHTML; -}; - -class View { - private readonly root: HTMLDivElement; private state: State; - - private static subCircuitButtons = [ - { - id: 'exportSvg', - buttonText: 'SVG', - callback: ({model, paper}: ButtonCallback) => { - const svgData = getSvg( - paper.svg.cloneNode(true) as Element, - paper.svg.clientWidth, - paper.svg.clientHeight - ); - vscode.postMessage({ - type: 'requestSave', - data: { - fileContents: svgData, - defaultPath: `${model.get('label')}.svg`, - saveFilters: {svg: ['.svg']} - } - }); - } - } - ] + private viewer: BaseViewer | null; + private readonly globalStore: GlobalStoreConnector; constructor(root: HTMLDivElement, state: State) { this.root = root; this.state = state; + this.viewer = null; + + this.globalStore = new GlobalStoreConnector(); addEventListener('message', this.handleMessage.bind(this)); addEventListener('messageerror', this.handleMessageError.bind(this)); addEventListener('resize', this.handleResize.bind(this)); if (this.state.document) { - this.renderDocument(); + this.renderDocument(false); } else { - vscode.postMessage({ + this.sendMessage({ type: 'ready' }); } } - updateState(partialState: Partial) { + private updateState(partialState: Partial) { this.state = { ...this.state, ...partialState @@ -99,131 +51,104 @@ class View { vscode.setState(this.state); } - handleMessage(message: MessageEvent) { + private handleMessage(message: MessageEvent) { switch (message.data.type) { case 'document': { this.updateState({ document: message.data.document }); - this.renderDocument(); + this.renderDocument(false); + break; + } + case 'broadcast': { + if (this.viewer) { + this.viewer.handleForeignViewMessage(message.data.message); + } + break; + } + case 'globalStore': { + this.globalStore.onMessage(message.data); } } } - handleMessageError(message: MessageEvent) { - console.error(message); - this.handleError(new Error('Message error.')); + private handleMessageError(message: MessageEvent) { + this.handleError(`Message error\n\n${message}`); } - handleError(error: unknown) { - if (error instanceof Error || typeof error === 'string') { - this.renderError(error); - } else { - this.renderError(new Error('Unknown error.')); - } + private handleResize() { + this.renderDocument(true); } - handleResize() { - this.renderDocument(); - } - - requestExport() { - // Find our SVG root element - const svgElems = document.getElementsByTagName('svg'); - if (!svgElems) { - throw new Error('Could not find SVG element to export'); + private findViewer(): BaseViewer { + if (!this.state.document) { + throw new Error('No data to find viewer!'); } - const svgElem = svgElems[0]; - // Extract viewable SVG data from elem - const svgData = getSvg( - // Deep clone so we don't affect the SVG in the DOM - svgElem.cloneNode(true) as Element, - svgElem.clientWidth, - svgElem.clientHeight - ); + const fileData = JSON.parse(this.state.document) as YosysFile; + if (!fileData['type'] || !fileData['data']) { + throw new Error('File is missing type or data keys.'); + } - // Send save request to main worker - vscode.postMessage({ - type: 'requestSave', - data: { - fileContents: svgData, - defaultPath: 'topLevel.svg', - saveFilters: {svg: ['.svg']} - } - }); + if (fileData['type'] === 'rtl') { + return new DiagramViewer(this, fileData['data']); + } else if (fileData['type'] === 'stats') { + return new StatsViewer(this, fileData['data']); + } else { + throw new Error(`Could not find viewer for type: ${fileData['type']}`); + } } - renderDocument() { + private renderDocument(isUpdate: boolean) { try { if (!this.state.document) { - throw new Error('No document to render.'); + throw new Error('No data to render document!'); + } else if (!this.viewer) { + this.viewer = this.findViewer(); } - // Parse Yosys netlist from JSON string - const json = JSON.parse(this.state.document); - - // Convert from Yosys netlist to DigitalJS format - const digitalJs = yosys2digitaljs(json); - - // Initialize circuit - const circuit = new Circuit(digitalJs, {subcircuitButtons: View.subCircuitButtons}); - - // Clear - this.root.replaceChildren(); + this.viewer.render(isUpdate).catch((err) => this.handleError(err, this.viewer)); + } catch (err) { + this.handleError(err); + } + } - // Render actions - const elementActions = document.createElement('div'); - elementActions.style.marginBottom = '1rem'; - elementActions.innerHTML = /*html*/ ` - - Start - - - - Stop - - - - Export to SVG - - - `; - this.root.appendChild(elementActions); + storeValue(name: string, value: object): Promise { + return this.globalStore.set(name, value); + } - const buttonStart = document.getElementById('digitaljs-start'); - const buttonStop = document.getElementById('digitaljs-stop'); - const buttonExport = document.getElementById('digitaljs-export'); + getValue(name: string): Promise { + return this.globalStore.get(name); + } - buttonStart?.addEventListener('click', () => circuit.start()); - buttonStop?.addEventListener('click', () => circuit.stop()); - buttonExport?.addEventListener('click', this.requestExport); + sendMessage(message: ViewMessage) { + vscode.postMessage(message); + } - circuit.on('changeRunning', () => { - if (circuit.running) { - buttonStart?.setAttribute('disabled', ''); - buttonStop?.removeAttribute('disabled'); - } else { - buttonStart?.removeAttribute('disabled'); - buttonStop?.setAttribute('disabled', ''); - } - }); + broadcastMessage(message: ForeignViewMessage) { + vscode.postMessage({ + type: 'broadcast', + message: message + }); + } - // Render circuit - const elementCircuit = document.createElement('div'); - circuit.displayOn(elementCircuit); - this.root.appendChild(elementCircuit); - } catch (err) { - this.handleError(err); + handleError(error: unknown, sourceViewer: BaseViewer | null = null) { + if (error instanceof Error || typeof error === 'string') { + this.renderError(error, sourceViewer); + } else { + this.renderError(new Error('Unknown error.'), sourceViewer); } } - renderError(error: Error | string) { + private renderError(error: Error | string, sourceViewer: BaseViewer | null) { const elementHeader = document.createElement('h3'); - elementHeader.textContent = 'Unable to render DigitalJS file'; + elementHeader.textContent = 'Unable to open DigitalJS file'; const elementCode = document.createElement('code'); - elementCode.textContent = typeof error === 'string' ? error : error.stack || error.message; + if (sourceViewer !== null) { + elementCode.textContent = `*** Error in Viewer: ${typeof sourceViewer} ***\n\n`; + } + elementCode.textContent += typeof error === 'string' ? error : error.stack || error.message; this.root.replaceChildren(); this.root.appendChild(elementHeader); diff --git a/views/digitaljs/src/messages.ts b/views/digitaljs/src/messages.ts new file mode 100644 index 0000000..b08fde2 --- /dev/null +++ b/views/digitaljs/src/messages.ts @@ -0,0 +1,61 @@ +interface ForeignViewMessageModuleFocus { + type: 'moduleFocus'; + breadcrumbs: string[]; +} + +export type ForeignViewMessage = ForeignViewMessageModuleFocus; + +interface MessageBroadcast { + type: 'broadcast'; + message: ForeignViewMessage; +} + +interface EditorMessageDocument { + type: 'document'; + document: string; +} + +export type EditorMessage = EditorMessageDocument | MessageBroadcast; + +interface ViewMessageReady { + type: 'ready'; +} + +interface ViewMessageChange { + type: 'change'; + document: string; +} + +interface ViewMessageRequestSave { + type: 'requestSave'; + data: { + fileContents: string; + defaultPath?: string; + filters?: Record; + }; +} + +export type ViewMessage = ViewMessageReady | ViewMessageChange | MessageBroadcast | ViewMessageRequestSave; + +interface GlobalStore { + type: 'globalStore'; + transaction: number; +} + +interface GlobalStoreSet extends GlobalStore { + action: 'set'; + name: string; + value: object; +} + +interface GlobalStoreGet extends GlobalStore { + action: 'get'; + name: string; +} + +interface GlobalStoreResult extends GlobalStore { + action: 'result'; + result?: object; +} + +export type GlobalStoreMessage = GlobalStoreSet | GlobalStoreGet | GlobalStoreResult; diff --git a/views/digitaljs/src/types.ts b/views/digitaljs/src/types.ts new file mode 100644 index 0000000..8b21771 --- /dev/null +++ b/views/digitaljs/src/types.ts @@ -0,0 +1,33 @@ +import type {yosys2digitaljs} from 'yosys2digitaljs'; + +export type YosysRTL = Parameters[0]; + +interface YosysFileRTL { + type: 'rtl'; + data: YosysRTL; +} + +export interface YosysModuleStats { + num_wires: number; + num_wire_bits: number; + num_pub_wires: number; + num_pub_wire_bits: number; + num_memories: number; + num_memory_bits: number; + num_processes: number; + num_cells: number; + num_cells_by_type: Record; +} + +export interface YosysStats { + creator: string; + invocation: string; + modules: Record; +} + +interface YosysFileStats { + type: 'stats'; + data: YosysStats; +} + +export type YosysFile = YosysFileRTL | YosysFileStats; diff --git a/views/digitaljs/src/viewers/base.ts b/views/digitaljs/src/viewers/base.ts new file mode 100644 index 0000000..b9eb37c --- /dev/null +++ b/views/digitaljs/src/viewers/base.ts @@ -0,0 +1,44 @@ +import type {View} from '../main'; +import type {ForeignViewMessage, ViewMessage} from '../messages'; + +export abstract class BaseViewer { + private readonly mainView: View; + protected readonly data: InitialData; + + constructor(mainView: View, initData: InitialData) { + this.mainView = mainView; + this.data = initData; + } + + abstract render(isUpdate: boolean): Promise; + + abstract handleForeignViewMessage(message: ForeignViewMessage): void; + + protected get root(): HTMLDivElement { + return this.mainView.root; + } + + protected storeValue(name: string, value: object): Promise { + return this.mainView.storeValue(name, value); + } + + protected getValue(name: string): Promise { + return this.mainView.getValue(name); + } + + protected broadcastMessage(message: ForeignViewMessage) { + return this.mainView.broadcastMessage(message); + } + + protected sendMessage(message: ViewMessage) { + return this.mainView.sendMessage(message); + } + + protected handleError(error: unknown) { + if (error instanceof Error || typeof error === 'string') { + this.mainView.handleError(error); + } else { + this.mainView.handleError(new Error('Unknown error.')); + } + } +} diff --git a/views/digitaljs/src/viewers/diagram/index.ts b/views/digitaljs/src/viewers/diagram/index.ts new file mode 100644 index 0000000..86deb62 --- /dev/null +++ b/views/digitaljs/src/viewers/diagram/index.ts @@ -0,0 +1,134 @@ +// @ts-expect-error: TODO: add module declaration (digitaljs.d.ts) +import {Circuit} from 'digitaljs'; +import {yosys2digitaljs} from 'yosys2digitaljs'; + +import type {ForeignViewMessage} from '../../messages'; +import type {YosysRTL} from '../../types'; +import {BaseViewer} from '../base'; + +// TODO: better typing - should be fixed from our digitaljs fork +type Model = any; // eslint-disable-line @typescript-eslint/no-explicit-any + +interface ButtonCallback { + circuit: Circuit; + model: Model; + paper: any; // eslint-disable-line @typescript-eslint/no-explicit-any + navHistory: Model[]; +} + +const getSvg = (svgElem: Element, width: number, height: number): string => { + // Filter conveniently labeled foreign objects from element + const foreignElems = svgElem.getElementsByTagName('foreignObject'); + for (const elem of Array.from(foreignElems)) { + elem.remove(); + } + + // Set correct XML namespace + svgElem.removeAttribute('xmlns:xlink'); + svgElem.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + + // Correctly specify width / height to prevent clipping + svgElem.setAttribute('width', `${width}px`); + svgElem.setAttribute('height', `${height}px`); + + // Add XML header + return '\n' + svgElem.outerHTML; +}; + +export class DiagramViewer extends BaseViewer { + private subCircuitButtons = [ + { + id: 'exportSvg', + buttonText: 'SVG', + callback: ({model, paper}: ButtonCallback) => { + this.requestExport(paper.svg, `${model.get('label')}.svg`); + } + }, + { + id: 'showStats', + buttonText: '🔍', + callback: ({navHistory}: ButtonCallback) => { + const cellHistory = navHistory.map((model) => model.get('celltype') as string); + this.broadcastMessage({type: 'moduleFocus', breadcrumbs: cellHistory}); + } + } + ]; + + handleForeignViewMessage(message: ForeignViewMessage): void { + console.log('Foreign message:'); + console.log(message); + } + + async render(_isUpdate: boolean) { + // Convert from Yosys netlist to DigitalJS format + const digitalJs = yosys2digitaljs(this.data); + + // Initialize circuit + const circuit = new Circuit(digitalJs, {subcircuitButtons: this.subCircuitButtons}); + + // Clear + this.root.replaceChildren(); + + // Render actions + const elementActions = document.createElement('div'); + elementActions.style.marginBottom = '1rem'; + elementActions.innerHTML = /*html*/ ` + + Start + + + + Stop + + + + Export to SVG + + + `; + this.root.appendChild(elementActions); + + const buttonStart = document.getElementById('digitaljs-start'); + const buttonStop = document.getElementById('digitaljs-stop'); + const buttonExport = document.getElementById('digitaljs-export'); + + buttonStart?.addEventListener('click', () => circuit.start()); + buttonStop?.addEventListener('click', () => circuit.stop()); + buttonExport?.addEventListener('click', () => { + const svgElems = document.getElementsByTagName('svg'); + if (!svgElems) { + throw new Error('Could not find SVG element to export'); + } + const svgElem = svgElems[0]; + + this.requestExport(svgElem, 'topLevel.svg'); + }); + + circuit.on('changeRunning', () => { + if (circuit.running) { + buttonStart?.setAttribute('disabled', ''); + buttonStop?.removeAttribute('disabled'); + } else { + buttonStart?.removeAttribute('disabled'); + buttonStop?.setAttribute('disabled', ''); + } + }); + + // Render circuit + const elementCircuit = document.createElement('div'); + circuit.displayOn(elementCircuit); + this.root.appendChild(elementCircuit); + } + + private requestExport(elem: Element, defaultPath: string) { + const svgData = getSvg(elem.cloneNode(true) as Element, elem.clientWidth, elem.clientHeight); + this.sendMessage({ + type: 'requestSave', + data: { + fileContents: svgData, + defaultPath: defaultPath, + filters: {svg: ['.svg']} + } + }); + } +} diff --git a/views/digitaljs/src/viewers/index.ts b/views/digitaljs/src/viewers/index.ts new file mode 100644 index 0000000..1e17c1d --- /dev/null +++ b/views/digitaljs/src/viewers/index.ts @@ -0,0 +1,3 @@ +export {DiagramViewer} from './diagram'; +export {StatsViewer} from './stats'; +export {BaseViewer} from './base'; diff --git a/views/digitaljs/src/viewers/stats/elements/base.ts b/views/digitaljs/src/viewers/stats/elements/base.ts new file mode 100644 index 0000000..6abe5dc --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/base.ts @@ -0,0 +1,23 @@ +export abstract class CustomElement { + protected abstract rootElem: HTMLElement; + + private eventsElem: Element; + + constructor() { + this.eventsElem = document.createElement('events'); + } + + addEventListener(type: K, listener: (ev: EventsDirectory[K]) => void) { + this.eventsElem.addEventListener(type, (ev: CustomEventInit) => listener(ev.detail)); + } + + protected dispatchEvent(type: K, data: EventsDirectory[K]) { + this.eventsElem.dispatchEvent(new CustomEvent(type, {detail: data})); + } + + get element() { + return this.rootElem; + } + + abstract render(): void; +} diff --git a/views/digitaljs/src/viewers/stats/elements/datagrid.ts b/views/digitaljs/src/viewers/stats/elements/datagrid.ts new file mode 100644 index 0000000..ac3038d --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/datagrid.ts @@ -0,0 +1,419 @@ +import {CustomElement} from './base'; + +export type DataGridCell = Node | string; + +class DataGrid extends CustomElement { + protected rootElem: HTMLElement; + + protected cells: DataGridCell[][]; + + constructor(headers: string[] = []) { + super(); + + this.cells = [headers]; + this.rootElem = document.createElement('vscode-data-grid'); + } + + get width() { + return this.cells[0].length; + } + + get height() { + return this.cells.length; + } + + addColumn(contents: DataGridCell[], pos?: number): number { + contents = contents.slice(0, this.height); + if (contents.length < this.height) { + contents = contents.concat(new Array(this.height - contents.length)); + } + + if (pos === undefined) { + pos = this.width; + } + + for (let i = 0; i < this.height; i++) { + this.cells[i].splice(pos, 0, contents[i]); + } + + return pos; + } + + delColumn(pos: number) { + for (const row of this.cells) { + row.splice(pos, 1); + } + } + + addRow(contents: DataGridCell[], pos?: number): number { + contents = contents.slice(0, this.width); + if (contents.length < this.width) { + contents = contents.concat(new Array(this.height - contents.length)); + } + + if (pos === undefined) { + pos = this.height - 1; + } + + this.cells.splice(pos + 1, 0, contents); + + return pos; + } + + setCell(x: number, y: number, value: DataGridCell) { + this.cells[y][x] = value; + + const row = this.rootElem.querySelectorAll('vscode-data-grid-row')[y]; + if (!row) { + return; + } + const cell = row.querySelectorAll('vscode-data-grid-cell')[x]; + if (!cell) { + return; + } + cell.replaceChildren(); + cell.append(value); + } + + clearRows() { + this.cells.splice(1); + } + + render() { + const newRoot = document.createElement('vscode-data-grid'); + this.rootElem.replaceWith(newRoot); + this.rootElem = newRoot; + + // Headers + const headerElem = document.createElement('vscode-data-grid-row'); + headerElem.setAttribute('row-type', 'header'); + + for (let i = 0; i < this.width; i++) { + const cell = document.createElement('vscode-data-grid-cell'); + cell.setAttribute('cell-type', 'columnheader'); + cell.setAttribute('grid-column', (i + 1).toString()); + cell.append(this.cells[0][i]); + headerElem.appendChild(cell); + } + this.rootElem.appendChild(headerElem); + + // Rows + for (let rowi = 1; rowi < this.height; rowi++) { + const row = document.createElement('vscode-data-grid-row'); + + for (let coli = 0; coli < this.width; coli++) { + const cell = document.createElement('vscode-data-grid-cell'); + cell.setAttribute('grid-column', (coli + 1).toString()); + cell.append(this.cells[rowi][coli]); + row.appendChild(cell); + } + this.rootElem.appendChild(row); + } + } +} + +export interface DatagridSetting { + id: string; + text: string; + default: boolean; +} + +export interface InteractiveDatagridConfig { + columns: ColumnOption[] | null; + settingValues: Record | null; +} + +interface GridConfigUpdateEvent { + config: InteractiveDatagridConfig; +} +interface InteractiveDataGridEvents { + gridConfigUpdate: GridConfigUpdateEvent; +} + +export abstract class InteractiveDataGrid extends DataGrid< + InteractiveDataGridEvents +> { + private rows: RowItem[]; + private cols: ColumnOption[]; + + private actualRoot: HTMLElement | null; + + private settingElems: Map; + + constructor() { + super(); + + this.rows = []; + this.cols = []; + this.actualRoot = null; + + // Create setting checkboxes (store references) + this.settingElems = new Map(); + for (const setting of this.getSettings()) { + const checkbox = document.createElement('vscode-checkbox') as HTMLInputElement; + checkbox.addEventListener('change', (_) => { + this.dispatchEvent('gridConfigUpdate', {config: this.getConfig()}); + + this.update(); + }); + if (setting.default) { + checkbox.setAttribute('checked', 'true'); + } + checkbox.textContent = setting.text; + this.settingElems.set(setting.id, checkbox); + } + + // The object renders to this.rootElem, but we want elements outside + // the datagrid. This actualRoot is just a div containing those elements + // and the rendered rootElem so we can 'adopt' them into this class. + this.actualRoot = null; + + this.reset(true, true); + } + + override get element(): HTMLElement { + if (this.actualRoot === null) { + this.actualRoot = this.createRoot(); + } + return this.actualRoot; + } + + getConfig(): InteractiveDatagridConfig { + return { + columns: this.cols, + settingValues: Object.fromEntries( + Array.from(this.settingElems.entries()).map(([id, elem]) => [id, elem.checked]) + ) + }; + } + + setConfig(config?: InteractiveDatagridConfig) { + if (!config) { + return; + } + + if (config.columns) { + this.reset(true, false); + + for (const col of config.columns) { + this.addCol(col); + } + } + + for (const [key, value] of Object.entries(config.settingValues ?? {})) { + this.setSetting(key, value); + } + + this.update(); + } + + protected getSetting(settingId: string): boolean { + return this.settingElems.get(settingId)?.checked || false; + } + + protected setSetting(settingId: string, value: boolean) { + const settingElem = this.settingElems.get(settingId); + if (!settingElem) { + return; + } + + if (value) { + settingElem.setAttribute('checked', 'true'); + } else { + settingElem.removeAttribute('checked'); + } + } + + // *** Start overrideable methods *** + + update() { + this.render(); + } + + abstract getIdentifier(): string; + + protected abstract getSettings(): DatagridSetting[]; + + protected abstract getDefaultOptions(): ColumnOption[]; + + protected abstract getAvailableOptions(): ColumnOption[]; + + protected abstract getNewOption(): ColumnOption; + + protected abstract getOptionName(option: ColumnOption): string; + + protected abstract getValue(item: RowItem, option: ColumnOption): DataGridCell; + + protected getGridHeaderElement(): HTMLElement { + const root = document.createElement('div'); + + // Add settings + for (const checkbox of this.settingElems.values()) { + root.appendChild(checkbox); + root.appendChild(document.createElement('br')); + } + + if (this.getSettings().length !== 0) { + root.appendChild(document.createElement('vscode-divider')); + root.appendChild(document.createElement('br')); + } + + // Add column button + const addColBtn = document.createElement('vscode-button'); + addColBtn.innerHTML = /* html */ ``; + addColBtn.addEventListener('click', (_ev) => { + this.addCol(); + + this.dispatchEvent('gridConfigUpdate', {config: this.getConfig()}); + }); + root.appendChild(addColBtn); + + // Reset button + const resetBtn = document.createElement('vscode-button'); + resetBtn.innerHTML = /* html */ ``; + resetBtn.addEventListener('click', (_ev) => { + this.reset(true, false); + + this.dispatchEvent('gridConfigUpdate', {config: this.getConfig()}); + }); + root.appendChild(resetBtn); + + return root; + } + + // *** End overrideable methods *** + + private createRoot() { + const root = document.createElement('div'); + root.style.width = '100%'; + + // Custom content + root.appendChild(this.getGridHeaderElement()); + + // The actual DataGrid + root.appendChild(super.element); + + return root; + } + + protected fillCell(x: number, y: number) { + const defColsLen = this.getDefaultOptions().length; + if (x < 0 || x >= defColsLen + this.cols.length || y < 1 || y - 1 >= this.rows.length) { + throw new Error('Out of bounds!'); + } + + const option = this.getDefaultOptions().concat(this.cols)[x]; + const item = this.rows[y - 1]; + const value = this.getValue(item, option); + + return super.setCell(x, y, value); + } + + addRowItem(item: RowItem): number { + this.rows.push(item); + const y = super.addRow([]); + + if (y !== 0) { + for (let x = 0; x < this.width; x++) { + this.fillCell(x, y); + } + } + + return y; + } + + addCol(option?: ColumnOption, onlyDraw = false): number { + if (option === undefined) { + option = this.getNewOption(); + } + if (!onlyDraw) { + this.cols.push(option); + } + + const header = document.createElement('div'); + + const delBtn = document.createElement('vscode-button'); + delBtn.innerHTML = /* html */ ``; + delBtn.addEventListener('click', (_ev) => { + const coli = this.cells[0].indexOf(header); + const defColCount = this.getDefaultOptions().length; + if (coli >= defColCount) { + this.cols.splice(coli - defColCount, 1); + this.delColumn(coli); + this.render(); + + this.dispatchEvent('gridConfigUpdate', {config: this.getConfig()}); + } + }); + header.appendChild(delBtn); + + const dropdown = document.createElement('vscode-dropdown') as HTMLSelectElement; + for (const avOption of this.getAvailableOptions()) { + const opt = document.createElement('vscode-option'); + opt.textContent = this.getOptionName(avOption); + if (avOption === option) { + opt.setAttribute('selected', ''); + } + dropdown.appendChild(opt); + } + header.appendChild(dropdown); + + dropdown.addEventListener('change', (_ev) => { + const headerIndex = this.cells[0].indexOf(header); + if (headerIndex === -1) return; + + const newOption = this.getAvailableOptions()[dropdown.selectedIndex]; + + // Actually update the column and re-render + this.cols.splice(headerIndex - this.getDefaultOptions().length, 1, newOption); + this.render(); + + this.dispatchEvent('gridConfigUpdate', {config: this.getConfig()}); + }); + + const pos = super.addColumn([header]); + + this.render(); + + for (let y = 1; y < this.height; y++) { + this.fillCell(pos, y); + } + + return pos; + } + + reset(resetCols: boolean, resetRows: boolean) { + if (resetCols) this.cols = []; + if (resetRows) this.rows = []; + + // Clear all row/cols + this.clearRows(); + while (this.width > 0) { + this.delColumn(0); + } + + // Restore them again + for (const option of this.getDefaultOptions()) { + super.addColumn([this.getOptionName(option)]); + } + for (const col of this.cols) { + this.addCol(col, true); + } + + this.render(); + } + + render() { + this.clearRows(); + + this.rows.forEach(() => super.addRow([])); + + for (let y = 1; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + this.fillCell(x, y); + } + } + + super.render(); + } +} diff --git a/views/digitaljs/src/viewers/stats/elements/explorer.ts b/views/digitaljs/src/viewers/stats/elements/explorer.ts new file mode 100644 index 0000000..8822647 --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/explorer.ts @@ -0,0 +1,181 @@ +import {type Module, type ModuleStatId, getModuleStatIds, getModuleStatName} from '../modules'; + +import {type DataGridCell, type DatagridSetting, InteractiveDataGrid} from './datagrid'; +import {getPercentage} from './util'; + +interface ModuleExplorerRowCurrent { + type: 'current'; +} +interface ModuleExplorerRowPrimitive { + type: 'primitive'; + primitive: string; +} +interface ModuleExplorerRowChild { + type: 'child'; + module: Module; +} +type ModuleExplorerRowItems = ModuleExplorerRowCurrent | ModuleExplorerRowPrimitive | ModuleExplorerRowChild; +type ModuleExplorerOptions = 'name' | 'count' | ModuleStatId; + +export class ModuleExplorerGrid extends InteractiveDataGrid { + private breadcrumbHeader: HTMLParagraphElement; + + private moduleBreadcrumbs: Module[]; + + constructor(initModule: Module) { + super(); + + this.breadcrumbHeader = document.createElement('p'); + + this.moduleBreadcrumbs = [initModule]; + + this.update(); + } + + getIdentifier(): string { + return 'module-explorer'; + } + + get curModule(): Module { + return this.moduleBreadcrumbs[this.moduleBreadcrumbs.length - 1]; + } + + protected override getGridHeaderElement(): HTMLElement { + const headerElem = super.getGridHeaderElement(); + + headerElem.appendChild(this.breadcrumbHeader); + + return headerElem; + } + + navigateSplice(i: number) { + if (i === 0) { + throw new Error('Cannot navigate above top-level module!'); + } + this.moduleBreadcrumbs.splice(i); + } + + navigate(module: Module) { + if (Array.from(this.curModule.children.keys()).indexOf(module) === -1) { + throw new Error(`Cannot navigate to module "${module.name}": not a child of "${this.curModule.name}"`); + } + this.moduleBreadcrumbs.push(module); + } + + update() { + // Update breadcrumbs + this.breadcrumbHeader.replaceChildren(); + + for (let i = 0; i < this.moduleBreadcrumbs.length; i++) { + const link = document.createElement('vscode-link'); + link.textContent = this.moduleBreadcrumbs[i].name; + link.addEventListener('click', (_ev) => { + this.navigateSplice(i + 1); + this.update(); + }); + this.breadcrumbHeader.append(link); + + if (i != this.moduleBreadcrumbs.length - 1) { + this.breadcrumbHeader.append(' > '); + } + } + + // Update grid + this.reset(false, true); + this.addRowItem({type: 'current'}); + for (const prim of this.curModule.primitives.keys()) { + this.addRowItem({type: 'primitive', primitive: prim}); + } + for (const subCircuit of this.curModule.children.keys()) { + this.addRowItem({type: 'child', module: subCircuit}); + } + + super.update(); + } + + protected getSettings(): DatagridSetting[] { + return [{id: 'count-all', text: 'Count all module occurences', default: false}]; + } + + protected getDefaultOptions(): ModuleExplorerOptions[] { + return ['name', 'count']; + } + + protected getAvailableOptions(): ModuleExplorerOptions[] { + return getModuleStatIds(); + } + + protected getNewOption(): ModuleExplorerOptions { + return this.getAvailableOptions()[0]; + } + + protected getOptionName(option: ModuleExplorerOptions): string { + switch (option) { + case 'name': + return 'Module name'; + case 'count': + return 'Count'; + default: + return getModuleStatName(option); + } + } + + protected getValue(item: ModuleExplorerRowItems, option: ModuleExplorerOptions): DataGridCell { + switch (item.type) { + case 'current': + return this.getValueCurrent(item, option); + case 'primitive': + return this.getValuePrimitive(item, option); + case 'child': + return this.getValueChild(item, option); + } + } + + private getValueCurrent(_item: ModuleExplorerRowCurrent, option: ModuleExplorerOptions): DataGridCell { + switch (option) { + case 'name': + return '< Current Module >'; + case 'count': + return '-'; + default: + return this.curModule.globalStats[option].toString(); + } + } + + private getValuePrimitive(item: ModuleExplorerRowPrimitive, option: ModuleExplorerOptions): DataGridCell { + switch (option) { + case 'name': + return '$' + item.primitive; + case 'count': + return this.curModule.primitives.get(item.primitive)?.toString() ?? '-'; + default: + return ''; + } + } + + private getValueChild(item: ModuleExplorerRowChild, option: ModuleExplorerOptions): DataGridCell { + switch (option) { + case 'name': { + const link = document.createElement('vscode-link'); + link.textContent = item.module.name; + link.addEventListener('click', (_ev) => { + this.navigate(item.module); + this.update(); + }); + return link; + } + case 'count': + return this.curModule.children.get(item.module)?.toString() ?? '-'; + default: { + let stat1 = item.module.globalStats[option]; + if (this.getSetting('count-all')) { + stat1 *= this.curModule.children.get(item.module) || 1; + } + + const stat2 = this.curModule.globalStats[option]; + + return `${stat1} (${getPercentage(stat1, stat2)}%)`; + } + } + } +} diff --git a/views/digitaljs/src/viewers/stats/elements/index.ts b/views/digitaljs/src/viewers/stats/elements/index.ts new file mode 100644 index 0000000..15ae558 --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/index.ts @@ -0,0 +1,4 @@ +export {ModuleOverviewGrid} from './moduleoverview'; +export {TabsContainer} from './tabs'; +export {ModuleExplorerGrid} from './explorer'; +export {PrimitivesOverviewGrid} from './primitives'; diff --git a/views/digitaljs/src/viewers/stats/elements/moduleoverview.ts b/views/digitaljs/src/viewers/stats/elements/moduleoverview.ts new file mode 100644 index 0000000..b6c6320 --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/moduleoverview.ts @@ -0,0 +1,74 @@ +import {type Module, type ModuleStatId, getModuleStatIds, getModuleStatName} from '../modules'; + +import {type DataGridCell, type DatagridSetting, InteractiveDataGrid} from './datagrid'; +import {getPercentage} from './util'; + +type ModuleOverviewOptions = 'name' | 'count' | ModuleStatId; + +export class ModuleOverviewGrid extends InteractiveDataGrid { + private modules: Module[]; + + constructor(modules: Module[]) { + super(); + + this.modules = modules; + + for (const module of modules) { + this.addRowItem(module); + } + } + + getIdentifier(): string { + return 'module-overview'; + } + + protected getSettings(): DatagridSetting[] { + return [ + {id: 'count-recursive', text: 'Count submodules recursively', default: true}, + {id: 'count-all', text: 'Count all module occurences', default: false} + ]; + } + + protected getDefaultOptions(): ModuleOverviewOptions[] { + return ['name', 'count']; + } + + protected getAvailableOptions(): ModuleOverviewOptions[] { + return getModuleStatIds(); + } + + protected getNewOption(): ModuleOverviewOptions { + return this.getAvailableOptions()[0]; + } + + protected getOptionName(option: ModuleOverviewOptions): string { + switch (option) { + case 'name': + return 'Module name'; + case 'count': + return 'Count'; + default: + return getModuleStatName(option); + } + } + + protected getValue(item: Module, option: ModuleOverviewOptions): DataGridCell { + switch (option) { + case 'name': + return item.name; + case 'count': + return this.modules[0].globalChildren.get(item)?.toString() ?? '-'; + default: { + const countVar = this.getSetting('count-recursive') ? 'globalStats' : 'stats'; + let stat1 = item[countVar][option]; + if (this.getSetting('count-all')) { + stat1 *= this.modules[0].globalChildren.get(item) || 1; + } + + const stat2 = this.modules[0].globalStats[option]; + + return `${stat1}/${stat2} (${getPercentage(stat1, stat2)}%)`; + } + } + } +} diff --git a/views/digitaljs/src/viewers/stats/elements/primitives.ts b/views/digitaljs/src/viewers/stats/elements/primitives.ts new file mode 100644 index 0000000..ca6226f --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/primitives.ts @@ -0,0 +1,101 @@ +import {type Module} from '../modules'; + +import {type DataGridCell, type DatagridSetting, InteractiveDataGrid, type InteractiveDatagridConfig} from './datagrid'; +import {getPercentage} from './util'; + +// TODO: typing for primitives (needs exhaustive list) +type PrimitivesOverviewOptions = 'name' | 'count' | string; + +export class PrimitivesOverviewGrid extends InteractiveDataGrid { + private modules: Module[]; + + constructor(modules: Module[]) { + super(); + + this.modules = modules; + + this.update(); + + const prims = this.getAvailableOptions(); + for (let i = 0; i < prims.length && i < 5; i++) { + this.addCol(prims[i]); + } + } + + getIdentifier(): string { + return 'primitives-overview'; + } + + // Config get/set overrides to prevent column back-ups and restores + getConfig(): InteractiveDatagridConfig { + const conf = super.getConfig(); + conf.columns = []; + return conf; + } + setConfig(config?: InteractiveDatagridConfig | undefined): void { + if (config) { + config.columns = null; + } + super.setConfig(config); + } + + protected getSettings(): DatagridSetting[] { + return [ + {id: 'count-recursive', text: 'Count submodules recursively', default: true}, + {id: 'count-all', text: 'Count all module occurences', default: false} + ]; + } + + protected getDefaultOptions(): PrimitivesOverviewOptions[] { + return ['name', 'count']; + } + + protected getAvailableOptions(): PrimitivesOverviewOptions[] { + return Array.from(this.modules[0].globalPrimitives.keys()); + } + + protected getNewOption(): PrimitivesOverviewOptions { + return this.getAvailableOptions()[0]; + } + + protected getOptionName(option: string): string { + if (option === 'name') { + return 'Module name'; + } else if (option === 'count') { + return 'Count'; + } + return '$' + option; + } + + protected getValue(item: Module, option: PrimitivesOverviewOptions): DataGridCell { + if (option === 'name') { + return item.name; + } else if (option === 'count') { + return this.modules[0].globalChildren.get(item)?.toString() ?? '-'; + } + + const totalCount = this.modules[0].globalPrimitives.get(option); + + const countVar = this.getSetting('count-recursive') ? 'globalPrimitives' : 'primitives'; + let count = item[countVar].get(option); + if (count === undefined || !totalCount) { + return '-'; + } + + if (this.getSetting('count-all')) { + count *= this.modules[0].globalChildren.get(item) || 1; + } + + return `${count} (${getPercentage(count, totalCount)}%)`; + } + + override update() { + this.reset(false, true); + + for (const module of this.modules) { + this.addRowItem(module); + } + + this.render(); + } +} diff --git a/views/digitaljs/src/viewers/stats/elements/tabs.ts b/views/digitaljs/src/viewers/stats/elements/tabs.ts new file mode 100644 index 0000000..5d9d897 --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/tabs.ts @@ -0,0 +1,62 @@ +import {CustomElement} from './base'; + +interface Tab { + id: string; + title: string; + element: CustomElement; +} + +export class TabsContainer extends CustomElement> { + protected rootElem: HTMLElement; + + private tabs: Map>; + private tabHeaders: Map; + + constructor(tabs: Tab[]) { + super(); + + this.tabs = new Map(); + for (const tab of tabs) { + this.tabs.set(tab.id, tab); + } + this.tabHeaders = new Map(); + + this.rootElem = this.createRoot(); + } + + private createRoot(): HTMLElement { + const root = document.createElement('vscode-panels'); + + for (const [id, tab] of this.tabs.entries()) { + const tabElem = document.createElement('vscode-panel-tab'); + tabElem.id = `tab-${id}`; + tabElem.textContent = tab.title; + root.appendChild(tabElem); + + this.tabHeaders.set(id, tabElem); + } + for (const [id, tab] of this.tabs.entries()) { + const view = document.createElement('vscode-panel-view'); + view.id = `view-${id}`; + view.appendChild(tab.element.element); + root.appendChild(view); + } + + return root; + } + + focusTab(id: string) { + const tabHeader = this.tabHeaders.get(id); + if (!tabHeader) { + throw new Error('Invalid tab ID to focus!'); + } + + tabHeader.click(); + } + + render(): void { + for (const tab of this.tabs.values()) { + tab.element.render(); + } + } +} diff --git a/views/digitaljs/src/viewers/stats/elements/util.ts b/views/digitaljs/src/viewers/stats/elements/util.ts new file mode 100644 index 0000000..fa593d4 --- /dev/null +++ b/views/digitaljs/src/viewers/stats/elements/util.ts @@ -0,0 +1,7 @@ +export const getPercentage = (val1: number, val2: number): number => { + if (val1 === 0 || val2 === 0) { + return 0; + } + + return Math.floor((val1 / val2) * 10_000) / 100; +}; diff --git a/views/digitaljs/src/viewers/stats/index.ts b/views/digitaljs/src/viewers/stats/index.ts new file mode 100644 index 0000000..e33760d --- /dev/null +++ b/views/digitaljs/src/viewers/stats/index.ts @@ -0,0 +1,96 @@ +import type {View} from '../../main'; +import type {ForeignViewMessage} from '../../messages'; +import type {YosysStats} from '../../types'; +import {BaseViewer} from '../base'; + +import {ModuleExplorerGrid, ModuleOverviewGrid, PrimitivesOverviewGrid, TabsContainer} from './elements'; +import type {InteractiveDataGrid, InteractiveDatagridConfig} from './elements/datagrid'; +import {type Module, buildModuleTree} from './modules'; + +export class StatsViewer extends BaseViewer { + private modules: Module[]; + + private tabsContainer: TabsContainer; + + private moduleOverview: ModuleOverviewGrid; + private moduleExplorer: ModuleExplorerGrid; + private primitivesOverview: PrimitivesOverviewGrid; + + constructor(mainView: View, initData: YosysStats) { + super(mainView, initData); + + this.modules = buildModuleTree(this.data.modules); + if (!this.modules) { + throw new Error('No circuit modules found to display!'); + } + + this.moduleOverview = new ModuleOverviewGrid(this.modules); + this.setupConfigStore(this.moduleOverview); + + this.moduleExplorer = new ModuleExplorerGrid(this.modules[0]); + this.setupConfigStore(this.moduleExplorer); + + this.primitivesOverview = new PrimitivesOverviewGrid(this.modules); + this.setupConfigStore(this.primitivesOverview); + + this.tabsContainer = new TabsContainer([ + {id: 'overview', title: 'Overview', element: this.moduleOverview}, + {id: 'explorer', title: 'Explorer', element: this.moduleExplorer}, + {id: 'primitives', title: 'Primitives', element: this.primitivesOverview} + ]); + } + + handleForeignViewMessage(message: ForeignViewMessage): void { + if (message.type === 'moduleFocus') { + // Focus explorer navigator according to breadcrumbs from djs viewer + this.moduleExplorer.navigateSplice(1); + for (const moduleName of message.breadcrumbs) { + const module = this.getModule(moduleName); + if (!module) { + throw new Error(`Unknown module: ${moduleName}`); + } + this.moduleExplorer.navigate(module); + } + + this.moduleExplorer.update(); + + this.tabsContainer.focusTab('explorer'); + } + } + + private setupConfigStore(element: InteractiveDataGrid) { + const storeId = `yosys.stats.${element.getIdentifier()}.settings`; + + // Register callbacks for config back-up + element.addEventListener('gridConfigUpdate', (event) => this.storeValue(storeId, event.config)); + + // Restore initial config + this.getValue(storeId).then((value) => { + element.setConfig(value as InteractiveDatagridConfig); + }); + } + + private getModule(name: string): Module | null { + for (const module of this.modules) { + if (module.name === name) { + return module; + } + } + return null; + } + + async render(isUpdate: boolean): Promise { + if (isUpdate) { + // All elements are dynamically resized so we don't need to redraw + return; + } + this.root.replaceChildren(); + + const header = document.createElement('h1'); + header.textContent = 'Module Statistics'; + this.root.appendChild(header); + + this.root.appendChild(this.tabsContainer.element); + this.tabsContainer.render(); + } +} diff --git a/views/digitaljs/src/viewers/stats/modules.ts b/views/digitaljs/src/viewers/stats/modules.ts new file mode 100644 index 0000000..372b6cf --- /dev/null +++ b/views/digitaljs/src/viewers/stats/modules.ts @@ -0,0 +1,179 @@ +import type {YosysModuleStats} from '../../types'; + +export enum ModuleStatId { + memoryCount = 'memoryCount', + memoryBitCount = 'memoryBitCount', + processCount = 'processCount', + cellCount = 'cellCount', + wireCount = 'wireCount', + wireCountPub = 'wireCountPub' +} + +type ModuleStats = Record; + +export const getModuleStatIds = (): ModuleStatId[] => { + return Object.values(ModuleStatId); +}; + +export const getModuleStatName = (stat: ModuleStatId): string => { + switch (stat) { + case ModuleStatId.memoryCount: + return 'Memories'; + case ModuleStatId.memoryBitCount: + return 'Memory Bits'; + case ModuleStatId.processCount: + return 'Processes'; + case ModuleStatId.cellCount: + return 'Cells'; + case ModuleStatId.wireCount: + return 'Wires'; + case ModuleStatId.wireCountPub: + return 'Wires (Pub.)'; + } +}; + +export class Module { + private parents: Set; + + public readonly name: string; + + private _primitives: Map; + private _globalPrimitives: Map; + + private _children: Map; + private _globalChildren: Map; + + private _stats: ModuleStats; + private _globalStats: ModuleStats; + + constructor(name: string, stats: YosysModuleStats) { + this.name = name; + + this._primitives = new Map(); + this._globalPrimitives = new Map(); + + this._children = new Map(); + this._globalChildren = new Map(); + + this._stats = { + memoryCount: stats.num_memories, + memoryBitCount: stats.num_memory_bits, + processCount: stats.num_processes, + cellCount: stats.num_cells, + wireCount: stats.num_wires, + wireCountPub: stats.num_pub_wires + }; + this._globalStats = structuredClone(this._stats); + + this.parents = new Set(); + } + + get isTopLevel(): boolean { + return this.parents.size === 0; + } + + get primitives(): Map { + return this._primitives; + } + + get globalPrimitives(): Map { + return this._globalPrimitives; + } + + get children(): Map { + return this._children; + } + + get globalChildren(): Map { + return this._globalChildren; + } + + get stats(): ModuleStats { + return this._stats; + } + + get globalStats(): ModuleStats { + return this._globalStats; + } + + addPrimitive(prim: string, count: number) { + this._primitives.set(prim, (this._primitives.get(prim) ?? 0) + count); + this._globalPrimitives.set(prim, (this._globalPrimitives.get(prim) ?? 0) + count); + } + + addChild(module: Module, count: number) { + this._children.set(module, (this._children.get(module) ?? 0) + count); + module.addParent(this); + + // Update global stats + this._globalStats.memoryCount += count * module.globalStats.memoryCount; + this._globalStats.memoryBitCount += count * module.globalStats.memoryBitCount; + this._globalStats.cellCount += count * module.globalStats.cellCount; + + // Update global children + this._globalChildren.set(module, (this._globalChildren.get(module) ?? 0) + count); + for (const [gloModule, gloCount] of module.globalChildren.entries()) { + this._globalChildren.set(gloModule, (this._globalChildren.get(gloModule) ?? 0) + count * gloCount); + } + + // Update global primitives + for (const [gloPrim, gloCount] of module.globalPrimitives.entries()) { + this._globalPrimitives.set(gloPrim, (this._globalPrimitives.get(gloPrim) ?? 0) + count * gloCount); + } + } + + protected addParent(module: Module) { + this.parents.add(module); + } +} + +const resolveModuleDeps = (name: string, stats: YosysModuleStats, resolved: Map): Module | null => { + const module = new Module(name, stats); + for (const [depName, count] of Object.entries(stats.num_cells_by_type)) { + if (depName.startsWith('$')) { + module.addPrimitive(depName.slice(1), count); + continue; + } + + const childDep = resolved.get(depName); + if (!childDep) { + // Cannot fully be resolved yet + return null; + } + module.addChild(childDep, count); + } + + return module; +}; + +export const buildModuleTree = (modules: Record): Module[] => { + const modEntries = Object.entries(modules); + const modCount = modEntries.length; + if (!modCount) { + return []; + } + + const resolved = new Map(); + while (resolved.size < modCount) { + const oldSize = resolved.size; + for (let i = modEntries.length - 1; i >= 0; i--) { + const [modName, stats] = modEntries[i]; + const sanitizedName = modName.slice(1); // Strip leading "\" + if (sanitizedName in resolved.keys()) { + continue; + } + + const module = resolveModuleDeps(sanitizedName, stats, resolved); + if (!module) { + continue; + } + resolved.set(sanitizedName, module); + modEntries.splice(i, 1); + } + if (resolved.size <= oldSize) { + throw new Error('Cyclic dependency loop detected; cannot resolve module dependency tree!'); + } + } + + return Array.from(resolved.values()).reverse(); +};