From c12b445ccd9f65e055535fd026b0f73390df7857 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 1 Mar 2024 20:25:39 +0100 Subject: [PATCH] more commands --- tabby-core/src/api/commands.ts | 55 ++-- tabby-core/src/api/index.ts | 1 + tabby-core/src/api/menu.ts | 12 +- tabby-core/src/api/toolbarButtonProvider.ts | 10 - tabby-core/src/commands.ts | 309 +++++++++++++++++- .../src/components/appRoot.component.ts | 2 +- .../src/components/startPage.component.ts | 4 +- .../src/components/tabHeader.component.ts | 33 +- tabby-core/src/index.ts | 10 +- tabby-core/src/services/commands.service.ts | 137 ++++++-- tabby-core/src/tabContextMenu.ts | 298 ----------------- tabby-local/src/buttonProvider.ts | 28 -- tabby-local/src/commands.ts | 123 +++++++ tabby-local/src/index.ts | 9 +- tabby-local/src/tabContextMenu.ts | 87 ----- .../src/{buttonProvider.ts => commands.ts} | 17 +- tabby-settings/src/index.ts | 6 +- tabby-ssh/src/commands.ts | 44 +++ tabby-ssh/src/index.ts | 6 +- tabby-ssh/src/tabContextMenu.ts | 40 --- .../src/api/baseTerminalTab.component.ts | 21 +- tabby-terminal/src/commands.ts | 180 ++++++++++ tabby-terminal/src/index.ts | 10 +- tabby-terminal/src/tabContextMenu.ts | 218 ------------ 24 files changed, 847 insertions(+), 813 deletions(-) delete mode 100644 tabby-core/src/tabContextMenu.ts delete mode 100644 tabby-local/src/buttonProvider.ts create mode 100644 tabby-local/src/commands.ts delete mode 100644 tabby-local/src/tabContextMenu.ts rename tabby-settings/src/{buttonProvider.ts => commands.ts} (63%) create mode 100644 tabby-ssh/src/commands.ts delete mode 100644 tabby-ssh/src/tabContextMenu.ts create mode 100644 tabby-terminal/src/commands.ts delete mode 100644 tabby-terminal/src/tabContextMenu.ts diff --git a/tabby-core/src/api/commands.ts b/tabby-core/src/api/commands.ts index b217a11a..462cfb2c 100644 --- a/tabby-core/src/api/commands.ts +++ b/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 + fullLabel?: string + locations: CommandLocation[] + run?: () => Promise /** * Raw SVG icon code */ icon?: string - /** - * Optional Touch Bar icon ID - */ - touchBarNSImage?: string - - /** - * Optional Touch Bar button label - */ - touchBarTitle?: string - weight?: number + parent?: string + + 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 } } diff --git a/tabby-core/src/api/index.ts b/tabby-core/src/api/index.ts index cc467f96..ac4e2652 100644 --- a/tabby-core/src/api/index.ts +++ b/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' diff --git a/tabby-core/src/api/menu.ts b/tabby-core/src/api/menu.ts index 5dd1099a..68144088 100644 --- a/tabby-core/src/api/menu.ts +++ b/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, +}) diff --git a/tabby-core/src/api/toolbarButtonProvider.ts b/tabby-core/src/api/toolbarButtonProvider.ts index a30b9987..20bf2efe 100644 --- a/tabby-core/src/api/toolbarButtonProvider.ts +++ b/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 diff --git a/tabby-core/src/commands.ts b/tabby-core/src/commands.ts index 88a81b26..fed85223 100644 --- a/tabby-core/src/commands.ts +++ b/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 { - return [ + async provide (context: CommandContext): Promise { + 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 { + 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 } } diff --git a/tabby-core/src/components/appRoot.component.ts b/tabby-core/src/components/appRoot.component.ts index 8f781711..4abf2f8c 100644 --- a/tabby-core/src/components/appRoot.component.ts +++ b/tabby-core/src/components/appRoot.component.ts @@ -238,7 +238,7 @@ export class AppRootComponent { private async getToolbarButtons (aboveZero: boolean): Promise { 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 { diff --git a/tabby-core/src/components/startPage.component.ts b/tabby-core/src/components/startPage.component.ts index 2d6c6a87..c34ec4ef 100644 --- a/tabby-core/src/components/startPage.component.ts +++ b/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)) }) } diff --git a/tabby-core/src/components/tabHeader.component.ts b/tabby-core/src/components/tabHeader.component.ts index 6fdd8552..b978b37f 100644 --- a/tabby-core/src/components/tabHeader.component.ts +++ b/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 { - 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) { diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index 33b41d54..c2ea6010 100644 --- a/tabby-core/src/index.ts +++ b/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], diff --git a/tabby-core/src/services/commands.service.ts b/tabby-core/src/services/commands.service.ts index edbfc18c..ffa07d8c 100644 --- a/tabby-core/src/services/commands.service.ts +++ b/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 { @@ -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 { + 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 { const commands = await this.getCommands(context) const command = commands.find(x => x.id === id) - await command?.run() + await command?.run?.() } async showSelector (): Promise { @@ -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 { + 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) + } } diff --git a/tabby-core/src/tabContextMenu.ts b/tabby-core/src/tabContextMenu.ts deleted file mode 100644 index e0ed6d1f..00000000 --- a/tabby-core/src/tabContextMenu.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - - if (tab.parent instanceof SplitTabComponent && tab.parent.getAllTabs().length > 1) { - return [ - { - label: this.translate.instant('Switch profile'), - click: () => this.switchTabProfile(tab), - }, - ] - } - - return [] - } -} diff --git a/tabby-local/src/buttonProvider.ts b/tabby-local/src/buttonProvider.ts deleted file mode 100644 index 410cc63e..00000000 --- a/tabby-local/src/buttonProvider.ts +++ /dev/null @@ -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() - }, - }, - ] - } -} diff --git a/tabby-local/src/commands.ts b/tabby-local/src/commands.ts new file mode 100644 index 00000000..f34c8a66 --- /dev/null +++ b/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 { + 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() + } + } +} diff --git a/tabby-local/src/index.ts b/tabby-local/src/index.ts index 3730c93d..bf005d49 100644 --- a/tabby-local/src/index.ts +++ b/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 }, diff --git a/tabby-local/src/tabContextMenu.ts b/tabby-local/src/tabContextMenu.ts deleted file mode 100644 index 108ad7e5..00000000 --- a/tabby-local/src/tabContextMenu.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/tabby-settings/src/buttonProvider.ts b/tabby-settings/src/commands.ts similarity index 63% rename from tabby-settings/src/buttonProvider.ts rename to tabby-settings/src/commands.ts index 243ddd8c..a2b3f6d5 100644 --- a/tabby-settings/src/buttonProvider.ts +++ b/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 { 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(), }] } diff --git a/tabby-settings/src/index.ts b/tabby-settings/src/index.ts index d9f0abad..0a88f03c 100644 --- a/tabby-settings/src/index.ts +++ b/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 }, diff --git a/tabby-ssh/src/commands.ts b/tabby-ssh/src/commands.ts new file mode 100644 index 00000000..730494de --- /dev/null +++ b/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 { + 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 + } +} diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index 280a5be4..09d0c348 100644 --- a/tabby-ssh/src/index.ts +++ b/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 }, ], diff --git a/tabby-ssh/src/tabContextMenu.ts b/tabby-ssh/src/tabContextMenu.ts deleted file mode 100644 index 68a0dcf5..00000000 --- a/tabby-ssh/src/tabContextMenu.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index 4cf02abc..925c111a 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/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

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

extends Bas this.notifications = injector.get(NotificationsService) this.log = injector.get(LogService) this.decorators = injector.get(TerminalDecorator, null, InjectFlags.Optional) as TerminalDecorator[] - this.contextMenuProviders = injector.get(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

extends Bas this.bellPlayer = document.createElement('audio') this.bellPlayer.src = require('../bell.ogg') this.bellPlayer.load() - - this.contextMenuProviders.sort((a, b) => a.weight - b.weight) } /** @hidden */ @@ -470,13 +468,14 @@ export class BaseTerminalTabComponent

extends Bas } async buildContextMenu (): Promise { - 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) } /** diff --git a/tabby-terminal/src/commands.ts b/tabby-terminal/src/commands.ts new file mode 100644 index 00000000..db80f47e --- /dev/null +++ b/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 { + 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 = { + 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 + } +} diff --git a/tabby-terminal/src/index.ts b/tabby-terminal/src/index.ts index 76226124..4b73a228 100644 --- a/tabby-terminal/src/index.ts +++ b/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 }, diff --git a/tabby-terminal/src/tabContextMenu.ts b/tabby-terminal/src/tabContextMenu.ts deleted file mode 100644 index 817837f0..00000000 --- a/tabby-terminal/src/tabContextMenu.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 = { - 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 [] - } -}