import { Injectable, Inject } from '@angular/core' import { TranslateService } from '@ngx-translate/core' import { NewTabParameters } from './tabs.service' import { BaseTabComponent } from '../components/baseTab.component' import { PartialProfile, PartialProfileGroup, Profile, ProfileGroup, ProfileProvider } from '../api/profileProvider' import { SelectorOption } from '../api/selector' import { AppService } from './app.service' import { configMerge, ConfigProxy, ConfigService } from './config.service' import { NotificationsService } from './notifications.service' import { SelectorService } from './selector.service' import deepClone from 'clone-deep' import { v4 as uuidv4 } from 'uuid' import slugify from 'slugify' @Injectable({ providedIn: 'root' }) export class ProfilesService { private profileDefaults = { id: '', type: '', name: '', group: '', options: {}, icon: '', color: '', disableDynamicTitle: false, weight: 0, isBuiltin: false, isTemplate: false, terminalColorScheme: null, behaviorOnSessionEnd: 'auto', } constructor ( private app: AppService, private config: ConfigService, private notifications: NotificationsService, private selector: SelectorService, private translate: TranslateService, @Inject(ProfileProvider) private profileProviders: ProfileProvider[], ) { } /* * Methods used to interract with ProfileProvider */ getProviders (): ProfileProvider[] { return [...this.profileProviders] } providerForProfile (profile: PartialProfile): ProfileProvider|null { const provider = this.profileProviders.find(x => x.id === profile.type) ?? null return provider as unknown as ProfileProvider|null } getDescription

(profile: PartialProfile

): string|null { profile = this.getConfigProxyForProfile(profile) return this.providerForProfile(profile)?.getDescription(profile) ?? null } /* * Methods used to interract with Profile */ /* * Return ConfigProxy for a given Profile * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy */ getConfigProxyForProfile (profile: PartialProfile, skipUserDefaults = false): T { const defaults = this.getProfileDefaults(profile, skipUserDefaults).reduce(configMerge, {}) return new ConfigProxy(profile, defaults) as unknown as T } /** * Return an Array of Profiles * arg: includeBuiltin (default: true) -> include BuiltinProfiles * arg: clone (default: false) -> return deepclone Array */ async getProfiles (includeBuiltin = true, clone = false): Promise[]> { let list = this.config.store.profiles ?? [] if (includeBuiltin) { const lists = await Promise.all(this.config.enabledServices(this.profileProviders).map(x => x.getBuiltinProfiles())) list = [ ...this.config.store.profiles ?? [], ...lists.reduce((a, b) => a.concat(b), []), ] } const sortKey = p => `${this.resolveProfileGroupName(p.group ?? '')} / ${p.name}` list.sort((a, b) => sortKey(a).localeCompare(sortKey(b))) list.sort((a, b) => (a.isBuiltin ? 1 : 0) - (b.isBuiltin ? 1 : 0)) return clone ? deepClone(list) : list } /** * Insert a new Profile in config * arg: saveConfig (default: true) -> invoke after the Profile was updated * arg: genId (default: true) -> generate uuid in before pushing Profile into config */ async newProfile (profile: PartialProfile, saveConfig = true, genId = true): Promise { if (genId) { profile.id = `${profile.type}:custom:${slugify(profile.name)}:${uuidv4()}` } const cProfile = this.config.store.profiles.find(p => p.id === profile.id) if (cProfile) { throw new Error(`Cannot insert new Profile, duplicated Id: ${profile.id}`) } this.config.store.profiles.push(profile) if (saveConfig) { return this.config.save() } } /** * Write a Profile in config * arg: saveConfig (default: true) -> invoke after the Profile was updated */ async writeProfile (profile: PartialProfile, saveConfig = true): Promise { const cProfile = this.config.store.profiles.find(p => p.id === profile.id) if (cProfile) { // Fully replace the config for (const k in cProfile) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete cProfile[k] } Object.assign(cProfile, profile) if (saveConfig) { return this.config.save() } } } /** * Delete a Profile from config * arg: saveConfig (default: true) -> invoke after the Profile was deleted */ async deleteProfile (profile: PartialProfile, saveConfig = true): Promise { this.providerForProfile(profile)?.deleteProfile(this.getConfigProxyForProfile(profile)) this.config.store.profiles = this.config.store.profiles.filter(p => p.id !== profile.id) const profileHotkeyName = ProfilesService.getProfileHotkeyName(profile) if (this.config.store.hotkeys.profile.hasOwnProperty(profileHotkeyName)) { const profileHotkeys = deepClone(this.config.store.hotkeys.profile) // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete profileHotkeys[profileHotkeyName] this.config.store.hotkeys.profile = profileHotkeys } if (saveConfig) { return this.config.save() } } /** * Delete all Profiles from config using option filter * arg: options { group: string } -> options used to filter which profile have to be deleted * arg: saveConfig (default: true) -> invoke after the Profile was deleted */ async deleteBulkProfiles (options: { group: string }, saveConfig = true): Promise { for (const profile of this.config.store.profiles.filter(p => p.group === options.group)) { this.providerForProfile(profile)?.deleteProfile(this.getConfigProxyForProfile(profile)) const profileHotkeyName = ProfilesService.getProfileHotkeyName(profile) if (this.config.store.hotkeys.profile.hasOwnProperty(profileHotkeyName)) { const profileHotkeys = deepClone(this.config.store.hotkeys.profile) // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete profileHotkeys[profileHotkeyName] this.config.store.hotkeys.profile = profileHotkeys } } this.config.store.profiles = this.config.store.profiles.filter(p => p.group !== options.group) if (saveConfig) { return this.config.save() } } async openNewTabForProfile

(profile: PartialProfile

): Promise { const params = await this.newTabParametersForProfile(profile) if (params) { return this.app.openNewTab(params) } return null } async newTabParametersForProfile

(profile: PartialProfile

): Promise|null> { const fullProfile = this.getConfigProxyForProfile(profile) const params = await this.providerForProfile(fullProfile)?.getNewTabParameters(fullProfile) ?? null if (params) { params.inputs ??= {} params.inputs['title'] = profile.name if (fullProfile.disableDynamicTitle) { params.inputs['disableDynamicTitle'] = true } if (fullProfile.color) { params.inputs['color'] = fullProfile.color } if (fullProfile.icon) { params.inputs['icon'] = fullProfile.icon } } return params } async launchProfile (profile: PartialProfile): Promise { await this.openNewTabForProfile(profile) let recentProfiles: PartialProfile[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') if (this.config.store.terminal.showRecentProfiles > 0) { recentProfiles = recentProfiles.filter(x => x.group !== profile.group || x.name !== profile.name) recentProfiles.unshift(profile) recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) } else { recentProfiles = [] } window.localStorage['recentProfiles'] = JSON.stringify(recentProfiles) } static getProfileHotkeyName (profile: PartialProfile): string { return (profile.id ?? profile.name).replace(/\./g, '-') } /* * Methods used to interract with Profile Selector */ selectorOptionForProfile

(profile: PartialProfile

): SelectorOption { const fullProfile = this.getConfigProxyForProfile(profile) const provider = this.providerForProfile(fullProfile) const freeInputEquivalent = provider?.intoQuickConnectString(fullProfile) ?? undefined return { ...profile, group: this.resolveProfileGroupName(profile.group ?? ''), freeInputEquivalent, description: provider?.getDescription(fullProfile), } } showProfileSelector (): Promise|null> { if (this.selector.active) { return Promise.resolve(null) } return new Promise|null>(async (resolve, reject) => { try { const recentProfiles = this.getRecentProfiles() let options: SelectorOption[] = recentProfiles.map((p, i) => ({ ...this.selectorOptionForProfile(p), group: this.translate.instant('Recent'), icon: 'fas fa-history', color: p.color, weight: i - (recentProfiles.length + 1), callback: async () => { if (p.id) { p = (await this.getProfiles()).find(x => x.id === p.id) ?? p } resolve(p) }, })) if (recentProfiles.length) { options.push({ name: this.translate.instant('Clear recent profiles'), group: this.translate.instant('Recent'), icon: 'fas fa-eraser', weight: -1, callback: async () => { window.localStorage.removeItem('recentProfiles') this.config.save() resolve(null) }, }) } let profiles = await this.getProfiles() if (!this.config.store.terminal.showBuiltinProfiles) { profiles = profiles.filter(x => !x.isBuiltin) } profiles = profiles.filter(x => !x.isTemplate) profiles = profiles.filter(x => x.id && !this.config.store.profileBlacklist.includes(x.id)) options = [...options, ...profiles.map((p): SelectorOption => ({ ...this.selectorOptionForProfile(p), weight: p.isBuiltin ? 2 : 1, callback: () => resolve(p), }))] try { const { SettingsTabComponent } = window['nodeRequire']('tabby-settings') options.push({ name: this.translate.instant('Manage profiles'), icon: 'fas fa-window-restore', weight: 10, callback: () => { this.app.openNewTabRaw({ type: SettingsTabComponent, inputs: { activeTab: 'profiles' }, }) resolve(null) }, }) } catch { } this.getProviders().filter(x => x.supportsQuickConnect).forEach(provider => { options.push({ name: this.translate.instant('Quick connect'), freeInputPattern: this.translate.instant('Connect to "%s"...'), description: `(${provider.name.toUpperCase()})`, icon: 'fas fa-arrow-right', weight: provider.id !== this.config.store.defaultQuickConnectProvider ? 1 : 0, callback: query => { const profile = provider.quickConnect(query) resolve(profile) }, }) }) await this.selector.show(this.translate.instant('Select profile or enter an address'), options) } catch (err) { reject(err) } }) } getRecentProfiles (): PartialProfile[] { let recentProfiles: PartialProfile[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) return recentProfiles } async quickConnect (query: string): Promise|null> { for (const provider of this.getProviders()) { if (provider.supportsQuickConnect) { const profile = provider.quickConnect(query) if (profile) { return profile } } } this.notifications.error(`Could not parse "${query}"`) return null } /* * Methods used to interract with Profile/ProfileGroup/Global defaults */ /** * Return global defaults for a given profile provider * Always return something, empty object if no defaults found */ getProviderDefaults (provider: ProfileProvider): any { const defaults = this.config.store.profileDefaults return defaults[provider.id] ?? {} } /** * Set global defaults for a given profile provider */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types setProviderDefaults (provider: ProfileProvider, pdefaults: any): void { this.config.store.profileDefaults[provider.id] = pdefaults } /** * Return defaults for a given profile * Always return something, empty object if no defaults found * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy */ getProfileDefaults (profile: PartialProfile, skipUserDefaults = false): any { const provider = this.providerForProfile(profile) return [ this.profileDefaults, provider?.configDefaults ?? {}, !provider || skipUserDefaults ? {} : this.getProviderDefaults(provider), ] } /* * Methods used to interract with ProfileGroup */ /** * Return an Array of the existing ProfileGroups * arg: includeProfiles (default: false) -> if false, does not fill up the profiles field of ProfileGroup * arg: includeNonUserGroup (default: false) -> if false, does not add built-in and ungrouped groups */ async getProfileGroups (includeProfiles = false, includeNonUserGroup = false): Promise[]> { let profiles: PartialProfile[] = [] if (includeProfiles) { profiles = await this.getProfiles(includeNonUserGroup, true) } const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') let groups: PartialProfileGroup[] = deepClone(this.config.store.groups ?? []) groups = groups.map(x => { x.editable = true x.collapsed = profileGroupCollapsed[x.id] ?? false if (includeProfiles) { x.profiles = profiles.filter(p => p.group === x.id) profiles = profiles.filter(p => p.group !== x.id) } return x }) if (includeNonUserGroup) { const builtIn: PartialProfileGroup = { id: 'built-in', name: this.translate.instant('Built-in'), editable: false, } builtIn.collapsed = profileGroupCollapsed[builtIn.id] ?? false const ungrouped: PartialProfileGroup = { id: 'ungrouped', name: this.translate.instant('Ungrouped'), editable: false, } ungrouped.collapsed = profileGroupCollapsed[ungrouped.id] ?? false if (includeProfiles) { builtIn.profiles = profiles.filter(p => p.isBuiltin) profiles = profiles.filter(p => !p.isBuiltin) ungrouped.profiles = profiles } groups.push(builtIn) groups.push(ungrouped) } return groups } /** * Insert a new ProfileGroup in config * arg: saveConfig (default: true) -> invoke after the Profile was updated * arg: genId (default: true) -> generate uuid in before pushing Profile into config */ async newProfileGroup (group: PartialProfileGroup, saveConfig = true, genId = true): Promise { if (genId) { group.id = `${uuidv4()}` } const cProfileGroup = this.config.store.groups.find(p => p.id === group.id) if (cProfileGroup) { throw new Error(`Cannot insert new ProfileGroup, duplicated Id: ${group.id}`) } this.config.store.groups.push(group) if (saveConfig) { return this.config.save() } } /** * Write a ProfileGroup in config * arg: saveConfig (default: true) -> invoke after the ProfileGroup was updated */ async writeProfileGroup (group: PartialProfileGroup, saveConfig = true): Promise { delete group.profiles delete group.editable delete group.collapsed const cGroup = this.config.store.groups.find(g => g.id === group.id) if (cGroup) { Object.assign(cGroup, group) if (saveConfig) { return this.config.save() } } } /** * Delete a ProfileGroup from config * arg: saveConfig (default: true) -> invoke after the ProfileGroup was deleted */ async deleteProfileGroup (group: PartialProfileGroup, saveConfig = true, deleteProfiles = true): Promise { this.config.store.groups = this.config.store.groups.filter(g => g.id !== group.id) if (deleteProfiles) { await this.deleteBulkProfiles({ group: group.id }, false) } else { for (const profile of this.config.store.profiles.filter(x => x.group === group.id)) { delete profile.group } } if (saveConfig) { return this.config.save() } } /** * Resolve and return ProfileGroup from ProfileGroup ID */ resolveProfileGroupName (groupId: string): string { return this.config.store.groups.find(g => g.id === groupId)?.name ?? '' } /** * Save ProfileGroup collapse state in localStorage */ saveProfileGroupCollapse (group: PartialProfileGroup): void { const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') profileGroupCollapsed[group.id] = group.collapsed window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed) } }