From 24809bc33ba2111f2079853d52d9f5138a1e7610 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Sun, 5 Oct 2025 01:00:45 +0100 Subject: [PATCH 01/13] 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. --- tabby-core/src/api/profileProvider.ts | 3 + .../editProfileGroupModal.component.pug | 51 +++++++- .../editProfileGroupModal.component.ts | 107 +++++++++++++++- .../profilesSettingsTab.component.pug | 114 +++++------------- .../profilesSettingsTab.component.scss | 20 ++- .../profilesSettingsTab.component.ts | 53 ++++++-- 6 files changed, 249 insertions(+), 99 deletions(-) diff --git a/tabby-core/src/api/profileProvider.ts b/tabby-core/src/api/profileProvider.ts index ba13b0ff18..539cc59835 100644 --- a/tabby-core/src/api/profileProvider.ts +++ b/tabby-core/src/api/profileProvider.ts @@ -37,6 +37,9 @@ export type PartialProfile = Omit[] defaults: any diff --git a/tabby-settings/src/components/editProfileGroupModal.component.pug b/tabby-settings/src/components/editProfileGroupModal.component.pug index c5864fdd26..1f4e083982 100644 --- a/tabby-settings/src/components/editProfileGroupModal.component.pug +++ b/tabby-settings/src/components/editProfileGroupModal.component.pug @@ -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 diff --git a/tabby-settings/src/components/editProfileGroupModal.component.ts b/tabby-settings/src/components/editProfileGroupModal.component.ts index e1cec2312f..cefa55f8eb 100644 --- a/tabby-settings/src/components/editProfileGroupModal.component.ts +++ b/tabby-settings/src/components/editProfileGroupModal.component.ts @@ -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,105 @@ import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, T export class EditProfileGroupModalComponent { @Input() group: G & ConfigProxy @Input() providers: ProfileProvider[] + @Input() selectedParentGroup: PartialProfileGroup | undefined + groups: PartialProfileGroup[] + + getValidParents (groups: PartialProfileGroup[], targetId: string): PartialProfileGroup[] { + // Build a quick lookup: parentGroupId -> [child groups] + const childrenMap = new Map(); + 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 { + const descendants = new Set(); + 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); - constructor ( + // 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[]> = (text$: Observable) => + text$.pipe( + debounceTime(200), + distinctUntilChanged(), + map(q => this.groups.filter(g => !q || g.name.toLowerCase().includes(q.toLowerCase()))), + ) + + groupFormatter = (g: PartialProfileGroup) => g.name + + iconSearch: OperatorFunction = (text$: Observable) => + 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({ + id: '', + name: this.group.name, + icon: this.group?.icon, + color: this.group?.color, + parentGroupId: this.group?.parentGroupId + }) + } this.modalInstance.close({ group: this.group }) } diff --git a/tabby-settings/src/components/profilesSettingsTab.component.pug b/tabby-settings/src/components/profilesSettingsTab.component.pug index 9c8144a8a4..52fed6f60c 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.pug +++ b/tabby-settings/src/components/profilesSettingsTab.component.pug @@ -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 - - .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' - ) - - .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)') + 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}') + + 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) }} + + 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) }} + + ng-container(*ngFor='let child of group.children') + .ps-4 + ng-container(*ngTemplateOutlet='recursiveGroup; context: {$implicit: child}') li(ngbNavItem) a(ngbNavLink, translate) Advanced diff --git a/tabby-settings/src/components/profilesSettingsTab.component.scss b/tabby-settings/src/components/profilesSettingsTab.component.scss index ed7f76232a..9e43a5f326 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.scss +++ b/tabby-settings/src/components/profilesSettingsTab.component.scss @@ -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); +} \ No newline at end of file diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index c1f5406525..cd3514e4d5 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -11,6 +11,7 @@ _('Ungrouped') interface CollapsableProfileGroup extends ProfileGroup { collapsed: boolean + children: PartialProfileGroup[] } /** @hidden */ @@ -24,6 +25,8 @@ export class ProfilesSettingsTabComponent extends BaseComponent { templateProfiles: PartialProfile[] = [] customProfiles: PartialProfile[] = [] profileGroups: PartialProfileGroup[] + rootGroups: PartialProfileGroup[] = [] + filter = '' Platform = Platform @@ -59,6 +62,29 @@ export class ProfilesSettingsTabComponent extends BaseComponent { this.profilesService.openNewTabForProfile(profile) } + private buildGroupTree (groups: PartialProfileGroup[]): PartialProfileGroup[] { + const map = new Map>() + + for (const group of groups) { + group.children = [] + map.set(group.id, group) + } + + const roots: PartialProfileGroup[] = [] + + 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 + } + async newProfile (base?: PartialProfile): Promise { if (!base) { let profiles = await this.profilesService.getProfiles() @@ -147,13 +173,20 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } async newProfileGroup (): Promise { - 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() + const modal = this.ngbModal.open( + EditProfileGroupModalComponent, + { size: 'lg' }, + ) + modal.componentInstance.group = { + id: 'new', + icon: 'far fa-folder', + color: '#B8B8B8' } + modal.componentInstance.providers = [] + + const createResult: EditProfileGroupModalComponentResult | null = await modal.result.catch(() => null) + if (!createResult) return + await this.config.save() } async editProfileGroup (group: PartialProfileGroup): Promise { @@ -161,6 +194,10 @@ export class ProfilesSettingsTabComponent extends BaseComponent { if (!result) { return } + + // don't save children to the config + delete group.children; + await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result)) await this.config.save() } @@ -254,6 +291,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.buildGroupTree(this.profileGroups) } isGroupVisible (group: PartialProfileGroup): boolean { @@ -286,9 +324,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } toggleGroupCollapse (group: PartialProfileGroup): void { - if (group.profiles?.length === 0) { - return - } group.collapsed = !group.collapsed this.saveProfileGroupCollapse(group) } From 86d3710c0e675291bb2366864aa01381d3f4cb49 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Sun, 5 Oct 2025 18:48:28 +0100 Subject: [PATCH 02/13] 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 --- .../editProfileGroupModal.component.ts | 10 ++------- .../profilesSettingsTab.component.ts | 21 +++++-------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/tabby-settings/src/components/editProfileGroupModal.component.ts b/tabby-settings/src/components/editProfileGroupModal.component.ts index cefa55f8eb..d9195f7825 100644 --- a/tabby-settings/src/components/editProfileGroupModal.component.ts +++ b/tabby-settings/src/components/editProfileGroupModal.component.ts @@ -60,7 +60,7 @@ export class EditProfileGroupModalComponent { ); } - constructor( + constructor ( private modalInstance: NgbActiveModal, private profilesService: ProfilesService, private platform: PlatformService, @@ -109,13 +109,7 @@ export class EditProfileGroupModalComponent { } if (this.group.id === 'new') { - await this.profilesService.newProfileGroup({ - id: '', - name: this.group.name, - icon: this.group?.icon, - color: this.group?.color, - parentGroupId: this.group?.parentGroupId - }) + await this.profilesService.newProfileGroup(this.group, { genId: true }) } this.modalInstance.close({ group: this.group }) } diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index cd3514e4d5..101e8ce33b 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -173,20 +173,11 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } async newProfileGroup (): Promise { - const modal = this.ngbModal.open( - EditProfileGroupModalComponent, - { size: 'lg' }, - ) - modal.componentInstance.group = { + this.editProfileGroup({ id: 'new', - icon: 'far fa-folder', - color: '#B8B8B8' - } - modal.componentInstance.providers = [] - - const createResult: EditProfileGroupModalComponentResult | null = await modal.result.catch(() => null) - if (!createResult) return - await this.config.save() + name: '', + icon: 'far fa-folder' + }) } async editProfileGroup (group: PartialProfileGroup): Promise { @@ -195,9 +186,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent { return } - // don't save children to the config - delete group.children; - await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result)) await this.config.save() } @@ -399,6 +387,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup): PartialProfileGroup { const g: any = { ...group } delete g.collapsed + delete g.children return g } From f43731d9dcfd36dbfcd90a3051bb3058b3568de1 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Sun, 5 Oct 2025 19:03:05 +0100 Subject: [PATCH 03/13] Moved buildGroupTree to profiles.service Moved buildGroupTree to profiles.service to allow for reusability --- tabby-core/src/services/profiles.service.ts | 25 ++++++++++++++++++- .../profilesSettingsTab.component.ts | 25 +------------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index 18522ee5cd..bdfce1563d 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -221,7 +221,30 @@ export class ProfilesService { } } - showProfileSelector (): Promise|null> { + buildGroupTree (groups: PartialProfileGroup[]): PartialProfileGroup[] { + const map = new Map>() + + for (const group of groups) { + group.children = [] + map.set(group.id, group) + } + + const roots: PartialProfileGroup[] = [] + + 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 | null> { if (this.selector.active) { return Promise.resolve(null) } diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index 101e8ce33b..3fe66dce6f 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -62,29 +62,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent { this.profilesService.openNewTabForProfile(profile) } - private buildGroupTree (groups: PartialProfileGroup[]): PartialProfileGroup[] { - const map = new Map>() - - for (const group of groups) { - group.children = [] - map.set(group.id, group) - } - - const roots: PartialProfileGroup[] = [] - - 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 - } - async newProfile (base?: PartialProfile): Promise { if (!base) { let profiles = await this.profilesService.getProfiles() @@ -279,7 +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.buildGroupTree(this.profileGroups) + this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups) } isGroupVisible (group: PartialProfileGroup): boolean { From c383728cc7c2c0163bbc8222853fb8e9b34b43a8 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Sun, 5 Oct 2025 21:48:45 +0100 Subject: [PATCH 04/13] Add full group paths to profile selector --- tabby-core/src/services/profiles.service.ts | 38 ++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index bdfce1563d..c7ea1a100b 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -215,7 +215,7 @@ 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), } @@ -284,6 +284,11 @@ export class ProfilesService { if (!this.config.store.terminal.showBuiltinProfiles) { profiles = profiles.filter(x => !x.isBuiltin) + 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) @@ -523,6 +528,37 @@ export class ProfilesService { */ 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 | null { + return this.config.store.groups.find(g => g.id === groupId) ?? null } /** From f4c54245a216f2220c1f1bdd1843413b804cd674 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Sun, 5 Oct 2025 22:42:12 +0100 Subject: [PATCH 05/13] 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 --- tabby-core/src/components/appRoot.component.pug | 2 +- tabby-core/src/theme.new.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tabby-core/src/components/appRoot.component.pug b/tabby-core/src/components/appRoot.component.pug index 59ef961fe9..abadea806d 100644 --- a/tabby-core/src/components/appRoot.component.pug +++ b/tabby-core/src/components/appRoot.component.pug @@ -5,7 +5,7 @@ title-bar( [class.inset]='hostApp.platform == Platform.macOS && !hostWindow.isFullscreen' ) -.content( +.content.main( *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"', diff --git a/tabby-core/src/theme.new.scss b/tabby-core/src/theme.new.scss index 9b86408c33..1486ff5a0a 100644 --- a/tabby-core/src/theme.new.scss +++ b/tabby-core/src/theme.new.scss @@ -8,7 +8,7 @@ app-root { background: rgba(var(--bs-dark-rgb),.65); } - &> .content { + .main.content { .tab-bar { background: var(--theme-bg-more-2); From 0a9788a91a5ff86bcd7f449c84acab1dd8e0464d Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Sun, 5 Oct 2025 23:38:33 +0100 Subject: [PATCH 06/13] Remove PromptModalComponent import --- tabby-settings/src/components/profilesSettingsTab.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index 3fe66dce6f..7d853c02b4 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -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' From 868a509bb09e52875bf25bd935b39ae28f215338 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 00:25:07 +0100 Subject: [PATCH 07/13] 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. --- .../src/components/appRoot.component.pug | 182 +++++------ .../src/components/appRoot.component.scss | 2 +- .../src/components/profileTree.component.pug | 53 ++++ .../src/components/profileTree.component.scss | 90 ++++++ .../src/components/profileTree.component.ts | 287 ++++++++++++++++++ tabby-core/src/index.ts | 2 + tabby-settings/src/index.ts | 6 +- 7 files changed, 532 insertions(+), 90 deletions(-) create mode 100644 tabby-core/src/components/profileTree.component.pug create mode 100644 tabby-core/src/components/profileTree.component.scss create mode 100644 tabby-core/src/components/profileTree.component.ts diff --git a/tabby-core/src/components/appRoot.component.pug b/tabby-core/src/components/appRoot.component.pug index abadea806d..57199a3201 100644 --- a/tabby-core/src/components/appRoot.component.pug +++ b/tabby-core/src/components/appRoot.component.pug @@ -5,107 +5,113 @@ title-bar( [class.inset]='hostApp.platform == Platform.macOS && !hostWindow.isFullscreen' ) -.content.main( - *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' ) - .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' + .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()' + ) + .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' ) - button.btn.btn-secondary.btn-tab-bar( - [ngbTooltip]='button.label', - (click)='button.run && button.run()', - [fastHtmlBind]='button.icon' + 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' ) - .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-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' + ) - .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)' - ) + .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-group.background - .d-flex( - *ngFor='let button of rightToolbarButtons' + .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)' ) - button.btn.btn-secondary.btn-tab-bar( - [ngbTooltip]='button.label', - (click)='button.run && button.run()', - [fastHtmlBind]='button.icon' + + .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')} + 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', - ) + 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') + 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', - ) + 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) diff --git a/tabby-core/src/components/appRoot.component.scss b/tabby-core/src/components/appRoot.component.scss index 15cab86862..b4db211df9 100644 --- a/tabby-core/src/components/appRoot.component.scss +++ b/tabby-core/src/components/appRoot.component.scss @@ -25,7 +25,7 @@ $tab-border-radius: 4px; } .content { - width: 100vw; + width: 100%; flex: 1 1 0; min-height: 0; display: flex; diff --git a/tabby-core/src/components/profileTree.component.pug b/tabby-core/src/components/profileTree.component.pug new file mode 100644 index 0000000000..307cd7c6f5 --- /dev/null +++ b/tabby-core/src/components/profileTree.component.pug @@ -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)" +) \ No newline at end of file diff --git a/tabby-core/src/components/profileTree.component.scss b/tabby-core/src/components/profileTree.component.scss new file mode 100644 index 0000000000..16e047fdbc --- /dev/null +++ b/tabby-core/src/components/profileTree.component.scss @@ -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; +} diff --git a/tabby-core/src/components/profileTree.component.ts b/tabby-core/src/components/profileTree.component.ts new file mode 100644 index 0000000000..c9dc8a1dac --- /dev/null +++ b/tabby-core/src/components/profileTree.component.ts @@ -0,0 +1,287 @@ +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[] +} + +/** @hidden */ +@Component({ + selector: 'profile-tree', + styleUrls: ['./profileTree.component.scss'], + templateUrl: './profileTree.component.pug', +}) +export class ProfileTreeComponent extends BaseComponent { + profileGroups: PartialProfileGroup[] = [] + rootGroups: PartialProfileGroup[] = [] + + filteredProfiles: PartialProfile[] = [] + @Input() filter: string = ''; + + + 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 { + 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((e) => this.tabStateChanged()) + } + + + private async loadTreeItems (): Promise { + 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): Promise { + 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): Promise { + 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, provider?: ProfileProvider }> = await modal.result.catch(() => null) + if (!result) return + if (!result?.group) return; + + if (result.provider) { + return this.editProfileGroupDefaults(result.group, result.provider) + } + + delete group.collapsed; + await this.profilesService.writeProfileGroup(result.group) + await this.config.save() + } + + private async editProfileGroupDefaults (group: PartialProfileGroup, provider: ProfileProvider): Promise { + 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, event: MouseEvent): Promise { + 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, event: MouseEvent): Promise { + 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 { + // TODO: show active tab in the side panel with eye icon + } + + async launchProfile

(profile: PartialProfile

): Promise { + return this.profilesService.launchProfile(profile) + } + + async onFilterChange(): Promise { + 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) { + this.panelIsResizing = true; + this.panelStartX = event.clientX; + this.panelStartWidth = this.panelWidth; + event.preventDefault(); + } + + @HostListener('document:mousemove', ['$event']) + onMouseMove(event: MouseEvent) { + 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() { + this.panelIsResizing = false; + } + + @HostBinding('style.width.px') + get panelWidth() { + return this.panelInternalWidth; + } + + set panelWidth(value: number) { + this.panelInternalWidth = value; + } + + ////// GROUP COLLAPSING ////// + toggleGroupCollapse (group: PartialProfileGroup): void { + group.collapsed = !group.collapsed + this.saveProfileGroupCollapse(group) + } + + private saveProfileGroupCollapse (group: PartialProfileGroup): void { + const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') + profileGroupCollapsed[group.id] = group.collapsed + window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed) + } + + private static intoPartialCollapsableProfileGroup (group: PartialProfileGroup, collapsed: boolean): PartialProfileGroup { + const collapsableGroup = { + ...group, + collapsed, + } + return collapsableGroup + } + +} diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index 4854f7f5f9..652c141964 100644 --- a/tabby-core/src/index.ts +++ b/tabby-core/src/index.ts @@ -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: [ diff --git a/tabby-settings/src/index.ts b/tabby-settings/src/index.ts index d9f0abad38..c7af901ffc 100644 --- a/tabby-settings/src/index.ts +++ b/tabby-settings/src/index.ts @@ -84,4 +84,8 @@ export default class SettingsModule { } export * from './api' -export { SettingsTabComponent } +export { + SettingsTabComponent, + EditProfileModalComponent, + EditProfileGroupModalComponent +} \ No newline at end of file From ed174d3128237095ae7e0eff832e0ed3e7102c5b Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 00:36:36 +0100 Subject: [PATCH 08/13] Add setting to hide profile tree --- tabby-core/src/components/appRoot.component.pug | 2 +- .../src/components/windowSettingsTab.component.pug | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tabby-core/src/components/appRoot.component.pug b/tabby-core/src/components/appRoot.component.pug index 57199a3201..622fda3ad0 100644 --- a/tabby-core/src/components/appRoot.component.pug +++ b/tabby-core/src/components/appRoot.component.pug @@ -8,7 +8,7 @@ title-bar( .window.h-100.d-flex profile-tree( - *ngIf='ready' + *ngIf='ready && !config.store.hideProfileTree' ) .content.main.h-100( diff --git a/tabby-settings/src/components/windowSettingsTab.component.pug b/tabby-settings/src/components/windowSettingsTab.component.pug index f9d9a8815a..25d7fb5145 100644 --- a/tabby-settings/src/components/windowSettingsTab.component.pug +++ b/tabby-settings/src/components/windowSettingsTab.component.pug @@ -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') From cc0391f2e4b9fced94acac0ea6f07ead2a96f6e2 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 00:25:35 +0100 Subject: [PATCH 09/13] missing lines --- tabby-core/src/services/profiles.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index c7ea1a100b..2e3996ab73 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -284,6 +284,7 @@ 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' @@ -527,7 +528,6 @@ 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 } From edb863b20027c7011fac9e59957c61b43333ad52 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 01:07:02 +0100 Subject: [PATCH 10/13] Fixed saving collapsed & children to config file --- tabby-core/src/components/profileTree.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tabby-core/src/components/profileTree.component.ts b/tabby-core/src/components/profileTree.component.ts index c9dc8a1dac..c3db7cec57 100644 --- a/tabby-core/src/components/profileTree.component.ts +++ b/tabby-core/src/components/profileTree.component.ts @@ -114,7 +114,7 @@ export class ProfileTreeComponent extends BaseComponent { modal.componentInstance.group = deepClone(group) modal.componentInstance.providers = this.profilesService.getProviders() - const result: PartialProfileGroup, provider?: ProfileProvider }> = await modal.result.catch(() => null) + const result: PartialProfileGroup, provider?: ProfileProvider }> = await modal.result.catch(() => null) if (!result) return if (!result?.group) return; @@ -122,7 +122,8 @@ export class ProfileTreeComponent extends BaseComponent { return this.editProfileGroupDefaults(result.group, result.provider) } - delete group.collapsed; + delete result.group.collapsed; + delete result.group.children; await this.profilesService.writeProfileGroup(result.group) await this.config.save() } From cfee1760bd3cc3061921333351b34d1fc1d2f15b Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 01:38:22 +0100 Subject: [PATCH 11/13] Bump initial window width --- app/lib/window.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/window.ts b/app/lib/window.ts index 8e01ee14fc..c238368c92 100644 --- a/app/lib/window.ts +++ b/app/lib/window.ts @@ -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, From 11e5674cf0813c5bb85768b9eef61be0cb3e67cd Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 01:40:28 +0100 Subject: [PATCH 12/13] Eslint cleanup --- .../src/components/profileTree.component.ts | 106 +++++++++--------- tabby-core/src/services/profiles.service.ts | 41 +++---- .../editProfileGroupModal.component.ts | 28 +++-- .../profilesSettingsTab.component.ts | 2 +- tabby-settings/src/index.ts | 6 +- 5 files changed, 91 insertions(+), 92 deletions(-) diff --git a/tabby-core/src/components/profileTree.component.ts b/tabby-core/src/components/profileTree.component.ts index c3db7cec57..0a71ba7ad9 100644 --- a/tabby-core/src/components/profileTree.component.ts +++ b/tabby-core/src/components/profileTree.component.ts @@ -2,7 +2,7 @@ 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 FuzzySearch from 'fuzzy-search' import { ConfigService } from '../services/config.service' import { ProfilesService } from '../services/profiles.service' @@ -28,15 +28,15 @@ export class ProfileTreeComponent extends BaseComponent { rootGroups: PartialProfileGroup[] = [] filteredProfiles: PartialProfile[] = [] - @Input() filter: string = ''; - - + @Input() filter = '' + + panelMinWidth = 200 panelMaxWidth = 600 - panelInternalWidth: number = parseInt(window.localStorage?.profileTreeWidth ?? 300); - panelStartWidth = this.panelInternalWidth; - panelIsResizing = false; - panelStartX = 0; + panelInternalWidth: number = parseInt(window.localStorage.profileTreeWidth ?? 300) + panelStartWidth = this.panelInternalWidth + panelIsResizing = false + panelStartX = 0 constructor ( private app: AppService, @@ -44,7 +44,7 @@ export class ProfileTreeComponent extends BaseComponent { private config: ConfigService, private profilesService: ProfilesService, private translate: TranslateService, - private ngbModal: NgbModal + private ngbModal: NgbModal, ) { super() } @@ -54,7 +54,7 @@ export class ProfileTreeComponent extends BaseComponent { this.subscribeUntilDestroyed(this.config.changed$, () => this.loadTreeItems()) this.subscribeUntilDestroyed(this.config.changed$, () => this.loadTreeItems()) this.app.tabsChanged$.subscribe(() => this.tabStateChanged()) - this.app.activeTabChange$.subscribe((e) => this.tabStateChanged()) + this.app.activeTabChange$.subscribe(() => this.tabStateChanged()) } @@ -63,7 +63,7 @@ export class ProfileTreeComponent extends BaseComponent { let groups = await this.profilesService.getProfileGroups({ includeNonUserGroup: true, includeProfiles: true }) for (const group of groups) { - if (group?.profiles?.length) { + if (group.profiles?.length) { // remove template profiles group.profiles = group.profiles.filter(x => !x.isTemplate) @@ -72,7 +72,7 @@ export class ProfileTreeComponent extends BaseComponent { } } - if (!this.config.store.terminal.showBuiltinProfiles) groups = groups.filter(g => g.id !== 'built-in') + 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)) @@ -89,13 +89,13 @@ export class ProfileTreeComponent extends BaseComponent { ) const provider = this.profilesService.providerForProfile(profile) - if (!provider) throw new Error('Cannot edit a profile without a provider') + 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; + if (!result) { return } result.type = provider.id @@ -114,16 +114,16 @@ export class ProfileTreeComponent extends BaseComponent { modal.componentInstance.group = deepClone(group) modal.componentInstance.providers = this.profilesService.getProviders() - const result: PartialProfileGroup, provider?: ProfileProvider }> = await modal.result.catch(() => null) - if (!result) return - if (!result?.group) return; + const result: PartialProfileGroup, provider?: ProfileProvider }> | 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; + delete result.group.collapsed + delete result.group.children await this.profilesService.writeProfileGroup(result.group) await this.config.save() } @@ -157,42 +157,42 @@ export class ProfileTreeComponent extends BaseComponent { return this.editProfileGroup(group) } - async profileContextMenu(profile: PartialProfile, event: MouseEvent): Promise { + async profileContextMenu (profile: PartialProfile, event: MouseEvent): Promise { event.preventDefault() this.platform.popupContextMenu([ { type: 'normal', label: this.translate.instant('Run'), - click: () => this.launchProfile(profile) + click: () => this.launchProfile(profile), }, { type: 'normal', label: this.translate.instant('Edit profile'), click: () => this.editProfile(profile), - enabled: !(profile.isBuiltin || profile.isTemplate) - } - ]); + enabled: !(profile.isBuiltin ?? profile.isTemplate), + }, + ]) } - async groupContextMenu(group: PartialProfileGroup, event: MouseEvent): Promise { + async groupContextMenu (group: PartialProfileGroup, event: MouseEvent): Promise { event.preventDefault() this.platform.popupContextMenu([ { type: 'normal', label: group.collapsed ? this.translate.instant('Expand group') : this.translate.instant('Collapse group'), - click: () => this.toggleGroupCollapse(group) + click: () => this.toggleGroupCollapse(group), }, { type: 'normal', label: this.translate.instant('Edit group'), click: () => this.editProfileGroup(group), - enabled: group.editable - } - ]); + enabled: group.editable, + }, + ]) } - private async tabStateChanged(): Promise { + private async tabStateChanged (): Promise { // TODO: show active tab in the side panel with eye icon } @@ -200,25 +200,25 @@ export class ProfileTreeComponent extends BaseComponent { return this.profilesService.launchProfile(profile) } - async onFilterChange(): Promise { + async onFilterChange (): Promise { try { const q = this.filter.trim().toLowerCase() if (q.length === 0) { - this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups); + this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups) return } const profiles = await this.profilesService.getProfiles({ includeBuiltin: this.config.store.terminal.showBuiltinProfiles, - clone: true + clone: true, }) const matches = new FuzzySearch( profiles.filter(p => !p.isTemplate), ['name', 'description'], { sort: false }, - ).search(q); + ).search(q) this.rootGroups = [ { @@ -226,43 +226,43 @@ export class ProfileTreeComponent extends BaseComponent { editable: false, name: this.translate.instant('Filter results'), icon: 'fas fa-magnifying-glass', - profiles: matches - } + profiles: matches, + }, ] } catch (error) { - console.error('Error occurred during search:', error); + console.error('Error occurred during search:', error) } } ////// RESIZING ////// - startResize(event: MouseEvent) { - this.panelIsResizing = true; - this.panelStartX = event.clientX; - this.panelStartWidth = this.panelWidth; - event.preventDefault(); + startResize (event: MouseEvent): void { + this.panelIsResizing = true + this.panelStartX = event.clientX + this.panelStartWidth = this.panelWidth + event.preventDefault() } @HostListener('document:mousemove', ['$event']) - onMouseMove(event: MouseEvent) { - if (!this.panelIsResizing) return; - const delta = event.clientX - this.panelStartX; + 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; + this.panelWidth = width + window.localStorage.profileTreeWidth = width } @HostListener('document:mouseup') - stopResize() { - this.panelIsResizing = false; + stopResize (): void { + this.panelIsResizing = false } @HostBinding('style.width.px') - get panelWidth() { - return this.panelInternalWidth; + get panelWidth (): number { + return this.panelInternalWidth } - set panelWidth(value: number) { - this.panelInternalWidth = value; + set panelWidth (value: number) { + this.panelInternalWidth = value } ////// GROUP COLLAPSING ////// diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index 2e3996ab73..76d9fb713d 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -215,27 +215,28 @@ export class ProfilesService { const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined return { ...profile, - group: this.resolveProfileGroupPath(profile?.group ?? '').join(' 🡒 '), + group: this.resolveProfileGroupPath(profile.group ?? '').join(' 🡒 '), freeInputEquivalent, description: provider?.getDescription(fullProfile), } } - buildGroupTree (groups: PartialProfileGroup[]): PartialProfileGroup[] { - const map = new Map>() + buildGroupTree (groups: PartialProfileGroup[]): PartialProfileGroup[] { + const map = new Map>() for (const group of groups) { group.children = [] map.set(group.id, group) } - const roots: PartialProfileGroup[] = [] + const roots: PartialProfileGroup[] = [] 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 + if (parent) { + parent.children.push(group) + } else { roots.push(group) } // Orphaned group, treat as root } else { roots.push(group) } @@ -286,8 +287,8 @@ export class ProfilesService { 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' + if (p.isBuiltin) { p.group = 'Built-in' } + if (!p.icon) { p.icon = 'fas fa-network-wired' } return p }) } @@ -528,30 +529,30 @@ export class ProfilesService { * Resolve and return ProfileGroup Name from ProfileGroup ID */ resolveProfileGroupName (groupId: string): string { - const group = this.resolveProfileGroup(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; + const groupNames: string[] = [] + let currentGroupId: string | undefined = groupId + let depth = 0 while (currentGroupId && depth <= 30) { - const group = this.resolveProfileGroup(currentGroupId); + const group = this.resolveProfileGroup(currentGroupId) if (!group) { - groupNames.unshift(currentGroupId); - break; + groupNames.unshift(currentGroupId) + break } - if (group.name) groupNames.unshift(group.name); + if (group.name) { groupNames.unshift(group.name) } - if (!group.parentGroupId) break; - currentGroupId = group.parentGroupId; - depth++; + if (!group.parentGroupId) { break } + currentGroupId = group.parentGroupId + depth++ } - return groupNames; + return groupNames } /** diff --git a/tabby-settings/src/components/editProfileGroupModal.component.ts b/tabby-settings/src/components/editProfileGroupModal.component.ts index d9195f7825..7c7c6464f7 100644 --- a/tabby-settings/src/components/editProfileGroupModal.component.ts +++ b/tabby-settings/src/components/editProfileGroupModal.component.ts @@ -23,41 +23,39 @@ export class EditProfileGroupModalComponent { getValidParents (groups: PartialProfileGroup[], targetId: string): PartialProfileGroup[] { // Build a quick lookup: parentGroupId -> [child groups] - const childrenMap = new Map(); + const childrenMap = new Map() for (const group of groups) { - const parent = group.parentGroupId ?? null; + const parent = group.parentGroupId ?? null if (!childrenMap.has(parent)) { - childrenMap.set(parent, []); + childrenMap.set(parent, []) } - childrenMap.get(parent)!.push(group.id); + childrenMap.get(parent)!.push(group.id) } // Depth-first search to find all descendants of target function getDescendants (id: string): Set { - const descendants = new Set(); - const stack: string[] = [id]; + const descendants = new Set() + const stack: string[] = [id] while (stack.length > 0) { - const current = stack.pop()!; - const children = childrenMap.get(current); + 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); + descendants.add(child) + stack.push(child) } } } } - return descendants; + return descendants } - const descendants = getDescendants(targetId); + 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) - ); + return groups.filter((g) => g.id !== targetId && !descendants.has(g.id)) } constructor ( diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index 7d853c02b4..6f3fcdb7a6 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -153,7 +153,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { this.editProfileGroup({ id: 'new', name: '', - icon: 'far fa-folder' + icon: 'far fa-folder', }) } diff --git a/tabby-settings/src/index.ts b/tabby-settings/src/index.ts index c7af901ffc..df88e1dd3c 100644 --- a/tabby-settings/src/index.ts +++ b/tabby-settings/src/index.ts @@ -84,8 +84,8 @@ export default class SettingsModule { } export * from './api' -export { +export { SettingsTabComponent, EditProfileModalComponent, - EditProfileGroupModalComponent -} \ No newline at end of file + EditProfileGroupModalComponent, +} From 77f179ee13d618b16e8be615de45d779e2d66823 Mon Sep 17 00:00:00 2001 From: D3VL Jack Date: Wed, 8 Oct 2025 01:57:17 +0100 Subject: [PATCH 13/13] eslint '?' --- .../src/components/editProfileGroupModal.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabby-settings/src/components/editProfileGroupModal.component.ts b/tabby-settings/src/components/editProfileGroupModal.component.ts index 7c7c6464f7..23dd58b878 100644 --- a/tabby-settings/src/components/editProfileGroupModal.component.ts +++ b/tabby-settings/src/components/editProfileGroupModal.component.ts @@ -65,7 +65,7 @@ export class EditProfileGroupModalComponent { private translate: TranslateService, ) { this.profilesService.getProfileGroups().then(groups => { - this.groups = this.getValidParents(groups, this.group?.id) + this.groups = this.getValidParents(groups, this.group.id) this.selectedParentGroup = groups.find(g => g.id === this.group.parentGroupId) ?? undefined }) }