fixed #6164 - added command palette

This commit is contained in:
Eugene Pankov 2022-11-01 17:13:23 +01:00
parent 856c042bb8
commit f094db9de2
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
17 changed files with 187 additions and 30 deletions

View File

@ -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,
}

View File

@ -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'

View File

@ -6,4 +6,7 @@ export interface MenuItemOptions {
checked?: boolean
submenu?: MenuItemOptions[]
click?: () => void
/** @hidden */
commandLabel?: string
}

View File

@ -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<MenuItemOptions[]>
abstract getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]>
}

View File

@ -31,6 +31,9 @@ export interface ToolbarButton {
showInToolbar?: boolean
showInStartPage?: boolean
/** @hidden */
commandLabel?: string
}
/**

View File

@ -38,3 +38,8 @@ input {
border-radius: 0;
border: none;
}
profile-icon {
width: 14px;
height: 14px;
}

View File

@ -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<MenuItemOptions[]> {
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()
}

View File

@ -94,3 +94,5 @@ hotkeys:
- 'Ctrl-Alt-T'
profile-selector:
- 'Ctrl-Shift-E'
command-selector:
- 'Ctrl-Shift-P'

View File

@ -93,5 +93,7 @@ hotkeys:
- '⌘-E'
switch-profile:
- '⌘-Shift-E'
command-selector:
- '⌘-Shift-P'
appearance:
vibrancy: true

View File

@ -95,3 +95,5 @@ hotkeys:
- 'Ctrl-Alt-T'
profile-selector:
- 'Ctrl-Shift-E'
command-selector:
- 'Ctrl-Shift-P'

View File

@ -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',

View File

@ -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()
}
})
}

View File

@ -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()

View File

@ -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<Command[]> {
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<void> {
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,
})),
)
}
}

View File

@ -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<MenuItemOptions[]> {
async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]> {
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,

View File

@ -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<MenuItemOptions[]> {
async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
if (!(tab instanceof TerminalTabComponent)) {
return []
}
@ -69,7 +69,7 @@ export class NewTabContextMenu extends TabContextMenuItemProvider {
super()
}
async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]> {
const profiles = (await this.profilesService.getProfiles()).filter(x => x.type === 'local') as LocalProfile[]
const items: MenuItemOptions[] = [

View File

@ -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<MenuItemOptions[]> {
async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]> {
if (tabHeader) {
return []
}
@ -77,7 +77,7 @@ export class LegacyContextMenu extends TabContextMenuItemProvider {
super()
}
async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
if (!this.contextMenuProviders) {
return []
}