diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index 0384396d..cc467f96 100644 --- a/tabby-core/src/api/index.ts +++ b/tabby-core/src/api/index.ts @@ -16,7 +16,7 @@ export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess' export { HostWindowService } from './hostWindow' export { HostAppService, Platform } from './hostApp' export { FileProvider } from './fileProvider' -export { ProfileProvider, Profile, PartialProfile, ProfileSettingsComponent } from './profileProvider' +export { ProfileProvider, ConnectableProfileProvider, QuickConnectProfileProvider, Profile, ConnectableProfile, PartialProfile, ProfileSettingsComponent, ProfileGroup, PartialProfileGroup } from './profileProvider' export { PromptModalComponent } from '../components/promptModal.component' export * from './commands' diff --git a/tabby-core/src/api/profileProvider.ts b/tabby-core/src/api/profileProvider.ts index a6e6bdd6..ba13b0ff 100644 --- a/tabby-core/src/api/profileProvider.ts +++ b/tabby-core/src/api/profileProvider.ts @@ -21,6 +21,10 @@ export interface Profile { isTemplate: boolean } +export interface ConnectableProfile extends Profile { + clearServiceMessagesOnConnect: boolean +} + export type PartialProfile = Omit, 'type'>, 'name'> & { @@ -31,6 +35,21 @@ export type PartialProfile = Omit[] + defaults: any + editable: boolean +} + +export type PartialProfileGroup = Omit, 'name'> & { + id: string + name: string +} + export interface ProfileSettingsComponent

{ profile: P save?: () => void @@ -39,7 +58,6 @@ export interface ProfileSettingsComponent

{ export abstract class ProfileProvider

{ id: string name: string - supportsQuickConnect = false settingsComponent?: new (...args: any[]) => ProfileSettingsComponent

configDefaults = {} @@ -53,13 +71,15 @@ export abstract class ProfileProvider

{ abstract getDescription (profile: PartialProfile

): string - quickConnect (query: string): PartialProfile

|null { - return null - } - - intoQuickConnectString (profile: P): string|null { - return null - } - deleteProfile (profile: P): void { } } + +export abstract class ConnectableProfileProvider

extends ProfileProvider

{} + +export abstract class QuickConnectProfileProvider

extends ConnectableProfileProvider

{ + + abstract quickConnect (query: string): PartialProfile

|null + + abstract intoQuickConnectString (profile: P): string|null + +} diff --git a/tabby-core/src/commands.ts b/tabby-core/src/commands.ts index 2af7b6b6..88a81b26 100644 --- a/tabby-core/src/commands.ts +++ b/tabby-core/src/commands.ts @@ -18,7 +18,7 @@ export class CoreCommandProvider extends CommandProvider { } async activate () { - const profile = await this.profilesService.showProfileSelector() + const profile = await this.profilesService.showProfileSelector().catch(() => null) if (profile) { this.profilesService.launchProfile(profile) } diff --git a/tabby-core/src/components/selectorModal.component.ts b/tabby-core/src/components/selectorModal.component.ts index 772adef2..a1160cc9 100644 --- a/tabby-core/src/components/selectorModal.component.ts +++ b/tabby-core/src/components/selectorModal.component.ts @@ -78,7 +78,7 @@ export class SelectorModalComponent { { sort: true }, ).search(f) - this.options.filter(x => x.freeInputPattern).forEach(freeOption => { + this.options.filter(x => x.freeInputPattern).sort(firstBy, number>(x => x.weight ?? 0)).forEach(freeOption => { if (!this.filteredOptions.includes(freeOption)) { this.filteredOptions.push(freeOption) } diff --git a/tabby-core/src/configDefaults.yaml b/tabby-core/src/configDefaults.yaml index 693c888b..1ad4f421 100644 --- a/tabby-core/src/configDefaults.yaml +++ b/tabby-core/src/configDefaults.yaml @@ -31,6 +31,7 @@ hotkeys: profile-selectors: __nonStructural: true profiles: [] +groups: [] profileDefaults: __nonStructural: true ssh: diff --git a/tabby-core/src/hotkeys.ts b/tabby-core/src/hotkeys.ts index c14ac666..d4465a36 100644 --- a/tabby-core/src/hotkeys.ts +++ b/tabby-core/src/hotkeys.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core' import { TranslateService } from '@ngx-translate/core' import { ProfilesService } from './services/profiles.service' import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider' -import { PartialProfile, Profile } from './api' /** @hidden */ @Injectable() @@ -268,7 +267,7 @@ export class AppHotkeyProvider extends HotkeyProvider { return [ ...this.hotkeys, ...profiles.map(profile => ({ - id: `profile.${AppHotkeyProvider.getProfileHotkeyName(profile)}`, + id: `profile.${ProfilesService.getProfileHotkeyName(profile)}`, name: this.translate.instant('New tab: {profile}', { profile: profile.name }), })), ...this.profilesService.getProviders().map(provider => ({ @@ -278,7 +277,4 @@ export class AppHotkeyProvider extends HotkeyProvider { ] } - static getProfileHotkeyName (profile: PartialProfile): string { - return (profile.id ?? profile.name).replace(/\./g, '-') - } } diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index 6cf14a24..25b5e1ad 100644 --- a/tabby-core/src/index.ts +++ b/tabby-core/src/index.ts @@ -37,7 +37,7 @@ import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive' import { DropZoneDirective } from './directives/dropZone.directive' import { CdkAutoDropGroup } from './directives/cdkAutoDropGroup.directive' -import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api' +import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, QuickConnectProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api' import { AppService } from './services/app.service' import { ConfigService } from './services/config.service' @@ -177,7 +177,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex if (hotkey.startsWith('profile.')) { const id = hotkey.substring(hotkey.indexOf('.') + 1) const profiles = await profilesService.getProfiles() - const profile = profiles.find(x => AppHotkeyProvider.getProfileHotkeyName(x) === id) + const profile = profiles.find(x => ProfilesService.getProfileHotkeyName(x) === id) if (profile) { profilesService.openNewTabForProfile(profile) } @@ -191,7 +191,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex this.showSelector(provider) } if (hotkey === 'command-selector') { - commands.showSelector() + commands.showSelector().catch(() => {return}) } if (hotkey === 'profile-selector') { @@ -214,7 +214,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex callback: () => this.profilesService.openNewTabForProfile(p), })) - if (provider.supportsQuickConnect) { + if (provider instanceof QuickConnectProfileProvider) { options.push({ name: this.translate.instant('Quick connect'), freeInputPattern: this.translate.instant('Connect to "%s"...'), @@ -229,7 +229,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex }) } - await this.selector.show(this.translate.instant('Select profile'), options) + await this.selector.show(this.translate.instant('Select profile'), options).catch(() => {return}) } static forRoot (): ModuleWithProviders { diff --git a/tabby-core/src/services/commands.service.ts b/tabby-core/src/services/commands.service.ts index f858edcb..739b0b49 100644 --- a/tabby-core/src/services/commands.service.ts +++ b/tabby-core/src/services/commands.service.ts @@ -101,7 +101,7 @@ export class CommandService { context.tab = tab.getFocusedTab() ?? undefined } const commands = await this.getCommands(context) - await this.selector.show( + return this.selector.show( this.translate.instant('Commands'), commands.map(c => ({ name: c.label, @@ -109,6 +109,6 @@ export class CommandService { description: c.sublabel, icon: c.icon, })), - ) + ).then(() => {return}) } } diff --git a/tabby-core/src/services/config.service.ts b/tabby-core/src/services/config.service.ts index daa0d746..4b91fefa 100644 --- a/tabby-core/src/services/config.service.ts +++ b/tabby-core/src/services/config.service.ts @@ -10,6 +10,7 @@ import { PlatformService } from '../api/platform' import { HostAppService } from '../api/hostApp' import { Vault, VaultService } from './vault.service' import { serializeFunction } from '../utils' +import { PartialProfileGroup, ProfileGroup } from '../api/profileProvider' const deepmerge = require('deepmerge') // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -364,6 +365,45 @@ export class ConfigService { } config.version = 4 } + if (config.version < 5) { + const groups: PartialProfileGroup[] = [] + for (const p of config.profiles ?? []) { + if (!(p.group ?? '').trim()) { + continue + } + + let group = groups.find(x => x.name === p.group) + if (!group) { + group = { + id: `${uuidv4()}`, + name: `${p.group}`, + } + groups.push(group) + } + p.group = group.id + } + + const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') + for (const g of groups) { + if (profileGroupCollapsed[g.name]) { + const collapsed = profileGroupCollapsed[g.name] + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete profileGroupCollapsed[g.name] + profileGroupCollapsed[g.id] = collapsed + } + } + window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed) + + config.groups = groups + config.version = 5 + } + if (config.version < 6) { + if (config.ssh.clearServiceMessagesOnConnect === false) { + config.profileDefaults.ssh.clearServiceMessagesOnConnect = false + delete config.ssh?.clearServiceMessagesOnConnect + } + config.version = 6 + } } private async maybeDecryptConfig (store) { diff --git a/tabby-core/src/services/fileProviders.service.ts b/tabby-core/src/services/fileProviders.service.ts index d6983b40..3290e842 100644 --- a/tabby-core/src/services/fileProviders.service.ts +++ b/tabby-core/src/services/fileProviders.service.ts @@ -13,8 +13,9 @@ export class FileProvidersService { ) { } async selectAndStoreFile (description: string): Promise { - const p = await this.selectProvider() - return p.selectAndStoreFile(description) + return this.selectProvider().then(p => { + return p.selectAndStoreFile(description) + }) } async retrieveFile (key: string): Promise { diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index 943d11c2..fc3b4211 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -2,12 +2,15 @@ import { Injectable, Inject } from '@angular/core' import { TranslateService } from '@ngx-translate/core' import { NewTabParameters } from './tabs.service' import { BaseTabComponent } from '../components/baseTab.component' -import { PartialProfile, Profile, ProfileProvider } from '../api/profileProvider' +import { QuickConnectProfileProvider, PartialProfile, PartialProfileGroup, Profile, ProfileGroup, ProfileProvider } from '../api/profileProvider' import { SelectorOption } from '../api/selector' import { AppService } from './app.service' import { configMerge, ConfigProxy, ConfigService } from './config.service' import { NotificationsService } from './notifications.service' import { SelectorService } from './selector.service' +import deepClone from 'clone-deep' +import { v4 as uuidv4 } from 'uuid' +import slugify from 'slugify' @Injectable({ providedIn: 'root' }) export class ProfilesService { @@ -36,6 +39,126 @@ export class ProfilesService { @Inject(ProfileProvider) private profileProviders: ProfileProvider[], ) { } + /* + * Methods used to interract with ProfileProvider + */ + + getProviders (): ProfileProvider[] { + return [...this.profileProviders] + } + + providerForProfile (profile: PartialProfile): ProfileProvider|null { + const provider = this.profileProviders.find(x => x.id === profile.type) ?? null + return provider as unknown as ProfileProvider|null + } + + getDescription

(profile: PartialProfile

): string|null { + profile = this.getConfigProxyForProfile(profile) + return this.providerForProfile(profile)?.getDescription(profile) ?? null + } + + /* + * Methods used to interract with Profile + */ + + /* + * Return ConfigProxy for a given Profile + * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy + * arg: skipGroupDefaults -> do not merge parent group provider defaults in ConfigProxy + */ + getConfigProxyForProfile (profile: PartialProfile, options?: { skipGlobalDefaults?: boolean, skipGroupDefaults?: boolean }): T { + const defaults = this.getProfileDefaults(profile, options).reduce(configMerge, {}) + return new ConfigProxy(profile, defaults) as unknown as T + } + + /** + * Return an Array of Profiles + * arg: includeBuiltin (default: true) -> include BuiltinProfiles + * arg: clone (default: false) -> return deepclone Array + */ + async getProfiles (options?: { includeBuiltin?: boolean, clone?: boolean }): Promise[]> { + let list = this.config.store.profiles ?? [] + if (options?.includeBuiltin ?? true) { + const lists = await Promise.all(this.config.enabledServices(this.profileProviders).map(x => x.getBuiltinProfiles())) + list = [ + ...this.config.store.profiles ?? [], + ...lists.reduce((a, b) => a.concat(b), []), + ] + } + + const sortKey = p => `${this.resolveProfileGroupName(p.group ?? '')} / ${p.name}` + list.sort((a, b) => sortKey(a).localeCompare(sortKey(b))) + list.sort((a, b) => (a.isBuiltin ? 1 : 0) - (b.isBuiltin ? 1 : 0)) + return options?.clone ? deepClone(list) : list + } + + /** + * Insert a new Profile in config + * arg: genId (default: true) -> generate uuid in before pushing Profile into config + */ + async newProfile (profile: PartialProfile, options?: { genId?: boolean }): Promise { + if (options?.genId ?? true) { + profile.id = `${profile.type}:custom:${slugify(profile.name)}:${uuidv4()}` + } + + const cProfile = this.config.store.profiles.find(p => p.id === profile.id) + if (cProfile) { + throw new Error(`Cannot insert new Profile, duplicated Id: ${profile.id}`) + } + + this.config.store.profiles.push(profile) + } + + /** + * Write a Profile in config + */ + async writeProfile (profile: PartialProfile): Promise { + const cProfile = this.config.store.profiles.find(p => p.id === profile.id) + if (cProfile) { + if (!profile.group) { + delete cProfile.group + } + + Object.assign(cProfile, profile) + } + } + + /** + * Delete a Profile from config + */ + async deleteProfile (profile: PartialProfile): Promise { + this.providerForProfile(profile)?.deleteProfile(this.getConfigProxyForProfile(profile)) + this.config.store.profiles = this.config.store.profiles.filter(p => p.id !== profile.id) + + const profileHotkeyName = ProfilesService.getProfileHotkeyName(profile) + if (this.config.store.hotkeys.profile.hasOwnProperty(profileHotkeyName)) { + const profileHotkeys = deepClone(this.config.store.hotkeys.profile) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete profileHotkeys[profileHotkeyName] + this.config.store.hotkeys.profile = profileHotkeys + } + } + + /** + * Delete all Profiles from config using option filter + * arg: filter (p: PartialProfile) => boolean -> predicate used to decide which profiles have to be deleted + */ + async bulkDeleteProfiles (filter: (p: PartialProfile) => boolean): Promise { + for (const profile of this.config.store.profiles.filter(filter)) { + this.providerForProfile(profile)?.deleteProfile(this.getConfigProxyForProfile(profile)) + + const profileHotkeyName = ProfilesService.getProfileHotkeyName(profile) + if (this.config.store.hotkeys.profile.hasOwnProperty(profileHotkeyName)) { + const profileHotkeys = deepClone(this.config.store.hotkeys.profile) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete profileHotkeys[profileHotkeyName] + this.config.store.hotkeys.profile = profileHotkeys + } + } + + this.config.store.profiles = this.config.store.profiles.filter(x => !filter(x)) + } + async openNewTabForProfile

(profile: PartialProfile

): Promise { const params = await this.newTabParametersForProfile(profile) if (params) { @@ -63,52 +186,40 @@ export class ProfilesService { return params } - getProviders (): ProfileProvider[] { - return [...this.profileProviders] + async launchProfile (profile: PartialProfile): Promise { + await this.openNewTabForProfile(profile) + + let recentProfiles: PartialProfile[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') + if (this.config.store.terminal.showRecentProfiles > 0) { + recentProfiles = recentProfiles.filter(x => x.group !== profile.group || x.name !== profile.name) + recentProfiles.unshift(profile) + recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) + } else { + recentProfiles = [] + } + window.localStorage['recentProfiles'] = JSON.stringify(recentProfiles) } - async getProfiles (): Promise[]> { - const lists = await Promise.all(this.config.enabledServices(this.profileProviders).map(x => x.getBuiltinProfiles())) - let list = lists.reduce((a, b) => a.concat(b), []) - list = [ - ...this.config.store.profiles ?? [], - ...list, - ] - const sortKey = p => `${p.group ?? ''} / ${p.name}` - list.sort((a, b) => sortKey(a).localeCompare(sortKey(b))) - list.sort((a, b) => (a.isBuiltin ? 1 : 0) - (b.isBuiltin ? 1 : 0)) - return list + static getProfileHotkeyName (profile: PartialProfile): string { + return (profile.id ?? profile.name).replace(/\./g, '-') } - providerForProfile (profile: PartialProfile): ProfileProvider|null { - const provider = this.profileProviders.find(x => x.id === profile.type) ?? null - return provider as unknown as ProfileProvider|null - } - - getDescription

(profile: PartialProfile

): string|null { - profile = this.getConfigProxyForProfile(profile) - return this.providerForProfile(profile)?.getDescription(profile) ?? null - } + /* + * Methods used to interract with Profile Selector + */ selectorOptionForProfile

(profile: PartialProfile

): SelectorOption { const fullProfile = this.getConfigProxyForProfile(profile) const provider = this.providerForProfile(fullProfile) - const freeInputEquivalent = provider?.intoQuickConnectString(fullProfile) ?? undefined + const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined return { ...profile, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - group: profile.group || '', + group: this.resolveProfileGroupName(profile.group ?? ''), freeInputEquivalent, description: provider?.getDescription(fullProfile), } } - getRecentProfiles (): PartialProfile[] { - let recentProfiles: PartialProfile[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') - recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) - return recentProfiles - } - showProfileSelector (): Promise|null> { if (this.selector.active) { return Promise.resolve(null) @@ -118,12 +229,12 @@ export class ProfilesService { try { const recentProfiles = this.getRecentProfiles() - let options: SelectorOption[] = recentProfiles.map(p => ({ + let options: SelectorOption[] = recentProfiles.map((p, i) => ({ ...this.selectorOptionForProfile(p), group: this.translate.instant('Recent'), icon: 'fas fa-history', color: p.color, - weight: -2, + weight: i - (recentProfiles.length + 1), callback: async () => { if (p.id) { p = (await this.getProfiles()).find(x => x.id === p.id) ?? p @@ -177,30 +288,38 @@ export class ProfilesService { }) } catch { } - this.getProviders().filter(x => x.supportsQuickConnect).forEach(provider => { - options.push({ - name: this.translate.instant('Quick connect'), - freeInputPattern: this.translate.instant('Connect to "%s"...'), - description: `(${provider.name.toUpperCase()})`, - icon: 'fas fa-arrow-right', - weight: provider.id !== this.config.store.defaultQuickConnectProvider ? 1 : 0, - callback: query => { - const profile = provider.quickConnect(query) - resolve(profile) - }, - }) + this.getProviders().forEach(provider => { + if (provider instanceof QuickConnectProfileProvider) { + options.push({ + name: this.translate.instant('Quick connect'), + freeInputPattern: this.translate.instant('Connect to "%s"...'), + description: `(${provider.name.toUpperCase()})`, + icon: 'fas fa-arrow-right', + weight: provider.id !== this.config.store.defaultQuickConnectProvider ? 1 : 0, + callback: query => { + const profile = provider.quickConnect(query) + resolve(profile) + }, + }) + } }) - await this.selector.show(this.translate.instant('Select profile or enter an address'), options) + await this.selector.show(this.translate.instant('Select profile or enter an address'), options).catch(() => reject()) } catch (err) { reject(err) } }) } + getRecentProfiles (): PartialProfile[] { + let recentProfiles: PartialProfile[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') + recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) + return recentProfiles + } + async quickConnect (query: string): Promise|null> { for (const provider of this.getProviders()) { - if (provider.supportsQuickConnect) { + if (provider instanceof QuickConnectProfileProvider) { const profile = provider.quickConnect(query) if (profile) { return profile @@ -211,27 +330,178 @@ export class ProfilesService { return null } - getConfigProxyForProfile (profile: PartialProfile, skipUserDefaults = false): T { + /* + * Methods used to interract with Profile/ProfileGroup/Global defaults + */ + + /** + * Return global defaults for a given profile provider + * Always return something, empty object if no defaults found + */ + getProviderDefaults (provider: ProfileProvider): any { + const defaults = this.config.store.profileDefaults + return defaults[provider.id] ?? {} + } + + /** + * Set global defaults for a given profile provider + */ + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + setProviderDefaults (provider: ProfileProvider, pdefaults: any): void { + this.config.store.profileDefaults[provider.id] = pdefaults + } + + /** + * Return defaults for a given profile + * Always return something, empty object if no defaults found + * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy + * arg: skipGroupDefaults -> do not merge parent group provider defaults in ConfigProxy + */ + getProfileDefaults (profile: PartialProfile, options?: { skipGlobalDefaults?: boolean, skipGroupDefaults?: boolean }): any[] { const provider = this.providerForProfile(profile) - const defaults = [ + + return [ this.profileDefaults, provider?.configDefaults ?? {}, - !provider || skipUserDefaults ? {} : this.config.store.profileDefaults[provider.id] ?? {}, - ].reduce(configMerge, {}) - return new ConfigProxy(profile, defaults) as unknown as T + provider && !options?.skipGlobalDefaults ? this.getProviderDefaults(provider) : {}, + provider && !options?.skipGlobalDefaults && !options?.skipGroupDefaults ? this.getProviderProfileGroupDefaults(profile.group ?? '', provider) : {}, + ] } - async launchProfile (profile: PartialProfile): Promise { - await this.openNewTabForProfile(profile) + /* + * Methods used to interract with ProfileGroup + */ - let recentProfiles: PartialProfile[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') - if (this.config.store.terminal.showRecentProfiles > 0) { - recentProfiles = recentProfiles.filter(x => x.group !== profile.group || x.name !== profile.name) - recentProfiles.unshift(profile) - recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) - } else { - recentProfiles = [] + /** + * Synchronously return an Array of the existing ProfileGroups + * Does not return builtin groups + */ + getSyncProfileGroups (): PartialProfileGroup[] { + return deepClone(this.config.store.groups ?? []) + } + + /** + * Return an Array of the existing ProfileGroups + * arg: includeProfiles (default: false) -> if false, does not fill up the profiles field of ProfileGroup + * arg: includeNonUserGroup (default: false) -> if false, does not add built-in and ungrouped groups + */ + async getProfileGroups (options?: { includeProfiles?: boolean, includeNonUserGroup?: boolean }): Promise[]> { + let profiles: PartialProfile[] = [] + if (options?.includeProfiles) { + profiles = await this.getProfiles({ includeBuiltin: options.includeNonUserGroup, clone: true }) } - window.localStorage['recentProfiles'] = JSON.stringify(recentProfiles) + + let groups: PartialProfileGroup[] = this.getSyncProfileGroups() + groups = groups.map(x => { + x.editable = true + + if (options?.includeProfiles) { + x.profiles = profiles.filter(p => p.group === x.id) + profiles = profiles.filter(p => p.group !== x.id) + } + + return x + }) + + if (options?.includeNonUserGroup) { + const builtInGroups: PartialProfileGroup[] = [] + builtInGroups.push({ + id: 'built-in', + name: this.translate.instant('Built-in'), + editable: false, + profiles: [], + }) + + const ungrouped: PartialProfileGroup = { + id: 'ungrouped', + name: this.translate.instant('Ungrouped'), + editable: false, + } + + if (options.includeProfiles) { + for (const profile of profiles.filter(p => p.isBuiltin)) { + let group: PartialProfileGroup | undefined = builtInGroups.find(g => g.id === slugify(profile.group ?? 'built-in')) + if (!group) { + group = { + id: `${slugify(profile.group!)}`, + name: `${profile.group!}`, + editable: false, + profiles: [], + } + builtInGroups.push(group) + } + + group.profiles!.push(profile) + } + + ungrouped.profiles = profiles.filter(p => !p.isBuiltin) + } + + groups = groups.concat(builtInGroups) + groups.push(ungrouped) + } + + return groups } + + /** + * Insert a new ProfileGroup in config + * arg: genId (default: true) -> generate uuid in before pushing Profile into config + */ + async newProfileGroup (group: PartialProfileGroup, options?: { genId?: boolean }): Promise { + if (options?.genId ?? true) { + group.id = `${uuidv4()}` + } + + const cProfileGroup = this.config.store.groups.find(p => p.id === group.id) + if (cProfileGroup) { + throw new Error(`Cannot insert new ProfileGroup, duplicated Id: ${group.id}`) + } + + this.config.store.groups.push(group) + } + + /** + * Write a ProfileGroup in config + */ + async writeProfileGroup (group: PartialProfileGroup): Promise { + delete group.profiles + delete group.editable + + const cGroup = this.config.store.groups.find(g => g.id === group.id) + if (cGroup) { + Object.assign(cGroup, group) + } + } + + /** + * Delete a ProfileGroup from config + */ + async deleteProfileGroup (group: PartialProfileGroup, options?: { deleteProfiles?: boolean }): Promise { + this.config.store.groups = this.config.store.groups.filter(g => g.id !== group.id) + if (options?.deleteProfiles) { + await this.bulkDeleteProfiles((p) => p.group === group.id) + } else { + for (const profile of this.config.store.profiles.filter(x => x.group === group.id)) { + delete profile.group + } + } + } + + /** + * Resolve and return ProfileGroup Name from ProfileGroup ID + */ + resolveProfileGroupName (groupId: string): string { + return this.config.store.groups.find(g => g.id === groupId)?.name ?? groupId + } + + /** + * Return defaults for a given group ID and provider + * Always return something, empty object if no defaults found + * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy + */ + getProviderProfileGroupDefaults (groupId: string, provider: ProfileProvider): any { + return this.getSyncProfileGroups().find(g => g.id === groupId)?.defaults?.[provider.id] ?? {} + } + } diff --git a/tabby-core/src/services/vault.service.ts b/tabby-core/src/services/vault.service.ts index bcc507b3..8602597e 100644 --- a/tabby-core/src/services/vault.service.ts +++ b/tabby-core/src/services/vault.service.ts @@ -285,7 +285,7 @@ export class VaultFileProvider extends FileProvider { icon: 'fas fa-file', result: f, })), - ]) + ]).catch(() => null) if (result) { return `${this.prefix}${result.key.id}` } diff --git a/tabby-core/src/tabContextMenu.ts b/tabby-core/src/tabContextMenu.ts index 31ee3e1f..e0ed6d1f 100644 --- a/tabby-core/src/tabContextMenu.ts +++ b/tabby-core/src/tabContextMenu.ts @@ -149,7 +149,7 @@ export class CommonOptionsContextMenu extends TabContextMenuItemProvider { click: async () => { const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = this.translate.instant('Profile name') - const name = (await modal.result)?.value + const name = (await modal.result.catch(() => null))?.value if (!name) { return } @@ -262,7 +262,7 @@ export class ProfilesContextMenu extends TabContextMenuItemProvider { } async switchTabProfile (tab: BaseTabComponent) { - const profile = await this.profilesService.showProfileSelector() + const profile = await this.profilesService.showProfileSelector().catch(() => null) if (!profile) { return } diff --git a/tabby-serial/src/api.ts b/tabby-serial/src/api.ts index 64d46e68..bc9d741c 100644 --- a/tabby-serial/src/api.ts +++ b/tabby-serial/src/api.ts @@ -3,10 +3,10 @@ import { SerialPortStream } from '@serialport/stream' import { LogService, NotificationsService } from 'tabby-core' import { Subject, Observable } from 'rxjs' import { Injector, NgZone } from '@angular/core' -import { BaseSession, BaseTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor, UTF8SplitterMiddleware } from 'tabby-terminal' +import { BaseSession, ConnectableTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor, UTF8SplitterMiddleware } from 'tabby-terminal' import { SerialService } from './services/serial.service' -export interface SerialProfile extends BaseTerminalProfile { +export interface SerialProfile extends ConnectableTerminalProfile { options: SerialProfileOptions } diff --git a/tabby-serial/src/profiles.ts b/tabby-serial/src/profiles.ts index c6c5d5bf..36f975fc 100644 --- a/tabby-serial/src/profiles.ts +++ b/tabby-serial/src/profiles.ts @@ -2,14 +2,14 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker' import slugify from 'slugify' import deepClone from 'clone-deep' import { Injectable } from '@angular/core' -import { ProfileProvider, NewTabParameters, SelectorService, HostAppService, Platform, TranslateService } from 'tabby-core' +import { NewTabParameters, SelectorService, HostAppService, Platform, TranslateService, ConnectableProfileProvider } from 'tabby-core' import { SerialProfileSettingsComponent } from './components/serialProfileSettings.component' import { SerialTabComponent } from './components/serialTab.component' import { SerialService } from './services/serial.service' import { BAUD_RATES, SerialProfile } from './api' @Injectable({ providedIn: 'root' }) -export class SerialProfilesService extends ProfileProvider { +export class SerialProfilesService extends ConnectableProfileProvider { id = 'serial' name = _('Serial') settingsComponent = SerialProfileSettingsComponent @@ -32,6 +32,7 @@ export class SerialProfilesService extends ProfileProvider { slowSend: false, input: { backspace: 'backspace' }, }, + clearServiceMessagesOnConnect: false, } constructor ( diff --git a/tabby-settings/src/components/configSyncSettingsTab.component.ts b/tabby-settings/src/components/configSyncSettingsTab.component.ts index 8e41f624..0cb9c3e3 100644 --- a/tabby-settings/src/components/configSyncSettingsTab.component.ts +++ b/tabby-settings/src/components/configSyncSettingsTab.component.ts @@ -59,7 +59,7 @@ export class ConfigSyncSettingsTabComponent extends BaseComponent { const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = this.translate.instant('Name for the new config') modal.componentInstance.value = name - name = (await modal.result)?.value + name = (await modal.result.catch(() => null))?.value if (!name) { return } diff --git a/tabby-settings/src/components/editProfileGroupModal.component.pug b/tabby-settings/src/components/editProfileGroupModal.component.pug new file mode 100644 index 00000000..c5864fdd --- /dev/null +++ b/tabby-settings/src/components/editProfileGroupModal.component.pug @@ -0,0 +1,32 @@ +.modal-header + h3.m-0 {{group.name}} + +.modal-body + .row + .col-12.col-lg-4 + .mb-3 + label(translate) Name + input.form-control( + type='text', + autofocus, + [(ngModel)]='group.name', + ) + + .col-12.col-lg-8 + .form-line.content-box + .header + .title(translate) Default profile group settings + .description(translate) These apply to all profiles of a given type in this group + + .list-group.mt-3.mb-3.content-box + a.list-group-item.list-group-item-action.d-flex.align-items-center( + (click)='editDefaults(provider)', + *ngFor='let provider of providers' + ) {{provider.name|translate}} + .me-auto + button.btn.btn-link.hover-reveal.ms-1((click)='$event.stopPropagation(); deleteDefaults(provider)') + i.fas.fa-trash-arrow-up + +.modal-footer + button.btn.btn-primary((click)='save()', translate) Save + button.btn.btn-danger((click)='cancel()', translate) Cancel diff --git a/tabby-settings/src/components/editProfileGroupModal.component.ts b/tabby-settings/src/components/editProfileGroupModal.component.ts new file mode 100644 index 00000000..e1cec231 --- /dev/null +++ b/tabby-settings/src/components/editProfileGroupModal.component.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { Component, Input } from '@angular/core' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, TranslateService } from 'tabby-core' + +/** @hidden */ +@Component({ + templateUrl: './editProfileGroupModal.component.pug', +}) +export class EditProfileGroupModalComponent { + @Input() group: G & ConfigProxy + @Input() providers: ProfileProvider[] + + constructor ( + private modalInstance: NgbActiveModal, + private platform: PlatformService, + private translate: TranslateService, + ) {} + + save () { + this.modalInstance.close({ group: this.group }) + } + + cancel () { + this.modalInstance.dismiss() + } + + editDefaults (provider: ProfileProvider) { + this.modalInstance.close({ group: this.group, provider }) + } + + async deleteDefaults (provider: ProfileProvider): Promise { + if ((await this.platform.showMessageBox( + { + type: 'warning', + message: this.translate.instant('Restore settings to inherited defaults ?'), + buttons: [ + this.translate.instant('Delete'), + this.translate.instant('Keep'), + ], + defaultId: 1, + cancelId: 1, + }, + )).response === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.group.defaults?.[provider.id] + } + } +} + +export interface EditProfileGroupModalComponentResult { + group: G + provider?: ProfileProvider +} diff --git a/tabby-settings/src/components/editProfileModal.component.pug b/tabby-settings/src/components/editProfileModal.component.pug index 7869099b..7b971bcd 100644 --- a/tabby-settings/src/components/editProfileModal.component.pug +++ b/tabby-settings/src/components/editProfileModal.component.pug @@ -1,7 +1,7 @@ -.modal-header(*ngIf='!defaultsMode') +.modal-header(*ngIf='defaultsMode === "disabled"') h3.m-0 {{profile.name}} -.modal-header(*ngIf='defaultsMode') +.modal-header(*ngIf='defaultsMode !== "disabled"') h3.m-0( translate='Defaults for {type}', [translateParams]='{type: profileProvider.name}' @@ -10,7 +10,7 @@ .modal-body .row .col-12.col-lg-4 - .mb-3(*ngIf='!defaultsMode') + .mb-3(*ngIf='defaultsMode === "disabled"') label(translate) Name input.form-control( type='text', @@ -18,17 +18,20 @@ [(ngModel)]='profile.name', ) - .mb-3(*ngIf='!defaultsMode') + .mb-3(*ngIf='defaultsMode === "disabled"') label(translate) Group input.form-control( type='text', alwaysVisibleTypeahead, placeholder='Ungrouped', - [(ngModel)]='profile.group', + [(ngModel)]='profileGroup', [ngbTypeahead]='groupTypeahead', + [inputFormatter]="groupFormatter", + [resultFormatter]="groupFormatter", + [editable]="false" ) - .mb-3(*ngIf='!defaultsMode') + .mb-3(*ngIf='defaultsMode === "disabled"') label(translate) Icon .input-group input.form-control( @@ -74,9 +77,15 @@ ) 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(*ngIf='isConnectable()', ngValue='reconnect', translate) Reconnect option(ngValue='close', translate) Close - + + .form-line(*ngIf='isConnectable()') + .header + .title(translate) Clear terminal after connection + toggle( + [(ngModel)]='profile.clearServiceMessagesOnConnect', + ) .mb-4 .col-12.col-lg-8(*ngIf='this.profileProvider.settingsComponent') diff --git a/tabby-settings/src/components/editProfileModal.component.ts b/tabby-settings/src/components/editProfileModal.component.ts index 90636672..ba037c30 100644 --- a/tabby-settings/src/components/editProfileModal.component.ts +++ b/tabby-settings/src/components/editProfileModal.component.ts @@ -2,7 +2,7 @@ import { Observable, OperatorFunction, debounceTime, map, distinctUntilChanged } from 'rxjs' import { Component, Input, ViewChild, ViewContainerRef, ComponentFactoryResolver, Injector } from '@angular/core' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigProxy, ConfigService, Profile, ProfileProvider, ProfileSettingsComponent, ProfilesService, TAB_COLORS } from 'tabby-core' +import { ConfigProxy, PartialProfileGroup, Profile, ProfileProvider, ProfileSettingsComponent, ProfilesService, TAB_COLORS, ProfileGroup, ConnectableProfileProvider } from 'tabby-core' const iconsData = require('../../../tabby-core/src/icons.json') const iconsClassList = Object.keys(iconsData).map( @@ -19,8 +19,9 @@ export class EditProfileModalComponent

{ @Input() profile: P & ConfigProxy @Input() profileProvider: ProfileProvider

@Input() settingsComponent: new () => ProfileSettingsComponent

- @Input() defaultsMode = false - groupNames: string[] + @Input() defaultsMode: 'enabled'|'group'|'disabled' = 'disabled' + @Input() profileGroup: PartialProfileGroup | undefined + groups: PartialProfileGroup[] @ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef private _profile: Profile @@ -30,14 +31,14 @@ export class EditProfileModalComponent

{ private injector: Injector, private componentFactoryResolver: ComponentFactoryResolver, private profilesService: ProfilesService, - config: ConfigService, private modalInstance: NgbActiveModal, ) { - this.groupNames = [...new Set( - (config.store.profiles as Profile[]) - .map(x => x.group) - .filter(x => !!x), - )].sort() as string[] + if (this.defaultsMode === 'disabled') { + this.profilesService.getProfileGroups().then(groups => { + this.groups = groups + this.profileGroup = groups.find(g => g.id === this.profile.group) + }) + } } colorsAutocomplete = text$ => text$.pipe( @@ -56,7 +57,7 @@ export class EditProfileModalComponent

{ ngOnInit () { this._profile = this.profile - this.profile = this.profilesService.getConfigProxyForProfile(this.profile, this.defaultsMode) + this.profile = this.profilesService.getConfigProxyForProfile(this.profile, { skipGlobalDefaults: this.defaultsMode === 'enabled', skipGroupDefaults: this.defaultsMode === 'group' }) } ngAfterViewInit () { @@ -72,13 +73,15 @@ export class EditProfileModalComponent

{ } } - groupTypeahead = (text$: Observable) => + groupTypeahead: OperatorFunction[]> = (text$: Observable) => text$.pipe( debounceTime(200), distinctUntilChanged(), - map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase()))), + map(q => this.groups.filter(g => !q || g.name.toLowerCase().includes(q.toLowerCase()))), ) + groupFormatter = (g: PartialProfileGroup) => g.name + iconSearch: OperatorFunction = (text$: Observable) => text$.pipe( debounceTime(200), @@ -86,7 +89,12 @@ export class EditProfileModalComponent

{ ) save () { - this.profile.group ||= undefined + if (!this.profileGroup) { + this.profile.group = undefined + } else { + this.profile.group = this.profileGroup.id + } + this.settingsComponentInstance?.save?.() this.profile.__cleanup() this.modalInstance.close(this._profile) @@ -95,4 +103,9 @@ export class EditProfileModalComponent

{ cancel () { this.modalInstance.dismiss() } + + isConnectable (): boolean { + return this.profileProvider instanceof ConnectableProfileProvider + } + } diff --git a/tabby-settings/src/components/profilesSettingsTab.component.pug b/tabby-settings/src/components/profilesSettingsTab.component.pug index 9ce3f981..f6d60b1a 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.pug +++ b/tabby-settings/src/components/profilesSettingsTab.component.pug @@ -27,9 +27,17 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') i.fas.fa-fw.fa-search input.form-control(type='search', [placeholder]='"Filter"|translate', [(ngModel)]='filter') - button.btn.btn-primary.flex-shrink-0.ms-3((click)='newProfile()') - i.fas.fa-fw.fa-plus - span(translate) New profile + div(ngbDropdown).d-inline-block.flex-shrink-0.ms-3 + button.btn.btn-primary(ngbDropdownToggle) + i.fas.fa-fw.fa-plus + span(translate) New + div(ngbDropdownMenu) + button(ngbDropdownItem, (click)='newProfile()') + i.fas.fa-fw.fa-plus + span(translate) New profile + button(ngbDropdownItem, (click)='newProfileGroup()') + i.fas.fa-fw.fa-plus + span(translate) New profile Group .list-group.mt-3.mb-3 ng-container(*ngFor='let group of profileGroups') @@ -37,17 +45,17 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') .list-group-item.list-group-item-action.d-flex.align-items-center( (click)='toggleGroupCollapse(group)' ) - .fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed') - .fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed') + .fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed && group.profiles?.length > 0') + .fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed && group.profiles?.length > 0') span.ms-3.me-auto {{group.name || ("Ungrouped"|translate)}} button.btn.btn-sm.btn-link.hover-reveal.ms-2( *ngIf='group.editable && group.name', - (click)='$event.stopPropagation(); editGroup(group)' + (click)='$event.stopPropagation(); editProfileGroup(group)' ) i.fas.fa-pencil-alt button.btn.btn-sm.btn-link.hover-reveal.ms-2( *ngIf='group.editable && group.name', - (click)='$event.stopPropagation(); deleteGroup(group)' + (click)='$event.stopPropagation(); deleteProfileGroup(group)' ) i.fas.fa-trash-alt ng-container(*ngIf='!group.collapsed') @@ -67,7 +75,7 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') .me-auto - button.btn.btn-link.hover-reveal.ms-1((click)='$event.stopPropagation(); launchProfile(profile)') + button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)') i.fas.fa-play .ms-1.hover-reveal(ngbDropdown, placement='bottom-right top-right auto') @@ -169,9 +177,12 @@ ul.nav-tabs(ngbNav, #nav='ngbNav') .description(translate) These apply to all profiles of a given type .list-group.mt-3.mb-3.content-box - a.list-group-item.list-group-item-action( + a.list-group-item.list-group-item-action.d-flex.align-items-center( (click)='editDefaults(provider)', *ngFor='let provider of profileProviders' ) {{provider.name|translate}} + .me-auto + button.btn.btn-link.hover-reveal.ms-1((click)='$event.stopPropagation(); deleteDefaults(provider)') + i.fas.fa-trash-arrow-up div([ngbNavOutlet]='nav') diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index 38898ae6..58439ca0 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -1,32 +1,27 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker' -import { v4 as uuidv4 } from 'uuid' -import slugify from 'slugify' import deepClone from 'clone-deep' import { Component, Inject } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent, PartialProfile, ProfileProvider, TranslateService, Platform, AppHotkeyProvider } from 'tabby-core' +import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent, PartialProfile, ProfileProvider, TranslateService, Platform, ProfileGroup, PartialProfileGroup, QuickConnectProfileProvider } from 'tabby-core' import { EditProfileModalComponent } from './editProfileModal.component' - -interface ProfileGroup { - name?: string - profiles: PartialProfile[] - editable: boolean - collapsed: boolean -} +import { EditProfileGroupModalComponent, EditProfileGroupModalComponentResult } from './editProfileGroupModal.component' _('Filter') _('Ungrouped') +interface CollapsableProfileGroup extends ProfileGroup { + collapsed: boolean +} + /** @hidden */ @Component({ templateUrl: './profilesSettingsTab.component.pug', styleUrls: ['./profilesSettingsTab.component.scss'], }) export class ProfilesSettingsTabComponent extends BaseComponent { - profiles: PartialProfile[] = [] builtinProfiles: PartialProfile[] = [] templateProfiles: PartialProfile[] = [] - profileGroups: ProfileGroup[] + profileGroups: PartialProfileGroup[] filter = '' Platform = Platform @@ -59,7 +54,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { async newProfile (base?: PartialProfile): Promise { if (!base) { - let profiles = [...this.templateProfiles, ...this.builtinProfiles, ...this.profiles] + let profiles = await this.profilesService.getProfiles() profiles = profiles.filter(x => !this.isProfileBlacklisted(x)) profiles.sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0)) base = await this.selector.show( @@ -67,10 +62,13 @@ export class ProfilesSettingsTabComponent extends BaseComponent { profiles.map(p => ({ icon: p.icon, description: this.profilesService.getDescription(p) ?? undefined, - name: p.group ? `${p.group} / ${p.name}` : p.name, + name: p.group ? `${this.profilesService.resolveProfileGroupName(p.group)} / ${p.name}` : p.name, result: p, })), - ) + ).catch(() => undefined) + if (!base) { + return + } } const profile: PartialProfile = deepClone(base) delete profile.id @@ -90,8 +88,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { const cfgProxy = this.profilesService.getConfigProxyForProfile(profile) profile.name = this.profilesService.providerForProfile(profile)?.getSuggestedName(cfgProxy) ?? this.translate.instant('{name} copy', base) } - profile.id = `${profile.type}:custom:${slugify(profile.name)}:${uuidv4()}` - this.config.store.profiles = [profile, ...this.config.store.profiles] + await this.profilesService.newProfile(profile) await this.config.save() } @@ -101,6 +98,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { return } Object.assign(profile, result) + await this.profilesService.writeProfile(profile) await this.config.save() } @@ -144,69 +142,80 @@ export class ProfilesSettingsTabComponent extends BaseComponent { cancelId: 1, }, )).response === 0) { - this.profilesService.providerForProfile(profile)?.deleteProfile( - this.profilesService.getConfigProxyForProfile(profile)) - this.config.store.profiles = this.config.store.profiles.filter(x => x !== profile) - const profileHotkeyName = AppHotkeyProvider.getProfileHotkeyName(profile) - if (this.config.store.hotkeys.profile.hasOwnProperty(profileHotkeyName)) { - const profileHotkeys = deepClone(this.config.store.hotkeys.profile) - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete profileHotkeys[profileHotkeyName] - this.config.store.hotkeys.profile = profileHotkeys - } + await this.profilesService.deleteProfile(profile) await this.config.save() } } - refresh (): void { - this.profiles = this.config.store.profiles - this.profileGroups = [] - const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') - - for (const profile of this.profiles) { - // Group null, undefined and empty together - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - let group = this.profileGroups.find(x => x.name === (profile.group || '')) - if (!group) { - group = { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - name: profile.group || '', - profiles: [], - editable: true, - collapsed: profileGroupCollapsed[profile.group ?? ''] ?? false, - } - this.profileGroups.push(group) - } - group.profiles.push(profile) - } - - this.profileGroups.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? -1) - - const builtIn = { - name: this.translate.instant('Built-in'), - profiles: this.builtinProfiles, - editable: false, - collapsed: false, - } - builtIn.collapsed = profileGroupCollapsed[builtIn.name ?? ''] ?? false - this.profileGroups.push(builtIn) - } - - async editGroup (group: ProfileGroup): Promise { + async newProfileGroup (): Promise { const modal = this.ngbModal.open(PromptModalComponent) - modal.componentInstance.prompt = this.translate.instant('New name') - modal.componentInstance.value = group.name - const result = await modal.result - if (result) { - for (const profile of this.profiles.filter(x => x.group === group.name)) { - profile.group = result.value - } - this.config.store.profiles = this.profiles + modal.componentInstance.prompt = this.translate.instant('New group name') + const result = await modal.result.catch(() => null) + if (result?.value.trim()) { + await this.profilesService.newProfileGroup({ id: '', name: result.value }) await this.config.save() } } - async deleteGroup (group: ProfileGroup): Promise { + async editProfileGroup (group: PartialProfileGroup): Promise { + const result = await this.showProfileGroupEditModal(group) + if (!result) { + return + } + Object.assign(group, result) + await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(group)) + await this.config.save() + } + + async showProfileGroupEditModal (group: PartialProfileGroup): Promise|null> { + const modal = this.ngbModal.open( + EditProfileGroupModalComponent, + { size: 'lg' }, + ) + + modal.componentInstance.group = deepClone(group) + modal.componentInstance.providers = this.profileProviders + + const result: EditProfileGroupModalComponentResult | null = await modal.result.catch(() => null) + if (!result) { + return null + } + + if (result.provider) { + return this.editProfileGroupDefaults(result.group, result.provider) + } + + return result.group + } + + private async editProfileGroupDefaults (group: PartialProfileGroup, provider: ProfileProvider): Promise|null> { + const modal = this.ngbModal.open( + EditProfileModalComponent, + { size: 'lg' }, + ) + const model = group.defaults?.[provider.id] ?? {} + model.type = provider.id + modal.componentInstance.profile = Object.assign({}, model) + modal.componentInstance.profileProvider = provider + modal.componentInstance.defaultsMode = 'group' + + const result = await modal.result.catch(() => null) + if (result) { + // Fully replace the config + for (const k in model) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete model[k] + } + Object.assign(model, result) + if (!group.defaults) { + group.defaults = {} + } + group.defaults[provider.id] = model + } + return this.showProfileGroupEditModal(group) + } + + async deleteProfileGroup (group: PartialProfileGroup): Promise { if ((await this.platform.showMessageBox( { type: 'warning', @@ -219,7 +228,8 @@ export class ProfilesSettingsTabComponent extends BaseComponent { cancelId: 1, }, )).response === 0) { - if ((await this.platform.showMessageBox( + let deleteProfiles = false + if ((group.profiles?.length ?? 0) > 0 && (await this.platform.showMessageBox( { type: 'warning', message: this.translate.instant('Delete the group\'s profiles?'), @@ -230,19 +240,26 @@ export class ProfilesSettingsTabComponent extends BaseComponent { defaultId: 0, cancelId: 0, }, - )).response === 0) { - for (const profile of this.profiles.filter(x => x.group === group.name)) { - delete profile.group - } - } else { - this.config.store.profiles = this.config.store.profiles.filter(x => x.group !== group.name) + )).response !== 0) { + deleteProfiles = true } + + await this.profilesService.deleteProfileGroup(group, { deleteProfiles }) await this.config.save() } } - isGroupVisible (group: ProfileGroup): boolean { - return !this.filter || group.profiles.some(x => this.isProfileVisible(x)) + async refresh (): Promise { + const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') + const groups = await this.profilesService.getProfileGroups({ includeNonUserGroup: true, includeProfiles: true }) + groups.sort((a, b) => a.name.localeCompare(b.name)) + groups.sort((a, b) => (a.id === 'built-in' || !a.editable ? 1 : 0) - (b.id === 'built-in' || !b.editable ? 1 : 0)) + groups.sort((a, b) => (a.id === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1)) + this.profileGroups = groups.map(g => ProfilesSettingsTabComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false)) + } + + isGroupVisible (group: PartialProfileGroup): boolean { + return !this.filter || (group.profiles ?? []).some(x => this.isProfileVisible(x)) } isProfileVisible (profile: PartialProfile): boolean { @@ -270,11 +287,12 @@ export class ProfilesSettingsTabComponent extends BaseComponent { }[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning' } - toggleGroupCollapse (group: ProfileGroup): void { + toggleGroupCollapse (group: PartialProfileGroup): void { + if (group.profiles?.length === 0) { + return + } group.collapsed = !group.collapsed - const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') - profileGroupCollapsed[group.name ?? ''] = group.collapsed - window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed) + this.saveProfileGroupCollapse(group) } async editDefaults (provider: ProfileProvider): Promise { @@ -282,21 +300,40 @@ export class ProfilesSettingsTabComponent extends BaseComponent { EditProfileModalComponent, { size: 'lg' }, ) - const model = this.config.store.profileDefaults[provider.id] ?? {} + const model = this.profilesService.getProviderDefaults(provider) model.type = provider.id modal.componentInstance.profile = Object.assign({}, model) modal.componentInstance.profileProvider = provider - modal.componentInstance.defaultsMode = true - const result = await modal.result - - // Fully replace the config - for (const k in model) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete model[k] + modal.componentInstance.defaultsMode = 'enabled' + const result = await modal.result.catch(() => null) + if (result) { + // Fully replace the config + for (const k in model) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete model[k] + } + Object.assign(model, result) + this.profilesService.setProviderDefaults(provider, model) + await this.config.save() + } + } + + async deleteDefaults (provider: ProfileProvider): Promise { + if ((await this.platform.showMessageBox( + { + type: 'warning', + message: this.translate.instant('Restore settings to defaults ?'), + buttons: [ + this.translate.instant('Delete'), + this.translate.instant('Keep'), + ], + defaultId: 1, + cancelId: 1, + }, + )).response === 0) { + this.profilesService.setProviderDefaults(provider, {}) + await this.config.save() } - Object.assign(model, result) - this.config.store.profileDefaults[provider.id] = model - await this.config.save() } blacklistProfile (profile: PartialProfile): void { @@ -314,6 +351,29 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } getQuickConnectProviders (): ProfileProvider[] { - return this.profileProviders.filter(x => x.supportsQuickConnect) + return this.profileProviders.filter(x => x instanceof QuickConnectProfileProvider) + } + + /** + * Save ProfileGroup collapse state in localStorage + */ + private saveProfileGroupCollapse (group: PartialProfileGroup): void { + const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') + profileGroupCollapsed[group.id] = group.collapsed + window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed) + } + + private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup): PartialProfileGroup { + const g: any = { ...group } + delete g.collapsed + return g + } + + private static intoPartialCollapsableProfileGroup (group: PartialProfileGroup, collapsed: boolean): PartialProfileGroup { + const collapsableGroup = { + ...group, + collapsed, + } + return collapsableGroup } } diff --git a/tabby-settings/src/components/vaultSettingsTab.component.ts b/tabby-settings/src/components/vaultSettingsTab.component.ts index b34fa8d6..8f81b985 100644 --- a/tabby-settings/src/components/vaultSettingsTab.component.ts +++ b/tabby-settings/src/components/vaultSettingsTab.component.ts @@ -35,9 +35,11 @@ export class VaultSettingsTabComponent extends BaseComponent { async enableVault () { const modal = this.ngbModal.open(SetVaultPassphraseModalComponent) - const newPassphrase = await modal.result - await this.vault.setEnabled(true, newPassphrase) - this.vaultContents = await this.vault.load(newPassphrase) + const newPassphrase = await modal.result.catch(() => null) + if (newPassphrase) { + await this.vault.setEnabled(true, newPassphrase) + this.vaultContents = await this.vault.load(newPassphrase) + } } async disableVault () { @@ -65,8 +67,10 @@ export class VaultSettingsTabComponent extends BaseComponent { return } const modal = this.ngbModal.open(SetVaultPassphraseModalComponent) - const newPassphrase = await modal.result - this.vault.save(this.vaultContents, newPassphrase) + const newPassphrase = await modal.result.catch(() => null) + if (newPassphrase) { + this.vault.save(this.vaultContents, newPassphrase) + } } async toggleConfigEncrypted () { @@ -118,7 +122,7 @@ export class VaultSettingsTabComponent extends BaseComponent { modal.componentInstance.prompt = this.translate.instant('New name') modal.componentInstance.value = secret.key.description - const description = (await modal.result)?.value + const description = (await modal.result.catch(() => null))?.value if (!description) { return } diff --git a/tabby-settings/src/index.ts b/tabby-settings/src/index.ts index 850b8d61..e6ae3a1e 100644 --- a/tabby-settings/src/index.ts +++ b/tabby-settings/src/index.ts @@ -7,6 +7,7 @@ import { InfiniteScrollModule } from 'ngx-infinite-scroll' import TabbyCorePlugin, { ToolbarButtonProvider, HotkeyProvider, ConfigProvider, HotkeysService, AppService } from 'tabby-core' import { EditProfileModalComponent } from './components/editProfileModal.component' +import { EditProfileGroupModalComponent } from './components/editProfileGroupModal.component' import { HotkeyInputModalComponent } from './components/hotkeyInputModal.component' import { HotkeySettingsTabComponent } from './components/hotkeySettingsTab.component' import { MultiHotkeyInputComponent } from './components/multiHotkeyInput.component' @@ -48,6 +49,7 @@ import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabP ], declarations: [ EditProfileModalComponent, + EditProfileGroupModalComponent, HotkeyInputModalComponent, HotkeySettingsTabComponent, MultiHotkeyInputComponent, diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index 901d5dd1..07a77a5c 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -1,4 +1,4 @@ -import { BaseTerminalProfile, InputProcessingOptions, LoginScriptsOptions } from 'tabby-terminal' +import { ConnectableTerminalProfile, InputProcessingOptions, LoginScriptsOptions } from 'tabby-terminal' export enum SSHAlgorithmType { HMAC = 'hmac', @@ -7,7 +7,7 @@ export enum SSHAlgorithmType { HOSTKEY = 'serverHostKey', } -export interface SSHProfile extends BaseTerminalProfile { +export interface SSHProfile extends ConnectableTerminalProfile { options: SSHProfileOptions } diff --git a/tabby-ssh/src/components/sftpPanel.component.ts b/tabby-ssh/src/components/sftpPanel.component.ts index f0bcfbef..b24134d6 100644 --- a/tabby-ssh/src/components/sftpPanel.component.ts +++ b/tabby-ssh/src/components/sftpPanel.component.ts @@ -113,8 +113,8 @@ export class SFTPPanelComponent { async openCreateDirectoryModal (): Promise { const modal = this.ngbModal.open(SFTPCreateDirectoryModalComponent) - const directoryName = await modal.result - if (directoryName !== '') { + const directoryName = await modal.result.catch(() => null) + if (directoryName?.trim()) { this.sftp.mkdir(path.join(this.path, directoryName)).then(() => { this.notifications.notice('The directory was created successfully') this.navigate(path.join(this.path, directoryName)) diff --git a/tabby-ssh/src/components/sshProfileSettings.component.ts b/tabby-ssh/src/components/sshProfileSettings.component.ts index d2891e51..59f148d8 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.ts +++ b/tabby-ssh/src/components/sshProfileSettings.component.ts @@ -75,7 +75,7 @@ export class SSHProfileSettingsComponent { modal.componentInstance.prompt = `Password for ${this.profile.options.user}@${this.profile.options.host}` modal.componentInstance.password = true try { - const result = await modal.result + const result = await modal.result.catch(() => null) if (result?.value) { this.passwordStorage.savePassword(this.profile, result.value) this.hasSavedPassword = true @@ -89,11 +89,13 @@ export class SSHProfileSettingsComponent { } async addPrivateKey () { - const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.profile.name}`) - this.profile.options.privateKeys = [ - ...this.profile.options.privateKeys!, - ref, - ] + const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.profile.name}`).catch(() => null) + if (ref) { + this.profile.options.privateKeys = [ + ...this.profile.options.privateKeys!, + ref, + ] + } } removePrivateKey (path: string) { diff --git a/tabby-ssh/src/components/sshSettingsTab.component.pug b/tabby-ssh/src/components/sshSettingsTab.component.pug index 2ff88f71..fd50f44f 100644 --- a/tabby-ssh/src/components/sshSettingsTab.component.pug +++ b/tabby-ssh/src/components/sshSettingsTab.component.pug @@ -61,12 +61,4 @@ h3 SSH (ngModelChange)='config.save()' ) -.form-line - .header - .title(translate) Clear terminal after connection - toggle( - [(ngModel)]='config.store.ssh.clearServiceMessagesOnConnect', - (ngModelChange)='config.save()', - ) - .alert.alert-info(translate) SSH connection management is now done through the "Profiles & connections" tab diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index b6c6d3eb..738862dd 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -83,7 +83,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent const jumpSession = await this.setupOneSession( this.injector, - this.profilesService.getConfigProxyForProfile(jumpConnection), + this.profilesService.getConfigProxyForProfile(jumpConnection), ) jumpSession.ref() @@ -163,10 +163,6 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent await session.start() - if (this.config.store.ssh.clearServiceMessagesOnConnect) { - this.frontend?.clear() - } - this.session?.resize(this.size.columns, this.size.rows) } diff --git a/tabby-ssh/src/config.ts b/tabby-ssh/src/config.ts index 82a35cdc..8c32ba89 100644 --- a/tabby-ssh/src/config.ts +++ b/tabby-ssh/src/config.ts @@ -11,7 +11,6 @@ export class SSHConfigProvider extends ConfigProvider { x11Display: null, knownHosts: [], verifyHostKeys: true, - clearServiceMessagesOnConnect: true, }, hotkeys: { 'restart-ssh-session': [], diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index 0952e713..36de9ce2 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -1,5 +1,5 @@ import { Injectable, InjectFlags, Injector } from '@angular/core' -import { ProfileProvider, NewTabParameters, PartialProfile, TranslateService } from 'tabby-core' +import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core' import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHTabComponent } from './components/sshTab.component' @@ -8,10 +8,9 @@ import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api' import { SSHProfileImporter } from './api/importer' @Injectable({ providedIn: 'root' }) -export class SSHProfilesService extends ProfileProvider { +export class SSHProfilesService extends QuickConnectProfileProvider { id = 'ssh' name = 'SSH' - supportsQuickConnect = true settingsComponent = SSHProfileSettingsComponent configDefaults = { options: { @@ -45,6 +44,7 @@ export class SSHProfilesService extends ProfileProvider { reuseSession: true, input: { backspace: 'backspace' }, }, + clearServiceMessagesOnConnect: true, } constructor ( diff --git a/tabby-ssh/src/services/sshMultiplexer.service.ts b/tabby-ssh/src/services/sshMultiplexer.service.ts index 2665187e..775e20fa 100644 --- a/tabby-ssh/src/services/sshMultiplexer.service.ts +++ b/tabby-ssh/src/services/sshMultiplexer.service.ts @@ -34,7 +34,7 @@ export class SSHMultiplexerService { if (!jumpConnection) { return key } - const jumpProfile = this.profilesService.getConfigProxyForProfile(jumpConnection) + const jumpProfile = this.profilesService.getConfigProxyForProfile(jumpConnection) key += '$' + await this.getMultiplexerKey(jumpProfile) } return key diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index f2931f57..02e77efe 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -210,7 +210,6 @@ export class SSHSession { if (!await this.verifyHostKey(handshake)) { this.ssh.end() reject(new Error('Host key verification failed')) - return } this.logger.info('Handshake complete:', handshake) resolve() @@ -300,7 +299,7 @@ export class SSHSession { const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = `Username for ${this.profile.options.host}` try { - const result = await modal.result + const result = await modal.result.catch(() => null) this.authUsername = result?.value ?? null } catch { this.authUsername = 'root' @@ -428,11 +427,7 @@ export class SSHSession { const modal = this.ngbModal.open(HostKeyPromptModalComponent) modal.componentInstance.selector = selector modal.componentInstance.digest = this.hostKeyDigest - try { - return await modal.result - } catch { - return false - } + return modal.result.catch(() => false) } return true } @@ -495,7 +490,7 @@ export class SSHSession { modal.componentInstance.showRememberCheckbox = true try { - const result = await modal.result + const result = await modal.result.catch(() => null) if (result) { if (result.remember) { this.savedPassword = result.value diff --git a/tabby-ssh/src/sftpContextMenu.ts b/tabby-ssh/src/sftpContextMenu.ts index 5a3a8213..490e6ee8 100644 --- a/tabby-ssh/src/sftpContextMenu.ts +++ b/tabby-ssh/src/sftpContextMenu.ts @@ -53,6 +53,6 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider { const modal = this.ngbModal.open(SFTPDeleteModalComponent) modal.componentInstance.item = item modal.componentInstance.sftp = session - await modal.result + await modal.result.catch(() => {return}) } } diff --git a/tabby-telnet/src/profiles.ts b/tabby-telnet/src/profiles.ts index fad4f8ce..212858e9 100644 --- a/tabby-telnet/src/profiles.ts +++ b/tabby-telnet/src/profiles.ts @@ -1,11 +1,11 @@ import { Injectable } from '@angular/core' -import { ProfileProvider, NewTabParameters, PartialProfile, TranslateService } from 'tabby-core' +import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core' import { TelnetProfileSettingsComponent } from './components/telnetProfileSettings.component' import { TelnetTabComponent } from './components/telnetTab.component' import { TelnetProfile } from './session' @Injectable({ providedIn: 'root' }) -export class TelnetProfilesService extends ProfileProvider { +export class TelnetProfilesService extends QuickConnectProfileProvider { id = 'telnet' name = 'Telnet' supportsQuickConnect = true @@ -21,6 +21,7 @@ export class TelnetProfilesService extends ProfileProvider { scripts: [], input: { backspace: 'backspace' }, }, + clearServiceMessagesOnConnect: false, } constructor (private translate: TranslateService) { super() } @@ -95,4 +96,12 @@ export class TelnetProfilesService extends ProfileProvider { }, } } + + intoQuickConnectString (profile: TelnetProfile): string | null { + let s = profile.options.host + if (profile.options.port !== 23) { + s = `${s}:${profile.options.port}` + } + return s + } } diff --git a/tabby-telnet/src/session.ts b/tabby-telnet/src/session.ts index 27c2cc27..1200d1c3 100644 --- a/tabby-telnet/src/session.ts +++ b/tabby-telnet/src/session.ts @@ -3,11 +3,11 @@ import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' import { Injector } from '@angular/core' import { LogService } from 'tabby-core' -import { BaseSession, BaseTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal' +import { BaseSession, ConnectableTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal' import { Subject, Observable } from 'rxjs' -export interface TelnetProfile extends BaseTerminalProfile { +export interface TelnetProfile extends ConnectableTerminalProfile { options: TelnetProfileOptions } diff --git a/tabby-terminal/src/api/connectableTerminalTab.component.ts b/tabby-terminal/src/api/connectableTerminalTab.component.ts index 97560ec1..cefd3e71 100644 --- a/tabby-terminal/src/api/connectableTerminalTab.component.ts +++ b/tabby-terminal/src/api/connectableTerminalTab.component.ts @@ -4,7 +4,7 @@ import { Injector, Component } from '@angular/core' import { first } from 'rxjs' -import { BaseTerminalProfile } from './interfaces' +import { ConnectableTerminalProfile } from './interfaces' import { BaseTerminalTabComponent } from './baseTerminalTab.component' import { GetRecoveryTokenOptions, RecoveryToken } from 'tabby-core' @@ -13,7 +13,7 @@ import { GetRecoveryTokenOptions, RecoveryToken } from 'tabby-core' * A class to base your custom connectable terminal tabs on */ @Component({ template: '' }) -export abstract class ConnectableTerminalTabComponent

extends BaseTerminalTabComponent

{ +export abstract class ConnectableTerminalTabComponent

extends BaseTerminalTabComponent

{ protected reconnectOffered = false protected isDisconnectedByHand = false @@ -57,6 +57,9 @@ export abstract class ConnectableTerminalTabComponent

{ this.reconnectOffered = false this.isDisconnectedByHand = false + if (this.profile.clearServiceMessagesOnConnect) { + this.frontend?.clear() + } } /** diff --git a/tabby-terminal/src/api/interfaces.ts b/tabby-terminal/src/api/interfaces.ts index 74143339..eb61a8f1 100644 --- a/tabby-terminal/src/api/interfaces.ts +++ b/tabby-terminal/src/api/interfaces.ts @@ -1,4 +1,4 @@ -import { Profile } from 'tabby-core' +import { ConnectableProfile, Profile } from 'tabby-core' export interface ResizeEvent { columns: number @@ -19,3 +19,5 @@ export interface TerminalColorScheme { export interface BaseTerminalProfile extends Profile { terminalColorScheme?: TerminalColorScheme } + +export interface ConnectableTerminalProfile extends BaseTerminalProfile, ConnectableProfile {} diff --git a/tabby-terminal/src/tabContextMenu.ts b/tabby-terminal/src/tabContextMenu.ts index bef7cbba..817837f0 100644 --- a/tabby-terminal/src/tabContextMenu.ts +++ b/tabby-terminal/src/tabContextMenu.ts @@ -175,7 +175,7 @@ export class SaveAsProfileContextMenu extends TabContextMenuItemProvider { const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = this.translate.instant('New profile name') modal.componentInstance.value = tab.profile.name - const name = (await modal.result)?.value + const name = (await modal.result.catch(() => null))?.value if (!name) { return }