From 1af26789da6d7dca0890a019e3925416981a4d97 Mon Sep 17 00:00:00 2001 From: Nick Mitchell Date: Tue, 2 Jun 2020 08:38:53 -0400 Subject: [PATCH] feat: split screen Terminal Fixes #4814 --- packages/core/src/core/events.ts | 231 +++++++++- packages/core/src/index.ts | 3 +- packages/core/src/models/NavResponse.ts | 2 + packages/core/src/models/execOptions.ts | 2 + packages/core/src/models/mmr/types.ts | 2 + packages/core/src/repl/events.ts | 54 +++ packages/core/src/repl/exec.ts | 109 ++--- packages/core/src/webapp/cancel.ts | 23 +- packages/core/src/webapp/queueing.ts | 4 +- packages/core/src/webapp/tab.ts | 28 +- packages/test/src/api/cli.ts | 11 +- packages/test/src/api/repl-expect.ts | 14 + packages/test/src/api/selectors.ts | 23 +- plugins/plugin-bash-like/src/pty/client.ts | 30 +- .../src/test/bash-like/pty-copy-paste.ts | 2 +- .../plugin-bash-like/src/test/bash-like/vi.ts | 4 +- .../plugin-bash-like/web/css/static/xterm.css | 8 +- .../web/scss/kui--alternate.scss | 5 - .../src/test/bottom-input/new-tab.ts | 2 +- .../src/components/Client/InputStripe.tsx | 5 +- .../Client/InputStripeExperimental.tsx | 5 +- .../src/components/Client/Popup.tsx | 14 +- .../StatusStripe/TextWithIconWidget.tsx | 6 +- .../components/Client/StatusStripe/index.tsx | 5 +- .../src/components/Client/TabContainer.tsx | 32 +- .../src/components/Client/TabContent.tsx | 10 +- .../Client/TopTabStripe/NewTabButton.tsx | 2 +- .../TopTabStripe/SplitTerminalButton.tsx | 44 ++ .../components/Client/TopTabStripe/Tab.tsx | 9 +- .../components/Client/TopTabStripe/index.tsx | 2 + .../components/Client/props/FeatureFlags.ts | 3 + .../components/Views/Sidecar/BaseSidecar.tsx | 21 +- .../components/Views/Sidecar/ComboSidecar.tsx | 24 +- .../Views/Sidecar/LeftNavSidecar.tsx | 7 +- .../Views/Sidecar/TopNavSidecar.tsx | 6 +- .../components/Views/Terminal/Block/Input.tsx | 2 +- .../Views/Terminal/Block/OnKeyDown.ts | 3 +- .../Views/Terminal/Block/OnPaste.tsx | 3 +- .../Views/Terminal/ScrollableTerminal.tsx | 416 +++++++++++++----- .../src/components/Views/Terminal/getSize.ts | 44 ++ .../src/components/Views/WatchPane.tsx | 5 +- .../src/components/spi/Icons/impl/Carbon.tsx | 2 + .../components/spi/Icons/impl/PatternFly.tsx | 3 + .../src/components/spi/Icons/index.tsx | 1 + .../src/controller/confirm.ts | 4 +- .../src/controller/split.ts | 35 ++ plugins/plugin-client-common/src/plugin.ts | 1 + .../web/css/static/TopTabStripe.scss | 2 +- .../web/css/static/grid.scss | 17 + .../web/css/static/repl.scss | 55 ++- .../web/css/static/ui.css | 12 +- plugins/plugin-client-default/src/index.tsx | 2 +- .../plugin-core-support/src/lib/cmds/quit.ts | 2 +- .../src/lib/cmds/tab-management.ts | 2 +- .../src/test/core-support/tab-management.ts | 2 +- .../src/test/core-support2/split-terminals.ts | 149 +++++++ .../logs/src/controller/kubectl/logs.ts | 11 +- .../logs/src/test/logs/logs-dash-c.ts | 21 +- .../src/controller/kubectl/get-namespaces.ts | 3 +- .../src/lib/view/modes/ExecIntoPod.tsx | 6 +- 60 files changed, 1217 insertions(+), 338 deletions(-) create mode 100644 packages/core/src/repl/events.ts create mode 100644 plugins/plugin-client-common/src/components/Client/TopTabStripe/SplitTerminalButton.tsx create mode 100644 plugins/plugin-client-common/src/components/Views/Terminal/getSize.ts create mode 100644 plugins/plugin-client-common/src/controller/split.ts create mode 100644 plugins/plugin-client-common/web/css/static/grid.scss create mode 100644 plugins/plugin-core-support/src/test/core-support2/split-terminals.ts diff --git a/packages/core/src/core/events.ts b/packages/core/src/core/events.ts index 1dea7521565..0ed5538277b 100644 --- a/packages/core/src/core/events.ts +++ b/packages/core/src/core/events.ts @@ -13,9 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + /* eslint-disable no-dupe-class-members */ + import { EventEmitter } from 'events' -import { Tab } from '../webapp/tab' + +import { ExecType } from '../models/command' +import { ScalarResponse } from '../models/entity' +import MultiModalResponse from '../models/mmr/types' +import NavResponse from '../models/NavResponse' +import Tab, { getPrimaryTabId } from '../webapp/tab' +import { CommandStartEvent, CommandCompleteEvent, CommandStartHandler, CommandCompleteHandler } from '../repl/events' const eventChannelUnsafe = new EventEmitter() eventChannelUnsafe.setMaxListeners(100) @@ -33,16 +41,49 @@ class EventBusBase { } class WriteEventBus extends EventBusBase { - public emit(channel: '/tab/new' | '/tab/close/request' | '/tab/close' | '/tab/offline', tab: Tab): void + public emit(channel: '/tab/new' | '/tab/close' | '/tab/offline', tab: Tab): void public emit(channel: '/tab/new/request'): void public emit(channel: '/tab/switch/request', idx: number): void public emit(channel: string, args?: any) { return this.eventBus.emit(channel, args) } - public emitWithTabId(channel: '/tab/offline', tabId: string): void - public emitWithTabId(channel: string, tabId: string) { - return this.eventBus.emit(`${channel}/${tabId}`) + private emitCommandEvent(which: 'start' | 'complete', event: CommandStartEvent | CommandCompleteEvent) { + this.eventBus.emit(`/command/${which}`, event) + + if (event.execType !== ExecType.Nested) { + this.eventBus.emit(`/command/${which}/fromuser`, event) + this.eventBus.emit(`/command/${which}/fromuser/${event.tab.uuid}`, event) + + const primary = getPrimaryTabId(event.tab) + if (event.tab.uuid !== primary) { + this.eventBus.emit(`/command/${which}/fromuser/${primary}`, event) + } + + this.eventBus.emit(`/command/${which}/fromuser/${primary}/type/${event.execType}`, event) + } + } + + public emitCommandStart(event: CommandStartEvent): void { + this.emitCommandEvent('start', event) + } + + public emitCommandComplete(event: CommandCompleteEvent): void { + this.emitCommandEvent('complete', event) + + if (event.execType !== ExecType.Nested) { + this.eventBus.emit(`/command/complete/fromuser/${event.responseType}`, event) + this.eventBus.emit(`/command/complete/fromuser/${event.responseType}/${event.tab.uuid}`, event) + + const primary = getPrimaryTabId(event.tab) + if (primary !== event.tab.uuid) { + this.eventBus.emit(`/command/complete/fromuser/${event.responseType}/${primary}`, event) + } + } + } + + public emitWithTabId(channel: '/tab/offline' | '/tab/close/request', tabId: string, tab?: Tab): void { + this.eventBus.emit(`${channel}/${tabId}`, tabId, tab) } } @@ -58,14 +99,180 @@ class ReadEventBus extends WriteEventBus { return this.eventBus.on(channel, listener) } - public onWithTabId(channel: '/tab/offline', tabId: string, listener: (tabId: string) => void): void - public onWithTabId(channel: string, tabId: string, listener: any) { - return this.eventBus.on(`${channel}/${tabId}`, listener) + private onCommand( + which: 'start' | 'complete', + splitId: string, + splitHandler: CommandStartHandler | CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.eventBus.on(`/command/${which}/fromuser/${splitId}`, splitHandler) + + if (tabId) { + this.eventBus.on(`/command/${which}/fromuser/${tabId}/type/${ExecType.ClickHandler}`, tabHandler) + } + } + + private offCommand( + which: 'start' | 'complete', + splitId: string, + splitHandler: CommandStartHandler | CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.eventBus.off(`/command/${which}/fromuser/${splitId}`, splitHandler) + + if (tabId) { + this.eventBus.off(`/command/${which}/fromuser/${tabId}/type/${ExecType.ClickHandler}`, tabHandler) + } + } + + public onAnyCommandStart(handler: CommandStartHandler) { + this.eventBus.on('/command/start/fromuser', handler) + } + + public offAnyCommandStart(handler: CommandStartHandler) { + this.eventBus.on('/command/start/fromuser', handler) + } + + public onAnyCommandComplete(handler: CommandStartHandler) { + this.eventBus.on('/command/complete/fromuser', handler) + } + + public offAnyCommandComplete(handler: CommandStartHandler) { + this.eventBus.on('/command/complete/fromuser', handler) + } + + public onCommandStart( + splitId: string, + splitHandler: CommandStartHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.onCommand('start', splitId, splitHandler, tabId, tabHandler) + } + + public onceCommandStarts(tabId: string, handler: CommandStartHandler): void { + this.eventBus.once(`/command/start/fromuser/${tabId}`, handler) + } + + private onResponseType( + responseType: 'ScalarResponse' | 'MultiModalResponse' | 'NavResponse', + splitId: string, + splitHandler: CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.eventBus.on(`/command/complete/fromuser/${responseType}/${splitId}`, splitHandler) + + if (tabId) { + this.eventBus.on(`/command/complete/fromuser/${responseType}/${tabId}`, tabHandler) + } + } + + private offResponseType( + responseType: 'ScalarResponse' | 'MultiModalResponse' | 'NavResponse', + splitId: string, + splitHandler: CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.eventBus.off(`/command/complete/fromuser/${responseType}/${splitId}`, splitHandler) + + if (tabId) { + this.eventBus.off(`/command/complete/fromuser/${responseType}/${tabId}`, tabHandler) + } + } + + public onScalarResponse( + splitId: string, + splitHandler: CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.onResponseType('ScalarResponse', splitId, splitHandler, tabId, tabHandler) + } + + public offScalarResponse( + splitId: string, + splitHandler: CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.offResponseType('ScalarResponse', splitId, splitHandler, tabId, tabHandler) + } + + public onMultiModalResponse( + tabId: string, + handler: CommandCompleteHandler + ): void { + this.onResponseType('MultiModalResponse', tabId, handler) + } + + public offMultiModalResponse( + tabId: string, + handler: CommandCompleteHandler + ): void { + this.offResponseType('MultiModalResponse', tabId, handler) + } + + public onNavResponse(tabId: string, handler: CommandCompleteHandler): void { + this.onResponseType('NavResponse', tabId, handler) + } + + public offNavResponse(tabId: string, handler: CommandCompleteHandler): void { + this.offResponseType('NavResponse', tabId, handler) + } + + public onCommandComplete( + splitId: string, + splitHandler: CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.onCommand('complete', splitId, splitHandler, tabId, tabHandler) + } + + public offCommandStart( + splitId: string, + splitHandler: CommandStartHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.offCommand('start', splitId, splitHandler, tabId, tabHandler) + } + + public offCommandComplete( + splitId: string, + splitHandler: CommandCompleteHandler, + tabId?: string, + tabHandler = splitHandler + ): void { + this.offCommand('complete', splitId, splitHandler, tabId, tabHandler) + } + + public onWithTabId( + channel: '/tab/offline' | '/tab/close/request', + tabId: string, + listener: (tabId: string, tab: Tab) => void + ): void { + this.eventBus.on(`${channel}/${tabId}`, listener) + } + + public offWithTabId( + channel: '/tab/offline' | '/tab/close/request', + tabId: string, + listener: (tabId: string, tab: Tab) => void + ): void { + this.eventBus.off(`${channel}/${tabId}`, listener) } - public offWithTabId(channel: '/tab/offline', tabId: string, listener: () => void): void - public offWithTabId(channel: string, tabId: string, listener: any) { - return this.eventBus.off(`${channel}/${tabId}`, listener) + public onceWithTabId( + channel: '/tab/offline' | '/tab/close/request', + tabId: string, + listener: (tabId: string, tab: Tab) => void + ): void { + this.eventBus.once(`${channel}/${tabId}`, listener) } public once(channel: '/tab/new', listener: (tab: Tab) => void): void @@ -85,5 +292,5 @@ export const eventBus = new EventBus() export function wireToStandardEvents(listener: () => void) { eventBus.on('/tab/new', listener) eventBus.on('/tab/switch/request', listener) - eventChannelUnsafe.on('/command/complete/fromuser', listener) + eventBus.onAnyCommandComplete(listener) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bcfb59fdff0..0a41852c944 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -114,9 +114,10 @@ export { split, _split, Split } from './repl/split' export { ReplEval, DirectReplEval } from './repl/types' export { default as encodeComponent } from './repl/encode' export { exec as internalBeCarefulExec, pexec as internalBeCarefulPExec, setEvaluatorImpl, doEval } from './repl/exec' +export { CommandStartEvent, CommandCompleteEvent } from './repl/events' // Tabs -export { Tab, getCurrentTab, getTabId, sameTab } from './webapp/tab' +export { Tab, getCurrentTab, pexecInCurrentTab, getTabId, getPrimaryTabId, sameTab } from './webapp/tab' export { default as TabState } from './models/tab-state' // Themes diff --git a/packages/core/src/models/NavResponse.ts b/packages/core/src/models/NavResponse.ts index 02a2a0ab5be..63626ef9c44 100644 --- a/packages/core/src/models/NavResponse.ts +++ b/packages/core/src/models/NavResponse.ts @@ -59,3 +59,5 @@ export function isNavResponse(entity: Entity): entity is NavResponse { const nav = entity as NavResponse return nav.apiVersion === 'kui-shell/v1' && nav.kind === 'NavResponse' } + +export default NavResponse diff --git a/packages/core/src/models/execOptions.ts b/packages/core/src/models/execOptions.ts index 31b4c8230d4..6e569248de6 100644 --- a/packages/core/src/models/execOptions.ts +++ b/packages/core/src/models/execOptions.ts @@ -138,3 +138,5 @@ export class DefaultExecOptionsForTab extends DefaultExecOptions { this.block = block } } + +export default ExecOptions diff --git a/packages/core/src/models/mmr/types.ts b/packages/core/src/models/mmr/types.ts index 27c334ab348..ca08cb58810 100644 --- a/packages/core/src/models/mmr/types.ts +++ b/packages/core/src/models/mmr/types.ts @@ -170,3 +170,5 @@ export interface VisibilityTraits { export interface IconTrait { icon: ReactElement } + +export default MultiModalResponse diff --git a/packages/core/src/repl/events.ts b/packages/core/src/repl/events.ts new file mode 100644 index 00000000000..16dd4ab733b --- /dev/null +++ b/packages/core/src/repl/events.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2017-20 IBM Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Tab from '../webapp/tab' +import ExecOptions from '../models/execOptions' +import { CommandOptions, ExecType, KResponse, ParsedOptions } from '../models/command' + +export interface CommandStartEvent { + tab: Tab + route: string + command: string + execUUID: string + execType: ExecType + echo: boolean +} + +type ResponseTypeStr = 'MultiModalResponse' | 'NavResponse' | 'ScalarResponse' | 'Incomplete' | 'Error' + +export interface CommandCompleteEvent { + tab: Tab + + command: string + argvNoOptions: string[] + parsedOptions: ParsedOptions + execOptions: ExecOptions + + execUUID: string + execType: ExecType + cancelled: boolean + echo: boolean + evaluatorOptions: CommandOptions + + response: R + responseType: T +} + +export type CommandStartHandler = (event: CommandStartEvent) => void + +export type CommandCompleteHandler = ( + event: CommandCompleteEvent +) => void diff --git a/packages/core/src/repl/exec.ts b/packages/core/src/repl/exec.ts index 6883eb3f9a9..3f3299b01f3 100644 --- a/packages/core/src/repl/exec.ts +++ b/packages/core/src/repl/exec.ts @@ -31,6 +31,7 @@ import { split, patterns } from './split' import { Executor, ReplEval, DirectReplEval } from './types' import { isMultiModalResponse } from '../models/mmr/is' import { isNavResponse } from '../models/NavResponse' +import { CommandStartEvent, CommandCompleteEvent } from './events' import { CommandTreeResolution, @@ -46,7 +47,7 @@ import { import REPL from '../models/repl' import { RawContent, RawResponse, isRawResponse, MixedResponse, MixedResponsePart } from '../models/entity' import { ExecOptions, DefaultExecOptions, DefaultExecOptionsForTab } from '../models/execOptions' -import eventChannelUnsafe from '../core/events' +import eventChannelUnsafe, { eventBus } from '../core/events' import { CodedError } from '../models/errors' import { UsageModel, UsageRow } from '../core/usage-error' @@ -161,6 +162,27 @@ class InProcessExecutor implements Executor { } } + /** Notify the world that a command execution has begun */ + private emitStartEvent(startEvent: CommandStartEvent) { + eventBus.emitCommandStart(startEvent) + } + + /** Notify the world that a command execution has finished */ + private emitCompletionEvent( + presponse: T | Promise, + endEvent: Omit + ) { + return Promise.resolve(presponse).then(response => { + const responseType = isMultiModalResponse(response) + ? ('MultiModalResponse' as const) + : isNavResponse(response) + ? ('NavResponse' as const) + : ('ScalarResponse' as const) + + eventBus.emitCommandComplete(Object.assign(endEvent, { response, responseType })) + }) + } + /** * Split an `argv` into a pair of `argvNoOptions` and `ParsedOptions`. * @@ -262,6 +284,7 @@ class InProcessExecutor implements Executor { ): Promise | HTMLElement | CommandEvaluationError> { // const tab = execOptions.tab || getCurrentTab() + const execType = (execOptions && execOptions.type) || ExecType.TopLevel const REPL = tab.REPL || getImpl(tab) @@ -284,36 +307,29 @@ class InProcessExecutor implements Executor { execOptions.execUUID = execUUID const evaluatorOptions = evaluator.options - const startEvent = { + this.emitStartEvent({ tab, route: evaluator.route, command, execType, execUUID, echo: execOptions.echo - } - eventChannelUnsafe.emit('/command/start', startEvent) - eventChannelUnsafe.emit(`/command/start/${getTabId(tab)}`, startEvent) - if (execType !== ExecType.Nested) { - eventChannelUnsafe.emit(`/command/start/fromuser/${getTabId(tab)}`, startEvent) - } + }) if (command.length === 0) { // blank line (after stripping off comments) - const endEvent = { + this.emitCompletionEvent(true, { tab, execType, command: commandUntrimmed, - response: true, + argvNoOptions, + parsedOptions, + execOptions, execUUID, cancelled: true, echo: execOptions.echo, evaluatorOptions - } - eventChannelUnsafe.emit('/command/complete', endEvent) - if (execType !== ExecType.Nested) { - eventChannelUnsafe.emit(`/command/complete/fromuser/${getTabId(tab)}`, endEvent, execUUID, 'ScalarResponse') - } + }) return } @@ -323,12 +339,18 @@ class InProcessExecutor implements Executor { enforceUsage(argv, evaluator, execOptions) } catch (err) { debug('usage enforcement failure', err, execType === ExecType.Nested) - - const endEvent = { tab, execType, command: commandUntrimmed, response: err } - eventChannelUnsafe.emit('/command/complete', endEvent, execUUID, 'ScalarResponse') - if (execType !== ExecType.Nested) { - eventChannelUnsafe.emit(`/command/complete/fromuser/${getTabId(tab)}`, endEvent, execUUID, 'ScalarResponse') - } + this.emitCompletionEvent(err, { + tab, + execType, + command: commandUntrimmed, + argvNoOptions, + parsedOptions, + execOptions, + cancelled: false, + echo: execOptions.echo, + execUUID, + evaluatorOptions + }) if (execOptions.type === ExecType.Nested) { throw err @@ -396,53 +418,18 @@ class InProcessExecutor implements Executor { debug('warning: command handler returned nothing', commandUntrimmed) } - const endEvent = { + this.emitCompletionEvent(response || true, { tab, execType, command: commandUntrimmed, - response: response || true, + argvNoOptions, + parsedOptions, execUUID, + cancelled: false, echo: execOptions.echo, evaluatorOptions, execOptions - } - eventChannelUnsafe.emit(`/command/complete`, endEvent) - eventChannelUnsafe.emit(`/command/complete/${getTabId(tab)}`, endEvent) - - if (execType !== ExecType.Nested) { - Promise.resolve(response).then(_ => { - const responseType = isMultiModalResponse(_) - ? 'MultiModalResponse' - : isNavResponse(_) - ? 'NavResponse' - : 'ScalarResponse' - - eventChannelUnsafe.emit(`/command/complete/fromuser`, endEvent, responseType) - eventChannelUnsafe.emit(`/command/complete/fromuser/${getTabId(tab)}`, endEvent, execUUID, responseType) - eventChannelUnsafe.emit( - `/command/complete/fromuser/${responseType}`, - tab, - response, - execUUID, - argvNoOptions, - parsedOptions, - responseType, - evaluatorOptions - ) - eventChannelUnsafe.emit( - `/command/complete/fromuser/${responseType}/${getTabId(tab)}`, - tab, - response, - execUUID, - argvNoOptions, - parsedOptions, - responseType, - evaluatorOptions, - execOptions, - commandUntrimmed - ) - }) - } + }) return response } else { diff --git a/packages/core/src/webapp/cancel.ts b/packages/core/src/webapp/cancel.ts index 5d98d10d483..2f8764ccc49 100644 --- a/packages/core/src/webapp/cancel.ts +++ b/packages/core/src/webapp/cancel.ts @@ -22,16 +22,29 @@ * */ -import eventChannelUnsafe from '../core/events' -import { Tab, getTabId } from './tab' +import { eventBus } from '../core/events' +import { Tab } from './tab' import { Block } from './models/block' import { ExecType } from '../models/command' +import { CommandCompleteEvent } from '../repl/events' export default function doCancel(tab: Tab, block: Block) { block.isCancelled = true const execUUID = block.getAttribute('data-uuid') - const endEvent = { tab, execType: ExecType.TopLevel, cancelled: true, execUUID } - eventChannelUnsafe.emit('/command/complete', endEvent) - eventChannelUnsafe.emit(`/command/complete/fromuser/${getTabId(tab)}`, endEvent) + const endEvent: CommandCompleteEvent = { + tab, + execType: ExecType.TopLevel, + cancelled: true, + execUUID, + command: undefined, + argvNoOptions: undefined, + execOptions: undefined, + parsedOptions: undefined, + echo: true, + evaluatorOptions: undefined, + response: undefined, + responseType: 'Incomplete' + } + eventBus.emitCommandComplete(endEvent) } diff --git a/packages/core/src/webapp/queueing.ts b/packages/core/src/webapp/queueing.ts index 1975312f1a9..aaf36c65306 100644 --- a/packages/core/src/webapp/queueing.ts +++ b/packages/core/src/webapp/queueing.ts @@ -15,7 +15,7 @@ */ import { keys } from './keys' -import doCancel from './cancel' +// import doCancel from './cancel' import { getCurrentProcessingBlock } from './block' import { Tab } from './tab' @@ -37,7 +37,7 @@ export function startInputQueueing(tab: Tab) { try { const block = getCurrentProcessingBlock(tab) if (block) { - doCancel(tab, block) + // doCancel(tab, block) } } catch (err) { console.error(err) diff --git a/packages/core/src/webapp/tab.ts b/packages/core/src/webapp/tab.ts index a79a25a3c50..7ad25d42720 100644 --- a/packages/core/src/webapp/tab.ts +++ b/packages/core/src/webapp/tab.ts @@ -29,6 +29,9 @@ export interface Tab extends HTMLDivElement { addClass(cls: string): void removeClass(cls: string): void + + scrollToBottom(): void + getSize(): { width: number; height: number } } export function isTab(node: Element | Tab): node is Tab { @@ -40,7 +43,21 @@ export function isTab(node: Element | Tab): node is Tab { * */ export function getTabId(tab: Tab) { - return tab.getAttribute('data-tab-id') + return tab.uuid +} + +export function getTabIds(tab: Tab) { + const uuid = getTabId(tab) + if (uuid) { + const [u1] = uuid.split(/_/) + return u1 === uuid ? [u1] : [u1, uuid] + } else { + return [] + } +} + +export function getPrimaryTabId(tab: Tab) { + return getTabIds(tab)[0] } export const sameTab = (tab1: Tab, tab2: Tab): boolean => { @@ -50,3 +67,12 @@ export const sameTab = (tab1: Tab, tab2: Tab): boolean => { export const getCurrentTab = (): Tab => { return document.querySelector('.kui--tab-content.visible') as Tab } + +export function pexecInCurrentTab(command: string) { + const { facade: tab } = (document.querySelector('.kui--tab-content.visible .kui--scrollback') as any) as { + facade: Tab + } + return tab.REPL.pexec(command, { tab }) +} + +export default Tab diff --git a/packages/test/src/api/cli.ts b/packages/test/src/api/cli.ts index 5a6ca22dc13..fdbe8e5bd17 100644 --- a/packages/test/src/api/cli.ts +++ b/packages/test/src/api/cli.ts @@ -54,17 +54,16 @@ export const command = async ( app: Application, noNewline = false, noCopyPaste = false, - noFocus = false -) => { - const block = process.env.KUI_POPUP ? Selectors.STATUS_STRIPE_BLOCK : Selectors.CURRENT_PROMPT_BLOCK - const currentPrompt = process.env.KUI_POPUP + noFocus = false, + block = process.env.KUI_POPUP ? Selectors.STATUS_STRIPE_BLOCK : Selectors.CURRENT_PROMPT_BLOCK, + currentPrompt = process.env.KUI_POPUP ? Selectors.STATUS_STRIPE_PROMPT : !process.env.BOTTOM_INPUT_MODE ? Selectors.CURRENT_PROMPT : Selectors.BOTTOM_PROMPT - +) => { return app.client - .waitForExist(block, timeout - 5000) + .waitForExist(block, waitTimeout) .then(async () => { if (process.env.BOTTOM_INPUT_MODE) await app.client.waitForExist(Selectors.BOTTOM_PROMPT_BLOCK, timeout - 5000) if (!noFocus) return grabFocus(app) diff --git a/packages/test/src/api/repl-expect.ts b/packages/test/src/api/repl-expect.ts index e15ab44c03f..a1773aae111 100644 --- a/packages/test/src/api/repl-expect.ts +++ b/packages/test/src/api/repl-expect.ts @@ -255,3 +255,17 @@ export const okWith = (entityName: string) => async (res: AppAndCount) => expect /** expect just ok, and no result value */ export const justOK = async (res: AppAndCount) => expectOK(res, { expectJustOK: true }).then(() => res.app) + +/** Expect the given number of terminal splits in the current tab */ +export function splitCount(expectedSplitCount: number) { + return (app: Application) => { + let idx = 0 + return app.client.waitUntil(async () => { + const { value } = await app.client.elements(Selectors.SPLITS) + if (++idx > 5) { + console.error(`still waiting for splitCount; actual=${value.length} expected=${expectedSplitCount}`) + } + return value.length === expectedSplitCount + }, waitTimeout) + } +} diff --git a/packages/test/src/api/selectors.ts b/packages/test/src/api/selectors.ts index 0a52900c97a..5f0fb1921fa 100644 --- a/packages/test/src/api/selectors.ts +++ b/packages/test/src/api/selectors.ts @@ -5,7 +5,8 @@ export const TAB_SELECTED_N = (N: number) => `${TAB_N(N)}.visible` export const SIDECAR_BASE = `${CURRENT_TAB} .kui--sidecar` export const SIDECAR_FULLSCREEN = `${CURRENT_TAB} .kui--sidecar.visible.maximized:not(.minimized)` export const TERMINAL_WITH_SIDECAR_VISIBLE = `${CURRENT_TAB} .repl.sidecar-visible` -export const PROMPT_BLOCK = `${CURRENT_TAB} .repl .repl-block` +const _PROMPT_BLOCK = '.repl-block' +export const PROMPT_BLOCK = `${CURRENT_TAB} .repl ${_PROMPT_BLOCK}` export const BOTTOM_PROMPT_BLOCK = `${CURRENT_TAB} .kui--input-stripe .repl-block` export const BOTTOM_PROMPT = `${BOTTOM_PROMPT_BLOCK} input` export const STATUS_STRIPE_BLOCK = '.kui--status-stripe .kui--input-stripe .repl-block' @@ -87,8 +88,9 @@ export const PROCESSING_PROMPT_BLOCK = `${PROMPT_BLOCK}.repl-active` export const CURRENT_PROMPT_BLOCK = `${PROMPT_BLOCK}.repl-active` export const PROMPT_BLOCK_N = (N: number) => `${PROMPT_BLOCK}[data-input-count="${N}"]` export const PROCESSING_N = (N: number) => `${PROMPT_BLOCK_N(N)}.processing` -export const CURRENT_PROMPT = `${CURRENT_PROMPT_BLOCK} .repl-input-element` -export const PROMPT_N = (N: number) => `${PROMPT_BLOCK_N(N)} .repl-input-element` +const _PROMPT = '.repl-input-element' +export const CURRENT_PROMPT = `${CURRENT_PROMPT_BLOCK} ${_PROMPT}` +export const PROMPT_N = (N: number) => `${PROMPT_BLOCK_N(N)} ${_PROMPT}` export const OUTPUT_N = (N: number) => `${PROMPT_BLOCK_N(N)} .repl-result` export const OUTPUT_N_STREAMING = (N: number) => `${PROMPT_BLOCK_N(N)} [data-stream]` export const OUTPUT_N_PTY = (N: number) => OUTPUT_N_STREAMING(N) @@ -143,3 +145,18 @@ export const WATCHER_N_DROPDOWN_ITEM = (N: number, label: string) => `${WATCHER_N(N)} .pf-c-dropdown button.pf-c-dropdown__menu-item[data-mode="${label}"]` export const WATCHER_N_CLOSE = (N: number) => WATCHER_N_DROPDOWN_ITEM(N, 'Stop watching') export const WATCHER_N_SHOW_AS_TABLE = (N: number) => WATCHER_N_DROPDOWN_ITEM(N, 'Show as table') + +/** + * Terminal splits + * + */ +export const NEW_SPLIT_BUTTON = '#kui--split-terminal-button' +export const SPLITS = `${CURRENT_TAB} .kui--scrollback` +export const SPLIT_N = (N: number) => `${SPLITS}:nth-child(${N})` +export const CURRENT_PROMPT_BLOCK_FOR_SPLIT = (N: number) => `${SPLIT_N(N)} ${_PROMPT_BLOCK}` +export const CURRENT_PROMPT_FOR_SPLIT = (N: number) => `${CURRENT_PROMPT_BLOCK_FOR_SPLIT(N)} ${_PROMPT}` + +/** xterm */ +export const ALT_BUFFER_N = (N: number) => `${CURRENT_TAB} .kui--scrollback:nth-child(${N}).xterm-alt-buffer-mode` +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const NOSPLIT_ALT_BUFFER_N = (N: number) => `${CURRENT_TAB}.xterm-alt-buffer-mode` diff --git a/plugins/plugin-bash-like/src/pty/client.ts b/plugins/plugin-bash-like/src/pty/client.ts index 57bdccd58e9..d2bc0902118 100644 --- a/plugins/plugin-bash-like/src/pty/client.ts +++ b/plugins/plugin-bash-like/src/pty/client.ts @@ -236,22 +236,6 @@ class Resizer { cleanupTerminalAfterTermination(element) } - static paddingHorizontal(elt: Element) { - const style = window.getComputedStyle(elt) - return ( - parseInt(style.getPropertyValue('padding-left') || '0', 10) + - parseInt(style.getPropertyValue('padding-right') || '0', 10) - ) - } - - static paddingVertical(elt: Element) { - const style = window.getComputedStyle(elt) - return ( - parseInt(style.getPropertyValue('padding-top') || '0', 10) + - parseInt(style.getPropertyValue('padding-bottom') || '0', 10) - ) - } - private getSize(forceRecompute: boolean) { const cachedSize = getCachedSize(this.tab) if (!forceRecompute && cachedSize !== undefined) { @@ -265,16 +249,7 @@ class Resizer { const scaledCharWidth = hack._charSizeService.width * window.devicePixelRatio const ratio = scaledCharWidth / dimensions.scaledCharWidth - const selectorForSize = '.repl-inner' - const sizeElement = this.tab.querySelector(selectorForSize) - const enclosingRect = sizeElement.getBoundingClientRect() - - const selectorForWidthPad = '.repl-inner .repl-block .repl-output' - const widthPadElement = this.tab.querySelector(selectorForWidthPad) - const heightPadElement = sizeElement - - const width = enclosingRect.width - Resizer.paddingHorizontal(widthPadElement) - const height = enclosingRect.height - Resizer.paddingVertical(heightPadElement) + const { width, height } = this.tab.getSize() const cols = Math.floor(width / dimensions.actualCellWidth / ratio) const rows = Math.floor(height / dimensions.actualCellHeight) @@ -464,10 +439,9 @@ async function initOnMessage( // // here, we debounce scroll to bottom events // - const activeDiv = tab.querySelector('.repl-inner') const doScroll = () => { if (!resizer.inAltBufferMode()) { - activeDiv.scrollTop = activeDiv.scrollHeight + tab.scrollToBottom() } } const scrollPoll = terminal && setInterval(doScroll, 200) diff --git a/plugins/plugin-bash-like/src/test/bash-like/pty-copy-paste.ts b/plugins/plugin-bash-like/src/test/bash-like/pty-copy-paste.ts index b6a77c78f74..3cfd6415f97 100644 --- a/plugins/plugin-bash-like/src/test/bash-like/pty-copy-paste.ts +++ b/plugins/plugin-bash-like/src/test/bash-like/pty-copy-paste.ts @@ -110,7 +110,7 @@ describe(`xterm copy paste ${process.env.MOCHA_RUN_TARGET || ''}`, function(this // wait for vi to come up in alt buffer mode console.error('CP8') - await this.app.client.waitForExist(`${Selectors.CURRENT_TAB}.xterm-alt-buffer-mode`) + await this.app.client.waitForExist(Selectors.ALT_BUFFER_N(1)) // enter insert mode, and wait for INSERT to appear at the bottom console.error('CP9') diff --git a/plugins/plugin-bash-like/src/test/bash-like/vi.ts b/plugins/plugin-bash-like/src/test/bash-like/vi.ts index b4ca3acf030..d1b2171452e 100644 --- a/plugins/plugin-bash-like/src/test/bash-like/vi.ts +++ b/plugins/plugin-bash-like/src/test/bash-like/vi.ts @@ -40,7 +40,7 @@ describe(`xterm vi 1 ${process.env.MOCHA_RUN_TARGET || ''}`, function(this: Comm await this.app.client.waitForExist(rows(res.count)) // wait for vi to come up in alt buffer mode - await this.app.client.waitForExist(`${Selectors.CURRENT_TAB}.xterm-alt-buffer-mode`) + await this.app.client.waitForExist(Selectors.ALT_BUFFER_N(1)) // enter insert mode, and wait for INSERT to appear at the bottom let iter = 0 @@ -102,7 +102,7 @@ describe(`xterm vi 2 ${process.env.MOCHA_RUN_TARGET || ''}`, function(this: Comm await this.app.client.waitForExist(rows(res.count)) // wait for vi to come up in alt buffer mode - await this.app.client.waitForExist(`${Selectors.CURRENT_TAB}.xterm-alt-buffer-mode`) + await this.app.client.waitForExist(Selectors.ALT_BUFFER_N(1)) // :wq await this.app.client.keys(':wq') diff --git a/plugins/plugin-bash-like/web/css/static/xterm.css b/plugins/plugin-bash-like/web/css/static/xterm.css index 9bad09ac80c..36e05c230bb 100644 --- a/plugins/plugin-bash-like/web/css/static/xterm.css +++ b/plugins/plugin-bash-like/web/css/static/xterm.css @@ -35,12 +35,12 @@ disabled see https://github.com/IBM/kui/issues/3939 transition: width 10ms ease-in-out, height 10ms ease-in-out; } */ - .kui--tab-content:not(.xterm-alt-buffer-mode) .xterm-container .xterm-screen { + .kui--scrollback:not(.xterm-alt-buffer-mode) .xterm-container .xterm-screen { height: auto !important; } /* alt buffer mode */ - .kui--tab-content.xterm-alt-buffer-mode { + .xterm-alt-buffer-mode { .xterm-container .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-block { background-color: var(--color-base08); color: var(--color-base00); @@ -50,6 +50,7 @@ disabled see https://github.com/IBM/kui/issues/3939 display: none; } + &, .repl-inner { overflow: hidden; display: flex; @@ -58,6 +59,7 @@ disabled see https://github.com/IBM/kui/issues/3939 /* when in alt-buffer mode, hide anything in the REPL not related to the current output */ .repl-block:not(.processing), + .kui--input-stripe, .repl-input, .repl-result, .repl-result-spinner { @@ -97,7 +99,7 @@ disabled see https://github.com/IBM/kui/issues/3939 } } - /* .kui--tab-content:not(.xterm-alt-buffer-mode) .xterm.xterm-empty-row-heuristic .xterm-rows > div:empty { + /* .kui--scrollback:not(.xterm-alt-buffer-mode) .xterm.xterm-empty-row-heuristic .xterm-rows > div:empty { display: none; } */ diff --git a/plugins/plugin-carbon-themes/web/scss/kui--alternate.scss b/plugins/plugin-carbon-themes/web/scss/kui--alternate.scss index 25c83d66038..531e6d1afa3 100644 --- a/plugins/plugin-carbon-themes/web/scss/kui--alternate.scss +++ b/plugins/plugin-carbon-themes/web/scss/kui--alternate.scss @@ -1,11 +1,6 @@ /* Alternate look for carbon themes */ body.kui--alternate { - .repl-block { - padding-left: calc(1em * 0.875); - padding-right: calc(1em * 0.875 - 0.1875em - 6px); - } - .repl-prompt { filter: none; color: var(--color-text-02); diff --git a/plugins/plugin-client-alternate/src/test/bottom-input/new-tab.ts b/plugins/plugin-client-alternate/src/test/bottom-input/new-tab.ts index f742cec3f4e..d0fe5553cde 100644 --- a/plugins/plugin-client-alternate/src/test/bottom-input/new-tab.ts +++ b/plugins/plugin-client-alternate/src/test/bottom-input/new-tab.ts @@ -52,7 +52,7 @@ describe('core new tab from pty active tab via button click', function(this: Com it('start vi, then new tab via button click', () => CLI.command('vi', this.app) - .then(() => this.app.client.waitForExist(`${Selectors.CURRENT_TAB}.xterm-alt-buffer-mode`)) + .then(() => this.app.client.waitForExist(Selectors.NOSPLIT_ALT_BUFFER_N(1))) .then(() => this.app.client.click(tabButtonSelector)) .then(() => this.app.client.waitForVisible(Selectors.TAB_SELECTED_N(2))) .then(() => CLI.waitForSession(this)) // should have an active repl diff --git a/plugins/plugin-client-common/src/components/Client/InputStripe.tsx b/plugins/plugin-client-common/src/components/Client/InputStripe.tsx index 44ff9ab46a8..774635bd35f 100644 --- a/plugins/plugin-client-common/src/components/Client/InputStripe.tsx +++ b/plugins/plugin-client-common/src/components/Client/InputStripe.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react' -import { Tab as KuiTab, eventChannelUnsafe } from '@kui-shell/core' +import { Tab as KuiTab, eventBus } from '@kui-shell/core' import Block from '../Views/Terminal/Block' import { InputOptions } from '../Views/Terminal/Block/Input' @@ -39,8 +39,7 @@ export default class InputStripe extends React.PureComponent { public constructor(props: Props) { super(props) - const channel = `/command/complete/fromuser/${this.props.uuid}` - eventChannelUnsafe.on(channel, this.onOutputRender.bind(this)) + eventBus.onCommandComplete(this.props.uuid, this.onOutputRender.bind(this)) this.state = { idx: 0, diff --git a/plugins/plugin-client-common/src/components/Client/InputStripeExperimental.tsx b/plugins/plugin-client-common/src/components/Client/InputStripeExperimental.tsx index 5ad70423a5e..c7220381956 100644 --- a/plugins/plugin-client-common/src/components/Client/InputStripeExperimental.tsx +++ b/plugins/plugin-client-common/src/components/Client/InputStripeExperimental.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react' -import { Tab as KuiTab, eventChannelUnsafe } from '@kui-shell/core' +import { Tab as KuiTab, eventBus } from '@kui-shell/core' import Block from '../Views/Terminal/Block' import BlockModel, { Active } from '../Views/Terminal/Block/BlockModel' @@ -63,8 +63,7 @@ export default class InputStripe extends React.PureComponent { public constructor(props: Props) { super(props) - const channel = `/command/complete/fromuser/${this.props.uuid}` - eventChannelUnsafe.on(channel, this.onOutputRender.bind(this)) + eventBus.onCommandComplete(this.props.uuid, this.onOutputRender.bind(this)) this.state = { idx: 0, diff --git a/plugins/plugin-client-common/src/components/Client/Popup.tsx b/plugins/plugin-client-common/src/components/Client/Popup.tsx index 621c105674f..38023a06f90 100644 --- a/plugins/plugin-client-common/src/components/Client/Popup.tsx +++ b/plugins/plugin-client-common/src/components/Client/Popup.tsx @@ -18,7 +18,7 @@ /* eslint-disable react/prop-types */ import * as React from 'react' -import { eventChannelUnsafe, eventBus, Tab as KuiTab, teeToFile } from '@kui-shell/core' +import { eventBus, Tab as KuiTab, teeToFile } from '@kui-shell/core' import Width from '../Views/Sidecar/width' import { ComboSidecar, ContextWidgets, InputStripe, StatusStripe, TabContent, TabModel } from '../..' @@ -39,12 +39,14 @@ export default class Popup extends React.PureComponent { public constructor(props: Props) { super(props) - eventBus.on('/tab/close/request', async (tab: KuiTab) => { + const tabModel = new TabModel() + + eventBus.onceWithTabId('/tab/close/request', tabModel.uuid, async (_, tab: KuiTab) => { // tab close is window close for the popup client - tab.REPL.qexec('window close') + tab.REPL.qexec('window close', undefined, undefined, { tab }) }) - eventChannelUnsafe.on('/command/complete/fromuser', async ({ command, response }) => { + eventBus.onCommandComplete(tabModel.uuid, async ({ command, response }) => { if (process.env.KUI_TEE_TO_FILE) { // tee the response to a file // maybe in the future we could do this better @@ -56,13 +58,13 @@ export default class Popup extends React.PureComponent { }) this.state = { - model: new TabModel(), + model: tabModel, promptPlaceholder: '' } } private onTabReady(tab: KuiTab) { - tab.REPL.pexec(this.props.commandLine.join(' ')) + tab.REPL.pexec(this.props.commandLine.join(' '), { tab }) } public render() { diff --git a/plugins/plugin-client-common/src/components/Client/StatusStripe/TextWithIconWidget.tsx b/plugins/plugin-client-common/src/components/Client/StatusStripe/TextWithIconWidget.tsx index a49acb74426..0dc544bbb20 100644 --- a/plugins/plugin-client-common/src/components/Client/StatusStripe/TextWithIconWidget.tsx +++ b/plugins/plugin-client-common/src/components/Client/StatusStripe/TextWithIconWidget.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react' -import { getCurrentTab } from '@kui-shell/core' +import { pexecInCurrentTab } from '@kui-shell/core' /** variants of how the information should be presented */ export type ViewLevel = 'removed' | 'hidden' | 'normal' | 'obscured' | 'ok' | 'warn' | 'error' @@ -43,7 +43,7 @@ export default class TextWithIconWidget extends React.PureComponent { href="#" className={iconClassName} onMouseDown={evt => evt.preventDefault()} - onClick={this.props.iconOnclick ? () => getCurrentTab().REPL.pexec(this.props.iconOnclick) : undefined} + onClick={this.props.iconOnclick ? () => pexecInCurrentTab(this.props.iconOnclick) : undefined} > {this.props.children} @@ -55,7 +55,7 @@ export default class TextWithIconWidget extends React.PureComponent { getCurrentTab().REPL.pexec(this.props.textOnclick)} + onClick={() => pexecInCurrentTab(this.props.textOnclick)} > {this.props.text} diff --git a/plugins/plugin-client-common/src/components/Client/StatusStripe/index.tsx b/plugins/plugin-client-common/src/components/Client/StatusStripe/index.tsx index 97d4543a7da..213ece707c2 100644 --- a/plugins/plugin-client-common/src/components/Client/StatusStripe/index.tsx +++ b/plugins/plugin-client-common/src/components/Client/StatusStripe/index.tsx @@ -18,7 +18,7 @@ /* eslint-disable react/prop-types */ import * as React from 'react' -import { inElectron } from '@kui-shell/core' +import { inElectron, pexecInCurrentTab } from '@kui-shell/core' import Icons from '../../spi/Icons' @@ -28,8 +28,7 @@ export default class StatusStripe extends React.PureComponent { * */ private async doAbout() { - const { internalBeCarefulPExec: pexec } = await import('@kui-shell/core') - pexec('about') + pexecInCurrentTab('about') } /** diff --git a/plugins/plugin-client-common/src/components/Client/TabContainer.tsx b/plugins/plugin-client-common/src/components/Client/TabContainer.tsx index d27ee2618e0..79d1782a32c 100644 --- a/plugins/plugin-client-common/src/components/Client/TabContainer.tsx +++ b/plugins/plugin-client-common/src/components/Client/TabContainer.tsx @@ -52,7 +52,7 @@ export default class TabContainer extends React.PureComponent { super(props) this.state = { - tabs: [new TabModel()], + tabs: [this.newTabModel()], activeIdx: 0 } @@ -60,15 +60,6 @@ export default class TabContainer extends React.PureComponent { this.onNewTab() }) - eventBus.on('/tab/close/request', async (tab: Tab) => { - if (this.state.tabs.length === 1) { - // then we are closing the last tab, so close the window - tab.REPL.qexec('window close') - } else { - this.onCloseTab(this.state.activeIdx) - } - }) - eventBus.on('/tab/switch/request', (idx: number) => { this.onSwitchTab(idx) }) @@ -142,6 +133,23 @@ export default class TabContainer extends React.PureComponent { } } + private listenForTabClose(model: TabModel) { + eventBus.onceWithTabId('/tab/close/request', model.uuid, async (uuid: string, tab: Tab) => { + if (this.state.tabs.length === 1) { + // then we are closing the last tab, so close the window + tab.REPL.qexec('window close') + } else { + this.onCloseTab(this.state.activeIdx) + } + }) + } + + private newTabModel() { + const model = new TabModel() + this.listenForTabClose(model) + return model + } + /** * New Tab event * @@ -149,8 +157,10 @@ export default class TabContainer extends React.PureComponent { private onNewTab() { this.captureState() + const model = this.newTabModel() + this.setState(curState => ({ - tabs: curState.tabs.concat(new TabModel()), + tabs: curState.tabs.concat(model), activeIdx: curState.tabs.length })) } diff --git a/plugins/plugin-client-common/src/components/Client/TabContent.tsx b/plugins/plugin-client-common/src/components/Client/TabContent.tsx index 3177231faf5..4823c1ffe46 100644 --- a/plugins/plugin-client-common/src/components/Client/TabContent.tsx +++ b/plugins/plugin-client-common/src/components/Client/TabContent.tsx @@ -24,6 +24,8 @@ import Confirm from '../Views/Confirm' import Loading from '../spi/Loading' import Width from '../Views/Sidecar/width' import WatchPane, { Height } from '../Views/WatchPane' + +import getSize from '../Views/Terminal/getSize' import ScrollableTerminal, { TerminalOptions } from '../Views/Terminal/ScrollableTerminal' import '../../../web/css/static/split-pane.scss' @@ -135,8 +137,7 @@ export default class TabContent extends React.PureComponent { this.cleaners.push(() => eventBus.offWithTabId('/tab/offline', this.props.uuid, onOffline)) // see https://github.com/IBM/kui/issues/4683 - const firstCommandIsExecuted = `/command/start/fromuser/${this.props.uuid}` - eventChannelUnsafe.once(firstCommandIsExecuted, () => { + eventBus.onceCommandStarts(this.props.uuid, () => { this.setState({ showSessionInitDone: false }) }) } @@ -394,6 +395,11 @@ export default class TabContent extends React.PureComponent { if (tab) { tab.uuid = this.props.uuid + + tab.getSize = getSize.bind(c) + tab.scrollToBottom = () => { + c.scrollTop = c.scrollHeight + } tab.onActivate = (handler: (isActive: boolean) => void) => { this.activateHandlers.push(handler) } diff --git a/plugins/plugin-client-common/src/components/Client/TopTabStripe/NewTabButton.tsx b/plugins/plugin-client-common/src/components/Client/TopTabStripe/NewTabButton.tsx index cc1cb8d38c5..e16f3ed032f 100644 --- a/plugins/plugin-client-common/src/components/Client/TopTabStripe/NewTabButton.tsx +++ b/plugins/plugin-client-common/src/components/Client/TopTabStripe/NewTabButton.tsx @@ -26,7 +26,7 @@ export default class NewTabButton extends React.PureComponent { return ( + {config => + config.splitTerminals && ( + onSplit()} + > + + + ) + } + + ) + } +} diff --git a/plugins/plugin-client-common/src/components/Client/TopTabStripe/Tab.tsx b/plugins/plugin-client-common/src/components/Client/TopTabStripe/Tab.tsx index ddaeb51a7be..fc957fda602 100644 --- a/plugins/plugin-client-common/src/components/Client/TopTabStripe/Tab.tsx +++ b/plugins/plugin-client-common/src/components/Client/TopTabStripe/Tab.tsx @@ -17,6 +17,7 @@ import * as React from 'react' import { i18n, + eventBus, eventChannelUnsafe, Event, ExecType, @@ -85,8 +86,8 @@ export default class Tab extends React.PureComponent { } private removeCommandEvaluationListeners() { - eventChannelUnsafe.off('/command/start', this.onCommandStart) - eventChannelUnsafe.off('/command/complete', this.onCommandComplete) + eventBus.offCommandStart(this.props.uuid, this.onCommandStart) + eventBus.offCommandComplete(this.props.uuid, this.onCommandStart) eventChannelUnsafe.off('/theme/change', this.onThemeChange) } @@ -132,8 +133,8 @@ export default class Tab extends React.PureComponent { }) } - eventChannelUnsafe.on('/command/start', this.onCommandStart) - eventChannelUnsafe.on('/command/complete', this.onCommandComplete) + eventBus.onCommandStart(this.props.uuid, this.onCommandStart) + eventBus.onCommandComplete(this.props.uuid, this.onCommandStart) eventChannelUnsafe.on('/theme/change', this.onThemeChange) } diff --git a/plugins/plugin-client-common/src/components/Client/TopTabStripe/index.tsx b/plugins/plugin-client-common/src/components/Client/TopTabStripe/index.tsx index 5df719bed6a..3fff2400051 100644 --- a/plugins/plugin-client-common/src/components/Client/TopTabStripe/index.tsx +++ b/plugins/plugin-client-common/src/components/Client/TopTabStripe/index.tsx @@ -22,6 +22,7 @@ import TabModel from '../TabModel' import KuiContext from '../context' import NewTabButton from './NewTabButton' import Tab, { TabConfiguration } from './Tab' +import SplitTerminalButton from './SplitTerminalButton' import '../../../../web/css/static/TopTabStripe.scss' @@ -133,6 +134,7 @@ export default class TopTabStripe extends React.PureComponent { this.props.onNewTab() }} /> + ) diff --git a/plugins/plugin-client-common/src/components/Client/props/FeatureFlags.ts b/plugins/plugin-client-common/src/components/Client/props/FeatureFlags.ts index 6854ccd224f..30ef37d0b9e 100644 --- a/plugins/plugin-client-common/src/components/Client/props/FeatureFlags.ts +++ b/plugins/plugin-client-common/src/components/Client/props/FeatureFlags.ts @@ -20,6 +20,9 @@ type FeatureFlags = { /** [Optional] disable table title? */ disableTableTitle?: boolean + + /** [Optional] Enable Split Terminals? */ + splitTerminals?: boolean } export default FeatureFlags diff --git a/plugins/plugin-client-common/src/components/Views/Sidecar/BaseSidecar.tsx b/plugins/plugin-client-common/src/components/Views/Sidecar/BaseSidecar.tsx index 443da1704d1..62f30716447 100644 --- a/plugins/plugin-client-common/src/components/Views/Sidecar/BaseSidecar.tsx +++ b/plugins/plugin-client-common/src/components/Views/Sidecar/BaseSidecar.tsx @@ -15,7 +15,16 @@ */ import * as React from 'react' -import { isPopup, inBrowser, REPL, KResponse, Tab as KuiTab, ParsedOptions, eventChannelUnsafe } from '@kui-shell/core' +import { + isPopup, + inBrowser, + REPL, + KResponse, + Tab as KuiTab, + ParsedOptions, + eventChannelUnsafe, + CommandCompleteEvent +} from '@kui-shell/core' import Width from './width' import sameCommand from '../util/same' @@ -115,13 +124,9 @@ export abstract class BaseSidecar< } /** Enter a given `response` into the History model */ - protected onResponse( - tab: KuiTab, - response: R, - execUUID: string, - argvNoOptions: string[], - parsedOptions: ParsedOptions - ) { + protected onResponse(event: CommandCompleteEvent) { + const { tab, response, execUUID, argvNoOptions, parsedOptions } = event + this.setState(curState => { const existingIdx = curState.history ? this.lookupHistory(response, argvNoOptions, parsedOptions, cwd()) : -1 const current = diff --git a/plugins/plugin-client-common/src/components/Views/Sidecar/ComboSidecar.tsx b/plugins/plugin-client-common/src/components/Views/Sidecar/ComboSidecar.tsx index 4ec5a4f138f..1f9f03fbc38 100644 --- a/plugins/plugin-client-common/src/components/Views/Sidecar/ComboSidecar.tsx +++ b/plugins/plugin-client-common/src/components/Views/Sidecar/ComboSidecar.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react' -import { eventChannelUnsafe, Tab, NavResponse, MultiModalResponse, isMultiModalResponse } from '@kui-shell/core' +import { eventBus, Tab, NavResponse, MultiModalResponse, CommandCompleteEvent } from '@kui-shell/core' import { SidecarOptions } from './BaseSidecar' import TopNavSidecar from './TopNavSidecar' @@ -28,7 +28,7 @@ export type Props = SidecarOptions & { interface State { tab: Tab - response: MultiModalResponse | NavResponse + responseType: 'MultiModalResponse' | 'NavResponse' } export default class ComboSidecar extends React.PureComponent { @@ -37,14 +37,12 @@ export default class ComboSidecar extends React.PureComponent { this.state = { tab: undefined, - response: undefined + responseType: undefined } - const channel1 = `/command/complete/fromuser/NavResponse/${props.uuid}` - const channel2 = `/command/complete/fromuser/MultiModalResponse/${props.uuid}` const onResponse = this.onResponse.bind(this) - eventChannelUnsafe.on(channel1, onResponse) - eventChannelUnsafe.on(channel2, onResponse) + eventBus.onMultiModalResponse(props.uuid, onResponse) + eventBus.onNavResponse(props.uuid, onResponse) // this.cleaners.push(() => eventChannelUnsafe.off(channel1, onResponse)) } @@ -52,10 +50,12 @@ export default class ComboSidecar extends React.PureComponent { console.error(error, errorInfo) } - private onResponse(tab: Tab, response: MultiModalResponse | NavResponse) { + private onResponse( + event: CommandCompleteEvent + ) { this.setState({ - tab, - response + tab: event.tab, + responseType: event.responseType }) } @@ -67,11 +67,11 @@ export default class ComboSidecar extends React.PureComponent { } public render() { - const isLeftNav = this.state.response && !isMultiModalResponse(this.state.response) + const isLeftNav = this.state.responseType && this.state.responseType === 'NavResponse' return (
-
+
diff --git a/plugins/plugin-client-common/src/components/Views/Sidecar/LeftNavSidecar.tsx b/plugins/plugin-client-common/src/components/Views/Sidecar/LeftNavSidecar.tsx index b7eec5cc66a..d899f9c4aa5 100644 --- a/plugins/plugin-client-common/src/components/Views/Sidecar/LeftNavSidecar.tsx +++ b/plugins/plugin-client-common/src/components/Views/Sidecar/LeftNavSidecar.tsx @@ -17,7 +17,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import * as React from 'react' -import { eventChannelUnsafe, Tab, NavResponse, ParsedOptions } from '@kui-shell/core' +import { eventBus, Tab, NavResponse, ParsedOptions } from '@kui-shell/core' import Width from './width' import { Loading } from '../../..' @@ -50,10 +50,9 @@ export default class LeftNavSidecar extends BaseSidecar eventChannelUnsafe.off(channel, onResponse)) + eventBus.onNavResponse(this.props.uuid, onResponse) + this.cleaners.push(() => eventBus.offNavResponse(this.props.uuid, onResponse)) this.state = { repl: undefined, diff --git a/plugins/plugin-client-common/src/components/Views/Sidecar/TopNavSidecar.tsx b/plugins/plugin-client-common/src/components/Views/Sidecar/TopNavSidecar.tsx index 92e9523582c..70126c084b4 100644 --- a/plugins/plugin-client-common/src/components/Views/Sidecar/TopNavSidecar.tsx +++ b/plugins/plugin-client-common/src/components/Views/Sidecar/TopNavSidecar.tsx @@ -20,6 +20,7 @@ import sameCommand from '../util/same' import { Tabs, Tab } from 'carbon-components-react' import { + eventBus, eventChannelUnsafe, Tab as KuiTab, ParsedOptions, @@ -132,10 +133,9 @@ export default class TopNavSidecar extends BaseSidecar eventChannelUnsafe.off(channel, onResponse)) + eventBus.onMultiModalResponse(this.props.uuid, onResponse) + this.cleaners.push(() => eventBus.offMultiModalResponse(this.props.uuid, onResponse)) this.state = { repl: undefined, diff --git a/plugins/plugin-client-common/src/components/Views/Terminal/Block/Input.tsx b/plugins/plugin-client-common/src/components/Views/Terminal/Block/Input.tsx index 97007a26bc5..59dd5984aeb 100644 --- a/plugins/plugin-client-common/src/components/Views/Terminal/Block/Input.tsx +++ b/plugins/plugin-client-common/src/components/Views/Terminal/Block/Input.tsx @@ -316,7 +316,7 @@ export default class Input extends InputProvider { readOnly value={value} onKeyDown={evt => { - if (evt.key === 'C' && evt.ctrlKey) { + if (evt.key === 'c' && evt.ctrlKey) { doCancel(this.props.tab, this.props._block) } }} diff --git a/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnKeyDown.ts b/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnKeyDown.ts index d527af75818..12837587f05 100644 --- a/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnKeyDown.ts +++ b/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnKeyDown.ts @@ -85,8 +85,7 @@ export default async function onKeyDown(this: Input, event: KeyboardEvent) { if (prompt.value === '') { // <-- only if the line is blank debug('exit via ctrl+D') - const { internalBeCarefulPExec: pexec } = await import('@kui-shell/core') - pexec('exit') + tab.REPL.pexec('exit', { tab }) } } else if (char === KeyCodes.PAGEUP) { if (inBrowser()) { diff --git a/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnPaste.tsx b/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnPaste.tsx index 9cb618b63f2..bcd1dc9d89b 100644 --- a/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnPaste.tsx +++ b/plugins/plugin-client-common/src/components/Views/Terminal/Block/OnPaste.tsx @@ -29,8 +29,7 @@ export const doPaste = (text: string, tab: KuiTab, prompt: HTMLInputElement) => return pasteLooper(idx + 1) */ } else if (idx <= lines.length - 2) { // then this is a command line with a trailing newline - const { internalBeCarefulPExec: pexec } = await import('@kui-shell/core') - await pexec(prompt.value + lines[idx], { tab }) + tab.REPL.pexec(prompt.value + lines[idx], { tab }) pasteLooper(idx + 1) } else { // then this is the last line, but without a trailing newline. diff --git a/plugins/plugin-client-common/src/components/Views/Terminal/ScrollableTerminal.tsx b/plugins/plugin-client-common/src/components/Views/Terminal/ScrollableTerminal.tsx index 42a6bafc000..76cc3eb40d6 100644 --- a/plugins/plugin-client-common/src/components/Views/Terminal/ScrollableTerminal.tsx +++ b/plugins/plugin-client-common/src/components/Views/Terminal/ScrollableTerminal.tsx @@ -15,22 +15,29 @@ */ import * as React from 'react' +import { v4 as uuid } from 'uuid' + import { + eventBus, eventChannelUnsafe, - ExecOptions, ScalarResponse, Tab as KuiTab, + ExecOptions, isPopup, - CommandOptions, + CommandStartEvent, + CommandCompleteEvent, isWatchable } from '@kui-shell/core' import Block from './Block' +import getSize from './getSize' import KuiConfiguration from '../../Client/KuiConfiguration' import { Active, Finished, Cancelled, Processing, isActive, isProcessing, BlockModel } from './Block/BlockModel' type Cleaner = () => void +const MAX_SPLITS = 4 + export interface TerminalOptions { noActiveInput?: boolean } @@ -52,8 +59,48 @@ type Props = TerminalOptions & { closeSidecar: () => void } -interface State { +interface ScrollbackState { + uuid: string blocks: BlockModel[] + + /** tab facade */ + facade?: KuiTab + + /** grab a ref to the active block, to help us maintain focus */ + _activeBlock?: Block + + /** cleanup routines for this split */ + cleaners: Cleaner[] +} + +function isScrollback(tab: KuiTab): boolean { + return /_/.test(tab.uuid) +} + +interface State { + focusedIdx: number + splits: ScrollbackState[] +} + +/** Split the given tab uuid */ +export function doSplitView(tab: KuiTab) { + return new Promise((resolve, reject) => { + const uuid = isScrollback(tab) ? tab.uuid : tab.querySelector('.kui--scrollback').getAttribute('data-scrollback-id') + const requestChannel = `/kui-shell/TabContent/v1/tab/${uuid}` + setTimeout(() => eventChannelUnsafe.emit(requestChannel, resolve, reject)) + }) +} + +type SplitHandler = (resolve: (response: true) => void, reject: (err: Error) => void) => void + +function onSplit(uuid: string, handler: SplitHandler) { + const requestChannel = `/kui-shell/TabContent/v1/tab/${uuid}` + eventChannelUnsafe.on(requestChannel, handler) +} + +function offSplit(uuid: string, handler: () => void) { + const requestChannel = `/kui-shell/TabContent/v1/tab/${uuid}` + eventChannelUnsafe.off(requestChannel, handler) } /** Is the given `elm` on visible in the current viewport? */ @@ -64,34 +111,55 @@ function isInViewport(elm: HTMLElement) { } export default class ScrollableTerminal extends React.PureComponent { - private readonly cleaners: Cleaner[] = [] private _scrollRegion: HTMLDivElement - /** grab a ref to the active block, to help us maintain focus */ - private _activeBlock: Block - public constructor(props: Props) { super(props) - this.initEvents() - this.state = { - blocks: [Active()] // <-- TODO: restore from localStorage for a given tab UUID? + focusedIdx: 0, + splits: [this.scrollback()] + } + } + + private allocateUUIDForScrollback() { + if (this.props.config.splitTerminals) { + return `${this.props.uuid}_${uuid()}` + } else { + return this.props.uuid } } + private scrollback(capturedValue?: string, sbuuid = this.allocateUUIDForScrollback()): ScrollbackState { + const state = { + uuid: sbuuid, + cleaners: [], + blocks: [Active(capturedValue)] // <-- TODO: restore from localStorage for a given tab UUID? + } + + eventBus.onceWithTabId('/tab/close/request', sbuuid, async () => { + // async, to allow for e.g. command completion events to finish + // propagating to the split before we remove it + setTimeout(() => this.removeSplit(sbuuid)) + }) + + return this.initEvents(state) + } + + private get current() { + return this.state.splits[0] + } + /** Clear Terminal; TODO: also clear persisted state, when we have it */ - private clear() { + private clear(uuid: string) { if (this.props.onClear) { this.props.onClear() } - this.setState(() => { + this.splice(uuid, ({ _activeBlock }) => { // capture the value of the last input - const capturedValue = this._activeBlock ? this._activeBlock.inputValue() : '' - return { - blocks: [Active(capturedValue)] - } + const capturedValue = _activeBlock ? _activeBlock.inputValue() : '' + return this.scrollback(capturedValue, uuid) }) } @@ -105,8 +173,8 @@ export default class ScrollableTerminal extends React.PureComponent { - const idx = curState.blocks.length - 1 + // uuid might be undefined if the split is going away + if (uuid) { + this.splice(uuid, curState => { + const idx = curState.blocks.length - 1 - // Transform the last block to Processing - return { - blocks: curState.blocks.slice(0, idx).concat([Processing(curState.blocks[idx], command, execUUID)]) - } - }) + // Transform the last block to Processing + return { + blocks: curState.blocks + .slice(0, idx) + .concat([Processing(curState.blocks[idx], event.command, event.execUUID)]) + } + }) + } } /** the REPL finished executing a command */ - private onExecEnd( - { - response, - cancelled, - echo, - evaluatorOptions, - execOptions - }: { - response: ScalarResponse - cancelled: boolean - echo: boolean - evaluatorOptions: CommandOptions - execOptions: ExecOptions - }, - execUUID: string, - responseType: string - ) { - if (echo === false) { + private onExecEnd(uuid = this.current ? this.current.uuid : undefined, event: CommandCompleteEvent) { + if (event.echo === false) { // then the command wants to be incognito; e.g. onclickSilence for tables return } - this.setState(curState => { - const inProcessIdx = curState.blocks.findIndex(_ => isProcessing(_) && _.execUUID === execUUID) + if (!uuid) return + + this.splice(uuid, curState => { + const inProcessIdx = curState.blocks.findIndex(_ => isProcessing(_) && _.execUUID === event.execUUID) // response `showInTerminal` is either non-watchable response, or watch response that's forced to show in terminal const showInTerminal = !this.props.config.enableWatchPane || - !isWatchable(response) || - (isWatchable(response) && - (evaluatorOptions.alwaysViewIn === 'Terminal' || execOptions.alwaysViewIn === 'Terminal')) + !isWatchable(event.response) || + (isWatchable(event.response) && + (event.evaluatorOptions.alwaysViewIn === 'Terminal' || event.execOptions.alwaysViewIn === 'Terminal')) if (inProcessIdx >= 0) { const inProcess = curState.blocks[inProcessIdx] @@ -166,7 +225,11 @@ export default class ScrollableTerminal extends React.PureComponent { - eventChannelUnsafe.off(channel1, onExecStart) - }) - eventChannelUnsafe.on(channel1, onExecStart) + /** + * Handle CommandStart/Complete events directed at the given + * scrollback region. + * + */ + private hookIntoREPL(state: ScrollbackState) { + const onStartForSplit = this.onExecStart.bind(this, state.uuid) + const onCompleteForSplit = this.onExecEnd.bind(this, state.uuid) + eventBus.onCommandStart(state.uuid, onStartForSplit) + eventBus.onCommandComplete(state.uuid, onCompleteForSplit) + state.cleaners.push(() => eventBus.offCommandStart(state.uuid, onStartForSplit)) + state.cleaners.push(() => eventBus.offCommandComplete(state.uuid, onCompleteForSplit)) + } - const channel2 = `/command/complete/fromuser/${this.props.uuid}` - const onExecEnd = this.onExecEnd.bind(this) - this.cleaners.push(() => { - eventChannelUnsafe.off(channel2, onExecEnd) + /** + * Handle events directed at the given scrollback region. + * + */ + private initEvents(state: ScrollbackState) { + this.hookIntoREPL(state) + + const clear = this.clear.bind(this, state.uuid) + eventChannelUnsafe.on(`/terminal/clear/${state.uuid}`, clear) + state.cleaners.push(() => { + eventChannelUnsafe.off(`/terminal/clear/${state.uuid}`, clear) }) - eventChannelUnsafe.on(channel2, onExecEnd) + + if (this.props.config.splitTerminals) { + const split = this.onSplit.bind(this) + onSplit(state.uuid, split) + state.cleaners.push(() => offSplit(state.uuid, split)) + } + + return state } - private initEvents() { - this.hookIntoREPL() + /** Split the view */ + private onSplit(resolve: (response: true) => void, reject: (err: Error) => void) { + if (this.state.splits.length === MAX_SPLITS) { + reject(new Error('No more splits allowed')) + } else { + this.setState(({ splits, focusedIdx }) => { + const newFocus = focusedIdx + 1 + const newSplits = splits + .slice(0, newFocus) + .concat(this.scrollback()) + .concat(splits.slice(newFocus)) - const clear = this.clear.bind(this) - eventChannelUnsafe.on(`/terminal/clear/${this.props.uuid}`, clear) - this.cleaners.push(() => { - eventChannelUnsafe.off(`/terminal/clear/${this.props.uuid}`, clear) - }) + return { + focusedIdx: newFocus, + splits: newSplits + } + }) + resolve(true) + } } - /** Detach hooks the core's eventChannelUnsafe */ + /** Detach hooks that might have been registered */ private uninitEvents() { - this.cleaners.forEach(cleaner => cleaner()) + // clean up per-split event handlers + this.state.splits.forEach(({ cleaners }) => { + cleaners.forEach(cleaner => cleaner()) + }) } public componentWillUnmount() { this.uninitEvents() } - private onClick() { + private onClick(scrollback: ScrollbackState) { if (document.activeElement === document.body && !getSelection().toString()) { - this.doFocus() + this.doFocus(scrollback) } } + /** + * @return the index of the given scrollback, in the context of the + * current (given) state + * + */ + private findSplit(curState: State, uuid: string): number { + return curState.splits.findIndex(_ => _.uuid === uuid) + } + + /** + * Remove the given split (identified by `sbuuid`) from the state. + * + */ + private removeSplit(sbuuid: string) { + this.setState(curState => { + const idx = this.findSplit(this.state, sbuuid) + if (idx >= 0) { + const splits = curState.splits.slice(0, idx).concat(curState.splits.slice(idx + 1)) + + if (splits.length === 0) { + // the last split was removed; notify parent + const parent = this.props.tab + eventBus.emitWithTabId('/tab/close/request', parent.uuid, parent) + } + + // const focusedIdx = curState.focusedIdx !== idx ? curState.focusedIdx : idx === 0 ? idx + + return { + splits + } + } + }) + } + + /** + * Splice in an update to the given split (identified by `sbuuid`), + * using the giving ScrollbackState mutator. + * + */ + private splice(sbuuid: string, mutator: (state: ScrollbackState) => Pick) { + this.setState(curState => { + const focusedIdx = this.findSplit(curState, sbuuid) + const splits = curState.splits + .slice(0, focusedIdx) + .concat([Object.assign({}, curState.splits[focusedIdx], mutator(curState.splits[focusedIdx]))]) + .concat(curState.splits.slice(focusedIdx + 1)) + + return { + splits + } + }) + } + /** remove the block at the given index */ - private willRemoveBlock(idx: number) { - this.setState(curState => ({ + private willRemoveBlock(uuid: string, idx: number) { + this.splice(uuid, curState => ({ blocks: curState.blocks .slice(0, idx) .concat(curState.blocks.slice(idx + 1)) @@ -265,29 +415,91 @@ export default class ScrollableTerminal extends React.PureComponent { + ref.scrollTop = ref.scrollHeight + } + + scrollback.facade.addClass = (cls: string) => { + ref.classList.add(cls) + } + scrollback.facade.removeClass = (cls: string) => { + ref.classList.remove(cls) + } + } + } + + private tabFor(scrollback: ScrollbackState): KuiTab { + if (!scrollback.facade) { + const { uuid } = scrollback + let facade: KuiTab // eslint-disable-line prefer-const + const tabFacade = Object.assign({}, this.props.tab, { + REPL: Object.assign({}, this.props.tab.REPL, { + pexec: (command: string, execOptions?: ExecOptions) => { + return this.props.tab.REPL.pexec(command, Object.assign({ tab: facade }, execOptions)) + }, + qexec: ( + command: string, + b1?: HTMLElement | boolean, + b2?: boolean, + execOptions?: ExecOptions, + b3?: HTMLElement + ) => { + return this.props.tab.REPL.qexec(command, b1, b2, Object.assign({ tab: facade }, execOptions), b3) + } + }) + }) + facade = Object.assign({}, tabFacade, { uuid }) + scrollback.facade = facade + } + + return scrollback.facade + } + public render() { return (
-
(this._scrollRegion = c)} onClick={this.onClick.bind(this)}> - {this.state.blocks.map((_, idx) => ( - this.doFocus()} - ref={c => { - if (isActive(_)) { - // grab a ref to the active block, to help us maintain focus - this._activeBlock = c - } - }} - /> - ))} +
(this._scrollRegion = c)} + data-split-count={this.state.splits.length} + > + {this.state.splits.map((scrollback, sbidx) => { + const tab = this.tabFor(scrollback) + return ( +
this.tabRefFor(scrollback, ref)} + onClick={this.onClick.bind(this, scrollback)} + > + {scrollback.blocks.map((_, idx) => ( + this.doFocus(scrollback)} + ref={c => { + if (isActive(_)) { + // grab a ref to the active block, to help us maintain focus + scrollback._activeBlock = c + } + }} + /> + ))} +
+ ) + })}
) diff --git a/plugins/plugin-client-common/src/components/Views/Terminal/getSize.ts b/plugins/plugin-client-common/src/components/Views/Terminal/getSize.ts new file mode 100644 index 00000000000..1cc7be5e687 --- /dev/null +++ b/plugins/plugin-client-common/src/components/Views/Terminal/getSize.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2020 IBM Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function paddingHorizontal(elt: Element) { + const style = window.getComputedStyle(elt) + return ( + parseInt(style.getPropertyValue('padding-left') || '0', 10) + + parseInt(style.getPropertyValue('padding-right') || '0', 10) + ) +} + +function paddingVertical(elt: Element) { + const style = window.getComputedStyle(elt) + return ( + parseInt(style.getPropertyValue('padding-top') || '0', 10) + + parseInt(style.getPropertyValue('padding-bottom') || '0', 10) + ) +} + +export default function getSize(this: HTMLElement): { width: number; height: number } { + const enclosingRect = this.getBoundingClientRect() + + const selectorForWidthPad = '.repl-block .repl-output' + const widthPadElement = this.querySelector(selectorForWidthPad) + /* const heightPadElement = this */ + + const width = enclosingRect.width - paddingHorizontal(widthPadElement) + const height = enclosingRect.height - paddingVertical(this /* heightPadElement */) + + return { width, height } +} diff --git a/plugins/plugin-client-common/src/components/Views/WatchPane.tsx b/plugins/plugin-client-common/src/components/Views/WatchPane.tsx index 594b72d3f6e..5068452f375 100644 --- a/plugins/plugin-client-common/src/components/Views/WatchPane.tsx +++ b/plugins/plugin-client-common/src/components/Views/WatchPane.tsx @@ -18,7 +18,7 @@ import * as React from 'react' import { v4 as uuid } from 'uuid' import { - eventChannelUnsafe, + eventBus, ExecOptions, i18n, isWatchable, @@ -73,9 +73,8 @@ export default class WatchPane extends React.PureComponent { public constructor(props: Props) { super(props) - const channel = `/command/complete/fromuser/ScalarResponse/${this.props.uuid}` const onResponse = this.onResponse.bind(this) - eventChannelUnsafe.on(channel, onResponse) + eventBus.onScalarResponse(this.props.uuid, onResponse) this.state = { current: undefined, diff --git a/plugins/plugin-client-common/src/components/spi/Icons/impl/Carbon.tsx b/plugins/plugin-client-common/src/components/spi/Icons/impl/Carbon.tsx index 60e4d64e8de..a1e81159ee2 100644 --- a/plugins/plugin-client-common/src/components/spi/Icons/impl/Carbon.tsx +++ b/plugins/plugin-client-common/src/components/spi/Icons/impl/Carbon.tsx @@ -46,6 +46,7 @@ import { PlayFilled16 as Play, Renew16 as Retry, Edit16 as Edit, + SplitScreen20 as Split, CaretRight20 as NextPage, CaretLeft20 as PreviousPage, FlashOffFilled20 as Network @@ -76,6 +77,7 @@ const icons: Record, CarbonIconType> = { ScreenshotInProgress, Server, Settings, + Split, Trash, TerminalOnly, TerminalPlusSidecar, diff --git a/plugins/plugin-client-common/src/components/spi/Icons/impl/PatternFly.tsx b/plugins/plugin-client-common/src/components/spi/Icons/impl/PatternFly.tsx index 7082bfad0dc..1fcdf52d827 100644 --- a/plugins/plugin-client-common/src/components/spi/Icons/impl/PatternFly.tsx +++ b/plugins/plugin-client-common/src/components/spi/Icons/impl/PatternFly.tsx @@ -44,6 +44,7 @@ import { CaretRightIcon as NextPage, NetworkWiredIcon as Network, PauseCircleIcon as Pause, + GripLinesVerticalIcon as Split, RebootingIcon as Retry, PlayCircleIcon as Play } from '@patternfly/react-icons' @@ -106,6 +107,8 @@ export default function PatternFly4Icons(props: Props) { return case 'ScreenshotInProgress': return + case 'Split': + return case 'Trash': return case 'TerminalOnly': diff --git a/plugins/plugin-client-common/src/components/spi/Icons/index.tsx b/plugins/plugin-client-common/src/components/spi/Icons/index.tsx index b253c2f06ca..a3fa10a5167 100644 --- a/plugins/plugin-client-common/src/components/spi/Icons/index.tsx +++ b/plugins/plugin-client-common/src/components/spi/Icons/index.tsx @@ -42,6 +42,7 @@ export type SupportedIcon = | 'ScreenshotInProgress' | 'Server' | 'Settings' + | 'Split' | 'Trash' | 'TerminalOnly' | 'TerminalPlusSidecar' diff --git a/plugins/plugin-client-common/src/controller/confirm.ts b/plugins/plugin-client-common/src/controller/confirm.ts index 2d84a7ab866..3331ec392f8 100644 --- a/plugins/plugin-client-common/src/controller/confirm.ts +++ b/plugins/plugin-client-common/src/controller/confirm.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { eventChannelUnsafe, getTabId, Registrar, ExecType, UsageModel, i18n } from '@kui-shell/core' +import { eventChannelUnsafe, getPrimaryTabId, Registrar, ExecType, UsageModel, i18n } from '@kui-shell/core' const strings = i18n('plugin-core-support') @@ -50,7 +50,7 @@ export default async (commandTree: Registrar) => { const command = argvNoOptions[argvNoOptions.indexOf('confirm') + 1] const { execUUID } = execOptions - const requestChannel = `/kui-shell/Confirm/v1/tab/${getTabId(tab)}` + const requestChannel = `/kui-shell/Confirm/v1/tab/${getPrimaryTabId(tab)}` const responseChannel = `${requestChannel}/execUUID/${execUUID}/confirmed` const onConfirm = ({ confirmed }: { confirmed: boolean }) => { diff --git a/plugins/plugin-client-common/src/controller/split.ts b/plugins/plugin-client-common/src/controller/split.ts new file mode 100644 index 00000000000..3e86dce3779 --- /dev/null +++ b/plugins/plugin-client-common/src/controller/split.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 IBM Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Arguments, ParsedOptions, getCurrentTab } from '@kui-shell/core' + +interface Options extends ParsedOptions { + debug: boolean +} + +/** + * This plugin introduces the /split command + * + */ +export default async function split(args?: Arguments) { + if (args && args.parsedOptions.debug) { + // for debugging + return args.tab.uuid + } + + const { doSplitView } = await import('../components/Views/Terminal/ScrollableTerminal') + return doSplitView(args ? args.tab : getCurrentTab()) +} diff --git a/plugins/plugin-client-common/src/plugin.ts b/plugins/plugin-client-common/src/plugin.ts index 42ebd95046e..679698885c5 100644 --- a/plugins/plugin-client-common/src/plugin.ts +++ b/plugins/plugin-client-common/src/plugin.ts @@ -19,5 +19,6 @@ import { isHeadless, Registrar } from '@kui-shell/core' export default async (registrar: Registrar) => { if (!isHeadless()) { await import(/* webpackMode: "lazy" */ './controller/confirm').then(_ => _.default(registrar)) + await import(/* webpackMode: "lazy" */ './controller/split').then(_ => registrar.listen('/split', _.default)) } } diff --git a/plugins/plugin-client-common/web/css/static/TopTabStripe.scss b/plugins/plugin-client-common/web/css/static/TopTabStripe.scss index e4b4eb6d623..f082f8fb3eb 100644 --- a/plugins/plugin-client-common/web/css/static/TopTabStripe.scss +++ b/plugins/plugin-client-common/web/css/static/TopTabStripe.scss @@ -178,7 +178,7 @@ align-items: center; margin: 0 0.375em; - .kui--new-tab__plus { + .kui--top-tab-button { display: flex; padding: 3px; diff --git a/plugins/plugin-client-common/web/css/static/grid.scss b/plugins/plugin-client-common/web/css/static/grid.scss new file mode 100644 index 00000000000..fd91cdf65df --- /dev/null +++ b/plugins/plugin-client-common/web/css/static/grid.scss @@ -0,0 +1,17 @@ +@mixin kui-rows { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; /* notes: editor maximized horizontally overflows without this */ +} +@mixin kui-columns { + flex: 4; + display: flex; + overflow: hidden; /* notes: chrome doesn't seem to need this; otherwise, repl scrolling will cause sidecar to overflow vertically */ +} +.kui--rows { + @include kui-rows; +} +.kui--columns { + @include kui-columns; +} diff --git a/plugins/plugin-client-common/web/css/static/repl.scss b/plugins/plugin-client-common/web/css/static/repl.scss index 2ee4103d89e..467bbd5a621 100644 --- a/plugins/plugin-client-common/web/css/static/repl.scss +++ b/plugins/plugin-client-common/web/css/static/repl.scss @@ -197,7 +197,6 @@ display: flex; flex-direction: column; margin: 0; - padding-top: 0.5rem; flex: 4; background-color: var(--color-repl-background); } @@ -249,3 +248,57 @@ } } } + +/** Scrollback */ +.kui--terminal-split-container { + display: grid; + grid-gap: 2px; + overflow: hidden; + background-color: var(--color-table-border3); + margin: 0.5rem 0; +} +.kui--scrollback { + background-color: var(--color-repl-background); + padding-left: calc(1em * 0.875); + padding-right: calc(1em * 0.875 - 0.1875em - 6px); +} +.repl:not(.sidecar-visible) .kui--terminal-split-container { + &[data-split-count='1'] { + grid-template-columns: 1fr; + } + &[data-split-count='2'] { + grid-template-columns: 1fr 1fr; + } + &[data-split-count='3'] { + grid-template-columns: 1fr 1fr 1fr; + } + &[data-split-count='4'] { + grid-template-columns: 1fr 1fr 1fr 1fr; + } +} +.repl.sidecar-visible .kui--terminal-split-container { + &[data-split-count='1'] { + grid-template-rows: 1fr; + } + &[data-split-count='2'] { + grid-template-rows: 1fr 1fr; + } + &[data-split-count='3'] { + grid-template-rows: 1fr 1fr 1fr; + } + &[data-split-count='4'] { + grid-template-rows: 1fr 1fr 1fr 1fr; + } +} +/*.repl:not(.sidecar-visible) .kui--terminal-split-container { + @include kui-columns; + .kui--scrollback:not(:first-child) { + border-left: 2px solid var(--color-table-border3); + } +} +.repl.sidecar-visible .kui--terminal-split-container { + @include kui-rows; + .kui--scrollback:not(:first-child) { + border-top: 2px solid var(--color-table-border3); + } +}*/ diff --git a/plugins/plugin-client-common/web/css/static/ui.css b/plugins/plugin-client-common/web/css/static/ui.css index e00c74147e2..4a9f1baf3b7 100644 --- a/plugins/plugin-client-common/web/css/static/ui.css +++ b/plugins/plugin-client-common/web/css/static/ui.css @@ -1206,14 +1206,4 @@ body[kui-theme-style="dark"] .kui--inverted-color-context { } } -.kui--rows { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; /* notes: editor maximized horizontally overflows without this */ -} -.kui--columns { - flex: 4; - display: flex; - overflow: hidden; /* notes: chrome doesn't seem to need this; otherwise, repl scrolling will cause sidecar to overflow vertically */ -} +@import "grid"; diff --git a/plugins/plugin-client-default/src/index.tsx b/plugins/plugin-client-default/src/index.tsx index 611c11a3997..585b0b8239d 100644 --- a/plugins/plugin-client-default/src/index.tsx +++ b/plugins/plugin-client-default/src/index.tsx @@ -33,7 +33,7 @@ import { productName } from '@kui-shell/client/config.d/name.json' */ export default function renderMain(props: KuiProps) { return ( - + diff --git a/plugins/plugin-core-support/src/lib/cmds/quit.ts b/plugins/plugin-core-support/src/lib/cmds/quit.ts index ff4b9d59167..7ea656b893d 100644 --- a/plugins/plugin-core-support/src/lib/cmds/quit.ts +++ b/plugins/plugin-core-support/src/lib/cmds/quit.ts @@ -21,7 +21,7 @@ import { Arguments, Registrar } from '@kui-shell/core' -const doQuit = ({ REPL }: Arguments) => REPL.qexec('tab close') +const doQuit = ({ tab, REPL }: Arguments) => REPL.qexec('tab close', undefined, undefined, { tab }) const usage = (command: string) => ({ command, diff --git a/plugins/plugin-core-support/src/lib/cmds/tab-management.ts b/plugins/plugin-core-support/src/lib/cmds/tab-management.ts index aa489e0f8b2..406c2d7bfbe 100644 --- a/plugins/plugin-core-support/src/lib/cmds/tab-management.ts +++ b/plugins/plugin-core-support/src/lib/cmds/tab-management.ts @@ -30,7 +30,7 @@ const usage = { * */ function closeTab(tab: Tab) { - eventBus.emit('/tab/close/request', tab) + eventBus.emitWithTabId('/tab/close/request', tab.uuid, tab) return true } diff --git a/plugins/plugin-core-support/src/test/core-support/tab-management.ts b/plugins/plugin-core-support/src/test/core-support/tab-management.ts index 546060631ba..079439945b8 100644 --- a/plugins/plugin-core-support/src/test/core-support/tab-management.ts +++ b/plugins/plugin-core-support/src/test/core-support/tab-management.ts @@ -278,7 +278,7 @@ describe('core new tab from pty active tab via button click', function(this: Com it('start vi, then new tab via button click', () => CLI.command('vi', this.app) - .then(() => this.app.client.waitForExist(`${Selectors.CURRENT_TAB}.xterm-alt-buffer-mode`)) + .then(() => this.app.client.waitForExist(Selectors.ALT_BUFFER_N(1))) .then(() => this.app.client.click(tabButtonSelector)) .then(() => this.app.client.waitForVisible(Selectors.TAB_SELECTED_N(2))) .then(() => CLI.waitForSession(this)) // should have an active repl diff --git a/plugins/plugin-core-support/src/test/core-support2/split-terminals.ts b/plugins/plugin-core-support/src/test/core-support2/split-terminals.ts new file mode 100644 index 00000000000..4044adbad86 --- /dev/null +++ b/plugins/plugin-core-support/src/test/core-support2/split-terminals.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2020 IBM Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Test terminal splits + * + */ + +import { notStrictEqual } from 'assert' +import { Common, CLI, ReplExpect, Selectors } from '@kui-shell/test' + +/** Report Version */ +function version(this: Common.ISuite, splitCount: number) { + it(`should report proper version with splitCount=${splitCount}`, () => + CLI.command('version', this.app) + .then(ReplExpect.okWithCustom({ expect: Common.expectedVersion })) + .then(ReplExpect.splitCount(splitCount)) + .catch(Common.oops(this))) +} + +/** Split the terminal in the current tab by using the split button */ +function splitViaButton(this: Common.ISuite, splitCount: number) { + it(`should split the terminal via button in the current tab and expect splitCount=${splitCount}`, async () => { + try { + await this.app.client.click(Selectors.NEW_SPLIT_BUTTON) + await ReplExpect.splitCount(splitCount)(this.app) + } catch (err) { + await Common.oops(this, true)(err) + } + }) +} + +/** Split the terminal in the current tab by using the "split" command */ +function splitViaCommand(this: Common.ISuite, splitCount: number, expectErr = false) { + it(`should split the terminal via command in the current tab and expect splitCount=${splitCount}`, () => + CLI.command('split', this.app) + .then(expectErr ? ReplExpect.error(500) : ReplExpect.justOK) + .then(ReplExpect.splitCount(splitCount)) + .catch(Common.oops(this, true))) +} + +/** Close the split in the current tab by using the "exit" command */ +function close(this: Common.ISuite, splitCount: number) { + it(`should close the split via command in the current tab and expect splitCount=${splitCount}`, () => + CLI.command('exit', this.app) + .then(() => ReplExpect.splitCount(splitCount)(this.app)) + .catch(Common.oops(this, true))) +} + +/** Click to focus the given split */ +function focus(this: Common.ISuite, fromSplitIndex: number, toSplitIndex: number) { + it(`should click to focus from split ${fromSplitIndex} to split ${toSplitIndex}`, async () => { + try { + const res1 = await CLI.command( + 'split --debug', + this.app, + undefined, + undefined, + true, + Selectors.CURRENT_PROMPT_BLOCK_FOR_SPLIT(fromSplitIndex), + Selectors.CURRENT_PROMPT_FOR_SPLIT(fromSplitIndex) + ) + const N1 = res1.count + const id1 = await this.app.client.getText(Selectors.OUTPUT_N(N1)) + + console.error('1') + await this.app.client.click(Selectors.SPLIT_N(toSplitIndex)) + console.error('2') + await this.app.client.waitUntil( + () => this.app.client.hasFocus(Selectors.CURRENT_PROMPT_FOR_SPLIT(toSplitIndex)), + CLI.waitTimeout + ) + console.error('3') + + // last true: noFocus, since we want to do this ourselves + const res2 = await CLI.command( + 'split --debug', + this.app, + undefined, + undefined, + true, + Selectors.CURRENT_PROMPT_BLOCK_FOR_SPLIT(toSplitIndex), + Selectors.CURRENT_PROMPT_FOR_SPLIT(toSplitIndex) + ) + const N2 = res2.count + const id2 = await this.app.client.getText(Selectors.OUTPUT_N(N2)) + console.error('5') + + notStrictEqual(id1, id2, 'the split identifiers should differ') + } catch (err) { + await Common.oops(this, true) + } + }) +} + +describe(`split terminals ${process.env.MOCHA_RUN_TARGET || ''}`, function(this: Common.ISuite) { + before(Common.before(this)) + after(Common.after(this)) + + const showVersion = version.bind(this) + const splitTheTerminalViaButton = splitViaButton.bind(this) + const splitTheTerminalViaCommand = splitViaCommand.bind(this) + const closeTheSplit = close.bind(this) + const focusOnSplit = focus.bind(this) + + // here come the tests + showVersion(1) + splitTheTerminalViaCommand(2) + showVersion(2) + splitTheTerminalViaButton(3) + showVersion(3) + splitTheTerminalViaCommand(4) + showVersion(4) + splitTheTerminalViaCommand(4, true) + showVersion(4) + closeTheSplit(3) + showVersion(3) + closeTheSplit(2) + showVersion(2) + splitTheTerminalViaButton(3) + showVersion(3) + closeTheSplit(2) + closeTheSplit(1) + splitTheTerminalViaCommand(2) + closeTheSplit(1) + + splitTheTerminalViaCommand(2) + focusOnSplit(1, 2) + splitTheTerminalViaCommand(3) + focusOnSplit(2, 1) + focusOnSplit(1, 2) + focusOnSplit(2, 3) + /* closeTheSplit(2) + focusOnSplit(2) + focusOnSplit(1) */ +}) diff --git a/plugins/plugin-kubectl/logs/src/controller/kubectl/logs.ts b/plugins/plugin-kubectl/logs/src/controller/kubectl/logs.ts index 3baa373461a..5c5cf88b17d 100644 --- a/plugins/plugin-kubectl/logs/src/controller/kubectl/logs.ts +++ b/plugins/plugin-kubectl/logs/src/controller/kubectl/logs.ts @@ -86,9 +86,16 @@ function getOrPty(verb: string) { if (!label) { const idx = args.argvNoOptions.indexOf(verb) const name = args.argvNoOptions[idx + 1] - return args.REPL.qexec(`${cmd} get pod ${name} -n ${await getNamespace(args)} -o yaml`) + return args.REPL.qexec(`${cmd} get pod ${name} -n ${await getNamespace(args)} -o yaml`, undefined, undefined, { + tab: args.tab + }) } else { - return args.REPL.qexec(`${cmd} get pod -l ${label} -n ${await getNamespace(args)} -o json`) + return args.REPL.qexec( + `${cmd} get pod -l ${label} -n ${await getNamespace(args)} -o json`, + undefined, + undefined, + { tab: args.tab } + ) } } else { return doExecWithPty(args) diff --git a/plugins/plugin-kubectl/logs/src/test/logs/logs-dash-c.ts b/plugins/plugin-kubectl/logs/src/test/logs/logs-dash-c.ts index 8886d9a57a5..ce92600c66c 100644 --- a/plugins/plugin-kubectl/logs/src/test/logs/logs-dash-c.ts +++ b/plugins/plugin-kubectl/logs/src/test/logs/logs-dash-c.ts @@ -97,16 +97,15 @@ wdescribe(`kubectl Logs tab ${process.env.MOCHA_RUN_TARGET || ''}`, function(thi }) } - const getPodViaYaml = (podName: string) => { - it('should get pods via kubectl get -o yaml', async () => { - try { - await CLI.command(`kubectl get pods ${podName} -n ${ns} -o yaml`, this.app) - await SidecarExpect.open(this.app) - } catch (err) { - return Common.oops(this, true)(err) - } - }) - } + /* const getPodViaYaml = (podName: string) => { + it('should get pods via kubectl get -o yaml', async () => + CLI.command(`kubectl get pods ${podName} -n ${ns} -o yaml`, this.app) + .then(ReplExpect.justOK) + .then(SidecarExpect.open) + .then(SidecarExpect.showing(podName, undefined, undefined, ns)) + .then(SidecarExpect.mode(defaultModeForGet)) + .catch(Common.oops(this, true))) + } */ const testLogsContent = (show: string[], notShow?: string[]) => { if (show) { @@ -288,6 +287,7 @@ wdescribe(`kubectl Logs tab ${process.env.MOCHA_RUN_TARGET || ''}`, function(thi type: 'error' }) + /* this part isn't stable, and doesn't really test what we want, reliably: if the create is fast, then "without waiting' won't matter createPodWithoutWaiting(inputEncoded1, podName1) // recreate this pod getPodViaYaml(podName1) // NOTE: immediately open sidecar when pod is in creation @@ -302,6 +302,7 @@ wdescribe(`kubectl Logs tab ${process.env.MOCHA_RUN_TARGET || ''}`, function(thi text: 'Logs are live', type: 'info' }) + */ deleteNS(this, ns) }) diff --git a/plugins/plugin-kubectl/src/controller/kubectl/get-namespaces.ts b/plugins/plugin-kubectl/src/controller/kubectl/get-namespaces.ts index 8bfcf1327b6..b579de0dcf5 100644 --- a/plugins/plugin-kubectl/src/controller/kubectl/get-namespaces.ts +++ b/plugins/plugin-kubectl/src/controller/kubectl/get-namespaces.ts @@ -131,9 +131,10 @@ export function t2rt({ name, attributes }: Row): RadioTableRow { /** Format as RadioTable */ async function asRadioTable(args: Arguments, { header, body }: Table): Promise { + const { tab } = args const { metadata: { namespace: currentNamespace } - } = await getCurrentContext(args.tab) + } = await getCurrentContext(tab) const defaultSelectedIdx = body.findIndex(_ => _.name === currentNamespace) diff --git a/plugins/plugin-kubectl/src/lib/view/modes/ExecIntoPod.tsx b/plugins/plugin-kubectl/src/lib/view/modes/ExecIntoPod.tsx index e9d245d5798..6fb2458df3e 100644 --- a/plugins/plugin-kubectl/src/lib/view/modes/ExecIntoPod.tsx +++ b/plugins/plugin-kubectl/src/lib/view/modes/ExecIntoPod.tsx @@ -24,6 +24,7 @@ import { ToolbarProps, ToolbarText, i18n, + getPrimaryTabId, eventChannelUnsafe } from '@kui-shell/core' @@ -127,12 +128,12 @@ export class Terminal extends Container this.doFocus() this.doXon() } - const focusOnEvent = `/mode/focus/on/tab/${this.props.tab.uuid}/mode/terminal` + const focusOnEvent = `/mode/focus/on/tab/${getPrimaryTabId(this.props.tab)}/mode/terminal` eventChannelUnsafe.on(focusOnEvent, focus) this.cleaners.push(() => eventChannelUnsafe.off(focusOnEvent, focus)) const xoff = this.doXoff.bind(this) - const focusOffEvent = `/mode/focus/off/tab/${this.props.tab.uuid}/mode/terminal` + const focusOffEvent = `/mode/focus/off/tab/${getPrimaryTabId(this.props.tab)}/mode/terminal` eventChannelUnsafe.on(focusOffEvent, xoff) this.cleaners.push(() => eventChannelUnsafe.off(focusOffEvent, xoff)) @@ -391,6 +392,7 @@ export class Terminal extends Container // onExit lifecycle handlers repl .qexec(command, undefined, undefined, { + tab: this.props.tab, onInit: () => { if (this._unmounted) { return