mirror of
https://github.com/Eugeny/tabby.git
synced 2025-10-04 22:14:55 +00:00
moved stream processing into tabby-terminal
This commit is contained in:
137
tabby-terminal/src/api/streamProcessing.ts
Normal file
137
tabby-terminal/src/api/streamProcessing.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import hexdump from 'hexer'
|
||||
import bufferReplace from 'buffer-replace'
|
||||
import colors from 'ansi-colors'
|
||||
import binstring from 'binstring'
|
||||
import { Subject, Observable, interval } from 'rxjs'
|
||||
import { debounce } from 'rxjs/operators'
|
||||
import { PassThrough, Readable, Writable } from 'stream'
|
||||
import { ReadLine, createInterface as createReadline, clearLine } from 'readline'
|
||||
|
||||
export type InputMode = null | 'readline' | 'readline-hex' // eslint-disable-line @typescript-eslint/no-type-alias
|
||||
export type OutputMode = null | 'hex' // eslint-disable-line @typescript-eslint/no-type-alias
|
||||
export type NewlineMode = null | 'cr' | 'lf' | 'crlf' // eslint-disable-line @typescript-eslint/no-type-alias
|
||||
|
||||
export interface StreamProcessingOptions {
|
||||
inputMode?: InputMode
|
||||
inputNewlines?: NewlineMode
|
||||
outputMode?: OutputMode
|
||||
outputNewlines?: NewlineMode
|
||||
}
|
||||
|
||||
export class TerminalStreamProcessor {
|
||||
get outputToSession$ (): Observable<Buffer> { return this.outputToSession }
|
||||
get outputToTerminal$ (): Observable<Buffer> { return this.outputToTerminal }
|
||||
|
||||
protected outputToSession = new Subject<Buffer>()
|
||||
protected outputToTerminal = new Subject<Buffer>()
|
||||
|
||||
private inputReadline: ReadLine
|
||||
private inputPromptVisible = true
|
||||
private inputReadlineInStream: Readable & Writable
|
||||
private inputReadlineOutStream: Readable & Writable
|
||||
|
||||
constructor (private options: StreamProcessingOptions) {
|
||||
this.inputReadlineInStream = new PassThrough()
|
||||
this.inputReadlineOutStream = new PassThrough()
|
||||
this.inputReadline = createReadline({
|
||||
input: this.inputReadlineInStream,
|
||||
output: this.inputReadlineOutStream,
|
||||
terminal: true,
|
||||
prompt: this.options.inputMode === 'readline-hex' ? 'hex> ' : '> ',
|
||||
} as any)
|
||||
this.inputReadlineOutStream.on('data', data => {
|
||||
this.outputToTerminal.next(Buffer.from(data))
|
||||
})
|
||||
this.inputReadline.on('line', line => {
|
||||
this.onTerminalInput(Buffer.from(line + '\n'))
|
||||
this.resetInputPrompt()
|
||||
})
|
||||
this.outputToTerminal$.pipe(debounce(() => interval(500))).subscribe(() => this.onOutputSettled())
|
||||
}
|
||||
|
||||
feedFromSession (data: Buffer): void {
|
||||
if (this.options.inputMode?.startsWith('readline')) {
|
||||
if (this.inputPromptVisible) {
|
||||
clearLine(this.inputReadlineOutStream, 0)
|
||||
this.inputPromptVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
data = this.replaceNewlines(data, this.options.outputNewlines)
|
||||
|
||||
if (this.options.outputMode === 'hex') {
|
||||
this.outputToTerminal.next(Buffer.concat([
|
||||
Buffer.from('\r\n'),
|
||||
Buffer.from(hexdump(data, {
|
||||
group: 1,
|
||||
gutter: 4,
|
||||
divide: colors.gray(' | '),
|
||||
emptyHuman: colors.gray('╳'),
|
||||
}).replace(/\n/g, '\r\n')),
|
||||
Buffer.from('\r\n\n'),
|
||||
]))
|
||||
} else {
|
||||
this.outputToTerminal.next(data)
|
||||
}
|
||||
}
|
||||
|
||||
feedFromTerminal (data: Buffer): void {
|
||||
if (this.options.inputMode?.startsWith('readline')) {
|
||||
this.inputReadlineInStream.write(data)
|
||||
} else {
|
||||
this.onTerminalInput(data)
|
||||
}
|
||||
}
|
||||
|
||||
resize (): void {
|
||||
this.inputReadlineOutStream.emit('resize')
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.inputReadline.close()
|
||||
this.outputToSession.complete()
|
||||
this.outputToTerminal.complete()
|
||||
}
|
||||
|
||||
private onTerminalInput (data: Buffer) {
|
||||
if (this.options.inputMode === 'readline-hex') {
|
||||
const tokens = data.toString().split(/\s/g)
|
||||
data = Buffer.concat(tokens.filter(t => !!t).map(t => {
|
||||
if (t.startsWith('0x')) {
|
||||
t = t.substring(2)
|
||||
}
|
||||
return binstring(t, { 'in': 'hex' })
|
||||
}))
|
||||
}
|
||||
|
||||
data = this.replaceNewlines(data, this.options.inputNewlines)
|
||||
this.outputToSession.next(data)
|
||||
}
|
||||
|
||||
private onOutputSettled () {
|
||||
if (this.options.inputMode?.startsWith('readline') && !this.inputPromptVisible) {
|
||||
this.resetInputPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private resetInputPrompt () {
|
||||
this.outputToTerminal.next(Buffer.from('\r\n'))
|
||||
this.inputReadline.prompt(true)
|
||||
this.inputPromptVisible = true
|
||||
}
|
||||
|
||||
private replaceNewlines (data: Buffer, mode?: NewlineMode): Buffer {
|
||||
if (!mode) {
|
||||
return data
|
||||
}
|
||||
data = bufferReplace(data, '\r\n', '\n')
|
||||
data = bufferReplace(data, '\r', '\n')
|
||||
const replacement = {
|
||||
strip: '',
|
||||
cr: '\r',
|
||||
lf: '\n',
|
||||
crlf: '\r\n',
|
||||
}[mode]
|
||||
return bufferReplace(data, '\n', replacement)
|
||||
}
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
.form-line
|
||||
.header
|
||||
.title Input mode
|
||||
|
||||
.d-flex(ngbDropdown)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
ngbDropdownToggle,
|
||||
) {{getInputModeName(options.inputMode)}}
|
||||
|
||||
div(ngbDropdownMenu)
|
||||
a.d-flex.flex-column(
|
||||
*ngFor='let mode of inputModes',
|
||||
(click)='options.inputMode = mode.key',
|
||||
ngbDropdownItem
|
||||
)
|
||||
div {{mode.name}}
|
||||
.text-muted {{mode.description}}
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Input newlines
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='options.inputNewlines',
|
||||
)
|
||||
option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Output mode
|
||||
|
||||
.d-flex(ngbDropdown)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
ngbDropdownToggle,
|
||||
) {{getOutputModeName(options.outputMode)}}
|
||||
|
||||
div(ngbDropdownMenu)
|
||||
a.d-flex.flex-column(
|
||||
*ngFor='let mode of outputModes',
|
||||
(click)='options.outputMode = mode.key',
|
||||
ngbDropdownItem
|
||||
)
|
||||
div {{mode.name}}
|
||||
.text-muted {{mode.description}}
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Output newlines
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='options.outputNewlines',
|
||||
)
|
||||
option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
|
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { StreamProcessingOptions } from '../api/streamProcessing'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'stream-processing-settings',
|
||||
template: require('./streamProcessingSettings.component.pug'),
|
||||
})
|
||||
export class StreamProcessingSettingsComponent {
|
||||
@Input() options: StreamProcessingOptions
|
||||
|
||||
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' },
|
||||
{ key: 'readline-hex', name: 'Hexadecimal', description: 'Send bytes by typing in hex values' },
|
||||
]
|
||||
outputModes = [
|
||||
{ key: null, name: 'Normal', description: 'Output is shown as it is received' },
|
||||
{ key: 'hex', name: 'Hexadecimal', description: 'Output is shown as a hexdump' },
|
||||
]
|
||||
newlineModes = [
|
||||
{ key: null, name: 'Keep' },
|
||||
{ key: 'strip', name: 'Strip' },
|
||||
{ key: 'cr', name: 'Force CR' },
|
||||
{ key: 'lf', name: 'Force LF' },
|
||||
{ key: 'crlf', name: 'Force CRLF' },
|
||||
]
|
||||
|
||||
getInputModeName (key) {
|
||||
return this.inputModes.find(x => x.key === key)?.name
|
||||
}
|
||||
|
||||
getOutputModeName (key) {
|
||||
return this.outputModes.find(x => x.key === key)?.name
|
||||
}
|
||||
}
|
@@ -13,6 +13,7 @@ import { TerminalSettingsTabComponent } from './components/terminalSettingsTab.c
|
||||
import { ColorPickerComponent } from './components/colorPicker.component'
|
||||
import { ColorSchemePreviewComponent } from './components/colorSchemePreview.component'
|
||||
import { SearchPanelComponent } from './components/searchPanel.component'
|
||||
import { StreamProcessingSettingsComponent } from './components/streamProcessingSettings.component'
|
||||
|
||||
import { TerminalFrontendService } from './services/terminalFrontend.service'
|
||||
|
||||
@@ -70,10 +71,12 @@ import { TerminalCLIHandler } from './cli'
|
||||
ColorSchemeSettingsTabComponent,
|
||||
TerminalSettingsTabComponent,
|
||||
SearchPanelComponent,
|
||||
StreamProcessingSettingsComponent,
|
||||
] as any[],
|
||||
exports: [
|
||||
ColorPickerComponent,
|
||||
SearchPanelComponent,
|
||||
StreamProcessingSettingsComponent,
|
||||
],
|
||||
})
|
||||
export default class TerminalModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
|
||||
@@ -111,4 +114,5 @@ export { TerminalFrontendService, TerminalDecorator, TerminalContextMenuItemProv
|
||||
export { Frontend, XTermFrontend, XTermWebGLFrontend, HTermFrontend }
|
||||
export { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
|
||||
export * from './api/interfaces'
|
||||
export * from './api/streamProcessing'
|
||||
export * from './session'
|
||||
|
Reference in New Issue
Block a user