From 92b34fbc083b96294476e8b9b97afb385290dc96 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sun, 4 Jul 2021 12:23:27 +0200 Subject: [PATCH] new profile system --- app/lib/cli.ts | 6 - app/src/global.scss | 4 + package.json | 3 + scripts/generate-icon-metadata.js | 12 + tabby-core/package.json | 1 - tabby-core/src/api/index.ts | 7 +- tabby-core/src/api/profileProvider.ts | 43 +++ tabby-core/src/api/tabRecovery.ts | 22 +- tabby-core/src/buttonProvider.ts | 115 ++++++++ tabby-core/src/cli.ts | 35 +++ .../src/components/promptModal.component.pug | 0 .../src/components/promptModal.component.ts | 0 .../components/selectorModal.component.pug | 2 +- .../src/components/splitTab.component.ts | 12 +- tabby-core/src/configDefaults.yaml | 5 +- .../alwaysVisibleTypeahead.directive.ts | 19 ++ tabby-core/src/hotkeys.ts | 14 +- tabby-core/src/icons.json | 1 + tabby-core/src/icons/plus.svg | 1 + .../src/icons/profiles.svg | 0 tabby-core/src/index.ts | 36 ++- tabby-core/src/services/app.service.ts | 19 +- tabby-core/src/services/config.service.ts | 91 +++++- tabby-core/src/services/profiles.service.ts | 54 ++++ .../src/services/tabRecovery.service.ts | 21 +- tabby-core/src/services/tabs.service.ts | 25 +- tabby-core/src/services/vault.service.ts | 4 +- tabby-core/src/theme.scss | 3 +- tabby-core/yarn.lock | 33 --- tabby-local/package.json | 1 - tabby-local/src/api.ts | 14 +- tabby-local/src/buttonProvider.ts | 27 +- tabby-local/src/cli.ts | 16 +- .../components/editProfileModal.component.pug | 114 ++++---- .../components/editProfileModal.component.ts | 60 +++- .../localProfileSettings.component.pug | 51 ++++ .../localProfileSettings.component.ts | 47 +++ .../profilesSettingsTab.component.pug | 94 ++++++ .../profilesSettingsTab.component.ts | 201 +++++++++++++ .../components/shellSettingsTab.component.pug | 87 ------ .../components/shellSettingsTab.component.ts | 77 +---- .../src/components/terminalTab.component.ts | 6 + tabby-local/src/config.ts | 11 +- tabby-local/src/hotkeys.ts | 14 +- tabby-local/src/index.ts | 22 +- tabby-local/src/profiles.ts | 72 +++++ tabby-local/src/recoveryProvider.ts | 8 +- tabby-local/src/services/dockMenu.service.ts | 15 +- tabby-local/src/services/terminal.service.ts | 129 ++------- tabby-local/src/settings.ts | 22 +- tabby-local/src/shells/custom.ts | 25 -- tabby-local/src/shells/macDefault.ts | 2 +- tabby-local/src/shells/posix.ts | 1 + tabby-local/src/shells/winDefault.ts | 2 +- tabby-local/src/tabContextMenu.ts | 21 +- tabby-local/yarn.lock | 5 - tabby-serial/src/api.ts | 50 ++-- tabby-serial/src/buttonProvider.ts | 36 --- tabby-serial/src/cli.ts | 30 -- .../editConnectionModal.component.pug | 200 ------------- .../serialProfileSettings.component.pug | 171 +++++++++++ ....ts => serialProfileSettings.component.ts} | 57 ++-- .../serialSettingsTab.component.pug | 16 -- .../components/serialSettingsTab.component.ts | 82 ------ .../src/components/serialTab.component.pug | 2 +- .../src/components/serialTab.component.ts | 16 +- tabby-serial/src/config.ts | 5 - tabby-serial/src/index.ts | 20 +- tabby-serial/src/profiles.ts | 74 +++++ tabby-serial/src/recoveryProvider.ts | 10 +- tabby-serial/src/services/serial.service.ts | 138 +++------ tabby-serial/src/settings.ts | 16 -- tabby-settings/src/buttonProvider.ts | 2 +- .../hotkeySettingsTab.component.pug | 26 +- .../hotkeySettingsTab.component.scss | 7 - .../components/hotkeySettingsTab.component.ts | 5 +- .../src/components/settingsTab.component.ts | 1 + tabby-ssh/package.json | 1 - tabby-ssh/src/api.ts | 47 ++- tabby-ssh/src/buttonProvider.ts | 43 --- tabby-ssh/src/cli.ts | 30 -- .../editConnectionModal.component.pug | 269 ------------------ .../sshProfileSettings.component.pug | 231 +++++++++++++++ ...ent.ts => sshProfileSettings.component.ts} | 109 +++---- .../components/sshSettingsTab.component.pug | 55 +--- .../components/sshSettingsTab.component.scss | 3 - .../components/sshSettingsTab.component.ts | 147 +--------- tabby-ssh/src/components/sshTab.component.pug | 2 +- tabby-ssh/src/components/sshTab.component.ts | 34 +-- tabby-ssh/src/config.ts | 2 - tabby-ssh/src/hotkeys.ts | 4 - tabby-ssh/src/icons/globe.svg | 1 - tabby-ssh/src/index.ts | 17 +- tabby-ssh/src/profiles.ts | 79 +++++ tabby-ssh/src/recoveryProvider.ts | 10 +- .../src/services/passwordStorage.service.ts | 42 +-- tabby-ssh/src/services/ssh.service.ts | 180 ++---------- tabby-ssh/src/tabContextMenu.ts | 2 +- tabby-ssh/yarn.lock | 33 --- tabby-terminal/package.json | 1 - .../terminalSettingsTab.component.pug | 2 +- tabby-terminal/yarn.lock | 5 - webpack.plugin.config.js | 2 +- yarn.lock | 187 ++++-------- 104 files changed, 2029 insertions(+), 2205 deletions(-) create mode 100755 scripts/generate-icon-metadata.js create mode 100644 tabby-core/src/api/profileProvider.ts create mode 100644 tabby-core/src/buttonProvider.ts rename {tabby-ssh => tabby-core}/src/components/promptModal.component.pug (100%) rename {tabby-ssh => tabby-core}/src/components/promptModal.component.ts (100%) create mode 100644 tabby-core/src/directives/alwaysVisibleTypeahead.directive.ts create mode 100644 tabby-core/src/icons.json create mode 100644 tabby-core/src/icons/plus.svg rename {tabby-local => tabby-core}/src/icons/profiles.svg (100%) create mode 100644 tabby-core/src/services/profiles.service.ts create mode 100644 tabby-local/src/components/localProfileSettings.component.pug create mode 100644 tabby-local/src/components/localProfileSettings.component.ts create mode 100644 tabby-local/src/components/profilesSettingsTab.component.pug create mode 100644 tabby-local/src/components/profilesSettingsTab.component.ts create mode 100644 tabby-local/src/profiles.ts delete mode 100644 tabby-local/src/shells/custom.ts delete mode 100644 tabby-serial/src/buttonProvider.ts delete mode 100644 tabby-serial/src/cli.ts delete mode 100644 tabby-serial/src/components/editConnectionModal.component.pug create mode 100644 tabby-serial/src/components/serialProfileSettings.component.pug rename tabby-serial/src/components/{editConnectionModal.component.ts => serialProfileSettings.component.ts} (62%) delete mode 100644 tabby-serial/src/components/serialSettingsTab.component.pug delete mode 100644 tabby-serial/src/components/serialSettingsTab.component.ts create mode 100644 tabby-serial/src/profiles.ts delete mode 100644 tabby-serial/src/settings.ts delete mode 100644 tabby-settings/src/components/hotkeySettingsTab.component.scss delete mode 100644 tabby-ssh/src/buttonProvider.ts delete mode 100644 tabby-ssh/src/cli.ts delete mode 100644 tabby-ssh/src/components/editConnectionModal.component.pug create mode 100644 tabby-ssh/src/components/sshProfileSettings.component.pug rename tabby-ssh/src/components/{editConnectionModal.component.ts => sshProfileSettings.component.ts} (52%) delete mode 100644 tabby-ssh/src/components/sshSettingsTab.component.scss delete mode 100644 tabby-ssh/src/icons/globe.svg create mode 100644 tabby-ssh/src/profiles.ts diff --git a/app/lib/cli.ts b/app/lib/cli.ts index a5304869..d0729cd7 100644 --- a/app/lib/cli.ts +++ b/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', diff --git a/app/src/global.scss b/app/src/global.scss index 30094c6b..21127521 100644 --- a/app/src/global.scss +++ b/app/src/global.scss @@ -158,3 +158,7 @@ ngb-typeahead-window { text-overflow: ellipsis; overflow: hidden; } + +.list-group-item > button { + margin: -7px 0; +} diff --git a/package.json b/package.json index 83caa6f9..a5dc7c05 100644 --- a/package.json +++ b/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" }, diff --git a/scripts/generate-icon-metadata.js b/scripts/generate-icon-metadata.js new file mode 100755 index 00000000..7bbd5e1c --- /dev/null +++ b/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)) diff --git a/tabby-core/package.json b/tabby-core/package.json index f27509b3..2cc9e1f7 100644 --- a/tabby-core/package.json +++ b/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", diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index bc1e1f3b..aae0d4cb 100644 --- a/tabby-core/src/api/index.ts +++ b/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' diff --git a/tabby-core/src/api/profileProvider.ts b/tabby-core/src/api/profileProvider.ts new file mode 100644 index 00000000..3f0c2665 --- /dev/null +++ b/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 + + 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 + + abstract getNewTabParameters (profile: Profile): Promise> + + abstract getDescription (profile: Profile): string + + quickConnect (query: string): Profile|null { + return null + } + + deleteProfile (profile: Profile): void { } +} diff --git a/tabby-core/src/api/tabRecovery.ts b/tabby-core/src/api/tabRecovery.ts index c1bd050e..c804d261 100644 --- a/tabby-core/src/api/tabRecovery.ts +++ b/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 { /** * @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 + /** * @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 + abstract recover (recoveryToken: RecoveryToken): Promise> /** * @param recoveryToken a recovery token found in the saved tabs list diff --git a/tabby-core/src/buttonProvider.ts b/tabby-core/src/buttonProvider.ts new file mode 100644 index 00000000..afdccf3f --- /dev/null +++ b/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 => ({ + 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(), + }] + } +} diff --git a/tabby-core/src/cli.ts b/tabby-core/src/cli.ts index 395b50e5..d8b49172 100644 --- a/tabby-core/src/cli.ts +++ b/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 { + 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 { diff --git a/tabby-ssh/src/components/promptModal.component.pug b/tabby-core/src/components/promptModal.component.pug similarity index 100% rename from tabby-ssh/src/components/promptModal.component.pug rename to tabby-core/src/components/promptModal.component.pug diff --git a/tabby-ssh/src/components/promptModal.component.ts b/tabby-core/src/components/promptModal.component.ts similarity index 100% rename from tabby-ssh/src/components/promptModal.component.ts rename to tabby-core/src/components/promptModal.component.ts diff --git a/tabby-core/src/components/selectorModal.component.pug b/tabby-core/src/components/selectorModal.component.pug index 79b7c8d3..9ae37154 100644 --- a/tabby-core/src/components/selectorModal.component.pug +++ b/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( diff --git a/tabby-core/src/components/splitTab.component.ts b/tabby-core/src/components/splitTab.component.ts index 10e8239a..5d0370ad 100644 --- a/tabby-core/src/components/splitTab.component.ts +++ b/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 { async applicableTo (recoveryToken: RecoveryToken): Promise { return recoveryToken.type === 'app:split-tab' } - async recover (recoveryToken: RecoveryToken): Promise { + async recover (recoveryToken: RecoveryToken): Promise> { return { type: SplitTabComponent, - options: { _recoveredState: recoveryToken }, + inputs: { _recoveredState: recoveryToken }, } } diff --git a/tabby-core/src/configDefaults.yaml b/tabby-core/src/configDefaults.yaml index cc9f7446..a84f2a03 100644 --- a/tabby-core/src/configDefaults.yaml +++ b/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: diff --git a/tabby-core/src/directives/alwaysVisibleTypeahead.directive.ts b/tabby-core/src/directives/alwaysVisibleTypeahead.directive.ts new file mode 100644 index 00000000..660dc9e3 --- /dev/null +++ b/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) + }) + } +} diff --git a/tabby-core/src/hotkeys.ts b/tabby-core/src/hotkeys.ts index 437f0a62..4c03620a 100644 --- a/tabby-core/src/hotkeys.ts +++ b/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 { - return this.hotkeys + const profiles = await this.profilesService.getProfiles() + return [ + ...this.hotkeys, + ...profiles.map(profile => ({ + id: `profile.${profile.id}`, + name: `New tab: ${profile.name}`, + })), + ] } } diff --git a/tabby-core/src/icons.json b/tabby-core/src/icons.json new file mode 100644 index 00000000..fc13b485 --- /dev/null +++ b/tabby-core/src/icons.json @@ -0,0 +1 @@ +{"500px":["b"],"accessible-icon":["b"],"accusoft":["b"],"acquisitions-incorporated":["b"],"ad":["s"],"address-book":["s","r"],"address-card":["s","r"],"adjust":["s"],"adn":["b"],"adversal":["b"],"affiliatetheme":["b"],"air-freshener":["s"],"airbnb":["b"],"algolia":["b"],"align-center":["s"],"align-justify":["s"],"align-left":["s"],"align-right":["s"],"alipay":["b"],"allergies":["s"],"amazon":["b"],"amazon-pay":["b"],"ambulance":["s"],"american-sign-language-interpreting":["s"],"amilia":["b"],"anchor":["s"],"android":["b"],"angellist":["b"],"angle-double-down":["s"],"angle-double-left":["s"],"angle-double-right":["s"],"angle-double-up":["s"],"angle-down":["s"],"angle-left":["s"],"angle-right":["s"],"angle-up":["s"],"angry":["s","r"],"angrycreative":["b"],"angular":["b"],"ankh":["s"],"app-store":["b"],"app-store-ios":["b"],"apper":["b"],"apple":["b"],"apple-alt":["s"],"apple-pay":["b"],"archive":["s"],"archway":["s"],"arrow-alt-circle-down":["s","r"],"arrow-alt-circle-left":["s","r"],"arrow-alt-circle-right":["s","r"],"arrow-alt-circle-up":["s","r"],"arrow-circle-down":["s"],"arrow-circle-left":["s"],"arrow-circle-right":["s"],"arrow-circle-up":["s"],"arrow-down":["s"],"arrow-left":["s"],"arrow-right":["s"],"arrow-up":["s"],"arrows-alt":["s"],"arrows-alt-h":["s"],"arrows-alt-v":["s"],"artstation":["b"],"assistive-listening-systems":["s"],"asterisk":["s"],"asymmetrik":["b"],"at":["s"],"atlas":["s"],"atlassian":["b"],"atom":["s"],"audible":["b"],"audio-description":["s"],"autoprefixer":["b"],"avianex":["b"],"aviato":["b"],"award":["s"],"aws":["b"],"baby":["s"],"baby-carriage":["s"],"backspace":["s"],"backward":["s"],"bacon":["s"],"bacteria":["s"],"bacterium":["s"],"bahai":["s"],"balance-scale":["s"],"balance-scale-left":["s"],"balance-scale-right":["s"],"ban":["s"],"band-aid":["s"],"bandcamp":["b"],"barcode":["s"],"bars":["s"],"baseball-ball":["s"],"basketball-ball":["s"],"bath":["s"],"battery-empty":["s"],"battery-full":["s"],"battery-half":["s"],"battery-quarter":["s"],"battery-three-quarters":["s"],"battle-net":["b"],"bed":["s"],"beer":["s"],"behance":["b"],"behance-square":["b"],"bell":["s","r"],"bell-slash":["s","r"],"bezier-curve":["s"],"bible":["s"],"bicycle":["s"],"biking":["s"],"bimobject":["b"],"binoculars":["s"],"biohazard":["s"],"birthday-cake":["s"],"bitbucket":["b"],"bitcoin":["b"],"bity":["b"],"black-tie":["b"],"blackberry":["b"],"blender":["s"],"blender-phone":["s"],"blind":["s"],"blog":["s"],"blogger":["b"],"blogger-b":["b"],"bluetooth":["b"],"bluetooth-b":["b"],"bold":["s"],"bolt":["s"],"bomb":["s"],"bone":["s"],"bong":["s"],"book":["s"],"book-dead":["s"],"book-medical":["s"],"book-open":["s"],"book-reader":["s"],"bookmark":["s","r"],"bootstrap":["b"],"border-all":["s"],"border-none":["s"],"border-style":["s"],"bowling-ball":["s"],"box":["s"],"box-open":["s"],"box-tissue":["s"],"boxes":["s"],"braille":["s"],"brain":["s"],"bread-slice":["s"],"briefcase":["s"],"briefcase-medical":["s"],"broadcast-tower":["s"],"broom":["s"],"brush":["s"],"btc":["b"],"buffer":["b"],"bug":["s"],"building":["s","r"],"bullhorn":["s"],"bullseye":["s"],"burn":["s"],"buromobelexperte":["b"],"bus":["s"],"bus-alt":["s"],"business-time":["s"],"buy-n-large":["b"],"buysellads":["b"],"calculator":["s"],"calendar":["s","r"],"calendar-alt":["s","r"],"calendar-check":["s","r"],"calendar-day":["s"],"calendar-minus":["s","r"],"calendar-plus":["s","r"],"calendar-times":["s","r"],"calendar-week":["s"],"camera":["s"],"camera-retro":["s"],"campground":["s"],"canadian-maple-leaf":["b"],"candy-cane":["s"],"cannabis":["s"],"capsules":["s"],"car":["s"],"car-alt":["s"],"car-battery":["s"],"car-crash":["s"],"car-side":["s"],"caravan":["s"],"caret-down":["s"],"caret-left":["s"],"caret-right":["s"],"caret-square-down":["s","r"],"caret-square-left":["s","r"],"caret-square-right":["s","r"],"caret-square-up":["s","r"],"caret-up":["s"],"carrot":["s"],"cart-arrow-down":["s"],"cart-plus":["s"],"cash-register":["s"],"cat":["s"],"cc-amazon-pay":["b"],"cc-amex":["b"],"cc-apple-pay":["b"],"cc-diners-club":["b"],"cc-discover":["b"],"cc-jcb":["b"],"cc-mastercard":["b"],"cc-paypal":["b"],"cc-stripe":["b"],"cc-visa":["b"],"centercode":["b"],"centos":["b"],"certificate":["s"],"chair":["s"],"chalkboard":["s"],"chalkboard-teacher":["s"],"charging-station":["s"],"chart-area":["s"],"chart-bar":["s","r"],"chart-line":["s"],"chart-pie":["s"],"check":["s"],"check-circle":["s","r"],"check-double":["s"],"check-square":["s","r"],"cheese":["s"],"chess":["s"],"chess-bishop":["s"],"chess-board":["s"],"chess-king":["s"],"chess-knight":["s"],"chess-pawn":["s"],"chess-queen":["s"],"chess-rook":["s"],"chevron-circle-down":["s"],"chevron-circle-left":["s"],"chevron-circle-right":["s"],"chevron-circle-up":["s"],"chevron-down":["s"],"chevron-left":["s"],"chevron-right":["s"],"chevron-up":["s"],"child":["s"],"chrome":["b"],"chromecast":["b"],"church":["s"],"circle":["s","r"],"circle-notch":["s"],"city":["s"],"clinic-medical":["s"],"clipboard":["s","r"],"clipboard-check":["s"],"clipboard-list":["s"],"clock":["s","r"],"clone":["s","r"],"closed-captioning":["s","r"],"cloud":["s"],"cloud-download-alt":["s"],"cloud-meatball":["s"],"cloud-moon":["s"],"cloud-moon-rain":["s"],"cloud-rain":["s"],"cloud-showers-heavy":["s"],"cloud-sun":["s"],"cloud-sun-rain":["s"],"cloud-upload-alt":["s"],"cloudflare":["b"],"cloudscale":["b"],"cloudsmith":["b"],"cloudversify":["b"],"cocktail":["s"],"code":["s"],"code-branch":["s"],"codepen":["b"],"codiepie":["b"],"coffee":["s"],"cog":["s"],"cogs":["s"],"coins":["s"],"columns":["s"],"comment":["s","r"],"comment-alt":["s","r"],"comment-dollar":["s"],"comment-dots":["s","r"],"comment-medical":["s"],"comment-slash":["s"],"comments":["s","r"],"comments-dollar":["s"],"compact-disc":["s"],"compass":["s","r"],"compress":["s"],"compress-alt":["s"],"compress-arrows-alt":["s"],"concierge-bell":["s"],"confluence":["b"],"connectdevelop":["b"],"contao":["b"],"cookie":["s"],"cookie-bite":["s"],"copy":["s","r"],"copyright":["s","r"],"cotton-bureau":["b"],"couch":["s"],"cpanel":["b"],"creative-commons":["b"],"creative-commons-by":["b"],"creative-commons-nc":["b"],"creative-commons-nc-eu":["b"],"creative-commons-nc-jp":["b"],"creative-commons-nd":["b"],"creative-commons-pd":["b"],"creative-commons-pd-alt":["b"],"creative-commons-remix":["b"],"creative-commons-sa":["b"],"creative-commons-sampling":["b"],"creative-commons-sampling-plus":["b"],"creative-commons-share":["b"],"creative-commons-zero":["b"],"credit-card":["s","r"],"critical-role":["b"],"crop":["s"],"crop-alt":["s"],"cross":["s"],"crosshairs":["s"],"crow":["s"],"crown":["s"],"crutch":["s"],"css3":["b"],"css3-alt":["b"],"cube":["s"],"cubes":["s"],"cut":["s"],"cuttlefish":["b"],"d-and-d":["b"],"d-and-d-beyond":["b"],"dailymotion":["b"],"dashcube":["b"],"database":["s"],"deaf":["s"],"deezer":["b"],"delicious":["b"],"democrat":["s"],"deploydog":["b"],"deskpro":["b"],"desktop":["s"],"dev":["b"],"deviantart":["b"],"dharmachakra":["s"],"dhl":["b"],"diagnoses":["s"],"diaspora":["b"],"dice":["s"],"dice-d20":["s"],"dice-d6":["s"],"dice-five":["s"],"dice-four":["s"],"dice-one":["s"],"dice-six":["s"],"dice-three":["s"],"dice-two":["s"],"digg":["b"],"digital-ocean":["b"],"digital-tachograph":["s"],"directions":["s"],"discord":["b"],"discourse":["b"],"disease":["s"],"divide":["s"],"dizzy":["s","r"],"dna":["s"],"dochub":["b"],"docker":["b"],"dog":["s"],"dollar-sign":["s"],"dolly":["s"],"dolly-flatbed":["s"],"donate":["s"],"door-closed":["s"],"door-open":["s"],"dot-circle":["s","r"],"dove":["s"],"download":["s"],"draft2digital":["b"],"drafting-compass":["s"],"dragon":["s"],"draw-polygon":["s"],"dribbble":["b"],"dribbble-square":["b"],"dropbox":["b"],"drum":["s"],"drum-steelpan":["s"],"drumstick-bite":["s"],"drupal":["b"],"dumbbell":["s"],"dumpster":["s"],"dumpster-fire":["s"],"dungeon":["s"],"dyalog":["b"],"earlybirds":["b"],"ebay":["b"],"edge":["b"],"edge-legacy":["b"],"edit":["s","r"],"egg":["s"],"eject":["s"],"elementor":["b"],"ellipsis-h":["s"],"ellipsis-v":["s"],"ello":["b"],"ember":["b"],"empire":["b"],"envelope":["s","r"],"envelope-open":["s","r"],"envelope-open-text":["s"],"envelope-square":["s"],"envira":["b"],"equals":["s"],"eraser":["s"],"erlang":["b"],"ethereum":["b"],"ethernet":["s"],"etsy":["b"],"euro-sign":["s"],"evernote":["b"],"exchange-alt":["s"],"exclamation":["s"],"exclamation-circle":["s"],"exclamation-triangle":["s"],"expand":["s"],"expand-alt":["s"],"expand-arrows-alt":["s"],"expeditedssl":["b"],"external-link-alt":["s"],"external-link-square-alt":["s"],"eye":["s","r"],"eye-dropper":["s"],"eye-slash":["s","r"],"facebook":["b"],"facebook-f":["b"],"facebook-messenger":["b"],"facebook-square":["b"],"fan":["s"],"fantasy-flight-games":["b"],"fast-backward":["s"],"fast-forward":["s"],"faucet":["s"],"fax":["s"],"feather":["s"],"feather-alt":["s"],"fedex":["b"],"fedora":["b"],"female":["s"],"fighter-jet":["s"],"figma":["b"],"file":["s","r"],"file-alt":["s","r"],"file-archive":["s","r"],"file-audio":["s","r"],"file-code":["s","r"],"file-contract":["s"],"file-csv":["s"],"file-download":["s"],"file-excel":["s","r"],"file-export":["s"],"file-image":["s","r"],"file-import":["s"],"file-invoice":["s"],"file-invoice-dollar":["s"],"file-medical":["s"],"file-medical-alt":["s"],"file-pdf":["s","r"],"file-powerpoint":["s","r"],"file-prescription":["s"],"file-signature":["s"],"file-upload":["s"],"file-video":["s","r"],"file-word":["s","r"],"fill":["s"],"fill-drip":["s"],"film":["s"],"filter":["s"],"fingerprint":["s"],"fire":["s"],"fire-alt":["s"],"fire-extinguisher":["s"],"firefox":["b"],"firefox-browser":["b"],"first-aid":["s"],"first-order":["b"],"first-order-alt":["b"],"firstdraft":["b"],"fish":["s"],"fist-raised":["s"],"flag":["s","r"],"flag-checkered":["s"],"flag-usa":["s"],"flask":["s"],"flickr":["b"],"flipboard":["b"],"flushed":["s","r"],"fly":["b"],"folder":["s","r"],"folder-minus":["s"],"folder-open":["s","r"],"folder-plus":["s"],"font":["s"],"font-awesome":["b"],"font-awesome-alt":["b"],"font-awesome-flag":["b"],"font-awesome-logo-full":["r","s","b"],"fonticons":["b"],"fonticons-fi":["b"],"football-ball":["s"],"fort-awesome":["b"],"fort-awesome-alt":["b"],"forumbee":["b"],"forward":["s"],"foursquare":["b"],"free-code-camp":["b"],"freebsd":["b"],"frog":["s"],"frown":["s","r"],"frown-open":["s","r"],"fulcrum":["b"],"funnel-dollar":["s"],"futbol":["s","r"],"galactic-republic":["b"],"galactic-senate":["b"],"gamepad":["s"],"gas-pump":["s"],"gavel":["s"],"gem":["s","r"],"genderless":["s"],"get-pocket":["b"],"gg":["b"],"gg-circle":["b"],"ghost":["s"],"gift":["s"],"gifts":["s"],"git":["b"],"git-alt":["b"],"git-square":["b"],"github":["b"],"github-alt":["b"],"github-square":["b"],"gitkraken":["b"],"gitlab":["b"],"gitter":["b"],"glass-cheers":["s"],"glass-martini":["s"],"glass-martini-alt":["s"],"glass-whiskey":["s"],"glasses":["s"],"glide":["b"],"glide-g":["b"],"globe":["s"],"globe-africa":["s"],"globe-americas":["s"],"globe-asia":["s"],"globe-europe":["s"],"gofore":["b"],"golf-ball":["s"],"goodreads":["b"],"goodreads-g":["b"],"google":["b"],"google-drive":["b"],"google-pay":["b"],"google-play":["b"],"google-plus":["b"],"google-plus-g":["b"],"google-plus-square":["b"],"google-wallet":["b"],"gopuram":["s"],"graduation-cap":["s"],"gratipay":["b"],"grav":["b"],"greater-than":["s"],"greater-than-equal":["s"],"grimace":["s","r"],"grin":["s","r"],"grin-alt":["s","r"],"grin-beam":["s","r"],"grin-beam-sweat":["s","r"],"grin-hearts":["s","r"],"grin-squint":["s","r"],"grin-squint-tears":["s","r"],"grin-stars":["s","r"],"grin-tears":["s","r"],"grin-tongue":["s","r"],"grin-tongue-squint":["s","r"],"grin-tongue-wink":["s","r"],"grin-wink":["s","r"],"grip-horizontal":["s"],"grip-lines":["s"],"grip-lines-vertical":["s"],"grip-vertical":["s"],"gripfire":["b"],"grunt":["b"],"guilded":["b"],"guitar":["s"],"gulp":["b"],"h-square":["s"],"hacker-news":["b"],"hacker-news-square":["b"],"hackerrank":["b"],"hamburger":["s"],"hammer":["s"],"hamsa":["s"],"hand-holding":["s"],"hand-holding-heart":["s"],"hand-holding-medical":["s"],"hand-holding-usd":["s"],"hand-holding-water":["s"],"hand-lizard":["s","r"],"hand-middle-finger":["s"],"hand-paper":["s","r"],"hand-peace":["s","r"],"hand-point-down":["s","r"],"hand-point-left":["s","r"],"hand-point-right":["s","r"],"hand-point-up":["s","r"],"hand-pointer":["s","r"],"hand-rock":["s","r"],"hand-scissors":["s","r"],"hand-sparkles":["s"],"hand-spock":["s","r"],"hands":["s"],"hands-helping":["s"],"hands-wash":["s"],"handshake":["s","r"],"handshake-alt-slash":["s"],"handshake-slash":["s"],"hanukiah":["s"],"hard-hat":["s"],"hashtag":["s"],"hat-cowboy":["s"],"hat-cowboy-side":["s"],"hat-wizard":["s"],"hdd":["s","r"],"head-side-cough":["s"],"head-side-cough-slash":["s"],"head-side-mask":["s"],"head-side-virus":["s"],"heading":["s"],"headphones":["s"],"headphones-alt":["s"],"headset":["s"],"heart":["s","r"],"heart-broken":["s"],"heartbeat":["s"],"helicopter":["s"],"highlighter":["s"],"hiking":["s"],"hippo":["s"],"hips":["b"],"hire-a-helper":["b"],"history":["s"],"hive":["b"],"hockey-puck":["s"],"holly-berry":["s"],"home":["s"],"hooli":["b"],"hornbill":["b"],"horse":["s"],"horse-head":["s"],"hospital":["s","r"],"hospital-alt":["s"],"hospital-symbol":["s"],"hospital-user":["s"],"hot-tub":["s"],"hotdog":["s"],"hotel":["s"],"hotjar":["b"],"hourglass":["s","r"],"hourglass-end":["s"],"hourglass-half":["s"],"hourglass-start":["s"],"house-damage":["s"],"house-user":["s"],"houzz":["b"],"hryvnia":["s"],"html5":["b"],"hubspot":["b"],"i-cursor":["s"],"ice-cream":["s"],"icicles":["s"],"icons":["s"],"id-badge":["s","r"],"id-card":["s","r"],"id-card-alt":["s"],"ideal":["b"],"igloo":["s"],"image":["s","r"],"images":["s","r"],"imdb":["b"],"inbox":["s"],"indent":["s"],"industry":["s"],"infinity":["s"],"info":["s"],"info-circle":["s"],"innosoft":["b"],"instagram":["b"],"instagram-square":["b"],"instalod":["b"],"intercom":["b"],"internet-explorer":["b"],"invision":["b"],"ioxhost":["b"],"italic":["s"],"itch-io":["b"],"itunes":["b"],"itunes-note":["b"],"java":["b"],"jedi":["s"],"jedi-order":["b"],"jenkins":["b"],"jira":["b"],"joget":["b"],"joint":["s"],"joomla":["b"],"journal-whills":["s"],"js":["b"],"js-square":["b"],"jsfiddle":["b"],"kaaba":["s"],"kaggle":["b"],"key":["s"],"keybase":["b"],"keyboard":["s","r"],"keycdn":["b"],"khanda":["s"],"kickstarter":["b"],"kickstarter-k":["b"],"kiss":["s","r"],"kiss-beam":["s","r"],"kiss-wink-heart":["s","r"],"kiwi-bird":["s"],"korvue":["b"],"landmark":["s"],"language":["s"],"laptop":["s"],"laptop-code":["s"],"laptop-house":["s"],"laptop-medical":["s"],"laravel":["b"],"lastfm":["b"],"lastfm-square":["b"],"laugh":["s","r"],"laugh-beam":["s","r"],"laugh-squint":["s","r"],"laugh-wink":["s","r"],"layer-group":["s"],"leaf":["s"],"leanpub":["b"],"lemon":["s","r"],"less":["b"],"less-than":["s"],"less-than-equal":["s"],"level-down-alt":["s"],"level-up-alt":["s"],"life-ring":["s","r"],"lightbulb":["s","r"],"line":["b"],"link":["s"],"linkedin":["b"],"linkedin-in":["b"],"linode":["b"],"linux":["b"],"lira-sign":["s"],"list":["s"],"list-alt":["s","r"],"list-ol":["s"],"list-ul":["s"],"location-arrow":["s"],"lock":["s"],"lock-open":["s"],"long-arrow-alt-down":["s"],"long-arrow-alt-left":["s"],"long-arrow-alt-right":["s"],"long-arrow-alt-up":["s"],"low-vision":["s"],"luggage-cart":["s"],"lungs":["s"],"lungs-virus":["s"],"lyft":["b"],"magento":["b"],"magic":["s"],"magnet":["s"],"mail-bulk":["s"],"mailchimp":["b"],"male":["s"],"mandalorian":["b"],"map":["s","r"],"map-marked":["s"],"map-marked-alt":["s"],"map-marker":["s"],"map-marker-alt":["s"],"map-pin":["s"],"map-signs":["s"],"markdown":["b"],"marker":["s"],"mars":["s"],"mars-double":["s"],"mars-stroke":["s"],"mars-stroke-h":["s"],"mars-stroke-v":["s"],"mask":["s"],"mastodon":["b"],"maxcdn":["b"],"mdb":["b"],"medal":["s"],"medapps":["b"],"medium":["b"],"medium-m":["b"],"medkit":["s"],"medrt":["b"],"meetup":["b"],"megaport":["b"],"meh":["s","r"],"meh-blank":["s","r"],"meh-rolling-eyes":["s","r"],"memory":["s"],"mendeley":["b"],"menorah":["s"],"mercury":["s"],"meteor":["s"],"microblog":["b"],"microchip":["s"],"microphone":["s"],"microphone-alt":["s"],"microphone-alt-slash":["s"],"microphone-slash":["s"],"microscope":["s"],"microsoft":["b"],"minus":["s"],"minus-circle":["s"],"minus-square":["s","r"],"mitten":["s"],"mix":["b"],"mixcloud":["b"],"mixer":["b"],"mizuni":["b"],"mobile":["s"],"mobile-alt":["s"],"modx":["b"],"monero":["b"],"money-bill":["s"],"money-bill-alt":["s","r"],"money-bill-wave":["s"],"money-bill-wave-alt":["s"],"money-check":["s"],"money-check-alt":["s"],"monument":["s"],"moon":["s","r"],"mortar-pestle":["s"],"mosque":["s"],"motorcycle":["s"],"mountain":["s"],"mouse":["s"],"mouse-pointer":["s"],"mug-hot":["s"],"music":["s"],"napster":["b"],"neos":["b"],"network-wired":["s"],"neuter":["s"],"newspaper":["s","r"],"nimblr":["b"],"node":["b"],"node-js":["b"],"not-equal":["s"],"notes-medical":["s"],"npm":["b"],"ns8":["b"],"nutritionix":["b"],"object-group":["s","r"],"object-ungroup":["s","r"],"octopus-deploy":["b"],"odnoklassniki":["b"],"odnoklassniki-square":["b"],"oil-can":["s"],"old-republic":["b"],"om":["s"],"opencart":["b"],"openid":["b"],"opera":["b"],"optin-monster":["b"],"orcid":["b"],"osi":["b"],"otter":["s"],"outdent":["s"],"page4":["b"],"pagelines":["b"],"pager":["s"],"paint-brush":["s"],"paint-roller":["s"],"palette":["s"],"palfed":["b"],"pallet":["s"],"paper-plane":["s","r"],"paperclip":["s"],"parachute-box":["s"],"paragraph":["s"],"parking":["s"],"passport":["s"],"pastafarianism":["s"],"paste":["s"],"patreon":["b"],"pause":["s"],"pause-circle":["s","r"],"paw":["s"],"paypal":["b"],"peace":["s"],"pen":["s"],"pen-alt":["s"],"pen-fancy":["s"],"pen-nib":["s"],"pen-square":["s"],"pencil-alt":["s"],"pencil-ruler":["s"],"penny-arcade":["b"],"people-arrows":["s"],"people-carry":["s"],"pepper-hot":["s"],"perbyte":["b"],"percent":["s"],"percentage":["s"],"periscope":["b"],"person-booth":["s"],"phabricator":["b"],"phoenix-framework":["b"],"phoenix-squadron":["b"],"phone":["s"],"phone-alt":["s"],"phone-slash":["s"],"phone-square":["s"],"phone-square-alt":["s"],"phone-volume":["s"],"photo-video":["s"],"php":["b"],"pied-piper":["b"],"pied-piper-alt":["b"],"pied-piper-hat":["b"],"pied-piper-pp":["b"],"pied-piper-square":["b"],"piggy-bank":["s"],"pills":["s"],"pinterest":["b"],"pinterest-p":["b"],"pinterest-square":["b"],"pizza-slice":["s"],"place-of-worship":["s"],"plane":["s"],"plane-arrival":["s"],"plane-departure":["s"],"plane-slash":["s"],"play":["s"],"play-circle":["s","r"],"playstation":["b"],"plug":["s"],"plus":["s"],"plus-circle":["s"],"plus-square":["s","r"],"podcast":["s"],"poll":["s"],"poll-h":["s"],"poo":["s"],"poo-storm":["s"],"poop":["s"],"portrait":["s"],"pound-sign":["s"],"power-off":["s"],"pray":["s"],"praying-hands":["s"],"prescription":["s"],"prescription-bottle":["s"],"prescription-bottle-alt":["s"],"print":["s"],"procedures":["s"],"product-hunt":["b"],"project-diagram":["s"],"pump-medical":["s"],"pump-soap":["s"],"pushed":["b"],"puzzle-piece":["s"],"python":["b"],"qq":["b"],"qrcode":["s"],"question":["s"],"question-circle":["s","r"],"quidditch":["s"],"quinscape":["b"],"quora":["b"],"quote-left":["s"],"quote-right":["s"],"quran":["s"],"r-project":["b"],"radiation":["s"],"radiation-alt":["s"],"rainbow":["s"],"random":["s"],"raspberry-pi":["b"],"ravelry":["b"],"react":["b"],"reacteurope":["b"],"readme":["b"],"rebel":["b"],"receipt":["s"],"record-vinyl":["s"],"recycle":["s"],"red-river":["b"],"reddit":["b"],"reddit-alien":["b"],"reddit-square":["b"],"redhat":["b"],"redo":["s"],"redo-alt":["s"],"registered":["s","r"],"remove-format":["s"],"renren":["b"],"reply":["s"],"reply-all":["s"],"replyd":["b"],"republican":["s"],"researchgate":["b"],"resolving":["b"],"restroom":["s"],"retweet":["s"],"rev":["b"],"ribbon":["s"],"ring":["s"],"road":["s"],"robot":["s"],"rocket":["s"],"rocketchat":["b"],"rockrms":["b"],"route":["s"],"rss":["s"],"rss-square":["s"],"ruble-sign":["s"],"ruler":["s"],"ruler-combined":["s"],"ruler-horizontal":["s"],"ruler-vertical":["s"],"running":["s"],"rupee-sign":["s"],"rust":["b"],"sad-cry":["s","r"],"sad-tear":["s","r"],"safari":["b"],"salesforce":["b"],"sass":["b"],"satellite":["s"],"satellite-dish":["s"],"save":["s","r"],"schlix":["b"],"school":["s"],"screwdriver":["s"],"scribd":["b"],"scroll":["s"],"sd-card":["s"],"search":["s"],"search-dollar":["s"],"search-location":["s"],"search-minus":["s"],"search-plus":["s"],"searchengin":["b"],"seedling":["s"],"sellcast":["b"],"sellsy":["b"],"server":["s"],"servicestack":["b"],"shapes":["s"],"share":["s"],"share-alt":["s"],"share-alt-square":["s"],"share-square":["s","r"],"shekel-sign":["s"],"shield-alt":["s"],"shield-virus":["s"],"ship":["s"],"shipping-fast":["s"],"shirtsinbulk":["b"],"shoe-prints":["s"],"shopify":["b"],"shopping-bag":["s"],"shopping-basket":["s"],"shopping-cart":["s"],"shopware":["b"],"shower":["s"],"shuttle-van":["s"],"sign":["s"],"sign-in-alt":["s"],"sign-language":["s"],"sign-out-alt":["s"],"signal":["s"],"signature":["s"],"sim-card":["s"],"simplybuilt":["b"],"sink":["s"],"sistrix":["b"],"sitemap":["s"],"sith":["b"],"skating":["s"],"sketch":["b"],"skiing":["s"],"skiing-nordic":["s"],"skull":["s"],"skull-crossbones":["s"],"skyatlas":["b"],"skype":["b"],"slack":["b"],"slack-hash":["b"],"slash":["s"],"sleigh":["s"],"sliders-h":["s"],"slideshare":["b"],"smile":["s","r"],"smile-beam":["s","r"],"smile-wink":["s","r"],"smog":["s"],"smoking":["s"],"smoking-ban":["s"],"sms":["s"],"snapchat":["b"],"snapchat-ghost":["b"],"snapchat-square":["b"],"snowboarding":["s"],"snowflake":["s","r"],"snowman":["s"],"snowplow":["s"],"soap":["s"],"socks":["s"],"solar-panel":["s"],"sort":["s"],"sort-alpha-down":["s"],"sort-alpha-down-alt":["s"],"sort-alpha-up":["s"],"sort-alpha-up-alt":["s"],"sort-amount-down":["s"],"sort-amount-down-alt":["s"],"sort-amount-up":["s"],"sort-amount-up-alt":["s"],"sort-down":["s"],"sort-numeric-down":["s"],"sort-numeric-down-alt":["s"],"sort-numeric-up":["s"],"sort-numeric-up-alt":["s"],"sort-up":["s"],"soundcloud":["b"],"sourcetree":["b"],"spa":["s"],"space-shuttle":["s"],"speakap":["b"],"speaker-deck":["b"],"spell-check":["s"],"spider":["s"],"spinner":["s"],"splotch":["s"],"spotify":["b"],"spray-can":["s"],"square":["s","r"],"square-full":["s"],"square-root-alt":["s"],"squarespace":["b"],"stack-exchange":["b"],"stack-overflow":["b"],"stackpath":["b"],"stamp":["s"],"star":["s","r"],"star-and-crescent":["s"],"star-half":["s","r"],"star-half-alt":["s"],"star-of-david":["s"],"star-of-life":["s"],"staylinked":["b"],"steam":["b"],"steam-square":["b"],"steam-symbol":["b"],"step-backward":["s"],"step-forward":["s"],"stethoscope":["s"],"sticker-mule":["b"],"sticky-note":["s","r"],"stop":["s"],"stop-circle":["s","r"],"stopwatch":["s"],"stopwatch-20":["s"],"store":["s"],"store-alt":["s"],"store-alt-slash":["s"],"store-slash":["s"],"strava":["b"],"stream":["s"],"street-view":["s"],"strikethrough":["s"],"stripe":["b"],"stripe-s":["b"],"stroopwafel":["s"],"studiovinari":["b"],"stumbleupon":["b"],"stumbleupon-circle":["b"],"subscript":["s"],"subway":["s"],"suitcase":["s"],"suitcase-rolling":["s"],"sun":["s","r"],"superpowers":["b"],"superscript":["s"],"supple":["b"],"surprise":["s","r"],"suse":["b"],"swatchbook":["s"],"swift":["b"],"swimmer":["s"],"swimming-pool":["s"],"symfony":["b"],"synagogue":["s"],"sync":["s"],"sync-alt":["s"],"syringe":["s"],"table":["s"],"table-tennis":["s"],"tablet":["s"],"tablet-alt":["s"],"tablets":["s"],"tachometer-alt":["s"],"tag":["s"],"tags":["s"],"tape":["s"],"tasks":["s"],"taxi":["s"],"teamspeak":["b"],"teeth":["s"],"teeth-open":["s"],"telegram":["b"],"telegram-plane":["b"],"temperature-high":["s"],"temperature-low":["s"],"tencent-weibo":["b"],"tenge":["s"],"terminal":["s"],"text-height":["s"],"text-width":["s"],"th":["s"],"th-large":["s"],"th-list":["s"],"the-red-yeti":["b"],"theater-masks":["s"],"themeco":["b"],"themeisle":["b"],"thermometer":["s"],"thermometer-empty":["s"],"thermometer-full":["s"],"thermometer-half":["s"],"thermometer-quarter":["s"],"thermometer-three-quarters":["s"],"think-peaks":["b"],"thumbs-down":["s","r"],"thumbs-up":["s","r"],"thumbtack":["s"],"ticket-alt":["s"],"tiktok":["b"],"times":["s"],"times-circle":["s","r"],"tint":["s"],"tint-slash":["s"],"tired":["s","r"],"toggle-off":["s"],"toggle-on":["s"],"toilet":["s"],"toilet-paper":["s"],"toilet-paper-slash":["s"],"toolbox":["s"],"tools":["s"],"tooth":["s"],"torah":["s"],"torii-gate":["s"],"tractor":["s"],"trade-federation":["b"],"trademark":["s"],"traffic-light":["s"],"trailer":["s"],"train":["s"],"tram":["s"],"transgender":["s"],"transgender-alt":["s"],"trash":["s"],"trash-alt":["s","r"],"trash-restore":["s"],"trash-restore-alt":["s"],"tree":["s"],"trello":["b"],"tripadvisor":["b"],"trophy":["s"],"truck":["s"],"truck-loading":["s"],"truck-monster":["s"],"truck-moving":["s"],"truck-pickup":["s"],"tshirt":["s"],"tty":["s"],"tumblr":["b"],"tumblr-square":["b"],"tv":["s"],"twitch":["b"],"twitter":["b"],"twitter-square":["b"],"typo3":["b"],"uber":["b"],"ubuntu":["b"],"uikit":["b"],"umbraco":["b"],"umbrella":["s"],"umbrella-beach":["s"],"uncharted":["b"],"underline":["s"],"undo":["s"],"undo-alt":["s"],"uniregistry":["b"],"unity":["b"],"universal-access":["s"],"university":["s"],"unlink":["s"],"unlock":["s"],"unlock-alt":["s"],"unsplash":["b"],"untappd":["b"],"upload":["s"],"ups":["b"],"usb":["b"],"user":["s","r"],"user-alt":["s"],"user-alt-slash":["s"],"user-astronaut":["s"],"user-check":["s"],"user-circle":["s","r"],"user-clock":["s"],"user-cog":["s"],"user-edit":["s"],"user-friends":["s"],"user-graduate":["s"],"user-injured":["s"],"user-lock":["s"],"user-md":["s"],"user-minus":["s"],"user-ninja":["s"],"user-nurse":["s"],"user-plus":["s"],"user-secret":["s"],"user-shield":["s"],"user-slash":["s"],"user-tag":["s"],"user-tie":["s"],"user-times":["s"],"users":["s"],"users-cog":["s"],"users-slash":["s"],"usps":["b"],"ussunnah":["b"],"utensil-spoon":["s"],"utensils":["s"],"vaadin":["b"],"vector-square":["s"],"venus":["s"],"venus-double":["s"],"venus-mars":["s"],"vest":["s"],"vest-patches":["s"],"viacoin":["b"],"viadeo":["b"],"viadeo-square":["b"],"vial":["s"],"vials":["s"],"viber":["b"],"video":["s"],"video-slash":["s"],"vihara":["s"],"vimeo":["b"],"vimeo-square":["b"],"vimeo-v":["b"],"vine":["b"],"virus":["s"],"virus-slash":["s"],"viruses":["s"],"vk":["b"],"vnv":["b"],"voicemail":["s"],"volleyball-ball":["s"],"volume-down":["s"],"volume-mute":["s"],"volume-off":["s"],"volume-up":["s"],"vote-yea":["s"],"vr-cardboard":["s"],"vuejs":["b"],"walking":["s"],"wallet":["s"],"warehouse":["s"],"watchman-monitoring":["b"],"water":["s"],"wave-square":["s"],"waze":["b"],"weebly":["b"],"weibo":["b"],"weight":["s"],"weight-hanging":["s"],"weixin":["b"],"whatsapp":["b"],"whatsapp-square":["b"],"wheelchair":["s"],"whmcs":["b"],"wifi":["s"],"wikipedia-w":["b"],"wind":["s"],"window-close":["s","r"],"window-maximize":["s","r"],"window-minimize":["s","r"],"window-restore":["s","r"],"windows":["b"],"wine-bottle":["s"],"wine-glass":["s"],"wine-glass-alt":["s"],"wix":["b"],"wizards-of-the-coast":["b"],"wodu":["b"],"wolf-pack-battalion":["b"],"won-sign":["s"],"wordpress":["b"],"wordpress-simple":["b"],"wpbeginner":["b"],"wpexplorer":["b"],"wpforms":["b"],"wpressr":["b"],"wrench":["s"],"x-ray":["s"],"xbox":["b"],"xing":["b"],"xing-square":["b"],"y-combinator":["b"],"yahoo":["b"],"yammer":["b"],"yandex":["b"],"yandex-international":["b"],"yarn":["b"],"yelp":["b"],"yen-sign":["s"],"yin-yang":["s"],"yoast":["b"],"youtube":["b"],"youtube-square":["b"],"zhihu":["b"]} \ No newline at end of file diff --git a/tabby-core/src/icons/plus.svg b/tabby-core/src/icons/plus.svg new file mode 100644 index 00000000..9862e6d7 --- /dev/null +++ b/tabby-core/src/icons/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tabby-local/src/icons/profiles.svg b/tabby-core/src/icons/profiles.svg similarity index 100% rename from tabby-local/src/icons/profiles.svg rename to tabby-core/src/icons/profiles.svg diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index c996627a..550d5f8e 100644 --- a/tabby-core/src/index.ts +++ b/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 { diff --git a/tabby-core/src/services/app.service.ts b/tabby-core/src/services/app.service.ts index b6486d4a..80a0015e 100644 --- a/tabby-core/src/services/app.service.ts +++ b/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): BaseTabComponent { - const tab = this.tabsService.create(type, inputs) + openNewTabRaw (params: NewTabParameters): 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): BaseTabComponent { - const splitTab = this.tabsService.create(SplitTabComponent) as SplitTabComponent - const tab = this.tabsService.create(type, inputs) + openNewTab (params: NewTabParameters): 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 { diff --git a/tabby-core/src/services/config.service.ts b/tabby-core/src/services/config.service.ts index 0750dbcb..79660678 100644 --- a/tabby-core/src/services/config.service.ts +++ b/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) { diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts new file mode 100644 index 00000000..2a3fa869 --- /dev/null +++ b/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 { + 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|null> { + return this.providerForProfile(profile)?.getNewTabParameters(profile) ?? null + } + + getProviders (): ProfileProvider[] { + return [...this.profileProviders] + } + + async getProfiles (): Promise { + const lists = await Promise.all(this.config.enabledServices(this.profileProviders).map(x => x.getBuiltinProfiles())) + let list = lists.reduce((a, b) => a.concat(b), []) + list = [ + ...this.config.store.profiles ?? [], + ...list, + ] + 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 + } +} diff --git a/tabby-core/src/services/tabRecovery.service.ts b/tabby-core/src/services/tabRecovery.service.ts index 96741094..622f1452 100644 --- a/tabby-core/src/services/tabRecovery.service.ts +++ b/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[]|null, private config: ConfigService, log: LogService ) { @@ -40,7 +41,7 @@ export class TabRecoveryService { return token } - async recoverTab (token: RecoveryToken, duplicate = false): Promise { + async recoverTab (token: RecoveryToken, duplicate = false): Promise|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 { + async recoverTabs (): Promise[]> { if (window.localStorage.tabsRecovery) { - const tabs: RecoveredTab[] = [] + const tabs: NewTabParameters[] = [] for (const token of JSON.parse(window.localStorage.tabsRecovery)) { const tab = await this.recoverTab(token) if (tab) { diff --git a/tabby-core/src/services/tabs.service.ts b/tabby-core/src/services/tabs.service.ts index 4c4403b8..eb04a601 100644 --- a/tabby-core/src/services/tabs.service.ts +++ b/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 { + // eslint-disable-next-line @typescript-eslint/prefer-function-type + new (...args: any[]): T +} + +export interface NewTabParameters { + /** + * Component type to be instantiated + */ + type: TabComponentType + + /** + * Component instance inputs + */ + inputs?: Record +} @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): BaseTabComponent { - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(type) + create (params: NewTabParameters): 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 } diff --git a/tabby-core/src/services/vault.service.ts b/tabby-core/src/services/vault.service.ts index 39fa7fda..150513a7 100644 --- a/tabby-core/src/services/vault.service.ts +++ b/tabby-core/src/services/vault.service.ts @@ -247,12 +247,12 @@ export class VaultFileProvider extends FileProvider { const result = await this.selector.show('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, })), ]) diff --git a/tabby-core/src/theme.scss b/tabby-core/src/theme.scss index 4f9c025b..29f5491a 100644 --- a/tabby-core/src/theme.scss +++ b/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; diff --git a/tabby-core/yarn.lock b/tabby-core/yarn.lock index 1085747d..ccd0eb54 100644 --- a/tabby-core/yarn.lock +++ b/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" diff --git a/tabby-local/package.json b/tabby-local/package.json index 8b0ca46f..d9ca03cb 100644 --- a/tabby-local/package.json +++ b/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": { diff --git a/tabby-local/src/api.ts b/tabby-local/src/api.ts index 4ee176f5..e5c047aa 100644 --- a/tabby-local/src/api.ts +++ b/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 @@ -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 { diff --git a/tabby-local/src/buttonProvider.ts b/tabby-local/src/buttonProvider.ts index 48ad21ff..68eff2b5 100644 --- a/tabby-local/src/buttonProvider.ts +++ b/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[] = [] - 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(), - }, ] } } diff --git a/tabby-local/src/cli.ts b/tabby-local/src/cli.ts index 88e9a635..b6f2ab6e 100644 --- a/tabby-local/src/cli.ts +++ b/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() - } } diff --git a/tabby-local/src/components/editProfileModal.component.pug b/tabby-local/src/components/editProfileModal.component.pug index 0ad3dd5b..ef506882 100644 --- a/tabby-local/src/components/editProfileModal.component.pug +++ b/tabby-local/src/components/editProfileModal.component.pug @@ -1,72 +1,64 @@ +.modal-header + h3.m-0 {{profile.name}} + .modal-body - .form-group - label Name - input.form-control( - type='text', - autofocus, - [(ngModel)]='profile.name', - ) + .row + .col-12.col-lg-4 + .form-group + label Name + input.form-control( + type='text', + autofocus, + [(ngModel)]='profile.name', + ) - .form-group - label Command - input.form-control( - type='text', - [(ngModel)]='profile.sessionOptions.command', - ) + .form-group + label Group + input.form-control( + type='text', + alwaysVisibleTypeahead, + placeholder='Ungrouped', + [(ngModel)]='profile.group', + [ngbTypeahead]='groupTypeahead', + ) - .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 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') - .mt-2 - button.btn.btn-secondary((click)='profile.sessionOptions.args.push("")') - i.fas.fa-plus.mr-2 - | Add + ng-template(#rt,let-r='result',let-t='term') + i([class]='"fa-fw " + r') + ngb-highlight.ml-2([result]='r', [term]='t') - .form-line(*ngIf='uac.isAvailable') - .header - .title Run as administrator - toggle( - [(ngModel)]='profile.sessionOptions.runAsAdministrator', - ) + .form-line + .header + .title Color + input.form-control.w-50( + type='text', + [(ngModel)]='profile.color', + placeholder='#000000' + ) - .form-group - label Working directory - input.form-control( - type='text', - [(ngModel)]='profile.sessionOptions.cwd', - ) + .form-line + .header + .title Disable dynamic tab title + .description Connection name will be used instead + toggle([(ngModel)]='profile.disableDynamicTitle') - .form-group - label Environment - environment-editor( - type='text', - [(model)]='profile.sessionOptions.env', - ) + .mb-4 - .form-group - label Tab color - input.form-control( - type='text', - autofocus, - [(ngModel)]='profile.color', - placeholder='#000000' - ) - - .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 diff --git a/tabby-local/src/components/editProfileModal.component.ts b/tabby-local/src/components/editProfileModal.component.ts index ab70a2b6..1fd7daba 100644 --- a/tabby-local/src/components/editProfileModal.component.ts +++ b/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) => + text$.pipe( + debounceTime(200), + distinctUntilChanged(), + map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase()))) + ) + + iconSearch: OperatorFunction = (text$: Observable) => + 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 - } } diff --git a/tabby-local/src/components/localProfileSettings.component.pug b/tabby-local/src/components/localProfileSettings.component.pug new file mode 100644 index 00000000..11cd89a6 --- /dev/null +++ b/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', + ) diff --git a/tabby-local/src/components/localProfileSettings.component.ts b/tabby-local/src/components/localProfileSettings.component.ts new file mode 100644 index 00000000..88745b42 --- /dev/null +++ b/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 { + // 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 + } +} diff --git a/tabby-local/src/components/profilesSettingsTab.component.pug b/tabby-local/src/components/profilesSettingsTab.component.pug new file mode 100644 index 00000000..f5ff92ae --- /dev/null +++ b/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)}} diff --git a/tabby-local/src/components/profilesSettingsTab.component.ts b/tabby-local/src/components/profilesSettingsTab.component.ts new file mode 100644 index 00000000..549858ce --- /dev/null +++ b/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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' + } +} diff --git a/tabby-local/src/components/shellSettingsTab.component.pug b/tabby-local/src/components/shellSettingsTab.component.pug index 78e5bdff..33ff7518 100644 --- a/tabby-local/src/components/shellSettingsTab.component.pug +++ b/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}} diff --git a/tabby-local/src/components/shellSettingsTab.component.ts b/tabby-local/src/components/shellSettingsTab.component.ts index 62055013..940af1be 100644 --- a/tabby-local/src/components/shellSettingsTab.component.ts +++ b/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 { - this.shells = (await this.terminal.shells$.toPromise())! - } - - ngOnDestroy (): void { - this.configSubscription.unsubscribe() - } - - async reload (): Promise { - this.profiles = await this.terminal.getProfiles({ includeHidden: true }) - } - - async pickWorkingDirectory (): Promise { - 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() - } } diff --git a/tabby-local/src/components/terminalTab.component.ts b/tabby-local/src/components/terminalTab.component.ts index 8383c821..5cf55a45 100644 --- a/tabby-local/src/components/terminalTab.component.ts +++ b/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, diff --git a/tabby-local/src/config.ts b/tabby-local/src/config.ts index dbb16748..d2403154 100644 --- a/tabby-local/src/config.ts +++ b/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': [ diff --git a/tabby-local/src/hotkeys.ts b/tabby-local/src/hotkeys.ts index 707f66d5..a5534647 100644 --- a/tabby-local/src/hotkeys.ts +++ b/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 { - 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 } } diff --git a/tabby-local/src/index.ts b/tabby-local/src/index.ts index 87fa9ae1..070a2962 100644 --- a/tabby-local/src/index.ts +++ b/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(() => { diff --git a/tabby-local/src/profiles.ts b/tabby-local/src/profiles.ts new file mode 100644 index 00000000..3111fcdd --- /dev/null +++ b/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 { + 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> { + 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 { + 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 + } +} diff --git a/tabby-local/src/recoveryProvider.ts b/tabby-local/src/recoveryProvider.ts index d0751827..d14a1cd6 100644 --- a/tabby-local/src/recoveryProvider.ts +++ b/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 { async applicableTo (recoveryToken: RecoveryToken): Promise { return recoveryToken.type === 'app:terminal-tab' } - async recover (recoveryToken: RecoveryToken): Promise { + async recover (recoveryToken: RecoveryToken): Promise> { return { type: TerminalTabComponent, - options: { + inputs: { sessionOptions: recoveryToken.sessionOptions, savedState: recoveryToken.savedState, }, diff --git a/tabby-local/src/services/dockMenu.service.ts b/tabby-local/src/services/dockMenu.service.ts index 3dcde5fc..19046253 100644 --- a/tabby-local/src/services/dockMenu.service.ts +++ b/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) }), })) )) diff --git a/tabby-local/src/services/terminal.service.ts b/tabby-local/src/services/terminal.service.ts index 1481cdc6..ab427e25 100644 --- a/tabby-local/src/services/terminal.service.ts +++ b/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() private logger: Logger - /** - * A fresh list of all available shells - */ - get shells$ (): Observable { 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 { - 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 { - const profiles = await this.getProfiles({ includeHidden: true }) - return profiles.find(x => this.getProfileID(x) === id) ?? null + async getDefaultProfile (): Promise { + 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 { + async openTab (profile?: LocalProfile|null, cwd?: string|null, pause?: boolean): Promise { 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 { - 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() - 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 } } diff --git a/tabby-local/src/settings.ts b/tabby-local/src/settings.ts index 2df9e9e5..ab7eebe9 100644 --- a/tabby-local/src/settings.ts +++ b/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 { - return ShellSettingsTabComponent + 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 ProfilesSettingsTabComponent } } diff --git a/tabby-local/src/shells/custom.ts b/tabby-local/src/shells/custom.ts deleted file mode 100644 index c36f5ee6..00000000 --- a/tabby-local/src/shells/custom.ts +++ /dev/null @@ -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 { - const args = this.config.store.terminal.customShell.split(' ') - return [{ - id: 'custom', - name: 'Custom shell', - command: args[0], - args: args.slice(1), - env: {}, - }] - } -} diff --git a/tabby-local/src/shells/macDefault.ts b/tabby-local/src/shells/macDefault.ts index 4c4e4cd3..82f27858 100644 --- a/tabby-local/src/shells/macDefault.ts +++ b/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, diff --git a/tabby-local/src/shells/posix.ts b/tabby-local/src/shells/posix.ts index e6a53644..d244f075 100644 --- a/tabby-local/src/shells/posix.ts +++ b/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: {}, diff --git a/tabby-local/src/shells/winDefault.ts b/tabby-local/src/shells/winDefault.ts index 1788b109..79887285 100644 --- a/tabby-local/src/shells/winDefault.ts +++ b/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: {}, }] diff --git a/tabby-local/src/tabContextMenu.ts b/tabby-local/src/tabContextMenu.ts index fd1ebb80..9772cafd 100644 --- a/tabby-local/src/tabContextMenu.ts +++ b/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 { - 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, }) }, diff --git a/tabby-local/yarn.lock b/tabby-local/yarn.lock index 4d2729f4..54961cfb 100644 --- a/tabby-local/yarn.lock +++ b/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" diff --git a/tabby-serial/src/api.ts b/tabby-serial/src/api.ts index 1726733b..a4ffb506 100644 --- a/tabby-serial/src/api.ts +++ b/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[] -} diff --git a/tabby-serial/src/buttonProvider.ts b/tabby-serial/src/buttonProvider.ts deleted file mode 100644 index 6a7fbc8e..00000000 --- a/tabby-serial/src/buttonProvider.ts +++ /dev/null @@ -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() - }, - }] - } -} diff --git a/tabby-serial/src/cli.ts b/tabby-serial/src/cli.ts deleted file mode 100644 index 66e460f1..00000000 --- a/tabby-serial/src/cli.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/tabby-serial/src/components/editConnectionModal.component.pug b/tabby-serial/src/components/editConnectionModal.component.pug deleted file mode 100644 index a6f2a330..00000000 --- a/tabby-serial/src/components/editConnectionModal.component.pug +++ /dev/null @@ -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 diff --git a/tabby-serial/src/components/serialProfileSettings.component.pug b/tabby-serial/src/components/serialProfileSettings.component.pug new file mode 100644 index 00000000..e29ae568 --- /dev/null +++ b/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') diff --git a/tabby-serial/src/components/editConnectionModal.component.ts b/tabby-serial/src/components/serialProfileSettings.component.ts similarity index 62% rename from tabby-serial/src/components/editConnectionModal.component.ts rename to tabby-serial/src/components/serialProfileSettings.component.ts index b4bde061..6e17be49 100644 --- a/tabby-serial/src/components/editConnectionModal.component.ts +++ b/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: '' }) } } diff --git a/tabby-serial/src/components/serialSettingsTab.component.pug b/tabby-serial/src/components/serialSettingsTab.component.pug deleted file mode 100644 index 3976b8a6..00000000 --- a/tabby-serial/src/components/serialSettingsTab.component.pug +++ /dev/null @@ -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 diff --git a/tabby-serial/src/components/serialSettingsTab.component.ts b/tabby-serial/src/components/serialSettingsTab.component.ts deleted file mode 100644 index 864acf24..00000000 --- a/tabby-serial/src/components/serialSettingsTab.component.ts +++ /dev/null @@ -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 - } -} diff --git a/tabby-serial/src/components/serialTab.component.pug b/tabby-serial/src/components/serialTab.component.pug index 9087c040..37bc2c45 100644 --- a/tabby-serial/src/components/serialTab.component.pug +++ b/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 diff --git a/tabby-serial/src/components/serialTab.component.ts b/tabby-serial/src/components/serialTab.component.ts index 45cd7af9..243f5ade 100644 --- a/tabby-serial/src/components/serialTab.component.ts +++ b/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 { 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 } } diff --git a/tabby-serial/src/config.ts b/tabby-serial/src/config.ts index 1d9fd907..45235295 100644 --- a/tabby-serial/src/config.ts +++ b/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', diff --git a/tabby-serial/src/index.ts b/tabby-serial/src/index.ts index 5656f0d6..30530e3d 100644 --- a/tabby-serial/src/index.ts +++ b/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, ], }) diff --git a/tabby-serial/src/profiles.ts b/tabby-serial/src/profiles.ts new file mode 100644 index 00000000..5e1130b4 --- /dev/null +++ b/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 { + 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> { + 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 + } +} diff --git a/tabby-serial/src/recoveryProvider.ts b/tabby-serial/src/recoveryProvider.ts index ee5e2ea6..7caacccc 100644 --- a/tabby-serial/src/recoveryProvider.ts +++ b/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 { async applicableTo (recoveryToken: RecoveryToken): Promise { return recoveryToken.type === 'app:serial-tab' } - async recover (recoveryToken: RecoveryToken): Promise { + async recover (recoveryToken: RecoveryToken): Promise> { return { type: SerialTabComponent, - options: { - connection: recoveryToken.connection, + inputs: { + profile: recoveryToken.profile, savedState: recoveryToken.savedState, }, } diff --git a/tabby-serial/src/services/serial.service.ts b/tabby-serial/src/services/serial.service.ts index 5e73699e..a4155a73 100644 --- a/tabby-serial/src/services/serial.service.ts +++ b/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 { @@ -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 { - 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 { - const options: SelectorOption[] = [] - 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 { - 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 { + quickConnect (query: string): Promise { 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 } - async connectFoundPort (port: SerialPortInfo): Promise { + async connectFoundPort (port: SerialPortInfo): Promise { const rate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({ name: x.toString(), result: x, }))) diff --git a/tabby-serial/src/settings.ts b/tabby-serial/src/settings.ts deleted file mode 100644 index 7df98c2e..00000000 --- a/tabby-serial/src/settings.ts +++ /dev/null @@ -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 - } -} diff --git a/tabby-settings/src/buttonProvider.ts b/tabby-settings/src/buttonProvider.ts index 1c20c806..c26c1709 100644 --- a/tabby-settings/src/buttonProvider.ts +++ b/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 }) } } } diff --git a/tabby-settings/src/components/hotkeySettingsTab.component.pug b/tabby-settings/src/components/hotkeySettingsTab.component.pug index 4d8d93cd..0f9f2469 100644 --- a/tabby-settings/src/components/hotkeySettingsTab.component.pug +++ b/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)' + ) diff --git a/tabby-settings/src/components/hotkeySettingsTab.component.scss b/tabby-settings/src/components/hotkeySettingsTab.component.scss deleted file mode 100644 index 6205aa1b..00000000 --- a/tabby-settings/src/components/hotkeySettingsTab.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -.hotkeys-table { - margin-top: 30px; - - td, th { - padding: 5px 10px; - } -} diff --git a/tabby-settings/src/components/hotkeySettingsTab.component.ts b/tabby-settings/src/components/hotkeySettingsTab.component.ts index 50487afb..d243b598 100644 --- a/tabby-settings/src/components/hotkeySettingsTab.component.ts +++ b/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()) } } diff --git a/tabby-settings/src/components/settingsTab.component.ts b/tabby-settings/src/components/settingsTab.component.ts index b140245f..b1cee9f3 100644 --- a/tabby-settings/src/components/settingsTab.component.ts +++ b/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()) diff --git a/tabby-ssh/package.json b/tabby-ssh/package.json index 44e86104..7c40834e 100644 --- a/tabby-ssh/package.json +++ b/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" diff --git a/tabby-ssh/src/api.ts b/tabby-ssh/src/api.ts index 0016e501..e42d0e99 100644 --- a/tabby-ssh/src/api.ts +++ b/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) { diff --git a/tabby-ssh/src/buttonProvider.ts b/tabby-ssh/src/buttonProvider.ts deleted file mode 100644 index c0ef0f1d..00000000 --- a/tabby-ssh/src/buttonProvider.ts +++ /dev/null @@ -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(), - }] - } - } -} diff --git a/tabby-ssh/src/cli.ts b/tabby-ssh/src/cli.ts deleted file mode 100644 index 1c6f4004..00000000 --- a/tabby-ssh/src/cli.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/tabby-ssh/src/components/editConnectionModal.component.pug b/tabby-ssh/src/components/editConnectionModal.component.pug deleted file mode 100644 index b1161ea8..00000000 --- a/tabby-ssh/src/components/editConnectionModal.component.pug +++ /dev/null @@ -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 diff --git a/tabby-ssh/src/components/sshProfileSettings.component.pug b/tabby-ssh/src/components/sshProfileSettings.component.pug new file mode 100644 index 00000000..cf5ebe96 --- /dev/null +++ b/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') diff --git a/tabby-ssh/src/components/editConnectionModal.component.ts b/tabby-ssh/src/components/sshProfileSettings.component.ts similarity index 52% rename from tabby-ssh/src/components/editConnectionModal.component.ts rename to tabby-ssh/src/components/sshProfileSettings.component.ts index 2d47fb75..0565bccc 100644 --- a/tabby-ssh/src/components/editConnectionModal.component.ts +++ b/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 = {} defaultAlgorithms: Record = {} algorithms: Record> = {} - - 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) => - 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) } } diff --git a/tabby-ssh/src/components/sshSettingsTab.component.pug b/tabby-ssh/src/components/sshSettingsTab.component.pug index cac683cc..c59e3d87 100644 --- a/tabby-ssh/src/components/sshSettingsTab.component.pug +++ b/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 diff --git a/tabby-ssh/src/components/sshSettingsTab.component.scss b/tabby-ssh/src/components/sshSettingsTab.component.scss deleted file mode 100644 index f360a956..00000000 --- a/tabby-ssh/src/components/sshSettingsTab.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.list-group-item { - padding: 0.3rem 1rem; -} diff --git a/tabby-ssh/src/components/sshSettingsTab.component.ts b/tabby-ssh/src/components/sshSettingsTab.component.ts index 7d6dd0ba..28a89707 100644 --- a/tabby-ssh/src/components/sshSettingsTab.component.ts +++ b/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 = {} - 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()) - } + ) { } } diff --git a/tabby-ssh/src/components/sshTab.component.pug b/tabby-ssh/src/components/sshTab.component.pug index 95e03fbd..f3e9d811 100644 --- a/tabby-ssh/src/components/sshTab.component.pug +++ b/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 diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index db24fbf9..42c5f500 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/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 { - 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 { 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 { 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, } diff --git a/tabby-ssh/src/config.ts b/tabby-ssh/src/config.ts index 21a9c8d6..5b9f41dc 100644 --- a/tabby-ssh/src/config.ts +++ b/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', diff --git a/tabby-ssh/src/hotkeys.ts b/tabby-ssh/src/hotkeys.ts index 88666113..658d06c3 100644 --- a/tabby-ssh/src/hotkeys.ts +++ b/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', diff --git a/tabby-ssh/src/icons/globe.svg b/tabby-ssh/src/icons/globe.svg deleted file mode 100644 index a03885c9..00000000 --- a/tabby-ssh/src/icons/globe.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index 078c31bd..5ab66075 100644 --- a/tabby-ssh/src/index.ts +++ b/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, diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts new file mode 100644 index 00000000..1bfc5587 --- /dev/null +++ b/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 { + 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> { + 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, + }, + } + } +} diff --git a/tabby-ssh/src/recoveryProvider.ts b/tabby-ssh/src/recoveryProvider.ts index 0081460f..7817c600 100644 --- a/tabby-ssh/src/recoveryProvider.ts +++ b/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 { async applicableTo (recoveryToken: RecoveryToken): Promise { return recoveryToken.type === 'app:ssh-tab' } - async recover (recoveryToken: RecoveryToken): Promise { + async recover (recoveryToken: RecoveryToken): Promise> { return { type: SSHTabComponent, - options: { - connection: recoveryToken['connection'], + inputs: { + profile: recoveryToken['profile'], savedState: recoveryToken['savedState'], }, } diff --git a/tabby-ssh/src/services/passwordStorage.service.ts b/tabby-ssh/src/services/passwordStorage.service.ts index ac785f58..7722b6e8 100644 --- a/tabby-ssh/src/services/passwordStorage.service.ts +++ b/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 { + async savePassword (profile: SSHProfile, password: string): Promise { 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 { + async deletePassword (profile: SSHProfile): Promise { 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 { + async loadPassword (profile: SSHProfile): Promise { 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, } } diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index 40544c90..1a04875d 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/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 = 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 { - const options: SelectorOption[] = [] - 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 { - 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 { - 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 { - let uri = `scp://${connection.user}` - const password = await this.passwordStorage.loadPassword(connection) + async getWinSCPURI (profile: SSHProfile): Promise { + 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) diff --git a/tabby-ssh/src/tabContextMenu.ts b/tabby-ssh/src/tabContextMenu.ts index b5cec59a..dae9c269 100644 --- a/tabby-ssh/src/tabContextMenu.ts +++ b/tabby-ssh/src/tabContextMenu.ts @@ -17,7 +17,7 @@ export class SFTPContextMenu extends TabContextMenuItemProvider { } async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise { - if (!(tab instanceof SSHTabComponent) || !tab.connection) { + if (!(tab instanceof SSHTabComponent) || !tab.profile) { return [] } const items = [{ diff --git a/tabby-ssh/yarn.lock b/tabby-ssh/yarn.lock index 88c6a317..4aaa884e 100644 --- a/tabby-ssh/yarn.lock +++ b/tabby-ssh/yarn.lock @@ -95,15 +95,6 @@ cliff@0.1.x: 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" - colors@0.6.x: 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= - isstream@0.1.x: 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" diff --git a/tabby-terminal/package.json b/tabby-terminal/package.json index d8dd35f3..1422018e 100644 --- a/tabby-terminal/package.json +++ b/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", diff --git a/tabby-terminal/src/components/terminalSettingsTab.component.pug b/tabby-terminal/src/components/terminalSettingsTab.component.pug index 0a965a5c..67550b57 100644 --- a/tabby-terminal/src/components/terminalSettingsTab.component.pug +++ b/tabby-terminal/src/components/terminalSettingsTab.component.pug @@ -100,7 +100,7 @@ h3.mb-3 Terminal .title Restore terminal tabs on app start toggle( - [(ngModel)]='config.store.terminal.recoverTabs', + [(ngModel)]='config.store.recoverTabs', (ngModelChange)='config.save()', ) diff --git a/tabby-terminal/yarn.lock b/tabby-terminal/yarn.lock index 7fd6ca08..1c667c05 100644 --- a/tabby-terminal/yarn.lock +++ b/tabby-terminal/yarn.lock @@ -430,11 +430,6 @@ side-channel@^1.0.3: get-intrinsic "^1.0.2" object-inspect "^1.9.0" -slugify@^1.4.0: - 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" diff --git a/webpack.plugin.config.js b/webpack.plugin.config.js index 646b6a02..a5f9a93f 100644 --- a/webpack.plugin.config.js +++ b/webpack.plugin.config.js @@ -40,7 +40,7 @@ module.exports = options => { }, resolve: { alias: options.alias ?? {}, - modules: ['.', 'src', 'node_modules', '../app/node_modules'].map(x => path.join(options.dirname, x)), + modules: ['.', 'src', 'node_modules', '../app/node_modules', '../node_modules'].map(x => path.join(options.dirname, x)), extensions: ['.ts', '.js'], }, module: { diff --git a/yarn.lock b/yarn.lock index 04e93688..b0ae492e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -283,10 +283,10 @@ dependencies: defer-to-connect "^2.0.0" -"@terminus-term/to-string-loader@1.1.7-beta.1": - version "1.1.7-beta.1" - resolved "https://registry.yarnpkg.com/@terminus-term/to-string-loader/-/to-string-loader-1.1.7-beta.1.tgz#5a622830a7f12ebbb2e2c600c621f586259dc7fe" - integrity sha512-mYUDUYkEKpr/mS4LucALv4QKHsF8xWXcYChQdN2nZIXCoXJoBQFsQPSzdcAeCzbl/XDsyop/mI5vIA34RnDd0Q== +"@tabby-gang/to-string-loader@^1.1.7-beta.2": + version "1.1.7-beta.2" + resolved "https://registry.yarnpkg.com/@tabby-gang/to-string-loader/-/to-string-loader-1.1.7-beta.2.tgz#5519ec87d5b3a49998e74d01c26c269770be50c8" + integrity sha512-2hgj8KMl2Qm4dcruu1iFZqeIMXLvMpNrEKIDjEjei5NbQ/aOagOozPQV4B/jlTDybiLiXzx33Ys6Xj/8tVXZMw== dependencies: loader-utils "^1.0.0" @@ -1637,7 +1637,7 @@ character-parser@^2.1.1, character-parser@^2.2.0: dependencies: is-regex "^1.0.3" -chownr@^1.0.1, chownr@^1.1.1: +chownr@^1.0.1: version "1.1.4" resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== @@ -2198,7 +2198,7 @@ debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: dependencies: ms "2.0.0" -debug@^3.0.0, debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: +debug@^3.0.0, debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== @@ -2313,7 +2313,7 @@ detect-indent@~5.0.0: resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= -detect-libc@^1.0.2, detect-libc@^1.0.3: +detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= @@ -3394,13 +3394,6 @@ fs-extra@^9.0.0, fs-extra@^9.0.1, fs-extra@^9.1.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-minipass@^1.2.5: - version "1.2.7" - resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz" - integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== - dependencies: - minipass "^2.6.0" - fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz" @@ -4018,13 +4011,6 @@ iconv-corefoundation@^1.1.5: cli-truncate "^1.1.0" node-addon-api "^1.6.3" -iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - iconv-lite@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz" @@ -4052,13 +4038,6 @@ iferr@^0.1.5, iferr@~0.1.5: resolved "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.3" - resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz" - integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== - dependencies: - minimatch "^3.0.4" - ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -5117,15 +5096,14 @@ lunr@^2.3.9: resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== -lzma-native@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/lzma-native/-/lzma-native-6.0.1.tgz" - integrity sha512-O6oWF0xe1AFvOCjU8uOZBZ/lhjaMNwHfVNaqVMqmoQXlRwBcFWpCAToiZOdXcKVMdo/5s/D0a2QgA5laMErxHQ== +lzma-native@^6.0.1, lzma-native@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/lzma-native/-/lzma-native-8.0.1.tgz#8569e2f88de461a9a2469ac9d8183637c387d682" + integrity sha512-Ryr9X3yDVZhRYOxR8QhUBCNe6GdEfy9BvFDIFtUvEkocvSvnrYt9lRm6FR1z0eQn0QSMenrgrDIJRMgUf9zsKQ== dependencies: - node-addon-api "^1.6.0" - node-pre-gyp "^0.11.0" - readable-stream "^2.3.5" - rimraf "^2.7.1" + node-addon-api "^3.1.0" + node-gyp-build "^4.2.1" + readable-stream "^3.6.0" macos-release@^2.5.0: version "2.5.0" @@ -5350,14 +5328,6 @@ minimist@^1.1.0, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: - version "2.9.0" - resolved "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - minipass@^3.0.0: version "3.1.3" resolved "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz" @@ -5365,13 +5335,6 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" -minizlib@^1.2.1: - version "1.3.3" - resolved "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz" - integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== - dependencies: - minipass "^2.9.0" - minizlib@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz" @@ -5485,15 +5448,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -needle@^2.2.1: - version "2.5.2" - resolved "https://registry.npmjs.org/needle/-/needle-2.5.2.tgz" - integrity sha512-LbRIwS9BfkPvNwNHlsA41Q29kL2L/6VaOJ0qisM5lLWsTV3nP15abO5ITL6L81zqFhzjRKDAYjpcBcwM0AVvLQ== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - neo-async@^2.6.0, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" @@ -5524,11 +5478,16 @@ node-abi@^2.19.2, node-abi@^2.30.0: dependencies: semver "^5.4.1" -node-addon-api@^1.6.0, node-addon-api@^1.6.3: +node-addon-api@^1.6.3: version "1.7.2" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz" integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== +node-addon-api@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + node-fetch-npm@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/node-fetch-npm/-/node-fetch-npm-2.0.1.tgz" @@ -5543,6 +5502,11 @@ node-fetch@^2.6.0: resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== +node-gyp-build@^4.2.1: + version "4.2.3" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.2.3.tgz#ce6277f853835f718829efb47db20f3e4d9c4739" + integrity sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg== + node-gyp@^7.1.0: version "7.1.2" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz" @@ -5578,22 +5542,6 @@ node-gyp@~3.6.2: tar "^2.0.0" which "1" -node-pre-gyp@^0.11.0: - version "0.11.0" - resolved "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz" - integrity sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.71: version "1.1.72" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" @@ -5627,14 +5575,6 @@ node-sass@^6.0.1: dependencies: abbrev "1" -nopt@^4.0.1, nopt@~4.0.1: - version "4.0.3" - resolved "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz" - integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg== - dependencies: - abbrev "1" - osenv "^0.1.4" - nopt@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz" @@ -5642,6 +5582,14 @@ nopt@^5.0.0: dependencies: abbrev "1" +nopt@~4.0.1: + version "4.0.3" + resolved "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz" + integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg== + dependencies: + abbrev "1" + osenv "^0.1.4" + normalize-package-data@^2.0.0, normalize-package-data@^2.4.0, "normalize-package-data@~1.0.1 || ^2.0.0", normalize-package-data@~2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz" @@ -5677,13 +5625,6 @@ normalize-url@^4.1.0: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz" integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ== -npm-bundled@^1.0.1: - version "1.1.1" - resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== - dependencies: - npm-normalize-package-bin "^1.0.1" - npm-cache-filename@~1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/npm-cache-filename/-/npm-cache-filename-1.0.2.tgz" @@ -5704,11 +5645,6 @@ npm-install-checks@~3.0.0: dependencies: semver "^2.3.0 || 3.x || 4 || 5" -npm-normalize-package-bin@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - "npm-package-arg@^3.0.0 || ^4.0.0 || ^5.0.0", "npm-package-arg@^4.0.0 || ^5.0.0", npm-package-arg@^5.1.2, npm-package-arg@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-5.1.2.tgz" @@ -5729,15 +5665,6 @@ npm-package-arg@^6.0.0: semver "^5.6.0" validate-npm-package-name "^3.0.0" -npm-packlist@^1.1.6: - version "1.4.8" - resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz" - integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-normalize-package-bin "^1.0.1" - npm-pick-manifest@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-1.0.4.tgz" @@ -5882,7 +5809,7 @@ npm@5.1.0: wrappy "~1.0.2" write-file-atomic "~2.1.0" -"npmlog@0 || 1 || 2 || 3 || 4", "npmlog@2 || ^3.1.0 || ^4.0.0", npmlog@4.1.2, npmlog@^4.0.0, npmlog@^4.0.2, npmlog@^4.1.2, npmlog@~4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", "npmlog@2 || ^3.1.0 || ^4.0.0", npmlog@4.1.2, npmlog@^4.0.0, npmlog@^4.1.2, npmlog@~4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -6948,7 +6875,7 @@ raw-loader@4.0.2: loader-utils "^2.0.0" schema-utils "^3.0.0" -rc@^1.0.1, rc@^1.1.6, rc@^1.2.1, rc@^1.2.7, rc@^1.2.8: +rc@^1.0.1, rc@^1.1.6, rc@^1.2.1, rc@^1.2.8: version "1.2.8" resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -7085,6 +7012,15 @@ read@1, read@~1.0.1, read@~1.0.7: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@~1.1.10, readable-stream@~1.1.9: version "1.1.14" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" @@ -7357,7 +7293,7 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@2, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1: +rimraf@2, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -7404,7 +7340,7 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -7421,7 +7357,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -7656,6 +7592,11 @@ slide@^1.1.3, slide@^1.1.5, slide@~1.1.3, slide@~1.1.6: resolved "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz" integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc= +slugify@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.5.3.tgz#36e009864f5476bfd5db681222643d92339c890d" + integrity sha512-/HkjRdwPY3yHJReXu38NiusZw2+LLE2SrhkWJtmlPDB1fqFSvioYj62NkPcrKiNCgRLeGcGK7QBvr1iQwybeXw== + smart-buffer@^1.0.13: version "1.1.15" resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-1.1.15.tgz" @@ -7985,6 +7926,13 @@ string.prototype.trimstart@^1.0.4: call-bind "^1.0.2" define-properties "^1.1.3" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" @@ -8184,19 +8132,6 @@ tar@^2.0.0, tar@~2.2.1: fstream "^1.0.12" inherits "2" -tar@^4: - version "4.4.13" - resolved "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz" - integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - tar@^6.0.2, tar@^6.0.5: version "6.1.0" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" @@ -8782,7 +8717,7 @@ utf8-byte-length@^1.0.1: resolved "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz" integrity sha1-9F8VDExm7uloGGUFq5P8u4rWv2E= -util-deprecate@^1.0.2, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= @@ -9218,7 +9153,7 @@ yallist@^2.1.2: resolved "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= -yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: +yallist@^3.0.2: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==