From ef122d5b38f5668e56f2fd6fd22dc08176fea963 Mon Sep 17 00:00:00 2001 From: dlilley Date: Wed, 6 Nov 2024 08:50:28 -0500 Subject: [PATCH 1/7] v1.2.7 commit --- .vscodeignore | 8 +- CHANGELOG.md | 17 +- README.md | 10 +- package-lock.json | 4 +- package.json | 31 ++- server | 2 +- src/Notifications.ts | 11 +- src/extension.ts | 118 ++++++++++- src/styling/Decorations.ts | 40 ++++ src/styling/LineRangeTree.ts | 116 +++++++++++ src/styling/SectionStylingService.ts | 291 +++++++++++++++++++++++++++ src/styling/StylingInterfaces.ts | 22 ++ src/utils/BrowserUtils.ts | 50 +++++ src/utils/LicensingUtils.ts | 83 ++++++++ 14 files changed, 776 insertions(+), 27 deletions(-) create mode 100644 src/styling/Decorations.ts create mode 100644 src/styling/LineRangeTree.ts create mode 100644 src/styling/SectionStylingService.ts create mode 100644 src/styling/StylingInterfaces.ts create mode 100644 src/utils/BrowserUtils.ts create mode 100644 src/utils/LicensingUtils.ts diff --git a/.vscodeignore b/.vscodeignore index c68f7d1..6370fa6 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -26,4 +26,10 @@ server/.git server/.github server/.gitattributes server/.gitignore -server/webpack.config.js \ No newline at end of file +server/webpack.config.js + +# Skip packaging the the source code in licensing/gui folder as it is already built and moved into +# out/ directory by the "compile" script in server/package.json. + +# The server/src/licensing/*.ts files compilation is already handled by server/tsconfig.json +server/src/licensing/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b2e13..1ad11ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.7] - 2024-11-06 + +### Added +- Visual indication of code sections +- Enable browser-based sign in using the `signIn` setting +- Specify the maximum file size for code analysis using the new `maxFileSizeForAnalysis` setting +- Linting support in untitled files and in MATLAB files with different file extensions + +### Fixed +- The `installPath` setting no longer syncs between machines + ## [1.2.6] - 2024-09-20 ### Fixed @@ -18,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Symbol rename support -- Enables users to hide "feature not available" error popups +- Enable hiding "feature not available" error popups - Provides context menu option to add selected folder and subfolders to the path ### Fixed @@ -27,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.4] - 2024-07-12 ### Added -- Enable users to specify workspace-specific MATLAB install paths +- Enable specifying workspace-specific MATLAB install paths - Improvements to code folding (requires MATLAB R2024b or later) ### Fixed @@ -42,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This extension will no longer support MATLAB R2021a in a future release. To make use of the advanced features of the extension or run MATLAB code, you will need to have MATLAB R2021b or later installed. ### Added -- Popups will be shown to inform the user when the connected MATLAB is not supported by the extension, or if support is planned to be removed in a future update. +- Popups will be shown to inform when the connected MATLAB is not supported by the extension, or if support is planned to be removed in a future update. ### Fixed - Resolved issue with connecting to Intel MATLAB installation on Apple Silicon machines diff --git a/README.md b/README.md index 1669ebe..b35efb8 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,17 @@ You can help improve the extension by sending user experience information to Mat For more information, see the [MathWorks Privacy Policy](https://www.mathworks.com/company/aboutus/policies_statements.html). -### MATLAB Show Feature Not Available Error +### MATLAB Show Feature Not Available Error Setting By default, the extension displays an error when a feature requires MATLAB and MATLAB is unable to start. To not display an error, set the `matlab.showFeatureNotAvailableError` setting to `false`. +### MATLAB Max File Size for Analysis Setting +By default, the extension analyzes all files, regardless of their size, for features such as linting, code navigation, and symbol renaming. To limit the maximum number of characters a file can contain, set the `matlab.maxFileSizeForAnalysis` setting. For example, to limit the number of characters to 50,000, set the `matlab.maxFileSizeForAnalysis` setting to `50000`. If a file contains more than the maximum number of characters, features such as linting, code navigation, and symbol renaming are disabled for that file. To remove the limit and analyze all files regardless of their size, set the `matlab.maxFileSizeForAnalysis` setting to `0`. + +### MATLAB Sign In Setting +By default, the extension assumes that the MATLAB installation specified in the Install Path setting is activated. + +To enable browser-based sign in to your MathWorks account using the Online License Manager or a Network License Manager, set the `matlab.signIn` setting to true. When this setting is enabled, the extension prompts you to sign in when it starts MATLAB. + ## Troubleshooting If the MATLAB install path is not properly configured, you get an error when you try to use certain advanced features, such as document formatting and code navigation. diff --git a/package-lock.json b/package-lock.json index d4ef4b7..f92cc58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "language-matlab", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "language-matlab", - "version": "1.2.6", + "version": "1.2.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7e15772..bb21888 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Edit MATLAB code with syntax highlighting, linting, navigation support, and more", "icon": "public/L-Membrane_RGB_128x128.png", "license": "MIT", - "version": "1.2.6", + "version": "1.2.7", "engines": { "vscode": "^1.67.0" }, @@ -61,6 +61,10 @@ "command": "matlab.changeDirectory", "title": "MATLAB: Change current directory" }, + { + "command": "matlab.enableSignIn", + "title": "MATLAB: Manage Sign In Options" + }, { "command": "matlab.resetDeprecationPopups", "title": "MATLAB: Reset Deprecation Warning Popups" @@ -156,7 +160,7 @@ "MATLAB.installPath": { "type": "string", "markdownDescription": "The full path to the top-level directory of the MATLAB installation you want to use with this extension. You can determine the full path to your MATLAB installation using the `matlabroot` command in MATLAB. For more information, refer to the [README](https://github.com/mathworks/MATLAB-extension-for-vscode/blob/main/README.md). This setting can be specified for both the user and workspace setting scopes using the User and Workspace tabs above.", - "scope": "window" + "scope": "machine-overridable" }, "MATLAB.matlabConnectionTiming": { "type": "string", @@ -190,14 +194,27 @@ "usesOnlineServices" ] }, + "MATLAB.signIn": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable this option to present Sign In Options for unactivated MATLAB installations.", + "scope": "machine" + }, "MATLAB.showFeatureNotAvailableError": { "type": "boolean", "default": true, "description": "Display an error when a feature requires MATLAB and MATLAB is unable to start.", "scope": "window" + }, + "MATLAB.maxFileSizeForAnalysis": { + "type": "number", + "default": 0, + "markdownDescription": "The maximum number of characters a file can contain for features such as linting, code navigation, and symbol renaming to be enabled. Use `0` for no limit.", + "scope": "window" } } }, + "languages": [ { "id": "matlab", @@ -249,7 +266,7 @@ "test-smoke": "npm run test-setup && node ./out/test/smoke/runTest.js", "test-ui": "npm run test-setup && node ./out/test/ui/runTest.js", "test": "npm run test-smoke && npm run test-ui", - "postinstall": "cd server && npm install && cd ..", + "postinstall": "cd server && npm install && cd src/licensing/ && npm install && cd gui && npm install && cd ../../..", "package": "vsce package" }, "devDependencies": { @@ -276,11 +293,5 @@ "dependencies": { "node-fetch": "^2.6.6", "vscode-languageclient": "^8.0.2" - }, - "__metadata": { - "id": "a41a2325-daf7-4cf4-beee-6c76190ad9fc", - "publisherDisplayName": "MathWorks", - "publisherId": "77ca8d5e-8b29-492a-8efb-4bd3de0c10c2", - "isPreReleaseVersion": false } -} \ No newline at end of file +} diff --git a/server b/server index bf34646..2245fbe 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit bf34646e9afbb21a45db1977c23eaa7a768ecd76 +Subproject commit 2245fbecbe4059f692616f868b4d7fc9e61a0d43 diff --git a/src/Notifications.ts b/src/Notifications.ts index 1d63e53..6ecaef6 100644 --- a/src/Notifications.ts +++ b/src/Notifications.ts @@ -29,7 +29,16 @@ enum Notification { MVMStateChange = 'mvmStateChange', // Telemetry - LogTelemetryData = 'telemetry/logdata' + LogTelemetryData = 'telemetry/logdata', + + // Sections generated for Section Styling + MatlabSections = 'matlab/sections', + + // Licensing + LicensingServerUrl = 'licensing/server/url', + LicensingData = 'licensing/data', + LicensingDelete = 'licensing/delete', + LicensingError = 'licensing/error' } export default Notification diff --git a/src/extension.ts b/src/extension.ts index c960c7a..9733141 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,10 +12,10 @@ import { Notifier } from './commandwindow/Utilities' import TerminalService from './commandwindow/TerminalService' import Notification from './Notifications' import ExecutionCommandProvider from './commandwindow/ExecutionCommandProvider' +import * as LicensingUtils from './utils/LicensingUtils' import DeprecationPopupService from './DeprecationPopupService' - +import SectionStylingService from './styling/SectionStylingService' let client: LanguageClient - const OPEN_SETTINGS_ACTION = 'workbench.action.openSettings' const MATLAB_INSTALL_PATH_SETTING = 'matlab.installPath' @@ -27,10 +27,15 @@ export const CONNECTION_STATUS_LABELS = { const CONNECTION_STATUS_COMMAND = 'matlab.changeMatlabConnection' export let connectionStatusNotification: vscode.StatusBarItem +// Command to enable or disable Sign In options for MATLAB +const MATLAB_ENABLE_SIGN_IN_COMMAND = 'matlab.enableSignIn' + let telemetryLogger: TelemetryLogger let deprecationPopupService: DeprecationPopupService +let sectionStylingService: SectionStylingService; + let mvm: MVM; let terminalService: TerminalService; let executionCommandProvider: ExecutionCommandProvider; @@ -53,9 +58,20 @@ export async function activate (context: vscode.ExtensionContext): Promise connectionStatusNotification.text = CONNECTION_STATUS_LABELS.NOT_CONNECTED connectionStatusNotification.command = CONNECTION_STATUS_COMMAND connectionStatusNotification.show() - context.subscriptions.push(connectionStatusNotification) + context.subscriptions.push(connectionStatusNotification) context.subscriptions.push(vscode.commands.registerCommand(CONNECTION_STATUS_COMMAND, () => handleChangeMatlabConnection())) + // Event handler when VSCode configuration is changed by the user and executes corresponding functions for specific settings. + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => { + const configuration = vscode.workspace.getConfiguration('MATLAB') + + // Updates the licensing status bar item and listeners based on the 'signIn' setting. + if (configuration.get(LicensingUtils.LICENSING_SETTING_NAME) ?? false) { + LicensingUtils.setupLicensingListeners(client) + } else { + LicensingUtils.removeLicensingListeners() + } + })) // Set up langauge server const serverModule: string = context.asAbsolutePath( @@ -102,7 +118,6 @@ export async function activate (context: vscode.ExtensionContext): Promise client.onNotification(Notification.MatlabFeatureUnavailable, () => handleFeatureUnavailable()) client.onNotification(Notification.MatlabFeatureUnavailableNoMatlab, () => handleFeatureUnavailableWithNoMatlab()) client.onNotification(Notification.LogTelemetryData, (data: TelemetryEvent) => handleTelemetryReceived(data)) - mvm = new MVM(client as Notifier); terminalService = new TerminalService(client as Notifier, mvm); executionCommandProvider = new ExecutionCommandProvider(mvm, terminalService, telemetryLogger); @@ -115,32 +130,114 @@ export async function activate (context: vscode.ExtensionContext): Promise context.subscriptions.push(vscode.commands.registerCommand('matlab.addFolderAndSubfoldersToPath', async (uri: vscode.Uri) => await executionCommandProvider.handleAddFolderAndSubfoldersToPath(uri))) context.subscriptions.push(vscode.commands.registerCommand('matlab.changeDirectory', async (uri: vscode.Uri) => await executionCommandProvider.handleChangeDirectory(uri))) + // Register a custom command which allows the user enable / disable Sign In options. + // Using this custom command would be an alternative approach to going to enabling the setting. + context.subscriptions.push(vscode.commands.registerCommand(MATLAB_ENABLE_SIGN_IN_COMMAND, async () => await handleEnableSignIn())) + + // Setup listeners only if licensing workflows are enabled. + // Any further changes to the configuration settings will be handled by configChangeListener. + if (LicensingUtils.isSignInSettingEnabled()) { + LicensingUtils.setupLicensingListeners(client) + } + deprecationPopupService = new DeprecationPopupService(context) deprecationPopupService.initialize(client) + sectionStylingService = new SectionStylingService(context) + sectionStylingService.initialize(client); + await client.start() } +/** + * Handles enabling MATLAB licensing workflows. + * + * Checks if the `signIn` setting is enabled. If it is not enabled, + * updates the setting to enable it and displays a message indicating the workflows + * have been enabled. If it is already enabled, displays a message indicating that. + * + * @param context - The context in which the extension is running. + * @returns A promise that resolves when the operation is complete. + */ +async function handleEnableSignIn (): Promise { + const configuration = vscode.workspace.getConfiguration('MATLAB'); + const enable = 'Enable' + const disable = 'Disable'; + + const choices = LicensingUtils.isSignInSettingEnabled() ? [disable] : [enable] + + const choice = await vscode.window.showQuickPick(choices, { + placeHolder: 'Manage Sign In Options' + }) + + if (choice == null) { + return + } + + if (choice === 'Enable') { + await configuration.update(LicensingUtils.LICENSING_SETTING_NAME, true, vscode.ConfigurationTarget.Global); + void vscode.window.showInformationMessage('Sign In Options enabled.') + } else if (choice === 'Disable') { + await configuration.update(LicensingUtils.LICENSING_SETTING_NAME, false, vscode.ConfigurationTarget.Global); + void vscode.window.showInformationMessage('Sign In Options disabled.') + } +} + /** * Handles user input about whether to connect or disconnect from MATLABĀ® */ function handleChangeMatlabConnection (): void { - void vscode.window.showQuickPick(['Connect to MATLAB', 'Disconnect from MATLAB'], { + const connect = 'Connect to MATLAB'; + const disconnect = 'Disconnect from MATLAB'; + const options = [connect, disconnect] + + const isSignInEnabled = LicensingUtils.isSignInSettingEnabled() + const signOut = 'Sign Out of MATLAB' + const isLicensed = LicensingUtils.getMinimalLicensingInfo() !== '' + const isMatlabConnecting = connectionStatusNotification.text === CONNECTION_STATUS_LABELS.CONNECTING + + // Only show signout option when signin setting is enabled, MATLAB is connected and is licensed + if (isSignInEnabled && isLicensed && isMatlabConnected()) { + options.push(signOut) + } + + void vscode.window.showQuickPick(options, { placeHolder: 'Change MATLAB Connection' }).then(choice => { if (choice == null) { return } - if (choice === 'Connect to MATLAB') { + if (choice === connect) { + // Opens the browser tab with licensing URL. + // This will only occur when the tab is accidentally closed by the user and wants to + // connect to MATLAB + if (isSignInEnabled && !isLicensed && isMatlabConnecting) { + void client.sendNotification(Notification.LicensingServerUrl) + } sendConnectionActionNotification('connect') - } else if (choice === 'Disconnect from MATLAB') { + } else if (choice === disconnect) { sendConnectionActionNotification('disconnect') terminalService.closeTerminal(); + } else if (choice === signOut) { + void client.sendNotification(Notification.LicensingDelete) + sendConnectionActionNotification('disconnect') } }) } +/** + * Checks if a connection to MATLAB is currently established. + * + * This function determines the connection status by checking if the connection status + * notification text includes a specific label indicating a successful connection. + * + * @returns `true` if MATLAB is connected, otherwise `false`. + */ +function isMatlabConnected (): boolean { + return connectionStatusNotification.text.includes(CONNECTION_STATUS_LABELS.CONNECTED) +} + /** * Handles the notifiaction that the connection to MATLAB has changed (either has connected, * disconnected, or is in the process of connecting) @@ -150,9 +247,14 @@ function handleChangeMatlabConnection (): void { function handleConnectionStatusChange (data: { connectionStatus: string }): void { if (data.connectionStatus === 'connected') { connectionStatusNotification.text = CONNECTION_STATUS_LABELS.CONNECTED + const licensingInfo = LicensingUtils.getMinimalLicensingInfo() + + if (LicensingUtils.isSignInSettingEnabled() && licensingInfo !== '') { + connectionStatusNotification.text += licensingInfo + } } else if (data.connectionStatus === 'disconnected') { terminalService.closeTerminal(); - if (connectionStatusNotification.text === CONNECTION_STATUS_LABELS.CONNECTED) { + if (isMatlabConnected()) { const message = NotificationConstants.MATLAB_CLOSED.message const options = NotificationConstants.MATLAB_CLOSED.options vscode.window.showWarningMessage(message, ...options diff --git a/src/styling/Decorations.ts b/src/styling/Decorations.ts new file mode 100644 index 0000000..dc89e9b --- /dev/null +++ b/src/styling/Decorations.ts @@ -0,0 +1,40 @@ +// Copyright 2024 The MathWorks, Inc. +import * as vscode from 'vscode'; + +const BLUE_COLOR = 'rgb(38,140,221)'; +const LIGHT_GREY = 'rgb(136,136,136)'; +const DARK_GREY = 'rgb(166,166,166)'; +const borderBottomStyle = '0 0 1px 0'; +const borderTopStyle = '1px 0 0 0'; + +// Create a decorator type for highlighted section +const blueBorder = { + borderColor: BLUE_COLOR, + borderStyle: 'solid', + isWholeLine: true, + light: { + borderColor: BLUE_COLOR + }, + dark: { + borderColor: BLUE_COLOR + } +}; +const blueBorderTopDecoration = vscode.window.createTextEditorDecorationType(Object.assign({ borderWidth: borderTopStyle }, blueBorder)); +const blueBorderBottomDecoration = vscode.window.createTextEditorDecorationType(Object.assign({ borderWidth: borderBottomStyle }, blueBorder)); + +const greyBorder = { + borderColor: LIGHT_GREY, + borderStyle: 'solid', + isWholeLine: true, + light: { + borderColor: LIGHT_GREY + }, + dark: { + borderColor: DARK_GREY + } +}; + +const greyBorderTopDecoration = vscode.window.createTextEditorDecorationType(Object.assign({ borderWidth: borderTopStyle }, greyBorder)); +const greyBorderBottomDecoration = vscode.window.createTextEditorDecorationType(Object.assign({ borderWidth: borderBottomStyle }, greyBorder)); +const fontWeightBoldDecoration = vscode.window.createTextEditorDecorationType({ fontWeight: 'bold', isWholeLine: true }); +export { blueBorderTopDecoration, blueBorderBottomDecoration, greyBorderTopDecoration, greyBorderBottomDecoration, fontWeightBoldDecoration }; diff --git a/src/styling/LineRangeTree.ts b/src/styling/LineRangeTree.ts new file mode 100644 index 0000000..82cb2b3 --- /dev/null +++ b/src/styling/LineRangeTree.ts @@ -0,0 +1,116 @@ +// Copyright 2024 The MathWorks, Inc. +import * as vscode from 'vscode'; + +/** + * A node used in the LineRangeTree. + * @class TreeNode + */ +class TreeNode { + range: vscode.Range | undefined; + children: TreeNode[]; + parent: TreeNode | undefined; + constructor (range: vscode.Range | undefined) { + this.range = range; + this.children = []; + this.parent = undefined; + } + + add (treeNode: TreeNode): void { + this.children.push(treeNode); + treeNode.parent = this; + } + + getStartLine (): number { + if (this.range !== undefined) { + return this.range.start.line; + } + return 0; + } + + getEndLine (): number { + if (this.range !== undefined) { + return this.range.end.line; + } + + return Infinity; + } +} + +export default class LineRangeTree { + private _root: TreeNode | undefined; + + constructor (sectonRanges: vscode.Range[]) { + this._set(sectonRanges); + } + + /** + * Creates a tree from the given section ranges array based on the start and end lines. + */ + _set (sectonRanges: vscode.Range[]): void { + this._root = new TreeNode(undefined); + const objectLength = sectonRanges.length; + let currentNode: TreeNode | undefined; + currentNode = this._root; + + for (let i = 0; i < objectLength; i++) { + const sectionRange = new TreeNode(sectonRanges[i]); + + while (currentNode != null) { + if (sectionRange.getStartLine() >= currentNode.getStartLine() && + sectionRange.getEndLine() <= currentNode.getEndLine()) { + currentNode.add(sectionRange); + currentNode = sectionRange; + break; + } else { + currentNode = currentNode.parent; + } + } + } + } + + /** + * Finds the object with smallest range (dfs) containing the given line number + * @param line number + * @returns Section Range + */ + find (line: number): vscode.Range | undefined { + let currentNode: TreeNode | undefined; + + currentNode = this._root; + let lastNode = currentNode; + + while (currentNode != null) { + currentNode = this._searchByLine(line, currentNode); + lastNode = currentNode ?? lastNode; + } + return (lastNode != null) ? lastNode.range : undefined; + } + + private _searchByLine (line: number, parentNode: TreeNode): TreeNode | undefined { + const length = parentNode.children.length; + if (length === 0) { + return undefined; + } + + let result: TreeNode | undefined; + let start = 0; + let end = length - 1; + + while (start <= end) { + const mid = Math.floor((start + end) / 2); + const midNode = parentNode.children[mid]; + const midNodeStartLine = midNode.getStartLine() ?? 0; + if (line >= midNodeStartLine && + line <= midNode.getEndLine()) { + result = midNode; + break; + } else if (line < midNodeStartLine) { + end = mid - 1; + } else { + start = mid + 1; + } + } + + return result; + } +} diff --git a/src/styling/SectionStylingService.ts b/src/styling/SectionStylingService.ts new file mode 100644 index 0000000..31c2bd5 --- /dev/null +++ b/src/styling/SectionStylingService.ts @@ -0,0 +1,291 @@ +// Copyright 2024 The MathWorks, Inc. +import * as vscode from 'vscode'; +import { LanguageClient } from 'vscode-languageclient/node' +import Notification from '../Notifications' + +import LineRangeTree from './LineRangeTree'; + +import { blueBorderTopDecoration, blueBorderBottomDecoration, greyBorderTopDecoration, greyBorderBottomDecoration, fontWeightBoldDecoration } from './Decorations'; +import { StartAndEndLines, SectionsData, TopAndBottomRanges, StylingRanges } from './StylingInterfaces'; +const sectionsCacheByPath = new Map(); +let previousFocusedEditor: vscode.TextEditor | undefined; + +class SectionStylingService { + constructor (private readonly context: vscode.ExtensionContext) {} + + initialize (client: LanguageClient): void { + this.context.subscriptions.push(client.onNotification(Notification.MatlabSections, (data) => this._onNewSectionsGenerated(data))) + // Listen to cursor change to highlight the section + this.context.subscriptions.push(vscode.window.onDidChangeTextEditorSelection((event) => this._handleTextEditorSelectionChange(event))); + + // Clear the active blue borders for focus out + this.context.subscriptions.push(vscode.window.onDidChangeActiveTextEditor((editor) => this._handleEditorFocusChange(editor))) + this.context.subscriptions.push(vscode.window.onDidChangeWindowState((windowFocusState) => this._handleWindowLostFocus(windowFocusState))); + + this.context.subscriptions.push(vscode.workspace.onDidChangeTextDocument((event) => this._onDocumentChange(event))); + this.context.subscriptions.push(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => this._onDocumentClose(document))); + } + + private _onDocumentChange (event: vscode.TextDocumentChangeEvent): void { + const filePath = decodeURIComponent(event.document.uri.toString()); + const sectionCache = sectionsCacheByPath.get(filePath); + if (sectionCache === undefined || event.contentChanges.length === 0) { + return + } + // As content changes enable isSectionCreationInProgress flag, so cursor changes will not renders sections with cache. + // This flag will reset once the sections are regenerated by matlab + sectionCache.isSectionCreationInProgress = true; + } + + private _onNewSectionsGenerated (sectionsData: SectionsData): void { + const decodedUri = decodeURIComponent(sectionsData.uri); + const editorInTheWindow: vscode.TextEditor | undefined = vscode.window.visibleTextEditors.find((editor) => { + const textEditorURI = this._getDecodedURI(editor) + return (textEditorURI === decodedUri); + }); + if (editorInTheWindow === undefined) { + return + } + this._preProcessAndSaveSectionByEditor(sectionsData, decodedUri, editorInTheWindow); + this._postProcessSectionsData(sectionsData, editorInTheWindow); + } + + private _preProcessAndSaveSectionByEditor (sectionsData: SectionsData, decodedUri: string, editorInTheWindow: vscode.TextEditor): void { + const { sectionRanges } = sectionsData; + sectionRanges.sort((a: vscode.Range, b: vscode.Range) => a.start.line - b.start.line); + const isSectionAddedAtStart = this._addFirstSectionIfNotExists(sectionRanges); + // Creates a tree of sections and saves it in the sectionsData cache + // Used for retrieving the active section to highlight + sectionsData.sectionsTree = new LineRangeTree(sectionRanges); + sectionsData.isSectionCreationInProgress = false; + sectionsData.implictSectionAtStart = isSectionAddedAtStart; + sectionsCacheByPath.set(decodedUri, sectionsData); + } + + // Add first section if not exists otherwise the cursor will not highlight the section + private _addFirstSectionIfNotExists (sectionRanges: vscode.Range[]): boolean { + if (sectionRanges.length === 0) { + return false; + } + const firstSection = sectionRanges[0]; + const startLine = firstSection?.start?.line; + if (startLine === 0) { + return false; + } + const newStartLine = 0; + const newEndLine = startLine - 1; + const range = new vscode.Range(newStartLine, 0, newEndLine, 0); + sectionRanges.unshift(range); + return true; + } + + private _postProcessSectionsData (sectionsData: SectionsData, editorInTheWindow: vscode.TextEditor): void { + const { sectionRanges } = sectionsData; + if (sectionRanges !== undefined && sectionRanges.length === 0) { + // If there are no sections, clear the decorations + this._clearDecorations(editorInTheWindow); + return + } + const activeEditor = this._getActiveEditor(); + if (activeEditor !== undefined && activeEditor === editorInTheWindow) { + const cursorPosition = editorInTheWindow.selection.active; + if (cursorPosition !== undefined) { + // Highlight active sections to blue and inactive sections to grey + this._highlightSections(activeEditor, sectionsData, cursorPosition); + return; + } + } + // Highlight all sections to grey + this._highlightSections(editorInTheWindow, sectionsData, null); + } + + private _clearDecorations (editorInTheWindow: vscode.TextEditor): void { + this._setDecorations(editorInTheWindow, { blue: { top: [], bottom: [] }, grey: { top: [], bottom: [] } }, []); + } + + private _getDecodedURI (editor: vscode.TextEditor): string { + return decodeURIComponent(editor.document.uri.toString()) + } + + private _handleWindowLostFocus (windowState: vscode.WindowState): void { + if (!windowState.focused) { + const activeEditor = this._getActiveEditor(); + // Clear the blue borders when the window lost focus + if (activeEditor != null) { + this._clearBlueDecorations(activeEditor); + } + } + } + + private _handleEditorFocusChange (editor: vscode.TextEditor | undefined): void { + if (previousFocusedEditor !== undefined && previousFocusedEditor !== editor) { + // Clear the blue borders for previous editors when new editor is focused + this._clearBlueDecorations(previousFocusedEditor); + } + previousFocusedEditor = editor; + } + + private _handleTextEditorSelectionChange (event: vscode.TextEditorSelectionChangeEvent): void { + const editor = event.textEditor; + const cursorPosition = editor.selection.active; + const editorSections = sectionsCacheByPath.get(this._getDecodedURI(editor)); + if (editorSections?.isSectionCreationInProgress === true) { + // Don't highlight sections if section creation is in progress + // This will create sections with cache data which will be wrong + return; + } + + if (editorSections != null) { + // Don't highlight sections if there is an error + this._highlightSections(editor, editorSections, cursorPosition); + } + } + + private _onDocumentClose (document: vscode.TextDocument): void { + // Remove sections from cache when document is closed + const decodedURI = decodeURIComponent(document.uri.toString()); + sectionsCacheByPath.delete(decodedURI); + } + + private _clearBlueDecorations (previousEditor: vscode.TextEditor): void { + const decodedUri = this._getDecodedURI(previousEditor); + const sections = sectionsCacheByPath.get(decodedUri); + if (sections !== undefined && sections.isSectionCreationInProgress === false) { + this._highlightSections(previousEditor, sections, null); + } + } + + private _getActiveEditor (): vscode.TextEditor | undefined { + return vscode.window.activeTextEditor; + } + + /** + * Highlights sections in the editor with grey lines if no cursor is present + * Highlights active section (cursor present) in the editor with blue lines and others in grey line + * Bold the first line of the sections + */ + private _highlightSections (editor: vscode.TextEditor, sections: SectionsData, activeCursorPosition: vscode.Position | null): void { + const startAndEndLines = this._sectionsToStartAndEndLines(sections.sectionRanges); + const allStartLinesRange = this._generateRanges(startAndEndLines.startLines); + const lastLineinSection = startAndEndLines.endLines.sort((a, b) => a - b)[startAndEndLines.endLines.length - 1]; + + let stylingRanges: StylingRanges; + if (activeCursorPosition !== null) { + let cursorPositionLine = activeCursorPosition.line; + if (cursorPositionLine > lastLineinSection) { + cursorPositionLine = lastLineinSection + } + const focusedSectionRange: vscode.Range | undefined = this._findFocusedSectionRange(sections, cursorPositionLine); + if (focusedSectionRange !== undefined) { + stylingRanges = this._getBlueAndGreyRanges(startAndEndLines, focusedSectionRange); + } else { + stylingRanges = { blue: { top: [], bottom: [] }, grey: this._getGreyRanges(startAndEndLines) }; + } + } else { + stylingRanges = { blue: { top: [], bottom: [] }, grey: this._getGreyRanges(startAndEndLines) }; + } + this._filterFirstAndLastSection(stylingRanges, lastLineinSection, editor.document); + + if (sections.implictSectionAtStart === true) { + // Remove as the first section is implicit + allStartLinesRange.shift(); + } + this._setDecorations(editor, stylingRanges, allStartLinesRange); + } + + private _getBlueAndGreyRanges (startAndEndLines: StartAndEndLines, focusedSectionRange: vscode.Range): StylingRanges { + const focusedStartLine = focusedSectionRange.start.line; + const focusedEndLine = focusedSectionRange.end.line; + const { startLines, endLines } = startAndEndLines; + + const startLinesWithoutFocusLine = startLines.filter((startLine) => { + // Remove the start lines if the start is coinciding with + // focus start or + // start line is after the focused end line + // so we can color them blue + return !((startLine === focusedStartLine) || (startLine === (focusedEndLine + 1))); + }); + + const endLinesWithoutFocusLine = endLines.filter((endLine) => { + // Remove the end lines if the end is coinciding with + // focus end or + // before the focus end line or + // is it already part of the start lines + return !(endLine === focusedEndLine || + endLine === (focusedStartLine - 1) || + startLines.includes(endLine + 1)); + }); + + const blue: TopAndBottomRanges = { top: [], bottom: [] }; + + const isTheEndLineAdjacentToStartLine = startLines.includes(focusedEndLine + 1) + const topBordersToStyle = [focusedStartLine]; + const bottomLineNumbersToStyle = []; + + if (isTheEndLineAdjacentToStartLine) { + // Style using top borders if the end line is adjacent to the start line + topBordersToStyle.push(focusedEndLine + 1) + } else { + bottomLineNumbersToStyle.push(focusedEndLine) + } + blue.top = this._generateRanges(topBordersToStyle); + blue.bottom = this._generateRanges(bottomLineNumbersToStyle); + + return { + blue, + grey: { + top: this._generateRanges(startLinesWithoutFocusLine), + bottom: this._generateRanges(endLinesWithoutFocusLine) + } + }; + } + + private _getGreyRanges (startAndEndLines: StartAndEndLines): TopAndBottomRanges { + const { startLines, endLines } = startAndEndLines + const endLinesFiltered = endLines.filter((endLine) => !startLines.includes(endLine + 1)); + return { top: this._generateRanges(startLines), bottom: this._generateRanges(endLinesFiltered) }; + } + + private _setDecorations (editor: vscode.TextEditor, stylingRange: StylingRanges, allStartLinesRange: vscode.Range[]): void { + editor.setDecorations(blueBorderTopDecoration, stylingRange.blue.top); + editor.setDecorations(blueBorderBottomDecoration, stylingRange.blue.bottom); + editor.setDecorations(greyBorderTopDecoration, stylingRange.grey.top); + editor.setDecorations(greyBorderBottomDecoration, stylingRange.grey.bottom); + editor.setDecorations(fontWeightBoldDecoration, allStartLinesRange); + } + + private _generateRanges (lines: number[]): vscode.Range[] { + return lines.map((line: number) => new vscode.Range(line, 0, line, Infinity)); + } + + private _findFocusedSectionRange (sections: SectionsData, lineNumber: number): vscode.Range | undefined { + let activeSection: vscode.Range | undefined; + if (lineNumber !== undefined && sections.sectionsTree !== undefined) { + activeSection = sections.sectionsTree.find(lineNumber); + } + return activeSection; + } + + private _sectionsToStartAndEndLines (sectionRanges: vscode.Range[]): StartAndEndLines { + const startLines = new Set(); + const endLines = new Set(); + sectionRanges.forEach((sectionRange: vscode.Range) => { + const startingIndex = sectionRange.start.line; + const endingIndex = sectionRange.end.line; + startLines.add(startingIndex); + endLines.add(endingIndex); + }); + return { startLines: Array.from(startLines), endLines: Array.from(endLines) }; + } + + private _filterFirstAndLastSection (stylingRanges: StylingRanges, endingLineOfSections: number, document: vscode.TextDocument): void { + const filterByLineNumber = (lineNumber: number) => (position: vscode.Range): boolean => !(position.start.line === lineNumber); + const startingLineOfDocument = 0; + stylingRanges.blue.top = stylingRanges.blue.top.filter(filterByLineNumber(startingLineOfDocument)); + stylingRanges.grey.top = stylingRanges.grey.top.filter(filterByLineNumber(startingLineOfDocument)); + stylingRanges.blue.bottom = stylingRanges.blue.bottom.filter(filterByLineNumber(endingLineOfSections)); + stylingRanges.grey.bottom = stylingRanges.grey.bottom.filter(filterByLineNumber(endingLineOfSections)); + } +} + +export default SectionStylingService; diff --git a/src/styling/StylingInterfaces.ts b/src/styling/StylingInterfaces.ts new file mode 100644 index 0000000..9b18357 --- /dev/null +++ b/src/styling/StylingInterfaces.ts @@ -0,0 +1,22 @@ +// Copyright 2024 The MathWorks, Inc. + +import * as vscode from 'vscode'; +import LineRangeTree from './LineRangeTree'; + +interface StartAndEndLines { + startLines: number[] + endLines: number[] +} + +interface SectionsData { + uri: string + sectionRanges: vscode.Range[] + sectionsTree: LineRangeTree | undefined + isSectionCreationInProgress: boolean | undefined + implictSectionAtStart: boolean | undefined +} + +interface TopAndBottomRanges { top: vscode.Range[], bottom: vscode.Range[] } + +interface StylingRanges {blue: TopAndBottomRanges, grey: TopAndBottomRanges} +export { StartAndEndLines, SectionsData, TopAndBottomRanges, StylingRanges }; diff --git a/src/utils/BrowserUtils.ts b/src/utils/BrowserUtils.ts new file mode 100644 index 0000000..ec552da --- /dev/null +++ b/src/utils/BrowserUtils.ts @@ -0,0 +1,50 @@ +// Copyright 2024 The MathWorks, Inc. + +import * as vscode from 'vscode' + +/** + * Opens the provided URL in an external browser. + * If the URL fails to open in the browser, it renders the URL inside a VS Code webview panel. + * @param url - The URL to open. + * @returns A Promise that resolves when the URL is opened or rendered. + */ +export async function openUrlInExternalBrowser (url: string): Promise { + const parsedUrl = vscode.Uri.parse(url) + // This is a no-op if the extension is running on the client machine. + let externalUri = await vscode.env.asExternalUri(parsedUrl) + + // In remote environments (ie. codespaces) asExternalUri() removes path and query fields in the vscode.Uri object + // So, reinitialize it with required fields. + externalUri = externalUri.with({ path: parsedUrl.path, query: parsedUrl.query }) + + const success = await vscode.env.openExternal(externalUri); + // Render inside vscode's webview if the url fails to open in the browser. + if (!success) { + void vscode.window.showWarningMessage('Failed to open licensing server url in browser. Opening it within vs code.') + const panel = vscode.window.createWebviewPanel('matlabLicensing', 'MATLAB Licensing', vscode.ViewColumn.Active, { enableScripts: true }); + + panel.webview.html = ` + + + + + + Webview Example + + + + + + + `; + } +} diff --git a/src/utils/LicensingUtils.ts b/src/utils/LicensingUtils.ts new file mode 100644 index 0000000..39b63ec --- /dev/null +++ b/src/utils/LicensingUtils.ts @@ -0,0 +1,83 @@ +// Copyright 2024 The MathWorks, Inc. + +import * as vscode from 'vscode' +import { openUrlInExternalBrowser } from './BrowserUtils' +import Notification from '../Notifications' +import { LanguageClient } from 'vscode-languageclient/node'; + +let minimalLicensingInfo: string = '' +let licensingUrlNotificationListener: vscode.Disposable | undefined +let licensingDataNotificationListener: vscode.Disposable | undefined +let licensingErrorNotificationListener: vscode.Disposable | undefined + +let isInitialized = false; + +export const LICENSING_SETTING_NAME: string = 'signIn' + +/** + * Gets the minimal licensing information as a string. + * @returns {string} The minimal licensing information. + */ +export function getMinimalLicensingInfo (): string { + return minimalLicensingInfo +} + +/** + * Sets up the licensing notification listeners for the extension. + * + * @param client - The language client instance. + */ +export function setupLicensingListeners (client: LanguageClient): void { + if (!isInitialized) { + licensingUrlNotificationListener = client.onNotification(Notification.LicensingServerUrl, async (url: string) => { + const result = await vscode.window.showInformationMessage( + 'Sign in required to open MATLAB. Click OK to open your system browser and sign in.', + 'OK' + ); + + if (result === 'OK') { + void openUrlInExternalBrowser(url); + } + + }) + licensingDataNotificationListener = client.onNotification(Notification.LicensingData, (data: string) => { + minimalLicensingInfo = data + }) + licensingErrorNotificationListener = client.onNotification(Notification.LicensingError, (data: string) => handleLicensingError(data)) + isInitialized = true; + } +} + +/** + * Removes the licensing notification listeners for the extension. + */ +export function removeLicensingListeners (): void { + if (isInitialized) { + licensingUrlNotificationListener?.dispose(); + licensingUrlNotificationListener = undefined; + + licensingDataNotificationListener?.dispose(); + licensingDataNotificationListener = undefined; + + licensingErrorNotificationListener?.dispose(); + licensingErrorNotificationListener = undefined; + + isInitialized = false; + } +} + +/** + * Handles the licensing error notification by displaying an information message. + * + * @param data - The error message data. + */ +function handleLicensingError (data: string): void { + void vscode.window.showErrorMessage(`Licensing failed with error: ${data}`) +} + +/** + * Returns true if the SignIn setting is enabled. + */ +export function isSignInSettingEnabled (): boolean { + return vscode.workspace.getConfiguration('MATLAB').get(LICENSING_SETTING_NAME) as boolean +} From c1106cda481cca91879f55a06cfd0b5d5235599e Mon Sep 17 00:00:00 2001 From: dlilley Date: Wed, 6 Nov 2024 08:50:51 -0500 Subject: [PATCH 2/7] Update server submodule --- server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server b/server index 2245fbe..261e7ab 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 2245fbecbe4059f692616f868b4d7fc9e61a0d43 +Subproject commit 261e7abe30145290634341868477966ca445b372 From a9828c83af3644296d50544ebd8c493b245dd897 Mon Sep 17 00:00:00 2001 From: dlilley Date: Wed, 6 Nov 2024 09:05:06 -0500 Subject: [PATCH 3/7] Update server submodule (adding missing dependency) --- server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server b/server index 261e7ab..58388df 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 261e7abe30145290634341868477966ca445b372 +Subproject commit 58388df444acc997073417be3382b9db580efca6 From 0be647a404b6d465f155f06c52b60bd21a326dae Mon Sep 17 00:00:00 2001 From: dlilley Date: Wed, 6 Nov 2024 10:06:37 -0500 Subject: [PATCH 4/7] Update installation steps --- package.json | 2 +- server | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bb21888..f6ae68a 100644 --- a/package.json +++ b/package.json @@ -266,7 +266,7 @@ "test-smoke": "npm run test-setup && node ./out/test/smoke/runTest.js", "test-ui": "npm run test-setup && node ./out/test/ui/runTest.js", "test": "npm run test-smoke && npm run test-ui", - "postinstall": "cd server && npm install && cd src/licensing/ && npm install && cd gui && npm install && cd ../../..", + "postinstall": "cd server && npm install && cd ..", "package": "vsce package" }, "devDependencies": { diff --git a/server b/server index 58388df..2245fbe 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 58388df444acc997073417be3382b9db580efca6 +Subproject commit 2245fbecbe4059f692616f868b4d7fc9e61a0d43 From 5220a9e42f6ebd0121cb55dbd642a03de488536b Mon Sep 17 00:00:00 2001 From: dlilley Date: Wed, 6 Nov 2024 10:35:37 -0500 Subject: [PATCH 5/7] Resolve linting errors --- server | 2 +- src/utils/LicensingUtils.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server b/server index 2245fbe..0b4bd3e 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 2245fbecbe4059f692616f868b4d7fc9e61a0d43 +Subproject commit 0b4bd3e5af32c21d3529c408f3371a8705014a0d diff --git a/src/utils/LicensingUtils.ts b/src/utils/LicensingUtils.ts index 39b63ec..1a36034 100644 --- a/src/utils/LicensingUtils.ts +++ b/src/utils/LicensingUtils.ts @@ -29,16 +29,16 @@ export function getMinimalLicensingInfo (): string { */ export function setupLicensingListeners (client: LanguageClient): void { if (!isInitialized) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises licensingUrlNotificationListener = client.onNotification(Notification.LicensingServerUrl, async (url: string) => { const result = await vscode.window.showInformationMessage( 'Sign in required to open MATLAB. Click OK to open your system browser and sign in.', 'OK' - ); + ); - if (result === 'OK') { + if (result === 'OK') { void openUrlInExternalBrowser(url); - } - + } }) licensingDataNotificationListener = client.onNotification(Notification.LicensingData, (data: string) => { minimalLicensingInfo = data From 7869e70d5409e634a4fe8ce6e4ae481190e2984f Mon Sep 17 00:00:00 2001 From: dlilley Date: Thu, 7 Nov 2024 14:35:58 -0500 Subject: [PATCH 6/7] Update server submodule --- server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server b/server index 0b4bd3e..2b1076d 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 0b4bd3e5af32c21d3529c408f3371a8705014a0d +Subproject commit 2b1076d9f43ec9bf0d8a0b26ad4ea322ebd446f8 From fa0ca8eb1d800a80e33555c6f9a3da7beb0db549 Mon Sep 17 00:00:00 2001 From: dlilley Date: Thu, 7 Nov 2024 14:55:52 -0500 Subject: [PATCH 7/7] Update release date --- CHANGELOG.md | 2 +- server | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad11ba..a0d3d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.2.7] - 2024-11-06 +## [1.2.7] - 2024-11-07 ### Added - Visual indication of code sections diff --git a/server b/server index 2b1076d..c98bd0d 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 2b1076d9f43ec9bf0d8a0b26ad4ea322ebd446f8 +Subproject commit c98bd0dd23ae12ff29152030d7790e7b8e86fc61