From f094db9de215b0698100d83a73855a9d17ef012f Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Tue, 1 Nov 2022 17:13:23 +0100 Subject: [PATCH] fixed #6164 - added command palette --- tabby-core/src/api/commands.ts | 34 ++++++++ tabby-core/src/api/index.ts | 1 + tabby-core/src/api/menu.ts | 3 + tabby-core/src/api/tabContextMenuProvider.ts | 3 +- tabby-core/src/api/toolbarButtonProvider.ts | 3 + .../components/selectorModal.component.scss | 5 ++ .../src/components/tabHeader.component.ts | 21 +---- tabby-core/src/configDefaults.linux.yaml | 2 + tabby-core/src/configDefaults.macos.yaml | 2 + tabby-core/src/configDefaults.windows.yaml | 2 + tabby-core/src/hotkeys.ts | 6 +- tabby-core/src/index.ts | 5 ++ tabby-core/src/services/app.service.ts | 15 +++- tabby-core/src/services/commands.service.ts | 86 +++++++++++++++++++ tabby-core/src/tabContextMenu.ts | 17 +++- tabby-local/src/tabContextMenu.ts | 6 +- tabby-terminal/src/tabContextMenu.ts | 6 +- 17 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 tabby-core/src/api/commands.ts create mode 100644 tabby-core/src/services/commands.service.ts diff --git a/tabby-core/src/api/commands.ts b/tabby-core/src/api/commands.ts new file mode 100644 index 00000000..e3d278b0 --- /dev/null +++ b/tabby-core/src/api/commands.ts @@ -0,0 +1,34 @@ +import { BaseTabComponent } from '../components/baseTab.component' +import { MenuItemOptions } from './menu' +import { ToolbarButton } from './toolbarButtonProvider' + +export class Command { + label: string + sublabel?: string + click?: () => void + + /** + * Raw SVG icon code + */ + icon?: string + + static fromToolbarButton (button: ToolbarButton): Command { + const command = new Command() + command.label = button.commandLabel ?? button.title + command.click = button.click + command.icon = button.icon + return command + } + + static fromMenuItem (item: MenuItemOptions): Command { + const command = new Command() + command.label = item.commandLabel ?? item.label ?? '' + command.sublabel = item.sublabel + command.click = item.click + return command + } +} + +export interface CommandContext { + tab?: BaseTabComponent, +} diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index ee916fc7..c99e16f8 100644 --- a/tabby-core/src/api/index.ts +++ b/tabby-core/src/api/index.ts @@ -18,6 +18,7 @@ export { HostAppService, Platform } from './hostApp' export { FileProvider } from './fileProvider' export { ProfileProvider, Profile, PartialProfile, ProfileSettingsComponent } from './profileProvider' export { PromptModalComponent } from '../components/promptModal.component' +export * from './commands' export { AppService } from '../services/app.service' export { ConfigService, configMerge, ConfigProxy } from '../services/config.service' diff --git a/tabby-core/src/api/menu.ts b/tabby-core/src/api/menu.ts index 2641eb62..b778db85 100644 --- a/tabby-core/src/api/menu.ts +++ b/tabby-core/src/api/menu.ts @@ -6,4 +6,7 @@ export interface MenuItemOptions { checked?: boolean submenu?: MenuItemOptions[] click?: () => void + + /** @hidden */ + commandLabel?: string } diff --git a/tabby-core/src/api/tabContextMenuProvider.ts b/tabby-core/src/api/tabContextMenuProvider.ts index baad789b..d530443e 100644 --- a/tabby-core/src/api/tabContextMenuProvider.ts +++ b/tabby-core/src/api/tabContextMenuProvider.ts @@ -1,5 +1,4 @@ import { BaseTabComponent } from '../components/baseTab.component' -import { TabHeaderComponent } from '../components/tabHeader.component' import { MenuItemOptions } from './menu' /** @@ -8,5 +7,5 @@ import { MenuItemOptions } from './menu' export abstract class TabContextMenuItemProvider { weight = 0 - abstract getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise + abstract getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise } diff --git a/tabby-core/src/api/toolbarButtonProvider.ts b/tabby-core/src/api/toolbarButtonProvider.ts index a3e2402a..8243cda6 100644 --- a/tabby-core/src/api/toolbarButtonProvider.ts +++ b/tabby-core/src/api/toolbarButtonProvider.ts @@ -31,6 +31,9 @@ export interface ToolbarButton { showInToolbar?: boolean showInStartPage?: boolean + + /** @hidden */ + commandLabel?: string } /** diff --git a/tabby-core/src/components/selectorModal.component.scss b/tabby-core/src/components/selectorModal.component.scss index be51f409..f3f92701 100644 --- a/tabby-core/src/components/selectorModal.component.scss +++ b/tabby-core/src/components/selectorModal.component.scss @@ -38,3 +38,8 @@ input { border-radius: 0; border: none; } + +profile-icon { + width: 14px; + height: 14px; +} diff --git a/tabby-core/src/components/tabHeader.component.ts b/tabby-core/src/components/tabHeader.component.ts index 0239fdb3..b7df24e8 100644 --- a/tabby-core/src/components/tabHeader.component.ts +++ b/tabby-core/src/components/tabHeader.component.ts @@ -1,10 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Component, Input, Optional, Inject, HostBinding, HostListener, NgZone } from '@angular/core' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { auditTime } from 'rxjs' import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider' import { BaseTabComponent } from './baseTab.component' -import { RenameTabModalComponent } from './renameTabModal.component' import { SplitTabComponent } from './splitTab.component' import { HotkeysService } from '../services/hotkeys.service' import { AppService } from '../services/app.service' @@ -31,7 +29,6 @@ export class TabHeaderComponent extends BaseComponent { public app: AppService, public config: ConfigService, public hostApp: HostAppService, - private ngbModal: NgbModal, private hotkeys: HotkeysService, private platform: PlatformService, private zone: NgZone, @@ -41,7 +38,7 @@ export class TabHeaderComponent extends BaseComponent { this.subscribeUntilDestroyed(this.hotkeys.hotkey$, (hotkey) => { if (this.app.activeTab === this.tab) { if (hotkey === 'rename-tab') { - this.showRenameTabModal() + this.app.renameTab(this.tab) } } }) @@ -58,27 +55,17 @@ export class TabHeaderComponent extends BaseComponent { }) } - showRenameTabModal (): void { - const modal = this.ngbModal.open(RenameTabModalComponent) - modal.componentInstance.value = this.tab.customTitle || this.tab.title - modal.result.then(result => { - this.tab.setTitle(result) - this.tab.customTitle = result - this.app.emitTabsChanged() - }).catch(() => null) - } - async buildContextMenu (): Promise { let items: MenuItemOptions[] = [] // Top-level tab menu - for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this.tab, this)))) { + for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this.tab, true)))) { items.push({ type: 'separator' }) items = items.concat(section) } if (this.tab instanceof SplitTabComponent) { const tab = this.tab.getFocusedTab() if (tab) { - for (let section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(tab, this)))) { + for (let section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(tab, true)))) { // eslint-disable-next-line @typescript-eslint/no-loop-func section = section.filter(item => !items.some(ex => ex.label === item.label)) if (section.length) { @@ -107,7 +94,7 @@ export class TabHeaderComponent extends BaseComponent { } @HostListener('dblclick', ['$event']) onDoubleClick ($event: MouseEvent): void { - this.showRenameTabModal() + this.app.renameTab(this.tab) $event.stopPropagation() } diff --git a/tabby-core/src/configDefaults.linux.yaml b/tabby-core/src/configDefaults.linux.yaml index 4805ce0e..11a2fed1 100644 --- a/tabby-core/src/configDefaults.linux.yaml +++ b/tabby-core/src/configDefaults.linux.yaml @@ -94,3 +94,5 @@ hotkeys: - 'Ctrl-Alt-T' profile-selector: - 'Ctrl-Shift-E' + command-selector: + - 'Ctrl-Shift-P' diff --git a/tabby-core/src/configDefaults.macos.yaml b/tabby-core/src/configDefaults.macos.yaml index b1be9458..8d9d40f6 100644 --- a/tabby-core/src/configDefaults.macos.yaml +++ b/tabby-core/src/configDefaults.macos.yaml @@ -93,5 +93,7 @@ hotkeys: - '⌘-E' switch-profile: - '⌘-Shift-E' + command-selector: + - '⌘-Shift-P' appearance: vibrancy: true diff --git a/tabby-core/src/configDefaults.windows.yaml b/tabby-core/src/configDefaults.windows.yaml index 80bd85ee..03b4ed6b 100644 --- a/tabby-core/src/configDefaults.windows.yaml +++ b/tabby-core/src/configDefaults.windows.yaml @@ -95,3 +95,5 @@ hotkeys: - 'Ctrl-Alt-T' profile-selector: - 'Ctrl-Shift-E' + command-selector: + - 'Ctrl-Shift-P' diff --git a/tabby-core/src/hotkeys.ts b/tabby-core/src/hotkeys.ts index c6c15403..c14ac666 100644 --- a/tabby-core/src/hotkeys.ts +++ b/tabby-core/src/hotkeys.ts @@ -8,6 +8,10 @@ import { PartialProfile, Profile } from './api' @Injectable() export class AppHotkeyProvider extends HotkeyProvider { hotkeys: HotkeyDescription[] = [ + { + id: 'command-selector', + name: this.translate.instant('Show command selector'), + }, { id: 'profile-selector', name: this.translate.instant('Show profile selector'), @@ -18,7 +22,7 @@ export class AppHotkeyProvider extends HotkeyProvider { }, { id: 'rename-tab', - name: this.translate.instant('Rename Tab'), + name: this.translate.instant('Rename tab'), }, { id: 'close-tab', diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index d5092b5b..5eec617f 100644 --- a/tabby-core/src/index.ts +++ b/tabby-core/src/index.ts @@ -44,6 +44,7 @@ import { ConfigService } from './services/config.service' import { VaultFileProvider } from './services/vault.service' import { HotkeysService } from './services/hotkeys.service' import { LocaleService, TranslateServiceWrapper } from './services/locale.service' +import { CommandService } from './services/commands.service' import { StandardTheme, StandardCompactTheme, PaperTheme } from './theme' import { CoreConfigProvider } from './config' @@ -161,6 +162,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex config: ConfigService, platform: PlatformService, hotkeys: HotkeysService, + commands: CommandService, public locale: LocaleService, private translate: TranslateService, private profilesService: ProfilesService, @@ -195,6 +197,9 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex } this.showSelector(provider) } + if (hotkey === 'command-selector') { + commands.showSelector() + } }) } diff --git a/tabby-core/src/services/app.service.ts b/tabby-core/src/services/app.service.ts index ab4786ab..16829a23 100644 --- a/tabby-core/src/services/app.service.ts +++ b/tabby-core/src/services/app.service.ts @@ -1,8 +1,10 @@ -import { Observable, Subject, AsyncSubject, takeUntil, debounceTime } from 'rxjs' import { Injectable, Inject } from '@angular/core' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { Observable, Subject, AsyncSubject, takeUntil, debounceTime } from 'rxjs' import { BaseTabComponent } from '../components/baseTab.component' import { SplitTabComponent } from '../components/splitTab.component' +import { RenameTabModalComponent } from '../components/renameTabModal.component' import { SelectorOption } from '../api/selector' import { RecoveryToken } from '../api/tabRecovery' import { BootstrapData, BOOTSTRAP_DATA } from '../api/mainProcess' @@ -80,6 +82,7 @@ export class AppService { private tabRecovery: TabRecoveryService, private tabsService: TabsService, private selector: SelectorService, + private ngbModal: NgbModal, @Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData, ) { this.tabsChanged$.subscribe(() => { @@ -318,6 +321,16 @@ export class AppService { this.tabs[i2] = a } + renameTab (tab: BaseTabComponent): void { + const modal = this.ngbModal.open(RenameTabModalComponent) + modal.componentInstance.value = tab.customTitle || tab.title + modal.result.then(result => { + tab.setTitle(result) + tab.customTitle = result + this.emitTabsChanged() + }).catch(() => null) + } + /** @hidden */ emitTabsChanged (): void { this.tabsChanged.next() diff --git a/tabby-core/src/services/commands.service.ts b/tabby-core/src/services/commands.service.ts new file mode 100644 index 00000000..c24d055c --- /dev/null +++ b/tabby-core/src/services/commands.service.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable, Optional } from '@angular/core' +import { AppService, Command, CommandContext, ConfigService, MenuItemOptions, SplitTabComponent, TabContextMenuItemProvider, ToolbarButton, ToolbarButtonProvider, TranslateService } from '../api' +import { SelectorService } from './selector.service' + +@Injectable({ providedIn: 'root' }) +export class CommandService { + constructor ( + private selector: SelectorService, + private config: ConfigService, + private app: AppService, + private translate: TranslateService, + @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[], + @Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[], + ) { + this.contextMenuProviders.sort((a, b) => a.weight - b.weight) + } + + async getCommands (context: CommandContext): Promise { + let buttons: ToolbarButton[] = [] + this.config.enabledServices(this.toolbarButtonProviders).forEach(provider => { + buttons = buttons.concat(provider.provide()) + }) + buttons = buttons + .sort((a: ToolbarButton, b: ToolbarButton) => (a.weight ?? 0) - (b.weight ?? 0)) + + let items: MenuItemOptions[] = [] + if (context.tab) { + for (const tabHeader of [false, true]) { + // Top-level tab menu + for (let section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(context.tab!, tabHeader)))) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + section = section.filter(item => !items.some(ex => ex.label === item.label)) + items = items.concat(section) + } + if (context.tab instanceof SplitTabComponent) { + const tab = context.tab.getFocusedTab() + if (tab) { + for (let section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(tab, tabHeader)))) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + section = section.filter(item => !items.some(ex => ex.label === item.label)) + items = items.concat(section) + } + } + } + } + } + + items = items.filter(x => (x.enabled ?? true) && x.type !== 'separator') + + const flatItems: MenuItemOptions[] = [] + function flattenItem (item: MenuItemOptions, prefix?: string): void { + if (item.submenu) { + item.submenu.forEach(x => flattenItem(x, (prefix ? `${prefix} > ` : '') + (item.commandLabel ?? item.label))) + } else { + flatItems.push({ + ...item, + label: (prefix ? `${prefix} > ` : '') + (item.commandLabel ?? item.label), + }) + } + } + items.forEach(x => flattenItem(x)) + + let commands = buttons.map(x => Command.fromToolbarButton(x)) + commands = commands.concat(flatItems.map(x => Command.fromMenuItem(x))) + + return commands + } + + async showSelector (): Promise { + const context: CommandContext = {} + const tab = this.app.activeTab + if (tab instanceof SplitTabComponent) { + context.tab = tab.getFocusedTab() ?? undefined + } + const commands = await this.getCommands(context) + await this.selector.show( + this.translate.instant('Commands'), + commands.map(c => ({ + name: c.label, + callback: c.click, + description: c.sublabel, + icon: c.icon, + })), + ) + } +} diff --git a/tabby-core/src/tabContextMenu.ts b/tabby-core/src/tabContextMenu.ts index d89f1263..31ee3e1f 100644 --- a/tabby-core/src/tabContextMenu.ts +++ b/tabby-core/src/tabContextMenu.ts @@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core' import { Subscription } from 'rxjs' import { AppService } from './services/app.service' import { BaseTabComponent } from './components/baseTab.component' -import { TabHeaderComponent } from './components/tabHeader.component' import { SplitTabComponent, SplitDirection } from './components/splitTab.component' import { TabContextMenuItemProvider } from './api/tabContextMenuProvider' import { MenuItemOptions } from './api/menu' @@ -32,6 +31,7 @@ export class TabManagementContextMenu extends TabContextMenuItemProvider { let items: MenuItemOptions[] = [ { label: this.translate.instant('Close'), + commandLabel: this.translate.instant('Close tab'), click: () => { if (this.app.tabs.includes(tab)) { this.app.closeTab(tab, true) @@ -80,6 +80,12 @@ export class TabManagementContextMenu extends TabContextMenuItemProvider { l: this.translate.instant('Left'), t: this.translate.instant('Up'), }[dir], + commandLabel: { + r: this.translate.instant('Split to the right'), + b: this.translate.instant('Split to the down'), + l: this.translate.instant('Split to the left'), + t: this.translate.instant('Split to the up'), + }[dir], click: () => { (tab.parent as SplitTabComponent).splitTab(tab, dir) }, @@ -104,7 +110,7 @@ export class CommonOptionsContextMenu extends TabContextMenuItemProvider { super() } - async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise { + async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise { let items: MenuItemOptions[] = [] if (tabHeader) { const currentColor = TAB_COLORS.find(x => x.value === tab.color)?.name @@ -112,14 +118,19 @@ export class CommonOptionsContextMenu extends TabContextMenuItemProvider { ...items, { label: this.translate.instant('Rename'), - click: () => tabHeader.showRenameTabModal(), + commandLabel: this.translate.instant('Rename tab'), + click: () => { + this.app.renameTab(tab) + }, }, { label: this.translate.instant('Duplicate'), + commandLabel: this.translate.instant('Duplicate tab'), click: () => this.app.duplicateTab(tab), }, { label: this.translate.instant('Color'), + commandLabel: this.translate.instant('Change tab color'), sublabel: currentColor ? this.translate.instant(currentColor) : undefined, submenu: TAB_COLORS.map(color => ({ label: this.translate.instant(color.name) ?? color.name, diff --git a/tabby-local/src/tabContextMenu.ts b/tabby-local/src/tabContextMenu.ts index d935fd41..79a02516 100644 --- a/tabby-local/src/tabContextMenu.ts +++ b/tabby-local/src/tabContextMenu.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, SplitTabComponent, NotificationsService, MenuItemOptions, ProfilesService, PromptModalComponent, TranslateService } from 'tabby-core' +import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, SplitTabComponent, NotificationsService, MenuItemOptions, ProfilesService, PromptModalComponent, TranslateService } from 'tabby-core' import { TerminalTabComponent } from './components/terminalTab.component' import { UACService } from './services/uac.service' import { TerminalService } from './services/terminal.service' @@ -18,7 +18,7 @@ export class SaveAsProfileContextMenu extends TabContextMenuItemProvider { super() } - async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise { + async getItems (tab: BaseTabComponent): Promise { if (!(tab instanceof TerminalTabComponent)) { return [] } @@ -69,7 +69,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider { super() } - async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise { + async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise { const profiles = (await this.profilesService.getProfiles()).filter(x => x.type === 'local') as LocalProfile[] const items: MenuItemOptions[] = [ diff --git a/tabby-terminal/src/tabContextMenu.ts b/tabby-terminal/src/tabContextMenu.ts index 7ac90eea..6b78751a 100644 --- a/tabby-terminal/src/tabContextMenu.ts +++ b/tabby-terminal/src/tabContextMenu.ts @@ -1,5 +1,5 @@ import { Injectable, Optional, Inject } from '@angular/core' -import { BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, NotificationsService, MenuItemOptions, TranslateService } from 'tabby-core' +import { BaseTabComponent, TabContextMenuItemProvider, NotificationsService, MenuItemOptions, TranslateService } from 'tabby-core' import { BaseTerminalTabComponent } from './api/baseTerminalTab.component' import { TerminalContextMenuItemProvider } from './api/contextMenuProvider' @@ -15,7 +15,7 @@ export class CopyPasteContextMenu extends TabContextMenuItemProvider { super() } - async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise { + async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise { if (tabHeader) { return [] } @@ -77,7 +77,7 @@ export class LegacyContextMenu extends TabContextMenuItemProvider { super() } - async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise { + async getItems (tab: BaseTabComponent): Promise { if (!this.contextMenuProviders) { return [] }