diff --git a/app/src/global.scss b/app/src/global.scss index 6f316ea7..8869057c 100644 --- a/app/src/global.scss +++ b/app/src/global.scss @@ -95,3 +95,7 @@ input[type=range] { &::-moz-range-track { @include track(); } &::-ms-track { @include track(); } } + +a[ngbdropdownitem] { + cursor: pointer; +} diff --git a/terminus-serial/src/api.ts b/terminus-serial/src/api.ts index 8c70482c..89823f39 100644 --- a/terminus-serial/src/api.ts +++ b/terminus-serial/src/api.ts @@ -2,7 +2,10 @@ import stripAnsi from 'strip-ansi' import { BaseSession } from 'terminus-terminal' import { SerialPort } from 'serialport' import { Logger } from 'terminus-core' -import { Subject, Observable } from 'rxjs' +import { Subject, Observable, interval } from 'rxjs' +import { debounce } from 'rxjs/operators' +import { ReadLine, createInterface as createReadline, clearLine } from 'readline' +import { PassThrough, Readable, Writable } from 'stream' export interface LoginScript { expect: string @@ -24,6 +27,7 @@ export interface SerialConnection { xany: boolean scripts?: LoginScript[] color?: string + inputMode?: InputMode } export const BAUD_RATES = [ @@ -35,6 +39,8 @@ export interface SerialPortInfo { description?: string } +export type InputMode = null | 'readline' + export class SerialSession extends BaseSession { scripts?: LoginScript[] serial: SerialPort @@ -42,17 +48,38 @@ export class SerialSession extends BaseSession { get serviceMessage$ (): Observable { return this.serviceMessage } private serviceMessage = new Subject() + private inputReadline: ReadLine + private inputPromptVisible = true + private inputReadlineInStream: Readable & Writable + private inputReadlineOutStream: Readable & Writable constructor (public connection: SerialConnection) { super() this.scripts = connection.scripts ?? [] + + this.inputReadlineInStream = new PassThrough() + this.inputReadlineOutStream = new PassThrough() + this.inputReadline = createReadline({ + input: this.inputReadlineInStream, + output: this.inputReadlineOutStream, + terminal: true, + } as any) + this.inputReadlineOutStream.on('data', data => { + if (this.connection.inputMode == 'readline') { + this.emitOutput(data) + } + }) + this.inputReadline.on('line', line => { + this.onInput(new Buffer(line + '\n')) + }) + this.output$.pipe(debounce(() => interval(500))).subscribe(() => this.onOutputSettled()) } async start (): Promise { this.open = true this.serial.on('readable', () => { - this.onData(this.serial.read()) + this.onOutput(this.serial.read()) }) this.serial.on('end', () => { @@ -66,18 +93,23 @@ export class SerialSession extends BaseSession { } write (data: Buffer): void { - if (this.serial) { - this.serial.write(data.toString()) + if (this.connection.inputMode == 'readline') { + this.inputReadlineInStream.write(data) + } else { + this.onInput(data) } } async destroy (): Promise { this.serviceMessage.complete() + this.inputReadline.close() await super.destroy() } // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function - resize (_, __) { } + resize (_, __) { + this.inputReadlineOutStream.emit('resize') + } kill (_?: string): void { this.serial.close() @@ -104,8 +136,33 @@ export class SerialSession extends BaseSession { return null } - private onData (data: Buffer) { + private onInput (data: Buffer) { + if (this.serial) { + this.serial.write(data.toString()) + } + } + + private onOutputSettled () { + if (this.connection.inputMode == 'readline' && !this.inputPromptVisible) { + this.resetInputPrompt() + } + } + + private resetInputPrompt () { + this.emitOutput(new Buffer('\r\n')) + this.inputReadline.prompt(true) + this.inputPromptVisible = true + } + + private onOutput (data: Buffer) { const dataString = data.toString() + + if (this.connection.inputMode == 'readline') { + if (this.inputPromptVisible) { + clearLine(this.inputReadlineOutStream, 0) + this.inputPromptVisible = false + } + } this.emitOutput(data) if (this.scripts) { diff --git a/terminus-serial/src/components/editConnectionModal.component.pug b/terminus-serial/src/components/editConnectionModal.component.pug index 0e47fcfd..c7f12ca5 100644 --- a/terminus-serial/src/components/editConnectionModal.component.pug +++ b/terminus-serial/src/components/editConnectionModal.component.pug @@ -11,21 +11,43 @@ [(ngModel)]='connection.name', ) - .form-group - label Path - input.form-control( - type='text', - [(ngModel)]='connection.port', - [ngbTypeahead]='portsAutocomplete', - [resultFormatter]='portsFormatter', - ) + .row + .col-6 + .form-group + label Path + input.form-control( + type='text', + [(ngModel)]='connection.port', + [ngbTypeahead]='portsAutocomplete', + [resultFormatter]='portsFormatter', + ) + + .col-6 + .form-group + label Baud Rate + select.form-control( + [(ngModel)]='connection.baudrate', + ) + option([value]='x', *ngFor='let x of baudRates') {{x}} + + .form-line + .header + .title Input mode + + .d-flex(ngbDropdown) + button.btn.btn-secondary.btn-tab-bar( + ngbDropdownToggle, + ) {{getInputModeName(connection.inputMode)}} + + div(ngbDropdownMenu) + a.d-flex.flex-column( + *ngFor='let mode of inputModes', + (click)='connection.inputMode = mode.key', + ngbDropdownItem + ) + div {{mode.name}} + .text-muted {{mode.description}} - .form-group - label Baud Rate - select.form-control( - [(ngModel)]='connection.baudrate', - ) - option([value]='x', *ngFor='let x of baudRates') {{x}} ngb-tab(id='advanced') ng-template(ngbTabTitle) Advanced diff --git a/terminus-serial/src/components/editConnectionModal.component.ts b/terminus-serial/src/components/editConnectionModal.component.ts index d4db3e95..ee139c79 100644 --- a/terminus-serial/src/components/editConnectionModal.component.ts +++ b/terminus-serial/src/components/editConnectionModal.component.ts @@ -15,6 +15,10 @@ export class EditConnectionModalComponent { connection: SerialConnection foundPorts: SerialPortInfo[] baudRates = BAUD_RATES + inputModes = [ + { key: null, name: 'Normal', description: 'Input is sent as you type' }, + { key: 'readline', name: 'Line by line', description: 'Line editor, input is sent after you press Enter' }, + ] constructor ( private modalInstance: NgbActiveModal, @@ -24,6 +28,10 @@ export class EditConnectionModalComponent { ) { } + getInputModeName (key) { + return this.inputModes.find(x => x.key === key)?.name + } + portsAutocomplete = text$ => text$.pipe(map(() => { return this.foundPorts.map(x => x.name) })) diff --git a/terminus-serial/src/components/serialSettingsTab.component.ts b/terminus-serial/src/components/serialSettingsTab.component.ts index 41dc137b..2dcce9ed 100644 --- a/terminus-serial/src/components/serialSettingsTab.component.ts +++ b/terminus-serial/src/components/serialSettingsTab.component.ts @@ -34,6 +34,7 @@ export class SerialSettingsTabComponent { xany: false, xoff: false, xon: false, + inputMode: null } const modal = this.ngbModal.open(EditConnectionModalComponent) diff --git a/terminus-serial/src/services/serial.service.ts b/terminus-serial/src/services/serial.service.ts index e0a30fd0..f8939252 100644 --- a/terminus-serial/src/services/serial.service.ts +++ b/terminus-serial/src/services/serial.service.ts @@ -114,7 +114,7 @@ export class SerialService { options.push({ name: 'Manage connections', icon: 'cog', - callback: () => this.app.openNewTab(SettingsTabComponent, { activeTab: 'serial' }), + callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'serial' }), }) options.push({ diff --git a/terminus-serial/webpack.config.js b/terminus-serial/webpack.config.js index bb0a9a95..ce0a0465 100644 --- a/terminus-serial/webpack.config.js +++ b/terminus-serial/webpack.config.js @@ -50,6 +50,8 @@ module.exports = { 'path', 'ngx-toastr', 'serialport', + 'readline', + 'stream', 'windows-process-tree/build/Release/windows_process_tree.node', /^rxjs/, /^@angular/, diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index 9dfbda60..25d9a3b9 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/terminus-ssh/src/services/ssh.service.ts @@ -383,7 +383,7 @@ export class SSHService { options.push({ name: 'Manage connections', icon: 'cog', - callback: () => this.app.openNewTab(SettingsTabComponent, { activeTab: 'ssh' }), + callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'ssh' }), }) options.push({