diff --git a/docs/RunDebug.md b/docs/RunDebug.md index 5a76cfc3..2823b0a2 100644 --- a/docs/RunDebug.md +++ b/docs/RunDebug.md @@ -121,10 +121,44 @@ The InterSystems ObjectScript Extension provides a [Webview](https://code.visual ## Troubleshooting Debugger Issues -If you are experiencing issues using the debugger, please follow these steps before opening an issue on GitHub: +If you are experiencing issues using the debugger, please follow these steps before opening an issue on GitHub. Note that the trace global may contain confidential information, so you should review the contents and mask/remove anything that you want to keep private. 1. Open a terminal on your server and `zn` to the namespace containing the class or routine you are debugging. 2. Run the command `Kill ^IRIS.Temp.Atelier("debug")`, then `Set ^IRIS.Temp.Atelier("debug") = 1` to turn on the Atelier API debug logging feature. If you are on Caché or Ensemble, the global is `^CacheTemp.ISC.Atelier("debug")`. 3. In VS Code, start a debugging session using the configuration that produces the error. 4. Once the error appears, copy the contents of the `^IRIS.Temp.Atelier("debug")` global and add it to your GitHub issue. 5. After you capture the log, run the command `Kill ^IRIS.Temp.Atelier("debug")`, then `Set ^IRIS.Temp.Atelier("debug") = 0` to turn logging back off again. + +{: #terminal} +## Using the WebSocket Terminal + +The InterSystems ObjectScript Extension provides support for a WebSocket-based command-line interface for executing ObjectScript commands on a connected server. The server can be on the same system as VS Code, or a remote system. This feature is only supported when connecting to InterSystems IRIS version 2023.2 or later. + +The WebSocket terminal supports the following features: + +- VS Code's [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) feature so your command history and output will be captured by VS Code and can be accessed by its UI. +- Multi-line editing. An additional editable line will be added when the user presses `Enter` and there are unclosed `{` or `(` in the command input. +- Syntax coloring for command input. (Toggleable using the `objectscript.webSocketTerminal.syntaxColoring` setting) +- Syntax checking for entered command input with detailed error messages reported along with the standard `` error. +- Many features of the [standard terminal](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GTER_intro), including: + - The Read command + - Interrupts (`Ctrl-C`) + - Namespace switches + - [Custom terminal prompts](https://irisdocs.intersystems.com/irislatest/csp/documatic/%25CSP.Documatic.cls?LIBRARY=%25SYS&CLASSNAME=%25SYSTEM.Process#TerminalPrompt) (except code 7) + - Shells like SQL (`Do $SYSTEM.SQL.Shell()`) and Python (`Do $SYSTEM.Python.Shell()`) + +The WebSocket terminal does not support [command-line debugging](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GCOS_debug) since the InterSystems ObjectScript Extension contains an interactive debugger. Users are also discouraged from using [routine editing commands](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=RCOS_ZCOMMANDS) since VS Code with the InterSystems ObjectScript Extension Pack provides an excellent ObjectScript editing experience. + +Note that the terminal process is started using the JOB command, so if you have a [`^%ZSTART` routine](https://docs.intersystems.com/iris20223/csp/docbook/Doc.View.cls?KEY=GSTU_customize_startstop) enabled the `JOB` subroutine will be called at the start of the process, not `LOGIN` like the standard terminal. Also, the [`ZWELCOME` routine](https://docs.intersystems.com/irislatest/csp/docbook/DocBook.UI.Page.cls?KEY=GTER_intro#GTER_zwelcome) will not be run before the first command prompt is shown. + +The WebSocket terminal can be opened from [the command palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) using the `ObjectScript: Launch WebSocket Terminal` command. The WebSocket terminal connection will be established using the current server connection. A WebSocket terminal connection can also be opened from the [Terminal Profiles menu](https://code.visualstudio.com/docs/terminal/basics#_terminal-shells). + +## Troubleshooting WebSocket Terminal Issues + +If you are experiencing issues using the WebSocket terminal, please follow these steps before opening an issue on GitHub. Note that the trace global may contain confidential information, so you should review the contents and mask/remove anything that you want to keep private. + +1. Open a standard terminal on your server and `zn` to the namespace containing the class or routine you are debugging. +2. Run the command `Kill ^IRIS.Temp.Atelier("terminal")`, then `Set ^IRIS.Temp.Atelier("terminal") = 1` to turn on the Atelier API terminal logging feature. +3. In VS Code, launch the WebSocket terminal and run the commands that produce the error. +4. Once the error appears, copy the contents of the `^IRIS.Temp.Atelier("terminal")` global and add it to your GitHub issue. +5. After you capture the log, run the command `Kill ^IRIS.Temp.Atelier("terminal")`, then `Set ^IRIS.Temp.Atelier("terminal") = 0` to turn logging back off again. diff --git a/docs/SettingsReference.md b/docs/SettingsReference.md index d3ae249b..37c5cddd 100644 --- a/docs/SettingsReference.md +++ b/docs/SettingsReference.md @@ -82,6 +82,7 @@ The extensions in the InterSystems ObjectScript Extension Pack provide many sett | `"objectscript.studioActionDebugOutput"` | Log in JSON format the action that VS Code should perform as requested by the server. | `boolean` | `false` | Actions will be logged to the `ObjectScript` Output channel. | | `"objectscript.suppressCompileErrorMessages"` | Suppress popup messages about errors during compile, but still focus on Output view. | `boolean` | `false` | | | `"objectscript.suppressCompileMessages"` | Suppress popup messages about successful compile. | `boolean` | `true` | | +| `"objectscript.webSocketTerminal.syntaxColoring"` | Enable syntax coloring for command input in the InterSystems WebSocket Terminal. | `boolean` | `true` | | {: #intersystems-servermanager} ## InterSystems Server Manager diff --git a/package.json b/package.json index 4f0fa0b0..db7f46d7 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,8 @@ "onCommand:vscode-objectscript.newFile.dtl", "onCommand:vscode-objectscript.modifyWsFolder", "onCommand:vscode-objectscript.openErrorLocation", + "onCommand:vscode-objectscript.launchWebSocketTerminal", + "onTerminalProfile:vscode-objectscript.webSocketTerminal", "onLanguage:objectscript", "onLanguage:objectscript-int", "onLanguage:objectscript-class", @@ -343,6 +345,10 @@ { "command": "vscode-objectscript.openErrorLocation", "when": "vscode-objectscript.connectActive && workspaceFolderCount != 0" + }, + { + "command": "vscode-objectscript.launchWebSocketTerminal", + "when": "vscode-objectscript.connectActive" } ], "view/title": [ @@ -1119,6 +1125,11 @@ "category": "ObjectScript", "command": "vscode-objectscript.openErrorLocation", "title": "Open Error Location..." + }, + { + "category": "ObjectScript", + "command": "vscode-objectscript.launchWebSocketTerminal", + "title": "Launch WebSocket Terminal" } ], "keybindings": [ @@ -1478,6 +1489,11 @@ "type": "boolean", "default": true }, + "objectscript.webSocketTerminal.syntaxColoring": { + "description": "Enable syntax coloring for command input in the InterSystems WebSocket Terminal.", + "type": "boolean", + "default": true + }, "objectscript.showProposedApiPrompt": { "description": "Controls whether a prompt to enable VS Code proposed APIs is shown when a server-side workspace folder is opened.", "type": "boolean", @@ -1624,7 +1640,16 @@ ], "priority": "option" } - ] + ], + "terminal": { + "profiles": [ + { + "id": "vscode-objectscript.webSocketTerminal", + "title": "InterSystems WebSocket Terminal", + "icon": "./images/fileIcon.svg" + } + ] + } }, "scripts": { "vscode:prepublish": "webpack --mode production", diff --git a/src/api/index.ts b/src/api/index.ts index 2648e2bf..5a8ba238 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -150,6 +150,13 @@ export class AtelierAPI { return `${proto}://${host}:${port}${pathPrefix}/api/atelier/v${apiVersion}/%25SYS/debug`; } + public terminalUrl(): string { + const { host, https, port, apiVersion, pathPrefix } = this.config; + return apiVersion >= 7 + ? `${https ? "wss" : "ws"}://${host}:${port}${pathPrefix}/api/atelier/v${apiVersion}/%25SYS/terminal` + : ""; + } + public async updateCookies(newCookies: string[]): Promise { const cookies = this.cache.get("cookies", []); newCookies.forEach((cookie) => { diff --git a/src/commands/webSocketTerminal.ts b/src/commands/webSocketTerminal.ts new file mode 100644 index 00000000..4af26399 --- /dev/null +++ b/src/commands/webSocketTerminal.ts @@ -0,0 +1,672 @@ +import * as vscode from "vscode"; +import WebSocket = require("ws"); + +import { AtelierAPI } from "../api"; +import { currentFile, outputChannel } from "../utils"; + +const keys = { + enter: "\r", + backspace: "\x7f", + up: "\x1b\x5b\x41", + down: "\x1b\x5b\x42", + left: "\x1b\x5b\x44", + right: "\x1b\x5b\x43", + interrupt: "\x03", + ctrlU: "\x15", + ctrlA: "\x01", + ctrlE: "\x05", + ctrlH: "\x08", + del: "\x1b[3~", +}; + +const actions = { + cursorUp: "\x1b[A", + cursorDown: "\x1b[B", + cursorForward: "\x1b[C", + cursorBack: "\x1b[D", + deleteChar: "\x1b[P", + clearLine: "\x1b[2K\r", + clear: "\x1b[2J\x1b[3J\x1b[;H", +}; + +/** Data received from the WebSocket */ +interface WebSocketMessage { + /** The type of the message */ + type: "prompt" | "read" | "error" | "output" | "init" | "color"; + /** The text of the message. Present for all types but "read" and "init". */ + text?: string; + /** The WebSocket protocol version. Only present for "init". */ + protocol?: number; + /** The InterSystems IRIS `$ZVERSION`. Only present for "init". */ + version?: string; +} + +class WebSocketTerminal implements vscode.Pseudoterminal { + private _writeEmitter = new vscode.EventEmitter(); + onDidWrite: vscode.Event = this._writeEmitter.event; + private _closeEmitter = new vscode.EventEmitter(); + onDidClose: vscode.Event = this._closeEmitter.event; + + /** The number of characters on the line that the user can't delete */ + private _margin = 0; + + /** The text writted by the user since the last prompt/read */ + private _input = ""; + + /** The position of the cursor within the line */ + private _cursorCol = 0; + + /** All command input that have been sent to the server */ + private _history: string[] = []; + + /** + * The index in the `history` that we last showed the user. + * -1 if we haven't begun a history scroll, -2 if we scrolled to the end. + */ + private _historyIdx = -1; + + /** Current state */ + private _state: "prompt" | "read" | "eval" = "eval"; + + /** If `true`, the next output line is the first since sending the prompt input */ + private _firstOutputLineSincePrompt = true; + + /** The `text` of the last `prompt` message sent by the server */ + private _prompt = ""; + + /** The exit code to report for the last prompt executed */ + private _promptExitCode = ";0"; + + /** The leading characters for multi-line editing mode */ + private readonly _multiLinePrompt: string = "... "; + + /** The WebSocket used to talk to the server */ + private _socket: WebSocket; + + constructor(private readonly _api: AtelierAPI) {} + + /** Hide the cursor, write `data` to the terminal, then show the cursor again. */ + private _hideCursorWrite(data: string): void { + this._writeEmitter.fire(`\x1b[?25l${data}\x1b[?25h`); + } + + /** Detect if `this._input` has any unmatched `{` or `(` */ + private _inputIsUnterminated(): boolean { + let inString = false; + let openParen = 0; + let openBrace = 0; + for (const c of this._input) { + switch (c) { + case '"': + inString = !inString; + break; + case "(": + if (!inString) { + openParen++; + } + break; + case ")": + if (!inString) { + openParen--; + } + break; + case "{": + if (!inString) { + openBrace++; + } + break; + case "}": + if (!inString) { + openBrace--; + } + break; + } + } + return openParen > 0 || openBrace > 0; + } + + /** Checks if syntax coloring is enabled */ + private _syntaxColoringEnabled(): boolean { + return vscode.workspace + .getConfiguration( + "objectscript.webSocketTerminal", + vscode.workspace.getWorkspaceFolder(this._api.wsOrFile instanceof vscode.Uri ? this._api.wsOrFile : undefined) + ) + .get("syntaxColoring"); + } + + /** + * Converts `_input` for use as `` by VS Code shell integration sequence `OSC 633 ; E ; ST`. + * See https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st + */ + private _inputEscaped(): string { + let result = ""; + for (const c of this._input) { + const cc = c.charCodeAt(0); + if (cc <= 0x20 || c == ";") { + result += `\\x${cc.toString(16).padStart(2, "0")}`; + } else if (c == "\\") { + result += "\\\\"; + } else { + result += c; + } + } + return result; + } + + open(): void { + try { + // Open the WebSocket + this._socket = new WebSocket(this._api.terminalUrl(), { + rejectUnauthorized: vscode.workspace.getConfiguration("http").get("proxyStrictSSL"), + headers: { + cookie: this._api.cookies, + }, + }); + } catch (error) { + outputChannel.appendLine( + typeof error == "string" ? error : error instanceof Error ? error.message : JSON.stringify(error) + ); + outputChannel.show(true); + vscode.window.showErrorMessage( + "Failed to initialize WebSocket Terminal. Check 'ObjectScript' Output channel for details.", + "Dismiss" + ); + this._closeEmitter.fire(); + return; + } + // Print the opening message + this._hideCursorWrite( + `\x1b[32mConnected to \x1b[0m\x1b[4m${this._api.config.host}:${this._api.config.port}${this._api.config.pathPrefix}\x1b[0m\x1b[32m as \x1b[0m\x1b[3m${this._api.config.username}\x1b[0m\r\n\r\n` + ); + // Add event handlers to the socket + this._socket + .on("error", (error) => { + // Log the error and close + outputChannel.appendLine(`WebSocket error: ${error.toString()}`); + outputChannel.show(true); + vscode.window.showErrorMessage( + "WebSocket Terminal failed. Check 'ObjectScript' Output channel for details.", + "Dismiss" + ); + this._closeEmitter.fire(); + }) + .on("close", () => { + // Close the terminal + this._closeEmitter.fire(); + }) + .on("message", (data: string) => { + let message: WebSocketMessage; + try { + message = JSON.parse(data); + } catch { + return; + } + switch (message.type) { + case "error": + // Log the error and close + outputChannel.appendLine(message.text); + outputChannel.show(true); + vscode.window.showErrorMessage( + "WebSocket Terminal failed. Check 'ObjectScript' Output channel for details.", + "Dismiss" + ); + this._closeEmitter.fire(); + break; + case "output": + // Write the output to the terminal + if (this._firstOutputLineSincePrompt) { + // Strip leading \r\n since we printed it already + message.text = message.text.startsWith("\r\n") ? message.text.slice(2) : message.text; + this._firstOutputLineSincePrompt = false; + } + if (message.text.includes("\x1b[31;1m")) { + if (message.text.includes("\x1b[31;1m")) { + // Report no exit code for interrupts + this._promptExitCode = ""; + } else { + this._promptExitCode = ";1"; + } + } + this._margin = this._cursorCol = message.text.split("\r\n").pop().length; + this._hideCursorWrite(message.text); + break; + case "prompt": + case "read": + if (message.type == "prompt") { + // Write the prompt to the terminal + this._hideCursorWrite( + `\x1b]633;D${this._promptExitCode}\x07${this._margin ? "\r\n" : ""}\x1b]633;A\x07${ + message.text + }\x1b]633;B\x07` + ); + this._margin = this._cursorCol = message.text.length; + this._prompt = message.text; + this._promptExitCode = ";0"; + } + // Enable input + this._state = message.type; + break; + case "init": + this._socket.send( + JSON.stringify({ + type: "config", + // Start in the current namespace + namespace: this._api.ns, + // Have the server send ANSI escape codes since we can print them + rawMode: false, + }) + ); + break; + case "color": { + // Replace the input with the syntax colored text, keeping the cursor at the same spot + const lines = message.text.split("\r\n").length; + if (lines > 1) { + this._hideCursorWrite( + `\x1b7\x1b[${lines - 1}A\r\x1b[0J${this._prompt}${message.text.replace( + /\r\n/g, + `\r\n${this._multiLinePrompt}` + )}\x1b8` + ); + } else { + this._hideCursorWrite(`\x1b7\x1b[2K\r${this._prompt}${message.text}\x1b8`); + } + break; + } + } + }); + } + + close(): void { + if ( + this._socket && + this._socket.readyState != this._socket.CLOSED && + this._socket.readyState != this._socket.CLOSING + ) { + this._socket.close(); + } + } + + async handleInput(char: string): Promise { + switch (char) { + case keys.enter: { + if (this._state == "eval") { + // Terminal is already evaluating user input + return; + } + + if (this._state == "prompt") { + // Reset historyIdx + this._historyIdx = -1; + + if (this._input != "" && !this._input.includes("\r\n")) { + // Remove the input from the existing history + this._history = this._history.filter((h) => h != this._input); + + // Append this input to the history + this._history.push(this._input); + } + + // Check if we should enter multi-line mode + if (this._inputIsUnterminated()) { + // Write the multi-line mode prompt to the terminal + this._hideCursorWrite(`\r\n${this._multiLinePrompt}`); + this._margin = this._cursorCol = this._multiLinePrompt.length; + this._input += "\r\n"; + return; + } + + // Reset first line tracker + this._firstOutputLineSincePrompt = true; + } else { + // Reset first line tracker + this._firstOutputLineSincePrompt = false; + } + + // Send the input to the server for processing + this._socket.send(JSON.stringify({ type: this._state, input: this._input })); + if (this._state == "prompt") { + this._hideCursorWrite(`\x1b]633;C\x07\x1b]633;E;${this._inputEscaped()}\x07\r\n`); + if (this._input == "") { + this._promptExitCode = ""; + } + } + this._input = ""; + this._state = "eval"; + return; + } + case keys.ctrlH: + case keys.backspace: { + // Erase to the left + if (this._state == "eval") { + // We're not accepting user input + return; + } + if (this._cursorCol <= this._margin) { + // Don't delete the prompt + return; + } + const inputArr = this._input.split("\r\n"); + inputArr[inputArr.length - 1] = + inputArr[inputArr.length - 1].slice(0, this._cursorCol - this._margin - 1) + + inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin); + this._input = inputArr.join("\r\n"); + this._cursorCol--; + this._hideCursorWrite(actions.cursorBack + actions.deleteChar); + if (this._input != "" && this._state == "prompt" && this._syntaxColoringEnabled()) { + // Syntax color input + this._socket.send(JSON.stringify({ type: "color", input: this._input })); + } + return; + } + case keys.del: { + // Erase to the right + if (this._state == "eval") { + // We're not accepting user input + return; + } + const inputArr = this._input.split("\r\n"); + if (this._margin + inputArr[inputArr.length - 1].length - this._cursorCol > 0) { + inputArr[inputArr.length - 1] = + inputArr[inputArr.length - 1].slice(0, this._cursorCol - this._margin) + + inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin + 1); + this._input = inputArr.join("\r\n"); + this._hideCursorWrite(actions.cursorForward + actions.deleteChar + actions.cursorBack); + if (this._input != "" && this._state == "prompt" && this._syntaxColoringEnabled()) { + // Syntax color input + this._socket.send(JSON.stringify({ type: "color", input: this._input })); + } + } + return; + } + case keys.up: { + if (this._state != "prompt" || this._input.includes("\r\n")) { + // History only available for prompts + return; + } + if (this._historyIdx == -1) { + // Show the most recent input + this._historyIdx = this._history.length - 1; + } else if (this._historyIdx == 0) { + // This is the end of our history + this._historyIdx = -2; + } else if (this._historyIdx == -2) { + // We hit the end of our history + return; + } else { + // Scroll back one more input + this._historyIdx--; + } + const oldInput = this._input; + if (this._historyIdx >= 0) { + this._input = this._history[this._historyIdx]; + } else if (this._historyIdx == -1) { + // There is no history, so do nothing + return; + } else { + // If we hit the end, leave the input blank + this._input = ""; + } + this._cursorCol = this._margin + this._input.length; + this._hideCursorWrite(`${oldInput.length ? `\x1b[${oldInput.length}D\x1b[0K` : ""}${this._input}`); + if (this._input != "" && this._syntaxColoringEnabled()) { + // Syntax color input + this._socket.send(JSON.stringify({ type: "color", input: this._input })); + } + return; + } + case keys.down: { + if (this._state != "prompt" || this._input.includes("\r\n")) { + // History only available for prompts + return; + } + if (this._historyIdx == -1) { + // We're not in the history + return; + } else if (this._historyIdx == -2) { + // We hit the end of our history + this._historyIdx = 0; + } else if (this._historyIdx == this._history.length - 1) { + // We hit the beginning of our history + this._historyIdx = -1; + } else { + this._historyIdx++; + } + const oldInput = this._input; + if (this._historyIdx != -1) { + this._input = this._history[this._historyIdx]; + } else { + // If we hit the beginning, leave the input blank + this._input = ""; + } + this._cursorCol = this._margin + this._input.length; + this._hideCursorWrite(`${oldInput.length ? `\x1b[${oldInput.length}D\x1b[0K` : ""}${this._input}`); + if (this._input != "" && this._syntaxColoringEnabled()) { + // Syntax color input + this._socket.send(JSON.stringify({ type: "color", input: this._input })); + } + return; + } + case keys.left: { + if (this._state == "eval") { + // User can't move cursor + return; + } + if (this._cursorCol > this._margin) { + // Move the cursor back one column + this._cursorCol--; + this._hideCursorWrite(actions.cursorBack); + } + return; + } + case keys.right: { + if (this._state == "eval") { + // User can't move cursor + return; + } + if (this._cursorCol < this._margin + this._input.length) { + // Move the cursor forward one column + this._cursorCol++; + this._hideCursorWrite(actions.cursorForward); + } + return; + } + case keys.interrupt: { + // Send interrupt message + this._socket.send(JSON.stringify({ type: "interrupt" })); + this._input = ""; + if (this._state == "prompt") { + this._hideCursorWrite("\r\n"); + // Reset first line tracker + this._firstOutputLineSincePrompt = true; + } + this._state = "eval"; + return; + } + case keys.ctrlA: { + if (this._state == "prompt" && this._cursorCol - this._margin > 0) { + // Move the cursor to the beginning of the line + this._hideCursorWrite(`\x1b[${this._cursorCol - this._margin}D`); + this._cursorCol = this._margin; + } + return; + } + case keys.ctrlE: { + if (this._state == "prompt") { + // Move the cursor to the end of the line + const inputArr = this._input.split("\r\n"); + if (this._margin + inputArr[inputArr.length - 1].length - this._cursorCol > 0) { + this._hideCursorWrite(`\x1b[${this._margin + inputArr[inputArr.length - 1].length - this._cursorCol}C`); + this._cursorCol = this._margin + inputArr[inputArr.length - 1].length; + } + } + return; + } + case keys.ctrlU: { + if (this._state == "prompt") { + // Erase the line if the cursor is at the end + const inputArr = this._input.split("\r\n"); + if (this._cursorCol == this._margin + inputArr[inputArr.length - 1].length) { + this._hideCursorWrite(`\x1b[2K\r${inputArr.length > 1 ? this._multiLinePrompt : this._prompt}`); + this._cursorCol = this._margin; + inputArr[inputArr.length - 1] = ""; + this._input = inputArr.join("\r\n"); + if (this._input != "" && this._syntaxColoringEnabled()) { + // Syntax color input + this._socket.send(JSON.stringify({ type: "color", input: this._input })); + } + } + } + return; + } + default: { + if (this._state == "eval") { + // Terminal is already evaluating user input + return; + } + // Turn all newlines and tabs into spaces + char = char.replace(/\r?\n/g, " "); + if (this._state == "prompt") { + char = char.replace(/\t/g, " "); + } + let submit = false; + if (char.endsWith("\r")) { + // Submit the input after processing + // This should only happen due to VS Code's shell integration + submit = true; + char = char.slice(0, -1); + } + // Replace all single \r with \r\n (prompt) or space (read) + char = char.replace(/\r/g, this._state == "prompt" ? "\r\n" : " "); + const inputArr = this._input.split("\r\n"); + if (this._cursorCol < this._margin + inputArr[inputArr.length - 1].length) { + // Insert the new char(s) + inputArr[inputArr.length - 1] = `${inputArr[inputArr.length - 1].slice( + 0, + this._cursorCol - this._margin + )}${char}${inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin)}`; + this._input = inputArr.join("\r\n"); + this._cursorCol += char.length; + this._hideCursorWrite(`\x1b[4h${char.replace(/\r\n/g, `\r\n${this._multiLinePrompt}`)}\x1b[4l`); + } else { + // Append the new char(s) + this._input += char; + this._cursorCol += char.length; + this._hideCursorWrite(char.replace(/\r\n/g, `\r\n${this._multiLinePrompt}`)); + } + if (submit) { + if (this._state == "prompt") { + // Reset historyIdx + this._historyIdx = -1; + + if (this._input != "" && !this._input.includes("\r\n")) { + // Remove the input from the existing history + this._history = this._history.filter((h) => h != this._input); + + // Append this input to the history + this._history.push(this._input); + } + + // Reset first line tracker + this._firstOutputLineSincePrompt = true; + } else { + // Reset first line tracker + this._firstOutputLineSincePrompt = false; + } + + // Send the input to the server for processing + this._socket.send(JSON.stringify({ type: this._state, input: this._input })); + if (this._state == "prompt") { + this._hideCursorWrite(`\x1b]633;C\x07\x1b]633;E;${this._inputEscaped()}\x07\r\n`); + if (this._input == "") { + this._promptExitCode = ""; + } + } + this._input = ""; + this._state = "eval"; + } else if (this._input != "" && this._state == "prompt" && this._syntaxColoringEnabled()) { + // Syntax color input + this._socket.send(JSON.stringify({ type: "color", input: this._input })); + } + } + } + } +} + +function terminalConfigForUri( + api: AtelierAPI, + extensionUri: vscode.Uri, + throwErrors = false +): vscode.ExtensionTerminalOptions | undefined { + const reportError = (msg: string) => { + if (throwErrors) { + throw new Error(msg); + } else { + vscode.window.showErrorMessage(msg, "Dismiss"); + } + }; + + // Make sure the server connection is active + if (!api.active || api.ns == "") { + reportError("WebSocket Terminal requires an active server connection."); + return; + } + // Make sure the server has the terminal endpoint + if (api.config.apiVersion < 7) { + reportError("WebSocket Terminal requires InterSystems IRIS version 2023.2 or above."); + return; + } + + return { + name: api.config.serverName && api.config.serverName != "" ? api.config.serverName : "iris", + location: vscode.TerminalLocation.Panel, + pty: new WebSocketTerminal(api), + isTransient: true, + iconPath: vscode.Uri.joinPath(extensionUri, "images", "fileIcon.svg"), + }; +} + +export async function launchWebSocketTerminal(extensionUri: vscode.Uri): Promise { + // Determine the server to connect to + const api = new AtelierAPI(currentFile()?.uri); + + // Get the terminal configuration + const terminalOpts = terminalConfigForUri(api, extensionUri); + if (terminalOpts) { + // Launch the terminal + const terminal = vscode.window.createTerminal(terminalOpts); + terminal.show(); + } +} + +export class WebSocketTerminalProfileProvider implements vscode.TerminalProfileProvider { + constructor(private readonly _extensionUri: vscode.Uri) {} + + async provideTerminalProfile(token: vscode.CancellationToken): Promise { + // Determine the server connection to use + let uri: vscode.Uri; + const workspaceFolders = vscode.workspace.workspaceFolders || []; + if (workspaceFolders.length == 0) { + throw new Error("WebSocket Terminal requires an open workspace."); + } else if (workspaceFolders.length == 1) { + // Use the current connection + uri = workspaceFolders[0].uri; + } else { + // Pick from the workspace folders + uri = ( + await vscode.window.showWorkspaceFolderPick({ + ignoreFocusOut: true, + placeHolder: "Pick the workspace folder to get server connection information from", + }) + )?.uri; + } + + if (uri) { + // Get the terminal configuration. Will throw if there's an error. + const terminalOpts = terminalConfigForUri(new AtelierAPI(uri), this._extensionUri, true); + return new vscode.TerminalProfile(terminalOpts); + } else { + throw new Error("WebSocket Terminal requires a selected workspace folder."); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index bb8f6709..98bd02c2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -119,6 +119,7 @@ import { newFile, NewFileType } from "./commands/newFile"; import { FileDecorationProvider } from "./providers/FileDecorationProvider"; import { RESTDebugPanel } from "./commands/restDebugPanel"; import { modifyWsFolder } from "./commands/addServerNamespaceToWorkspace"; +import { WebSocketTerminalProfileProvider, launchWebSocketTerminal } from "./commands/webSocketTerminal"; const packageJson = vscode.extensions.getExtension(extensionId).packageJSON; const extensionVersion = packageJson.version; @@ -1305,6 +1306,13 @@ export async function activate(context: vscode.ExtensionContext): Promise { }), vscode.commands.registerCommand("vscode-objectscript.modifyWsFolder", modifyWsFolder), vscode.commands.registerCommand("vscode-objectscript.openErrorLocation", openErrorLocation), + vscode.commands.registerCommand("vscode-objectscript.launchWebSocketTerminal", () => + launchWebSocketTerminal(context.extensionUri) + ), + vscode.window.registerTerminalProfileProvider( + "vscode-objectscript.webSocketTerminal", + new WebSocketTerminalProfileProvider(context.extensionUri) + ), vscode.workspace.onDidChangeWorkspaceFolders((e) => { // Show the proposed API prompt if required proposedApiPrompt(proposed.length > 0, e.added);