Ver código fonte

new profile system

Eugene Pankov 4 anos atrás
pai
commit
92b34fbc08
100 arquivos alterados com 1965 adições e 2072 exclusões
  1. 0 6
      app/lib/cli.ts
  2. 4 0
      app/src/global.scss
  3. 3 0
      package.json
  4. 12 0
      scripts/generate-icon-metadata.js
  5. 0 1
      tabby-core/package.json
  6. 5 2
      tabby-core/src/api/index.ts
  7. 43 0
      tabby-core/src/api/profileProvider.ts
  8. 6 16
      tabby-core/src/api/tabRecovery.ts
  9. 115 0
      tabby-core/src/buttonProvider.ts
  10. 35 0
      tabby-core/src/cli.ts
  11. 0 0
      tabby-core/src/components/promptModal.component.pug
  12. 0 0
      tabby-core/src/components/promptModal.component.ts
  13. 1 1
      tabby-core/src/components/selectorModal.component.pug
  14. 6 6
      tabby-core/src/components/splitTab.component.ts
  15. 3 2
      tabby-core/src/configDefaults.yaml
  16. 19 0
      tabby-core/src/directives/alwaysVisibleTypeahead.directive.ts
  17. 13 1
      tabby-core/src/hotkeys.ts
  18. 0 0
      tabby-core/src/icons.json
  19. 1 0
      tabby-core/src/icons/plus.svg
  20. 0 0
      tabby-core/src/icons/profiles.svg
  21. 32 4
      tabby-core/src/index.ts
  22. 9 10
      tabby-core/src/services/app.service.ts
  23. 82 9
      tabby-core/src/services/config.service.ts
  24. 54 0
      tabby-core/src/services/profiles.service.ts
  25. 11 10
      tabby-core/src/services/tabRecovery.service.ts
  26. 20 5
      tabby-core/src/services/tabs.service.ts
  27. 2 2
      tabby-core/src/services/vault.service.ts
  28. 1 2
      tabby-core/src/theme.scss
  29. 0 33
      tabby-core/yarn.lock
  30. 0 1
      tabby-local/package.json
  31. 5 9
      tabby-local/src/api.ts
  32. 1 26
      tabby-local/src/buttonProvider.ts
  33. 2 14
      tabby-local/src/cli.ts
  34. 53 61
      tabby-local/src/components/editProfileModal.component.pug
  35. 50 10
      tabby-local/src/components/editProfileModal.component.ts
  36. 51 0
      tabby-local/src/components/localProfileSettings.component.pug
  37. 47 0
      tabby-local/src/components/localProfileSettings.component.ts
  38. 94 0
      tabby-local/src/components/profilesSettingsTab.component.pug
  39. 201 0
      tabby-local/src/components/profilesSettingsTab.component.ts
  40. 0 87
      tabby-local/src/components/shellSettingsTab.component.pug
  41. 1 76
      tabby-local/src/components/shellSettingsTab.component.ts
  42. 6 0
      tabby-local/src/components/terminalTab.component.ts
  43. 4 7
      tabby-local/src/config.ts
  44. 1 13
      tabby-local/src/hotkeys.ts
  45. 12 10
      tabby-local/src/index.ts
  46. 72 0
      tabby-local/src/profiles.ts
  47. 4 4
      tabby-local/src/recoveryProvider.ts
  48. 7 8
      tabby-local/src/services/dockMenu.service.ts
  49. 24 105
      tabby-local/src/services/terminal.service.ts
  50. 21 1
      tabby-local/src/settings.ts
  51. 0 25
      tabby-local/src/shells/custom.ts
  52. 1 1
      tabby-local/src/shells/macDefault.ts
  53. 1 0
      tabby-local/src/shells/posix.ts
  54. 1 1
      tabby-local/src/shells/winDefault.ts
  55. 12 9
      tabby-local/src/tabContextMenu.ts
  56. 0 5
      tabby-local/yarn.lock
  57. 24 26
      tabby-serial/src/api.ts
  58. 0 36
      tabby-serial/src/buttonProvider.ts
  59. 0 30
      tabby-serial/src/cli.ts
  60. 0 200
      tabby-serial/src/components/editConnectionModal.component.pug
  61. 171 0
      tabby-serial/src/components/serialProfileSettings.component.pug
  62. 23 34
      tabby-serial/src/components/serialProfileSettings.component.ts
  63. 0 16
      tabby-serial/src/components/serialSettingsTab.component.pug
  64. 0 82
      tabby-serial/src/components/serialSettingsTab.component.ts
  65. 1 1
      tabby-serial/src/components/serialTab.component.pug
  66. 8 8
      tabby-serial/src/components/serialTab.component.ts
  67. 0 5
      tabby-serial/src/config.ts
  68. 6 14
      tabby-serial/src/index.ts
  69. 74 0
      tabby-serial/src/profiles.ts
  70. 5 5
      tabby-serial/src/recoveryProvider.ts
  71. 32 106
      tabby-serial/src/services/serial.service.ts
  72. 0 16
      tabby-serial/src/settings.ts
  73. 1 1
      tabby-settings/src/buttonProvider.ts
  74. 11 15
      tabby-settings/src/components/hotkeySettingsTab.component.pug
  75. 0 7
      tabby-settings/src/components/hotkeySettingsTab.component.scss
  76. 1 4
      tabby-settings/src/components/hotkeySettingsTab.component.ts
  77. 1 0
      tabby-settings/src/components/settingsTab.component.ts
  78. 0 1
      tabby-ssh/package.json
  79. 23 24
      tabby-ssh/src/api.ts
  80. 0 43
      tabby-ssh/src/buttonProvider.ts
  81. 0 30
      tabby-ssh/src/cli.ts
  82. 0 269
      tabby-ssh/src/components/editConnectionModal.component.pug
  83. 231 0
      tabby-ssh/src/components/sshProfileSettings.component.pug
  84. 45 64
      tabby-ssh/src/components/sshProfileSettings.component.ts
  85. 1 54
      tabby-ssh/src/components/sshSettingsTab.component.pug
  86. 0 3
      tabby-ssh/src/components/sshSettingsTab.component.scss
  87. 2 145
      tabby-ssh/src/components/sshSettingsTab.component.ts
  88. 1 1
      tabby-ssh/src/components/sshTab.component.pug
  89. 17 17
      tabby-ssh/src/components/sshTab.component.ts
  90. 0 2
      tabby-ssh/src/config.ts
  91. 0 4
      tabby-ssh/src/hotkeys.ts
  92. 0 1
      tabby-ssh/src/icons/globe.svg
  93. 6 11
      tabby-ssh/src/index.ts
  94. 79 0
      tabby-ssh/src/profiles.ts
  95. 5 5
      tabby-ssh/src/recoveryProvider.ts
  96. 21 21
      tabby-ssh/src/services/passwordStorage.service.ts
  97. 27 153
      tabby-ssh/src/services/ssh.service.ts
  98. 1 1
      tabby-ssh/src/tabContextMenu.ts
  99. 0 33
      tabby-ssh/yarn.lock
  100. 0 1
      tabby-terminal/package.json

+ 0 - 6
app/lib/cli.ts

@@ -16,12 +16,6 @@ export function parseArgs (argv: string[], cwd: string): any {
         .command('profile [profileName]', 'open a tab with specified profile', {
             profileName: { type: 'string' },
         })
-        .command('connect-ssh [connectionName]', 'open a tab for a saved SSH connection', {
-            connectionName: { type: 'string' },
-        })
-        .command('connect-serial [connectionName]', 'open a tab for a saved serial connection', {
-            connectionName: { type: 'string' },
-        })
         .command('paste [text]', 'paste stdin into the active tab', yargs => {
             return yargs.option('escape', {
                 alias: 'e',

+ 4 - 0
app/src/global.scss

@@ -158,3 +158,7 @@ ngb-typeahead-window {
     text-overflow: ellipsis;
     overflow: hidden;
 }
+
+.list-group-item > button {
+    margin: -7px 0;
+}

+ 3 - 0
package.json

@@ -14,6 +14,7 @@
     "@typescript-eslint/parser": "^4.28.0",
     "apply-loader": "2.0.0",
     "awesome-typescript-loader": "^5.2.1",
+    "clone-deep": "^4.0.1",
     "compare-versions": "^3.6.0",
     "core-js": "^3.14.0",
     "cross-env": "7.0.3",
@@ -45,6 +46,7 @@
     "raw-loader": "4.0.2",
     "sass-loader": "^12.1.0",
     "shelljs": "0.8.4",
+    "slugify": "^1.5.3",
     "source-code-pro": "^2.38.0",
     "source-sans-pro": "3.6.0",
     "style-loader": "^3.0.0",
@@ -60,6 +62,7 @@
     "yaml-loader": "0.6.0"
   },
   "resolutions": {
+    "lzma-native": "^8.0.0",
     "*/node-abi": "^2.30.0",
     "**/graceful-fs": "^4.2.4"
   },

+ 12 - 0
scripts/generate-icon-metadata.js

@@ -0,0 +1,12 @@
+#!/usr/bin/env node
+const jsYaml = require('js-yaml')
+const fs = require('fs')
+const path = require('path')
+const metadata = jsYaml.load(fs.readFileSync(path.resolve(__dirname, '../node_modules/@fortawesome/fontawesome-free/metadata/icons.yml')))
+
+let result = {}
+for (let key in metadata) {
+    result[key] = metadata[key].styles.map(x => x[0])
+}
+
+fs.writeFileSync(path.resolve(__dirname, '../tabby-core/src/icons.json'), JSON.stringify(result))

+ 0 - 1
tabby-core/package.json

@@ -19,7 +19,6 @@
   "devDependencies": {
     "@types/js-yaml": "^4.0.0",
     "bootstrap": "^4.1.3",
-    "clone-deep": "^4.0.1",
     "core-js": "^3.1.2",
     "deep-equal": "^2.0.5",
     "deepmerge": "^4.1.1",

+ 5 - 2
tabby-core/src/api/index.ts

@@ -2,7 +2,7 @@ export { BaseComponent, SubscriptionContainer } from '../components/base.compone
 export { BaseTabComponent, BaseTabProcess } from '../components/baseTab.component'
 export { TabHeaderComponent } from '../components/tabHeader.component'
 export { SplitTabComponent, SplitContainer } from '../components/splitTab.component'
-export { TabRecoveryProvider, RecoveredTab, RecoveryToken } from './tabRecovery'
+export { TabRecoveryProvider, RecoveryToken } from './tabRecovery'
 export { ToolbarButtonProvider, ToolbarButton } from './toolbarButtonProvider'
 export { ConfigProvider } from './configProvider'
 export { HotkeyProvider, HotkeyDescription } from './hotkeyProvider'
@@ -16,6 +16,8 @@ export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
 export { HostWindowService } from './hostWindow'
 export { HostAppService, Platform } from './hostApp'
 export { FileProvider } from './fileProvider'
+export { ProfileProvider, Profile, ProfileSettingsComponent } from './profileProvider'
+export { PromptModalComponent } from '../components/promptModal.component'
 
 export { AppService } from '../services/app.service'
 export { ConfigService } from '../services/config.service'
@@ -25,8 +27,9 @@ export { HomeBaseService } from '../services/homeBase.service'
 export { HotkeysService } from '../services/hotkeys.service'
 export { NotificationsService } from '../services/notifications.service'
 export { ThemesService } from '../services/themes.service'
+export { ProfilesService } from '../services/profiles.service'
 export { SelectorService } from '../services/selector.service'
-export { TabsService } from '../services/tabs.service'
+export { TabsService, NewTabParameters, TabComponentType } from '../services/tabs.service'
 export { UpdaterService } from '../services/updater.service'
 export { VaultService, Vault, VaultSecret, VAULT_SECRET_TYPE_FILE } from '../services/vault.service'
 export { FileProvidersService } from '../services/fileProviders.service'

+ 43 - 0
tabby-core/src/api/profileProvider.ts

@@ -0,0 +1,43 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable @typescript-eslint/no-empty-function */
+import { BaseTabComponent } from '../components/baseTab.component'
+import { NewTabParameters } from '../services/tabs.service'
+
+export interface Profile {
+    id?: string
+    type: string
+    name: string
+    group?: string
+    options?: Record<string, any>
+
+    icon?: string
+    color?: string
+    disableDynamicTitle?: boolean
+
+    isBuiltin?: boolean
+    isTemplate?: boolean
+}
+
+export interface ProfileSettingsComponent {
+    profile: Profile
+    save?: () => void
+}
+
+export abstract class ProfileProvider {
+    id: string
+    name: string
+    supportsQuickConnect = false
+    settingsComponent: new (...args: any[]) => ProfileSettingsComponent
+
+    abstract getBuiltinProfiles (): Promise<Profile[]>
+
+    abstract getNewTabParameters (profile: Profile): Promise<NewTabParameters<BaseTabComponent>>
+
+    abstract getDescription (profile: Profile): string
+
+    quickConnect (query: string): Profile|null {
+        return null
+    }
+
+    deleteProfile (profile: Profile): void { }
+}

+ 6 - 16
tabby-core/src/api/tabRecovery.ts

@@ -1,17 +1,6 @@
 import deepClone from 'clone-deep'
-import { TabComponentType } from '../services/tabs.service'
-
-export interface RecoveredTab {
-    /**
-     * Component type to be instantiated
-     */
-    type: TabComponentType
-
-    /**
-     * Component instance inputs
-     */
-    options?: any
-}
+import { BaseTabComponent } from '../components/baseTab.component'
+import { NewTabParameters } from '../services/tabs.service'
 
 export interface RecoveryToken {
     [_: string]: any
@@ -35,19 +24,20 @@ export interface RecoveryToken {
  * }
  * ```
  */
-export abstract class TabRecoveryProvider {
+export abstract class TabRecoveryProvider <T extends BaseTabComponent> {
     /**
      * @param recoveryToken a recovery token found in the saved tabs list
      * @returns [[boolean]] whether this [[TabRecoveryProvider]] can recover a tab from this token
      */
 
     abstract applicableTo (recoveryToken: RecoveryToken): Promise<boolean>
+
     /**
      * @param recoveryToken a recovery token found in the saved tabs list
-     * @returns [[RecoveredTab]] descriptor containing tab type and component inputs
+     * @returns [[NewTabParameters]] descriptor containing tab type and component inputs
      *          or `null` if this token is from a different tab type or is not supported
      */
-    abstract recover (recoveryToken: RecoveryToken): Promise<RecoveredTab>
+    abstract recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<T>>
 
     /**
      * @param recoveryToken a recovery token found in the saved tabs list

+ 115 - 0
tabby-core/src/buttonProvider.ts

@@ -0,0 +1,115 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Injectable } from '@angular/core'
+
+import { ToolbarButton, ToolbarButtonProvider } from './api/toolbarButtonProvider'
+import { SelectorService } from './services/selector.service'
+import { HostAppService, Platform } from './api/hostApp'
+import { Profile } from './api/profileProvider'
+import { ConfigService } from './services/config.service'
+import { SelectorOption } from './api/selector'
+import { ProfilesService } from './services/profiles.service'
+import { AppService } from './services/app.service'
+import { NotificationsService } from './services/notifications.service'
+
+/** @hidden */
+@Injectable()
+export class ButtonProvider extends ToolbarButtonProvider {
+    constructor (
+        private selector: SelectorService,
+        private app: AppService,
+        private hostApp: HostAppService,
+        private profilesServices: ProfilesService,
+        private config: ConfigService,
+        private notifications: NotificationsService,
+    ) {
+        super()
+    }
+
+    async activate () {
+        const recentProfiles: Profile[] = this.config.store.recentProfiles
+
+        const getProfileOptions = (profile): SelectorOption<void> => ({
+            icon: recentProfiles.includes(profile) ? 'fas fa-history' : profile.icon,
+            name: profile.group ? `${profile.group} / ${profile.name}` : profile.name,
+            description: this.profilesServices.providerForProfile(profile)?.getDescription(profile),
+            callback: () => this.launchProfile(profile),
+        })
+
+        let options = recentProfiles.map(getProfileOptions)
+        if (recentProfiles.length) {
+            options.push({
+                name: 'Clear recent connections',
+                icon: 'fas fa-eraser',
+                callback: () => {
+                    this.config.store.recentProfiles = []
+                    this.config.save()
+                },
+            })
+        }
+
+        let profiles = await this.profilesServices.getProfiles()
+
+        if (!this.config.store.terminal.showBuiltinProfiles) {
+            profiles = profiles.filter(x => !x.isBuiltin)
+        }
+
+        profiles = profiles.filter(x => !x.isTemplate)
+
+        options = [...options, ...profiles.map(getProfileOptions)]
+
+        try {
+            const { SettingsTabComponent } = window['nodeRequire']('tabby-settings')
+            options.push({
+                name: 'Manage profiles',
+                icon: 'fas fa-window-restore',
+                callback: () => this.app.openNewTabRaw({
+                    type: SettingsTabComponent,
+                    inputs: { activeTab: 'profiles' },
+                }),
+            })
+        } catch { }
+
+        if (this.profilesServices.getProviders().some(x => x.supportsQuickConnect)) {
+            options.push({
+                name: 'Quick connect',
+                freeInputPattern: 'Connect to "%s"...',
+                icon: 'fas fa-arrow-right',
+                callback: query => this.quickConnect(query),
+            })
+        }
+        await this.selector.show('Select profile', options)
+    }
+
+    quickConnect (query: string) {
+        for (const provider of this.profilesServices.getProviders()) {
+            const profile = provider.quickConnect(query)
+            if (profile) {
+                this.launchProfile(profile)
+                return
+            }
+        }
+        this.notifications.error(`Could not parse "${query}"`)
+    }
+
+    async launchProfile (profile: Profile) {
+        await this.profilesServices.openNewTabForProfile(profile)
+
+        const recentProfiles = this.config.store.recentProfiles
+        recentProfiles.unshift(profile)
+        if (recentProfiles.length > 5) {
+            recentProfiles.pop()
+        }
+        this.config.store.recentProfiles = recentProfiles
+        this.config.save()
+    }
+
+    provide (): ToolbarButton[] {
+        return [{
+            icon: this.hostApp.platform === Platform.Web
+                ? require('./icons/plus.svg')
+                : require('./icons/profiles.svg'),
+            title: 'New tab with profile',
+            click: () => this.activate(),
+        }]
+    }
+}

+ 35 - 0
tabby-core/src/cli.ts

@@ -1,6 +1,41 @@
 import { Injectable } from '@angular/core'
 import { HostAppService } from './api/hostApp'
 import { CLIHandler, CLIEvent } from './api/cli'
+import { HostWindowService } from './api/hostWindow'
+import { ProfilesService } from './services/profiles.service'
+
+@Injectable()
+export class ProfileCLIHandler extends CLIHandler {
+    firstMatchOnly = true
+    priority = 0
+
+    constructor (
+        private profiles: ProfilesService,
+        private hostWindow: HostWindowService,
+    ) {
+        super()
+    }
+
+    async handle (event: CLIEvent): Promise<boolean> {
+        const op = event.argv._[0]
+
+        if (op === 'profile') {
+            this.handleOpenProfile(event.argv.profileName)
+            return true
+        }
+        return false
+    }
+
+    private async handleOpenProfile (profileName: string) {
+        const profile = (await this.profiles.getProfiles()).find(x => x.name === profileName)
+        if (!profile) {
+            console.error('Requested profile', profileName, 'not found')
+            return
+        }
+        this.profiles.openNewTabForProfile(profile)
+        this.hostWindow.bringToFront()
+    }
+}
 
 @Injectable()
 export class LastCLIHandler extends CLIHandler {

+ 0 - 0
tabby-ssh/src/components/promptModal.component.pug → tabby-core/src/components/promptModal.component.pug


+ 0 - 0
tabby-ssh/src/components/promptModal.component.ts → tabby-core/src/components/promptModal.component.ts


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

@@ -15,7 +15,7 @@
             *ngFor='let option of filteredOptions; let i = index'
         )
             i.icon(
-                class='fa-fw fas fa-{{option.icon}}',
+                class='fa-fw {{option.icon}}',
                 *ngIf='!iconIsSVG(option.icon)'
             )
             .icon(

+ 6 - 6
tabby-core/src/components/splitTab.component.ts

@@ -1,8 +1,8 @@
 import { Observable, Subject } from 'rxjs'
 import { Component, Injectable, ViewChild, ViewContainerRef, EmbeddedViewRef, AfterViewInit, OnDestroy } from '@angular/core'
 import { BaseTabComponent, BaseTabProcess } from './baseTab.component'
-import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from '../api/tabRecovery'
-import { TabsService } from '../services/tabs.service'
+import { TabRecoveryProvider, RecoveryToken } from '../api/tabRecovery'
+import { TabsService, NewTabParameters } from '../services/tabs.service'
 import { HotkeysService } from '../services/hotkeys.service'
 import { TabRecoveryService } from '../services/tabRecovery.service'
 
@@ -601,7 +601,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
             } else {
                 const recovered = await this.tabRecovery.recoverTab(childState, duplicate)
                 if (recovered) {
-                    const tab = this.tabsService.create(recovered.type, recovered.options)
+                    const tab = this.tabsService.create(recovered)
                     children.push(tab)
                     tab.parent = this
                     this.attachTabView(tab)
@@ -619,15 +619,15 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
 
 /** @hidden */
 @Injectable()
-export class SplitTabRecoveryProvider extends TabRecoveryProvider {
+export class SplitTabRecoveryProvider extends TabRecoveryProvider<SplitTabComponent> {
     async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
         return recoveryToken.type === 'app:split-tab'
     }
 
-    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+    async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<SplitTabComponent>> {
         return {
             type: SplitTabComponent,
-            options: { _recoveredState: recoveryToken },
+            inputs: { _recoveredState: recoveryToken },
         }
     }
 

+ 3 - 2
tabby-core/src/configDefaults.yaml

@@ -14,8 +14,9 @@ appearance:
   opacity: 1.0
   vibrancy: true
   vibrancyType: 'blur'
-terminal:
-  recoverTabs: true
+profiles: []
+recentProfiles: []
+recoverTabs: true
 enableAnalytics: true
 enableWelcomeTab: true
 electronFlags:

+ 19 - 0
tabby-core/src/directives/alwaysVisibleTypeahead.directive.ts

@@ -0,0 +1,19 @@
+import { Directive, ElementRef, AfterViewInit } from '@angular/core'
+
+/** @hidden */
+@Directive({
+    selector: '[alwaysVisibleTypeahead]',
+})
+export class AlwaysVisibleTypeaheadDirective implements AfterViewInit {
+    constructor (private el: ElementRef) { }
+
+    ngAfterViewInit (): void {
+        this.el.nativeElement.addEventListener('focus', e => {
+            e.stopPropagation()
+            setTimeout(() => {
+                const inputEvent: Event = new Event('input')
+                e.target.dispatchEvent(inputEvent)
+            }, 0)
+        })
+    }
+}

+ 13 - 1
tabby-core/src/hotkeys.ts

@@ -1,4 +1,5 @@
 import { Injectable } from '@angular/core'
+import { ProfilesService } from './services/profiles.service'
 import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider'
 
 /** @hidden */
@@ -171,7 +172,18 @@ export class AppHotkeyProvider extends HotkeyProvider {
         },
     ]
 
+    constructor (
+        private profilesService: ProfilesService,
+    ) { super() }
+
     async provide (): Promise<HotkeyDescription[]> {
-        return this.hotkeys
+        const profiles = await this.profilesService.getProfiles()
+        return [
+            ...this.hotkeys,
+            ...profiles.map(profile => ({
+                id: `profile.${profile.id}`,
+                name: `New tab: ${profile.name}`,
+            })),
+        ]
     }
 }

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
tabby-core/src/icons.json


+ 1 - 0
tabby-core/src/icons/plus.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-plus fa-w-12 fa-3x" data-icon="plus" data-prefix="fal" focusable="false" role="img" viewBox="0 0 384 512"><path fill="#fff" stroke="none" stroke-width="1" d="M376 232H216V72c0-4.42-3.58-8-8-8h-32c-4.42 0-8 3.58-8 8v160H8c-4.42 0-8 3.58-8 8v32c0 4.42 3.58 8 8 8h160v160c0 4.42 3.58 8 8 8h32c4.42 0 8-3.58 8-8V280h160c4.42 0 8-3.58 8-8v-32c0-4.42-3.58-8-8-8z"/></svg>

+ 0 - 0
tabby-local/src/icons/profiles.svg → tabby-core/src/icons/profiles.svg


+ 32 - 4
tabby-core/src/index.ts

@@ -10,6 +10,7 @@ import { DndModule } from 'ng2-dnd'
 import { AppRootComponent } from './components/appRoot.component'
 import { CheckboxComponent } from './components/checkbox.component'
 import { TabBodyComponent } from './components/tabBody.component'
+import { PromptModalComponent } from './components/promptModal.component'
 import { SafeModeModalComponent } from './components/safeModeModal.component'
 import { StartPageComponent } from './components/startPage.component'
 import { TabHeaderComponent } from './components/tabHeader.component'
@@ -25,20 +26,23 @@ import { WelcomeTabComponent } from './components/welcomeTab.component'
 import { TransfersMenuComponent } from './components/transfersMenu.component'
 
 import { AutofocusDirective } from './directives/autofocus.directive'
+import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive'
 import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
 import { DropZoneDirective } from './directives/dropZone.directive'
 
-import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider } from './api'
+import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ToolbarButtonProvider, ProfilesService } from './api'
 
 import { AppService } from './services/app.service'
 import { ConfigService } from './services/config.service'
 import { VaultFileProvider } from './services/vault.service'
+import { HotkeysService } from './services/hotkeys.service'
 
 import { StandardTheme, StandardCompactTheme, PaperTheme } from './theme'
 import { CoreConfigProvider } from './config'
 import { AppHotkeyProvider } from './hotkeys'
 import { TaskCompletionContextMenu, CommonOptionsContextMenu, TabManagementContextMenu } from './tabContextMenu'
-import { LastCLIHandler } from './cli'
+import { LastCLIHandler, ProfileCLIHandler } from './cli'
+import { ButtonProvider } from './buttonProvider'
 
 import 'perfect-scrollbar/css/perfect-scrollbar.css'
 import 'ng2-dnd/bundles/style.css'
@@ -53,9 +57,11 @@ const PROVIDERS = [
     { provide: TabContextMenuItemProvider, useClass: TabManagementContextMenu, multi: true },
     { provide: TabContextMenuItemProvider, useClass: TaskCompletionContextMenu, multi: true },
     { provide: TabRecoveryProvider, useClass: SplitTabRecoveryProvider, multi: true },
+    { provide: CLIHandler, useClass: ProfileCLIHandler, multi: true },
     { provide: CLIHandler, useClass: LastCLIHandler, multi: true },
     { provide: PERFECT_SCROLLBAR_CONFIG, useValue: { suppressScrollX: true } },
     { provide: FileProvider, useClass: VaultFileProvider, multi: true },
+    { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
 ]
 
 /** @hidden */
@@ -72,6 +78,7 @@ const PROVIDERS = [
     declarations: [
         AppRootComponent as any,
         CheckboxComponent,
+        PromptModalComponent,
         StartPageComponent,
         TabBodyComponent,
         TabHeaderComponent,
@@ -82,6 +89,7 @@ const PROVIDERS = [
         SafeModeModalComponent,
         AutofocusDirective,
         FastHtmlBindDirective,
+        AlwaysVisibleTypeaheadDirective,
         SelectorModalComponent,
         SplitTabComponent,
         SplitTabSpannerComponent,
@@ -91,6 +99,7 @@ const PROVIDERS = [
         DropZoneDirective,
     ],
     entryComponents: [
+        PromptModalComponent,
         RenameTabModalComponent,
         SafeModeModalComponent,
         SelectorModalComponent,
@@ -101,21 +110,40 @@ const PROVIDERS = [
     exports: [
         CheckboxComponent,
         ToggleComponent,
+        PromptModalComponent,
         AutofocusDirective,
         DropZoneDirective,
+        FastHtmlBindDirective,
+        AlwaysVisibleTypeaheadDirective,
     ],
 })
 export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
-    constructor (app: AppService, config: ConfigService, platform: PlatformService) {
+    constructor (
+        app: AppService,
+        config: ConfigService,
+        platform: PlatformService,
+        hotkeys: HotkeysService,
+        profilesService: ProfilesService,
+    ) {
         app.ready$.subscribe(() => {
             if (config.store.enableWelcomeTab) {
-                app.openNewTabRaw(WelcomeTabComponent)
+                app.openNewTabRaw({ type: WelcomeTabComponent })
             }
         })
 
         platform.setErrorHandler(err => {
             console.error('Unhandled exception:', err)
         })
+
+        hotkeys.matchedHotkey.subscribe(async (hotkey) => {
+            if (hotkey.startsWith('profile.')) {
+                const id = hotkey.split('.')[1]
+                const profile = (await profilesService.getProfiles()).find(x => x.id === id)
+                if (profile) {
+                    profilesService.openNewTabForProfile(profile)
+                }
+            }
+        })
     }
 
     static forRoot (): ModuleWithProviders<AppModule> {

+ 9 - 10
tabby-core/src/services/app.service.ts

@@ -1,4 +1,3 @@
-
 import { Observable, Subject, AsyncSubject } from 'rxjs'
 import { takeUntil } from 'rxjs/operators'
 import { Injectable, Inject } from '@angular/core'
@@ -13,7 +12,7 @@ import { HostAppService } from '../api/hostApp'
 
 import { ConfigService } from './config.service'
 import { TabRecoveryService } from './tabRecovery.service'
-import { TabsService, TabComponentType } from './tabs.service'
+import { TabsService, NewTabParameters } from './tabs.service'
 import { SelectorService } from './selector.service'
 
 class CompletionObserver {
@@ -88,10 +87,10 @@ export class AppService {
 
         config.ready$.toPromise().then(async () => {
             if (this.bootstrapData.isFirstWindow) {
-                if (config.store.terminal.recoverTabs) {
+                if (config.store.recoverTabs) {
                     const tabs = await this.tabRecovery.recoverTabs()
                     for (const tab of tabs) {
-                        this.openNewTabRaw(tab.type, tab.options)
+                        this.openNewTabRaw(tab)
                     }
                 }
                 /** Continue to store the tabs even if the setting is currently off */
@@ -152,8 +151,8 @@ export class AppService {
      * Adds a new tab **without** wrapping it in a SplitTabComponent
      * @param inputs  Properties to be assigned on the new tab component instance
      */
-    openNewTabRaw (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
-        const tab = this.tabsService.create(type, inputs)
+    openNewTabRaw <T extends BaseTabComponent> (params: NewTabParameters<T>): T {
+        const tab = this.tabsService.create(params)
         this.addTabRaw(tab)
         return tab
     }
@@ -162,9 +161,9 @@ export class AppService {
      * Adds a new tab while wrapping it in a SplitTabComponent
      * @param inputs  Properties to be assigned on the new tab component instance
      */
-    openNewTab (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
-        const splitTab = this.tabsService.create(SplitTabComponent) as SplitTabComponent
-        const tab = this.tabsService.create(type, inputs)
+    openNewTab <T extends BaseTabComponent> (params: NewTabParameters<T>): T {
+        const splitTab = this.tabsService.create({ type: SplitTabComponent })
+        const tab = this.tabsService.create(params)
         splitTab.addTab(tab, null, 'r')
         this.addTabRaw(splitTab)
         return tab
@@ -175,7 +174,7 @@ export class AppService {
         if (token) {
             const recoveredTab = await this.tabRecovery.recoverTab(token)
             if (recoveredTab) {
-                const tab = this.tabsService.create(recoveredTab.type, recoveredTab.options)
+                const tab = this.tabsService.create(recoveredTab)
                 if (this.activeTab) {
                     this.addTabRaw(tab, this.tabs.indexOf(this.activeTab) + 1)
                 } else {

+ 82 - 9
tabby-core/src/services/config.service.ts

@@ -1,5 +1,6 @@
-import { Observable, Subject, AsyncSubject } from 'rxjs'
+import { v4 as uuidv4 } from 'uuid'
 import * as yaml from 'js-yaml'
+import { Observable, Subject, AsyncSubject } from 'rxjs'
 import { Injectable, Inject } from '@angular/core'
 import { ConfigProvider } from '../api/configProvider'
 import { PlatformService } from '../api/platform'
@@ -58,18 +59,27 @@ export class ConfigProxy {
             if (real[key] !== undefined) {
                 return real[key]
             } else {
-                if (isNonStructuralObjectMember(defaults[key])) {
-                    real[key] = { ...defaults[key] }
-                    delete real[key].__nonStructural
-                    return real[key]
-                } else {
-                    return defaults[key]
-                }
+                return this.getDefault(key)
+            }
+        }
+
+        this.getDefault = (key: string) => { // eslint-disable-line @typescript-eslint/unbound-method
+            if (isNonStructuralObjectMember(defaults[key])) {
+                real[key] = { ...defaults[key] }
+                delete real[key].__nonStructural
+                return real[key]
+            } else {
+                return defaults[key]
             }
         }
 
         this.setValue = (key: string, value: any) => { // eslint-disable-line @typescript-eslint/unbound-method
-            real[key] = value
+            if (value === this.getDefault(key)) {
+                // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+                delete real[key]
+            } else {
+                real[key] = value
+            }
         }
     }
 
@@ -77,6 +87,8 @@ export class ConfigProxy {
     getValue (_key: string): any { }
     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
     setValue (_key: string, _value: any) { }
+    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
+    getDefault (_key: string) { }
 }
 
 @Injectable({ providedIn: 'root' })
@@ -250,6 +262,67 @@ export class ConfigService {
             }
             config.version = 1
         }
+        if (config.version < 2) {
+            if (config.terminal?.recoverTabs !== undefined) {
+                config.recoverTabs = config.terminal.recoverTabs
+                delete config.terminal.recoverTabs
+            }
+            for (const profile of config.terminal?.profiles ?? []) {
+                if (profile.sessionOptions) {
+                    profile.options = profile.sessionOptions
+                    delete profile.sessionOptions
+                }
+                profile.type = 'local'
+                profile.id = `local:custom:${uuidv4()}`
+            }
+            if (config.terminal?.profiles) {
+                config.profiles = config.terminal.profiles
+                delete config.terminal.profiles
+                delete config.terminal.environment
+                config.terminal.profile = `local:${config.terminal.profile}`
+            }
+            config.version = 2
+        }
+        if (config.version < 3) {
+            delete config.ssh.recentConnections
+            for (const c of config.ssh?.connections ?? []) {
+                const p = {
+                    id: `ssh:${uuidv4()}`,
+                    type: 'ssh',
+                    icon: 'fas fa-desktop',
+                    name: c.name,
+                    group: c.group ?? undefined,
+                    color: c.color,
+                    disableDynamicTitle: c.disableDynamicTitle,
+                    options: c,
+                }
+                config.profiles.push(p)
+            }
+            for (const p of config.profiles ?? []) {
+                if (p.type === 'ssh') {
+                    if (p.options.jumpHost) {
+                        p.options.jumpHost = config.profiles.find(x => x.name === p.options.jumpHost)?.id
+                    }
+                }
+            }
+            for (const c of config.serial?.connections ?? []) {
+                const p = {
+                    id: `serial:${uuidv4()}`,
+                    type: 'serial',
+                    icon: 'fas fa-microchip',
+                    name: c.name,
+                    group: c.group ?? undefined,
+                    color: c.color,
+                    options: c,
+                }
+                config.profiles.push(p)
+            }
+            delete config.ssh?.connections
+            delete config.serial?.connections
+            delete window.localStorage.lastSerialConnection
+            // config.version = 3
+            // migrate jump hosts
+        }
     }
 
     private async maybeDecryptConfig (store) {

+ 54 - 0
tabby-core/src/services/profiles.service.ts

@@ -0,0 +1,54 @@
+import { Injectable, Inject } from '@angular/core'
+import { NewTabParameters } from './tabs.service'
+import { BaseTabComponent } from '../components/baseTab.component'
+import { Profile, ProfileProvider } from '../api/profileProvider'
+import { AppService } from './app.service'
+import { ConfigService } from './config.service'
+
+@Injectable({ providedIn: 'root' })
+export class ProfilesService {
+    constructor (
+        private app: AppService,
+        private config: ConfigService,
+        @Inject(ProfileProvider) private profileProviders: ProfileProvider[],
+    ) { }
+
+    async openNewTabForProfile (profile: Profile): Promise<BaseTabComponent|null> {
+        const params = await this.newTabParametersForProfile(profile)
+        if (params) {
+            const tab = this.app.openNewTab(params)
+            ;(this.app.getParentTab(tab) ?? tab).color = profile.color ?? null
+            if (profile.disableDynamicTitle) {
+                tab['enableDynamicTitle'] = false
+                tab.setTitle(profile.name)
+            }
+            return tab
+        }
+        return null
+    }
+
+    async newTabParametersForProfile (profile: Profile): Promise<NewTabParameters<BaseTabComponent>|null> {
+        return this.providerForProfile(profile)?.getNewTabParameters(profile) ?? null
+    }
+
+    getProviders (): ProfileProvider[] {
+        return [...this.profileProviders]
+    }
+
+    async getProfiles (): Promise<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,
+        ]
+        list.sort((a, b) => a.group?.localeCompare(b.group ?? '') ?? -1)
+        list.sort((a, b) => a.name.localeCompare(b.name))
+        list.sort((a, b) => (a.isBuiltin ? 1 : 0) - (b.isBuiltin ? 1 : 0))
+        return list
+    }
+
+    providerForProfile (profile: Profile): ProfileProvider|null {
+        return this.profileProviders.find(x => x.id === profile.type) ?? null
+    }
+}

+ 11 - 10
tabby-core/src/services/tabRecovery.service.ts

@@ -1,8 +1,9 @@
 import { Injectable, Inject } from '@angular/core'
-import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from '../api/tabRecovery'
+import { TabRecoveryProvider, RecoveryToken } from '../api/tabRecovery'
 import { BaseTabComponent } from '../components/baseTab.component'
-import { Logger, LogService } from '../services/log.service'
-import { ConfigService } from '../services/config.service'
+import { Logger, LogService } from './log.service'
+import { ConfigService } from './config.service'
+import { NewTabParameters } from './tabs.service'
 
 /** @hidden */
 @Injectable({ providedIn: 'root' })
@@ -11,7 +12,7 @@ export class TabRecoveryService {
     enabled = false
 
     private constructor (
-        @Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[]|null,
+        @Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider<BaseTabComponent>[]|null,
         private config: ConfigService,
         log: LogService
     ) {
@@ -40,7 +41,7 @@ export class TabRecoveryService {
         return token
     }
 
-    async recoverTab (token: RecoveryToken, duplicate = false): Promise<RecoveredTab|null> {
+    async recoverTab (token: RecoveryToken, duplicate = false): Promise<NewTabParameters<BaseTabComponent>|null> {
         for (const provider of this.config.enabledServices(this.tabRecoveryProviders ?? [])) {
             try {
                 if (!await provider.applicableTo(token)) {
@@ -50,9 +51,9 @@ export class TabRecoveryService {
                     token = provider.duplicate(token)
                 }
                 const tab = await provider.recover(token)
-                tab.options = tab.options || {}
-                tab.options.color = token.tabColor ?? null
-                tab.options.title = token.tabTitle || ''
+                tab.inputs = tab.inputs ?? {}
+                tab.inputs.color = token.tabColor ?? null
+                tab.inputs.title = token.tabTitle || ''
                 return tab
             } catch (error) {
                 this.logger.warn('Tab recovery crashed:', token, provider, error)
@@ -61,9 +62,9 @@ export class TabRecoveryService {
         return null
     }
 
-    async recoverTabs (): Promise<RecoveredTab[]> {
+    async recoverTabs (): Promise<NewTabParameters<BaseTabComponent>[]> {
         if (window.localStorage.tabsRecovery) {
-            const tabs: RecoveredTab[] = []
+            const tabs: NewTabParameters<BaseTabComponent>[] = []
             for (const token of JSON.parse(window.localStorage.tabsRecovery)) {
                 const tab = await this.recoverTab(token)
                 if (tab) {

+ 20 - 5
tabby-core/src/services/tabs.service.ts

@@ -3,7 +3,22 @@ import { BaseTabComponent } from '../components/baseTab.component'
 import { TabRecoveryService } from './tabRecovery.service'
 
 // eslint-disable-next-line @typescript-eslint/no-type-alias
-export type TabComponentType = new (...args: any[]) => BaseTabComponent
+export interface TabComponentType<T extends BaseTabComponent> {
+    // eslint-disable-next-line @typescript-eslint/prefer-function-type
+    new (...args: any[]): T
+}
+
+export interface NewTabParameters<T extends BaseTabComponent> {
+    /**
+     * Component type to be instantiated
+     */
+    type: TabComponentType<T>
+
+    /**
+     * Component instance inputs
+     */
+    inputs?: Record<string, any>
+}
 
 @Injectable({ providedIn: 'root' })
 export class TabsService {
@@ -17,12 +32,12 @@ export class TabsService {
     /**
      * Instantiates a tab component and assigns given inputs
      */
-    create (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
-        const componentFactory = this.componentFactoryResolver.resolveComponentFactory(type)
+    create <T extends BaseTabComponent> (params: NewTabParameters<T>): T {
+        const componentFactory = this.componentFactoryResolver.resolveComponentFactory(params.type)
         const componentRef = componentFactory.create(this.injector)
         const tab = componentRef.instance
         tab.hostView = componentRef.hostView
-        Object.assign(tab, inputs ?? {})
+        Object.assign(tab, params.inputs ?? {})
         return tab
     }
 
@@ -36,7 +51,7 @@ export class TabsService {
         }
         const dup = await this.tabRecovery.recoverTab(token, true)
         if (dup) {
-            return this.create(dup.type, dup.options)
+            return this.create(dup)
         }
         return null
     }

+ 2 - 2
tabby-core/src/services/vault.service.ts

@@ -247,12 +247,12 @@ export class VaultFileProvider extends FileProvider {
             const result = await this.selector.show<VaultSecret|null>('Select file', [
                 {
                     name: 'Add a new file',
-                    icon: 'plus',
+                    icon: 'fas fa-plus',
                     result: null,
                 },
                 ...files.map(f => ({
                     name: f.key.description,
-                    icon: 'file',
+                    icon: 'fas fa-file',
                     result: f,
                 })),
             ])

+ 1 - 2
tabby-core/src/theme.scss

@@ -235,12 +235,11 @@ hotkey-input-modal {
     }
 }
 
-
 .list-group-light {
     .list-group-item {
         background: transparent;
         border: none;
-        border-top: 1px solid rgba(255, 255, 255, .1);
+        border-top: 1px solid rgba(255, 255, 255, .05);
 
         &:first-child {
             border-top: none;

+ 0 - 33
tabby-core/yarn.lock

@@ -50,15 +50,6 @@ call-bind@^1.0.0, call-bind@^1.0.2:
     function-bind "^1.1.1"
     get-intrinsic "^1.0.2"
 
-clone-deep@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
-  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
-  dependencies:
-    is-plain-object "^2.0.4"
-    kind-of "^6.0.2"
-    shallow-clone "^3.0.0"
-
 core-js@^3.1.2:
   version "3.14.0"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.14.0.tgz#62322b98c71cc2018b027971a69419e2425c2a6c"
@@ -282,13 +273,6 @@ is-number-object@^1.0.4:
   resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.5.tgz#6edfaeed7950cff19afedce9fbfca9ee6dd289eb"
   integrity sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw==
 
-is-plain-object@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
-  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
-  dependencies:
-    isobject "^3.0.1"
-
 is-regex@^1.1.1, is-regex@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f"
@@ -340,11 +324,6 @@ isarray@^2.0.5:
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
   integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
 
-isobject@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
-  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
-
 js-yaml@^4.0.0, js-yaml@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
@@ -361,11 +340,6 @@ jsonfile@^6.0.1:
   optionalDependencies:
     graceful-fs "^4.1.6"
 
-kind-of@^6.0.2:
-  version "6.0.3"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
-  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
-
 lazy-val@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.4.tgz#882636a7245c2cfe6e0a4e3ba6c5d68a137e5c65"
@@ -494,13 +468,6 @@ semver@^7.3.5:
   dependencies:
     lru-cache "^6.0.0"
 
-shallow-clone@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
-  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
-  dependencies:
-    kind-of "^6.0.2"
-
 side-channel@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"

+ 0 - 1
tabby-local/package.json

@@ -29,7 +29,6 @@
     "ps-node": "^0.1.6",
     "runes": "^0.4.2",
     "shell-escape": "^0.2.0",
-    "slugify": "^1.5.3",
     "utils-decorators": "^1.8.3"
   },
   "peerDependencies": {

+ 5 - 9
tabby-local/src/api.ts

@@ -1,6 +1,8 @@
+import { Profile } from 'tabby-core'
+
 export interface Shell {
     id: string
-    name?: string
+    name: string
     command: string
     args?: string[]
     env: Record<string, string>
@@ -40,14 +42,8 @@ export interface SessionOptions {
     runAsAdministrator?: boolean
 }
 
-export interface Profile {
-    name: string
-    color?: string
-    sessionOptions: SessionOptions
-    shell?: string
-    isBuiltin?: boolean
-    icon?: string
-    disableDynamicTitle?: boolean
+export interface LocalProfile extends Profile {
+    options: SessionOptions
 }
 
 export interface ChildProcess {

+ 1 - 26
tabby-local/src/buttonProvider.ts

@@ -1,37 +1,17 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { Injectable } from '@angular/core'
-import { ToolbarButtonProvider, ToolbarButton, ConfigService, SelectorOption, SelectorService } from 'tabby-core'
-import { ElectronService } from 'tabby-electron'
-
+import { ToolbarButtonProvider, ToolbarButton } from 'tabby-core'
 import { TerminalService } from './services/terminal.service'
 
 /** @hidden */
 @Injectable()
 export class ButtonProvider extends ToolbarButtonProvider {
     constructor (
-        electron: ElectronService,
-        private selector: SelectorService,
-        private config: ConfigService,
         private terminal: TerminalService,
     ) {
         super()
     }
 
-    async activate () {
-        const options: SelectorOption<void>[] = []
-        const profiles = await this.terminal.getProfiles({ skipDefault: !this.config.store.terminal.showDefaultProfiles })
-
-        for (const profile of profiles) {
-            options.push({
-                icon: profile.icon,
-                name: profile.name,
-                callback: () => this.terminal.openTab(profile),
-            })
-        }
-
-        await this.selector.show('Select profile', options)
-    }
-
     provide (): ToolbarButton[] {
         return [
             {
@@ -42,11 +22,6 @@ export class ButtonProvider extends ToolbarButtonProvider {
                     this.terminal.openTab()
                 },
             },
-            {
-                icon: require('./icons/profiles.svg'),
-                title: 'New terminal with profile',
-                click: () => this.activate(),
-            },
         ]
     }
 }

+ 2 - 14
tabby-local/src/cli.ts

@@ -10,7 +10,6 @@ export class TerminalCLIHandler extends CLIHandler {
     priority = 0
 
     constructor (
-        private config: ConfigService,
         private hostWindow: HostWindowService,
         private terminal: TerminalService,
     ) {
@@ -24,8 +23,6 @@ export class TerminalCLIHandler extends CLIHandler {
             this.handleOpenDirectory(path.resolve(event.cwd, event.argv.directory))
         } else if (op === 'run') {
             this.handleRunCommand(event.argv.command)
-        } else if (op === 'profile') {
-            this.handleOpenProfile(event.argv.profileName)
         } else {
             return false
         }
@@ -47,24 +44,15 @@ export class TerminalCLIHandler extends CLIHandler {
 
     private handleRunCommand (command: string[]) {
         this.terminal.openTab({
+            type: 'local',
             name: '',
-            sessionOptions: {
+            options: {
                 command: command[0],
                 args: command.slice(1),
             },
         }, null, true)
         this.hostWindow.bringToFront()
     }
-
-    private handleOpenProfile (profileName: string) {
-        const profile = this.config.store.terminal.profiles.find(x => x.name === profileName)
-        if (!profile) {
-            console.error('Requested profile', profileName, 'not found')
-            return
-        }
-        this.terminal.openTabWithOptions(profile.sessionOptions)
-        this.hostWindow.bringToFront()
-    }
 }
 
 

+ 53 - 61
tabby-local/src/components/editProfileModal.component.pug

@@ -1,72 +1,64 @@
-.modal-body
-    .form-group
-        label Name
-        input.form-control(
-            type='text',
-            autofocus,
-            [(ngModel)]='profile.name',
-        )
+.modal-header
+    h3.m-0 {{profile.name}}
 
-    .form-group
-        label Command
-        input.form-control(
-            type='text',
-            [(ngModel)]='profile.sessionOptions.command',
-        )
+.modal-body
+    .row
+        .col-12.col-lg-4
+            .form-group
+                label Name
+                input.form-control(
+                    type='text',
+                    autofocus,
+                    [(ngModel)]='profile.name',
+                )
 
-    .form-group
-        label Arguments
-        .input-group(
-            *ngFor='let arg of profile.sessionOptions.args; index as i; trackBy: trackByIndex',
-        )
-            input.form-control(
-                type='text',
-                [(ngModel)]='profile.sessionOptions.args[i]',
-            )
-            .input-group-append
-                button.btn.btn-secondary((click)='profile.sessionOptions.args.splice(i, 1)')
-                    i.fas.fa-trash
+            .form-group
+                label Group
+                input.form-control(
+                    type='text',
+                    alwaysVisibleTypeahead,
+                    placeholder='Ungrouped',
+                    [(ngModel)]='profile.group',
+                    [ngbTypeahead]='groupTypeahead',
+                )
 
-        .mt-2
-            button.btn.btn-secondary((click)='profile.sessionOptions.args.push("")')
-                i.fas.fa-plus.mr-2
-                | Add
+            .form-group
+                label Icon
+                .input-group
+                    input.form-control(
+                        type='text',
+                        alwaysVisibleTypeahead,
+                        [(ngModel)]='profile.icon',
+                        [ngbTypeahead]='iconSearch',
+                        [resultTemplate]='rt'
+                    )
+                    .input-group-append
+                        .input-group-text
+                            i([class]='"fa-fw " + profile.icon')
 
-    .form-line(*ngIf='uac.isAvailable')
-        .header
-            .title Run as administrator
-        toggle(
-            [(ngModel)]='profile.sessionOptions.runAsAdministrator',
-        )
+                ng-template(#rt,let-r='result',let-t='term')
+                    i([class]='"fa-fw " + r')
+                    ngb-highlight.ml-2([result]='r', [term]='t')
 
-    .form-group
-        label Working directory
-        input.form-control(
-            type='text',
-            [(ngModel)]='profile.sessionOptions.cwd',
-        )
+            .form-line
+                .header
+                    .title Color
+                input.form-control.w-50(
+                    type='text',
+                    [(ngModel)]='profile.color',
+                    placeholder='#000000'
+                )
 
-    .form-group
-        label Environment
-        environment-editor(
-            type='text',
-            [(model)]='profile.sessionOptions.env',
-        )
+            .form-line
+                .header
+                    .title Disable dynamic tab title
+                    .description Connection name will be used instead
+                toggle([(ngModel)]='profile.disableDynamicTitle')
 
-    .form-group
-        label Tab color
-        input.form-control(
-            type='text',
-            autofocus,
-            [(ngModel)]='profile.color',
-            placeholder='#000000'
-        )
+            .mb-4
 
-    .form-line
-        .header
-            .title Disable dynamic tab title
-            .description Connection name will be used as a title instead
-        toggle([(ngModel)]='profile.disableDynamicTitle')
+        .col-12.col-lg-8
+            ng-template(#placeholder)
 
 .modal-footer
     button.btn.btn-outline-primary((click)='save()') Save

+ 50 - 10
tabby-local/src/components/editProfileModal.component.ts

@@ -1,36 +1,76 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Component } from '@angular/core'
+import { Observable, OperatorFunction } from 'rxjs'
+import { debounceTime, map, distinctUntilChanged } from 'rxjs/operators'
+import { Component, Input, ViewChild, ViewContainerRef, ComponentFactoryResolver, Injector } from '@angular/core'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 import { UACService } from '../services/uac.service'
-import { Profile } from '../api'
+import { LocalProfile } from '../api'
+import { ConfigService, Profile, ProfileProvider, ProfileSettingsComponent } from 'tabby-core'
+
+const iconsData = require('../../../tabby-core/src/icons.json')
+const iconsClassList = Object.keys(iconsData).map(
+    icon => iconsData[icon].map(
+        style => `fa${style[0]} fa-${icon}`
+    )
+).flat()
 
 /** @hidden */
 @Component({
     template: require('./editProfileModal.component.pug'),
 })
 export class EditProfileModalComponent {
-    profile: Profile
+    @Input() profile: LocalProfile
+    @Input() profileProvider: ProfileProvider
+    @Input() settingsComponent: new () => ProfileSettingsComponent
+    groupNames: string[]
+    @ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef
+
+    private settingsComponentInstance: ProfileSettingsComponent
 
     constructor (
         public uac: UACService,
+        private injector: Injector,
+        private componentFactoryResolver: ComponentFactoryResolver,
+        config: ConfigService,
         private modalInstance: NgbActiveModal,
     ) {
+        this.groupNames = [...new Set(
+            (config.store.profiles as Profile[])
+                .map(x => x.group)
+                .filter(x => !!x)
+        )].sort() as string[]
     }
 
-    ngOnInit () {
-        this.profile.sessionOptions.env = this.profile.sessionOptions.env ?? {}
-        this.profile.sessionOptions.args = this.profile.sessionOptions.args ?? []
+    ngAfterViewInit () {
+        setTimeout(() => {
+            const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.profileProvider.settingsComponent)
+            const componentRef = componentFactory.create(this.injector)
+            this.settingsComponentInstance = componentRef.instance
+            this.settingsComponentInstance.profile = this.profile
+            this.placeholder.insert(componentRef.hostView)
+        })
     }
 
+    groupTypeahead = (text$: Observable<string>) =>
+        text$.pipe(
+            debounceTime(200),
+            distinctUntilChanged(),
+            map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase())))
+        )
+
+    iconSearch: OperatorFunction<string, string[]> = (text$: Observable<string>) =>
+        text$.pipe(
+            debounceTime(200),
+            map(term => iconsClassList.filter(v => v.toLowerCase().includes(term.toLowerCase())).slice(0, 10))
+        )
+
     save () {
+        this.profile.group ||= undefined
+        this.settingsComponentInstance.save?.()
         this.modalInstance.close(this.profile)
     }
 
     cancel () {
         this.modalInstance.dismiss()
     }
-
-    trackByIndex (index) {
-        return index
-    }
 }

+ 51 - 0
tabby-local/src/components/localProfileSettings.component.pug

@@ -0,0 +1,51 @@
+.form-group
+    label Command
+    input.form-control(
+        type='text',
+        [(ngModel)]='profile.options.command',
+    )
+
+.form-group
+    label Arguments
+    .input-group(
+        *ngFor='let arg of profile.options.args; index as i; trackBy: trackByIndex',
+    )
+        input.form-control(
+            type='text',
+            [(ngModel)]='profile.options.args[i]',
+        )
+        .input-group-append
+            button.btn.btn-secondary((click)='profile.options.args.splice(i, 1)')
+                i.fas.fa-trash
+
+    .mt-2
+        button.btn.btn-secondary((click)='profile.options.args.push("")')
+            i.fas.fa-plus.mr-2
+            | Add
+
+.form-line(*ngIf='uac.isAvailable')
+    .header
+        .title Run as administrator
+    toggle(
+        [(ngModel)]='profile.options.runAsAdministrator',
+    )
+
+.form-group
+    label Working directory
+
+    .input-group
+        input.form-control(
+            type='text',
+            placeholder='Home directory',
+            [(ngModel)]='profile.options.cwd'
+        )
+        .input-group-append
+            button.btn.btn-secondary((click)='pickWorkingDirectory()')
+                i.fas.fa-folder-open
+
+.form-group
+    label Environment
+    environment-editor(
+        type='text',
+        [(model)]='profile.options.env',
+    )

+ 47 - 0
tabby-local/src/components/localProfileSettings.component.ts

@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Component } from '@angular/core'
+import { UACService } from '../services/uac.service'
+import { LocalProfile } from '../api'
+import { ElectronHostWindow, ElectronService } from 'tabby-electron'
+import { ProfileSettingsComponent } from '../../../tabby-core/src/api/profileProvider'
+
+
+/** @hidden */
+@Component({
+    template: require('./localProfileSettings.component.pug'),
+})
+export class LocalProfileSettingsComponent implements ProfileSettingsComponent {
+    profile: LocalProfile
+
+    constructor (
+        public uac: UACService,
+        private hostWindow: ElectronHostWindow,
+        private electron: ElectronService,
+    ) { }
+
+    ngOnInit () {
+        this.profile.options.env = this.profile.options.env ?? {}
+        this.profile.options.args = this.profile.options.args ?? []
+    }
+
+    async pickWorkingDirectory (): Promise<void> {
+        // const profile = await this.terminal.getProfileByID(this.config.store.terminal.profile)
+        // const shell = this.shells.find(x => x.id === profile?.shell)
+        // if (!shell) {
+        //     return
+        // }
+        const paths = (await this.electron.dialog.showOpenDialog(
+            this.hostWindow.getWindow(),
+            {
+                // TODO
+                // defaultPath: shell.fsBase,
+                properties: ['openDirectory', 'showHiddenFiles'],
+            }
+        )).filePaths
+        this.profile.options.cwd = paths[0]
+    }
+
+    trackByIndex (index) {
+        return index
+    }
+}

+ 94 - 0
tabby-local/src/components/profilesSettingsTab.component.pug

@@ -0,0 +1,94 @@
+h3.mb-3 Profiles
+
+.form-line
+    .header
+        .title Default profile for new tabs
+
+    select.form-control(
+        [(ngModel)]='config.store.terminal.profile',
+        (ngModelChange)='config.save()',
+    )
+        option(
+            *ngFor='let profile of profiles',
+            [ngValue]='profile.id'
+        ) {{profile.name}}
+        option(
+            *ngFor='let profile of builtinProfiles',
+            [ngValue]='profile.id'
+        ) {{profile.name}}
+
+.form-line(*ngIf='config.store.profiles.length > 0')
+    .header
+        .title Show built-in profiles in selector
+        .description If disabled, only custom profiles will show up in the profile selector
+
+    toggle(
+        [(ngModel)]='config.store.terminal.showBuiltinProfiles',
+        (ngModelChange)='config.save()'
+    )
+
+.d-flex.mb-3.mt-4
+    .input-group
+        .input-group-prepend
+            .input-group-text
+                i.fas.fa-fw.fa-search
+        input.form-control(type='search', placeholder='Filter', [(ngModel)]='filter')
+
+    button.btn.btn-primary.flex-shrink-0.ml-3((click)='newProfile()')
+        i.fas.fa-fw.fa-plus
+        | New profile
+
+.list-group.list-group-light.mt-3.mb-3
+    ng-container(*ngFor='let group of profileGroups')
+        ng-container(*ngIf='isGroupVisible(group)')
+            .list-group-item.list-group-item-action.d-flex.align-items-center(
+                (click)='group.collapsed = !group.collapsed'
+            )
+                .fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed')
+                .fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed')
+                span.ml-3.mr-auto {{group.name || "Ungrouped"}}
+                button.btn.btn-sm.btn-link.hover-reveal.ml-2(
+                    *ngIf='group.editable && group.name',
+                    (click)='$event.stopPropagation(); editGroup(group)'
+                )
+                    i.fas.fa-pencil-alt
+                button.btn.btn-sm.btn-link.hover-reveal.ml-2(
+                    *ngIf='group.editable && group.name',
+                    (click)='$event.stopPropagation(); deleteGroup(group)'
+                )
+                    i.fas.fa-trash
+            ng-container(*ngIf='!group.collapsed')
+                ng-container(*ngFor='let profile of group.profiles')
+                    .list-group-item.pl-5.d-flex.align-items-center(
+                        *ngIf='isProfileVisible(profile)',
+                        [class.list-group-item-action]='!profile.isBuiltin',
+                        (click)='profile.isBuiltin ? null : editProfile(profile)'
+                    )
+                        i.icon(
+                            class='fa-fw {{profile.icon}}',
+                            [style.color]='profile.color',
+                            *ngIf='!iconIsSVG(profile.icon)'
+                        )
+                        .icon(
+                            [fastHtmlBind]='profile.icon',
+                            *ngIf='iconIsSVG(profile.icon)'
+                        )
+
+                        div {{profile.name}}
+                        .text-muted.ml-2 {{getDescription(profile)}}
+
+                        .mr-auto
+
+                        button.btn.btn-link.hover-reveal.ml-1((click)='$event.stopPropagation(); launchProfile(profile)')
+                            i.fas.fa-play
+
+                        button.btn.btn-link.hover-reveal.ml-1((click)='$event.stopPropagation(); newProfile(profile)')
+                            i.fas.fa-copy
+
+                        button.btn.btn-link.text-danger.hover-reveal.ml-1(
+                            *ngIf='!profile.isBuiltin',
+                            (click)='$event.stopPropagation(); deleteProfile(profile)'
+                        )
+                            i.fas.fa-trash
+
+                        .ml-1(class='badge badge-{{getTypeColorClass(profile)}}') {{getTypeLabel(profile)}}

+ 201 - 0
tabby-local/src/components/profilesSettingsTab.component.ts

@@ -0,0 +1,201 @@
+import { v4 as uuidv4 } from 'uuid'
+import slugify from 'slugify'
+import deepClone from 'clone-deep'
+import { Component } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent } from 'tabby-core'
+import { EditProfileModalComponent } from './editProfileModal.component'
+
+interface ProfileGroup {
+    name?: string
+    profiles: Profile[]
+    editable: boolean
+    collapsed: boolean
+}
+
+/** @hidden */
+@Component({
+    template: require('./profilesSettingsTab.component.pug'),
+})
+export class ProfilesSettingsTabComponent extends BaseComponent {
+    profiles: Profile[] = []
+    builtinProfiles: Profile[] = []
+    templateProfiles: Profile[] = []
+    profileGroups: ProfileGroup[]
+    filter = ''
+
+    constructor (
+        public config: ConfigService,
+        public hostApp: HostAppService,
+        private profilesService: ProfilesService,
+        private selector: SelectorService,
+        private ngbModal: NgbModal,
+        private platform: PlatformService,
+    ) {
+        super()
+    }
+
+    async ngOnInit (): Promise<void> {
+        this.refresh()
+        this.builtinProfiles = (await this.profilesService.getProfiles()).filter(x => x.isBuiltin)
+        this.templateProfiles = this.builtinProfiles.filter(x => x.isTemplate)
+        this.builtinProfiles = this.builtinProfiles.filter(x => !x.isTemplate)
+        this.refresh()
+        this.subscribeUntilDestroyed(this.config.changed$, () => this.refresh())
+    }
+
+    launchProfile (profile: Profile): void {
+        this.profilesService.openNewTabForProfile(profile)
+    }
+
+    async newProfile (base?: Profile): Promise<void> {
+        if (!base) {
+            const profiles = [...this.templateProfiles, ...this.builtinProfiles, ...this.profiles]
+            base = await this.selector.show(
+                'Select a base profile to use as a template',
+                profiles.map(p => ({
+                    icon: p.icon,
+                    description: this.profilesService.providerForProfile(p)?.getDescription(p),
+                    name: p.group ? `${p.group} / ${p.name}` : p.name,
+                    result: p,
+                })),
+            )
+        }
+        const profile = deepClone(base)
+        profile.id = null
+        profile.name = ''
+        profile.isBuiltin = false
+        profile.isTemplate = false
+        await this.editProfile(profile)
+        profile.id = `${profile.type}:custom:${slugify(profile.name)}:${uuidv4()}`
+        this.config.store.profiles = [profile, ...this.config.store.profiles]
+        await this.config.save()
+    }
+
+    async editProfile (profile: Profile): Promise<void> {
+        const modal = this.ngbModal.open(
+            EditProfileModalComponent,
+            { size: 'lg' },
+        )
+        modal.componentInstance.profile = Object.assign({}, profile)
+        modal.componentInstance.profileProvider = this.profilesService.providerForProfile(profile)
+        const result = await modal.result
+        Object.assign(profile, result)
+        await this.config.save()
+    }
+
+    async deleteProfile (profile: Profile): Promise<void> {
+        if ((await this.platform.showMessageBox(
+            {
+                type: 'warning',
+                message: `Delete "${profile.name}"?`,
+                buttons: ['Keep', 'Delete'],
+                defaultId: 0,
+            }
+        )).response === 1) {
+            this.profilesService.providerForProfile(profile)?.deleteProfile(profile)
+            this.config.store.profiles = this.config.store.profiles.filter(x => x !== profile)
+            await this.config.save()
+        }
+    }
+
+    refresh (): void {
+        this.profiles = this.config.store.profiles
+        this.profileGroups = []
+
+        for (const profile of this.profiles) {
+            let group = this.profileGroups.find(x => x.name === profile.group)
+            if (!group) {
+                group = {
+                    name: profile.group,
+                    profiles: [],
+                    editable: true,
+                    collapsed: false,
+                }
+                this.profileGroups.push(group)
+            }
+            group.profiles.push(profile)
+        }
+
+        this.profileGroups.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? -1)
+
+        this.profileGroups.push({
+            name: 'Built-in',
+            profiles: this.builtinProfiles,
+            editable: false,
+            collapsed: false,
+        })
+    }
+
+    async editGroup (group: ProfileGroup): Promise<void> {
+        const modal = this.ngbModal.open(PromptModalComponent)
+        modal.componentInstance.prompt = '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
+            await this.config.save()
+        }
+    }
+
+    async deleteGroup (group: ProfileGroup): Promise<void> {
+        if ((await this.platform.showMessageBox(
+            {
+                type: 'warning',
+                message: `Delete "${group.name}"?`,
+                buttons: ['Keep', 'Delete'],
+                defaultId: 0,
+            }
+        )).response === 1) {
+            if ((await this.platform.showMessageBox(
+                {
+                    type: 'warning',
+                    message: `Delete the group's profiles?`,
+                    buttons: ['Move to "Ungrouped"', 'Delete'],
+                    defaultId: 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)
+            }
+            await this.config.save()
+        }
+    }
+
+    isGroupVisible (group: ProfileGroup): boolean {
+        return !this.filter || group.profiles.some(x => this.isProfileVisible(x))
+    }
+
+    isProfileVisible (profile: Profile): boolean {
+        return !this.filter || profile.name.toLowerCase().includes(this.filter.toLowerCase())
+    }
+
+    iconIsSVG (icon?: string): boolean {
+        return icon?.startsWith('<') ?? false
+    }
+
+    getDescription (profile: Profile): string|null {
+        return this.profilesService.providerForProfile(profile)?.getDescription(profile) ?? null
+    }
+
+    getTypeLabel (profile: Profile): string {
+        const name = this.profilesService.providerForProfile(profile)?.name
+        if (name === 'Local') {
+            return ''
+        }
+        return name ?? 'Unknown'
+    }
+
+    getTypeColorClass (profile: Profile): string {
+        return {
+            ssh: 'secondary',
+            serial: 'success',
+        }[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning'
+    }
+}

+ 0 - 87
tabby-local/src/components/shellSettingsTab.component.pug

@@ -1,20 +1,5 @@
 h3.mb-3 Shell
 
-.form-line
-    .header
-        .title Profile
-        .description Default profile for new tabs
-
-    select.form-control(
-        [(ngModel)]='config.store.terminal.profile',
-        (ngModelChange)='config.save()',
-    )
-        option(
-            *ngFor='let profile of profiles',
-            [ngValue]='terminal.getProfileID(profile)'
-        ) {{profile.name}}
-
-
 .form-line(*ngIf='isConPTYAvailable')
     .header
         .title Use ConPTY
@@ -30,75 +15,3 @@ h3.mb-3 Shell
 
 .alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.profile.startsWith("WSL") && (!config.store.terminal.useConPTY)')
     .mr-auto WSL terminal only supports TrueColor with ConPTY
-
-.form-line(*ngIf='config.store.terminal.profile == "custom-shell"')
-    .header
-        .title Custom shell
-
-    input.form-control(
-        type='text',
-        [(ngModel)]='config.store.terminal.customShell',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Working directory
-    .input-group
-        input.form-control(
-            type='text',
-            placeholder='Home directory',
-            [(ngModel)]='config.store.terminal.workingDirectory',
-            (ngModelChange)='config.save()',
-        )
-        .input-group-append
-            button.btn.btn-secondary((click)='pickWorkingDirectory()')
-                i.fas.fa-folder-open
-
-.form-line
-    .header
-        .title Directory for new tabs
-
-    select.form-control(
-        [(ngModel)]='config.store.terminal.alwaysUseWorkingDirectory',
-        (ngModelChange)='config.save()',
-    )
-        option([ngValue]='false') Same as active tab's directory
-        option([ngValue]='true') The working directory from above
-
-.form-line.align-items-start
-    .header
-        .title Environment
-        .description Inject additional environment variables
-
-    environment-editor([(model)]='this.config.store.terminal.environment')
-
-.form-line(*ngIf='config.store.terminal.profiles.length > 0')
-    .header
-        .title Show default profiles in the selector
-        .description If disabled, only custom profiles will show up in the profile selector
-
-    toggle(
-        [(ngModel)]='config.store.terminal.showDefaultProfiles',
-        (ngModelChange)='config.save()'
-    )
-
-h3.mt-3 Saved Profiles
-
-.list-group.list-group-flush.mt-3.mb-3
-    .list-group-item.list-group-item-action.d-flex.align-items-center(
-        *ngFor='let profile of config.store.terminal.profiles',
-        (click)='editProfile(profile)',
-    )
-        .mr-auto
-            div {{profile.name}}
-            .text-muted {{profile.sessionOptions.command}}
-        button.btn.btn-outline-danger.ml-1((click)='$event.stopPropagation(); deleteProfile(profile)')
-            i.fas.fa-trash
-
-.pb-4(ngbDropdown, placement='top-left')
-    button.btn.btn-primary(ngbDropdownToggle)
-        i.fas.fa-fw.fa-plus
-        | New profile
-    div(ngbDropdownMenu)
-        button.dropdown-item(*ngFor='let shell of shells', (click)='newProfile(shell)') {{shell.name}}

+ 1 - 76
tabby-local/src/components/shellSettingsTab.component.ts

@@ -1,93 +1,18 @@
 import { Component } from '@angular/core'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { Subscription } from 'rxjs'
-import { ConfigService, HostAppService, Platform, WIN_BUILD_CONPTY_SUPPORTED, WIN_BUILD_CONPTY_STABLE, isWindowsBuild } from 'tabby-core'
-import { ElectronService, ElectronHostWindow } from 'tabby-electron'
-import { EditProfileModalComponent } from './editProfileModal.component'
-import { Shell, Profile } from '../api'
-import { TerminalService } from '../services/terminal.service'
+import { WIN_BUILD_CONPTY_SUPPORTED, WIN_BUILD_CONPTY_STABLE, isWindowsBuild, ConfigService } from 'tabby-core'
 
 /** @hidden */
 @Component({
     template: require('./shellSettingsTab.component.pug'),
 })
 export class ShellSettingsTabComponent {
-    shells: Shell[] = []
-    profiles: Profile[] = []
-    Platform = Platform
     isConPTYAvailable: boolean
     isConPTYStable: boolean
-    private configSubscription: Subscription
 
     constructor (
         public config: ConfigService,
-        public hostApp: HostAppService,
-        public hostWindow: ElectronHostWindow,
-        public terminal: TerminalService,
-        private electron: ElectronService,
-        private ngbModal: NgbModal,
     ) {
-        config.store.terminal.environment = config.store.terminal.environment || {}
-        this.configSubscription = this.config.changed$.subscribe(() => {
-            this.reload()
-        })
-        this.reload()
-
         this.isConPTYAvailable = isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED)
         this.isConPTYStable = isWindowsBuild(WIN_BUILD_CONPTY_STABLE)
     }
-
-    async ngOnInit (): Promise<void> {
-        this.shells = (await this.terminal.shells$.toPromise())!
-    }
-
-    ngOnDestroy (): void {
-        this.configSubscription.unsubscribe()
-    }
-
-    async reload (): Promise<void> {
-        this.profiles = await this.terminal.getProfiles({ includeHidden: true })
-    }
-
-    async pickWorkingDirectory (): Promise<void> {
-        const profile = await this.terminal.getProfileByID(this.config.store.terminal.profile)
-        const shell = this.shells.find(x => x.id === profile?.shell)
-        if (!shell) {
-            return
-        }
-        const paths = (await this.electron.dialog.showOpenDialog(
-            this.hostWindow.getWindow(),
-            {
-                defaultPath: shell.fsBase,
-                properties: ['openDirectory', 'showHiddenFiles'],
-            }
-        )).filePaths
-        this.config.store.terminal.workingDirectory = paths[0]
-    }
-
-    newProfile (shell: Shell): void {
-        const profile: Profile = {
-            name: shell.name ?? '',
-            shell: shell.id,
-            sessionOptions: this.terminal.optionsFromShell(shell),
-        }
-        this.config.store.terminal.profiles = [profile, ...this.config.store.terminal.profiles]
-        this.config.save()
-        this.reload()
-    }
-
-    editProfile (profile: Profile): void {
-        const modal = this.ngbModal.open(EditProfileModalComponent)
-        modal.componentInstance.profile = Object.assign({}, profile)
-        modal.result.then(result => {
-            Object.assign(profile, result)
-            this.config.save()
-        })
-    }
-
-    deleteProfile (profile: Profile): void {
-        this.config.store.terminal.profiles = this.config.store.terminal.profiles.filter(x => x !== profile)
-        this.config.save()
-        this.reload()
-    }
 }

+ 6 - 0
tabby-local/src/components/terminalTab.component.ts

@@ -3,6 +3,7 @@ import { BaseTabProcess, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild } from 'tabb
 import { BaseTerminalTabComponent } from 'tabby-terminal'
 import { SessionOptions } from '../api'
 import { Session } from '../session'
+import { UACService } from '../services/uac.service'
 
 /** @hidden */
 @Component({
@@ -18,6 +19,7 @@ export class TerminalTabComponent extends BaseTerminalTabComponent {
     // eslint-disable-next-line @typescript-eslint/no-useless-constructor
     constructor (
         injector: Injector,
+        private uac: UACService,
     ) {
         super(injector)
     }
@@ -52,6 +54,10 @@ export class TerminalTabComponent extends BaseTerminalTabComponent {
     }
 
     initializeSession (columns: number, rows: number): void {
+        if (this.sessionOptions.runAsAdministrator && this.uac.isAvailable) {
+            this.sessionOptions = this.uac.patchSessionOptionsForUAC(this.sessionOptions)
+        }
+
         this.session!.start({
             ...this.sessionOptions,
             width: columns,

+ 4 - 7
tabby-local/src/config.ts

@@ -14,11 +14,8 @@ export class TerminalConfigProvider extends ConfigProvider {
         },
         terminal: {
             autoOpen: false,
-            customShell: '',
-            workingDirectory: '',
-            alwaysUseWorkingDirectory: false,
             useConPTY: true,
-            showDefaultProfiles: true,
+            showBuiltinProfiles: true,
             environment: {},
             profiles: [],
         },
@@ -28,7 +25,7 @@ export class TerminalConfigProvider extends ConfigProvider {
         [Platform.macOS]: {
             terminal: {
                 shell: 'default',
-                profile: 'user-default',
+                profile: 'local:user-default',
             },
             hotkeys: {
                 'new-tab': [
@@ -39,7 +36,7 @@ export class TerminalConfigProvider extends ConfigProvider {
         [Platform.Windows]: {
             terminal: {
                 shell: 'clink',
-                profile: 'cmd-clink',
+                profile: 'local:cmd-clink',
             },
             hotkeys: {
                 'new-tab': [
@@ -50,7 +47,7 @@ export class TerminalConfigProvider extends ConfigProvider {
         [Platform.Linux]: {
             terminal: {
                 shell: 'default',
-                profile: 'user-default',
+                profile: 'local:user-default',
             },
             hotkeys: {
                 'new-tab': [

+ 1 - 13
tabby-local/src/hotkeys.ts

@@ -1,6 +1,5 @@
 import { Injectable } from '@angular/core'
 import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
-import { TerminalService } from './services/terminal.service'
 
 /** @hidden */
 @Injectable()
@@ -12,18 +11,7 @@ export class LocalTerminalHotkeyProvider extends HotkeyProvider {
         },
     ]
 
-    constructor (
-        private terminal: TerminalService,
-    ) { super() }
-
     async provide (): Promise<HotkeyDescription[]> {
-        const profiles = await this.terminal.getProfiles()
-        return [
-            ...this.hotkeys,
-            ...profiles.map(profile => ({
-                id: `profile.${this.terminal.getProfileID(profile)}`,
-                name: `New tab: ${profile.name}`,
-            })),
-        ]
+        return this.hotkeys
     }
 }

+ 12 - 10
tabby-local/src/index.ts

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'
 import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
 
-import TabbyCorePlugin, { HostAppService, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler, ConfigService } from 'tabby-core'
+import TabbyCorePlugin, { HostAppService, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler, ConfigService, ProfileProvider } from 'tabby-core'
 import TabbyTerminalModule from 'tabby-terminal'
 import TabbyElectronPlugin from 'tabby-electron'
 import { SettingsTabProvider } from 'tabby-settings'
@@ -13,6 +13,8 @@ import { TerminalTabComponent } from './components/terminalTab.component'
 import { ShellSettingsTabComponent } from './components/shellSettingsTab.component'
 import { EditProfileModalComponent } from './components/editProfileModal.component'
 import { EnvironmentEditorComponent } from './components/environmentEditor.component'
+import { ProfilesSettingsTabComponent } from './components/profilesSettingsTab.component'
+import { LocalProfileSettingsComponent } from './components/localProfileSettings.component'
 
 import { TerminalService } from './services/terminal.service'
 import { DockMenuService } from './services/dockMenu.service'
@@ -20,13 +22,12 @@ import { DockMenuService } from './services/dockMenu.service'
 import { ButtonProvider } from './buttonProvider'
 import { RecoveryProvider } from './recoveryProvider'
 import { ShellProvider } from './api'
-import { ShellSettingsTabProvider } from './settings'
+import { ProfilesSettingsTabProvider, ShellSettingsTabProvider } from './settings'
 import { TerminalConfigProvider } from './config'
 import { LocalTerminalHotkeyProvider } from './hotkeys'
 import { NewTabContextMenu, SaveAsProfileContextMenu } from './tabContextMenu'
 
 import { CmderShellProvider } from './shells/cmder'
-import { CustomShellProvider } from './shells/custom'
 import { Cygwin32ShellProvider } from './shells/cygwin32'
 import { Cygwin64ShellProvider } from './shells/cygwin64'
 import { GitBashShellProvider } from './shells/gitBash'
@@ -39,6 +40,7 @@ import { WindowsStockShellsProvider } from './shells/windowsStock'
 import { WSLShellProvider } from './shells/wsl'
 
 import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from './cli'
+import { LocalProfilesService } from './profiles'
 
 /** @hidden */
 @NgModule({
@@ -53,6 +55,7 @@ import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from '.
     ],
     providers: [
         { provide: SettingsTabProvider, useClass: ShellSettingsTabProvider, multi: true },
+        { provide: SettingsTabProvider, useClass: ProfilesSettingsTabProvider, multi: true },
 
         { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
         { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
@@ -65,13 +68,14 @@ import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from '.
         { provide: ShellProvider, useClass: WindowsStockShellsProvider, multi: true },
         { provide: ShellProvider, useClass: PowerShellCoreShellProvider, multi: true },
         { provide: ShellProvider, useClass: CmderShellProvider, multi: true },
-        { provide: ShellProvider, useClass: CustomShellProvider, multi: true },
         { provide: ShellProvider, useClass: Cygwin32ShellProvider, multi: true },
         { provide: ShellProvider, useClass: Cygwin64ShellProvider, multi: true },
         { provide: ShellProvider, useClass: GitBashShellProvider, multi: true },
         { provide: ShellProvider, useClass: POSIXShellsProvider, multi: true },
         { provide: ShellProvider, useClass: WSLShellProvider, multi: true },
 
+        { provide: ProfileProvider, useClass: LocalProfilesService, multi: true },
+
         { provide: TabContextMenuItemProvider, useClass: NewTabContextMenu, multi: true },
         { provide: TabContextMenuItemProvider, useClass: SaveAsProfileContextMenu, multi: true },
 
@@ -86,14 +90,18 @@ import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from '.
     ],
     entryComponents: [
         TerminalTabComponent,
+        ProfilesSettingsTabComponent,
         ShellSettingsTabComponent,
         EditProfileModalComponent,
+        LocalProfileSettingsComponent,
     ] as any[],
     declarations: [
         TerminalTabComponent,
+        ProfilesSettingsTabComponent,
         ShellSettingsTabComponent,
         EditProfileModalComponent,
         EnvironmentEditorComponent,
+        LocalProfileSettingsComponent,
     ] as any[],
     exports: [
         TerminalTabComponent,
@@ -115,12 +123,6 @@ export default class LocalTerminalModule { // eslint-disable-line @typescript-es
             if (hotkey === 'new-window') {
                 hostApp.newWindow()
             }
-            if (hotkey.startsWith('profile.')) {
-                const profile = await terminal.getProfileByID(hotkey.split('.')[1])
-                if (profile) {
-                    terminal.openTabWithOptions(profile.sessionOptions)
-                }
-            }
         })
 
         config.ready$.toPromise().then(() => {

+ 72 - 0
tabby-local/src/profiles.ts

@@ -0,0 +1,72 @@
+import { Injectable, Inject } from '@angular/core'
+import { ProfileProvider, Profile, NewTabParameters, ConfigService, SplitTabComponent, AppService } from 'tabby-core'
+import { TerminalTabComponent } from './components/terminalTab.component'
+import { LocalProfileSettingsComponent } from './components/localProfileSettings.component'
+import { ShellProvider, Shell, SessionOptions } from './api'
+
+@Injectable({ providedIn: 'root' })
+export class LocalProfilesService extends ProfileProvider {
+    id = 'local'
+    name = 'Local'
+    settingsComponent = LocalProfileSettingsComponent
+
+    constructor (
+        private app: AppService,
+        private config: ConfigService,
+        @Inject(ShellProvider) private shellProviders: ShellProvider[],
+    ) {
+        super()
+    }
+
+    async getBuiltinProfiles (): Promise<Profile[]> {
+        return (await this.getShells()).map(shell => ({
+            id: `local:${shell.id}`,
+            type: 'local',
+            name: shell.name,
+            icon: shell.icon,
+            options: this.optionsFromShell(shell),
+            isBuiltin: true,
+        }))
+    }
+
+    async getNewTabParameters (profile: Profile): Promise<NewTabParameters<TerminalTabComponent>> {
+        const options = { ...profile.options }
+
+        if (!options.cwd) {
+            if (this.app.activeTab instanceof TerminalTabComponent && this.app.activeTab.session) {
+                options.cwd = await this.app.activeTab.session.getWorkingDirectory()
+            }
+            if (this.app.activeTab instanceof SplitTabComponent) {
+                const focusedTab = this.app.activeTab.getFocusedTab()
+
+                if (focusedTab instanceof TerminalTabComponent && focusedTab.session) {
+                    options.cwd = await focusedTab.session.getWorkingDirectory()
+                }
+            }
+        }
+
+        return {
+            type: TerminalTabComponent,
+            inputs: {
+                sessionOptions: options,
+            },
+        }
+    }
+
+    async getShells (): Promise<Shell[]> {
+        const shellLists = await Promise.all(this.config.enabledServices(this.shellProviders).map(x => x.provide()))
+        return shellLists.reduce((a, b) => a.concat(b), [])
+    }
+
+    optionsFromShell (shell: Shell): SessionOptions {
+        return {
+            command: shell.command,
+            args: shell.args ?? [],
+            env: shell.env,
+        }
+    }
+
+    getDescription (profile: Profile): string {
+        return profile.options?.command
+    }
+}

+ 4 - 4
tabby-local/src/recoveryProvider.ts

@@ -1,19 +1,19 @@
 import { Injectable } from '@angular/core'
-import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
+import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
 
 import { TerminalTabComponent } from './components/terminalTab.component'
 
 /** @hidden */
 @Injectable()
-export class RecoveryProvider extends TabRecoveryProvider {
+export class RecoveryProvider extends TabRecoveryProvider<TerminalTabComponent> {
     async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
         return recoveryToken.type === 'app:terminal-tab'
     }
 
-    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+    async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<TerminalTabComponent>> {
         return {
             type: TerminalTabComponent,
-            options: {
+            inputs: {
                 sessionOptions: recoveryToken.sessionOptions,
                 savedState: recoveryToken.savedState,
             },

+ 7 - 8
tabby-local/src/services/dockMenu.service.ts

@@ -1,7 +1,6 @@
 import { NgZone, Injectable } from '@angular/core'
-import { ConfigService, HostAppService, Platform } from 'tabby-core'
+import { ConfigService, HostAppService, Platform, ProfilesService } from 'tabby-core'
 import { ElectronService } from 'tabby-electron'
-import { TerminalService } from './terminal.service'
 
 /** @hidden */
 @Injectable({ providedIn: 'root' })
@@ -13,17 +12,17 @@ export class DockMenuService {
         private config: ConfigService,
         private hostApp: HostAppService,
         private zone: NgZone,
-        private terminalService: TerminalService,
+        private profilesService: ProfilesService,
     ) {
         config.changed$.subscribe(() => this.update())
     }
 
     update (): void {
         if (this.hostApp.platform === Platform.Windows) {
-            this.electron.app.setJumpList(this.config.store.terminal.profiles.length ? [{
+            this.electron.app.setJumpList(this.config.store.profiles.length ? [{
                 type: 'custom',
                 name: 'Profiles',
-                items: this.config.store.terminal.profiles.map(profile => ({
+                items: this.config.store.profiles.map(profile => ({
                     type: 'task',
                     program: process.execPath,
                     args: `profile "${profile.name}"`,
@@ -35,10 +34,10 @@ export class DockMenuService {
         }
         if (this.hostApp.platform === Platform.macOS) {
             this.electron.app.dock.setMenu(this.electron.Menu.buildFromTemplate(
-                this.config.store.terminal.profiles.map(profile => ({
+                this.config.store.profiles.map(profile => ({
                     label: profile.name,
-                    click: () => this.zone.run(() => {
-                        this.terminalService.openTabWithOptions(profile.sessionOptions)
+                    click: () => this.zone.run(async () => {
+                        this.profilesService.openNewTabForProfile(profile)
                     }),
                 }))
             ))

+ 24 - 105
tabby-local/src/services/terminal.service.ts

@@ -1,150 +1,69 @@
 import * as fs from 'mz/fs'
-import slugify from 'slugify'
-import { Observable, AsyncSubject } from 'rxjs'
-import { Injectable, Inject } from '@angular/core'
-import { AppService, Logger, LogService, ConfigService, SplitTabComponent } from 'tabby-core'
+import { Injectable } from '@angular/core'
+import { Logger, LogService, ConfigService, AppService, ProfilesService } from 'tabby-core'
 import { TerminalTabComponent } from '../components/terminalTab.component'
-import { ShellProvider, Shell, SessionOptions, Profile } from '../api'
-import { UACService } from './uac.service'
+import { SessionOptions, LocalProfile } from '../api'
 
 @Injectable({ providedIn: 'root' })
 export class TerminalService {
-    private shells = new AsyncSubject<Shell[]>()
     private logger: Logger
 
-    /**
-     * A fresh list of all available shells
-     */
-    get shells$ (): Observable<Shell[]> { return this.shells }
-
     /** @hidden */
     private constructor (
         private app: AppService,
+        private profilesService: ProfilesService,
         private config: ConfigService,
-        private uac: UACService,
-        @Inject(ShellProvider) private shellProviders: ShellProvider[],
         log: LogService,
     ) {
         this.logger = log.create('terminal')
-
-        config.ready$.toPromise().then(() => {
-            this.reloadShells()
-            config.changed$.subscribe(() => {
-                this.reloadShells()
-            })
-        })
-    }
-
-    async getProfiles ({ includeHidden, skipDefault }: { includeHidden?: boolean, skipDefault?: boolean } = {}): Promise<Profile[]> {
-        const shells = (await this.shells$.toPromise())!
-        return [
-            ...this.config.store.terminal.profiles,
-            // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
-            ...skipDefault ? [] : shells.filter(x => includeHidden || !x.hidden).map(shell => ({
-                name: shell.name,
-                shell: shell.id,
-                icon: shell.icon,
-                sessionOptions: this.optionsFromShell(shell),
-                isBuiltin: true,
-            })),
-        ]
     }
 
-    getProfileID (profile: Profile): string {
-        return slugify(profile.name, { remove: /[:.]/g }).toLowerCase()
-    }
-
-    async getProfileByID (id: string): Promise<Profile|null> {
-        const profiles = await this.getProfiles({ includeHidden: true })
-        return profiles.find(x => this.getProfileID(x) === id) ?? null
+    async getDefaultProfile (): Promise<LocalProfile> {
+        const profiles = await this.profilesService.getProfiles()
+        let profile = profiles.find(x => x.id === this.config.store.terminal.profile)
+        if (!profile) {
+            profile = profiles.filter(x => x.type === 'local' && x.isBuiltin)[0]
+        }
+        return profile as LocalProfile
     }
 
     /**
      * Launches a new terminal with a specific shell and CWD
      * @param pause Wait for a keypress when the shell exits
      */
-    async openTab (profile?: Profile|null, cwd?: string|null, pause?: boolean): Promise<TerminalTabComponent> {
+    async openTab (profile?: LocalProfile|null, cwd?: string|null, pause?: boolean): Promise<TerminalTabComponent> {
         if (!profile) {
-            profile = await this.getProfileByID(this.config.store.terminal.profile)
-            if (!profile) {
-                profile = (await this.getProfiles({ includeHidden: true }))[0]
-            }
+            profile = await this.getDefaultProfile()
         }
 
-        cwd = cwd ?? profile.sessionOptions.cwd
+        cwd = cwd ?? profile.options.cwd
 
         if (cwd && !fs.existsSync(cwd)) {
             console.warn('Ignoring non-existent CWD:', cwd)
             cwd = null
         }
 
-        if (!cwd) {
-            if (!this.config.store.terminal.alwaysUseWorkingDirectory) {
-                if (this.app.activeTab instanceof TerminalTabComponent && this.app.activeTab.session) {
-                    cwd = await this.app.activeTab.session.getWorkingDirectory()
-                }
-                if (this.app.activeTab instanceof SplitTabComponent) {
-                    const focusedTab = this.app.activeTab.getFocusedTab()
-
-                    if (focusedTab instanceof TerminalTabComponent && focusedTab.session) {
-                        cwd = await focusedTab.session.getWorkingDirectory()
-                    }
-                }
-            }
-            cwd = cwd ?? this.config.store.terminal.workingDirectory
-        }
-
         this.logger.info(`Starting profile ${profile.name}`, profile)
-        const sessionOptions = {
-            ...profile.sessionOptions,
+        const options = {
+            ...profile.options,
             pauseAfterExit: pause,
             cwd: cwd ?? undefined,
         }
 
-        const tab = this.openTabWithOptions(sessionOptions)
-        if (profile.color) {
-            (this.app.getParentTab(tab) ?? tab).color = profile.color
-        }
-        if (profile.disableDynamicTitle) {
-            tab.enableDynamicTitle = false
-            tab.setTitle(profile.name)
-        }
-        return tab
-    }
-
-    optionsFromShell (shell: Shell): SessionOptions {
-        return {
-            command: shell.command,
-            args: shell.args ?? [],
-            env: shell.env,
-        }
+        return (await this.profilesService.openNewTabForProfile({
+            ...profile,
+            options,
+        })) as TerminalTabComponent
     }
 
     /**
      * Open a terminal with custom session options
      */
     openTabWithOptions (sessionOptions: SessionOptions): TerminalTabComponent {
-        if (sessionOptions.runAsAdministrator && this.uac.isAvailable) {
-            sessionOptions = this.uac.patchSessionOptionsForUAC(sessionOptions)
-        }
         this.logger.info('Using session options:', sessionOptions)
-
-        return this.app.openNewTab(
-            TerminalTabComponent,
-            { sessionOptions }
-        ) as TerminalTabComponent
-    }
-
-    private async getShells (): Promise<Shell[]> {
-        const shellLists = await Promise.all(this.config.enabledServices(this.shellProviders).map(x => x.provide()))
-        return shellLists.reduce((a, b) => a.concat(b), [])
-    }
-
-    private async reloadShells () {
-        this.shells = new AsyncSubject<Shell[]>()
-        const shells = await this.getShells()
-        this.logger.debug('Shells list:', shells)
-        this.shells.next(shells)
-        this.shells.complete()
+        return this.app.openNewTab({
+            type: TerminalTabComponent,
+            inputs: { sessionOptions },
+        }) as TerminalTabComponent
     }
 }

+ 21 - 1
tabby-local/src/settings.ts

@@ -1,6 +1,8 @@
 import { Injectable } from '@angular/core'
+import { HostAppService, Platform } from 'tabby-core'
 import { SettingsTabProvider } from 'tabby-settings'
 
+import { ProfilesSettingsTabComponent } from './components/profilesSettingsTab.component'
 import { ShellSettingsTabComponent } from './components/shellSettingsTab.component'
 
 /** @hidden */
@@ -10,7 +12,25 @@ export class ShellSettingsTabProvider extends SettingsTabProvider {
     icon = 'list-ul'
     title = 'Shell'
 
+    constructor (private hostApp: HostAppService) {
+        super()
+    }
+
+    getComponentType (): any {
+        if (this.hostApp.platform === Platform.Windows) {
+            return ShellSettingsTabComponent
+        }
+    }
+}
+
+/** @hidden */
+@Injectable()
+export class ProfilesSettingsTabProvider extends SettingsTabProvider {
+    id = 'profiles'
+    icon = 'window-restore'
+    title = 'Profiles'
+
     getComponentType (): any {
-        return ShellSettingsTabComponent
+        return ProfilesSettingsTabComponent
     }
 }

+ 0 - 25
tabby-local/src/shells/custom.ts

@@ -1,25 +0,0 @@
-import { Injectable } from '@angular/core'
-import { ConfigService } from 'tabby-core'
-
-import { ShellProvider, Shell } from '../api'
-
-/** @hidden */
-@Injectable()
-export class CustomShellProvider extends ShellProvider {
-    constructor (
-        private config: ConfigService,
-    ) {
-        super()
-    }
-
-    async provide (): Promise<Shell[]> {
-        const args = this.config.store.terminal.customShell.split(' ')
-        return [{
-            id: 'custom',
-            name: 'Custom shell',
-            command: args[0],
-            args: args.slice(1),
-            env: {},
-        }]
-    }
-}

+ 1 - 1
tabby-local/src/shells/macDefault.ts

@@ -21,7 +21,7 @@ export class MacOSDefaultShellProvider extends ShellProvider {
         }
         return [{
             id: 'default',
-            name: 'User default',
+            name: 'OS default',
             command: await this.getDefaultShellCached(),
             args: ['--login'],
             hidden: true,

+ 1 - 0
tabby-local/src/shells/posix.ts

@@ -25,6 +25,7 @@ export class POSIXShellsProvider extends ShellProvider {
             .map(x => ({
                 id: slugify(x),
                 name: x.split('/')[2],
+                icon: 'fas fa-terminal',
                 command: x,
                 args: ['-l'],
                 env: {},

+ 1 - 1
tabby-local/src/shells/winDefault.ts

@@ -39,7 +39,7 @@ export class WindowsDefaultShellProvider extends ShellProvider {
                 return [{
                     ...shell,
                     id: 'default',
-                    name: `Default (${shell.name})`,
+                    name: `OS default (${shell.name})`,
                     hidden: true,
                     env: {},
                 }]

+ 12 - 9
tabby-local/src/tabContextMenu.ts

@@ -1,8 +1,9 @@
 import { Injectable } from '@angular/core'
-import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, SplitTabComponent, NotificationsService, MenuItemOptions } from 'tabby-core'
+import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, SplitTabComponent, NotificationsService, MenuItemOptions, ProfilesService } from 'tabby-core'
 import { TerminalTabComponent } from './components/terminalTab.component'
 import { UACService } from './services/uac.service'
 import { TerminalService } from './services/terminal.service'
+import { LocalProfile } from './api'
 
 /** @hidden */
 @Injectable()
@@ -23,14 +24,15 @@ export class SaveAsProfileContextMenu extends TabContextMenuItemProvider {
                 label: 'Save as profile',
                 click: async () => {
                     const profile = {
-                        sessionOptions: {
+                        options: {
                             ...tab.sessionOptions,
                             cwd: await tab.session?.getWorkingDirectory() ?? tab.sessionOptions.cwd,
                         },
                         name: tab.sessionOptions.command,
+                        type: 'local',
                     }
-                    this.config.store.terminal.profiles = [
-                        ...this.config.store.terminal.profiles,
+                    this.config.store.profiles = [
+                        ...this.config.store.profiles,
                         profile,
                     ]
                     this.config.save()
@@ -50,6 +52,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
 
     constructor (
         public config: ConfigService,
+        private profilesService: ProfilesService,
         private terminalService: TerminalService,
         private uac: UACService,
     ) {
@@ -57,7 +60,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
     }
 
     async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
-        const profiles = await this.terminalService.getProfiles()
+        const profiles = (await this.profilesService.getProfiles()).filter(x => x.type === 'local') as LocalProfile[]
 
         const items: MenuItemOptions[] = [
             {
@@ -71,9 +74,9 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
                 submenu: profiles.map(profile => ({
                     label: profile.name,
                     click: async () => {
-                        let workingDirectory = this.config.store.terminal.workingDirectory
-                        if (this.config.store.terminal.alwaysUseWorkingDirectory !== true && tab instanceof TerminalTabComponent) {
-                            workingDirectory = await tab.session?.getWorkingDirectory()
+                        let workingDirectory = profile.options.cwd
+                        if (!workingDirectory && tab instanceof TerminalTabComponent) {
+                            workingDirectory = await tab.session?.getWorkingDirectory() ?? undefined
                         }
                         await this.terminalService.openTab(profile, workingDirectory)
                     },
@@ -88,7 +91,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
                     label: profile.name,
                     click: () => {
                         this.terminalService.openTabWithOptions({
-                            ...profile.sessionOptions,
+                            ...profile.options,
                             runAsAdministrator: true,
                         })
                     },

+ 0 - 5
tabby-local/yarn.lock

@@ -371,11 +371,6 @@ side-channel@^1.0.3:
     get-intrinsic "^1.0.2"
     object-inspect "^1.9.0"
 
-slugify@^1.5.3:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.5.3.tgz#36e009864f5476bfd5db681222643d92339c890d"
-  integrity sha512-/HkjRdwPY3yHJReXu38NiusZw2+LLE2SrhkWJtmlPDB1fqFSvioYj62NkPcrKiNCgRLeGcGK7QBvr1iQwybeXw==
-
 string.prototype.codepointat@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz#004ad44c8afc727527b108cd462b4d971cd469bc"

+ 24 - 26
tabby-serial/src/api.ts

@@ -5,7 +5,7 @@ import stripAnsi from 'strip-ansi'
 import bufferReplace from 'buffer-replace'
 import { BaseSession } from 'tabby-terminal'
 import { SerialPort } from 'serialport'
-import { Logger } from 'tabby-core'
+import { Logger, Profile } from 'tabby-core'
 import { Subject, Observable, interval } from 'rxjs'
 import { debounce } from 'rxjs/operators'
 import { ReadLine, createInterface as createReadline, clearLine } from 'readline'
@@ -18,17 +18,20 @@ export interface LoginScript {
     optional?: boolean
 }
 
-export interface SerialConnection {
-    name: string
+export interface SerialProfile extends Profile {
+    options: SerialProfileOptions
+}
+
+export interface SerialProfileOptions {
     port: string
-    baudrate: number
-    databits: number
-    stopbits: number
-    parity: string
-    rtscts: boolean
-    xon: boolean
-    xoff: boolean
-    xany: boolean
+    baudrate?: number
+    databits?: number
+    stopbits?: number
+    parity?: string
+    rtscts?: boolean
+    xon?: boolean
+    xoff?: boolean
+    xany?: boolean
     scripts?: LoginScript[]
     color?: string
     inputMode?: InputMode
@@ -62,9 +65,9 @@ export class SerialSession extends BaseSession {
     private inputReadlineInStream: Readable & Writable
     private inputReadlineOutStream: Readable & Writable
 
-    constructor (public connection: SerialConnection) {
+    constructor (public profile: SerialProfile) {
         super()
-        this.scripts = connection.scripts ?? []
+        this.scripts = profile.options.scripts ?? []
 
         this.inputReadlineInStream = new PassThrough()
         this.inputReadlineOutStream = new PassThrough()
@@ -72,7 +75,7 @@ export class SerialSession extends BaseSession {
             input: this.inputReadlineInStream,
             output: this.inputReadlineOutStream,
             terminal: true,
-            prompt: this.connection.inputMode === 'readline-hex' ? 'hex> ' : '> ',
+            prompt: this.profile.options.inputMode === 'readline-hex' ? 'hex> ' : '> ',
         } as any)
         this.inputReadlineOutStream.on('data', data => {
             this.emitOutput(Buffer.from(data))
@@ -102,7 +105,7 @@ export class SerialSession extends BaseSession {
     }
 
     write (data: Buffer): void {
-        if (this.connection.inputMode?.startsWith('readline')) {
+        if (this.profile.options.inputMode?.startsWith('readline')) {
             this.inputReadlineInStream.write(data)
         } else {
             this.onInput(data)
@@ -161,7 +164,7 @@ export class SerialSession extends BaseSession {
     }
 
     private onInput (data: Buffer) {
-        if (this.connection.inputMode === 'readline-hex') {
+        if (this.profile.options.inputMode === 'readline-hex') {
             const tokens = data.toString().split(/\s/g)
             data = Buffer.concat(tokens.filter(t => !!t).map(t => {
                 if (t.startsWith('0x')) {
@@ -171,14 +174,14 @@ export class SerialSession extends BaseSession {
             }))
         }
 
-        data = this.replaceNewlines(data, this.connection.inputNewlines)
+        data = this.replaceNewlines(data, this.profile.options.inputNewlines)
         if (this.serial) {
             this.serial.write(data.toString())
         }
     }
 
     private onOutputSettled () {
-        if (this.connection.inputMode?.startsWith('readline') && !this.inputPromptVisible) {
+        if (this.profile.options.inputMode?.startsWith('readline') && !this.inputPromptVisible) {
             this.resetInputPrompt()
         }
     }
@@ -192,16 +195,16 @@ export class SerialSession extends BaseSession {
     private onOutput (data: Buffer) {
         const dataString = data.toString()
 
-        if (this.connection.inputMode?.startsWith('readline')) {
+        if (this.profile.options.inputMode?.startsWith('readline')) {
             if (this.inputPromptVisible) {
                 clearLine(this.inputReadlineOutStream, 0)
                 this.inputPromptVisible = false
             }
         }
 
-        data = this.replaceNewlines(data, this.connection.outputNewlines)
+        data = this.replaceNewlines(data, this.profile.options.outputNewlines)
 
-        if (this.connection.outputMode === 'hex') {
+        if (this.profile.options.outputMode === 'hex') {
             this.emitOutput(Buffer.concat([
                 Buffer.from('\r\n'),
                 Buffer.from(hexdump(data, {
@@ -271,8 +274,3 @@ export class SerialSession extends BaseSession {
         }
     }
 }
-
-export interface SerialConnectionGroup {
-    name: string
-    connections: SerialConnection[]
-}

+ 0 - 36
tabby-serial/src/buttonProvider.ts

@@ -1,36 +0,0 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Injectable, Injector } from '@angular/core'
-import { HotkeysService, ToolbarButtonProvider, ToolbarButton } from 'tabby-core'
-import { SerialService } from './services/serial.service'
-
-/** @hidden */
-@Injectable()
-export class ButtonProvider extends ToolbarButtonProvider {
-    constructor (
-        private injector: Injector,
-        hotkeys: HotkeysService,
-    ) {
-        super()
-        hotkeys.matchedHotkey.subscribe(async (hotkey: string) => {
-            if (hotkey === 'serial') {
-                this.activate()
-            }
-        })
-    }
-
-    activate () {
-        this.injector.get(SerialService).showConnectionSelector()
-    }
-
-    provide (): ToolbarButton[] {
-        return [{
-            icon: require('./icons/serial.svg'),
-            weight: 5,
-            title: 'Serial connections',
-            touchBarNSImage: 'NSTouchBarOpenInBrowserTemplate',
-            click: () => {
-                this.activate()
-            },
-        }]
-    }
-}

+ 0 - 30
tabby-serial/src/cli.ts

@@ -1,30 +0,0 @@
-import { Injectable } from '@angular/core'
-import { CLIHandler, CLIEvent, ConfigService } from 'tabby-core'
-import { SerialService } from './services/serial.service'
-
-@Injectable()
-export class SerialCLIHandler extends CLIHandler {
-    firstMatchOnly = true
-    priority = 0
-
-    constructor (
-        private serial: SerialService,
-        private config: ConfigService,
-    ) {
-        super()
-    }
-
-    async handle (event: CLIEvent): Promise<boolean> {
-        const op = event.argv._[0]
-
-        if (op === 'connect-serial') {
-            const connection = this.config.store.serial.connections.find(x => x.name === event.argv.connectionName)
-            if (connection) {
-                this.serial.connect(connection)
-            }
-            return true
-        }
-
-        return false
-    }
-}

+ 0 - 200
tabby-serial/src/components/editConnectionModal.component.pug

@@ -1,200 +0,0 @@
-.modal-body
-    ul.nav-tabs(ngbNav, #nav='ngbNav')
-        li(ngbNavItem)
-            a(ngbNavLink) General
-            ng-template(ngbNavContent)
-                .form-group
-                    label Name
-                    input.form-control(
-                        type='text',
-                        autofocus,
-                        [(ngModel)]='connection.name',
-                    )
-
-                .row
-                    .col-6
-                        .form-group
-                            label Path
-                            input.form-control(
-                                type='text',
-                                [(ngModel)]='connection.port',
-                                [ngbTypeahead]='portsAutocomplete',
-                                [resultFormatter]='portsFormatter',
-                            )
-
-                    .col-6
-                        .form-group
-                            label Baud Rate
-                            input.form-control(
-                                type='number',
-                                [(ngModel)]='connection.baudrate',
-                                [ngbTypeahead]='baudratesAutocomplete',
-                            )
-
-                .row
-                    .col-6
-                        .form-line
-                            .header
-                                .title Input mode
-
-                            .d-flex(ngbDropdown)
-                                button.btn.btn-secondary.btn-tab-bar(
-                                    ngbDropdownToggle,
-                                ) {{getInputModeName(connection.inputMode)}}
-
-                                div(ngbDropdownMenu)
-                                    a.d-flex.flex-column(
-                                        *ngFor='let mode of inputModes',
-                                        (click)='connection.inputMode = mode.key',
-                                        ngbDropdownItem
-                                    )
-                                        div {{mode.name}}
-                                        .text-muted {{mode.description}}
-
-                    .col-6
-                        .form-line
-                            .header
-                                .title Input newlines
-
-                            select.form-control(
-                                [(ngModel)]='connection.inputNewlines',
-                            )
-                                option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
-
-                .row
-                    .col-6
-                        .form-line
-                            .header
-                                .title Output mode
-
-                            .d-flex(ngbDropdown)
-                                button.btn.btn-secondary.btn-tab-bar(
-                                    ngbDropdownToggle,
-                                ) {{getOutputModeName(connection.outputMode)}}
-
-                                div(ngbDropdownMenu)
-                                    a.d-flex.flex-column(
-                                        *ngFor='let mode of outputModes',
-                                        (click)='connection.outputMode = mode.key',
-                                        ngbDropdownItem
-                                    )
-                                        div {{mode.name}}
-                                        .text-muted {{mode.description}}
-
-                    .col-6
-                        .form-line
-                            .header
-                                .title Output newlines
-
-                            select.form-control(
-                                [(ngModel)]='connection.outputNewlines',
-                            )
-                                option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
-
-        li(ngbNavItem)
-            a(ngbNavLink) Advanced
-            ng-template(ngbNavContent)
-                .form-line
-                    .header
-                        .title Tab color
-                    input.form-control(
-                        type='text',
-                        autofocus,
-                        [(ngModel)]='connection.color',
-                        placeholder='#000000'
-                    )
-
-                .form-line
-                    .header
-                        .title DataBits
-                    input.form-control(
-                        type='number',
-                        placeholder='8',
-                        [(ngModel)]='connection.databits',
-                    )
-
-                .form-line
-                    .header
-                        .title StopBits
-                    input.form-control(
-                        type='number',
-                        placeholder='1',
-                        [(ngModel)]='connection.stopbits',
-                    )
-
-                .form-line
-                    .header
-                        .title Parity
-                    input.form-control(
-                        type='text',
-                        [(ngModel)]='connection.parity',
-                        placeholder='none'
-                    )
-
-                .form-line
-                    .header
-                        .title RTSCTS
-                    toggle([(ngModel)]='connection.rtscts')
-
-                .form-line
-                    .header
-                        .title Xon
-                    toggle([(ngModel)]='connection.xon')
-
-                .form-line
-                    .header
-                        .title Xoff
-                    toggle([(ngModel)]='connection.xoff')
-
-                .form-line
-                    .header
-                        .title Xany
-                    toggle([(ngModel)]='connection.xany')
-
-        li(ngbNavItem)
-            a(ngbNavLink) Login scripts
-            ng-template(ngbNavContent)
-                table(*ngIf='connection.scripts.length > 0')
-                    tr
-                        th String to expect
-                        th String to be sent
-                        th.pl-2 Regex
-                        th.pl-2 Optional
-                        th.pl-2 Actions
-                    tr(*ngFor='let script of connection.scripts')
-                        td.pr-2
-                            input.form-control(
-                                type='text',
-                                [(ngModel)]='script.expect'
-                            )
-                        td
-                            input.form-control(
-                                type='text',
-                                [(ngModel)]='script.send'
-                            )
-                        td.pl-2
-                            checkbox(
-                                [(ngModel)]='script.isRegex',
-                            )
-                        td.pl-2
-                            checkbox(
-                                [(ngModel)]='script.optional',
-                            )
-                        td.pl-2
-                            .input-group.flex-nowrap
-                                button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
-                                    i.fas.fa-arrow-up
-                                button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
-                                    i.fas.fa-arrow-down
-                                button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
-                                    i.fas.fa-trash
-
-                button.btn.btn-outline-info.mt-2((click)='addScript()')
-                    i.fas.fa-plus
-                    span New item
-
-    div([ngbNavOutlet]='nav')
-
-.modal-footer
-    button.btn.btn-outline-primary((click)='save()') Save
-    button.btn.btn-outline-danger((click)='cancel()') Cancel

+ 171 - 0
tabby-serial/src/components/serialProfileSettings.component.pug

@@ -0,0 +1,171 @@
+ul.nav-tabs(ngbNav, #nav='ngbNav')
+    li(ngbNavItem)
+        a(ngbNavLink) General
+        ng-template(ngbNavContent)
+            .row
+                .col-6
+                    .form-group
+                        label Device
+                        input.form-control(
+                            type='text',
+                            [(ngModel)]='profile.options.port',
+                            [ngbTypeahead]='portsAutocomplete',
+                            [resultFormatter]='portsFormatter',
+                        )
+
+                .col-6
+                    .form-group
+                        label Baud Rate
+                        input.form-control(
+                            type='number',
+                            [(ngModel)]='profile.options.baudrate',
+                            [ngbTypeahead]='baudratesAutocomplete',
+                        )
+
+            .form-line
+                .header
+                    .title Input mode
+
+                .d-flex(ngbDropdown)
+                    button.btn.btn-secondary.btn-tab-bar(
+                        ngbDropdownToggle,
+                    ) {{getInputModeName(profile.options.inputMode)}}
+
+                    div(ngbDropdownMenu)
+                        a.d-flex.flex-column(
+                            *ngFor='let mode of inputModes',
+                            (click)='profile.options.inputMode = mode.key',
+                            ngbDropdownItem
+                        )
+                            div {{mode.name}}
+                            .text-muted {{mode.description}}
+
+            .form-line
+                .header
+                    .title Input newlines
+
+                select.form-control(
+                    [(ngModel)]='profile.options.inputNewlines',
+                )
+                    option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
+
+            .form-line
+                .header
+                    .title Output mode
+
+                .d-flex(ngbDropdown)
+                    button.btn.btn-secondary.btn-tab-bar(
+                        ngbDropdownToggle,
+                    ) {{getOutputModeName(profile.options.outputMode)}}
+
+                    div(ngbDropdownMenu)
+                        a.d-flex.flex-column(
+                            *ngFor='let mode of outputModes',
+                            (click)='profile.options.outputMode = mode.key',
+                            ngbDropdownItem
+                        )
+                            div {{mode.name}}
+                            .text-muted {{mode.description}}
+
+            .form-line
+                .header
+                    .title Output newlines
+
+                select.form-control(
+                    [(ngModel)]='profile.options.outputNewlines',
+                )
+                    option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
+
+    li(ngbNavItem)
+        a(ngbNavLink) Advanced
+        ng-template(ngbNavContent)
+            .form-line
+                .header
+                    .title Data bits
+                input.form-control(
+                    type='number',
+                    placeholder='8',
+                    [(ngModel)]='profile.options.databits',
+                )
+
+            .form-line
+                .header
+                    .title Stop bits
+                input.form-control(
+                    type='number',
+                    placeholder='1',
+                    [(ngModel)]='profile.options.stopbits',
+                )
+
+            .form-line
+                .header
+                    .title Parity
+                input.form-control(
+                    type='text',
+                    [(ngModel)]='profile.options.parity',
+                    placeholder='none'
+                )
+
+            .form-line
+                .header
+                    .title RTS / CTS
+                toggle([(ngModel)]='profile.options.rtscts')
+
+            .form-line
+                .header
+                    .title XON
+                toggle([(ngModel)]='profile.options.xon')
+
+            .form-line
+                .header
+                    .title XOFF
+                toggle([(ngModel)]='profile.options.xoff')
+
+            .form-line
+                .header
+                    .title Xany
+                toggle([(ngModel)]='profile.options.xany')
+
+    li(ngbNavItem)
+        a(ngbNavLink) Login scripts
+        ng-template(ngbNavContent)
+            table(*ngIf='profile.options.scripts.length > 0')
+                tr
+                    th String to expect
+                    th String to be sent
+                    th.pl-2 Regex
+                    th.pl-2 Optional
+                    th.pl-2 Actions
+                tr(*ngFor='let script of profile.options.scripts')
+                    td.pr-2
+                        input.form-control(
+                            type='text',
+                            [(ngModel)]='script.expect'
+                        )
+                    td
+                        input.form-control(
+                            type='text',
+                            [(ngModel)]='script.send'
+                        )
+                    td.pl-2
+                        checkbox(
+                            [(ngModel)]='script.isRegex',
+                        )
+                    td.pl-2
+                        checkbox(
+                            [(ngModel)]='script.optional',
+                        )
+                    td.pl-2
+                        .input-group.flex-nowrap
+                            button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
+                                i.fas.fa-arrow-up
+                            button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
+                                i.fas.fa-arrow-down
+                            button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
+                                i.fas.fa-trash
+
+            button.btn.btn-outline-info.mt-2((click)='addScript()')
+                i.fas.fa-plus
+                span New item
+
+div([ngbNavOutlet]='nav')

+ 23 - 34
tabby-serial/src/components/editConnectionModal.component.ts → tabby-serial/src/components/serialProfileSettings.component.ts

@@ -1,17 +1,16 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { Component } from '@angular/core'
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
-import { PlatformService } from 'tabby-core'
-import { SerialConnection, LoginScript, SerialPortInfo, BAUD_RATES } from '../api'
+import { PlatformService, ProfileSettingsComponent } from 'tabby-core'
+import { LoginScript, SerialPortInfo, BAUD_RATES, SerialProfile } from '../api'
 import { SerialService } from '../services/serial.service'
 
 /** @hidden */
 @Component({
-    template: require('./editConnectionModal.component.pug'),
+    template: require('./serialProfileSettings.component.pug'),
 })
-export class EditConnectionModalComponent {
-    connection: SerialConnection
+export class SerialProfileSettingsComponent implements ProfileSettingsComponent {
+    profile: SerialProfile
     foundPorts: SerialPortInfo[]
     inputModes = [
         { key: null, name: 'Normal', description: 'Input is sent as you type' },
@@ -31,11 +30,9 @@ export class EditConnectionModalComponent {
     ]
 
     constructor (
-        private modalInstance: NgbActiveModal,
         private platform: PlatformService,
         private serial: SerialService,
-    ) {
-    }
+    ) { }
 
     getInputModeName (key) {
         return this.inputModes.find(x => x.key === key)?.name
@@ -64,42 +61,34 @@ export class EditConnectionModalComponent {
     }
 
     async ngOnInit () {
-        this.connection.scripts = this.connection.scripts ?? []
+        this.profile.options.scripts = this.profile.options.scripts ?? []
         this.foundPorts = await this.serial.listPorts()
     }
 
-    save () {
-        this.modalInstance.close(this.connection)
-    }
-
-    cancel () {
-        this.modalInstance.dismiss()
-    }
-
     moveScriptUp (script: LoginScript) {
-        if (!this.connection.scripts) {
-            this.connection.scripts = []
+        if (!this.profile.options.scripts) {
+            this.profile.options.scripts = []
         }
-        const index = this.connection.scripts.indexOf(script)
+        const index = this.profile.options.scripts.indexOf(script)
         if (index > 0) {
-            this.connection.scripts.splice(index, 1)
-            this.connection.scripts.splice(index - 1, 0, script)
+            this.profile.options.scripts.splice(index, 1)
+            this.profile.options.scripts.splice(index - 1, 0, script)
         }
     }
 
     moveScriptDown (script: LoginScript) {
-        if (!this.connection.scripts) {
-            this.connection.scripts = []
+        if (!this.profile.options.scripts) {
+            this.profile.options.scripts = []
         }
-        const index = this.connection.scripts.indexOf(script)
-        if (index >= 0 && index < this.connection.scripts.length - 1) {
-            this.connection.scripts.splice(index, 1)
-            this.connection.scripts.splice(index + 1, 0, script)
+        const index = this.profile.options.scripts.indexOf(script)
+        if (index >= 0 && index < this.profile.options.scripts.length - 1) {
+            this.profile.options.scripts.splice(index, 1)
+            this.profile.options.scripts.splice(index + 1, 0, script)
         }
     }
 
     async deleteScript (script: LoginScript) {
-        if (this.connection.scripts && (await this.platform.showMessageBox(
+        if (this.profile.options.scripts && (await this.platform.showMessageBox(
             {
                 type: 'warning',
                 message: 'Delete this script?',
@@ -108,14 +97,14 @@ export class EditConnectionModalComponent {
                 defaultId: 1,
             }
         )).response === 1) {
-            this.connection.scripts = this.connection.scripts.filter(x => x !== script)
+            this.profile.options.scripts = this.profile.options.scripts.filter(x => x !== script)
         }
     }
 
     addScript () {
-        if (!this.connection.scripts) {
-            this.connection.scripts = []
+        if (!this.profile.options.scripts) {
+            this.profile.options.scripts = []
         }
-        this.connection.scripts.push({ expect: '', send: '' })
+        this.profile.options.scripts.push({ expect: '', send: '' })
     }
 }

+ 0 - 16
tabby-serial/src/components/serialSettingsTab.component.pug

@@ -1,16 +0,0 @@
-h3 Connections
-
-.list-group.list-group-flush.mt-3.mb-3
-    .list-group-item.list-group-item-action.d-flex.align-items-center(
-        *ngFor='let connection of connections',
-        (click)='editConnection(connection)'
-    )
-        .mr-auto
-            div {{connection.name}}
-            .text-muted {{connection.port}}
-        button.btn.btn-outline-danger.ml-1((click)='$event.stopPropagation(); deleteConnection(connection)')
-            i.fas.fa-trash
-
-button.btn.btn-primary((click)='createConnection()')
-    i.fas.fa-fw.fa-plus
-    span.ml-2 Add connection

+ 0 - 82
tabby-serial/src/components/serialSettingsTab.component.ts

@@ -1,82 +0,0 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Component } from '@angular/core'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { ConfigService, PlatformService } from 'tabby-core'
-import { SerialConnection } from '../api'
-import { EditConnectionModalComponent } from './editConnectionModal.component'
-
-/** @hidden */
-@Component({
-    template: require('./serialSettingsTab.component.pug'),
-})
-export class SerialSettingsTabComponent {
-    connections: SerialConnection[]
-
-    constructor (
-        public config: ConfigService,
-        private platform: PlatformService,
-        private ngbModal: NgbModal,
-    ) {
-        this.connections = this.config.store.serial.connections
-        this.refresh()
-    }
-
-    createConnection () {
-        const connection: SerialConnection = {
-            name: '',
-            port: '',
-            baudrate: 115200,
-            databits: 8,
-            parity: 'none',
-            rtscts: false,
-            stopbits: 1,
-            xany: false,
-            xoff: false,
-            xon: false,
-            inputMode: null,
-            outputMode: null,
-            inputNewlines: null,
-            outputNewlines: null,
-        }
-
-        const modal = this.ngbModal.open(EditConnectionModalComponent)
-        modal.componentInstance.connection = connection
-        modal.result.then(result => {
-            this.connections.push(result)
-            this.config.store.serial.connections = this.connections
-            this.config.save()
-            this.refresh()
-        })
-    }
-
-    editConnection (connection: SerialConnection) {
-        const modal = this.ngbModal.open(EditConnectionModalComponent, { size: 'lg' })
-        modal.componentInstance.connection = Object.assign({}, connection)
-        modal.result.then(result => {
-            Object.assign(connection, result)
-            this.config.store.serial.connections = this.connections
-            this.config.save()
-            this.refresh()
-        })
-    }
-
-    async deleteConnection (connection: SerialConnection) {
-        if ((await this.platform.showMessageBox(
-            {
-                type: 'warning',
-                message: `Delete "${connection.name}"?`,
-                buttons: ['Keep', 'Delete'],
-                defaultId: 1,
-            }
-        )).response === 1) {
-            this.connections = this.connections.filter(x => x !== connection)
-            this.config.store.serial.connections = this.connections
-            this.config.save()
-            this.refresh()
-        }
-    }
-
-    refresh () {
-        this.connections = this.config.store.serial.connections
-    }
-}

+ 1 - 1
tabby-serial/src/components/serialTab.component.pug

@@ -4,7 +4,7 @@
     .toolbar
         i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
         i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
-        strong {{connection.port}} ({{connection.baudrate}})
+        strong {{profile.options.port}} ({{profile.options.baudrate}})
 
         .mr-auto
 

+ 8 - 8
tabby-serial/src/components/serialTab.component.ts

@@ -6,7 +6,7 @@ import { first } from 'rxjs/operators'
 import { SelectorService } from 'tabby-core'
 import { BaseTerminalTabComponent } from 'tabby-terminal'
 import { SerialService } from '../services/serial.service'
-import { SerialConnection, SerialSession, BAUD_RATES } from '../api'
+import { SerialSession, BAUD_RATES, SerialProfile } from '../api'
 
 /** @hidden */
 @Component({
@@ -16,7 +16,7 @@ import { SerialConnection, SerialSession, BAUD_RATES } from '../api'
     animations: BaseTerminalTabComponent.animations,
 })
 export class SerialTabComponent extends BaseTerminalTabComponent {
-    connection?: SerialConnection
+    profile?: SerialProfile
     session: SerialSession|null = null
     serialPort: any
     private serialService: SerialService
@@ -57,17 +57,17 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
         super.ngOnInit()
 
         setImmediate(() => {
-            this.setTitle(this.connection!.name)
+            this.setTitle(this.profile!.name)
         })
     }
 
     async initializeSession () {
-        if (!this.connection) {
-            this.logger.error('No Serial connection info supplied')
+        if (!this.profile) {
+            this.logger.error('No serial profile info supplied')
             return
         }
 
-        const session = this.serialService.createSession(this.connection)
+        const session = this.serialService.createSession(this.profile)
         this.setSession(session)
         this.write(`Connecting to `)
 
@@ -112,7 +112,7 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
     async getRecoveryToken (): Promise<any> {
         return {
             type: 'app:serial-tab',
-            connection: this.connection,
+            profile: this.profile,
             savedState: this.frontend?.saveState(),
         }
     }
@@ -128,6 +128,6 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
             name: x.toString(), result: x,
         })))
         this.serialPort.update({ baudRate: rate })
-        this.connection!.baudrate = rate
+        this.profile!.options.baudrate = rate
     }
 }

+ 0 - 5
tabby-serial/src/config.ts

@@ -3,11 +3,6 @@ import { ConfigProvider } from 'tabby-core'
 /** @hidden */
 export class SerialConfigProvider extends ConfigProvider {
     defaults = {
-        serial: {
-            connections: [],
-            options: {
-            },
-        },
         hotkeys: {
             serial: [
                 'Alt-K',

+ 6 - 14
tabby-serial/src/index.ts

@@ -3,20 +3,16 @@ import { CommonModule } from '@angular/common'
 import { FormsModule } from '@angular/forms'
 import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
-import TabbyCoreModule, { ToolbarButtonProvider, ConfigProvider, TabRecoveryProvider, HotkeyProvider, CLIHandler } from 'tabby-core'
-import { SettingsTabProvider } from 'tabby-settings'
+import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, ProfileProvider } from 'tabby-core'
 import TabbyTerminalModule from 'tabby-terminal'
 
-import { EditConnectionModalComponent } from './components/editConnectionModal.component'
-import { SerialSettingsTabComponent } from './components/serialSettingsTab.component'
+import { SerialProfileSettingsComponent } from './components/serialProfileSettings.component'
 import { SerialTabComponent } from './components/serialTab.component'
 
-import { ButtonProvider } from './buttonProvider'
 import { SerialConfigProvider } from './config'
-import { SerialSettingsTabProvider } from './settings'
 import { RecoveryProvider } from './recoveryProvider'
 import { SerialHotkeyProvider } from './hotkeys'
-import { SerialCLIHandler } from './cli'
+import { SerialProfilesService } from './profiles'
 
 /** @hidden */
 @NgModule({
@@ -29,21 +25,17 @@ import { SerialCLIHandler } from './cli'
         TabbyTerminalModule,
     ],
     providers: [
-        { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
         { provide: ConfigProvider, useClass: SerialConfigProvider, multi: true },
-        { provide: SettingsTabProvider, useClass: SerialSettingsTabProvider, multi: true },
+        { provide: ProfileProvider, useClass: SerialProfilesService, multi: true },
         { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
         { provide: HotkeyProvider, useClass: SerialHotkeyProvider, multi: true },
-        { provide: CLIHandler, useClass: SerialCLIHandler, multi: true },
     ],
     entryComponents: [
-        EditConnectionModalComponent,
-        SerialSettingsTabComponent,
+        SerialProfileSettingsComponent,
         SerialTabComponent,
     ],
     declarations: [
-        EditConnectionModalComponent,
-        SerialSettingsTabComponent,
+        SerialProfileSettingsComponent,
         SerialTabComponent,
     ],
 })

+ 74 - 0
tabby-serial/src/profiles.ts

@@ -0,0 +1,74 @@
+import slugify from 'slugify'
+import deepClone from 'clone-deep'
+import { Injectable } from '@angular/core'
+import { ProfileProvider, NewTabParameters, SelectorService } 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 {
+    id = 'serial'
+    name = 'Serial'
+    settingsComponent = SerialProfileSettingsComponent
+
+    constructor (
+        private selector: SelectorService,
+        private serial: SerialService,
+    ) { super() }
+
+    async getBuiltinProfiles (): Promise<SerialProfile[]> {
+        return [
+            {
+                id: `serial:template`,
+                type: 'serial',
+                name: 'Serial connection',
+                icon: 'fas fa-microchip',
+                options: {
+                    port: '',
+                    databits: 8,
+                    parity: 'none',
+                    rtscts: false,
+                    stopbits: 1,
+                    xany: false,
+                    xoff: false,
+                    xon: false,
+                    inputMode: null,
+                    outputMode: null,
+                    inputNewlines: null,
+                    outputNewlines: null,
+                },
+                isBuiltin: true,
+                isTemplate: true,
+            },
+            ...(await this.serial.listPorts()).map(p => ({
+                id: `serial:port-${slugify(p.name).replace('.', '-')}`,
+                type: 'serial',
+                name: p.description ? `Serial: ${p.description}` : 'Serial',
+                icon: 'fas fa-microchip',
+                isBuiltin: true,
+                options: {
+                    port: p.name,
+                },
+            })),
+        ]
+    }
+
+    async getNewTabParameters (profile: SerialProfile): Promise<NewTabParameters<SerialTabComponent>> {
+        if (!profile.options.baudrate) {
+            profile = deepClone(profile)
+            profile.options.baudrate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({
+                name: x.toString(), result: x,
+            })))
+        }
+        return {
+            type: SerialTabComponent,
+            inputs: { profile },
+        }
+    }
+
+    getDescription (profile: SerialProfile): string {
+        return profile.options.port
+    }
+}

+ 5 - 5
tabby-serial/src/recoveryProvider.ts

@@ -1,20 +1,20 @@
 import { Injectable } from '@angular/core'
-import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
+import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
 
 import { SerialTabComponent } from './components/serialTab.component'
 
 /** @hidden */
 @Injectable()
-export class RecoveryProvider extends TabRecoveryProvider {
+export class RecoveryProvider extends TabRecoveryProvider<SerialTabComponent> {
     async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
         return recoveryToken.type === 'app:serial-tab'
     }
 
-    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+    async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<SerialTabComponent>> {
         return {
             type: SerialTabComponent,
-            options: {
-                connection: recoveryToken.connection,
+            inputs: {
+                profile: recoveryToken.profile,
                 savedState: recoveryToken.savedState,
             },
         }

+ 32 - 106
tabby-serial/src/services/serial.service.ts

@@ -1,8 +1,7 @@
 import { Injectable, NgZone } from '@angular/core'
 import SerialPort from 'serialport'
-import { LogService, AppService, SelectorOption, ConfigService, NotificationsService, SelectorService } from 'tabby-core'
-import { SettingsTabComponent } from 'tabby-settings'
-import { SerialConnection, SerialSession, SerialPortInfo, BAUD_RATES } from '../api'
+import { LogService, NotificationsService, SelectorService, ProfilesService } from 'tabby-core'
+import { SerialSession, SerialPortInfo, BAUD_RATES, SerialProfile } from '../api'
 import { SerialTabComponent } from '../components/serialTab.component'
 
 @Injectable({ providedIn: 'root' })
@@ -11,9 +10,8 @@ export class SerialService {
         private log: LogService,
         private zone: NgZone,
         private notifications: NotificationsService,
-        private app: AppService,
+        private profilesService: ProfilesService,
         private selector: SelectorService,
-        private config: ConfigService,
     ) { }
 
     async listPorts (): Promise<SerialPortInfo[]> {
@@ -23,23 +21,23 @@ export class SerialService {
         }))
     }
 
-    createSession (connection: SerialConnection): SerialSession {
-        const session = new SerialSession(connection)
-        session.logger = this.log.create(`serial-${connection.port}`)
+    createSession (profile: SerialProfile): SerialSession {
+        const session = new SerialSession(profile)
+        session.logger = this.log.create(`serial-${profile.options.port}`)
         return session
     }
 
     async connectSession (session: SerialSession): Promise<SerialPort> {
-        const serial = new SerialPort(session.connection.port, {
+        const serial = new SerialPort(session.profile.options.port, {
             autoOpen: false,
-            baudRate: parseInt(session.connection.baudrate as any),
-            dataBits: session.connection.databits,
-            stopBits: session.connection.stopbits,
-            parity: session.connection.parity,
-            rtscts: session.connection.rtscts,
-            xon: session.connection.xon,
-            xoff: session.connection.xoff,
-            xany: session.connection.xany,
+            baudRate: parseInt(session.profile.options.baudrate as any),
+            dataBits: session.profile.options.databits,
+            stopBits: session.profile.options.stopbits,
+            parity: session.profile.options.parity,
+            rtscts: session.profile.options.rtscts,
+            xon: session.profile.options.xon,
+            xoff: session.profile.options.xoff,
+            xany: session.profile.options.xany,
         })
         session.serial = serial
         let connected = false
@@ -72,105 +70,33 @@ export class SerialService {
         return serial
     }
 
-    async showConnectionSelector (): Promise<void> {
-        const options: SelectorOption<void>[] = []
-        const foundPorts = await this.listPorts()
-
-        try {
-            const lastConnection = JSON.parse(window.localStorage.lastSerialConnection)
-            if (lastConnection) {
-                options.push({
-                    name: lastConnection.name,
-                    icon: 'history',
-                    callback: () => this.connect(lastConnection),
-                })
-                options.push({
-                    name: 'Clear last connection',
-                    icon: 'eraser',
-                    callback: () => {
-                        window.localStorage.lastSerialConnection = null
-                    },
-                })
-            }
-        } catch { }
-
-        for (const port of foundPorts) {
-            options.push({
-                name: port.name,
-                description: port.description,
-                icon: 'arrow-right',
-                callback: () => this.connectFoundPort(port),
-            })
-        }
-
-        for (const connection of this.config.store.serial.connections) {
-            options.push({
-                name: connection.name,
-                description: connection.port,
-                callback: () => this.connect(connection),
-            })
-        }
-
-        options.push({
-            name: 'Manage connections',
-            icon: 'cog',
-            callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'serial' }),
-        })
-
-        options.push({
-            name: 'Quick connect',
-            freeInputPattern: 'Open device: %s...',
-            icon: 'arrow-right',
-            callback: query => this.quickConnect(query),
-        })
-
-
-        await this.selector.show('Open a serial port', options)
-    }
-
-    async connect (connection: SerialConnection): Promise<SerialTabComponent> {
-        try {
-            const tab = this.app.openNewTab(
-                SerialTabComponent,
-                { connection }
-            ) as SerialTabComponent
-            if (connection.color) {
-                (this.app.getParentTab(tab) ?? tab).color = connection.color
-            }
-            setTimeout(() => {
-                this.app.activeTab?.emitFocused()
-            })
-            return tab
-        } catch (error) {
-            this.notifications.error(`Could not connect: ${error}`)
-            throw error
-        }
-    }
-
-    quickConnect (query: string): Promise<SerialTabComponent> {
+    quickConnect (query: string): Promise<SerialTabComponent|null> {
         let path = query
         let baudrate = 115200
         if (query.includes('@')) {
             baudrate = parseInt(path.split('@')[1])
             path = path.split('@')[0]
         }
-        const connection: SerialConnection = {
+        const profile: SerialProfile = {
             name: query,
-            port: path,
-            baudrate: baudrate,
-            databits: 8,
-            parity: 'none',
-            rtscts: false,
-            stopbits: 1,
-            xany: false,
-            xoff: false,
-            xon: false,
+            type: 'serial',
+            options: {
+                port: path,
+                baudrate: baudrate,
+                databits: 8,
+                parity: 'none',
+                rtscts: false,
+                stopbits: 1,
+                xany: false,
+                xoff: false,
+                xon: false,
+            },
         }
-        window.localStorage.lastSerialConnection = JSON.stringify(connection)
-        return this.connect(connection)
+        window.localStorage.lastSerialConnection = JSON.stringify(profile)
+        return this.profilesService.openNewTabForProfile(profile) as Promise<SerialTabComponent|null>
     }
 
-    async connectFoundPort (port: SerialPortInfo): Promise<SerialTabComponent> {
+    async connectFoundPort (port: SerialPortInfo): Promise<SerialTabComponent|null> {
         const rate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({
             name: x.toString(), result: x,
         })))

+ 0 - 16
tabby-serial/src/settings.ts

@@ -1,16 +0,0 @@
-import { Injectable } from '@angular/core'
-import { SettingsTabProvider } from 'tabby-settings'
-
-import { SerialSettingsTabComponent } from './components/serialSettingsTab.component'
-
-/** @hidden */
-@Injectable()
-export class SerialSettingsTabProvider extends SettingsTabProvider {
-    id = 'serial'
-    icon = 'keyboard'
-    title = 'Serial'
-
-    getComponentType (): any {
-        return SerialSettingsTabComponent
-    }
-}

+ 1 - 1
tabby-settings/src/buttonProvider.ts

@@ -36,7 +36,7 @@ export class ButtonProvider extends ToolbarButtonProvider {
         if (settingsTab) {
             this.app.selectTab(settingsTab)
         } else {
-            this.app.openNewTabRaw(SettingsTabComponent)
+            this.app.openNewTabRaw({ type: SettingsTabComponent })
         }
     }
 }

+ 11 - 15
tabby-settings/src/components/hotkeySettingsTab.component.pug

@@ -6,18 +6,14 @@ h3.mb-3 Hotkeys
             i.fas.fa-fw.fa-search
     input.form-control(type='search', placeholder='Search hotkeys', [(ngModel)]='hotkeyFilter')
 
-.form-group
-    table.hotkeys-table
-        tr
-            th Name
-            th ID
-            th Hotkey
-        ng-container(*ngFor='let hotkey of hotkeyDescriptions')
-            tr(*ngIf='!hotkeyFilter || hotkeyFilterFn(hotkey, hotkeyFilter)')
-                td {{hotkey.name}}
-                td {{hotkey.id}}
-                td.pr-5
-                    multi-hotkey-input(
-                        [model]='getHotkey(hotkey.id) || []',
-                        (modelChange)='setHotkey(hotkey.id, $event)'
-                    )
+.form-group.hotkeys-table
+    ng-container(*ngFor='let hotkey of hotkeyDescriptions')
+        .row.align-items-center(*ngIf='!hotkeyFilter || hotkeyFilterFn(hotkey, hotkeyFilter)')
+            .col-8.py-2
+                span {{hotkey.name}}
+                span.ml-2.text-muted ({{hotkey.id}})
+            .col-4.pr-5
+                multi-hotkey-input(
+                    [model]='getHotkey(hotkey.id) || []',
+                    (modelChange)='setHotkey(hotkey.id, $event)'
+                )

+ 0 - 7
tabby-settings/src/components/hotkeySettingsTab.component.scss

@@ -1,7 +0,0 @@
-.hotkeys-table {
-    margin-top: 30px;
-
-    td, th {
-        padding: 5px 10px;
-    }
-}

+ 1 - 4
tabby-settings/src/components/hotkeySettingsTab.component.ts

@@ -11,9 +11,6 @@ import {
 @Component({
     selector: 'hotkey-settings-tab',
     template: require('./hotkeySettingsTab.component.pug'),
-    styles: [
-        require('./hotkeySettingsTab.component.scss'),
-    ],
 })
 export class HotkeySettingsTabComponent {
     hotkeyFilter = ''
@@ -51,7 +48,7 @@ export class HotkeySettingsTabComponent {
 
     hotkeyFilterFn (hotkey: HotkeyDescription, query: string): boolean {
         // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
-        const s = hotkey.name + (this.getHotkey(hotkey.id) || []).toString()
+        const s = hotkey.name + hotkey.id + (this.getHotkey(hotkey.id) || []).toString()
         return s.toLowerCase().includes(query.toLowerCase())
     }
 }

+ 1 - 0
tabby-settings/src/components/settingsTab.component.ts

@@ -47,6 +47,7 @@ export class SettingsTabComponent extends BaseTabComponent {
         super()
         this.setTitle('Settings')
         this.settingsProviders = config.enabledServices(this.settingsProviders)
+        this.settingsProviders = this.settingsProviders.filter(x => !!x.getComponentType())
         this.settingsProviders.sort((a, b) => a.title.localeCompare(b.title))
 
         this.configDefaults = yaml.dump(config.getDefaults())

+ 0 - 1
tabby-ssh/package.json

@@ -25,7 +25,6 @@
     "@types/ssh2": "^0.5.46",
     "ansi-colors": "^4.1.1",
     "cli-spinner": "^0.2.10",
-    "clone-deep": "^4.0.1",
     "ssh2": "^1.1.0",
     "sshpk": "Eugeny/node-sshpk#89ed17dfae425a8b629873c8337e77d26838c04f",
     "strip-ansi": "^7.0.0"

+ 23 - 24
tabby-ssh/src/api.ts

@@ -10,7 +10,7 @@ import stripAnsi from 'strip-ansi'
 import socksv5 from 'socksv5'
 import { Injector, NgZone } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { ConfigService, FileProvidersService, HostAppService, Logger, NotificationsService, Platform, PlatformService, wrapPromise } from 'tabby-core'
+import { ConfigService, FileProvidersService, HostAppService, Logger, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, Profile } from 'tabby-core'
 import { BaseSession } from 'tabby-terminal'
 import { Server, Socket, createServer, createConnection } from 'net'
 import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
@@ -18,7 +18,6 @@ import type { FileEntry, Stats } from 'ssh2-streams'
 import { Subject, Observable } from 'rxjs'
 import { ProxyCommandStream } from './services/ssh.service'
 import { PasswordStorageService } from './services/passwordStorage.service'
-import { PromptModalComponent } from './components/promptModal.component'
 import { promisify } from 'util'
 
 const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
@@ -37,23 +36,23 @@ export enum SSHAlgorithmType {
     HOSTKEY = 'serverHostKey',
 }
 
-export interface SSHConnection {
-    name: string
+export interface SSHProfile extends Profile {
+    options: SSHProfileOptions
+}
+
+export interface SSHProfileOptions {
     host: string
     port?: number
     user: string
     auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
     password?: string
     privateKeys?: string[]
-    group: string | null
     scripts?: LoginScript[]
     keepaliveInterval?: number
     keepaliveCountMax?: number
     readyTimeout?: number
-    color?: string
     x11?: boolean
     skipBanner?: boolean
-    disableDynamicTitle?: boolean
     jumpHost?: string
     agentForward?: boolean
     warnOnClose?: boolean
@@ -285,7 +284,7 @@ export class SSHSession extends BaseSession {
 
     constructor (
         injector: Injector,
-        public connection: SSHConnection,
+        public profile: SSHProfile,
     ) {
         super()
         this.passwordStorage = injector.get(PasswordStorageService)
@@ -297,7 +296,7 @@ export class SSHSession extends BaseSession {
         this.fileProviders = injector.get(FileProvidersService)
         this.config = injector.get(ConfigService)
 
-        this.scripts = connection.scripts ?? []
+        this.scripts = profile.options.scripts ?? []
         this.destroyed$.subscribe(() => {
             for (const port of this.forwardedPorts) {
                 if (port.type === PortForwardType.Local) {
@@ -327,9 +326,9 @@ export class SSHSession extends BaseSession {
         }
 
         this.remainingAuthMethods = [{ type: 'none' }]
-        if (!this.connection.auth || this.connection.auth === 'publicKey') {
-            if (this.connection.privateKeys?.length) {
-                for (const pk of this.connection.privateKeys) {
+        if (!this.profile.options.auth || this.profile.options.auth === 'publicKey') {
+            if (this.profile.options.privateKeys?.length) {
+                for (const pk of this.profile.options.privateKeys) {
                     try {
                         this.remainingAuthMethods.push({
                             type: 'publickey',
@@ -347,17 +346,17 @@ export class SSHSession extends BaseSession {
                 })
             }
         }
-        if (!this.connection.auth || this.connection.auth === 'agent') {
+        if (!this.profile.options.auth || this.profile.options.auth === 'agent') {
             if (!this.agentPath) {
                 this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
             } else {
                 this.remainingAuthMethods.push({ type: 'agent' })
             }
         }
-        if (!this.connection.auth || this.connection.auth === 'password') {
+        if (!this.profile.options.auth || this.profile.options.auth === 'password') {
             this.remainingAuthMethods.push({ type: 'password' })
         }
-        if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') {
+        if (!this.profile.options.auth || this.profile.options.auth === 'keyboardInteractive') {
             this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
         }
         this.remainingAuthMethods.push({ type: 'hostbased' })
@@ -379,7 +378,7 @@ export class SSHSession extends BaseSession {
         })
 
         try {
-            this.shell = await this.openShellChannel({ x11: this.connection.x11 })
+            this.shell = await this.openShellChannel({ x11: this.profile.options.x11 })
         } catch (err) {
             this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected opening a shell channel: ${err}`)
             if (err.toString().includes('Unable to request X11')) {
@@ -535,30 +534,30 @@ export class SSHSession extends BaseSession {
                 continue
             }
             if (method.type === 'password') {
-                if (this.connection.password) {
+                if (this.profile.options.password) {
                     this.emitServiceMessage('Using preset password')
                     return {
                         type: 'password',
-                        username: this.connection.user,
-                        password: this.connection.password,
+                        username: this.profile.options.user,
+                        password: this.profile.options.password,
                     }
                 }
 
                 if (!this.keychainPasswordUsed) {
-                    const password = await this.passwordStorage.loadPassword(this.connection)
+                    const password = await this.passwordStorage.loadPassword(this.profile)
                     if (password) {
                         this.emitServiceMessage('Trying saved password')
                         this.keychainPasswordUsed = true
                         return {
                             type: 'password',
-                            username: this.connection.user,
+                            username: this.profile.options.user,
                             password,
                         }
                     }
                 }
 
                 const modal = this.ngbModal.open(PromptModalComponent)
-                modal.componentInstance.prompt = `Password for ${this.connection.user}@${this.connection.host}`
+                modal.componentInstance.prompt = `Password for ${this.profile.options.user}@${this.profile.options.host}`
                 modal.componentInstance.password = true
                 modal.componentInstance.showRememberCheckbox = true
 
@@ -570,7 +569,7 @@ export class SSHSession extends BaseSession {
                         }
                         return {
                             type: 'password',
-                            username: this.connection.user,
+                            username: this.profile.options.user,
                             password: result.value,
                         }
                     } else {
@@ -585,7 +584,7 @@ export class SSHSession extends BaseSession {
                     const key = await this.loadPrivateKey(method.contents)
                     return {
                         type: 'publickey',
-                        username: this.connection.user,
+                        username: this.profile.options.user,
                         key,
                     }
                 } catch (e) {

+ 0 - 43
tabby-ssh/src/buttonProvider.ts

@@ -1,43 +0,0 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Injectable } from '@angular/core'
-import { HotkeysService, ToolbarButtonProvider, ToolbarButton, HostAppService, Platform } from 'tabby-core'
-import { SSHService } from './services/ssh.service'
-
-/** @hidden */
-@Injectable()
-export class ButtonProvider extends ToolbarButtonProvider {
-    constructor (
-        hotkeys: HotkeysService,
-        private hostApp: HostAppService,
-        private ssh: SSHService,
-    ) {
-        super()
-        hotkeys.matchedHotkey.subscribe(async (hotkey: string) => {
-            if (hotkey === 'ssh') {
-                this.activate()
-            }
-        })
-    }
-
-    activate () {
-        this.ssh.showConnectionSelector()
-    }
-
-    provide (): ToolbarButton[] {
-        if (this.hostApp.platform === Platform.Web) {
-            return [{
-                icon: require('../../tabby-local/src/icons/plus.svg'),
-                title: 'SSH connections',
-                click: () => this.activate(),
-            }]
-        } else {
-            return [{
-                icon: require('./icons/globe.svg'),
-                weight: 5,
-                title: 'SSH connections',
-                touchBarNSImage: 'NSTouchBarOpenInBrowserTemplate',
-                click: () => this.activate(),
-            }]
-        }
-    }
-}

+ 0 - 30
tabby-ssh/src/cli.ts

@@ -1,30 +0,0 @@
-import { Injectable } from '@angular/core'
-import { CLIHandler, CLIEvent, ConfigService } from 'tabby-core'
-import { SSHService } from './services/ssh.service'
-
-@Injectable()
-export class SSHCLIHandler extends CLIHandler {
-    firstMatchOnly = true
-    priority = 0
-
-    constructor (
-        private ssh: SSHService,
-        private config: ConfigService,
-    ) {
-        super()
-    }
-
-    async handle (event: CLIEvent): Promise<boolean> {
-        const op = event.argv._[0]
-
-        if (op === 'connect-ssh') {
-            const connection = this.config.store.ssh.connections.find(x => x.name === event.argv.connectionName)
-            if (connection) {
-                this.ssh.connect(connection)
-            }
-            return true
-        }
-
-        return false
-    }
-}

+ 0 - 269
tabby-ssh/src/components/editConnectionModal.component.pug

@@ -1,269 +0,0 @@
-.modal-body
-    ul.nav-tabs(ngbNav, #nav='ngbNav')
-        li(ngbNavItem)
-            a(ngbNavLink) General
-            ng-template(ngbNavContent)
-                .form-group
-                    label Name
-                    input.form-control(
-                        type='text',
-                        autofocus,
-                        [(ngModel)]='connection.name',
-                    )
-
-                .form-group
-                    label Group
-                    input.form-control(
-                        type='text',
-                        placeholder='Ungrouped',
-                        [(ngModel)]='connection.group',
-                        [ngbTypeahead]='groupTypeahead',
-                    )
-
-                .d-flex.w-100(*ngIf='!useProxyCommand')
-                    .form-group.w-100.mr-4
-                        label Host
-                        input.form-control(
-                            type='text',
-                            [(ngModel)]='connection.host',
-                        )
-
-                    .form-group
-                        label Port
-                        input.form-control(
-                            type='number',
-                            placeholder='22',
-                            [(ngModel)]='connection.port',
-                        )
-
-                .alert.alert-info(*ngIf='useProxyCommand')
-                    .mr-auto Using a proxy command instead of a network connection
-
-                .form-group
-                    label Username
-                    input.form-control(
-                        type='text',
-                        [(ngModel)]='connection.user',
-                    )
-
-                .form-group
-                    label Authentication method
-
-                    .btn-group.mt-1.w-100(
-                        [(ngModel)]='connection.auth',
-                        ngbRadioGroup
-                    )
-                        label.btn.btn-secondary(ngbButtonLabel)
-                            input(type='radio', ngbButton, [value]='null')
-                            i.far.fa-lightbulb
-                            .m-0 Auto
-                        label.btn.btn-secondary(ngbButtonLabel)
-                            input(type='radio', ngbButton, [value]='"password"')
-                            i.fas.fa-font
-                            .m-0 Password
-                        label.btn.btn-secondary(ngbButtonLabel)
-                            input(type='radio', ngbButton, [value]='"publicKey"')
-                            i.fas.fa-key
-                            .m-0 Key
-                        label.btn.btn-secondary(ngbButtonLabel, ng:if='hostApp.platform !== Platform.Web')
-                            input(type='radio', ngbButton, [value]='"agent"')
-                            i.fas.fa-user-secret
-                            .m-0 Agent
-                        label.btn.btn-secondary(ngbButtonLabel)
-                            input(type='radio', ngbButton, [value]='"keyboardInteractive"')
-                            i.far.fa-keyboard
-                            .m-0 Interactive
-
-                .form-line(*ngIf='!connection.auth || connection.auth === "password"')
-                    .header
-                        .title Password
-                        .description(*ngIf='!hasSavedPassword') Save a password in the keychain
-                        .description(*ngIf='hasSavedPassword') There is a saved password for this connection
-                    button.btn.btn-outline-success.ml-4(*ngIf='!hasSavedPassword', (click)='setPassword()')
-                        i.fas.fa-key
-                        span Set password
-                    button.btn.btn-danger.ml-4(*ngIf='hasSavedPassword', (click)='clearSavedPassword()')
-                        i.fas.fa-trash-alt
-                        span Forget
-
-                .form-group(*ngIf='!connection.auth || connection.auth === "publicKey"')
-                    label Private keys
-                    .list-group.mb-2
-                        .list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys')
-                            i.fas.fa-key
-                            .no-wrap.mr-auto {{path}}
-                            button.btn.btn-link((click)='removePrivateKey(path)')
-                                i.fas.fa-trash
-                    button.btn.btn-secondary((click)='addPrivateKey()')
-                        i.fas.fa-folder-open
-                        span Add a private key
-
-        li(ngbNavItem)
-            a(ngbNavLink) Ports
-            ng-template(ngbNavContent)
-                ssh-port-forwarding-config(
-                    [model]='connection.forwardedPorts',
-                    (forwardAdded)='onForwardAdded($event)',
-                    (forwardRemoved)='onForwardRemoved($event)'
-                )
-
-        li(ngbNavItem)
-            a(ngbNavLink) Advanced
-            ng-template(ngbNavContent)
-                .form-line(*ngIf='!useProxyCommand')
-                    .header
-                        .title Jump host
-                    select.form-control([(ngModel)]='connection.jumpHost')
-                        option(value='') None
-                        option([ngValue]='x.name', *ngFor='let x of config.store.ssh.connections') {{x.name}}
-
-                .form-line(ng:if='hostApp.platform !== Platform.Web')
-                    .header
-                        .title X11 forwarding
-                    toggle([(ngModel)]='connection.x11')
-
-                .form-line(ng:if='hostApp.platform !== Platform.Web')
-                    .header
-                        .title Agent forwarding
-                    toggle([(ngModel)]='connection.agentForward')
-
-                .form-line
-                    .header
-                        .title Tab color
-                    input.form-control(
-                        type='text',
-                        autofocus,
-                        [(ngModel)]='connection.color',
-                        placeholder='#000000'
-                    )
-
-                .form-line
-                    .header
-                        .title Disable dynamic tab title
-                        .description Connection name will be used as a title instead
-                    toggle([(ngModel)]='connection.disableDynamicTitle')
-
-                .form-line
-                    .header
-                        .title Skip MoTD/banner
-                        .description Will prevent the SSH greeting from showing up
-                    toggle([(ngModel)]='connection.skipBanner')
-
-                .form-line
-                    .header
-                        .title Keep Alive Interval (Milliseconds)
-                    input.form-control(
-                        type='number',
-                        placeholder='0',
-                        [(ngModel)]='connection.keepaliveInterval',
-                    )
-
-                .form-line
-                    .header
-                        .title Max Keep Alive Count
-                    input.form-control(
-                        type='number',
-                        placeholder='3',
-                        [(ngModel)]='connection.keepaliveCountMax',
-                    )
-
-                .form-line
-                    .header
-                        .title Ready Timeout (Milliseconds)
-                    input.form-control(
-                        type='number',
-                        placeholder='20000',
-                        [(ngModel)]='connection.readyTimeout',
-                    )
-
-                .form-line(*ngIf='!connection.jumpHost && hostApp.platform !== Platform.Web')
-                    .header
-                        .title Use a proxy command
-                        .description Command's stdin/stdout is used instead of a network connection
-                    toggle([(ngModel)]='useProxyCommand')
-
-                .form-group(*ngIf='useProxyCommand && !connection.jumpHost')
-                    label Proxy command
-                    input.form-control(
-                        type='text',
-                        [(ngModel)]='connection.proxyCommand',
-                    )
-
-        li(ngbNavItem)
-            a(ngbNavLink) Ciphers
-            ng-template(ngbNavContent)
-                .form-line.align-items-start
-                    .header
-                        .title Ciphers
-                    .w-75
-                        div(*ngFor='let alg of supportedAlgorithms.cipher')
-                            checkbox([text]='alg', [(ngModel)]='algorithms.cipher[alg]')
-
-                .form-line.align-items-start
-                    .header
-                        .title Key exchange
-                    .w-75
-                        div(*ngFor='let alg of supportedAlgorithms.kex')
-                            checkbox([text]='alg', [(ngModel)]='algorithms.kex[alg]')
-
-                .form-line.align-items-start
-                    .header
-                        .title HMAC
-                    .w-75
-                        div(*ngFor='let alg of supportedAlgorithms.hmac')
-                            checkbox([text]='alg', [(ngModel)]='algorithms.hmac[alg]')
-
-                .form-line.align-items-start
-                    .header
-                        .title Host key
-                    .w-75
-                        div(*ngFor='let alg of supportedAlgorithms.serverHostKey')
-                            checkbox([text]='alg', [(ngModel)]='algorithms.serverHostKey[alg]')
-
-        li(ngbNavItem)
-            a(ngbNavLink) Login scripts
-            ng-template(ngbNavContent)
-                table(*ngIf='connection.scripts.length > 0')
-                    tr
-                        th String to expect
-                        th String to be sent
-                        th.pl-2 Regex
-                        th.pl-2 Optional
-                        th.pl-2 Actions
-                    tr(*ngFor='let script of connection.scripts')
-                        td.pr-2
-                            input.form-control(
-                                type='text',
-                                [(ngModel)]='script.expect'
-                            )
-                        td
-                            input.form-control(
-                                type='text',
-                                [(ngModel)]='script.send'
-                            )
-                        td.pl-2
-                            checkbox(
-                                [(ngModel)]='script.isRegex',
-                            )
-                        td.pl-2
-                            checkbox(
-                                [(ngModel)]='script.optional',
-                            )
-                        td.pl-2
-                            .input-group.flex-nowrap
-                                button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
-                                    i.fas.fa-arrow-up
-                                button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
-                                    i.fas.fa-arrow-down
-                                button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
-                                    i.fas.fa-trash
-
-                button.btn.btn-outline-info.mt-2((click)='addScript()')
-                    i.fas.fa-plus
-                    span New item
-
-    div([ngbNavOutlet]='nav')
-
-.modal-footer
-    button.btn.btn-outline-primary((click)='save()') Save
-    button.btn.btn-outline-danger((click)='cancel()') Cancel

+ 231 - 0
tabby-ssh/src/components/sshProfileSettings.component.pug

@@ -0,0 +1,231 @@
+ul.nav-tabs(ngbNav, #nav='ngbNav')
+    li(ngbNavItem)
+        a(ngbNavLink) General
+        ng-template(ngbNavContent)
+            .d-flex.w-100(*ngIf='!useProxyCommand')
+                .form-group.w-100.mr-4
+                    label Host
+                    input.form-control(
+                        type='text',
+                        [(ngModel)]='profile.options.host',
+                    )
+
+                .form-group
+                    label Port
+                    input.form-control(
+                        type='number',
+                        placeholder='22',
+                        [(ngModel)]='profile.options.port',
+                    )
+
+            .alert.alert-info(*ngIf='useProxyCommand')
+                .mr-auto Using a proxy command instead of a network connection
+
+            .form-group
+                label Username
+                input.form-control(
+                    type='text',
+                    [(ngModel)]='profile.options.user',
+                )
+
+            .form-group
+                label Authentication method
+
+                .btn-group.mt-1.w-100(
+                    [(ngModel)]='profile.options.auth',
+                    ngbRadioGroup
+                )
+                    label.btn.btn-secondary(ngbButtonLabel)
+                        input(type='radio', ngbButton, [value]='null')
+                        i.far.fa-lightbulb
+                        .m-0 Auto
+                    label.btn.btn-secondary(ngbButtonLabel)
+                        input(type='radio', ngbButton, [value]='"password"')
+                        i.fas.fa-font
+                        .m-0 Password
+                    label.btn.btn-secondary(ngbButtonLabel)
+                        input(type='radio', ngbButton, [value]='"publicKey"')
+                        i.fas.fa-key
+                        .m-0 Key
+                    label.btn.btn-secondary(ngbButtonLabel, ng:if='hostApp.platform !== Platform.Web')
+                        input(type='radio', ngbButton, [value]='"agent"')
+                        i.fas.fa-user-secret
+                        .m-0 Agent
+                    label.btn.btn-secondary(ngbButtonLabel)
+                        input(type='radio', ngbButton, [value]='"keyboardInteractive"')
+                        i.far.fa-keyboard
+                        .m-0 Interactive
+
+            .form-line(*ngIf='!profile.options.auth || profile.options.auth === "password"')
+                .header
+                    .title Password
+                    .description(*ngIf='!hasSavedPassword') Save a password in the keychain
+                    .description(*ngIf='hasSavedPassword') There is a saved password for this connection
+                button.btn.btn-outline-success.ml-4(*ngIf='!hasSavedPassword', (click)='setPassword()')
+                    i.fas.fa-key
+                    span Set password
+                button.btn.btn-danger.ml-4(*ngIf='hasSavedPassword', (click)='clearSavedPassword()')
+                    i.fas.fa-trash-alt
+                    span Forget
+
+            .form-group(*ngIf='!profile.options.auth || profile.options.auth === "publicKey"')
+                label Private keys
+                .list-group.mb-2
+                    .list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of profile.options.privateKeys')
+                        i.fas.fa-key
+                        .no-wrap.mr-auto {{path}}
+                        button.btn.btn-link((click)='removePrivateKey(path)')
+                            i.fas.fa-trash
+                button.btn.btn-secondary((click)='addPrivateKey()')
+                    i.fas.fa-folder-open
+                    span Add a private key
+
+    li(ngbNavItem)
+        a(ngbNavLink) Ports
+        ng-template(ngbNavContent)
+            ssh-port-forwarding-config(
+                [model]='profile.options.forwardedPorts',
+                (forwardAdded)='onForwardAdded($event)',
+                (forwardRemoved)='onForwardRemoved($event)'
+            )
+
+    li(ngbNavItem)
+        a(ngbNavLink) Advanced
+        ng-template(ngbNavContent)
+            .form-line(*ngIf='!useProxyCommand')
+                .header
+                    .title Jump host
+                select.form-control([(ngModel)]='profile.options.jumpHost')
+                    option(value='') None
+                    option([ngValue]='x.id', *ngFor='let x of jumpHosts') {{x.name}}
+
+            .form-line(ng:if='hostApp.platform !== Platform.Web')
+                .header
+                    .title X11 forwarding
+                toggle([(ngModel)]='profile.options.x11')
+
+            .form-line(ng:if='hostApp.platform !== Platform.Web')
+                .header
+                    .title Agent forwarding
+                toggle([(ngModel)]='profile.options.agentForward')
+
+            .form-line
+                .header
+                    .title Skip MoTD/banner
+                    .description Will prevent the SSH greeting from showing up
+                toggle([(ngModel)]='profile.options.skipBanner')
+
+            .form-line
+                .header
+                    .title Keep Alive Interval (Milliseconds)
+                input.form-control(
+                    type='number',
+                    placeholder='0',
+                    [(ngModel)]='profile.options.keepaliveInterval',
+                )
+
+            .form-line
+                .header
+                    .title Max Keep Alive Count
+                input.form-control(
+                    type='number',
+                    placeholder='3',
+                    [(ngModel)]='profile.options.keepaliveCountMax',
+                )
+
+            .form-line
+                .header
+                    .title Ready Timeout (Milliseconds)
+                input.form-control(
+                    type='number',
+                    placeholder='20000',
+                    [(ngModel)]='profile.options.readyTimeout',
+                )
+
+            .form-line(*ngIf='!profile.options.jumpHost && hostApp.platform !== Platform.Web')
+                .header
+                    .title Use a proxy command
+                    .description Command's stdin/stdout is used instead of a network connection
+                toggle([(ngModel)]='useProxyCommand')
+
+            .form-group(*ngIf='useProxyCommand && !profile.options.jumpHost')
+                label Proxy command
+                input.form-control(
+                    type='text',
+                    [(ngModel)]='profile.options.proxyCommand',
+                )
+
+    li(ngbNavItem)
+        a(ngbNavLink) Ciphers
+        ng-template(ngbNavContent)
+            .form-line.align-items-start
+                .header
+                    .title Ciphers
+                .w-75
+                    div(*ngFor='let alg of supportedAlgorithms.cipher')
+                        checkbox([text]='alg', [(ngModel)]='algorithms.cipher[alg]')
+
+            .form-line.align-items-start
+                .header
+                    .title Key exchange
+                .w-75
+                    div(*ngFor='let alg of supportedAlgorithms.kex')
+                        checkbox([text]='alg', [(ngModel)]='algorithms.kex[alg]')
+
+            .form-line.align-items-start
+                .header
+                    .title HMAC
+                .w-75
+                    div(*ngFor='let alg of supportedAlgorithms.hmac')
+                        checkbox([text]='alg', [(ngModel)]='algorithms.hmac[alg]')
+
+            .form-line.align-items-start
+                .header
+                    .title Host key
+                .w-75
+                    div(*ngFor='let alg of supportedAlgorithms.serverHostKey')
+                        checkbox([text]='alg', [(ngModel)]='algorithms.serverHostKey[alg]')
+
+    li(ngbNavItem)
+        a(ngbNavLink) Login scripts
+        ng-template(ngbNavContent)
+            table(*ngIf='profile.options.scripts.length > 0')
+                tr
+                    th String to expect
+                    th String to be sent
+                    th.pl-2 Regex
+                    th.pl-2 Optional
+                    th.pl-2 Actions
+                tr(*ngFor='let script of profile.options.scripts')
+                    td.pr-2
+                        input.form-control(
+                            type='text',
+                            [(ngModel)]='script.expect'
+                        )
+                    td
+                        input.form-control(
+                            type='text',
+                            [(ngModel)]='script.send'
+                        )
+                    td.pl-2
+                        checkbox(
+                            [(ngModel)]='script.isRegex',
+                        )
+                    td.pl-2
+                        checkbox(
+                            [(ngModel)]='script.optional',
+                        )
+                    td.pl-2
+                        .input-group.flex-nowrap
+                            button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
+                                i.fas.fa-arrow-up
+                            button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
+                                i.fas.fa-arrow-down
+                            button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
+                                i.fas.fa-trash
+
+            button.btn.btn-outline-info.mt-2((click)='addScript()')
+                i.fas.fa-plus
+                span New item
+
+div([ngbNavOutlet]='nav')

+ 45 - 64
tabby-ssh/src/components/editConnectionModal.component.ts → tabby-ssh/src/components/sshProfileSettings.component.ts

@@ -1,35 +1,30 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { Component } from '@angular/core'
-import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
-import { Observable } from 'rxjs'
-import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 
-import { ConfigService, PlatformService, FileProvidersService, Platform, HostAppService } from 'tabby-core'
+import { ConfigService, PlatformService, FileProvidersService, Platform, HostAppService, PromptModalComponent } from 'tabby-core'
 import { PasswordStorageService } from '../services/passwordStorage.service'
-import { SSHConnection, LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST } from '../api'
-import { PromptModalComponent } from './promptModal.component'
+import { LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST, SSHProfile } from '../api'
 import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
 
 /** @hidden */
 @Component({
-    template: require('./editConnectionModal.component.pug'),
+    template: require('./sshProfileSettings.component.pug'),
 })
-export class EditConnectionModalComponent {
+export class SSHProfileSettingsComponent {
     Platform = Platform
-    connection: SSHConnection
+    profile: SSHProfile
     hasSavedPassword: boolean
     useProxyCommand: boolean
 
     supportedAlgorithms: Record<string, string> = {}
     defaultAlgorithms: Record<string, string[]> = {}
     algorithms: Record<string, Record<string, boolean>> = {}
-
-    private groupNames: string[]
+    jumpHosts: SSHProfile[]
 
     constructor (
         public config: ConfigService,
         public hostApp: HostAppService,
-        private modalInstance: NgbActiveModal,
         private platform: PlatformService,
         private passwordStorage: PasswordStorageService,
         private ngbModal: NgbModal,
@@ -51,39 +46,30 @@ export class EditConnectionModalComponent {
             this.supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort()
             this.defaultAlgorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x))
         }
-
-        this.groupNames = [...new Set(config.store.ssh.connections.map(x => x.group))] as string[]
-        this.groupNames = this.groupNames.filter(x => x).sort()
     }
 
-    groupTypeahead = (text$: Observable<string>) =>
-        text$.pipe(
-            debounceTime(200),
-            distinctUntilChanged(),
-            map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase())))
-        )
-
     async ngOnInit () {
-        this.connection.algorithms = this.connection.algorithms ?? {}
+        this.jumpHosts = this.config.store.profiles.filter(x => x.type === 'ssh' && x !== this.profile)
+        this.profile.options.algorithms = this.profile.options.algorithms ?? {}
         for (const k of Object.values(SSHAlgorithmType)) {
             // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-            if (!this.connection.algorithms[k]) {
-                this.connection.algorithms[k] = this.defaultAlgorithms[k]
+            if (!this.profile.options.algorithms[k]) {
+                this.profile.options.algorithms[k] = this.defaultAlgorithms[k]
             }
 
             this.algorithms[k] = {}
-            for (const alg of this.connection.algorithms[k]) {
+            for (const alg of this.profile.options.algorithms[k]) {
                 this.algorithms[k][alg] = true
             }
         }
 
-        this.connection.scripts = this.connection.scripts ?? []
-        this.connection.auth = this.connection.auth ?? null
-        this.connection.privateKeys ??= []
+        this.profile.options.scripts = this.profile.options.scripts ?? []
+        this.profile.options.auth = this.profile.options.auth ?? null
+        this.profile.options.privateKeys ??= []
 
-        this.useProxyCommand = !!this.connection.proxyCommand
+        this.useProxyCommand = !!this.profile.options.proxyCommand
         try {
-            this.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.connection)
+            this.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.profile)
         } catch (e) {
             console.error('Could not check for saved password', e)
         }
@@ -91,12 +77,12 @@ export class EditConnectionModalComponent {
 
     async setPassword () {
         const modal = this.ngbModal.open(PromptModalComponent)
-        modal.componentInstance.prompt = `Password for ${this.connection.user}@${this.connection.host}`
+        modal.componentInstance.prompt = `Password for ${this.profile.options.user}@${this.profile.options.host}`
         modal.componentInstance.password = true
         try {
             const result = await modal.result
             if (result?.value) {
-                this.passwordStorage.savePassword(this.connection, result.value)
+                this.passwordStorage.savePassword(this.profile, result.value)
                 this.hasSavedPassword = true
             }
         } catch { }
@@ -104,61 +90,56 @@ export class EditConnectionModalComponent {
 
     clearSavedPassword () {
         this.hasSavedPassword = false
-        this.passwordStorage.deletePassword(this.connection)
+        this.passwordStorage.deletePassword(this.profile)
     }
 
     async addPrivateKey () {
-        const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.connection.name}`)
-        this.connection.privateKeys = [
-            ...this.connection.privateKeys!,
+        const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.profile.name}`)
+        this.profile.options.privateKeys = [
+            ...this.profile.options.privateKeys!,
             ref,
         ]
     }
 
     removePrivateKey (path: string) {
-        this.connection.privateKeys = this.connection.privateKeys?.filter(x => x !== path)
+        this.profile.options.privateKeys = this.profile.options.privateKeys?.filter(x => x !== path)
     }
 
     save () {
         for (const k of Object.values(SSHAlgorithmType)) {
-            this.connection.algorithms![k] = Object.entries(this.algorithms[k])
+            this.profile.options.algorithms![k] = Object.entries(this.algorithms[k])
                 .filter(([_, v]) => !!v)
                 .map(([key, _]) => key)
         }
         if (!this.useProxyCommand) {
-            this.connection.proxyCommand = undefined
+            this.profile.options.proxyCommand = undefined
         }
-        this.modalInstance.close(this.connection)
-    }
-
-    cancel () {
-        this.modalInstance.dismiss()
     }
 
     moveScriptUp (script: LoginScript) {
-        if (!this.connection.scripts) {
-            this.connection.scripts = []
+        if (!this.profile.options.scripts) {
+            this.profile.options.scripts = []
         }
-        const index = this.connection.scripts.indexOf(script)
+        const index = this.profile.options.scripts.indexOf(script)
         if (index > 0) {
-            this.connection.scripts.splice(index, 1)
-            this.connection.scripts.splice(index - 1, 0, script)
+            this.profile.options.scripts.splice(index, 1)
+            this.profile.options.scripts.splice(index - 1, 0, script)
         }
     }
 
     moveScriptDown (script: LoginScript) {
-        if (!this.connection.scripts) {
-            this.connection.scripts = []
+        if (!this.profile.options.scripts) {
+            this.profile.options.scripts = []
         }
-        const index = this.connection.scripts.indexOf(script)
-        if (index >= 0 && index < this.connection.scripts.length - 1) {
-            this.connection.scripts.splice(index, 1)
-            this.connection.scripts.splice(index + 1, 0, script)
+        const index = this.profile.options.scripts.indexOf(script)
+        if (index >= 0 && index < this.profile.options.scripts.length - 1) {
+            this.profile.options.scripts.splice(index, 1)
+            this.profile.options.scripts.splice(index + 1, 0, script)
         }
     }
 
     async deleteScript (script: LoginScript) {
-        if (this.connection.scripts && (await this.platform.showMessageBox(
+        if (this.profile.options.scripts && (await this.platform.showMessageBox(
             {
                 type: 'warning',
                 message: 'Delete this script?',
@@ -167,23 +148,23 @@ export class EditConnectionModalComponent {
                 defaultId: 1,
             }
         )).response === 1) {
-            this.connection.scripts = this.connection.scripts.filter(x => x !== script)
+            this.profile.options.scripts = this.profile.options.scripts.filter(x => x !== script)
         }
     }
 
     addScript () {
-        if (!this.connection.scripts) {
-            this.connection.scripts = []
+        if (!this.profile.options.scripts) {
+            this.profile.options.scripts = []
         }
-        this.connection.scripts.push({ expect: '', send: '' })
+        this.profile.options.scripts.push({ expect: '', send: '' })
     }
 
     onForwardAdded (fw: ForwardedPortConfig) {
-        this.connection.forwardedPorts = this.connection.forwardedPorts ?? []
-        this.connection.forwardedPorts.push(fw)
+        this.profile.options.forwardedPorts = this.profile.options.forwardedPorts ?? []
+        this.profile.options.forwardedPorts.push(fw)
     }
 
     onForwardRemoved (fw: ForwardedPortConfig) {
-        this.connection.forwardedPorts = this.connection.forwardedPorts?.filter(x => x !== fw)
+        this.profile.options.forwardedPorts = this.profile.options.forwardedPorts?.filter(x => x !== fw)
     }
 }

+ 1 - 54
tabby-ssh/src/components/sshSettingsTab.component.pug

@@ -1,57 +1,4 @@
-.d-flex.align-items-center.mb-3
-    h3.m-0 SSH Connections
-
-    button.btn.btn-primary.ml-auto((click)='createConnection()')
-        i.fas.fa-fw.fa-plus
-        span.ml-2 Add connection
-
-.input-group.mb-3
-    .input-group-prepend
-        .input-group-text
-            i.fas.fa-fw.fa-search
-    input.form-control(type='search', placeholder='Filter', [(ngModel)]='filter')
-
-.list-group.list-group-light.mt-3.mb-3
-    ng-container(*ngFor='let group of childGroups')
-        ng-container(*ngIf='isGroupVisible(group)')
-            .list-group-item.list-group-item-action.d-flex.align-items-center(
-                (click)='groupCollapsed[group.name] = !groupCollapsed[group.name]'
-            )
-                .fa.fa-fw.fa-chevron-right(*ngIf='groupCollapsed[group.name]')
-                .fa.fa-fw.fa-chevron-down(*ngIf='!groupCollapsed[group.name]')
-                span.ml-3.mr-auto {{group.name || "Ungrouped"}}
-                button.btn.btn-sm.btn-link.hover-reveal.ml-2(
-                    [class.invisible]='!group.name',
-                    (click)='$event.stopPropagation(); editGroup(group)'
-                )
-                    i.fas.fa-edit
-                button.btn.btn-sm.btn-link.hover-reveal.ml-2(
-                    [class.invisible]='!group.name',
-                    (click)='$event.stopPropagation(); deleteGroup(group)'
-                )
-                    i.fas.fa-trash
-
-            ng-container(*ngIf='!groupCollapsed[group.name]')
-                ng-container(*ngFor='let connection of group.connections')
-                    .list-group-item.list-group-item-action.pl-5.d-flex.align-items-center(
-                        *ngIf='isConnectionVisible(connection)',
-                        (click)='editConnection(connection)'
-                    )
-                        .mr-3 {{connection.name}}
-                        .mr-auto.text-muted {{connection.host}}
-
-                        .hover-reveal(ngbDropdown, placement='bottom-right')
-                            button.btn.btn-link(ngbDropdownToggle, (click)='$event.stopPropagation()')
-                                i.fas.fa-fw.fa-ellipsis-v
-                            div(ngbDropdownMenu)
-                                button.dropdown-item((click)='$event.stopPropagation(); copyConnection(connection)')
-                                    i.fas.fa-copy
-                                    span Duplicate
-                                button.dropdown-item((click)='$event.stopPropagation(); deleteConnection(connection)')
-                                    i.fas.fa-trash
-                                    span Delete
-
-h3.mt-5 Options
+h3 SSH
 
 .form-line
     .header

+ 0 - 3
tabby-ssh/src/components/sshSettingsTab.component.scss

@@ -1,3 +0,0 @@
-.list-group-item {
-    padding: 0.3rem 1rem;
-}

+ 2 - 145
tabby-ssh/src/components/sshSettingsTab.component.ts

@@ -1,158 +1,15 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import deepClone from 'clone-deep'
 import { Component } from '@angular/core'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
-import { PasswordStorageService } from '../services/passwordStorage.service'
-import { SSHConnection } from '../api'
-import { EditConnectionModalComponent } from './editConnectionModal.component'
-import { PromptModalComponent } from './promptModal.component'
-
-interface SSHConnectionGroup {
-    name: string|null
-    connections: SSHConnection[]
-}
+import { ConfigService, HostAppService, Platform } from 'tabby-core'
 
 /** @hidden */
 @Component({
     template: require('./sshSettingsTab.component.pug'),
-    styles: [require('./sshSettingsTab.component.scss')],
 })
 export class SSHSettingsTabComponent {
-    connections: SSHConnection[]
-    childGroups: SSHConnectionGroup[]
-    groupCollapsed: Record<string, boolean> = {}
-    filter = ''
     Platform = Platform
 
     constructor (
         public config: ConfigService,
         public hostApp: HostAppService,
-        private platform: PlatformService,
-        private ngbModal: NgbModal,
-        private passwordStorage: PasswordStorageService,
-    ) {
-        this.connections = this.config.store.ssh.connections
-        this.refresh()
-    }
-
-    createConnection () {
-        const connection: SSHConnection = {
-            name: '',
-            group: null,
-            host: '',
-            port: 22,
-            user: 'root',
-        }
-
-        const modal = this.ngbModal.open(EditConnectionModalComponent)
-        modal.componentInstance.connection = connection
-        modal.result.then(result => {
-            this.connections.push(result)
-            this.config.store.ssh.connections = this.connections
-            this.config.save()
-            this.refresh()
-        })
-    }
-
-    copyConnection (connection: SSHConnection) {
-        const modal = this.ngbModal.open(EditConnectionModalComponent)
-        modal.componentInstance.connection = {
-            ...deepClone(connection),
-            name: `${connection.name} Copy`,
-        }
-        modal.result.then(result => {
-            this.connections.push(result)
-            this.config.store.ssh.connections = this.connections
-            this.config.save()
-            this.refresh()
-        })
-    }
-
-    editConnection (connection: SSHConnection) {
-        const modal = this.ngbModal.open(EditConnectionModalComponent, { size: 'lg' })
-        modal.componentInstance.connection = deepClone(connection)
-        modal.result.then(result => {
-            Object.assign(connection, result)
-            this.config.store.ssh.connections = this.connections
-            this.config.save()
-            this.refresh()
-        })
-    }
-
-    async deleteConnection (connection: SSHConnection) {
-        if ((await this.platform.showMessageBox(
-            {
-                type: 'warning',
-                message: `Delete "${connection.name}"?`,
-                buttons: ['Keep', 'Delete'],
-                defaultId: 1,
-            }
-        )).response === 1) {
-            this.connections = this.connections.filter(x => x !== connection)
-            this.passwordStorage.deletePassword(connection)
-            this.config.store.ssh.connections = this.connections
-            this.config.save()
-            this.refresh()
-        }
-    }
-
-    editGroup (group: SSHConnectionGroup) {
-        const modal = this.ngbModal.open(PromptModalComponent)
-        modal.componentInstance.prompt = 'New group name'
-        modal.componentInstance.value = group.name
-        modal.result.then(result => {
-            if (result) {
-                for (const connection of this.connections.filter(x => x.group === group.name)) {
-                    connection.group = result.value
-                }
-                this.config.store.ssh.connections = this.connections
-                this.config.save()
-                this.refresh()
-            }
-        })
-    }
-
-    async deleteGroup (group: SSHConnectionGroup) {
-        if ((await this.platform.showMessageBox(
-            {
-                type: 'warning',
-                message: `Delete "${group.name}"?`,
-                buttons: ['Keep', 'Delete'],
-                defaultId: 1,
-            }
-        )).response === 1) {
-            for (const connection of this.connections.filter(x => x.group === group.name)) {
-                connection.group = null
-            }
-            this.config.save()
-            this.refresh()
-        }
-    }
-
-    refresh () {
-        this.connections = this.config.store.ssh.connections
-        this.childGroups = []
-
-        for (const connection of this.connections) {
-            connection.group = connection.group ?? null
-            let group = this.childGroups.find(x => x.name === connection.group)
-            if (!group) {
-                group = {
-                    name: connection.group,
-                    connections: [],
-                }
-                this.childGroups.push(group)
-            }
-            group.connections.push(connection)
-        }
-    }
-
-    isGroupVisible (group: SSHConnectionGroup): boolean {
-        return !this.filter || group.connections.some(x => this.isConnectionVisible(x))
-    }
-
-    isConnectionVisible (connection: SSHConnection): boolean {
-        return !this.filter || `${connection.name}$${connection.host}`.toLowerCase().includes(this.filter.toLowerCase())
-    }
+    ) { }
 }

+ 1 - 1
tabby-ssh/src/components/sshTab.component.pug

@@ -4,7 +4,7 @@
     .toolbar
         i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
         i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
-        strong.mr-auto {{connection.user}}@{{connection.host}}:{{connection.port}}
+        strong.mr-auto {{profile.options.user}}@{{profile.options.host}}:{{profile.options.port}}
 
         button.btn.btn-secondary.mr-2((click)='reconnect()', [class.btn-info]='!session || !session.open')
             span Reconnect

+ 17 - 17
tabby-ssh/src/components/sshTab.component.ts

@@ -6,7 +6,7 @@ import { first } from 'rxjs/operators'
 import { Platform, RecoveryToken } from 'tabby-core'
 import { BaseTerminalTabComponent } from 'tabby-terminal'
 import { SSHService } from '../services/ssh.service'
-import { SSHConnection, SSHSession } from '../api'
+import { SSHProfile, SSHSession } from '../api'
 import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
 
 
@@ -19,7 +19,7 @@ import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.compon
 })
 export class SSHTabComponent extends BaseTerminalTabComponent {
     Platform = Platform
-    connection?: SSHConnection
+    profile?: SSHProfile
     session: SSHSession|null = null
     sftpPanelVisible = false
     sftpPath = '/'
@@ -43,13 +43,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
     }
 
     ngOnInit (): void {
-        if (!this.connection) {
-            throw new Error('Connection not set')
+        if (!this.profile) {
+            throw new Error('Profile not set')
         }
 
         this.logger = this.log.create('terminalTab')
 
-        this.enableDynamicTitle = !this.connection.disableDynamicTitle
+        this.enableDynamicTitle = !this.profile.disableDynamicTitle
 
         this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
             if (!this.hasFocus) {
@@ -84,16 +84,16 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
         super.ngOnInit()
 
         setImmediate(() => {
-            this.setTitle(this.connection!.name)
+            this.setTitle(this.profile!.name)
         })
     }
 
     async setupOneSession (session: SSHSession): Promise<void> {
-        if (session.connection.jumpHost) {
-            const jumpConnection: SSHConnection|null = this.config.store.ssh.connections.find(x => x.name === session.connection.jumpHost)
+        if (session.profile.options.jumpHost) {
+            const jumpConnection: SSHProfile|null = this.config.store.profiles.find(x => x.id === session.profile.options.jumpHost)
 
             if (!jumpConnection) {
-                throw new Error(`${session.connection.host}: jump host "${session.connection.jumpHost}" not found in your config`)
+                throw new Error(`${session.profile.options.host}: jump host "${session.profile.options.jumpHost}" not found in your config`)
             }
 
             const jumpSession = this.ssh.createSession(jumpConnection)
@@ -107,7 +107,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
             })
 
             session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
-                '127.0.0.1', 0, session.connection.host, session.connection.port ?? 22,
+                '127.0.0.1', 0, session.profile.options.host, session.profile.options.port ?? 22,
                 (err, stream) => {
                     if (err) {
                         jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
@@ -124,7 +124,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
             this.sessionStack.push(session)
         }
 
-        this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
+        this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`)
 
         this.startSpinner()
 
@@ -157,7 +157,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
                 this.destroy()
             } else if (this.frontend) {
                 // Session was closed abruptly
-                this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
+                this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.profile.options.host}: session closed\r\n`)
                 if (!this.reconnectOffered) {
                     this.reconnectOffered = true
                     this.write('Press any key to reconnect\r\n')
@@ -174,12 +174,12 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
 
     async initializeSession (): Promise<void> {
         this.reconnectOffered = false
-        if (!this.connection) {
+        if (!this.profile) {
             this.logger.error('No SSH connection info supplied')
             return
         }
 
-        const session = this.ssh.createSession(this.connection)
+        const session = this.ssh.createSession(this.profile)
         this.setSession(session)
 
         try {
@@ -195,7 +195,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
     async getRecoveryToken (): Promise<RecoveryToken> {
         return {
             type: 'app:ssh-tab',
-            connection: this.connection,
+            profile: this.profile,
             savedState: this.frontend?.saveState(),
         }
     }
@@ -215,13 +215,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
         if (!this.session?.open) {
             return true
         }
-        if (!(this.connection?.warnOnClose ?? this.config.store.ssh.warnOnClose)) {
+        if (!(this.profile?.options.warnOnClose ?? this.config.store.ssh.warnOnClose)) {
             return true
         }
         return (await this.platform.showMessageBox(
             {
                 type: 'warning',
-                message: `Disconnect from ${this.connection?.host}?`,
+                message: `Disconnect from ${this.profile?.options.host}?`,
                 buttons: ['Cancel', 'Disconnect'],
                 defaultId: 1,
             }

+ 0 - 2
tabby-ssh/src/config.ts

@@ -4,8 +4,6 @@ import { ConfigProvider } from 'tabby-core'
 export class SSHConfigProvider extends ConfigProvider {
     defaults = {
         ssh: {
-            connections: [],
-            recentConnections: [],
             warnOnClose: false,
             winSCPPath: null,
             agentType: 'auto',

+ 0 - 4
tabby-ssh/src/hotkeys.ts

@@ -5,10 +5,6 @@ import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
 @Injectable()
 export class SSHHotkeyProvider extends HotkeyProvider {
     hotkeys: HotkeyDescription[] = [
-        {
-            id: 'ssh',
-            name: 'Show SSH connections',
-        },
         {
             id: 'restart-ssh-session',
             name: 'Restart current SSH session',

+ 0 - 1
tabby-ssh/src/icons/globe.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="#fff" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm193.2 152h-82.5c-9-44.4-24.1-82.2-43.2-109.1 55 18.2 100.2 57.9 125.7 109.1zM336 256c0 22.9-1.6 44.2-4.3 64H164.3c-2.7-19.8-4.3-41.1-4.3-64s1.6-44.2 4.3-64h167.4c2.7 19.8 4.3 41.1 4.3 64zM248 40c26.9 0 61.4 44.1 78.1 120H169.9C186.6 84.1 221.1 40 248 40zm-67.5 10.9c-19 26.8-34.2 64.6-43.2 109.1H54.8c25.5-51.2 70.7-90.9 125.7-109.1zM32 256c0-22.3 3.4-43.8 9.7-64h90.5c-2.6 20.5-4.2 41.8-4.2 64s1.5 43.5 4.2 64H41.7c-6.3-20.2-9.7-41.7-9.7-64zm22.8 96h82.5c9 44.4 24.1 82.2 43.2 109.1-55-18.2-100.2-57.9-125.7-109.1zM248 472c-26.9 0-61.4-44.1-78.1-120h156.2c-16.7 75.9-51.2 120-78.1 120zm67.5-10.9c19-26.8 34.2-64.6 43.2-109.1h82.5c-25.5 51.2-70.7 90.9-125.7 109.1zM363.8 320c2.6-20.5 4.2-41.8 4.2-64s-1.5-43.5-4.2-64h90.5c6.3 20.2 9.7 41.7 9.7 64s-3.4 43.8-9.7 64h-90.5z"></path></svg>

+ 6 - 11
tabby-ssh/src/index.ts

@@ -4,26 +4,24 @@ import { FormsModule } from '@angular/forms'
 import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
 import { NgxFilesizeModule } from 'ngx-filesize'
-import TabbyCoreModule, { ToolbarButtonProvider, ConfigProvider, TabRecoveryProvider, HotkeyProvider, TabContextMenuItemProvider, CLIHandler } from 'tabby-core'
+import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, TabContextMenuItemProvider, ProfileProvider } from 'tabby-core'
 import { SettingsTabProvider } from 'tabby-settings'
 import TabbyTerminalModule from 'tabby-terminal'
 
-import { EditConnectionModalComponent } from './components/editConnectionModal.component'
+import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
 import { SSHPortForwardingModalComponent } from './components/sshPortForwardingModal.component'
 import { SSHPortForwardingConfigComponent } from './components/sshPortForwardingConfig.component'
-import { PromptModalComponent } from './components/promptModal.component'
 import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
 import { SSHTabComponent } from './components/sshTab.component'
 import { SFTPPanelComponent } from './components/sftpPanel.component'
 import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
 
-import { ButtonProvider } from './buttonProvider'
 import { SSHConfigProvider } from './config'
 import { SSHSettingsTabProvider } from './settings'
 import { RecoveryProvider } from './recoveryProvider'
 import { SSHHotkeyProvider } from './hotkeys'
 import { SFTPContextMenu } from './tabContextMenu'
-import { SSHCLIHandler } from './cli'
+import { SSHProfilesService } from './profiles'
 
 /** @hidden */
 @NgModule({
@@ -37,25 +35,22 @@ import { SSHCLIHandler } from './cli'
         TabbyTerminalModule,
     ],
     providers: [
-        { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
         { provide: ConfigProvider, useClass: SSHConfigProvider, multi: true },
         { provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true },
         { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
         { provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true },
         { provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true },
-        { provide: CLIHandler, useClass: SSHCLIHandler, multi: true },
+        { provide: ProfileProvider, useClass: SSHProfilesService, multi: true },
     ],
     entryComponents: [
-        EditConnectionModalComponent,
-        PromptModalComponent,
+        SSHProfileSettingsComponent,
         SFTPDeleteModalComponent,
         SSHPortForwardingModalComponent,
         SSHSettingsTabComponent,
         SSHTabComponent,
     ],
     declarations: [
-        EditConnectionModalComponent,
-        PromptModalComponent,
+        SSHProfileSettingsComponent,
         SFTPDeleteModalComponent,
         SSHPortForwardingModalComponent,
         SSHPortForwardingConfigComponent,

+ 79 - 0
tabby-ssh/src/profiles.ts

@@ -0,0 +1,79 @@
+import { Injectable } from '@angular/core'
+import { ProfileProvider, Profile, NewTabParameters } from 'tabby-core'
+import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
+import { SSHTabComponent } from './components/sshTab.component'
+import { PasswordStorageService } from './services/passwordStorage.service'
+import { SSHProfile } from './api'
+
+@Injectable({ providedIn: 'root' })
+export class SSHProfilesService extends ProfileProvider {
+    id = 'ssh'
+    name = 'SSH'
+    supportsQuickConnect = true
+    settingsComponent = SSHProfileSettingsComponent
+
+    constructor (
+        private passwordStorage: PasswordStorageService
+    ) {
+        super()
+    }
+
+    async getBuiltinProfiles (): Promise<Profile[]> {
+        return [{
+            id: `ssh:template`,
+            type: 'ssh',
+            name: 'SSH connection',
+            icon: 'fas fa-desktop',
+            options: {
+                host: '',
+                port: 22,
+                user: 'root',
+            },
+            isBuiltin: true,
+            isTemplate: true,
+        }]
+    }
+
+    async getNewTabParameters (profile: Profile): Promise<NewTabParameters<SSHTabComponent>> {
+        return {
+            type: SSHTabComponent,
+            inputs: { profile },
+        }
+    }
+
+    getDescription (profile: SSHProfile): string {
+        return profile.options.host
+    }
+
+    deleteProfile (profile: SSHProfile): void {
+        this.passwordStorage.deletePassword(profile)
+    }
+
+    quickConnect (query: string): SSHProfile {
+        let user = 'root'
+        let host = query
+        let port = 22
+        if (host.includes('@')) {
+            const parts = host.split(/@/g)
+            host = parts[parts.length - 1]
+            user = parts.slice(0, parts.length - 1).join('@')
+        }
+        if (host.includes('[')) {
+            port = parseInt(host.split(']')[1].substring(1))
+            host = host.split(']')[0].substring(1)
+        } else if (host.includes(':')) {
+            port = parseInt(host.split(/:/g)[1])
+            host = host.split(':')[0]
+        }
+
+        return {
+            name: query,
+            type: 'ssh',
+            options: {
+                host,
+                user,
+                port,
+            },
+        }
+    }
+}

+ 5 - 5
tabby-ssh/src/recoveryProvider.ts

@@ -1,20 +1,20 @@
 import { Injectable } from '@angular/core'
-import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
+import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
 
 import { SSHTabComponent } from './components/sshTab.component'
 
 /** @hidden */
 @Injectable()
-export class RecoveryProvider extends TabRecoveryProvider {
+export class RecoveryProvider extends TabRecoveryProvider<SSHTabComponent> {
     async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
         return recoveryToken.type === 'app:ssh-tab'
     }
 
-    async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
+    async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<SSHTabComponent>> {
         return {
             type: SSHTabComponent,
-            options: {
-                connection: recoveryToken['connection'],
+            inputs: {
+                profile: recoveryToken['profile'],
                 savedState: recoveryToken['savedState'],
             },
         }

+ 21 - 21
tabby-ssh/src/services/passwordStorage.service.ts

@@ -1,6 +1,6 @@
 import * as keytar from 'keytar'
 import { Injectable } from '@angular/core'
-import { SSHConnection } from '../api'
+import { SSHProfile } from '../api'
 import { VaultService } from 'tabby-core'
 
 export const VAULT_SECRET_TYPE_PASSWORD = 'ssh:password'
@@ -10,33 +10,33 @@ export const VAULT_SECRET_TYPE_PASSPHRASE = 'ssh:key-passphrase'
 export class PasswordStorageService {
     constructor (private vault: VaultService) { }
 
-    async savePassword (connection: SSHConnection, password: string): Promise<void> {
+    async savePassword (profile: SSHProfile, password: string): Promise<void> {
         if (this.vault.isEnabled()) {
-            const key = this.getVaultKeyForConnection(connection)
+            const key = this.getVaultKeyForConnection(profile)
             this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSWORD, key, value: password })
         } else {
-            const key = this.getKeytarKeyForConnection(connection)
-            return keytar.setPassword(key, connection.user, password)
+            const key = this.getKeytarKeyForConnection(profile)
+            return keytar.setPassword(key, profile.options.user, password)
         }
     }
 
-    async deletePassword (connection: SSHConnection): Promise<void> {
+    async deletePassword (profile: SSHProfile): Promise<void> {
         if (this.vault.isEnabled()) {
-            const key = this.getVaultKeyForConnection(connection)
+            const key = this.getVaultKeyForConnection(profile)
             this.vault.removeSecret(VAULT_SECRET_TYPE_PASSWORD, key)
         } else {
-            const key = this.getKeytarKeyForConnection(connection)
-            await keytar.deletePassword(key, connection.user)
+            const key = this.getKeytarKeyForConnection(profile)
+            await keytar.deletePassword(key, profile.options.user)
         }
     }
 
-    async loadPassword (connection: SSHConnection): Promise<string|null> {
+    async loadPassword (profile: SSHProfile): Promise<string|null> {
         if (this.vault.isEnabled()) {
-            const key = this.getVaultKeyForConnection(connection)
+            const key = this.getVaultKeyForConnection(profile)
             return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSWORD, key))?.value ?? null
         } else {
-            const key = this.getKeytarKeyForConnection(connection)
-            return keytar.getPassword(key, connection.user)
+            const key = this.getKeytarKeyForConnection(profile)
+            return keytar.getPassword(key, profile.options.user)
         }
     }
 
@@ -70,10 +70,10 @@ export class PasswordStorageService {
         }
     }
 
-    private getKeytarKeyForConnection (connection: SSHConnection): string {
-        let key = `ssh@${connection.host}`
-        if (connection.port) {
-            key = `ssh@${connection.host}:${connection.port}`
+    private getKeytarKeyForConnection (profile: SSHProfile): string {
+        let key = `ssh@${profile.options.host}`
+        if (profile.options.port) {
+            key = `ssh@${profile.options.host}:${profile.options.port}`
         }
         return key
     }
@@ -82,11 +82,11 @@ export class PasswordStorageService {
         return `ssh-private-key:${id}`
     }
 
-    private getVaultKeyForConnection (connection: SSHConnection) {
+    private getVaultKeyForConnection (profile: SSHProfile) {
         return {
-            user: connection.user,
-            host: connection.host,
-            port: connection.port,
+            user: profile.options.user,
+            host: profile.options.host,
+            port: profile.options.port,
         }
     }
 

+ 27 - 153
tabby-ssh/src/services/ssh.service.ts

@@ -5,12 +5,9 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { Client } from 'ssh2'
 import { exec } from 'child_process'
 import { Subject, Observable } from 'rxjs'
-import { Logger, LogService, AppService, SelectorOption, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, SelectorService } from 'tabby-core'
-import { SettingsTabComponent } from 'tabby-settings'
-import { ALGORITHM_BLACKLIST, ForwardedPort, SSHConnection, SSHSession } from '../api'
-import { PromptModalComponent } from '../components/promptModal.component'
+import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core'
+import { ALGORITHM_BLACKLIST, ForwardedPort, SSHProfile, SSHSession } from '../api'
 import { PasswordStorageService } from './passwordStorage.service'
-import { SSHTabComponent } from '../components/sshTab.component'
 import { ChildProcess } from 'node:child_process'
 
 @Injectable({ providedIn: 'root' })
@@ -25,8 +22,6 @@ export class SSHService {
         private ngbModal: NgbModal,
         private passwordStorage: PasswordStorageService,
         private notifications: NotificationsService,
-        private app: AppService,
-        private selector: SelectorService,
         private config: ConfigService,
         hostApp: HostAppService,
         private platform: PlatformService,
@@ -37,9 +32,9 @@ export class SSHService {
         }
     }
 
-    createSession (connection: SSHConnection): SSHSession {
-        const session = new SSHSession(this.injector, connection)
-        session.logger = this.log.create(`ssh-${connection.host}-${connection.port}`)
+    createSession (profile: SSHProfile): SSHSession {
+        const session = new SSHSession(this.injector, profile)
+        session.logger = this.log.create(`ssh-${profile.options.host}-${profile.options.port}`)
         return session
     }
 
@@ -52,18 +47,18 @@ export class SSHService {
 
         let connected = false
         const algorithms = {}
-        for (const key of Object.keys(session.connection.algorithms ?? {})) {
-            algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
+        for (const key of Object.keys(session.profile.options.algorithms ?? {})) {
+            algorithms[key] = session.profile.options.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
         }
 
         const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
             ssh.on('ready', () => {
                 connected = true
                 if (session.savedPassword) {
-                    this.passwordStorage.savePassword(session.connection, session.savedPassword)
+                    this.passwordStorage.savePassword(session.profile, session.savedPassword)
                 }
 
-                for (const fw of session.connection.forwardedPorts ?? []) {
+                for (const fw of session.profile.options.forwardedPorts ?? []) {
                     session.addPortForward(Object.assign(new ForwardedPort(), fw))
                 }
 
@@ -74,7 +69,7 @@ export class SSHService {
             })
             ssh.on('error', error => {
                 if (error.message === 'All configured authentication methods failed') {
-                    this.passwordStorage.deletePassword(session.connection)
+                    this.passwordStorage.deletePassword(session.profile)
                 }
                 this.zone.run(() => {
                     if (connected) {
@@ -111,22 +106,22 @@ export class SSHService {
             }))
 
             ssh.on('greeting', greeting => {
-                if (!session.connection.skipBanner) {
+                if (!session.profile.options.skipBanner) {
                     log('Greeting: ' + greeting)
                 }
             })
 
             ssh.on('banner', banner => {
-                if (!session.connection.skipBanner) {
+                if (!session.profile.options.skipBanner) {
                     log(banner)
                 }
             })
         })
 
         try {
-            if (session.connection.proxyCommand) {
-                session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.connection.proxyCommand}`)
-                session.proxyCommandStream = new ProxyCommandStream(session.connection.proxyCommand)
+            if (session.profile.options.proxyCommand) {
+                session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.profile.options.proxyCommand}`)
+                session.proxyCommandStream = new ProxyCommandStream(session.profile.options.proxyCommand)
 
                 session.proxyCommandStream.output$.subscribe((message: string) => {
                     session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim())
@@ -136,16 +131,16 @@ export class SSHService {
             }
 
             ssh.connect({
-                host: session.connection.host.trim(),
-                port: session.connection.port ?? 22,
+                host: session.profile.options.host.trim(),
+                port: session.profile.options.port ?? 22,
                 sock: session.proxyCommandStream ?? session.jumpStream,
-                username: session.connection.user,
+                username: session.profile.options.user,
                 tryKeyboard: true,
                 agent: session.agentPath,
-                agentForward: session.connection.agentForward && !!session.agentPath,
-                keepaliveInterval: session.connection.keepaliveInterval ?? 15000,
-                keepaliveCountMax: session.connection.keepaliveCountMax,
-                readyTimeout: session.connection.readyTimeout,
+                agentForward: session.profile.options.agentForward && !!session.agentPath,
+                keepaliveInterval: session.profile.options.keepaliveInterval ?? 15000,
+                keepaliveCountMax: session.profile.options.keepaliveCountMax,
+                readyTimeout: session.profile.options.readyTimeout,
                 hostVerifier: (digest: string) => {
                     log('Host key fingerprint:')
                     log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
@@ -167,138 +162,17 @@ export class SSHService {
         return resultPromise
     }
 
-    async showConnectionSelector (): Promise<void> {
-        const options: SelectorOption<void>[] = []
-        const recentConnections = this.config.store.ssh.recentConnections
-
-        for (const connection of recentConnections) {
-            options.push({
-                name: connection.name,
-                description: connection.host,
-                icon: 'history',
-                callback: () => this.connect(connection),
-            })
-        }
-
-        if (recentConnections.length) {
-            options.push({
-                name: 'Clear recent connections',
-                icon: 'eraser',
-                callback: () => {
-                    this.config.store.ssh.recentConnections = []
-                    this.config.save()
-                },
-            })
-        }
-
-        const groups: { name: string, connections: SSHConnection[] }[] = []
-        const connections = this.config.store.ssh.connections
-        for (const connection of connections) {
-            connection.group = connection.group || null
-            let group = groups.find(x => x.name === connection.group)
-            if (!group) {
-                group = {
-                    name: connection.group!,
-                    connections: [],
-                }
-                groups.push(group)
-            }
-            group.connections.push(connection)
-        }
-
-        for (const group of groups) {
-            for (const connection of group.connections) {
-                options.push({
-                    name: (group.name ? `${group.name} / ` : '') + connection.name,
-                    description: connection.host,
-                    icon: 'desktop',
-                    callback: () => this.connect(connection),
-                })
-            }
-        }
-
-        options.push({
-            name: 'Manage connections',
-            icon: 'cog',
-            callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'ssh' }),
-        })
-
-        options.push({
-            name: 'Quick connect',
-            freeInputPattern: 'Connect to "%s"...',
-            icon: 'arrow-right',
-            callback: query => this.quickConnect(query),
-        })
-
-
-        await this.selector.show('Open an SSH connection', options)
-    }
-
-    async connect (connection: SSHConnection): Promise<SSHTabComponent> {
-        try {
-            const tab = this.app.openNewTab(
-                SSHTabComponent,
-                { connection }
-            ) as SSHTabComponent
-            if (connection.color) {
-                (this.app.getParentTab(tab) ?? tab).color = connection.color
-            }
-
-            setTimeout(() => this.app.activeTab?.emitFocused())
-
-            return tab
-        } catch (error) {
-            this.notifications.error(`Could not connect: ${error}`)
-            throw error
-        }
-    }
-
-    quickConnect (query: string): Promise<SSHTabComponent> {
-        let user = 'root'
-        let host = query
-        let port = 22
-        if (host.includes('@')) {
-            const parts = host.split(/@/g)
-            host = parts[parts.length - 1]
-            user = parts.slice(0, parts.length - 1).join('@')
-        }
-        if (host.includes('[')) {
-            port = parseInt(host.split(']')[1].substring(1))
-            host = host.split(']')[0].substring(1)
-        } else if (host.includes(':')) {
-            port = parseInt(host.split(/:/g)[1])
-            host = host.split(':')[0]
-        }
-
-        const connection: SSHConnection = {
-            name: query,
-            group: null,
-            host,
-            user,
-            port,
-        }
-
-        const recentConnections = this.config.store.ssh.recentConnections
-        recentConnections.unshift(connection)
-        if (recentConnections.length > 5) {
-            recentConnections.pop()
-        }
-        this.config.store.ssh.recentConnections = recentConnections
-        this.config.save()
-        return this.connect(connection)
-    }
-
     getWinSCPPath (): string|undefined {
         return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
     }
 
-    async getWinSCPURI (connection: SSHConnection): Promise<string> {
-        let uri = `scp://${connection.user}`
-        const password = await this.passwordStorage.loadPassword(connection)
+    async getWinSCPURI (profile: SSHProfile): Promise<string> {
+        let uri = `scp://${profile.options.user}`
+        const password = await this.passwordStorage.loadPassword(profile)
         if (password) {
             uri += ':' + encodeURIComponent(password)
         }
-        uri += `@${connection.host}:${connection.port}/`
+        uri += `@${profile.options.host}:${profile.options.port}/`
         return uri
     }
 
@@ -307,7 +181,7 @@ export class SSHService {
         if (!path) {
             return
         }
-        const args = [await this.getWinSCPURI(session.connection)]
+        const args = [await this.getWinSCPURI(session.profile)]
         if (session.activePrivateKey) {
             args.push('/privatekey')
             args.push(session.activePrivateKey)

+ 1 - 1
tabby-ssh/src/tabContextMenu.ts

@@ -17,7 +17,7 @@ export class SFTPContextMenu extends TabContextMenuItemProvider {
     }
 
     async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
-        if (!(tab instanceof SSHTabComponent) || !tab.connection) {
+        if (!(tab instanceof SSHTabComponent) || !tab.profile) {
             return []
         }
         const items = [{

+ 0 - 33
tabby-ssh/yarn.lock

@@ -95,15 +95,6 @@ [email protected]:
     eyes "~0.1.8"
     winston "0.8.x"
 
-clone-deep@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
-  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
-  dependencies:
-    is-plain-object "^2.0.4"
-    kind-of "^6.0.2"
-    shallow-clone "^3.0.0"
-
 [email protected]:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc"
@@ -197,18 +188,6 @@ ipv6@*:
     cliff "0.1.x"
     sprintf "0.1.x"
 
-is-plain-object@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
-  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
-  dependencies:
-    isobject "^3.0.1"
-
-isobject@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
-  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
-
 [email protected]:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -219,11 +198,6 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-kind-of@^6.0.2:
-  version "6.0.3"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
-  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
-
 minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
@@ -263,13 +237,6 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-shallow-clone@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
-  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
-  dependencies:
-    kind-of "^6.0.2"
-
 socksv5@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/socksv5/-/socksv5-0.0.6.tgz#1327235ff7e8de21ac434a0a579dc69c3f071061"

+ 0 - 1
tabby-terminal/package.json

@@ -29,7 +29,6 @@
     "ps-node": "^0.1.6",
     "runes": "^0.4.2",
     "shell-escape": "^0.2.0",
-    "slugify": "^1.4.0",
     "utils-decorators": "^1.8.1",
     "xterm": "^4.9.0-beta.7",
     "xterm-addon-fit": "^0.5.0",

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff