mirror of
https://github.com/Eugeny/tabby.git
synced 2026-08-24 10:14:31 -05:00
[Feature] Group nesting "folders" & Profile tree view (#10788)
Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
@@ -38,6 +38,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
|
||||
|
||||
@@ -5,107 +5,116 @@ 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()'
|
||||
)
|
||||
.inset.background(*ngIf='hostApp.platform == Platform.macOS \
|
||||
.window.h-100.d-flex
|
||||
profile-tree(
|
||||
*ngIf='ready && config.store.showProfileTree',
|
||||
[class.mac-inset]='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'
|
||||
&& config.store.appearance.frame == "thin"'
|
||||
)
|
||||
|
||||
.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") \
|
||||
&& !config.store.showProfileTree')
|
||||
.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)
|
||||
|
||||
@@ -25,7 +25,7 @@ $tab-border-radius: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.p-2.h-100.d-flex.flex-column
|
||||
input.form-control.form-control-sm.mb-1(
|
||||
type='text',
|
||||
[(ngModel)]='filter',
|
||||
[placeholder]='"Filter"|translate',
|
||||
(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
|
||||
|
||||
ng-container(*ngFor='let child of group.children')
|
||||
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: child, depth: depth + 1}')
|
||||
.grabber(
|
||||
(mousedown)="startResize($event)"
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
:host {
|
||||
background-color: var(--theme-bg-more-2);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:host(.mac-inset) {
|
||||
padding-top: var(--tabs-height);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--tabs-height);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
}
|
||||
|
||||
.grabber {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 7px;
|
||||
height: 25px;
|
||||
display: block;
|
||||
background-color: var(--theme-secondary-fg);
|
||||
border: 3px solid var(--theme-secondary);
|
||||
border-radius: 0.4rem;
|
||||
top: 50%;
|
||||
right: -4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Component, HostBinding, HostListener, Input } from '@angular/core'
|
||||
import { TranslateService } from '@ngx-translate/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import deepClone from 'clone-deep'
|
||||
import FuzzySearch from 'fuzzy-search'
|
||||
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { ProfilesService } from '../services/profiles.service'
|
||||
import { AppService } from '../services/app.service'
|
||||
import { PlatformService } from '../api/platform'
|
||||
import { ProfileProvider } from '../api/index'
|
||||
import { PartialProfileGroup, ProfileGroup, PartialProfile, Profile } from '../index'
|
||||
import { BaseComponent } from './base.component'
|
||||
|
||||
interface CollapsableProfileGroup extends ProfileGroup {
|
||||
collapsed: boolean
|
||||
children: PartialProfileGroup<CollapsableProfileGroup>[]
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'profile-tree',
|
||||
styleUrls: ['./profileTree.component.scss'],
|
||||
templateUrl: './profileTree.component.pug',
|
||||
})
|
||||
export class ProfileTreeComponent extends BaseComponent {
|
||||
profileGroups: PartialProfileGroup<ProfileGroup>[] = []
|
||||
rootGroups: PartialProfileGroup<ProfileGroup>[] = []
|
||||
|
||||
filteredProfiles: PartialProfile<Profile>[] = []
|
||||
@Input() filter = ''
|
||||
|
||||
|
||||
panelMinWidth = 200
|
||||
panelMaxWidth = 600
|
||||
panelInternalWidth: number = parseInt(window.localStorage.profileTreeWidth ?? '300')
|
||||
panelStartWidth = this.panelInternalWidth
|
||||
panelIsResizing = false
|
||||
panelStartX = 0
|
||||
|
||||
constructor (
|
||||
private app: AppService,
|
||||
private platform: PlatformService,
|
||||
private config: ConfigService,
|
||||
private profilesService: ProfilesService,
|
||||
private translate: TranslateService,
|
||||
private ngbModal: NgbModal,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async ngOnInit (): Promise<void> {
|
||||
await this.loadTreeItems()
|
||||
this.subscribeUntilDestroyed(this.config.changed$, () => this.loadTreeItems())
|
||||
this.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.partialProfile = 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 (): boolean {
|
||||
this.panelIsResizing = false
|
||||
return true
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -61,3 +61,4 @@ hacks:
|
||||
globalHotkey: null
|
||||
language: null
|
||||
defaultQuickConnectProvider: "ssh"
|
||||
showProfileTree: true
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -217,13 +217,37 @@ export class ProfilesService {
|
||||
...profile,
|
||||
icon: profile.icon ?? undefined,
|
||||
color: profile.color ?? undefined,
|
||||
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)
|
||||
}
|
||||
@@ -263,6 +287,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)
|
||||
@@ -501,7 +531,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ app-root {
|
||||
background: rgba(var(--bs-dark-rgb),.65);
|
||||
}
|
||||
|
||||
&> .content {
|
||||
.main.content {
|
||||
.tab-bar {
|
||||
background: var(--theme-bg-more-2);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user