diff --git a/README.md b/README.md index b5a3482..5080cca 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,14 @@ class App extends Component { render() { return ( -
+

-## Using plugins 🔥 +## Using plugins 🔥 [WIP] We have also developed a plugin system for the `` component which helps you develop custom plugins. Here is one example of plugin which creates a fake file system called [terminal-in-react-pseudo-file-system-plugin](https://github.com/jcgertig/terminal-in-react-pseudo-file-system-plugin). @@ -323,6 +330,31 @@ You can mix and match The value of the shortcut should be a command to run. + +## Override the top bar buttons actionHandlers + +Use the prop `actionHandlers`. + +The object allows for 3 methods `handleClose`, `handleMaximise`, `handleMinimise`; + +Each one is a function and will pass in the default method as the first param. +Any method not passed in will use the default. + +```jsx + { + // do something on close + toggleClose(); + }, + handleMaximise: (toggleMaximise) => { + // do something on maximise + toggleMaximise(); + } + }} +/> +``` + ## Customization Use @@ -331,6 +363,9 @@ Use * prop `backgroundColor` to change the background. * prop `barColor` to change the color of bar. * prop `prompt` to change the prompt (`>`) color. +* prop `showActions` to change if the three circles are shown. +* prop `hideTopBar` to hide the top bar altogether. +* prop `allowTabs` to allow multiple tabs. Follow me on Twitter [@NTulswani](https://twitter.com/NTulswani) for new updates and progress 😄 @@ -351,6 +386,11 @@ Follow me on Twitter [@NTulswani](https://twitter.com/NTulswani) for new updates | **commandPassThrough** | function | null | | **promptSymbol** | string | > | | **plugins** | array | [ { name: '', load: new Plugin(), commands: {} descriptions: {} } ] | +| **startState** | string ['open', 'maximised', 'minimised', 'closed'] | 'open' | +| **showActions** | bool | true | +| **hideTopBar** | bool | false | +| **allowTabs** | bool | true | +| **actionHandlers** | object | undefined | ## Built-in commands @@ -369,7 +409,7 @@ Follow me on Twitter [@NTulswani](https://twitter.com/NTulswani) for new updates ## You want a X feature -Sure! Check our [todolist](./todo.md) or create an issue and I will look into it. +Sure! Check our [todolist](./todo.md) or create an issue and I will look into it. ## Contributing diff --git a/package.json b/package.json index bd24d27..869325e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "terminal-in-react", - "version": "3.3.3", + "version": "3.4.0", "description": "A component for making a terminal in React", "main": "lib/js/index.js", "module": "src/index.js", diff --git a/src/js/components/Bar.js b/src/js/components/Bar.js index 2a767e7..54eeced 100644 --- a/src/js/components/Bar.js +++ b/src/js/components/Bar.js @@ -5,11 +5,16 @@ class Bar extends Component { static displayName = 'Bar'; static propTypes = { - style: PropTypes.object // eslint-disable-line + style: PropTypes.object, // eslint-disable-line + showActions: PropTypes.bool, + handleMinimise: PropTypes.func, + handleMaximise: PropTypes.func, + handleClose: PropTypes.func, }; static defaultProps = { style: {}, + showActions: true, }; static contextTypes = { @@ -21,45 +26,60 @@ class Bar extends Component { // Close the window handleClose = () => { - this.context.toggleShow(); + if (this.props.handleClose) { + this.props.handleClose(this.context.toggleShow); + } else { + this.context.toggleShow(); + } }; // Minimise the window handleMinimise = () => { - this.context.toggleMinimize(); + if (this.props.handleMinimise) { + this.props.handleMinimise(this.context.toggleMinimize); + } else { + this.context.toggleMinimize(); + } }; // Maximise the window handleMaximise = () => { - this.context.toggleMaximise(); + if (this.props.handleMaximise) { + this.props.handleMaximise(this.context.toggleMaximise); + } else { + this.context.toggleMaximise(); + } }; render() { + const { style, showActions } = this.props; return (
- - - - - + { showActions && ( + + + + + + ) }
); } diff --git a/src/js/components/Content.js b/src/js/components/Content.js index c262649..b038216 100644 --- a/src/js/components/Content.js +++ b/src/js/components/Content.js @@ -1,46 +1,119 @@ import React, { Component } from 'react'; // eslint-disable-line import PropTypes from 'prop-types'; +import whatkey, { unprintableKeys } from 'whatkey'; class Content extends Component { static displayName = 'Content'; static propTypes = { + id: PropTypes.string, + oldData: PropTypes.object, // eslint-disable-line backgroundColor: PropTypes.objectOf(PropTypes.string), - output: PropTypes.arrayOf(PropTypes.element), prompt: PropTypes.objectOf(PropTypes.string), inputStyles: PropTypes.objectOf(PropTypes.string), + register: PropTypes.func, handleChange: PropTypes.func, handlerKeyPress: PropTypes.func.isRequired, }; + static defaultProps = { + oldData: {}, + }; + static contextTypes = { symbol: PropTypes.string, maximise: PropTypes.bool, + instances: PropTypes.array, + activeTab: PropTypes.string, + barShowing: PropTypes.bool, + tabsShowing: PropTypes.bool, + }; + + state = { + summary: [], + promptPrefix: '', + history: [], + historyCounter: 0, + input: [], + keyInputs: [], + }; + + componentWillMount = () => { + const data = this.context.instances.find(i => i.index === this.props.id); + if (data) { + this.setState(data.oldData); + } }; componentDidMount = () => { this.focusInput(); + const data = this.context.instances.find(i => i.index === this.props.id); + this.unregister = this.props.register(this); + if (!data || Object.keys(data.oldData).length === 0) { + this.handleChange({ target: { value: 'show' }, key: 'Enter', dontShowCommand: true }); + } }; // Adjust scrolling componentDidUpdate = () => { - if (this.inputWrapper !== null) this.inputWrapper.scrollIntoView(false); + if (this.inputWrapper !== null) { + this.inputWrapper.scrollIntoView(false); + } }; + componentWillUnmount() { + this.unregister(this.state); + } + focusInput = () => { - if (this.com !== null) this.com.focus(); + if (this.com !== null) { + this.com.focus(); + } }; + handleChange = (e) => { + this.props.handleChange(this, e); + } + + handleKeyPress = (e) => { + this.props.handlerKeyPress(this, e, this.com); + } + + handleOuterKeypress = (e) => { + const key = whatkey(e).key; + const actionKeys = ['up', 'down', 'left', 'right', 'enter']; + if (unprintableKeys.indexOf(key) < 0) { + if (document.activeElement !== this.com) { + this.com.focus(); + this.com.value += whatkey(e).char; + } + } else if (actionKeys.indexOf(key) > -1) { + this.com.focus(); + } + } + render() { - const { - output, - prompt, - inputStyles, - handleChange, - backgroundColor, - handlerKeyPress, - } = this.props; - const { symbol, maximise } = this.context; + const { prompt, inputStyles, backgroundColor, id } = this.props; + const { symbol, maximise, activeTab, barShowing, tabsShowing } = this.context; + + if (id !== activeTab) { + return null; + } + + const output = this.state.summary.map((content, i) => { + if (typeof content === 'string' && content.length === 0) { + return
 
; + } + return
{content}
; + }); + + let toSubtract = 30; + if (!barShowing) { + toSubtract -= 30; + } + if (tabsShowing) { + toSubtract += 30; + } return (
@@ -61,15 +135,17 @@ class Content extends Component { className="terminal-input" ref={elm => (this.inputWrapper = elm)} > - {symbol} + + {this.state.promptPrefix + symbol} + (this.com = com)} - onKeyPress={handleChange} - onKeyDown={e => handlerKeyPress(e, this.com)} + onKeyPress={this.handleChange} + onKeyDown={this.handleKeyPress} />
diff --git a/src/js/components/Plugin.js b/src/js/components/Plugin.js index ee68a4d..e5d6c3f 100644 --- a/src/js/components/Plugin.js +++ b/src/js/components/Plugin.js @@ -1,14 +1,21 @@ export default class Plugin { - constructor(name = '', version = '1.0.0') { - this.name = name; - this.version = version; - } + static displayName = ''; + static version = '1.0.0'; + static defaultData = ''; - load = () => {}; + static commands = {}; + static descriptions = {}; - afterLoad = () => {}; + static defaultConfig = {}; - getPublicMethods = () => ({}); + constructor(api, config = Plugin.defaultConfig) { + this.api = api; + this.config = config; + this.commands = {}; + this.descriptions = {}; - readStdOut = () => true; + this.updateApi = newApi => (this.api = newApi); + this.getPublicMethods = () => ({}); + this.readStdOut = () => true; + } } diff --git a/src/js/components/Tabs.js b/src/js/components/Tabs.js new file mode 100644 index 0000000..fdec31c --- /dev/null +++ b/src/js/components/Tabs.js @@ -0,0 +1,119 @@ +import React, { Component } from 'react'; // eslint-disable-line +import PropTypes from 'prop-types'; + +function last(arr, pre = '') { + let base = arr.length > 2 ? `${arr[arr.length - 2]}` : ''; + if (base.indexOf(`${pre}> `) !== 0) { + base = 'bash'; + } + return base.replace(`${pre}> `, '').split(' ')[0]; +} + +class Tabs extends Component { + static displayName = 'Tabs'; + + static propTypes = { + style: PropTypes.object, // eslint-disable-line + active: PropTypes.string, + setActiveTab: PropTypes.func, + removeTab: PropTypes.func, + createTab: PropTypes.func, + }; + + static defaultProps = { + style: {}, + }; + + static contextTypes = { + instances: PropTypes.array, + maximise: PropTypes.bool, + }; + + state = { + showingPlus: false, + mouseOver: false, + }; + + handleBarClick = (e) => { + e.stopPropagation(); + this.props.createTab(); + }; + + // handle clicking a tab + handleTabClick = (e, index) => { + e.preventDefault(); + e.stopPropagation(); + this.props.setActiveTab(index); + }; + + // handle remove clicked + handleRemoveClick = (e, index, instance) => { + e.preventDefault(); + e.stopPropagation(); + this.props.removeTab(index, instance.props.id); + return false; + }; + + removePlus = () => { + if (this.state.showingPlus) { + this.setState({ showingPlus: false }); + } + } + + showPlus = () => { + if (!this.state.showingPlus) { + this.setState({ showingPlus: true }); + } + } + + render() { + const { showingPlus } = this.state; + const { style, active } = this.props; + const tabs = this.context.instances.map(({ index, instance }) => { + const title = (instance && instance.state) ? last(instance.state.summary, instance.state.promptPrefix) : 'bash'; + return ( +
this.handleTabClick(e, index)} + onFocus={e => this.handleTabClick(e, index)} + title={title} + tabIndex={0} + > + {this.context.instances.length > 1 && ( +
this.handleRemoveClick(e, index, instance)} + >x
+ ) } + {title} +
+ ); + }); + + return ( +
+ {tabs} +
+
+
+
+
+ ); + } +} + +export default Tabs; diff --git a/src/js/components/Terminal.js b/src/js/components/Terminal.js index a2f9df8..de7ad5a 100644 --- a/src/js/components/Terminal.js +++ b/src/js/components/Terminal.js @@ -8,21 +8,42 @@ import { handleLogging, getOs } from '../utils'; import { TerminalPropTypes, TerminalContextTypes, - TerminalDefaultProps + TerminalDefaultProps, } from './types'; import Bar from './Bar'; import Content from './Content'; +import Tabs from './Tabs'; + +function pluginMap(plugins, eachHandler) { + return plugins.map((plugin) => { + if (typeof plugin === 'function') { + plugin = { + class: plugin, + config: undefined, + }; + } + return plugin; + }).forEach(pluginObj => eachHandler(pluginObj.class, pluginObj.config)); +} + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; // eslint-disable-line no-bitwise + const v = c === 'x' ? r : (r & 0x3 | 0x8); // eslint-disable-line + return v.toString(16); + }); +} const os = getOs(); function getShortcuts(shortcuts, obj) { - Object.keys(obj).forEach(key => { + Object.keys(obj).forEach((key) => { const split = key.toLowerCase().replace(/\s/g, '').split(','); - split.forEach(osName => { + split.forEach((osName) => { if (osName === os) { shortcuts = { ...shortcuts, - ...obj[key] + ...obj[key], }; } }); @@ -30,6 +51,43 @@ function getShortcuts(shortcuts, obj) { return shortcuts; } +function modCommands(commands) { + const newCommands = {}; + + Object.keys(commands).forEach((name) => { + let needsInstance = false; + const definition = commands[name]; + let method = definition; + let parse = i => i; + if (typeof definition === 'object') { + const cmd = new Command(); + if (typeof definition.options !== 'undefined') { + try { + cmd.options(definition.options); + } catch (e) { + throw new Error('options for command wrong format'); + } + } + parse = i => + cmd.parse(i, { + name, + help: true, + version: false, + }); + method = definition.method; + needsInstance = definition.needsInstance || false; + } + + newCommands[name] = { + parse, + method, + needsInstance, + }; + }); + + return newCommands; +} + class Terminal extends Component { static displayName = 'Terminal'; @@ -42,67 +100,78 @@ class Terminal extends Component { constructor(props) { super(props); - this.pluginMethods = {}; + this.pluginData = {}; this.defaultCommands = { // eslint-disable-line react/sort-comp show: this.showMsg, - clear: this.clearScreen, - help: this.showHelp, + clear: { + method: this.clearScreen, + needsInstance: true, + }, + help: { + method: this.showHelp, + needsInstance: true, + }, echo: input => input.slice(1).join(' '), 'edit-line': { method: this.editLine, + needsInstance: true, options: [ { name: 'line', description: 'the line you want to edit. -1 is the last line', init: value => parseInt(value, 10), - defaultValue: -1 - } - ] - } + defaultValue: -1, + }, + ], + }, }; this.defaultDesciptions = { - show: 'show the msg', + show: (props.msg && props.msg.length > 0) ? 'show the msg' : false, clear: 'clear the screen', help: 'list all the commands', - echo: 'output the input', - 'edit-line': 'edit the contents of an output line' + echo: false, + 'edit-line': false, }; this.defaultShortcuts = { + 'win, linux, darwin': { + 'alt + t': this.createTab, + }, 'win, linux': { - 'ctrl + l': 'clear' + 'ctrl + l': 'clear', }, darwin: { - 'cmd + k': 'clear' - } + 'cmd + k': 'clear', + }, }; - } - state = { - prompt: '>', - promptPrefix: '', - summary: [], - commands: {}, - descriptions: {}, - history: [], - historyCounter: 0, - show: true, - minimise: false, - maximise: false, - input: [], - shortcuts: {}, - keyInputs: [] - }; + this.state = { + prompt: '>', + commands: {}, + descriptions: {}, + show: props.startState !== 'closed', + minimise: props.startState === 'minimised', + maximise: props.startState === 'maximised', + shortcuts: {}, + activeTab: '', + tabs: [], + instances: [], + }; + } getChildContext() { return { - symbol: this.state.promptPrefix + this.state.prompt, + instances: this.state.instances, + symbol: this.state.prompt, show: this.state.show, minimise: this.state.minimise, maximise: this.state.maximise, + activeTab: this.state.activeTab, + barShowing: !this.props.hideTopBar, + tabsShowing: this.props.allowTabs, openWindow: this.setTrue('show'), closeWindow: this.setFalse('show'), minimiseWindow: this.setTrue('minimise'), @@ -111,28 +180,63 @@ class Terminal extends Component { unmaximiseWindow: this.setFalse('maximise'), toggleShow: this.toggleState('show'), toggleMaximise: this.toggleState('maximise'), - toggleMinimize: this.toggleState('minimise') + toggleMinimize: this.toggleState('minimise'), }; } // Prepare the symbol componentWillMount = () => { - this.setState({ prompt: this.props.promptSymbol }); - }; - - // Load everything! - componentDidMount = () => { this.loadPlugins(); this.assembleCommands(); this.setDescriptions(); this.setShortcuts(); - this.showMsg(); + this.createTab(); + this.setState({ prompt: this.props.promptSymbol }); + }; + + // Load everything! + componentDidMount = () => { if (this.props.watchConsoleLogging) { this.watchConsoleLogging(); } }; + // Tab creation + createTab = () => { + const { color, backgroundColor, prompt, allowTabs } = this.props; + if (allowTabs) { + const { tabs } = this.state; + const id = uuidv4(); + + const inputStyles = { backgroundColor, color }; + const promptStyles = { color: prompt }; + const backgroundColorStyles = { backgroundColor }; + + tabs.push(( + this.registerInstance(id, ...args)} + /> + )); + + this.setState({ activeTab: id, tabs }); + } + } + + // Tab removal + removeTab = (index) => { + const { tabs } = this.state; + tabs.splice(index, 1); + this.setState({ tabs }); + } + // Show the content on toggling getAppContent = () => { const { show, minimise } = this.state; @@ -147,49 +251,55 @@ class Terminal extends Component { // Shows the full window (normal window) getContent = () => { - const { backgroundColor, color, style, barColor, prompt } = this.props; + const { + color, + style, + barColor, + showActions, + hideTopBar, + allowTabs, + actionHandlers, + } = this.props; + const { activeTab, tabs } = this.state; - const inputStyles = { backgroundColor, color }; - const promptStyles = { color: prompt }; const barColorStyles = { backgroundColor: barColor }; - const backgroundColorStyles = { backgroundColor }; - - const output = this.state.summary.map((content, i) => { - if (typeof content === 'string' && content.length === 0) { - return
 
; - } - return
{content}
; - }); return (
- - + {!hideTopBar && ( + + )} + {allowTabs && ( + + )} + {tabs}
); }; // Show only bar (minimise) getBar = () => { - const { color, barColor, style } = this.props; + const { color, barColor, style, showActions, actionHandlers } = this.props; const barColorStyles = { backgroundColor: barColor }; return (
- +
); }; // Show msg (on window close) - getNote = () => + getNote = () => (

OOPS! You closed the window.

Click on the icon to reopen. -
; + + ); + + // Plugin data getter + getPluginData = name => this.pluginData[name]; + + // Plugin data setter + setPluginData = (name, data) => (this.pluginData[name] = data); // Set descriptions of the commands setDescriptions = () => { let descriptions = { ...this.defaultDesciptions, - ...this.props.descriptions + ...this.props.descriptions, }; - this.props.plugins.forEach(plugin => { + pluginMap(this.props.plugins, (plugin) => { if (plugin.descriptions) { descriptions = { ...descriptions, - ...plugin.descriptions + ...plugin.descriptions, }; } }); @@ -226,8 +343,14 @@ class Terminal extends Component { this.setState({ shortcuts }); }; - setPromptPrefix = promptPrefix => { - this.setState({ promptPrefix }); + // Setter to change the prefix of the input prompt + setPromptPrefix = (instance, promptPrefix) => { + instance.setState({ promptPrefix }); + }; + + // Set the currently active tab + setActiveTab = (activeTab) => { + this.setState({ activeTab }); }; // Hide window @@ -240,74 +363,111 @@ class Terminal extends Component { * set the input value with the possible history value * @param {number} next position on the history */ - setValueWithHistory = (position, inputRef) => { - const { history } = this.state; + setValueWithHistory = (instance, position, inputRef) => { + const { history } = instance.state; if (history[position]) { - this.setState({ historyCounter: position }); + instance.setState({ historyCounter: position }); inputRef.value = history[position]; } }; + // Used to keep track of all instances + registerInstance = (index, instance) => { + const { instances } = this.state; + const pluginInstances = {}; + const pluginMethods = {}; + + const old = instances.find(i => i.index === index); + + pluginMap(this.props.plugins, (PluginClass, config) => { + try { + const api = { + printLine: this.printLine.bind(this, instance), + removeLine: this.removeLine.bind(this, instance), + runCommand: this.runCommand.bind(this, instance), + setPromptPrefix: this.setPromptPrefix.bind(this, instance), + getPluginMethod: this.getPluginMethod.bind(this, instance), + getData: () => this.getPluginData(PluginClass.displayName), + setData: data => this.setPluginData(PluginClass.displayName, data), + os, + }; + + let plugin; + if (old) { + old.pluginInstances[PluginClass.displayName].updateApi(api); + } else { + plugin = new PluginClass(api, config); + pluginMethods[PluginClass.displayName] = { + ...plugin.getPublicMethods(), + _getName: () => PluginClass.displayName, + _getVersion: () => PluginClass.version, + }; + } + + pluginInstances[PluginClass.displayName] = plugin; + } catch (e) { + console.error(`Error instantiating plugin ${PluginClass.displayName}`, e); // eslint-disable-line no-console + } + }); + + const data = { + index, + instance, + pluginMethods: old ? old.pluginMethods : pluginMethods, + pluginInstances: old ? old.pluginInstances : pluginInstances, + }; + + if (old) { + const realIndex = instances.indexOf(old); + instances[realIndex] = data; + } else { + instances.push(data); + } + + this.setState({ instances }); + + return () => { + const insts = this.state.instances; + this.setState({ + instances: insts.filter(i => !isEqual(i.instance, instance)), + }); + }; + } + + // Toggle a state boolean toggleState = name => () => this.setState({ [name]: !this.state[name] }); // Prepare the built-in commands assembleCommands = () => { let commands = { ...this.defaultCommands, - ...this.props.commands + ...this.props.commands, }; - this.props.plugins.forEach(plugin => { + pluginMap(this.props.plugins, (plugin) => { if (plugin.commands) { commands = { ...commands, - ...plugin.commands + ...plugin.commands, }; } }); - Object.keys(commands).forEach(name => { - const definition = commands[name]; - let method = definition; - let parse = i => i; - if (typeof definition === 'object') { - const cmd = new Command(); - if (typeof definition.options !== 'undefined') { - try { - cmd.options(definition.options); - } catch (e) { - throw new Error('options for command wrong format'); - } - } - parse = i => - cmd.parse(i, { - name, - help: true, - version: false - }); - method = definition.method; - } - - commands[name] = { - parse, - method - }; - }); - this.setState({ commands }); + this.setState({ commands: modCommands(commands) }); }; /** * autocomplete with the command the have the best match * @param {object} input reference */ - autocompleteValue = inputRef => { + autocompleteValue = (inputRef) => { const { descriptions } = this.state; const keysToCheck = Object.keys(descriptions).filter( - key => descriptions[key] !== false + key => descriptions[key] !== false, ); const { bestMatch } = stringSimilarity.findBestMatch( inputRef.value, - keysToCheck + keysToCheck, ); if (bestMatch.rating >= 0.5) { @@ -318,15 +478,15 @@ class Terminal extends Component { }; // Refresh or clear the screen - clearScreen = () => { - this.setState({ summary: [] }); + clearScreen = (args, printLine, runCommand, instance) => { + instance.setState({ summary: [] }); }; // Method to check for shortcut and invoking commands - checkShortcuts = key => { + checkShortcuts = (instance, key, e) => { const shortcuts = Object.keys(this.state.shortcuts); if (shortcuts.length > 0) { - const { keyInputs } = this.state; + const { keyInputs } = instance.state; let modKey = key; if (key === 'meta') { // eslint-disable-next-line no-nested-ternary @@ -343,61 +503,68 @@ class Terminal extends Component { if (options.length > 0) { if (options.length === 1 && options[0][0].length === len) { const shortcut = shortcuts[options[0][1]]; - this.runCommand(this.state.shortcuts[shortcut]); - this.setState({ keyInputs: [] }); + const action = this.state.shortcuts[shortcut]; + if (typeof action === 'string') { + this.runCommand(instance, this.state.shortcuts[shortcut]); + } else if (typeof action === 'function') { + e.preventDefault(); + e.stopPropagation(); + action(); + } + instance.setState({ keyInputs: [] }); } } else if (keyInputs.length > 0) { - this.setState({ keyInputs: [] }); + instance.setState({ keyInputs: [] }); } } }; // edit-line command - editLine = args => { - const { summary } = this.state; + editLine = (args, printLine, runCommand, instance) => { + const { summary } = instance.state; let index = args.line; - if (index === -1) { - index = summary.length === 0 ? 0 : summary.length - 1; + if (index < 0) { + index = summary.length === 0 ? 0 : summary.length - index; } summary[index] = args._.join(' '); - this.setState({ summary }); + instance.setState({ summary }); }; // Listen for user input - handleChange = e => { + handleChange = (instance, e) => { + const { input, promptPrefix, history } = instance.state; if (e.key === 'Enter' && !e.shiftKey) { - this.printLine( - `${this.state.promptPrefix}${this.state.prompt} ${e.target.value}`, - false - ); - const { input } = this.state; + if (typeof e.dontShowCommand === 'undefined') { + this.printLine.bind(this, instance)( + `${promptPrefix}${this.state.prompt} ${e.target.value}`, + false, + ); + } - const res = this.runCommand( - `${input.join('\n')}${input.length > 0 ? '\n' : ''}${e.target.value}` - ); + input.push(e.target.value); + const res = this.runCommand(instance, `${input.join('\n')}`); if (typeof res !== 'undefined') { - this.printLine(res); + this.printLine.bind(this, instance)(res); } - const history = [...this.state.history, e.target.value]; - this.setState({ + const newHistory = [...history, e.target.value]; + instance.setState({ input: [], - history, - historyCounter: history.length + history: newHistory, + historyCounter: newHistory.length, }); e.target.value = ''; // eslint-disable-line no-param-reassign } else if (e.key === 'Enter' && e.shiftKey) { - this.printLine( - `${this.state.promptPrefix}${this.state.prompt} ${e.target.value}`, - false + this.printLine.bind(this, instance)( + `${promptPrefix}${this.state.prompt} ${e.target.value}`, + false, ); - const { input } = this.state; - const history = [...this.state.history, e.target.value]; - this.setState({ + const newHistory = [...history, e.target.value]; + instance.setState({ input: [...input, e.target.value], - history, - historyCounter: history.length + history: newHistory, + historyCounter: newHistory.length, }); e.target.value = ''; // eslint-disable-line no-param-reassign } @@ -408,16 +575,16 @@ class Terminal extends Component { * with the history * @param {event} event of input */ - handlerKeyPress = (e, inputRef) => { + handlerKeyPress = (instance, e, inputRef) => { const key = whatkey(e).key; - const { historyCounter, keyInputs } = this.state; + const { historyCounter, keyInputs } = instance.state; if (keyInputs.length === 0) { switch (key) { case 'up': - this.setValueWithHistory(historyCounter - 1, inputRef); + this.setValueWithHistory(instance, historyCounter - 1, inputRef); break; case 'down': - this.setValueWithHistory(historyCounter + 1, inputRef); + this.setValueWithHistory(instance, historyCounter + 1, inputRef); break; case 'tab': inputRef.value = this.autocompleteValue(inputRef); @@ -427,83 +594,88 @@ class Terminal extends Component { break; } } - this.checkShortcuts(key); - }; + this.checkShortcuts(instance, key, e); + } // Plugins loadPlugins = () => { - if (this.props.plugins) { - this.props.plugins.forEach(plugin => { - try { - plugin.load({ - printLine: this.printLine, - runCommand: this.runCommand, - setPromptPrefix: this.setPromptPrefix, - getPluginMethod: this.getPluginMethod - }); - - this.pluginMethods[plugin.name] = { - ...plugin.getPublicMethods(), - _getName: () => plugin.name, - _getVersion: () => plugin.version - }; - } catch (e) { - console.error(`Error loading plugin ${plugin.name}`); // eslint-disable-line no-console - console.dir(e); - } - }); - - this.props.plugins.forEach(plugin => { - try { - plugin.afterLoad(); - } catch (e) { - // Do nothing - } - }); - } + const pluginData = {}; + pluginMap(this.props.plugins, (plugin) => { + try { + pluginData[plugin.displayName] = plugin.defaultData; + } catch (e) { + console.error(`Error loading plugin ${plugin.displayName}`, e); // eslint-disable-line no-console + } + }); + this.pluginData = pluginData; }; // Plugin api method to get a public plugin method - getPluginMethod = (name, method) => { - if (this.pluginMethods[name]) { - if (this.pluginMethods[name][method]) { - return this.pluginMethods[name][method]; + getPluginMethod = (instance, name, method) => { + const instanceData = this.state.instances.find(i => isEqual(i.instance, instance)); + if (instanceData) { + if (instanceData.pluginMethods[name]) { + if (instanceData.pluginMethods[name][method]) { + return instanceData.pluginMethods[name][method]; + } + console.log(instanceData.pluginMethods[name]); + throw new Error( + `No method with name ${method} has been registered for plugin ${name}`, + ); + } else { + throw new Error(`No plugin with name ${name} has been registered`); } - throw new Error( - `No method with name ${name} has been registered for plugin ${name}` - ); - } else { - throw new Error(`No plugin with name ${name} has been registered`); } + return null; }; // Print the summary (input -> output) - printLine = (inp, std = true) => { + printLine = (instance, inp, std = true) => { let print = true; if (std) { - const { plugins } = this.props; - for (let i = 0; i < plugins.length; i += 1) { - try { - print = plugins[i].readStdOut(inp); - } catch (e) { - // Do nothing + const instanceData = this.state.instances.find(i => isEqual(i.instance, instance)); + if (instanceData) { + const plugins = instanceData.pluginInstances; + for (let i = 0; i < plugins.length; i += 1) { + try { + print = plugins[i].readStdOut(inp); + } catch (e) { + // Do nothing + } } } } if (print !== false) { - const summary = this.state.summary; + const summary = instance.state.summary; summary.push(inp); - this.setState({ summary }); + instance.setState({ summary }); } }; + // Remove a line from the summary + removeLine = (instance, lineNumber = -1) => { + const summary = instance.state.summary; + summary.splice(lineNumber, 1); + instance.setState({ summary }); + } + // Execute the commands - runCommand = inputText => { + runCommand = (instance, inputText) => { const inputArray = inputText.split(' '); const input = inputArray[0]; const args = inputArray; // Undefined for function call - const command = this.state.commands[input]; + const instanceData = this.state.instances.find(i => isEqual(i.instance, instance)); + let commands = { ...this.state.commands }; + if (instanceData) { + Object.values(instanceData.pluginInstances).forEach((i) => { + commands = { + ...commands, + ...modCommands(i.commands), + }; + }); + } + const command = commands[input]; let res; if (input === '') { @@ -512,47 +684,75 @@ class Terminal extends Component { if (typeof this.props.commandPassThrough === 'function') { res = this.props.commandPassThrough( inputArray, - this.printLine, - this.runCommand + this.printLine.bind(this, instance), + this.runCommand.bind(this, instance), ); } else { - this.printLine(`-bash:${input}: command not found`); + this.printLine.bind(this, instance)(`-bash:${input}: command not found`); } } else { const parsedArgs = command.parse(args); - if ( - typeof parsedArgs !== 'object' || - (typeof parsedArgs === 'object' && !parsedArgs.help) - ) { - res = command.method(parsedArgs, this.printLine, this.runCommand); + const type = typeof parsedArgs; + if (type !== 'object' || (type === 'object' && !parsedArgs.help)) { + res = command.method( + parsedArgs, + this.printLine.bind(this, instance), + this.runCommand.bind(this, instance), + command.needsInstance === true ? instance : undefined, + ); } } return res; }; + // Print to active instance + printToActive = (...args) => { + const data = this.state.instances.find(i => i.index === this.state.activeTab); + if (data && data.instance !== null) { + this.printLine(data.instance, ...args); + } + } + // Listen for console logging and pass the input to handler (handleLogging) watchConsoleLogging = () => { - handleLogging('log', this.printLine); - handleLogging('info', this.printLine); - // handleLogging('warn', this.printLine); - // handleLogging('error', this.printLine); + handleLogging('log', this.printToActive); + handleLogging('info', this.printToActive); + // handleLogging('warn', this.printToActive); + // handleLogging('error', this.printToActive); }; // List all the commands (state + user defined) - showHelp = () => { - const options = Object.keys(this.state.commands); - const descriptions = this.state.descriptions; + showHelp = (args, printLine, runCommand, instance) => { + let commands = { ...this.state.commands }; + let descriptions = { ...this.state.descriptions }; + const instanceData = this.state.instances.find(i => isEqual(i.instance, instance)); + if (instanceData) { + Object.values(instanceData.pluginInstances).forEach((i) => { + commands = { + ...commands, + ...i.commands, + }; + descriptions = { + ...descriptions, + ...i.descriptions, + }; + }); + } + const options = Object.keys(commands); + for (const option of options) { // eslint-disable-line no-restricted-syntax if (descriptions[option] !== false) { - this.printLine(`${option} - ${descriptions[option]}`); + printLine(`${option} - ${descriptions[option]}`); } } }; // Show the msg (prop msg) - showMsg = () => { - this.printLine(this.props.msg); + showMsg = (args, printLine) => { + if (this.props.msg && this.props.msg.length > 0) { + printLine(this.props.msg); + } }; render() { @@ -567,4 +767,4 @@ class Terminal extends Component { } } -export default Terminal; \ No newline at end of file +export default Terminal; diff --git a/src/js/components/types.js b/src/js/components/types.js index d64209c..598e9db 100644 --- a/src/js/components/types.js +++ b/src/js/components/types.js @@ -18,6 +18,10 @@ export const descriptionsPropType = PropTypes.objectOf(PropTypes.oneOfType([ ])); export const TerminalPropTypes = { + startState: PropTypes.oneOf(['minimised', 'maximised', 'open', 'closed']), + showActions: PropTypes.bool, + hideTopBar: PropTypes.bool, + allowTabs: PropTypes.bool, msg: PropTypes.string, color: PropTypes.string, style: PropTypes.object, // eslint-disable-line @@ -32,13 +36,19 @@ export const TerminalPropTypes = { PropTypes.bool, ]), promptSymbol: PropTypes.string, - plugins: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string.isRequired, - load: PropTypes.func, - commands: commandsPropType, - descriptions: descriptionsPropType, - })), + plugins: PropTypes.arrayOf(PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ + class: PropTypes.func, + config: PropTypes.object, + }), + ])), shortcuts: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + actionHandlers: PropTypes.shape({ + handleClose: PropTypes.func, + handleMinimise: PropTypes.func, + handleMaximise: PropTypes.func, + }), }; // shortcuts example @@ -52,6 +62,10 @@ export const TerminalPropTypes = { // } export const TerminalContextTypes = { + barShowing: PropTypes.bool, + tabsShowing: PropTypes.bool, + activeTab: PropTypes.string, + instances: PropTypes.array, symbol: PropTypes.string, show: PropTypes.bool, minimise: PropTypes.bool, @@ -68,6 +82,10 @@ export const TerminalContextTypes = { }; export const TerminalDefaultProps = { + startState: 'open', + hideTopBar: false, + allowTabs: true, + showActions: true, msg: '', color: 'green', style: {}, diff --git a/src/js/index.js b/src/js/index.js index be18306..a0922d8 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -1,8 +1,8 @@ import Terminal from './components/Terminal'; -// import BasePlugin from './components/Plugin'; +import PluginBase from './components/Plugin'; export default Terminal; -// export { -// BasePlugin, -// }; +export { + PluginBase, +}; diff --git a/src/styles/Bar.scss b/src/styles/Bar.scss index 3194f4a..c3492ab 100644 --- a/src/styles/Bar.scss +++ b/src/styles/Bar.scss @@ -3,9 +3,10 @@ max-width: 600px; transition: all 0.4s ease-out; background: black; -} - -.adjust-bar { display: block; margin: 0 auto; + + circle { + cursor: pointer; + } } diff --git a/src/styles/Tabs.scss b/src/styles/Tabs.scss new file mode 100644 index 0000000..b1d13c1 --- /dev/null +++ b/src/styles/Tabs.scss @@ -0,0 +1,82 @@ +.terminal-tab-bar { + height: 30px; + max-width: 600px; + transition: all 0.4s ease-out; + background: #222; + display: flex; + margin: 0 auto; +} + +.terminal-tab { + display: inline-block; + vertical-align: top; + height: 30px; + background-color: #333; + border-bottom: 2px solid #333; + text-align: center; + line-height: 30px; + width: 100px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 3px; + padding-right: 3px; + cursor: pointer; + white-space: pre; + position: relative; +} + +.terminal-tab-active { + border-bottom-color: #777; +} + +.terminal-tab:focus { + outline: none; +} + +.terminal-tab-close { + position: absolute; + top: 8px; + height: 13px; + line-height: 11px; + right: 3px; + font-size: 11px; + width: 13px; + text-align: center; + color: black; + cursor: pointer; + + &:hover { + color: white; + background-color: black; + border-radius: 50%; + } +} + +.terminal-tab-plus { + display: inline-block; + color: white; + border: 1px solid white; + border-radius: 2px; + width: 13px; + height: 13px; + line-height: 13px; + margin-left: 5px; + margin-top: 8px; + text-align: center; + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity 0.3s; +} + +.terminal-tab-plus-visible { + opacity: 0.7; +} + +.terminal-tab-bar-empty { + display: inline-block; + min-width: 25px; + height: 100%; + flex: 1; +} diff --git a/src/styles/Terminal.scss b/src/styles/Terminal.scss index 0da663e..f0244a8 100644 --- a/src/styles/Terminal.scss +++ b/src/styles/Terminal.scss @@ -72,6 +72,11 @@ max-height: 600px; height: 100%; overflow: scroll; + position: relative; + + &:focus { + outline: none; + } } .terminal-holder { diff --git a/src/styles/index.scss b/src/styles/index.scss index aa1d638..0ebea6b 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,2 +1,3 @@ -@import 'Bar'; -@import 'Terminal'; +@import "Bar"; +@import "Tabs"; +@import "Terminal"; diff --git a/starter/App.js b/starter/App.js index bfc41ae..d13ab92 100644 --- a/starter/App.js +++ b/starter/App.js @@ -1,14 +1,17 @@ -import React from 'react'; +import React from 'react'; // eslint-disable-line import { render } from 'react-dom'; -import PseudoFileSystem from 'terminal-in-react-pseudo-file-system-plugin'; // eslint-disable-line -// Bundle generated with npm run build:production ('../lib/js/index') or use '../components' -import Terminal from '../lib/js/index'; -import '../lib/css/index.css'; // needed to test prod +// import pseudoFileSystemPlugin from 'terminal-in-react-pseudo-file-system-plugin'; // eslint-disable-line +// import NodeEvalPlugin from 'terminal-in-react-node-eval-plugin'; // eslint-disable-line +// Bundle generated with npm run build:production ('../lib/js/index') or use '../src/js' +import Terminal from '../src/js'; +import '../src/styles/index.scss'; // (../lib/css/index.css) or '../src/styles/index.scss' + +// const FileSystemPlugin = pseudoFileSystemPlugin(); const App = () => (
{ @@ -31,18 +34,27 @@ const App = () => ( }, 100 * i); } }, - open: () => window.open('https://www.nitintulswani.surge.sh', '_blank') + open: () => window.open('http://terminal-in-react.surge.sh', '_blank'), }} descriptions={{ color: 'option for color. For eg - color red', 'type-text': 'Types out input text', - open: 'Open a website' + open: 'Open a terminal website', }} shortcuts={{ 'darwin,win,linux': { 'ctrl + a': 'echo whoo', }, }} + // plugins={[ + // FileSystemPlugin, + // { + // class: NodeEvalPlugin, + // config: { + // filesystem: FileSystemPlugin.displayName, + // }, + // }, + // ]} />
); diff --git a/todo.md b/todo.md index 94b0ac1..dd21754 100644 --- a/todo.md +++ b/todo.md @@ -5,4 +5,4 @@ - [x] Media support - [ ] Upgrades to the plugin system - [ ] Bash to js ast -- [ ] Tabs +- [x] Tabs diff --git a/webpack/webpack.dev.config.js b/webpack/webpack.dev.config.js index 6aee75b..56e6d2e 100644 --- a/webpack/webpack.dev.config.js +++ b/webpack/webpack.dev.config.js @@ -15,6 +15,10 @@ module.exports = { test: /\.css$/, use: ['style-loader', 'css-loader'], }, + { + test: /\.scss$/, + use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'], + }, ], }, target: 'web',