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 bwOptions: BrowserWindowConstructorOptions = {
width: 800,
width: 1150,
height: 600,
title: 'Tabby',
minWidth: 400,

View File

@ -16,7 +16,7 @@
},
"dependencies": {
"@electron/remote": "^2",
"node-pty": "^1.1.0-beta34",
"node-pty": "^1.1.0-beta39",
"any-promise": "^1.3.0",
"electron-config": "2.0.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"
which "^4.0.0"
node-pty@^1.1.0-beta34:
node-pty@^1.1.0-beta39:
version "1.1.0-beta9"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d"
integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==

View File

@ -37,6 +37,9 @@ export type PartialProfile<T extends Profile> = Omit<Omit<Omit<{
export interface ProfileGroup {
id: string
parentGroupId?: string
icon?: string
color?: string
name: string
profiles: PartialProfile<Profile>[]
defaults: any

View File

@ -5,107 +5,113 @@ title-bar(
[class.inset]='hostApp.platform == Platform.macOS && !hostWindow.isFullscreen'
)
.content(
*ngIf='ready',
[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-titlebar-enabled]='isTitleBarNeeded()',
[class.tabs-on-right]='hasVerticalTabs() && config.store.appearance.tabsLocation == "right"',
)
.tab-bar(
*ngIf='!hostWindow.isFullscreen || config.store.appearance.tabsInFullscreen',
[class.tab-bar-no-controls-overlay]='hostApp.platform == Platform.macOS',
(dblclick)='!isTitleBarNeeded() && toggleMaximize()'
.window.h-100.d-flex
profile-tree(
*ngIf='ready && !config.store.hideProfileTree'
)
.inset.background(*ngIf='hostApp.platform == Platform.macOS \
&& !hostWindow.isFullscreen \
&& config.store.appearance.frame == "thin" \
&& (config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left")')
.tabs(
cdkDropList,
[cdkDropListOrientation]='(config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "bottom") ? "horizontal" : "vertical"',
(cdkDropListDropped)='onTabsReordered($event)',
cdkAutoDropGroup='app-tabs'
.content.main.h-100(
*ngIf='ready',
[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-titlebar-enabled]='isTitleBarNeeded()',
[class.tabs-on-right]='hasVerticalTabs() && config.store.appearance.tabsLocation == "right"',
)
.tab-bar(
*ngIf='!hostWindow.isFullscreen || config.store.appearance.tabsInFullscreen',
[class.tab-bar-no-controls-overlay]='hostApp.platform == Platform.macOS',
(dblclick)='!isTitleBarNeeded() && toggleMaximize()'
)
tab-header(
*ngFor='let tab of app.tabs; let idx = index',
[index]='idx',
[tab]='tab',
.inset.background(*ngIf='hostApp.platform == Platform.macOS \
&& !hostWindow.isFullscreen \
&& config.store.appearance.frame == "thin" \
&& (config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left")')
.tabs(
cdkDropList,
[cdkDropListOrientation]='(config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "bottom") ? "horizontal" : "vertical"',
(cdkDropListDropped)='onTabsReordered($event)',
cdkAutoDropGroup='app-tabs'
)
tab-header(
*ngFor='let tab of app.tabs; let idx = index',
[index]='idx',
[tab]='tab',
[active]='tab == app.activeTab',
[@animateTab]='{value: "in", params: {size: targetTabSize}}',
[@.disabled]='hasVerticalTabs() || !config.store.accessibility.animations',
(click)='app.selectTab(tab)',
[class.fully-draggable]='hostApp.platform !== Platform.macOS',
[ngbTooltip]='tab.customTitle || tab.title'
)
.btn-group.background
.d-flex(
*ngFor='let button of leftToolbarButtons'
)
button.btn.btn-secondary.btn-tab-bar(
[ngbTooltip]='button.label',
(click)='button.run && button.run()',
[fastHtmlBind]='button.icon'
)
.d-flex(
ngbDropdown,
container='body',
#activeTransfersDropdown='ngbDropdown'
)
button.btn.btn-secondary.btn-tab-bar(
[hidden]='activeTransfers.length == 0',
[ngbTooltip]='"File transfers"|translate',
ngbDropdownToggle
) !{require('../icons/transfers.svg')}
transfers-menu(
ngbDropdownMenu,
[(transfers)]='activeTransfers',
(transfersChange)='onTransfersChange()'
)
.btn-space.background(
[class.persistent]='config.store.appearance.frame == "thin"',
[class.drag]='config.store.appearance.frame == "thin" \
&& ((config.store.appearance.tabsLocation !== "left" && config.store.appearance.tabsLocation !== "right") || hostApp.platform !== Platform.macOS)'
)
.btn-group.background
.d-flex(
*ngFor='let button of rightToolbarButtons'
)
button.btn.btn-secondary.btn-tab-bar(
[ngbTooltip]='button.label',
(click)='button.run && button.run()',
[fastHtmlBind]='button.icon'
)
button.btn.btn-secondary.btn-tab-bar.btn-update(
*ngIf='updatesAvailable',
[ngbTooltip]='"Update available - Click to install"|translate',
(click)='updater.update()'
) !{require('../icons/gift.svg')}
window-controls.background(
*ngIf='config.store.appearance.frame == "thin" \
&& config.store.appearance.tabsLocation !== "left" \
&& config.store.appearance.tabsLocation !== "right" \
&& hostApp.platform == Platform.Linux',
)
div.window-controls-spacer(
*ngIf='config.store.appearance.frame == "thin" && (hostApp.platform == Platform.Windows) && (config.store.appearance.tabsLocation == "top")',
)
.content
start-page.content-tab.content-tab-active(*ngIf='ready && app.tabs.length == 0')
tab-body.content-tab(
#tabBodies,
*ngFor='let tab of unsortedTabs',
[class.content-tab-active]='tab == app.activeTab',
[active]='tab == app.activeTab',
[@animateTab]='{value: "in", params: {size: targetTabSize}}',
[@.disabled]='hasVerticalTabs() || !config.store.accessibility.animations',
(click)='app.selectTab(tab)',
[class.fully-draggable]='hostApp.platform !== Platform.macOS',
[ngbTooltip]='tab.customTitle || tab.title'
[tab]='tab',
)
.btn-group.background
.d-flex(
*ngFor='let button of leftToolbarButtons'
)
button.btn.btn-secondary.btn-tab-bar(
[ngbTooltip]='button.label',
(click)='button.run && button.run()',
[fastHtmlBind]='button.icon'
)
.d-flex(
ngbDropdown,
container='body',
#activeTransfersDropdown='ngbDropdown'
)
button.btn.btn-secondary.btn-tab-bar(
[hidden]='activeTransfers.length == 0',
[ngbTooltip]='"File transfers"|translate',
ngbDropdownToggle
) !{require('../icons/transfers.svg')}
transfers-menu(
ngbDropdownMenu,
[(transfers)]='activeTransfers',
(transfersChange)='onTransfersChange()'
)
.btn-space.background(
[class.persistent]='config.store.appearance.frame == "thin"',
[class.drag]='config.store.appearance.frame == "thin" \
&& ((config.store.appearance.tabsLocation !== "left" && config.store.appearance.tabsLocation !== "right") || hostApp.platform !== Platform.macOS)'
)
.btn-group.background
.d-flex(
*ngFor='let button of rightToolbarButtons'
)
button.btn.btn-secondary.btn-tab-bar(
[ngbTooltip]='button.label',
(click)='button.run && button.run()',
[fastHtmlBind]='button.icon'
)
button.btn.btn-secondary.btn-tab-bar.btn-update(
*ngIf='updatesAvailable',
[ngbTooltip]='"Update available - Click to install"|translate',
(click)='updater.update()'
) !{require('../icons/gift.svg')}
window-controls.background(
*ngIf='config.store.appearance.frame == "thin" \
&& config.store.appearance.tabsLocation !== "left" \
&& config.store.appearance.tabsLocation !== "right" \
&& hostApp.platform == Platform.Linux',
)
div.window-controls-spacer(
*ngIf='config.store.appearance.frame == "thin" && (hostApp.platform == Platform.Windows) && (config.store.appearance.tabsLocation == "top")',
)
.content
start-page.content-tab.content-tab-active(*ngIf='ready && app.tabs.length == 0')
tab-body.content-tab(
#tabBodies,
*ngFor='let tab of unsortedTabs',
[class.content-tab-active]='tab == app.activeTab',
[active]='tab == app.activeTab',
[tab]='tab',
)
ng-template(ngbModalContainer)

View File

@ -25,7 +25,7 @@ $tab-border-radius: 4px;
}
.content {
width: 100vw;
width: 100%;
flex: 1 1 0;
min-height: 0;
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 { TransfersMenuComponent } from './components/transfersMenu.component'
import { ProfileIconComponent } from './components/profileIcon.component'
import { ProfileTreeComponent } from './components/profileTree.component'
import { AutofocusDirective } from './directives/autofocus.directive'
import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive'
@ -130,6 +131,7 @@ const PROVIDERS = [
DropZoneDirective,
CdkAutoDropGroup,
ProfileIconComponent,
ProfileTreeComponent,
TabbyFormatedDatePipe,
],
exports: [

View File

@ -215,13 +215,37 @@ export class ProfilesService {
const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined
return {
...profile,
group: this.resolveProfileGroupName(profile.group ?? ''),
group: this.resolveProfileGroupPath(profile.group ?? '').join(' 🡒 '),
freeInputEquivalent,
description: provider?.getDescription(fullProfile),
}
}
showProfileSelector (): Promise<PartialProfile<Profile>|null> {
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> {
if (this.selector.active) {
return Promise.resolve(null)
}
@ -261,6 +285,12 @@ export class ProfilesService {
if (!this.config.store.terminal.showBuiltinProfiles) {
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)
@ -499,7 +529,37 @@ export class ProfilesService {
* Resolve and return ProfileGroup Name from ProfileGroup ID
*/
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);
}
&> .content {
.main.content {
.tab-bar {
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 path from 'path'
import * as glob from 'glob'
import slugify from 'slugify'
import * as yaml from 'js-yaml'
import { Injectable } from '@angular/core'
import { PartialProfile } from 'tabby-core'
@ -145,15 +144,24 @@ async function parseSSHConfigFile (
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 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
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
const thisProfile: PartialProfile<SSHProfile> = {
id: deriveID(host),
id: await deriveID(host),
name: `${host} (.ssh/config)`,
type: 'ssh',
group: 'Imported from .ssh/config',
@ -194,7 +202,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
const basicString = settings[key]
if (typeof basicString === 'string') {
if (targetName === SSHProfilePropertyNames.JumpHost) {
options[targetName] = deriveID(basicString)
options[targetName] = await deriveID(basicString)
} else {
options[targetName] = basicString
}
@ -295,7 +303,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
return thisProfile
}
function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[] {
async function convertToSSHProfiles (config: SSHConfig): Promise<PartialProfile<SSHProfile>[]> {
const myMap = new Map<string, PartialProfile<SSHProfile>>()
function noWildCardsInName (name: string) {
@ -333,7 +341,7 @@ function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[]
// NOTE: SSHConfig.compute() lies about the return types
const configuration: Record<string, string | string[] | object[]> = config.compute(host)
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 {
const config: SSHConfig = await parseSSHConfigFile(configPath)
return convertToSSHProfiles(config)
return await convertToSSHProfiles(config)
} catch (e) {
if (e.code === 'ENOENT') {
return []
@ -376,7 +384,7 @@ export class StaticFileImporter extends SSHProfileImporter {
}
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)) {
return []
@ -387,11 +395,11 @@ export class StaticFileImporter extends SSHProfileImporter {
return []
}
return (yaml.load(content) as PartialProfile<SSHProfile>[]).map(item => ({
return Promise.all((yaml.load(content) as PartialProfile<SSHProfile>[]).map(async item => ({
...item,
id: deriveID(item.name),
id: await deriveID(item.name),
type: 'ssh',
}))
})))
}
}

View File

@ -12,7 +12,56 @@
[(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
.header
.title(translate) Default profile group settings

View File

@ -1,7 +1,15 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Input } from '@angular/core'
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 */
@Component({
@ -10,14 +18,97 @@ import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, T
export class EditProfileGroupModalComponent<G extends ProfileGroup> {
@Input() group: G & ConfigProxy
@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 (
private modalInstance: NgbActiveModal,
private profilesService: ProfilesService,
private platform: PlatformService,
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 })
}

View File

@ -39,87 +39,39 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
span(translate) New profile
button(ngbDropdownItem, (click)='newProfileGroup()')
i.fas.fa-fw.fa-plus
span(translate) New profile Group
span(translate) New profile Group
.d-flex.flex-column.my-3.p-2.collapse-container
ng-container(*ngFor='let group of rootGroups')
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: group}')
.list-group.mt-3.mb-3
ng-container(*ngFor='let group of profileGroups')
ng-container(*ngIf='isGroupVisible(group)')
.list-group-item.list-group-item-action.d-flex.align-items-center(
(click)='toggleGroupCollapse(group)'
)
.fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed && group.profiles?.length > 0')
.fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed && group.profiles?.length > 0')
span.ms-3.me-auto {{group.name || ("Ungrouped"|translate)}}
button.btn.btn-sm.btn-link.hover-reveal.ms-2(
*ngIf='group.editable && group.name',
(click)='$event.stopPropagation(); editProfileGroup(group)'
)
i.fas.fa-pencil-alt
button.btn.btn-sm.btn-link.hover-reveal.ms-2(
*ngIf='group.editable && group.name',
(click)='$event.stopPropagation(); deleteProfileGroup(group)'
)
i.fas.fa-trash-alt
ng-container(*ngIf='!group.collapsed')
ng-container(*ngFor='let profile of group.profiles')
.list-group-item.ps-5.d-flex.align-items-center(
*ngIf='isProfileVisible(profile)',
[class.list-group-item-action]='!profile.isBuiltin',
(click)='profile.isBuiltin ? null : editProfile(profile)'
)
profile-icon(
[icon]='profile.icon',
[color]='profile.color'
)
ng-template(#recursiveGroup let-group)
.collapse-item.d-flex.align-items-center.p-1((click)='toggleGroupCollapse(group)')
.fa.fa-fw.far.fa-folder.ms-1(*ngIf='group.collapsed')
.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) }}
.no-wrap {{profile.name}}
.text-muted.no-wrap.ms-2 {{getDescription(profile)}}
button.btn.btn-sm.btn-link.hover-reveal.ms-2(*ngIf='group.editable && group.name', (click)='$event.stopPropagation(); editProfileGroup(group)')
i.fas.fa-pencil-alt
button.btn.btn-sm.btn-link.hover-reveal.ms-2(*ngIf='group.editable && group.name', (click)='$event.stopPropagation(); deleteProfileGroup(group)')
i.fas.fa-trash-alt
ng-container(*ngIf='!group.collapsed')
ng-container(*ngFor='let profile of group.profiles')
.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)')
profile-icon.ms-1([icon]='profile.icon', [color]='profile.color')
span.ms-3.no-wrap {{ profile.name }}
.text-muted.no-wrap.ms-2 {{ getDescription(profile) }}
.me-auto
button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)')
i.fas.fa-play
.mx-2(class='badge text-bg-{{getTypeColorClass(profile)}}') {{ getTypeLabel(profile) }}
.me-auto
button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)')
i.fas.fa-play
.ms-1.hover-reveal(ngbDropdown, placement='bottom-right top-right auto')
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(
ngbDropdownItem,
*ngIf='profile.id && !isProfileBlacklisted(profile)',
(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)')
ng-container(*ngFor='let child of group.children')
.ps-4
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: child}')
li(ngbNavItem)
a(ngbNavLink, translate) Advanced

View File

@ -1,8 +1,20 @@
profile-icon {
width: 1.25rem;
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
profile-icon + * {
margin-left: 10px;
.collapse-container {
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 { Component, Inject } from '@angular/core'
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 { EditProfileGroupModalComponent, EditProfileGroupModalComponentResult } from './editProfileGroupModal.component'
@ -11,6 +11,7 @@ _('Ungrouped')
interface CollapsableProfileGroup extends ProfileGroup {
collapsed: boolean
children: PartialProfileGroup<CollapsableProfileGroup>[]
}
/** @hidden */
@ -24,6 +25,8 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
templateProfiles: PartialProfile<Profile>[] = []
customProfiles: PartialProfile<Profile>[] = []
profileGroups: PartialProfileGroup<CollapsableProfileGroup>[]
rootGroups: PartialProfileGroup<CollapsableProfileGroup>[] = []
filter = ''
Platform = Platform
@ -147,13 +150,11 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
}
async newProfileGroup (): Promise<void> {
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = this.translate.instant('New group name')
const result = await modal.result.catch(() => null)
if (result?.value.trim()) {
await this.profilesService.newProfileGroup({ id: '', name: result.value })
await this.config.save()
}
this.editProfileGroup({
id: 'new',
name: '',
icon: 'far fa-folder',
})
}
async editProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<void> {
@ -161,6 +162,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
if (!result) {
return
}
await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result))
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 === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1))
this.profileGroups = groups.map(g => ProfilesSettingsTabComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false))
this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups)
}
isGroupVisible (group: PartialProfileGroup<ProfileGroup>): boolean {
@ -286,9 +289,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
}
toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
if (group.profiles?.length === 0) {
return
}
group.collapsed = !group.collapsed
this.saveProfileGroupCollapse(group)
}
@ -364,6 +364,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): PartialProfileGroup<ProfileGroup> {
const g: any = { ...group }
delete g.collapsed
delete g.children
return g
}

View File

@ -130,6 +130,15 @@ h3.mb-3(translate) Window
(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
.form-line(*ngIf='docking')

View File

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

View File

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

View File

@ -27,18 +27,89 @@ export class SSHService {
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}`
const password = await this.passwordStorage.loadPassword(profile, username)
if (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(':')) {
uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}`
}else {
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> {
@ -46,38 +117,26 @@ export class SSHService {
if (!path) {
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
try {
if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) {
tmpFile = await tmp.file()
let passphrase: string|null = null
for (const pk of session.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
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
const profile = session.profile
const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(profile)
tmpFile = privateKeyPairs.privateKeyFile
if (tmpFile) {
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)
} finally {
tmpFile?.cleanup()
winscpParms.privateKeyFile?.cleanup()
}
}
}