mirror of https://github.com/Eugeny/tabby.git
Compare commits
18 Commits
7fbe3f0bc8
...
f4b0a1c7a5
| Author | SHA1 | Date |
|---|---|---|
|
|
f4b0a1c7a5 | |
|
|
2cf6ef0036 | |
|
|
7a7d3d2b77 | |
|
|
4f14e92e6a | |
|
|
52463555ab | |
|
|
77f179ee13 | |
|
|
11e5674cf0 | |
|
|
cfee1760bd | |
|
|
edb863b200 | |
|
|
cc0391f2e4 | |
|
|
ed174d3128 | |
|
|
868a509bb0 | |
|
|
0a9788a91a | |
|
|
f4c54245a2 | |
|
|
c383728cc7 | |
|
|
f43731d9dc | |
|
|
86d3710c0e | |
|
|
24809bc33b |
|
|
@ -58,7 +58,7 @@ export class Window {
|
||||||
|
|
||||||
const maximized = this.windowConfig.get('maximized')
|
const maximized = this.windowConfig.get('maximized')
|
||||||
const bwOptions: BrowserWindowConstructorOptions = {
|
const bwOptions: BrowserWindowConstructorOptions = {
|
||||||
width: 800,
|
width: 1150,
|
||||||
height: 600,
|
height: 600,
|
||||||
title: 'Tabby',
|
title: 'Tabby',
|
||||||
minWidth: 400,
|
minWidth: 400,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/remote": "^2",
|
"@electron/remote": "^2",
|
||||||
"node-pty": "^1.1.0-beta34",
|
"node-pty": "^1.1.0-beta39",
|
||||||
"any-promise": "^1.3.0",
|
"any-promise": "^1.3.0",
|
||||||
"electron-config": "2.0.0",
|
"electron-config": "2.0.0",
|
||||||
"electron-debug": "^3.2.0",
|
"electron-debug": "^3.2.0",
|
||||||
|
|
|
||||||
|
|
@ -2813,7 +2813,7 @@ node-gyp@^10.0.0, node-gyp@^5.0.2, node-gyp@^5.1.0:
|
||||||
tar "^6.1.2"
|
tar "^6.1.2"
|
||||||
which "^4.0.0"
|
which "^4.0.0"
|
||||||
|
|
||||||
node-pty@^1.1.0-beta34:
|
node-pty@^1.1.0-beta39:
|
||||||
version "1.1.0-beta9"
|
version "1.1.0-beta9"
|
||||||
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d"
|
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d"
|
||||||
integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==
|
integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ export type PartialProfile<T extends Profile> = Omit<Omit<Omit<{
|
||||||
|
|
||||||
export interface ProfileGroup {
|
export interface ProfileGroup {
|
||||||
id: string
|
id: string
|
||||||
|
parentGroupId?: string
|
||||||
|
icon?: string
|
||||||
|
color?: string
|
||||||
name: string
|
name: string
|
||||||
profiles: PartialProfile<Profile>[]
|
profiles: PartialProfile<Profile>[]
|
||||||
defaults: any
|
defaults: any
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,13 @@ title-bar(
|
||||||
[class.inset]='hostApp.platform == Platform.macOS && !hostWindow.isFullscreen'
|
[class.inset]='hostApp.platform == Platform.macOS && !hostWindow.isFullscreen'
|
||||||
)
|
)
|
||||||
|
|
||||||
.content(
|
.window.h-100.d-flex
|
||||||
|
|
||||||
|
profile-tree(
|
||||||
|
*ngIf='ready && !config.store.hideProfileTree'
|
||||||
|
)
|
||||||
|
|
||||||
|
.content.main.h-100(
|
||||||
*ngIf='ready',
|
*ngIf='ready',
|
||||||
[class.tabs-on-top]='config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left" || config.store.appearance.tabsLocation == "right"',
|
[class.tabs-on-top]='config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left" || config.store.appearance.tabsLocation == "right"',
|
||||||
[class.tabs-on-left]='hasVerticalTabs() && config.store.appearance.tabsLocation == "left"',
|
[class.tabs-on-left]='hasVerticalTabs() && config.store.appearance.tabsLocation == "left"',
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ $tab-border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
.div.p-2.h-100.d-flex.flex-column
|
||||||
|
input.form-control.form-control-sm.mb-1(
|
||||||
|
type='text',
|
||||||
|
[(ngModel)]='filter',
|
||||||
|
placeholder='Filter',
|
||||||
|
(ngModelChange)='onFilterChange()'
|
||||||
|
)
|
||||||
|
|
||||||
|
.profile-tree.h-100
|
||||||
|
.d-flex.flex-column.p-2.profile-tree-container
|
||||||
|
|
||||||
|
ng-container(*ngFor='let group of rootGroups')
|
||||||
|
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: group, depth: 0}')
|
||||||
|
|
||||||
|
ng-template(#recursiveGroup let-group let-depth='depth')
|
||||||
|
a.tree-item(
|
||||||
|
(click)='toggleGroupCollapse(group)',
|
||||||
|
[style.paddingLeft.px]='depth * 20',
|
||||||
|
(contextmenu)='groupContextMenu(group, $event)',
|
||||||
|
href='#'
|
||||||
|
)
|
||||||
|
.fw-20
|
||||||
|
.fa.fa-fw.fas.fa-chevron-right.ms-1.text-muted(*ngIf='group.collapsed')
|
||||||
|
.fa.fa-fw.fas.fa-chevron-down.ms-1.text-muted(*ngIf='!group.collapsed')
|
||||||
|
.fw-20
|
||||||
|
profile-icon.ms-1([icon]='group.icon ?? "far fa-folder"', [color]='group?.color')
|
||||||
|
span.ms-2.me-auto {{ group.name || ("Ungrouped"|translate) }}
|
||||||
|
|
||||||
|
ng-container(*ngIf='!group.collapsed')
|
||||||
|
ng-container(*ngFor='let profile of group.profiles')
|
||||||
|
a.tree-item(
|
||||||
|
(dblclick)='launchProfile(profile)',
|
||||||
|
[style.paddingLeft.px]='(depth + 1) * 20',
|
||||||
|
(contextmenu)='profileContextMenu(profile, $event)',
|
||||||
|
href='#'
|
||||||
|
)
|
||||||
|
.fw-20
|
||||||
|
profile-icon.ms-1([icon]='profile.icon', [color]='profile.color')
|
||||||
|
span.ms-2.no-wrap {{ profile.name }}
|
||||||
|
|
||||||
|
.actions
|
||||||
|
.action((click)='launchProfile(profile)')
|
||||||
|
.fa.fa-fw.fas.fa-play
|
||||||
|
//- .action
|
||||||
|
//- .fa.fa-fw.fas.fa-eject
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ng-container(*ngFor='let child of group.children')
|
||||||
|
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: child, depth: depth + 1}')
|
||||||
|
.grabber(
|
||||||
|
(mousedown)="startResize($event)"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
:host {
|
||||||
|
background-color: var(--theme-bg-more-2);
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
border-right: 1px solid var(--theme-secondary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: 1px solid var(--theme-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tree {
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
.fw-20 {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fas.fa-chevron-right,
|
||||||
|
.fas.fa-chevron-down {
|
||||||
|
font-size: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: calc(.25rem * calc(var(--spaciness) * var(--spaciness))) 0;
|
||||||
|
padding-right: .25rem;
|
||||||
|
border-radius: .3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-secondary);
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: calc(.25rem * calc(var(--spaciness) * var(--spaciness)));
|
||||||
|
height: 100%;
|
||||||
|
padding: calc(.25rem * calc(var(--spaciness) * var(--spaciness)));
|
||||||
|
background: var(--theme-secondary);
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
background-color: var(--theme-bg-more-2);
|
||||||
|
border-radius: .2rem;
|
||||||
|
padding: 0 calc(.34rem * calc(var(--spaciness) * var(--spaciness)));
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-primary);
|
||||||
|
color: var(--theme-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.grabber {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
width: 7px;
|
||||||
|
height: 25px;
|
||||||
|
display: block;
|
||||||
|
background-color: var(--theme-secondary-fg);
|
||||||
|
border: 3px solid var(--theme-secondary);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
top: 50%;
|
||||||
|
right: -4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { Component, HostBinding, HostListener, Input } from '@angular/core'
|
||||||
|
import { TranslateService } from '@ngx-translate/core'
|
||||||
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import deepClone from 'clone-deep'
|
||||||
|
import FuzzySearch from 'fuzzy-search'
|
||||||
|
|
||||||
|
import { ConfigService } from '../services/config.service'
|
||||||
|
import { ProfilesService } from '../services/profiles.service'
|
||||||
|
import { AppService } from '../services/app.service'
|
||||||
|
import { PlatformService } from '../api/platform'
|
||||||
|
import { ProfileProvider } from '../api/index'
|
||||||
|
import { PartialProfileGroup, ProfileGroup, PartialProfile, Profile } from '../index'
|
||||||
|
import { BaseComponent } from './base.component'
|
||||||
|
|
||||||
|
interface CollapsableProfileGroup extends ProfileGroup {
|
||||||
|
collapsed: boolean
|
||||||
|
children: PartialProfileGroup<CollapsableProfileGroup>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @hidden */
|
||||||
|
@Component({
|
||||||
|
selector: 'profile-tree',
|
||||||
|
styleUrls: ['./profileTree.component.scss'],
|
||||||
|
templateUrl: './profileTree.component.pug',
|
||||||
|
})
|
||||||
|
export class ProfileTreeComponent extends BaseComponent {
|
||||||
|
profileGroups: PartialProfileGroup<ProfileGroup>[] = []
|
||||||
|
rootGroups: PartialProfileGroup<ProfileGroup>[] = []
|
||||||
|
|
||||||
|
filteredProfiles: PartialProfile<Profile>[] = []
|
||||||
|
@Input() filter = ''
|
||||||
|
|
||||||
|
|
||||||
|
panelMinWidth = 200
|
||||||
|
panelMaxWidth = 600
|
||||||
|
panelInternalWidth: number = parseInt(window.localStorage.profileTreeWidth ?? 300)
|
||||||
|
panelStartWidth = this.panelInternalWidth
|
||||||
|
panelIsResizing = false
|
||||||
|
panelStartX = 0
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private app: AppService,
|
||||||
|
private platform: PlatformService,
|
||||||
|
private config: ConfigService,
|
||||||
|
private profilesService: ProfilesService,
|
||||||
|
private translate: TranslateService,
|
||||||
|
private ngbModal: NgbModal,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit (): Promise<void> {
|
||||||
|
await this.loadTreeItems()
|
||||||
|
this.subscribeUntilDestroyed(this.config.changed$, () => this.loadTreeItems())
|
||||||
|
this.subscribeUntilDestroyed(this.config.changed$, () => this.loadTreeItems())
|
||||||
|
this.app.tabsChanged$.subscribe(() => this.tabStateChanged())
|
||||||
|
this.app.activeTabChange$.subscribe(() => this.tabStateChanged())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async loadTreeItems (): Promise<void> {
|
||||||
|
const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
|
||||||
|
let groups = await this.profilesService.getProfileGroups({ includeNonUserGroup: true, includeProfiles: true })
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.profiles?.length) {
|
||||||
|
// remove template profiles
|
||||||
|
group.profiles = group.profiles.filter(x => !x.isTemplate)
|
||||||
|
|
||||||
|
// remove blocklisted profiles
|
||||||
|
group.profiles = group.profiles.filter(x => x.id && !this.config.store.profileBlacklist.includes(x.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.store.terminal.showBuiltinProfiles) { groups = groups.filter(g => g.id !== 'built-in') }
|
||||||
|
|
||||||
|
groups.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
groups.sort((a, b) => (a.id === 'built-in' || !a.editable ? 1 : 0) - (b.id === 'built-in' || !b.editable ? 1 : 0))
|
||||||
|
groups.sort((a, b) => (a.id === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1))
|
||||||
|
this.profileGroups = groups.map(g => ProfileTreeComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false))
|
||||||
|
this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async editProfile (profile: PartialProfile<Profile>): Promise<void> {
|
||||||
|
const { EditProfileModalComponent } = window['nodeRequire']('tabby-settings')
|
||||||
|
const modal = this.ngbModal.open(
|
||||||
|
EditProfileModalComponent,
|
||||||
|
{ size: 'lg' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const provider = this.profilesService.providerForProfile(profile)
|
||||||
|
if (!provider) { throw new Error('Cannot edit a profile without a provider') }
|
||||||
|
|
||||||
|
modal.componentInstance.profile = deepClone(profile)
|
||||||
|
modal.componentInstance.profileProvider = provider
|
||||||
|
|
||||||
|
const result = await modal.result.catch(() => null)
|
||||||
|
if (!result) { return }
|
||||||
|
|
||||||
|
result.type = provider.id
|
||||||
|
|
||||||
|
await this.profilesService.writeProfile(result)
|
||||||
|
await this.config.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async editProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<void> {
|
||||||
|
const { EditProfileGroupModalComponent } = window['nodeRequire']('tabby-settings')
|
||||||
|
|
||||||
|
const modal = this.ngbModal.open(
|
||||||
|
EditProfileGroupModalComponent,
|
||||||
|
{ size: 'lg' },
|
||||||
|
)
|
||||||
|
|
||||||
|
modal.componentInstance.group = deepClone(group)
|
||||||
|
modal.componentInstance.providers = this.profilesService.getProviders()
|
||||||
|
|
||||||
|
const result: PartialProfileGroup<ProfileGroup & { group: PartialProfileGroup<CollapsableProfileGroup>, provider?: ProfileProvider<Profile> }> | null = await modal.result.catch(() => null)
|
||||||
|
if (!result) { return }
|
||||||
|
if (!result.group) { return }
|
||||||
|
|
||||||
|
if (result.provider) {
|
||||||
|
return this.editProfileGroupDefaults(result.group, result.provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete result.group.collapsed
|
||||||
|
delete result.group.children
|
||||||
|
await this.profilesService.writeProfileGroup(result.group)
|
||||||
|
await this.config.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async editProfileGroupDefaults (group: PartialProfileGroup<CollapsableProfileGroup>, provider: ProfileProvider<Profile>): Promise<void> {
|
||||||
|
const { EditProfileModalComponent } = window['nodeRequire']('tabby-settings')
|
||||||
|
|
||||||
|
const modal = this.ngbModal.open(
|
||||||
|
EditProfileModalComponent,
|
||||||
|
{ size: 'lg' },
|
||||||
|
)
|
||||||
|
const model = group.defaults?.[provider.id] ?? {}
|
||||||
|
model.type = provider.id
|
||||||
|
modal.componentInstance.profile = Object.assign({}, model)
|
||||||
|
modal.componentInstance.profileProvider = provider
|
||||||
|
modal.componentInstance.defaultsMode = 'group'
|
||||||
|
|
||||||
|
const result = await modal.result.catch(() => null)
|
||||||
|
if (result) {
|
||||||
|
// Fully replace the config
|
||||||
|
for (const k in model) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete model[k]
|
||||||
|
}
|
||||||
|
Object.assign(model, result)
|
||||||
|
if (!group.defaults) {
|
||||||
|
group.defaults = {}
|
||||||
|
}
|
||||||
|
group.defaults[provider.id] = model
|
||||||
|
}
|
||||||
|
return this.editProfileGroup(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
async profileContextMenu (profile: PartialProfile<Profile>, event: MouseEvent): Promise<void> {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.platform.popupContextMenu([
|
||||||
|
{
|
||||||
|
type: 'normal',
|
||||||
|
label: this.translate.instant('Run'),
|
||||||
|
click: () => this.launchProfile(profile),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'normal',
|
||||||
|
label: this.translate.instant('Edit profile'),
|
||||||
|
click: () => this.editProfile(profile),
|
||||||
|
enabled: !(profile.isBuiltin ?? profile.isTemplate),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async groupContextMenu (group: PartialProfileGroup<CollapsableProfileGroup>, event: MouseEvent): Promise<void> {
|
||||||
|
event.preventDefault()
|
||||||
|
this.platform.popupContextMenu([
|
||||||
|
{
|
||||||
|
type: 'normal',
|
||||||
|
label: group.collapsed ? this.translate.instant('Expand group') : this.translate.instant('Collapse group'),
|
||||||
|
click: () => this.toggleGroupCollapse(group),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'normal',
|
||||||
|
label: this.translate.instant('Edit group'),
|
||||||
|
click: () => this.editProfileGroup(group),
|
||||||
|
enabled: group.editable,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tabStateChanged (): Promise<void> {
|
||||||
|
// TODO: show active tab in the side panel with eye icon
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchProfile<P extends Profile> (profile: PartialProfile<P>): Promise<any> {
|
||||||
|
return this.profilesService.launchProfile(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFilterChange (): Promise<void> {
|
||||||
|
try {
|
||||||
|
const q = this.filter.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (q.length === 0) {
|
||||||
|
this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await this.profilesService.getProfiles({
|
||||||
|
includeBuiltin: this.config.store.terminal.showBuiltinProfiles,
|
||||||
|
clone: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const matches = new FuzzySearch(
|
||||||
|
profiles.filter(p => !p.isTemplate),
|
||||||
|
['name', 'description'],
|
||||||
|
{ sort: false },
|
||||||
|
).search(q)
|
||||||
|
|
||||||
|
this.rootGroups = [
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
editable: false,
|
||||||
|
name: this.translate.instant('Filter results'),
|
||||||
|
icon: 'fas fa-magnifying-glass',
|
||||||
|
profiles: matches,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error occurred during search:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////// RESIZING //////
|
||||||
|
startResize (event: MouseEvent): void {
|
||||||
|
this.panelIsResizing = true
|
||||||
|
this.panelStartX = event.clientX
|
||||||
|
this.panelStartWidth = this.panelWidth
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mousemove', ['$event'])
|
||||||
|
onMouseMove (event: MouseEvent): void {
|
||||||
|
if (!this.panelIsResizing) { return }
|
||||||
|
const delta = event.clientX - this.panelStartX
|
||||||
|
const width = Math.min(Math.max(this.panelMinWidth, this.panelStartWidth + delta), this.panelMaxWidth)
|
||||||
|
this.panelWidth = width
|
||||||
|
window.localStorage.profileTreeWidth = width
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mouseup')
|
||||||
|
stopResize (): void {
|
||||||
|
this.panelIsResizing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostBinding('style.width.px')
|
||||||
|
get panelWidth (): number {
|
||||||
|
return this.panelInternalWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
set panelWidth (value: number) {
|
||||||
|
this.panelInternalWidth = value
|
||||||
|
}
|
||||||
|
|
||||||
|
////// GROUP COLLAPSING //////
|
||||||
|
toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
|
||||||
|
group.collapsed = !group.collapsed
|
||||||
|
this.saveProfileGroupCollapse(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveProfileGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
|
||||||
|
const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
|
||||||
|
profileGroupCollapsed[group.id] = group.collapsed
|
||||||
|
window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static intoPartialCollapsableProfileGroup (group: PartialProfileGroup<ProfileGroup>, collapsed: boolean): PartialProfileGroup<CollapsableProfileGroup> {
|
||||||
|
const collapsableGroup = {
|
||||||
|
...group,
|
||||||
|
collapsed,
|
||||||
|
}
|
||||||
|
return collapsableGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import { UnlockVaultModalComponent } from './components/unlockVaultModal.compone
|
||||||
import { WelcomeTabComponent } from './components/welcomeTab.component'
|
import { WelcomeTabComponent } from './components/welcomeTab.component'
|
||||||
import { TransfersMenuComponent } from './components/transfersMenu.component'
|
import { TransfersMenuComponent } from './components/transfersMenu.component'
|
||||||
import { ProfileIconComponent } from './components/profileIcon.component'
|
import { ProfileIconComponent } from './components/profileIcon.component'
|
||||||
|
import { ProfileTreeComponent } from './components/profileTree.component'
|
||||||
|
|
||||||
import { AutofocusDirective } from './directives/autofocus.directive'
|
import { AutofocusDirective } from './directives/autofocus.directive'
|
||||||
import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive'
|
import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive'
|
||||||
|
|
@ -130,6 +131,7 @@ const PROVIDERS = [
|
||||||
DropZoneDirective,
|
DropZoneDirective,
|
||||||
CdkAutoDropGroup,
|
CdkAutoDropGroup,
|
||||||
ProfileIconComponent,
|
ProfileIconComponent,
|
||||||
|
ProfileTreeComponent,
|
||||||
TabbyFormatedDatePipe,
|
TabbyFormatedDatePipe,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
||||||
|
|
@ -215,12 +215,36 @@ export class ProfilesService {
|
||||||
const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined
|
const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined
|
||||||
return {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
group: this.resolveProfileGroupName(profile.group ?? ''),
|
group: this.resolveProfileGroupPath(profile.group ?? '').join(' 🡒 '),
|
||||||
freeInputEquivalent,
|
freeInputEquivalent,
|
||||||
description: provider?.getDescription(fullProfile),
|
description: provider?.getDescription(fullProfile),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildGroupTree (groups: PartialProfileGroup<ProfileGroup & { children: any }>[]): PartialProfileGroup<ProfileGroup & { children: any }>[] {
|
||||||
|
const map = new Map<string, PartialProfileGroup<ProfileGroup & { children: any }>>()
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
group.children = []
|
||||||
|
map.set(group.id, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: PartialProfileGroup<ProfileGroup & { children: any }>[] = []
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.parentGroupId) {
|
||||||
|
const parent = map.get(group.parentGroupId)
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(group)
|
||||||
|
} else { roots.push(group) } // Orphaned group, treat as root
|
||||||
|
} else {
|
||||||
|
roots.push(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
showProfileSelector (): Promise<PartialProfile<Profile> | null> {
|
showProfileSelector (): Promise<PartialProfile<Profile> | null> {
|
||||||
if (this.selector.active) {
|
if (this.selector.active) {
|
||||||
return Promise.resolve(null)
|
return Promise.resolve(null)
|
||||||
|
|
@ -261,6 +285,12 @@ export class ProfilesService {
|
||||||
|
|
||||||
if (!this.config.store.terminal.showBuiltinProfiles) {
|
if (!this.config.store.terminal.showBuiltinProfiles) {
|
||||||
profiles = profiles.filter(x => !x.isBuiltin)
|
profiles = profiles.filter(x => !x.isBuiltin)
|
||||||
|
} else {
|
||||||
|
profiles = profiles.map(p => {
|
||||||
|
if (p.isBuiltin) { p.group = 'Built-in' }
|
||||||
|
if (!p.icon) { p.icon = 'fas fa-network-wired' }
|
||||||
|
return p
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles = profiles.filter(x => !x.isTemplate)
|
profiles = profiles.filter(x => !x.isTemplate)
|
||||||
|
|
@ -499,7 +529,37 @@ export class ProfilesService {
|
||||||
* Resolve and return ProfileGroup Name from ProfileGroup ID
|
* Resolve and return ProfileGroup Name from ProfileGroup ID
|
||||||
*/
|
*/
|
||||||
resolveProfileGroupName (groupId: string): string {
|
resolveProfileGroupName (groupId: string): string {
|
||||||
return this.config.store.groups.find(g => g.id === groupId)?.name ?? groupId
|
const group = this.resolveProfileGroup(groupId)
|
||||||
|
return group?.name ?? groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveProfileGroupPath (groupId: string): string[] {
|
||||||
|
const groupNames: string[] = []
|
||||||
|
let currentGroupId: string | undefined = groupId
|
||||||
|
let depth = 0
|
||||||
|
|
||||||
|
while (currentGroupId && depth <= 30) {
|
||||||
|
const group = this.resolveProfileGroup(currentGroupId)
|
||||||
|
if (!group) {
|
||||||
|
groupNames.unshift(currentGroupId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.name) { groupNames.unshift(group.name) }
|
||||||
|
|
||||||
|
if (!group.parentGroupId) { break }
|
||||||
|
currentGroupId = group.parentGroupId
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupNames
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve and return ProfileGroup | null from ProfileGroup ID
|
||||||
|
*/
|
||||||
|
resolveProfileGroup (groupId: string): PartialProfileGroup<ProfileGroup> | null {
|
||||||
|
return this.config.store.groups.find(g => g.id === groupId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ app-root {
|
||||||
background: rgba(var(--bs-dark-rgb),.65);
|
background: rgba(var(--bs-dark-rgb),.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
&> .content {
|
.main.content {
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
background: var(--theme-bg-more-2);
|
background: var(--theme-bg-more-2);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import * as fs from 'fs/promises'
|
||||||
import * as fsSync from 'fs'
|
import * as fsSync from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as glob from 'glob'
|
import * as glob from 'glob'
|
||||||
import slugify from 'slugify'
|
|
||||||
import * as yaml from 'js-yaml'
|
import * as yaml from 'js-yaml'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { PartialProfile } from 'tabby-core'
|
import { PartialProfile } from 'tabby-core'
|
||||||
|
|
@ -145,15 +144,24 @@ async function parseSSHConfigFile (
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to convert an SSH Profile name into a sha256 hash-based ID
|
||||||
|
async function hashSSHProfileName (name: string) {
|
||||||
|
const textEncoder = new TextEncoder()
|
||||||
|
const encoded = textEncoder.encode(name)
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', encoded)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hash))
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
// Function to take an ssh-config entry and convert it into an SSHProfile
|
// Function to take an ssh-config entry and convert it into an SSHProfile
|
||||||
function convertHostToSSHProfile (host: string, settings: Record<string, string | string[] | object[] >): PartialProfile<SSHProfile> {
|
async function convertHostToSSHProfile (host: string, settings: Record<string, string | string[] | object[] >): Promise<PartialProfile<SSHProfile>> {
|
||||||
|
|
||||||
// inline function to generate an id for this profile
|
// inline function to generate an id for this profile
|
||||||
const deriveID = (name: string) => 'openssh-config:' + slugify(name)
|
const deriveID = async (name: string) => 'openssh-config:' + await hashSSHProfileName(name)
|
||||||
|
|
||||||
// Start point of the profile, with an ID, name, type and group
|
// Start point of the profile, with an ID, name, type and group
|
||||||
const thisProfile: PartialProfile<SSHProfile> = {
|
const thisProfile: PartialProfile<SSHProfile> = {
|
||||||
id: deriveID(host),
|
id: await deriveID(host),
|
||||||
name: `${host} (.ssh/config)`,
|
name: `${host} (.ssh/config)`,
|
||||||
type: 'ssh',
|
type: 'ssh',
|
||||||
group: 'Imported from .ssh/config',
|
group: 'Imported from .ssh/config',
|
||||||
|
|
@ -194,7 +202,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
|
||||||
const basicString = settings[key]
|
const basicString = settings[key]
|
||||||
if (typeof basicString === 'string') {
|
if (typeof basicString === 'string') {
|
||||||
if (targetName === SSHProfilePropertyNames.JumpHost) {
|
if (targetName === SSHProfilePropertyNames.JumpHost) {
|
||||||
options[targetName] = deriveID(basicString)
|
options[targetName] = await deriveID(basicString)
|
||||||
} else {
|
} else {
|
||||||
options[targetName] = basicString
|
options[targetName] = basicString
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +303,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
|
||||||
return thisProfile
|
return thisProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[] {
|
async function convertToSSHProfiles (config: SSHConfig): Promise<PartialProfile<SSHProfile>[]> {
|
||||||
const myMap = new Map<string, PartialProfile<SSHProfile>>()
|
const myMap = new Map<string, PartialProfile<SSHProfile>>()
|
||||||
|
|
||||||
function noWildCardsInName (name: string) {
|
function noWildCardsInName (name: string) {
|
||||||
|
|
@ -333,7 +341,7 @@ function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[]
|
||||||
// NOTE: SSHConfig.compute() lies about the return types
|
// NOTE: SSHConfig.compute() lies about the return types
|
||||||
const configuration: Record<string, string | string[] | object[]> = config.compute(host)
|
const configuration: Record<string, string | string[] | object[]> = config.compute(host)
|
||||||
if (Object.keys(configuration).map(key => key.toLowerCase()).includes('hostname')) {
|
if (Object.keys(configuration).map(key => key.toLowerCase()).includes('hostname')) {
|
||||||
myMap[host] = convertHostToSSHProfile(host, configuration)
|
myMap[host] = await convertHostToSSHProfile(host, configuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +362,7 @@ export class OpenSSHImporter extends SSHProfileImporter {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config: SSHConfig = await parseSSHConfigFile(configPath)
|
const config: SSHConfig = await parseSSHConfigFile(configPath)
|
||||||
return convertToSSHProfiles(config)
|
return await convertToSSHProfiles(config)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'ENOENT') {
|
if (e.code === 'ENOENT') {
|
||||||
return []
|
return []
|
||||||
|
|
@ -376,7 +384,7 @@ export class StaticFileImporter extends SSHProfileImporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProfiles (): Promise<PartialProfile<SSHProfile>[]> {
|
async getProfiles (): Promise<PartialProfile<SSHProfile>[]> {
|
||||||
const deriveID = name => 'file-config:' + slugify(name)
|
const deriveID = async name => 'file-config:' + await hashSSHProfileName(name)
|
||||||
|
|
||||||
if (!fsSync.existsSync(this.configPath)) {
|
if (!fsSync.existsSync(this.configPath)) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -387,11 +395,11 @@ export class StaticFileImporter extends SSHProfileImporter {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return (yaml.load(content) as PartialProfile<SSHProfile>[]).map(item => ({
|
return Promise.all((yaml.load(content) as PartialProfile<SSHProfile>[]).map(async item => ({
|
||||||
...item,
|
...item,
|
||||||
id: deriveID(item.name),
|
id: await deriveID(item.name),
|
||||||
type: 'ssh',
|
type: 'ssh',
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,56 @@
|
||||||
[(ngModel)]='group.name',
|
[(ngModel)]='group.name',
|
||||||
)
|
)
|
||||||
|
|
||||||
.col-12.col-lg-8
|
.mb-3
|
||||||
|
label(translate) Parent Group
|
||||||
|
input.form-control(
|
||||||
|
type='text',
|
||||||
|
alwaysVisibleTypeahead,
|
||||||
|
placeholder='Ungrouped',
|
||||||
|
[(ngModel)]='selectedParentGroup',
|
||||||
|
[ngbTypeahead]='groupTypeahead',
|
||||||
|
[inputFormatter]="groupFormatter",
|
||||||
|
[resultFormatter]="groupFormatter",
|
||||||
|
[editable]="false"
|
||||||
|
)
|
||||||
|
|
||||||
|
.mb-3
|
||||||
|
label(translate) Icon
|
||||||
|
.input-group
|
||||||
|
input.form-control(
|
||||||
|
type='text',
|
||||||
|
alwaysVisibleTypeahead,
|
||||||
|
[(ngModel)]='group.icon',
|
||||||
|
[ngbTypeahead]='iconSearch',
|
||||||
|
[resultTemplate]='rt'
|
||||||
|
)
|
||||||
|
.input-group-text
|
||||||
|
profile-icon(
|
||||||
|
[icon]='group.icon',
|
||||||
|
[color]='group.color'
|
||||||
|
)
|
||||||
|
|
||||||
|
ng-template(#rt,let-r='result',let-t='term')
|
||||||
|
i([class]='"fa-fw " + r')
|
||||||
|
ngb-highlight.ms-2([result]='r', [term]='t')
|
||||||
|
|
||||||
|
.mb-3
|
||||||
|
label(translate) Color
|
||||||
|
.input-group
|
||||||
|
input.form-control.color-picker(
|
||||||
|
type='color',
|
||||||
|
[(ngModel)]='group.color',
|
||||||
|
[ngbTypeahead]='colorsAutocomplete',
|
||||||
|
[resultFormatter]='colorsFormatter'
|
||||||
|
)
|
||||||
|
input.form-control(
|
||||||
|
type='text',
|
||||||
|
[(ngModel)]='group.color',
|
||||||
|
[ngbTypeahead]='colorsAutocomplete',
|
||||||
|
[resultFormatter]='colorsFormatter'
|
||||||
|
)
|
||||||
|
|
||||||
|
.col-12.col-lg-8(*ngIf='providers.length !== 0')
|
||||||
.form-line.content-box
|
.form-line.content-box
|
||||||
.header
|
.header
|
||||||
.title(translate) Default profile group settings
|
.title(translate) Default profile group settings
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||||
import { Component, Input } from '@angular/core'
|
import { Component, Input } from '@angular/core'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, TranslateService } from 'tabby-core'
|
import { Observable, OperatorFunction, debounceTime, map, distinctUntilChanged } from 'rxjs'
|
||||||
|
import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, TranslateService, PartialProfileGroup, ProfilesService, TAB_COLORS } 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 */
|
/** @hidden */
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -10,14 +18,97 @@ import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, T
|
||||||
export class EditProfileGroupModalComponent<G extends ProfileGroup> {
|
export class EditProfileGroupModalComponent<G extends ProfileGroup> {
|
||||||
@Input() group: G & ConfigProxy
|
@Input() group: G & ConfigProxy
|
||||||
@Input() providers: ProfileProvider<Profile>[]
|
@Input() providers: ProfileProvider<Profile>[]
|
||||||
|
@Input() selectedParentGroup: PartialProfileGroup<ProfileGroup> | undefined
|
||||||
|
groups: PartialProfileGroup<ProfileGroup>[]
|
||||||
|
|
||||||
|
getValidParents (groups: PartialProfileGroup<ProfileGroup>[], targetId: string): PartialProfileGroup<ProfileGroup>[] {
|
||||||
|
// Build a quick lookup: parentGroupId -> [child groups]
|
||||||
|
const childrenMap = new Map<string | null, string[]>()
|
||||||
|
for (const group of groups) {
|
||||||
|
const parent = group.parentGroupId ?? null
|
||||||
|
if (!childrenMap.has(parent)) {
|
||||||
|
childrenMap.set(parent, [])
|
||||||
|
}
|
||||||
|
childrenMap.get(parent)!.push(group.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth-first search to find all descendants of target
|
||||||
|
function getDescendants (id: string): Set<string> {
|
||||||
|
const descendants = new Set<string>()
|
||||||
|
const stack: string[] = [id]
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!
|
||||||
|
const children = childrenMap.get(current)
|
||||||
|
if (children) {
|
||||||
|
for (const child of children) {
|
||||||
|
if (!descendants.has(child)) {
|
||||||
|
descendants.add(child)
|
||||||
|
stack.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return descendants
|
||||||
|
}
|
||||||
|
|
||||||
|
const descendants = getDescendants(targetId)
|
||||||
|
|
||||||
|
// Valid parents = all groups that are not the target or its descendants
|
||||||
|
return groups.filter((g) => g.id !== targetId && !descendants.has(g.id))
|
||||||
|
}
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private modalInstance: NgbActiveModal,
|
private modalInstance: NgbActiveModal,
|
||||||
|
private profilesService: ProfilesService,
|
||||||
private platform: PlatformService,
|
private platform: PlatformService,
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
) {}
|
) {
|
||||||
|
this.profilesService.getProfileGroups().then(groups => {
|
||||||
|
this.groups = this.getValidParents(groups, this.group.id)
|
||||||
|
this.selectedParentGroup = groups.find(g => g.id === this.group.parentGroupId) ?? undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
save () {
|
colorsAutocomplete = text$ => text$.pipe(
|
||||||
|
debounceTime(200),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((q: string) =>
|
||||||
|
TAB_COLORS
|
||||||
|
.filter(x => !q || x.name.toLowerCase().startsWith(q.toLowerCase()))
|
||||||
|
.map(x => x.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
colorsFormatter = value => {
|
||||||
|
return TAB_COLORS.find(x => x.value === value)?.name ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
groupTypeahead: OperatorFunction<string, readonly PartialProfileGroup<ProfileGroup>[]> = (text$: Observable<string>) =>
|
||||||
|
text$.pipe(
|
||||||
|
debounceTime(200),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map(q => this.groups.filter(g => !q || g.name.toLowerCase().includes(q.toLowerCase()))),
|
||||||
|
)
|
||||||
|
|
||||||
|
groupFormatter = (g: PartialProfileGroup<ProfileGroup>) => g.name
|
||||||
|
|
||||||
|
iconSearch: OperatorFunction<string, string[]> = (text$: Observable<string>) =>
|
||||||
|
text$.pipe(
|
||||||
|
debounceTime(200),
|
||||||
|
map(term => iconsClassList.filter(v => v.toLowerCase().includes(term.toLowerCase())).slice(0, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async save () {
|
||||||
|
if (!this.selectedParentGroup) {
|
||||||
|
this.group.parentGroupId = undefined
|
||||||
|
} else {
|
||||||
|
this.group.parentGroupId = this.selectedParentGroup.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.group.id === 'new') {
|
||||||
|
await this.profilesService.newProfileGroup(this.group, { genId: true })
|
||||||
|
}
|
||||||
this.modalInstance.close({ group: this.group })
|
this.modalInstance.close({ group: this.group })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,85 +41,37 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
|
||||||
i.fas.fa-fw.fa-plus
|
i.fas.fa-fw.fa-plus
|
||||||
span(translate) New profile Group
|
span(translate) New profile Group
|
||||||
|
|
||||||
.list-group.mt-3.mb-3
|
.d-flex.flex-column.my-3.p-2.collapse-container
|
||||||
ng-container(*ngFor='let group of profileGroups')
|
ng-container(*ngFor='let group of rootGroups')
|
||||||
ng-container(*ngIf='isGroupVisible(group)')
|
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: group}')
|
||||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
|
||||||
(click)='toggleGroupCollapse(group)'
|
ng-template(#recursiveGroup let-group)
|
||||||
)
|
.collapse-item.d-flex.align-items-center.p-1((click)='toggleGroupCollapse(group)')
|
||||||
.fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed && group.profiles?.length > 0')
|
.fa.fa-fw.far.fa-folder.ms-1(*ngIf='group.collapsed')
|
||||||
.fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed && group.profiles?.length > 0')
|
.fa.fa-fw.far.fa-folder-open.ms-1(*ngIf='!group.collapsed')
|
||||||
|
//- profile-icon.ms-1([icon]='group.icon', [color]='group.color')
|
||||||
span.ms-3.me-auto {{ group.name || ("Ungrouped"|translate) }}
|
span.ms-3.me-auto {{ group.name || ("Ungrouped"|translate) }}
|
||||||
button.btn.btn-sm.btn-link.hover-reveal.ms-2(
|
|
||||||
*ngIf='group.editable && group.name',
|
button.btn.btn-sm.btn-link.hover-reveal.ms-2(*ngIf='group.editable && group.name', (click)='$event.stopPropagation(); editProfileGroup(group)')
|
||||||
(click)='$event.stopPropagation(); editProfileGroup(group)'
|
|
||||||
)
|
|
||||||
i.fas.fa-pencil-alt
|
i.fas.fa-pencil-alt
|
||||||
button.btn.btn-sm.btn-link.hover-reveal.ms-2(
|
button.btn.btn-sm.btn-link.hover-reveal.ms-2(*ngIf='group.editable && group.name', (click)='$event.stopPropagation(); deleteProfileGroup(group)')
|
||||||
*ngIf='group.editable && group.name',
|
|
||||||
(click)='$event.stopPropagation(); deleteProfileGroup(group)'
|
|
||||||
)
|
|
||||||
i.fas.fa-trash-alt
|
i.fas.fa-trash-alt
|
||||||
|
|
||||||
ng-container(*ngIf='!group.collapsed')
|
ng-container(*ngIf='!group.collapsed')
|
||||||
ng-container(*ngFor='let profile of group.profiles')
|
ng-container(*ngFor='let profile of group.profiles')
|
||||||
.list-group-item.ps-5.d-flex.align-items-center(
|
.collapse-item.d-flex.align-items-center.p-1.ps-4(*ngIf='isProfileVisible(profile)', [class.list-group-item-action]='!profile.isBuiltin', (click)='profile.isBuiltin ? null : editProfile(profile)')
|
||||||
*ngIf='isProfileVisible(profile)',
|
profile-icon.ms-1([icon]='profile.icon', [color]='profile.color')
|
||||||
[class.list-group-item-action]='!profile.isBuiltin',
|
span.ms-3.no-wrap {{ profile.name }}
|
||||||
(click)='profile.isBuiltin ? null : editProfile(profile)'
|
|
||||||
)
|
|
||||||
profile-icon(
|
|
||||||
[icon]='profile.icon',
|
|
||||||
[color]='profile.color'
|
|
||||||
)
|
|
||||||
|
|
||||||
.no-wrap {{profile.name}}
|
|
||||||
.text-muted.no-wrap.ms-2 {{ getDescription(profile) }}
|
.text-muted.no-wrap.ms-2 {{ getDescription(profile) }}
|
||||||
|
|
||||||
.me-auto
|
.me-auto
|
||||||
|
|
||||||
button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)')
|
button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)')
|
||||||
i.fas.fa-play
|
i.fas.fa-play
|
||||||
|
|
||||||
.ms-1.hover-reveal(ngbDropdown, placement='bottom-right top-right auto')
|
.mx-2(class='badge text-bg-{{getTypeColorClass(profile)}}') {{ getTypeLabel(profile) }}
|
||||||
button.btn.btn-link.ms-1(
|
|
||||||
ngbDropdownToggle,
|
|
||||||
(click)='$event.stopPropagation()'
|
|
||||||
)
|
|
||||||
i.fas.fa-fw.fa-ellipsis-vertical
|
|
||||||
div(ngbDropdownMenu)
|
|
||||||
button.dropdown-item(
|
|
||||||
ngbDropdownItem,
|
|
||||||
(click)='$event.stopPropagation(); newProfile(profile)'
|
|
||||||
)
|
|
||||||
i.fas.fa-fw.fa-copy
|
|
||||||
span(translate) Duplicate
|
|
||||||
|
|
||||||
button.dropdown-item(
|
ng-container(*ngFor='let child of group.children')
|
||||||
ngbDropdownItem,
|
.ps-4
|
||||||
*ngIf='profile.id && !isProfileBlacklisted(profile)',
|
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: child}')
|
||||||
(click)='$event.stopPropagation(); blacklistProfile(profile)'
|
|
||||||
)
|
|
||||||
i.fas.fa-fw.fa-eye-slash
|
|
||||||
span(translate) Hide
|
|
||||||
|
|
||||||
button.dropdown-item(
|
|
||||||
ngbDropdownItem,
|
|
||||||
*ngIf='profile.id && isProfileBlacklisted(profile)',
|
|
||||||
(click)='$event.stopPropagation(); unblacklistProfile(profile)'
|
|
||||||
)
|
|
||||||
i.fas.fa-fw.fa-eye
|
|
||||||
span(translate) Show
|
|
||||||
|
|
||||||
button.dropdown-item(
|
|
||||||
*ngIf='!profile.isBuiltin',
|
|
||||||
(click)='$event.stopPropagation(); deleteProfile(profile)'
|
|
||||||
)
|
|
||||||
i.fas.fa-fw.fa-trash-alt
|
|
||||||
span(translate) Delete
|
|
||||||
|
|
||||||
.ms-1(class='badge text-bg-{{getTypeColorClass(profile)}}') {{getTypeLabel(profile)}}
|
|
||||||
|
|
||||||
.ms-1.text-danger.fas.fa-eye-slash(*ngIf='isProfileBlacklisted(profile)')
|
|
||||||
|
|
||||||
li(ngbNavItem)
|
li(ngbNavItem)
|
||||||
a(ngbNavLink, translate) Advanced
|
a(ngbNavLink, translate) Advanced
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
profile-icon {
|
profile-icon {
|
||||||
width: 1.25rem;
|
width: 1rem;
|
||||||
margin-right: 0.25rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
profile-icon + * {
|
.collapse-container {
|
||||||
margin-left: 10px;
|
background-color: var(--theme-bg-less);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-item {
|
||||||
|
height: 2.25rem;
|
||||||
|
background-color: var(--theme-bg-less);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-item:hover {
|
||||||
|
background-color: var(--theme-bg-less-2);
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'
|
||||||
import deepClone from 'clone-deep'
|
import deepClone from 'clone-deep'
|
||||||
import { Component, Inject } from '@angular/core'
|
import { Component, Inject } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent, PartialProfile, ProfileProvider, TranslateService, Platform, ProfileGroup, PartialProfileGroup, QuickConnectProfileProvider } from 'tabby-core'
|
import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PlatformService, BaseComponent, PartialProfile, ProfileProvider, TranslateService, Platform, ProfileGroup, PartialProfileGroup, QuickConnectProfileProvider } from 'tabby-core'
|
||||||
import { EditProfileModalComponent } from './editProfileModal.component'
|
import { EditProfileModalComponent } from './editProfileModal.component'
|
||||||
import { EditProfileGroupModalComponent, EditProfileGroupModalComponentResult } from './editProfileGroupModal.component'
|
import { EditProfileGroupModalComponent, EditProfileGroupModalComponentResult } from './editProfileGroupModal.component'
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ _('Ungrouped')
|
||||||
|
|
||||||
interface CollapsableProfileGroup extends ProfileGroup {
|
interface CollapsableProfileGroup extends ProfileGroup {
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
|
children: PartialProfileGroup<CollapsableProfileGroup>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @hidden */
|
/** @hidden */
|
||||||
|
|
@ -24,6 +25,8 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||||
templateProfiles: PartialProfile<Profile>[] = []
|
templateProfiles: PartialProfile<Profile>[] = []
|
||||||
customProfiles: PartialProfile<Profile>[] = []
|
customProfiles: PartialProfile<Profile>[] = []
|
||||||
profileGroups: PartialProfileGroup<CollapsableProfileGroup>[]
|
profileGroups: PartialProfileGroup<CollapsableProfileGroup>[]
|
||||||
|
rootGroups: PartialProfileGroup<CollapsableProfileGroup>[] = []
|
||||||
|
|
||||||
filter = ''
|
filter = ''
|
||||||
Platform = Platform
|
Platform = Platform
|
||||||
|
|
||||||
|
|
@ -147,13 +150,11 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async newProfileGroup (): Promise<void> {
|
async newProfileGroup (): Promise<void> {
|
||||||
const modal = this.ngbModal.open(PromptModalComponent)
|
this.editProfileGroup({
|
||||||
modal.componentInstance.prompt = this.translate.instant('New group name')
|
id: 'new',
|
||||||
const result = await modal.result.catch(() => null)
|
name: '',
|
||||||
if (result?.value.trim()) {
|
icon: 'far fa-folder',
|
||||||
await this.profilesService.newProfileGroup({ id: '', name: result.value })
|
})
|
||||||
await this.config.save()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async editProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<void> {
|
async editProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<void> {
|
||||||
|
|
@ -161,6 +162,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result))
|
await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result))
|
||||||
await this.config.save()
|
await this.config.save()
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +256,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||||
groups.sort((a, b) => (a.id === 'built-in' || !a.editable ? 1 : 0) - (b.id === 'built-in' || !b.editable ? 1 : 0))
|
groups.sort((a, b) => (a.id === 'built-in' || !a.editable ? 1 : 0) - (b.id === 'built-in' || !b.editable ? 1 : 0))
|
||||||
groups.sort((a, b) => (a.id === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1))
|
groups.sort((a, b) => (a.id === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1))
|
||||||
this.profileGroups = groups.map(g => ProfilesSettingsTabComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false))
|
this.profileGroups = groups.map(g => ProfilesSettingsTabComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false))
|
||||||
|
this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
isGroupVisible (group: PartialProfileGroup<ProfileGroup>): boolean {
|
isGroupVisible (group: PartialProfileGroup<ProfileGroup>): boolean {
|
||||||
|
|
@ -286,9 +289,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
|
toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
|
||||||
if (group.profiles?.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
group.collapsed = !group.collapsed
|
group.collapsed = !group.collapsed
|
||||||
this.saveProfileGroupCollapse(group)
|
this.saveProfileGroupCollapse(group)
|
||||||
}
|
}
|
||||||
|
|
@ -364,6 +364,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
|
||||||
private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): PartialProfileGroup<ProfileGroup> {
|
private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): PartialProfileGroup<ProfileGroup> {
|
||||||
const g: any = { ...group }
|
const g: any = { ...group }
|
||||||
delete g.collapsed
|
delete g.collapsed
|
||||||
|
delete g.children
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,15 @@ h3.mb-3(translate) Window
|
||||||
(ngModelChange)='saveConfiguration(true)'
|
(ngModelChange)='saveConfiguration(true)'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
.form-line
|
||||||
|
.header
|
||||||
|
.title(translate) Hide profile sidebar
|
||||||
|
.description(translate) Hide profile selector sidebar.
|
||||||
|
toggle(
|
||||||
|
[(ngModel)]='config.store.hideProfileTree',
|
||||||
|
(ngModelChange)='saveConfiguration(false)'
|
||||||
|
)
|
||||||
|
|
||||||
h3.mt-4(translate) Docking
|
h3.mt-4(translate) Docking
|
||||||
|
|
||||||
.form-line(*ngIf='docking')
|
.form-line(*ngIf='docking')
|
||||||
|
|
|
||||||
|
|
@ -84,4 +84,8 @@ export default class SettingsModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from './api'
|
export * from './api'
|
||||||
export { SettingsTabComponent }
|
export {
|
||||||
|
SettingsTabComponent,
|
||||||
|
EditProfileModalComponent,
|
||||||
|
EditProfileGroupModalComponent,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@ export const defaultAlgorithms = {
|
||||||
'hmac-sha1',
|
'hmac-sha1',
|
||||||
],
|
],
|
||||||
[SSHAlgorithmType.COMPRESSION]: [
|
[SSHAlgorithmType.COMPRESSION]: [
|
||||||
'zlib@openssh.com',
|
|
||||||
'zlib',
|
|
||||||
'none',
|
'none',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,89 @@ export class SSHService {
|
||||||
return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
|
return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<string> {
|
async generateWinSCPXTunnelURI (jumpHostProfile: SSHProfile|null): Promise<{ uri: string|null, privateKeyFile?: tmp.FileResult|null }> {
|
||||||
|
let uri = ''
|
||||||
|
let tmpFile: tmp.FileResult|null = null
|
||||||
|
if (jumpHostProfile) {
|
||||||
|
uri += ';x-tunnel=1'
|
||||||
|
const jumpHostname = jumpHostProfile.options.host
|
||||||
|
uri += `;x-tunnelhostname=${jumpHostname}`
|
||||||
|
const jumpPort = jumpHostProfile.options.port ?? 22
|
||||||
|
uri += `;x-tunnelportnumber=${jumpPort}`
|
||||||
|
const jumpUsername = jumpHostProfile.options.user
|
||||||
|
uri += `;x-tunnelusername=${jumpUsername}`
|
||||||
|
if (jumpHostProfile.options.auth === 'password') {
|
||||||
|
const jumpPassword = await this.passwordStorage.loadPassword(jumpHostProfile, jumpUsername)
|
||||||
|
if (jumpPassword) {
|
||||||
|
uri += `;x-tunnelpasswordplain=${encodeURIComponent(jumpPassword)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jumpHostProfile.options.auth === 'publicKey' && jumpHostProfile.options.privateKeys && jumpHostProfile.options.privateKeys.length > 0) {
|
||||||
|
const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(jumpHostProfile)
|
||||||
|
tmpFile = privateKeyPairs.privateKeyFile
|
||||||
|
if (tmpFile) {
|
||||||
|
uri += `;x-tunnelpublickeyfile=${encodeURIComponent(tmpFile.path)}`
|
||||||
|
}
|
||||||
|
if (privateKeyPairs.passphrase != null) {
|
||||||
|
uri += `;x-tunnelpassphraseplain=${encodeURIComponent(privateKeyPairs.passphrase)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { uri: uri, privateKeyFile: tmpFile?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<{ uri: string, privateKeyFile?: tmp.FileResult|null }> {
|
||||||
let uri = `scp://${username ?? profile.options.user}`
|
let uri = `scp://${username ?? profile.options.user}`
|
||||||
const password = await this.passwordStorage.loadPassword(profile, username)
|
const password = await this.passwordStorage.loadPassword(profile, username)
|
||||||
if (password) {
|
if (password) {
|
||||||
uri += ':' + encodeURIComponent(password)
|
uri += ':' + encodeURIComponent(password)
|
||||||
}
|
}
|
||||||
|
let tmpFile: tmp.FileResult|null = null
|
||||||
|
if (profile.options.jumpHost) {
|
||||||
|
const jumpHostProfile = this.config.store.profiles.find(x => x.id === profile.options.jumpHost) ?? null
|
||||||
|
const xTunnelParams = await this.generateWinSCPXTunnelURI(jumpHostProfile)
|
||||||
|
uri += xTunnelParams.uri ?? ''
|
||||||
|
tmpFile = xTunnelParams.privateKeyFile ?? null
|
||||||
|
}
|
||||||
if (profile.options.host.includes(':')) {
|
if (profile.options.host.includes(':')) {
|
||||||
uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}`
|
uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}`
|
||||||
}else {
|
}else {
|
||||||
uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}`
|
uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}`
|
||||||
}
|
}
|
||||||
return uri
|
return { uri, privateKeyFile: tmpFile?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertPrivateKeyFileToPuTTYFormat (profile: SSHProfile): Promise<{ passphrase: string|null, privateKeyFile: tmp.FileResult|null }> {
|
||||||
|
if (!profile.options.privateKeys || profile.options.privateKeys.length === 0) {
|
||||||
|
throw new Error('No private keys in profile')
|
||||||
|
}
|
||||||
|
const path = this.getWinSCPPath()
|
||||||
|
if (!path) {
|
||||||
|
throw new Error('WinSCP not found')
|
||||||
|
}
|
||||||
|
let tmpPrivateKeyFile: tmp.FileResult|null = null
|
||||||
|
let passphrase: string|null = null
|
||||||
|
const tmpFile: tmp.FileResult = await tmp.file()
|
||||||
|
for (const pk of profile.options.privateKeys) {
|
||||||
|
let privateKeyContent: string|null = null
|
||||||
|
const buffer = await this.fileProviders.retrieveFile(pk)
|
||||||
|
privateKeyContent = buffer.toString()
|
||||||
|
await fs.writeFile(tmpFile.path, privateKeyContent)
|
||||||
|
const keyHash = crypto.createHash('sha512').update(privateKeyContent).digest('hex')
|
||||||
|
// need to pass an default passphrase, otherwise it might get stuck at the passphrase input
|
||||||
|
const curPassphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) ?? 'tabby'
|
||||||
|
const winSCPcom = path.slice(0, -3) + 'com'
|
||||||
|
try {
|
||||||
|
await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, '-o', tmpFile.path, '--old-passphrase', curPassphrase])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not convert private key ', error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tmpPrivateKeyFile = tmpFile
|
||||||
|
passphrase = curPassphrase
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return { passphrase, privateKeyFile: tmpPrivateKeyFile }
|
||||||
}
|
}
|
||||||
|
|
||||||
async launchWinSCP (session: SSHSession): Promise<void> {
|
async launchWinSCP (session: SSHSession): Promise<void> {
|
||||||
|
|
@ -46,38 +117,26 @@ export class SSHService {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const args = [await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)]
|
const winscpParms = await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)
|
||||||
|
const args = [winscpParms.uri]
|
||||||
|
|
||||||
let tmpFile: tmp.FileResult|null = null
|
let tmpFile: tmp.FileResult|null = null
|
||||||
try {
|
try {
|
||||||
if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) {
|
if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) {
|
||||||
tmpFile = await tmp.file()
|
const profile = session.profile
|
||||||
let passphrase: string|null = null
|
const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(profile)
|
||||||
for (const pk of session.profile.options.privateKeys) {
|
tmpFile = privateKeyPairs.privateKeyFile
|
||||||
let privateKeyContent: string|null = null
|
if (tmpFile) {
|
||||||
const buffer = await this.fileProviders.retrieveFile(pk)
|
|
||||||
privateKeyContent = buffer.toString()
|
|
||||||
await fs.writeFile(tmpFile.path, privateKeyContent)
|
|
||||||
const keyHash = crypto.createHash('sha512').update(privateKeyContent).digest('hex')
|
|
||||||
// need to pass an default passphrase, otherwise it might get stuck at the passphrase input
|
|
||||||
passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) ?? 'tabby'
|
|
||||||
const winSCPcom = path.slice(0, -3) + 'com'
|
|
||||||
try {
|
|
||||||
await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, '-o', tmpFile.path, '--old-passphrase', passphrase])
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not convert private key ', error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
args.push(`/privatekey=${tmpFile.path}`)
|
args.push(`/privatekey=${tmpFile.path}`)
|
||||||
if (passphrase != null) {
|
}
|
||||||
args.push(`/passphrase=${passphrase}`)
|
if (privateKeyPairs.passphrase != null) {
|
||||||
|
args.push(`/passphrase=${privateKeyPairs.passphrase}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.platform.exec(path, args)
|
await this.platform.exec(path, args)
|
||||||
} finally {
|
} finally {
|
||||||
tmpFile?.cleanup()
|
tmpFile?.cleanup()
|
||||||
|
winscpParms.privateKeyFile?.cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue