Browse Source

more commands

Eugene 1 year ago
parent
commit
c12b445ccd

+ 32 - 21
tabby-core/src/api/commands.ts

@@ -1,3 +1,4 @@
+import slugify from 'slugify'
 import { BaseTabComponent } from '../components/baseTab.component'
 import { MenuItemOptions } from './menu'
 import { ToolbarButton } from './toolbarButtonProvider'
@@ -6,34 +7,33 @@ export enum CommandLocation {
     LeftToolbar = 'left-toolbar',
     RightToolbar = 'right-toolbar',
     StartPage = 'start-page',
+    TabHeaderMenu = 'tab-header-menu',
+    TabBodyMenu = 'tab-body-menu',
 }
 
 export class Command {
-    id?: string
+    id: string
     label: string
-    sublabel?: string
-    locations?: CommandLocation[]
-    run: () => Promise<void>
+    fullLabel?: string
+    locations: CommandLocation[]
+    run?: () => Promise<any>
 
     /**
      * Raw SVG icon code
      */
     icon?: string
 
-    /**
-     * Optional Touch Bar icon ID
-     */
-    touchBarNSImage?: string
+    weight?: number
 
-    /**
-     * Optional Touch Bar button label
-     */
-    touchBarTitle?: string
+    parent?: string
 
-    weight?: number
+    group?: string
+
+    checked?: boolean
 
     static fromToolbarButton (button: ToolbarButton): Command {
         const command = new Command()
+        command.id = `legacy:${slugify(button.title)}`
         command.label = button.title
         command.run = async () => button.click?.()
         command.icon = button.icon
@@ -44,18 +44,29 @@ export class Command {
         if ((button.weight ?? 0) > 0) {
             command.locations.push(CommandLocation.RightToolbar)
         }
-        command.touchBarNSImage = button.touchBarNSImage
-        command.touchBarTitle = button.touchBarTitle
         command.weight = button.weight
         return command
     }
 
-    static fromMenuItem (item: MenuItemOptions): Command {
-        const command = new Command()
-        command.label = item.commandLabel ?? item.label ?? ''
-        command.sublabel = item.sublabel
-        command.run = async () => item.click?.()
-        return command
+    static fromMenuItem (item: MenuItemOptions): Command[] {
+        if (item.type === 'separator') {
+            return []
+        }
+        const commands: Command[] = [{
+            id: `legacy:${slugify(item.commandLabel ?? item.label).toLowerCase()}`,
+            label: item.commandLabel ?? item.label,
+            run: async () => item.click?.(),
+            locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+            checked: item.checked,
+        }]
+        for (const submenu of item.submenu ?? []) {
+            commands.push(...Command.fromMenuItem(submenu).map(x => ({
+                ...x,
+                id: `${commands[0].id}:${slugify(x.label).toLowerCase()}`,
+                parent: commands[0].id,
+            })))
+        }
+        return commands
     }
 }
 

+ 1 - 0
tabby-core/src/api/index.ts

@@ -37,6 +37,7 @@ export { UpdaterService } from '../services/updater.service'
 export { VaultService, Vault, VaultSecret, VaultFileSecret, VAULT_SECRET_TYPE_FILE, StoredVault, VaultSecretKey } from '../services/vault.service'
 export { FileProvidersService } from '../services/fileProviders.service'
 export { LocaleService } from '../services/locale.service'
+export { CommandService } from '../services/commands.service'
 export { TranslateService } from '@ngx-translate/core'
 export * from '../utils'
 export { UTF8Splitter } from '../utfSplitter'

+ 8 - 4
tabby-core/src/api/menu.ts

@@ -1,6 +1,4 @@
-export interface MenuItemOptions {
-    type?: 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'
-    label?: string
+export type MenuItemOptions = {
     sublabel?: string
     enabled?: boolean
     checked?: boolean
@@ -9,4 +7,10 @@ export interface MenuItemOptions {
 
     /** @hidden */
     commandLabel?: string
-}
+} & ({
+    type: 'separator',
+    label?: string,
+} | {
+    type?: 'normal' | 'submenu' | 'checkbox' | 'radio',
+    label: string,
+})

+ 0 - 10
tabby-core/src/api/toolbarButtonProvider.ts

@@ -9,16 +9,6 @@ export interface ToolbarButton {
 
     title: string
 
-    /**
-     * Optional Touch Bar icon ID
-     */
-    touchBarNSImage?: string
-
-    /**
-     * Optional Touch Bar button label
-     */
-    touchBarTitle?: string
-
     weight?: number
 
     click?: () => void

+ 304 - 5
tabby-core/src/commands.ts

@@ -1,10 +1,20 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { Injectable } from '@angular/core'
 import { TranslateService } from '@ngx-translate/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 
 import { HostAppService, Platform } from './api/hostApp'
 import { ProfilesService } from './services/profiles.service'
-import { CommandProvider, Command, CommandLocation } from './api/commands'
+import { AppService } from './services/app.service'
+import { CommandProvider, Command, CommandLocation, CommandContext } from './api/commands'
+import { SplitDirection, SplitTabComponent } from './components/splitTab.component'
+import { BaseTabComponent } from './components/baseTab.component'
+import { PromptModalComponent } from './components/promptModal.component'
+import { HotkeysService } from './services/hotkeys.service'
+import { TabsService } from './services/tabs.service'
+import { SplitLayoutProfilesService } from './profiles'
+import { TAB_COLORS } from './utils'
+import { Subscription } from 'rxjs'
 
 /** @hidden */
 @Injectable({ providedIn: 'root' })
@@ -13,38 +23,327 @@ export class CoreCommandProvider extends CommandProvider {
         private hostApp: HostAppService,
         private profilesService: ProfilesService,
         private translate: TranslateService,
+        private app: AppService,
+        private splitLayoutProfilesService: SplitLayoutProfilesService,
+        private ngbModal: NgbModal,
+        private tabsService: TabsService,
+        hotkeys: HotkeysService,
     ) {
         super()
+        hotkeys.hotkey$.subscribe(hotkey => {
+            if (hotkey === 'switch-profile') {
+                let tab = this.app.activeTab
+                if (tab instanceof SplitTabComponent) {
+                    tab = tab.getFocusedTab()
+                    if (tab) {
+                        this.switchTabProfile(tab)
+                    }
+                }
+            }
+        })
     }
 
-    async activate () {
+    async switchTabProfile (tab: BaseTabComponent) {
+        const profile = await this.profilesService.showProfileSelector().catch(() => null)
+        if (!profile) {
+            return
+        }
+
+        const params = await this.profilesService.newTabParametersForProfile(profile)
+        if (!params) {
+            return
+        }
+
+        if (!await tab.canClose()) {
+            return
+        }
+
+        const newTab = this.tabsService.create(params)
+        ;(tab.parent as SplitTabComponent).replaceTab(tab, newTab)
+
+        tab.destroy()
+    }
+
+    async showProfileSelector () {
         const profile = await this.profilesService.showProfileSelector().catch(() => null)
         if (profile) {
             this.profilesService.launchProfile(profile)
         }
     }
 
-    async provide (): Promise<Command[]> {
-        return [
+    async provide (context: CommandContext): Promise<Command[]> {
+        const commands: Command[] = [
             {
                 id: 'core:profile-selector',
                 locations: [CommandLocation.LeftToolbar, CommandLocation.StartPage],
                 label: this.translate.instant('Profiles & connections'),
+                weight: 12,
                 icon: this.hostApp.platform === Platform.Web
                     ? require('./icons/plus.svg')
                     : require('./icons/profiles.svg'),
-                run: async () => this.activate(),
+                run: async () => this.showProfileSelector(),
             },
             ...this.profilesService.getRecentProfiles().map((profile, index) => ({
                 id: `core:recent-profile-${index}`,
                 label: profile.name,
                 locations: [CommandLocation.StartPage],
                 icon: require('./icons/history.svg'),
+                weight: 20,
                 run: async () => {
                     const p = (await this.profilesService.getProfiles()).find(x => x.id === profile.id) ?? profile
                     this.profilesService.launchProfile(p)
                 },
             })),
         ]
+
+        if (context.tab) {
+            const tab = context.tab
+
+            commands.push({
+                id: `core:close-tab`,
+                label: this.translate.instant('Close tab'),
+                locations: [CommandLocation.TabHeaderMenu],
+                weight: -35,
+                group: 'core:close',
+                run: async () => {
+                    if (this.app.tabs.includes(tab)) {
+                        this.app.closeTab(tab, true)
+                    } else {
+                        tab.destroy()
+                    }
+                },
+            })
+
+            commands.push({
+                id: `core:close`,
+                label: this.translate.instant('Close'),
+                locations: [CommandLocation.TabBodyMenu],
+                weight: 99,
+                group: 'core:close',
+                run: async () => {
+                    tab.destroy()
+                },
+            })
+
+            if (!context.tab.parent) {
+                commands.push(...[{
+                    id: 'core:close-other-tabs',
+                    label: this.translate.instant('Close other tabs'),
+                    locations: [CommandLocation.TabHeaderMenu],
+                    weight: -34,
+                    group: 'core:close',
+                    run: async () => {
+                        for (const t of this.app.tabs.filter(x => x !== tab)) {
+                            this.app.closeTab(t, true)
+                        }
+                    },
+                },
+                {
+                    id: 'core:close-tabs-to-the-right',
+                    label: this.translate.instant('Close tabs to the right'),
+                    locations: [CommandLocation.TabHeaderMenu],
+                    weight: -33,
+                    group: 'core:close',
+                    run: async () => {
+                        for (const t of this.app.tabs.slice(this.app.tabs.indexOf(tab) + 1)) {
+                            this.app.closeTab(t, true)
+                        }
+                    },
+                },
+                {
+                    id: 'core:close-tabs-to-the-left',
+                    label: this.translate.instant('Close tabs to the left'),
+                    locations: [CommandLocation.TabHeaderMenu],
+                    weight: -32,
+                    group: 'core:close',
+                    run: async () => {
+                        for (const t of this.app.tabs.slice(0, this.app.tabs.indexOf(tab))) {
+                            this.app.closeTab(t, true)
+                        }
+                    },
+                }])
+            }
+
+            commands.push({
+                id: 'core:rename-tab',
+                label: this.translate.instant('Rename tab'),
+                locations: [CommandLocation.TabHeaderMenu],
+                group: 'core:common',
+                weight: -13,
+                run: async () => this.app.renameTab(tab),
+            })
+            commands.push({
+                id: 'core:duplicate-tab',
+                label: this.translate.instant('Duplicate tab'),
+                locations: [CommandLocation.TabHeaderMenu],
+                group: 'core:common',
+                weight: -12,
+                run: async () => this.app.duplicateTab(tab),
+            })
+            commands.push({
+                id: 'core:tab-color',
+                label: this.translate.instant('Color'),
+                group: 'core:common',
+                locations: [CommandLocation.TabHeaderMenu],
+                weight: -11,
+            })
+            for (const color of TAB_COLORS) {
+                commands.push({
+                    id: `core:tab-color-${color.name.toLowerCase()}`,
+                    parent: 'core:tab-color',
+                    label: this.translate.instant(color.name) ?? color.name,
+                    fullLabel: this.translate.instant('Set tab color to {color}', { color: this.translate.instant(color.name) }),
+                    checked: tab.color === color.value,
+                    locations: [CommandLocation.TabHeaderMenu],
+                    run: async () => {
+                        tab.color = color.value
+                    },
+                })
+            }
+
+            if (tab.parent instanceof SplitTabComponent) {
+                const directions: SplitDirection[] = ['r', 'b', 'l', 't']
+                commands.push({
+                    id: 'core:split',
+                    label: this.translate.instant('Split'),
+                    group: 'core:panes',
+                    locations: [CommandLocation.TabBodyMenu],
+                })
+                for (const dir of directions) {
+                    commands.push({
+                        id: `core:split-${dir}`,
+                        label: {
+                            r: this.translate.instant('Right'),
+                            b: this.translate.instant('Down'),
+                            l: this.translate.instant('Left'),
+                            t: this.translate.instant('Up'),
+                        }[dir],
+                        fullLabel: {
+                            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],
+                        locations: [CommandLocation.TabBodyMenu],
+                        parent: 'core:split',
+                        run: async () => {
+                            (tab.parent as SplitTabComponent).splitTab(tab, dir)
+                        },
+                    })
+                }
+
+                commands.push({
+                    id: 'core:switch-profile',
+                    label: this.translate.instant('Switch profile'),
+                    group: 'core:common',
+                    locations: [CommandLocation.TabBodyMenu],
+                    run: async () => this.switchTabProfile(tab),
+                })
+            }
+
+            if (tab instanceof SplitTabComponent && tab.getAllTabs().length > 1) {
+                commands.push({
+                    id: 'core:save-split-tab-as-profile',
+                    label: this.translate.instant('Save layout as profile'),
+                    group: 'core:common',
+                    locations: [CommandLocation.TabHeaderMenu],
+                    run: async () => {
+                        const modal = this.ngbModal.open(PromptModalComponent)
+                        modal.componentInstance.prompt = this.translate.instant('Profile name')
+                        const name = (await modal.result.catch(() => null))?.value
+                        if (!name) {
+                            return
+                        }
+                        this.splitLayoutProfilesService.createProfile(tab, name)
+                    },
+                })
+            }
+        }
+
+        return commands
+    }
+}
+
+/** @hidden */
+@Injectable({ providedIn: 'root' })
+export class TaskCompletionCommandProvider extends CommandProvider {
+    constructor (
+        private app: AppService,
+        private translate: TranslateService,
+    ) {
+        super()
+    }
+
+    async provide (context: CommandContext): Promise<Command[]> {
+        if (!context.tab) {
+            return []
+        }
+
+        const process = await context.tab.getCurrentProcess()
+        const items: Command[] = []
+
+        const extTab: (BaseTabComponent & { __completionNotificationEnabled?: boolean, __outputNotificationSubscription?: Subscription|null }) = context.tab
+
+        if (process) {
+            items.push({
+                id: 'core:process-name',
+                label: this.translate.instant('Current process: {name}', process),
+                group: 'core:process',
+                weight: -1,
+                locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+            })
+            items.push({
+                id: 'core:notify-when-done',
+                label: this.translate.instant('Notify when done'),
+                group: 'core:process',
+                weight: 0,
+                checked: extTab.__completionNotificationEnabled,
+                locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+                run: async () => {
+                    extTab.__completionNotificationEnabled = !extTab.__completionNotificationEnabled
+
+                    if (extTab.__completionNotificationEnabled) {
+                        this.app.observeTabCompletion(extTab).subscribe(() => {
+                            new Notification(this.translate.instant('Process completed'), {
+                                body: process.name,
+                            }).addEventListener('click', () => {
+                                this.app.selectTab(extTab)
+                            })
+                            extTab.__completionNotificationEnabled = false
+                        })
+                    } else {
+                        this.app.stopObservingTabCompletion(extTab)
+                    }
+                },
+            })
+        }
+        items.push({
+            id: 'core:notify-on-activity',
+            label: this.translate.instant('Notify on activity'),
+            group: 'core:process',
+            checked: !!extTab.__outputNotificationSubscription,
+            locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+            run: async () => {
+                extTab.clearActivity()
+
+                if (extTab.__outputNotificationSubscription) {
+                    extTab.__outputNotificationSubscription.unsubscribe()
+                    extTab.__outputNotificationSubscription = null
+                } else {
+                    extTab.__outputNotificationSubscription = extTab.activity$.subscribe(active => {
+                        if (extTab.__outputNotificationSubscription && active) {
+                            extTab.__outputNotificationSubscription.unsubscribe()
+                            extTab.__outputNotificationSubscription = null
+                            new Notification(this.translate.instant('Tab activity'), {
+                                body: extTab.title,
+                            }).addEventListener('click', () => {
+                                this.app.selectTab(extTab)
+                            })
+                        }
+                    })
+                }
+            },
+        })
+        return items
     }
 }

+ 1 - 1
tabby-core/src/components/appRoot.component.ts

@@ -238,7 +238,7 @@ export class AppRootComponent {
 
     private async getToolbarButtons (aboveZero: boolean): Promise<Command[]> {
         return (await this.commands.getCommands({ tab: this.app.activeTab ?? undefined }))
-            .filter(x => x.locations?.includes(aboveZero ? CommandLocation.RightToolbar : CommandLocation.LeftToolbar))
+            .filter(x => x.locations.includes(aboveZero ? CommandLocation.RightToolbar : CommandLocation.LeftToolbar))
     }
 
     toggleMaximize (): void {

+ 3 - 1
tabby-core/src/components/startPage.component.ts

@@ -1,5 +1,6 @@
 import { Component } from '@angular/core'
 import { DomSanitizer } from '@angular/platform-browser'
+import { firstBy } from 'thenby'
 import { HomeBaseService } from '../services/homeBase.service'
 import { CommandService } from '../services/commands.service'
 import { Command, CommandLocation } from '../api/commands'
@@ -20,7 +21,8 @@ export class StartPageComponent {
         commands: CommandService,
     ) {
         commands.getCommands({}).then(c => {
-            this.commands = c.filter(x => x.locations?.includes(CommandLocation.StartPage))
+            this.commands = c.filter(x => x.locations.includes(CommandLocation.StartPage))
+            this.commands.sort(firstBy(x => x.weight ?? 0))
         })
     }
 

+ 13 - 20
tabby-core/src/components/tabHeader.component.ts

@@ -1,16 +1,19 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Component, Input, Optional, Inject, HostBinding, HostListener, NgZone } from '@angular/core'
+import { Component, Input, HostBinding, HostListener, NgZone } from '@angular/core'
 import { auditTime } from 'rxjs'
-import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider'
+
 import { BaseTabComponent } from './baseTab.component'
-import { SplitTabComponent } from './splitTab.component'
 import { HotkeysService } from '../services/hotkeys.service'
 import { AppService } from '../services/app.service'
 import { HostAppService, Platform } from '../api/hostApp'
 import { ConfigService } from '../services/config.service'
-import { BaseComponent } from './base.component'
+import { CommandService } from '../services/commands.service'
 import { MenuItemOptions } from '../api/menu'
 import { PlatformService } from '../api/platform'
+import { CommandContext, CommandLocation } from '../api/commands'
+
+import { BaseComponent } from './base.component'
+import { SplitTabComponent } from './splitTab.component'
 
 /** @hidden */
 @Component({
@@ -31,8 +34,8 @@ export class TabHeaderComponent extends BaseComponent {
         public hostApp: HostAppService,
         private hotkeys: HotkeysService,
         private platform: PlatformService,
+        private commands: CommandService,
         private zone: NgZone,
-        @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[],
     ) {
         super()
         this.subscribeUntilDestroyed(this.hotkeys.hotkey$, (hotkey) => {
@@ -42,7 +45,6 @@ export class TabHeaderComponent extends BaseComponent {
                 }
             }
         })
-        this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
     }
 
     ngOnInit () {
@@ -56,26 +58,17 @@ export class TabHeaderComponent extends BaseComponent {
     }
 
     async buildContextMenu (): Promise<MenuItemOptions[]> {
-        let items: MenuItemOptions[] = []
+        const contexts: CommandContext[] = [{ tab: this.tab }]
+
         // Top-level tab menu
-        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, true)))) {
-                    // eslint-disable-next-line @typescript-eslint/no-loop-func
-                    section = section.filter(item => !items.some(ex => ex.label === item.label))
-                    if (section.length) {
-                        items.push({ type: 'separator' })
-                        items = items.concat(section)
-                    }
-                }
+                contexts.push({ tab })
             }
         }
-        return items.slice(1)
+
+        return this.commands.buildContextMenu(contexts, CommandLocation.TabHeaderMenu)
     }
 
     onTabDragStart (tab: BaseTabComponent) {

+ 3 - 7
tabby-core/src/index.ts

@@ -37,7 +37,7 @@ import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
 import { DropZoneDirective } from './directives/dropZone.directive'
 import { CdkAutoDropGroup } from './directives/cdkAutoDropGroup.directive'
 
-import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, QuickConnectProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api'
+import { Theme, CLIHandler, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, QuickConnectProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api'
 
 import { AppService } from './services/app.service'
 import { ConfigService } from './services/config.service'
@@ -49,10 +49,9 @@ import { CommandService } from './services/commands.service'
 import { StandardTheme, StandardCompactTheme, PaperTheme, NewTheme } from './theme'
 import { CoreConfigProvider } from './config'
 import { AppHotkeyProvider } from './hotkeys'
-import { TaskCompletionContextMenu, CommonOptionsContextMenu, TabManagementContextMenu, ProfilesContextMenu } from './tabContextMenu'
 import { LastCLIHandler, ProfileCLIHandler } from './cli'
 import { SplitLayoutProfilesService } from './profiles'
-import { CoreCommandProvider } from './commands'
+import { CoreCommandProvider, TaskCompletionCommandProvider } from './commands'
 
 export function TranslateMessageFormatCompilerFactory (): TranslateMessageFormatCompiler {
     return new TranslateMessageFormatCompiler()
@@ -65,16 +64,13 @@ const PROVIDERS = [
     { provide: Theme, useClass: PaperTheme, multi: true },
     { provide: Theme, useClass: NewTheme, multi: true },
     { provide: ConfigProvider, useClass: CoreConfigProvider, multi: true },
-    { provide: TabContextMenuItemProvider, useClass: CommonOptionsContextMenu, multi: true },
-    { provide: TabContextMenuItemProvider, useClass: TabManagementContextMenu, multi: true },
-    { provide: TabContextMenuItemProvider, useClass: TaskCompletionContextMenu, multi: true },
-    { provide: TabContextMenuItemProvider, useClass: ProfilesContextMenu, multi: true },
     { provide: TabRecoveryProvider, useExisting: SplitTabRecoveryProvider, multi: true },
     { provide: CLIHandler, useClass: ProfileCLIHandler, multi: true },
     { provide: CLIHandler, useClass: LastCLIHandler, multi: true },
     { provide: FileProvider, useClass: VaultFileProvider, multi: true },
     { provide: ProfileProvider, useExisting: SplitLayoutProfilesService, multi: true },
     { provide: CommandProvider, useExisting: CoreCommandProvider, multi: true },
+    { provide: CommandProvider, useExisting: TaskCompletionCommandProvider, multi: true },
     {
         provide: LOCALE_ID,
         deps: [LocaleService],

+ 103 - 34
tabby-core/src/services/commands.service.ts

@@ -1,6 +1,10 @@
 import { Inject, Injectable, Optional } from '@angular/core'
-import { AppService, Command, CommandContext, CommandProvider, ConfigService, MenuItemOptions, SplitTabComponent, TabContextMenuItemProvider, ToolbarButton, ToolbarButtonProvider, TranslateService } from '../api'
+import { TranslateService } from '@ngx-translate/core'
+import { Command, CommandContext, CommandLocation, CommandProvider, MenuItemOptions, SplitTabComponent, TabContextMenuItemProvider, ToolbarButton, ToolbarButtonProvider } from '../api'
+import { AppService } from './app.service'
+import { ConfigService } from './config.service'
 import { SelectorService } from './selector.service'
+import { firstBy } from 'thenby'
 
 @Injectable({ providedIn: 'root' })
 export class CommandService {
@@ -11,11 +15,11 @@ export class CommandService {
         private config: ConfigService,
         private app: AppService,
         private translate: TranslateService,
-        @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[],
+        @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[]|null,
         @Optional() @Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
         @Inject(CommandProvider) private commandProviders: CommandProvider[],
     ) {
-        this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
+        this.contextMenuProviders?.sort((a, b) => a.weight - b.weight)
     }
 
     async getCommands (context: CommandContext): Promise<Command[]> {
@@ -29,8 +33,8 @@ export class CommandService {
         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)))) {
+                // 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)
@@ -38,7 +42,7 @@ export class CommandService {
                 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)))) {
+                        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)
@@ -50,21 +54,10 @@ export class CommandService {
 
         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))
-
-        const commands = buttons.map(x => Command.fromToolbarButton(x))
-        commands.push(...flatItems.map(x => Command.fromMenuItem(x)))
+        const commands = [
+            ...buttons.map(x => Command.fromToolbarButton(x)),
+            ...items.map(x => Command.fromMenuItem(x)).flat(),
+        ]
 
         for (const provider of this.config.enabledServices(this.commandProviders)) {
             commands.push(...await provider.provide(context))
@@ -74,20 +67,36 @@ export class CommandService {
             .filter(c => !this.config.store.commandBlacklist.includes(c.id))
             .sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0))
             .map(command => {
-                const run = command.run
-                command.run = async () => {
-                    // Serialize execution
-                    this.lastCommand = this.lastCommand.finally(run)
-                    await this.lastCommand
+                if (command.run) {
+                    const run = command.run
+                    command.run = async () => {
+                        // Serialize execution
+                        this.lastCommand = this.lastCommand.finally(run)
+                        await this.lastCommand
+                    }
                 }
                 return command
             })
     }
 
+    async getCommandsWithContexts (context: CommandContext[]): Promise<Command[]> {
+        let commands: Command[] = []
+
+        for (const commandSet of await Promise.all(context.map(x => this.getCommands(x)))) {
+            for (const command of commandSet) {
+                // eslint-disable-next-line @typescript-eslint/no-loop-func
+                commands = commands.filter(x => x.id !== command.id)
+                commands.push(command)
+            }
+        }
+
+        return commands
+    }
+
     async run (id: string, context: CommandContext): Promise<void> {
         const commands = await this.getCommands(context)
         const command = commands.find(x => x.id === id)
-        await command?.run()
+        await command?.run?.()
     }
 
     async showSelector (): Promise<void> {
@@ -95,20 +104,80 @@ export class CommandService {
             return
         }
 
-        const context: CommandContext = {}
-        const tab = this.app.activeTab
-        if (tab instanceof SplitTabComponent) {
-            context.tab = tab.getFocusedTab() ?? undefined
+        const contexts: CommandContext[] = [{}]
+        if (this.app.activeTab) {
+            contexts.push({ tab: this.app.activeTab })
         }
-        const commands = await this.getCommands(context)
+        if (this.app.activeTab instanceof SplitTabComponent) {
+            const tab = this.app.activeTab.getFocusedTab()
+            if (tab) {
+                contexts.push({ tab })
+            }
+        }
+
+        const commands = (await this.getCommandsWithContexts(contexts))
+            .filter(x => x.run)
+            .sort(firstBy(x => x.weight ?? 0))
+
         return this.selector.show(
             this.translate.instant('Commands'),
             commands.map(c => ({
-                name: c.label,
+                name: c.fullLabel ?? c.label,
                 callback: c.run,
-                description: c.sublabel,
                 icon: c.icon,
             })),
         )
     }
+
+    /** @hidden */
+    async buildContextMenu (contexts: CommandContext[], location: CommandLocation): Promise<MenuItemOptions[]> {
+        let commands = await this.getCommandsWithContexts(contexts)
+
+        commands = commands.filter(x => x.locations.includes(location))
+        commands.sort(firstBy(x => x.weight ?? 0))
+
+        interface Group {
+            id?: string
+            weight: number
+            commands: Command[]
+        }
+
+        const groups: Group[] = []
+
+        for (const command of commands.filter(x => !x.parent)) {
+            let group = groups.find(x => x.id === command.group)
+            if (!group) {
+                group = {
+                    id: command.group,
+                    weight: 0,
+                    commands: [],
+                }
+                groups.push(group)
+            }
+            group.weight += command.weight ?? 0
+            group.commands.push(command)
+        }
+
+        groups.sort(firstBy(x => x.weight / x.commands.length))
+
+        function mapCommand (command: Command): MenuItemOptions {
+            const submenu = command.id ? commands.filter(x => x.parent === command.id).map(mapCommand) : []
+            return {
+                label: command.label,
+                submenu: submenu.length ? submenu : undefined,
+                checked: command.checked,
+                enabled: !!command.run || !!submenu.length,
+                type: command.checked ? 'checkbox' : undefined,
+                click: () => command.run?.(),
+            }
+        }
+
+        const items: MenuItemOptions[] = []
+        for (const group of groups) {
+            items.push({ type: 'separator' })
+            items.push(...group.commands.map(mapCommand))
+        }
+
+        return items.slice(1)
+    }
 }

+ 0 - 298
tabby-core/src/tabContextMenu.ts

@@ -1,298 +0,0 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Injectable } from '@angular/core'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { TranslateService } from '@ngx-translate/core'
-import { Subscription } from 'rxjs'
-import { AppService } from './services/app.service'
-import { BaseTabComponent } from './components/baseTab.component'
-import { SplitTabComponent, SplitDirection } from './components/splitTab.component'
-import { TabContextMenuItemProvider } from './api/tabContextMenuProvider'
-import { MenuItemOptions } from './api/menu'
-import { ProfilesService } from './services/profiles.service'
-import { TabsService } from './services/tabs.service'
-import { HotkeysService } from './services/hotkeys.service'
-import { PromptModalComponent } from './components/promptModal.component'
-import { SplitLayoutProfilesService } from './profiles'
-import { TAB_COLORS } from './utils'
-
-/** @hidden */
-@Injectable()
-export class TabManagementContextMenu extends TabContextMenuItemProvider {
-    weight = 99
-
-    constructor (
-        private app: AppService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        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)
-                    } else {
-                        tab.destroy()
-                    }
-                },
-            },
-        ]
-        if (!tab.parent) {
-            items = [
-                ...items,
-                {
-                    label: this.translate.instant('Close other tabs'),
-                    click: () => {
-                        for (const t of this.app.tabs.filter(x => x !== tab)) {
-                            this.app.closeTab(t, true)
-                        }
-                    },
-                },
-                {
-                    label: this.translate.instant('Close tabs to the right'),
-                    click: () => {
-                        for (const t of this.app.tabs.slice(this.app.tabs.indexOf(tab) + 1)) {
-                            this.app.closeTab(t, true)
-                        }
-                    },
-                },
-                {
-                    label: this.translate.instant('Close tabs to the left'),
-                    click: () => {
-                        for (const t of this.app.tabs.slice(0, this.app.tabs.indexOf(tab))) {
-                            this.app.closeTab(t, true)
-                        }
-                    },
-                },
-            ]
-        } else if (tab.parent instanceof SplitTabComponent) {
-            const directions: SplitDirection[] = ['r', 'b', 'l', 't']
-            items.push({
-                label: this.translate.instant('Split'),
-                submenu: directions.map(dir => ({
-                    label: {
-                        r: this.translate.instant('Right'),
-                        b: this.translate.instant('Down'),
-                        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)
-                    },
-                })) as MenuItemOptions[],
-            })
-        }
-        return items
-    }
-}
-
-/** @hidden */
-@Injectable()
-export class CommonOptionsContextMenu extends TabContextMenuItemProvider {
-    weight = -1
-
-    constructor (
-        private app: AppService,
-        private ngbModal: NgbModal,
-        private splitLayoutProfilesService: SplitLayoutProfilesService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]> {
-        let items: MenuItemOptions[] = []
-        if (tabHeader) {
-            const currentColor = TAB_COLORS.find(x => x.value === tab.color)?.name
-            items = [
-                ...items,
-                {
-                    label: this.translate.instant('Rename'),
-                    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,
-                        type: 'radio',
-                        checked: tab.color === color.value,
-                        click: () => {
-                            tab.color = color.value
-                        },
-                    })) as MenuItemOptions[],
-                },
-            ]
-
-            if (tab instanceof SplitTabComponent && tab.getAllTabs().length > 1) {
-                items.push({
-                    label: this.translate.instant('Save layout as profile'),
-                    click: async () => {
-                        const modal = this.ngbModal.open(PromptModalComponent)
-                        modal.componentInstance.prompt = this.translate.instant('Profile name')
-                        const name = (await modal.result.catch(() => null))?.value
-                        if (!name) {
-                            return
-                        }
-                        this.splitLayoutProfilesService.createProfile(tab, name)
-                    },
-                })
-            }
-        }
-        return items
-    }
-}
-
-/** @hidden */
-@Injectable()
-export class TaskCompletionContextMenu extends TabContextMenuItemProvider {
-    constructor (
-        private app: AppService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        const process = await tab.getCurrentProcess()
-        const items: MenuItemOptions[] = []
-
-        const extTab: (BaseTabComponent & { __completionNotificationEnabled?: boolean, __outputNotificationSubscription?: Subscription|null }) = tab
-
-        if (process) {
-            items.push({
-                enabled: false,
-                label: this.translate.instant('Current process: {name}', process),
-            })
-            items.push({
-                label: this.translate.instant('Notify when done'),
-                type: 'checkbox',
-                checked: extTab.__completionNotificationEnabled,
-                click: () => {
-                    extTab.__completionNotificationEnabled = !extTab.__completionNotificationEnabled
-
-                    if (extTab.__completionNotificationEnabled) {
-                        this.app.observeTabCompletion(tab).subscribe(() => {
-                            new Notification(this.translate.instant('Process completed'), {
-                                body: process.name,
-                            }).addEventListener('click', () => {
-                                this.app.selectTab(tab)
-                            })
-                            extTab.__completionNotificationEnabled = false
-                        })
-                    } else {
-                        this.app.stopObservingTabCompletion(tab)
-                    }
-                },
-            })
-        }
-        items.push({
-            label: this.translate.instant('Notify on activity'),
-            type: 'checkbox',
-            checked: !!extTab.__outputNotificationSubscription,
-            click: () => {
-                tab.clearActivity()
-
-                if (extTab.__outputNotificationSubscription) {
-                    extTab.__outputNotificationSubscription.unsubscribe()
-                    extTab.__outputNotificationSubscription = null
-                } else {
-                    extTab.__outputNotificationSubscription = tab.activity$.subscribe(active => {
-                        if (extTab.__outputNotificationSubscription && active) {
-                            extTab.__outputNotificationSubscription.unsubscribe()
-                            extTab.__outputNotificationSubscription = null
-                            new Notification(this.translate.instant('Tab activity'), {
-                                body: tab.title,
-                            }).addEventListener('click', () => {
-                                this.app.selectTab(tab)
-                            })
-                        }
-                    })
-                }
-            },
-        })
-        return items
-    }
-}
-
-
-/** @hidden */
-@Injectable()
-export class ProfilesContextMenu extends TabContextMenuItemProvider {
-    weight = 10
-
-    constructor (
-        private profilesService: ProfilesService,
-        private tabsService: TabsService,
-        private app: AppService,
-        private translate: TranslateService,
-        hotkeys: HotkeysService,
-    ) {
-        super()
-        hotkeys.hotkey$.subscribe(hotkey => {
-            if (hotkey === 'switch-profile') {
-                let tab = this.app.activeTab
-                if (tab instanceof SplitTabComponent) {
-                    tab = tab.getFocusedTab()
-                    if (tab) {
-                        this.switchTabProfile(tab)
-                    }
-                }
-            }
-        })
-    }
-
-    async switchTabProfile (tab: BaseTabComponent) {
-        const profile = await this.profilesService.showProfileSelector().catch(() => null)
-        if (!profile) {
-            return
-        }
-
-        const params = await this.profilesService.newTabParametersForProfile(profile)
-        if (!params) {
-            return
-        }
-
-        if (!await tab.canClose()) {
-            return
-        }
-
-        const newTab = this.tabsService.create(params)
-        ;(tab.parent as SplitTabComponent).replaceTab(tab, newTab)
-
-        tab.destroy()
-    }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-
-        if (tab.parent instanceof SplitTabComponent && tab.parent.getAllTabs().length > 1) {
-            return [
-                {
-                    label: this.translate.instant('Switch profile'),
-                    click: () => this.switchTabProfile(tab),
-                },
-            ]
-        }
-
-        return []
-    }
-}

+ 0 - 28
tabby-local/src/buttonProvider.ts

@@ -1,28 +0,0 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { Injectable } from '@angular/core'
-import { ToolbarButtonProvider, ToolbarButton, TranslateService } from 'tabby-core'
-import { TerminalService } from './services/terminal.service'
-
-/** @hidden */
-@Injectable()
-export class ButtonProvider extends ToolbarButtonProvider {
-    constructor (
-        private terminal: TerminalService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    provide (): ToolbarButton[] {
-        return [
-            {
-                icon: require('./icons/plus.svg'),
-                title: this.translate.instant('New terminal'),
-                touchBarNSImage: 'NSTouchBarAddDetailTemplate',
-                click: () => {
-                    this.terminal.openTab()
-                },
-            },
-        ]
-    }
-}

+ 123 - 0
tabby-local/src/commands.ts

@@ -0,0 +1,123 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Inject, Injectable, Optional } from '@angular/core'
+
+import { CommandProvider, Command, CommandLocation, TranslateService, CommandContext, ProfilesService } from 'tabby-core'
+
+import { TerminalTabComponent } from './components/terminalTab.component'
+import { TerminalService } from './services/terminal.service'
+import { LocalProfile, UACService } from './api'
+
+/** @hidden */
+@Injectable({ providedIn: 'root' })
+export class LocalCommandProvider extends CommandProvider {
+    constructor (
+        private terminal: TerminalService,
+        private profilesService: ProfilesService,
+        private translate: TranslateService,
+        @Optional() @Inject(UACService) private uac: UACService|undefined,
+    ) {
+        super()
+    }
+
+    async provide (context: CommandContext): Promise<Command[]> {
+        const profiles = (await this.profilesService.getProfiles()).filter(x => x.type === 'local') as LocalProfile[]
+
+        const commands: Command[] = [
+            {
+                id: 'local:new-tab',
+                group: 'local:new-tab',
+                label: this.translate.instant('New terminal'),
+                locations: [CommandLocation.LeftToolbar, CommandLocation.StartPage, CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+                weight: 11,
+                icon: require('./icons/plus.svg'),
+                run: async () => this.runOpenTab(context),
+            },
+        ]
+
+        commands.push({
+            id: 'local:new-tab-with-profile',
+            group: 'local:new-tab',
+            label: this.translate.instant('New with profile'),
+            locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+            weight: 12,
+        })
+
+        for (const profile of profiles) {
+            commands.push({
+                id: `local:new-tab-with-profile:${profile.id}`,
+                group: 'local:new-tab',
+                parent: 'local:new-tab-with-profile',
+                label: profile.name,
+                fullLabel: this.translate.instant('New terminal with profile: {profile}', { profile: profile.name }),
+                locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+                // eslint-disable-next-line @typescript-eslint/no-loop-func
+                run: async () => {
+                    let workingDirectory = profile.options.cwd
+                    if (!workingDirectory && context.tab instanceof TerminalTabComponent) {
+                        workingDirectory = await context.tab.session?.getWorkingDirectory() ?? undefined
+                    }
+                    await this.terminal.openTab(profile, workingDirectory)
+                },
+            })
+        }
+
+        if (this.uac?.isAvailable) {
+            commands.push({
+                id: 'local:new-tab-as-administrator-with-profile',
+                group: 'local:new-tab',
+                label: this.translate.instant('New admin tab'),
+                locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+                weight: 13,
+            })
+
+            for (const profile of profiles) {
+                commands.push({
+                    id: `local:new-tab-as-administrator-with-profile:${profile.id}`,
+                    group: 'local:new-tab',
+                    label: profile.name,
+                    fullLabel: this.translate.instant('New admin tab with profile: {profile}', { profile: profile.name }),
+                    locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+                    run: async () => {
+                        this.profilesService.openNewTabForProfile({
+                            ...profile,
+                            options: {
+                                ...profile.options,
+                                runAsAdministrator: true,
+                            },
+                        })
+                    },
+                })
+            }
+
+            if (context.tab && context.tab instanceof TerminalTabComponent) {
+                const terminalTab = context.tab
+                commands.push({
+                    id: 'local:duplicate-tab-as-administrator',
+                    group: 'local:new-tab',
+                    label: this.translate.instant('Duplicate as administrator'),
+                    locations: [CommandLocation.TabHeaderMenu],
+                    weight: 14,
+                    run: async () => {
+                        this.profilesService.openNewTabForProfile({
+                            ...terminalTab.profile,
+                            options: {
+                                ...terminalTab.profile.options,
+                                runAsAdministrator: true,
+                            },
+                        })
+                    },
+                })
+            }
+        }
+
+        return commands
+    }
+
+    runOpenTab (context: CommandContext) {
+        if (context.tab && context.tab instanceof TerminalTabComponent) {
+            this.profilesService.openNewTabForProfile(context.tab.profile)
+        } else {
+            this.terminal.openTab()
+        }
+    }
+}

+ 3 - 6
tabby-local/src/index.ts

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'
 import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
 
-import TabbyCorePlugin, { HostAppService, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler, ProfileProvider } from 'tabby-core'
+import TabbyCorePlugin, { HostAppService, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, CLIHandler, ProfileProvider, CommandProvider } from 'tabby-core'
 import TabbyTerminalModule from 'tabby-terminal'
 import { SettingsTabProvider } from 'tabby-settings'
 
@@ -16,15 +16,14 @@ import { CommandLineEditorComponent } from './components/commandLineEditor.compo
 
 import { TerminalService } from './services/terminal.service'
 
-import { ButtonProvider } from './buttonProvider'
 import { RecoveryProvider } from './recoveryProvider'
 import { ShellSettingsTabProvider } from './settings'
 import { TerminalConfigProvider } from './config'
 import { LocalTerminalHotkeyProvider } from './hotkeys'
-import { NewTabContextMenu } from './tabContextMenu'
 
 import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from './cli'
 import { LocalProfilesService } from './profiles'
+import { LocalCommandProvider } from './commands'
 
 /** @hidden */
 @NgModule({
@@ -39,15 +38,13 @@ import { LocalProfilesService } from './profiles'
     providers: [
         { provide: SettingsTabProvider, useClass: ShellSettingsTabProvider, multi: true },
 
-        { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
+        { provide: CommandProvider, useExisting: LocalCommandProvider, multi: true },
         { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
         { provide: ConfigProvider, useClass: TerminalConfigProvider, multi: true },
         { provide: HotkeyProvider, useClass: LocalTerminalHotkeyProvider, multi: true },
 
         { provide: ProfileProvider, useClass: LocalProfilesService, multi: true },
 
-        { provide: TabContextMenuItemProvider, useClass: NewTabContextMenu, multi: true },
-
         { provide: CLIHandler, useClass: TerminalCLIHandler, multi: true },
         { provide: CLIHandler, useClass: OpenPathCLIHandler, multi: true },
         { provide: CLIHandler, useClass: AutoOpenTabCLIHandler, multi: true },

+ 0 - 87
tabby-local/src/tabContextMenu.ts

@@ -1,87 +0,0 @@
-import { Inject, Injectable, Optional } from '@angular/core'
-import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, MenuItemOptions, ProfilesService, TranslateService } from 'tabby-core'
-import { TerminalTabComponent } from './components/terminalTab.component'
-import { TerminalService } from './services/terminal.service'
-import { LocalProfile, UACService } from './api'
-
-/** @hidden */
-@Injectable()
-export class NewTabContextMenu extends TabContextMenuItemProvider {
-    weight = 10
-
-    constructor (
-        public config: ConfigService,
-        private profilesService: ProfilesService,
-        private terminalService: TerminalService,
-        @Optional() @Inject(UACService) private uac: UACService|undefined,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]> {
-        const profiles = (await this.profilesService.getProfiles()).filter(x => x.type === 'local') as LocalProfile[]
-
-        const items: MenuItemOptions[] = [
-            {
-                label: this.translate.instant('New terminal'),
-                click: () => {
-                    if (tab instanceof TerminalTabComponent) {
-                        this.profilesService.openNewTabForProfile(tab.profile)
-                    } else {
-                        this.terminalService.openTab()
-                    }
-                },
-            },
-            {
-                label: this.translate.instant('New with profile'),
-                submenu: profiles.map(profile => ({
-                    label: profile.name,
-                    click: async () => {
-                        let workingDirectory = profile.options.cwd
-                        if (!workingDirectory && tab instanceof TerminalTabComponent) {
-                            workingDirectory = await tab.session?.getWorkingDirectory() ?? undefined
-                        }
-                        await this.terminalService.openTab(profile, workingDirectory)
-                    },
-                })),
-            },
-        ]
-
-        if (this.uac?.isAvailable) {
-            items.push({
-                label: this.translate.instant('New admin tab'),
-                submenu: profiles.map(profile => ({
-                    label: profile.name,
-                    click: () => {
-                        this.profilesService.openNewTabForProfile({
-                            ...profile,
-                            options: {
-                                ...profile.options,
-                                runAsAdministrator: true,
-                            },
-                        })
-                    },
-                })),
-            })
-        }
-
-        if (tab instanceof TerminalTabComponent && tabHeader && this.uac?.isAvailable) {
-            const terminalTab = tab
-            items.push({
-                label: this.translate.instant('Duplicate as administrator'),
-                click: () => {
-                    this.profilesService.openNewTabForProfile({
-                        ...terminalTab.profile,
-                        options: {
-                            ...terminalTab.profile.options,
-                            runAsAdministrator: true,
-                        },
-                    })
-                },
-            })
-        }
-
-        return items
-    }
-}

+ 9 - 8
tabby-settings/src/buttonProvider.ts → tabby-settings/src/commands.ts

@@ -1,11 +1,11 @@
 import { Injectable } from '@angular/core'
-import { ToolbarButtonProvider, ToolbarButton, AppService, HostAppService, HotkeysService, TranslateService } from 'tabby-core'
+import { CommandProvider, AppService, HostAppService, HotkeysService, TranslateService, Command, CommandLocation } from 'tabby-core'
 
 import { SettingsTabComponent } from './components/settingsTab.component'
 
 /** @hidden */
-@Injectable()
-export class ButtonProvider extends ToolbarButtonProvider {
+@Injectable({ providedIn: 'root' })
+export class SettingsCommandProvider extends CommandProvider {
     constructor (
         hostApp: HostAppService,
         hotkeys: HotkeysService,
@@ -22,13 +22,14 @@ export class ButtonProvider extends ToolbarButtonProvider {
         })
     }
 
-    provide (): ToolbarButton[] {
+    async provide (): Promise<Command[]> {
         return [{
+            id: 'settings:open',
             icon: require('./icons/cog.svg'),
-            title: this.translate.instant('Settings'),
-            touchBarNSImage: 'NSTouchBarComposeTemplate',
-            weight: 10,
-            click: (): void => this.open(),
+            label: this.translate.instant('Settings'),
+            weight: 99,
+            locations: [CommandLocation.RightToolbar, CommandLocation.StartPage],
+            run: async () => this.open(),
         }]
     }
 

+ 3 - 3
tabby-settings/src/index.ts

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'
 import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { InfiniteScrollModule } from 'ngx-infinite-scroll'
 
-import TabbyCorePlugin, { ToolbarButtonProvider, HotkeyProvider, ConfigProvider, HotkeysService, AppService } from 'tabby-core'
+import TabbyCorePlugin, { HotkeyProvider, ConfigProvider, HotkeysService, AppService, CommandProvider } from 'tabby-core'
 
 import { EditProfileModalComponent } from './components/editProfileModal.component'
 import { EditProfileGroupModalComponent } from './components/editProfileGroupModal.component'
@@ -24,7 +24,7 @@ import { ShowSecretModalComponent } from './components/showSecretModal.component
 import { ConfigSyncService } from './services/configSync.service'
 
 import { SettingsTabProvider } from './api'
-import { ButtonProvider } from './buttonProvider'
+import { SettingsCommandProvider } from './commands'
 import { SettingsHotkeyProvider } from './hotkeys'
 import { SettingsConfigProvider } from './config'
 import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabProvider, ProfilesSettingsTabProvider, ConfigSyncSettingsTabProvider } from './settings'
@@ -39,7 +39,7 @@ import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabP
         InfiniteScrollModule,
     ],
     providers: [
-        { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
+        { provide: CommandProvider, useExisting: SettingsCommandProvider, multi: true },
         { provide: ConfigProvider, useClass: SettingsConfigProvider, multi: true },
         { provide: HotkeyProvider, useClass: SettingsHotkeyProvider, multi: true },
         { provide: SettingsTabProvider, useClass: HotkeySettingsTabProvider, multi: true },

+ 44 - 0
tabby-ssh/src/commands.ts

@@ -0,0 +1,44 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Injectable } from '@angular/core'
+
+import { CommandProvider, Command, CommandLocation, TranslateService, CommandContext, Platform, HostAppService } from 'tabby-core'
+
+import { SSHTabComponent } from './components/sshTab.component'
+import { SSHService } from './services/ssh.service'
+
+/** @hidden */
+@Injectable({ providedIn: 'root' })
+export class SSHCommandProvider extends CommandProvider {
+    constructor (
+        private hostApp: HostAppService,
+        private ssh: SSHService,
+        private translate: TranslateService,
+    ) {
+        super()
+    }
+
+    async provide (context: CommandContext): Promise<Command[]> {
+        const tab = context.tab
+        if (!tab || !(tab instanceof SSHTabComponent)) {
+            return []
+        }
+
+        const commands: Command[] = [{
+            id: 'ssh:open-sftp-panel',
+            group: 'ssh:sftp',
+            label: this.translate.instant('Open SFTP panel'),
+            locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+            run: async () => tab.openSFTP(),
+        }]
+        if (this.hostApp.platform === Platform.Windows && this.ssh.getWinSCPPath()) {
+            commands.push({
+                id: 'ssh:open-winscp',
+                group: 'ssh:sftp',
+                label: this.translate.instant('Launch WinSCP'),
+                locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+                run: async () => this.ssh.launchWinSCP(tab.sshSession!),
+            })
+        }
+        return commands
+    }
+}

+ 3 - 3
tabby-ssh/src/index.ts

@@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms'
 import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
 import { NgxFilesizeModule } from 'ngx-filesize'
-import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, TabContextMenuItemProvider, ProfileProvider } from 'tabby-core'
+import TabbyCoreModule, { ConfigProvider, TabRecoveryProvider, HotkeyProvider, ProfileProvider, CommandProvider } from 'tabby-core'
 import { SettingsTabProvider } from 'tabby-settings'
 import TabbyTerminalModule from 'tabby-terminal'
 
@@ -24,11 +24,11 @@ import { SSHConfigProvider } from './config'
 import { SSHSettingsTabProvider } from './settings'
 import { RecoveryProvider } from './recoveryProvider'
 import { SSHHotkeyProvider } from './hotkeys'
-import { SFTPContextMenu } from './tabContextMenu'
 import { SSHProfilesService } from './profiles'
 import { SFTPContextMenuItemProvider } from './api/contextMenu'
 import { CommonSFTPContextMenu } from './sftpContextMenu'
 import { SFTPCreateDirectoryModalComponent } from './components/sftpCreateDirectoryModal.component'
+import { SSHCommandProvider } from './commands'
 
 /** @hidden */
 @NgModule({
@@ -46,7 +46,7 @@ import { SFTPCreateDirectoryModalComponent } from './components/sftpCreateDirect
         { provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true },
         { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
         { provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true },
-        { provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true },
+        { provide: CommandProvider, useExisting: SSHCommandProvider, multi: true },
         { provide: ProfileProvider, useExisting: SSHProfilesService, multi: true },
         { provide: SFTPContextMenuItemProvider, useClass: CommonSFTPContextMenu, multi: true },
     ],

+ 0 - 40
tabby-ssh/src/tabContextMenu.ts

@@ -1,40 +0,0 @@
-import { Injectable } from '@angular/core'
-import { BaseTabComponent, TabContextMenuItemProvider, HostAppService, Platform, MenuItemOptions, TranslateService } from 'tabby-core'
-import { SSHTabComponent } from './components/sshTab.component'
-import { SSHService } from './services/ssh.service'
-
-
-/** @hidden */
-@Injectable()
-export class SFTPContextMenu extends TabContextMenuItemProvider {
-    weight = 10
-
-    constructor (
-        private hostApp: HostAppService,
-        private ssh: SSHService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        if (!(tab instanceof SSHTabComponent)) {
-            return []
-        }
-        const items = [{
-            label: this.translate.instant('Open SFTP panel'),
-            click: () => {
-                tab.openSFTP()
-            },
-        }]
-        if (this.hostApp.platform === Platform.Windows && this.ssh.getWinSCPPath()) {
-            items.push({
-                label: this.translate.instant('Launch WinSCP'),
-                click: (): void => {
-                    this.ssh.launchWinSCP(tab.sshSession!)
-                },
-            })
-        }
-        return items
-    }
-}

+ 10 - 11
tabby-terminal/src/api/baseTerminalTab.component.ts

@@ -3,7 +3,7 @@ import { Spinner } from 'cli-spinner'
 import colors from 'ansi-colors'
 import { NgZone, OnInit, OnDestroy, Injector, ViewChild, HostBinding, Input, ElementRef, InjectFlags, Component } from '@angular/core'
 import { trigger, transition, style, animate, AnimationTriggerMetadata } from '@angular/animations'
-import { AppService, ConfigService, BaseTabComponent, HostAppService, HotkeysService, NotificationsService, Platform, LogService, Logger, TabContextMenuItemProvider, SplitTabComponent, SubscriptionContainer, MenuItemOptions, PlatformService, HostWindowService, ResettableTimeout, TranslateService, ThemesService } from 'tabby-core'
+import { AppService, ConfigService, BaseTabComponent, HostAppService, HotkeysService, NotificationsService, Platform, LogService, Logger, SplitTabComponent, SubscriptionContainer, MenuItemOptions, PlatformService, HostWindowService, ResettableTimeout, TranslateService, ThemesService, CommandContext, CommandLocation, CommandService } from 'tabby-core'
 
 import { BaseSession } from '../session'
 
@@ -121,11 +121,11 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
     protected notifications: NotificationsService
     protected log: LogService
     protected decorators: TerminalDecorator[] = []
-    protected contextMenuProviders: TabContextMenuItemProvider[]
     protected hostWindow: HostWindowService
     protected translate: TranslateService
     protected multifocus: MultifocusService
     protected themes: ThemesService
+    protected commands: CommandService
     // Deps end
 
     protected logger: Logger
@@ -200,11 +200,11 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
         this.notifications = injector.get(NotificationsService)
         this.log = injector.get(LogService)
         this.decorators = injector.get<any>(TerminalDecorator, null, InjectFlags.Optional) as TerminalDecorator[]
-        this.contextMenuProviders = injector.get<any>(TabContextMenuItemProvider, null, InjectFlags.Optional) as TabContextMenuItemProvider[]
         this.hostWindow = injector.get(HostWindowService)
         this.translate = injector.get(TranslateService)
         this.multifocus = injector.get(MultifocusService)
         this.themes = injector.get(ThemesService)
+        this.commands = injector.get(CommandService)
 
         this.logger = this.log.create('baseTerminalTab')
         this.setTitle(this.translate.instant('Terminal'))
@@ -323,8 +323,6 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
         this.bellPlayer = document.createElement('audio')
         this.bellPlayer.src = require<string>('../bell.ogg')
         this.bellPlayer.load()
-
-        this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
     }
 
     /** @hidden */
@@ -470,13 +468,14 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
     }
 
     async buildContextMenu (): Promise<MenuItemOptions[]> {
-        let items: MenuItemOptions[] = []
-        for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this)))) {
-            items = items.concat(section)
-            items.push({ type: 'separator' })
+        const contexts: CommandContext[] = [{ tab: this }]
+
+        // Top-level tab menu
+        if (this.parent) {
+            contexts.unshift({ tab: this.parent })
         }
-        items.splice(items.length - 1, 1)
-        return items
+
+        return this.commands.buildContextMenu(contexts, CommandLocation.TabBodyMenu)
     }
 
     /**

+ 180 - 0
tabby-terminal/src/commands.ts

@@ -0,0 +1,180 @@
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { Injectable } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import slugify from 'slugify'
+import { v4 as uuidv4 } from 'uuid'
+
+import { CommandProvider, Command, CommandLocation, TranslateService, CommandContext, PromptModalComponent, PartialProfile, Profile, ConfigService, NotificationsService, SplitTabComponent } from 'tabby-core'
+
+import { ConnectableTerminalTabComponent } from './api/connectableTerminalTab.component'
+import { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
+import { MultifocusService } from './services/multifocus.service'
+
+/** @hidden */
+@Injectable({ providedIn: 'root' })
+export class TerminalCommandProvider extends CommandProvider {
+    constructor (
+        private config: ConfigService,
+        private ngbModal: NgbModal,
+        private notifications: NotificationsService,
+        private translate: TranslateService,
+        private multifocus: MultifocusService,
+    ) {
+        super()
+    }
+
+    async provide (context: CommandContext): Promise<Command[]> {
+        const commands: Command[] = []
+        const tab = context.tab
+        if (!tab) {
+            return []
+        }
+
+        if (tab instanceof BaseTerminalTabComponent && tab.enableToolbar && !tab.pinToolbar) {
+            commands.push({
+                id: 'terminal:show-toolbar',
+                group: 'terminal:misc',
+                label: this.translate.instant('Show toolbar'),
+                locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+                run: async () => {
+                    tab.pinToolbar = true
+                },
+            })
+        }
+        if (tab instanceof BaseTerminalTabComponent && tab.session?.supportsWorkingDirectory()) {
+            commands.push({
+                id: 'terminal:copy-current-path',
+                group: 'terminal:misc',
+                label: this.translate.instant('Copy current path'),
+                locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+                run: async () => tab.copyCurrentPath(),
+            })
+        }
+        commands.push({
+            id: 'terminal:focus-all-tabs',
+            group: 'core:panes',
+            label: this.translate.instant('Focus all tabs'),
+            locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+            run: async () => {
+                this.multifocus.focusAllTabs()
+            },
+        })
+
+        let splitTab: SplitTabComponent|null = null
+        if (tab.parent instanceof SplitTabComponent) {
+            splitTab = tab.parent
+        }
+        if (tab instanceof SplitTabComponent) {
+            splitTab = tab
+        }
+
+        if (splitTab && splitTab.getAllTabs().length > 1) {
+            commands.push({
+                id: 'terminal:focus-all-panes',
+                group: 'terminal:misc',
+                label: this.translate.instant('Focus all panes'),
+                locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+                run: async () => {
+                    this.multifocus.focusAllPanes()
+                },
+            })
+        }
+
+        if (tab instanceof BaseTerminalTabComponent) {
+            commands.push({
+                id: 'terminal:save-as-profile',
+                group: 'terminal:misc',
+                label: this.translate.instant('Save as profile'),
+                locations: [CommandLocation.TabBodyMenu, CommandLocation.TabHeaderMenu],
+                run: async () => {
+                    const modal = this.ngbModal.open(PromptModalComponent)
+                    modal.componentInstance.prompt = this.translate.instant('New profile name')
+                    modal.componentInstance.value = tab.profile.name
+                    const name = (await modal.result.catch(() => null))?.value
+                    if (!name) {
+                        return
+                    }
+
+                    const options = {
+                        ...tab.profile.options,
+                    }
+
+                    const cwd = await tab.session?.getWorkingDirectory() ?? tab.profile.options.cwd
+                    if (cwd) {
+                        options.cwd = cwd
+                    }
+
+                    const profile: PartialProfile<Profile> = {
+                        type: tab.profile.type,
+                        name,
+                        options,
+                    }
+
+                    profile.id = `${profile.type}:custom:${slugify(name)}:${uuidv4()}`
+                    profile.group = tab.profile.group
+                    profile.icon = tab.profile.icon
+                    profile.color = tab.profile.color
+                    profile.disableDynamicTitle = tab.profile.disableDynamicTitle
+                    profile.behaviorOnSessionEnd = tab.profile.behaviorOnSessionEnd
+
+                    this.config.store.profiles = [
+                        ...this.config.store.profiles,
+                        profile,
+                    ]
+                    this.config.save()
+                    this.notifications.info(this.translate.instant('Saved'))
+                },
+            })
+        }
+
+        if (tab instanceof ConnectableTerminalTabComponent) {
+            commands.push({
+                id: 'terminal:disconnect',
+                label: this.translate.instant('Disconnect'),
+                group: 'terminal:connection',
+                locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+                run: async () => {
+                    setTimeout(() => {
+                        tab.disconnect()
+                        this.notifications.notice(this.translate.instant('Disconnect'))
+                    })
+                },
+            })
+            commands.push({
+                id: 'terminal:reconnect',
+                label: this.translate.instant('Reconnect'),
+                group: 'terminal:connection',
+                locations: [CommandLocation.TabHeaderMenu, CommandLocation.TabBodyMenu],
+                run: async () => {
+                    setTimeout(() => {
+                        tab.reconnect()
+                        this.notifications.notice(this.translate.instant('Reconnect'))
+                    })
+                },
+            })
+        }
+
+        if (tab instanceof BaseTerminalTabComponent) {
+            commands.push({
+                id: 'terminal:copy',
+                label: this.translate.instant('Copy'),
+                locations: [CommandLocation.TabBodyMenu],
+                weight: -2,
+                run: async () => {
+                    setTimeout(() => {
+                        tab.frontend?.copySelection()
+                        this.notifications.notice(this.translate.instant('Copied'))
+                    })
+                },
+            })
+            commands.push({
+                id: 'terminal:paste',
+                label: this.translate.instant('Paste'),
+                locations: [CommandLocation.TabBodyMenu],
+                weight: -1,
+                run: async () => tab.paste(),
+            })
+        }
+        return commands
+    }
+}

+ 3 - 7
tabby-terminal/src/index.ts

@@ -5,7 +5,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
 import { NgxColorsModule } from 'ngx-colors'
 
-import TabbyCorePlugin, { ConfigProvider, HotkeyProvider, TabContextMenuItemProvider, CLIHandler } from 'tabby-core'
+import TabbyCorePlugin, { ConfigProvider, HotkeyProvider, CLIHandler, CommandProvider } from 'tabby-core'
 import { SettingsTabProvider } from 'tabby-settings'
 
 import { AppearanceSettingsTabComponent } from './components/appearanceSettingsTab.component'
@@ -30,7 +30,7 @@ import { PathDropDecorator } from './features/pathDrop'
 import { ZModemDecorator } from './features/zmodem'
 import { TerminalConfigProvider } from './config'
 import { TerminalHotkeyProvider } from './hotkeys'
-import { CopyPasteContextMenu, MiscContextMenu, LegacyContextMenu, ReconnectContextMenu, SaveAsProfileContextMenu } from './tabContextMenu'
+import { TerminalCommandProvider } from './commands'
 
 import { Frontend } from './frontends/frontend'
 import { XTermFrontend, XTermWebGLFrontend } from './frontends/xtermFrontend'
@@ -58,11 +58,7 @@ import { DefaultColorSchemes } from './colorSchemes'
         { provide: TerminalDecorator, useClass: ZModemDecorator, multi: true },
         { provide: TerminalDecorator, useClass: DebugDecorator, multi: true },
 
-        { provide: TabContextMenuItemProvider, useClass: CopyPasteContextMenu, multi: true },
-        { provide: TabContextMenuItemProvider, useClass: MiscContextMenu, multi: true },
-        { provide: TabContextMenuItemProvider, useClass: LegacyContextMenu, multi: true },
-        { provide: TabContextMenuItemProvider, useClass: ReconnectContextMenu, multi: true },
-        { provide: TabContextMenuItemProvider, useClass: SaveAsProfileContextMenu, multi: true },
+        { provide: CommandProvider, useExisting: TerminalCommandProvider, multi: true },
 
         { provide: CLIHandler, useClass: TerminalCLIHandler, multi: true },
         { provide: TerminalColorSchemeProvider, useClass: DefaultColorSchemes, multi: true },

+ 0 - 218
tabby-terminal/src/tabContextMenu.ts

@@ -1,218 +0,0 @@
-import { Injectable, Optional, Inject } from '@angular/core'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { BaseTabComponent, TabContextMenuItemProvider, NotificationsService, MenuItemOptions, TranslateService, SplitTabComponent, PromptModalComponent, ConfigService, PartialProfile, Profile } from 'tabby-core'
-import { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
-import { TerminalContextMenuItemProvider } from './api/contextMenuProvider'
-import { MultifocusService } from './services/multifocus.service'
-import { ConnectableTerminalTabComponent } from './api/connectableTerminalTab.component'
-import { v4 as uuidv4 } from 'uuid'
-import slugify from 'slugify'
-
-/** @hidden */
-@Injectable()
-export class CopyPasteContextMenu extends TabContextMenuItemProvider {
-    weight = -10
-
-    constructor (
-        private notifications: NotificationsService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent, tabHeader?: boolean): Promise<MenuItemOptions[]> {
-        if (tabHeader) {
-            return []
-        }
-        if (tab instanceof BaseTerminalTabComponent) {
-            return [
-                {
-                    label: this.translate.instant('Copy'),
-                    click: (): void => {
-                        setTimeout(() => {
-                            tab.frontend?.copySelection()
-                            this.notifications.notice(this.translate.instant('Copied'))
-                        })
-                    },
-                },
-                {
-                    label: this.translate.instant('Paste'),
-                    click: () => tab.paste(),
-                },
-            ]
-        }
-        return []
-    }
-}
-
-/** @hidden */
-@Injectable()
-export class MiscContextMenu extends TabContextMenuItemProvider {
-    weight = 1
-
-    constructor (
-        private translate: TranslateService,
-        private multifocus: MultifocusService,
-    ) { super() }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        const items: MenuItemOptions[] = []
-        if (tab instanceof BaseTerminalTabComponent && tab.enableToolbar && !tab.pinToolbar) {
-            items.push({
-                label: this.translate.instant('Show toolbar'),
-                click: () => {
-                    tab.pinToolbar = true
-                },
-            })
-        }
-        if (tab instanceof BaseTerminalTabComponent && tab.session?.supportsWorkingDirectory()) {
-            items.push({
-                label: this.translate.instant('Copy current path'),
-                click: () => tab.copyCurrentPath(),
-            })
-        }
-        items.push({
-            label: this.translate.instant('Focus all tabs'),
-            click: () => {
-                this.multifocus.focusAllTabs()
-            },
-        })
-        if (tab.parent instanceof SplitTabComponent && tab.parent.getAllTabs().length > 1) {
-            items.push({
-                label: this.translate.instant('Focus all panes'),
-                click: () => {
-                    this.multifocus.focusAllPanes()
-                },
-            })
-        }
-        return items
-    }
-}
-
-/** @hidden */
-@Injectable()
-export class ReconnectContextMenu extends TabContextMenuItemProvider {
-    weight = 1
-
-    constructor (
-        private translate: TranslateService,
-        private notifications: NotificationsService,
-    ) { super() }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        if (tab instanceof ConnectableTerminalTabComponent) {
-            return [
-                {
-                    label: this.translate.instant('Disconnect'),
-                    click: (): void => {
-                        setTimeout(() => {
-                            tab.disconnect()
-                            this.notifications.notice(this.translate.instant('Disconnect'))
-                        })
-                    },
-                },
-                {
-                    label: this.translate.instant('Reconnect'),
-                    click: (): void => {
-                        setTimeout(() => {
-                            tab.reconnect()
-                            this.notifications.notice(this.translate.instant('Reconnect'))
-                        })
-                    },
-                },
-            ]
-        }
-        return []
-    }
-
-}
-
-/** @hidden */
-@Injectable()
-export class LegacyContextMenu extends TabContextMenuItemProvider {
-    weight = 1
-
-    constructor (
-        @Optional() @Inject(TerminalContextMenuItemProvider) protected contextMenuProviders: TerminalContextMenuItemProvider[]|null,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        if (!this.contextMenuProviders) {
-            return []
-        }
-        if (tab instanceof BaseTerminalTabComponent) {
-            let items: MenuItemOptions[] = []
-            for (const p of this.contextMenuProviders) {
-                items = items.concat(await p.getItems(tab))
-            }
-            return items
-        }
-        return []
-    }
-
-}
-
-/** @hidden */
-@Injectable()
-export class SaveAsProfileContextMenu extends TabContextMenuItemProvider {
-    constructor (
-        private config: ConfigService,
-        private ngbModal: NgbModal,
-        private notifications: NotificationsService,
-        private translate: TranslateService,
-    ) {
-        super()
-    }
-
-    async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
-        if (tab instanceof BaseTerminalTabComponent) {
-            return [
-                {
-                    label: this.translate.instant('Save as profile'),
-                    click: async () => {
-                        const modal = this.ngbModal.open(PromptModalComponent)
-                        modal.componentInstance.prompt = this.translate.instant('New profile name')
-                        modal.componentInstance.value = tab.profile.name
-                        const name = (await modal.result.catch(() => null))?.value
-                        if (!name) {
-                            return
-                        }
-
-                        const options = {
-                            ...tab.profile.options,
-                        }
-
-                        const cwd = await tab.session?.getWorkingDirectory() ?? tab.profile.options.cwd
-                        if (cwd) {
-                            options.cwd = cwd
-                        }
-
-                        const profile: PartialProfile<Profile> = {
-                            type: tab.profile.type,
-                            name,
-                            options,
-                        }
-
-                        profile.id = `${profile.type}:custom:${slugify(name)}:${uuidv4()}`
-                        profile.group = tab.profile.group
-                        profile.icon = tab.profile.icon
-                        profile.color = tab.profile.color
-                        profile.disableDynamicTitle = tab.profile.disableDynamicTitle
-                        profile.behaviorOnSessionEnd = tab.profile.behaviorOnSessionEnd
-
-                        this.config.store.profiles = [
-                            ...this.config.store.profiles,
-                            profile,
-                        ]
-                        this.config.save()
-                        this.notifications.info(this.translate.instant('Saved'))
-                    },
-                },
-            ]
-        }
-
-        return []
-    }
-}