diff --git a/.eslintrc.json b/.eslintrc.json index 3769b20..9456fa6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,6 +17,9 @@ "@typescript-eslint" ], "rules": { + "@typescript-eslint/semi": "off", + "@typescript-eslint/no-inferrable-types": "off", + "@typescript-eslint/promise-function-async": "off", "@typescript-eslint/indent": ["error", 4, { "SwitchCase": 1, "VariableDeclarator": 1, diff --git a/CHANGELOG.md b/CHANGELOG.md index 852300b..9220272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2024-03-05 + +### Added +- Code execution support + +### Fixed +- Fixed linting diagnostic suppression with MATLAB R2024a + ## [1.1.6] - 2024-01-16 ### Fixed diff --git a/README.md b/README.md index b23cd51..69a8c1c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # MATLAB extension for Visual Studio Code -This extension provides support for editing MATLAB® code in Visual Studio® Code and includes features such as syntax highlighting, code analysis, navigation support, and more. +This extension provides support for editing and running MATLAB® code in Visual Studio® Code and includes features such as syntax highlighting, code analysis, navigation support, and more. -You can use this extension with or without MATLAB installed on your system. However, to make use of the advanced code-editing features of the extension, you must have MATLAB R2021a or later installed. For more information, see the [Get Started](#get-started) section. +You can use this extension with or without MATLAB installed on your system. However, to make use of the advanced features of the extension or run MATLAB code, you must have MATLAB R2021a or later installed. For more information, see the [Get Started](#get-started) section. -For an overview of the major features of this extension, you can watch the [Introducing the New MATLAB Extension for Visual Studio Code](https://www.youtube.com/watch?v=kYTBAr9LlGg) video. +For an overview of some of the major features of this extension, you can watch the [Introducing the New MATLAB Extension for Visual Studio Code](https://www.youtube.com/watch?v=kYTBAr9LlGg) video. ## Installation You can install the extension from within Visual Studio Code or download it from [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=MathWorks.language-matlab). After installing the extension, you might need to configure it to make full use of all the features. For more information, see the [Configuration](#configuration) section. ## Get Started -To get started using the extension, open any MATLAB code file, or create a new file and set the language to MATLAB. +To get started using the extension, open any MATLAB code file (.m), or create a new file and set the language to MATLAB. ### Basic Features (MATLAB not required) The extension provides several basic features, regardless of whether you have MATLAB installed on your system. These features include: @@ -21,7 +21,8 @@ The extension provides several basic features, regardless of whether you have MA ![MATLAB Extension Demo](public/BasicFeatures.gif) ### Advanced Features (requires MATLAB installed on your system) -If you have MATLAB R2021a or later installed on your system, you have access to an additional set of advanced code-editing features. These features include: +If you have MATLAB R2021a or later installed on your system, you have access to an additional set of advanced features. These features include: +* Code execution * Automatic code completion * Source code formatting (document formatting) * Code navigation @@ -30,6 +31,19 @@ If you have MATLAB R2021a or later installed on your system, you have access to ![MATLAB Extension Demo](public/AdvancedFeatures.gif) +## Run MATLAB Code +You can run a MATLAB code file or a selection within a MATLAB code file in Visual Studio Code using the Run button at the top of the file or the `Run File` or `Run Current Selection` commands. When you run the file, output displays in the "Terminal" pane of Visual Studio Code. You also can enter MATLAB code directly in the MATLAB terminal. To stop execution of MATLAB code, press **Ctrl+C**. + +![MATLAB Execution Demo](public/CodeExecution.gif) + +### Limitations +There are some limitations to running MATLAB code in Visual Studio Code: +* Debugging is not supported. +* The **pause** and **input** functions are not supported. +* Output from timers, callbacks, and DataQueue objects is not shown in the Command Window. +* Creating a custom run configuration for a file is not supported. + + ## Configuration To configure the extension, go to the extension settings and select from the available options. @@ -74,8 +88,14 @@ We encourage all feedback. If you encounter a technical issue or have an enhance ## Release Notes +### 1.2.0 +Release date: 2024-03-05 + +Added: +* Code execution support + ### 1.1.0 -Release date: 2023-06-5 +Release date: 2023-06-05 Added: * Document symbol and outline support diff --git a/package-lock.json b/package-lock.json index ae36ea2..ef19f90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "language-matlab", - "version": "1.1.6", + "version": "1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "language-matlab", - "version": "1.1.6", + "version": "1.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ec4838c..5b651e0 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.1.6", + "version": "1.2.0", "engines": { "vscode": "^1.67.0" }, @@ -20,7 +20,8 @@ "Snippets" ], "activationEvents": [ - "onLanguage:matlab" + "onLanguage:matlab", + "onTerminalProfile:matlab.terminal-profile" ], "main": "./out/extension.js", "contributes": { @@ -28,8 +29,99 @@ { "command": "matlab.changeMatlabConnection", "title": "MATLAB: Change MATLAB Connection" + }, + { + "command": "matlab.runFile", + "title": "MATLAB: Run File", + "icon": "$(play)" + }, + { + "command": "matlab.runSelection", + "title": "MATLAB: Run Current Selection", + "icon": "$(play)" + }, + { + "command": "matlab.openCommandWindow", + "title": "MATLAB: Open Command Window", + "icon": "$(output)" + }, + { + "command": "matlab.interrupt", + "title": "MATLAB: Interrupt" + }, + { + "command": "matlab.addToPath", + "title": "MATLAB: Add Folder To Path" + }, + { + "command": "matlab.changeDirectory", + "title": "MATLAB: Change current directory" + } + ], + "keybindings": [ + { + "command": "matlab.runFile", + "key": "f5", + "when": "editorTextFocus && editorLangId == matlab && !findInputFocussed && !replaceInputFocussed && resourceScheme != 'untitled'" + }, + { + "command": "matlab.runSelection", + "key": "shift+enter", + "when": "editorTextFocus && editorHasSelection && !editorHasMultipleSelections && editorLangId == matlab && !findInputFocussed && !replaceInputFocussed" + }, + { + "command": "matlab.interrupt", + "key": "Ctrl+C", + "mac": "Cmd+C", + "when": "((editorTextFocus && !editorHasSelection && editorLangId == matlab) || (terminalFocus && matlab.isActiveTerminal && !matlab.terminalHasSelection && !terminalTextSelectedInFocused)) " } ], + "menus": { + "commandPalette": [ + { + "command": "matlab.addToPath", + "when": "false" + }, + { + "command": "matlab.changeDirectory", + "when": "false" + } + ], + "editor/title/run": [ + { + "command": "matlab.runFile", + "when": "editorLangId == matlab && resourceScheme != 'untitled'", + "group": "1_run" + }, + { + "command": "matlab.runSelection", + "when": "editorLangId == matlab && editorHasSelection && !editorHasMultipleSelections", + "group": "2_run" + } + ], + "editor/context": [ + { + "command": "matlab.runFile", + "when": "editorLangId == matlab && resourceScheme != 'untitled'", + "group": "1_run" + }, + { + "command": "matlab.runSelection", + "when": "editorLangId == matlab && editorHasSelection && !editorHasMultipleSelections", + "group": "1_run" + } + ], + "explorer/context": [ + { + "command": "matlab.addToPath", + "when": "explorerResourceIsFolder" + }, + { + "command": "matlab.changeDirectory", + "when": "explorerResourceIsFolder" + } + ] + }, "configuration": { "title": "MATLAB", "properties": { @@ -101,7 +193,15 @@ "language": "matlab", "path": "./snippets/matlab.json" } - ] + ], + "terminal": { + "profiles": [ + { + "title": "MATLAB", + "id": "matlab.terminal-profile" + } + ] + } }, "scripts": { "vscode:prepublish": "npm run compile && cd server && npm prune --production && cd ..", diff --git a/public/CodeExecution.gif b/public/CodeExecution.gif new file mode 100644 index 0000000..cddedc8 Binary files /dev/null and b/public/CodeExecution.gif differ diff --git a/public/Execution.gif b/public/Execution.gif new file mode 100644 index 0000000..d49ad65 Binary files /dev/null and b/public/Execution.gif differ diff --git a/public/runcode.gif b/public/runcode.gif new file mode 100644 index 0000000..3d89ba5 Binary files /dev/null and b/public/runcode.gif differ diff --git a/server b/server index d0fd57d..4f4f1f2 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit d0fd57d16519622f49c816a73ffd59dd022c7d41 +Subproject commit 4f4f1f287de97006394aa17959e6349c804f1e72 diff --git a/src/Notifications.ts b/src/Notifications.ts new file mode 100644 index 0000000..dff5a7e --- /dev/null +++ b/src/Notifications.ts @@ -0,0 +1,32 @@ +// Copyright 2023-2024 The MathWorks, Inc. + +enum Notification { + // Connection Status Updates + MatlabConnectionClientUpdate = 'matlab/connection/update/client', + MatlabConnectionServerUpdate = 'matlab/connection/update/server', + + // Errors + MatlabLaunchFailed = 'matlab/launchfailed', + MatlabFeatureUnavailable = 'feature/needsmatlab', + MatlabFeatureUnavailableNoMatlab = 'feature/needsmatlab/nomatlab', + + // Execution + MatlabRequestInstance = 'matlab/request', + + MVMEvalRequest = 'evalRequest', + MVMEvalComplete = 'evalRequest', + MVMFevalRequest = 'fevalRequest', + MVMFevalComplete = 'fevalRequest', + + MVMText = 'text', + MVMClc = 'clc', + + MVMInterruptRequest = 'interruptRequest', + + MVMStateChange = 'mvmStateChange', + + // Telemetry + LogTelemetryData = 'telemetry/logdata' +} + +export default Notification diff --git a/src/commandwindow/CommandWindow.ts b/src/commandwindow/CommandWindow.ts new file mode 100644 index 0000000..a74789c --- /dev/null +++ b/src/commandwindow/CommandWindow.ts @@ -0,0 +1,716 @@ +// Copyright 2024 The MathWorks, Inc. + +import * as vscode from 'vscode' +import MVM, { MatlabState } from './MVM' +import { TextEvent } from './MVMInterface' + +/** + * Direction of cursor movement + */ +enum CursorDirection { + LEFT, + RIGHT +} + +/** + * Direction of history movement + */ +enum HistoryDirection { + BACKWARDS, + FORWARDS +} + +/** + * Indicator of whether the selection anchor should be kept in place or moved + */ +enum AnchorPolicy { + MOVE, + KEEP +} + +const ESC = '\x1b'; + +/** + * Various terminal escape sequences + */ +const ACTION_KEYS = { + LEFT: ESC + '[D', + RIGHT: ESC + '[C', + UP: ESC + '[A', + DOWN: ESC + '[B', + SHIFT_LEFT: ESC + '[1;2D', + SHIFT_RIGHT: ESC + '[1;2C', + HOME: ESC + '[H', + END: ESC + '[F', + SHIFT_HOME: ESC + '[1;2H', + SHIFT_END: ESC + '[1;2F', + NEWLINE: '\r\n', + BACKSPACE: '\x7f', + BACKSPACE_ALTERNATIVE: '\b', + SELECT_ALL: '\x01', + DELETE: ESC + '[3~', + ESCAPE: ESC, + + INVERT_COLORS: ESC + '[7m', + RESTORE_COLORS: ESC + '[27m', + RED_FOREGROUND: ESC + '[31m', + ALL_DEFAULT_COLORS: ESC + '[0m', + + COPY: '\x03', + PASTE: '\x16', + + MOVE_TO_POSITION_IN_LINE: (n: number) => ESC + '[' + n.toString() + 'G', + CLEAR_AND_MOVE_TO_BEGINNING: ESC + '[0G' + ESC + '[0J', + CLEAR_COMPLETELY: ESC + '[2J' + ESC + '[1;1H', + + QUERY_CURSOR: ESC + '[6n', + SET_CURSOR_STYLE_TO_BAR: ESC + '[5 q' +}; + +const PROMPTS = { + IDLE_PROMPT: '>> ' +}; + +/** + * Represents command window. Is a pseudoterminal to be used as the input/output processor in a VS Code terminal. + */ +export default class CommandWindow implements vscode.Pseudoterminal { + private readonly _mvm: MVM; + private readonly _writeEmitter: vscode.EventEmitter; + + private _initialized: boolean = false; + private _isBusy: boolean = false; + private readonly _currentPrompt = PROMPTS.IDLE_PROMPT; + + private _currentLine: string = this._currentPrompt; + private _cursorIndex: number = 0; + private _anchorIndex?: number = undefined; + + private readonly _commandHistory: string[] = []; + private _historyIndex: number = 0; + private _lastKnownCurrentLine: string = ''; + + private _isLineDirty: boolean = false; + + private _terminalDimensions: vscode.TerminalDimensions; + private _lastSentTerminalDimensions: vscode.TerminalDimensions | null = null; + + private readonly _inputQueue: string[] = []; + + private _justTypedLastInRow: boolean = false; + + constructor (mvm: MVM) { + this._mvm = mvm; + this._mvm.onOutput = this.addOutput.bind(this); + this._mvm.onClc = this.clear.bind(this); + + this._initialized = false; + + this._writeEmitter = new vscode.EventEmitter(); + this.onDidWrite = this._writeEmitter.event; + this._terminalDimensions = { rows: 30, columns: 100 }; + + this._mvm.addStateChangeListener(this._handleMatlabStateChange.bind(this)); + + this._updateHasSelectionContext(); + } + + /** + * Called when a terminal with this pseudoterminal is opened. + * + * Depending on MATLAB state we will either clear the terminal or write the current line again. + * @param initialDimensions + */ + open (initialDimensions?: vscode.TerminalDimensions): void { + if (initialDimensions != null) { + this._terminalDimensions = initialDimensions; + } + + this._writeEmitter.fire(ACTION_KEYS.SET_CURSOR_STYLE_TO_BAR); + + const currentMatlabState = this._mvm.getMatlabState(); + if (currentMatlabState === MatlabState.READY) { + this._isBusy = false; + this._initialized = true; + this._writeCurrentLine(); + } else if (currentMatlabState === MatlabState.DISCONNECTED) { + this._clearState(); + this._isBusy = false; + this._initialized = false; + } else if (currentMatlabState === MatlabState.BUSY) { + this._clearState(); + this._isBusy = true; + this._initialized = true; + } + } + + close (): void { + // Unimplemented + } + + /** + * Resets the terminal state + */ + private _clearState (): void { + this._writeEmitter.fire(ACTION_KEYS.CLEAR_COMPLETELY) + this._setToEmptyPrompt(); + this._lastSentTerminalDimensions = null; + } + + private _handleMatlabStateChange (oldState: MatlabState, newState: MatlabState): void { + if (oldState === newState) { + return; + } + + if (newState === MatlabState.READY) { + this._clearState(); + this._isBusy = false; + this._initialized = true; + this._writeCurrentLine(); + } else if (newState === MatlabState.DISCONNECTED) { + this._clearState(); + this._isBusy = false; + this._initialized = false; + } else if (newState === MatlabState.BUSY) { + this._clearState(); + this._isBusy = true; + this._initialized = true; + } + } + + /** + * Clear current line and selection + */ + private _setToEmptyPrompt (): void { + this._currentLine = this._currentPrompt; + this._lastKnownCurrentLine = this._currentLine; + this._cursorIndex = 0; + this._anchorIndex = undefined; + this._updateHasSelectionContext(); + } + + /** + * Insert a command to run and submit it. + * @param command + */ + insertCommandForEval (command: string): void { + if (this._currentLine !== this._currentPrompt) { + this._setToEmptyPrompt(); + this._writeCurrentLine(); + } + // TODO: handle partially enter commands when run is hit + this.handleInput(command + ACTION_KEYS.NEWLINE); + } + + /** + * Handles input data from the user. Adds it to a queue to be processed asynchronously when we are next idle. + * @param data + * @returns + */ + handleInput (data: string): void { + if (!this._initialized) { + return; + } + + this._inputQueue.push(data); + this._processQueueUntilBusy(); + } + + private _processQueueItem (): void { + const nextItem = this._inputQueue.shift(); + if (nextItem === undefined) { + return; + } + this.handleText(nextItem, false); + } + + private _processQueueUntilBusy (): void { + while (this._inputQueue.length > 0 && !this._isBusy) { + this._processQueueItem(); + } + } + + /** + * Processes the incoming text, handling the terminal escape sequences as needed. + * @param data + * @param isOutput + * @returns + */ + handleText (data: string, isOutput: boolean): void { + if (data.startsWith(ESC)) { + /* eslint-disable-next-line no-control-regex */ + const match = data.match(/^\x1b\[(?[0-9]+);(?[0-9]+)R$/) + if (match !== null && 'groups' in match && (match.groups != null) && 'row' in match.groups && 'col' in match.groups) { + return; + } + + switch (data) { + case ACTION_KEYS.LEFT: + this._handleLeftRight(CursorDirection.LEFT, AnchorPolicy.MOVE); + break; + case ACTION_KEYS.RIGHT: + this._handleLeftRight(CursorDirection.RIGHT, AnchorPolicy.MOVE); + break; + case ACTION_KEYS.SHIFT_LEFT: + this._handleLeftRight(CursorDirection.LEFT, AnchorPolicy.KEEP); + break; + case ACTION_KEYS.SHIFT_RIGHT: + this._handleLeftRight(CursorDirection.RIGHT, AnchorPolicy.KEEP); + break; + case ACTION_KEYS.END: + this._handleEnd(AnchorPolicy.MOVE); + break; + case ACTION_KEYS.SHIFT_END: + this._handleEnd(AnchorPolicy.KEEP); + break; + case ACTION_KEYS.HOME: + this._handleHome(AnchorPolicy.MOVE); + break; + case ACTION_KEYS.SHIFT_HOME: + this._handleHome(AnchorPolicy.KEEP); + break; + case ACTION_KEYS.DELETE: + this._handleDelete(); + break; + case ACTION_KEYS.UP: + this._handleNavigateHistory(HistoryDirection.BACKWARDS); + break; + case ACTION_KEYS.DOWN: + this._handleNavigateHistory(HistoryDirection.FORWARDS); + break; + case ACTION_KEYS.ESCAPE: + this._handleEscape(); + } + + if (this._isLineDirty) { + this._writeCurrentLine(); + } + return; + } + + switch (data) { + case ACTION_KEYS.BACKSPACE: + case ACTION_KEYS.BACKSPACE_ALTERNATIVE: + this._handleBackspace(); + return; + case ACTION_KEYS.SELECT_ALL: + this._handleSelectAll(); + return; + case ACTION_KEYS.COPY: + this._handleCopy(); + return; + case ACTION_KEYS.PASTE: + this._handlePaste(); + return; + } + + if (data.length === 1 && data.charCodeAt(0) < ' '.charCodeAt(0) && data !== '\r' && data !== '\n') { + return; + } + + const lines = this._preprocessInputLines(data); + + // Case 1: Normal typing + if (lines.length === 1) { + this._handleLine(lines[0]); + + // Case 2: Normal typing followed by an enter. + } else if (lines.length === 2 && lines[1].length === 0) { + this._handleLine(lines[0]); + if (isOutput) { + this._handleOutputEnter(); + } else { + this._handleEnter(); + } + // Case 3: Multi-line input (ie, from pasting, etc) + } else { + for (let i = 0; i < lines.length; i++) { + this._handleLine(lines[i] + (i === lines.length - 1 ? '' : ACTION_KEYS.NEWLINE)); + } + if (isOutput) { + this._handleOutputEnter(); + } else { + this._handleEnter(); + } + } + } + + private _preprocessInputLines (data: string): string[] { + data = data.replace(/\r\n?/g, '\n'); + const lines = data.split('\n'); + return lines; + } + + private _handleNavigateHistory (direction: HistoryDirection): void { + const isCurrentlyAtEndOfHistory = this._historyIndex === this._commandHistory.length; + const isCurrentlyAtBeginningOfHistory = this._historyIndex === 0; + + if (direction === HistoryDirection.BACKWARDS && isCurrentlyAtBeginningOfHistory) { + return; + } + + if (direction === HistoryDirection.FORWARDS && isCurrentlyAtEndOfHistory) { + return; + } + + if (isCurrentlyAtEndOfHistory) { + this._lastKnownCurrentLine = this._currentLine; + } + + this._historyIndex += direction === HistoryDirection.BACKWARDS ? -1 : 1; + this._replaceCurrentLineWithNewLine(this._getHistoryItem(this._historyIndex)) + + this._justTypedLastInRow = this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0; + } + + private _markCurrentLineChanged (): void { + this._historyIndex = this._commandHistory.length; + this._lastKnownCurrentLine = ''; + } + + private _possiblyUpdateAnchor (policy: AnchorPolicy): void { + if (policy === AnchorPolicy.MOVE && this._anchorIndex !== undefined) { + this._anchorIndex = undefined; + this._isLineDirty = true; + } else if (policy === AnchorPolicy.KEEP) { + if (this._anchorIndex === undefined) { + this._anchorIndex = this._cursorIndex; + } + this._isLineDirty = true; + } + this._updateHasSelectionContext(); + } + + private _handleEnd (anchorPolicy: AnchorPolicy): void { + const currentCursorLine = Math.ceil(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns); + this._possiblyUpdateAnchor(anchorPolicy); + this._cursorIndex = this._getMaxIndexOnLine(); + this._moveCursorToCurrent(currentCursorLine); + } + + private _handleHome (anchorPolicy: AnchorPolicy): void { + const currentCursorLine = Math.ceil(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns); + this._possiblyUpdateAnchor(anchorPolicy); + this._cursorIndex = 0; + this._moveCursorToCurrent(currentCursorLine); + } + + private _handleLeftRight (direction: CursorDirection, anchorPolicy: AnchorPolicy): void { + if (direction === CursorDirection.LEFT && this._cursorIndex !== 0) { + if (this._justTypedLastInRow) { + // Don't actually move the cursor, but do move the index we think the cursor is at. + this._justTypedLastInRow = false; + } else { + if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0) { + this._writeEmitter.fire(ACTION_KEYS.UP + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(this._terminalDimensions.columns)); + } else { + this._writeEmitter.fire(ACTION_KEYS.LEFT); + } + } + + this._possiblyUpdateAnchor(anchorPolicy); + this._cursorIndex--; + } + + if (direction === CursorDirection.RIGHT && this._cursorIndex !== this._getMaxIndexOnLine()) { + if (this._justTypedLastInRow) { + // Not possible + } else { + if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === (this._terminalDimensions.columns - 1)) { + this._writeEmitter.fire(ACTION_KEYS.DOWN + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(0)); + } else { + this._writeEmitter.fire(ACTION_KEYS.RIGHT); + } + } + + this._possiblyUpdateAnchor(anchorPolicy); + this._cursorIndex++; + } + } + + private _getMaxIndexOnLine (): number { + return this._currentLine.length - this._currentPrompt.length; + } + + private _getAbsoluteIndexOnLine (index: number): number { + return index + this._currentPrompt.length; + } + + private _handleBackspace (): void { + if (this._anchorIndex !== undefined) { + this._removeSelection(); + if (this._isLineDirty) { + this._markCurrentLineChanged(); + this._writeCurrentLine(); + } + return; + } + + if (this._cursorIndex === 0) { + return; + } + + const before = this._currentLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex) - 1); + const after = this._currentLine.substring(this._getAbsoluteIndexOnLine(this._cursorIndex)); + this._currentLine = before + after; + this._cursorIndex--; + this._markCurrentLineChanged(); + this._writeCurrentLine(); + } + + private _handleSelectAll (): void { + this._cursorIndex = this._getMaxIndexOnLine(); + this._anchorIndex = 0; + this._updateHasSelectionContext(); + this._writeCurrentLine(); + } + + private _handleDelete (): void { + if (this._anchorIndex !== undefined) { + this._removeSelection(); + if (this._isLineDirty) { + this._markCurrentLineChanged(); + this._writeCurrentLine(); + } + return; + } + + if (this._cursorIndex === this._getMaxIndexOnLine()) { + return; + } + + const before = this._currentLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex)); + const after = this._currentLine.substring(this._getAbsoluteIndexOnLine(this._cursorIndex) + 1); + this._currentLine = before + after; + this._markCurrentLineChanged(); + this._writeCurrentLine(); + } + + private _writeCurrentLine (): void { + const numberOfLinesBehind = Math.floor(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns); + if (numberOfLinesBehind !== 0) { + this._writeEmitter.fire(ACTION_KEYS.UP.repeat(numberOfLinesBehind)) + } + this._writeEmitter.fire(ACTION_KEYS.CLEAR_AND_MOVE_TO_BEGINNING) + if (this._anchorIndex === undefined) { + this._writeEmitter.fire(this._currentLine) + } else { + const selectionStart = this._currentPrompt.length + Math.min(this._cursorIndex, this._anchorIndex); + const selectionEnd = this._currentPrompt.length + Math.max(this._cursorIndex, this._anchorIndex); + const preSelection = this._currentLine.slice(0, selectionStart); + const selection = this._currentLine.slice(selectionStart, selectionEnd); + const postSelection = this._currentLine.slice(selectionEnd); + this._writeEmitter.fire(preSelection); + this._writeEmitter.fire(ACTION_KEYS.INVERT_COLORS); + this._writeEmitter.fire(selection); + this._writeEmitter.fire(ACTION_KEYS.RESTORE_COLORS); + this._writeEmitter.fire(postSelection); + } + const currentCursorLine = Math.ceil(this._currentLine.length / this._terminalDimensions.columns); + this._moveCursorToCurrent(currentCursorLine); + this._isLineDirty = false; + } + + private _replaceCurrentLineWithNewLine (updatedLine: string): void { + this._currentLine = updatedLine; + this._cursorIndex = this._getMaxIndexOnLine(); + this._anchorIndex = undefined; + this._writeCurrentLine(); + } + + private _removeSelection (): void { + if (this._anchorIndex === undefined || this._cursorIndex === this._anchorIndex) { + this._anchorIndex = undefined; + this._updateHasSelectionContext(); + return; + } + const selectionStart = this._getAbsoluteIndexOnLine(Math.min(this._cursorIndex, this._anchorIndex)); + const selectionEnd = this._getAbsoluteIndexOnLine(Math.max(this._cursorIndex, this._anchorIndex)); + const preSelection = this._currentLine.slice(0, selectionStart); + const postSelection = this._currentLine.slice(selectionEnd); + this._currentLine = preSelection + postSelection; + this._cursorIndex = selectionStart - this._currentPrompt.length; + this._anchorIndex = undefined; + this._isLineDirty = true; + this._updateHasSelectionContext(); + } + + private _handleLine (line: string): void { + this._removeSelection(); + if (this._isLineDirty) { + this._writeCurrentLine(); + } + + if (this._cursorIndex === this._getMaxIndexOnLine()) { + this._currentLine += line; + this._cursorIndex += line.length; + this._writeEmitter.fire(line); + } else { + const before = this._currentLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex)); + const after = this._currentLine.substring(this._getAbsoluteIndexOnLine(this._cursorIndex)); + this._currentLine = before + line + after; + this._cursorIndex += line.length; + this._isLineDirty = true; + this._writeCurrentLine(); + } + this._markCurrentLineChanged(); + this._justTypedLastInRow = this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0; + } + + private _handleOutputEnter (): void { + this._handleEnd(AnchorPolicy.MOVE); + this._writeEmitter.fire(ACTION_KEYS.NEWLINE); + } + + private _handleEnter (): void { + const stringToEvaluate = this._currentLine.substring(this._getAbsoluteIndexOnLine(0), this._getAbsoluteIndexOnLine(this._getMaxIndexOnLine())).trim(); + this._addToHistory(this._currentLine); + this._handleEnd(AnchorPolicy.MOVE); + this._writeEmitter.fire(ACTION_KEYS.NEWLINE); + this._setToEmptyPrompt(); + this._isBusy = true; + this._evaluateCommand(stringToEvaluate).then(() => { + this._setToEmptyPrompt(); + this._writeCurrentLine(); + this._justTypedLastInRow = this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0; + this._isBusy = false; + this._processQueueUntilBusy(); + }, () => { + // Ignored + }) + } + + private _addToHistory (command: string): void { + const isEmpty = command === this._currentPrompt; + const isLastInHistory = this._commandHistory.length !== 0 && command === this._commandHistory[this._commandHistory.length - 1]; + if (!isEmpty && !isLastInHistory) { + this._commandHistory.push(command); + } + this._historyIndex = this._commandHistory.length; + } + + private _getHistoryItem (n: number): string { + if (this._historyIndex < this._commandHistory.length) { + return this._commandHistory[n]; + } else { + return this._lastKnownCurrentLine; + } + } + + private _moveCursorToCurrent (lineOfInputCursorIsCurrentlyOn?: number): void { + const lineNumberCursorShouldBeOn = Math.ceil(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns); + if (lineOfInputCursorIsCurrentlyOn === undefined) { + lineOfInputCursorIsCurrentlyOn = lineNumberCursorShouldBeOn; + } + if (lineNumberCursorShouldBeOn > lineOfInputCursorIsCurrentlyOn) { + this._writeEmitter.fire(ACTION_KEYS.DOWN.repeat(lineNumberCursorShouldBeOn - lineOfInputCursorIsCurrentlyOn)); + } else if (lineNumberCursorShouldBeOn < lineOfInputCursorIsCurrentlyOn) { + this._writeEmitter.fire(ACTION_KEYS.UP.repeat(lineOfInputCursorIsCurrentlyOn - lineNumberCursorShouldBeOn)); + } + this._writeEmitter.fire(ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns) + 1)); + } + + setDimensions (dimensions: vscode.TerminalDimensions): void { + this._terminalDimensions = dimensions; + } + + private _sendTerminalDimensionsIfNeeded (): void { + if ((this._lastSentTerminalDimensions == null) || this._lastSentTerminalDimensions.columns !== this._terminalDimensions.columns || this._lastSentTerminalDimensions.rows !== this._terminalDimensions.rows) { + void this._mvm.eval(`try; if usejava('jvm'); com.mathworks.mde.cmdwin.CmdWinMLIF.setCWSize(${this._terminalDimensions.rows}, ${this._terminalDimensions.columns}); end; end;`); + this._lastSentTerminalDimensions = this._terminalDimensions; + } + } + + private async _evaluateCommand (command: string): Promise { + this._sendTerminalDimensionsIfNeeded(); + return await (this._mvm.eval(command) as Promise); + } + + /** + * + * @param output Add an output TextEvent to the command window. Stderr is displayed in red. + */ + addOutput (output: TextEvent): void { + if (this._initialized) { + if (output.stream === 0) { + this.handleText(output.text, true); + } else { + this._writeEmitter.fire(ACTION_KEYS.RED_FOREGROUND); + this.handleText(output.text, true); + this._writeEmitter.fire(ACTION_KEYS.ALL_DEFAULT_COLORS); + } + } + } + + /** + * Clears the command window, and also wipes out the terminal's scroll history as well. + */ + clear (): void { + this._writeEmitter.fire(ACTION_KEYS.CLEAR_COMPLETELY) + void vscode.commands.executeCommand('workbench.action.terminal.clear'); + } + + private _updateHasSelectionContext (): void { + void vscode.commands.executeCommand('setContext', 'matlab.terminalHasSelection', this._anchorIndex !== undefined); + } + + private _handleCopy (): void { + if (this._anchorIndex === undefined) { + return; + } + + const selectionStart = this._currentPrompt.length + Math.min(this._cursorIndex, this._anchorIndex); + const selectionEnd = this._currentPrompt.length + Math.max(this._cursorIndex, this._anchorIndex); + const selection = this._currentLine.slice(selectionStart, selectionEnd); + void vscode.env.clipboard.writeText(selection); + } + + private _handlePaste (): void { + vscode.env.clipboard.readText().then((text: string) => { + this.handleInput(text); + }, () => { + // Ignored + }); + } + + private _handleEscape (): void { + this._setToEmptyPrompt(); + this._isLineDirty = true; + } + + onDidWrite: vscode.Event; + onDidOverrideDimensions?: vscode.Event | undefined; + onDidClose?: vscode.Event | undefined; + onDidChangeName?: vscode.Event | undefined; + + /** + * + * @param data Helper used to log input is a readible manner + */ + private _logInput (data: string): void { + let shouldPrint = false; + let s = '['; + const prefix = '' + for (let i = 0; i < data.length; i++) { + let ch = data[i]; + if (data.charCodeAt(i) === 0x1b) { + ch = 'ESC' + shouldPrint = true; + } else { + if (ch.match(/[a-z0-9,./;'[\]\\`~!@#$%^&*()_+\-=|:'{}<>?]/i) === null) { + let hex = data.charCodeAt(i).toString(16); + if (hex.length === 1) { + hex = '0' + hex; + } + ch = '\\x' + hex; + shouldPrint = true; + } + } + s += prefix + ch; + } + s += ']' + if (shouldPrint) { + console.log(s); + } + } +} diff --git a/src/commandwindow/ExecutionCommandProvider.ts b/src/commandwindow/ExecutionCommandProvider.ts new file mode 100644 index 0000000..44b4748 --- /dev/null +++ b/src/commandwindow/ExecutionCommandProvider.ts @@ -0,0 +1,276 @@ +// Copyright 2024 The MathWorks, Inc. + +import * as vscode from 'vscode' +import MVM from './MVM' +import TerminalService from './TerminalService' +import TelemetryLogger from '../telemetry/TelemetryLogger' +import * as path from 'path' + +// These values must match the results returned by mdbfileonpath.m in FilePathState.m +enum FILE_PATH_STATE { + FILE_NOT_ON_PATH = 0, + FILE_WILL_RUN = 1, + FILE_SHADOWED_BY_PWD = 2, + FILE_SHADOWED_BY_TBX = 3, + FILE_SHADOWED_BY_PFILE = 4, + FILE_SHADOWED_BY_MEXFILE = 5, + FILE_SHADOWED_BY_MLXFILE = 6, + FILE_SHADOWED_BY_MLAPPFILE = 7 +} + +export default class ExecutionCommandProvider { + private readonly _mvm: MVM; + private readonly _terminalService: TerminalService; + private readonly _telemetryLogger: TelemetryLogger; + + constructor (mvm: MVM, terminalService: TerminalService, telemetryLogger: TelemetryLogger) { + this._mvm = mvm; + this._terminalService = terminalService; + this._telemetryLogger = telemetryLogger; + } + + /** + * Handle the run file action + * @returns + */ + async handleRunFile (): Promise { + const editor = vscode.window.activeTextEditor + + this._telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { + action_type: 'runFile', + result: '' + } + }); + + // Early return if the editor isn't valid or isn't MATLAB + if (editor === undefined || editor.document.languageId !== 'matlab') { + return; + } + + // Open the terminal and wait for the MVM to be started. + // This could take a while, so should double check the editor state after this point. + await this._terminalService.openTerminalOrBringToFront(); + try { + await this._mvm.getReadyPromise(); + } catch (e) { + return; + } + + // If the editor has been closed or is untitled, return + if (editor.document.isClosed || editor.document.isUntitled) { + return; + } + + // If the editor is dirty, or an untitled document, the save it and notify MATLAB of the change. + if (editor.document.isDirty) { + try { + await editor.document.save(); + const filePath = editor.document.fileName; + await this._mvm.feval('fschange', 0, [filePath]); + await this._mvm.feval('clear', 0, [filePath]); + } catch (e) { + return; + } + } + + if (editor.document.isUntitled) { + return; + } + + const filePath = editor.document.fileName; + const fileParts = filePath.split(path.sep); + const filenameWithExtension = fileParts[fileParts.length - 1]; + + // Verify that this is an m file. + if (!filenameWithExtension.endsWith('.m')) { + return; + } + + // Extract the command to run the file. That is, the default run configuration. + const filenameWithoutExtension = filenameWithExtension.substring(0, filenameWithExtension.length - 2) + + let commandToRun = filenameWithoutExtension; + + // Handle the case where the file is in a class folder + let parentIndex = fileParts.length - 2; + const parentFolder = fileParts[parentIndex] + if (parentFolder.startsWith('@')) { + commandToRun = parentFolder.substring(1) + '.' + commandToRun; + parentIndex--; + } + + // Handle MATLAB namespaces by prepending the folder names until the first "+" folder. + while (parentIndex >= 0) { + const parentFolder = fileParts[parentIndex] + if (!parentFolder.startsWith('+')) { + break; + } + + commandToRun = parentFolder.substring(1) + '.' + commandToRun; + parentIndex--; + } + + // Check whether the file is runnable, shadowed, etc. + let mdbfileonpathResult; + try { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + mdbfileonpathResult = await this._mvm.feval('mdbfileonpath', 2, [filePath]); + } catch (e) { + return; + } + if (mdbfileonpathResult.error !== undefined) { + return; + } + + const status = mdbfileonpathResult.result[1] as FILE_PATH_STATE; + + // Handle the results of the path check + try { + switch (status) { + case FILE_PATH_STATE.FILE_WILL_RUN: + this._terminalService.getCommandWindow().insertCommandForEval(commandToRun); + break; + case FILE_PATH_STATE.FILE_NOT_ON_PATH: + case FILE_PATH_STATE.FILE_SHADOWED_BY_TBX: + await this._handleNotOnPath(filePath, commandToRun); + break; + case FILE_PATH_STATE.FILE_SHADOWED_BY_PWD: + await this._handleShadowedByPwd(filePath, commandToRun); + break; + default: + void vscode.window.showErrorMessage('Unable to run file as it is shadowed by another file in the same folder.'); + } + } catch (e) { + + } + } + + /** + * + * @param fileParts + * @param commandToRun + * @returns + */ + private async _handleNotOnPath (filePath: string, commandToRun: string): Promise { + const choice = await vscode.window.showWarningMessage('File is not found in the current folder or on the MATLAB path.', 'Add to Path', 'Change Folder', 'Cancel'); + if (choice === undefined || choice === 'Cancel') { + return; + } + + const filePathWithoutFilename = path.dirname(filePath); + if (choice === 'Add to Path') { + await this._mvm.feval('addpath', 0, [filePathWithoutFilename]); + } else { + await this._mvm.feval('cd', 0, [filePathWithoutFilename]); + } + await this._terminalService.openTerminalOrBringToFront(); + this._terminalService.getCommandWindow().insertCommandForEval(commandToRun); + } + + private async _handleShadowedByPwd (filePath: string, commandToRun: string): Promise { + const choice = await vscode.window.showWarningMessage('File is shadowed by another file in the current folder.', 'Change Folder', 'Cancel'); + if (choice === undefined || choice === 'Cancel') { + return; + } + const filePathWithoutFilename = path.dirname(filePath); + await this._mvm.feval('cd', 0, [filePathWithoutFilename]); + await this._terminalService.openTerminalOrBringToFront(); + this._terminalService.getCommandWindow().insertCommandForEval(commandToRun); + } + + /** + * Implements the run selection action + * @returns + */ + async handleRunSelection (): Promise { + this._telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { + action_type: 'runSelection', + result: '' + } + }); + + const editor = vscode.window.activeTextEditor + if (editor === undefined || editor.document.languageId !== 'matlab') { + return; + } + + const text = editor.document.getText(editor.selection); + + await this._terminalService.openTerminalOrBringToFront(); + try { + await this._mvm.getReadyPromise(); + } catch (e) { + return; + } + + this._terminalService.getCommandWindow().insertCommandForEval(text); + } + + /** + * Implements the interrupt action + */ + handleInterrupt (): void { + this._telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { + action_type: 'interrupt', + result: '' + } + }); + this._mvm.interrupt(); + } + + /** + * Implements the add to path action + * @param uri The file path that should be added to the MATLAB path + * @returns + */ + async handleAddToPath (uri: vscode.Uri): Promise { + this._telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { + action_type: 'addToPath', + result: '' + } + }); + + await this._terminalService.openTerminalOrBringToFront(); + + try { + await this._mvm.getReadyPromise(); + } catch (e) { + return; + } + + void this._mvm.feval('addpath', 0, [uri.fsPath]); + } + + /** + * Implements the MATLAB change directory action + * @param uri The file path that MATLAB should "cd" to + * @returns + */ + async handleChangeDirectory (uri: vscode.Uri): Promise { + this._telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { + action_type: 'changeDirectory', + result: '' + } + }); + + await this._terminalService.openTerminalOrBringToFront(); + + try { + await this._mvm.getReadyPromise(); + } catch (e) { + return; + } + + void this._mvm.feval('cd', 0, [uri.fsPath]); + } +} diff --git a/src/commandwindow/MVM.ts b/src/commandwindow/MVM.ts new file mode 100644 index 0000000..76d5de7 --- /dev/null +++ b/src/commandwindow/MVM.ts @@ -0,0 +1,217 @@ +// Copyright 2024 The MathWorks, Inc. + +import IMVM, { TextEvent, FEvalResponse, EvalResponse, FEvalError } from './MVMInterface' +import { createResolvablePromise, ResolvablePromise, Notifier } from './Utilities' +import Notification from '../Notifications' + +/** + * The current state of MATLAB + */ +export enum MatlabState { + DISCONNECTED = 'disconnected', + READY = 'ready', + BUSY = 'busy' +} + +/** + * A clientside implementation of MATLAB + */ +export default class MVMImpl implements IMVM { + private _requestMap: {[requestId: string]: {promise: ResolvablePromise, isUserEval: boolean}} = {} + private _pendingUserEvals: number; + + private readonly _notifier: Notifier; + + private readonly _stateObservers: Array<(oldState: MatlabState, newState: MatlabState) => void> = []; + private _currentState: MatlabState = MatlabState.DISCONNECTED; + + private _currentReadyPromise: ResolvablePromise; + + constructor (notifier: Notifier) { + this._notifier = notifier; + + this._notifier.onNotification(Notification.MVMEvalComplete, this._handleEvalResponse.bind(this)); + this._notifier.onNotification(Notification.MVMFevalComplete, this._handleFevalResponse.bind(this)); + this._notifier.onNotification(Notification.MVMStateChange, this._handleMatlabStateChange.bind(this)); + this._notifier.onNotification(Notification.MVMText, (data: TextEvent) => { + this.onOutput(data) + }); + this._notifier.onNotification(Notification.MVMClc, () => { this.onClc() }); + + this._currentReadyPromise = createResolvablePromise(); + + this._pendingUserEvals = 0; + } + + /** + * + * @returns a promise that is resolved when MATLAB is connected an available + */ + async getReadyPromise (): Promise { + return await this._currentReadyPromise; + } + + /** + * + * @returns The current state of MATLAB + */ + getMatlabState (): MatlabState { + if (this._currentState === MatlabState.DISCONNECTED) { + return this._currentState; + } + return this._pendingUserEvals > 0 ? MatlabState.BUSY : MatlabState.READY; + } + + private _handleMatlabStateChange (newState: string): void { + const oldState = this._currentState; + this._currentState = MatlabState[newState.toUpperCase() as keyof typeof MatlabState]; + + if (this._currentState === MatlabState.DISCONNECTED) { + this._handleDisconnection(); + } + + this._stateObservers.forEach((observer) => { + observer(oldState, this._currentState); + }, this); + + if (this._currentState !== MatlabState.DISCONNECTED) { + this._pendingUserEvals = 0; + this._currentReadyPromise.resolve(); + } + } + + private _handleDisconnection (): void { + const oldPromise = this._currentReadyPromise; + this._currentReadyPromise = createResolvablePromise(); + oldPromise.reject(); + + const requestMap = this._requestMap; + this._requestMap = {}; + + for (const requestIdToCancel in requestMap) { + requestMap[requestIdToCancel].promise.reject(); + } + + this._pendingUserEvals = 0; + } + + /** + * Allow listening to MATLAB state changes + * @param observer + */ + addStateChangeListener (observer: (oldState: MatlabState, newState: MatlabState) => void): void { + this._stateObservers.push(observer); + } + + /** + * Evaluate the given command. + * @param command the command to run + * @param isUserEval Only user evals contribute to the current busy state + * @returns a promise that is resolved when the eval completes + */ + eval (command: string): ResolvablePromise; + eval (command: string, isUserEval: boolean = true): ResolvablePromise { + const requestId = this._getNewRequestId(); + const promise = createResolvablePromise(); + this._requestMap[requestId] = { + promise, + isUserEval + }; + + if (isUserEval) { + this._pendingUserEvals++; + } + + this._currentReadyPromise.then(() => { + this._notifier.sendNotification(Notification.MVMEvalRequest, { + requestId, + command + }); + }, () => { + // Ignored + }); + + return promise; + } + + /** + * Evaluate the given function + * @param functionName The function to run + * @param nargout the number of output arguments to request + * @param args The arguments of the function + * @returns A promise resolved when the feval completes + */ + feval (functionName: string, nargout: number, args: unknown[]): ResolvablePromise { + const requestId = this._getNewRequestId(); + const promise = createResolvablePromise(); + this._requestMap[requestId] = { + promise, + isUserEval: false + }; + + this._currentReadyPromise.then(() => { + this._notifier.sendNotification(Notification.MVMFevalRequest, { + requestId, + functionName, + nargout, + args + }); + }, () => { + // Ignored + }); + + return promise; + } + + /** + * Interrupt all pending evaluations + */ + interrupt (): void { + this._notifier.sendNotification(Notification.MVMInterruptRequest); + } + + /** + * Called with output from any requests + * @param data + */ + onOutput (data: TextEvent): void { + throw new Error('Method not overridden.'); + } + + /** + * Called when a clc is run + */ + onClc (): void { + throw new Error('Method not overridden.'); + } + + private _handleEvalResponse (message: EvalResponse): void { + const obj = this._requestMap[message.requestId]; + if (obj === undefined) { + return; + } + const promise = obj.promise; + if (this._requestMap[message.requestId].isUserEval) { + this._pendingUserEvals--; + } + + promise.resolve(); + } + + private _handleFevalResponse (message: FEvalResponse): void { + const obj = this._requestMap[message.requestId]; + if (obj === undefined) { + return; + } + const promise = obj.promise; + if (this._requestMap[message.requestId].isUserEval) { + this._pendingUserEvals--; + } + + promise.resolve(message.result); + } + + private _getNewRequestId (): string { + return Math.random().toString(36).substr(2, 9); + } +} diff --git a/src/commandwindow/MVMInterface.ts b/src/commandwindow/MVMInterface.ts new file mode 100644 index 0000000..3bed1bf --- /dev/null +++ b/src/commandwindow/MVMInterface.ts @@ -0,0 +1,62 @@ +// Copyright 2024 The MathWorks, Inc. + +import { ResolvablePromise } from './Utilities'; + +/** + * Represents text coming from MATLAB + */ +export interface TextEvent { + text: string + stream: number // 1 = stdout, 2 = stderr +} + +/** + * Represents a eval request to MATLAB + */ +export interface EvalRequest { + requestId: string | number + command: string +} + +/** + * Represents a eval response to MATLAB + */ +export interface EvalResponse { + requestId: string | number +} + +/** + * Represents a feval request to MATLAB + */ +export interface FEvalRequest { + requestId: string | number + functionName: string + nargout: number + args: unknown[] +} + +/** + * Represents a feval response to MATLAB + */ +export interface FEvalResponse { + requestId: string | number + result: unknown +} + +/** + * MATLAB Error result + */ +export interface FEvalError { + error: unknown +} + +/** + * The base functionality for any MVM instance to support + */ +export default interface IMVM { + eval: (command: string) => ResolvablePromise + feval: (functionName: string, nargout: number, args: unknown[]) => ResolvablePromise + interrupt: () => void + onOutput: (data: TextEvent) => void + onClc: () => void +} diff --git a/src/commandwindow/TerminalService.ts b/src/commandwindow/TerminalService.ts new file mode 100644 index 0000000..eb5cd8d --- /dev/null +++ b/src/commandwindow/TerminalService.ts @@ -0,0 +1,120 @@ +// Copyright 2024 The MathWorks, Inc. + +import * as vscode from 'vscode' +import MVM from './MVM' +import { Notifier, ResolvablePromise, createResolvablePromise } from './Utilities' +import CommandWindow from './CommandWindow' +import Notification from '../Notifications' + +/** + * Manages the MATLAB VS Code terminal ensuring that only a single one is open at a time + */ +export default class TerminalService { + private readonly _mvm: MVM; + private readonly _client: Notifier; + private readonly _commandWindow: CommandWindow; + + private readonly _terminalOptions: vscode.ExtensionTerminalOptions; + + private _currentMatlabTerminal?: vscode.Terminal; + private _terminalCreationPromise?: ResolvablePromise; + + constructor (client: Notifier, mvm: MVM) { + this._mvm = mvm; + this._client = client; + + this._commandWindow = new CommandWindow(mvm); + + this._terminalOptions = { + name: 'MATLAB', + pty: this._commandWindow, + isTransient: true + }; + + vscode.window.onDidOpenTerminal((terminal) => { + if (terminal.creationOptions.name === 'MATLAB') { + this._currentMatlabTerminal = terminal; + client.sendNotification(Notification.MatlabRequestInstance); + this._currentMatlabTerminal.show(); + setTimeout(() => { + if (this._terminalCreationPromise != null) { + this._terminalCreationPromise.resolve(); + } + }, 100); + } + }); + + vscode.window.onDidCloseTerminal((terminal) => { + if (terminal === this._currentMatlabTerminal) { + this._currentMatlabTerminal = undefined; + } + }); + + vscode.window.registerTerminalProfileProvider('matlab.terminal-profile', new MatlabTerminalProvider(this, this._terminalOptions)); + + vscode.window.onDidChangeActiveTerminal(terminal => { + if ((this._currentMatlabTerminal != null) && terminal === this._currentMatlabTerminal) { + void vscode.commands.executeCommand('setContext', 'matlab.isActiveTerminal', true); + } else { + void vscode.commands.executeCommand('setContext', 'matlab.isActiveTerminal', false); + } + }) + + // Required to ensure that Ctrl+C keybinding is handled by vscode and not the terminal itself + const terminalConfiguration = vscode.workspace.getConfiguration('terminal.integrated'); + const commandsToNotSendToTerminal: string[] | undefined = terminalConfiguration.get('commandsToSkipShell'); + if ((commandsToNotSendToTerminal != null) && !commandsToNotSendToTerminal.includes('matlab.interrupt')) { + commandsToNotSendToTerminal.push('matlab.interrupt'); + void terminalConfiguration.update('commandsToSkipShell', commandsToNotSendToTerminal, true); + } + } + + /** + * Opens or brings the MATLAB termianl to the front. + * @returns resolves when the terminal is visible + */ + async openTerminalOrBringToFront (): Promise { + this._client.sendNotification(Notification.MatlabRequestInstance); + if (this._currentMatlabTerminal != null) { + this._currentMatlabTerminal.show(); + } else { + vscode.window.createTerminal(this._terminalOptions); + this._terminalCreationPromise = createResolvablePromise(); + await this._terminalCreationPromise; + } + } + + /** + * Close the current MATLAB terminal + */ + closeTerminal (): void { + if (this._currentMatlabTerminal != null) { + this._currentMatlabTerminal.dispose(); + } + } + + /** + * @returns The command window + */ + getCommandWindow (): CommandWindow { + return this._commandWindow; + } +} + +/** + * Provides a VS Code terminal window backed by the command window. + */ +class MatlabTerminalProvider { + private readonly _terminalService: TerminalService; + private readonly _terminalOptions: vscode.ExtensionTerminalOptions; + + constructor (terminalService: TerminalService, terminalOptions: vscode.ExtensionTerminalOptions) { + this._terminalService = terminalService; + this._terminalOptions = terminalOptions; + } + + provideTerminalProfile (token: vscode.CancellationToken): vscode.ProviderResult { + this._terminalService.closeTerminal(); + return new vscode.TerminalProfile(this._terminalOptions); + } +} diff --git a/src/commandwindow/Utilities.ts b/src/commandwindow/Utilities.ts new file mode 100644 index 0000000..e006772 --- /dev/null +++ b/src/commandwindow/Utilities.ts @@ -0,0 +1,36 @@ +// Copyright 2024 The MathWorks, Inc. + +/** + * A promise with resolve and reject methods. Allows easier storing of the promise to be resolved elsewhere. + */ +export interface ResolvablePromise extends Promise { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + resolve: any + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + reject: any +} + +/** + * Creates a resolvable promise + * @returns A resolvable promise + */ +export function createResolvablePromise (): ResolvablePromise { + let res, rej; + const p = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }) as ResolvablePromise; + p.resolve = res; + p.reject = rej; + return p; +} + +/** + * Represents an object that can send and recieve data on specific channels. + */ +export interface Notifier { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + sendNotification: (tag: string, data?: any) => void + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + onNotification: (tag: string, callback: (data: any) => void) => void +} diff --git a/src/extension.ts b/src/extension.ts index a2a5948..b22e7c1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,11 @@ import { } from 'vscode-languageclient/node' import NotificationConstants from './NotificationConstants' import TelemetryLogger, { TelemetryEvent } from './telemetry/TelemetryLogger' +import MVM from './commandwindow/MVM' +import { Notifier } from './commandwindow/Utilities' +import TerminalService from './commandwindow/TerminalService' +import Notification from './Notifications' +import ExecutionCommandProvider from './commandwindow/ExecutionCommandProvider' let client: LanguageClient @@ -23,19 +28,9 @@ export let connectionStatusNotification: vscode.StatusBarItem let telemetryLogger: TelemetryLogger -enum Notification { - // Connection Status Updates - MatlabConnectionClientUpdate = 'matlab/connection/update/client', - MatlabConnectionServerUpdate = 'matlab/connection/update/server', - - // Errors - MatlabLaunchFailed = 'matlab/launchfailed', - MatlabFeatureUnavailable = 'feature/needsmatlab', - MatlabFeatureUnavailableNoMatlab = 'feature/needsmatlab/nomatlab', - - // Telemetry - LogTelemetryData = 'telemetry/logdata' -} +let mvm: MVM; +let terminalService: TerminalService; +let executionCommandProvider: ExecutionCommandProvider; export async function activate (context: vscode.ExtensionContext): Promise { // Initialize telemetry logger @@ -99,11 +94,22 @@ export async function activate (context: vscode.ExtensionContext): Promise ) // Set up notification listeners - client.onNotification(Notification.MatlabConnectionServerUpdate, data => handleConnectionStatusChange(data)) + client.onNotification(Notification.MatlabConnectionServerUpdate, (data: { connectionStatus: string }) => handleConnectionStatusChange(data)) client.onNotification(Notification.MatlabLaunchFailed, () => handleMatlabLaunchFailed()) client.onNotification(Notification.MatlabFeatureUnavailable, () => handleFeatureUnavailable()) client.onNotification(Notification.MatlabFeatureUnavailableNoMatlab, () => handleFeatureUnavailableWithNoMatlab()) - client.onNotification(Notification.LogTelemetryData, data => handleTelemetryReceived(data)) + 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); + + context.subscriptions.push(vscode.commands.registerCommand('matlab.runFile', async () => await executionCommandProvider.handleRunFile())) + context.subscriptions.push(vscode.commands.registerCommand('matlab.runSelection', async () => await executionCommandProvider.handleRunSelection())) + context.subscriptions.push(vscode.commands.registerCommand('matlab.interrupt', () => executionCommandProvider.handleInterrupt())) + context.subscriptions.push(vscode.commands.registerCommand('matlab.openCommandWindow', async () => await terminalService.openTerminalOrBringToFront())) + context.subscriptions.push(vscode.commands.registerCommand('matlab.addToPath', async (uri: vscode.Uri) => await executionCommandProvider.handleAddToPath(uri))) + context.subscriptions.push(vscode.commands.registerCommand('matlab.changeDirectory', async (uri: vscode.Uri) => await executionCommandProvider.handleChangeDirectory(uri))) await client.start() } @@ -123,6 +129,7 @@ function handleChangeMatlabConnection (): void { sendConnectionActionNotification('connect') } else if (choice === 'Disconnect from MATLAB') { sendConnectionActionNotification('disconnect') + terminalService.closeTerminal(); } }) } @@ -137,6 +144,7 @@ function handleConnectionStatusChange (data: { connectionStatus: string }): void if (data.connectionStatus === 'connected') { connectionStatusNotification.text = CONNECTION_STATUS_LABELS.CONNECTED } else if (data.connectionStatus === 'disconnected') { + terminalService.closeTerminal(); if (connectionStatusNotification.text === CONNECTION_STATUS_LABELS.CONNECTED) { const message = NotificationConstants.MATLAB_CLOSED.message const options = NotificationConstants.MATLAB_CLOSED.options @@ -170,6 +178,7 @@ function handleMatlabLaunchFailed (): void { const options = NotificationConstants.MATLAB_LAUNCH_FAILED.options const url = 'https://www.mathworks.com/products/get-matlab.html' + terminalService.closeTerminal(); vscode.window.showErrorMessage(message, ...options).then(choice => { switch (choice) { case options[0]: // Get MATLAB @@ -189,6 +198,7 @@ function handleFeatureUnavailable (): void { const message = NotificationConstants.FEATURE_UNAVAILABLE.message const options = NotificationConstants.FEATURE_UNAVAILABLE.options + terminalService.closeTerminal(); vscode.window.showErrorMessage( message, ...options @@ -209,6 +219,7 @@ function handleFeatureUnavailableWithNoMatlab (): void { const options = NotificationConstants.FEATURE_UNAVAILABLE_NO_MATLAB.options const url = 'https://www.mathworks.com/products/get-matlab.html' + terminalService.closeTerminal(); vscode.window.showErrorMessage(message, ...options).then(choice => { switch (choice) { case options[0]: // Get MATLAB diff --git a/src/telemetry/TelemetryLogger.ts b/src/telemetry/TelemetryLogger.ts index 4a5af79..5006864 100644 --- a/src/telemetry/TelemetryLogger.ts +++ b/src/telemetry/TelemetryLogger.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The MathWorks, Inc. +// Copyright 2023-2024 The MathWorks, Inc. import fetch from 'node-fetch' import { env, workspace } from 'vscode' @@ -24,7 +24,7 @@ export default class TelemetryLogger { } private shouldLogTelemetry (): boolean { - const configuration = workspace.getConfiguration('matlab') + const configuration = workspace.getConfiguration('MATLAB') return env.isTelemetryEnabled && (configuration.get('telemetry') ?? true) } diff --git a/syntaxes b/syntaxes index 96dc679..f353382 160000 --- a/syntaxes +++ b/syntaxes @@ -1 +1 @@ -Subproject commit 96dc679a5e94d966f8920497883ba92e9ae51b62 +Subproject commit f3533822b2d740fd4128722854c98b9f1b5d07ee