element reloads
+ React.useEffect(() => {
+ if (term) {
+ term.open(termRef.current);
+ setXtermjsTheme(props.initialFontFamily, props.initialFontSize);
+ fitAddon.fit();
+ }
+ return () => {
+ // do nothing
+ };
+ }, [term, termRef]);
+
+ // Send a message to VS Code to indicate that this terminal (this tab) is currently selected and being rendered.
+ // When it's about to be unrendered, send a message to VS Code to indicate this.
+ // VS Code uses this information to decide whether to pipe terminal output to the webview or to buffer it.
+ React.useEffect(() => {
+ window.vscodeApi.postMessage({
+ kind: 'termInit',
+ data: {
+ uuid: props.uuid,
+ },
+ });
+
+ return () => {
+ window.vscodeApi.postMessage({
+ kind: 'termSuspend',
+ data: {
+ uuid: props.uuid,
+ },
+ });
+ };
+ }, []);
+
+ // send the user's input from the terminal back to the pty on the extension side
+ React.useEffect(() => {
+ const disposable = term.onData((data) => {
+ window.vscodeApi.postMessage({
+ kind: 'input',
+ data: {
+ uuid: props.uuid,
+ data,
+ },
+ });
+ });
+ return () => {
+ disposable.dispose();
+ };
+ });
+
+ // respond to messages directed at this terminal coming from VS Code
+ // - termInit(uuid, serializedOutput):
+ // when a user switches tabs, the xtermjs instance gets destroyed,
+ // and the output gets destroyed along with it.
+ // To prevent the output from being lost, we save a copy of the output
+ // in a headless terminal instance on the VS Code side.
+ // this message rehydrates the terminal with the data from the headless terminal
+ // when the user switches to this terminal tab.
+ // - termOutput(uuid, output): a message containing output from the program the terminal is running
+ // - setTheme(kind, fontFamily, fontSize):
+ // sent when the VS Code integrated terminal font settings have been changed
+ // contains the new font settings. the `kind` parameter is ignored
+ const respondToMessage = function (message: MessageEvent
) {
+ if (message.data.data.uuid === props.uuid) {
+ if (message.data.kind === 'termInit') {
+ term.write(message.data.data.serializedOutput as string);
+ } else if (message.data.kind === 'termOutput') {
+ term.write(message.data.data.output as string);
+ }
+ } else if (message.data.kind === 'setTheme') {
+ setXtermjsTheme(message.data.data.fontFamily, message.data.data.fontSize);
+ }
+ };
+
+ React.useEffect(() => {
+ window.addEventListener('message', respondToMessage);
+ return () => {
+ window.removeEventListener('message', respondToMessage);
+ };
+ }, []);
+
+ const performResize = function () {
+ const dims = fitAddon.proposeDimensions();
+ window.vscodeApi.postMessage({
+ kind: 'resize',
+ data: {
+ uuid: props.uuid,
+ ...dims,
+ },
+ });
+ fitAddon.fit();
+ }
+
+ const handleResize = function (_e: UIEvent) {
+ if (resizeTimeout) {
+ clearTimeout(resizeTimeout);
+ }
+ resizeTimeout = setTimeout(performResize, 200);
+ };
+
+ // resize the terminal when the window is resized
+ React.useEffect(() => {
+ addEventListener('resize', handleResize);
+ return () => {
+ removeEventListener('resize', handleResize);
+ };
+ }, [fitAddon]);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/webview/openshift-terminal/app/terminalMultiplexer.tsx b/src/webview/openshift-terminal/app/terminalMultiplexer.tsx
new file mode 100644
index 000000000..4ca47403f
--- /dev/null
+++ b/src/webview/openshift-terminal/app/terminalMultiplexer.tsx
@@ -0,0 +1,272 @@
+/*-----------------------------------------------------------------------------------------------
+ * Copyright (c) Red Hat, Inc. All rights reserved.
+ * Licensed under the MIT License. See LICENSE file in the project root for license information.
+ *-----------------------------------------------------------------------------------------------*/
+
+import { TabContext, TabList, TabPanel } from '@mui/lab';
+import {
+ PaletteMode,
+ createTheme,
+ SvgIcon,
+ Typography,
+ Box,
+ Stack,
+ styled,
+ Tab,
+ ThemeProvider
+} from '@mui/material';
+import React from 'react';
+import { VSCodeMessage } from './vscodeMessage';
+import OpenShiftIcon from '../../../../images/openshift_view.svg';
+import { TerminalInstance } from './terminalInstance';
+import CloseIcon from '@mui/icons-material/Close';
+import TerminalIcon from '@mui/icons-material/Terminal';
+
+/**
+ * Represents the label for the tab that's used in the list of tabs.
+ *
+ * @param props
+ * - name: the name of the tab
+ * - closeTab: the function to close the tab
+ */
+const TabLabel = (props: { name: string; closeTab: () => void }) => {
+ const TabText = styled('div')(({ theme }) => ({
+ ...theme.typography.button,
+ fontSize: 'var(--vscode-font-size)',
+ }));
+
+ return (
+
+
+ {props.name}
+ {
+ props.closeTab();
+ }}
+ />
+
+ );
+};
+
+/**
+ * Multiplexes xtermjs terminals and displays them in a tabbed view.
+ */
+export const TerminalMultiplexer = () => {
+ // represents whether this webview is using a dark theme or a light theme
+ const [themeKind, setThemeKind] = React.useState('dark');
+
+ // represents the font family being used by the VS Code integrated terminal,
+ // used when making a new terminal tab
+ const fontFamily = React.useRef('monospace');
+
+ // represents the font size being used by the VS Code integrated terminal
+ // used when making a new terminal tab
+ const fontSize = React.useRef(16);
+
+ // represents the Material UI theme currently being used by this webview
+ const theme = React.useMemo(
+ () =>
+ createTheme({
+ palette: {
+ mode: themeKind,
+ },
+ }),
+ [themeKind],
+ );
+
+ // represents the terminals that the multiplexer manages
+ const [terminals, setTerminals] = React.useState<{ name: string; terminal: JSX.Element }[]>([]);
+
+ // represents the index of the terminal that is currently being displayed
+ const [activeTerminal, setActiveTerminal] = React.useState(0);
+
+ // represents the number of terminals that were present before `terminals` was modified
+ const [oldNumTerminals, setOldNumTerminals] = React.useState(0);
+
+ function reassignActiveTerminal() {
+ if (terminals.length === 0) {
+ setActiveTerminal(0);
+ } else if (activeTerminal >= terminals.length) {
+ setActiveTerminal(terminals.length - 1);
+ } else if (terminals.length > oldNumTerminals) {
+ setActiveTerminal(terminals.length - 1);
+ }
+ setOldNumTerminals(terminals.length);
+ }
+
+ const closeTerminal = function (uuid: string) {
+ window.vscodeApi.postMessage({
+ kind: 'closeTerminal',
+ data: {
+ uuid,
+ },
+ });
+ setTerminals((terms) => {
+ const i: number = terms.findIndex((terminal) => terminal.terminal.props.uuid === uuid);
+ return [...terms.slice(0, i), ...terms.slice(i + 1, terms.length)];
+ });
+ };
+
+ // Respond to messages coming from VS Code:
+ // - createTerminal(uuid, name): create a new terminal tab with the given uuid and name
+ // - termExit(uuid): close the terminal tab with the corresponding uuid
+ // - setTheme(kind, fontFamily, fontSize):
+ // the colour theme or terminal font settings changed.
+ // update the material UI theme (light vs dark mode) to match.
+ // update the font family and size used to create new terminals.
+ // - switchToTerminal(uuid): switch to the terminal tab with the given uuid
+ const respondToMessage = function (message: MessageEvent) {
+ if (message.data.kind === 'createTerminal') {
+ const uuid = message.data.data.uuid as string;
+ setTerminals([
+ ...terminals,
+
+ {
+ name: message.data.data.name,
+ terminal: (
+
+ ),
+ },
+ ]);
+ } else if (message.data.kind === 'termExit') {
+ const uuid = message.data.data.uuid as string;
+ closeTerminal(uuid);
+ } else if (message.data.kind === 'setTheme') {
+ setThemeKind(message.data.data.kind === 1 ? 'light' : 'dark');
+ fontFamily.current = message.data.data.fontFamily;
+ fontSize.current = message.data.data.fontSize;
+ } else if (message.data.kind === 'switchToTerminal') {
+ for (let i = 0; i < terminals.length; i++) {
+ if (terminals[i].terminal.props.uuid === message.data.data.uuid) {
+ setActiveTerminal(i);
+ break;
+ }
+ }
+ }
+ };
+
+ // When the number of terminals changes, change the current terminal if needed
+ React.useEffect(() => {
+ reassignActiveTerminal();
+ }, [terminals]);
+
+ // Register the function to respond to messages from VS Code
+ React.useEffect(() => {
+ window.addEventListener('message', respondToMessage);
+ return () => {
+ window.removeEventListener('message', respondToMessage);
+ };
+ }, [terminals, themeKind, activeTerminal]);
+
+ // let VS Code know that the terminal multiplexer is ready to create terminals when the component gets rendered
+ React.useEffect(() => {
+ window.vscodeApi.postMessage({
+ kind: 'termMuxUp',
+ data: undefined,
+ });
+ return () => undefined;
+ });
+
+ function handleTabChange(e, value) {
+ setActiveTerminal(value);
+ }
+
+ function closeTab(tabNum: number) {
+ const closedTerminal = terminals[tabNum];
+ closeTerminal(closedTerminal.terminal.props.uuid);
+ }
+
+ function handleAuxClick(event: React.MouseEvent, i: number) {
+ // middle click closes tab
+ if (event.button === 1) {
+ closeTab(i);
+ }
+ }
+
+ if (!terminals.length) {
+ return (
+
+
+
+ No terminals opened.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {terminals.map((terminal, i) => (
+ {
+ closeTab(i);
+ }}
+ />
+ }
+ value={`${i}`}
+ key={terminal.terminal.props.uuid}
+ onAuxClick={(event) => {
+ handleAuxClick(event, i);
+ }}
+ />
+ ))}
+
+
+
+ {terminals.map((terminal, i) => (
+
+ {terminal.terminal}
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/webview/openshift-terminal/app/tsconfig.json b/src/webview/openshift-terminal/app/tsconfig.json
new file mode 100644
index 000000000..1072d0ea9
--- /dev/null
+++ b/src/webview/openshift-terminal/app/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "target": "es6",
+ "outDir": "openshiftTerminal",
+ "lib": [
+ "es6",
+ "dom"
+ ],
+ "jsx": "react",
+ "sourceMap": true,
+ "noUnusedLocals": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "experimentalDecorators": true,
+ "baseUrl": ".",
+ "typeRoots": [
+ "../../../../node_modules/@types",
+ "../../@types"
+ ]
+ },
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/src/webview/openshift-terminal/app/vscodeMessage.ts b/src/webview/openshift-terminal/app/vscodeMessage.ts
new file mode 100644
index 000000000..195824128
--- /dev/null
+++ b/src/webview/openshift-terminal/app/vscodeMessage.ts
@@ -0,0 +1,9 @@
+/*-----------------------------------------------------------------------------------------------
+ * Copyright (c) Red Hat, Inc. All rights reserved.
+ * Licensed under the MIT License. See LICENSE file in the project root for license information.
+ *-----------------------------------------------------------------------------------------------*/
+
+export interface VSCodeMessage {
+ kind: string;
+ data: any;
+}
diff --git a/src/webview/openshift-terminal/openShiftTerminal.ts b/src/webview/openshift-terminal/openShiftTerminal.ts
new file mode 100644
index 000000000..8f5a5b653
--- /dev/null
+++ b/src/webview/openshift-terminal/openShiftTerminal.ts
@@ -0,0 +1,595 @@
+/*-----------------------------------------------------------------------------------------------
+ * Copyright (c) Red Hat, Inc. All rights reserved.
+ * Licensed under the MIT License. See LICENSE file in the project root for license information.
+ *-----------------------------------------------------------------------------------------------*/
+
+import { randomUUID } from 'crypto';
+import * as fs from 'fs/promises';
+import type * as pty from 'node-pty';
+import { platform } from 'os';
+import {
+ CancellationToken,
+ ColorTheme, commands, Disposable,
+ Webview,
+ WebviewView,
+ WebviewViewProvider,
+ WebviewViewResolveContext, window,
+ workspace
+} from 'vscode';
+import { SerializeAddon } from 'xterm-addon-serialize';
+import { Terminal } from 'xterm-headless';
+import { CommandText } from '../../base/command';
+import { CliChannel } from '../../cli';
+import { ToolsConfig } from '../../tools';
+import { getVscodeModule } from '../../util/credentialManager';
+import { loadWebviewHtml } from '../common-ext/utils';
+
+// HACK: we cannot include node-pty ourselves,
+// since the library can only be run under one version of node
+// (whichever one it was compiled for).
+// If we want to install the extension across different VS Code versions,
+// we need to use the `node-pty` included in those versions
+const ptyInstance: typeof pty | undefined = getVscodeModule('node-pty');
+if (!ptyInstance) {
+ throw new Error('Unable to access node-pty from VS Code');
+}
+
+interface Message {
+ kind: string;
+ data: any;
+}
+
+/**
+ * An API to interact with a running terminal instance
+ */
+export interface OpenShiftTerminalApi {
+ /**
+ * Open the OpenShift terminal and switch to the tab for the given program
+ */
+ focusTerminal: () => void;
+
+ /**
+ * Input text into the terminal.
+ *
+ * @param text the text to input into the terminal
+ */
+ sendText: (text: string) => void;
+
+ /**
+ * Close the terminal by sending `\u0003` (`^C`).
+ */
+ kill: () => void;
+
+ /**
+ * Close the terminal. If the extension is not running on Windows, the process will be terminated using SIGABRT.
+ */
+ forceKill: () => void;
+}
+
+/**
+ * Represents a command running in the OpenShift Terminal view.
+ */
+class OpenShiftTerminal {
+ private _pty;
+ private _file: string;
+ private _args: string | string[];
+ private _options;
+
+ private _sendTerminalData: (data: string) => void;
+ private _sendExitMessage: () => void;
+
+ private _onSpawnListener: () => void;
+ private _onExitListener: () => void;
+ private _onTextListener: (text: string) => void;
+
+ private _closeOnExit: boolean;
+
+ private _uuid: string;
+ private _headlessTerm: Terminal;
+ private _termSerializer: SerializeAddon;
+ private _disposables: { dispose(): void }[];
+ private _ptyExited = false;
+
+ private _terminalRendering = false;
+
+ private _buffer: Buffer;
+
+ /**
+ * Creates a new OpenShiftTerminal
+ *
+ * @param uuid a unique identifier for the terminal
+ * @param sendMessage the function used to send a message to the UI terminal
+ * @param file the path to the program to execute
+ * @param args the arguments to pass to the program
+ * @param options the options for spawning the pty (name, current working directory, environment)
+ * @param closeOnExit true if the terminal should close when the program exits, or false if it should stay open until the user presses an additional key
+ * @param callbacks functions to execute in response to events in the terminal:
+ * - onSpawn(): called when the pty is created
+ * - onExit(): called when the pty exits (whether normally or though SIGABRT)
+ * - onText(): called when text is printed to the terminal, whether by the program or by user input
+ */
+ constructor(
+ uuid: string,
+ sendMessage: (message: Message) => Promise,
+ file: string,
+ args: string | string[],
+ options,
+ closeOnExit = false,
+ callbacks?: {
+ onSpawn?: () => void;
+ onExit?: () => void;
+ onText?: (text: string) => void;
+ },
+ ) {
+ this._uuid = uuid;
+
+ this._sendTerminalData = (data) => {
+ void sendMessage({ kind: 'termOutput', data: { uuid, output: data } });
+ };
+ this._sendExitMessage = () => {
+ void sendMessage({ kind: 'termExit', data: { uuid } });
+ };
+ this._onSpawnListener = callbacks?.onSpawn || (() => undefined);
+ this._onExitListener = callbacks?.onExit || (() => undefined);
+ this._onTextListener = callbacks?.onText || ((_text: string) => undefined);
+
+ this._closeOnExit = closeOnExit;
+
+ this._file = file;
+ this._args = args;
+ this._options = options;
+
+ this._disposables = [];
+ this._headlessTerm = new Terminal({ allowProposedApi: true });
+ this._termSerializer = new SerializeAddon();
+ this._headlessTerm.loadAddon(this._termSerializer);
+ this._buffer = Buffer.from('');
+
+ this._disposables.push(this._headlessTerm);
+ this._disposables.push(this._termSerializer);
+ }
+
+ startPty() {
+ if (platform() === 'win32') {
+ const escapedArgs = Array.isArray(this._args)
+ ? this._args.join(' ')
+ : this._args;
+ this._options.useConpty = false;
+ this._pty = ptyInstance.spawn(
+ 'cmd.EXE',
+ [
+ '/c',
+ `${this._file} ${escapedArgs}`,
+ ],
+ this._options,
+ );
+ } else {
+ this._pty = ptyInstance.spawn(this._file, this._args, this._options);
+ }
+ this._disposables.push(
+ this._pty.onData((data) => {
+ if (this._terminalRendering) {
+ this._sendTerminalData(data);
+ this._headlessTerm.write(data);
+ this._onTextListener(data);
+ } else {
+ this._buffer = Buffer.concat([this._buffer, Buffer.from(data)]);
+ this._onTextListener(data);
+ }
+ }),
+ );
+ this._disposables.push(
+ this._pty.onExit((_e) => {
+ this.onExit();
+ }),
+ );
+ this._onSpawnListener();
+ }
+
+ private onExit() {
+ this._onExitListener();
+ if (this._closeOnExit) {
+ this._sendExitMessage();
+ } else {
+ const msg = '\r\n\r\nPress any key to close this terminal\r\n';
+ this._sendTerminalData(msg);
+ this._headlessTerm.write(msg);
+ }
+ this._ptyExited = true;
+ }
+
+ /**
+ * Returns the unique identifier of this terminal.
+ *
+ * @returns the unique identifier of this terminal
+ */
+ public get uuid() {
+ return this._uuid;
+ }
+
+ /**
+ * Returns true if the pty that's running the program associated with this terminal has been started and has not exited, and false otherwise.
+ *
+ * @return true if the pty that's running the program associated with this terminal has been started and has not exited, and false otherwise
+ */
+ public get isPtyLive() {
+ return this._pty && !this._ptyExited;
+ }
+
+ /**
+ * Returns the string data of the terminal, serialized
+ */
+ public serialized(): Promise {
+ return new Promise((resolve) => {
+ this._headlessTerm.write(this._buffer, () => {
+ resolve(this._termSerializer.serialize());
+ });
+ this._buffer = Buffer.from('');
+ });
+ }
+
+ /**
+ * Resizes the terminal to the given size.
+ *
+ * @param cols the new number of columns for the terminal
+ * @param rows the new number of rows for the terminal
+ */
+ public resize(cols: number, rows: number): void {
+ if (this.isPtyLive) {
+ this._pty.resize(cols, rows);
+ }
+ this._headlessTerm.resize(cols, rows);
+ }
+
+ /**
+ * Input text into the terminal.
+ *
+ * @param data the text to input into the terminal
+ */
+ public write(data: string) {
+ if (this.isPtyLive) {
+ this._pty.write(data);
+ } else if (this._ptyExited) {
+ this._sendExitMessage();
+ }
+ }
+
+ /**
+ * Dispose of all resources associated with this terminal.
+ */
+ public dispose(): void {
+ if (this.isPtyLive) {
+ const termKilled = new Promise((resolve) => {
+ this._disposables.push(
+ this._pty.onExit((_e) => {
+ resolve();
+ }),
+ );
+ });
+ this._pty.kill();
+ void Promise.race([
+ termKilled,
+ new Promise((_, reject) => {
+ // force kill the terminal if it takes more than a minute to shut down
+ setTimeout(reject, 60_000);
+ }),
+ ])
+ .then(() => {
+ for (const disposable of this._disposables) {
+ disposable.dispose();
+ }
+ })
+ .catch((_error) => {
+ // force kill handles disposing of the disposables
+ this.forceKill();
+ });
+ } else {
+ for (const disposable of this._disposables) {
+ disposable.dispose();
+ }
+ }
+ }
+
+ /**
+ * Send SIGABRT to the program if it's running and the operating system is not on Windows,
+ * then dispose of all resources associated with this terminal.
+ */
+ public forceKill(): void {
+ if (this.isPtyLive) {
+ if (platform() !== 'win32') {
+ this._pty.kill('SIGABRT');
+ // pty won't send the exit message, so we have to perform the exit code ourselves
+ this.onExit();
+ }
+ // can't do anything better on windows, so just wait
+ }
+ this.dispose();
+ }
+
+ /**
+ * Start the program if it hasn't been started yet, and start sending data to the terminal UI.
+ */
+ public startRendering(): void {
+ this._terminalRendering = true;
+ if (!this._pty) {
+ this.startPty();
+ }
+ }
+
+ /**
+ * Stop sending data to the terminal UI.
+ *
+ * Terminal output will be buffered in a headless terminal until rendering is started again.
+ */
+ public stopRendering(): void {
+ this._terminalRendering = false;
+ }
+}
+
+/**
+ * Represents the OpenShift Terminal view.
+ */
+export class OpenShiftTerminalManager implements WebviewViewProvider {
+ private static INSTANCE = new OpenShiftTerminalManager();
+
+ private webview: Webview;
+ private webviewView: WebviewView;
+ private readonly openShiftTerminals: Map = new Map();
+
+ private webviewResolved: Promise;
+ private markWebviewResolved: () => void;
+
+ constructor() {
+ // create a promise that is resolved when `markWebviewResolved` is called
+ this.webviewResolved = new Promise((resolve) => {
+ this.markWebviewResolved = resolve;
+ });
+ }
+
+ public static getInstance() {
+ return OpenShiftTerminalManager.INSTANCE;
+ }
+
+ /**
+ * Resolves the HTML content for the webview of the given webview view.
+ *
+ * To be called by VS Code only
+ *
+ * @param webviewView the webview view containing the webview to resolve the content for
+ * @param context ignored
+ * @param token the cancellation token
+ */
+ async resolveWebviewView(
+ webviewView: WebviewView,
+ _context: WebviewViewResolveContext,
+ token: CancellationToken,
+ ): Promise {
+ this.webviewView = webviewView;
+ this.webview = webviewView.webview;
+
+ this.webviewView.show();
+
+ this.webview.options = {
+ enableScripts: true,
+ };
+
+ const newHtml: string = await loadWebviewHtml('openshiftTerminalViewer', this.webviewView);
+ if (!token.isCancellationRequested) {
+ this.webview.html = newHtml;
+ }
+ const disposables: Disposable[] = [];
+
+ // handle messages from the webview:
+ // - `termInit(uuid): string`: one of the following happened
+ // - a terminal was created in the webview and is ready to receive output; start the pty
+ // - an existing webview terminal which had been unfocused was refocused.
+ // Since the terminal output gets cleared between tab switches,
+ // rehydrate the terminal with the old output
+ // and start forwarding pty data to the terminal again
+ // - `termSuspend(uuid): void`: the given webview terminal was unfocused; stop sending pty data to the terminal
+ // - `input(uuid): void`: then given webview terminal received user input; pass this on to the pty
+ // - `resize(uuid, row, col): void`: the given webview terminal was resize; resize the headless terminal, and resize the pty if it's still alive
+ // - `closeTerminal(uuid): void`: the given webview terminal was closed; kill the process if needed and dispose of all the resources for the terminal in node
+ // - `termMuxUp(): void`: the webview has rendered for the first time and is ready to respond to `createTerminal` messages
+ this.webview.onDidReceiveMessage(
+ (event) => {
+ const message = event as Message;
+ const terminal = this.openShiftTerminals.get(message?.data?.uuid);
+ if (terminal) {
+ if (message.kind === 'termInit') {
+ void terminal
+ .serialized()
+ .then((serializedData) => {
+ void this.sendMessage({
+ kind: 'termInit',
+ data: {
+ uuid: terminal.uuid,
+ serializedOutput: serializedData,
+ },
+ });
+ })
+ .then(() => {
+ terminal.startRendering();
+ });
+ } else if (message.kind === 'termSuspend') {
+ terminal.stopRendering();
+ } else if (message.kind === 'input') {
+ terminal.write(message.data.data);
+ } else if (message.kind === 'resize') {
+ terminal.resize(message.data.cols, message.data.rows);
+ } else if (message.kind === 'closeTerminal') {
+ terminal.dispose();
+ this.openShiftTerminals.delete(message?.data?.uuid);
+ }
+ } else if (message.kind === 'termMuxUp') {
+ // mark the webview as resolved, to signal to `createTerminal`
+ // that it can issue requests to get a terminal
+ this.markWebviewResolved();
+ }
+ },
+ undefined,
+ disposables,
+ );
+
+ webviewView.onDidDispose(() => {
+ disposables.forEach((disposable) => {
+ disposable.dispose();
+ });
+ });
+
+ // Synchronize the color theme, font family, and font size of VS Code with the webview
+
+ workspace.onDidChangeConfiguration((e) => {
+ // adapt to the font family and size changes
+ // see note in ./app/index.tsx for a detailed explanation on why
+ // we listen to 'editor' config changes
+ // instead of 'terminal.integrated' config changes
+ if (e.affectsConfiguration('terminal.integrated')) {
+ void this.sendMessage({
+ kind: 'setTheme',
+ data: {
+ kind: window.activeColorTheme.kind,
+ fontFamily: workspace
+ .getConfiguration('terminal.integrated')
+ .get('fontFamily'),
+ fontSize: Number.parseInt(
+ workspace.getConfiguration('terminal.integrated').get('fontSize'),
+ 10,
+ ),
+ },
+ });
+ }
+ }, disposables);
+
+ void this.webviewResolved.then(() => {
+ void this.sendMessage({
+ kind: 'setTheme',
+ data: {
+ kind: window.activeColorTheme.kind,
+ fontFamily: workspace.getConfiguration('terminal.integrated').get('fontFamily'),
+ fontSize: Number.parseInt(
+ workspace.getConfiguration('terminal.integrated').get('fontSize'),
+ 10,
+ ),
+ },
+ });
+ disposables.push(
+ window.onDidChangeActiveColorTheme((colorTheme: ColorTheme) => {
+ void this.sendMessage({
+ kind: 'setTheme',
+ data: {
+ kind: colorTheme.kind,
+ fontFamily: workspace
+ .getConfiguration('terminal.integrated')
+ .get('fontFamily'),
+ fontSize: Number.parseInt(
+ workspace.getConfiguration('terminal.integrated').get('fontSize'),
+ 10,
+ ),
+ },
+ });
+ }),
+ );
+ });
+ }
+
+ public async executeInTerminal(command: CommandText, cwd: string = process.cwd(), name = 'OpenShift', addEnv = {} as {[key : string]: string} ): Promise {
+ const merged = Object.fromEntries([...Object.entries(addEnv), ...Object.entries(CliChannel.createTelemetryEnv()), ...Object.entries(process.env)]);
+ await OpenShiftTerminalManager.getInstance().createTerminal(command, name, cwd, merged);
+ }
+
+ /**
+ * Run a command in the OpenShift Terminal view and return an api to interact with the running command.
+ *
+ * The command will be run in a new 'tab' of the terminal.
+ *
+ * @param commandText the command to run in the terminal
+ * @param name the display name of the terminal session
+ * @param cwd the current working directory to use when running the command
+ * @param env the environment to use when running the command
+ * @param exitOnClose true if the terminal should close when the program exits, or false if it should stay open until the user presses an additional key
+ * @param callbacks functions to execute in response to events in the terminal:
+ * - onSpawn(): called when the pty is created
+ * - onExit(): called when the pty exits (whether normally or though SIGABRT)
+ * - onText(): called when text is printed to the terminal, whether by the program or by user input
+ * @returns an api to interact with the running command
+ */
+ public async createTerminal(
+ commandText: CommandText,
+ name: string,
+ cwd = process.cwd(),
+ env = process.env,
+ exitOnClose = false,
+ callbacks?: {
+ onSpawn?: () => void;
+ onExit?: () => void;
+ onText?: (text: string) => void;
+ },
+ ): Promise {
+ // focus the OpenShift terminal view in order to force the webview to be created
+ // (if it hasn't already)
+ await commands.executeCommand('openShiftTerminalView.focus');
+ // wait until the webview is ready to receive requests to create terminals
+ await this.webviewResolved;
+
+ const [cmd, ...args] = `${commandText}`.split(' ');
+ let toolLocation: string | undefined;
+ try {
+ toolLocation = await ToolsConfig.detect(cmd);
+ } catch (_e) {
+ // do nothing
+ }
+ if (!toolLocation) {
+ try {
+ await fs.access(cmd);
+ toolLocation = cmd;
+ } catch (__e) {
+ // do nothing
+ }
+ }
+ if (!toolLocation) {
+ const msg = `OpenShift Toolkit internal error: could not find ${cmd}`;
+ void window.showErrorMessage(msg);
+ throw new Error(msg);
+ }
+
+ const newTermUUID = randomUUID();
+
+ // create the object that manages the headless terminal and the pty.
+ // the process is run as a child process under node.
+ // the webview is synchronized to the pty and headless terminal using message passing
+ this.openShiftTerminals.set(
+ newTermUUID,
+ new OpenShiftTerminal(
+ newTermUUID,
+ (message: Message) => {
+ return this.sendMessage(message);
+ },
+ toolLocation,
+ args,
+ {
+ cwd,
+ env,
+ name,
+ },
+ exitOnClose,
+ callbacks,
+ ),
+ );
+
+ // issue request to create terminal in the webview
+ await this.sendMessage({ kind: 'createTerminal', data: { uuid: newTermUUID, name } });
+
+ return {
+ sendText: (text: string) => this.openShiftTerminals.get(newTermUUID).write(text),
+ focusTerminal: () =>
+ void this.sendMessage({ kind: 'switchToTerminal', data: { uuid: newTermUUID } }),
+ kill: () => this.openShiftTerminals.get(newTermUUID).write('\u0003'),
+ forceKill: () => this.openShiftTerminals.get(newTermUUID).forceKill(),
+ };
+ }
+
+ private async sendMessage(msg: Message): Promise {
+ await this.webview.postMessage(msg);
+ }
+}
diff --git a/test/integration/odo.test.ts b/test/integration/odo.test.ts
index 1ea4a8f29..87b46a4fb 100644
--- a/test/integration/odo.test.ts
+++ b/test/integration/odo.test.ts
@@ -6,12 +6,11 @@
import { V1Deployment } from '@kubernetes/client-node';
import { expect } from 'chai';
import * as fs from 'fs/promises';
+import * as JSYAML from 'js-yaml';
import { suite, suiteSetup } from 'mocha';
import * as tmp from 'tmp';
import { promisify } from 'util';
-import { Uri, window, workspace } from 'vscode';
-import * as JSYAML from 'js-yaml';
-import { CommandText } from '../../src/base/command';
+import { Uri, workspace } from 'vscode';
import * as Odo from '../../src/odo';
import { Command } from '../../src/odo/command';
import { Project } from '../../src/odo/project';
@@ -313,10 +312,4 @@ suite('odo integration', function () {
});
});
- test('executeInTerminal()', async function () {
- const numTerminals = window.terminals.length;
- await odo.executeInTerminal(new CommandText('odo', 'version'));
- expect(window.terminals).length(numTerminals + 1);
- });
-
});
diff --git a/test/ui/suite/command-about.ts b/test/ui/suite/command-about.ts
index d8c5b11bf..334435051 100644
--- a/test/ui/suite/command-about.ts
+++ b/test/ui/suite/command-about.ts
@@ -17,17 +17,19 @@ export function checkAboutCommand() {
await activateCommand(command);
})
- it('New terminal opens', async function() {
+ // Pending on https://github.com/redhat-developer/vscode-extension-tester/pull/855
+ it.skip('New terminal opens', async function() {
this.timeout(60000);
await new Promise(res => setTimeout(res, 6000));
const terminalName = await new TerminalView().getCurrentChannel();
expect(terminalName).to.include(expectedTerminalName);
});
- it('Terminal shows according information', async function() {
+ // Pending on https://github.com/redhat-developer/vscode-extension-tester/pull/855
+ it.skip('Terminal shows according information', async function() {
this.timeout(60000);
const terminalText = await new TerminalView().getText();
expect(terminalText).to.include(odoVersion);
});
});
-}
\ No newline at end of file
+}
diff --git a/test/ui/suite/component.ts b/test/ui/suite/component.ts
index 59bb86749..d736e376e 100644
--- a/test/ui/suite/component.ts
+++ b/test/ui/suite/component.ts
@@ -160,6 +160,7 @@ export function createComponentTest(contextFolder: string) {
await itemExists(compName, components, 30_000);
});
+ // Pending on https://github.com/redhat-developer/vscode-extension-tester/pull/855
it.skip('Start the component in dev mode', async function() {
this.timeout(180000);
const component = await itemExists(compName, components, 30_000);
diff --git a/test/ui/suite/extension.ts b/test/ui/suite/extension.ts
index 5cfaa02d5..4fad604eb 100644
--- a/test/ui/suite/extension.ts
+++ b/test/ui/suite/extension.ts
@@ -17,7 +17,7 @@ export function checkExtension() {
this.timeout(15_000);
const btn = await new ActivityBar().getViewControl(VIEWS.extensions);
await btn.openView();
- extView = await new SideBarView().getContent().getSection(VIEWS.installed);
+ extView = await new SideBarView().getContent().getSection(VIEWS.installed) as ExtensionsViewSection;
item = await extView.findItem(`@installed ${pjson.displayName}`);
});
diff --git a/test/ui/suite/openshift.ts b/test/ui/suite/openshift.ts
index a204eab2a..cdb163ef5 100644
--- a/test/ui/suite/openshift.ts
+++ b/test/ui/suite/openshift.ts
@@ -20,7 +20,7 @@ async function collapse(section: ViewSection){
}
export function checkOpenshiftView() {
- describe('OpenShift View', () => {
+ describe('OpenShift View', function() {
let view: SideBarView;
before(async function context() {
@@ -41,11 +41,11 @@ export function checkOpenshiftView() {
}
});
- describe('Application Explorer', () => {
+ describe('Application Explorer', function () {
let explorer: ViewSection;
let welcome: WelcomeContentSection;
- before(async () => {
+ before(async function() {
explorer = await view.getContent().getSection(VIEWS.appExplorer);
await explorer.expand();
welcome = await explorer.findWelcomeContent();
@@ -55,14 +55,15 @@ export function checkOpenshiftView() {
}
});
- it('shows welcome content when not logged in', async () => {
+ it('shows welcome content when not logged in', async function() {
expect(welcome).not.undefined;
const description = (await welcome.getTextSections()).join('');
expect(description).not.empty;
});
- it('shows buttons for basic actions when logged out', async () => {
+ it('shows buttons for basic actions when logged out', async function() {
const btns = await welcome.getButtons();
+ await Promise.all(btns.map(async btn => btn.wait(5_000)));
const titles = await Promise.all(btns.map(async btn => btn.getTitle()));
const expected = [BUTTONS.login, BUTTONS.kubeContext, BUTTONS.addCluster];
@@ -71,13 +72,13 @@ export function checkOpenshiftView() {
}
});
- it('shows more actions on hover', async () => {
+ it('shows more actions on hover', async function() {
const actions = await explorer.getActions();
expect(actions).length.above(3);
});
});
- describe('Components', () => {
+ describe('Components', function() {
let section: ViewSection;
let welcome: WelcomeContentSection;
@@ -89,7 +90,7 @@ export function checkOpenshiftView() {
welcome = await section.findWelcomeContent();
});
- it('shows welcome content when not logged in', async () => {
+ it('shows welcome content when not logged in', async function() {
expect(welcome).not.undefined;
expect((await welcome.getTextSections()).join('')).not.empty;
});
@@ -107,11 +108,11 @@ export function checkOpenshiftView() {
});
});
- describe('Devfile Registries', () => {
+ describe('Devfile Registries', function() {
let registries: CustomTreeSection;
- before(async () => {
- registries = await view.getContent().getSection(VIEWS.compRegistries);
+ before(async function() {
+ registries = await view.getContent().getSection(VIEWS.compRegistries) as CustomTreeSection;
await registries.expand();
});
diff --git a/test/unit/k8s/build.test.ts b/test/unit/k8s/build.test.ts
index d1566f87e..b9f86338c 100644
--- a/test/unit/k8s/build.test.ts
+++ b/test/unit/k8s/build.test.ts
@@ -8,9 +8,9 @@ import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import * as vscode from 'vscode';
import * as k8s from 'vscode-kubernetes-tools-api';
-import { CliChannel } from '../../../src/cli';
import { Build } from '../../../src/k8s/build';
import { ChildProcessUtil } from '../../../src/util/childProcessUtil';
+import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal';
const {expect} = chai;
chai.use(sinonChai);
@@ -62,7 +62,7 @@ suite('K8s/build', () => {
setup(() => {
sandbox = sinon.createSandbox();
- termStub = sandbox.stub(CliChannel.prototype, 'executeInTerminal');
+ termStub = sandbox.stub(OpenShiftTerminalManager.prototype, 'executeInTerminal');
execStub = sandbox.stub(ChildProcessUtil.prototype, 'execute').resolves({ stdout: '', stderr: undefined, error: undefined });
// sandbox.stub(Progress, 'execFunctionWithProgress').yields();
});
diff --git a/test/unit/k8s/deployment.test.ts b/test/unit/k8s/deployment.test.ts
index ae94c08c2..a23cc637d 100644
--- a/test/unit/k8s/deployment.test.ts
+++ b/test/unit/k8s/deployment.test.ts
@@ -12,6 +12,7 @@ import { CliChannel } from '../../../src/cli';
import { DeploymentConfig } from '../../../src/k8s/deploymentConfig';
import { ChildProcessUtil } from '../../../src/util/childProcessUtil';
import { Progress } from '../../../src/util/progress';
+import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal';
const {expect} = chai;
chai.use(sinonChai);
@@ -89,7 +90,7 @@ suite('K8s/deployment', () => {
setup(() => {
sandbox = sinon.createSandbox();
- termStub = sandbox.stub(CliChannel.prototype, 'executeInTerminal');
+ termStub = sandbox.stub(OpenShiftTerminalManager.prototype, 'executeInTerminal');
execStub = sandbox.stub(CliChannel.prototype, 'executeTool').resolves({ stdout: '', stderr: undefined, error: undefined});
sandbox.stub(Progress, 'execFunctionWithProgress').yields();
});
diff --git a/test/unit/odo.test.ts b/test/unit/odo.test.ts
index 0cd16002a..abf8fcadb 100644
--- a/test/unit/odo.test.ts
+++ b/test/unit/odo.test.ts
@@ -6,7 +6,6 @@
import * as chai from 'chai';
import { ExecException } from 'child_process';
import * as fs from 'fs';
-import * as path from 'path';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import { window, workspace } from 'vscode';
@@ -15,7 +14,6 @@ import { CliChannel } from '../../src/cli';
import * as odo from '../../src/odo';
import { ToolsConfig } from '../../src/tools';
import { ChildProcessUtil, CliExitData } from '../../src/util/childProcessUtil';
-import { WindowUtil } from '../../src/util/windowUtils';
const {expect} = chai;
chai.use(sinonChai);
@@ -106,24 +104,6 @@ suite('odo', () => {
expect(result).deep.equals({ error: err, stdout: '', stderr: '' });
});
-
- test('executeInTerminal send command to terminal and shows it', async () => {
- const termFake: any = {
- name: 'name',
- processId: Promise.resolve(1),
- sendText: sinon.stub(),
- show: sinon.stub(),
- hide: sinon.stub(),
- dispose: sinon.stub()
- };
- toolsStub.restore();
- toolsStub = sandbox.stub(ToolsConfig, 'detect').resolves(path.join('segment1', 'segment2'));
- const ctStub = sandbox.stub(WindowUtil, 'createTerminal').returns(termFake);
- await odoCli.executeInTerminal(new CommandText('cmd'));
- expect(termFake.sendText).calledOnce;
- expect(termFake.show).calledOnce;
- expect(ctStub).calledWith('OpenShift', process.cwd());
- });
});
suite('item listings', () => {
diff --git a/test/unit/openshift/cluster.test.ts b/test/unit/openshift/cluster.test.ts
index 9f6d55b4c..f771f1c84 100644
--- a/test/unit/openshift/cluster.test.ts
+++ b/test/unit/openshift/cluster.test.ts
@@ -12,7 +12,8 @@ import { ContextType, OdoImpl } from '../../../src/odo';
import { Command } from '../../../src/odo/command';
import { Cluster } from '../../../src/openshift/cluster';
import { CliExitData } from '../../../src/util/childProcessUtil';
-import { getVscodeModule, TokenStore } from '../../../src/util/credentialManager';
+import { TokenStore, getVscodeModule } from '../../../src/util/credentialManager';
+import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal';
import { TestItem } from './testOSItem';
import pq = require('proxyquire');
@@ -367,7 +368,7 @@ suite('Openshift/Cluster', () => {
suite('about', () => {
test('calls the proper odo command in terminal', () => {
- const stub = sandbox.stub(OdoImpl.prototype, 'executeInTerminal');
+ const stub = sandbox.stub(OpenShiftTerminalManager.prototype, 'executeInTerminal');
void Cluster.about();
expect(stub).calledOnceWith(Command.printOdoVersion());
diff --git a/test/unit/openshift/component.test.ts b/test/unit/openshift/component.test.ts
index 107339003..0a6116973 100644
--- a/test/unit/openshift/component.test.ts
+++ b/test/unit/openshift/component.test.ts
@@ -9,19 +9,20 @@ import * as path from 'path';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import * as vscode from 'vscode';
+import { ComponentInfo, ComponentsTreeDataProvider } from '../../../src/componentsView';
import { ContextType, OdoImpl } from '../../../src/odo';
import { Command } from '../../../src/odo/command';
import { ComponentTypeAdapter } from '../../../src/odo/componentType';
+import { CommandProvider } from '../../../src/odo/componentTypeDescription';
import { Project } from '../../../src/odo/project';
import { ComponentWorkspaceFolder, OdoWorkspace } from '../../../src/odo/workspace';
import * as openShiftComponent from '../../../src/openshift/component';
import * as Util from '../../../src/util/async';
+import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal';
import { comp1Folder } from '../../fixtures';
import { TestItem } from './testOSItem';
import pq = require('proxyquire');
import fs = require('fs-extra');
-import { ComponentInfo, ComponentsTreeDataProvider } from '../../../src/componentsView';
-import { CommandProvider } from '../../../src/odo/componentTypeDescription';
const { expect } = chai;
chai.use(sinonChai);
@@ -128,7 +129,7 @@ suite('OpenShift/Component', function () {
sandbox = sinon.createSandbox();
sandbox.stub(vscode.workspace, 'updateWorkspaceFolders');
Component = pq('../../../src/openshift/component', {}).Component;
- termStub = sandbox.stub(OdoImpl.prototype, 'executeInTerminal');
+ termStub = sandbox.stub(OpenShiftTerminalManager.prototype, 'executeInTerminal');
execStub = sandbox.stub(OdoImpl.prototype, 'execute').resolves({ stdout: '', stderr: undefined, error: undefined });
sandbox.stub(OdoImpl.prototype, 'getActiveCluster').resolves('cluster');
sandbox.stub(OdoImpl.prototype, 'getProjects').resolves([projectItem]);
diff --git a/test/unit/util/window.test.ts b/test/unit/util/window.test.ts
deleted file mode 100644
index 417d57b4e..000000000
--- a/test/unit/util/window.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*-----------------------------------------------------------------------------------------------
- * Copyright (c) Red Hat, Inc. All rights reserved.
- * Licensed under the MIT License. See LICENSE file in the project root for license information.
- *-----------------------------------------------------------------------------------------------*/
-
-import * as chai from 'chai';
-import * as sinonChai from 'sinon-chai';
-import * as sinon from 'sinon';
-import { window } from 'vscode';
-import { WindowUtil } from '../../../src/util/windowUtils';
-
-const {expect} = chai;
-chai.use(sinonChai);
-
-suite('Window Utility', () => {
- let sandbox: sinon.SinonSandbox;
- let termStub: sinon.SinonStub;
-
- setup(() => {
- sandbox = sinon.createSandbox();
- termStub = sandbox.stub(window, 'createTerminal');
- });
-
- teardown(() => {
- sandbox.restore();
- });
-
- test('createTerminal creates a terminal object', () => {
- WindowUtil.createTerminal('name', process.cwd());
- expect(termStub).calledOnce;
- });
-
- test('createTerminal creates a terminal object using cmd shell on windows', () => {
- sandbox.stub(process, 'platform').value('win32');
- sandbox.stub(process, 'env').value({ComSpec: 'path'});
- WindowUtil.createTerminal('name', process.cwd());
- expect(termStub).calledOnceWith({cwd: process.cwd(), name: 'name', shellPath: 'path', env: {ComSpec: 'path'}});
- });
-
-});
diff --git a/tsconfig.json b/tsconfig.json
index bd25b22f5..dfb8df146 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,9 @@
"es6",
"dom"
],
+ "typeRoots": [
+ "./src/@types"
+ ],
"sourceMap": true,
"rootDir": ".",
/* Strict Type-Checking Option */
@@ -39,6 +42,7 @@
"src/webview/feedback/app",
"src/webview/serverless-function/app",
"src/webview/serverless-manage-repository/app",
+ "src/webview/openshift-terminal/app",
"src/webview/common"
]
}