diff --git a/terminus-terminal/src/api.ts b/terminus-terminal/src/api.ts index fef5fc9c..be2e6aff 100644 --- a/terminus-terminal/src/api.ts +++ b/terminus-terminal/src/api.ts @@ -1,10 +1,10 @@ -import { TerminalTabComponent } from './components/terminalTab.component' +import { BaseTerminalTabComponent } from './components/baseTerminalTab.component' export abstract class TerminalDecorator { // tslint:disable-next-line no-empty - attach (_terminal: TerminalTabComponent): void { } + attach (_terminal: BaseTerminalTabComponent): void { } // tslint:disable-next-line no-empty - detach (_terminal: TerminalTabComponent): void { } + detach (_terminal: BaseTerminalTabComponent): void { } } export interface ResizeEvent { @@ -44,7 +44,7 @@ export abstract class TerminalColorSchemeProvider { export abstract class TerminalContextMenuItemProvider { weight: number - abstract async getItems (tab: TerminalTabComponent): Promise + abstract async getItems (tab: BaseTerminalTabComponent): Promise } export interface IShell { diff --git a/terminus-terminal/src/components/baseTerminalTab.component.ts b/terminus-terminal/src/components/baseTerminalTab.component.ts new file mode 100644 index 00000000..62ff955b --- /dev/null +++ b/terminus-terminal/src/components/baseTerminalTab.component.ts @@ -0,0 +1,336 @@ +import { Observable, Subject, Subscription } from 'rxjs' +import { first } from 'rxjs/operators' +import { ToastrService } from 'ngx-toastr' +import { NgZone, OnInit, OnDestroy, Inject, Optional, ViewChild, HostBinding, Input } from '@angular/core' +import { AppService, ConfigService, BaseTabComponent, ElectronService, HostAppService, HotkeysService, Platform, LogService, Logger } from 'terminus-core' + +import { Session, SessionsService } from '../services/sessions.service' +import { TerminalFrontendService } from '../services/terminalFrontend.service' + +import { TerminalDecorator, ResizeEvent, TerminalContextMenuItemProvider } from '../api' +import { Frontend } from '../frontends/frontend' + +export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit, OnDestroy { + static template = ` +
+ ` + static styles = [require('./terminalTab.component.scss')] + + session: Session + @Input() zoom = 0 + @ViewChild('content') content + @HostBinding('style.background-color') backgroundColor: string + frontend: Frontend + sessionCloseSubscription: Subscription + hotkeysSubscription: Subscription + htermVisible = false + frontendReady = new Subject() + size: ResizeEvent + protected logger: Logger + protected output = new Subject() + private bellPlayer: HTMLAudioElement + private termContainerSubscriptions: Subscription[] = [] + + get input$ (): Observable { return this.frontend.input$ } + get output$ (): Observable { return this.output } + get resize$ (): Observable { return this.frontend.resize$ } + get alternateScreenActive$ (): Observable { return this.frontend.alternateScreenActive$ } + get frontendReady$ (): Observable { return this.frontendReady } + + constructor ( + public config: ConfigService, + protected zone: NgZone, + protected app: AppService, + protected hostApp: HostAppService, + protected hotkeys: HotkeysService, + protected sessions: SessionsService, + protected electron: ElectronService, + protected terminalContainersService: TerminalFrontendService, + protected toastr: ToastrService, + protected log: LogService, + @Optional() @Inject(TerminalDecorator) protected decorators: TerminalDecorator[], + @Optional() @Inject(TerminalContextMenuItemProvider) protected contextMenuProviders: TerminalContextMenuItemProvider[], + ) { + super() + this.logger = log.create('baseTerminalTab') + this.decorators = this.decorators || [] + this.setTitle('Terminal') + + this.session = new Session(this.config) + + this.hotkeysSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => { + if (!this.hasFocus) { + return + } + switch (hotkey) { + case 'ctrl-c': + if (this.frontend.getSelection()) { + this.frontend.copySelection() + this.frontend.clearSelection() + this.toastr.info('Copied') + } else { + this.sendInput('\x03') + } + break + case 'copy': + this.frontend.copySelection() + this.toastr.info('Copied') + break + case 'paste': + this.paste() + break + case 'clear': + this.frontend.clear() + break + case 'zoom-in': + this.zoomIn() + break + case 'zoom-out': + this.zoomOut() + break + case 'reset-zoom': + this.resetZoom() + break + case 'home': + this.sendInput('\x1bOH') + break + case 'end': + this.sendInput('\x1bOF') + break + case 'previous-word': + this.sendInput('\x1bb') + break + case 'next-word': + this.sendInput('\x1bf') + break + case 'delete-previous-word': + this.sendInput('\x1b\x7f') + break + case 'delete-next-word': + this.sendInput('\x1bd') + break + } + }) + this.bellPlayer = document.createElement('audio') + this.bellPlayer.src = require('../bell.ogg') + + this.contextMenuProviders.sort((a, b) => a.weight - b.weight) + } + + ngOnInit () { + this.focused$.subscribe(() => { + this.configure() + this.frontend.focus() + }) + + this.frontend = this.terminalContainersService.getFrontend(this.session) + + this.frontend.ready$.subscribe(() => { + this.htermVisible = true + }) + + this.frontend.resize$.pipe(first()).subscribe(async ({ columns, rows }) => { + this.size = { columns, rows } + this.frontendReady.next() + + setTimeout(() => { + this.session.resize(columns, rows) + }, 1000) + + this.session.releaseInitialDataBuffer() + }) + + this.frontend.configure(this.config.store) + this.frontend.attach(this.content.nativeElement) + this.attachTermContainerHandlers() + + this.configure() + + this.config.enabledServices(this.decorators).forEach((decorator) => { + decorator.attach(this) + }) + + setTimeout(() => { + this.output.subscribe(() => { + this.displayActivity() + }) + }, 1000) + + this.frontend.bell$.subscribe(() => { + if (this.config.store.terminal.bell === 'visual') { + this.frontend.visualBell() + } + if (this.config.store.terminal.bell === 'audible') { + this.bellPlayer.play() + } + }) + + this.frontend.focus() + } + + async buildContextMenu (): Promise { + let items: Electron.MenuItemConstructorOptions[] = [] + for (let section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this)))) { + items = items.concat(section) + items.push({ type: 'separator' }) + } + items.splice(items.length - 1, 1) + return items + } + + detachTermContainerHandlers () { + for (let subscription of this.termContainerSubscriptions) { + subscription.unsubscribe() + } + this.termContainerSubscriptions = [] + } + + attachTermContainerHandlers () { + this.detachTermContainerHandlers() + this.termContainerSubscriptions = [ + this.frontend.title$.subscribe(title => this.zone.run(() => this.setTitle(title))), + + this.focused$.subscribe(() => this.frontend.enableResizing = true), + this.blurred$.subscribe(() => this.frontend.enableResizing = false), + + this.frontend.mouseEvent$.subscribe(async event => { + if (event.type === 'mousedown') { + if (event.which === 3) { + if (this.config.store.terminal.rightClick === 'menu') { + this.hostApp.popupContextMenu(await this.buildContextMenu()) + } else if (this.config.store.terminal.rightClick === 'paste') { + this.paste() + } + event.preventDefault() + event.stopPropagation() + return + } + } + if (event.type === 'mousewheel') { + let wheelDeltaY = 0 + + if ('wheelDeltaY' in event) { + wheelDeltaY = (event as MouseWheelEvent)['wheelDeltaY'] + } else { + wheelDeltaY = (event as MouseWheelEvent)['deltaY'] + } + if (event.ctrlKey || event.metaKey) { + + if (wheelDeltaY > 0) { + this.zoomIn() + } else { + this.zoomOut() + } + } else if (event.altKey) { + event.preventDefault() + let delta = Math.round(wheelDeltaY / 50) + this.sendInput(((delta > 0) ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta))) + } + } + }), + + this.frontend.input$.subscribe(data => { + this.sendInput(data) + }), + + this.frontend.resize$.subscribe(({ columns, rows }) => { + this.logger.info(`Resizing to ${columns}x${rows}`) + this.size = { columns, rows } + this.zone.run(() => { + if (this.session.open) { + this.session.resize(columns, rows) + } + }) + }), + + this.hostApp.windowMoved$.subscribe(() => setTimeout(() => { + this.configure() + }, 250)), + ] + } + + sendInput (data: string) { + this.session.write(data) + if (this.config.store.terminal.scrollOnInput) { + this.frontend.scrollToBottom() + } + } + + write (data: string) { + let percentageMatch = /(^|[^\d])(\d+(\.\d+)?)%([^\d]|$)/.exec(data) + if (percentageMatch) { + let percentage = percentageMatch[3] ? parseFloat(percentageMatch[2]) : parseInt(percentageMatch[2]) + if (percentage > 0 && percentage <= 100) { + this.setProgress(percentage) + this.logger.debug('Detected progress:', percentage) + } + } else { + this.setProgress(null) + } + this.frontend.write(data) + } + + paste () { + let data = this.electron.clipboard.readText() + if (this.config.store.terminal.bracketedPaste) { + data = '\x1b[200~' + data + '\x1b[201~' + } + if (this.hostApp.platform === Platform.Windows) { + data = data.replace(/\r\n/g, '\r') + } else { + data = data.replace(/\n/g, '\r') + } + this.sendInput(data) + } + + configure (): void { + this.frontend.configure(this.config.store) + + if (this.config.store.terminal.background === 'colorScheme') { + if (this.config.store.terminal.colorScheme.background) { + this.backgroundColor = this.config.store.terminal.colorScheme.background + } + } else { + this.backgroundColor = null + } + } + + zoomIn () { + this.zoom++ + this.frontend.setZoom(this.zoom) + } + + zoomOut () { + this.zoom-- + this.frontend.setZoom(this.zoom) + } + + resetZoom () { + this.zoom = 0 + this.frontend.setZoom(this.zoom) + } + + ngOnDestroy () { + this.frontend.detach(this.content.nativeElement) + this.detachTermContainerHandlers() + this.config.enabledServices(this.decorators).forEach(decorator => { + decorator.detach(this) + }) + this.hotkeysSubscription.unsubscribe() + if (this.sessionCloseSubscription) { + this.sessionCloseSubscription.unsubscribe() + } + this.output.complete() + } + + async destroy () { + super.destroy() + if (this.session && this.session.open) { + await this.session.destroy() + } + } +} diff --git a/terminus-terminal/src/components/terminalTab.component.ts b/terminus-terminal/src/components/terminalTab.component.ts index 61b49aee..5789574f 100644 --- a/terminus-terminal/src/components/terminalTab.component.ts +++ b/terminus-terminal/src/components/terminalTab.component.ts @@ -1,121 +1,25 @@ -import { Observable, Subject, Subscription } from 'rxjs' +import { Component, Input } from '@angular/core' import { first } from 'rxjs/operators' -import { ToastrService } from 'ngx-toastr' -import { Component, NgZone, Inject, Optional, ViewChild, HostBinding, Input } from '@angular/core' -import { AppService, ConfigService, BaseTabComponent, BaseTabProcess, ElectronService, HostAppService, HotkeysService, Platform } from 'terminus-core' - -import { Session, SessionsService } from '../services/sessions.service' -import { TerminalFrontendService } from '../services/terminalFrontend.service' - -import { TerminalDecorator, ResizeEvent, SessionOptions, TerminalContextMenuItemProvider } from '../api' -import { Frontend } from '../frontends/frontend' +import { BaseTabProcess } from 'terminus-core' +import { BaseTerminalTabComponent } from './baseTerminalTab.component' +import { SessionOptions } from '../api' @Component({ selector: 'terminalTab', - template: ` -
- `, - styles: [require('./terminalTab.component.scss')], + template: BaseTerminalTabComponent.template, + styles: BaseTerminalTabComponent.styles, }) -export class TerminalTabComponent extends BaseTabComponent { - session: Session +export class TerminalTabComponent extends BaseTerminalTabComponent { @Input() sessionOptions: SessionOptions - @Input() zoom = 0 - @ViewChild('content') content - @HostBinding('style.background-color') backgroundColor: string - frontend: Frontend - sessionCloseSubscription: Subscription - hotkeysSubscription: Subscription - htermVisible = false - private output = new Subject() - private bellPlayer: HTMLAudioElement - private termContainerSubscriptions: Subscription[] = [] - get input$ (): Observable { return this.frontend.input$ } - get output$ (): Observable { return this.output } - get resize$ (): Observable { return this.frontend.resize$ } - get alternateScreenActive$ (): Observable { return this.frontend.alternateScreenActive$ } + ngOnInit () { + this.logger = this.log.create('terminalTab') - constructor ( - private zone: NgZone, - private app: AppService, - private hostApp: HostAppService, - private hotkeys: HotkeysService, - private sessions: SessionsService, - private electron: ElectronService, - private terminalContainersService: TerminalFrontendService, - public config: ConfigService, - private toastr: ToastrService, - @Optional() @Inject(TerminalDecorator) private decorators: TerminalDecorator[], - @Optional() @Inject(TerminalContextMenuItemProvider) private contextMenuProviders: TerminalContextMenuItemProvider[], - ) { - super() - this.decorators = this.decorators || [] - this.setTitle('Terminal') - - this.session = new Session(this.config) - - this.hotkeysSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => { - if (!this.hasFocus) { - return - } - switch (hotkey) { - case 'ctrl-c': - if (this.frontend.getSelection()) { - this.frontend.copySelection() - this.frontend.clearSelection() - this.toastr.info('Copied') - } else { - this.sendInput('\x03') - } - break - case 'copy': - this.frontend.copySelection() - this.toastr.info('Copied') - break - case 'paste': - this.paste() - break - case 'clear': - this.frontend.clear() - break - case 'zoom-in': - this.zoomIn() - break - case 'zoom-out': - this.zoomOut() - break - case 'reset-zoom': - this.resetZoom() - break - case 'home': - this.sendInput('\x1bOH') - break - case 'end': - this.sendInput('\x1bOF') - break - case 'previous-word': - this.sendInput('\x1bb') - break - case 'next-word': - this.sendInput('\x1bf') - break - case 'delete-previous-word': - this.sendInput('\x1b\x7f') - break - case 'delete-next-word': - this.sendInput('\x1bd') - break - } + this.frontendReady$.pipe(first()).subscribe(() => { + this.initializeSession(this.size.columns, this.size.rows) }) - this.bellPlayer = document.createElement('audio') - this.bellPlayer.src = require('../bell.ogg') - this.contextMenuProviders.sort((a, b) => a.weight - b.weight) + super.ngOnInit() } initializeSession (columns: number, rows: number) { @@ -152,199 +56,6 @@ export class TerminalTabComponent extends BaseTabComponent { } } - ngOnInit () { - this.focused$.subscribe(() => { - this.configure() - this.frontend.focus() - }) - - this.frontend = this.terminalContainersService.getFrontend(this.session) - - this.frontend.ready$.subscribe(() => { - this.htermVisible = true - }) - - this.frontend.resize$.pipe(first()).subscribe(async ({ columns, rows }) => { - if (!this.session.open) { - this.initializeSession(columns, rows) - } - - setTimeout(() => { - this.session.resize(columns, rows) - }, 1000) - - this.session.releaseInitialDataBuffer() - }) - - this.frontend.configure(this.config.store) - this.frontend.attach(this.content.nativeElement) - this.attachTermContainerHandlers() - - this.configure() - - this.config.enabledServices(this.decorators).forEach((decorator) => { - decorator.attach(this) - }) - - setTimeout(() => { - this.output.subscribe(() => { - this.displayActivity() - }) - }, 1000) - - this.frontend.bell$.subscribe(() => { - if (this.config.store.terminal.bell === 'visual') { - this.frontend.visualBell() - } - if (this.config.store.terminal.bell === 'audible') { - this.bellPlayer.play() - } - }) - - this.frontend.focus() - } - - async buildContextMenu (): Promise { - let items: Electron.MenuItemConstructorOptions[] = [] - for (let section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this)))) { - items = items.concat(section) - items.push({ type: 'separator' }) - } - items.splice(items.length - 1, 1) - return items - } - - detachTermContainerHandlers () { - for (let subscription of this.termContainerSubscriptions) { - subscription.unsubscribe() - } - this.termContainerSubscriptions = [] - } - - attachTermContainerHandlers () { - this.detachTermContainerHandlers() - this.termContainerSubscriptions = [ - this.frontend.title$.subscribe(title => this.zone.run(() => this.setTitle(title))), - - this.focused$.subscribe(() => this.frontend.enableResizing = true), - this.blurred$.subscribe(() => this.frontend.enableResizing = false), - - this.frontend.mouseEvent$.subscribe(async event => { - if (event.type === 'mousedown') { - if (event.which === 3) { - if (this.config.store.terminal.rightClick === 'menu') { - this.hostApp.popupContextMenu(await this.buildContextMenu()) - } else if (this.config.store.terminal.rightClick === 'paste') { - this.paste() - } - event.preventDefault() - event.stopPropagation() - return - } - } - if (event.type === 'mousewheel') { - let wheelDeltaY = 0 - - if ('wheelDeltaY' in event) { - wheelDeltaY = (event as MouseWheelEvent)['wheelDeltaY'] - } else { - wheelDeltaY = (event as MouseWheelEvent)['deltaY'] - } - if (event.ctrlKey || event.metaKey) { - - if (wheelDeltaY > 0) { - this.zoomIn() - } else { - this.zoomOut() - } - } else if (event.altKey) { - event.preventDefault() - let delta = Math.round(wheelDeltaY / 50) - this.sendInput(((delta > 0) ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta))) - } - } - }), - - this.frontend.input$.subscribe(data => { - this.sendInput(data) - }), - - this.frontend.resize$.subscribe(({ columns, rows }) => { - console.log(`Resizing to ${columns}x${rows}`) - this.zone.run(() => { - if (this.session.open) { - this.session.resize(columns, rows) - } - }) - }), - - this.hostApp.windowMoved$.subscribe(() => setTimeout(() => { - this.configure() - }, 250)), - ] - } - - sendInput (data: string) { - this.session.write(data) - if (this.config.store.terminal.scrollOnInput) { - this.frontend.scrollToBottom() - } - } - - write (data: string) { - let percentageMatch = /(^|[^\d])(\d+(\.\d+)?)%([^\d]|$)/.exec(data) - if (percentageMatch) { - let percentage = percentageMatch[3] ? parseFloat(percentageMatch[2]) : parseInt(percentageMatch[2]) - if (percentage > 0 && percentage <= 100) { - this.setProgress(percentage) - console.log('Detected progress:', percentage) - } - } else { - this.setProgress(null) - } - this.frontend.write(data) - } - - paste () { - let data = this.electron.clipboard.readText() - if (this.config.store.terminal.bracketedPaste) { - data = '\x1b[200~' + data + '\x1b[201~' - } - if (this.hostApp.platform === Platform.Windows) { - data = data.replace(/\r\n/g, '\r') - } else { - data = data.replace(/\n/g, '\r') - } - this.sendInput(data) - } - - configure (): void { - this.frontend.configure(this.config.store) - - if (this.config.store.terminal.background === 'colorScheme') { - if (this.config.store.terminal.colorScheme.background) { - this.backgroundColor = this.config.store.terminal.colorScheme.background - } - } else { - this.backgroundColor = null - } - } - - zoomIn () { - this.zoom++ - this.frontend.setZoom(this.zoom) - } - - zoomOut () { - this.zoom-- - this.frontend.setZoom(this.zoom) - } - - resetZoom () { - this.zoom = 0 - this.frontend.setZoom(this.zoom) - } - async getCurrentProcess (): Promise { let children = await this.session.getChildProcesses() if (!children.length) { @@ -355,26 +66,6 @@ export class TerminalTabComponent extends BaseTabComponent { } } - ngOnDestroy () { - this.frontend.detach(this.content.nativeElement) - this.detachTermContainerHandlers() - this.config.enabledServices(this.decorators).forEach(decorator => { - decorator.detach(this) - }) - this.hotkeysSubscription.unsubscribe() - if (this.sessionCloseSubscription) { - this.sessionCloseSubscription.unsubscribe() - } - this.output.complete() - } - - async destroy () { - super.destroy() - if (this.session && this.session.open) { - await this.session.destroy() - } - } - async canClose (): Promise { let children = await this.session.getChildProcesses() if (children.length === 0) { diff --git a/terminus-terminal/src/index.ts b/terminus-terminal/src/index.ts index f7c3e6c2..f8cd4c7d 100644 --- a/terminus-terminal/src/index.ts +++ b/terminus-terminal/src/index.ts @@ -16,6 +16,7 @@ import { TerminalSettingsTabComponent } from './components/terminalSettingsTab.c import { ColorPickerComponent } from './components/colorPicker.component' import { EditProfileModalComponent } from './components/editProfileModal.component' import { EnvironmentEditorComponent } from './components/environmentEditor.component' +import { BaseTerminalTabComponent } from './components/baseTerminalTab.component' import { BaseSession } from './services/sessions.service' import { TerminalFrontendService } from './services/terminalFrontend.service' @@ -203,5 +204,5 @@ export default class TerminalModule { } } -export { TerminalService, BaseSession, TerminalTabComponent, TerminalFrontendService } +export { TerminalService, BaseSession, TerminalTabComponent, TerminalFrontendService, BaseTerminalTabComponent } export * from './api'