1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-11-22 03:26:09 +03:00

more commands

This commit is contained in:
Eugene 2024-03-01 20:25:39 +01:00
parent 5d8ff72850
commit c12b445ccd
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
24 changed files with 847 additions and 813 deletions

View File

@ -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
/**
* 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 []
}
}

View File

@ -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
tabby-local/src/commands.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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
tabby-ssh/src/commands.ts Normal file
View File

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

View File

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

View File

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

View File

@ -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)
}
/**

View File

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

View File

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

View File

@ -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 []
}
}