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

support for providing commands as toolbar buttons

This commit is contained in:
Eugene Pankov 2022-12-05 12:03:08 +01:00
parent a1825bbbe6
commit b0600b10cc
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
18 changed files with 133 additions and 128 deletions

View File

@ -2,21 +2,51 @@ import { BaseTabComponent } from '../components/baseTab.component'
import { MenuItemOptions } from './menu'
import { ToolbarButton } from './toolbarButtonProvider'
export enum CommandLocation {
LeftToolbar = 'left-toolbar',
RightToolbar = 'right-toolbar',
StartPage = 'start-page',
}
export class Command {
id?: string
label: string
sublabel?: string
click?: () => void
locations?: CommandLocation[]
run: () => Promise<void>
/**
* Raw SVG icon code
*/
icon?: string
/**
* Optional Touch Bar icon ID
*/
touchBarNSImage?: string
/**
* Optional Touch Bar button label
*/
touchBarTitle?: string
weight?: number
static fromToolbarButton (button: ToolbarButton): Command {
const command = new Command()
command.label = button.commandLabel ?? button.title
command.click = button.click
command.label = button.title
command.run = async () => button.click?.()
command.icon = button.icon
command.locations = [CommandLocation.StartPage]
if ((button.weight ?? 0) <= 0) {
command.locations.push(CommandLocation.LeftToolbar)
}
if ((button.weight ?? 0) > 0) {
command.locations.push(CommandLocation.RightToolbar)
}
command.touchBarNSImage = button.touchBarNSImage
command.touchBarTitle = button.touchBarTitle
command.weight = button.weight
return command
}
@ -24,7 +54,7 @@ export class Command {
const command = new Command()
command.label = item.commandLabel ?? item.label ?? ''
command.sublabel = item.sublabel
command.click = item.click
command.run = async () => item.click?.()
return command
}
}
@ -32,3 +62,10 @@ export class Command {
export interface CommandContext {
tab?: BaseTabComponent,
}
/**
* Extend to add commands
*/
export abstract class CommandProvider {
abstract provide (context: CommandContext): Promise<Command[]>
}

View File

@ -27,13 +27,6 @@ export interface ToolbarButton {
/** @hidden */
submenuItems?: ToolbarButton[]
showInToolbar?: boolean
showInStartPage?: boolean
/** @hidden */
commandLabel?: string
}
/**

View File

@ -2,26 +2,19 @@
import { Injectable } from '@angular/core'
import { TranslateService } from '@ngx-translate/core'
import { ToolbarButton, ToolbarButtonProvider } from './api/toolbarButtonProvider'
import { HostAppService, Platform } from './api/hostApp'
import { HotkeysService } from './services/hotkeys.service'
import { ProfilesService } from './services/profiles.service'
import { CommandProvider, Command, CommandLocation } from './api/commands'
/** @hidden */
@Injectable()
export class ButtonProvider extends ToolbarButtonProvider {
@Injectable({ providedIn: 'root' })
export class CoreCommandProvider extends CommandProvider {
constructor (
private hostApp: HostAppService,
private profilesService: ProfilesService,
private translate: TranslateService,
hotkeys: HotkeysService,
) {
super()
hotkeys.hotkey$.subscribe(hotkey => {
if (hotkey === 'profile-selector') {
this.activate()
}
})
}
async activate () {
@ -31,21 +24,22 @@ export class ButtonProvider extends ToolbarButtonProvider {
}
}
provide (): ToolbarButton[] {
async provide (): Promise<Command[]> {
return [
{
id: 'profile-selector',
locations: [CommandLocation.LeftToolbar, CommandLocation.StartPage],
label: this.translate.instant('Profiles & connections'),
icon: this.hostApp.platform === Platform.Web
? require('./icons/plus.svg')
: require('./icons/profiles.svg'),
title: this.translate.instant('Profiles & connections'),
click: () => this.activate(),
run: async () => this.activate(),
},
...this.profilesService.getRecentProfiles().map(profile => ({
label: profile.name,
locations: [CommandLocation.StartPage],
icon: require('./icons/history.svg'),
title: profile.name,
showInToolbar: false,
showinStartPage: true,
click: async () => {
run: async () => {
const p = (await this.profilesService.getProfiles()).find(x => x.id === profile.id) ?? profile
this.profilesService.launchProfile(p)
},

View File

@ -38,26 +38,14 @@ title-bar(
.btn-group.background
.d-flex(
*ngFor='let button of leftToolbarButtons',
ngbDropdown,
(openChange)='generateButtonSubmenu(button)',
ngbDropdown
)
button.btn.btn-secondary.btn-tab-bar(
[title]='button.title',
(click)='button.click && button.click()',
[title]='button.label',
(click)='button.run && button.run()',
[fastHtmlBind]='button.icon',
ngbDropdownToggle,
)
div(*ngIf='button.submenu', ngbDropdownMenu)
button.dropdown-item.d-flex.align-items-center(
*ngFor='let item of button.submenuItems',
(click)='item.click()',
ngbDropdownItem,
)
.icon-wrapper(
*ngIf='hasIcons(button.submenuItems)',
[fastHtmlBind]='item.icon'
)
div([class.ml-3]='hasIcons(button.submenuItems)') {{item.title}}
.d-flex(
ngbDropdown,
@ -80,26 +68,14 @@ title-bar(
.btn-group.background
.d-flex(
*ngFor='let button of rightToolbarButtons',
ngbDropdown,
(openChange)='generateButtonSubmenu(button)',
ngbDropdown
)
button.btn.btn-secondary.btn-tab-bar(
[title]='button.title',
(click)='button.click && button.click()',
(click)='button.run && button.run()',
[fastHtmlBind]='button.icon',
ngbDropdownToggle,
)
div(*ngIf='button.submenu', ngbDropdownMenu)
button.dropdown-item.d-flex.align-items-center(
*ngFor='let item of button.submenuItems',
(click)='item.click()',
ngbDropdownItem,
)
.icon-wrapper(
*ngIf='hasIcons(button.submenuItems)',
[fastHtmlBind]='item.icon'
)
div([class.ml-3]='hasIcons(button.submenuItems)') {{item.title}}
button.btn.btn-secondary.btn-tab-bar.btn-update(
*ngIf='updatesAvailable',

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Inject, Input, HostListener, HostBinding, ViewChildren, ViewChild } from '@angular/core'
import { Component, Input, HostListener, HostBinding, ViewChildren, ViewChild } from '@angular/core'
import { trigger, style, animate, transition, state } from '@angular/animations'
import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
@ -10,12 +10,13 @@ import { Logger, LogService } from '../services/log.service'
import { ConfigService } from '../services/config.service'
import { ThemesService } from '../services/themes.service'
import { UpdaterService } from '../services/updater.service'
import { CommandService } from '../services/commands.service'
import { BaseTabComponent } from './baseTab.component'
import { SafeModeModalComponent } from './safeModeModal.component'
import { TabBodyComponent } from './tabBody.component'
import { SplitTabComponent } from './splitTab.component'
import { AppService, FileTransfer, HostWindowService, PlatformService, ToolbarButton, ToolbarButtonProvider } from '../api'
import { AppService, Command, CommandLocation, FileTransfer, HostWindowService, PlatformService } from '../api'
function makeTabAnimation (dimension: string, size: number) {
return [
@ -63,8 +64,8 @@ function makeTabAnimation (dimension: string, size: number) {
export class AppRootComponent {
Platform = Platform
@Input() ready = false
@Input() leftToolbarButtons: ToolbarButton[]
@Input() rightToolbarButtons: ToolbarButton[]
@Input() leftToolbarButtons: Command[]
@Input() rightToolbarButtons: Command[]
@HostBinding('class.platform-win32') platformClassWindows = process.platform === 'win32'
@HostBinding('class.platform-darwin') platformClassMacOS = process.platform === 'darwin'
@HostBinding('class.platform-linux') platformClassLinux = process.platform === 'linux'
@ -79,11 +80,11 @@ export class AppRootComponent {
constructor (
private hotkeys: HotkeysService,
private updater: UpdaterService,
private commands: CommandService,
public hostWindow: HostWindowService,
public hostApp: HostAppService,
public config: ConfigService,
public app: AppService,
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
platform: PlatformService,
log: LogService,
ngbModal: NgbModal,
@ -170,9 +171,9 @@ export class AppRootComponent {
this.activeTransfersDropdown.open()
})
config.ready$.toPromise().then(() => {
this.leftToolbarButtons = this.getToolbarButtons(false)
this.rightToolbarButtons = this.getToolbarButtons(true)
config.ready$.toPromise().then(async () => {
this.leftToolbarButtons = await this.getToolbarButtons(false)
this.rightToolbarButtons = await this.getToolbarButtons(true)
setInterval(() => {
if (this.config.store.enableAutomaticUpdates) {
@ -212,16 +213,6 @@ export class AppRootComponent {
return this.config.store.appearance.flexTabs ? '*' : '200px'
}
async generateButtonSubmenu (button: ToolbarButton) {
if (button.submenu) {
button.submenuItems = await button.submenu()
}
}
hasIcons (submenuItems: ToolbarButton[]): boolean {
return submenuItems.some(x => !!x.icon)
}
onTabsReordered (event: CdkDragDrop<BaseTabComponent[]>) {
const tab: BaseTabComponent = event.item.data
if (!this.app.tabs.includes(tab)) {
@ -244,14 +235,8 @@ export class AppRootComponent {
return this.config.store?.appearance.vibrancy
}
private getToolbarButtons (aboveZero: boolean): ToolbarButton[] {
let buttons: ToolbarButton[] = []
this.config.enabledServices(this.toolbarButtonProviders).forEach(provider => {
buttons = buttons.concat(provider.provide())
})
return buttons
.filter(x => x.showInToolbar ?? true)
.filter(button => (button.weight ?? 0) > 0 === aboveZero)
.sort((a: ToolbarButton, b: ToolbarButton) => (a.weight ?? 0) - (b.weight ?? 0))
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))
}
}

View File

@ -1,7 +1,8 @@
import { Observable, Subject, distinctUntilChanged, filter, debounceTime } from 'rxjs'
import { EmbeddedViewRef, ViewContainerRef, ViewRef } from '@angular/core'
import { EmbeddedViewRef, Injector, ViewContainerRef, ViewRef } from '@angular/core'
import { RecoveryToken } from '../api/tabRecovery'
import { BaseComponent } from './base.component'
import { ConfigService } from '../services/config.service'
/**
* Represents an active "process" inside a tab,
@ -87,8 +88,11 @@ export abstract class BaseTabComponent extends BaseComponent {
get destroyed$ (): Observable<void> { return this.destroyed }
get recoveryStateChangedHint$ (): Observable<void> { return this.recoveryStateChangedHint }
protected constructor () {
protected config: ConfigService
protected constructor (injector: Injector) {
super()
this.config = injector.get(ConfigService)
this.focused$.subscribe(() => {
this.hasFocus = true
})

View File

@ -97,8 +97,8 @@ export class SelectorModalComponent<T> {
}
selectOption (option: SelectorOption<T>): void {
option.callback?.(this.filter)
this.modalInstance.close(option.result)
setTimeout(() => option.callback?.(this.filter))
}
canEditSelected (): boolean {

View File

@ -1,11 +1,10 @@
import { Observable, Subject } from 'rxjs'
import { Component, Injectable, ViewChild, ViewContainerRef, EmbeddedViewRef, AfterViewInit, OnDestroy } from '@angular/core'
import { Component, Injectable, ViewChild, ViewContainerRef, EmbeddedViewRef, AfterViewInit, OnDestroy, Injector } from '@angular/core'
import { BaseTabComponent, BaseTabProcess, GetRecoveryTokenOptions } from './baseTab.component'
import { TabRecoveryProvider, RecoveryToken } from '../api/tabRecovery'
import { TabsService, NewTabParameters } from '../services/tabs.service'
import { HotkeysService } from '../services/hotkeys.service'
import { TabRecoveryService } from '../services/tabRecovery.service'
import { ConfigService } from '../api'
export type SplitOrientation = 'v' | 'h'
export type SplitDirection = 'r' | 't' | 'b' | 'l'
@ -261,9 +260,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
private hotkeys: HotkeysService,
private tabsService: TabsService,
private tabRecovery: TabRecoveryService,
private config: ConfigService,
injector: Injector,
) {
super()
super(injector)
this.root = new SplitContainer()
this.setTitle('')

View File

@ -5,11 +5,11 @@ div
.list-group.mb-4
a.list-group-item.list-group-item-action.d-flex(
*ngFor='let button of getButtons(); trackBy: buttonsTrackBy',
(click)='button.click()',
*ngFor='let command of commands; trackBy: buttonsTrackBy',
(click)='command.run()',
)
.d-flex.align-self-center([innerHTML]='sanitizeIcon(button.icon)')
span {{button.title}}
.d-flex.align-self-center([innerHTML]='sanitizeIcon(command.icon)')
span {{command.label}}
footer.d-flex.align-items-center
.btn-group.mr-auto

View File

@ -1,8 +1,8 @@
import { Component, Inject } from '@angular/core'
import { Component } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
import { ConfigService } from '../services/config.service'
import { HomeBaseService } from '../services/homeBase.service'
import { ToolbarButton, ToolbarButtonProvider } from '../api'
import { CommandService } from '../services/commands.service'
import { Command, CommandLocation } from '../api/commands'
/** @hidden */
@Component({
@ -12,29 +12,23 @@ import { ToolbarButton, ToolbarButtonProvider } from '../api'
})
export class StartPageComponent {
version: string
commands: Command[] = []
constructor (
private config: ConfigService,
private domSanitizer: DomSanitizer,
public homeBase: HomeBaseService,
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
commands: CommandService,
) {
}
getButtons (): ToolbarButton[] {
return this.config.enabledServices(this.toolbarButtonProviders)
.map(provider => provider.provide())
.reduce((a, b) => a.concat(b))
.filter(x => x.showInStartPage ?? true)
.filter(x => !!x.click)
.sort((a: ToolbarButton, b: ToolbarButton) => (a.weight ?? 0) - (b.weight ?? 0))
commands.getCommands({}).then(c => {
this.commands = c.filter(x => x.locations?.includes(CommandLocation.StartPage))
})
}
sanitizeIcon (icon?: string): any {
return this.domSanitizer.bypassSecurityTrustHtml(icon ?? '')
}
buttonsTrackBy (btn: ToolbarButton): any {
return btn.title + btn.icon
buttonsTrackBy (btn: Command): any {
return btn.label + btn.icon
}
}

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component } from '@angular/core'
import { Component, Injector } from '@angular/core'
import { TranslateService } from '@ngx-translate/core'
import { BaseTabComponent } from './baseTab.component'
import { ConfigService } from '../services/config.service'
@ -19,8 +19,9 @@ export class WelcomeTabComponent extends BaseTabComponent {
public config: ConfigService,
public locale: LocaleService,
translate: TranslateService,
injector: Injector,
) {
super()
super(injector)
this.setTitle(translate.instant('Welcome'))
}

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, ToolbarButtonProvider, ProfilesService, ProfileProvider, SelectorOption, Profile, SelectorService } from './api'
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api'
import { AppService } from './services/app.service'
import { ConfigService } from './services/config.service'
@ -51,8 +51,8 @@ import { CoreConfigProvider } from './config'
import { AppHotkeyProvider } from './hotkeys'
import { TaskCompletionContextMenu, CommonOptionsContextMenu, TabManagementContextMenu, ProfilesContextMenu } from './tabContextMenu'
import { LastCLIHandler, ProfileCLIHandler } from './cli'
import { ButtonProvider } from './buttonProvider'
import { SplitLayoutProfilesService } from './profiles'
import { CoreCommandProvider } from './commands'
import 'perfect-scrollbar/css/perfect-scrollbar.css'
@ -75,8 +75,8 @@ const PROVIDERS = [
{ provide: CLIHandler, useClass: LastCLIHandler, multi: true },
{ provide: PERFECT_SCROLLBAR_CONFIG, useValue: { suppressScrollX: true } },
{ provide: FileProvider, useClass: VaultFileProvider, multi: true },
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: ProfileProvider, useExisting: SplitLayoutProfilesService, multi: true },
{ provide: CommandProvider, useExisting: CoreCommandProvider, multi: true },
{
provide: LOCALE_ID,
deps: [LocaleService],
@ -180,7 +180,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex
console.error('Unhandled exception:', err)
})
hotkeys.hotkey$.subscribe(async (hotkey) => {
hotkeys.hotkey$.subscribe(async hotkey => {
if (hotkey.startsWith('profile.')) {
const id = hotkey.substring(hotkey.indexOf('.') + 1)
const profiles = await profilesService.getProfiles()
@ -200,6 +200,10 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex
if (hotkey === 'command-selector') {
commands.showSelector()
}
if (hotkey === 'profile-selector') {
commands.run('profile-selector', {})
}
})
}

View File

@ -1,5 +1,5 @@
import { Inject, Injectable, Optional } from '@angular/core'
import { AppService, Command, CommandContext, ConfigService, MenuItemOptions, SplitTabComponent, TabContextMenuItemProvider, ToolbarButton, ToolbarButtonProvider, TranslateService } from '../api'
import { AppService, Command, CommandContext, CommandProvider, ConfigService, MenuItemOptions, SplitTabComponent, TabContextMenuItemProvider, ToolbarButton, ToolbarButtonProvider, TranslateService } from '../api'
import { SelectorService } from './selector.service'
@Injectable({ providedIn: 'root' })
@ -11,6 +11,7 @@ export class CommandService {
private translate: TranslateService,
@Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[],
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
@Inject(CommandProvider) private commandProviders: CommandProvider[],
) {
this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
}
@ -60,10 +61,20 @@ export class CommandService {
}
items.forEach(x => flattenItem(x))
let commands = buttons.map(x => Command.fromToolbarButton(x))
commands = commands.concat(flatItems.map(x => Command.fromMenuItem(x)))
const commands = buttons.map(x => Command.fromToolbarButton(x))
commands.push(...flatItems.map(x => Command.fromMenuItem(x)))
return commands
for (const provider of this.config.enabledServices(this.commandProviders)) {
commands.push(...await provider.provide(context))
}
return commands.sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0))
}
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()
}
async showSelector (): Promise<void> {
@ -81,7 +92,7 @@ export class CommandService {
this.translate.instant('Commands'),
commands.map(c => ({
name: c.label,
callback: c.click,
callback: c.run,
description: c.sublabel,
icon: c.icon,
})),

View File

@ -2,7 +2,7 @@
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'
import axios from 'axios'
import { marked } from 'marked'
import { Component } from '@angular/core'
import { Component, Injector } from '@angular/core'
import { BaseTabComponent, TranslateService } from 'tabby-core'
export interface Release {
@ -22,8 +22,8 @@ export class ReleaseNotesComponent extends BaseTabComponent {
releases: Release[] = []
lastPage = 1
constructor (translate: TranslateService) {
super()
constructor (translate: TranslateService, injector: Injector) {
super(injector)
this.setTitle(translate.instant(_('Release notes')))
this.loadReleases(1)
}

View File

@ -2,7 +2,7 @@
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'
import * as yaml from 'js-yaml'
import { debounce } from 'utils-decorators/dist/esm/debounce/debounce'
import { Component, Inject, Input, HostBinding, NgZone } from '@angular/core'
import { Component, Inject, Input, HostBinding, NgZone, Injector } from '@angular/core'
import {
ConfigService,
BaseTabComponent,
@ -52,8 +52,9 @@ export class SettingsTabComponent extends BaseTabComponent {
private app: AppService,
@Inject(SettingsTabProvider) public settingsProviders: SettingsTabProvider[],
translate: TranslateService,
injector: Injector,
) {
super()
super(injector)
this.setTitle(translate.instant(_('Settings')))
this.settingsProviders = config.enabledServices(this.settingsProviders)
this.settingsProviders = this.settingsProviders.filter(x => !!x.getComponentType())

View File

@ -17,6 +17,7 @@
"author": "Eugene Pankov",
"license": "MIT",
"devDependencies": {
"ansi-colors": "^4.1.1",
"@types/node": "14.14.31"
},
"peerDependencies": {

View File

@ -6,3 +6,8 @@
version "14.14.31"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055"
integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==
ansi-colors@^4.1.1:
version "4.1.3"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"
integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==

View File

@ -175,7 +175,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
get sessionChanged$ (): Observable<BaseSession|null> { return this.sessionChanged }
constructor (protected injector: Injector) {
super()
super(injector)
this.config = injector.get(ConfigService)
this.element = injector.get(ElementRef)