diff --git a/terminus-core/src/components/baseTab.component.ts b/terminus-core/src/components/baseTab.component.ts index 62e8cba1..eac27096 100644 --- a/terminus-core/src/components/baseTab.component.ts +++ b/terminus-core/src/components/baseTab.component.ts @@ -38,7 +38,7 @@ export abstract class BaseTabComponent { */ color: string|null = null - protected hasFocus = false + hasFocus = false /** * Ping this if your recovery state has been changed and you want diff --git a/terminus-core/src/services/hotkeys.service.ts b/terminus-core/src/services/hotkeys.service.ts index aa757882..8361a9f5 100644 --- a/terminus-core/src/services/hotkeys.service.ts +++ b/terminus-core/src/services/hotkeys.service.ts @@ -1,4 +1,5 @@ import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core' +import { Observable, Subject } from 'rxjs' import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider' import { stringifyKeySequence } from './hotkeys.util' import { ConfigService } from '../services/config.service' @@ -20,8 +21,17 @@ interface EventBufferEntry { @Injectable({ providedIn: 'root' }) export class HotkeysService { key = new EventEmitter() + + /** @hidden */ matchedHotkey = new EventEmitter() + + /** + * Fired for each recognized hotkey + */ + get hotkey$ (): Observable { return this._hotkey } + globalHotkey = new EventEmitter() + private _hotkey = new Subject() private currentKeystrokes: EventBufferEntry[] = [] private disabledLevel = 0 private hotkeyDescriptions: HotkeyDescription[] = [] @@ -49,6 +59,9 @@ export class HotkeysService { this.getHotkeyDescriptions().then(hotkeys => { this.hotkeyDescriptions = hotkeys }) + + // deprecated + this.hotkey$.subscribe(h => this.matchedHotkey.emit(h)) } /** @@ -71,7 +84,7 @@ export class HotkeysService { const matched = this.getCurrentFullyMatchedHotkey() if (matched) { console.log('Matched hotkey', matched) - this.matchedHotkey.emit(matched) + this._hotkey.next(matched) this.clearCurrentKeystrokes() } }) diff --git a/terminus-terminal/src/api/decorator.ts b/terminus-terminal/src/api/decorator.ts index b0ec0a17..1c3db15e 100644 --- a/terminus-terminal/src/api/decorator.ts +++ b/terminus-terminal/src/api/decorator.ts @@ -1,16 +1,35 @@ +import { Subscription } from 'rxjs' import { BaseTerminalTabComponent } from './baseTerminalTab.component' /** * Extend to automatically run actions on new terminals */ export abstract class TerminalDecorator { + private smartSubscriptions = new Map() + /** * Called when a new terminal tab starts */ attach (terminal: BaseTerminalTabComponent): void { } // eslint-disable-line /** - * Called before a terminal tab is destroyed + * Called before a terminal tab is destroyed. + * Make sure to call super() */ - detach (terminal: BaseTerminalTabComponent): void { } // eslint-disable-line + detach (terminal: BaseTerminalTabComponent): void { + for (const s of this.smartSubscriptions.get(terminal) || []) { + s.unsubscribe() + } + this.smartSubscriptions.delete(terminal) + } + + /** + * Automatically cancel @subscription once detached from @terminal + */ + protected subscribeUntilDetached (terminal: BaseTerminalTabComponent, subscription: Subscription) { + if (!this.smartSubscriptions.has(terminal)) { + this.smartSubscriptions.set(terminal, []) + } + this.smartSubscriptions.get(terminal)?.push(subscription) + } } diff --git a/terminus-terminal/src/features/pathDrop.ts b/terminus-terminal/src/features/pathDrop.ts index 0c8415c2..e3d4b3a3 100644 --- a/terminus-terminal/src/features/pathDrop.ts +++ b/terminus-terminal/src/features/pathDrop.ts @@ -1,4 +1,3 @@ -import { Subscription } from 'rxjs' import { Injectable } from '@angular/core' import { TerminalDecorator } from '../api/decorator' import { TerminalTabComponent } from '../components/terminalTab.component' @@ -6,21 +5,17 @@ import { TerminalTabComponent } from '../components/terminalTab.component' /** @hidden */ @Injectable() export class PathDropDecorator extends TerminalDecorator { - private subscriptions: Subscription[] = [] - attach (terminal: TerminalTabComponent): void { setTimeout(() => { - this.subscriptions = [ - terminal.frontend.dragOver$.subscribe(event => { - event.preventDefault() - }), - terminal.frontend.drop$.subscribe(event => { - for (const file of event.dataTransfer!.files as any) { - this.injectPath(terminal, file.path) - } - event.preventDefault() - }), - ] + this.subscribeUntilDetached(terminal, terminal.frontend.dragOver$.subscribe(event => { + event.preventDefault() + })) + this.subscribeUntilDetached(terminal, terminal.frontend.drop$.subscribe(event => { + for (const file of event.dataTransfer!.files as any) { + this.injectPath(terminal, file.path) + } + event.preventDefault() + })) }) } @@ -31,10 +26,4 @@ export class PathDropDecorator extends TerminalDecorator { path = path.replace(/\\/g, '\\\\') terminal.sendInput(path + ' ') } - - detach (_terminal: TerminalTabComponent): void { - for (const s of this.subscriptions) { - s.unsubscribe() - } - } } diff --git a/terminus-terminal/src/features/zmodem.ts b/terminus-terminal/src/features/zmodem.ts index 5490f98a..3c79d4a0 100644 --- a/terminus-terminal/src/features/zmodem.ts +++ b/terminus-terminal/src/features/zmodem.ts @@ -1,29 +1,33 @@ /* eslint-disable @typescript-eslint/camelcase */ +import colors from 'ansi-colors' import * as ZModem from 'zmodem.js' import * as fs from 'fs' import * as path from 'path' -import { Subscription } from 'rxjs' +import { Observable } from 'rxjs' +import { filter } from 'rxjs/operators' import { Injectable } from '@angular/core' import { TerminalDecorator } from '../api/decorator' import { TerminalTabComponent } from '../components/terminalTab.component' -import { LogService, Logger, ElectronService, HostAppService } from 'terminus-core' +import { LogService, Logger, ElectronService, HostAppService, HotkeysService } from 'terminus-core' const SPACER = ' ' /** @hidden */ @Injectable() export class ZModemDecorator extends TerminalDecorator { - private subscriptions: Subscription[] = [] private logger: Logger private activeSession: any = null + private cancelEvent: Observable constructor ( log: LogService, + hotkeys: HotkeysService, private electron: ElectronService, private hostApp: HostAppService, ) { super() this.logger = log.create('zmodem') + this.cancelEvent = hotkeys.hotkey$.pipe(filter(x => x === 'ctrl-c')) } attach (terminal: TerminalTabComponent): void { @@ -47,27 +51,27 @@ export class ZModemDecorator extends TerminalDecorator { }, }) setTimeout(() => { - this.subscriptions = [ - terminal.session.binaryOutput$.subscribe(data => { - const chunkSize = 1024 - for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) { - try { - sentry.consume(data.subarray(i * chunkSize, (i + 1) * chunkSize)) - } catch (e) { - this.logger.error('protocol error', e) - this.activeSession.abort() - this.activeSession = null - terminal.enablePassthrough = true - return - } + this.subscribeUntilDetached(terminal, terminal.session.binaryOutput$.subscribe(data => { + const chunkSize = 1024 + for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) { + try { + sentry.consume(data.subarray(i * chunkSize, (i + 1) * chunkSize)) + } catch (e) { + this.logger.error('protocol error', e) + this.activeSession.abort() + this.activeSession = null + terminal.enablePassthrough = true + return } - }), - ] + } + })) }) } async process (terminal, detection) { - this.showMessage(terminal, '[Terminus] ZModem session started') + this.showMessage(terminal, colors.bgBlue.black(' ZMODEM ') + ' Session started') + this.showMessage(terminal, '------------------------') + const zsession = detection.confirm() this.activeSession = zsession this.logger.info('new session', zsession) @@ -94,7 +98,7 @@ export class ZModemDecorator extends TerminalDecorator { await zsession.close() } else { zsession.on('offer', xfer => { - this.receiveFile(terminal, xfer) + this.receiveFile(terminal, xfer, zsession) }) zsession.start() @@ -104,15 +108,12 @@ export class ZModemDecorator extends TerminalDecorator { } } - detach (_terminal: TerminalTabComponent): void { - for (const s of this.subscriptions) { - s.unsubscribe() - } - } - - private async receiveFile (terminal, xfer) { - const details = xfer.get_details() - this.showMessage(terminal, `🟡 Offered ${details.name}`, true) + private async receiveFile (terminal, xfer, zsession) { + const details: { + name: string, + size: number, + } = xfer.get_details() + this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + details.name, true) this.logger.info('offered', xfer) const result = await this.electron.dialog.showSaveDialog( this.hostApp.getWindow(), @@ -121,20 +122,48 @@ export class ZModemDecorator extends TerminalDecorator { }, ) if (!result.filePath) { - this.showMessage(terminal, `🔴 Rejected ${details.name}`) + this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + details.name) xfer.skip() return } + const stream = fs.createWriteStream(result.filePath) let bytesSent = 0 - await xfer.accept({ - on_input: chunk => { - stream.write(Buffer.from(chunk)) - bytesSent += chunk.length - this.showMessage(terminal, `🟡 Receiving ${details.name}: ${Math.round(100 * bytesSent / details.size)}%`, true) - }, + let canceled = false + const cancelSubscription = this.cancelEvent.subscribe(() => { + if (terminal.hasFocus) { + try { + zsession._skip() + } catch {} + canceled = true + } }) - this.showMessage(terminal, `✅ Received ${details.name}`) + + try { + await Promise.race([ + xfer.accept({ + on_input: chunk => { + if (canceled) { + return + } + stream.write(Buffer.from(chunk)) + bytesSent += chunk.length + this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * bytesSent / details.size).toString().padStart(3, ' ') + '% ') + ' ' + details.name, true) + }, + }), + this.cancelEvent.toPromise(), + ]) + + if (canceled) { + this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + details.name) + } else { + this.showMessage(terminal, colors.bgGreen.black(' Received ') + ' ' + details.name) + } + } catch { + this.showMessage(terminal, colors.bgRed.black(' Error ') + ' ' + details.name) + } + + cancelSubscription.unsubscribe() stream.end() } @@ -149,23 +178,46 @@ export class ZModemDecorator extends TerminalDecorator { bytes_remaining: stat.size, } this.logger.info('offering', offer) - this.showMessage(terminal, `🟡 Offering ${offer.name}`, true) + this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + offer.name, true) const xfer = await zsession.send_offer(offer) if (xfer) { let bytesSent = 0 + let canceled = false const stream = fs.createReadStream(filePath) + const cancelSubscription = this.cancelEvent.subscribe(() => { + if (terminal.hasFocus) { + canceled = true + } + }) + stream.on('data', chunk => { + if (canceled) { + stream.close() + return + } xfer.send(chunk) bytesSent += chunk.length - this.showMessage(terminal, `🟡 Sending ${offer.name}: ${Math.round(100 * bytesSent / offer.size)}%`, true) + this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * bytesSent / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true) }) - await new Promise(resolve => stream.on('end', resolve)) + + await Promise.race([ + new Promise(resolve => stream.on('end', resolve)), + this.cancelEvent.toPromise(), + ]) + await xfer.end() + + if (canceled) { + this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + offer.name) + } else { + this.showMessage(terminal, colors.bgGreen.black(' Sent ') + ' ' + offer.name) + } + stream.close() - this.showMessage(terminal, `✅ Sent ${offer.name}`) + cancelSubscription.unsubscribe() } else { - this.showMessage(terminal, `🔴 Other side rejected ${offer.name}`) + this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + offer.name) this.logger.warn('rejected by the other side') } }