diff --git a/src/Contracts/ExplorerContracts.ts b/src/Contracts/ExplorerContracts.ts index d1c3dba58..09a271194 100644 --- a/src/Contracts/ExplorerContracts.ts +++ b/src/Contracts/ExplorerContracts.ts @@ -33,6 +33,7 @@ export enum MessageTypes { CreateWorkspace, CreateSparkPool, RefreshDatabaseAccount, + CloseTab, } export { Versions, ActionContracts, Diagnostics }; diff --git a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx index d9747d6ee..fdc93adea 100644 --- a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx +++ b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx @@ -55,6 +55,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testAccount, notebookServerInfo: testNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); @@ -65,6 +66,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testMongo32Account, notebookServerInfo: testMongoNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); @@ -75,6 +77,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testMongo36Account, notebookServerInfo: testMongoNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); @@ -85,6 +88,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testCassandraAccount, notebookServerInfo: testCassandraNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx index 637f24192..9df968226 100644 --- a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx +++ b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx @@ -12,6 +12,7 @@ import * as StringUtils from "../../../Utils/StringUtils"; export interface NotebookTerminalComponentProps { notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo; databaseAccount: DataModels.DatabaseAccount; + tabId: string; } export class NotebookTerminalComponent extends React.Component { @@ -55,6 +56,7 @@ export class NotebookTerminalComponent extends React.Component tab.tabTitle() === title) as TerminalTab[]; + .getTabs(ViewModels.CollectionTabKind.Terminal, (tab) => tab.tabTitle().startsWith(title)) as TerminalTab[]; let index = 1; if (terminalTabs.length > 0) { diff --git a/src/Explorer/Tabs/TerminalTab.tsx b/src/Explorer/Tabs/TerminalTab.tsx index 6f318f3cf..1c010d6b3 100644 --- a/src/Explorer/Tabs/TerminalTab.tsx +++ b/src/Explorer/Tabs/TerminalTab.tsx @@ -25,7 +25,8 @@ class NotebookTerminalComponentAdapter implements ReactAdapter { public parameters: ko.Computed; constructor( private getNotebookServerInfo: () => DataModels.NotebookWorkspaceConnectionInfo, - private getDatabaseAccount: () => DataModels.DatabaseAccount + private getDatabaseAccount: () => DataModels.DatabaseAccount, + private getTabId: () => string ) {} public renderComponent(): JSX.Element { @@ -33,6 +34,7 @@ class NotebookTerminalComponentAdapter implements ReactAdapter { ) : ( @@ -50,7 +52,8 @@ export default class TerminalTab extends TabsBase { this.container = options.container; this.notebookTerminalComponentAdapter = new NotebookTerminalComponentAdapter( () => this.getNotebookServerInfo(options), - () => userContext?.databaseAccount + () => userContext?.databaseAccount, + () => this.tabId ); this.notebookTerminalComponentAdapter.parameters = ko.computed(() => { if ( diff --git a/src/Terminal/JupyterLabAppFactory.ts b/src/Terminal/JupyterLabAppFactory.ts index a1eef5bbf..639469285 100644 --- a/src/Terminal/JupyterLabAppFactory.ts +++ b/src/Terminal/JupyterLabAppFactory.ts @@ -2,15 +2,48 @@ * JupyterLab applications based on jupyterLab components */ import { ServerConnection, TerminalManager } from "@jupyterlab/services"; +import { IMessage } from "@jupyterlab/services/lib/terminal/terminal"; import { Terminal } from "@jupyterlab/terminal"; import { Panel, Widget } from "@phosphor/widgets"; +import { userContext } from "UserContext"; export class JupyterLabAppFactory { - public static async createTerminalApp(serverSettings: ServerConnection.ISettings) { + private isShellClosed: boolean; + private onShellExited: () => void; + private checkShellClosed: ((content: string | undefined) => boolean | undefined) | undefined; + + constructor(closeTab: () => void) { + this.onShellExited = closeTab; + this.isShellClosed = false; + this.checkShellClosed = undefined; + + switch (userContext.apiType) { + case "Mongo": + this.checkShellClosed = JupyterLabAppFactory.isMongoShellClosed; + break; + case "Cassandra": + this.checkShellClosed = JupyterLabAppFactory.isCassandraShellClosed; + break; + } + } + + public async createTerminalApp(serverSettings: ServerConnection.ISettings) { const manager = new TerminalManager({ serverSettings: serverSettings, }); const session = await manager.startNew(); + session.messageReceived.connect(async (_, message: IMessage) => { + const content = message.content && message.content[0]?.toString(); + if (this.checkShellClosed && message.type == "stdout") { + //Close the terminal tab once the shell closed messages are received + if (this.checkShellClosed(content)) { + this.isShellClosed = true; + } else if (content?.includes("cosmosuser@") && this.isShellClosed) { + this.onShellExited(); + } + } + }, this); + const term = new Terminal(session, { theme: "dark", shutdownOnClose: true }); if (!term) { @@ -38,4 +71,12 @@ export class JupyterLabAppFactory { panel.dispose(); }); } + + private static isMongoShellClosed(content: string | undefined) { + return content?.endsWith("bye\r\n") || (content?.includes("Stopped") && content?.includes("mongo --host")); + } + + private static isCassandraShellClosed(content: string | undefined) { + return content == "\r\n" || (content?.includes("Stopped") && content?.includes("cqlsh")); + } } diff --git a/src/Terminal/TerminalProps.ts b/src/Terminal/TerminalProps.ts index 4fe3c539c..5122a6cb7 100644 --- a/src/Terminal/TerminalProps.ts +++ b/src/Terminal/TerminalProps.ts @@ -10,4 +10,5 @@ export interface TerminalProps { authType: AuthType; apiType: ApiType; subscriptionId: string; + tabId: string; } diff --git a/src/Terminal/index.ts b/src/Terminal/index.ts index dae3059b5..f71792fab 100644 --- a/src/Terminal/index.ts +++ b/src/Terminal/index.ts @@ -1,5 +1,6 @@ import { ServerConnection } from "@jupyterlab/services"; import "@jupyterlab/terminal/style/index.css"; +import { MessageTypes } from "Contracts/ExplorerContracts"; import postRobot from "post-robot"; import { HttpHeaders } from "../Common/Constants"; import { Action } from "../Shared/Telemetry/TelemetryConstants"; @@ -54,13 +55,20 @@ const initTerminal = async (props: TerminalProps) => { const startTime = TelemetryProcessor.traceStart(Action.OpenTerminal, data); try { - await JupyterLabAppFactory.createTerminalApp(serverSettings); + await new JupyterLabAppFactory(() => closeTab(props.tabId)).createTerminalApp(serverSettings); TelemetryProcessor.traceSuccess(Action.OpenTerminal, data, startTime); } catch (error) { TelemetryProcessor.traceFailure(Action.OpenTerminal, data, startTime); } }; +const closeTab = (tabId: string): void => { + window.parent.postMessage( + { type: MessageTypes.CloseTab, data: { tabId: tabId }, signature: "pcIframe" }, + window.document.referrer + ); +}; + const main = async (): Promise => { postRobot.on( "props", diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 319840b33..245fc1fdb 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -1,3 +1,4 @@ +import { useTabs } from "hooks/useTabs"; import { useEffect, useState } from "react"; import { applyExplorerBindings } from "../applyExplorerBindings"; import { AuthType } from "../AuthType"; @@ -69,16 +70,38 @@ export function useKnockoutExplorer(platform: Platform): Explorer { async function configureHosted(): Promise { const win = (window as unknown) as HostedExplorerChildFrame; + let explorer: Explorer; if (win.hostedConfig.authType === AuthType.EncryptedToken) { - return configureHostedWithEncryptedToken(win.hostedConfig); + explorer = configureHostedWithEncryptedToken(win.hostedConfig); } else if (win.hostedConfig.authType === AuthType.ResourceToken) { - return configureHostedWithResourceToken(win.hostedConfig); + explorer = configureHostedWithResourceToken(win.hostedConfig); } else if (win.hostedConfig.authType === AuthType.ConnectionString) { - return configureHostedWithConnectionString(win.hostedConfig); + explorer = configureHostedWithConnectionString(win.hostedConfig); } else if (win.hostedConfig.authType === AuthType.AAD) { - return configureHostedWithAAD(win.hostedConfig); + explorer = await configureHostedWithAAD(win.hostedConfig); + } else { + throw new Error(`Unknown hosted config: ${win.hostedConfig}`); } - throw new Error(`Unknown hosted config: ${win.hostedConfig}`); + + window.addEventListener( + "message", + (event) => { + if (isInvalidParentFrameOrigin(event)) { + return; + } + + if (!shouldProcessMessage(event)) { + return; + } + + if (event.data?.type === MessageTypes.CloseTab) { + useTabs.getState().closeTabsByComparator((tab) => tab.tabId === event.data?.data?.tabId); + } + }, + false + ); + + return explorer; } async function configureHostedWithAAD(config: AAD): Promise { @@ -261,6 +284,8 @@ async function configurePortal(): Promise { } } else if (shouldForwardMessage(message, event.origin)) { sendMessage(message); + } else if (event.data?.type === MessageTypes.CloseTab) { + useTabs.getState().closeTabsByComparator((tab) => tab.tabId === event.data?.data?.tabId); } }, false