diff --git a/tabby-core/src/directives/fastHtmlBind.directive.ts b/tabby-core/src/directives/fastHtmlBind.directive.ts index 0693b65045..255c27dce1 100644 --- a/tabby-core/src/directives/fastHtmlBind.directive.ts +++ b/tabby-core/src/directives/fastHtmlBind.directive.ts @@ -7,6 +7,7 @@ import { PlatformService } from '../api/platform' }) export class FastHtmlBindDirective implements OnChanges { @Input() fastHtmlBind?: string + private _lastValue?: string constructor ( private el: ElementRef, @@ -14,6 +15,10 @@ export class FastHtmlBindDirective implements OnChanges { ) { } ngOnChanges (): void { + if (this.fastHtmlBind === this._lastValue) { + return + } + this._lastValue = this.fastHtmlBind this.el.nativeElement.innerHTML = this.fastHtmlBind ?? '' for (const link of this.el.nativeElement.querySelectorAll('a')) { link.addEventListener('click', event => { diff --git a/tabby-core/src/services/profiles.service.ts b/tabby-core/src/services/profiles.service.ts index d0b9f0fec2..40388b0799 100644 --- a/tabby-core/src/services/profiles.service.ts +++ b/tabby-core/src/services/profiles.service.ts @@ -14,7 +14,7 @@ import slugify from 'slugify' @Injectable({ providedIn: 'root' }) export class ProfilesService { - private profileDefaults: Profile = { + private profileDefaults = { id: '', type: '', name: '', @@ -26,6 +26,7 @@ export class ProfilesService { weight: 0, isBuiltin: false, isTemplate: false, + terminalColorScheme: null, behaviorOnSessionEnd: 'auto', } @@ -52,8 +53,8 @@ export class ProfilesService { } getDescription

(profile: PartialProfile

): string|null { - profile = this.getConfigProxyForProfile(profile) as PartialProfile

- return this.providerForProfile(profile)?.getDescription(profile) ?? null + const fullProfile = this.getConfigProxyForProfile(profile) + return this.providerForProfile(fullProfile)?.getDescription(fullProfile) ?? null } /* @@ -65,7 +66,7 @@ export class ProfilesService { * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy * arg: skipGroupDefaults -> do not merge parent group provider defaults in ConfigProxy */ - getConfigProxyForProfile

(profile: PartialProfile

, options?: { skipGlobalDefaults?: boolean, skipGroupDefaults?: boolean }): FullyDefined

& ConfigProxy> { + getConfigProxyForProfile (profile: PartialProfile, options?: { skipGlobalDefaults?: boolean, skipGroupDefaults?: boolean }): FullyDefined & ConfigProxy> { const defaults = this.getProfileDefaults(profile, options).reduce(configMerge, {}) return new ConfigProxy(profile, defaults) as any } @@ -213,10 +214,9 @@ export class ProfilesService { const provider = this.providerForProfile(fullProfile) const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined return { - name: profile.name, + ...profile, icon: profile.icon ?? undefined, color: profile.color ?? undefined, - weight: profile.weight, group: this.resolveProfileGroupName(profile.group ?? ''), freeInputEquivalent, description: provider?.getDescription(fullProfile), @@ -510,7 +510,7 @@ export class ProfilesService { * arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy */ getProviderProfileGroupDefaults (groupId: string, provider: ProfileProvider): any { - return this.getSyncProfileGroups().find(g => g.id === groupId)?.defaults?.[provider.id] ?? {} + return (this.config.store.groups ?? []).find(g => g.id === groupId)?.defaults?.[provider.id] ?? {} } } diff --git a/tabby-electron/src/shells/windowsStock.ts b/tabby-electron/src/shells/windowsStock.ts index e2eaafa4f6..d1094a97f5 100644 --- a/tabby-electron/src/shells/windowsStock.ts +++ b/tabby-electron/src/shells/windowsStock.ts @@ -72,22 +72,26 @@ export class WindowsStockShellsProvider extends WindowsBaseShellProvider { } private async getPowerShellPath () { - for (const name of ['pwsh.exe', 'powershell.exe']) { - if (await which(name, { nothrow: true })) { - return name - } - } + // Check well-known paths first to avoid slow PATH scanning via `which` for (const psPath of [ `${process.env.USERPROFILE}\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe`, + `${process.env.ProgramFiles}\\PowerShell\\7\\pwsh.exe`, + `${process.env['ProgramFiles(x86)']}\\PowerShell\\7\\pwsh.exe`, `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, `${process.env.SystemRoot}\\System32\\powershell.exe`, - (process.env.SystemRoot ?? 'C:\\Windows') + '\\powerhshell.exe', ]) { try { await fs.stat(psPath) return psPath } catch { } } + // Fall back to PATH search only if not found in standard locations + for (const name of ['pwsh.exe', 'powershell.exe']) { + const found = await which(name, { nothrow: true }) + if (found) { + return found + } + } return 'powershell.exe' } } diff --git a/tabby-electron/src/sshImporters.ts b/tabby-electron/src/sshImporters.ts index f301bc8f6e..3de2e44ee7 100644 --- a/tabby-electron/src/sshImporters.ts +++ b/tabby-electron/src/sshImporters.ts @@ -354,21 +354,72 @@ async function convertToSSHProfiles (config: SSHConfig): Promise[] | null = null +let _openSSHCacheMtime: number | null = null +let _openSSHCachePromise: Promise[]> | null = null + +interface SSHProfileDiskCache { + mtime: number + profiles: PartialProfile[] +} + @Injectable({ providedIn: 'root' }) export class OpenSSHImporter extends SSHProfileImporter { + private diskCachePath: string + + constructor (electron: ElectronService) { + super() + this.diskCachePath = path.join(electron.app.getPath('userData'), 'ssh-profiles-cache.json') + } + async getProfiles (): Promise[]> { + if (_openSSHCachePromise) { + return _openSSHCachePromise + } const configPath = path.join(process.env.HOME ?? '~', '.ssh', 'config') - try { - const config: SSHConfig = await parseSSHConfigFile(configPath) - return await convertToSSHProfiles(config) - } catch (e) { - if (e.code === 'ENOENT') { - return [] + _openSSHCachePromise = (async () => { + try { + const stat = await fs.stat(configPath) + const mtime = stat.mtimeMs + + if (_openSSHCache && _openSSHCacheMtime === mtime) { + return _openSSHCache + } + + // Try disk cache first + try { + const diskCache: SSHProfileDiskCache = JSON.parse( + await fs.readFile(this.diskCachePath, 'utf8'), + ) + if (diskCache.mtime === mtime) { + _openSSHCache = diskCache.profiles + _openSSHCacheMtime = mtime + return _openSSHCache + } + } catch { /* no cache or invalid */ } + + const config: SSHConfig = await parseSSHConfigFile(configPath) + _openSSHCache = await convertToSSHProfiles(config) + _openSSHCacheMtime = mtime + + // Save to disk cache (fire and forget) + fs.writeFile(this.diskCachePath, JSON.stringify({ mtime, profiles: _openSSHCache })) + .catch(() => { /* ignore write errors */ }) + + return _openSSHCache + } catch (e) { + if (e.code === 'ENOENT') { + return [] + } + throw e + } finally { + _openSSHCachePromise = null } - throw e - } + })() + + return _openSSHCachePromise } } diff --git a/tabby-settings/src/components/profilesSettingsTab.component.ts b/tabby-settings/src/components/profilesSettingsTab.component.ts index e7a810bd14..bff6def9ed 100644 --- a/tabby-settings/src/components/profilesSettingsTab.component.ts +++ b/tabby-settings/src/components/profilesSettingsTab.component.ts @@ -26,6 +26,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent { profileGroups: PartialProfileGroup[] filter = '' Platform = Platform + private descriptionCache = new Map() constructor ( public config: ConfigService, @@ -49,10 +50,17 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } async refreshProfiles (): Promise { - this.builtinProfiles = (await this.profilesService.getProfiles()).filter(x => x.isBuiltin) - this.customProfiles = (await this.profilesService.getProfiles()).filter(x => !x.isBuiltin) - this.templateProfiles = this.builtinProfiles.filter(x => x.isTemplate) - this.builtinProfiles = this.builtinProfiles.filter(x => !x.isTemplate) + const allProfiles = await this.profilesService.getProfiles() + this.builtinProfiles = allProfiles.filter(x => x.isBuiltin && !x.isTemplate) + this.templateProfiles = allProfiles.filter(x => x.isBuiltin && x.isTemplate) + this.customProfiles = allProfiles.filter(x => !x.isBuiltin) + + this.descriptionCache.clear() + for (const p of allProfiles) { + if (p.id) { + this.descriptionCache.set(p.id, this.profilesService.getDescription(p)) + } + } } launchProfile (profile: PartialProfile): void { @@ -265,6 +273,9 @@ export class ProfilesSettingsTabComponent extends BaseComponent { } getDescription (profile: PartialProfile): string|null { + if (profile.id) { + return this.descriptionCache.get(profile.id) ?? null + } return this.profilesService.getDescription(profile) }