diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 3b2c8a1cd9..347dfac224 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -19,11 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { stringify } from "querystring"; import { boundMethod, base64, EventEmitter } from "../utils"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; import { isDevelopment } from "../../common/vars"; +import url from "url"; +import { makeObservable, observable } from "mobx"; +import type { ParsedUrlQueryInput } from "querystring"; export enum TerminalChannels { STDIN = 0, @@ -55,7 +57,9 @@ export class TerminalApi extends WebSocketApi { protected size: { Width: number; Height: number }; public onReady = new EventEmitter<[]>(); - public isReady = false; + @observable public isReady = false; + @observable public shellRunCommandsFinished = false; + public readonly url: string; constructor(protected options: TerminalApiQuery) { super({ @@ -63,34 +67,33 @@ export class TerminalApi extends WebSocketApi { flushOnOpen: false, pingIntervalSeconds: 30, }); - } + makeObservable(this); - async getUrl() { - let { port } = location; - const { hostname, protocol } = location; - const { id, node } = this.options; - const wss = `ws${protocol === "https:" ? "s" : ""}://`; - const query: TerminalApiQuery = { id }; + const { hostname, protocol, port } = location; + const query: ParsedUrlQueryInput = { + id: options.id, + }; - if (port) { - port = `:${port}`; + if (options.node) { + query.node = options.node; + query.type = options.type || "node"; } - if (node) { - query.node = node; - query.type = "node"; - } - - return `${wss}${hostname}${port}/api?${stringify(query)}`; + this.url = url.format({ + protocol: protocol.includes("https") ? "wss" : "ws", + hostname, + port, + pathname: "/api", + query, + slashes: true, + }); } - async connect() { - const apiUrl = await this.getUrl(); - + connect() { this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); - - return super.connect(apiUrl); + this.onData.addListener(this._onShellRunCommandsFinished); + super.connect(this.url); } destroy() { @@ -106,6 +109,24 @@ export class TerminalApi extends WebSocketApi { this.onReady.removeAllListeners(); } + _onShellRunCommandsFinished = (data: string) => { + if (!data) { + return; + } + + /** + * This is a heuistic for ditermining when a shell has finished executing + * its own rc file (or RunCommands file) such as `.bashrc` or `.zshrc`. + * + * This heuistic assumes that the prompt line of a terminal is a single line + * and ends with a whitespace character. + */ + if (data.match(/\r?\n/) === null && data.match(/\s$/)) { + this.shellRunCommandsFinished = true; + this.onData.removeListener(this._onShellRunCommandsFinished); + } + }; + @boundMethod protected _onReady(data: string) { if (!data) return true; diff --git a/src/renderer/components/dock/terminal.store.ts b/src/renderer/components/dock/terminal.store.ts index fb3359368d..dd5251bac2 100644 --- a/src/renderer/components/dock/terminal.store.ts +++ b/src/renderer/components/dock/terminal.store.ts @@ -19,12 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { autorun, observable } from "mobx"; -import { autoBind, Singleton } from "../../utils"; +import { autorun, observable, when } from "mobx"; +import { autoBind, noop, Singleton } from "../../utils"; import { Terminal } from "./terminal"; import { TerminalApi } from "../../api/terminal-api"; import { dockStore, DockTab, DockTabCreateSpecific, TabId, TabKind } from "./dock.store"; import { WebSocketApiState } from "../../api/websocket-api"; +import { Notifications } from "../notifications"; export interface ITerminalTab extends DockTab { node?: string; // activate node shell mode @@ -64,7 +65,7 @@ export class TerminalStore extends Singleton { }); } - async connect(tabId: TabId) { + connect(tabId: TabId) { if (this.isConnected(tabId)) { return; } @@ -104,18 +105,36 @@ export class TerminalStore extends Singleton { return this.connections.get(tabId)?.readyState === WebSocketApiState.CLOSED; } - sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { + async sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { const { enter, newTab, tabId } = options; - const { selectTab, getTabById } = dockStore; - const tab = tabId && getTabById(tabId); - if (tab) selectTab(tabId); - if (newTab) createTerminalTab(); + if (tabId) { + dockStore.selectTab(tabId); + } + + if (newTab) { + const tab = createTerminalTab(); + + await when(() => this.connections.has(tab.id)); + + const rcIsFinished = when(() => this.connections.get(tab.id).shellRunCommandsFinished); + const notifyVeryLong = setTimeout(() => { + rcIsFinished.cancel(); + Notifications.info("Terminal shell is taking a long time to complete startup. Please check your .rc file. Bypassing shell completion check.", { + timeout: 4_000, + }); + }, 10_000); + + await rcIsFinished.catch(noop); + clearTimeout(notifyVeryLong); + } const terminalApi = this.connections.get(dockStore.selectedTabId); if (terminalApi) { terminalApi.sendCommand(command + (enter ? "\r" : "")); + } else { + console.warn("The selected tab is does not have a connection. Cannot send command.", { tabId: dockStore.selectedTabId, command }); } }