浏览代码

Merge pull request #8726 from Clem-Fern/profiles-rework

Eugene 2 年之前
父节点
当前提交
4684b0d6f5
共有 39 个文件被更改,包括 775 次插入263 次删除
  1. 1 1
      tabby-core/src/api/index.ts
  2. 28 8
      tabby-core/src/api/profileProvider.ts
  3. 1 1
      tabby-core/src/commands.ts
  4. 1 1
      tabby-core/src/components/selectorModal.component.ts
  5. 1 0
      tabby-core/src/configDefaults.yaml
  6. 1 5
      tabby-core/src/hotkeys.ts
  7. 5 5
      tabby-core/src/index.ts
  8. 2 2
      tabby-core/src/services/commands.service.ts
  9. 40 0
      tabby-core/src/services/config.service.ts
  10. 3 2
      tabby-core/src/services/fileProviders.service.ts
  11. 331 61
      tabby-core/src/services/profiles.service.ts
  12. 1 1
      tabby-core/src/services/vault.service.ts
  13. 2 2
      tabby-core/src/tabContextMenu.ts
  14. 2 2
      tabby-serial/src/api.ts
  15. 3 2
      tabby-serial/src/profiles.ts
  16. 1 1
      tabby-settings/src/components/configSyncSettingsTab.component.ts
  17. 32 0
      tabby-settings/src/components/editProfileGroupModal.component.pug
  18. 54 0
      tabby-settings/src/components/editProfileGroupModal.component.ts
  19. 17 8
      tabby-settings/src/components/editProfileModal.component.pug
  20. 26 13
      tabby-settings/src/components/editProfileModal.component.ts
  21. 20 9
      tabby-settings/src/components/profilesSettingsTab.component.pug
  22. 148 88
      tabby-settings/src/components/profilesSettingsTab.component.ts
  23. 10 6
      tabby-settings/src/components/vaultSettingsTab.component.ts
  24. 2 0
      tabby-settings/src/index.ts
  25. 2 2
      tabby-ssh/src/api/interfaces.ts
  26. 2 2
      tabby-ssh/src/components/sftpPanel.component.ts
  27. 8 6
      tabby-ssh/src/components/sshProfileSettings.component.ts
  28. 0 8
      tabby-ssh/src/components/sshSettingsTab.component.pug
  29. 1 5
      tabby-ssh/src/components/sshTab.component.ts
  30. 0 1
      tabby-ssh/src/config.ts
  31. 3 3
      tabby-ssh/src/profiles.ts
  32. 1 1
      tabby-ssh/src/services/sshMultiplexer.service.ts
  33. 3 8
      tabby-ssh/src/session/ssh.ts
  34. 1 1
      tabby-ssh/src/sftpContextMenu.ts
  35. 11 2
      tabby-telnet/src/profiles.ts
  36. 2 2
      tabby-telnet/src/session.ts
  37. 5 2
      tabby-terminal/src/api/connectableTerminalTab.component.ts
  38. 3 1
      tabby-terminal/src/api/interfaces.ts
  39. 1 1
      tabby-terminal/src/tabContextMenu.ts

+ 1 - 1
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'
 

+ 28 - 8
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<T extends Profile> = Omit<Omit<Omit<{
     [K in keyof T]?: T[K]
 }, 'options'>, 'type'>, 'name'> & {
@@ -31,6 +35,21 @@ export type PartialProfile<T extends Profile> = Omit<Omit<Omit<{
     }
 }
 
+export interface ProfileGroup {
+    id: string
+    name: string
+    profiles: PartialProfile<Profile>[]
+    defaults: any
+    editable: boolean
+}
+
+export type PartialProfileGroup<T extends ProfileGroup> = Omit<Omit<{
+    [K in keyof T]?: T[K]
+}, 'id'>, 'name'> & {
+    id: string
+    name: string
+}
+
 export interface ProfileSettingsComponent<P extends Profile> {
     profile: P
     save?: () => void
@@ -39,7 +58,6 @@ export interface ProfileSettingsComponent<P extends Profile> {
 export abstract class ProfileProvider<P extends Profile> {
     id: string
     name: string
-    supportsQuickConnect = false
     settingsComponent?: new (...args: any[]) => ProfileSettingsComponent<P>
     configDefaults = {}
 
@@ -53,13 +71,15 @@ export abstract class ProfileProvider<P extends Profile> {
 
     abstract getDescription (profile: PartialProfile<P>): string
 
-    quickConnect (query: string): PartialProfile<P>|null {
-        return null
-    }
+    deleteProfile (profile: P): void { }
+}
 
-    intoQuickConnectString (profile: P): string|null {
-        return null
-    }
+export abstract class ConnectableProfileProvider<P extends ConnectableProfile> extends ProfileProvider<P> {}
+
+export abstract class QuickConnectProfileProvider<P extends ConnectableProfile> extends ConnectableProfileProvider<P> {
+
+    abstract quickConnect (query: string): PartialProfile<P>|null
+
+    abstract intoQuickConnectString (profile: P): string|null
 
-    deleteProfile (profile: P): void { }
 }

+ 1 - 1
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)
         }

+ 1 - 1
tabby-core/src/components/selectorModal.component.ts

@@ -78,7 +78,7 @@ export class SelectorModalComponent<T> {
                 { sort: true },
             ).search(f)
 
-            this.options.filter(x => x.freeInputPattern).forEach(freeOption => {
+            this.options.filter(x => x.freeInputPattern).sort(firstBy<SelectorOption<T>, number>(x => x.weight ?? 0)).forEach(freeOption => {
                 if (!this.filteredOptions.includes(freeOption)) {
                     this.filteredOptions.push(freeOption)
                 }

+ 1 - 0
tabby-core/src/configDefaults.yaml

@@ -31,6 +31,7 @@ hotkeys:
   profile-selectors:
     __nonStructural: true
 profiles: []
+groups: []
 profileDefaults:
   __nonStructural: true
   ssh:

+ 1 - 5
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<Profile>): string {
-        return (profile.id ?? profile.name).replace(/\./g, '-')
-    }
 }

+ 5 - 5
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<AppModule> {

+ 2 - 2
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})
     }
 }

+ 40 - 0
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<ProfileGroup>[] = []
+            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) {

+ 3 - 2
tabby-core/src/services/fileProviders.service.ts

@@ -13,8 +13,9 @@ export class FileProvidersService {
     ) { }
 
     async selectAndStoreFile (description: string): Promise<string> {
-        const p = await this.selectProvider()
-        return p.selectAndStoreFile(description)
+        return this.selectProvider().then(p => {
+            return p.selectAndStoreFile(description)
+        })
     }
 
     async retrieveFile (key: string): Promise<Buffer> {

+ 331 - 61
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<Profile>[],
     ) { }
 
+    /*
+    * Methods used to interract with ProfileProvider
+    */
+
+    getProviders (): ProfileProvider<Profile>[] {
+        return [...this.profileProviders]
+    }
+
+    providerForProfile <T extends Profile> (profile: PartialProfile<T>): ProfileProvider<T>|null {
+        const provider = this.profileProviders.find(x => x.id === profile.type) ?? null
+        return provider as unknown as ProfileProvider<T>|null
+    }
+
+    getDescription <P extends Profile> (profile: PartialProfile<P>): 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 <T extends Profile> (profile: PartialProfile<T>, 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<PartialProfile<Profile>[]> {
+        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<Profile>, options?: { genId?: boolean }): Promise<void> {
+        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<Profile>): Promise<void> {
+        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<Profile>): Promise<void> {
+        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<Profile>) => boolean -> predicate used to decide which profiles have to be deleted
+    */
+    async bulkDeleteProfiles (filter: (p: PartialProfile<Profile>) => boolean): Promise<void> {
+        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 <P extends Profile> (profile: PartialProfile<P>): Promise<BaseTabComponent|null> {
         const params = await this.newTabParametersForProfile(profile)
         if (params) {
@@ -63,52 +186,40 @@ export class ProfilesService {
         return params
     }
 
-    getProviders (): ProfileProvider<Profile>[] {
-        return [...this.profileProviders]
-    }
+    async launchProfile (profile: PartialProfile<Profile>): Promise<void> {
+        await this.openNewTabForProfile(profile)
 
-    async getProfiles (): Promise<PartialProfile<Profile>[]> {
-        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
+        let recentProfiles: PartialProfile<Profile>[] = 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)
     }
 
-    providerForProfile <T extends Profile> (profile: PartialProfile<T>): ProfileProvider<T>|null {
-        const provider = this.profileProviders.find(x => x.id === profile.type) ?? null
-        return provider as unknown as ProfileProvider<T>|null
+    static getProfileHotkeyName (profile: PartialProfile<Profile>): string {
+        return (profile.id ?? profile.name).replace(/\./g, '-')
     }
 
-    getDescription <P extends Profile> (profile: PartialProfile<P>): string|null {
-        profile = this.getConfigProxyForProfile(profile)
-        return this.providerForProfile(profile)?.getDescription(profile) ?? null
-    }
+    /*
+    * Methods used to interract with Profile Selector
+    */
 
     selectorOptionForProfile <P extends Profile, T> (profile: PartialProfile<P>): SelectorOption<T> {
         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<Profile>[] {
-        let recentProfiles: PartialProfile<Profile>[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]')
-        recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles)
-        return recentProfiles
-    }
-
     showProfileSelector (): Promise<PartialProfile<Profile>|null> {
         if (this.selector.active) {
             return Promise.resolve(null)
@@ -118,12 +229,12 @@ export class ProfilesService {
             try {
                 const recentProfiles = this.getRecentProfiles()
 
-                let options: SelectorOption<void>[] = recentProfiles.map(p => ({
+                let options: SelectorOption<void>[] = 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<Profile>[] {
+        let recentProfiles: PartialProfile<Profile>[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]')
+        recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles)
+        return recentProfiles
+    }
+
     async quickConnect (query: string): Promise<PartialProfile<Profile>|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 <T extends Profile> (profile: PartialProfile<T>, 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<Profile>): 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<Profile>, 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<Profile>, 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<Profile>): Promise<void> {
-        await this.openNewTabForProfile(profile)
+    /*
+    * Methods used to interract with ProfileGroup
+    */
 
-        let recentProfiles: PartialProfile<Profile>[] = 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)
+    /**
+    * Synchronously return an Array of the existing ProfileGroups
+    * Does not return builtin groups
+    */
+    getSyncProfileGroups (): PartialProfileGroup<ProfileGroup>[] {
+        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<PartialProfileGroup<ProfileGroup>[]> {
+        let profiles: PartialProfile<Profile>[] = []
+        if (options?.includeProfiles) {
+            profiles = await this.getProfiles({ includeBuiltin: options.includeNonUserGroup, clone: true })
+        }
+
+        let groups: PartialProfileGroup<ProfileGroup>[] = 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<ProfileGroup>[] = []
+            builtInGroups.push({
+                id: 'built-in',
+                name: this.translate.instant('Built-in'),
+                editable: false,
+                profiles: [],
+            })
+
+            const ungrouped: PartialProfileGroup<ProfileGroup> = {
+                id: 'ungrouped',
+                name: this.translate.instant('Ungrouped'),
+                editable: false,
+            }
+
+            if (options.includeProfiles) {
+                for (const profile of profiles.filter(p => p.isBuiltin)) {
+                    let group: PartialProfileGroup<ProfileGroup> | 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<ProfileGroup>, options?: { genId?: boolean }): Promise<void> {
+        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<ProfileGroup>): Promise<void> {
+        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<ProfileGroup>, options?: { deleteProfiles?: boolean }): Promise<void> {
+        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 {
-            recentProfiles = []
+            for (const profile of this.config.store.profiles.filter(x => x.group === group.id)) {
+                delete profile.group
+            }
         }
-        window.localStorage['recentProfiles'] = JSON.stringify(recentProfiles)
     }
+
+    /**
+    * 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<Profile>): any {
+        return this.getSyncProfileGroups().find(g => g.id === groupId)?.defaults?.[provider.id] ?? {}
+    }
+
 }

+ 1 - 1
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}`
             }

+ 2 - 2
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
         }

+ 2 - 2
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
 }
 

+ 3 - 2
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<SerialProfile> {
+export class SerialProfilesService extends ConnectableProfileProvider<SerialProfile> {
     id = 'serial'
     name = _('Serial')
     settingsComponent = SerialProfileSettingsComponent
@@ -32,6 +32,7 @@ export class SerialProfilesService extends ProfileProvider<SerialProfile> {
             slowSend: false,
             input: { backspace: 'backspace' },
         },
+        clearServiceMessagesOnConnect: false,
     }
 
     constructor (

+ 1 - 1
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
         }

+ 32 - 0
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

+ 54 - 0
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<G extends ProfileGroup> {
+    @Input() group: G & ConfigProxy
+    @Input() providers: ProfileProvider<Profile>[]
+
+    constructor (
+        private modalInstance: NgbActiveModal,
+        private platform: PlatformService,
+        private translate: TranslateService,
+    ) {}
+
+    save () {
+        this.modalInstance.close({ group: this.group })
+    }
+
+    cancel () {
+        this.modalInstance.dismiss()
+    }
+
+    editDefaults (provider: ProfileProvider<Profile>) {
+        this.modalInstance.close({ group: this.group, provider })
+    }
+
+    async deleteDefaults (provider: ProfileProvider<Profile>): Promise<void> {
+        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<G extends ProfileGroup> {
+    group: G
+    provider?: ProfileProvider<Profile>
+}

+ 17 - 8
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')

+ 26 - 13
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<P extends Profile> {
     @Input() profile: P & ConfigProxy
     @Input() profileProvider: ProfileProvider<P>
     @Input() settingsComponent: new () => ProfileSettingsComponent<P>
-    @Input() defaultsMode = false
-    groupNames: string[]
+    @Input() defaultsMode: 'enabled'|'group'|'disabled' = 'disabled'
+    @Input() profileGroup: PartialProfileGroup<ProfileGroup> | undefined
+    groups: PartialProfileGroup<ProfileGroup>[]
     @ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef
 
     private _profile: Profile
@@ -30,14 +31,14 @@ export class EditProfileModalComponent<P extends Profile> {
         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<P extends Profile> {
 
     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<P extends Profile> {
         }
     }
 
-    groupTypeahead = (text$: Observable<string>) =>
+    groupTypeahead: OperatorFunction<string, readonly PartialProfileGroup<ProfileGroup>[]> = (text$: Observable<string>) =>
         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<ProfileGroup>) => g.name
+
     iconSearch: OperatorFunction<string, string[]> = (text$: Observable<string>) =>
         text$.pipe(
             debounceTime(200),
@@ -86,7 +89,12 @@ export class EditProfileModalComponent<P extends Profile> {
         )
 
     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<P extends Profile> {
     cancel () {
         this.modalInstance.dismiss()
     }
+
+    isConnectable (): boolean {
+        return this.profileProvider instanceof ConnectableProfileProvider
+    }
+
 }

+ 20 - 9
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')

+ 148 - 88
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<Profile>[]
-    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<Profile>[] = []
     builtinProfiles: PartialProfile<Profile>[] = []
     templateProfiles: PartialProfile<Profile>[] = []
-    profileGroups: ProfileGroup[]
+    profileGroups: PartialProfileGroup<CollapsableProfileGroup>[]
     filter = ''
     Platform = Platform
 
@@ -59,7 +54,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
 
     async newProfile (base?: PartialProfile<Profile>): Promise<void> {
         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<Profile> = 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 ?? '{}')
+    async newProfileGroup (): Promise<void> {
+        const modal = this.ngbModal.open(PromptModalComponent)
+        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()
+        }
+    }
 
-        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)
+    async editProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<void> {
+        const result = await this.showProfileGroupEditModal(group)
+        if (!result) {
+            return
         }
+        Object.assign(group, result)
+        await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(group))
+        await this.config.save()
+    }
 
-        this.profileGroups.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? -1)
+    async showProfileGroupEditModal (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<PartialProfileGroup<CollapsableProfileGroup>|null> {
+        const modal = this.ngbModal.open(
+            EditProfileGroupModalComponent,
+            { size: 'lg' },
+        )
+
+        modal.componentInstance.group = deepClone(group)
+        modal.componentInstance.providers = this.profileProviders
+
+        const result: EditProfileGroupModalComponentResult<CollapsableProfileGroup> | null = await modal.result.catch(() => null)
+        if (!result) {
+            return null
+        }
 
-        const builtIn = {
-            name: this.translate.instant('Built-in'),
-            profiles: this.builtinProfiles,
-            editable: false,
-            collapsed: false,
+        if (result.provider) {
+            return this.editProfileGroupDefaults(result.group, result.provider)
         }
-        builtIn.collapsed = profileGroupCollapsed[builtIn.name ?? ''] ?? false
-        this.profileGroups.push(builtIn)
+
+        return result.group
     }
 
-    async editGroup (group: ProfileGroup): Promise<void> {
-        const modal = this.ngbModal.open(PromptModalComponent)
-        modal.componentInstance.prompt = this.translate.instant('New name')
-        modal.componentInstance.value = group.name
-        const result = await modal.result
+    private async editProfileGroupDefaults (group: PartialProfileGroup<CollapsableProfileGroup>, provider: ProfileProvider<Profile>): Promise<PartialProfileGroup<CollapsableProfileGroup>|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) {
-            for (const profile of this.profiles.filter(x => x.group === group.name)) {
-                profile.group = result.value
+            // Fully replace the config
+            for (const k in model) {
+                // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+                delete model[k]
             }
-            this.config.store.profiles = this.profiles
-            await this.config.save()
+            Object.assign(model, result)
+            if (!group.defaults) {
+                group.defaults = {}
+            }
+            group.defaults[provider.id] = model
         }
+        return this.showProfileGroupEditModal(group)
     }
 
-    async deleteGroup (group: ProfileGroup): Promise<void> {
+    async deleteProfileGroup (group: PartialProfileGroup<ProfileGroup>): Promise<void> {
         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<void> {
+        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<ProfileGroup>): boolean {
+        return !this.filter || (group.profiles ?? []).some(x => this.isProfileVisible(x))
     }
 
     isProfileVisible (profile: PartialProfile<Profile>): boolean {
@@ -270,11 +287,12 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
         }[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning'
     }
 
-    toggleGroupCollapse (group: ProfileGroup): void {
+    toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): 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<Profile>): Promise<void> {
@@ -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
+        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()
+        }
+    }
 
-        // Fully replace the config
-        for (const k in model) {
-            // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
-            delete model[k]
+    async deleteDefaults (provider: ProfileProvider<Profile>): Promise<void> {
+        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<Profile>): void {
@@ -314,6 +351,29 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
     }
 
     getQuickConnectProviders (): ProfileProvider<Profile>[] {
-        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<CollapsableProfileGroup>): void {
+        const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
+        profileGroupCollapsed[group.id] = group.collapsed
+        window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed)
+    }
+
+    private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): PartialProfileGroup<ProfileGroup> {
+        const g: any = { ...group }
+        delete g.collapsed
+        return g
+    }
+
+    private static intoPartialCollapsableProfileGroup (group: PartialProfileGroup<ProfileGroup>, collapsed: boolean): PartialProfileGroup<CollapsableProfileGroup> {
+        const collapsableGroup = {
+            ...group,
+            collapsed,
+        }
+        return collapsableGroup
     }
 }

+ 10 - 6
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
         }

+ 2 - 0
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,

+ 2 - 2
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
 }
 

+ 2 - 2
tabby-ssh/src/components/sftpPanel.component.ts

@@ -113,8 +113,8 @@ export class SFTPPanelComponent {
 
     async openCreateDirectoryModal (): Promise<void> {
         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))

+ 8 - 6
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) {

+ 0 - 8
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

+ 1 - 5
tabby-ssh/src/components/sshTab.component.ts

@@ -83,7 +83,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
 
                 const jumpSession = await this.setupOneSession(
                     this.injector,
-                    this.profilesService.getConfigProxyForProfile(jumpConnection),
+                    this.profilesService.getConfigProxyForProfile<SSHProfile>(jumpConnection),
                 )
 
                 jumpSession.ref()
@@ -163,10 +163,6 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
 
         await session.start()
 
-        if (this.config.store.ssh.clearServiceMessagesOnConnect) {
-            this.frontend?.clear()
-        }
-
         this.session?.resize(this.size.columns, this.size.rows)
     }
 

+ 0 - 1
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': [],

+ 3 - 3
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<SSHProfile> {
+export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile> {
     id = 'ssh'
     name = 'SSH'
-    supportsQuickConnect = true
     settingsComponent = SSHProfileSettingsComponent
     configDefaults = {
         options: {
@@ -45,6 +44,7 @@ export class SSHProfilesService extends ProfileProvider<SSHProfile> {
             reuseSession: true,
             input: { backspace: 'backspace' },
         },
+        clearServiceMessagesOnConnect: true,
     }
 
     constructor (

+ 1 - 1
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<SSHProfile>(jumpConnection)
             key += '$' + await this.getMultiplexerKey(jumpProfile)
         }
         return key

+ 3 - 8
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

+ 1 - 1
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})
     }
 }

+ 11 - 2
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<TelnetProfile> {
+export class TelnetProfilesService extends QuickConnectProfileProvider<TelnetProfile> {
     id = 'telnet'
     name = 'Telnet'
     supportsQuickConnect = true
@@ -21,6 +21,7 @@ export class TelnetProfilesService extends ProfileProvider<TelnetProfile> {
             scripts: [],
             input: { backspace: 'backspace' },
         },
+        clearServiceMessagesOnConnect: false,
     }
 
     constructor (private translate: TranslateService) { super() }
@@ -95,4 +96,12 @@ export class TelnetProfilesService extends ProfileProvider<TelnetProfile> {
             },
         }
     }
+
+    intoQuickConnectString (profile: TelnetProfile): string | null {
+        let s = profile.options.host
+        if (profile.options.port !== 23) {
+            s = `${s}:${profile.options.port}`
+        }
+        return s
+    }
 }

+ 2 - 2
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
 }
 

+ 5 - 2
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<P extends BaseTerminalProfile> extends BaseTerminalTabComponent<P> {
+export abstract class ConnectableTerminalTabComponent<P extends ConnectableTerminalProfile> extends BaseTerminalTabComponent<P> {
 
     protected reconnectOffered = false
     protected isDisconnectedByHand = false
@@ -57,6 +57,9 @@ export abstract class ConnectableTerminalTabComponent<P extends BaseTerminalProf
     async initializeSession (): Promise<void> {
         this.reconnectOffered = false
         this.isDisconnectedByHand = false
+        if (this.profile.clearServiceMessagesOnConnect) {
+            this.frontend?.clear()
+        }
     }
 
     /**

+ 3 - 1
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 {}

+ 1 - 1
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
                         }