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, 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-core/src/components/appRoot.component.pug b/tabby-core/src/components/appRoot.component.pug index 59ef961fe9..622fda3ad0 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( - *ngIf='ready', - [class.tabs-on-top]='config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left" || config.store.appearance.tabsLocation == "right"', - [class.tabs-on-left]='hasVerticalTabs() && config.store.appearance.tabsLocation == "left"', - [class.tabs-titlebar-enabled]='isTitleBarNeeded()', - [class.tabs-on-right]='hasVerticalTabs() && config.store.appearance.tabsLocation == "right"', -) - .tab-bar( - *ngIf='!hostWindow.isFullscreen || config.store.appearance.tabsInFullscreen', - [class.tab-bar-no-controls-overlay]='hostApp.platform == Platform.macOS', - (dblclick)='!isTitleBarNeeded() && toggleMaximize()' +.window.h-100.d-flex + + profile-tree( + *ngIf='ready && !config.store.hideProfileTree' ) - .inset.background(*ngIf='hostApp.platform == Platform.macOS \ - && !hostWindow.isFullscreen \ - && config.store.appearance.frame == "thin" \ - && (config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left")') - .tabs( - cdkDropList, - [cdkDropListOrientation]='(config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "bottom") ? "horizontal" : "vertical"', - (cdkDropListDropped)='onTabsReordered($event)', - cdkAutoDropGroup='app-tabs' - ) - 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..0a71ba7ad9 --- /dev/null +++ b/tabby-core/src/components/profileTree.component.ts @@ -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[] +} + +/** @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 = '' + + + 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(() => 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 }> | 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, 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): void { + this.panelIsResizing = true + this.panelStartX = event.clientX + this.panelStartWidth = this.panelWidth + event.preventDefault() + } + + @HostListener('document:mousemove', ['$event']) + onMouseMove (event: MouseEvent): void { + if (!this.panelIsResizing) { return } + const delta = event.clientX - this.panelStartX + const width = Math.min(Math.max(this.panelMinWidth, this.panelStartWidth + delta), this.panelMaxWidth) + this.panelWidth = width + window.localStorage.profileTreeWidth = width + } + + @HostListener('document:mouseup') + stopResize (): void { + this.panelIsResizing = false + } + + @HostBinding('style.width.px') + get panelWidth (): number { + return this.panelInternalWidth + } + + set panelWidth (value: number) { + this.panelInternalWidth = value + } + + ////// GROUP COLLAPSING ////// + toggleGroupCollapse (group: PartialProfileGroup): 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-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index 18522ee5cd..76d9fb713d 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -215,13 +215,37 @@ export class ProfilesService { const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined return { ...profile, - group: this.resolveProfileGroupName(profile.group ?? ''), + group: this.resolveProfileGroupPath(profile.group ?? '').join(' 🡒 '), freeInputEquivalent, description: provider?.getDescription(fullProfile), } } - showProfileSelector (): Promise|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) } @@ -261,6 +285,12 @@ export class ProfilesService { if (!this.config.store.terminal.showBuiltinProfiles) { profiles = profiles.filter(x => !x.isBuiltin) + } else { + profiles = profiles.map(p => { + if (p.isBuiltin) { p.group = 'Built-in' } + if (!p.icon) { p.icon = 'fas fa-network-wired' } + return p + }) } profiles = profiles.filter(x => !x.isTemplate) @@ -499,7 +529,37 @@ export class ProfilesService { * Resolve and return ProfileGroup Name from ProfileGroup ID */ resolveProfileGroupName (groupId: string): string { - return this.config.store.groups.find(g => g.id === groupId)?.name ?? groupId + const group = this.resolveProfileGroup(groupId) + return group?.name ?? groupId + } + + resolveProfileGroupPath (groupId: string): string[] { + const groupNames: string[] = [] + let currentGroupId: string | undefined = groupId + let depth = 0 + + while (currentGroupId && depth <= 30) { + const group = this.resolveProfileGroup(currentGroupId) + if (!group) { + groupNames.unshift(currentGroupId) + break + } + + if (group.name) { groupNames.unshift(group.name) } + + if (!group.parentGroupId) { break } + currentGroupId = group.parentGroupId + depth++ + } + + return groupNames + } + + /** + * Resolve and return ProfileGroup | null from ProfileGroup ID + */ + resolveProfileGroup (groupId: string): PartialProfileGroup | null { + return this.config.store.groups.find(g => g.id === groupId) ?? null } /** 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); 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..23dd58b878 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,97 @@ 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) + + // 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(this.group, { genId: true }) + } 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..6f3fcdb7a6 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' @@ -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 @@ -147,13 +150,11 @@ 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() - } + this.editProfileGroup({ + id: 'new', + name: '', + icon: 'far fa-folder', + }) } async editProfileGroup (group: PartialProfileGroup): Promise { @@ -161,6 +162,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { if (!result) { return } + await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(result)) await this.config.save() } @@ -254,6 +256,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { groups.sort((a, b) => (a.id === 'built-in' || !a.editable ? 1 : 0) - (b.id === 'built-in' || !b.editable ? 1 : 0)) groups.sort((a, b) => (a.id === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1)) this.profileGroups = groups.map(g => ProfilesSettingsTabComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false)) + this.rootGroups = this.profilesService.buildGroupTree(this.profileGroups) } isGroupVisible (group: PartialProfileGroup): boolean { @@ -286,9 +289,6 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } toggleGroupCollapse (group: PartialProfileGroup): void { - if (group.profiles?.length === 0) { - return - } group.collapsed = !group.collapsed this.saveProfileGroupCollapse(group) } @@ -364,6 +364,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup): PartialProfileGroup { const g: any = { ...group } delete g.collapsed + delete g.children return g } 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') diff --git a/tabby-settings/src/index.ts b/tabby-settings/src/index.ts index d9f0abad38..df88e1dd3c 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, +}