diff --git a/tabby-core/src/api/profileProvider.ts b/tabby-core/src/api/profileProvider.ts index 8fdbc2e6..a6e6bdd6 100644 --- a/tabby-core/src/api/profileProvider.ts +++ b/tabby-core/src/api/profileProvider.ts @@ -14,6 +14,7 @@ export interface Profile { icon?: string color?: string disableDynamicTitle: boolean + behaviorOnSessionEnd: 'auto'|'keep'|'reconnect'|'close' weight: number isBuiltin: boolean diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index 97d3488d..ef26e683 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -24,6 +24,7 @@ export class ProfilesService { isBuiltin: false, isTemplate: false, terminalColorScheme: null, + behaviorOnSessionEnd: 'auto', } constructor ( diff --git a/tabby-local/src/components/terminalTab.component.ts b/tabby-local/src/components/terminalTab.component.ts index 325264d8..b14d03b6 100644 --- a/tabby-local/src/components/terminalTab.component.ts +++ b/tabby-local/src/components/terminalTab.component.ts @@ -28,7 +28,6 @@ export class TerminalTabComponent extends BaseTerminalTabComponent this.sessionOptions = this.profile.options this.logger = this.log.create('terminalTab') - this.session = new Session(this.injector) const isConPTY = isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED) && this.config.store.terminal.useConPTY @@ -56,6 +55,9 @@ export class TerminalTabComponent extends BaseTerminalTabComponent } initializeSession (columns: number, rows: number): void { + + const session = new Session(this.injector) + if (this.profile.options.runAsAdministrator && this.uac?.isAvailable) { this.profile = { ...this.profile, @@ -63,13 +65,13 @@ export class TerminalTabComponent extends BaseTerminalTabComponent } } - this.session!.start({ + session.start({ ...this.profile.options, width: columns, height: rows, }) - this.attachSessionHandlers(true) + this.setSession(session) this.recoveryStateChangedHint.next() } @@ -125,4 +127,12 @@ export class TerminalTabComponent extends BaseTerminalTabComponent super.ngOnDestroy() this.session?.destroy() } + + /** + * Return true if the user explicitly exit the session. + * Always return true for terminalTab as the session can only be ended by the user + */ + protected isSessionExplicitlyTerminated (): boolean { + return true + } } diff --git a/tabby-serial/src/components/serialTab.component.ts b/tabby-serial/src/components/serialTab.component.ts index d648dac4..3e332f81 100644 --- a/tabby-serial/src/components/serialTab.component.ts +++ b/tabby-serial/src/components/serialTab.component.ts @@ -47,10 +47,6 @@ export class SerialTabComponent extends BaseTerminalTabComponent } }) - this.frontendReady$.pipe(first()).subscribe(() => { - this.initializeSession() - }) - super.ngOnInit() setImmediate(() => { @@ -58,6 +54,11 @@ export class SerialTabComponent extends BaseTerminalTabComponent }) } + protected onFrontendReady (): void { + this.initializeSession() + super.onFrontendReady() + } + async initializeSession () { const session = new SerialSession(this.injector, this.profile) this.setSession(session) @@ -82,12 +83,21 @@ export class SerialTabComponent extends BaseTerminalTabComponent this.session?.resize(this.size.columns, this.size.rows) }) this.attachSessionHandler(this.session!.destroyed$, () => { - this.write(this.translate.instant(_('Press any key to reconnect')) + '\r\n') - this.input$.pipe(first()).subscribe(() => { - if (!this.session?.open) { + if (this.frontend) { + // Session was closed abruptly + this.write('\r\n' + colors.black.bgWhite(' SERIAL ') + ` session closed\r\n`) + + if (this.profile.behaviorOnSessionEnd === 'reconnect') { this.reconnect() + } else if (this.profile.behaviorOnSessionEnd === 'keep' || this.profile.behaviorOnSessionEnd === 'auto' && !this.isSessionExplicitlyTerminated()) { + this.write(this.translate.instant(_('Press any key to reconnect')) + '\r\n') + this.input$.pipe(first()).subscribe(() => { + if (!this.session?.open) { + this.reconnect() + } + }) } - }) + } }) super.attachSessionHandlers() } @@ -116,4 +126,10 @@ export class SerialTabComponent extends BaseTerminalTabComponent this.session?.serial?.update({ baudRate: rate }) this.profile.options.baudrate = rate } + + protected isSessionExplicitlyTerminated (): boolean { + return super.isSessionExplicitlyTerminated() || + this.recentInputs.endsWith('close\r') || + this.recentInputs.endsWith('quit\r') + } } diff --git a/tabby-settings/src/components/editProfileModal.component.pug b/tabby-settings/src/components/editProfileModal.component.pug index 2a70ce5a..7869099b 100644 --- a/tabby-settings/src/components/editProfileModal.component.pug +++ b/tabby-settings/src/components/editProfileModal.component.pug @@ -65,6 +65,18 @@ .description(translate) Connection name will be used instead toggle([(ngModel)]='profile.disableDynamicTitle') + .form-line + .header + .title(translate) When a session ends + .description(*ngIf='profile.behaviorOnSessionEnd == "auto"', translate) Only close the tab when session is explicitly terminated + select.form-control( + [(ngModel)]='profile.behaviorOnSessionEnd', + ) + option(ngValue='auto', translate) Auto + option(ngValue='keep', translate) Keep + option(*ngIf='profile.type == "serial" || profile.type == "telnet" || profile.type == "ssh"', ngValue='reconnect', translate) Reconnect + option(ngValue='close', translate) Close + .mb-4 .col-12.col-lg-8(*ngIf='this.profileProvider.settingsComponent') diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index 11af1368..97bc2da5 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -30,7 +30,6 @@ export class SSHTabComponent extends BaseTerminalTabComponent implem sftpPath = '/' enableToolbar = true activeKIPrompt: KeyboardInteractivePrompt|null = null - private recentInputs = '' private reconnectOffered = false constructor ( @@ -71,17 +70,14 @@ export class SSHTabComponent extends BaseTerminalTabComponent implem } }) - this.frontendReady$.pipe(first()).subscribe(() => { - this.initializeSession() - this.input$.subscribe(data => { - this.recentInputs += data - this.recentInputs = this.recentInputs.substring(this.recentInputs.length - 32) - }) - }) - super.ngOnInit() } + protected onFrontendReady (): void { + this.initializeSession() + super.onFrontendReady() + } + async setupOneSession (injector: Injector, profile: SSHProfile, multiplex = true): Promise { let session = await this.sshMultiplexer.getSession(profile) if (!multiplex || !session || !profile.options.reuseSession) { @@ -157,24 +153,22 @@ export class SSHTabComponent extends BaseTerminalTabComponent implem protected attachSessionHandlers (): void { const session = this.session! this.attachSessionHandler(session.destroyed$, () => { - if ( - // Ctrl-D - this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 || - this.recentInputs.endsWith('exit\r') - ) { - // User closed the session - this.destroy() - } else if (this.frontend) { + if (this.frontend) { // Session was closed abruptly this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${this.sshSession?.profile.options.host}: session closed\r\n`) - if (!this.reconnectOffered) { - this.reconnectOffered = true - this.write(this.translate.instant(_('Press any key to reconnect')) + '\r\n') - this.input$.pipe(first()).subscribe(() => { - if (!this.session?.open && this.reconnectOffered) { - this.reconnect() - } - }) + + if (this.profile.behaviorOnSessionEnd === 'reconnect') { + this.reconnect() + } else if (this.profile.behaviorOnSessionEnd === 'keep' || this.profile.behaviorOnSessionEnd === 'auto' && !this.isSessionExplicitlyTerminated()) { + if (!this.reconnectOffered) { + this.reconnectOffered = true + this.write(this.translate.instant(_('Press any key to reconnect')) + '\r\n') + this.input$.pipe(first()).subscribe(() => { + if (!this.session?.open && this.reconnectOffered) { + this.reconnect() + } + }) + } } } }) @@ -266,4 +260,10 @@ export class SSHTabComponent extends BaseTerminalTabComponent implem onClick (): void { this.sftpPanelVisible = false } + + protected isSessionExplicitlyTerminated (): boolean { + return super.isSessionExplicitlyTerminated() || + this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 || + this.recentInputs.endsWith('exit\r') + } } diff --git a/tabby-telnet/src/components/telnetTab.component.ts b/tabby-telnet/src/components/telnetTab.component.ts index 440e3432..d3ae2cf4 100644 --- a/tabby-telnet/src/components/telnetTab.component.ts +++ b/tabby-telnet/src/components/telnetTab.component.ts @@ -36,26 +36,33 @@ export class TelnetTabComponent extends BaseTerminalTabComponent } }) - this.frontendReady$.pipe(first()).subscribe(() => { - this.initializeSession() - }) - super.ngOnInit() } + protected onFrontendReady (): void { + this.initializeSession() + super.onFrontendReady() + } + protected attachSessionHandlers (): void { const session = this.session! this.attachSessionHandler(session.destroyed$, () => { if (this.frontend) { // Session was closed abruptly - if (!this.reconnectOffered) { - this.reconnectOffered = true - this.write(this.translate.instant(_('Press any key to reconnect')) + '\r\n') - this.input$.pipe(first()).subscribe(() => { - if (!this.session?.open && this.reconnectOffered) { - this.reconnect() - } - }) + this.write('\r\n' + colors.black.bgWhite(' TELNET ') + ` ${this.session?.profile.options.host}: session closed\r\n`) + + if (this.profile.behaviorOnSessionEnd === 'reconnect') { + this.reconnect() + } else if (this.profile.behaviorOnSessionEnd === 'keep' || this.profile.behaviorOnSessionEnd === 'auto' && !this.isSessionExplicitlyTerminated()) { + if (!this.reconnectOffered) { + this.reconnectOffered = true + this.write(this.translate.instant(_('Press any key to reconnect')) + '\r\n') + this.input$.pipe(first()).subscribe(() => { + if (!this.session?.open && this.reconnectOffered) { + this.reconnect() + } + }) + } } } }) @@ -120,4 +127,11 @@ export class TelnetTabComponent extends BaseTerminalTabComponent }, )).response === 0 } + + protected isSessionExplicitlyTerminated (): boolean { + return super.isSessionExplicitlyTerminated() || + this.recentInputs.endsWith('close\r') || + this.recentInputs.endsWith('quit\r') + } + } diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index f781afd6..f6a5a619 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -129,6 +129,7 @@ export class BaseTerminalTabComponent

extends Bas protected output = new Subject() protected binaryOutput = new Subject() protected sessionChanged = new Subject() + protected recentInputs = '' private bellPlayer: HTMLAudioElement private termContainerSubscriptions = new SubscriptionContainer() private sessionHandlers = new SubscriptionContainer() @@ -415,6 +416,11 @@ export class BaseTerminalTabComponent

extends Bas this.frontend!.write('\r\n\r\n') } } + + this.input$.subscribe(data => { + this.recentInputs += data + this.recentInputs = this.recentInputs.substring(this.recentInputs.length - 32) + }) } async buildContextMenu (): Promise { @@ -765,11 +771,12 @@ export class BaseTerminalTabComponent

extends Bas } }) - if (destroyOnSessionClose) { - this.attachSessionHandler(this.session.closed$, () => { + this.attachSessionHandler(this.session.closed$, () => { + const behavior = this.profile.behaviorOnSessionEnd + if (destroyOnSessionClose || behavior === 'close' || behavior === 'auto' && this.isSessionExplicitlyTerminated()) { this.destroy() - }) - } + } + }) this.attachSessionHandler(this.session.destroyed$, () => { this.setSession(null) @@ -835,4 +842,11 @@ export class BaseTerminalTabComponent

extends Bas cb(this) } } + + /** + * Return true if the user explicitly exit the session + */ + protected isSessionExplicitlyTerminated (): boolean { + return false + } }