mirror of
https://github.com/Eugeny/tabby.git
synced 2024-12-22 10:01:40 +03:00
new profile system
This commit is contained in:
parent
38b7e44f64
commit
92b34fbc08
@ -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',
|
||||
|
@ -158,3 +158,7 @@ ngb-typeahead-window {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-group-item > button {
|
||||
margin: -7px 0;
|
||||
}
|
||||
|
@ -14,6 +14,7 @@
|
||||
"@typescript-eslint/parser": "^4.28.0",
|
||||
"apply-loader": "2.0.0",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"compare-versions": "^3.6.0",
|
||||
"core-js": "^3.14.0",
|
||||
"cross-env": "7.0.3",
|
||||
@ -45,6 +46,7 @@
|
||||
"raw-loader": "4.0.2",
|
||||
"sass-loader": "^12.1.0",
|
||||
"shelljs": "0.8.4",
|
||||
"slugify": "^1.5.3",
|
||||
"source-code-pro": "^2.38.0",
|
||||
"source-sans-pro": "3.6.0",
|
||||
"style-loader": "^3.0.0",
|
||||
@ -60,6 +62,7 @@
|
||||
"yaml-loader": "0.6.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"lzma-native": "^8.0.0",
|
||||
"*/node-abi": "^2.30.0",
|
||||
"**/graceful-fs": "^4.2.4"
|
||||
},
|
||||
|
12
scripts/generate-icon-metadata.js
Executable file
12
scripts/generate-icon-metadata.js
Executable file
@ -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))
|
@ -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",
|
||||
|
@ -2,7 +2,7 @@ export { BaseComponent, SubscriptionContainer } from '../components/base.compone
|
||||
export { BaseTabComponent, BaseTabProcess } from '../components/baseTab.component'
|
||||
export { TabHeaderComponent } from '../components/tabHeader.component'
|
||||
export { SplitTabComponent, SplitContainer } from '../components/splitTab.component'
|
||||
export { TabRecoveryProvider, RecoveredTab, RecoveryToken } from './tabRecovery'
|
||||
export { TabRecoveryProvider, RecoveryToken } from './tabRecovery'
|
||||
export { ToolbarButtonProvider, ToolbarButton } from './toolbarButtonProvider'
|
||||
export { ConfigProvider } from './configProvider'
|
||||
export { HotkeyProvider, HotkeyDescription } from './hotkeyProvider'
|
||||
@ -16,6 +16,8 @@ export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
|
||||
export { HostWindowService } from './hostWindow'
|
||||
export { HostAppService, Platform } from './hostApp'
|
||||
export { FileProvider } from './fileProvider'
|
||||
export { ProfileProvider, Profile, ProfileSettingsComponent } from './profileProvider'
|
||||
export { PromptModalComponent } from '../components/promptModal.component'
|
||||
|
||||
export { AppService } from '../services/app.service'
|
||||
export { ConfigService } from '../services/config.service'
|
||||
@ -25,8 +27,9 @@ export { HomeBaseService } from '../services/homeBase.service'
|
||||
export { HotkeysService } from '../services/hotkeys.service'
|
||||
export { NotificationsService } from '../services/notifications.service'
|
||||
export { ThemesService } from '../services/themes.service'
|
||||
export { ProfilesService } from '../services/profiles.service'
|
||||
export { SelectorService } from '../services/selector.service'
|
||||
export { TabsService } from '../services/tabs.service'
|
||||
export { TabsService, NewTabParameters, TabComponentType } from '../services/tabs.service'
|
||||
export { UpdaterService } from '../services/updater.service'
|
||||
export { VaultService, Vault, VaultSecret, VAULT_SECRET_TYPE_FILE } from '../services/vault.service'
|
||||
export { FileProvidersService } from '../services/fileProviders.service'
|
||||
|
43
tabby-core/src/api/profileProvider.ts
Normal file
43
tabby-core/src/api/profileProvider.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { NewTabParameters } from '../services/tabs.service'
|
||||
|
||||
export interface Profile {
|
||||
id?: string
|
||||
type: string
|
||||
name: string
|
||||
group?: string
|
||||
options?: Record<string, any>
|
||||
|
||||
icon?: string
|
||||
color?: string
|
||||
disableDynamicTitle?: boolean
|
||||
|
||||
isBuiltin?: boolean
|
||||
isTemplate?: boolean
|
||||
}
|
||||
|
||||
export interface ProfileSettingsComponent {
|
||||
profile: Profile
|
||||
save?: () => void
|
||||
}
|
||||
|
||||
export abstract class ProfileProvider {
|
||||
id: string
|
||||
name: string
|
||||
supportsQuickConnect = false
|
||||
settingsComponent: new (...args: any[]) => ProfileSettingsComponent
|
||||
|
||||
abstract getBuiltinProfiles (): Promise<Profile[]>
|
||||
|
||||
abstract getNewTabParameters (profile: Profile): Promise<NewTabParameters<BaseTabComponent>>
|
||||
|
||||
abstract getDescription (profile: Profile): string
|
||||
|
||||
quickConnect (query: string): Profile|null {
|
||||
return null
|
||||
}
|
||||
|
||||
deleteProfile (profile: Profile): void { }
|
||||
}
|
@ -1,17 +1,6 @@
|
||||
import deepClone from 'clone-deep'
|
||||
import { TabComponentType } from '../services/tabs.service'
|
||||
|
||||
export interface RecoveredTab {
|
||||
/**
|
||||
* Component type to be instantiated
|
||||
*/
|
||||
type: TabComponentType
|
||||
|
||||
/**
|
||||
* Component instance inputs
|
||||
*/
|
||||
options?: any
|
||||
}
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { NewTabParameters } from '../services/tabs.service'
|
||||
|
||||
export interface RecoveryToken {
|
||||
[_: string]: any
|
||||
@ -35,19 +24,20 @@ export interface RecoveryToken {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class TabRecoveryProvider {
|
||||
export abstract class TabRecoveryProvider <T extends BaseTabComponent> {
|
||||
/**
|
||||
* @param recoveryToken a recovery token found in the saved tabs list
|
||||
* @returns [[boolean]] whether this [[TabRecoveryProvider]] can recover a tab from this token
|
||||
*/
|
||||
|
||||
abstract applicableTo (recoveryToken: RecoveryToken): Promise<boolean>
|
||||
|
||||
/**
|
||||
* @param recoveryToken a recovery token found in the saved tabs list
|
||||
* @returns [[RecoveredTab]] descriptor containing tab type and component inputs
|
||||
* @returns [[NewTabParameters]] descriptor containing tab type and component inputs
|
||||
* or `null` if this token is from a different tab type or is not supported
|
||||
*/
|
||||
abstract recover (recoveryToken: RecoveryToken): Promise<RecoveredTab>
|
||||
abstract recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<T>>
|
||||
|
||||
/**
|
||||
* @param recoveryToken a recovery token found in the saved tabs list
|
||||
|
115
tabby-core/src/buttonProvider.ts
Normal file
115
tabby-core/src/buttonProvider.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
import { ToolbarButton, ToolbarButtonProvider } from './api/toolbarButtonProvider'
|
||||
import { SelectorService } from './services/selector.service'
|
||||
import { HostAppService, Platform } from './api/hostApp'
|
||||
import { Profile } from './api/profileProvider'
|
||||
import { ConfigService } from './services/config.service'
|
||||
import { SelectorOption } from './api/selector'
|
||||
import { ProfilesService } from './services/profiles.service'
|
||||
import { AppService } from './services/app.service'
|
||||
import { NotificationsService } from './services/notifications.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class ButtonProvider extends ToolbarButtonProvider {
|
||||
constructor (
|
||||
private selector: SelectorService,
|
||||
private app: AppService,
|
||||
private hostApp: HostAppService,
|
||||
private profilesServices: ProfilesService,
|
||||
private config: ConfigService,
|
||||
private notifications: NotificationsService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async activate () {
|
||||
const recentProfiles: Profile[] = this.config.store.recentProfiles
|
||||
|
||||
const getProfileOptions = (profile): SelectorOption<void> => ({
|
||||
icon: recentProfiles.includes(profile) ? 'fas fa-history' : profile.icon,
|
||||
name: profile.group ? `${profile.group} / ${profile.name}` : profile.name,
|
||||
description: this.profilesServices.providerForProfile(profile)?.getDescription(profile),
|
||||
callback: () => this.launchProfile(profile),
|
||||
})
|
||||
|
||||
let options = recentProfiles.map(getProfileOptions)
|
||||
if (recentProfiles.length) {
|
||||
options.push({
|
||||
name: 'Clear recent connections',
|
||||
icon: 'fas fa-eraser',
|
||||
callback: () => {
|
||||
this.config.store.recentProfiles = []
|
||||
this.config.save()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let profiles = await this.profilesServices.getProfiles()
|
||||
|
||||
if (!this.config.store.terminal.showBuiltinProfiles) {
|
||||
profiles = profiles.filter(x => !x.isBuiltin)
|
||||
}
|
||||
|
||||
profiles = profiles.filter(x => !x.isTemplate)
|
||||
|
||||
options = [...options, ...profiles.map(getProfileOptions)]
|
||||
|
||||
try {
|
||||
const { SettingsTabComponent } = window['nodeRequire']('tabby-settings')
|
||||
options.push({
|
||||
name: 'Manage profiles',
|
||||
icon: 'fas fa-window-restore',
|
||||
callback: () => this.app.openNewTabRaw({
|
||||
type: SettingsTabComponent,
|
||||
inputs: { activeTab: 'profiles' },
|
||||
}),
|
||||
})
|
||||
} catch { }
|
||||
|
||||
if (this.profilesServices.getProviders().some(x => x.supportsQuickConnect)) {
|
||||
options.push({
|
||||
name: 'Quick connect',
|
||||
freeInputPattern: 'Connect to "%s"...',
|
||||
icon: 'fas fa-arrow-right',
|
||||
callback: query => this.quickConnect(query),
|
||||
})
|
||||
}
|
||||
await this.selector.show('Select profile', options)
|
||||
}
|
||||
|
||||
quickConnect (query: string) {
|
||||
for (const provider of this.profilesServices.getProviders()) {
|
||||
const profile = provider.quickConnect(query)
|
||||
if (profile) {
|
||||
this.launchProfile(profile)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.notifications.error(`Could not parse "${query}"`)
|
||||
}
|
||||
|
||||
async launchProfile (profile: Profile) {
|
||||
await this.profilesServices.openNewTabForProfile(profile)
|
||||
|
||||
const recentProfiles = this.config.store.recentProfiles
|
||||
recentProfiles.unshift(profile)
|
||||
if (recentProfiles.length > 5) {
|
||||
recentProfiles.pop()
|
||||
}
|
||||
this.config.store.recentProfiles = recentProfiles
|
||||
this.config.save()
|
||||
}
|
||||
|
||||
provide (): ToolbarButton[] {
|
||||
return [{
|
||||
icon: this.hostApp.platform === Platform.Web
|
||||
? require('./icons/plus.svg')
|
||||
: require('./icons/profiles.svg'),
|
||||
title: 'New tab with profile',
|
||||
click: () => this.activate(),
|
||||
}]
|
||||
}
|
||||
}
|
@ -1,6 +1,41 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService } from './api/hostApp'
|
||||
import { CLIHandler, CLIEvent } from './api/cli'
|
||||
import { HostWindowService } from './api/hostWindow'
|
||||
import { ProfilesService } from './services/profiles.service'
|
||||
|
||||
@Injectable()
|
||||
export class ProfileCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = 0
|
||||
|
||||
constructor (
|
||||
private profiles: ProfilesService,
|
||||
private hostWindow: HostWindowService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
const op = event.argv._[0]
|
||||
|
||||
if (op === 'profile') {
|
||||
this.handleOpenProfile(event.argv.profileName)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async handleOpenProfile (profileName: string) {
|
||||
const profile = (await this.profiles.getProfiles()).find(x => x.name === profileName)
|
||||
if (!profile) {
|
||||
console.error('Requested profile', profileName, 'not found')
|
||||
return
|
||||
}
|
||||
this.profiles.openNewTabForProfile(profile)
|
||||
this.hostWindow.bringToFront()
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LastCLIHandler extends CLIHandler {
|
||||
|
@ -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(
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { Component, Injectable, ViewChild, ViewContainerRef, EmbeddedViewRef, AfterViewInit, OnDestroy } from '@angular/core'
|
||||
import { BaseTabComponent, BaseTabProcess } from './baseTab.component'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from '../api/tabRecovery'
|
||||
import { TabsService } from '../services/tabs.service'
|
||||
import { TabRecoveryProvider, RecoveryToken } from '../api/tabRecovery'
|
||||
import { TabsService, NewTabParameters } from '../services/tabs.service'
|
||||
import { HotkeysService } from '../services/hotkeys.service'
|
||||
import { TabRecoveryService } from '../services/tabRecovery.service'
|
||||
|
||||
@ -601,7 +601,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
|
||||
} else {
|
||||
const recovered = await this.tabRecovery.recoverTab(childState, duplicate)
|
||||
if (recovered) {
|
||||
const tab = this.tabsService.create(recovered.type, recovered.options)
|
||||
const tab = this.tabsService.create(recovered)
|
||||
children.push(tab)
|
||||
tab.parent = this
|
||||
this.attachTabView(tab)
|
||||
@ -619,15 +619,15 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class SplitTabRecoveryProvider extends TabRecoveryProvider {
|
||||
export class SplitTabRecoveryProvider extends TabRecoveryProvider<SplitTabComponent> {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:split-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<SplitTabComponent>> {
|
||||
return {
|
||||
type: SplitTabComponent,
|
||||
options: { _recoveredState: recoveryToken },
|
||||
inputs: { _recoveredState: recoveryToken },
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,8 +14,9 @@ appearance:
|
||||
opacity: 1.0
|
||||
vibrancy: true
|
||||
vibrancyType: 'blur'
|
||||
terminal:
|
||||
recoverTabs: true
|
||||
profiles: []
|
||||
recentProfiles: []
|
||||
recoverTabs: true
|
||||
enableAnalytics: true
|
||||
enableWelcomeTab: true
|
||||
electronFlags:
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ProfilesService } from './services/profiles.service'
|
||||
import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider'
|
||||
|
||||
/** @hidden */
|
||||
@ -171,7 +172,18 @@ export class AppHotkeyProvider extends HotkeyProvider {
|
||||
},
|
||||
]
|
||||
|
||||
constructor (
|
||||
private profilesService: ProfilesService,
|
||||
) { super() }
|
||||
|
||||
async provide (): Promise<HotkeyDescription[]> {
|
||||
return this.hotkeys
|
||||
const profiles = await this.profilesService.getProfiles()
|
||||
return [
|
||||
...this.hotkeys,
|
||||
...profiles.map(profile => ({
|
||||
id: `profile.${profile.id}`,
|
||||
name: `New tab: ${profile.name}`,
|
||||
})),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
1
tabby-core/src/icons.json
Normal file
1
tabby-core/src/icons.json
Normal file
File diff suppressed because one or more lines are too long
1
tabby-core/src/icons/plus.svg
Normal file
1
tabby-core/src/icons/plus.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-plus fa-w-12 fa-3x" data-icon="plus" data-prefix="fal" focusable="false" role="img" viewBox="0 0 384 512"><path fill="#fff" stroke="none" stroke-width="1" d="M376 232H216V72c0-4.42-3.58-8-8-8h-32c-4.42 0-8 3.58-8 8v160H8c-4.42 0-8 3.58-8 8v32c0 4.42 3.58 8 8 8h160v160c0 4.42 3.58 8 8 8h32c4.42 0 8-3.58 8-8V280h160c4.42 0 8-3.58 8-8v-32c0-4.42-3.58-8-8-8z"/></svg>
|
After Width: | Height: | Size: 449 B |
Before Width: | Height: | Size: 665 B After Width: | Height: | Size: 665 B |
@ -10,6 +10,7 @@ import { DndModule } from 'ng2-dnd'
|
||||
import { AppRootComponent } from './components/appRoot.component'
|
||||
import { CheckboxComponent } from './components/checkbox.component'
|
||||
import { TabBodyComponent } from './components/tabBody.component'
|
||||
import { PromptModalComponent } from './components/promptModal.component'
|
||||
import { SafeModeModalComponent } from './components/safeModeModal.component'
|
||||
import { StartPageComponent } from './components/startPage.component'
|
||||
import { TabHeaderComponent } from './components/tabHeader.component'
|
||||
@ -25,20 +26,23 @@ import { WelcomeTabComponent } from './components/welcomeTab.component'
|
||||
import { TransfersMenuComponent } from './components/transfersMenu.component'
|
||||
|
||||
import { AutofocusDirective } from './directives/autofocus.directive'
|
||||
import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive'
|
||||
import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
|
||||
import { DropZoneDirective } from './directives/dropZone.directive'
|
||||
|
||||
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider } from './api'
|
||||
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ToolbarButtonProvider, ProfilesService } from './api'
|
||||
|
||||
import { AppService } from './services/app.service'
|
||||
import { ConfigService } from './services/config.service'
|
||||
import { VaultFileProvider } from './services/vault.service'
|
||||
import { HotkeysService } from './services/hotkeys.service'
|
||||
|
||||
import { StandardTheme, StandardCompactTheme, PaperTheme } from './theme'
|
||||
import { CoreConfigProvider } from './config'
|
||||
import { AppHotkeyProvider } from './hotkeys'
|
||||
import { TaskCompletionContextMenu, CommonOptionsContextMenu, TabManagementContextMenu } from './tabContextMenu'
|
||||
import { LastCLIHandler } from './cli'
|
||||
import { LastCLIHandler, ProfileCLIHandler } from './cli'
|
||||
import { ButtonProvider } from './buttonProvider'
|
||||
|
||||
import 'perfect-scrollbar/css/perfect-scrollbar.css'
|
||||
import 'ng2-dnd/bundles/style.css'
|
||||
@ -53,9 +57,11 @@ const PROVIDERS = [
|
||||
{ provide: TabContextMenuItemProvider, useClass: TabManagementContextMenu, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: TaskCompletionContextMenu, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: SplitTabRecoveryProvider, multi: true },
|
||||
{ provide: CLIHandler, useClass: ProfileCLIHandler, multi: true },
|
||||
{ provide: CLIHandler, useClass: LastCLIHandler, multi: true },
|
||||
{ provide: PERFECT_SCROLLBAR_CONFIG, useValue: { suppressScrollX: true } },
|
||||
{ provide: FileProvider, useClass: VaultFileProvider, multi: true },
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
]
|
||||
|
||||
/** @hidden */
|
||||
@ -72,6 +78,7 @@ const PROVIDERS = [
|
||||
declarations: [
|
||||
AppRootComponent as any,
|
||||
CheckboxComponent,
|
||||
PromptModalComponent,
|
||||
StartPageComponent,
|
||||
TabBodyComponent,
|
||||
TabHeaderComponent,
|
||||
@ -82,6 +89,7 @@ const PROVIDERS = [
|
||||
SafeModeModalComponent,
|
||||
AutofocusDirective,
|
||||
FastHtmlBindDirective,
|
||||
AlwaysVisibleTypeaheadDirective,
|
||||
SelectorModalComponent,
|
||||
SplitTabComponent,
|
||||
SplitTabSpannerComponent,
|
||||
@ -91,6 +99,7 @@ const PROVIDERS = [
|
||||
DropZoneDirective,
|
||||
],
|
||||
entryComponents: [
|
||||
PromptModalComponent,
|
||||
RenameTabModalComponent,
|
||||
SafeModeModalComponent,
|
||||
SelectorModalComponent,
|
||||
@ -101,21 +110,40 @@ const PROVIDERS = [
|
||||
exports: [
|
||||
CheckboxComponent,
|
||||
ToggleComponent,
|
||||
PromptModalComponent,
|
||||
AutofocusDirective,
|
||||
DropZoneDirective,
|
||||
FastHtmlBindDirective,
|
||||
AlwaysVisibleTypeaheadDirective,
|
||||
],
|
||||
})
|
||||
export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
|
||||
constructor (app: AppService, config: ConfigService, platform: PlatformService) {
|
||||
constructor (
|
||||
app: AppService,
|
||||
config: ConfigService,
|
||||
platform: PlatformService,
|
||||
hotkeys: HotkeysService,
|
||||
profilesService: ProfilesService,
|
||||
) {
|
||||
app.ready$.subscribe(() => {
|
||||
if (config.store.enableWelcomeTab) {
|
||||
app.openNewTabRaw(WelcomeTabComponent)
|
||||
app.openNewTabRaw({ type: WelcomeTabComponent })
|
||||
}
|
||||
})
|
||||
|
||||
platform.setErrorHandler(err => {
|
||||
console.error('Unhandled exception:', err)
|
||||
})
|
||||
|
||||
hotkeys.matchedHotkey.subscribe(async (hotkey) => {
|
||||
if (hotkey.startsWith('profile.')) {
|
||||
const id = hotkey.split('.')[1]
|
||||
const profile = (await profilesService.getProfiles()).find(x => x.id === id)
|
||||
if (profile) {
|
||||
profilesService.openNewTabForProfile(profile)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static forRoot (): ModuleWithProviders<AppModule> {
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
import { Observable, Subject, AsyncSubject } from 'rxjs'
|
||||
import { takeUntil } from 'rxjs/operators'
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
@ -13,7 +12,7 @@ import { HostAppService } from '../api/hostApp'
|
||||
|
||||
import { ConfigService } from './config.service'
|
||||
import { TabRecoveryService } from './tabRecovery.service'
|
||||
import { TabsService, TabComponentType } from './tabs.service'
|
||||
import { TabsService, NewTabParameters } from './tabs.service'
|
||||
import { SelectorService } from './selector.service'
|
||||
|
||||
class CompletionObserver {
|
||||
@ -88,10 +87,10 @@ export class AppService {
|
||||
|
||||
config.ready$.toPromise().then(async () => {
|
||||
if (this.bootstrapData.isFirstWindow) {
|
||||
if (config.store.terminal.recoverTabs) {
|
||||
if (config.store.recoverTabs) {
|
||||
const tabs = await this.tabRecovery.recoverTabs()
|
||||
for (const tab of tabs) {
|
||||
this.openNewTabRaw(tab.type, tab.options)
|
||||
this.openNewTabRaw(tab)
|
||||
}
|
||||
}
|
||||
/** Continue to store the tabs even if the setting is currently off */
|
||||
@ -152,8 +151,8 @@ export class AppService {
|
||||
* Adds a new tab **without** wrapping it in a SplitTabComponent
|
||||
* @param inputs Properties to be assigned on the new tab component instance
|
||||
*/
|
||||
openNewTabRaw (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
|
||||
const tab = this.tabsService.create(type, inputs)
|
||||
openNewTabRaw <T extends BaseTabComponent> (params: NewTabParameters<T>): T {
|
||||
const tab = this.tabsService.create(params)
|
||||
this.addTabRaw(tab)
|
||||
return tab
|
||||
}
|
||||
@ -162,9 +161,9 @@ export class AppService {
|
||||
* Adds a new tab while wrapping it in a SplitTabComponent
|
||||
* @param inputs Properties to be assigned on the new tab component instance
|
||||
*/
|
||||
openNewTab (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
|
||||
const splitTab = this.tabsService.create(SplitTabComponent) as SplitTabComponent
|
||||
const tab = this.tabsService.create(type, inputs)
|
||||
openNewTab <T extends BaseTabComponent> (params: NewTabParameters<T>): T {
|
||||
const splitTab = this.tabsService.create({ type: SplitTabComponent })
|
||||
const tab = this.tabsService.create(params)
|
||||
splitTab.addTab(tab, null, 'r')
|
||||
this.addTabRaw(splitTab)
|
||||
return tab
|
||||
@ -175,7 +174,7 @@ export class AppService {
|
||||
if (token) {
|
||||
const recoveredTab = await this.tabRecovery.recoverTab(token)
|
||||
if (recoveredTab) {
|
||||
const tab = this.tabsService.create(recoveredTab.type, recoveredTab.options)
|
||||
const tab = this.tabsService.create(recoveredTab)
|
||||
if (this.activeTab) {
|
||||
this.addTabRaw(tab, this.tabs.indexOf(this.activeTab) + 1)
|
||||
} else {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Observable, Subject, AsyncSubject } from 'rxjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Observable, Subject, AsyncSubject } from 'rxjs'
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { ConfigProvider } from '../api/configProvider'
|
||||
import { PlatformService } from '../api/platform'
|
||||
@ -58,18 +59,27 @@ export class ConfigProxy {
|
||||
if (real[key] !== undefined) {
|
||||
return real[key]
|
||||
} else {
|
||||
if (isNonStructuralObjectMember(defaults[key])) {
|
||||
real[key] = { ...defaults[key] }
|
||||
delete real[key].__nonStructural
|
||||
return real[key]
|
||||
} else {
|
||||
return defaults[key]
|
||||
}
|
||||
return this.getDefault(key)
|
||||
}
|
||||
}
|
||||
|
||||
this.getDefault = (key: string) => { // eslint-disable-line @typescript-eslint/unbound-method
|
||||
if (isNonStructuralObjectMember(defaults[key])) {
|
||||
real[key] = { ...defaults[key] }
|
||||
delete real[key].__nonStructural
|
||||
return real[key]
|
||||
} else {
|
||||
return defaults[key]
|
||||
}
|
||||
}
|
||||
|
||||
this.setValue = (key: string, value: any) => { // eslint-disable-line @typescript-eslint/unbound-method
|
||||
real[key] = value
|
||||
if (value === this.getDefault(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete real[key]
|
||||
} else {
|
||||
real[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,6 +87,8 @@ export class ConfigProxy {
|
||||
getValue (_key: string): any { }
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
||||
setValue (_key: string, _value: any) { }
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
||||
getDefault (_key: string) { }
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@ -250,6 +262,67 @@ export class ConfigService {
|
||||
}
|
||||
config.version = 1
|
||||
}
|
||||
if (config.version < 2) {
|
||||
if (config.terminal?.recoverTabs !== undefined) {
|
||||
config.recoverTabs = config.terminal.recoverTabs
|
||||
delete config.terminal.recoverTabs
|
||||
}
|
||||
for (const profile of config.terminal?.profiles ?? []) {
|
||||
if (profile.sessionOptions) {
|
||||
profile.options = profile.sessionOptions
|
||||
delete profile.sessionOptions
|
||||
}
|
||||
profile.type = 'local'
|
||||
profile.id = `local:custom:${uuidv4()}`
|
||||
}
|
||||
if (config.terminal?.profiles) {
|
||||
config.profiles = config.terminal.profiles
|
||||
delete config.terminal.profiles
|
||||
delete config.terminal.environment
|
||||
config.terminal.profile = `local:${config.terminal.profile}`
|
||||
}
|
||||
config.version = 2
|
||||
}
|
||||
if (config.version < 3) {
|
||||
delete config.ssh.recentConnections
|
||||
for (const c of config.ssh?.connections ?? []) {
|
||||
const p = {
|
||||
id: `ssh:${uuidv4()}`,
|
||||
type: 'ssh',
|
||||
icon: 'fas fa-desktop',
|
||||
name: c.name,
|
||||
group: c.group ?? undefined,
|
||||
color: c.color,
|
||||
disableDynamicTitle: c.disableDynamicTitle,
|
||||
options: c,
|
||||
}
|
||||
config.profiles.push(p)
|
||||
}
|
||||
for (const p of config.profiles ?? []) {
|
||||
if (p.type === 'ssh') {
|
||||
if (p.options.jumpHost) {
|
||||
p.options.jumpHost = config.profiles.find(x => x.name === p.options.jumpHost)?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of config.serial?.connections ?? []) {
|
||||
const p = {
|
||||
id: `serial:${uuidv4()}`,
|
||||
type: 'serial',
|
||||
icon: 'fas fa-microchip',
|
||||
name: c.name,
|
||||
group: c.group ?? undefined,
|
||||
color: c.color,
|
||||
options: c,
|
||||
}
|
||||
config.profiles.push(p)
|
||||
}
|
||||
delete config.ssh?.connections
|
||||
delete config.serial?.connections
|
||||
delete window.localStorage.lastSerialConnection
|
||||
// config.version = 3
|
||||
// migrate jump hosts
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeDecryptConfig (store) {
|
||||
|
54
tabby-core/src/services/profiles.service.ts
Normal file
54
tabby-core/src/services/profiles.service.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { NewTabParameters } from './tabs.service'
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { Profile, ProfileProvider } from '../api/profileProvider'
|
||||
import { AppService } from './app.service'
|
||||
import { ConfigService } from './config.service'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfilesService {
|
||||
constructor (
|
||||
private app: AppService,
|
||||
private config: ConfigService,
|
||||
@Inject(ProfileProvider) private profileProviders: ProfileProvider[],
|
||||
) { }
|
||||
|
||||
async openNewTabForProfile (profile: Profile): Promise<BaseTabComponent|null> {
|
||||
const params = await this.newTabParametersForProfile(profile)
|
||||
if (params) {
|
||||
const tab = this.app.openNewTab(params)
|
||||
;(this.app.getParentTab(tab) ?? tab).color = profile.color ?? null
|
||||
if (profile.disableDynamicTitle) {
|
||||
tab['enableDynamicTitle'] = false
|
||||
tab.setTitle(profile.name)
|
||||
}
|
||||
return tab
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async newTabParametersForProfile (profile: Profile): Promise<NewTabParameters<BaseTabComponent>|null> {
|
||||
return this.providerForProfile(profile)?.getNewTabParameters(profile) ?? null
|
||||
}
|
||||
|
||||
getProviders (): ProfileProvider[] {
|
||||
return [...this.profileProviders]
|
||||
}
|
||||
|
||||
async getProfiles (): Promise<Profile[]> {
|
||||
const lists = await Promise.all(this.config.enabledServices(this.profileProviders).map(x => x.getBuiltinProfiles()))
|
||||
let list = lists.reduce((a, b) => a.concat(b), [])
|
||||
list = [
|
||||
...this.config.store.profiles ?? [],
|
||||
...list,
|
||||
]
|
||||
list.sort((a, b) => a.group?.localeCompare(b.group ?? '') ?? -1)
|
||||
list.sort((a, b) => a.name.localeCompare(b.name))
|
||||
list.sort((a, b) => (a.isBuiltin ? 1 : 0) - (b.isBuiltin ? 1 : 0))
|
||||
return list
|
||||
}
|
||||
|
||||
providerForProfile (profile: Profile): ProfileProvider|null {
|
||||
return this.profileProviders.find(x => x.id === profile.type) ?? null
|
||||
}
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from '../api/tabRecovery'
|
||||
import { TabRecoveryProvider, RecoveryToken } from '../api/tabRecovery'
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { Logger, LogService } from '../services/log.service'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { Logger, LogService } from './log.service'
|
||||
import { ConfigService } from './config.service'
|
||||
import { NewTabParameters } from './tabs.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@ -11,7 +12,7 @@ export class TabRecoveryService {
|
||||
enabled = false
|
||||
|
||||
private constructor (
|
||||
@Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[]|null,
|
||||
@Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider<BaseTabComponent>[]|null,
|
||||
private config: ConfigService,
|
||||
log: LogService
|
||||
) {
|
||||
@ -40,7 +41,7 @@ export class TabRecoveryService {
|
||||
return token
|
||||
}
|
||||
|
||||
async recoverTab (token: RecoveryToken, duplicate = false): Promise<RecoveredTab|null> {
|
||||
async recoverTab (token: RecoveryToken, duplicate = false): Promise<NewTabParameters<BaseTabComponent>|null> {
|
||||
for (const provider of this.config.enabledServices(this.tabRecoveryProviders ?? [])) {
|
||||
try {
|
||||
if (!await provider.applicableTo(token)) {
|
||||
@ -50,9 +51,9 @@ export class TabRecoveryService {
|
||||
token = provider.duplicate(token)
|
||||
}
|
||||
const tab = await provider.recover(token)
|
||||
tab.options = tab.options || {}
|
||||
tab.options.color = token.tabColor ?? null
|
||||
tab.options.title = token.tabTitle || ''
|
||||
tab.inputs = tab.inputs ?? {}
|
||||
tab.inputs.color = token.tabColor ?? null
|
||||
tab.inputs.title = token.tabTitle || ''
|
||||
return tab
|
||||
} catch (error) {
|
||||
this.logger.warn('Tab recovery crashed:', token, provider, error)
|
||||
@ -61,9 +62,9 @@ export class TabRecoveryService {
|
||||
return null
|
||||
}
|
||||
|
||||
async recoverTabs (): Promise<RecoveredTab[]> {
|
||||
async recoverTabs (): Promise<NewTabParameters<BaseTabComponent>[]> {
|
||||
if (window.localStorage.tabsRecovery) {
|
||||
const tabs: RecoveredTab[] = []
|
||||
const tabs: NewTabParameters<BaseTabComponent>[] = []
|
||||
for (const token of JSON.parse(window.localStorage.tabsRecovery)) {
|
||||
const tab = await this.recoverTab(token)
|
||||
if (tab) {
|
||||
|
@ -3,7 +3,22 @@ import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { TabRecoveryService } from './tabRecovery.service'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-type-alias
|
||||
export type TabComponentType = new (...args: any[]) => BaseTabComponent
|
||||
export interface TabComponentType<T extends BaseTabComponent> {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-function-type
|
||||
new (...args: any[]): T
|
||||
}
|
||||
|
||||
export interface NewTabParameters<T extends BaseTabComponent> {
|
||||
/**
|
||||
* Component type to be instantiated
|
||||
*/
|
||||
type: TabComponentType<T>
|
||||
|
||||
/**
|
||||
* Component instance inputs
|
||||
*/
|
||||
inputs?: Record<string, any>
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TabsService {
|
||||
@ -17,12 +32,12 @@ export class TabsService {
|
||||
/**
|
||||
* Instantiates a tab component and assigns given inputs
|
||||
*/
|
||||
create (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
|
||||
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(type)
|
||||
create <T extends BaseTabComponent> (params: NewTabParameters<T>): T {
|
||||
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(params.type)
|
||||
const componentRef = componentFactory.create(this.injector)
|
||||
const tab = componentRef.instance
|
||||
tab.hostView = componentRef.hostView
|
||||
Object.assign(tab, inputs ?? {})
|
||||
Object.assign(tab, params.inputs ?? {})
|
||||
return tab
|
||||
}
|
||||
|
||||
@ -36,7 +51,7 @@ export class TabsService {
|
||||
}
|
||||
const dup = await this.tabRecovery.recoverTab(token, true)
|
||||
if (dup) {
|
||||
return this.create(dup.type, dup.options)
|
||||
return this.create(dup)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
@ -247,12 +247,12 @@ export class VaultFileProvider extends FileProvider {
|
||||
const result = await this.selector.show<VaultSecret|null>('Select file', [
|
||||
{
|
||||
name: 'Add a new file',
|
||||
icon: 'plus',
|
||||
icon: 'fas fa-plus',
|
||||
result: null,
|
||||
},
|
||||
...files.map(f => ({
|
||||
name: f.key.description,
|
||||
icon: 'file',
|
||||
icon: 'fas fa-file',
|
||||
result: f,
|
||||
})),
|
||||
])
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
|
@ -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": {
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Profile } from 'tabby-core'
|
||||
|
||||
export interface Shell {
|
||||
id: string
|
||||
name?: string
|
||||
name: string
|
||||
command: string
|
||||
args?: string[]
|
||||
env: Record<string, string>
|
||||
@ -40,14 +42,8 @@ export interface SessionOptions {
|
||||
runAsAdministrator?: boolean
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
color?: string
|
||||
sessionOptions: SessionOptions
|
||||
shell?: string
|
||||
isBuiltin?: boolean
|
||||
icon?: string
|
||||
disableDynamicTitle?: boolean
|
||||
export interface LocalProfile extends Profile {
|
||||
options: SessionOptions
|
||||
}
|
||||
|
||||
export interface ChildProcess {
|
||||
|
@ -1,37 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToolbarButtonProvider, ToolbarButton, ConfigService, SelectorOption, SelectorService } from 'tabby-core'
|
||||
import { ElectronService } from 'tabby-electron'
|
||||
|
||||
import { ToolbarButtonProvider, ToolbarButton } from 'tabby-core'
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class ButtonProvider extends ToolbarButtonProvider {
|
||||
constructor (
|
||||
electron: ElectronService,
|
||||
private selector: SelectorService,
|
||||
private config: ConfigService,
|
||||
private terminal: TerminalService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async activate () {
|
||||
const options: SelectorOption<void>[] = []
|
||||
const profiles = await this.terminal.getProfiles({ skipDefault: !this.config.store.terminal.showDefaultProfiles })
|
||||
|
||||
for (const profile of profiles) {
|
||||
options.push({
|
||||
icon: profile.icon,
|
||||
name: profile.name,
|
||||
callback: () => this.terminal.openTab(profile),
|
||||
})
|
||||
}
|
||||
|
||||
await this.selector.show('Select profile', options)
|
||||
}
|
||||
|
||||
provide (): ToolbarButton[] {
|
||||
return [
|
||||
{
|
||||
@ -42,11 +22,6 @@ export class ButtonProvider extends ToolbarButtonProvider {
|
||||
this.terminal.openTab()
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: require('./icons/profiles.svg'),
|
||||
title: 'New terminal with profile',
|
||||
click: () => this.activate(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -1,36 +1,76 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { Observable, OperatorFunction } from 'rxjs'
|
||||
import { debounceTime, map, distinctUntilChanged } from 'rxjs/operators'
|
||||
import { Component, Input, ViewChild, ViewContainerRef, ComponentFactoryResolver, Injector } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { UACService } from '../services/uac.service'
|
||||
import { Profile } from '../api'
|
||||
import { LocalProfile } from '../api'
|
||||
import { ConfigService, Profile, ProfileProvider, ProfileSettingsComponent } from 'tabby-core'
|
||||
|
||||
const iconsData = require('../../../tabby-core/src/icons.json')
|
||||
const iconsClassList = Object.keys(iconsData).map(
|
||||
icon => iconsData[icon].map(
|
||||
style => `fa${style[0]} fa-${icon}`
|
||||
)
|
||||
).flat()
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./editProfileModal.component.pug'),
|
||||
})
|
||||
export class EditProfileModalComponent {
|
||||
profile: Profile
|
||||
@Input() profile: LocalProfile
|
||||
@Input() profileProvider: ProfileProvider
|
||||
@Input() settingsComponent: new () => ProfileSettingsComponent
|
||||
groupNames: string[]
|
||||
@ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef
|
||||
|
||||
private settingsComponentInstance: ProfileSettingsComponent
|
||||
|
||||
constructor (
|
||||
public uac: UACService,
|
||||
private injector: Injector,
|
||||
private componentFactoryResolver: ComponentFactoryResolver,
|
||||
config: ConfigService,
|
||||
private modalInstance: NgbActiveModal,
|
||||
) {
|
||||
this.groupNames = [...new Set(
|
||||
(config.store.profiles as Profile[])
|
||||
.map(x => x.group)
|
||||
.filter(x => !!x)
|
||||
)].sort() as string[]
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.profile.sessionOptions.env = this.profile.sessionOptions.env ?? {}
|
||||
this.profile.sessionOptions.args = this.profile.sessionOptions.args ?? []
|
||||
ngAfterViewInit () {
|
||||
setTimeout(() => {
|
||||
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.profileProvider.settingsComponent)
|
||||
const componentRef = componentFactory.create(this.injector)
|
||||
this.settingsComponentInstance = componentRef.instance
|
||||
this.settingsComponentInstance.profile = this.profile
|
||||
this.placeholder.insert(componentRef.hostView)
|
||||
})
|
||||
}
|
||||
|
||||
groupTypeahead = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase())))
|
||||
)
|
||||
|
||||
iconSearch: OperatorFunction<string, string[]> = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
map(term => iconsClassList.filter(v => v.toLowerCase().includes(term.toLowerCase())).slice(0, 10))
|
||||
)
|
||||
|
||||
save () {
|
||||
this.profile.group ||= undefined
|
||||
this.settingsComponentInstance.save?.()
|
||||
this.modalInstance.close(this.profile)
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
|
||||
trackByIndex (index) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,51 @@
|
||||
.form-group
|
||||
label Command
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='profile.options.command',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Arguments
|
||||
.input-group(
|
||||
*ngFor='let arg of profile.options.args; index as i; trackBy: trackByIndex',
|
||||
)
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='profile.options.args[i]',
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='profile.options.args.splice(i, 1)')
|
||||
i.fas.fa-trash
|
||||
|
||||
.mt-2
|
||||
button.btn.btn-secondary((click)='profile.options.args.push("")')
|
||||
i.fas.fa-plus.mr-2
|
||||
| Add
|
||||
|
||||
.form-line(*ngIf='uac.isAvailable')
|
||||
.header
|
||||
.title Run as administrator
|
||||
toggle(
|
||||
[(ngModel)]='profile.options.runAsAdministrator',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Working directory
|
||||
|
||||
.input-group
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Home directory',
|
||||
[(ngModel)]='profile.options.cwd'
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='pickWorkingDirectory()')
|
||||
i.fas.fa-folder-open
|
||||
|
||||
.form-group
|
||||
label Environment
|
||||
environment-editor(
|
||||
type='text',
|
||||
[(model)]='profile.options.env',
|
||||
)
|
47
tabby-local/src/components/localProfileSettings.component.ts
Normal file
47
tabby-local/src/components/localProfileSettings.component.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { UACService } from '../services/uac.service'
|
||||
import { LocalProfile } from '../api'
|
||||
import { ElectronHostWindow, ElectronService } from 'tabby-electron'
|
||||
import { ProfileSettingsComponent } from '../../../tabby-core/src/api/profileProvider'
|
||||
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./localProfileSettings.component.pug'),
|
||||
})
|
||||
export class LocalProfileSettingsComponent implements ProfileSettingsComponent {
|
||||
profile: LocalProfile
|
||||
|
||||
constructor (
|
||||
public uac: UACService,
|
||||
private hostWindow: ElectronHostWindow,
|
||||
private electron: ElectronService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.profile.options.env = this.profile.options.env ?? {}
|
||||
this.profile.options.args = this.profile.options.args ?? []
|
||||
}
|
||||
|
||||
async pickWorkingDirectory (): Promise<void> {
|
||||
// const profile = await this.terminal.getProfileByID(this.config.store.terminal.profile)
|
||||
// const shell = this.shells.find(x => x.id === profile?.shell)
|
||||
// if (!shell) {
|
||||
// return
|
||||
// }
|
||||
const paths = (await this.electron.dialog.showOpenDialog(
|
||||
this.hostWindow.getWindow(),
|
||||
{
|
||||
// TODO
|
||||
// defaultPath: shell.fsBase,
|
||||
properties: ['openDirectory', 'showHiddenFiles'],
|
||||
}
|
||||
)).filePaths
|
||||
this.profile.options.cwd = paths[0]
|
||||
}
|
||||
|
||||
trackByIndex (index) {
|
||||
return index
|
||||
}
|
||||
}
|
94
tabby-local/src/components/profilesSettingsTab.component.pug
Normal file
94
tabby-local/src/components/profilesSettingsTab.component.pug
Normal file
@ -0,0 +1,94 @@
|
||||
h3.mb-3 Profiles
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Default profile for new tabs
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='config.store.terminal.profile',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
option(
|
||||
*ngFor='let profile of profiles',
|
||||
[ngValue]='profile.id'
|
||||
) {{profile.name}}
|
||||
option(
|
||||
*ngFor='let profile of builtinProfiles',
|
||||
[ngValue]='profile.id'
|
||||
) {{profile.name}}
|
||||
|
||||
.form-line(*ngIf='config.store.profiles.length > 0')
|
||||
.header
|
||||
.title Show built-in profiles in selector
|
||||
.description If disabled, only custom profiles will show up in the profile selector
|
||||
|
||||
toggle(
|
||||
[(ngModel)]='config.store.terminal.showBuiltinProfiles',
|
||||
(ngModelChange)='config.save()'
|
||||
)
|
||||
|
||||
.d-flex.mb-3.mt-4
|
||||
.input-group
|
||||
.input-group-prepend
|
||||
.input-group-text
|
||||
i.fas.fa-fw.fa-search
|
||||
input.form-control(type='search', placeholder='Filter', [(ngModel)]='filter')
|
||||
|
||||
button.btn.btn-primary.flex-shrink-0.ml-3((click)='newProfile()')
|
||||
i.fas.fa-fw.fa-plus
|
||||
| New profile
|
||||
|
||||
.list-group.list-group-light.mt-3.mb-3
|
||||
ng-container(*ngFor='let group of profileGroups')
|
||||
ng-container(*ngIf='isGroupVisible(group)')
|
||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
(click)='group.collapsed = !group.collapsed'
|
||||
)
|
||||
.fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed')
|
||||
.fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed')
|
||||
span.ml-3.mr-auto {{group.name || "Ungrouped"}}
|
||||
button.btn.btn-sm.btn-link.hover-reveal.ml-2(
|
||||
*ngIf='group.editable && group.name',
|
||||
(click)='$event.stopPropagation(); editGroup(group)'
|
||||
)
|
||||
i.fas.fa-pencil-alt
|
||||
button.btn.btn-sm.btn-link.hover-reveal.ml-2(
|
||||
*ngIf='group.editable && group.name',
|
||||
(click)='$event.stopPropagation(); deleteGroup(group)'
|
||||
)
|
||||
i.fas.fa-trash
|
||||
ng-container(*ngIf='!group.collapsed')
|
||||
ng-container(*ngFor='let profile of group.profiles')
|
||||
.list-group-item.pl-5.d-flex.align-items-center(
|
||||
*ngIf='isProfileVisible(profile)',
|
||||
[class.list-group-item-action]='!profile.isBuiltin',
|
||||
(click)='profile.isBuiltin ? null : editProfile(profile)'
|
||||
)
|
||||
i.icon(
|
||||
class='fa-fw {{profile.icon}}',
|
||||
[style.color]='profile.color',
|
||||
*ngIf='!iconIsSVG(profile.icon)'
|
||||
)
|
||||
.icon(
|
||||
[fastHtmlBind]='profile.icon',
|
||||
*ngIf='iconIsSVG(profile.icon)'
|
||||
)
|
||||
|
||||
div {{profile.name}}
|
||||
.text-muted.ml-2 {{getDescription(profile)}}
|
||||
|
||||
.mr-auto
|
||||
|
||||
button.btn.btn-link.hover-reveal.ml-1((click)='$event.stopPropagation(); launchProfile(profile)')
|
||||
i.fas.fa-play
|
||||
|
||||
button.btn.btn-link.hover-reveal.ml-1((click)='$event.stopPropagation(); newProfile(profile)')
|
||||
i.fas.fa-copy
|
||||
|
||||
button.btn.btn-link.text-danger.hover-reveal.ml-1(
|
||||
*ngIf='!profile.isBuiltin',
|
||||
(click)='$event.stopPropagation(); deleteProfile(profile)'
|
||||
)
|
||||
i.fas.fa-trash
|
||||
|
||||
.ml-1(class='badge badge-{{getTypeColorClass(profile)}}') {{getTypeLabel(profile)}}
|
201
tabby-local/src/components/profilesSettingsTab.component.ts
Normal file
201
tabby-local/src/components/profilesSettingsTab.component.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import slugify from 'slugify'
|
||||
import deepClone from 'clone-deep'
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent } from 'tabby-core'
|
||||
import { EditProfileModalComponent } from './editProfileModal.component'
|
||||
|
||||
interface ProfileGroup {
|
||||
name?: string
|
||||
profiles: Profile[]
|
||||
editable: boolean
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./profilesSettingsTab.component.pug'),
|
||||
})
|
||||
export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||
profiles: Profile[] = []
|
||||
builtinProfiles: Profile[] = []
|
||||
templateProfiles: Profile[] = []
|
||||
profileGroups: ProfileGroup[]
|
||||
filter = ''
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
private profilesService: ProfilesService,
|
||||
private selector: SelectorService,
|
||||
private ngbModal: NgbModal,
|
||||
private platform: PlatformService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async ngOnInit (): Promise<void> {
|
||||
this.refresh()
|
||||
this.builtinProfiles = (await this.profilesService.getProfiles()).filter(x => x.isBuiltin)
|
||||
this.templateProfiles = this.builtinProfiles.filter(x => x.isTemplate)
|
||||
this.builtinProfiles = this.builtinProfiles.filter(x => !x.isTemplate)
|
||||
this.refresh()
|
||||
this.subscribeUntilDestroyed(this.config.changed$, () => this.refresh())
|
||||
}
|
||||
|
||||
launchProfile (profile: Profile): void {
|
||||
this.profilesService.openNewTabForProfile(profile)
|
||||
}
|
||||
|
||||
async newProfile (base?: Profile): Promise<void> {
|
||||
if (!base) {
|
||||
const profiles = [...this.templateProfiles, ...this.builtinProfiles, ...this.profiles]
|
||||
base = await this.selector.show(
|
||||
'Select a base profile to use as a template',
|
||||
profiles.map(p => ({
|
||||
icon: p.icon,
|
||||
description: this.profilesService.providerForProfile(p)?.getDescription(p),
|
||||
name: p.group ? `${p.group} / ${p.name}` : p.name,
|
||||
result: p,
|
||||
})),
|
||||
)
|
||||
}
|
||||
const profile = deepClone(base)
|
||||
profile.id = null
|
||||
profile.name = ''
|
||||
profile.isBuiltin = false
|
||||
profile.isTemplate = false
|
||||
await this.editProfile(profile)
|
||||
profile.id = `${profile.type}:custom:${slugify(profile.name)}:${uuidv4()}`
|
||||
this.config.store.profiles = [profile, ...this.config.store.profiles]
|
||||
await this.config.save()
|
||||
}
|
||||
|
||||
async editProfile (profile: Profile): Promise<void> {
|
||||
const modal = this.ngbModal.open(
|
||||
EditProfileModalComponent,
|
||||
{ size: 'lg' },
|
||||
)
|
||||
modal.componentInstance.profile = Object.assign({}, profile)
|
||||
modal.componentInstance.profileProvider = this.profilesService.providerForProfile(profile)
|
||||
const result = await modal.result
|
||||
Object.assign(profile, result)
|
||||
await this.config.save()
|
||||
}
|
||||
|
||||
async deleteProfile (profile: Profile): Promise<void> {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete "${profile.name}"?`,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 0,
|
||||
}
|
||||
)).response === 1) {
|
||||
this.profilesService.providerForProfile(profile)?.deleteProfile(profile)
|
||||
this.config.store.profiles = this.config.store.profiles.filter(x => x !== profile)
|
||||
await this.config.save()
|
||||
}
|
||||
}
|
||||
|
||||
refresh (): void {
|
||||
this.profiles = this.config.store.profiles
|
||||
this.profileGroups = []
|
||||
|
||||
for (const profile of this.profiles) {
|
||||
let group = this.profileGroups.find(x => x.name === profile.group)
|
||||
if (!group) {
|
||||
group = {
|
||||
name: profile.group,
|
||||
profiles: [],
|
||||
editable: true,
|
||||
collapsed: false,
|
||||
}
|
||||
this.profileGroups.push(group)
|
||||
}
|
||||
group.profiles.push(profile)
|
||||
}
|
||||
|
||||
this.profileGroups.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? -1)
|
||||
|
||||
this.profileGroups.push({
|
||||
name: 'Built-in',
|
||||
profiles: this.builtinProfiles,
|
||||
editable: false,
|
||||
collapsed: false,
|
||||
})
|
||||
}
|
||||
|
||||
async editGroup (group: ProfileGroup): Promise<void> {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = 'New name'
|
||||
modal.componentInstance.value = group.name
|
||||
const result = await modal.result
|
||||
if (result) {
|
||||
for (const profile of this.profiles.filter(x => x.group === group.name)) {
|
||||
profile.group = result.value
|
||||
}
|
||||
this.config.store.profiles = this.profiles
|
||||
await this.config.save()
|
||||
}
|
||||
}
|
||||
|
||||
async deleteGroup (group: ProfileGroup): Promise<void> {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete "${group.name}"?`,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 0,
|
||||
}
|
||||
)).response === 1) {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete the group's profiles?`,
|
||||
buttons: ['Move to "Ungrouped"', 'Delete'],
|
||||
defaultId: 0,
|
||||
}
|
||||
)).response === 0) {
|
||||
for (const profile of this.profiles.filter(x => x.group === group.name)) {
|
||||
delete profile.group
|
||||
}
|
||||
} else {
|
||||
this.config.store.profiles = this.config.store.profiles.filter(x => x.group !== group.name)
|
||||
}
|
||||
await this.config.save()
|
||||
}
|
||||
}
|
||||
|
||||
isGroupVisible (group: ProfileGroup): boolean {
|
||||
return !this.filter || group.profiles.some(x => this.isProfileVisible(x))
|
||||
}
|
||||
|
||||
isProfileVisible (profile: Profile): boolean {
|
||||
return !this.filter || profile.name.toLowerCase().includes(this.filter.toLowerCase())
|
||||
}
|
||||
|
||||
iconIsSVG (icon?: string): boolean {
|
||||
return icon?.startsWith('<') ?? false
|
||||
}
|
||||
|
||||
getDescription (profile: Profile): string|null {
|
||||
return this.profilesService.providerForProfile(profile)?.getDescription(profile) ?? null
|
||||
}
|
||||
|
||||
getTypeLabel (profile: Profile): string {
|
||||
const name = this.profilesService.providerForProfile(profile)?.name
|
||||
if (name === 'Local') {
|
||||
return ''
|
||||
}
|
||||
return name ?? 'Unknown'
|
||||
}
|
||||
|
||||
getTypeColorClass (profile: Profile): string {
|
||||
return {
|
||||
ssh: 'secondary',
|
||||
serial: 'success',
|
||||
}[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning'
|
||||
}
|
||||
}
|
@ -1,20 +1,5 @@
|
||||
h3.mb-3 Shell
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Profile
|
||||
.description Default profile for new tabs
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='config.store.terminal.profile',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
option(
|
||||
*ngFor='let profile of profiles',
|
||||
[ngValue]='terminal.getProfileID(profile)'
|
||||
) {{profile.name}}
|
||||
|
||||
|
||||
.form-line(*ngIf='isConPTYAvailable')
|
||||
.header
|
||||
.title Use ConPTY
|
||||
@ -30,75 +15,3 @@ h3.mb-3 Shell
|
||||
|
||||
.alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.profile.startsWith("WSL") && (!config.store.terminal.useConPTY)')
|
||||
.mr-auto WSL terminal only supports TrueColor with ConPTY
|
||||
|
||||
.form-line(*ngIf='config.store.terminal.profile == "custom-shell"')
|
||||
.header
|
||||
.title Custom shell
|
||||
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='config.store.terminal.customShell',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Working directory
|
||||
.input-group
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Home directory',
|
||||
[(ngModel)]='config.store.terminal.workingDirectory',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='pickWorkingDirectory()')
|
||||
i.fas.fa-folder-open
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Directory for new tabs
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='config.store.terminal.alwaysUseWorkingDirectory',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
option([ngValue]='false') Same as active tab's directory
|
||||
option([ngValue]='true') The working directory from above
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Environment
|
||||
.description Inject additional environment variables
|
||||
|
||||
environment-editor([(model)]='this.config.store.terminal.environment')
|
||||
|
||||
.form-line(*ngIf='config.store.terminal.profiles.length > 0')
|
||||
.header
|
||||
.title Show default profiles in the selector
|
||||
.description If disabled, only custom profiles will show up in the profile selector
|
||||
|
||||
toggle(
|
||||
[(ngModel)]='config.store.terminal.showDefaultProfiles',
|
||||
(ngModelChange)='config.save()'
|
||||
)
|
||||
|
||||
h3.mt-3 Saved Profiles
|
||||
|
||||
.list-group.list-group-flush.mt-3.mb-3
|
||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
*ngFor='let profile of config.store.terminal.profiles',
|
||||
(click)='editProfile(profile)',
|
||||
)
|
||||
.mr-auto
|
||||
div {{profile.name}}
|
||||
.text-muted {{profile.sessionOptions.command}}
|
||||
button.btn.btn-outline-danger.ml-1((click)='$event.stopPropagation(); deleteProfile(profile)')
|
||||
i.fas.fa-trash
|
||||
|
||||
.pb-4(ngbDropdown, placement='top-left')
|
||||
button.btn.btn-primary(ngbDropdownToggle)
|
||||
i.fas.fa-fw.fa-plus
|
||||
| New profile
|
||||
div(ngbDropdownMenu)
|
||||
button.dropdown-item(*ngFor='let shell of shells', (click)='newProfile(shell)') {{shell.name}}
|
||||
|
@ -1,93 +1,18 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { ConfigService, HostAppService, Platform, WIN_BUILD_CONPTY_SUPPORTED, WIN_BUILD_CONPTY_STABLE, isWindowsBuild } from 'tabby-core'
|
||||
import { ElectronService, ElectronHostWindow } from 'tabby-electron'
|
||||
import { EditProfileModalComponent } from './editProfileModal.component'
|
||||
import { Shell, Profile } from '../api'
|
||||
import { TerminalService } from '../services/terminal.service'
|
||||
import { WIN_BUILD_CONPTY_SUPPORTED, WIN_BUILD_CONPTY_STABLE, isWindowsBuild, ConfigService } from 'tabby-core'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./shellSettingsTab.component.pug'),
|
||||
})
|
||||
export class ShellSettingsTabComponent {
|
||||
shells: Shell[] = []
|
||||
profiles: Profile[] = []
|
||||
Platform = Platform
|
||||
isConPTYAvailable: boolean
|
||||
isConPTYStable: boolean
|
||||
private configSubscription: Subscription
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
public hostWindow: ElectronHostWindow,
|
||||
public terminal: TerminalService,
|
||||
private electron: ElectronService,
|
||||
private ngbModal: NgbModal,
|
||||
) {
|
||||
config.store.terminal.environment = config.store.terminal.environment || {}
|
||||
this.configSubscription = this.config.changed$.subscribe(() => {
|
||||
this.reload()
|
||||
})
|
||||
this.reload()
|
||||
|
||||
this.isConPTYAvailable = isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED)
|
||||
this.isConPTYStable = isWindowsBuild(WIN_BUILD_CONPTY_STABLE)
|
||||
}
|
||||
|
||||
async ngOnInit (): Promise<void> {
|
||||
this.shells = (await this.terminal.shells$.toPromise())!
|
||||
}
|
||||
|
||||
ngOnDestroy (): void {
|
||||
this.configSubscription.unsubscribe()
|
||||
}
|
||||
|
||||
async reload (): Promise<void> {
|
||||
this.profiles = await this.terminal.getProfiles({ includeHidden: true })
|
||||
}
|
||||
|
||||
async pickWorkingDirectory (): Promise<void> {
|
||||
const profile = await this.terminal.getProfileByID(this.config.store.terminal.profile)
|
||||
const shell = this.shells.find(x => x.id === profile?.shell)
|
||||
if (!shell) {
|
||||
return
|
||||
}
|
||||
const paths = (await this.electron.dialog.showOpenDialog(
|
||||
this.hostWindow.getWindow(),
|
||||
{
|
||||
defaultPath: shell.fsBase,
|
||||
properties: ['openDirectory', 'showHiddenFiles'],
|
||||
}
|
||||
)).filePaths
|
||||
this.config.store.terminal.workingDirectory = paths[0]
|
||||
}
|
||||
|
||||
newProfile (shell: Shell): void {
|
||||
const profile: Profile = {
|
||||
name: shell.name ?? '',
|
||||
shell: shell.id,
|
||||
sessionOptions: this.terminal.optionsFromShell(shell),
|
||||
}
|
||||
this.config.store.terminal.profiles = [profile, ...this.config.store.terminal.profiles]
|
||||
this.config.save()
|
||||
this.reload()
|
||||
}
|
||||
|
||||
editProfile (profile: Profile): void {
|
||||
const modal = this.ngbModal.open(EditProfileModalComponent)
|
||||
modal.componentInstance.profile = Object.assign({}, profile)
|
||||
modal.result.then(result => {
|
||||
Object.assign(profile, result)
|
||||
this.config.save()
|
||||
})
|
||||
}
|
||||
|
||||
deleteProfile (profile: Profile): void {
|
||||
this.config.store.terminal.profiles = this.config.store.terminal.profiles.filter(x => x !== profile)
|
||||
this.config.save()
|
||||
this.reload()
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -14,11 +14,8 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
},
|
||||
terminal: {
|
||||
autoOpen: false,
|
||||
customShell: '',
|
||||
workingDirectory: '',
|
||||
alwaysUseWorkingDirectory: false,
|
||||
useConPTY: true,
|
||||
showDefaultProfiles: true,
|
||||
showBuiltinProfiles: true,
|
||||
environment: {},
|
||||
profiles: [],
|
||||
},
|
||||
@ -28,7 +25,7 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
[Platform.macOS]: {
|
||||
terminal: {
|
||||
shell: 'default',
|
||||
profile: 'user-default',
|
||||
profile: 'local:user-default',
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
@ -39,7 +36,7 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
[Platform.Windows]: {
|
||||
terminal: {
|
||||
shell: 'clink',
|
||||
profile: 'cmd-clink',
|
||||
profile: 'local:cmd-clink',
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
@ -50,7 +47,7 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
[Platform.Linux]: {
|
||||
terminal: {
|
||||
shell: 'default',
|
||||
profile: 'user-default',
|
||||
profile: 'local:user-default',
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
@ -12,18 +11,7 @@ export class LocalTerminalHotkeyProvider extends HotkeyProvider {
|
||||
},
|
||||
]
|
||||
|
||||
constructor (
|
||||
private terminal: TerminalService,
|
||||
) { super() }
|
||||
|
||||
async provide (): Promise<HotkeyDescription[]> {
|
||||
const profiles = await this.terminal.getProfiles()
|
||||
return [
|
||||
...this.hotkeys,
|
||||
...profiles.map(profile => ({
|
||||
id: `profile.${this.terminal.getProfileID(profile)}`,
|
||||
name: `New tab: ${profile.name}`,
|
||||
})),
|
||||
]
|
||||
return this.hotkeys
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastrModule } from 'ngx-toastr'
|
||||
|
||||
import TabbyCorePlugin, { HostAppService, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler, ConfigService } from 'tabby-core'
|
||||
import TabbyCorePlugin, { HostAppService, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler, ConfigService, ProfileProvider } from 'tabby-core'
|
||||
import TabbyTerminalModule from 'tabby-terminal'
|
||||
import TabbyElectronPlugin from 'tabby-electron'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
@ -13,6 +13,8 @@ import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
import { ShellSettingsTabComponent } from './components/shellSettingsTab.component'
|
||||
import { EditProfileModalComponent } from './components/editProfileModal.component'
|
||||
import { EnvironmentEditorComponent } from './components/environmentEditor.component'
|
||||
import { ProfilesSettingsTabComponent } from './components/profilesSettingsTab.component'
|
||||
import { LocalProfileSettingsComponent } from './components/localProfileSettings.component'
|
||||
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
import { DockMenuService } from './services/dockMenu.service'
|
||||
@ -20,13 +22,12 @@ import { DockMenuService } from './services/dockMenu.service'
|
||||
import { ButtonProvider } from './buttonProvider'
|
||||
import { RecoveryProvider } from './recoveryProvider'
|
||||
import { ShellProvider } from './api'
|
||||
import { ShellSettingsTabProvider } from './settings'
|
||||
import { ProfilesSettingsTabProvider, ShellSettingsTabProvider } from './settings'
|
||||
import { TerminalConfigProvider } from './config'
|
||||
import { LocalTerminalHotkeyProvider } from './hotkeys'
|
||||
import { NewTabContextMenu, SaveAsProfileContextMenu } from './tabContextMenu'
|
||||
|
||||
import { CmderShellProvider } from './shells/cmder'
|
||||
import { CustomShellProvider } from './shells/custom'
|
||||
import { Cygwin32ShellProvider } from './shells/cygwin32'
|
||||
import { Cygwin64ShellProvider } from './shells/cygwin64'
|
||||
import { GitBashShellProvider } from './shells/gitBash'
|
||||
@ -39,6 +40,7 @@ import { WindowsStockShellsProvider } from './shells/windowsStock'
|
||||
import { WSLShellProvider } from './shells/wsl'
|
||||
|
||||
import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from './cli'
|
||||
import { LocalProfilesService } from './profiles'
|
||||
|
||||
/** @hidden */
|
||||
@NgModule({
|
||||
@ -53,6 +55,7 @@ import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from '.
|
||||
],
|
||||
providers: [
|
||||
{ provide: SettingsTabProvider, useClass: ShellSettingsTabProvider, multi: true },
|
||||
{ provide: SettingsTabProvider, useClass: ProfilesSettingsTabProvider, multi: true },
|
||||
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
|
||||
@ -65,13 +68,14 @@ import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from '.
|
||||
{ provide: ShellProvider, useClass: WindowsStockShellsProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: PowerShellCoreShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: CmderShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: CustomShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: Cygwin32ShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: Cygwin64ShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: GitBashShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: POSIXShellsProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: WSLShellProvider, multi: true },
|
||||
|
||||
{ provide: ProfileProvider, useClass: LocalProfilesService, multi: true },
|
||||
|
||||
{ provide: TabContextMenuItemProvider, useClass: NewTabContextMenu, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: SaveAsProfileContextMenu, multi: true },
|
||||
|
||||
@ -86,14 +90,18 @@ import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from '.
|
||||
],
|
||||
entryComponents: [
|
||||
TerminalTabComponent,
|
||||
ProfilesSettingsTabComponent,
|
||||
ShellSettingsTabComponent,
|
||||
EditProfileModalComponent,
|
||||
LocalProfileSettingsComponent,
|
||||
] as any[],
|
||||
declarations: [
|
||||
TerminalTabComponent,
|
||||
ProfilesSettingsTabComponent,
|
||||
ShellSettingsTabComponent,
|
||||
EditProfileModalComponent,
|
||||
EnvironmentEditorComponent,
|
||||
LocalProfileSettingsComponent,
|
||||
] as any[],
|
||||
exports: [
|
||||
TerminalTabComponent,
|
||||
@ -115,12 +123,6 @@ export default class LocalTerminalModule { // eslint-disable-line @typescript-es
|
||||
if (hotkey === 'new-window') {
|
||||
hostApp.newWindow()
|
||||
}
|
||||
if (hotkey.startsWith('profile.')) {
|
||||
const profile = await terminal.getProfileByID(hotkey.split('.')[1])
|
||||
if (profile) {
|
||||
terminal.openTabWithOptions(profile.sessionOptions)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
config.ready$.toPromise().then(() => {
|
||||
|
72
tabby-local/src/profiles.ts
Normal file
72
tabby-local/src/profiles.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { ProfileProvider, Profile, NewTabParameters, ConfigService, SplitTabComponent, AppService } from 'tabby-core'
|
||||
import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
import { LocalProfileSettingsComponent } from './components/localProfileSettings.component'
|
||||
import { ShellProvider, Shell, SessionOptions } from './api'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalProfilesService extends ProfileProvider {
|
||||
id = 'local'
|
||||
name = 'Local'
|
||||
settingsComponent = LocalProfileSettingsComponent
|
||||
|
||||
constructor (
|
||||
private app: AppService,
|
||||
private config: ConfigService,
|
||||
@Inject(ShellProvider) private shellProviders: ShellProvider[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getBuiltinProfiles (): Promise<Profile[]> {
|
||||
return (await this.getShells()).map(shell => ({
|
||||
id: `local:${shell.id}`,
|
||||
type: 'local',
|
||||
name: shell.name,
|
||||
icon: shell.icon,
|
||||
options: this.optionsFromShell(shell),
|
||||
isBuiltin: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async getNewTabParameters (profile: Profile): Promise<NewTabParameters<TerminalTabComponent>> {
|
||||
const options = { ...profile.options }
|
||||
|
||||
if (!options.cwd) {
|
||||
if (this.app.activeTab instanceof TerminalTabComponent && this.app.activeTab.session) {
|
||||
options.cwd = await this.app.activeTab.session.getWorkingDirectory()
|
||||
}
|
||||
if (this.app.activeTab instanceof SplitTabComponent) {
|
||||
const focusedTab = this.app.activeTab.getFocusedTab()
|
||||
|
||||
if (focusedTab instanceof TerminalTabComponent && focusedTab.session) {
|
||||
options.cwd = await focusedTab.session.getWorkingDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: TerminalTabComponent,
|
||||
inputs: {
|
||||
sessionOptions: options,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async getShells (): Promise<Shell[]> {
|
||||
const shellLists = await Promise.all(this.config.enabledServices(this.shellProviders).map(x => x.provide()))
|
||||
return shellLists.reduce((a, b) => a.concat(b), [])
|
||||
}
|
||||
|
||||
optionsFromShell (shell: Shell): SessionOptions {
|
||||
return {
|
||||
command: shell.command,
|
||||
args: shell.args ?? [],
|
||||
env: shell.env,
|
||||
}
|
||||
}
|
||||
|
||||
getDescription (profile: Profile): string {
|
||||
return profile.options?.command
|
||||
}
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
|
||||
import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
|
||||
|
||||
import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class RecoveryProvider extends TabRecoveryProvider {
|
||||
export class RecoveryProvider extends TabRecoveryProvider<TerminalTabComponent> {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:terminal-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<TerminalTabComponent>> {
|
||||
return {
|
||||
type: TerminalTabComponent,
|
||||
options: {
|
||||
inputs: {
|
||||
sessionOptions: recoveryToken.sessionOptions,
|
||||
savedState: recoveryToken.savedState,
|
||||
},
|
||||
|
@ -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)
|
||||
}),
|
||||
}))
|
||||
))
|
||||
|
@ -1,150 +1,69 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import slugify from 'slugify'
|
||||
import { Observable, AsyncSubject } from 'rxjs'
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { AppService, Logger, LogService, ConfigService, SplitTabComponent } from 'tabby-core'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Logger, LogService, ConfigService, AppService, ProfilesService } from 'tabby-core'
|
||||
import { TerminalTabComponent } from '../components/terminalTab.component'
|
||||
import { ShellProvider, Shell, SessionOptions, Profile } from '../api'
|
||||
import { UACService } from './uac.service'
|
||||
import { SessionOptions, LocalProfile } from '../api'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TerminalService {
|
||||
private shells = new AsyncSubject<Shell[]>()
|
||||
private logger: Logger
|
||||
|
||||
/**
|
||||
* A fresh list of all available shells
|
||||
*/
|
||||
get shells$ (): Observable<Shell[]> { return this.shells }
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private app: AppService,
|
||||
private profilesService: ProfilesService,
|
||||
private config: ConfigService,
|
||||
private uac: UACService,
|
||||
@Inject(ShellProvider) private shellProviders: ShellProvider[],
|
||||
log: LogService,
|
||||
) {
|
||||
this.logger = log.create('terminal')
|
||||
|
||||
config.ready$.toPromise().then(() => {
|
||||
this.reloadShells()
|
||||
config.changed$.subscribe(() => {
|
||||
this.reloadShells()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getProfiles ({ includeHidden, skipDefault }: { includeHidden?: boolean, skipDefault?: boolean } = {}): Promise<Profile[]> {
|
||||
const shells = (await this.shells$.toPromise())!
|
||||
return [
|
||||
...this.config.store.terminal.profiles,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
...skipDefault ? [] : shells.filter(x => includeHidden || !x.hidden).map(shell => ({
|
||||
name: shell.name,
|
||||
shell: shell.id,
|
||||
icon: shell.icon,
|
||||
sessionOptions: this.optionsFromShell(shell),
|
||||
isBuiltin: true,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
getProfileID (profile: Profile): string {
|
||||
return slugify(profile.name, { remove: /[:.]/g }).toLowerCase()
|
||||
}
|
||||
|
||||
async getProfileByID (id: string): Promise<Profile|null> {
|
||||
const profiles = await this.getProfiles({ includeHidden: true })
|
||||
return profiles.find(x => this.getProfileID(x) === id) ?? null
|
||||
async getDefaultProfile (): Promise<LocalProfile> {
|
||||
const profiles = await this.profilesService.getProfiles()
|
||||
let profile = profiles.find(x => x.id === this.config.store.terminal.profile)
|
||||
if (!profile) {
|
||||
profile = profiles.filter(x => x.type === 'local' && x.isBuiltin)[0]
|
||||
}
|
||||
return profile as LocalProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a new terminal with a specific shell and CWD
|
||||
* @param pause Wait for a keypress when the shell exits
|
||||
*/
|
||||
async openTab (profile?: Profile|null, cwd?: string|null, pause?: boolean): Promise<TerminalTabComponent> {
|
||||
async openTab (profile?: LocalProfile|null, cwd?: string|null, pause?: boolean): Promise<TerminalTabComponent> {
|
||||
if (!profile) {
|
||||
profile = await this.getProfileByID(this.config.store.terminal.profile)
|
||||
if (!profile) {
|
||||
profile = (await this.getProfiles({ includeHidden: true }))[0]
|
||||
}
|
||||
profile = await this.getDefaultProfile()
|
||||
}
|
||||
|
||||
cwd = cwd ?? profile.sessionOptions.cwd
|
||||
cwd = cwd ?? profile.options.cwd
|
||||
|
||||
if (cwd && !fs.existsSync(cwd)) {
|
||||
console.warn('Ignoring non-existent CWD:', cwd)
|
||||
cwd = null
|
||||
}
|
||||
|
||||
if (!cwd) {
|
||||
if (!this.config.store.terminal.alwaysUseWorkingDirectory) {
|
||||
if (this.app.activeTab instanceof TerminalTabComponent && this.app.activeTab.session) {
|
||||
cwd = await this.app.activeTab.session.getWorkingDirectory()
|
||||
}
|
||||
if (this.app.activeTab instanceof SplitTabComponent) {
|
||||
const focusedTab = this.app.activeTab.getFocusedTab()
|
||||
|
||||
if (focusedTab instanceof TerminalTabComponent && focusedTab.session) {
|
||||
cwd = await focusedTab.session.getWorkingDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
cwd = cwd ?? this.config.store.terminal.workingDirectory
|
||||
}
|
||||
|
||||
this.logger.info(`Starting profile ${profile.name}`, profile)
|
||||
const sessionOptions = {
|
||||
...profile.sessionOptions,
|
||||
const options = {
|
||||
...profile.options,
|
||||
pauseAfterExit: pause,
|
||||
cwd: cwd ?? undefined,
|
||||
}
|
||||
|
||||
const tab = this.openTabWithOptions(sessionOptions)
|
||||
if (profile.color) {
|
||||
(this.app.getParentTab(tab) ?? tab).color = profile.color
|
||||
}
|
||||
if (profile.disableDynamicTitle) {
|
||||
tab.enableDynamicTitle = false
|
||||
tab.setTitle(profile.name)
|
||||
}
|
||||
return tab
|
||||
}
|
||||
|
||||
optionsFromShell (shell: Shell): SessionOptions {
|
||||
return {
|
||||
command: shell.command,
|
||||
args: shell.args ?? [],
|
||||
env: shell.env,
|
||||
}
|
||||
return (await this.profilesService.openNewTabForProfile({
|
||||
...profile,
|
||||
options,
|
||||
})) as TerminalTabComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a terminal with custom session options
|
||||
*/
|
||||
openTabWithOptions (sessionOptions: SessionOptions): TerminalTabComponent {
|
||||
if (sessionOptions.runAsAdministrator && this.uac.isAvailable) {
|
||||
sessionOptions = this.uac.patchSessionOptionsForUAC(sessionOptions)
|
||||
}
|
||||
this.logger.info('Using session options:', sessionOptions)
|
||||
|
||||
return this.app.openNewTab(
|
||||
TerminalTabComponent,
|
||||
{ sessionOptions }
|
||||
) as TerminalTabComponent
|
||||
}
|
||||
|
||||
private async getShells (): Promise<Shell[]> {
|
||||
const shellLists = await Promise.all(this.config.enabledServices(this.shellProviders).map(x => x.provide()))
|
||||
return shellLists.reduce((a, b) => a.concat(b), [])
|
||||
}
|
||||
|
||||
private async reloadShells () {
|
||||
this.shells = new AsyncSubject<Shell[]>()
|
||||
const shells = await this.getShells()
|
||||
this.logger.debug('Shells list:', shells)
|
||||
this.shells.next(shells)
|
||||
this.shells.complete()
|
||||
return this.app.openNewTab({
|
||||
type: TerminalTabComponent,
|
||||
inputs: { sessionOptions },
|
||||
}) as TerminalTabComponent
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ConfigService } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class CustomShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
const args = this.config.store.terminal.customShell.split(' ')
|
||||
return [{
|
||||
id: 'custom',
|
||||
name: 'Custom shell',
|
||||
command: args[0],
|
||||
args: args.slice(1),
|
||||
env: {},
|
||||
}]
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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: {},
|
||||
|
@ -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: {},
|
||||
}]
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, SplitTabComponent, NotificationsService, MenuItemOptions } from 'tabby-core'
|
||||
import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, SplitTabComponent, NotificationsService, MenuItemOptions, ProfilesService } from 'tabby-core'
|
||||
import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
import { UACService } from './services/uac.service'
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
import { LocalProfile } from './api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
@ -23,14 +24,15 @@ export class SaveAsProfileContextMenu extends TabContextMenuItemProvider {
|
||||
label: 'Save as profile',
|
||||
click: async () => {
|
||||
const profile = {
|
||||
sessionOptions: {
|
||||
options: {
|
||||
...tab.sessionOptions,
|
||||
cwd: await tab.session?.getWorkingDirectory() ?? tab.sessionOptions.cwd,
|
||||
},
|
||||
name: tab.sessionOptions.command,
|
||||
type: 'local',
|
||||
}
|
||||
this.config.store.terminal.profiles = [
|
||||
...this.config.store.terminal.profiles,
|
||||
this.config.store.profiles = [
|
||||
...this.config.store.profiles,
|
||||
profile,
|
||||
]
|
||||
this.config.save()
|
||||
@ -50,6 +52,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
private profilesService: ProfilesService,
|
||||
private terminalService: TerminalService,
|
||||
private uac: UACService,
|
||||
) {
|
||||
@ -57,7 +60,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
const profiles = await this.terminalService.getProfiles()
|
||||
const profiles = (await this.profilesService.getProfiles()).filter(x => x.type === 'local') as LocalProfile[]
|
||||
|
||||
const items: MenuItemOptions[] = [
|
||||
{
|
||||
@ -71,9 +74,9 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
|
||||
submenu: profiles.map(profile => ({
|
||||
label: profile.name,
|
||||
click: async () => {
|
||||
let workingDirectory = this.config.store.terminal.workingDirectory
|
||||
if (this.config.store.terminal.alwaysUseWorkingDirectory !== true && tab instanceof TerminalTabComponent) {
|
||||
workingDirectory = await tab.session?.getWorkingDirectory()
|
||||
let workingDirectory = profile.options.cwd
|
||||
if (!workingDirectory && tab instanceof TerminalTabComponent) {
|
||||
workingDirectory = await tab.session?.getWorkingDirectory() ?? undefined
|
||||
}
|
||||
await this.terminalService.openTab(profile, workingDirectory)
|
||||
},
|
||||
@ -88,7 +91,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
|
||||
label: profile.name,
|
||||
click: () => {
|
||||
this.terminalService.openTabWithOptions({
|
||||
...profile.sessionOptions,
|
||||
...profile.options,
|
||||
runAsAdministrator: true,
|
||||
})
|
||||
},
|
||||
|
@ -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"
|
||||
|
@ -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[]
|
||||
}
|
||||
|
@ -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()
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CLIHandler, CLIEvent, ConfigService } from 'tabby-core'
|
||||
import { SerialService } from './services/serial.service'
|
||||
|
||||
@Injectable()
|
||||
export class SerialCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = 0
|
||||
|
||||
constructor (
|
||||
private serial: SerialService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
const op = event.argv._[0]
|
||||
|
||||
if (op === 'connect-serial') {
|
||||
const connection = this.config.store.serial.connections.find(x => x.name === event.argv.connectionName)
|
||||
if (connection) {
|
||||
this.serial.connect(connection)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,200 +0,0 @@
|
||||
.modal-body
|
||||
ul.nav-tabs(ngbNav, #nav='ngbNav')
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) General
|
||||
ng-template(ngbNavContent)
|
||||
.form-group
|
||||
label Name
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='connection.name',
|
||||
)
|
||||
|
||||
.row
|
||||
.col-6
|
||||
.form-group
|
||||
label Path
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.port',
|
||||
[ngbTypeahead]='portsAutocomplete',
|
||||
[resultFormatter]='portsFormatter',
|
||||
)
|
||||
|
||||
.col-6
|
||||
.form-group
|
||||
label Baud Rate
|
||||
input.form-control(
|
||||
type='number',
|
||||
[(ngModel)]='connection.baudrate',
|
||||
[ngbTypeahead]='baudratesAutocomplete',
|
||||
)
|
||||
|
||||
.row
|
||||
.col-6
|
||||
.form-line
|
||||
.header
|
||||
.title Input mode
|
||||
|
||||
.d-flex(ngbDropdown)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
ngbDropdownToggle,
|
||||
) {{getInputModeName(connection.inputMode)}}
|
||||
|
||||
div(ngbDropdownMenu)
|
||||
a.d-flex.flex-column(
|
||||
*ngFor='let mode of inputModes',
|
||||
(click)='connection.inputMode = mode.key',
|
||||
ngbDropdownItem
|
||||
)
|
||||
div {{mode.name}}
|
||||
.text-muted {{mode.description}}
|
||||
|
||||
.col-6
|
||||
.form-line
|
||||
.header
|
||||
.title Input newlines
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='connection.inputNewlines',
|
||||
)
|
||||
option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
|
||||
|
||||
.row
|
||||
.col-6
|
||||
.form-line
|
||||
.header
|
||||
.title Output mode
|
||||
|
||||
.d-flex(ngbDropdown)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
ngbDropdownToggle,
|
||||
) {{getOutputModeName(connection.outputMode)}}
|
||||
|
||||
div(ngbDropdownMenu)
|
||||
a.d-flex.flex-column(
|
||||
*ngFor='let mode of outputModes',
|
||||
(click)='connection.outputMode = mode.key',
|
||||
ngbDropdownItem
|
||||
)
|
||||
div {{mode.name}}
|
||||
.text-muted {{mode.description}}
|
||||
|
||||
.col-6
|
||||
.form-line
|
||||
.header
|
||||
.title Output newlines
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='connection.outputNewlines',
|
||||
)
|
||||
option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Advanced
|
||||
ng-template(ngbNavContent)
|
||||
.form-line
|
||||
.header
|
||||
.title Tab color
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='connection.color',
|
||||
placeholder='#000000'
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title DataBits
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='8',
|
||||
[(ngModel)]='connection.databits',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title StopBits
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='1',
|
||||
[(ngModel)]='connection.stopbits',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Parity
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.parity',
|
||||
placeholder='none'
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title RTSCTS
|
||||
toggle([(ngModel)]='connection.rtscts')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Xon
|
||||
toggle([(ngModel)]='connection.xon')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Xoff
|
||||
toggle([(ngModel)]='connection.xoff')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Xany
|
||||
toggle([(ngModel)]='connection.xany')
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Login scripts
|
||||
ng-template(ngbNavContent)
|
||||
table(*ngIf='connection.scripts.length > 0')
|
||||
tr
|
||||
th String to expect
|
||||
th String to be sent
|
||||
th.pl-2 Regex
|
||||
th.pl-2 Optional
|
||||
th.pl-2 Actions
|
||||
tr(*ngFor='let script of connection.scripts')
|
||||
td.pr-2
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='script.expect'
|
||||
)
|
||||
td
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='script.send'
|
||||
)
|
||||
td.pl-2
|
||||
checkbox(
|
||||
[(ngModel)]='script.isRegex',
|
||||
)
|
||||
td.pl-2
|
||||
checkbox(
|
||||
[(ngModel)]='script.optional',
|
||||
)
|
||||
td.pl-2
|
||||
.input-group.flex-nowrap
|
||||
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
|
||||
i.fas.fa-arrow-up
|
||||
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
|
||||
i.fas.fa-arrow-down
|
||||
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
|
||||
i.fas.fa-trash
|
||||
|
||||
button.btn.btn-outline-info.mt-2((click)='addScript()')
|
||||
i.fas.fa-plus
|
||||
span New item
|
||||
|
||||
div([ngbNavOutlet]='nav')
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-primary((click)='save()') Save
|
||||
button.btn.btn-outline-danger((click)='cancel()') Cancel
|
171
tabby-serial/src/components/serialProfileSettings.component.pug
Normal file
171
tabby-serial/src/components/serialProfileSettings.component.pug
Normal file
@ -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')
|
@ -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: '' })
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { first } from 'rxjs/operators'
|
||||
import { SelectorService } from 'tabby-core'
|
||||
import { BaseTerminalTabComponent } from 'tabby-terminal'
|
||||
import { SerialService } from '../services/serial.service'
|
||||
import { SerialConnection, SerialSession, BAUD_RATES } from '../api'
|
||||
import { SerialSession, BAUD_RATES, SerialProfile } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
@ -16,7 +16,7 @@ import { SerialConnection, SerialSession, BAUD_RATES } from '../api'
|
||||
animations: BaseTerminalTabComponent.animations,
|
||||
})
|
||||
export class SerialTabComponent extends BaseTerminalTabComponent {
|
||||
connection?: SerialConnection
|
||||
profile?: SerialProfile
|
||||
session: SerialSession|null = null
|
||||
serialPort: any
|
||||
private serialService: SerialService
|
||||
@ -57,17 +57,17 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
|
||||
super.ngOnInit()
|
||||
|
||||
setImmediate(() => {
|
||||
this.setTitle(this.connection!.name)
|
||||
this.setTitle(this.profile!.name)
|
||||
})
|
||||
}
|
||||
|
||||
async initializeSession () {
|
||||
if (!this.connection) {
|
||||
this.logger.error('No Serial connection info supplied')
|
||||
if (!this.profile) {
|
||||
this.logger.error('No serial profile info supplied')
|
||||
return
|
||||
}
|
||||
|
||||
const session = this.serialService.createSession(this.connection)
|
||||
const session = this.serialService.createSession(this.profile)
|
||||
this.setSession(session)
|
||||
this.write(`Connecting to `)
|
||||
|
||||
@ -112,7 +112,7 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
|
||||
async getRecoveryToken (): Promise<any> {
|
||||
return {
|
||||
type: 'app:serial-tab',
|
||||
connection: this.connection,
|
||||
profile: this.profile,
|
||||
savedState: this.frontend?.saveState(),
|
||||
}
|
||||
}
|
||||
@ -128,6 +128,6 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
|
||||
name: x.toString(), result: x,
|
||||
})))
|
||||
this.serialPort.update({ baudRate: rate })
|
||||
this.connection!.baudrate = rate
|
||||
this.profile!.options.baudrate = rate
|
||||
}
|
||||
}
|
||||
|
@ -3,11 +3,6 @@ import { ConfigProvider } from 'tabby-core'
|
||||
/** @hidden */
|
||||
export class SerialConfigProvider extends ConfigProvider {
|
||||
defaults = {
|
||||
serial: {
|
||||
connections: [],
|
||||
options: {
|
||||
},
|
||||
},
|
||||
hotkeys: {
|
||||
serial: [
|
||||
'Alt-K',
|
||||
|
@ -3,20 +3,16 @@ import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastrModule } from 'ngx-toastr'
|
||||
import TabbyCoreModule, { ToolbarButtonProvider, ConfigProvider, TabRecoveryProvider, HotkeyProvider, CLIHandler } from 'tabby-core'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, ProfileProvider } from 'tabby-core'
|
||||
import TabbyTerminalModule from 'tabby-terminal'
|
||||
|
||||
import { EditConnectionModalComponent } from './components/editConnectionModal.component'
|
||||
import { SerialSettingsTabComponent } from './components/serialSettingsTab.component'
|
||||
import { SerialProfileSettingsComponent } from './components/serialProfileSettings.component'
|
||||
import { SerialTabComponent } from './components/serialTab.component'
|
||||
|
||||
import { ButtonProvider } from './buttonProvider'
|
||||
import { SerialConfigProvider } from './config'
|
||||
import { SerialSettingsTabProvider } from './settings'
|
||||
import { RecoveryProvider } from './recoveryProvider'
|
||||
import { SerialHotkeyProvider } from './hotkeys'
|
||||
import { SerialCLIHandler } from './cli'
|
||||
import { SerialProfilesService } from './profiles'
|
||||
|
||||
/** @hidden */
|
||||
@NgModule({
|
||||
@ -29,21 +25,17 @@ import { SerialCLIHandler } from './cli'
|
||||
TabbyTerminalModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
{ provide: ConfigProvider, useClass: SerialConfigProvider, multi: true },
|
||||
{ provide: SettingsTabProvider, useClass: SerialSettingsTabProvider, multi: true },
|
||||
{ provide: ProfileProvider, useClass: SerialProfilesService, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
|
||||
{ provide: HotkeyProvider, useClass: SerialHotkeyProvider, multi: true },
|
||||
{ provide: CLIHandler, useClass: SerialCLIHandler, multi: true },
|
||||
],
|
||||
entryComponents: [
|
||||
EditConnectionModalComponent,
|
||||
SerialSettingsTabComponent,
|
||||
SerialProfileSettingsComponent,
|
||||
SerialTabComponent,
|
||||
],
|
||||
declarations: [
|
||||
EditConnectionModalComponent,
|
||||
SerialSettingsTabComponent,
|
||||
SerialProfileSettingsComponent,
|
||||
SerialTabComponent,
|
||||
],
|
||||
})
|
||||
|
74
tabby-serial/src/profiles.ts
Normal file
74
tabby-serial/src/profiles.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import slugify from 'slugify'
|
||||
import deepClone from 'clone-deep'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ProfileProvider, NewTabParameters, SelectorService } from 'tabby-core'
|
||||
import { SerialProfileSettingsComponent } from './components/serialProfileSettings.component'
|
||||
import { SerialTabComponent } from './components/serialTab.component'
|
||||
import { SerialService } from './services/serial.service'
|
||||
import { BAUD_RATES, SerialProfile } from './api'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SerialProfilesService extends ProfileProvider {
|
||||
id = 'serial'
|
||||
name = 'Serial'
|
||||
settingsComponent = SerialProfileSettingsComponent
|
||||
|
||||
constructor (
|
||||
private selector: SelectorService,
|
||||
private serial: SerialService,
|
||||
) { super() }
|
||||
|
||||
async getBuiltinProfiles (): Promise<SerialProfile[]> {
|
||||
return [
|
||||
{
|
||||
id: `serial:template`,
|
||||
type: 'serial',
|
||||
name: 'Serial connection',
|
||||
icon: 'fas fa-microchip',
|
||||
options: {
|
||||
port: '',
|
||||
databits: 8,
|
||||
parity: 'none',
|
||||
rtscts: false,
|
||||
stopbits: 1,
|
||||
xany: false,
|
||||
xoff: false,
|
||||
xon: false,
|
||||
inputMode: null,
|
||||
outputMode: null,
|
||||
inputNewlines: null,
|
||||
outputNewlines: null,
|
||||
},
|
||||
isBuiltin: true,
|
||||
isTemplate: true,
|
||||
},
|
||||
...(await this.serial.listPorts()).map(p => ({
|
||||
id: `serial:port-${slugify(p.name).replace('.', '-')}`,
|
||||
type: 'serial',
|
||||
name: p.description ? `Serial: ${p.description}` : 'Serial',
|
||||
icon: 'fas fa-microchip',
|
||||
isBuiltin: true,
|
||||
options: {
|
||||
port: p.name,
|
||||
},
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
async getNewTabParameters (profile: SerialProfile): Promise<NewTabParameters<SerialTabComponent>> {
|
||||
if (!profile.options.baudrate) {
|
||||
profile = deepClone(profile)
|
||||
profile.options.baudrate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({
|
||||
name: x.toString(), result: x,
|
||||
})))
|
||||
}
|
||||
return {
|
||||
type: SerialTabComponent,
|
||||
inputs: { profile },
|
||||
}
|
||||
}
|
||||
|
||||
getDescription (profile: SerialProfile): string {
|
||||
return profile.options.port
|
||||
}
|
||||
}
|
@ -1,20 +1,20 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
|
||||
import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
|
||||
|
||||
import { SerialTabComponent } from './components/serialTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class RecoveryProvider extends TabRecoveryProvider {
|
||||
export class RecoveryProvider extends TabRecoveryProvider<SerialTabComponent> {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:serial-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<SerialTabComponent>> {
|
||||
return {
|
||||
type: SerialTabComponent,
|
||||
options: {
|
||||
connection: recoveryToken.connection,
|
||||
inputs: {
|
||||
profile: recoveryToken.profile,
|
||||
savedState: recoveryToken.savedState,
|
||||
},
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { Injectable, NgZone } from '@angular/core'
|
||||
import SerialPort from 'serialport'
|
||||
import { LogService, AppService, SelectorOption, ConfigService, NotificationsService, SelectorService } from 'tabby-core'
|
||||
import { SettingsTabComponent } from 'tabby-settings'
|
||||
import { SerialConnection, SerialSession, SerialPortInfo, BAUD_RATES } from '../api'
|
||||
import { LogService, NotificationsService, SelectorService, ProfilesService } from 'tabby-core'
|
||||
import { SerialSession, SerialPortInfo, BAUD_RATES, SerialProfile } from '../api'
|
||||
import { SerialTabComponent } from '../components/serialTab.component'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@ -11,9 +10,8 @@ export class SerialService {
|
||||
private log: LogService,
|
||||
private zone: NgZone,
|
||||
private notifications: NotificationsService,
|
||||
private app: AppService,
|
||||
private profilesService: ProfilesService,
|
||||
private selector: SelectorService,
|
||||
private config: ConfigService,
|
||||
) { }
|
||||
|
||||
async listPorts (): Promise<SerialPortInfo[]> {
|
||||
@ -23,23 +21,23 @@ export class SerialService {
|
||||
}))
|
||||
}
|
||||
|
||||
createSession (connection: SerialConnection): SerialSession {
|
||||
const session = new SerialSession(connection)
|
||||
session.logger = this.log.create(`serial-${connection.port}`)
|
||||
createSession (profile: SerialProfile): SerialSession {
|
||||
const session = new SerialSession(profile)
|
||||
session.logger = this.log.create(`serial-${profile.options.port}`)
|
||||
return session
|
||||
}
|
||||
|
||||
async connectSession (session: SerialSession): Promise<SerialPort> {
|
||||
const serial = new SerialPort(session.connection.port, {
|
||||
const serial = new SerialPort(session.profile.options.port, {
|
||||
autoOpen: false,
|
||||
baudRate: parseInt(session.connection.baudrate as any),
|
||||
dataBits: session.connection.databits,
|
||||
stopBits: session.connection.stopbits,
|
||||
parity: session.connection.parity,
|
||||
rtscts: session.connection.rtscts,
|
||||
xon: session.connection.xon,
|
||||
xoff: session.connection.xoff,
|
||||
xany: session.connection.xany,
|
||||
baudRate: parseInt(session.profile.options.baudrate as any),
|
||||
dataBits: session.profile.options.databits,
|
||||
stopBits: session.profile.options.stopbits,
|
||||
parity: session.profile.options.parity,
|
||||
rtscts: session.profile.options.rtscts,
|
||||
xon: session.profile.options.xon,
|
||||
xoff: session.profile.options.xoff,
|
||||
xany: session.profile.options.xany,
|
||||
})
|
||||
session.serial = serial
|
||||
let connected = false
|
||||
@ -72,105 +70,33 @@ export class SerialService {
|
||||
return serial
|
||||
}
|
||||
|
||||
async showConnectionSelector (): Promise<void> {
|
||||
const options: SelectorOption<void>[] = []
|
||||
const foundPorts = await this.listPorts()
|
||||
|
||||
try {
|
||||
const lastConnection = JSON.parse(window.localStorage.lastSerialConnection)
|
||||
if (lastConnection) {
|
||||
options.push({
|
||||
name: lastConnection.name,
|
||||
icon: 'history',
|
||||
callback: () => this.connect(lastConnection),
|
||||
})
|
||||
options.push({
|
||||
name: 'Clear last connection',
|
||||
icon: 'eraser',
|
||||
callback: () => {
|
||||
window.localStorage.lastSerialConnection = null
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch { }
|
||||
|
||||
for (const port of foundPorts) {
|
||||
options.push({
|
||||
name: port.name,
|
||||
description: port.description,
|
||||
icon: 'arrow-right',
|
||||
callback: () => this.connectFoundPort(port),
|
||||
})
|
||||
}
|
||||
|
||||
for (const connection of this.config.store.serial.connections) {
|
||||
options.push({
|
||||
name: connection.name,
|
||||
description: connection.port,
|
||||
callback: () => this.connect(connection),
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
name: 'Manage connections',
|
||||
icon: 'cog',
|
||||
callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'serial' }),
|
||||
})
|
||||
|
||||
options.push({
|
||||
name: 'Quick connect',
|
||||
freeInputPattern: 'Open device: %s...',
|
||||
icon: 'arrow-right',
|
||||
callback: query => this.quickConnect(query),
|
||||
})
|
||||
|
||||
|
||||
await this.selector.show('Open a serial port', options)
|
||||
}
|
||||
|
||||
async connect (connection: SerialConnection): Promise<SerialTabComponent> {
|
||||
try {
|
||||
const tab = this.app.openNewTab(
|
||||
SerialTabComponent,
|
||||
{ connection }
|
||||
) as SerialTabComponent
|
||||
if (connection.color) {
|
||||
(this.app.getParentTab(tab) ?? tab).color = connection.color
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.app.activeTab?.emitFocused()
|
||||
})
|
||||
return tab
|
||||
} catch (error) {
|
||||
this.notifications.error(`Could not connect: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
quickConnect (query: string): Promise<SerialTabComponent> {
|
||||
quickConnect (query: string): Promise<SerialTabComponent|null> {
|
||||
let path = query
|
||||
let baudrate = 115200
|
||||
if (query.includes('@')) {
|
||||
baudrate = parseInt(path.split('@')[1])
|
||||
path = path.split('@')[0]
|
||||
}
|
||||
const connection: SerialConnection = {
|
||||
const profile: SerialProfile = {
|
||||
name: query,
|
||||
port: path,
|
||||
baudrate: baudrate,
|
||||
databits: 8,
|
||||
parity: 'none',
|
||||
rtscts: false,
|
||||
stopbits: 1,
|
||||
xany: false,
|
||||
xoff: false,
|
||||
xon: false,
|
||||
type: 'serial',
|
||||
options: {
|
||||
port: path,
|
||||
baudrate: baudrate,
|
||||
databits: 8,
|
||||
parity: 'none',
|
||||
rtscts: false,
|
||||
stopbits: 1,
|
||||
xany: false,
|
||||
xoff: false,
|
||||
xon: false,
|
||||
},
|
||||
}
|
||||
window.localStorage.lastSerialConnection = JSON.stringify(connection)
|
||||
return this.connect(connection)
|
||||
window.localStorage.lastSerialConnection = JSON.stringify(profile)
|
||||
return this.profilesService.openNewTabForProfile(profile) as Promise<SerialTabComponent|null>
|
||||
}
|
||||
|
||||
async connectFoundPort (port: SerialPortInfo): Promise<SerialTabComponent> {
|
||||
async connectFoundPort (port: SerialPortInfo): Promise<SerialTabComponent|null> {
|
||||
const rate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({
|
||||
name: x.toString(), result: x,
|
||||
})))
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)'
|
||||
)
|
||||
|
@ -1,7 +0,0 @@
|
||||
.hotkeys-table {
|
||||
margin-top: 30px;
|
||||
|
||||
td, th {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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"
|
||||
|
@ -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) {
|
||||
|
@ -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(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CLIHandler, CLIEvent, ConfigService } from 'tabby-core'
|
||||
import { SSHService } from './services/ssh.service'
|
||||
|
||||
@Injectable()
|
||||
export class SSHCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = 0
|
||||
|
||||
constructor (
|
||||
private ssh: SSHService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
const op = event.argv._[0]
|
||||
|
||||
if (op === 'connect-ssh') {
|
||||
const connection = this.config.store.ssh.connections.find(x => x.name === event.argv.connectionName)
|
||||
if (connection) {
|
||||
this.ssh.connect(connection)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,269 +0,0 @@
|
||||
.modal-body
|
||||
ul.nav-tabs(ngbNav, #nav='ngbNav')
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) General
|
||||
ng-template(ngbNavContent)
|
||||
.form-group
|
||||
label Name
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='connection.name',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Group
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Ungrouped',
|
||||
[(ngModel)]='connection.group',
|
||||
[ngbTypeahead]='groupTypeahead',
|
||||
)
|
||||
|
||||
.d-flex.w-100(*ngIf='!useProxyCommand')
|
||||
.form-group.w-100.mr-4
|
||||
label Host
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.host',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Port
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='22',
|
||||
[(ngModel)]='connection.port',
|
||||
)
|
||||
|
||||
.alert.alert-info(*ngIf='useProxyCommand')
|
||||
.mr-auto Using a proxy command instead of a network connection
|
||||
|
||||
.form-group
|
||||
label Username
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.user',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Authentication method
|
||||
|
||||
.btn-group.mt-1.w-100(
|
||||
[(ngModel)]='connection.auth',
|
||||
ngbRadioGroup
|
||||
)
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='null')
|
||||
i.far.fa-lightbulb
|
||||
.m-0 Auto
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"password"')
|
||||
i.fas.fa-font
|
||||
.m-0 Password
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"publicKey"')
|
||||
i.fas.fa-key
|
||||
.m-0 Key
|
||||
label.btn.btn-secondary(ngbButtonLabel, ng:if='hostApp.platform !== Platform.Web')
|
||||
input(type='radio', ngbButton, [value]='"agent"')
|
||||
i.fas.fa-user-secret
|
||||
.m-0 Agent
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"keyboardInteractive"')
|
||||
i.far.fa-keyboard
|
||||
.m-0 Interactive
|
||||
|
||||
.form-line(*ngIf='!connection.auth || connection.auth === "password"')
|
||||
.header
|
||||
.title Password
|
||||
.description(*ngIf='!hasSavedPassword') Save a password in the keychain
|
||||
.description(*ngIf='hasSavedPassword') There is a saved password for this connection
|
||||
button.btn.btn-outline-success.ml-4(*ngIf='!hasSavedPassword', (click)='setPassword()')
|
||||
i.fas.fa-key
|
||||
span Set password
|
||||
button.btn.btn-danger.ml-4(*ngIf='hasSavedPassword', (click)='clearSavedPassword()')
|
||||
i.fas.fa-trash-alt
|
||||
span Forget
|
||||
|
||||
.form-group(*ngIf='!connection.auth || connection.auth === "publicKey"')
|
||||
label Private keys
|
||||
.list-group.mb-2
|
||||
.list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys')
|
||||
i.fas.fa-key
|
||||
.no-wrap.mr-auto {{path}}
|
||||
button.btn.btn-link((click)='removePrivateKey(path)')
|
||||
i.fas.fa-trash
|
||||
button.btn.btn-secondary((click)='addPrivateKey()')
|
||||
i.fas.fa-folder-open
|
||||
span Add a private key
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Ports
|
||||
ng-template(ngbNavContent)
|
||||
ssh-port-forwarding-config(
|
||||
[model]='connection.forwardedPorts',
|
||||
(forwardAdded)='onForwardAdded($event)',
|
||||
(forwardRemoved)='onForwardRemoved($event)'
|
||||
)
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Advanced
|
||||
ng-template(ngbNavContent)
|
||||
.form-line(*ngIf='!useProxyCommand')
|
||||
.header
|
||||
.title Jump host
|
||||
select.form-control([(ngModel)]='connection.jumpHost')
|
||||
option(value='') None
|
||||
option([ngValue]='x.name', *ngFor='let x of config.store.ssh.connections') {{x.name}}
|
||||
|
||||
.form-line(ng:if='hostApp.platform !== Platform.Web')
|
||||
.header
|
||||
.title X11 forwarding
|
||||
toggle([(ngModel)]='connection.x11')
|
||||
|
||||
.form-line(ng:if='hostApp.platform !== Platform.Web')
|
||||
.header
|
||||
.title Agent forwarding
|
||||
toggle([(ngModel)]='connection.agentForward')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Tab color
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='connection.color',
|
||||
placeholder='#000000'
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Disable dynamic tab title
|
||||
.description Connection name will be used as a title instead
|
||||
toggle([(ngModel)]='connection.disableDynamicTitle')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Skip MoTD/banner
|
||||
.description Will prevent the SSH greeting from showing up
|
||||
toggle([(ngModel)]='connection.skipBanner')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Keep Alive Interval (Milliseconds)
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='0',
|
||||
[(ngModel)]='connection.keepaliveInterval',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Max Keep Alive Count
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='3',
|
||||
[(ngModel)]='connection.keepaliveCountMax',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Ready Timeout (Milliseconds)
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='20000',
|
||||
[(ngModel)]='connection.readyTimeout',
|
||||
)
|
||||
|
||||
.form-line(*ngIf='!connection.jumpHost && hostApp.platform !== Platform.Web')
|
||||
.header
|
||||
.title Use a proxy command
|
||||
.description Command's stdin/stdout is used instead of a network connection
|
||||
toggle([(ngModel)]='useProxyCommand')
|
||||
|
||||
.form-group(*ngIf='useProxyCommand && !connection.jumpHost')
|
||||
label Proxy command
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.proxyCommand',
|
||||
)
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Ciphers
|
||||
ng-template(ngbNavContent)
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Ciphers
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.cipher')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.cipher[alg]')
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Key exchange
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.kex')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.kex[alg]')
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title HMAC
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.hmac')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.hmac[alg]')
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Host key
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.serverHostKey')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.serverHostKey[alg]')
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Login scripts
|
||||
ng-template(ngbNavContent)
|
||||
table(*ngIf='connection.scripts.length > 0')
|
||||
tr
|
||||
th String to expect
|
||||
th String to be sent
|
||||
th.pl-2 Regex
|
||||
th.pl-2 Optional
|
||||
th.pl-2 Actions
|
||||
tr(*ngFor='let script of connection.scripts')
|
||||
td.pr-2
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='script.expect'
|
||||
)
|
||||
td
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='script.send'
|
||||
)
|
||||
td.pl-2
|
||||
checkbox(
|
||||
[(ngModel)]='script.isRegex',
|
||||
)
|
||||
td.pl-2
|
||||
checkbox(
|
||||
[(ngModel)]='script.optional',
|
||||
)
|
||||
td.pl-2
|
||||
.input-group.flex-nowrap
|
||||
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
|
||||
i.fas.fa-arrow-up
|
||||
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
|
||||
i.fas.fa-arrow-down
|
||||
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
|
||||
i.fas.fa-trash
|
||||
|
||||
button.btn.btn-outline-info.mt-2((click)='addScript()')
|
||||
i.fas.fa-plus
|
||||
span New item
|
||||
|
||||
div([ngbNavOutlet]='nav')
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-primary((click)='save()') Save
|
||||
button.btn.btn-outline-danger((click)='cancel()') Cancel
|
231
tabby-ssh/src/components/sshProfileSettings.component.pug
Normal file
231
tabby-ssh/src/components/sshProfileSettings.component.pug
Normal file
@ -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')
|
@ -1,35 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Observable } from 'rxjs'
|
||||
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
import { ConfigService, PlatformService, FileProvidersService, Platform, HostAppService } from 'tabby-core'
|
||||
import { ConfigService, PlatformService, FileProvidersService, Platform, HostAppService, PromptModalComponent } from 'tabby-core'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { SSHConnection, LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST } from '../api'
|
||||
import { PromptModalComponent } from './promptModal.component'
|
||||
import { LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST, SSHProfile } from '../api'
|
||||
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./editConnectionModal.component.pug'),
|
||||
template: require('./sshProfileSettings.component.pug'),
|
||||
})
|
||||
export class EditConnectionModalComponent {
|
||||
export class SSHProfileSettingsComponent {
|
||||
Platform = Platform
|
||||
connection: SSHConnection
|
||||
profile: SSHProfile
|
||||
hasSavedPassword: boolean
|
||||
useProxyCommand: boolean
|
||||
|
||||
supportedAlgorithms: Record<string, string> = {}
|
||||
defaultAlgorithms: Record<string, string[]> = {}
|
||||
algorithms: Record<string, Record<string, boolean>> = {}
|
||||
|
||||
private groupNames: string[]
|
||||
jumpHosts: SSHProfile[]
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
private modalInstance: NgbActiveModal,
|
||||
private platform: PlatformService,
|
||||
private passwordStorage: PasswordStorageService,
|
||||
private ngbModal: NgbModal,
|
||||
@ -51,39 +46,30 @@ export class EditConnectionModalComponent {
|
||||
this.supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort()
|
||||
this.defaultAlgorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x))
|
||||
}
|
||||
|
||||
this.groupNames = [...new Set(config.store.ssh.connections.map(x => x.group))] as string[]
|
||||
this.groupNames = this.groupNames.filter(x => x).sort()
|
||||
}
|
||||
|
||||
groupTypeahead = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase())))
|
||||
)
|
||||
|
||||
async ngOnInit () {
|
||||
this.connection.algorithms = this.connection.algorithms ?? {}
|
||||
this.jumpHosts = this.config.store.profiles.filter(x => x.type === 'ssh' && x !== this.profile)
|
||||
this.profile.options.algorithms = this.profile.options.algorithms ?? {}
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!this.connection.algorithms[k]) {
|
||||
this.connection.algorithms[k] = this.defaultAlgorithms[k]
|
||||
if (!this.profile.options.algorithms[k]) {
|
||||
this.profile.options.algorithms[k] = this.defaultAlgorithms[k]
|
||||
}
|
||||
|
||||
this.algorithms[k] = {}
|
||||
for (const alg of this.connection.algorithms[k]) {
|
||||
for (const alg of this.profile.options.algorithms[k]) {
|
||||
this.algorithms[k][alg] = true
|
||||
}
|
||||
}
|
||||
|
||||
this.connection.scripts = this.connection.scripts ?? []
|
||||
this.connection.auth = this.connection.auth ?? null
|
||||
this.connection.privateKeys ??= []
|
||||
this.profile.options.scripts = this.profile.options.scripts ?? []
|
||||
this.profile.options.auth = this.profile.options.auth ?? null
|
||||
this.profile.options.privateKeys ??= []
|
||||
|
||||
this.useProxyCommand = !!this.connection.proxyCommand
|
||||
this.useProxyCommand = !!this.profile.options.proxyCommand
|
||||
try {
|
||||
this.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.connection)
|
||||
this.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.profile)
|
||||
} catch (e) {
|
||||
console.error('Could not check for saved password', e)
|
||||
}
|
||||
@ -91,12 +77,12 @@ export class EditConnectionModalComponent {
|
||||
|
||||
async setPassword () {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = `Password for ${this.connection.user}@${this.connection.host}`
|
||||
modal.componentInstance.prompt = `Password for ${this.profile.options.user}@${this.profile.options.host}`
|
||||
modal.componentInstance.password = true
|
||||
try {
|
||||
const result = await modal.result
|
||||
if (result?.value) {
|
||||
this.passwordStorage.savePassword(this.connection, result.value)
|
||||
this.passwordStorage.savePassword(this.profile, result.value)
|
||||
this.hasSavedPassword = true
|
||||
}
|
||||
} catch { }
|
||||
@ -104,61 +90,56 @@ export class EditConnectionModalComponent {
|
||||
|
||||
clearSavedPassword () {
|
||||
this.hasSavedPassword = false
|
||||
this.passwordStorage.deletePassword(this.connection)
|
||||
this.passwordStorage.deletePassword(this.profile)
|
||||
}
|
||||
|
||||
async addPrivateKey () {
|
||||
const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.connection.name}`)
|
||||
this.connection.privateKeys = [
|
||||
...this.connection.privateKeys!,
|
||||
const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.profile.name}`)
|
||||
this.profile.options.privateKeys = [
|
||||
...this.profile.options.privateKeys!,
|
||||
ref,
|
||||
]
|
||||
}
|
||||
|
||||
removePrivateKey (path: string) {
|
||||
this.connection.privateKeys = this.connection.privateKeys?.filter(x => x !== path)
|
||||
this.profile.options.privateKeys = this.profile.options.privateKeys?.filter(x => x !== path)
|
||||
}
|
||||
|
||||
save () {
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
this.connection.algorithms![k] = Object.entries(this.algorithms[k])
|
||||
this.profile.options.algorithms![k] = Object.entries(this.algorithms[k])
|
||||
.filter(([_, v]) => !!v)
|
||||
.map(([key, _]) => key)
|
||||
}
|
||||
if (!this.useProxyCommand) {
|
||||
this.connection.proxyCommand = undefined
|
||||
this.profile.options.proxyCommand = undefined
|
||||
}
|
||||
this.modalInstance.close(this.connection)
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
|
||||
moveScriptUp (script: LoginScript) {
|
||||
if (!this.connection.scripts) {
|
||||
this.connection.scripts = []
|
||||
if (!this.profile.options.scripts) {
|
||||
this.profile.options.scripts = []
|
||||
}
|
||||
const index = this.connection.scripts.indexOf(script)
|
||||
const index = this.profile.options.scripts.indexOf(script)
|
||||
if (index > 0) {
|
||||
this.connection.scripts.splice(index, 1)
|
||||
this.connection.scripts.splice(index - 1, 0, script)
|
||||
this.profile.options.scripts.splice(index, 1)
|
||||
this.profile.options.scripts.splice(index - 1, 0, script)
|
||||
}
|
||||
}
|
||||
|
||||
moveScriptDown (script: LoginScript) {
|
||||
if (!this.connection.scripts) {
|
||||
this.connection.scripts = []
|
||||
if (!this.profile.options.scripts) {
|
||||
this.profile.options.scripts = []
|
||||
}
|
||||
const index = this.connection.scripts.indexOf(script)
|
||||
if (index >= 0 && index < this.connection.scripts.length - 1) {
|
||||
this.connection.scripts.splice(index, 1)
|
||||
this.connection.scripts.splice(index + 1, 0, script)
|
||||
const index = this.profile.options.scripts.indexOf(script)
|
||||
if (index >= 0 && index < this.profile.options.scripts.length - 1) {
|
||||
this.profile.options.scripts.splice(index, 1)
|
||||
this.profile.options.scripts.splice(index + 1, 0, script)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScript (script: LoginScript) {
|
||||
if (this.connection.scripts && (await this.platform.showMessageBox(
|
||||
if (this.profile.options.scripts && (await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: 'Delete this script?',
|
||||
@ -167,23 +148,23 @@ export class EditConnectionModalComponent {
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
this.connection.scripts = this.connection.scripts.filter(x => x !== script)
|
||||
this.profile.options.scripts = this.profile.options.scripts.filter(x => x !== script)
|
||||
}
|
||||
}
|
||||
|
||||
addScript () {
|
||||
if (!this.connection.scripts) {
|
||||
this.connection.scripts = []
|
||||
if (!this.profile.options.scripts) {
|
||||
this.profile.options.scripts = []
|
||||
}
|
||||
this.connection.scripts.push({ expect: '', send: '' })
|
||||
this.profile.options.scripts.push({ expect: '', send: '' })
|
||||
}
|
||||
|
||||
onForwardAdded (fw: ForwardedPortConfig) {
|
||||
this.connection.forwardedPorts = this.connection.forwardedPorts ?? []
|
||||
this.connection.forwardedPorts.push(fw)
|
||||
this.profile.options.forwardedPorts = this.profile.options.forwardedPorts ?? []
|
||||
this.profile.options.forwardedPorts.push(fw)
|
||||
}
|
||||
|
||||
onForwardRemoved (fw: ForwardedPortConfig) {
|
||||
this.connection.forwardedPorts = this.connection.forwardedPorts?.filter(x => x !== fw)
|
||||
this.profile.options.forwardedPorts = this.profile.options.forwardedPorts?.filter(x => x !== fw)
|
||||
}
|
||||
}
|
@ -1,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
|
||||
|
@ -1,3 +0,0 @@
|
||||
.list-group-item {
|
||||
padding: 0.3rem 1rem;
|
||||
}
|
@ -1,158 +1,15 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import deepClone from 'clone-deep'
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { SSHConnection } from '../api'
|
||||
import { EditConnectionModalComponent } from './editConnectionModal.component'
|
||||
import { PromptModalComponent } from './promptModal.component'
|
||||
|
||||
interface SSHConnectionGroup {
|
||||
name: string|null
|
||||
connections: SSHConnection[]
|
||||
}
|
||||
import { ConfigService, HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./sshSettingsTab.component.pug'),
|
||||
styles: [require('./sshSettingsTab.component.scss')],
|
||||
})
|
||||
export class SSHSettingsTabComponent {
|
||||
connections: SSHConnection[]
|
||||
childGroups: SSHConnectionGroup[]
|
||||
groupCollapsed: Record<string, boolean> = {}
|
||||
filter = ''
|
||||
Platform = Platform
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
private platform: PlatformService,
|
||||
private ngbModal: NgbModal,
|
||||
private passwordStorage: PasswordStorageService,
|
||||
) {
|
||||
this.connections = this.config.store.ssh.connections
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
createConnection () {
|
||||
const connection: SSHConnection = {
|
||||
name: '',
|
||||
group: null,
|
||||
host: '',
|
||||
port: 22,
|
||||
user: 'root',
|
||||
}
|
||||
|
||||
const modal = this.ngbModal.open(EditConnectionModalComponent)
|
||||
modal.componentInstance.connection = connection
|
||||
modal.result.then(result => {
|
||||
this.connections.push(result)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
copyConnection (connection: SSHConnection) {
|
||||
const modal = this.ngbModal.open(EditConnectionModalComponent)
|
||||
modal.componentInstance.connection = {
|
||||
...deepClone(connection),
|
||||
name: `${connection.name} Copy`,
|
||||
}
|
||||
modal.result.then(result => {
|
||||
this.connections.push(result)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
editConnection (connection: SSHConnection) {
|
||||
const modal = this.ngbModal.open(EditConnectionModalComponent, { size: 'lg' })
|
||||
modal.componentInstance.connection = deepClone(connection)
|
||||
modal.result.then(result => {
|
||||
Object.assign(connection, result)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
async deleteConnection (connection: SSHConnection) {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete "${connection.name}"?`,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
this.connections = this.connections.filter(x => x !== connection)
|
||||
this.passwordStorage.deletePassword(connection)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
editGroup (group: SSHConnectionGroup) {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = 'New group name'
|
||||
modal.componentInstance.value = group.name
|
||||
modal.result.then(result => {
|
||||
if (result) {
|
||||
for (const connection of this.connections.filter(x => x.group === group.name)) {
|
||||
connection.group = result.value
|
||||
}
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async deleteGroup (group: SSHConnectionGroup) {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete "${group.name}"?`,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
for (const connection of this.connections.filter(x => x.group === group.name)) {
|
||||
connection.group = null
|
||||
}
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
refresh () {
|
||||
this.connections = this.config.store.ssh.connections
|
||||
this.childGroups = []
|
||||
|
||||
for (const connection of this.connections) {
|
||||
connection.group = connection.group ?? null
|
||||
let group = this.childGroups.find(x => x.name === connection.group)
|
||||
if (!group) {
|
||||
group = {
|
||||
name: connection.group,
|
||||
connections: [],
|
||||
}
|
||||
this.childGroups.push(group)
|
||||
}
|
||||
group.connections.push(connection)
|
||||
}
|
||||
}
|
||||
|
||||
isGroupVisible (group: SSHConnectionGroup): boolean {
|
||||
return !this.filter || group.connections.some(x => this.isConnectionVisible(x))
|
||||
}
|
||||
|
||||
isConnectionVisible (connection: SSHConnection): boolean {
|
||||
return !this.filter || `${connection.name}$${connection.host}`.toLowerCase().includes(this.filter.toLowerCase())
|
||||
}
|
||||
) { }
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -6,7 +6,7 @@ import { first } from 'rxjs/operators'
|
||||
import { Platform, RecoveryToken } from 'tabby-core'
|
||||
import { BaseTerminalTabComponent } from 'tabby-terminal'
|
||||
import { SSHService } from '../services/ssh.service'
|
||||
import { SSHConnection, SSHSession } from '../api'
|
||||
import { SSHProfile, SSHSession } from '../api'
|
||||
import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.compon
|
||||
})
|
||||
export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
Platform = Platform
|
||||
connection?: SSHConnection
|
||||
profile?: SSHProfile
|
||||
session: SSHSession|null = null
|
||||
sftpPanelVisible = false
|
||||
sftpPath = '/'
|
||||
@ -43,13 +43,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
}
|
||||
|
||||
ngOnInit (): void {
|
||||
if (!this.connection) {
|
||||
throw new Error('Connection not set')
|
||||
if (!this.profile) {
|
||||
throw new Error('Profile not set')
|
||||
}
|
||||
|
||||
this.logger = this.log.create('terminalTab')
|
||||
|
||||
this.enableDynamicTitle = !this.connection.disableDynamicTitle
|
||||
this.enableDynamicTitle = !this.profile.disableDynamicTitle
|
||||
|
||||
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
|
||||
if (!this.hasFocus) {
|
||||
@ -84,16 +84,16 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
super.ngOnInit()
|
||||
|
||||
setImmediate(() => {
|
||||
this.setTitle(this.connection!.name)
|
||||
this.setTitle(this.profile!.name)
|
||||
})
|
||||
}
|
||||
|
||||
async setupOneSession (session: SSHSession): Promise<void> {
|
||||
if (session.connection.jumpHost) {
|
||||
const jumpConnection: SSHConnection|null = this.config.store.ssh.connections.find(x => x.name === session.connection.jumpHost)
|
||||
if (session.profile.options.jumpHost) {
|
||||
const jumpConnection: SSHProfile|null = this.config.store.profiles.find(x => x.id === session.profile.options.jumpHost)
|
||||
|
||||
if (!jumpConnection) {
|
||||
throw new Error(`${session.connection.host}: jump host "${session.connection.jumpHost}" not found in your config`)
|
||||
throw new Error(`${session.profile.options.host}: jump host "${session.profile.options.jumpHost}" not found in your config`)
|
||||
}
|
||||
|
||||
const jumpSession = this.ssh.createSession(jumpConnection)
|
||||
@ -107,7 +107,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
})
|
||||
|
||||
session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
|
||||
'127.0.0.1', 0, session.connection.host, session.connection.port ?? 22,
|
||||
'127.0.0.1', 0, session.profile.options.host, session.profile.options.port ?? 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
|
||||
@ -124,7 +124,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
this.sessionStack.push(session)
|
||||
}
|
||||
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`)
|
||||
|
||||
this.startSpinner()
|
||||
|
||||
@ -157,7 +157,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
this.destroy()
|
||||
} else if (this.frontend) {
|
||||
// Session was closed abruptly
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.profile.options.host}: session closed\r\n`)
|
||||
if (!this.reconnectOffered) {
|
||||
this.reconnectOffered = true
|
||||
this.write('Press any key to reconnect\r\n')
|
||||
@ -174,12 +174,12 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
|
||||
async initializeSession (): Promise<void> {
|
||||
this.reconnectOffered = false
|
||||
if (!this.connection) {
|
||||
if (!this.profile) {
|
||||
this.logger.error('No SSH connection info supplied')
|
||||
return
|
||||
}
|
||||
|
||||
const session = this.ssh.createSession(this.connection)
|
||||
const session = this.ssh.createSession(this.profile)
|
||||
this.setSession(session)
|
||||
|
||||
try {
|
||||
@ -195,7 +195,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
async getRecoveryToken (): Promise<RecoveryToken> {
|
||||
return {
|
||||
type: 'app:ssh-tab',
|
||||
connection: this.connection,
|
||||
profile: this.profile,
|
||||
savedState: this.frontend?.saveState(),
|
||||
}
|
||||
}
|
||||
@ -215,13 +215,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
if (!this.session?.open) {
|
||||
return true
|
||||
}
|
||||
if (!(this.connection?.warnOnClose ?? this.config.store.ssh.warnOnClose)) {
|
||||
if (!(this.profile?.options.warnOnClose ?? this.config.store.ssh.warnOnClose)) {
|
||||
return true
|
||||
}
|
||||
return (await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Disconnect from ${this.connection?.host}?`,
|
||||
message: `Disconnect from ${this.profile?.options.host}?`,
|
||||
buttons: ['Cancel', 'Disconnect'],
|
||||
defaultId: 1,
|
||||
}
|
||||
|
@ -4,8 +4,6 @@ import { ConfigProvider } from 'tabby-core'
|
||||
export class SSHConfigProvider extends ConfigProvider {
|
||||
defaults = {
|
||||
ssh: {
|
||||
connections: [],
|
||||
recentConnections: [],
|
||||
warnOnClose: false,
|
||||
winSCPPath: null,
|
||||
agentType: 'auto',
|
||||
|
@ -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',
|
||||
|
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="#fff" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm193.2 152h-82.5c-9-44.4-24.1-82.2-43.2-109.1 55 18.2 100.2 57.9 125.7 109.1zM336 256c0 22.9-1.6 44.2-4.3 64H164.3c-2.7-19.8-4.3-41.1-4.3-64s1.6-44.2 4.3-64h167.4c2.7 19.8 4.3 41.1 4.3 64zM248 40c26.9 0 61.4 44.1 78.1 120H169.9C186.6 84.1 221.1 40 248 40zm-67.5 10.9c-19 26.8-34.2 64.6-43.2 109.1H54.8c25.5-51.2 70.7-90.9 125.7-109.1zM32 256c0-22.3 3.4-43.8 9.7-64h90.5c-2.6 20.5-4.2 41.8-4.2 64s1.5 43.5 4.2 64H41.7c-6.3-20.2-9.7-41.7-9.7-64zm22.8 96h82.5c9 44.4 24.1 82.2 43.2 109.1-55-18.2-100.2-57.9-125.7-109.1zM248 472c-26.9 0-61.4-44.1-78.1-120h156.2c-16.7 75.9-51.2 120-78.1 120zm67.5-10.9c19-26.8 34.2-64.6 43.2-109.1h82.5c-25.5 51.2-70.7 90.9-125.7 109.1zM363.8 320c2.6-20.5 4.2-41.8 4.2-64s-1.5-43.5-4.2-64h90.5c6.3 20.2 9.7 41.7 9.7 64s-3.4 43.8-9.7 64h-90.5z"></path></svg>
|
Before Width: | Height: | Size: 939 B |
@ -4,26 +4,24 @@ import { FormsModule } from '@angular/forms'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastrModule } from 'ngx-toastr'
|
||||
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||
import TabbyCoreModule, { ToolbarButtonProvider, ConfigProvider, TabRecoveryProvider, HotkeyProvider, TabContextMenuItemProvider, CLIHandler } from 'tabby-core'
|
||||
import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, TabContextMenuItemProvider, ProfileProvider } from 'tabby-core'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
import TabbyTerminalModule from 'tabby-terminal'
|
||||
|
||||
import { EditConnectionModalComponent } from './components/editConnectionModal.component'
|
||||
import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
|
||||
import { SSHPortForwardingModalComponent } from './components/sshPortForwardingModal.component'
|
||||
import { SSHPortForwardingConfigComponent } from './components/sshPortForwardingConfig.component'
|
||||
import { PromptModalComponent } from './components/promptModal.component'
|
||||
import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
import { SFTPPanelComponent } from './components/sftpPanel.component'
|
||||
import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
|
||||
|
||||
import { ButtonProvider } from './buttonProvider'
|
||||
import { SSHConfigProvider } from './config'
|
||||
import { SSHSettingsTabProvider } from './settings'
|
||||
import { RecoveryProvider } from './recoveryProvider'
|
||||
import { SSHHotkeyProvider } from './hotkeys'
|
||||
import { SFTPContextMenu } from './tabContextMenu'
|
||||
import { SSHCLIHandler } from './cli'
|
||||
import { SSHProfilesService } from './profiles'
|
||||
|
||||
/** @hidden */
|
||||
@NgModule({
|
||||
@ -37,25 +35,22 @@ import { SSHCLIHandler } from './cli'
|
||||
TabbyTerminalModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
{ provide: ConfigProvider, useClass: SSHConfigProvider, multi: true },
|
||||
{ provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
|
||||
{ provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true },
|
||||
{ provide: CLIHandler, useClass: SSHCLIHandler, multi: true },
|
||||
{ provide: ProfileProvider, useClass: SSHProfilesService, multi: true },
|
||||
],
|
||||
entryComponents: [
|
||||
EditConnectionModalComponent,
|
||||
PromptModalComponent,
|
||||
SSHProfileSettingsComponent,
|
||||
SFTPDeleteModalComponent,
|
||||
SSHPortForwardingModalComponent,
|
||||
SSHSettingsTabComponent,
|
||||
SSHTabComponent,
|
||||
],
|
||||
declarations: [
|
||||
EditConnectionModalComponent,
|
||||
PromptModalComponent,
|
||||
SSHProfileSettingsComponent,
|
||||
SFTPDeleteModalComponent,
|
||||
SSHPortForwardingModalComponent,
|
||||
SSHPortForwardingConfigComponent,
|
||||
|
79
tabby-ssh/src/profiles.ts
Normal file
79
tabby-ssh/src/profiles.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ProfileProvider, Profile, NewTabParameters } from 'tabby-core'
|
||||
import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
import { PasswordStorageService } from './services/passwordStorage.service'
|
||||
import { SSHProfile } from './api'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SSHProfilesService extends ProfileProvider {
|
||||
id = 'ssh'
|
||||
name = 'SSH'
|
||||
supportsQuickConnect = true
|
||||
settingsComponent = SSHProfileSettingsComponent
|
||||
|
||||
constructor (
|
||||
private passwordStorage: PasswordStorageService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getBuiltinProfiles (): Promise<Profile[]> {
|
||||
return [{
|
||||
id: `ssh:template`,
|
||||
type: 'ssh',
|
||||
name: 'SSH connection',
|
||||
icon: 'fas fa-desktop',
|
||||
options: {
|
||||
host: '',
|
||||
port: 22,
|
||||
user: 'root',
|
||||
},
|
||||
isBuiltin: true,
|
||||
isTemplate: true,
|
||||
}]
|
||||
}
|
||||
|
||||
async getNewTabParameters (profile: Profile): Promise<NewTabParameters<SSHTabComponent>> {
|
||||
return {
|
||||
type: SSHTabComponent,
|
||||
inputs: { profile },
|
||||
}
|
||||
}
|
||||
|
||||
getDescription (profile: SSHProfile): string {
|
||||
return profile.options.host
|
||||
}
|
||||
|
||||
deleteProfile (profile: SSHProfile): void {
|
||||
this.passwordStorage.deletePassword(profile)
|
||||
}
|
||||
|
||||
quickConnect (query: string): SSHProfile {
|
||||
let user = 'root'
|
||||
let host = query
|
||||
let port = 22
|
||||
if (host.includes('@')) {
|
||||
const parts = host.split(/@/g)
|
||||
host = parts[parts.length - 1]
|
||||
user = parts.slice(0, parts.length - 1).join('@')
|
||||
}
|
||||
if (host.includes('[')) {
|
||||
port = parseInt(host.split(']')[1].substring(1))
|
||||
host = host.split(']')[0].substring(1)
|
||||
} else if (host.includes(':')) {
|
||||
port = parseInt(host.split(/:/g)[1])
|
||||
host = host.split(':')[0]
|
||||
}
|
||||
|
||||
return {
|
||||
name: query,
|
||||
type: 'ssh',
|
||||
options: {
|
||||
host,
|
||||
user,
|
||||
port,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
@ -1,20 +1,20 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
|
||||
import { TabRecoveryProvider, NewTabParameters, RecoveryToken } from 'tabby-core'
|
||||
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class RecoveryProvider extends TabRecoveryProvider {
|
||||
export class RecoveryProvider extends TabRecoveryProvider<SSHTabComponent> {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:ssh-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
async recover (recoveryToken: RecoveryToken): Promise<NewTabParameters<SSHTabComponent>> {
|
||||
return {
|
||||
type: SSHTabComponent,
|
||||
options: {
|
||||
connection: recoveryToken['connection'],
|
||||
inputs: {
|
||||
profile: recoveryToken['profile'],
|
||||
savedState: recoveryToken['savedState'],
|
||||
},
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as keytar from 'keytar'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { SSHConnection } from '../api'
|
||||
import { SSHProfile } from '../api'
|
||||
import { VaultService } from 'tabby-core'
|
||||
|
||||
export const VAULT_SECRET_TYPE_PASSWORD = 'ssh:password'
|
||||
@ -10,33 +10,33 @@ export const VAULT_SECRET_TYPE_PASSPHRASE = 'ssh:key-passphrase'
|
||||
export class PasswordStorageService {
|
||||
constructor (private vault: VaultService) { }
|
||||
|
||||
async savePassword (connection: SSHConnection, password: string): Promise<void> {
|
||||
async savePassword (profile: SSHProfile, password: string): Promise<void> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForConnection(connection)
|
||||
const key = this.getVaultKeyForConnection(profile)
|
||||
this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSWORD, key, value: password })
|
||||
} else {
|
||||
const key = this.getKeytarKeyForConnection(connection)
|
||||
return keytar.setPassword(key, connection.user, password)
|
||||
const key = this.getKeytarKeyForConnection(profile)
|
||||
return keytar.setPassword(key, profile.options.user, password)
|
||||
}
|
||||
}
|
||||
|
||||
async deletePassword (connection: SSHConnection): Promise<void> {
|
||||
async deletePassword (profile: SSHProfile): Promise<void> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForConnection(connection)
|
||||
const key = this.getVaultKeyForConnection(profile)
|
||||
this.vault.removeSecret(VAULT_SECRET_TYPE_PASSWORD, key)
|
||||
} else {
|
||||
const key = this.getKeytarKeyForConnection(connection)
|
||||
await keytar.deletePassword(key, connection.user)
|
||||
const key = this.getKeytarKeyForConnection(profile)
|
||||
await keytar.deletePassword(key, profile.options.user)
|
||||
}
|
||||
}
|
||||
|
||||
async loadPassword (connection: SSHConnection): Promise<string|null> {
|
||||
async loadPassword (profile: SSHProfile): Promise<string|null> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForConnection(connection)
|
||||
const key = this.getVaultKeyForConnection(profile)
|
||||
return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSWORD, key))?.value ?? null
|
||||
} else {
|
||||
const key = this.getKeytarKeyForConnection(connection)
|
||||
return keytar.getPassword(key, connection.user)
|
||||
const key = this.getKeytarKeyForConnection(profile)
|
||||
return keytar.getPassword(key, profile.options.user)
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,10 +70,10 @@ export class PasswordStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private getKeytarKeyForConnection (connection: SSHConnection): string {
|
||||
let key = `ssh@${connection.host}`
|
||||
if (connection.port) {
|
||||
key = `ssh@${connection.host}:${connection.port}`
|
||||
private getKeytarKeyForConnection (profile: SSHProfile): string {
|
||||
let key = `ssh@${profile.options.host}`
|
||||
if (profile.options.port) {
|
||||
key = `ssh@${profile.options.host}:${profile.options.port}`
|
||||
}
|
||||
return key
|
||||
}
|
||||
@ -82,11 +82,11 @@ export class PasswordStorageService {
|
||||
return `ssh-private-key:${id}`
|
||||
}
|
||||
|
||||
private getVaultKeyForConnection (connection: SSHConnection) {
|
||||
private getVaultKeyForConnection (profile: SSHProfile) {
|
||||
return {
|
||||
user: connection.user,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: profile.options.user,
|
||||
host: profile.options.host,
|
||||
port: profile.options.port,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,12 +5,9 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Client } from 'ssh2'
|
||||
import { exec } from 'child_process'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { Logger, LogService, AppService, SelectorOption, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, SelectorService } from 'tabby-core'
|
||||
import { SettingsTabComponent } from 'tabby-settings'
|
||||
import { ALGORITHM_BLACKLIST, ForwardedPort, SSHConnection, SSHSession } from '../api'
|
||||
import { PromptModalComponent } from '../components/promptModal.component'
|
||||
import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core'
|
||||
import { ALGORITHM_BLACKLIST, ForwardedPort, SSHProfile, SSHSession } from '../api'
|
||||
import { PasswordStorageService } from './passwordStorage.service'
|
||||
import { SSHTabComponent } from '../components/sshTab.component'
|
||||
import { ChildProcess } from 'node:child_process'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@ -25,8 +22,6 @@ export class SSHService {
|
||||
private ngbModal: NgbModal,
|
||||
private passwordStorage: PasswordStorageService,
|
||||
private notifications: NotificationsService,
|
||||
private app: AppService,
|
||||
private selector: SelectorService,
|
||||
private config: ConfigService,
|
||||
hostApp: HostAppService,
|
||||
private platform: PlatformService,
|
||||
@ -37,9 +32,9 @@ export class SSHService {
|
||||
}
|
||||
}
|
||||
|
||||
createSession (connection: SSHConnection): SSHSession {
|
||||
const session = new SSHSession(this.injector, connection)
|
||||
session.logger = this.log.create(`ssh-${connection.host}-${connection.port}`)
|
||||
createSession (profile: SSHProfile): SSHSession {
|
||||
const session = new SSHSession(this.injector, profile)
|
||||
session.logger = this.log.create(`ssh-${profile.options.host}-${profile.options.port}`)
|
||||
return session
|
||||
}
|
||||
|
||||
@ -52,18 +47,18 @@ export class SSHService {
|
||||
|
||||
let connected = false
|
||||
const algorithms = {}
|
||||
for (const key of Object.keys(session.connection.algorithms ?? {})) {
|
||||
algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
|
||||
for (const key of Object.keys(session.profile.options.algorithms ?? {})) {
|
||||
algorithms[key] = session.profile.options.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
|
||||
}
|
||||
|
||||
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
|
||||
ssh.on('ready', () => {
|
||||
connected = true
|
||||
if (session.savedPassword) {
|
||||
this.passwordStorage.savePassword(session.connection, session.savedPassword)
|
||||
this.passwordStorage.savePassword(session.profile, session.savedPassword)
|
||||
}
|
||||
|
||||
for (const fw of session.connection.forwardedPorts ?? []) {
|
||||
for (const fw of session.profile.options.forwardedPorts ?? []) {
|
||||
session.addPortForward(Object.assign(new ForwardedPort(), fw))
|
||||
}
|
||||
|
||||
@ -74,7 +69,7 @@ export class SSHService {
|
||||
})
|
||||
ssh.on('error', error => {
|
||||
if (error.message === 'All configured authentication methods failed') {
|
||||
this.passwordStorage.deletePassword(session.connection)
|
||||
this.passwordStorage.deletePassword(session.profile)
|
||||
}
|
||||
this.zone.run(() => {
|
||||
if (connected) {
|
||||
@ -111,22 +106,22 @@ export class SSHService {
|
||||
}))
|
||||
|
||||
ssh.on('greeting', greeting => {
|
||||
if (!session.connection.skipBanner) {
|
||||
if (!session.profile.options.skipBanner) {
|
||||
log('Greeting: ' + greeting)
|
||||
}
|
||||
})
|
||||
|
||||
ssh.on('banner', banner => {
|
||||
if (!session.connection.skipBanner) {
|
||||
if (!session.profile.options.skipBanner) {
|
||||
log(banner)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (session.connection.proxyCommand) {
|
||||
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.connection.proxyCommand}`)
|
||||
session.proxyCommandStream = new ProxyCommandStream(session.connection.proxyCommand)
|
||||
if (session.profile.options.proxyCommand) {
|
||||
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.profile.options.proxyCommand}`)
|
||||
session.proxyCommandStream = new ProxyCommandStream(session.profile.options.proxyCommand)
|
||||
|
||||
session.proxyCommandStream.output$.subscribe((message: string) => {
|
||||
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim())
|
||||
@ -136,16 +131,16 @@ export class SSHService {
|
||||
}
|
||||
|
||||
ssh.connect({
|
||||
host: session.connection.host.trim(),
|
||||
port: session.connection.port ?? 22,
|
||||
host: session.profile.options.host.trim(),
|
||||
port: session.profile.options.port ?? 22,
|
||||
sock: session.proxyCommandStream ?? session.jumpStream,
|
||||
username: session.connection.user,
|
||||
username: session.profile.options.user,
|
||||
tryKeyboard: true,
|
||||
agent: session.agentPath,
|
||||
agentForward: session.connection.agentForward && !!session.agentPath,
|
||||
keepaliveInterval: session.connection.keepaliveInterval ?? 15000,
|
||||
keepaliveCountMax: session.connection.keepaliveCountMax,
|
||||
readyTimeout: session.connection.readyTimeout,
|
||||
agentForward: session.profile.options.agentForward && !!session.agentPath,
|
||||
keepaliveInterval: session.profile.options.keepaliveInterval ?? 15000,
|
||||
keepaliveCountMax: session.profile.options.keepaliveCountMax,
|
||||
readyTimeout: session.profile.options.readyTimeout,
|
||||
hostVerifier: (digest: string) => {
|
||||
log('Host key fingerprint:')
|
||||
log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
|
||||
@ -167,138 +162,17 @@ export class SSHService {
|
||||
return resultPromise
|
||||
}
|
||||
|
||||
async showConnectionSelector (): Promise<void> {
|
||||
const options: SelectorOption<void>[] = []
|
||||
const recentConnections = this.config.store.ssh.recentConnections
|
||||
|
||||
for (const connection of recentConnections) {
|
||||
options.push({
|
||||
name: connection.name,
|
||||
description: connection.host,
|
||||
icon: 'history',
|
||||
callback: () => this.connect(connection),
|
||||
})
|
||||
}
|
||||
|
||||
if (recentConnections.length) {
|
||||
options.push({
|
||||
name: 'Clear recent connections',
|
||||
icon: 'eraser',
|
||||
callback: () => {
|
||||
this.config.store.ssh.recentConnections = []
|
||||
this.config.save()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const groups: { name: string, connections: SSHConnection[] }[] = []
|
||||
const connections = this.config.store.ssh.connections
|
||||
for (const connection of connections) {
|
||||
connection.group = connection.group || null
|
||||
let group = groups.find(x => x.name === connection.group)
|
||||
if (!group) {
|
||||
group = {
|
||||
name: connection.group!,
|
||||
connections: [],
|
||||
}
|
||||
groups.push(group)
|
||||
}
|
||||
group.connections.push(connection)
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
for (const connection of group.connections) {
|
||||
options.push({
|
||||
name: (group.name ? `${group.name} / ` : '') + connection.name,
|
||||
description: connection.host,
|
||||
icon: 'desktop',
|
||||
callback: () => this.connect(connection),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
options.push({
|
||||
name: 'Manage connections',
|
||||
icon: 'cog',
|
||||
callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'ssh' }),
|
||||
})
|
||||
|
||||
options.push({
|
||||
name: 'Quick connect',
|
||||
freeInputPattern: 'Connect to "%s"...',
|
||||
icon: 'arrow-right',
|
||||
callback: query => this.quickConnect(query),
|
||||
})
|
||||
|
||||
|
||||
await this.selector.show('Open an SSH connection', options)
|
||||
}
|
||||
|
||||
async connect (connection: SSHConnection): Promise<SSHTabComponent> {
|
||||
try {
|
||||
const tab = this.app.openNewTab(
|
||||
SSHTabComponent,
|
||||
{ connection }
|
||||
) as SSHTabComponent
|
||||
if (connection.color) {
|
||||
(this.app.getParentTab(tab) ?? tab).color = connection.color
|
||||
}
|
||||
|
||||
setTimeout(() => this.app.activeTab?.emitFocused())
|
||||
|
||||
return tab
|
||||
} catch (error) {
|
||||
this.notifications.error(`Could not connect: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
quickConnect (query: string): Promise<SSHTabComponent> {
|
||||
let user = 'root'
|
||||
let host = query
|
||||
let port = 22
|
||||
if (host.includes('@')) {
|
||||
const parts = host.split(/@/g)
|
||||
host = parts[parts.length - 1]
|
||||
user = parts.slice(0, parts.length - 1).join('@')
|
||||
}
|
||||
if (host.includes('[')) {
|
||||
port = parseInt(host.split(']')[1].substring(1))
|
||||
host = host.split(']')[0].substring(1)
|
||||
} else if (host.includes(':')) {
|
||||
port = parseInt(host.split(/:/g)[1])
|
||||
host = host.split(':')[0]
|
||||
}
|
||||
|
||||
const connection: SSHConnection = {
|
||||
name: query,
|
||||
group: null,
|
||||
host,
|
||||
user,
|
||||
port,
|
||||
}
|
||||
|
||||
const recentConnections = this.config.store.ssh.recentConnections
|
||||
recentConnections.unshift(connection)
|
||||
if (recentConnections.length > 5) {
|
||||
recentConnections.pop()
|
||||
}
|
||||
this.config.store.ssh.recentConnections = recentConnections
|
||||
this.config.save()
|
||||
return this.connect(connection)
|
||||
}
|
||||
|
||||
getWinSCPPath (): string|undefined {
|
||||
return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
|
||||
}
|
||||
|
||||
async getWinSCPURI (connection: SSHConnection): Promise<string> {
|
||||
let uri = `scp://${connection.user}`
|
||||
const password = await this.passwordStorage.loadPassword(connection)
|
||||
async getWinSCPURI (profile: SSHProfile): Promise<string> {
|
||||
let uri = `scp://${profile.options.user}`
|
||||
const password = await this.passwordStorage.loadPassword(profile)
|
||||
if (password) {
|
||||
uri += ':' + encodeURIComponent(password)
|
||||
}
|
||||
uri += `@${connection.host}:${connection.port}/`
|
||||
uri += `@${profile.options.host}:${profile.options.port}/`
|
||||
return uri
|
||||
}
|
||||
|
||||
@ -307,7 +181,7 @@ export class SSHService {
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
const args = [await this.getWinSCPURI(session.connection)]
|
||||
const args = [await this.getWinSCPURI(session.profile)]
|
||||
if (session.activePrivateKey) {
|
||||
args.push('/privatekey')
|
||||
args.push(session.activePrivateKey)
|
||||
|
@ -17,7 +17,7 @@ export class SFTPContextMenu extends TabContextMenuItemProvider {
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
if (!(tab instanceof SSHTabComponent) || !tab.connection) {
|
||||
if (!(tab instanceof SSHTabComponent) || !tab.profile) {
|
||||
return []
|
||||
}
|
||||
const items = [{
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user