import * as React from 'react'; import * as dateFormat from 'dateformat'; import { postConstruct, injectable, inject } from 'inversify'; import { OptionsType } from 'react-select/src/types'; import { isOSX } from '@theia/core/lib/common/os'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { Key, KeyCode } from '@theia/core/lib/browser/keys'; import { DisposableCollection } from '@theia/core/lib/common/disposable' import { ReactWidget, Message, Widget, MessageLoop } from '@theia/core/lib/browser/widgets'; import { Board, Port } from '../../common/protocol/boards-service'; import { MonitorConfig } from '../../common/protocol/monitor-service'; import { ArduinoSelect } from '../widgets/arduino-select'; import { MonitorModel } from './monitor-model'; import { MonitorConnection } from './monitor-connection'; import { MonitorServiceClientImpl } from './monitor-service-client-impl'; @injectable() export class MonitorWidget extends ReactWidget { static readonly ID = 'serial-monitor'; @inject(MonitorModel) protected readonly monitorModel: MonitorModel; @inject(MonitorConnection) protected readonly monitorConnection: MonitorConnection; @inject(MonitorServiceClientImpl) protected readonly monitorServiceClient: MonitorServiceClientImpl; protected widgetHeight: number; /** * Do not touch or use it. It is for setting the focus on the `input` after the widget activation. */ protected focusNode: HTMLElement | undefined; /** * Guard against re-rendering the view after the close was requested. * See: https://github.com/eclipse-theia/theia/issues/6704 */ protected closing = false; protected readonly clearOutputEmitter = new Emitter(); constructor() { super(); this.id = MonitorWidget.ID; this.title.label = 'Serial Monitor'; this.title.iconClass = 'arduino-serial-monitor-tab-icon'; this.title.closable = true; this.scrollOptions = undefined; this.toDispose.push(this.clearOutputEmitter); } @postConstruct() protected init(): void { this.update(); this.toDispose.push(this.monitorConnection.onConnectionChanged(() => this.clearConsole())); } clearConsole(): void { this.clearOutputEmitter.fire(undefined); this.update(); } dispose(): void { super.dispose(); } protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.monitorConnection.autoConnect = true; } onCloseRequest(msg: Message): void { this.closing = true; this.monitorConnection.autoConnect = false; if (this.monitorConnection.connected) { this.monitorConnection.disconnect(); } super.onCloseRequest(msg); } protected onUpdateRequest(msg: Message): void { // TODO: `this.isAttached` // See: https://github.com/eclipse-theia/theia/issues/6704#issuecomment-562574713 if (!this.closing && this.isAttached) { super.onUpdateRequest(msg); } } protected onResize(msg: Widget.ResizeMessage): void { super.onResize(msg); this.widgetHeight = msg.height; this.update(); } protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); (this.focusNode || this.node).focus(); } protected onFocusResolved = (element: HTMLElement | undefined) => { this.focusNode = element; requestAnimationFrame(() => MessageLoop.sendMessage(this, Widget.Msg.ActivateRequest)); } protected get lineEndings(): OptionsType> { return [ { label: 'No Line Ending', value: '' }, { label: 'New Line', value: '\n' }, { label: 'Carriage Return', value: '\r' }, { label: 'Both NL & CR', value: '\r\n' } ]; } protected get baudRates(): OptionsType> { const baudRates: Array = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200]; return baudRates.map(baudRate => ({ label: baudRate + ' baud', value: baudRate })); } protected render(): React.ReactNode { const { baudRates, lineEndings } = this; const lineEnding = lineEndings.find(item => item.value === this.monitorModel.lineEnding) || lineEndings[1]; // Defaults to `\n`. const baudRate = baudRates.find(item => item.value === this.monitorModel.baudRate) || baudRates[4]; // Defaults to `9600`. return
; } protected readonly onSend = (value: string) => this.doSend(value); protected async doSend(value: string): Promise { this.monitorConnection.send(value); } protected readonly onChangeLineEnding = (option: SelectOption) => { this.monitorModel.lineEnding = option.value; } protected readonly onChangeBaudRate = (option: SelectOption) => { this.monitorModel.baudRate = option.value; } } export namespace SerialMonitorSendInput { export interface Props { readonly monitorConfig?: MonitorConfig; readonly onSend: (text: string) => void; readonly resolveFocus: (element: HTMLElement | undefined) => void; } export interface State { text: string; } } export class SerialMonitorSendInput extends React.Component { constructor(props: Readonly) { super(props); this.state = { text: '' }; this.onChange = this.onChange.bind(this); this.onSend = this.onSend.bind(this); this.onKeyDown = this.onKeyDown.bind(this); } render(): React.ReactNode { return } protected get placeholder(): string { const { monitorConfig } = this.props; if (!monitorConfig) { return 'Not connected. Select a board and a port to connect automatically.' } const { board, port } = monitorConfig; return `Message (${isOSX ? '⌘' : 'Ctrl'}+Enter to send message to '${Board.toString(board, { useFqbn: false })}' on '${Port.toString(port)}')`; } protected setRef = (element: HTMLElement | null) => { if (this.props.resolveFocus) { this.props.resolveFocus(element || undefined); } } protected onChange(event: React.ChangeEvent): void { this.setState({ text: event.target.value }); } protected onSend(): void { this.props.onSend(this.state.text); this.setState({ text: '' }); } protected onKeyDown(event: React.KeyboardEvent): void { const keyCode = KeyCode.createKeyCode(event.nativeEvent); if (keyCode) { const { key, meta, ctrl } = keyCode; if (key === Key.ENTER && ((isOSX && meta) || (!isOSX && ctrl))) { this.onSend(); } } } } export namespace SerialMonitorOutput { export interface Props { readonly monitorModel: MonitorModel; readonly monitorConnection: MonitorConnection; readonly clearConsoleEvent: Event; } export interface State { content: string; timestamp: boolean; } } export class SerialMonitorOutput extends React.Component { /** * Do not touch it. It is used to be able to "follow" the serial monitor log. */ protected anchor: HTMLElement | null; protected toDisposeBeforeUnmount = new DisposableCollection(); constructor(props: Readonly) { super(props); this.state = { content: '', timestamp: this.props.monitorModel.timestamp }; } render(): React.ReactNode { return
{this.state.content}
{ this.anchor = element; }} /> ; } componentDidMount(): void { this.scrollToBottom(); this.toDisposeBeforeUnmount.pushAll([ this.props.monitorConnection.onRead(({ data }) => { const rawLines = data.split('\n'); const lines: string[] = [] const timestamp = () => this.state.timestamp ? `${dateFormat(new Date(), 'H:M:ss.l')} -> ` : ''; for (let i = 0; i < rawLines.length; i++) { if (i === 0 && this.state.content.length !== 0) { lines.push(rawLines[i]); } else { lines.push(timestamp() + rawLines[i]); } } const content = this.state.content + lines.join('\n'); this.setState({ content }); }), this.props.clearConsoleEvent(() => this.setState({ content: '' })), this.props.monitorModel.onChange(({ property }) => { if (property === 'timestamp') { const { timestamp } = this.props.monitorModel; this.setState({ timestamp }); } }) ]); } componentDidUpdate(): void { this.scrollToBottom(); } componentWillUnmount(): void { // TODO: "Your preferred browser's local storage is almost full." Discard `content` before saving layout? this.toDisposeBeforeUnmount.dispose(); } protected scrollToBottom(): void { if (this.props.monitorModel.autoscroll && this.anchor) { this.anchor.scrollIntoView(); } } } export interface SelectOption { readonly label: string; readonly value: T; }