Compare commits

..

58 Commits

Author SHA1 Message Date
Eugene
dc9a7d8fac fixed #8886 - crash on the color schemes settings tab 2023-08-29 22:41:03 +02:00
Eugene
0becf8cc76 fixed #8878 - default back to dark mode 2023-08-29 14:16:48 +02:00
Eugene
6042a5290c fixed #8868 - not starting on a fresh install 2023-08-29 13:59:18 +02:00
Eugene
344244297c updated electron 2023-08-28 08:25:58 +02:00
Eugene
75eedafbc9 lint 2023-08-26 10:23:27 +02:00
Eugene
1b0ce6d684 bumped eslint 2023-08-25 23:40:36 +02:00
Eugene
4684b0d6f5 Merge pull request #8726 from Clem-Fern/profiles-rework 2023-08-25 23:36:46 +02:00
Eugene
eddb50b529 fixed delete predicate 2023-08-25 23:04:20 +02:00
Eugene
96eee51590 cleanup saving 2023-08-25 23:03:02 +02:00
Eugene
f963167b70 fixed #8840 - polyfill support for unsafe DH groups 2023-08-25 22:55:51 +02:00
Clem
0128013308 ref(core): profiles.services deleteBulkProfiles filter with predicate function 2023-08-25 16:57:34 +02:00
Clem
5e5c80832d ref(core): profiles.services optionnal options object argument 2023-08-25 16:35:53 +02:00
Clem
06859de2de ref(core): profiles.services config saving should be the caller's responsibility 2023-08-25 15:26:44 +02:00
Clem
49f9a10372 ref: a9c63b5305 bad promise rejection handling 2023-08-25 13:43:43 +02:00
Eugene
85d988f6b3 Merge pull request #8801 from robinfai/memory_optimization 2023-08-20 19:42:05 +02:00
Eugene
7bc549b555 cleanup, delay canvas rescaling 2023-08-20 19:40:28 +02:00
Eugene Pankov
d6bdecb9c4 macOS: dragging window by tab headers no longer possible after electron update 2023-08-20 19:13:25 +02:00
Clem
7687972e65 ref ef6b8a4eaa 2023-08-18 17:18:06 +02:00
Eugene
34786b1459 Merge pull request #8842 from Clem-Fern/fix#8839 2023-08-17 20:53:12 +02:00
Clem
634d88d220 refactoring ad3b03cb83 2023-08-17 19:32:52 +02:00
Clem
ad3b03cb83 feat(core/selector): PageUp/Down toogle at end of filteredOptions 2023-08-17 17:58:41 +02:00
Clem
d354520910 fix(core/selector): avoid selectedIndex to go NaN if filteredOptions empty 2023-08-17 17:34:09 +02:00
Clem Fern
a9c63b5305 fix(selector): avoid error Uncaught (in promise) on modal dismiss (fix Eugeny/tabby#8065) 2023-08-15 19:53:52 +02:00
Clem Fern
d21282501f fix: avoid error Uncaught (in promise) on modal dismiss 2023-08-15 19:53:00 +02:00
Clem
d2752382aa Merge branch 'master' into profiles-rework 2023-08-14 17:08:27 +02:00
Clem
5eeaef954c ref(settings/ssh): migrate and remove deprecated clearServiceMessagesOnConnect ssh option 2023-08-14 15:53:58 +02:00
Clem
ef6b8a4eaa feat(core/settings/serial/ssh/telnet): add clearServiceMessagesOnConnect option on connectable profile 2023-08-14 15:24:41 +02:00
Clem
7be6fca493 Merge branch 'Eugeny:master' into profiles-rework 2023-08-14 14:16:37 +02:00
Clem
21e38c8453 ref(core/settings/serial/ssh/telnet): create ConnectableProfile & ConnectableProfileProvider Eugeny/tabby#8416 2023-08-14 14:14:57 +02:00
Clem
8adb9a6806 Merge branch 'Eugeny:master' into profiles-rework 2023-08-13 18:12:30 +02:00
Clem Fern
f369b140c6 feat(settings): Eugeny/tabby#7265 add a button to reset global & groups settings to defaults 2023-08-12 17:30:35 +02:00
Clem Fern
c108476262 ref(settings): editProfileModal component remove unused ConfigService 2023-08-12 17:08:37 +02:00
Clem Fern
695c5ba670 feat(core/settings): Eugeny/tabby#3999 Allow groups to specify settings that hosts inherit 2023-08-11 23:38:16 +02:00
binghuiluo(罗炳辉)
743ea04d0b revert: useless change 2023-08-08 00:07:18 +08:00
binghuiluo(罗炳辉)
6d0a84c94e perf: avoid gpu memory increase 2023-08-07 22:37:21 +08:00
Clem
0ef24ddf1d wip ref(settings): profilesSettingsTab profile template no launchable 2023-08-05 21:08:09 +02:00
Clem
935c981d2b wip ref(core): getProfileGroups create non editable group for built-in profile 2023-08-05 21:04:10 +02:00
Clem
951c69b31a wip ref(core): resolveProfileGroupName return groupId if no name found 2023-08-04 14:39:06 +02:00
Clem
30936b739e wip fix(core): writeProfile avoid deleting profile fields one by one 2023-08-04 14:21:46 +02:00
Clem
44c449bd4c wip ref(core): move group collapsed status into profileSettingsTab 2023-08-04 14:16:00 +02:00
Clem Fern
1c06a510bd wip ref(core): Profile & ProfileGroup add creation methods in ProfileService 2023-07-23 22:23:57 +02:00
Clem Fern
a0804cc564 wip fix(settings): newProfile unresolve group name 2023-07-23 22:11:01 +02:00
Clem Fern
b751e10082 wip fix(core): deleteProfile & deleteGroupProfile 8e9156e250 48d4b8e8f8 2023-07-23 21:46:36 +02:00
Clem Fern
48d4b8e8f8 wip ref(core): Profile avoid direct config interraction outside of profiles.services 2023-07-23 19:59:45 +02:00
Clem Fern
ef040ee342 wip fix(core): Cannot access 'ProfilesService' before initialization from AppHotkeyProvider 2023-07-23 19:47:38 +02:00
Clem Fern
8e9156e250 wip ref(core): ProfileGroup avoid direct config interraction outside of profiles.services 2023-07-23 18:14:55 +02:00
Clem Fern
1903ec5995 wip ref(settings): move out group managment from settings 2023-07-23 17:18:26 +02:00
Clem Fern
21df033012 lint 2023-07-22 22:36:05 +02:00
Clem Fern
4d146941f4 wip fix(core): getConfigProxyForProfile skipUserDefaults param never used c1e03ed532 2023-07-22 22:31:26 +02:00
Clem Fern
5ba6bfbd7d wip fix(core): getProfileGroups bad builtin profile filtering c1e03ed532 2023-07-22 22:27:02 +02:00
Clem Fern
f0e2482dd6 wip fix(core): group migration c1e03ed532 2023-07-22 22:20:18 +02:00
Clem Fern
5763919d85 wip ref(core/profiles.service): add methods to manage ProfileGroup collapse state 2023-07-22 22:13:43 +02:00
Clem Fern
c1e03ed532 wip ref(core/profiles.service): move out group managment from settings 2023-07-22 21:36:40 +02:00
Clem Fern
8a85fcac21 wip ref(core/profiles.service): add methods to interract with Provider defaults 2023-07-22 20:16:27 +02:00
Clem Fern
ee4487a517 Revert "wip ref(core): update profileDefaults in view of Eugeny/tabby#3999"
This reverts commit 272b9ee5dc.
2023-07-22 19:43:52 +02:00
Clem Fern
272b9ee5dc wip ref(core): update profileDefaults in view of Eugeny/tabby#3999 2023-07-22 16:12:34 +02:00
Clem Fern
d57757c66c fix(core): Eugeny/tabby#8709 sort freeInputPattern by weight 2023-07-20 20:15:36 +02:00
Clem Fern
4dedbbc25a feat[core): Eugeny/tabby#7057 order recent profile 2023-07-20 20:07:55 +02:00
71 changed files with 1628 additions and 739 deletions

View File

@@ -1,7 +1,13 @@
settings: settings:
import/parsers:
'@typescript-eslint/parser': ['.ts']
import/resolver: import/resolver:
typescript: true typescript:
project:
- tsconfig.json
- tabby-*/tsconfig.json
node: true node: true
env: env:
browser: true browser: true
es6: true es6: true
@@ -28,7 +34,7 @@ overrides:
- plugin:import/typescript - plugin:import/typescript
plugins: plugins:
- '@typescript-eslint' - '@typescript-eslint'
- 'import' - import
rules: rules:
'@typescript-eslint/semi': '@typescript-eslint/semi':
- error - error
@@ -130,6 +136,7 @@ overrides:
'@typescript-eslint/naming-convention': off '@typescript-eslint/naming-convention': off
'@typescript-eslint/lines-between-class-members': '@typescript-eslint/lines-between-class-members':
- error - error
- always
- exceptAfterSingleLine: true - exceptAfterSingleLine: true
'@typescript-eslint/dot-notation': off '@typescript-eslint/dot-notation': off
'@typescript-eslint/no-implicit-any-catch': off '@typescript-eslint/no-implicit-any-catch': off
@@ -152,3 +159,6 @@ overrides:
'@typescript-eslint/consistent-generic-constructors': off '@typescript-eslint/consistent-generic-constructors': off
'keyword-spacing': off 'keyword-spacing': off
'@typescript-eslint/keyword-spacing': off '@typescript-eslint/keyword-spacing': off
'@typescript-eslint/class-methods-use-this': off
'@typescript-eslint/lines-around-comment': off
'@typescript-eslint/no-redundant-type-constituents': off # broken

View File

@@ -183,7 +183,7 @@ export class Application {
} }
enableTray (): void { enableTray (): void {
if (this.tray || process.platform === 'linux') { if (!!this.tray || process.platform === 'linux') {
return return
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {

View File

@@ -31,7 +31,7 @@
"npm": "6", "npm": "6",
"rxjs": "^7.5.7", "rxjs": "^7.5.7",
"source-map-support": "^0.5.20", "source-map-support": "^0.5.20",
"v8-compile-cache": "^2.4.0", "v8-compile-cache": "^2.3.0",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@@ -3880,10 +3880,10 @@ uuid@^3.0.1, uuid@^3.3.2, uuid@^3.3.3:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
v8-compile-cache@^2.4.0: v8-compile-cache@^2.3.0:
version "2.4.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
integrity sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw== integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4:
version "3.0.4" version "3.0.4"

View File

@@ -26,8 +26,8 @@
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/node": "20.3.1", "@types/node": "20.3.1",
"@types/webpack-env": "^1.18.0", "@types/webpack-env": "^1.18.0",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^5.54.1", "@typescript-eslint/parser": "^6.4.1",
"apply-loader": "2.0.0", "apply-loader": "2.0.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.2",
@@ -44,9 +44,9 @@
"electron-download": "^4.1.1", "electron-download": "^4.1.1",
"electron-installer-snap": "^5.1.0", "electron-installer-snap": "^5.1.0",
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",
"eslint": "^8.38.0", "eslint": "^8.48.0",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.28.1",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"gettext-extractor": "^3.8.0", "gettext-extractor": "^3.8.0",
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",

View File

@@ -16,7 +16,7 @@ export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
export { HostWindowService } from './hostWindow' export { HostWindowService } from './hostWindow'
export { HostAppService, Platform } from './hostApp' export { HostAppService, Platform } from './hostApp'
export { FileProvider } from './fileProvider' export { FileProvider } from './fileProvider'
export { ProfileProvider, Profile, PartialProfile, ProfileSettingsComponent } from './profileProvider' export { ProfileProvider, ConnectableProfileProvider, QuickConnectProfileProvider, Profile, ConnectableProfile, PartialProfile, ProfileSettingsComponent, ProfileGroup, PartialProfileGroup } from './profileProvider'
export { PromptModalComponent } from '../components/promptModal.component' export { PromptModalComponent } from '../components/promptModal.component'
export * from './commands' export * from './commands'

View File

@@ -1,5 +1,5 @@
export interface MenuItemOptions { export interface MenuItemOptions {
type?: ('normal' | 'separator' | 'submenu' | 'checkbox' | 'radio') type?: 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'
label?: string label?: string
sublabel?: string sublabel?: string
enabled?: boolean enabled?: boolean

View File

@@ -21,6 +21,10 @@ export interface Profile {
isTemplate: boolean isTemplate: boolean
} }
export interface ConnectableProfile extends Profile {
clearServiceMessagesOnConnect: boolean
}
export type PartialProfile<T extends Profile> = Omit<Omit<Omit<{ export type PartialProfile<T extends Profile> = Omit<Omit<Omit<{
[K in keyof T]?: T[K] [K in keyof T]?: T[K]
}, 'options'>, 'type'>, 'name'> & { }, 'options'>, 'type'>, 'name'> & {
@@ -31,6 +35,21 @@ export type PartialProfile<T extends Profile> = Omit<Omit<Omit<{
} }
} }
export interface ProfileGroup {
id: string
name: string
profiles: PartialProfile<Profile>[]
defaults: any
editable: boolean
}
export type PartialProfileGroup<T extends ProfileGroup> = Omit<Omit<{
[K in keyof T]?: T[K]
}, 'id'>, 'name'> & {
id: string
name: string
}
export interface ProfileSettingsComponent<P extends Profile> { export interface ProfileSettingsComponent<P extends Profile> {
profile: P profile: P
save?: () => void save?: () => void
@@ -39,7 +58,6 @@ export interface ProfileSettingsComponent<P extends Profile> {
export abstract class ProfileProvider<P extends Profile> { export abstract class ProfileProvider<P extends Profile> {
id: string id: string
name: string name: string
supportsQuickConnect = false
settingsComponent?: new (...args: any[]) => ProfileSettingsComponent<P> settingsComponent?: new (...args: any[]) => ProfileSettingsComponent<P>
configDefaults = {} configDefaults = {}
@@ -53,13 +71,15 @@ export abstract class ProfileProvider<P extends Profile> {
abstract getDescription (profile: PartialProfile<P>): string abstract getDescription (profile: PartialProfile<P>): string
quickConnect (query: string): PartialProfile<P>|null {
return null
}
intoQuickConnectString (profile: P): string|null {
return null
}
deleteProfile (profile: P): void { } deleteProfile (profile: P): void { }
} }
export abstract class ConnectableProfileProvider<P extends ConnectableProfile> extends ProfileProvider<P> {}
export abstract class QuickConnectProfileProvider<P extends ConnectableProfile> extends ConnectableProfileProvider<P> {
abstract quickConnect (query: string): PartialProfile<P>|null
abstract intoQuickConnectString (profile: P): string|null
}

View File

@@ -18,7 +18,7 @@ export class CoreCommandProvider extends CommandProvider {
} }
async activate () { async activate () {
const profile = await this.profilesService.showProfileSelector() const profile = await this.profilesService.showProfileSelector().catch(() => null)
if (profile) { if (profile) {
this.profilesService.launchProfile(profile) this.profilesService.launchProfile(profile)
} }

View File

@@ -35,8 +35,7 @@ title-bar(
[@animateTab]='{value: "in", params: {size: targetTabSize}}', [@animateTab]='{value: "in", params: {size: targetTabSize}}',
[@.disabled]='hasVerticalTabs() || !config.store.accessibility.animations', [@.disabled]='hasVerticalTabs() || !config.store.accessibility.animations',
(click)='app.selectTab(tab)', (click)='app.selectTab(tab)',
[class.fully-draggable]='hostApp.platform != Platform.macOS', [class.fully-draggable]='hostApp.platform != Platform.macOS'
[class.drag-region]='hostApp.platform == Platform.macOS && !(app.tabDragActive$|async)',
) )
.btn-group.background .btn-group.background
@@ -65,7 +64,7 @@ title-bar(
(transfersChange)='onTransfersChange()' (transfersChange)='onTransfersChange()'
) )
.drag-space.background([class.persistent]='config.store.appearance.frame == "thin" && hostApp.platform != Platform.macOS') .drag-space.background([class.persistent]='config.store.appearance.frame == "thin"')
.btn-group.background .btn-group.background
.d-flex( .d-flex(

View File

@@ -75,6 +75,7 @@ export abstract class BaseTabComponent extends BaseComponent {
private titleChange = new Subject<string>() private titleChange = new Subject<string>()
private focused = new Subject<void>() private focused = new Subject<void>()
private blurred = new Subject<void>() private blurred = new Subject<void>()
private visibility = new Subject<boolean>()
private progress = new Subject<number|null>() private progress = new Subject<number|null>()
private activity = new Subject<boolean>() private activity = new Subject<boolean>()
private destroyed = new Subject<void>() private destroyed = new Subject<void>()
@@ -83,6 +84,8 @@ export abstract class BaseTabComponent extends BaseComponent {
get focused$ (): Observable<void> { return this.focused } get focused$ (): Observable<void> { return this.focused }
get blurred$ (): Observable<void> { return this.blurred } get blurred$ (): Observable<void> { return this.blurred }
/* @hidden */
get visibility$ (): Observable<boolean> { return this.visibility }
get titleChange$ (): Observable<string> { return this.titleChange.pipe(distinctUntilChanged()) } get titleChange$ (): Observable<string> { return this.titleChange.pipe(distinctUntilChanged()) }
get progress$ (): Observable<number|null> { return this.progress.pipe(distinctUntilChanged()) } get progress$ (): Observable<number|null> { return this.progress.pipe(distinctUntilChanged()) }
get activity$ (): Observable<boolean> { return this.activity } get activity$ (): Observable<boolean> { return this.activity }
@@ -177,6 +180,11 @@ export abstract class BaseTabComponent extends BaseComponent {
this.blurred.next() this.blurred.next()
} }
/* @hidden */
emitVisibility (visibility: boolean): void {
this.visibility.next(visibility)
}
insertIntoContainer (container: ViewContainerRef): EmbeddedViewRef<any> { insertIntoContainer (container: ViewContainerRef): EmbeddedViewRef<any> {
this.viewContainerEmbeddedRef = container.insert(this.hostView) as EmbeddedViewRef<any> this.viewContainerEmbeddedRef = container.insert(this.hostView) as EmbeddedViewRef<any>
this.viewContainer = container this.viewContainer = container

View File

@@ -29,11 +29,14 @@ export class SelectorModalComponent<T> {
} }
@HostListener('keydown', ['$event']) onKeyUp (event: KeyboardEvent): void { @HostListener('keydown', ['$event']) onKeyUp (event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.close()
} else if (this.filteredOptions.length > 0) {
if (event.key === 'PageUp' || event.key === 'ArrowUp' && event.metaKey) { if (event.key === 'PageUp' || event.key === 'ArrowUp' && event.metaKey) {
this.selectedIndex -= 10 this.selectedIndex -= Math.min(10, Math.max(1, this.selectedIndex))
event.preventDefault() event.preventDefault()
} else if (event.key === 'PageDown' || event.key === 'ArrowDown' && event.metaKey) { } else if (event.key === 'PageDown' || event.key === 'ArrowDown' && event.metaKey) {
this.selectedIndex += 10 this.selectedIndex += Math.min(10, Math.max(1, this.filteredOptions.length - this.selectedIndex - 1))
event.preventDefault() event.preventDefault()
} else if (event.key === 'ArrowUp') { } else if (event.key === 'ArrowUp') {
this.selectedIndex-- this.selectedIndex--
@@ -43,21 +46,20 @@ export class SelectorModalComponent<T> {
event.preventDefault() event.preventDefault()
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
this.selectOption(this.filteredOptions[this.selectedIndex]) this.selectOption(this.filteredOptions[this.selectedIndex])
} else if (event.key === 'Escape') { } else if (event.key === 'Backspace' && this.canEditSelected()) {
this.close()
}
if (event.key === 'Backspace' && this.canEditSelected()) {
event.preventDefault() event.preventDefault()
this.filter = this.filteredOptions[this.selectedIndex].freeInputEquivalent! this.filter = this.filteredOptions[this.selectedIndex].freeInputEquivalent!
this.onFilterChange() this.onFilterChange()
} }
this.selectedIndex = (this.selectedIndex + this.filteredOptions.length) % this.filteredOptions.length this.selectedIndex = (this.selectedIndex + this.filteredOptions.length) % this.filteredOptions.length
Array.from(this.itemChildren)[this.selectedIndex]?.nativeElement.scrollIntoView({ Array.from(this.itemChildren)[this.selectedIndex]?.nativeElement.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'nearest', block: 'nearest',
}) })
} }
}
onFilterChange (): void { onFilterChange (): void {
const f = this.filter.trim().toLowerCase() const f = this.filter.trim().toLowerCase()
@@ -76,7 +78,7 @@ export class SelectorModalComponent<T> {
{ sort: true }, { sort: true },
).search(f) ).search(f)
this.options.filter(x => x.freeInputPattern).forEach(freeOption => { this.options.filter(x => x.freeInputPattern).sort(firstBy<SelectorOption<T>, number>(x => x.weight ?? 0)).forEach(freeOption => {
if (!this.filteredOptions.includes(freeOption)) { if (!this.filteredOptions.includes(freeOption)) {
this.filteredOptions.push(freeOption) this.filteredOptions.push(freeOption)
} }

View File

@@ -275,6 +275,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit
} }
}) })
this.blurred$.subscribe(() => this.getAllTabs().forEach(x => x.emitBlurred())) this.blurred$.subscribe(() => this.getAllTabs().forEach(x => x.emitBlurred()))
this.visibility$.subscribe(visibility => this.getAllTabs().forEach(x => x.emitVisibility(visibility)))
this.tabAdded$.subscribe(() => this.updateTitle()) this.tabAdded$.subscribe(() => this.updateTitle())
this.tabRemoved$.subscribe(() => this.updateTitle()) this.tabRemoved$.subscribe(() => this.updateTitle())

View File

@@ -1,10 +1,10 @@
.container.mt-5.mb-5 .container.mt-3.mb-3
.mb-4 .mb-3
.tabby-logo .tabby-logo
h1.tabby-title Tabby h1.tabby-title Tabby
sup α sup α
.text-center.mb-5(translate) Thank you for downloading Tabby! .text-center.mb-3(translate) Thank you for downloading Tabby!
.form-line .form-line
.header .header
@@ -16,13 +16,54 @@
*ngFor='let lang of allLanguages' *ngFor='let lang of allLanguages'
) {{lang.name}} ) {{lang.name}}
.form-line
.header
.title(translate) Switch color scheme
.btn-group(role='group')
input.btn-check(
type='radio',
name='colorSchemeMode',
[(ngModel)]='config.store.appearance.colorSchemeMode',
(ngModelChange)='config.save()',
id='colorSchemeModeAuto',
[value]='"auto"'
)
label.btn.btn-secondary(
for='colorSchemeModeAuto'
)
span(translate) From system
input.btn-check(
type='radio',
name='colorSchemeMode',
[(ngModel)]='config.store.appearance.colorSchemeMode',
(ngModelChange)='config.save()',
id='colorSchemeModeDark',
[value]='"dark"'
)
label.btn.btn-secondary(
for='colorSchemeModeDark'
)
span(translate) Always dark
input.btn-check(
type='radio',
name='colorSchemeMode',
[(ngModel)]='config.store.appearance.colorSchemeMode',
(ngModelChange)='config.save()',
id='colorSchemeModeLight',
[value]='"light"'
)
label.btn.btn-secondary(
for='colorSchemeModeLight'
)
span(translate) Always light
.form-line .form-line
.header .header
.title(translate) Enable analytics .title(translate) Enable analytics
.description(translate) Help track the number of Tabby installs across the world! .description(translate) Help track the number of Tabby installs across the world!
toggle([(ngModel)]='config.store.enableAnalytics') toggle([(ngModel)]='config.store.enableAnalytics')
.form-line .form-line
.header .header
.title(translate) Enable global hotkey (Ctrl-Space) .title(translate) Enable global hotkey (Ctrl-Space)

View File

@@ -6,3 +6,8 @@
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
} }
.tabby-logo {
width: 60px;
height: 60px;
}

View File

@@ -9,5 +9,6 @@ export class CoreConfigProvider extends ConfigProvider {
[Platform.Linux]: require('./configDefaults.linux.yaml').default, [Platform.Linux]: require('./configDefaults.linux.yaml').default,
[Platform.Web]: require('./configDefaults.web.yaml').default, [Platform.Web]: require('./configDefaults.web.yaml').default,
} }
defaults = require('./configDefaults.yaml').default defaults = require('./configDefaults.yaml').default
} }

View File

@@ -96,5 +96,3 @@ hotkeys:
- '⌘-Shift-E' - '⌘-Shift-E'
command-selector: command-selector:
- '⌘-Shift-P' - '⌘-Shift-P'
appearance:
vibrancy: true

View File

@@ -19,6 +19,7 @@ appearance:
vibrancyType: 'blur' vibrancyType: 'blur'
lastTabClosesWindow: false lastTabClosesWindow: false
spaciness: 1 spaciness: 1
colorSchemeMode: 'dark'
terminal: terminal:
showBuiltinProfiles: true showBuiltinProfiles: true
showRecentProfiles: 3 showRecentProfiles: 3
@@ -31,6 +32,7 @@ hotkeys:
profile-selectors: profile-selectors:
__nonStructural: true __nonStructural: true
profiles: [] profiles: []
groups: []
profileDefaults: profileDefaults:
__nonStructural: true __nonStructural: true
ssh: ssh:

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'
import { TranslateService } from '@ngx-translate/core' import { TranslateService } from '@ngx-translate/core'
import { ProfilesService } from './services/profiles.service' import { ProfilesService } from './services/profiles.service'
import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider' import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider'
import { PartialProfile, Profile } from './api'
/** @hidden */ /** @hidden */
@Injectable() @Injectable()
@@ -268,7 +267,7 @@ export class AppHotkeyProvider extends HotkeyProvider {
return [ return [
...this.hotkeys, ...this.hotkeys,
...profiles.map(profile => ({ ...profiles.map(profile => ({
id: `profile.${AppHotkeyProvider.getProfileHotkeyName(profile)}`, id: `profile.${ProfilesService.getProfileHotkeyName(profile)}`,
name: this.translate.instant('New tab: {profile}', { profile: profile.name }), name: this.translate.instant('New tab: {profile}', { profile: profile.name }),
})), })),
...this.profilesService.getProviders().map(provider => ({ ...this.profilesService.getProviders().map(provider => ({
@@ -278,7 +277,4 @@ export class AppHotkeyProvider extends HotkeyProvider {
] ]
} }
static getProfileHotkeyName (profile: PartialProfile<Profile>): string {
return (profile.id ?? profile.name).replace(/\./g, '-')
}
} }

View File

@@ -37,7 +37,7 @@ import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
import { DropZoneDirective } from './directives/dropZone.directive' import { DropZoneDirective } from './directives/dropZone.directive'
import { CdkAutoDropGroup } from './directives/cdkAutoDropGroup.directive' import { CdkAutoDropGroup } from './directives/cdkAutoDropGroup.directive'
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api' import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ProfilesService, ProfileProvider, QuickConnectProfileProvider, SelectorOption, Profile, SelectorService, CommandProvider } from './api'
import { AppService } from './services/app.service' import { AppService } from './services/app.service'
import { ConfigService } from './services/config.service' import { ConfigService } from './services/config.service'
@@ -177,7 +177,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex
if (hotkey.startsWith('profile.')) { if (hotkey.startsWith('profile.')) {
const id = hotkey.substring(hotkey.indexOf('.') + 1) const id = hotkey.substring(hotkey.indexOf('.') + 1)
const profiles = await profilesService.getProfiles() const profiles = await profilesService.getProfiles()
const profile = profiles.find(x => AppHotkeyProvider.getProfileHotkeyName(x) === id) const profile = profiles.find(x => ProfilesService.getProfileHotkeyName(x) === id)
if (profile) { if (profile) {
profilesService.openNewTabForProfile(profile) profilesService.openNewTabForProfile(profile)
} }
@@ -188,10 +188,10 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex
if (!provider) { if (!provider) {
return return
} }
this.showSelector(provider) this.showSelector(provider).catch(() => null)
} }
if (hotkey === 'command-selector') { if (hotkey === 'command-selector') {
commands.showSelector() commands.showSelector().catch(() => null)
} }
if (hotkey === 'profile-selector') { if (hotkey === 'profile-selector') {
@@ -214,7 +214,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex
callback: () => this.profilesService.openNewTabForProfile(p), callback: () => this.profilesService.openNewTabForProfile(p),
})) }))
if (provider.supportsQuickConnect) { if (provider instanceof QuickConnectProfileProvider) {
options.push({ options.push({
name: this.translate.instant('Quick connect'), name: this.translate.instant('Quick connect'),
freeInputPattern: this.translate.instant('Connect to "%s"...'), freeInputPattern: this.translate.instant('Connect to "%s"...'),

View File

@@ -230,11 +230,13 @@ export class AppService {
if (this._activeTab) { if (this._activeTab) {
this._activeTab.clearActivity() this._activeTab.clearActivity()
this._activeTab.emitBlurred() this._activeTab.emitBlurred()
this._activeTab.emitVisibility(false)
} }
this._activeTab = tab this._activeTab = tab
this.activeTabChange.next(tab) this.activeTabChange.next(tab)
setImmediate(() => { setImmediate(() => {
this._activeTab?.emitFocused() this._activeTab?.emitFocused()
this._activeTab?.emitVisibility(true)
}) })
this.hostWindow.setTitle(this._activeTab?.title) this.hostWindow.setTitle(this._activeTab?.title)
} }

View File

@@ -101,7 +101,7 @@ export class CommandService {
context.tab = tab.getFocusedTab() ?? undefined context.tab = tab.getFocusedTab() ?? undefined
} }
const commands = await this.getCommands(context) const commands = await this.getCommands(context)
await this.selector.show( return this.selector.show(
this.translate.instant('Commands'), this.translate.instant('Commands'),
commands.map(c => ({ commands.map(c => ({
name: c.label, name: c.label,

View File

@@ -10,6 +10,7 @@ import { PlatformService } from '../api/platform'
import { HostAppService } from '../api/hostApp' import { HostAppService } from '../api/hostApp'
import { Vault, VaultService } from './vault.service' import { Vault, VaultService } from './vault.service'
import { serializeFunction } from '../utils' import { serializeFunction } from '../utils'
import { PartialProfileGroup, ProfileGroup } from '../api/profileProvider'
const deepmerge = require('deepmerge') const deepmerge = require('deepmerge')
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -364,6 +365,47 @@ export class ConfigService {
} }
config.version = 4 config.version = 4
} }
if (config.version < 5) {
const groups: PartialProfileGroup<ProfileGroup>[] = []
for (const p of config.profiles ?? []) {
if (!(p.group ?? '').trim()) {
continue
}
let group = groups.find(x => x.name === p.group)
if (!group) {
group = {
id: `${uuidv4()}`,
name: `${p.group}`,
}
groups.push(group)
}
p.group = group.id
}
const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
for (const g of groups) {
if (profileGroupCollapsed[g.name]) {
const collapsed = profileGroupCollapsed[g.name]
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete profileGroupCollapsed[g.name]
profileGroupCollapsed[g.id] = collapsed
}
}
window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed)
config.groups = groups
config.version = 5
}
if (config.version < 6) {
if (config.ssh?.clearServiceMessagesOnConnect === false) {
config.profileDefaults ??= {}
config.profileDefaults.ssh ??= {}
config.profileDefaults.ssh.clearServiceMessagesOnConnect = false
delete config.ssh?.clearServiceMessagesOnConnect
}
config.version = 6
}
} }
private async maybeDecryptConfig (store) { private async maybeDecryptConfig (store) {

View File

@@ -13,8 +13,9 @@ export class FileProvidersService {
) { } ) { }
async selectAndStoreFile (description: string): Promise<string> { async selectAndStoreFile (description: string): Promise<string> {
const p = await this.selectProvider() return this.selectProvider().then(p => {
return p.selectAndStoreFile(description) return p.selectAndStoreFile(description)
})
} }
async retrieveFile (key: string): Promise<Buffer> { async retrieveFile (key: string): Promise<Buffer> {

View File

@@ -2,12 +2,15 @@ import { Injectable, Inject } from '@angular/core'
import { TranslateService } from '@ngx-translate/core' import { TranslateService } from '@ngx-translate/core'
import { NewTabParameters } from './tabs.service' import { NewTabParameters } from './tabs.service'
import { BaseTabComponent } from '../components/baseTab.component' import { BaseTabComponent } from '../components/baseTab.component'
import { PartialProfile, Profile, ProfileProvider } from '../api/profileProvider' import { QuickConnectProfileProvider, PartialProfile, PartialProfileGroup, Profile, ProfileGroup, ProfileProvider } from '../api/profileProvider'
import { SelectorOption } from '../api/selector' import { SelectorOption } from '../api/selector'
import { AppService } from './app.service' import { AppService } from './app.service'
import { configMerge, ConfigProxy, ConfigService } from './config.service' import { configMerge, ConfigProxy, ConfigService } from './config.service'
import { NotificationsService } from './notifications.service' import { NotificationsService } from './notifications.service'
import { SelectorService } from './selector.service' import { SelectorService } from './selector.service'
import deepClone from 'clone-deep'
import { v4 as uuidv4 } from 'uuid'
import slugify from 'slugify'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ProfilesService { export class ProfilesService {
@@ -36,6 +39,126 @@ export class ProfilesService {
@Inject(ProfileProvider) private profileProviders: ProfileProvider<Profile>[], @Inject(ProfileProvider) private profileProviders: ProfileProvider<Profile>[],
) { } ) { }
/*
* Methods used to interract with ProfileProvider
*/
getProviders (): ProfileProvider<Profile>[] {
return [...this.profileProviders]
}
providerForProfile <T extends Profile> (profile: PartialProfile<T>): ProfileProvider<T>|null {
const provider = this.profileProviders.find(x => x.id === profile.type) ?? null
return provider as unknown as ProfileProvider<T>|null
}
getDescription <P extends Profile> (profile: PartialProfile<P>): 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
* arg: skipGroupDefaults -> do not merge parent group provider defaults in ConfigProxy
*/
getConfigProxyForProfile <T extends Profile> (profile: PartialProfile<T>, options?: { skipGlobalDefaults?: boolean, skipGroupDefaults?: boolean }): T {
const defaults = this.getProfileDefaults(profile, options).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 (options?: { includeBuiltin?: boolean, clone?: boolean }): Promise<PartialProfile<Profile>[]> {
let list = this.config.store.profiles ?? []
if (options?.includeBuiltin ?? true) {
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 options?.clone ? deepClone(list) : list
}
/**
* Insert a new Profile in config
* arg: genId (default: true) -> generate uuid in before pushing Profile into config
*/
async newProfile (profile: PartialProfile<Profile>, options?: { genId?: boolean }): Promise<void> {
if (options?.genId ?? true) {
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)
}
/**
* Write a Profile in config
*/
async writeProfile (profile: PartialProfile<Profile>): Promise<void> {
const cProfile = this.config.store.profiles.find(p => p.id === profile.id)
if (cProfile) {
if (!profile.group) {
delete cProfile.group
}
Object.assign(cProfile, profile)
}
}
/**
* Delete a Profile from config
*/
async deleteProfile (profile: PartialProfile<Profile>): Promise<void> {
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
}
}
/**
* Delete all Profiles from config using option filter
* arg: filter (p: PartialProfile<Profile>) => boolean -> predicate used to decide which profiles have to be deleted
*/
async bulkDeleteProfiles (filter: (p: PartialProfile<Profile>) => boolean): Promise<void> {
for (const profile of this.config.store.profiles.filter(filter)) {
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(x => !filter(x))
}
async openNewTabForProfile <P extends Profile> (profile: PartialProfile<P>): Promise<BaseTabComponent|null> { async openNewTabForProfile <P extends Profile> (profile: PartialProfile<P>): Promise<BaseTabComponent|null> {
const params = await this.newTabParametersForProfile(profile) const params = await this.newTabParametersForProfile(profile)
if (params) { if (params) {
@@ -63,52 +186,40 @@ export class ProfilesService {
return params return params
} }
getProviders (): ProfileProvider<Profile>[] { async launchProfile (profile: PartialProfile<Profile>): Promise<void> {
return [...this.profileProviders] await this.openNewTabForProfile(profile)
let recentProfiles: PartialProfile<Profile>[] = 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)
} }
async getProfiles (): Promise<PartialProfile<Profile>[]> { static getProfileHotkeyName (profile: PartialProfile<Profile>): string {
const lists = await Promise.all(this.config.enabledServices(this.profileProviders).map(x => x.getBuiltinProfiles())) return (profile.id ?? profile.name).replace(/\./g, '-')
let list = lists.reduce((a, b) => a.concat(b), [])
list = [
...this.config.store.profiles ?? [],
...list,
]
const sortKey = p => `${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 list
} }
providerForProfile <T extends Profile> (profile: PartialProfile<T>): ProfileProvider<T>|null { /*
const provider = this.profileProviders.find(x => x.id === profile.type) ?? null * Methods used to interract with Profile Selector
return provider as unknown as ProfileProvider<T>|null */
}
getDescription <P extends Profile> (profile: PartialProfile<P>): string|null {
profile = this.getConfigProxyForProfile(profile)
return this.providerForProfile(profile)?.getDescription(profile) ?? null
}
selectorOptionForProfile <P extends Profile, T> (profile: PartialProfile<P>): SelectorOption<T> { selectorOptionForProfile <P extends Profile, T> (profile: PartialProfile<P>): SelectorOption<T> {
const fullProfile = this.getConfigProxyForProfile(profile) const fullProfile = this.getConfigProxyForProfile(profile)
const provider = this.providerForProfile(fullProfile) const provider = this.providerForProfile(fullProfile)
const freeInputEquivalent = provider?.intoQuickConnectString(fullProfile) ?? undefined const freeInputEquivalent = provider instanceof QuickConnectProfileProvider ? provider.intoQuickConnectString(fullProfile) ?? undefined : undefined
return { return {
...profile, ...profile,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing group: this.resolveProfileGroupName(profile.group ?? ''),
group: profile.group || '',
freeInputEquivalent, freeInputEquivalent,
description: provider?.getDescription(fullProfile), description: provider?.getDescription(fullProfile),
} }
} }
getRecentProfiles (): PartialProfile<Profile>[] {
let recentProfiles: PartialProfile<Profile>[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]')
recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles)
return recentProfiles
}
showProfileSelector (): Promise<PartialProfile<Profile>|null> { showProfileSelector (): Promise<PartialProfile<Profile>|null> {
if (this.selector.active) { if (this.selector.active) {
return Promise.resolve(null) return Promise.resolve(null)
@@ -118,12 +229,12 @@ export class ProfilesService {
try { try {
const recentProfiles = this.getRecentProfiles() const recentProfiles = this.getRecentProfiles()
let options: SelectorOption<void>[] = recentProfiles.map(p => ({ let options: SelectorOption<void>[] = recentProfiles.map((p, i) => ({
...this.selectorOptionForProfile(p), ...this.selectorOptionForProfile(p),
group: this.translate.instant('Recent'), group: this.translate.instant('Recent'),
icon: 'fas fa-history', icon: 'fas fa-history',
color: p.color, color: p.color,
weight: -2, weight: i - (recentProfiles.length + 1),
callback: async () => { callback: async () => {
if (p.id) { if (p.id) {
p = (await this.getProfiles()).find(x => x.id === p.id) ?? p p = (await this.getProfiles()).find(x => x.id === p.id) ?? p
@@ -177,7 +288,8 @@ export class ProfilesService {
}) })
} catch { } } catch { }
this.getProviders().filter(x => x.supportsQuickConnect).forEach(provider => { this.getProviders().forEach(provider => {
if (provider instanceof QuickConnectProfileProvider) {
options.push({ options.push({
name: this.translate.instant('Quick connect'), name: this.translate.instant('Quick connect'),
freeInputPattern: this.translate.instant('Connect to "%s"...'), freeInputPattern: this.translate.instant('Connect to "%s"...'),
@@ -189,18 +301,25 @@ export class ProfilesService {
resolve(profile) resolve(profile)
}, },
}) })
}
}) })
await this.selector.show(this.translate.instant('Select profile or enter an address'), options) await this.selector.show(this.translate.instant('Select profile or enter an address'), options).catch(() => reject())
} catch (err) { } catch (err) {
reject(err) reject(err)
} }
}) })
} }
getRecentProfiles (): PartialProfile<Profile>[] {
let recentProfiles: PartialProfile<Profile>[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]')
recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles)
return recentProfiles
}
async quickConnect (query: string): Promise<PartialProfile<Profile>|null> { async quickConnect (query: string): Promise<PartialProfile<Profile>|null> {
for (const provider of this.getProviders()) { for (const provider of this.getProviders()) {
if (provider.supportsQuickConnect) { if (provider instanceof QuickConnectProfileProvider) {
const profile = provider.quickConnect(query) const profile = provider.quickConnect(query)
if (profile) { if (profile) {
return profile return profile
@@ -211,27 +330,178 @@ export class ProfilesService {
return null return null
} }
getConfigProxyForProfile <T extends Profile> (profile: PartialProfile<T>, skipUserDefaults = false): T { /*
* 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<Profile>): 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<Profile>, 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
* arg: skipGroupDefaults -> do not merge parent group provider defaults in ConfigProxy
*/
getProfileDefaults (profile: PartialProfile<Profile>, options?: { skipGlobalDefaults?: boolean, skipGroupDefaults?: boolean }): any[] {
const provider = this.providerForProfile(profile) const provider = this.providerForProfile(profile)
const defaults = [
return [
this.profileDefaults, this.profileDefaults,
provider?.configDefaults ?? {}, provider?.configDefaults ?? {},
!provider || skipUserDefaults ? {} : this.config.store.profileDefaults[provider.id] ?? {}, provider && !options?.skipGlobalDefaults ? this.getProviderDefaults(provider) : {},
].reduce(configMerge, {}) provider && !options?.skipGlobalDefaults && !options?.skipGroupDefaults ? this.getProviderProfileGroupDefaults(profile.group ?? '', provider) : {},
return new ConfigProxy(profile, defaults) as unknown as T ]
} }
async launchProfile (profile: PartialProfile<Profile>): Promise<void> { /*
await this.openNewTabForProfile(profile) * Methods used to interract with ProfileGroup
*/
let recentProfiles: PartialProfile<Profile>[] = JSON.parse(window.localStorage['recentProfiles'] ?? '[]') /**
if (this.config.store.terminal.showRecentProfiles > 0) { * Synchronously return an Array of the existing ProfileGroups
recentProfiles = recentProfiles.filter(x => x.group !== profile.group || x.name !== profile.name) * Does not return builtin groups
recentProfiles.unshift(profile) */
recentProfiles = recentProfiles.slice(0, this.config.store.terminal.showRecentProfiles) getSyncProfileGroups (): PartialProfileGroup<ProfileGroup>[] {
return deepClone(this.config.store.groups ?? [])
}
/**
* 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 (options?: { includeProfiles?: boolean, includeNonUserGroup?: boolean }): Promise<PartialProfileGroup<ProfileGroup>[]> {
let profiles: PartialProfile<Profile>[] = []
if (options?.includeProfiles) {
profiles = await this.getProfiles({ includeBuiltin: options.includeNonUserGroup, clone: true })
}
let groups: PartialProfileGroup<ProfileGroup>[] = this.getSyncProfileGroups()
groups = groups.map(x => {
x.editable = true
if (options?.includeProfiles) {
x.profiles = profiles.filter(p => p.group === x.id)
profiles = profiles.filter(p => p.group !== x.id)
}
return x
})
if (options?.includeNonUserGroup) {
const builtInGroups: PartialProfileGroup<ProfileGroup>[] = []
builtInGroups.push({
id: 'built-in',
name: this.translate.instant('Built-in'),
editable: false,
profiles: [],
})
const ungrouped: PartialProfileGroup<ProfileGroup> = {
id: 'ungrouped',
name: this.translate.instant('Ungrouped'),
editable: false,
}
if (options.includeProfiles) {
for (const profile of profiles.filter(p => p.isBuiltin)) {
let group: PartialProfileGroup<ProfileGroup> | undefined = builtInGroups.find(g => g.id === slugify(profile.group ?? 'built-in'))
if (!group) {
group = {
id: `${slugify(profile.group!)}`,
name: `${profile.group!}`,
editable: false,
profiles: [],
}
builtInGroups.push(group)
}
group.profiles!.push(profile)
}
ungrouped.profiles = profiles.filter(p => !p.isBuiltin)
}
groups = groups.concat(builtInGroups)
groups.push(ungrouped)
}
return groups
}
/**
* Insert a new ProfileGroup in config
* arg: genId (default: true) -> generate uuid in before pushing Profile into config
*/
async newProfileGroup (group: PartialProfileGroup<ProfileGroup>, options?: { genId?: boolean }): Promise<void> {
if (options?.genId ?? true) {
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)
}
/**
* Write a ProfileGroup in config
*/
async writeProfileGroup (group: PartialProfileGroup<ProfileGroup>): Promise<void> {
delete group.profiles
delete group.editable
const cGroup = this.config.store.groups.find(g => g.id === group.id)
if (cGroup) {
Object.assign(cGroup, group)
}
}
/**
* Delete a ProfileGroup from config
*/
async deleteProfileGroup (group: PartialProfileGroup<ProfileGroup>, options?: { deleteProfiles?: boolean }): Promise<void> {
this.config.store.groups = this.config.store.groups.filter(g => g.id !== group.id)
if (options?.deleteProfiles) {
await this.bulkDeleteProfiles((p) => p.group === group.id)
} else { } else {
recentProfiles = [] for (const profile of this.config.store.profiles.filter(x => x.group === group.id)) {
delete profile.group
} }
window.localStorage['recentProfiles'] = JSON.stringify(recentProfiles)
} }
}
/**
* Resolve and return ProfileGroup Name from ProfileGroup ID
*/
resolveProfileGroupName (groupId: string): string {
return this.config.store.groups.find(g => g.id === groupId)?.name ?? groupId
}
/**
* Return defaults for a given group ID and provider
* Always return something, empty object if no defaults found
* arg: skipUserDefaults -> do not merge global provider defaults in ConfigProxy
*/
getProviderProfileGroupDefaults (groupId: string, provider: ProfileProvider<Profile>): any {
return this.getSyncProfileGroups().find(g => g.id === groupId)?.defaults?.[provider.id] ?? {}
}
} }

View File

@@ -3,7 +3,7 @@ import { Subject, Observable } from 'rxjs'
import * as Color from 'color' import * as Color from 'color'
import { ConfigService } from '../services/config.service' import { ConfigService } from '../services/config.service'
import { Theme } from '../api/theme' import { Theme } from '../api/theme'
import { PlatformService } from '../api/platform' import { PlatformService, PlatformTheme } from '../api/platform'
import { NewTheme } from '../theme' import { NewTheme } from '../theme'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -194,7 +194,14 @@ export class ThemesService {
/// @hidden /// @hidden
_getActiveColorScheme (): any { _getActiveColorScheme (): any {
if (this.platform.getTheme() === 'light') { let theme: PlatformTheme = 'dark'
if (this.config.store.appearance.colorSchemeMode === 'light') {
theme = 'light'
} else if (this.config.store.appearance.colorSchemeMode === 'auto') {
theme = this.platform.getTheme()
}
if (theme === 'light') {
return this.config.store.terminal.lightColorScheme return this.config.store.terminal.lightColorScheme
} else { } else {
return this.config.store.terminal.colorScheme return this.config.store.terminal.colorScheme

View File

@@ -285,7 +285,7 @@ export class VaultFileProvider extends FileProvider {
icon: 'fas fa-file', icon: 'fas fa-file',
result: f, result: f,
})), })),
]) ]).catch(() => null)
if (result) { if (result) {
return `${this.prefix}${result.key.id}` return `${this.prefix}${result.key.id}`
} }

View File

@@ -149,7 +149,7 @@ export class CommonOptionsContextMenu extends TabContextMenuItemProvider {
click: async () => { click: async () => {
const modal = this.ngbModal.open(PromptModalComponent) const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = this.translate.instant('Profile name') modal.componentInstance.prompt = this.translate.instant('Profile name')
const name = (await modal.result)?.value const name = (await modal.result.catch(() => null))?.value
if (!name) { if (!name) {
return return
} }
@@ -262,7 +262,7 @@ export class ProfilesContextMenu extends TabContextMenuItemProvider {
} }
async switchTabProfile (tab: BaseTabComponent) { async switchTabProfile (tab: BaseTabComponent) {
const profile = await this.profilesService.showProfileSelector() const profile = await this.profilesService.showProfileSelector().catch(() => null)
if (!profile) { if (!profile) {
return return
} }

View File

@@ -22,5 +22,6 @@ export class ElectronConfigProvider extends ConfigProvider {
}, },
}, },
} }
defaults = {} defaults = {}
} }

View File

@@ -33,6 +33,7 @@ export class ShellIntegrationService {
command: 'paste "%V"', command: 'paste "%V"',
}, },
] ]
private constructor ( private constructor (
private electron: ElectronService, private electron: ElectronService,
private hostApp: HostAppService, private hostApp: HostAppService,

View File

@@ -70,6 +70,7 @@ export class PluginManagerService {
map(plugins => { map(plugins => {
const mapping: Record<string, PluginInfo[]> = {} const mapping: Record<string, PluginInfo[]> = {}
for (const p of plugins) { for (const p of plugins) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
mapping[p.name] ??= [] mapping[p.name] ??= []
mapping[p.name].push(p) mapping[p.name].push(p)
} }

View File

@@ -3,10 +3,10 @@ import { SerialPortStream } from '@serialport/stream'
import { LogService, NotificationsService } from 'tabby-core' import { LogService, NotificationsService } from 'tabby-core'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { Injector, NgZone } from '@angular/core' import { Injector, NgZone } from '@angular/core'
import { BaseSession, BaseTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor, UTF8SplitterMiddleware } from 'tabby-terminal' import { BaseSession, ConnectableTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor, UTF8SplitterMiddleware } from 'tabby-terminal'
import { SerialService } from './services/serial.service' import { SerialService } from './services/serial.service'
export interface SerialProfile extends BaseTerminalProfile { export interface SerialProfile extends ConnectableTerminalProfile {
options: SerialProfileOptions options: SerialProfileOptions
} }

View File

@@ -2,14 +2,14 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'
import slugify from 'slugify' import slugify from 'slugify'
import deepClone from 'clone-deep' import deepClone from 'clone-deep'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ProfileProvider, NewTabParameters, SelectorService, HostAppService, Platform, TranslateService } from 'tabby-core' import { NewTabParameters, SelectorService, HostAppService, Platform, TranslateService, ConnectableProfileProvider } from 'tabby-core'
import { SerialProfileSettingsComponent } from './components/serialProfileSettings.component' import { SerialProfileSettingsComponent } from './components/serialProfileSettings.component'
import { SerialTabComponent } from './components/serialTab.component' import { SerialTabComponent } from './components/serialTab.component'
import { SerialService } from './services/serial.service' import { SerialService } from './services/serial.service'
import { BAUD_RATES, SerialProfile } from './api' import { BAUD_RATES, SerialProfile } from './api'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SerialProfilesService extends ProfileProvider<SerialProfile> { export class SerialProfilesService extends ConnectableProfileProvider<SerialProfile> {
id = 'serial' id = 'serial'
name = _('Serial') name = _('Serial')
settingsComponent = SerialProfileSettingsComponent settingsComponent = SerialProfileSettingsComponent
@@ -32,6 +32,7 @@ export class SerialProfilesService extends ProfileProvider<SerialProfile> {
slowSend: false, slowSend: false,
input: { backspace: 'backspace' }, input: { backspace: 'backspace' },
}, },
clearServiceMessagesOnConnect: false,
} }
constructor ( constructor (

View File

@@ -59,7 +59,7 @@ export class ConfigSyncSettingsTabComponent extends BaseComponent {
const modal = this.ngbModal.open(PromptModalComponent) const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = this.translate.instant('Name for the new config') modal.componentInstance.prompt = this.translate.instant('Name for the new config')
modal.componentInstance.value = name modal.componentInstance.value = name
name = (await modal.result)?.value name = (await modal.result.catch(() => null))?.value
if (!name) { if (!name) {
return return
} }

View File

@@ -0,0 +1,32 @@
.modal-header
h3.m-0 {{group.name}}
.modal-body
.row
.col-12.col-lg-4
.mb-3
label(translate) Name
input.form-control(
type='text',
autofocus,
[(ngModel)]='group.name',
)
.col-12.col-lg-8
.form-line.content-box
.header
.title(translate) Default profile group settings
.description(translate) These apply to all profiles of a given type in this group
.list-group.mt-3.mb-3.content-box
a.list-group-item.list-group-item-action.d-flex.align-items-center(
(click)='editDefaults(provider)',
*ngFor='let provider of providers'
) {{provider.name|translate}}
.me-auto
button.btn.btn-link.hover-reveal.ms-1((click)='$event.stopPropagation(); deleteDefaults(provider)')
i.fas.fa-trash-arrow-up
.modal-footer
button.btn.btn-primary((click)='save()', translate) Save
button.btn.btn-danger((click)='cancel()', translate) Cancel

View File

@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Input } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigProxy, ProfileGroup, Profile, ProfileProvider, PlatformService, TranslateService } from 'tabby-core'
/** @hidden */
@Component({
templateUrl: './editProfileGroupModal.component.pug',
})
export class EditProfileGroupModalComponent<G extends ProfileGroup> {
@Input() group: G & ConfigProxy
@Input() providers: ProfileProvider<Profile>[]
constructor (
private modalInstance: NgbActiveModal,
private platform: PlatformService,
private translate: TranslateService,
) {}
save () {
this.modalInstance.close({ group: this.group })
}
cancel () {
this.modalInstance.dismiss()
}
editDefaults (provider: ProfileProvider<Profile>) {
this.modalInstance.close({ group: this.group, provider })
}
async deleteDefaults (provider: ProfileProvider<Profile>): Promise<void> {
if ((await this.platform.showMessageBox(
{
type: 'warning',
message: this.translate.instant('Restore settings to inherited defaults ?'),
buttons: [
this.translate.instant('Delete'),
this.translate.instant('Keep'),
],
defaultId: 1,
cancelId: 1,
},
)).response === 0) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.group.defaults?.[provider.id]
}
}
}
export interface EditProfileGroupModalComponentResult<G extends ProfileGroup> {
group: G
provider?: ProfileProvider<Profile>
}

View File

@@ -1,7 +1,7 @@
.modal-header(*ngIf='!defaultsMode') .modal-header(*ngIf='defaultsMode === "disabled"')
h3.m-0 {{profile.name}} h3.m-0 {{profile.name}}
.modal-header(*ngIf='defaultsMode') .modal-header(*ngIf='defaultsMode !== "disabled"')
h3.m-0( h3.m-0(
translate='Defaults for {type}', translate='Defaults for {type}',
[translateParams]='{type: profileProvider.name}' [translateParams]='{type: profileProvider.name}'
@@ -10,7 +10,7 @@
.modal-body .modal-body
.row .row
.col-12.col-lg-4 .col-12.col-lg-4
.mb-3(*ngIf='!defaultsMode') .mb-3(*ngIf='defaultsMode === "disabled"')
label(translate) Name label(translate) Name
input.form-control( input.form-control(
type='text', type='text',
@@ -18,17 +18,20 @@
[(ngModel)]='profile.name', [(ngModel)]='profile.name',
) )
.mb-3(*ngIf='!defaultsMode') .mb-3(*ngIf='defaultsMode === "disabled"')
label(translate) Group label(translate) Group
input.form-control( input.form-control(
type='text', type='text',
alwaysVisibleTypeahead, alwaysVisibleTypeahead,
placeholder='Ungrouped', placeholder='Ungrouped',
[(ngModel)]='profile.group', [(ngModel)]='profileGroup',
[ngbTypeahead]='groupTypeahead', [ngbTypeahead]='groupTypeahead',
[inputFormatter]="groupFormatter",
[resultFormatter]="groupFormatter",
[editable]="false"
) )
.mb-3(*ngIf='!defaultsMode') .mb-3(*ngIf='defaultsMode === "disabled"')
label(translate) Icon label(translate) Icon
.input-group .input-group
input.form-control( input.form-control(
@@ -74,9 +77,15 @@
) )
option(ngValue='auto', translate) Auto option(ngValue='auto', translate) Auto
option(ngValue='keep', translate) Keep option(ngValue='keep', translate) Keep
option(*ngIf='profile.type == "serial" || profile.type == "telnet" || profile.type == "ssh"', ngValue='reconnect', translate) Reconnect option(*ngIf='isConnectable()', ngValue='reconnect', translate) Reconnect
option(ngValue='close', translate) Close option(ngValue='close', translate) Close
.form-line(*ngIf='isConnectable()')
.header
.title(translate) Clear terminal after connection
toggle(
[(ngModel)]='profile.clearServiceMessagesOnConnect',
)
.mb-4 .mb-4
.col-12.col-lg-8(*ngIf='this.profileProvider.settingsComponent') .col-12.col-lg-8(*ngIf='this.profileProvider.settingsComponent')

View File

@@ -2,7 +2,7 @@
import { Observable, OperatorFunction, debounceTime, map, distinctUntilChanged } from 'rxjs' import { Observable, OperatorFunction, debounceTime, map, distinctUntilChanged } from 'rxjs'
import { Component, Input, ViewChild, ViewContainerRef, ComponentFactoryResolver, Injector } from '@angular/core' import { Component, Input, ViewChild, ViewContainerRef, ComponentFactoryResolver, Injector } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigProxy, ConfigService, Profile, ProfileProvider, ProfileSettingsComponent, ProfilesService, TAB_COLORS } from 'tabby-core' import { ConfigProxy, PartialProfileGroup, Profile, ProfileProvider, ProfileSettingsComponent, ProfilesService, TAB_COLORS, ProfileGroup, ConnectableProfileProvider } from 'tabby-core'
const iconsData = require('../../../tabby-core/src/icons.json') const iconsData = require('../../../tabby-core/src/icons.json')
const iconsClassList = Object.keys(iconsData).map( const iconsClassList = Object.keys(iconsData).map(
@@ -19,8 +19,9 @@ export class EditProfileModalComponent<P extends Profile> {
@Input() profile: P & ConfigProxy @Input() profile: P & ConfigProxy
@Input() profileProvider: ProfileProvider<P> @Input() profileProvider: ProfileProvider<P>
@Input() settingsComponent: new () => ProfileSettingsComponent<P> @Input() settingsComponent: new () => ProfileSettingsComponent<P>
@Input() defaultsMode = false @Input() defaultsMode: 'enabled'|'group'|'disabled' = 'disabled'
groupNames: string[] @Input() profileGroup: PartialProfileGroup<ProfileGroup> | undefined
groups: PartialProfileGroup<ProfileGroup>[]
@ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef @ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef
private _profile: Profile private _profile: Profile
@@ -30,14 +31,14 @@ export class EditProfileModalComponent<P extends Profile> {
private injector: Injector, private injector: Injector,
private componentFactoryResolver: ComponentFactoryResolver, private componentFactoryResolver: ComponentFactoryResolver,
private profilesService: ProfilesService, private profilesService: ProfilesService,
config: ConfigService,
private modalInstance: NgbActiveModal, private modalInstance: NgbActiveModal,
) { ) {
this.groupNames = [...new Set( if (this.defaultsMode === 'disabled') {
(config.store.profiles as Profile[]) this.profilesService.getProfileGroups().then(groups => {
.map(x => x.group) this.groups = groups
.filter(x => !!x), this.profileGroup = groups.find(g => g.id === this.profile.group)
)].sort() as string[] })
}
} }
colorsAutocomplete = text$ => text$.pipe( colorsAutocomplete = text$ => text$.pipe(
@@ -56,7 +57,7 @@ export class EditProfileModalComponent<P extends Profile> {
ngOnInit () { ngOnInit () {
this._profile = this.profile this._profile = this.profile
this.profile = this.profilesService.getConfigProxyForProfile(this.profile, this.defaultsMode) this.profile = this.profilesService.getConfigProxyForProfile(this.profile, { skipGlobalDefaults: this.defaultsMode === 'enabled', skipGroupDefaults: this.defaultsMode === 'group' })
} }
ngAfterViewInit () { ngAfterViewInit () {
@@ -72,13 +73,15 @@ export class EditProfileModalComponent<P extends Profile> {
} }
} }
groupTypeahead = (text$: Observable<string>) => groupTypeahead: OperatorFunction<string, readonly PartialProfileGroup<ProfileGroup>[]> = (text$: Observable<string>) =>
text$.pipe( text$.pipe(
debounceTime(200), debounceTime(200),
distinctUntilChanged(), distinctUntilChanged(),
map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase()))), map(q => this.groups.filter(g => !q || g.name.toLowerCase().includes(q.toLowerCase()))),
) )
groupFormatter = (g: PartialProfileGroup<ProfileGroup>) => g.name
iconSearch: OperatorFunction<string, string[]> = (text$: Observable<string>) => iconSearch: OperatorFunction<string, string[]> = (text$: Observable<string>) =>
text$.pipe( text$.pipe(
debounceTime(200), debounceTime(200),
@@ -86,7 +89,12 @@ export class EditProfileModalComponent<P extends Profile> {
) )
save () { save () {
this.profile.group ||= undefined if (!this.profileGroup) {
this.profile.group = undefined
} else {
this.profile.group = this.profileGroup.id
}
this.settingsComponentInstance?.save?.() this.settingsComponentInstance?.save?.()
this.profile.__cleanup() this.profile.__cleanup()
this.modalInstance.close(this._profile) this.modalInstance.close(this._profile)
@@ -95,4 +103,9 @@ export class EditProfileModalComponent<P extends Profile> {
cancel () { cancel () {
this.modalInstance.dismiss() this.modalInstance.dismiss()
} }
isConnectable (): boolean {
return this.profileProvider instanceof ConnectableProfileProvider
}
} }

View File

@@ -27,9 +27,17 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
i.fas.fa-fw.fa-search i.fas.fa-fw.fa-search
input.form-control(type='search', [placeholder]='"Filter"|translate', [(ngModel)]='filter') input.form-control(type='search', [placeholder]='"Filter"|translate', [(ngModel)]='filter')
button.btn.btn-primary.flex-shrink-0.ms-3((click)='newProfile()') div(ngbDropdown).d-inline-block.flex-shrink-0.ms-3
button.btn.btn-primary(ngbDropdownToggle)
i.fas.fa-fw.fa-plus
span(translate) New
div(ngbDropdownMenu)
button(ngbDropdownItem, (click)='newProfile()')
i.fas.fa-fw.fa-plus i.fas.fa-fw.fa-plus
span(translate) New profile span(translate) New profile
button(ngbDropdownItem, (click)='newProfileGroup()')
i.fas.fa-fw.fa-plus
span(translate) New profile Group
.list-group.mt-3.mb-3 .list-group.mt-3.mb-3
ng-container(*ngFor='let group of profileGroups') ng-container(*ngFor='let group of profileGroups')
@@ -37,17 +45,17 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
.list-group-item.list-group-item-action.d-flex.align-items-center( .list-group-item.list-group-item-action.d-flex.align-items-center(
(click)='toggleGroupCollapse(group)' (click)='toggleGroupCollapse(group)'
) )
.fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed') .fa.fa-fw.fa-chevron-right(*ngIf='group.collapsed && group.profiles?.length > 0')
.fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed') .fa.fa-fw.fa-chevron-down(*ngIf='!group.collapsed && group.profiles?.length > 0')
span.ms-3.me-auto {{group.name || ("Ungrouped"|translate)}} span.ms-3.me-auto {{group.name || ("Ungrouped"|translate)}}
button.btn.btn-sm.btn-link.hover-reveal.ms-2( button.btn.btn-sm.btn-link.hover-reveal.ms-2(
*ngIf='group.editable && group.name', *ngIf='group.editable && group.name',
(click)='$event.stopPropagation(); editGroup(group)' (click)='$event.stopPropagation(); editProfileGroup(group)'
) )
i.fas.fa-pencil-alt i.fas.fa-pencil-alt
button.btn.btn-sm.btn-link.hover-reveal.ms-2( button.btn.btn-sm.btn-link.hover-reveal.ms-2(
*ngIf='group.editable && group.name', *ngIf='group.editable && group.name',
(click)='$event.stopPropagation(); deleteGroup(group)' (click)='$event.stopPropagation(); deleteProfileGroup(group)'
) )
i.fas.fa-trash-alt i.fas.fa-trash-alt
ng-container(*ngIf='!group.collapsed') ng-container(*ngIf='!group.collapsed')
@@ -67,7 +75,7 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
.me-auto .me-auto
button.btn.btn-link.hover-reveal.ms-1((click)='$event.stopPropagation(); launchProfile(profile)') button.btn.btn-link.hover-reveal.ms-1(*ngIf='!profile.isTemplate', (click)='$event.stopPropagation(); launchProfile(profile)')
i.fas.fa-play i.fas.fa-play
.ms-1.hover-reveal(ngbDropdown, placement='bottom-right top-right auto') .ms-1.hover-reveal(ngbDropdown, placement='bottom-right top-right auto')
@@ -169,9 +177,12 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
.description(translate) These apply to all profiles of a given type .description(translate) These apply to all profiles of a given type
.list-group.mt-3.mb-3.content-box .list-group.mt-3.mb-3.content-box
a.list-group-item.list-group-item-action( a.list-group-item.list-group-item-action.d-flex.align-items-center(
(click)='editDefaults(provider)', (click)='editDefaults(provider)',
*ngFor='let provider of profileProviders' *ngFor='let provider of profileProviders'
) {{provider.name|translate}} ) {{provider.name|translate}}
.me-auto
button.btn.btn-link.hover-reveal.ms-1((click)='$event.stopPropagation(); deleteDefaults(provider)')
i.fas.fa-trash-arrow-up
div([ngbNavOutlet]='nav') div([ngbNavOutlet]='nav')

View File

@@ -1,32 +1,27 @@
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker' import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'
import { v4 as uuidv4 } from 'uuid'
import slugify from 'slugify'
import deepClone from 'clone-deep' import deepClone from 'clone-deep'
import { Component, Inject } from '@angular/core' import { Component, Inject } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent, PartialProfile, ProfileProvider, TranslateService, Platform, AppHotkeyProvider } from 'tabby-core' import { ConfigService, HostAppService, Profile, SelectorService, ProfilesService, PromptModalComponent, PlatformService, BaseComponent, PartialProfile, ProfileProvider, TranslateService, Platform, ProfileGroup, PartialProfileGroup, QuickConnectProfileProvider } from 'tabby-core'
import { EditProfileModalComponent } from './editProfileModal.component' import { EditProfileModalComponent } from './editProfileModal.component'
import { EditProfileGroupModalComponent, EditProfileGroupModalComponentResult } from './editProfileGroupModal.component'
interface ProfileGroup {
name?: string
profiles: PartialProfile<Profile>[]
editable: boolean
collapsed: boolean
}
_('Filter') _('Filter')
_('Ungrouped') _('Ungrouped')
interface CollapsableProfileGroup extends ProfileGroup {
collapsed: boolean
}
/** @hidden */ /** @hidden */
@Component({ @Component({
templateUrl: './profilesSettingsTab.component.pug', templateUrl: './profilesSettingsTab.component.pug',
styleUrls: ['./profilesSettingsTab.component.scss'], styleUrls: ['./profilesSettingsTab.component.scss'],
}) })
export class ProfilesSettingsTabComponent extends BaseComponent { export class ProfilesSettingsTabComponent extends BaseComponent {
profiles: PartialProfile<Profile>[] = []
builtinProfiles: PartialProfile<Profile>[] = [] builtinProfiles: PartialProfile<Profile>[] = []
templateProfiles: PartialProfile<Profile>[] = [] templateProfiles: PartialProfile<Profile>[] = []
profileGroups: ProfileGroup[] profileGroups: PartialProfileGroup<CollapsableProfileGroup>[]
filter = '' filter = ''
Platform = Platform Platform = Platform
@@ -59,7 +54,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
async newProfile (base?: PartialProfile<Profile>): Promise<void> { async newProfile (base?: PartialProfile<Profile>): Promise<void> {
if (!base) { if (!base) {
let profiles = [...this.templateProfiles, ...this.builtinProfiles, ...this.profiles] let profiles = await this.profilesService.getProfiles()
profiles = profiles.filter(x => !this.isProfileBlacklisted(x)) profiles = profiles.filter(x => !this.isProfileBlacklisted(x))
profiles.sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0)) profiles.sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0))
base = await this.selector.show( base = await this.selector.show(
@@ -67,10 +62,13 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
profiles.map(p => ({ profiles.map(p => ({
icon: p.icon, icon: p.icon,
description: this.profilesService.getDescription(p) ?? undefined, description: this.profilesService.getDescription(p) ?? undefined,
name: p.group ? `${p.group} / ${p.name}` : p.name, name: p.group ? `${this.profilesService.resolveProfileGroupName(p.group)} / ${p.name}` : p.name,
result: p, result: p,
})), })),
) ).catch(() => undefined)
if (!base) {
return
}
} }
const profile: PartialProfile<Profile> = deepClone(base) const profile: PartialProfile<Profile> = deepClone(base)
delete profile.id delete profile.id
@@ -90,8 +88,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
const cfgProxy = this.profilesService.getConfigProxyForProfile(profile) const cfgProxy = this.profilesService.getConfigProxyForProfile(profile)
profile.name = this.profilesService.providerForProfile(profile)?.getSuggestedName(cfgProxy) ?? this.translate.instant('{name} copy', base) profile.name = this.profilesService.providerForProfile(profile)?.getSuggestedName(cfgProxy) ?? this.translate.instant('{name} copy', base)
} }
profile.id = `${profile.type}:custom:${slugify(profile.name)}:${uuidv4()}` await this.profilesService.newProfile(profile)
this.config.store.profiles = [profile, ...this.config.store.profiles]
await this.config.save() await this.config.save()
} }
@@ -101,6 +98,7 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
return return
} }
Object.assign(profile, result) Object.assign(profile, result)
await this.profilesService.writeProfile(profile)
await this.config.save() await this.config.save()
} }
@@ -144,69 +142,80 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
cancelId: 1, cancelId: 1,
}, },
)).response === 0) { )).response === 0) {
this.profilesService.providerForProfile(profile)?.deleteProfile( await this.profilesService.deleteProfile(profile)
this.profilesService.getConfigProxyForProfile(profile))
this.config.store.profiles = this.config.store.profiles.filter(x => x !== profile)
const profileHotkeyName = AppHotkeyProvider.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
}
await this.config.save() await this.config.save()
} }
} }
refresh (): void { async newProfileGroup (): Promise<void> {
this.profiles = this.config.store.profiles
this.profileGroups = []
const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
for (const profile of this.profiles) {
// Group null, undefined and empty together
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let group = this.profileGroups.find(x => x.name === (profile.group || ''))
if (!group) {
group = {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: profile.group || '',
profiles: [],
editable: true,
collapsed: profileGroupCollapsed[profile.group ?? ''] ?? false,
}
this.profileGroups.push(group)
}
group.profiles.push(profile)
}
this.profileGroups.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? -1)
const builtIn = {
name: this.translate.instant('Built-in'),
profiles: this.builtinProfiles,
editable: false,
collapsed: false,
}
builtIn.collapsed = profileGroupCollapsed[builtIn.name ?? ''] ?? false
this.profileGroups.push(builtIn)
}
async editGroup (group: ProfileGroup): Promise<void> {
const modal = this.ngbModal.open(PromptModalComponent) const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = this.translate.instant('New name') modal.componentInstance.prompt = this.translate.instant('New group name')
modal.componentInstance.value = group.name const result = await modal.result.catch(() => null)
const result = await modal.result if (result?.value.trim()) {
if (result) { await this.profilesService.newProfileGroup({ id: '', name: result.value })
for (const profile of this.profiles.filter(x => x.group === group.name)) {
profile.group = result.value
}
this.config.store.profiles = this.profiles
await this.config.save() await this.config.save()
} }
} }
async deleteGroup (group: ProfileGroup): Promise<void> { async editProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<void> {
const result = await this.showProfileGroupEditModal(group)
if (!result) {
return
}
Object.assign(group, result)
await this.profilesService.writeProfileGroup(ProfilesSettingsTabComponent.collapsableIntoPartialProfileGroup(group))
await this.config.save()
}
async showProfileGroupEditModal (group: PartialProfileGroup<CollapsableProfileGroup>): Promise<PartialProfileGroup<CollapsableProfileGroup>|null> {
const modal = this.ngbModal.open(
EditProfileGroupModalComponent,
{ size: 'lg' },
)
modal.componentInstance.group = deepClone(group)
modal.componentInstance.providers = this.profileProviders
const result: EditProfileGroupModalComponentResult<CollapsableProfileGroup> | null = await modal.result.catch(() => null)
if (!result) {
return null
}
if (result.provider) {
return this.editProfileGroupDefaults(result.group, result.provider)
}
return result.group
}
private async editProfileGroupDefaults (group: PartialProfileGroup<CollapsableProfileGroup>, provider: ProfileProvider<Profile>): Promise<PartialProfileGroup<CollapsableProfileGroup>|null> {
const modal = this.ngbModal.open(
EditProfileModalComponent,
{ size: 'lg' },
)
const model = group.defaults?.[provider.id] ?? {}
model.type = provider.id
modal.componentInstance.profile = Object.assign({}, model)
modal.componentInstance.profileProvider = provider
modal.componentInstance.defaultsMode = 'group'
const result = await modal.result.catch(() => null)
if (result) {
// Fully replace the config
for (const k in model) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete model[k]
}
Object.assign(model, result)
if (!group.defaults) {
group.defaults = {}
}
group.defaults[provider.id] = model
}
return this.showProfileGroupEditModal(group)
}
async deleteProfileGroup (group: PartialProfileGroup<ProfileGroup>): Promise<void> {
if ((await this.platform.showMessageBox( if ((await this.platform.showMessageBox(
{ {
type: 'warning', type: 'warning',
@@ -219,7 +228,8 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
cancelId: 1, cancelId: 1,
}, },
)).response === 0) { )).response === 0) {
if ((await this.platform.showMessageBox( let deleteProfiles = false
if ((group.profiles?.length ?? 0) > 0 && (await this.platform.showMessageBox(
{ {
type: 'warning', type: 'warning',
message: this.translate.instant('Delete the group\'s profiles?'), message: this.translate.instant('Delete the group\'s profiles?'),
@@ -230,19 +240,26 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
defaultId: 0, defaultId: 0,
cancelId: 0, cancelId: 0,
}, },
)).response === 0) { )).response !== 0) {
for (const profile of this.profiles.filter(x => x.group === group.name)) { deleteProfiles = true
delete profile.group
}
} else {
this.config.store.profiles = this.config.store.profiles.filter(x => x.group !== group.name)
} }
await this.profilesService.deleteProfileGroup(group, { deleteProfiles })
await this.config.save() await this.config.save()
} }
} }
isGroupVisible (group: ProfileGroup): boolean { async refresh (): Promise<void> {
return !this.filter || group.profiles.some(x => this.isProfileVisible(x)) const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
const groups = await this.profilesService.getProfileGroups({ includeNonUserGroup: true, includeProfiles: true })
groups.sort((a, b) => a.name.localeCompare(b.name))
groups.sort((a, b) => (a.id === 'built-in' || !a.editable ? 1 : 0) - (b.id === 'built-in' || !b.editable ? 1 : 0))
groups.sort((a, b) => (a.id === 'ungrouped' ? 0 : 1) - (b.id === 'ungrouped' ? 0 : 1))
this.profileGroups = groups.map(g => ProfilesSettingsTabComponent.intoPartialCollapsableProfileGroup(g, profileGroupCollapsed[g.id] ?? false))
}
isGroupVisible (group: PartialProfileGroup<ProfileGroup>): boolean {
return !this.filter || (group.profiles ?? []).some(x => this.isProfileVisible(x))
} }
isProfileVisible (profile: PartialProfile<Profile>): boolean { isProfileVisible (profile: PartialProfile<Profile>): boolean {
@@ -270,11 +287,12 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
}[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning' }[this.profilesService.providerForProfile(profile)?.id ?? ''] ?? 'warning'
} }
toggleGroupCollapse (group: ProfileGroup): void { toggleGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
if (group.profiles?.length === 0) {
return
}
group.collapsed = !group.collapsed group.collapsed = !group.collapsed
const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}') this.saveProfileGroupCollapse(group)
profileGroupCollapsed[group.name ?? ''] = group.collapsed
window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed)
} }
async editDefaults (provider: ProfileProvider<Profile>): Promise<void> { async editDefaults (provider: ProfileProvider<Profile>): Promise<void> {
@@ -282,22 +300,41 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
EditProfileModalComponent, EditProfileModalComponent,
{ size: 'lg' }, { size: 'lg' },
) )
const model = this.config.store.profileDefaults[provider.id] ?? {} const model = this.profilesService.getProviderDefaults(provider)
model.type = provider.id model.type = provider.id
modal.componentInstance.profile = Object.assign({}, model) modal.componentInstance.profile = Object.assign({}, model)
modal.componentInstance.profileProvider = provider modal.componentInstance.profileProvider = provider
modal.componentInstance.defaultsMode = true modal.componentInstance.defaultsMode = 'enabled'
const result = await modal.result const result = await modal.result.catch(() => null)
if (result) {
// Fully replace the config // Fully replace the config
for (const k in model) { for (const k in model) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete model[k] delete model[k]
} }
Object.assign(model, result) Object.assign(model, result)
this.config.store.profileDefaults[provider.id] = model this.profilesService.setProviderDefaults(provider, model)
await this.config.save() await this.config.save()
} }
}
async deleteDefaults (provider: ProfileProvider<Profile>): Promise<void> {
if ((await this.platform.showMessageBox(
{
type: 'warning',
message: this.translate.instant('Restore settings to defaults ?'),
buttons: [
this.translate.instant('Delete'),
this.translate.instant('Keep'),
],
defaultId: 1,
cancelId: 1,
},
)).response === 0) {
this.profilesService.setProviderDefaults(provider, {})
await this.config.save()
}
}
blacklistProfile (profile: PartialProfile<Profile>): void { blacklistProfile (profile: PartialProfile<Profile>): void {
this.config.store.profileBlacklist = [...this.config.store.profileBlacklist, profile.id] this.config.store.profileBlacklist = [...this.config.store.profileBlacklist, profile.id]
@@ -314,6 +351,29 @@ export class ProfilesSettingsTabComponent extends BaseComponent {
} }
getQuickConnectProviders (): ProfileProvider<Profile>[] { getQuickConnectProviders (): ProfileProvider<Profile>[] {
return this.profileProviders.filter(x => x.supportsQuickConnect) return this.profileProviders.filter(x => x instanceof QuickConnectProfileProvider)
}
/**
* Save ProfileGroup collapse state in localStorage
*/
private saveProfileGroupCollapse (group: PartialProfileGroup<CollapsableProfileGroup>): void {
const profileGroupCollapsed = JSON.parse(window.localStorage.profileGroupCollapsed ?? '{}')
profileGroupCollapsed[group.id] = group.collapsed
window.localStorage.profileGroupCollapsed = JSON.stringify(profileGroupCollapsed)
}
private static collapsableIntoPartialProfileGroup (group: PartialProfileGroup<CollapsableProfileGroup>): PartialProfileGroup<ProfileGroup> {
const g: any = { ...group }
delete g.collapsed
return g
}
private static intoPartialCollapsableProfileGroup (group: PartialProfileGroup<ProfileGroup>, collapsed: boolean): PartialProfileGroup<CollapsableProfileGroup> {
const collapsableGroup = {
...group,
collapsed,
}
return collapsableGroup
} }
} }

View File

@@ -35,10 +35,12 @@ export class VaultSettingsTabComponent extends BaseComponent {
async enableVault () { async enableVault () {
const modal = this.ngbModal.open(SetVaultPassphraseModalComponent) const modal = this.ngbModal.open(SetVaultPassphraseModalComponent)
const newPassphrase = await modal.result const newPassphrase = await modal.result.catch(() => null)
if (newPassphrase) {
await this.vault.setEnabled(true, newPassphrase) await this.vault.setEnabled(true, newPassphrase)
this.vaultContents = await this.vault.load(newPassphrase) this.vaultContents = await this.vault.load(newPassphrase)
} }
}
async disableVault () { async disableVault () {
if ((await this.platform.showMessageBox( if ((await this.platform.showMessageBox(
@@ -65,9 +67,11 @@ export class VaultSettingsTabComponent extends BaseComponent {
return return
} }
const modal = this.ngbModal.open(SetVaultPassphraseModalComponent) const modal = this.ngbModal.open(SetVaultPassphraseModalComponent)
const newPassphrase = await modal.result const newPassphrase = await modal.result.catch(() => null)
if (newPassphrase) {
this.vault.save(this.vaultContents, newPassphrase) this.vault.save(this.vaultContents, newPassphrase)
} }
}
async toggleConfigEncrypted () { async toggleConfigEncrypted () {
this.config.store.encrypted = !this.config.store.encrypted this.config.store.encrypted = !this.config.store.encrypted
@@ -118,7 +122,7 @@ export class VaultSettingsTabComponent extends BaseComponent {
modal.componentInstance.prompt = this.translate.instant('New name') modal.componentInstance.prompt = this.translate.instant('New name')
modal.componentInstance.value = secret.key.description modal.componentInstance.value = secret.key.description
const description = (await modal.result)?.value const description = (await modal.result.catch(() => null))?.value
if (!description) { if (!description) {
return return
} }

View File

@@ -20,6 +20,7 @@ export class SettingsConfigProvider extends ConfigProvider {
}, },
}, },
} }
platformDefaults = { platformDefaults = {
[Platform.macOS]: { [Platform.macOS]: {
hotkeys: { hotkeys: {

View File

@@ -7,6 +7,7 @@ import { InfiniteScrollModule } from 'ngx-infinite-scroll'
import TabbyCorePlugin, { ToolbarButtonProvider, HotkeyProvider, ConfigProvider, HotkeysService, AppService } from 'tabby-core' import TabbyCorePlugin, { ToolbarButtonProvider, HotkeyProvider, ConfigProvider, HotkeysService, AppService } from 'tabby-core'
import { EditProfileModalComponent } from './components/editProfileModal.component' import { EditProfileModalComponent } from './components/editProfileModal.component'
import { EditProfileGroupModalComponent } from './components/editProfileGroupModal.component'
import { HotkeyInputModalComponent } from './components/hotkeyInputModal.component' import { HotkeyInputModalComponent } from './components/hotkeyInputModal.component'
import { HotkeySettingsTabComponent } from './components/hotkeySettingsTab.component' import { HotkeySettingsTabComponent } from './components/hotkeySettingsTab.component'
import { MultiHotkeyInputComponent } from './components/multiHotkeyInput.component' import { MultiHotkeyInputComponent } from './components/multiHotkeyInput.component'
@@ -48,6 +49,7 @@ import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabP
], ],
declarations: [ declarations: [
EditProfileModalComponent, EditProfileModalComponent,
EditProfileGroupModalComponent,
HotkeyInputModalComponent, HotkeyInputModalComponent,
HotkeySettingsTabComponent, HotkeySettingsTabComponent,
MultiHotkeyInputComponent, MultiHotkeyInputComponent,

View File

@@ -25,6 +25,7 @@
"@types/node": "20.3.1", "@types/node": "20.3.1",
"@types/ssh2": "^0.5.46", "@types/ssh2": "^0.5.46",
"ansi-colors": "^4.1.1", "ansi-colors": "^4.1.1",
"diffie-hellman": "^5.0.3",
"sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136", "sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136",
"strip-ansi": "^7.0.0" "strip-ansi": "^7.0.0"
}, },

View File

@@ -1,4 +1,4 @@
import { BaseTerminalProfile, InputProcessingOptions, LoginScriptsOptions } from 'tabby-terminal' import { ConnectableTerminalProfile, InputProcessingOptions, LoginScriptsOptions } from 'tabby-terminal'
export enum SSHAlgorithmType { export enum SSHAlgorithmType {
HMAC = 'hmac', HMAC = 'hmac',
@@ -7,7 +7,7 @@ export enum SSHAlgorithmType {
HOSTKEY = 'serverHostKey', HOSTKEY = 'serverHostKey',
} }
export interface SSHProfile extends BaseTerminalProfile { export interface SSHProfile extends ConnectableTerminalProfile {
options: SSHProfileOptions options: SSHProfileOptions
} }

View File

@@ -18,6 +18,7 @@ export class SFTPCreateDirectoryModalComponent extends BaseComponent {
create (): void { create (): void {
this.modalInstance.close(this.directoryName) this.modalInstance.close(this.directoryName)
} }
cancel (): void { cancel (): void {
this.modalInstance.close('') this.modalInstance.close('')
} }

View File

@@ -113,8 +113,8 @@ export class SFTPPanelComponent {
async openCreateDirectoryModal (): Promise<void> { async openCreateDirectoryModal (): Promise<void> {
const modal = this.ngbModal.open(SFTPCreateDirectoryModalComponent) const modal = this.ngbModal.open(SFTPCreateDirectoryModalComponent)
const directoryName = await modal.result const directoryName = await modal.result.catch(() => null)
if (directoryName !== '') { if (directoryName?.trim()) {
this.sftp.mkdir(path.join(this.path, directoryName)).then(() => { this.sftp.mkdir(path.join(this.path, directoryName)).then(() => {
this.notifications.notice('The directory was created successfully') this.notifications.notice('The directory was created successfully')
this.navigate(path.join(this.path, directoryName)) this.navigate(path.join(this.path, directoryName))

View File

@@ -75,7 +75,7 @@ export class SSHProfileSettingsComponent {
modal.componentInstance.prompt = `Password for ${this.profile.options.user}@${this.profile.options.host}` modal.componentInstance.prompt = `Password for ${this.profile.options.user}@${this.profile.options.host}`
modal.componentInstance.password = true modal.componentInstance.password = true
try { try {
const result = await modal.result const result = await modal.result.catch(() => null)
if (result?.value) { if (result?.value) {
this.passwordStorage.savePassword(this.profile, result.value) this.passwordStorage.savePassword(this.profile, result.value)
this.hasSavedPassword = true this.hasSavedPassword = true
@@ -89,12 +89,14 @@ export class SSHProfileSettingsComponent {
} }
async addPrivateKey () { async addPrivateKey () {
const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.profile.name}`) const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.profile.name}`).catch(() => null)
if (ref) {
this.profile.options.privateKeys = [ this.profile.options.privateKeys = [
...this.profile.options.privateKeys!, ...this.profile.options.privateKeys!,
ref, ref,
] ]
} }
}
removePrivateKey (path: string) { removePrivateKey (path: string) {
this.profile.options.privateKeys = this.profile.options.privateKeys?.filter(x => x !== path) this.profile.options.privateKeys = this.profile.options.privateKeys?.filter(x => x !== path)

View File

@@ -61,12 +61,4 @@ h3 SSH
(ngModelChange)='config.save()' (ngModelChange)='config.save()'
) )
.form-line
.header
.title(translate) Clear terminal after connection
toggle(
[(ngModel)]='config.store.ssh.clearServiceMessagesOnConnect',
(ngModelChange)='config.save()',
)
.alert.alert-info(translate) SSH connection management is now done through the "Profiles & connections" tab .alert.alert-info(translate) SSH connection management is now done through the "Profiles & connections" tab

View File

@@ -83,7 +83,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
const jumpSession = await this.setupOneSession( const jumpSession = await this.setupOneSession(
this.injector, this.injector,
this.profilesService.getConfigProxyForProfile(jumpConnection), this.profilesService.getConfigProxyForProfile<SSHProfile>(jumpConnection),
) )
jumpSession.ref() jumpSession.ref()
@@ -163,10 +163,6 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
await session.start() await session.start()
if (this.config.store.ssh.clearServiceMessagesOnConnect) {
this.frontend?.clear()
}
this.session?.resize(this.size.columns, this.size.rows) this.session?.resize(this.size.columns, this.size.rows)
} }

View File

@@ -11,7 +11,6 @@ export class SSHConfigProvider extends ConfigProvider {
x11Display: null, x11Display: null,
knownHosts: [], knownHosts: [],
verifyHostKeys: true, verifyHostKeys: true,
clearServiceMessagesOnConnect: true,
}, },
hotkeys: { hotkeys: {
'restart-ssh-session': [], 'restart-ssh-session': [],

View File

@@ -1,3 +1,5 @@
import './polyfills'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'

View File

@@ -0,0 +1,4 @@
const nodeCrypto = require('crypto')
const browserDH = require('diffie-hellman/browser')
nodeCrypto.createDiffieHellmanGroup = browserDH.createDiffieHellmanGroup
nodeCrypto.createDiffieHellman = browserDH.createDiffieHellman

View File

@@ -1,5 +1,5 @@
import { Injectable, InjectFlags, Injector } from '@angular/core' import { Injectable, InjectFlags, Injector } from '@angular/core'
import { ProfileProvider, NewTabParameters, PartialProfile, TranslateService } from 'tabby-core' import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core'
import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
import { SSHTabComponent } from './components/sshTab.component' import { SSHTabComponent } from './components/sshTab.component'
@@ -8,10 +8,9 @@ import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api'
import { SSHProfileImporter } from './api/importer' import { SSHProfileImporter } from './api/importer'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SSHProfilesService extends ProfileProvider<SSHProfile> { export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile> {
id = 'ssh' id = 'ssh'
name = 'SSH' name = 'SSH'
supportsQuickConnect = true
settingsComponent = SSHProfileSettingsComponent settingsComponent = SSHProfileSettingsComponent
configDefaults = { configDefaults = {
options: { options: {
@@ -45,6 +44,7 @@ export class SSHProfilesService extends ProfileProvider<SSHProfile> {
reuseSession: true, reuseSession: true,
input: { backspace: 'backspace' }, input: { backspace: 'backspace' },
}, },
clearServiceMessagesOnConnect: true,
} }
constructor ( constructor (

View File

@@ -34,7 +34,7 @@ export class SSHMultiplexerService {
if (!jumpConnection) { if (!jumpConnection) {
return key return key
} }
const jumpProfile = this.profilesService.getConfigProxyForProfile(jumpConnection) const jumpProfile = this.profilesService.getConfigProxyForProfile<SSHProfile>(jumpConnection)
key += '$' + await this.getMultiplexerKey(jumpProfile) key += '$' + await this.getMultiplexerKey(jumpProfile)
} }
return key return key

View File

@@ -1,5 +1,4 @@
import * as C from 'constants' import * as C from 'constants'
// eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { posix as posixPath } from 'path' import { posix as posixPath } from 'path'
import { Injector, NgZone } from '@angular/core' import { Injector, NgZone } from '@angular/core'

View File

@@ -1,6 +1,5 @@
import * as fs from 'mz/fs' import * as fs from 'mz/fs'
import * as crypto from 'crypto' import * as crypto from 'crypto'
// eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
import * as sshpk from 'sshpk' import * as sshpk from 'sshpk'
import colors from 'ansi-colors' import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
@@ -210,7 +209,6 @@ export class SSHSession {
if (!await this.verifyHostKey(handshake)) { if (!await this.verifyHostKey(handshake)) {
this.ssh.end() this.ssh.end()
reject(new Error('Host key verification failed')) reject(new Error('Host key verification failed'))
return
} }
this.logger.info('Handshake complete:', handshake) this.logger.info('Handshake complete:', handshake)
resolve() resolve()
@@ -300,7 +298,7 @@ export class SSHSession {
const modal = this.ngbModal.open(PromptModalComponent) const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Username for ${this.profile.options.host}` modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
try { try {
const result = await modal.result const result = await modal.result.catch(() => null)
this.authUsername = result?.value ?? null this.authUsername = result?.value ?? null
} catch { } catch {
this.authUsername = 'root' this.authUsername = 'root'
@@ -428,11 +426,7 @@ export class SSHSession {
const modal = this.ngbModal.open(HostKeyPromptModalComponent) const modal = this.ngbModal.open(HostKeyPromptModalComponent)
modal.componentInstance.selector = selector modal.componentInstance.selector = selector
modal.componentInstance.digest = this.hostKeyDigest modal.componentInstance.digest = this.hostKeyDigest
try { return modal.result.catch(() => false)
return await modal.result
} catch {
return false
}
} }
return true return true
} }
@@ -495,7 +489,7 @@ export class SSHSession {
modal.componentInstance.showRememberCheckbox = true modal.componentInstance.showRememberCheckbox = true
try { try {
const result = await modal.result const result = await modal.result.catch(() => null)
if (result) { if (result) {
if (result.remember) { if (result.remember) {
this.savedPassword = result.value this.savedPassword = result.value

View File

@@ -7,7 +7,7 @@ export class X11Socket {
static resolveDisplaySpec (spec?: string|null): SocketConnectOpts { static resolveDisplaySpec (spec?: string|null): SocketConnectOpts {
// eslint-disable-next-line prefer-const, @typescript-eslint/no-unused-vars // eslint-disable-next-line prefer-const, @typescript-eslint/no-unused-vars
let [_, xHost, xDisplay] = /^(.+):(\d+)(?:.(\d+))$/.exec(spec ?? process.env.DISPLAY ?? 'localhost:0') ?? [] let [_, xHost, xDisplay] = /^(.+):(\d+)(?:.(\d+))$/.exec(spec ?? process.env.DISPLAY ?? 'localhost:0') ?? [undefined, undefined, undefined]
if (process.platform === 'win32') { if (process.platform === 'win32') {
xHost ??= 'localhost' xHost ??= 'localhost'
} else { } else {
@@ -18,7 +18,7 @@ export class X11Socket {
xHost = spec xHost = spec
} }
const display = parseInt(xDisplay || '0') const display = parseInt(xDisplay ?? '0')
const port = display < 100 ? display + 6000 : display const port = display < 100 ? display + 6000 : display
if (xHost === 'unix') { if (xHost === 'unix') {

View File

@@ -53,6 +53,6 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider {
const modal = this.ngbModal.open(SFTPDeleteModalComponent) const modal = this.ngbModal.open(SFTPDeleteModalComponent)
modal.componentInstance.item = item modal.componentInstance.item = item
modal.componentInstance.sftp = session modal.componentInstance.sftp = session
await modal.result await modal.result.catch(() => null)
} }
} }

View File

@@ -68,6 +68,11 @@ bcrypt-pbkdf@^1.0.0:
dependencies: dependencies:
tweetnacl "^0.14.3" tweetnacl "^0.14.3"
bn.js@^4.0.0, bn.js@^4.1.0:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
brace-expansion@^1.1.7: brace-expansion@^1.1.7:
version "1.1.11" version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -76,6 +81,11 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0" balanced-match "^1.0.0"
concat-map "0.0.1" concat-map "0.0.1"
brorand@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==
cli@0.4.x: cli@0.4.x:
version "0.4.5" version "0.4.5"
resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.5.tgz#78f9485cd161b566e9a6c72d7170c4270e81db61" resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.5.tgz#78f9485cd161b566e9a6c72d7170c4270e81db61"
@@ -119,6 +129,15 @@ dashdash@^1.12.0:
dependencies: dependencies:
assert-plus "^1.0.0" assert-plus "^1.0.0"
diffie-hellman@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==
dependencies:
bn.js "^4.1.0"
miller-rabin "^4.0.0"
randombytes "^2.0.0"
ecc-jsbn@~0.1.1: ecc-jsbn@~0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@@ -200,6 +219,14 @@ jsbn@~0.1.0:
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
miller-rabin@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==
dependencies:
bn.js "^4.0.0"
brorand "^1.0.1"
minimatch@^3.0.4: minimatch@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
@@ -231,6 +258,13 @@ pkginfo@0.3.x:
resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21"
integrity sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE= integrity sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE=
randombytes@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
dependencies:
safe-buffer "^5.1.0"
rimraf@^3.0.0: rimraf@^3.0.0:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
@@ -243,6 +277,11 @@ run-script-os@^1.1.3:
resolved "https://registry.yarnpkg.com/run-script-os/-/run-script-os-1.1.6.tgz#8b0177fb1b54c99a670f95c7fdc54f18b9c72347" resolved "https://registry.yarnpkg.com/run-script-os/-/run-script-os-1.1.6.tgz#8b0177fb1b54c99a670f95c7fdc54f18b9c72347"
integrity sha512-ql6P2LzhBTTDfzKts+Qo4H94VUKpxKDFz6QxxwaUZN0mwvi7L3lpOI7BqPCq7lgDh3XLl0dpeXwfcVIitlrYrw== integrity sha512-ql6P2LzhBTTDfzKts+Qo4H94VUKpxKDFz6QxxwaUZN0mwvi7L3lpOI7BqPCq7lgDh3XLl0dpeXwfcVIitlrYrw==
safe-buffer@^5.1.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"

View File

@@ -1,11 +1,11 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ProfileProvider, NewTabParameters, PartialProfile, TranslateService } from 'tabby-core' import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core'
import { TelnetProfileSettingsComponent } from './components/telnetProfileSettings.component' import { TelnetProfileSettingsComponent } from './components/telnetProfileSettings.component'
import { TelnetTabComponent } from './components/telnetTab.component' import { TelnetTabComponent } from './components/telnetTab.component'
import { TelnetProfile } from './session' import { TelnetProfile } from './session'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TelnetProfilesService extends ProfileProvider<TelnetProfile> { export class TelnetProfilesService extends QuickConnectProfileProvider<TelnetProfile> {
id = 'telnet' id = 'telnet'
name = 'Telnet' name = 'Telnet'
supportsQuickConnect = true supportsQuickConnect = true
@@ -21,6 +21,7 @@ export class TelnetProfilesService extends ProfileProvider<TelnetProfile> {
scripts: [], scripts: [],
input: { backspace: 'backspace' }, input: { backspace: 'backspace' },
}, },
clearServiceMessagesOnConnect: false,
} }
constructor (private translate: TranslateService) { super() } constructor (private translate: TranslateService) { super() }
@@ -95,4 +96,12 @@ export class TelnetProfilesService extends ProfileProvider<TelnetProfile> {
}, },
} }
} }
intoQuickConnectString (profile: TelnetProfile): string | null {
let s = profile.options.host
if (profile.options.port !== 23) {
s = `${s}:${profile.options.port}`
}
return s
}
} }

View File

@@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
import { Socket } from 'net' import { Socket } from 'net'
import colors from 'ansi-colors' import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { Injector } from '@angular/core' import { Injector } from '@angular/core'
import { LogService } from 'tabby-core' import { LogService } from 'tabby-core'
import { BaseSession, BaseTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal' import { BaseSession, ConnectableTerminalProfile, InputProcessingOptions, InputProcessor, LoginScriptsOptions, SessionMiddleware, StreamProcessingOptions, TerminalStreamProcessor } from 'tabby-terminal'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
export interface TelnetProfile extends BaseTerminalProfile { export interface TelnetProfile extends ConnectableTerminalProfile {
options: TelnetProfileOptions options: TelnetProfileOptions
} }

View File

@@ -1,4 +1,4 @@
import { Observable, Subject, first, auditTime } from 'rxjs' import { Observable, Subject, first, auditTime, debounce, interval } from 'rxjs'
import { Spinner } from 'cli-spinner' import { Spinner } from 'cli-spinner'
import colors from 'ansi-colors' import colors from 'ansi-colors'
import { NgZone, OnInit, OnDestroy, Injector, ViewChild, HostBinding, Input, ElementRef, InjectFlags, Component } from '@angular/core' import { NgZone, OnInit, OnDestroy, Injector, ViewChild, HostBinding, Input, ElementRef, InjectFlags, Component } from '@angular/core'
@@ -14,6 +14,9 @@ import { TerminalDecorator } from './decorator'
import { SearchPanelComponent } from '../components/searchPanel.component' import { SearchPanelComponent } from '../components/searchPanel.component'
import { MultifocusService } from '../services/multifocus.service' import { MultifocusService } from '../services/multifocus.service'
const INACTIVE_TAB_UNLOAD_DELAY = 1000 * 30
/** /**
* A class to base your custom terminal tabs on * A class to base your custom terminal tabs on
*/ */
@@ -147,11 +150,13 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
}, },
}, },
}) })
private spinnerActive = false private spinnerActive = false
private spinnerPaused = false private spinnerPaused = false
private toolbarRevealTimeout = new ResettableTimeout(() => { private toolbarRevealTimeout = new ResettableTimeout(() => {
this.revealToolbar = false this.revealToolbar = false
}, 1000) }, 1000)
private frontendWriteLock = Promise.resolve() private frontendWriteLock = Promise.resolve()
get input$ (): Observable<Buffer> { get input$ (): Observable<Buffer> {
@@ -408,6 +413,24 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
this.blurred$.subscribe(() => { this.blurred$.subscribe(() => {
this.multifocus.cancel() this.multifocus.cancel()
}) })
this.visibility$
.pipe(debounce(visibility => interval(visibility ? 0 : INACTIVE_TAB_UNLOAD_DELAY)))
.subscribe(visibility => {
if (this.frontend instanceof XTermFrontend) {
if (visibility) {
// this.frontend.resizeHandler()
const term = this.frontend.xterm as any
term._core._renderService.clear()
term._core._renderService.handleResize(term.cols, term.rows)
} else {
this.frontend.xterm.element?.querySelectorAll('canvas').forEach(c => {
c.height = c.width = 0
c.style.height = c.style.width = '0px'
})
}
}
})
} }
protected onFrontendReady (): void { protected onFrontendReady (): void {

View File

@@ -4,7 +4,7 @@ import { Injector, Component } from '@angular/core'
import { first } from 'rxjs' import { first } from 'rxjs'
import { BaseTerminalProfile } from './interfaces' import { ConnectableTerminalProfile } from './interfaces'
import { BaseTerminalTabComponent } from './baseTerminalTab.component' import { BaseTerminalTabComponent } from './baseTerminalTab.component'
import { GetRecoveryTokenOptions, RecoveryToken } from 'tabby-core' import { GetRecoveryTokenOptions, RecoveryToken } from 'tabby-core'
@@ -13,7 +13,7 @@ import { GetRecoveryTokenOptions, RecoveryToken } from 'tabby-core'
* A class to base your custom connectable terminal tabs on * A class to base your custom connectable terminal tabs on
*/ */
@Component({ template: '' }) @Component({ template: '' })
export abstract class ConnectableTerminalTabComponent<P extends BaseTerminalProfile> extends BaseTerminalTabComponent<P> { export abstract class ConnectableTerminalTabComponent<P extends ConnectableTerminalProfile> extends BaseTerminalTabComponent<P> {
protected reconnectOffered = false protected reconnectOffered = false
protected isDisconnectedByHand = false protected isDisconnectedByHand = false
@@ -57,6 +57,9 @@ export abstract class ConnectableTerminalTabComponent<P extends BaseTerminalProf
async initializeSession (): Promise<void> { async initializeSession (): Promise<void> {
this.reconnectOffered = false this.reconnectOffered = false
this.isDisconnectedByHand = false this.isDisconnectedByHand = false
if (this.profile.clearServiceMessagesOnConnect) {
this.frontend?.clear()
}
} }
/** /**

View File

@@ -1,4 +1,4 @@
import { Profile } from 'tabby-core' import { ConnectableProfile, Profile } from 'tabby-core'
export interface ResizeEvent { export interface ResizeEvent {
columns: number columns: number
@@ -19,3 +19,5 @@ export interface TerminalColorScheme {
export interface BaseTerminalProfile extends Profile { export interface BaseTerminalProfile extends Profile {
terminalColorScheme?: TerminalColorScheme terminalColorScheme?: TerminalColorScheme
} }
export interface ConnectableTerminalProfile extends BaseTerminalProfile, ConnectableProfile {}

View File

@@ -1,5 +1,47 @@
h3.mb-3(translate) Color schemes h3.mb-3(translate) Color schemes
.form-line.mb-4
.header
.title(translate) Switch color scheme
.btn-group(role='group')
input.btn-check(
type='radio',
name='colorSchemeMode',
[(ngModel)]='config.store.appearance.colorSchemeMode',
(ngModelChange)='config.save()',
id='colorSchemeModeAuto',
[value]='"auto"'
)
label.btn.btn-secondary(
for='colorSchemeModeAuto'
)
span(translate) From system
input.btn-check(
type='radio',
name='colorSchemeMode',
[(ngModel)]='config.store.appearance.colorSchemeMode',
(ngModelChange)='config.save()',
id='colorSchemeModeDark',
[value]='"dark"'
)
label.btn.btn-secondary(
for='colorSchemeModeDark'
)
span(translate) Always dark
input.btn-check(
type='radio',
name='colorSchemeMode',
[(ngModel)]='config.store.appearance.colorSchemeMode',
(ngModelChange)='config.save()',
id='colorSchemeModeLight',
[value]='"light"'
)
label.btn.btn-secondary(
for='colorSchemeModeLight'
)
span(translate) Always light
ul.nav-tabs(ngbNav, #nav='ngbNav', [activeId]='defaultTab') ul.nav-tabs(ngbNav, #nav='ngbNav', [activeId]='defaultTab')
li(ngbNavItem='dark') li(ngbNavItem='dark')
a(ngbNavLink, translate) Dark mode a(ngbNavLink, translate) Dark mode

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { PlatformService } from 'tabby-core' import { ConfigService, PlatformService } from 'tabby-core'
/** @hidden */ /** @hidden */
@Component({ @Component({
@@ -10,6 +10,7 @@ export class ColorSchemeSettingsTabComponent {
constructor ( constructor (
platform: PlatformService, platform: PlatformService,
public config: ConfigService,
) { ) {
this.defaultTab = platform.getTheme() this.defaultTab = platform.getTheme()
} }

View File

@@ -33,6 +33,7 @@ export class StreamProcessingSettingsComponent {
description: _('Send bytes by typing in hex values'), description: _('Send bytes by typing in hex values'),
}, },
] ]
outputModes = [ outputModes = [
{ {
key: null, key: null,
@@ -45,6 +46,7 @@ export class StreamProcessingSettingsComponent {
description: _('Output is shown as a hexdump'), description: _('Output is shown as a hexdump'),
}, },
] ]
newlineModes = [ newlineModes = [
{ key: null, name: _('Keep') }, { key: null, name: _('Keep') },
{ key: 'strip', name: _('Strip') }, { key: 'strip', name: _('Strip') },

View File

@@ -106,6 +106,7 @@ export class MultifocusService {
return return
} }
const tabs = currentTab.getAllTabs().filter(t => t instanceof BaseTerminalTabComponent) const tabs = currentTab.getAllTabs().filter(t => t instanceof BaseTerminalTabComponent)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
this.start(pane, tabs as any) this.start(pane, tabs as any)
} }
} }

View File

@@ -175,7 +175,7 @@ export class SaveAsProfileContextMenu extends TabContextMenuItemProvider {
const modal = this.ngbModal.open(PromptModalComponent) const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = this.translate.instant('New profile name') modal.componentInstance.prompt = this.translate.instant('New profile name')
modal.componentInstance.value = tab.profile.name modal.componentInstance.value = tab.profile.name
const name = (await modal.result)?.value const name = (await modal.result.catch(() => null))?.value
if (!name) { if (!name) {
return return
} }

999
yarn.lock

File diff suppressed because it is too large Load Diff