/*
 * 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.
 */
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
import * as React from 'react';
import { v4 as uuid } from 'uuid';
import { i18n, eventBus, eventChannelUnsafe, ExecType, isPopup, isWatchable } from '@kui-shell/core';
import Block from './Block';
import getSize from './getSize';
import { Active, Finished, Announcement, Cancelled, Processing, isActive, isOk, isProcessing, hasUUID } from './Block/BlockModel';
import '../../../../web/scss/components/Terminal/_index.scss';
const strings = i18n('plugin-client-common');
/** Hard limit on the number of Terminal splits */
const MAX_TERMINALS = 2;
/** Hard limit on the number of Pinned splits */
const MAX_PINNED = 3;
/** Remember the welcomed count in localStorage, using this key */
const NUM_WELCOMED = 'kui-shell.org/ScrollableTerminal/NumWelcomed';
function isScrollback(tab) {
    return /_/.test(tab.uuid);
}
function doSplitViewViaId(uuid, focusBlock) {
    const res = new Promise((resolve, reject) => {
        const requestChannel = `/kui-shell/TabContent/v1/tab/${uuid}`;
        setTimeout(() => eventChannelUnsafe.emit(requestChannel, resolve, reject, focusBlock));
    }).catch(err => {
        console.error('doSplitViewViaId', err);
        throw err;
    });
    return res;
}
/** Split the given tab uuid */
export function doSplitView(tab) {
    const uuid = isScrollback(tab) ? tab.uuid : tab.querySelector('.kui--scrollback').getAttribute('data-scrollback-id');
    return doSplitViewViaId(uuid);
}
function onSplit(uuid, handler) {
    const requestChannel = `/kui-shell/TabContent/v1/tab/${uuid}`;
    eventChannelUnsafe.on(requestChannel, handler);
}
function offSplit(uuid, handler) {
    const requestChannel = `/kui-shell/TabContent/v1/tab/${uuid}`;
    eventChannelUnsafe.off(requestChannel, handler);
}
/** Is the given `elm` on visible in the current viewport? */
function isInViewport(elm) {
    const rect = elm.getBoundingClientRect();
    const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
    return !(rect.bottom < 0 || rect.top - viewHeight >= 0);
}
export default class ScrollableTerminal extends React.PureComponent {
    constructor(props) {
        super(props);
        this.state = {
            focusedIdx: 0,
            splits: [this.scrollbackWithWelcome()]
        };
    }
    /** add welcome blocks at the top of scrollback */
    scrollbackWithWelcome() {
        const scrollback = this.scrollback();
        const welcomeMax = this.props.config.showWelcomeMax;
        if (this.props.config.loadingDone && welcomeMax !== undefined) {
            const welcomed = parseInt(localStorage.getItem(NUM_WELCOMED)) || 0;
            if ((welcomeMax === -1 || welcomed < welcomeMax) && this.props.config.loadingDone) {
                const announcement = this.props.config.loadingDone(this.props.tab.REPL);
                if (announcement) {
                    eventBus.emitCommandComplete({
                        tab: this.props.tab,
                        command: 'welcome',
                        argvNoOptions: ['welcome'],
                        parsedOptions: {},
                        execOptions: {},
                        execUUID: '',
                        execType: ExecType.TopLevel,
                        cancelled: false,
                        echo: true,
                        evaluatorOptions: {},
                        response: { react: announcement },
                        responseType: 'ScalarResponse'
                    });
                }
                const welcomeBlocks = !announcement
                    ? []
                    : [
                        Announcement({
                            react: this.props.config.loadingDone && (React.createElement("div", { className: "kui--repl-message kui--session-init-done" }, announcement))
                        })
                    ];
                scrollback.blocks = welcomeBlocks.concat(scrollback.blocks);
                if (welcomeMax !== -1) {
                    localStorage.setItem(NUM_WELCOMED, (welcomed + 1).toString());
                }
            }
        }
        return scrollback;
    }
    allocateUUIDForScrollback(forSplit = false) {
        if (forSplit || (this.props.config.splitTerminals && !isPopup())) {
            return `${this.props.uuid}_${uuid()}`;
        }
        else {
            return this.props.uuid;
        }
    }
    scrollback(pinBlock, capturedValue, sbuuid = this.allocateUUIDForScrollback(!!pinBlock)) {
        const state = {
            uuid: sbuuid,
            cleaners: [],
            blocks: pinBlock ? [pinBlock] : [Active(capturedValue)] // <-- TODO: restore from localStorage for a given tab UUID?
        };
        eventBus.onceWithTabId('/tab/close/request', sbuuid, () => __awaiter(this, void 0, void 0, function* () {
            // 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);
    }
    get current() {
        return this.state.splits[0];
    }
    /** Clear Terminal; TODO: also clear persisted state, when we have it */
    clear(uuid) {
        if (this.props.onClear) {
            this.props.onClear();
        }
        this.splice(uuid, ({ _activeBlock, blocks, cleaners }) => {
            cleaners.forEach(cleaner => cleaner());
            blocks.forEach(this.removeWatchableBlock);
            // capture the value of the last input
            const capturedValue = _activeBlock ? _activeBlock.inputValue() : '';
            return this.scrollback(undefined, capturedValue, uuid);
        });
    }
    /** Output.tsx finished rendering something */
    onOutputRender(scrollback) {
        setTimeout(() => scrollback.facade.scrollToBottom());
    }
    /** the REPL started executing a command */
    onExecStart(uuid = this.current ? this.current.uuid : undefined, event) {
        if (event.echo === false) {
            // then the command wants to be incognito; e.g. onclickSilence for tables
            return;
        }
        if (isPopup() && this.props.sidecarIsVisible) {
            // see https://github.com/IBM/kui/issues/4183
            this.props.closeSidecar();
        }
        // 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], event.command, event.execUUID)])
                };
            });
        }
    }
    /** Format a MarkdownResponse */
    markdown(key) {
        return {
            content: strings(key),
            contentType: 'text/markdown'
        };
    }
    /** is the number of pinned views reached the max limit? */
    hasReachedMaxPinnned() {
        return this.numPinnedSplits() === MAX_PINNED;
    }
    /**
     * When the following requirements meet, auto-pin the command block:
     * 1. user has enabled the enableWatcherAutoPin feature flag
     * 2. not in popup mode
     * 3. the scalar response is watchable, e.g. watchable table,
     * 4. the command option or exec options doesn't say alwaysViewIn Terminal,
     * i.e. crud command may always want to be displayed in terminal even though it's watchable,
     *
     */
    shouldPinTheBlock(event) {
        return (this.props.config.enableWatcherAutoPin &&
            !isPopup() &&
            isWatchable(event.response) &&
            event.evaluatorOptions.alwaysViewIn !== 'Terminal' &&
            event.execOptions.alwaysViewIn !== 'Terminal');
    }
    /** the REPL finished executing a command */
    onExecEnd(uuid = this.current ? this.current.uuid : undefined, event) {
        if (event.echo === false) {
            // then the command wants to be incognito; e.g. onclickSilence for tables
            return;
        }
        if (!uuid)
            return;
        this.splice(uuid, curState => {
            const inProcessIdx = curState.blocks.findIndex(_ => isProcessing(_) && _.execUUID === event.execUUID);
            if (inProcessIdx >= 0) {
                const inProcess = curState.blocks[inProcessIdx];
                if (isProcessing(inProcess)) {
                    try {
                        if (this.shouldPinTheBlock(event)) {
                            if (this.hasReachedMaxPinnned()) {
                                const blocks = curState.blocks
                                    .slice(0, inProcessIdx) // everything before
                                    .concat([Finished(inProcess, new Error(strings('No more pins allowed')), false)]) // tell the user that we didn't pinned the output
                                    .concat(curState.blocks.slice(inProcessIdx + 1)) // everything after
                                    .concat([Active()]); // plus a new block!
                                return {
                                    blocks
                                };
                            }
                            else {
                                const pinnedBlock = Object.assign({}, Finished(inProcess, event.responseType === 'ScalarResponse' ? event.response : true, event.cancelled), {
                                    isPinned: true
                                } /** <--- pin the block, and render the block accordingly e.g. not show timestamp, auto-gridify table */);
                                // show the response in a split view
                                doSplitViewViaId(uuid, pinnedBlock);
                                // signify that the response has been pinned in the original block
                                const blocks = curState.blocks
                                    .slice(0, inProcessIdx) // everything before
                                    .concat([Finished(inProcess, this.markdown('Output has been pinned to a watch pane'), false)]) // tell the user that we pinned the output
                                    .concat(curState.blocks.slice(inProcessIdx + 1)) // everything after
                                    .concat([Active()]); // plus a new block!
                                return {
                                    blocks
                                };
                            }
                        }
                        else {
                            const blocks = curState.blocks
                                .slice(0, inProcessIdx) // everything before
                                .concat([
                                Finished(inProcess, event.responseType === 'ScalarResponse' ? event.response : true, event.cancelled)
                            ]) // mark as finished
                                .concat(curState.blocks.slice(inProcessIdx + 1)) // everything after
                                .concat([Active()]); // plus a new block!
                            return {
                                blocks
                            };
                        }
                    }
                    catch (err) {
                        console.error('error updating state', err);
                        throw err;
                    }
                }
                else {
                    console.error('invalid state: got a command completion event for a block that is not processing', event);
                }
            }
            else if (event.cancelled) {
                // we get here if the user just types ctrl+c without having executed any command. add a new block!
                const inProcessIdx = curState.blocks.length - 1;
                const inProcess = curState.blocks[inProcessIdx];
                const blocks = curState.blocks
                    .slice(0, inProcessIdx)
                    .concat([Cancelled(inProcess)]) // mark as cancelled
                    .concat([Active()]); // plus a new block!
                return {
                    blocks
                };
            }
            else {
                console.error('invalid state: got a command completion event, but never got command start event', event);
            }
        });
    }
    /** Owner wants us to focus on the current prompt */
    doFocus(scrollback = this.current) {
        const { _activeBlock } = scrollback;
        if (this.hasPinned(scrollback)) {
            return this.doFocus(this.state.splits.find(_ => !this.hasPinned(_)));
        }
        else if (_activeBlock) {
            if (_activeBlock.state._block && isInViewport(_activeBlock.state._block)) {
                // re: isInViewport, see https://github.com/IBM/kui/issues/4739
                _activeBlock.doFocus();
            }
        }
        else {
            // a bit of a data abstraction violation; we should figure out how to solve this better
            // see https://github.com/IBM/kui/issues/3945
            const xterm = document.querySelector('textarea.xterm-helper-textarea');
            if (xterm) {
                xterm.focus();
            }
        }
    }
    /**
     * Handle CommandStart/Complete events directed at the given
     * scrollback region.
     *
     */
    hookIntoREPL(state) {
        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));
    }
    /**
     * Handle events directed at the given scrollback region.
     *
     */
    initEvents(state) {
        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);
        });
        if ((this.props.config.splitTerminals || this.props.config.enableWatcherAutoPin) && !isPopup()) {
            const split = this.onSplit.bind(this);
            onSplit(state.uuid, split);
            state.cleaners.push(() => offSplit(state.uuid, split));
        }
        return state;
    }
    numPinnedSplits() {
        return this.state.splits.reduce((N, _) => (N += _.blocks.find(_ => _.isPinned) ? 1 : 0), 0);
    }
    /** Split the view */
    onSplit(resolve, reject, pinBlock) {
        const nPinned = this.numPinnedSplits();
        const nTerminals = this.state.splits.length - nPinned;
        if (!pinBlock && nTerminals === MAX_TERMINALS) {
            reject(new Error(strings('No more splits allowed')));
        }
        else if (pinBlock && this.hasReachedMaxPinnned()) {
            reject(new Error(strings('No more pins allowed')));
        }
        else {
            this.setState(({ splits, focusedIdx }) => {
                const newFocus = focusedIdx + 1;
                const newSplits = splits
                    .slice(0, newFocus)
                    .concat(this.scrollback(pinBlock))
                    .concat(splits.slice(newFocus));
                return {
                    focusedIdx: newFocus,
                    splits: newSplits
                };
            });
            resolve(true);
        }
    }
    /** Detach hooks that might have been registered */
    uninitEvents() {
        // clean up per-split event handlers
        this.state.splits.forEach(({ cleaners }) => {
            cleaners.forEach(cleaner => cleaner());
        });
    }
    componentWillUnmount() {
        this.uninitEvents();
    }
    onClick(scrollback) {
        if (document.activeElement === document.body && !getSelection().toString()) {
            this.doFocus(scrollback);
        }
    }
    /**
     * @return the index of the given scrollback, in the context of the
     * current (given) state
     *
     */
    findSplit(curState, uuid) {
        return curState.splits.findIndex(_ => _.uuid === uuid);
    }
    /** does the scrollback has pinned blocks? */
    hasPinned(scrollback) {
        return scrollback.blocks.findIndex(block => block.isPinned) !== -1;
    }
    /**
     * @return the index of the given scrollback, in the context of the
     * current (given) state
     * Note: if theres's no unpinned scrollback matched the given state,
     * e.g. the scrollback has been removed, return 0
     *
     */
    findAvailableSplit(curState, uuid) {
        const focusedIdx = this.findSplit(curState, uuid);
        const availableSplit = focusedIdx !== -1 ? focusedIdx : 0;
        return !this.hasPinned(curState.splits[availableSplit]) ? availableSplit : 0;
    }
    // If the block has watchable response, abort the job
    removeWatchableBlock(block) {
        if (isOk(block) && isWatchable(block.response)) {
            block.response.watch.abort();
        }
    }
    /**
     * Remove the given split (identified by `sbuuid`) from the state.
     *
     */
    removeSplit(sbuuid) {
        this.setState(curState => {
            const idx = this.findSplit(this.state, sbuuid);
            if (idx >= 0) {
                curState.splits[idx].blocks.forEach(this.removeWatchableBlock);
                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);
                }
                // if there's no unpinned split remained, make a new split
                const nonPinnedSplits = splits.filter(_ => !this.hasPinned(_));
                return nonPinnedSplits.length !== 0 ? { splits } : { splits: [this.scrollback()].concat(splits) };
            }
        });
    }
    /**
     * Splice in an update to the given split (identified by `sbuuid`),
     * using the giving ScrollbackState mutator.
     *
     */
    splice(sbuuid, mutator) {
        this.setState(curState => {
            const focusedIdx = this.findAvailableSplit(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 pinned block, and show the original response in a non-pinned scrollback */
    unPinTheBlock(uuid, block) {
        // 1. remove the pin
        this.removeSplit(uuid);
        // 2. find an available block to show the unpinned response
        this.splice(uuid, curState => {
            const unPinnedBlock = Object.assign(block, { isPinned: false });
            const findOriginalBlock = curState.blocks.findIndex(_ => hasUUID(_) && hasUUID(block) && _.execUUID === block.execUUID);
            const activeBlockIdx = curState.blocks.findIndex(_ => isActive(_));
            if (findOriginalBlock !== -1) {
                return {
                    blocks: curState.blocks
                        .slice(0, findOriginalBlock) // everything before the original block
                        .concat([unPinnedBlock]) // add the unpinned block
                        .concat(curState.blocks.slice(findOriginalBlock + 1)) // everything after the original block
                };
            }
            else {
                return {
                    blocks: curState.blocks
                        .slice(0, activeBlockIdx) // everything before the active block
                        .concat([unPinnedBlock]) // add the unpinned block
                        .concat(curState.blocks.slice(activeBlockIdx)) // everything after
                };
            }
        });
    }
    /** remove the block at the given index */
    willRemoveBlock(uuid, idx) {
        this.splice(uuid, curState => {
            this.removeWatchableBlock(curState.blocks[idx]);
            return {
                blocks: curState.blocks
                    .slice(0, idx)
                    .concat(curState.blocks.slice(idx + 1))
                    .concat(curState.blocks.find(_ => isActive(_)) ? [] : [Active()]) // plus a new block, if needed
            };
        });
    }
    tabRefFor(scrollback, ref) {
        if (ref) {
            ref['facade'] = scrollback.facade;
            scrollback.facade.getSize = getSize.bind(ref);
            scrollback.facade.scrollToBottom = () => {
                ref.scrollTop = ref.scrollHeight;
            };
            scrollback.facade.addClass = (cls) => {
                ref.classList.add(cls);
            };
            scrollback.facade.removeClass = (cls) => {
                ref.classList.remove(cls);
            };
        }
    }
    tabFor(scrollback) {
        if (!scrollback.facade) {
            const { uuid } = scrollback;
            let facade; // eslint-disable-line prefer-const
            const tabFacade = Object.assign({}, this.props.tab, {
                REPL: Object.assign({}, this.props.tab.REPL, {
                    pexec: (command, execOptions) => {
                        return this.props.tab.REPL.pexec(command, Object.assign({ tab: facade }, execOptions));
                    },
                    qexec: (command, b1, b2, execOptions, b3) => {
                        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;
    }
    render() {
        const nPinned = this.numPinnedSplits();
        const nTerminals = this.state.splits.length - nPinned;
        return (React.createElement("div", { className: 'repl' + (this.props.sidecarIsVisible ? ' sidecar-visible' : ''), id: "main-repl" },
            React.createElement("div", { className: "repl-inner zoomable kui--terminal-split-container", "data-split-count": nTerminals, "data-pinned-count": nPinned === 0 ? undefined : nPinned },
                this.state.splits.map(scrollback => {
                    const tab = this.tabFor(scrollback);
                    const hasPinned = !!scrollback.blocks.find(_ => _.isPinned);
                    return React.createElement(hasPinned ? 'span' : 'div', {
                        className: 'kui--scrollback scrollable scrollable-auto',
                        'data-has-pinned': hasPinned || undefined,
                        key: tab.uuid,
                        'data-scrollback-id': tab.uuid,
                        ref: ref => this.tabRefFor(scrollback, ref),
                        onClick: this.onClick.bind(this, scrollback)
                    }, scrollback.blocks.map((_, idx) => (React.createElement(Block, { key: hasUUID(_) ? _.execUUID : idx, idx: idx, model: _, uuid: scrollback.uuid, tab: tab, noActiveInput: this.props.noActiveInput, onOutputRender: this.onOutputRender.bind(this, scrollback), willRemove: _.isPinned
                            ? this.removeSplit.bind(this, scrollback.uuid)
                            : this.willRemoveBlock.bind(this, scrollback.uuid, idx), unPin: this.unPinTheBlock.bind(this, scrollback.uuid, _), willLoseFocus: () => this.doFocus(scrollback), isPinned: _.isPinned, ref: c => {
                            if (isActive(_)) {
                                // grab a ref to the active block, to help us maintain focus
                                scrollback._activeBlock = c;
                            }
                        } }))));
                }),
                nPinned > 0 &&
                    nPinned < MAX_PINNED &&
                    Array(MAX_PINNED - nPinned)
                        .fill(undefined)
                        .map((_, idx) => (React.createElement("span", { key: `kui--pinned-blank-${idx}`, "data-has-pinned": true, className: "kui--scrollback kui--pinned-blank" }))))));
    }
}
//# sourceMappingURL=ScrollableTerminal.js.map