From 28c58d4ec07ca468fa56f808e1555f95727342af Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Mon, 23 Mar 2020 00:39:31 +0100 Subject: [PATCH] new ssh connection selector - fixes #1557 --- terminus-core/src/api/selector.ts | 5 +- .../components/selectorModal.component.pug | 10 +- .../src/components/selectorModal.component.ts | 34 ++++- terminus-core/src/theme.scss | 2 +- terminus-ssh/src/buttonProvider.ts | 7 +- .../src/components/sshModal.component.pug | 35 ----- .../src/components/sshModal.component.scss | 5 - .../src/components/sshModal.component.ts | 123 ----------------- terminus-ssh/src/index.ts | 3 - terminus-ssh/src/services/ssh.service.ts | 126 +++++++++++++++++- 10 files changed, 165 insertions(+), 185 deletions(-) delete mode 100644 terminus-ssh/src/components/sshModal.component.pug delete mode 100644 terminus-ssh/src/components/sshModal.component.scss delete mode 100644 terminus-ssh/src/components/sshModal.component.ts diff --git a/terminus-core/src/api/selector.ts b/terminus-core/src/api/selector.ts index 8aa078d7..3118dfea 100644 --- a/terminus-core/src/api/selector.ts +++ b/terminus-core/src/api/selector.ts @@ -1,5 +1,8 @@ export interface SelectorOption { name: string description?: string - result: T + result?: T + icon?: string + freeInputPattern?: string + callback?: (string?) => void } diff --git a/terminus-core/src/components/selectorModal.component.pug b/terminus-core/src/components/selectorModal.component.pug index e99e5c7f..94b47381 100644 --- a/terminus-core/src/components/selectorModal.component.pug +++ b/terminus-core/src/components/selectorModal.component.pug @@ -4,15 +4,15 @@ [(ngModel)]='filter', autofocus, [placeholder]='name', - (ngModelChange)='onFilterChange()', - (keyup.enter)='onFilterEnter()', - (keyup.escape)='close()' + (ngModelChange)='onFilterChange()' ) .list-group.mt-3(*ngIf='filteredOptions.length') a.list-group-item.list-group-item-action.d-flex.align-items-center( (click)='selectOption(option)', - *ngFor='let option of filteredOptions' + [class.active]='selectedIndex == i', + *ngFor='let option of filteredOptions; let i = index' ) - .mr-2 {{option.name}} + i(class='fa-fw fas mr-1 fa-{{option.icon}}') + .mr-2 {{getOptionText(option)}} .text-muted {{option.description}} diff --git a/terminus-core/src/components/selectorModal.component.ts b/terminus-core/src/components/selectorModal.component.ts index 1c19f2f7..2ddad7a6 100644 --- a/terminus-core/src/components/selectorModal.component.ts +++ b/terminus-core/src/components/selectorModal.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core' +import { Component, Input, HostListener } from '@angular/core' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { SelectorOption } from '../api/selector' @@ -12,6 +12,7 @@ export class SelectorModalComponent { @Input() filteredOptions: SelectorOption[] @Input() filter = '' @Input() name: string + @Input() selectedIndex = 0 constructor ( public modalInstance: NgbActiveModal, @@ -21,23 +22,44 @@ export class SelectorModalComponent { this.onFilterChange() } + @HostListener('keyup', ['$event']) onKeyUp (event: KeyboardEvent): void { + if (event.key === 'ArrowUp') { + this.selectedIndex-- + } + if (event.key === 'ArrowDown') { + this.selectedIndex++ + } + if (event.key === 'Enter') { + this.selectOption(this.filteredOptions[this.selectedIndex]) + } + if (event.key === 'Escape') { + this.close() + } + + this.selectedIndex = (this.selectedIndex + this.filteredOptions.length) % this.filteredOptions.length + } + onFilterChange (): void { const f = this.filter.trim().toLowerCase() if (!f) { - this.filteredOptions = this.options + this.filteredOptions = this.options.filter(x => !x.freeInputPattern) } else { // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - this.filteredOptions = this.options.filter(x => (x.name + (x.description || '')).toLowerCase().includes(f)) + this.filteredOptions = this.options.filter(x => x.freeInputPattern || (x.name + (x.description || '')).toLowerCase().includes(f)) } + this.selectedIndex = Math.max(0, this.selectedIndex) + this.selectedIndex = Math.min(this.filteredOptions.length - 1, this.selectedIndex) } - onFilterEnter (): void { - if (this.filteredOptions.length === 1) { - this.selectOption(this.filteredOptions[0]) + getOptionText (option: SelectorOption): string { + if (option.freeInputPattern) { + return option.freeInputPattern.replace('%s', this.filter) } + return option.name } selectOption (option: SelectorOption): void { + option.callback?.(this.filter) this.modalInstance.close(option.result) } diff --git a/terminus-core/src/theme.scss b/terminus-core/src/theme.scss index cba6465a..b19116f5 100644 --- a/terminus-core/src/theme.scss +++ b/terminus-core/src/theme.scss @@ -246,7 +246,7 @@ ngb-tabset .tab-content { } .list-group-item { - transition: 0.25s background; + transition: 0.0625s background; i + * { margin-left: 10px; diff --git a/terminus-ssh/src/buttonProvider.ts b/terminus-ssh/src/buttonProvider.ts index 2cdf5084..db460c22 100644 --- a/terminus-ssh/src/buttonProvider.ts +++ b/terminus-ssh/src/buttonProvider.ts @@ -1,15 +1,14 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Injectable } from '@angular/core' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { HotkeysService, ToolbarButtonProvider, ToolbarButton } from 'terminus-core' -import { SSHModalComponent } from './components/sshModal.component' +import { SSHService } from './services/ssh.service' /** @hidden */ @Injectable() export class ButtonProvider extends ToolbarButtonProvider { constructor ( - private ngbModal: NgbModal, hotkeys: HotkeysService, + private ssh: SSHService, ) { super() hotkeys.matchedHotkey.subscribe(async (hotkey: string) => { @@ -20,7 +19,7 @@ export class ButtonProvider extends ToolbarButtonProvider { } activate () { - this.ngbModal.open(SSHModalComponent) + this.ssh.showConnectionSelector() } provide (): ToolbarButton[] { diff --git a/terminus-ssh/src/components/sshModal.component.pug b/terminus-ssh/src/components/sshModal.component.pug deleted file mode 100644 index e5c2b2a5..00000000 --- a/terminus-ssh/src/components/sshModal.component.pug +++ /dev/null @@ -1,35 +0,0 @@ -.modal-body - input.form-control( - type='text', - [(ngModel)]='quickTarget', - autofocus, - placeholder='Quick connect: [user@]host[:port]', - (ngModelChange)='refresh()', - (keyup.enter)='quickConnect()' - ) - - .list-group.mt-3(*ngIf='recentConnections') - a.list-group-item.list-group-item-action.d-flex.align-items-center( - *ngFor='let connection of recentConnections', - (click)='connect(connection)' - ) - i.fas.fa-fw.fa-history - .mr-auto {{connection.name}} - button.btn.btn-outline-danger.btn-sm((click)='clearConnection(connection); $event.stopPropagation()') - i.fas.fa-trash - - .list-group.mt-3.connections-list(*ngIf='childGroups.length') - ng-container(*ngFor='let group of childGroups') - .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]') - .ml-2 {{group.name || "Ungrouped"}} - ng-container(*ngIf='!groupCollapsed[group.name]') - .list-group-item.list-group-item-action.pl-5.d-flex.align-items-center( - *ngFor='let connection of group.connections', - (click)='connect(connection)' - ) - .mr-2 {{connection.name}} - .text-muted {{connection.host}} diff --git a/terminus-ssh/src/components/sshModal.component.scss b/terminus-ssh/src/components/sshModal.component.scss deleted file mode 100644 index eae336fa..00000000 --- a/terminus-ssh/src/components/sshModal.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -.list-group.connections-list { - display: block; - max-height: 70vh; - overflow-y: auto; -} diff --git a/terminus-ssh/src/components/sshModal.component.ts b/terminus-ssh/src/components/sshModal.component.ts deleted file mode 100644 index f442e167..00000000 --- a/terminus-ssh/src/components/sshModal.component.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { Component, NgZone } from '@angular/core' -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' -import { ToastrService } from 'ngx-toastr' -import { ConfigService, AppService } from 'terminus-core' -import { SettingsTabComponent } from 'terminus-settings' -import { SSHConnection, SSHConnectionGroup } from '../api' -import { SSHTabComponent } from './sshTab.component' - -/** @hidden */ -@Component({ - template: require('./sshModal.component.pug'), - styles: [require('./sshModal.component.scss')], -}) -export class SSHModalComponent { - connections: SSHConnection[] - childFolders: SSHConnectionGroup[] - quickTarget: string - recentConnections: SSHConnection[] - childGroups: SSHConnectionGroup[] - groupCollapsed: {[id: string]: boolean} = {} - - constructor ( - public modalInstance: NgbActiveModal, - private config: ConfigService, - private app: AppService, - private toastr: ToastrService, - private zone: NgZone, - ) { } - - ngOnInit () { - this.connections = this.config.store.ssh.connections - this.recentConnections = this.config.store.ssh.recentConnections - this.refresh() - } - - quickConnect () { - let user = 'root' - let host = this.quickTarget - let port = 22 - if (host.includes('@')) { - [user, host] = host.split('@') - } - if (host.includes(':')) { - port = parseInt(host.split(':')[1]) - host = host.split(':')[0] - } - - const connection: SSHConnection = { - name: this.quickTarget, - group: null, - host, - user, - port, - } - this.recentConnections.unshift(connection) - if (this.recentConnections.length > 5) { - this.recentConnections.pop() - } - this.config.store.ssh.recentConnections = this.recentConnections - this.config.save() - this.connect(connection) - } - - clearConnection (connection) { - this.recentConnections = this.recentConnections.filter(function (el) { - return el !== connection - }) - this.config.store.ssh.recentConnections = this.recentConnections - this.config.save() - } - - async connect (connection: SSHConnection) { - this.close() - - try { - const tab = this.zone.run(() => this.app.openNewTab( - SSHTabComponent, - { connection } - ) as SSHTabComponent) - if (connection.color) { - (this.app.getParentTab(tab) || tab).color = connection.color - } - - setTimeout(() => { - this.app.activeTab?.emitFocused() - }) - } catch (error) { - this.toastr.error(`Could not connect: ${error}`) - } - } - - manageConnections () { - this.close() - this.app.openNewTab(SettingsTabComponent, { activeTab: 'ssh' }) - } - - close () { - this.modalInstance.close() - } - - refresh () { - this.childGroups = [] - - let connections = this.connections - if (this.quickTarget) { - connections = connections.filter((connection: SSHConnection) => (connection.name + connection.group!).toLowerCase().includes(this.quickTarget)) - } - - for (const connection of 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) - } - } -} diff --git a/terminus-ssh/src/index.ts b/terminus-ssh/src/index.ts index 792c64ab..6d2e0fe8 100644 --- a/terminus-ssh/src/index.ts +++ b/terminus-ssh/src/index.ts @@ -8,7 +8,6 @@ import { SettingsTabProvider } from 'terminus-settings' import TerminusTerminalModule from 'terminus-terminal' import { EditConnectionModalComponent } from './components/editConnectionModal.component' -import { SSHModalComponent } from './components/sshModal.component' import { SSHPortForwardingModalComponent } from './components/sshPortForwardingModal.component' import { PromptModalComponent } from './components/promptModal.component' import { SSHSettingsTabComponent } from './components/sshSettingsTab.component' @@ -40,7 +39,6 @@ import { SSHHotkeyProvider } from './hotkeys' entryComponents: [ EditConnectionModalComponent, PromptModalComponent, - SSHModalComponent, SSHPortForwardingModalComponent, SSHSettingsTabComponent, SSHTabComponent, @@ -48,7 +46,6 @@ import { SSHHotkeyProvider } from './hotkeys' declarations: [ EditConnectionModalComponent, PromptModalComponent, - SSHModalComponent, SSHPortForwardingModalComponent, SSHSettingsTabComponent, SSHTabComponent, diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index 0af9184e..e47eb33c 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/terminus-ssh/src/services/ssh.service.ts @@ -3,16 +3,18 @@ import { open as openTemp } from 'temp' import { Injectable, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Client } from 'ssh2' +import { SSH2Stream } from 'ssh2-streams' import * as fs from 'mz/fs' import { execFile } from 'mz/child_process' import * as path from 'path' import * as sshpk from 'sshpk' import { ToastrService } from 'ngx-toastr' -import { HostAppService, Platform, Logger, LogService, ElectronService } from 'terminus-core' +import { HostAppService, Platform, Logger, LogService, ElectronService, AppService, SelectorOption, ConfigService } from 'terminus-core' +import { SettingsTabComponent } from 'terminus-settings' import { SSHConnection, SSHSession } from '../api' import { PromptModalComponent } from '../components/promptModal.component' import { PasswordStorageService } from './passwordStorage.service' -import { SSH2Stream } from 'ssh2-streams' +import { SSHTabComponent } from '../components/sshTab.component' try { var windowsProcessTreeNative = require('windows-process-tree/build/Release/windows_process_tree.node') // eslint-disable-line @typescript-eslint/no-var-requires, no-var @@ -30,6 +32,8 @@ export class SSHService { private hostApp: HostAppService, private passwordStorage: PasswordStorageService, private toastr: ToastrService, + private app: AppService, + private config: ConfigService, ) { this.logger = log.create('ssh') } @@ -249,6 +253,124 @@ export class SSHService { }) }) } + + 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() + }, + }) + } + + let groups: { name: string, connections: SSHConnection[] }[] = [] + let 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.openNewTab(SettingsTabComponent, { activeTab: 'ssh' }), + }) + + options.push({ + name: 'Quick connect', + freeInputPattern: 'Connect to "%s"...', + icon: 'arrow-right', + callback: query => this.quickConnect(query), + }) + + + await this.app.showSelector('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.toastr.error(`Could not connect: ${error}`) + throw error + } + } + + quickConnect (query: string): Promise { + let user = 'root' + let host = query + let port = 22 + if (host.includes('@')) { + [user, host] = host.split('@') + } + if (host.includes(':')) { + port = parseInt(host.split(':')[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) + } } /* eslint-disable */