Compare commits

...

18 Commits

Author SHA1 Message Date
D3VL Jack f4b0a1c7a5
Merge 77f179ee13 into 2cf6ef0036 2025-11-11 12:17:14 +01:00
Indigo.liu 2cf6ef0036
Add support to connect in winscp throug tunnel. (#10829)
Package-Build / Lint (push) Has been cancelled Details
Docs / build (push) Has been cancelled Details
Package-Build / macOS-Build (arm64, aarch64-apple-darwin) (push) Has been cancelled Details
Package-Build / macOS-Build (x86_64, x86_64-apple-darwin) (push) Has been cancelled Details
Package-Build / Linux-Build (amd64, x64, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled Details
Package-Build / Linux-Build (arm64, arm64, ubuntu-24.04-arm, aarch64-unknown-linux-gnu, aarch64-linux-gnu-) (push) Has been cancelled Details
Package-Build / Linux-Build (armhf, arm, ubuntu-24.04, arm-unknown-linux-gnueabihf, arm-linux-gnueabihf-) (push) Has been cancelled Details
Package-Build / Windows-Build (arm64, aarch64-pc-windows-msvc) (push) Has been cancelled Details
Package-Build / Windows-Build (x64, x86_64-pc-windows-msvc) (push) Has been cancelled Details
CodeQL / Analyze (javascript) (push) Has been cancelled Details
2025-11-11 12:14:42 +01:00
Kairlec 7a7d3d2b77
fix: unexpected configuration was selected in the recent profiles (#10817) 2025-11-11 12:09:09 +01:00
Eugene 4f14e92e6a
bump node-pty 2025-11-11 11:54:47 +01:00
Eugene 52463555ab
Disable SSH compression by default as a workaround for SFTP upload bugs 2025-11-11 11:35:01 +01:00
D3VL Jack 77f179ee13 eslint '?' 2025-10-08 01:57:17 +01:00
D3VL Jack 11e5674cf0 Eslint cleanup 2025-10-08 01:40:28 +01:00
D3VL Jack cfee1760bd Bump initial window width 2025-10-08 01:38:22 +01:00
D3VL Jack edb863b200 Fixed saving collapsed & children to config file 2025-10-08 01:07:02 +01:00
D3VL Jack cc0391f2e4 missing lines 2025-10-08 00:54:34 +01:00
D3VL Jack ed174d3128 Add setting to hide profile tree 2025-10-08 00:36:36 +01:00
D3VL Jack 868a509bb0 Add profile tree sidebar
Introduces a new ProfileTreeComponent with associated template and styles, displaying profile groups and profiles in a collapsible sidebar with filtering and resizing capabilities. Integrates the sidebar into the main app layout and updates module exports to support profile and group editing modals.
2025-10-08 00:25:07 +01:00
D3VL Jack 0a9788a91a Remove PromptModalComponent import 2025-10-05 23:41:20 +01:00
D3VL Jack f4c54245a2 add .main class to top .container
Groundwork for adding more panels to the app-root
Allows for better targeting than relying on the hierarchy of app-root>.content existing
2025-10-05 22:42:12 +01:00
D3VL Jack c383728cc7 Add full group paths to profile selector 2025-10-05 21:48:45 +01:00
D3VL Jack f43731d9dc Moved buildGroupTree to profiles.service
Moved buildGroupTree to profiles.service to allow for reusability
2025-10-05 19:03:05 +01:00
D3VL Jack 86d3710c0e Improved group creation flow
Re added the ability to set group defaults when creating a group
Moved delete group children to match group collapsed deletion
2025-10-05 18:48:28 +01:00
D3VL Jack 24809bc33b Add hierarchical profile groups with icons and colors
Introduces support for nested profile groups, allowing groups to have parent groups, icons, and colors. Updates the UI to display groups in a collapsible tree structure, adds icon and color pickers to the group edit modal, and refactors group creation and editing logic to support the new hierarchy.
2025-10-05 01:00:45 +01:00
22 changed files with 927 additions and 242 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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==

View File

@ -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

View File

@ -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"',

View File

@ -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;

View File

@ -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)"
)

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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: [

View File

@ -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
} }
/** /**

View File

@ -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);

View File

@ -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',
})) })))
} }
} }

View File

@ -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

View File

@ -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 })
} }

View File

@ -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

View File

@ -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);
} }

View File

@ -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
} }

View File

@ -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')

View File

@ -84,4 +84,8 @@ export default class SettingsModule {
} }
export * from './api' export * from './api'
export { SettingsTabComponent } export {
SettingsTabComponent,
EditProfileModalComponent,
EditProfileGroupModalComponent,
}

View File

@ -45,8 +45,6 @@ export const defaultAlgorithms = {
'hmac-sha1', 'hmac-sha1',
], ],
[SSHAlgorithmType.COMPRESSION]: [ [SSHAlgorithmType.COMPRESSION]: [
'zlib@openssh.com',
'zlib',
'none', 'none',
], ],
} }

View File

@ -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()
} }
} }
} }