[Feature] Group nesting "folders" & Profile tree view (#10788)

Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
D3VL Jack
2026-06-08 23:37:39 +02:00
committed by GitHub
co-authored by Eugene
parent 3142d125fa
commit 692ebdd297
17 changed files with 847 additions and 199 deletions
+3
View File
@@ -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
+107 -98
View File
@@ -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
}
}
+1
View File
@@ -61,3 +61,4 @@ hacks:
globalHotkey: null
language: null
defaultQuickConnectProvider: "ssh"
showProfileTree: true
+2
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: [
+63 -3
View File
@@ -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
}
/**
+1 -1
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);
@@ -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
@@ -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,98 @@ import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, T
export class EditProfileGroupModalComponent<G extends ProfileGroup> {
@Input() group: G & ConfigProxy<G>
@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,
) {}
) { }
save () {
async ngOnInit () {
const groups = await this.profilesService.getProfileGroups()
this.groups = this.getValidParents(groups, this.group.id)
this.selectedParentGroup = groups.find(g => g.id === this.group.parentGroupId) ?? undefined
}
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 })
}
@@ -39,87 +39,54 @@ 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
.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'
)
.d-flex.flex-column.my-3.p-2.collapse-container
ng-container(*ngFor='let group of rootGroups')
ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: group}')
.no-wrap {{profile.name}}
.text-muted.no-wrap.ms-2 {{getDescription(profile)}}
ng-template(#recursiveGroup let-group)
.collapse-item.d-flex.align-items-center.p-1((click)='toggleGroupCollapse(group)')
.fa.fa-fw.far.fa-folder(*ngIf='group.collapsed')
.fa.fa-fw.far.fa-folder-open(*ngIf='!group.collapsed')
span.ms-3.me-auto {{ group.name || ("Ungrouped"|translate) }}
.me-auto
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
button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)')
i.fas.fa-play
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
.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)')
.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
@@ -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);
}
@@ -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
private descriptionCache = new Map<string, string|null>()
@@ -156,13 +159,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> {
@@ -170,6 +171,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
if (!result) {
return
}
await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result))
await this.config.save()
}
@@ -263,6 +265,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 {
@@ -298,9 +301,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
}
toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
if (group.profiles?.length === 0) {
return
}
group.collapsed = !group.collapsed
this.saveProfileGroupCollapse(group)
}
@@ -376,6 +376,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
}
@@ -130,6 +130,15 @@ h3.mb-3(translate) Window
(ngModelChange)='saveConfiguration(true)'
)
.form-line
.header
.title(translate) Show profile sidebar
.description(translate) Displays a full tree of profiles in the sidebar.
toggle(
[(ngModel)]='config.store.showProfileTree',
(ngModelChange)='saveConfiguration(false)'
)
h3.mt-4(translate) Docking
.form-line(*ngIf='docking')
+5 -1
View File
@@ -84,4 +84,8 @@ export default class SettingsModule {
}
export * from './api'
export { SettingsTabComponent }
export {
SettingsTabComponent,
EditProfileModalComponent,
EditProfileGroupModalComponent,
}