mirror of
https://github.com/Eugeny/tabby.git
synced 2025-09-09 18:11:50 +00:00
project rename
This commit is contained in:
1
tabby-core/.gitignore
vendored
Normal file
1
tabby-core/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist
|
31
tabby-core/README.md
Normal file
31
tabby-core/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
Tabby Core Plugin
|
||||
--------------------
|
||||
|
||||
See also: [Settings plugin API](./settings/), [Terminal plugin API](./terminal/), [Local terminal API](./local/)
|
||||
|
||||
* tabbed interface services
|
||||
* toolbar UI
|
||||
* config file management
|
||||
* hotkeys
|
||||
* tab recovery
|
||||
* logging
|
||||
* theming
|
||||
|
||||
Using the API:
|
||||
|
||||
```ts
|
||||
import { AppService, TabContextMenuItemProvider } from 'tabby-core'
|
||||
```
|
||||
|
||||
Exporting your subclasses:
|
||||
|
||||
```ts
|
||||
@NgModule({
|
||||
...
|
||||
providers: [
|
||||
...
|
||||
{ provide: TabContextMenuItemProvider, useClass: MyContextMenu, multi: true },
|
||||
...
|
||||
]
|
||||
})
|
||||
```
|
44
tabby-core/package.json
Normal file
44
tabby-core/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "tabby-core",
|
||||
"version": "1.0.140",
|
||||
"description": "Tabby core",
|
||||
"keywords": [
|
||||
"tabby-builtin-plugin"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"typings": "typings/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "webpack --progress --color --display-modules",
|
||||
"watch": "webpack --progress --color --watch"
|
||||
},
|
||||
"files": [
|
||||
"typings"
|
||||
],
|
||||
"author": "Eugene Pankov",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.0",
|
||||
"bootstrap": "^4.1.3",
|
||||
"clone-deep": "^4.0.1",
|
||||
"core-js": "^3.1.2",
|
||||
"deep-equal": "^2.0.5",
|
||||
"deepmerge": "^4.1.1",
|
||||
"electron-updater": "^4.0.6",
|
||||
"js-yaml": "^4.0.0",
|
||||
"mixpanel": "^0.13.0",
|
||||
"ng2-dnd": "^5.0.2",
|
||||
"ngx-filesize": "^2.0.16",
|
||||
"ngx-perfect-scrollbar": "^10.1.0",
|
||||
"readable-stream": "3.6.0",
|
||||
"uuid": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "^9.1.9",
|
||||
"@angular/common": "^9.1.11",
|
||||
"@angular/core": "^9.1.9",
|
||||
"@angular/forms": "^9.1.11",
|
||||
"@angular/platform-browser": "^9.1.11",
|
||||
"@angular/platform-browser-dynamic": "^9.1.11",
|
||||
"rxjs": "^6.6.3"
|
||||
}
|
||||
}
|
12
tabby-core/src/api/cli.ts
Normal file
12
tabby-core/src/api/cli.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface CLIEvent {
|
||||
argv: any
|
||||
cwd: string
|
||||
secondInstance: boolean
|
||||
}
|
||||
|
||||
export abstract class CLIHandler {
|
||||
priority: number
|
||||
firstMatchOnly: boolean
|
||||
|
||||
abstract handle (event: CLIEvent): Promise<boolean>
|
||||
}
|
37
tabby-core/src/api/configProvider.ts
Normal file
37
tabby-core/src/api/configProvider.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Extend to add your own config options
|
||||
*/
|
||||
export abstract class ConfigProvider {
|
||||
/**
|
||||
* Default values, e.g.
|
||||
*
|
||||
* ```ts
|
||||
* defaults = {
|
||||
* myPlugin: {
|
||||
* foo: 1
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
defaults: any = {}
|
||||
|
||||
/**
|
||||
* [[Platform]] specific defaults, e.g.
|
||||
*
|
||||
* ```ts
|
||||
* platformDefaults = {
|
||||
* [Platform.Windows]: {
|
||||
* myPlugin: {
|
||||
* bar: true
|
||||
* }
|
||||
* },
|
||||
* [Platform.macOS]: {
|
||||
* myPlugin: {
|
||||
* bar: false
|
||||
* }
|
||||
* },
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
platformDefaults: Record<string, any> = {}
|
||||
}
|
13
tabby-core/src/api/fileProvider.ts
Normal file
13
tabby-core/src/api/fileProvider.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export abstract class FileProvider {
|
||||
name: string
|
||||
|
||||
async isAvailable (): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
abstract selectAndStoreFile (description: string): Promise<string>
|
||||
abstract retrieveFile (key: string): Promise<Buffer>
|
||||
}
|
53
tabby-core/src/api/hostApp.ts
Normal file
53
tabby-core/src/api/hostApp.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { Injector } from '@angular/core'
|
||||
import { Logger, LogService } from '../services/log.service'
|
||||
|
||||
export enum Platform {
|
||||
Linux = 'Linux',
|
||||
macOS = 'macOS',
|
||||
Windows = 'Windows',
|
||||
Web = 'Web',
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides interaction with the main process
|
||||
*/
|
||||
export abstract class HostAppService {
|
||||
abstract get platform (): Platform
|
||||
abstract get configPlatform (): Platform
|
||||
|
||||
protected settingsUIRequest = new Subject<void>()
|
||||
protected configChangeBroadcast = new Subject<void>()
|
||||
protected logger: Logger
|
||||
|
||||
/**
|
||||
* Fired when Preferences is selected in the macOS menu
|
||||
*/
|
||||
get settingsUIRequest$ (): Observable<void> { return this.settingsUIRequest }
|
||||
|
||||
/**
|
||||
* Fired when another window modified the config file
|
||||
*/
|
||||
get configChangeBroadcast$ (): Observable<void> { return this.configChangeBroadcast }
|
||||
|
||||
constructor (
|
||||
injector: Injector,
|
||||
) {
|
||||
this.logger = injector.get(LogService).create('hostApp')
|
||||
}
|
||||
|
||||
abstract newWindow (): void
|
||||
|
||||
/**
|
||||
* Notifies other windows of config file changes
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
broadcastConfigChange (_configStore: Record<string, any>): void { }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
emitReady (): void { }
|
||||
|
||||
abstract relaunch (): void
|
||||
|
||||
abstract quit (): void
|
||||
}
|
36
tabby-core/src/api/hostWindow.ts
Normal file
36
tabby-core/src/api/hostWindow.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
|
||||
export abstract class HostWindowService {
|
||||
|
||||
/**
|
||||
* Fired once the window is visible
|
||||
*/
|
||||
get windowShown$ (): Observable<void> { return this.windowShown }
|
||||
|
||||
/**
|
||||
* Fired when the window close button is pressed
|
||||
*/
|
||||
get windowCloseRequest$ (): Observable<void> { return this.windowCloseRequest }
|
||||
get windowMoved$ (): Observable<void> { return this.windowMoved }
|
||||
get windowFocused$ (): Observable<void> { return this.windowFocused }
|
||||
|
||||
protected windowShown = new Subject<void>()
|
||||
protected windowCloseRequest = new Subject<void>()
|
||||
protected windowMoved = new Subject<void>()
|
||||
protected windowFocused = new Subject<void>()
|
||||
|
||||
abstract readonly isFullscreen: boolean
|
||||
abstract reload (): void
|
||||
abstract setTitle (title?: string): void
|
||||
abstract toggleFullscreen (): void
|
||||
abstract minimize (): void
|
||||
abstract isMaximized (): boolean
|
||||
abstract toggleMaximize (): void
|
||||
abstract close (): void
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
openDevTools (): void { }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
bringToFront (): void { }
|
||||
}
|
12
tabby-core/src/api/hotkeyProvider.ts
Normal file
12
tabby-core/src/api/hotkeyProvider.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface HotkeyDescription {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend to provide your own hotkeys. A corresponding [[ConfigProvider]]
|
||||
* must also provide the `hotkeys.foo` config options with the default values
|
||||
*/
|
||||
export abstract class HotkeyProvider {
|
||||
abstract provide (): Promise<HotkeyDescription[]>
|
||||
}
|
33
tabby-core/src/api/index.ts
Normal file
33
tabby-core/src/api/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export { BaseComponent, SubscriptionContainer } from '../components/base.component'
|
||||
export { BaseTabComponent, BaseTabProcess } from '../components/baseTab.component'
|
||||
export { TabHeaderComponent } from '../components/tabHeader.component'
|
||||
export { SplitTabComponent, SplitContainer } from '../components/splitTab.component'
|
||||
export { TabRecoveryProvider, RecoveredTab, RecoveryToken } from './tabRecovery'
|
||||
export { ToolbarButtonProvider, ToolbarButton } from './toolbarButtonProvider'
|
||||
export { ConfigProvider } from './configProvider'
|
||||
export { HotkeyProvider, HotkeyDescription } from './hotkeyProvider'
|
||||
export { Theme } from './theme'
|
||||
export { TabContextMenuItemProvider } from './tabContextMenuProvider'
|
||||
export { SelectorOption } from './selector'
|
||||
export { CLIHandler, CLIEvent } from './cli'
|
||||
export { PlatformService, ClipboardContent, MessageBoxResult, MessageBoxOptions, FileDownload, FileUpload, FileTransfer, HTMLFileUpload, FileUploadOptions } from './platform'
|
||||
export { MenuItemOptions } from './menu'
|
||||
export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
|
||||
export { HostWindowService } from './hostWindow'
|
||||
export { HostAppService, Platform } from './hostApp'
|
||||
export { FileProvider } from './fileProvider'
|
||||
|
||||
export { AppService } from '../services/app.service'
|
||||
export { ConfigService } from '../services/config.service'
|
||||
export { DockingService, Screen } from '../services/docking.service'
|
||||
export { Logger, ConsoleLogger, LogService } from '../services/log.service'
|
||||
export { HomeBaseService } from '../services/homeBase.service'
|
||||
export { HotkeysService } from '../services/hotkeys.service'
|
||||
export { NotificationsService } from '../services/notifications.service'
|
||||
export { ThemesService } from '../services/themes.service'
|
||||
export { SelectorService } from '../services/selector.service'
|
||||
export { TabsService } from '../services/tabs.service'
|
||||
export { UpdaterService } from '../services/updater.service'
|
||||
export { VaultService, Vault, VaultSecret, VAULT_SECRET_TYPE_FILE } from '../services/vault.service'
|
||||
export { FileProvidersService } from '../services/fileProviders.service'
|
||||
export * from '../utils'
|
22
tabby-core/src/api/mainProcess.ts
Normal file
22
tabby-core/src/api/mainProcess.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const BOOTSTRAP_DATA = 'BOOTSTRAP_DATA'
|
||||
|
||||
export interface PluginInfo {
|
||||
name: string
|
||||
description: string
|
||||
packageName: string
|
||||
isBuiltin: boolean
|
||||
version: string
|
||||
author: string
|
||||
homepage?: string
|
||||
path?: string
|
||||
info?: any
|
||||
}
|
||||
|
||||
export interface BootstrapData {
|
||||
config: Record<string, any>
|
||||
executable: string
|
||||
isFirstWindow: boolean
|
||||
windowID: number
|
||||
installedPlugins: PluginInfo[]
|
||||
userPluginsPath: string
|
||||
}
|
9
tabby-core/src/api/menu.ts
Normal file
9
tabby-core/src/api/menu.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface MenuItemOptions {
|
||||
type?: ('normal' | 'separator' | 'submenu' | 'checkbox' | 'radio')
|
||||
label?: string
|
||||
sublabel?: string
|
||||
enabled?: boolean
|
||||
checked?: boolean
|
||||
submenu?: MenuItemOptions[]
|
||||
click?: () => void
|
||||
}
|
210
tabby-core/src/api/platform.ts
Normal file
210
tabby-core/src/api/platform.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { MenuItemOptions } from './menu'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
export interface ClipboardContent {
|
||||
text: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
export interface MessageBoxOptions {
|
||||
type: 'warning'|'error'
|
||||
message: string
|
||||
detail?: string
|
||||
buttons: string[]
|
||||
defaultId?: number
|
||||
}
|
||||
|
||||
export interface MessageBoxResult {
|
||||
response: number
|
||||
}
|
||||
|
||||
export abstract class FileTransfer {
|
||||
abstract getName (): string
|
||||
abstract getSize (): number
|
||||
abstract close (): void
|
||||
|
||||
getSpeed (): number {
|
||||
return this.lastChunkSpeed
|
||||
}
|
||||
|
||||
getCompletedBytes (): number {
|
||||
return this.completedBytes
|
||||
}
|
||||
|
||||
isComplete (): boolean {
|
||||
return this.completedBytes >= this.getSize()
|
||||
}
|
||||
|
||||
isCancelled (): boolean {
|
||||
return this.cancelled
|
||||
}
|
||||
|
||||
cancel (): void {
|
||||
this.cancelled = true
|
||||
this.close()
|
||||
}
|
||||
|
||||
protected increaseProgress (bytes: number): void {
|
||||
this.completedBytes += bytes
|
||||
this.lastChunkSpeed = bytes * 1000 / (Date.now() - this.lastChunkStartTime)
|
||||
this.lastChunkStartTime = Date.now()
|
||||
}
|
||||
|
||||
private completedBytes = 0
|
||||
private lastChunkStartTime = Date.now()
|
||||
private lastChunkSpeed = 0
|
||||
private cancelled = false
|
||||
}
|
||||
|
||||
export abstract class FileDownload extends FileTransfer {
|
||||
abstract write (buffer: Buffer): Promise<void>
|
||||
}
|
||||
|
||||
export abstract class FileUpload extends FileTransfer {
|
||||
abstract read (): Promise<Buffer>
|
||||
|
||||
async readAll (): Promise<Buffer> {
|
||||
const buffers: Buffer[] = []
|
||||
while (true) {
|
||||
const buf = await this.read()
|
||||
if (!buf.length) {
|
||||
break
|
||||
}
|
||||
buffers.push(Buffer.from(buf))
|
||||
}
|
||||
return Buffer.concat(buffers)
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileUploadOptions {
|
||||
multiple: boolean
|
||||
}
|
||||
|
||||
export abstract class PlatformService {
|
||||
supportsWindowControls = false
|
||||
|
||||
get fileTransferStarted$ (): Observable<FileTransfer> { return this.fileTransferStarted }
|
||||
get displayMetricsChanged$ (): Observable<void> { return this.displayMetricsChanged }
|
||||
|
||||
protected fileTransferStarted = new Subject<FileTransfer>()
|
||||
protected displayMetricsChanged = new Subject<void>()
|
||||
|
||||
abstract readClipboard (): string
|
||||
abstract setClipboard (content: ClipboardContent): void
|
||||
abstract loadConfig (): Promise<string>
|
||||
abstract saveConfig (content: string): Promise<void>
|
||||
|
||||
abstract startDownload (name: string, size: number): Promise<FileDownload|null>
|
||||
abstract startUpload (options?: FileUploadOptions): Promise<FileUpload[]>
|
||||
|
||||
startUploadFromDragEvent (event: DragEvent, multiple = false): FileUpload[] {
|
||||
const result: FileUpload[] = []
|
||||
if (!event.dataTransfer) {
|
||||
return []
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < event.dataTransfer.files.length; i++) {
|
||||
const file = event.dataTransfer.files[i]
|
||||
const transfer = new HTMLFileUpload(file)
|
||||
this.fileTransferStarted.next(transfer)
|
||||
result.push(transfer)
|
||||
if (!multiple) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getConfigPath (): string|null {
|
||||
return null
|
||||
}
|
||||
|
||||
showItemInFolder (path: string): void {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async isProcessRunning (name: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async installPlugin (name: string, version: string): Promise<void> {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async uninstallPlugin (name: string): Promise<void> {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
getWinSCPPath (): string|null {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
exec (app: string, argv: string[]): void {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
isShellIntegrationSupported (): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async isShellIntegrationInstalled (): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async installShellIntegration (): Promise<void> {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async uninstallShellIntegration (): Promise<void> {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
openPath (path: string): void {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
abstract getOSRelease (): string
|
||||
abstract getAppVersion (): string
|
||||
abstract openExternal (url: string): void
|
||||
abstract listFonts (): Promise<string[]>
|
||||
abstract setErrorHandler (handler: (_: any) => void): void
|
||||
abstract popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void
|
||||
abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
|
||||
abstract quit (): void
|
||||
}
|
||||
|
||||
export class HTMLFileUpload extends FileUpload {
|
||||
private stream: ReadableStream
|
||||
private reader: ReadableStreamDefaultReader
|
||||
|
||||
constructor (private file: File) {
|
||||
super()
|
||||
this.stream = this.file.stream()
|
||||
this.reader = this.stream.getReader()
|
||||
}
|
||||
|
||||
getName (): string {
|
||||
return this.file.name
|
||||
}
|
||||
|
||||
getSize (): number {
|
||||
return this.file.size
|
||||
}
|
||||
|
||||
async read (): Promise<Buffer> {
|
||||
const result: any = await this.reader.read()
|
||||
if (result.done || !result.value) {
|
||||
return Buffer.from('')
|
||||
}
|
||||
const chunk = Buffer.from(result.value)
|
||||
this.increaseProgress(chunk.length)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
bringToFront (): void { }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
close (): void { }
|
||||
}
|
8
tabby-core/src/api/selector.ts
Normal file
8
tabby-core/src/api/selector.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SelectorOption<T> {
|
||||
name: string
|
||||
description?: string
|
||||
result?: T
|
||||
icon?: string
|
||||
freeInputPattern?: string
|
||||
callback?: (string?) => void
|
||||
}
|
12
tabby-core/src/api/tabContextMenuProvider.ts
Normal file
12
tabby-core/src/api/tabContextMenuProvider.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { TabHeaderComponent } from '../components/tabHeader.component'
|
||||
import { MenuItemOptions } from './menu'
|
||||
|
||||
/**
|
||||
* Extend to add items to the tab header's context menu
|
||||
*/
|
||||
export abstract class TabContextMenuItemProvider {
|
||||
weight = 0
|
||||
|
||||
abstract getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]>
|
||||
}
|
61
tabby-core/src/api/tabRecovery.ts
Normal file
61
tabby-core/src/api/tabRecovery.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import deepClone from 'clone-deep'
|
||||
import { TabComponentType } from '../services/tabs.service'
|
||||
|
||||
export interface RecoveredTab {
|
||||
/**
|
||||
* Component type to be instantiated
|
||||
*/
|
||||
type: TabComponentType
|
||||
|
||||
/**
|
||||
* Component instance inputs
|
||||
*/
|
||||
options?: any
|
||||
}
|
||||
|
||||
export interface RecoveryToken {
|
||||
[_: string]: any
|
||||
type: string
|
||||
tabColor?: string|null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend to enable recovery for your custom tab.
|
||||
* This works in conjunction with [[getRecoveryToken()]]
|
||||
*
|
||||
* Tabby will try to find any [[TabRecoveryProvider]] that is able to process
|
||||
* the recovery token previously returned by [[getRecoveryToken]].
|
||||
*
|
||||
* Recommended token format:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* type: 'my-tab-type',
|
||||
* foo: 'bar',
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class TabRecoveryProvider {
|
||||
/**
|
||||
* @param recoveryToken a recovery token found in the saved tabs list
|
||||
* @returns [[boolean]] whether this [[TabRecoveryProvider]] can recover a tab from this token
|
||||
*/
|
||||
|
||||
abstract applicableTo (recoveryToken: RecoveryToken): Promise<boolean>
|
||||
/**
|
||||
* @param recoveryToken a recovery token found in the saved tabs list
|
||||
* @returns [[RecoveredTab]] descriptor containing tab type and component inputs
|
||||
* or `null` if this token is from a different tab type or is not supported
|
||||
*/
|
||||
abstract recover (recoveryToken: RecoveryToken): Promise<RecoveredTab>
|
||||
|
||||
/**
|
||||
* @param recoveryToken a recovery token found in the saved tabs list
|
||||
* @returns [[RecoveryToken]] a new recovery token to create the duplicate tab from
|
||||
*
|
||||
* The default implementation just returns a deep copy of the original token
|
||||
*/
|
||||
duplicate (recoveryToken: RecoveryToken): RecoveryToken {
|
||||
return deepClone(recoveryToken)
|
||||
}
|
||||
}
|
16
tabby-core/src/api/theme.ts
Normal file
16
tabby-core/src/api/theme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Extend to add a custom CSS theme
|
||||
*/
|
||||
export abstract class Theme {
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Complete CSS stylesheet
|
||||
*/
|
||||
css: string
|
||||
|
||||
terminalBackground: string
|
||||
|
||||
macOSWindowButtonsInsetX?: number
|
||||
macOSWindowButtonsInsetY?: number
|
||||
}
|
37
tabby-core/src/api/toolbarButtonProvider.ts
Normal file
37
tabby-core/src/api/toolbarButtonProvider.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* See [[ToolbarButtonProvider]]
|
||||
*/
|
||||
export interface ToolbarButton {
|
||||
/**
|
||||
* Raw SVG icon code
|
||||
*/
|
||||
icon?: string
|
||||
|
||||
title: string
|
||||
|
||||
/**
|
||||
* Optional Touch Bar icon ID
|
||||
*/
|
||||
touchBarNSImage?: string
|
||||
|
||||
/**
|
||||
* Optional Touch Bar button label
|
||||
*/
|
||||
touchBarTitle?: string
|
||||
|
||||
weight?: number
|
||||
|
||||
click?: () => void
|
||||
|
||||
submenu?: () => Promise<ToolbarButton[]>
|
||||
|
||||
/** @hidden */
|
||||
submenuItems?: ToolbarButton[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend to add buttons to the toolbar
|
||||
*/
|
||||
export abstract class ToolbarButtonProvider {
|
||||
abstract provide (): ToolbarButton[]
|
||||
}
|
21
tabby-core/src/cli.ts
Normal file
21
tabby-core/src/cli.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService } from './api/hostApp'
|
||||
import { CLIHandler, CLIEvent } from './api/cli'
|
||||
|
||||
@Injectable()
|
||||
export class LastCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = -999
|
||||
|
||||
constructor (private hostApp: HostAppService) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
if (event.secondInstance) {
|
||||
this.hostApp.newWindow()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
118
tabby-core/src/components/appRoot.component.pug
Normal file
118
tabby-core/src/components/appRoot.component.pug
Normal file
@@ -0,0 +1,118 @@
|
||||
title-bar(
|
||||
*ngIf='ready && !hostWindow.isFullScreen && config.store.appearance.frame == "full" && config.store.appearance.dock == "off"',
|
||||
[class.inset]='hostApp.platform == Platform.macOS && !hostWindow.isFullScreen'
|
||||
)
|
||||
|
||||
.content(
|
||||
*ngIf='ready',
|
||||
[class.tabs-on-top]='config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left"',
|
||||
[class.tabs-on-side]='hasVerticalTabs()',
|
||||
)
|
||||
.tab-bar
|
||||
.inset.background(*ngIf='hostApp.platform == Platform.macOS \
|
||||
&& !hostWindow.isFullScreen \
|
||||
&& config.store.appearance.frame == "thin" \
|
||||
&& (config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left")')
|
||||
.tabs(
|
||||
dnd-sortable-container,
|
||||
[sortableData]='app.tabs',
|
||||
)
|
||||
tab-header(
|
||||
*ngFor='let tab of app.tabs; let idx = index',
|
||||
dnd-sortable,
|
||||
[sortableIndex]='idx',
|
||||
(onDragStart)='onTabDragStart()',
|
||||
(onDragEnd)='onTabDragEnd()',
|
||||
[index]='idx',
|
||||
[tab]='tab',
|
||||
[active]='tab == app.activeTab',
|
||||
@animateTab,
|
||||
[@.disabled]='hasVerticalTabs()',
|
||||
(click)='app.selectTab(tab)',
|
||||
[class.fully-draggable]='hostApp.platform != Platform.macOS',
|
||||
[class.drag-region]='hostApp.platform == Platform.macOS && !tabsDragging',
|
||||
)
|
||||
|
||||
.btn-group.background
|
||||
.d-flex(
|
||||
*ngFor='let button of leftToolbarButtons',
|
||||
ngbDropdown,
|
||||
(openChange)='generateButtonSubmenu(button)',
|
||||
)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
[title]='button.title',
|
||||
(click)='button.click && button.click()',
|
||||
[fastHtmlBind]='button.icon',
|
||||
ngbDropdownToggle,
|
||||
)
|
||||
div(*ngIf='button.submenu', ngbDropdownMenu)
|
||||
button.dropdown-item.d-flex.align-items-center(
|
||||
*ngFor='let item of button.submenuItems',
|
||||
(click)='item.click()',
|
||||
ngbDropdownItem,
|
||||
)
|
||||
.icon-wrapper(
|
||||
*ngIf='hasIcons(button.submenuItems)',
|
||||
[fastHtmlBind]='item.icon'
|
||||
)
|
||||
div([class.ml-3]='hasIcons(button.submenuItems)') {{item.title}}
|
||||
|
||||
.d-flex(
|
||||
*ngIf='activeTransfers.length > 0',
|
||||
ngbDropdown,
|
||||
[(open)]='activeTransfersDropdownOpen'
|
||||
)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
title='File transfers',
|
||||
ngbDropdownToggle
|
||||
) !{require('../icons/download-solid.svg')}
|
||||
transfers-menu(ngbDropdownMenu, [(transfers)]='activeTransfers')
|
||||
|
||||
.drag-space.background([class.persistent]='config.store.appearance.frame == "thin" && hostApp.platform != Platform.macOS')
|
||||
|
||||
.btn-group.background
|
||||
.d-flex(
|
||||
*ngFor='let button of rightToolbarButtons',
|
||||
ngbDropdown,
|
||||
(openChange)='generateButtonSubmenu(button)',
|
||||
)
|
||||
button.btn.btn-secondary.btn-tab-bar(
|
||||
[title]='button.title',
|
||||
(click)='button.click && button.click()',
|
||||
[fastHtmlBind]='button.icon',
|
||||
ngbDropdownToggle,
|
||||
)
|
||||
div(*ngIf='button.submenu', ngbDropdownMenu)
|
||||
button.dropdown-item.d-flex.align-items-center(
|
||||
*ngFor='let item of button.submenuItems',
|
||||
(click)='item.click()',
|
||||
ngbDropdownItem,
|
||||
)
|
||||
.icon-wrapper(
|
||||
*ngIf='hasIcons(button.submenuItems)',
|
||||
[fastHtmlBind]='item.icon'
|
||||
)
|
||||
div([class.ml-3]='hasIcons(button.submenuItems)') {{item.title}}
|
||||
|
||||
button.btn.btn-secondary.btn-tab-bar.btn-update(
|
||||
*ngIf='updatesAvailable',
|
||||
title='Update available - Click to install',
|
||||
(click)='updater.update()'
|
||||
) !{require('../icons/gift.svg')}
|
||||
|
||||
window-controls.background(
|
||||
*ngIf='config.store.appearance.frame == "thin" \
|
||||
&& (hostApp.platform == Platform.Windows || hostApp.platform == Platform.Linux)',
|
||||
)
|
||||
|
||||
.content
|
||||
start-page.content-tab.content-tab-active(*ngIf='ready && app.tabs.length == 0')
|
||||
|
||||
tab-body.content-tab(
|
||||
*ngFor='let tab of unsortedTabs',
|
||||
[class.content-tab-active]='tab == app.activeTab',
|
||||
[active]='tab == app.activeTab',
|
||||
[tab]='tab',
|
||||
)
|
||||
|
||||
ng-template(ngbModalContainer)
|
180
tabby-core/src/components/appRoot.component.scss
Normal file
180
tabby-core/src/components/appRoot.component.scss
Normal file
@@ -0,0 +1,180 @@
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
will-change: transform;
|
||||
cursor: default;
|
||||
animation: 0.5s ease-out fadeIn;
|
||||
transition: 0.25s background;
|
||||
}
|
||||
|
||||
$tabs-height: 38px;
|
||||
$tab-border-radius: 4px;
|
||||
$side-tab-width: 200px;
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100vw;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
|
||||
&.tabs-on-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.tabs-on-side {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
&.tabs-on-top {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.content.tabs-on-side > .tab-bar {
|
||||
height: 100%;
|
||||
width: $side-tab-width;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex-direction: column;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
|
||||
.tabs {
|
||||
width: $side-tab-width;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
|
||||
tab-header {
|
||||
flex: 0 0 $tabs-height;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-space {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
&>.inset {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tab-bar {
|
||||
flex: none;
|
||||
height: $tabs-height;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.btn-tab-bar {
|
||||
line-height: $tabs-height + 2px;
|
||||
height: $tabs-height;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: 0.25s all;
|
||||
font-size: 12px;
|
||||
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
align-items: center;
|
||||
|
||||
&.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&>.tabs {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&>.drag-space {
|
||||
min-width: 1px;
|
||||
flex: 1 0 1%;
|
||||
margin-top: 2px; // for window resizing
|
||||
-webkit-app-region: drag;
|
||||
|
||||
&.persistent {
|
||||
min-width: 72px; // 2 x 36 px height, ie 2 squares
|
||||
}
|
||||
}
|
||||
|
||||
& > .inset {
|
||||
width: 85px;
|
||||
height: $tabs-height;
|
||||
flex: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
window-controls {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 0;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
> .content-tab {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
left: -1000%;
|
||||
|
||||
&.content-tab-active {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hotkey-hint {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
::ng-deep .btn-tab-bar svg,
|
||||
::ng-deep .btn-tab-bar + .dropdown-menu svg {
|
||||
width: 22px;
|
||||
height: 16px;
|
||||
fill: white;
|
||||
fill-opacity: 0.75;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
::ng-deep .btn-update svg {
|
||||
fill: cyan;
|
||||
}
|
206
tabby-core/src/components/appRoot.component.ts
Normal file
206
tabby-core/src/components/appRoot.component.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Inject, Input, HostListener, HostBinding } from '@angular/core'
|
||||
import { trigger, style, animate, transition, state } from '@angular/animations'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
import { HostAppService, Platform } from '../api/hostApp'
|
||||
import { HotkeysService } from '../services/hotkeys.service'
|
||||
import { Logger, LogService } from '../services/log.service'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { ThemesService } from '../services/themes.service'
|
||||
import { UpdaterService } from '../services/updater.service'
|
||||
|
||||
import { BaseTabComponent } from './baseTab.component'
|
||||
import { SafeModeModalComponent } from './safeModeModal.component'
|
||||
import { AppService, FileTransfer, HostWindowService, PlatformService, ToolbarButton, ToolbarButtonProvider } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: require('./appRoot.component.pug'),
|
||||
styles: [require('./appRoot.component.scss')],
|
||||
animations: [
|
||||
trigger('animateTab', [
|
||||
state('in', style({
|
||||
'flex-basis': '200px',
|
||||
width: '200px',
|
||||
})),
|
||||
transition(':enter', [
|
||||
style({
|
||||
'flex-basis': '1px',
|
||||
width: '1px',
|
||||
}),
|
||||
animate('250ms ease-in-out', style({
|
||||
'flex-basis': '200px',
|
||||
width: '200px',
|
||||
})),
|
||||
]),
|
||||
transition(':leave', [
|
||||
style({
|
||||
'flex-basis': '200px',
|
||||
width: '200px',
|
||||
}),
|
||||
animate('250ms ease-in-out', style({
|
||||
'flex-basis': '1px',
|
||||
width: '1px',
|
||||
})),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class AppRootComponent {
|
||||
Platform = Platform
|
||||
@Input() ready = false
|
||||
@Input() leftToolbarButtons: ToolbarButton[]
|
||||
@Input() rightToolbarButtons: ToolbarButton[]
|
||||
@HostBinding('class.platform-win32') platformClassWindows = process.platform === 'win32'
|
||||
@HostBinding('class.platform-darwin') platformClassMacOS = process.platform === 'darwin'
|
||||
@HostBinding('class.platform-linux') platformClassLinux = process.platform === 'linux'
|
||||
@HostBinding('class.no-tabs') noTabs = true
|
||||
tabsDragging = false
|
||||
unsortedTabs: BaseTabComponent[] = []
|
||||
updatesAvailable = false
|
||||
activeTransfers: FileTransfer[] = []
|
||||
activeTransfersDropdownOpen = false
|
||||
private logger: Logger
|
||||
|
||||
private constructor (
|
||||
private hotkeys: HotkeysService,
|
||||
private updater: UpdaterService,
|
||||
public hostWindow: HostWindowService,
|
||||
public hostApp: HostAppService,
|
||||
public config: ConfigService,
|
||||
public app: AppService,
|
||||
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
|
||||
platform: PlatformService,
|
||||
log: LogService,
|
||||
ngbModal: NgbModal,
|
||||
_themes: ThemesService,
|
||||
) {
|
||||
this.logger = log.create('main')
|
||||
this.logger.info('v', platform.getAppVersion())
|
||||
|
||||
this.hotkeys.matchedHotkey.subscribe((hotkey: string) => {
|
||||
if (hotkey.startsWith('tab-')) {
|
||||
const index = parseInt(hotkey.split('-')[1])
|
||||
if (index <= this.app.tabs.length) {
|
||||
this.app.selectTab(this.app.tabs[index - 1])
|
||||
}
|
||||
}
|
||||
if (this.app.activeTab) {
|
||||
if (hotkey === 'close-tab') {
|
||||
this.app.closeTab(this.app.activeTab, true)
|
||||
}
|
||||
if (hotkey === 'toggle-last-tab') {
|
||||
this.app.toggleLastTab()
|
||||
}
|
||||
if (hotkey === 'next-tab') {
|
||||
this.app.nextTab()
|
||||
}
|
||||
if (hotkey === 'previous-tab') {
|
||||
this.app.previousTab()
|
||||
}
|
||||
if (hotkey === 'move-tab-left') {
|
||||
this.app.moveSelectedTabLeft()
|
||||
}
|
||||
if (hotkey === 'move-tab-right') {
|
||||
this.app.moveSelectedTabRight()
|
||||
}
|
||||
if (hotkey === 'reopen-tab') {
|
||||
this.app.reopenLastTab()
|
||||
}
|
||||
}
|
||||
if (hotkey === 'toggle-fullscreen') {
|
||||
hostWindow.toggleFullscreen()
|
||||
}
|
||||
})
|
||||
|
||||
this.hostWindow.windowCloseRequest$.subscribe(async () => {
|
||||
this.app.closeWindow()
|
||||
})
|
||||
|
||||
if (window['safeModeReason']) {
|
||||
ngbModal.open(SafeModeModalComponent)
|
||||
}
|
||||
|
||||
this.app.tabOpened$.subscribe(tab => {
|
||||
this.unsortedTabs.push(tab)
|
||||
this.noTabs = false
|
||||
})
|
||||
|
||||
this.app.tabClosed$.subscribe(tab => {
|
||||
this.unsortedTabs = this.unsortedTabs.filter(x => x !== tab)
|
||||
this.noTabs = app.tabs.length === 0
|
||||
})
|
||||
|
||||
platform.fileTransferStarted$.subscribe(transfer => {
|
||||
this.activeTransfers.push(transfer)
|
||||
this.activeTransfersDropdownOpen = true
|
||||
})
|
||||
|
||||
config.ready$.toPromise().then(() => {
|
||||
this.leftToolbarButtons = this.getToolbarButtons(false)
|
||||
this.rightToolbarButtons = this.getToolbarButtons(true)
|
||||
|
||||
setInterval(() => {
|
||||
if (this.config.store.enableAutomaticUpdates) {
|
||||
this.updater.check().then(available => {
|
||||
this.updatesAvailable = available
|
||||
})
|
||||
}
|
||||
}, 3600 * 12 * 1000)
|
||||
})
|
||||
}
|
||||
|
||||
async ngOnInit () {
|
||||
this.config.ready$.toPromise().then(() => {
|
||||
this.ready = true
|
||||
this.app.emitReady()
|
||||
})
|
||||
}
|
||||
|
||||
@HostListener('dragover')
|
||||
onDragOver () {
|
||||
return false
|
||||
}
|
||||
|
||||
@HostListener('drop')
|
||||
onDrop () {
|
||||
return false
|
||||
}
|
||||
|
||||
hasVerticalTabs () {
|
||||
return this.config.store.appearance.tabsLocation === 'left' || this.config.store.appearance.tabsLocation === 'right'
|
||||
}
|
||||
|
||||
onTabDragStart () {
|
||||
this.tabsDragging = true
|
||||
}
|
||||
|
||||
onTabDragEnd () {
|
||||
setTimeout(() => {
|
||||
this.tabsDragging = false
|
||||
this.app.emitTabsChanged()
|
||||
})
|
||||
}
|
||||
|
||||
async generateButtonSubmenu (button: ToolbarButton) {
|
||||
if (button.submenu) {
|
||||
button.submenuItems = await button.submenu()
|
||||
}
|
||||
}
|
||||
|
||||
hasIcons (submenuItems: ToolbarButton[]): boolean {
|
||||
return submenuItems.some(x => !!x.icon)
|
||||
}
|
||||
|
||||
private getToolbarButtons (aboveZero: boolean): ToolbarButton[] {
|
||||
let buttons: ToolbarButton[] = []
|
||||
this.config.enabledServices(this.toolbarButtonProviders).forEach(provider => {
|
||||
buttons = buttons.concat(provider.provide())
|
||||
})
|
||||
return buttons
|
||||
.filter(button => (button.weight ?? 0) > 0 === aboveZero)
|
||||
.sort((a: ToolbarButton, b: ToolbarButton) => (a.weight ?? 0) - (b.weight ?? 0))
|
||||
}
|
||||
}
|
62
tabby-core/src/components/base.component.ts
Normal file
62
tabby-core/src/components/base.component.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Observable, Subscription, Subject } from 'rxjs'
|
||||
|
||||
interface CancellableEvent {
|
||||
element: HTMLElement
|
||||
event: string
|
||||
handler: EventListenerOrEventListenerObject
|
||||
options?: boolean|AddEventListenerOptions
|
||||
}
|
||||
|
||||
export class SubscriptionContainer {
|
||||
private subscriptions: Subscription[] = []
|
||||
private events: CancellableEvent[] = []
|
||||
|
||||
isEmpty (): boolean {
|
||||
return this.events.length === 0 && this.subscriptions.length === 0
|
||||
}
|
||||
|
||||
addEventListener (element: HTMLElement, event: string, handler: EventListenerOrEventListenerObject, options?: boolean|AddEventListenerOptions): void {
|
||||
element.addEventListener(event, handler, options)
|
||||
this.events.push({
|
||||
element,
|
||||
event,
|
||||
handler,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
subscribe <T> (observable: Observable<T>, handler: (v: T) => void): void {
|
||||
this.subscriptions.push(observable.subscribe(handler))
|
||||
}
|
||||
|
||||
cancelAll (): void {
|
||||
for (const s of this.subscriptions) {
|
||||
s.unsubscribe()
|
||||
}
|
||||
for (const e of this.events) {
|
||||
e.element.removeEventListener(e.event, e.handler, e.options)
|
||||
}
|
||||
this.subscriptions = []
|
||||
this.events = []
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseComponent {
|
||||
protected get destroyed$ (): Observable<void> { return this._destroyed }
|
||||
private _destroyed = new Subject<void>()
|
||||
private _subscriptionContainer = new SubscriptionContainer()
|
||||
|
||||
addEventListenerUntilDestroyed (element: HTMLElement, event: string, handler: EventListenerOrEventListenerObject, options?: boolean|AddEventListenerOptions): void {
|
||||
this._subscriptionContainer.addEventListener(element, event, handler, options)
|
||||
}
|
||||
|
||||
subscribeUntilDestroyed <T> (observable: Observable<T>, handler: (v: T) => void): void {
|
||||
this._subscriptionContainer.subscribe(observable, handler)
|
||||
}
|
||||
|
||||
ngOnDestroy (): void {
|
||||
this._destroyed.next()
|
||||
this._destroyed.complete()
|
||||
this._subscriptionContainer.cancelAll()
|
||||
}
|
||||
}
|
176
tabby-core/src/components/baseTab.component.ts
Normal file
176
tabby-core/src/components/baseTab.component.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { ViewRef } from '@angular/core'
|
||||
import { RecoveryToken } from '../api/tabRecovery'
|
||||
import { BaseComponent } from './base.component'
|
||||
|
||||
/**
|
||||
* Represents an active "process" inside a tab,
|
||||
* for example, a user process running inside a terminal tab
|
||||
*/
|
||||
export interface BaseTabProcess {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for custom tab components
|
||||
*/
|
||||
export abstract class BaseTabComponent extends BaseComponent {
|
||||
/**
|
||||
* Parent tab (usually a SplitTabComponent)
|
||||
*/
|
||||
parent: BaseTabComponent|null = null
|
||||
|
||||
/**
|
||||
* Current tab title
|
||||
*/
|
||||
title: string
|
||||
|
||||
/**
|
||||
* User-defined title override
|
||||
*/
|
||||
customTitle: string
|
||||
|
||||
/**
|
||||
* Last tab activity state
|
||||
*/
|
||||
hasActivity = false
|
||||
|
||||
/**
|
||||
* ViewRef to the tab DOM element
|
||||
*/
|
||||
hostView: ViewRef
|
||||
|
||||
/**
|
||||
* CSS color override for the tab's header
|
||||
*/
|
||||
color: string|null = null
|
||||
|
||||
hasFocus = false
|
||||
|
||||
/**
|
||||
* Ping this if your recovery state has been changed and you want
|
||||
* your tab state to be saved sooner
|
||||
*/
|
||||
protected recoveryStateChangedHint = new Subject<void>()
|
||||
|
||||
private progressClearTimeout: number
|
||||
private titleChange = new Subject<string>()
|
||||
private focused = new Subject<void>()
|
||||
private blurred = new Subject<void>()
|
||||
private progress = new Subject<number|null>()
|
||||
private activity = new Subject<boolean>()
|
||||
private destroyed = new Subject<void>()
|
||||
|
||||
get focused$ (): Observable<void> { return this.focused }
|
||||
get blurred$ (): Observable<void> { return this.blurred }
|
||||
get titleChange$ (): Observable<string> { return this.titleChange }
|
||||
get progress$ (): Observable<number|null> { return this.progress }
|
||||
get activity$ (): Observable<boolean> { return this.activity }
|
||||
get destroyed$ (): Observable<void> { return this.destroyed }
|
||||
get recoveryStateChangedHint$ (): Observable<void> { return this.recoveryStateChangedHint }
|
||||
|
||||
protected constructor () {
|
||||
super()
|
||||
this.focused$.subscribe(() => {
|
||||
this.hasFocus = true
|
||||
})
|
||||
this.blurred$.subscribe(() => {
|
||||
this.hasFocus = false
|
||||
})
|
||||
}
|
||||
|
||||
setTitle (title: string): void {
|
||||
this.title = title
|
||||
if (!this.customTitle) {
|
||||
this.titleChange.next(title)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets visual progressbar on the tab
|
||||
*
|
||||
* @param {type} progress: value between 0 and 1, or `null` to remove
|
||||
*/
|
||||
setProgress (progress: number|null): void {
|
||||
this.progress.next(progress)
|
||||
if (progress) {
|
||||
if (this.progressClearTimeout) {
|
||||
clearTimeout(this.progressClearTimeout)
|
||||
}
|
||||
this.progressClearTimeout = setTimeout(() => {
|
||||
this.setProgress(null)
|
||||
}, 5000) as any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the acticity marker on the tab header
|
||||
*/
|
||||
displayActivity (): void {
|
||||
this.hasActivity = true
|
||||
this.activity.next(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the acticity marker from the tab header
|
||||
*/
|
||||
clearActivity (): void {
|
||||
this.hasActivity = false
|
||||
this.activity.next(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this and implement a [[TabRecoveryProvider]] to enable recovery
|
||||
* for your custom tab
|
||||
*
|
||||
* @return JSON serializable tab state representation
|
||||
* for your [[TabRecoveryProvider]] to parse
|
||||
*/
|
||||
async getRecoveryToken (): Promise<RecoveryToken|null> {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this to enable task completion notifications for the tab
|
||||
*/
|
||||
async getCurrentProcess (): Promise<BaseTabProcess|null> {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Return false to prevent the tab from being closed
|
||||
*/
|
||||
async canClose (): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
emitFocused (): void {
|
||||
this.focused.next()
|
||||
}
|
||||
|
||||
emitBlurred (): void {
|
||||
this.blurred.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before the tab is closed
|
||||
*/
|
||||
destroy (skipDestroyedEvent = false): void {
|
||||
this.focused.complete()
|
||||
this.blurred.complete()
|
||||
this.titleChange.complete()
|
||||
this.progress.complete()
|
||||
this.activity.complete()
|
||||
this.recoveryStateChangedHint.complete()
|
||||
if (!skipDestroyedEvent) {
|
||||
this.destroyed.next()
|
||||
}
|
||||
this.destroyed.complete()
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
ngOnDestroy (): void {
|
||||
this.destroy()
|
||||
super.ngOnDestroy()
|
||||
}
|
||||
}
|
51
tabby-core/src/components/checkbox.component.ts
Normal file
51
tabby-core/src/components/checkbox.component.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { NgZone, Component, Input, HostBinding, HostListener } from '@angular/core'
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'checkbox',
|
||||
template: `
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" [(ngModel)]='model'>
|
||||
<label class="custom-control-label">{{text}}</label>
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: CheckboxComponent, multi: true },
|
||||
],
|
||||
})
|
||||
export class CheckboxComponent implements ControlValueAccessor {
|
||||
@HostBinding('class.active') @Input() model: boolean
|
||||
@HostBinding('class.disabled') @Input() disabled: boolean
|
||||
@Input() text: string
|
||||
private changed = new Array<(val: boolean) => void>()
|
||||
|
||||
@HostListener('click') click () {
|
||||
NgZone.assertInAngularZone()
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.model = !this.model
|
||||
for (const fx of this.changed) {
|
||||
fx(this.model)
|
||||
}
|
||||
}
|
||||
|
||||
writeValue (obj: any) {
|
||||
this.model = obj
|
||||
}
|
||||
|
||||
registerOnChange (fn: any): void {
|
||||
this.changed.push(fn)
|
||||
}
|
||||
|
||||
registerOnTouched (fn: any): void {
|
||||
this.changed.push(fn)
|
||||
}
|
||||
|
||||
setDisabledState (isDisabled: boolean) {
|
||||
this.disabled = isDisabled
|
||||
}
|
||||
}
|
6
tabby-core/src/components/renameTabModal.component.pug
Normal file
6
tabby-core/src/components/renameTabModal.component.pug
Normal file
@@ -0,0 +1,6 @@
|
||||
.modal-body
|
||||
input.form-control(type='text', #input, [(ngModel)]='value', (keyup.enter)='save()', autofocus)
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-primary((click)='save()') Save
|
||||
button.btn.btn-outline-secondary((click)='close()') Cancel
|
32
tabby-core/src/components/renameTabModal.component.ts
Normal file
32
tabby-core/src/components/renameTabModal.component.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input, ElementRef, ViewChild } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'rename-tab-modal',
|
||||
template: require('./renameTabModal.component.pug'),
|
||||
})
|
||||
export class RenameTabModalComponent {
|
||||
@Input() value: string
|
||||
@ViewChild('input') input: ElementRef
|
||||
|
||||
constructor (
|
||||
private modalInstance: NgbActiveModal
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
setTimeout(() => {
|
||||
this.input.nativeElement.focus()
|
||||
this.input.nativeElement.select()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
save () {
|
||||
this.modalInstance.close(this.value)
|
||||
}
|
||||
|
||||
close () {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
}
|
7
tabby-core/src/components/safeModeModal.component.pug
Normal file
7
tabby-core/src/components/safeModeModal.component.pug
Normal file
@@ -0,0 +1,7 @@
|
||||
.modal-body
|
||||
.alert.alert-danger Tabby could not start with your plugins, so all third party plugins have been disabled in this session. The error was:
|
||||
|
||||
pre {{error}}
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-primary((click)='close()') Close
|
20
tabby-core/src/components/safeModeModal.component.ts
Normal file
20
tabby-core/src/components/safeModeModal.component.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./safeModeModal.component.pug'),
|
||||
})
|
||||
export class SafeModeModalComponent {
|
||||
@Input() error: Error
|
||||
|
||||
constructor (
|
||||
public modalInstance: NgbActiveModal,
|
||||
) {
|
||||
this.error = window['safeModeReason']
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
}
|
26
tabby-core/src/components/selectorModal.component.pug
Normal file
26
tabby-core/src/components/selectorModal.component.pug
Normal file
@@ -0,0 +1,26 @@
|
||||
.modal-body
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='filter',
|
||||
autofocus,
|
||||
[placeholder]='name',
|
||||
(ngModelChange)='onFilterChange()'
|
||||
)
|
||||
|
||||
.list-group(*ngIf='filteredOptions.length')
|
||||
a.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
#item,
|
||||
(click)='selectOption(option)',
|
||||
[class.active]='selectedIndex == i',
|
||||
*ngFor='let option of filteredOptions; let i = index'
|
||||
)
|
||||
i.icon(
|
||||
class='fa-fw fas fa-{{option.icon}}',
|
||||
*ngIf='!iconIsSVG(option.icon)'
|
||||
)
|
||||
.icon(
|
||||
[fastHtmlBind]='option.icon',
|
||||
*ngIf='iconIsSVG(option.icon)'
|
||||
)
|
||||
.mr-2.title {{getOptionText(option)}}
|
||||
.text-muted {{option.description}}
|
24
tabby-core/src/components/selectorModal.component.scss
Normal file
24
tabby-core/src/components/selectorModal.component.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
79
tabby-core/src/components/selectorModal.component.ts
Normal file
79
tabby-core/src/components/selectorModal.component.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Component, Input, HostListener, ViewChildren, QueryList, ElementRef } from '@angular/core' // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { SelectorOption } from '../api/selector'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'selector-modal',
|
||||
template: require('./selectorModal.component.pug'),
|
||||
styles: [require('./selectorModal.component.scss')],
|
||||
})
|
||||
export class SelectorModalComponent<T> {
|
||||
@Input() options: SelectorOption<T>[]
|
||||
@Input() filteredOptions: SelectorOption<T>[]
|
||||
@Input() filter = ''
|
||||
@Input() name: string
|
||||
@Input() selectedIndex = 0
|
||||
@ViewChildren('item') itemChildren: QueryList<ElementRef>
|
||||
|
||||
constructor (
|
||||
public modalInstance: NgbActiveModal,
|
||||
) { }
|
||||
|
||||
ngOnInit (): void {
|
||||
this.onFilterChange()
|
||||
}
|
||||
|
||||
@HostListener('keyup', ['$event']) onKeyUp (event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.selectedIndex--
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.selectedIndex++
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
this.selectOption(this.filteredOptions[this.selectedIndex])
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
this.close()
|
||||
}
|
||||
|
||||
this.selectedIndex = (this.selectedIndex + this.filteredOptions.length) % this.filteredOptions.length
|
||||
Array.from(this.itemChildren)[this.selectedIndex]?.nativeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
|
||||
onFilterChange (): void {
|
||||
const f = this.filter.trim().toLowerCase()
|
||||
if (!f) {
|
||||
this.filteredOptions = this.options.filter(x => !x.freeInputPattern)
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
this.filteredOptions = this.options.filter(x => x.freeInputPattern ?? (x.name + (x.description ?? '')).toLowerCase().includes(f))
|
||||
}
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex)
|
||||
this.selectedIndex = Math.min(this.filteredOptions.length - 1, this.selectedIndex)
|
||||
}
|
||||
|
||||
getOptionText (option: SelectorOption<T>): string {
|
||||
if (option.freeInputPattern) {
|
||||
return option.freeInputPattern.replace('%s', this.filter)
|
||||
}
|
||||
return option.name
|
||||
}
|
||||
|
||||
selectOption (option: SelectorOption<T>): void {
|
||||
option.callback?.(this.filter)
|
||||
this.modalInstance.close(option.result)
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
|
||||
iconIsSVG (icon?: string): boolean {
|
||||
return icon?.startsWith('<') ?? false
|
||||
}
|
||||
}
|
26
tabby-core/src/components/splitTab.component.scss
Normal file
26
tabby-core/src/components/splitTab.component.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
::ng-deep split-tab > .child {
|
||||
position: absolute;
|
||||
transition: 0.125s all;
|
||||
opacity: .75;
|
||||
|
||||
&.focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.minimized {
|
||||
opacity: .1;
|
||||
}
|
||||
|
||||
&.maximized {
|
||||
z-index: 2;
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
640
tabby-core/src/components/splitTab.component.ts
Normal file
640
tabby-core/src/components/splitTab.component.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { Component, Injectable, ViewChild, ViewContainerRef, EmbeddedViewRef, AfterViewInit, OnDestroy } from '@angular/core'
|
||||
import { BaseTabComponent, BaseTabProcess } from './baseTab.component'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from '../api/tabRecovery'
|
||||
import { TabsService } from '../services/tabs.service'
|
||||
import { HotkeysService } from '../services/hotkeys.service'
|
||||
import { TabRecoveryService } from '../services/tabRecovery.service'
|
||||
|
||||
export type SplitOrientation = 'v' | 'h' // eslint-disable-line @typescript-eslint/no-type-alias
|
||||
export type SplitDirection = 'r' | 't' | 'b' | 'l' // eslint-disable-line @typescript-eslint/no-type-alias
|
||||
|
||||
/**
|
||||
* Describes a horizontal or vertical split row or column
|
||||
*/
|
||||
export class SplitContainer {
|
||||
orientation: SplitOrientation = 'h'
|
||||
|
||||
/**
|
||||
* Children could be tabs or other containers
|
||||
*/
|
||||
children: (BaseTabComponent | SplitContainer)[] = []
|
||||
|
||||
/**
|
||||
* Relative sizes of children, between 0 and 1. Total sum is 1
|
||||
*/
|
||||
ratios: number[] = []
|
||||
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
|
||||
/**
|
||||
* @return Flat list of all tabs inside this container
|
||||
*/
|
||||
getAllTabs (): BaseTabComponent[] {
|
||||
let r: BaseTabComponent[] = []
|
||||
for (const child of this.children) {
|
||||
if (child instanceof SplitContainer) {
|
||||
r = r.concat(child.getAllTabs())
|
||||
} else {
|
||||
r.push(child)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove unnecessarily nested child containers and renormalizes [[ratios]]
|
||||
*/
|
||||
normalize (): void {
|
||||
for (let i = 0; i < this.children.length; i++) {
|
||||
const child = this.children[i]
|
||||
|
||||
if (child instanceof SplitContainer) {
|
||||
child.normalize()
|
||||
|
||||
if (child.children.length === 0) {
|
||||
this.children.splice(i, 1)
|
||||
this.ratios.splice(i, 1)
|
||||
i--
|
||||
continue
|
||||
} else if (child.children.length === 1) {
|
||||
this.children[i] = child.children[0]
|
||||
} else if (child.orientation === this.orientation) {
|
||||
const ratio = this.ratios[i]
|
||||
this.children.splice(i, 1)
|
||||
this.ratios.splice(i, 1)
|
||||
for (let j = 0; j < child.children.length; j++) {
|
||||
this.children.splice(i, 0, child.children[j])
|
||||
this.ratios.splice(i, 0, child.ratios[j] * ratio)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let s = 0
|
||||
for (const x of this.ratios) {
|
||||
s += x
|
||||
}
|
||||
this.ratios = this.ratios.map(x => x / s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the left/top side offset for the given element index (between 0 and 1)
|
||||
*/
|
||||
getOffsetRatio (index: number): number {
|
||||
let s = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
s += this.ratios[i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
async serialize (): Promise<RecoveryToken> {
|
||||
const children: any[] = []
|
||||
for (const child of this.children) {
|
||||
if (child instanceof SplitContainer) {
|
||||
children.push(await child.serialize())
|
||||
} else {
|
||||
children.push(await child.getRecoveryToken())
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'app:split-tab',
|
||||
ratios: this.ratios,
|
||||
orientation: this.orientation,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a spanner (draggable border between two split areas)
|
||||
*/
|
||||
export interface SplitSpannerInfo {
|
||||
container: SplitContainer
|
||||
|
||||
/**
|
||||
* Number of the right/bottom split in the container
|
||||
*/
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Split tab is a tab that contains other tabs and allows further splitting them
|
||||
* You'll mainly encounter it inside [[AppService]].tabs
|
||||
*/
|
||||
@Component({
|
||||
selector: 'split-tab',
|
||||
template: `
|
||||
<ng-container #vc></ng-container>
|
||||
<split-tab-spanner
|
||||
*ngFor='let spanner of _spanners'
|
||||
[container]='spanner.container'
|
||||
[index]='spanner.index'
|
||||
(change)='onSpannerAdjusted(spanner)'
|
||||
></split-tab-spanner>
|
||||
`,
|
||||
styles: [require('./splitTab.component.scss')],
|
||||
})
|
||||
export class SplitTabComponent extends BaseTabComponent implements AfterViewInit, OnDestroy {
|
||||
static DIRECTIONS: SplitDirection[] = ['t', 'r', 'b', 'l']
|
||||
|
||||
/** @hidden */
|
||||
@ViewChild('vc', { read: ViewContainerRef }) viewContainer: ViewContainerRef
|
||||
|
||||
/**
|
||||
* Top-level split container
|
||||
*/
|
||||
root: SplitContainer
|
||||
|
||||
/** @hidden */
|
||||
_recoveredState: any
|
||||
|
||||
/** @hidden */
|
||||
_spanners: SplitSpannerInfo[] = []
|
||||
|
||||
/** @hidden */
|
||||
_allFocusMode = false
|
||||
|
||||
/** @hidden */
|
||||
private focusedTab: BaseTabComponent|null = null
|
||||
private maximizedTab: BaseTabComponent|null = null
|
||||
private viewRefs: Map<BaseTabComponent, EmbeddedViewRef<any>> = new Map()
|
||||
|
||||
private tabAdded = new Subject<BaseTabComponent>()
|
||||
private tabRemoved = new Subject<BaseTabComponent>()
|
||||
private splitAdjusted = new Subject<SplitSpannerInfo>()
|
||||
private focusChanged = new Subject<BaseTabComponent>()
|
||||
private initialized = new Subject<void>()
|
||||
|
||||
get tabAdded$ (): Observable<BaseTabComponent> { return this.tabAdded }
|
||||
get tabRemoved$ (): Observable<BaseTabComponent> { return this.tabRemoved }
|
||||
|
||||
/**
|
||||
* Fired when split ratio is changed for a given spanner
|
||||
*/
|
||||
get splitAdjusted$ (): Observable<SplitSpannerInfo> { return this.splitAdjusted }
|
||||
|
||||
/**
|
||||
* Fired when a different sub-tab gains focus
|
||||
*/
|
||||
get focusChanged$ (): Observable<BaseTabComponent> { return this.focusChanged }
|
||||
|
||||
/**
|
||||
* Fired once tab layout is created and child tabs can be added
|
||||
*/
|
||||
get initialized$ (): Observable<void> { return this.initialized }
|
||||
|
||||
/** @hidden */
|
||||
constructor (
|
||||
private hotkeys: HotkeysService,
|
||||
private tabsService: TabsService,
|
||||
private tabRecovery: TabRecoveryService,
|
||||
) {
|
||||
super()
|
||||
this.root = new SplitContainer()
|
||||
this.setTitle('')
|
||||
|
||||
this.focused$.subscribe(() => {
|
||||
this.getAllTabs().forEach(x => x.emitFocused())
|
||||
if (this.focusedTab) {
|
||||
this.focus(this.focusedTab)
|
||||
} else {
|
||||
this.focusAnyIn(this.root)
|
||||
}
|
||||
})
|
||||
this.blurred$.subscribe(() => this.getAllTabs().forEach(x => x.emitBlurred()))
|
||||
|
||||
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
|
||||
if (!this.hasFocus || !this.focusedTab) {
|
||||
return
|
||||
}
|
||||
switch (hotkey) {
|
||||
case 'split-right':
|
||||
this.splitTab(this.focusedTab, 'r')
|
||||
break
|
||||
case 'split-bottom':
|
||||
this.splitTab(this.focusedTab, 'b')
|
||||
break
|
||||
case 'split-top':
|
||||
this.splitTab(this.focusedTab, 't')
|
||||
break
|
||||
case 'split-left':
|
||||
this.splitTab(this.focusedTab, 'l')
|
||||
break
|
||||
case 'pane-nav-left':
|
||||
this.navigate('l')
|
||||
break
|
||||
case 'pane-nav-right':
|
||||
this.navigate('r')
|
||||
break
|
||||
case 'pane-nav-up':
|
||||
this.navigate('t')
|
||||
break
|
||||
case 'pane-nav-down':
|
||||
this.navigate('b')
|
||||
break
|
||||
case 'pane-nav-previous':
|
||||
this.navigateLinear(-1)
|
||||
break
|
||||
case 'pane-nav-next':
|
||||
this.navigateLinear(1)
|
||||
break
|
||||
case 'pane-maximize':
|
||||
if (this.maximizedTab) {
|
||||
this.maximize(null)
|
||||
} else if (this.getAllTabs().length > 1) {
|
||||
this.maximize(this.focusedTab)
|
||||
}
|
||||
break
|
||||
case 'close-pane':
|
||||
this.removeTab(this.focusedTab)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
async ngAfterViewInit (): Promise<void> {
|
||||
if (this._recoveredState) {
|
||||
await this.recoverContainer(this.root, this._recoveredState, this._recoveredState.duplicate)
|
||||
this.layout()
|
||||
setTimeout(() => {
|
||||
if (this.hasFocus) {
|
||||
for (const tab of this.getAllTabs()) {
|
||||
this.focus(tab)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
this.initialized.next()
|
||||
this.initialized.complete()
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
ngOnDestroy (): void {
|
||||
this.tabAdded.complete()
|
||||
this.tabRemoved.complete()
|
||||
super.ngOnDestroy()
|
||||
}
|
||||
|
||||
/** @returns Flat list of all sub-tabs */
|
||||
getAllTabs (): BaseTabComponent[] {
|
||||
return this.root.getAllTabs()
|
||||
}
|
||||
|
||||
getFocusedTab (): BaseTabComponent|null {
|
||||
return this.focusedTab
|
||||
}
|
||||
|
||||
getMaximizedTab (): BaseTabComponent|null {
|
||||
return this.maximizedTab
|
||||
}
|
||||
|
||||
focus (tab: BaseTabComponent): void {
|
||||
this.focusedTab = tab
|
||||
for (const x of this.getAllTabs()) {
|
||||
if (x !== tab) {
|
||||
x.emitBlurred()
|
||||
}
|
||||
}
|
||||
tab.emitFocused()
|
||||
this.focusChanged.next(tab)
|
||||
|
||||
if (this.maximizedTab !== tab) {
|
||||
this.maximizedTab = null
|
||||
}
|
||||
this.layout()
|
||||
}
|
||||
|
||||
maximize (tab: BaseTabComponent|null): void {
|
||||
this.maximizedTab = tab
|
||||
this.layout()
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the first available tab inside the given [[SplitContainer]]
|
||||
*/
|
||||
focusAnyIn (parent?: BaseTabComponent | SplitContainer): void {
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
if (parent instanceof SplitContainer) {
|
||||
this.focusAnyIn(parent.children[0])
|
||||
} else {
|
||||
this.focus(parent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new `tab` to the `side` of the `relative` tab
|
||||
*/
|
||||
async addTab (tab: BaseTabComponent, relative: BaseTabComponent|null, side: SplitDirection): Promise<void> {
|
||||
tab.parent = this
|
||||
|
||||
let target = (relative ? this.getParentOf(relative) : null) ?? this.root
|
||||
let insertIndex = relative ? target.children.indexOf(relative) : -1
|
||||
|
||||
if (
|
||||
target.orientation === 'v' && ['l', 'r'].includes(side) ||
|
||||
target.orientation === 'h' && ['t', 'b'].includes(side)
|
||||
) {
|
||||
const newContainer = new SplitContainer()
|
||||
newContainer.orientation = target.orientation === 'v' ? 'h' : 'v'
|
||||
newContainer.children = relative ? [relative] : []
|
||||
newContainer.ratios = [1]
|
||||
target.children[insertIndex] = newContainer
|
||||
target = newContainer
|
||||
insertIndex = 0
|
||||
}
|
||||
|
||||
if (insertIndex === -1) {
|
||||
insertIndex = 0
|
||||
} else {
|
||||
insertIndex += side === 'l' || side === 't' ? 0 : 1
|
||||
}
|
||||
|
||||
for (let i = 0; i < target.children.length; i++) {
|
||||
target.ratios[i] *= target.children.length / (target.children.length + 1)
|
||||
}
|
||||
target.ratios.splice(insertIndex, 0, 1 / (target.children.length + 1))
|
||||
target.children.splice(insertIndex, 0, tab)
|
||||
|
||||
this.recoveryStateChangedHint.next()
|
||||
|
||||
await this.initialized$.toPromise()
|
||||
|
||||
this.attachTabView(tab)
|
||||
|
||||
setImmediate(() => {
|
||||
this.layout()
|
||||
this.tabAdded.next(tab)
|
||||
this.focus(tab)
|
||||
})
|
||||
}
|
||||
|
||||
removeTab (tab: BaseTabComponent): void {
|
||||
const parent = this.getParentOf(tab)
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
const index = parent.children.indexOf(tab)
|
||||
parent.ratios.splice(index, 1)
|
||||
parent.children.splice(index, 1)
|
||||
|
||||
this.detachTabView(tab)
|
||||
tab.parent = null
|
||||
|
||||
this.layout()
|
||||
|
||||
this.tabRemoved.next(tab)
|
||||
if (this.root.children.length === 0) {
|
||||
this.destroy()
|
||||
} else {
|
||||
this.focusAnyIn(parent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves focus in the given direction
|
||||
*/
|
||||
navigate (dir: SplitDirection): void {
|
||||
if (!this.focusedTab) {
|
||||
return
|
||||
}
|
||||
|
||||
let rel: BaseTabComponent | SplitContainer = this.focusedTab
|
||||
let parent = this.getParentOf(rel)
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
|
||||
const orientation = ['l', 'r'].includes(dir) ? 'h' : 'v'
|
||||
|
||||
while (parent !== this.root && parent.orientation !== orientation) {
|
||||
rel = parent
|
||||
parent = this.getParentOf(rel)
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (parent.orientation !== orientation) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = parent.children.indexOf(rel)
|
||||
if (['l', 't'].includes(dir)) {
|
||||
if (index > 0) {
|
||||
this.focusAnyIn(parent.children[index - 1])
|
||||
}
|
||||
} else {
|
||||
if (index < parent.children.length - 1) {
|
||||
this.focusAnyIn(parent.children[index + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navigateLinear (delta: number): void {
|
||||
if (!this.focusedTab) {
|
||||
return
|
||||
}
|
||||
|
||||
const relativeTo: BaseTabComponent = this.focusedTab
|
||||
const all = this.getAllTabs()
|
||||
const target = all[(all.indexOf(relativeTo) + delta + all.length) % all.length]
|
||||
this.focus(target)
|
||||
}
|
||||
|
||||
async splitTab (tab: BaseTabComponent, dir: SplitDirection): Promise<BaseTabComponent|null> {
|
||||
const newTab = await this.tabsService.duplicate(tab)
|
||||
if (newTab) {
|
||||
this.addTab(newTab, tab, dir)
|
||||
}
|
||||
return newTab
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the immediate parent of `tab`
|
||||
*/
|
||||
getParentOf (tab: BaseTabComponent | SplitContainer, root?: SplitContainer): SplitContainer|null {
|
||||
root = root ?? this.root
|
||||
for (const child of root.children) {
|
||||
if (child instanceof SplitContainer) {
|
||||
const r = this.getParentOf(tab, child)
|
||||
if (r) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
if (child === tab) {
|
||||
return root
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
async canClose (): Promise<boolean> {
|
||||
return !(await Promise.all(this.getAllTabs().map(x => x.canClose()))).some(x => !x)
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
async getRecoveryToken (): Promise<any> {
|
||||
return this.root.serialize()
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
async getCurrentProcess (): Promise<BaseTabProcess|null> {
|
||||
return (await Promise.all(this.getAllTabs().map(x => x.getCurrentProcess()))).find(x => !!x) ?? null
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
onSpannerAdjusted (spanner: SplitSpannerInfo): void {
|
||||
this.layout()
|
||||
this.splitAdjusted.next(spanner)
|
||||
}
|
||||
|
||||
destroy (): void {
|
||||
super.destroy()
|
||||
for (const x of this.getAllTabs()) {
|
||||
x.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
layout (): void {
|
||||
this.root.normalize()
|
||||
this._spanners = []
|
||||
this.layoutInternal(this.root, 0, 0, 100, 100)
|
||||
}
|
||||
|
||||
private attachTabView (tab: BaseTabComponent) {
|
||||
const ref = this.viewContainer.insert(tab.hostView) as EmbeddedViewRef<any> // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
this.viewRefs.set(tab, ref)
|
||||
|
||||
tab.addEventListenerUntilDestroyed(ref.rootNodes[0], 'click', () => this.focus(tab))
|
||||
|
||||
tab.subscribeUntilDestroyed(tab.titleChange$, t => this.setTitle(t))
|
||||
tab.subscribeUntilDestroyed(tab.activity$, a => a ? this.displayActivity() : this.clearActivity())
|
||||
tab.subscribeUntilDestroyed(tab.progress$, p => this.setProgress(p))
|
||||
if (tab.title) {
|
||||
this.setTitle(tab.title)
|
||||
}
|
||||
tab.subscribeUntilDestroyed(tab.recoveryStateChangedHint$, () => {
|
||||
this.recoveryStateChangedHint.next()
|
||||
})
|
||||
tab.subscribeUntilDestroyed(tab.destroyed$, () => {
|
||||
this.removeTab(tab)
|
||||
})
|
||||
}
|
||||
|
||||
private detachTabView (tab: BaseTabComponent) {
|
||||
const ref = this.viewRefs.get(tab)
|
||||
if (ref) {
|
||||
this.viewRefs.delete(tab)
|
||||
this.viewContainer.remove(this.viewContainer.indexOf(ref))
|
||||
}
|
||||
}
|
||||
|
||||
private layoutInternal (root: SplitContainer, x: number, y: number, w: number, h: number) {
|
||||
const size = root.orientation === 'v' ? h : w
|
||||
const sizes = root.ratios.map(ratio => ratio * size)
|
||||
|
||||
root.x = x
|
||||
root.y = y
|
||||
root.w = w
|
||||
root.h = h
|
||||
|
||||
let offset = 0
|
||||
root.children.forEach((child, i) => {
|
||||
const childX = root.orientation === 'v' ? x : x + offset
|
||||
const childY = root.orientation === 'v' ? y + offset : y
|
||||
const childW = root.orientation === 'v' ? w : sizes[i]
|
||||
const childH = root.orientation === 'v' ? sizes[i] : h
|
||||
if (child instanceof SplitContainer) {
|
||||
this.layoutInternal(child, childX, childY, childW, childH)
|
||||
} else {
|
||||
const viewRef = this.viewRefs.get(child)
|
||||
if (viewRef) {
|
||||
const element = viewRef.rootNodes[0]
|
||||
element.classList.toggle('child', true)
|
||||
element.classList.toggle('maximized', child === this.maximizedTab)
|
||||
element.classList.toggle('minimized', this.maximizedTab && child !== this.maximizedTab)
|
||||
element.classList.toggle('focused', this._allFocusMode || child === this.focusedTab)
|
||||
element.style.left = `${childX}%`
|
||||
element.style.top = `${childY}%`
|
||||
element.style.width = `${childW}%`
|
||||
element.style.height = `${childH}%`
|
||||
|
||||
if (child === this.maximizedTab) {
|
||||
element.style.left = '5%'
|
||||
element.style.top = '5%'
|
||||
element.style.width = '90%'
|
||||
element.style.height = '90%'
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += sizes[i]
|
||||
|
||||
if (i !== 0) {
|
||||
this._spanners.push({
|
||||
container: root,
|
||||
index: i,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async recoverContainer (root: SplitContainer, state: any, duplicate = false) {
|
||||
const children: (SplitContainer | BaseTabComponent)[] = []
|
||||
root.orientation = state.orientation
|
||||
root.ratios = state.ratios
|
||||
root.children = children
|
||||
for (const childState of state.children) {
|
||||
if (childState.type === 'app:split-tab') {
|
||||
const child = new SplitContainer()
|
||||
await this.recoverContainer(child, childState, duplicate)
|
||||
children.push(child)
|
||||
} else {
|
||||
const recovered = await this.tabRecovery.recoverTab(childState, duplicate)
|
||||
if (recovered) {
|
||||
const tab = this.tabsService.create(recovered.type, recovered.options)
|
||||
children.push(tab)
|
||||
tab.parent = this
|
||||
this.attachTabView(tab)
|
||||
} else {
|
||||
state.ratios.splice(state.children.indexOf(childState), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
while (root.ratios.length < root.children.length) {
|
||||
root.ratios.push(1)
|
||||
}
|
||||
root.normalize()
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class SplitTabRecoveryProvider extends TabRecoveryProvider {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:split-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
return {
|
||||
type: SplitTabComponent,
|
||||
options: { _recoveredState: recoveryToken },
|
||||
}
|
||||
}
|
||||
|
||||
duplicate (recoveryToken: RecoveryToken): RecoveryToken {
|
||||
return {
|
||||
...recoveryToken,
|
||||
duplicate: true,
|
||||
}
|
||||
}
|
||||
}
|
22
tabby-core/src/components/splitTabSpanner.component.scss
Normal file
22
tabby-core/src/components/splitTabSpanner.component.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
transition: 0.125s background;
|
||||
|
||||
&.v {
|
||||
cursor: ns-resize;
|
||||
height: 10px;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
&.h {
|
||||
cursor: ew-resize;
|
||||
width: 10px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
&:hover, &.active {
|
||||
background: rgba(255, 255, 255, .125);
|
||||
}
|
||||
}
|
102
tabby-core/src/components/splitTabSpanner.component.ts
Normal file
102
tabby-core/src/components/splitTabSpanner.component.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input, HostBinding, ElementRef, Output, EventEmitter } from '@angular/core'
|
||||
import { SplitContainer } from './splitTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'split-tab-spanner',
|
||||
template: '',
|
||||
styles: [require('./splitTabSpanner.component.scss')],
|
||||
})
|
||||
export class SplitTabSpannerComponent {
|
||||
@Input() container: SplitContainer
|
||||
@Input() index: number
|
||||
@Output() change = new EventEmitter<void>()
|
||||
@HostBinding('class.active') isActive = false
|
||||
@HostBinding('class.h') isHorizontal = false
|
||||
@HostBinding('class.v') isVertical = true
|
||||
@HostBinding('style.left') cssLeft: string
|
||||
@HostBinding('style.top') cssTop: string
|
||||
@HostBinding('style.width') cssWidth: string | null
|
||||
@HostBinding('style.height') cssHeight: string | null
|
||||
private marginOffset = -5
|
||||
|
||||
constructor (private element: ElementRef) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
this.element.nativeElement.addEventListener('dblclick', () => {
|
||||
this.reset()
|
||||
})
|
||||
|
||||
this.element.nativeElement.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
this.isActive = true
|
||||
const start = this.isVertical ? e.pageY : e.pageX
|
||||
let current = start
|
||||
const oldPosition: number = this.isVertical ? this.element.nativeElement.offsetTop : this.element.nativeElement.offsetLeft
|
||||
|
||||
const dragHandler = (dragEvent: MouseEvent) => {
|
||||
current = this.isVertical ? dragEvent.pageY : dragEvent.pageX
|
||||
const newPosition = oldPosition + (current - start)
|
||||
if (this.isVertical) {
|
||||
this.element.nativeElement.style.top = `${newPosition - this.marginOffset}px`
|
||||
} else {
|
||||
this.element.nativeElement.style.left = `${newPosition - this.marginOffset}px`
|
||||
}
|
||||
}
|
||||
|
||||
const offHandler = () => {
|
||||
this.isActive = false
|
||||
document.removeEventListener('mouseup', offHandler)
|
||||
this.element.nativeElement.parentElement.removeEventListener('mousemove', dragHandler)
|
||||
|
||||
let diff = (current - start) / (this.isVertical ? this.element.nativeElement.parentElement.clientHeight : this.element.nativeElement.parentElement.clientWidth)
|
||||
|
||||
diff = Math.max(diff, -this.container.ratios[this.index - 1] + 0.1)
|
||||
diff = Math.min(diff, this.container.ratios[this.index] - 0.1)
|
||||
|
||||
if (diff) {
|
||||
this.container.ratios[this.index - 1] += diff
|
||||
this.container.ratios[this.index] -= diff
|
||||
this.change.emit()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mouseup', offHandler, { passive: true })
|
||||
this.element.nativeElement.parentElement.addEventListener('mousemove', dragHandler)
|
||||
}, { passive: true })
|
||||
}
|
||||
|
||||
ngOnChanges () {
|
||||
this.isHorizontal = this.container.orientation === 'h'
|
||||
this.isVertical = this.container.orientation === 'v'
|
||||
if (this.isVertical) {
|
||||
this.setDimensions(
|
||||
this.container.x,
|
||||
this.container.y + this.container.h * this.container.getOffsetRatio(this.index),
|
||||
this.container.w,
|
||||
0
|
||||
)
|
||||
} else {
|
||||
this.setDimensions(
|
||||
this.container.x + this.container.w * this.container.getOffsetRatio(this.index),
|
||||
this.container.y,
|
||||
0,
|
||||
this.container.h
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
reset () {
|
||||
const ratio = (this.container.ratios[this.index - 1] + this.container.ratios[this.index]) / 2
|
||||
this.container.ratios[this.index - 1] = ratio
|
||||
this.container.ratios[this.index] = ratio
|
||||
this.change.emit()
|
||||
}
|
||||
|
||||
private setDimensions (x: number, y: number, w: number, h: number) {
|
||||
this.cssLeft = `${x}%`
|
||||
this.cssTop = `${y}%`
|
||||
this.cssWidth = w ? `${w}%` : null
|
||||
this.cssHeight = h ? `${h}%` : null
|
||||
}
|
||||
}
|
23
tabby-core/src/components/startPage.component.pug
Normal file
23
tabby-core/src/components/startPage.component.pug
Normal file
@@ -0,0 +1,23 @@
|
||||
div
|
||||
.tabby-logo
|
||||
h1.tabby-title Tabby
|
||||
sup α
|
||||
|
||||
.list-group
|
||||
a.list-group-item.list-group-item-action.d-flex(
|
||||
*ngFor='let button of getButtons()',
|
||||
(click)='button.click()',
|
||||
)
|
||||
.d-flex.align-self-center([innerHTML]='sanitizeIcon(button.icon)')
|
||||
span {{button.title}}
|
||||
|
||||
footer.d-flex.align-items-center
|
||||
.btn-group.mr-auto
|
||||
button.btn.btn-secondary((click)='homeBase.openGitHub()')
|
||||
i.fab.fa-github
|
||||
span GitHub
|
||||
button.btn.btn-secondary((click)='homeBase.reportBug()')
|
||||
i.fas.fa-bug
|
||||
span Report a problem
|
||||
|
||||
.form-control-static.selectable.no-drag Version: {{homeBase.appVersion}}
|
31
tabby-core/src/components/startPage.component.scss
Normal file
31
tabby-core/src/components/startPage.component.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:host > div {
|
||||
flex: none;
|
||||
margin: auto;
|
||||
width: 300px;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabby-title {
|
||||
margin: 0 0 60px;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex: none;
|
||||
padding: 20px 30px;
|
||||
background: rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
.list-group-item ::ng-deep svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
35
tabby-core/src/components/startPage.component.ts
Normal file
35
tabby-core/src/components/startPage.component.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { DomSanitizer } from '@angular/platform-browser'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { HomeBaseService } from '../services/homeBase.service'
|
||||
import { ToolbarButton, ToolbarButtonProvider } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'start-page',
|
||||
template: require('./startPage.component.pug'),
|
||||
styles: [require('./startPage.component.scss')],
|
||||
})
|
||||
export class StartPageComponent {
|
||||
version: string
|
||||
|
||||
private constructor (
|
||||
private config: ConfigService,
|
||||
private domSanitizer: DomSanitizer,
|
||||
public homeBase: HomeBaseService,
|
||||
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
|
||||
) {
|
||||
}
|
||||
|
||||
getButtons (): ToolbarButton[] {
|
||||
return this.config.enabledServices(this.toolbarButtonProviders)
|
||||
.map(provider => provider.provide())
|
||||
.reduce((a, b) => a.concat(b))
|
||||
.filter(x => !!x.click)
|
||||
.sort((a: ToolbarButton, b: ToolbarButton) => (a.weight ?? 0) - (b.weight ?? 0))
|
||||
}
|
||||
|
||||
sanitizeIcon (icon?: string): any {
|
||||
return this.domSanitizer.bypassSecurityTrustHtml(icon ?? '')
|
||||
}
|
||||
}
|
15
tabby-core/src/components/tabBody.component.scss
Normal file
15
tabby-core/src/components/tabBody.component.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
>* {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
> perfect-scrollbar {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
38
tabby-core/src/components/tabBody.component.ts
Normal file
38
tabby-core/src/components/tabBody.component.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input, ViewChild, HostBinding, ViewContainerRef, OnChanges } from '@angular/core'
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'tab-body',
|
||||
template: `
|
||||
<!--perfect-scrollbar [config]="{ suppressScrollX: true }" *ngIf="scrollable">
|
||||
<ng-template #scrollablePlaceholder></ng-template>
|
||||
</perfect-scrollbar-->
|
||||
<ng-template #placeholder></ng-template>
|
||||
`,
|
||||
styles: [
|
||||
require('./tabBody.component.scss'),
|
||||
require('./tabBody.deep.component.css'),
|
||||
],
|
||||
})
|
||||
export class TabBodyComponent implements OnChanges {
|
||||
@Input() @HostBinding('class.active') active: boolean
|
||||
@Input() tab: BaseTabComponent
|
||||
@ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef
|
||||
|
||||
ngOnChanges (changes) {
|
||||
if (changes.tab) {
|
||||
if (this.placeholder) {
|
||||
this.placeholder.detach()
|
||||
}
|
||||
setImmediate(() => {
|
||||
this.placeholder.insert(this.tab.hostView)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.placeholder.detach()
|
||||
}
|
||||
}
|
4
tabby-core/src/components/tabBody.deep.component.css
Normal file
4
tabby-core/src/components/tabBody.deep.component.css
Normal file
@@ -0,0 +1,4 @@
|
||||
:host /deep/ .ps-content {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
}
|
10
tabby-core/src/components/tabHeader.component.pug
Normal file
10
tabby-core/src/components/tabHeader.component.pug
Normal file
@@ -0,0 +1,10 @@
|
||||
.colorbar([style.background-color]='tab.color', *ngIf='tab.color != null')
|
||||
.progressbar([style.width]='progress + "%"', *ngIf='progress != null')
|
||||
.activity-indicator(*ngIf='tab.activity$|async')
|
||||
|
||||
.index(*ngIf='!config.store.terminal.hideTabIndex', #handle) {{index + 1}}
|
||||
.name(
|
||||
[title]='tab.customTitle || tab.title',
|
||||
[class.no-hover]='config.store.terminal.hideCloseButton'
|
||||
) {{tab.customTitle || tab.title}}
|
||||
button(*ngIf='!config.store.terminal.hideCloseButton',(click)='app.closeTab(tab, true)') ×
|
132
tabby-core/src/components/tabHeader.component.scss
Normal file
132
tabby-core/src/components/tabHeader.component.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
$tabs-height: 38px;
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
> * { cursor: pointer; }
|
||||
|
||||
flex: 1000 1 200px;
|
||||
width: 200px;
|
||||
padding: 0 10px;
|
||||
|
||||
&.flex-width {
|
||||
flex: 1000 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 0;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
&.vertical {
|
||||
flex: none;
|
||||
height: $tabs-height;
|
||||
}
|
||||
|
||||
.index {
|
||||
flex: none;
|
||||
font-weight: bold;
|
||||
-webkit-app-region: no-drag;
|
||||
cursor: -webkit-grab;
|
||||
|
||||
width: 22px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
transition: 0.25s all;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: auto;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.index + .name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
flex: none;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
$button-size: 26px;
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
border-radius: $button-size / 6;
|
||||
line-height: $button-size;
|
||||
align-self: center;
|
||||
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .name:not(.no-hover) {
|
||||
-webkit-mask-image: linear-gradient(black 0 0), linear-gradient(to left, transparent 0%, black 100%);
|
||||
-webkit-mask-size: calc(100% - 60px) auto, 60px auto;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: left, right;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
transition: 0.25s opacity;
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
&.fully-draggable {
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
|
||||
.progressbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
z-index: 1;
|
||||
transition: 0.25s width;
|
||||
}
|
||||
|
||||
.colorbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.active .activity-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 4px;
|
||||
height: 2px;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
112
tabby-core/src/components/tabHeader.component.ts
Normal file
112
tabby-core/src/components/tabHeader.component.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input, Optional, Inject, HostBinding, HostListener, ViewChild, ElementRef, NgZone } from '@angular/core'
|
||||
import { SortableComponent } from 'ng2-dnd'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider'
|
||||
import { BaseTabComponent } from './baseTab.component'
|
||||
import { RenameTabModalComponent } from './renameTabModal.component'
|
||||
import { HotkeysService } from '../services/hotkeys.service'
|
||||
import { AppService } from '../services/app.service'
|
||||
import { HostAppService, Platform } from '../api/hostApp'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { BaseComponent } from './base.component'
|
||||
import { MenuItemOptions } from '../api/menu'
|
||||
import { PlatformService } from '../api/platform'
|
||||
|
||||
/** @hidden */
|
||||
export interface SortableComponentProxy {
|
||||
setDragHandle: (_: HTMLElement) => void
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'tab-header',
|
||||
template: require('./tabHeader.component.pug'),
|
||||
styles: [require('./tabHeader.component.scss')],
|
||||
})
|
||||
export class TabHeaderComponent extends BaseComponent {
|
||||
@Input() index: number
|
||||
@Input() @HostBinding('class.active') active: boolean
|
||||
@Input() tab: BaseTabComponent
|
||||
@Input() progress: number|null
|
||||
@ViewChild('handle') handle?: ElementRef
|
||||
|
||||
private constructor (
|
||||
public app: AppService,
|
||||
public config: ConfigService,
|
||||
private hostApp: HostAppService,
|
||||
private ngbModal: NgbModal,
|
||||
private hotkeys: HotkeysService,
|
||||
private platform: PlatformService,
|
||||
private zone: NgZone,
|
||||
@Inject(SortableComponent) private parentDraggable: SortableComponentProxy,
|
||||
@Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[],
|
||||
) {
|
||||
super()
|
||||
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, (hotkey) => {
|
||||
if (this.app.activeTab === this.tab) {
|
||||
if (hotkey === 'rename-tab') {
|
||||
this.showRenameTabModal()
|
||||
}
|
||||
}
|
||||
})
|
||||
this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.subscribeUntilDestroyed(this.tab.progress$, progress => {
|
||||
this.zone.run(() => {
|
||||
this.progress = progress
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
ngAfterViewInit () {
|
||||
if (this.handle && this.hostApp.platform === Platform.macOS) {
|
||||
this.parentDraggable.setDragHandle(this.handle.nativeElement)
|
||||
}
|
||||
}
|
||||
|
||||
showRenameTabModal (): void {
|
||||
const modal = this.ngbModal.open(RenameTabModalComponent)
|
||||
modal.componentInstance.value = this.tab.customTitle || this.tab.title
|
||||
modal.result.then(result => {
|
||||
this.tab.setTitle(result)
|
||||
this.tab.customTitle = result
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async buildContextMenu (): Promise<MenuItemOptions[]> {
|
||||
let items: MenuItemOptions[] = []
|
||||
for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this.tab, this)))) {
|
||||
items.push({ type: 'separator' })
|
||||
items = items.concat(section)
|
||||
}
|
||||
return items.slice(1)
|
||||
}
|
||||
|
||||
@HostBinding('class.flex-width') get isFlexWidthEnabled (): boolean {
|
||||
return this.config.store.appearance.flexTabs
|
||||
}
|
||||
|
||||
@HostListener('dblclick') onDoubleClick (): void {
|
||||
this.showRenameTabModal()
|
||||
}
|
||||
|
||||
@HostListener('mousedown', ['$event']) async onMouseDown ($event: MouseEvent) {
|
||||
if ($event.which === 2) {
|
||||
$event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('mouseup', ['$event']) async onMouseUp ($event: MouseEvent) {
|
||||
if ($event.which === 2) {
|
||||
this.app.closeTab(this.tab, true)
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('contextmenu', ['$event']) async onContextMenu ($event: MouseEvent) {
|
||||
$event.preventDefault()
|
||||
this.platform.popupContextMenu(await this.buildContextMenu(), $event)
|
||||
}
|
||||
}
|
2
tabby-core/src/components/titleBar.component.pug
Normal file
2
tabby-core/src/components/titleBar.component.pug
Normal file
@@ -0,0 +1,2 @@
|
||||
.title((dblclick)='hostApp.toggleMaximize()') Tabby
|
||||
window-controls
|
26
tabby-core/src/components/titleBar.component.scss
Normal file
26
tabby-core/src/components/titleBar.component.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
$titlebar-height: 30px;
|
||||
|
||||
:host {
|
||||
flex: 0 0 $titlebar-height;
|
||||
display: flex;
|
||||
|
||||
.title {
|
||||
flex: auto;
|
||||
padding-left: 15px;
|
||||
line-height: $titlebar-height;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
&.inset {
|
||||
flex-basis: 36px;
|
||||
|
||||
.title {
|
||||
padding-left: 80px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
window-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
9
tabby-core/src/components/titleBar.component.ts
Normal file
9
tabby-core/src/components/titleBar.component.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'title-bar',
|
||||
template: require('./titleBar.component.pug'),
|
||||
styles: [require('./titleBar.component.scss')],
|
||||
})
|
||||
export class TitleBarComponent { } // eslint-disable-line @typescript-eslint/no-extraneous-class
|
25
tabby-core/src/components/toggle.component.scss
Normal file
25
tabby-core/src/components/toggle.component.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
:host {
|
||||
flex: none;
|
||||
$toggle-size: 18px;
|
||||
$height: 30px;
|
||||
$padding: 2px;
|
||||
display: inline-flex;
|
||||
overflow: visible;
|
||||
border-radius: 3px;
|
||||
line-height: $height;
|
||||
height: $height;
|
||||
transition: 0.25s opacity;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
margin-left: -10px;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
20
tabby-core/src/components/toggle.component.ts
Normal file
20
tabby-core/src/components/toggle.component.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { CheckboxComponent } from './checkbox.component'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'toggle',
|
||||
template: `
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" [(ngModel)]='model'>
|
||||
<label class="custom-control-label"></label>
|
||||
</div>
|
||||
`,
|
||||
styles: [require('./toggle.component.scss')],
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: ToggleComponent, multi: true },
|
||||
],
|
||||
})
|
||||
export class ToggleComponent extends CheckboxComponent {
|
||||
}
|
19
tabby-core/src/components/transfersMenu.component.pug
Normal file
19
tabby-core/src/components/transfersMenu.component.pug
Normal file
@@ -0,0 +1,19 @@
|
||||
.d-flex.align-items-center
|
||||
.dropdown-header File transfers
|
||||
button.btn.btn-link.ml-auto((click)='removeAll(); $event.stopPropagation()') !{require('../icons/times.svg')}
|
||||
.transfer(*ngFor='let transfer of transfers', (click)='showTransfer(transfer)')
|
||||
.icon(*ngIf='isDownload(transfer)') !{require('../icons/download.svg')}
|
||||
.icon(*ngIf='!isDownload(transfer)') !{require('../icons/upload.svg')}
|
||||
.main
|
||||
label {{transfer.getName()}}
|
||||
.status(*ngIf='transfer.isComplete()')
|
||||
ngb-progressbar(type='success', [value]='100')
|
||||
.status(*ngIf='transfer.isCancelled()')
|
||||
ngb-progressbar(type='danger', [value]='100')
|
||||
.status(*ngIf='!transfer.isComplete() && !transfer.isCancelled()')
|
||||
ngb-progressbar(type='info', [value]='getProgress(transfer)')
|
||||
.metadata
|
||||
.size {{transfer.getSize()|filesize}}
|
||||
.speed(*ngIf='transfer.getSpeed()') {{transfer.getSpeed()|filesize}}/s
|
||||
|
||||
button.btn.btn-link((click)='removeTransfer(transfer); $event.stopPropagation()') !{require('../icons/times.svg')}
|
46
tabby-core/src/components/transfersMenu.component.scss
Normal file
46
tabby-core/src/components/transfersMenu.component.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
:host {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.transfer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 0 5px 25px;
|
||||
|
||||
.icon {
|
||||
padding: 4px 7px;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
background: rgba(0,0,0,.25);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
margin-right: auto;
|
||||
margin-bottom: 3px;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
font-size: 10px;
|
||||
opacity: .5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.speed {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
flex: none;
|
||||
}
|
54
tabby-core/src/components/transfersMenu.component.ts
Normal file
54
tabby-core/src/components/transfersMenu.component.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core'
|
||||
import { FileDownload, FileTransfer, PlatformService } from '../api/platform'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'transfers-menu',
|
||||
template: require('./transfersMenu.component.pug'),
|
||||
styles: [require('./transfersMenu.component.scss')],
|
||||
})
|
||||
export class TransfersMenuComponent {
|
||||
@Input() transfers: FileTransfer[]
|
||||
@Output() transfersChange = new EventEmitter<FileTransfer[]>()
|
||||
|
||||
constructor (private platform: PlatformService) { }
|
||||
|
||||
isDownload (transfer: FileTransfer): boolean {
|
||||
return transfer instanceof FileDownload
|
||||
}
|
||||
|
||||
getProgress (transfer: FileTransfer): number {
|
||||
return Math.round(100 * transfer.getCompletedBytes() / transfer.getSize())
|
||||
}
|
||||
|
||||
showTransfer (transfer: FileTransfer): void {
|
||||
const fp = transfer['filePath']
|
||||
if (fp) {
|
||||
this.platform.showItemInFolder(fp)
|
||||
}
|
||||
}
|
||||
|
||||
removeTransfer (transfer: FileTransfer): void {
|
||||
if (!transfer.isComplete()) {
|
||||
transfer.cancel()
|
||||
}
|
||||
this.transfers = this.transfers.filter(x => x !== transfer)
|
||||
this.transfersChange.emit(this.transfers)
|
||||
}
|
||||
|
||||
async removeAll (): Promise<void> {
|
||||
if (this.transfers.some(x => !x.isComplete())) {
|
||||
if ((await this.platform.showMessageBox({
|
||||
type: 'warning',
|
||||
message: 'There are active file transfers',
|
||||
buttons: ['Abort all', 'Do not abort'],
|
||||
defaultId: 1,
|
||||
})).response === 1) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for (const t of this.transfers) {
|
||||
this.removeTransfer(t)
|
||||
}
|
||||
}
|
||||
}
|
29
tabby-core/src/components/unlockVaultModal.component.pug
Normal file
29
tabby-core/src/components/unlockVaultModal.component.pug
Normal file
@@ -0,0 +1,29 @@
|
||||
.modal-body
|
||||
.d-flex.align-items-center.mb-3
|
||||
h3.m-0 Vault is locked
|
||||
.ml-auto(ngbDropdown, placement='bottom-right')
|
||||
button.btn.btn-link(ngbDropdownToggle, (click)='$event.stopPropagation()')
|
||||
span(*ngIf='rememberFor') Remember for {{rememberFor}} min
|
||||
span(*ngIf='!rememberFor') Do not remember
|
||||
div(ngbDropdownMenu)
|
||||
button.dropdown-item(
|
||||
(click)='rememberFor = 0',
|
||||
) Do not remember
|
||||
button.dropdown-item(
|
||||
*ngFor='let x of rememberOptions',
|
||||
(click)='rememberFor = x',
|
||||
) {{x}} min
|
||||
|
||||
.input-group
|
||||
input.form-control.form-control-lg(
|
||||
type='password',
|
||||
autofocus,
|
||||
[(ngModel)]='passphrase',
|
||||
#input,
|
||||
placeholder='Master passphrase',
|
||||
(keyup.enter)='ok()',
|
||||
(keyup.esc)='cancel()',
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='ok()', *ngIf='passphrase')
|
||||
i.fas.fa-check
|
36
tabby-core/src/components/unlockVaultModal.component.ts
Normal file
36
tabby-core/src/components/unlockVaultModal.component.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component, ViewChild, ElementRef } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./unlockVaultModal.component.pug'),
|
||||
})
|
||||
export class UnlockVaultModalComponent {
|
||||
passphrase: string
|
||||
rememberFor = 1
|
||||
rememberOptions = [1, 5, 15, 60]
|
||||
@ViewChild('input') input: ElementRef
|
||||
|
||||
constructor (
|
||||
private modalInstance: NgbActiveModal,
|
||||
) { }
|
||||
|
||||
ngOnInit (): void {
|
||||
this.rememberFor = parseInt(window.localStorage.vaultRememberPassphraseFor ?? 0)
|
||||
setTimeout(() => {
|
||||
this.input.nativeElement.focus()
|
||||
})
|
||||
}
|
||||
|
||||
ok (): void {
|
||||
window.localStorage.vaultRememberPassphraseFor = this.rememberFor
|
||||
this.modalInstance.close({
|
||||
passphrase: this.passphrase,
|
||||
rememberFor: this.rememberFor,
|
||||
})
|
||||
}
|
||||
|
||||
cancel (): void {
|
||||
this.modalInstance.close(null)
|
||||
}
|
||||
}
|
36
tabby-core/src/components/welcomeTab.component.pug
Normal file
36
tabby-core/src/components/welcomeTab.component.pug
Normal file
@@ -0,0 +1,36 @@
|
||||
.container.mt-5.mb-5
|
||||
.mb-4
|
||||
.tabby-logo
|
||||
h1.tabby-title Tabby
|
||||
sup α
|
||||
|
||||
.text-center.mb-5 Thank you for downloading Tabby!
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Enable analytics
|
||||
.description Help track the number of Tabby installs across the world!
|
||||
toggle([(ngModel)]='config.store.enableAnalytics')
|
||||
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Enable global hotkey (#[strong Ctrl-Space])
|
||||
.description Toggles the Tabby window visibility
|
||||
toggle([(ngModel)]='enableGlobalHotkey')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Enable #[strong SSH] plugin
|
||||
.description Adds an SSH connection manager UI to Tabby
|
||||
toggle([(ngModel)]='enableSSH')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Enable #[strong Serial] plugin
|
||||
.description Allows attaching Tabby to serial ports
|
||||
toggle([(ngModel)]='enableSerial')
|
||||
|
||||
|
||||
.text-center.mt-5
|
||||
button.btn.btn-primary((click)='closeAndDisable()') Close and never show again
|
8
tabby-core/src/components/welcomeTab.component.scss
Normal file
8
tabby-core/src/components/welcomeTab.component.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
flex: auto;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
43
tabby-core/src/components/welcomeTab.component.ts
Normal file
43
tabby-core/src/components/welcomeTab.component.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { BaseTabComponent } from './baseTab.component'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { HostWindowService } from '../api/hostWindow'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'welcome-page',
|
||||
template: require('./welcomeTab.component.pug'),
|
||||
styles: [require('./welcomeTab.component.scss')],
|
||||
})
|
||||
export class WelcomeTabComponent extends BaseTabComponent {
|
||||
enableSSH = false
|
||||
enableSerial = false
|
||||
enableGlobalHotkey = true
|
||||
|
||||
constructor (
|
||||
private hostWindow: HostWindowService,
|
||||
public config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
this.setTitle('Welcome')
|
||||
this.enableSSH = !config.store.pluginBlacklist.includes('ssh')
|
||||
this.enableSerial = !config.store.pluginBlacklist.includes('serial')
|
||||
}
|
||||
|
||||
closeAndDisable () {
|
||||
this.config.store.enableWelcomeTab = false
|
||||
this.config.store.pluginBlacklist = []
|
||||
if (!this.enableSSH) {
|
||||
this.config.store.pluginBlacklist.push('ssh')
|
||||
}
|
||||
if (!this.enableSerial) {
|
||||
this.config.store.pluginBlacklist.push('serial')
|
||||
}
|
||||
if (!this.enableGlobalHotkey) {
|
||||
this.config.store.hotkeys['toggle-window'] = []
|
||||
}
|
||||
this.config.save()
|
||||
this.hostWindow.reload()
|
||||
}
|
||||
}
|
19
tabby-core/src/components/windowControls.component.pug
Normal file
19
tabby-core/src/components/windowControls.component.pug
Normal file
@@ -0,0 +1,19 @@
|
||||
button.btn.btn-secondary.btn-minimize(
|
||||
(click)='hostWindow.minimize()',
|
||||
)
|
||||
svg(version='1.1', width='10', height='10')
|
||||
path(d='M 0,5 10,5 10,6 0,6 Z')
|
||||
|
||||
button.btn.btn-secondary.btn-maximize((click)='hostWindow.toggleMaximize()', *ngIf='!hostWindow.isMaximized()')
|
||||
svg(version='1.1', width='10', height='10')
|
||||
path(d='M 0,0 0,10 10,10 10,0 Z M 1,1 9,1 9,9 1,9 Z')
|
||||
|
||||
button.btn.btn-secondary.btn-maximize((click)='hostWindow.toggleMaximize()', *ngIf='hostWindow.isMaximized()')
|
||||
svg(version='1.1', width='10', height='10', viewBox='0 0 512 512')
|
||||
path(d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zM32 144c0-8.8 7.2-16 16-16h320c8.8 0 16 7.2 16 16v80H32v-80zm352 320c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16V256h352v208zm96-96c0 8.8-7.2 16-16 16h-48V144c0-26.5-21.5-48-48-48H128V48c0-8.8 7.2-16 16-16h320c8.8 0 16 7.2 16 16v320z")
|
||||
|
||||
button.btn.btn-secondary.btn-close(
|
||||
(click)='closeWindow()'
|
||||
)
|
||||
svg(version='1.1', width='10', height='10')
|
||||
path(d='M 0,0 0,0.7 4.3,5 0,9.3 0,10 0.7,10 5,5.7 9.3,10 10,10 10,9.3 5.7,5 10,0.7 10,0 9.3,0 5,4.3 0.7,0 Z')
|
24
tabby-core/src/components/windowControls.component.scss
Normal file
24
tabby-core/src/components/windowControls.component.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
font-size: 8px;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
|
||||
&:not(:hover):not(:active) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
18
tabby-core/src/components/windowControls.component.ts
Normal file
18
tabby-core/src/components/windowControls.component.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { HostWindowService } from '../api/hostWindow'
|
||||
import { AppService } from '../services/app.service'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'window-controls',
|
||||
template: require('./windowControls.component.pug'),
|
||||
styles: [require('./windowControls.component.scss')],
|
||||
})
|
||||
export class WindowControlsComponent {
|
||||
private constructor (public hostWindow: HostWindowService, public app: AppService) { }
|
||||
|
||||
async closeWindow () {
|
||||
this.app.closeWindow()
|
||||
}
|
||||
}
|
13
tabby-core/src/config.ts
Normal file
13
tabby-core/src/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ConfigProvider } from './api/configProvider'
|
||||
import { Platform } from './api/hostApp'
|
||||
|
||||
/** @hidden */
|
||||
export class CoreConfigProvider extends ConfigProvider {
|
||||
platformDefaults = {
|
||||
[Platform.macOS]: require('./configDefaults.macos.yaml'),
|
||||
[Platform.Windows]: require('./configDefaults.windows.yaml'),
|
||||
[Platform.Linux]: require('./configDefaults.linux.yaml'),
|
||||
[Platform.Web]: require('./configDefaults.web.yaml'),
|
||||
}
|
||||
defaults = require('./configDefaults.yaml')
|
||||
}
|
72
tabby-core/src/configDefaults.linux.yaml
Normal file
72
tabby-core/src/configDefaults.linux.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
hotkeys:
|
||||
toggle-fullscreen:
|
||||
- 'F11'
|
||||
close-tab:
|
||||
- 'Ctrl-Shift-W'
|
||||
reopen-tab:
|
||||
- 'Ctrl-Shift-T'
|
||||
toggle-last-tab: []
|
||||
rename-tab:
|
||||
- 'Ctrl-Shift-R'
|
||||
next-tab:
|
||||
- 'Ctrl-Shift-Right'
|
||||
- 'Ctrl-Tab'
|
||||
previous-tab:
|
||||
- 'Ctrl-Shift-Left'
|
||||
- 'Ctrl-Shift-Tab'
|
||||
move-tab-left:
|
||||
- 'Ctrl-Shift-PageUp'
|
||||
move-tab-right:
|
||||
- 'Ctrl-Shift-PageDown'
|
||||
tab-1:
|
||||
- 'Alt-1'
|
||||
tab-2:
|
||||
- 'Alt-2'
|
||||
tab-3:
|
||||
- 'Alt-3'
|
||||
tab-4:
|
||||
- 'Alt-4'
|
||||
tab-5:
|
||||
- 'Alt-5'
|
||||
tab-6:
|
||||
- 'Alt-6'
|
||||
tab-7:
|
||||
- 'Alt-7'
|
||||
tab-8:
|
||||
- 'Alt-8'
|
||||
tab-9:
|
||||
- 'Alt-9'
|
||||
tab-10:
|
||||
- 'Alt-0'
|
||||
tab-11: []
|
||||
tab-12: []
|
||||
tab-13: []
|
||||
tab-14: []
|
||||
tab-15: []
|
||||
tab-16: []
|
||||
tab-17: []
|
||||
tab-18: []
|
||||
tab-19: []
|
||||
tab-20: []
|
||||
split-right:
|
||||
- 'Ctrl-Shift-E'
|
||||
split-bottom:
|
||||
- 'Ctrl-Shift-D'
|
||||
split-left: []
|
||||
split-top: []
|
||||
pane-nav-right:
|
||||
- 'Ctrl-Alt-Right'
|
||||
pane-nav-down:
|
||||
- 'Ctrl-Alt-Down'
|
||||
pane-nav-up:
|
||||
- 'Ctrl-Alt-Up'
|
||||
pane-nav-left:
|
||||
- 'Ctrl-Alt-Left'
|
||||
pane-nav-previous:
|
||||
- 'Ctrl-Alt-['
|
||||
pane-nav-next:
|
||||
- 'Ctrl-Alt-]'
|
||||
pane-maximize:
|
||||
- 'Ctrl-Alt-Enter'
|
||||
close-pane: []
|
||||
pluginBlacklist: ['ssh']
|
71
tabby-core/src/configDefaults.macos.yaml
Normal file
71
tabby-core/src/configDefaults.macos.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
hotkeys:
|
||||
toggle-fullscreen:
|
||||
- 'Ctrl+⌘+F'
|
||||
close-tab:
|
||||
- '⌘-W'
|
||||
reopen-tab:
|
||||
- '⌘-Shift-T'
|
||||
toggle-last-tab: []
|
||||
rename-tab:
|
||||
- '⌘-R'
|
||||
next-tab:
|
||||
- 'Ctrl-Tab'
|
||||
previous-tab:
|
||||
- 'Ctrl-Shift-Tab'
|
||||
move-tab-left:
|
||||
- '⌘-Shift-Left'
|
||||
move-tab-right:
|
||||
- '⌘-Shift-Right'
|
||||
tab-1:
|
||||
- '⌘-1'
|
||||
tab-2:
|
||||
- '⌘-2'
|
||||
tab-3:
|
||||
- '⌘-3'
|
||||
tab-4:
|
||||
- '⌘-4'
|
||||
tab-5:
|
||||
- '⌘-5'
|
||||
tab-6:
|
||||
- '⌘-6'
|
||||
tab-7:
|
||||
- '⌘-7'
|
||||
tab-8:
|
||||
- '⌘-8'
|
||||
tab-9:
|
||||
- '⌘-9'
|
||||
tab-10:
|
||||
- '⌘-0'
|
||||
tab-11: []
|
||||
tab-12: []
|
||||
tab-13: []
|
||||
tab-14: []
|
||||
tab-15: []
|
||||
tab-16: []
|
||||
tab-17: []
|
||||
tab-18: []
|
||||
tab-19: []
|
||||
tab-20: []
|
||||
split-right:
|
||||
- '⌘-Shift-D'
|
||||
split-bottom:
|
||||
- '⌘-D'
|
||||
split-left: []
|
||||
split-top: []
|
||||
pane-nav-right:
|
||||
- '⌘-⌥-Right'
|
||||
pane-nav-down:
|
||||
- '⌘-⌥-Down'
|
||||
pane-nav-up:
|
||||
- '⌘-⌥-Up'
|
||||
pane-nav-left:
|
||||
- '⌘-⌥-Left'
|
||||
pane-nav-previous:
|
||||
- '⌘-⌥-['
|
||||
pane-nav-next:
|
||||
- '⌘-⌥-]'
|
||||
pane-maximize:
|
||||
- '⌘-⌥-Enter'
|
||||
close-pane:
|
||||
- '⌘-Shift-W'
|
||||
pluginBlacklist: ['ssh']
|
6
tabby-core/src/configDefaults.web.yaml
Normal file
6
tabby-core/src/configDefaults.web.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
pluginBlacklist: ['local']
|
||||
terminal:
|
||||
recoverTabs: false
|
||||
enableAnalytics: false
|
||||
enableWelcomeTab: false
|
||||
enableAutomaticUpdates: false
|
73
tabby-core/src/configDefaults.windows.yaml
Normal file
73
tabby-core/src/configDefaults.windows.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
hotkeys:
|
||||
toggle-fullscreen:
|
||||
- 'F11'
|
||||
- 'Alt-Enter'
|
||||
close-tab:
|
||||
- 'Ctrl-Shift-W'
|
||||
reopen-tab:
|
||||
- 'Ctrl-Shift-T'
|
||||
toggle-last-tab: []
|
||||
rename-tab:
|
||||
- 'Ctrl-Shift-R'
|
||||
next-tab:
|
||||
- 'Ctrl-Shift-Right'
|
||||
- 'Ctrl-Tab'
|
||||
previous-tab:
|
||||
- 'Ctrl-Shift-Left'
|
||||
- 'Ctrl-Shift-Tab'
|
||||
move-tab-left:
|
||||
- 'Ctrl-Shift-PageUp'
|
||||
move-tab-right:
|
||||
- 'Ctrl-Shift-PageDown'
|
||||
tab-1:
|
||||
- 'Alt-1'
|
||||
tab-2:
|
||||
- 'Alt-2'
|
||||
tab-3:
|
||||
- 'Alt-3'
|
||||
tab-4:
|
||||
- 'Alt-4'
|
||||
tab-5:
|
||||
- 'Alt-5'
|
||||
tab-6:
|
||||
- 'Alt-6'
|
||||
tab-7:
|
||||
- 'Alt-7'
|
||||
tab-8:
|
||||
- 'Alt-8'
|
||||
tab-9:
|
||||
- 'Alt-9'
|
||||
tab-10:
|
||||
- 'Alt-0'
|
||||
tab-11: []
|
||||
tab-12: []
|
||||
tab-13: []
|
||||
tab-14: []
|
||||
tab-15: []
|
||||
tab-16: []
|
||||
tab-17: []
|
||||
tab-18: []
|
||||
tab-19: []
|
||||
tab-20: []
|
||||
split-right:
|
||||
- 'Ctrl-Shift-E'
|
||||
split-bottom:
|
||||
- 'Ctrl-Shift-D'
|
||||
split-left: []
|
||||
split-top: []
|
||||
pane-nav-right:
|
||||
- 'Ctrl-Alt-Right'
|
||||
pane-nav-down:
|
||||
- 'Ctrl-Alt-Down'
|
||||
pane-nav-up:
|
||||
- 'Ctrl-Alt-Up'
|
||||
pane-nav-left:
|
||||
- 'Ctrl-Alt-Left'
|
||||
pane-nav-previous:
|
||||
- 'Ctrl-Alt-['
|
||||
pane-nav-next:
|
||||
- 'Ctrl-Alt-]'
|
||||
pane-maximize:
|
||||
- 'Ctrl-Alt-Enter'
|
||||
close-pane: []
|
||||
pluginBlacklist: []
|
26
tabby-core/src/configDefaults.yaml
Normal file
26
tabby-core/src/configDefaults.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
appearance:
|
||||
dock: off
|
||||
dockScreen: current
|
||||
dockFill: 0.5
|
||||
dockSpace: 1
|
||||
dockHideOnBlur: false
|
||||
dockAlwaysOnTop: true
|
||||
flexTabs: false
|
||||
tabsLocation: top
|
||||
cycleTabs: true
|
||||
theme: Standard
|
||||
frame: thin
|
||||
css: '/* * { color: blue !important; } */'
|
||||
opacity: 1.0
|
||||
vibrancy: true
|
||||
vibrancyType: 'blur'
|
||||
terminal:
|
||||
recoverTabs: true
|
||||
enableAnalytics: true
|
||||
enableWelcomeTab: true
|
||||
electronFlags:
|
||||
- ['force_discrete_gpu', '0']
|
||||
enableAutomaticUpdates: true
|
||||
version: 1
|
||||
vault: null
|
||||
encrypted: false
|
16
tabby-core/src/directives/autofocus.directive.ts
Normal file
16
tabby-core/src/directives/autofocus.directive.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Directive, AfterViewInit, ElementRef } from '@angular/core'
|
||||
|
||||
/** @hidden */
|
||||
@Directive({
|
||||
selector: '[autofocus]',
|
||||
})
|
||||
export class AutofocusDirective implements AfterViewInit {
|
||||
constructor (private el: ElementRef) { }
|
||||
|
||||
ngAfterViewInit (): void {
|
||||
this.el.nativeElement.blur()
|
||||
setTimeout(() => {
|
||||
this.el.nativeElement.focus()
|
||||
})
|
||||
}
|
||||
}
|
1
tabby-core/src/directives/dropZone.directive.pug
Normal file
1
tabby-core/src/directives/dropZone.directive.pug
Normal file
@@ -0,0 +1 @@
|
||||
i.fas.fa-upload
|
24
tabby-core/src/directives/dropZone.directive.scss
Normal file
24
tabby-core/src/directives/dropZone.directive.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.drop-zone-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
transition: .25s opacity ease-out;
|
||||
opacity: 0;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
align-self: center;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
49
tabby-core/src/directives/dropZone.directive.ts
Normal file
49
tabby-core/src/directives/dropZone.directive.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Directive, Output, ElementRef, EventEmitter, AfterViewInit } from '@angular/core'
|
||||
import { FileUpload, PlatformService } from '../api/platform'
|
||||
import './dropZone.directive.scss'
|
||||
|
||||
/** @hidden */
|
||||
@Directive({
|
||||
selector: '[dropZone]',
|
||||
})
|
||||
export class DropZoneDirective implements AfterViewInit {
|
||||
@Output() transfer = new EventEmitter<FileUpload>()
|
||||
private dropHint?: HTMLElement
|
||||
|
||||
constructor (
|
||||
private el: ElementRef,
|
||||
private platform: PlatformService,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit (): void {
|
||||
this.el.nativeElement.addEventListener('dragover', () => {
|
||||
if (!this.dropHint) {
|
||||
this.dropHint = document.createElement('div')
|
||||
this.dropHint.className = 'drop-zone-hint'
|
||||
this.dropHint.innerHTML = require('./dropZone.directive.pug')
|
||||
this.el.nativeElement.appendChild(this.dropHint)
|
||||
setTimeout(() => {
|
||||
this.dropHint!.classList.add('visible')
|
||||
})
|
||||
}
|
||||
})
|
||||
this.el.nativeElement.addEventListener('drop', (event: DragEvent) => {
|
||||
this.removeHint()
|
||||
for (const transfer of this.platform.startUploadFromDragEvent(event, true)) {
|
||||
this.transfer.emit(transfer)
|
||||
}
|
||||
})
|
||||
this.el.nativeElement.addEventListener('dragleave', () => {
|
||||
this.removeHint()
|
||||
})
|
||||
}
|
||||
|
||||
private removeHint () {
|
||||
const element = this.dropHint
|
||||
delete this.dropHint
|
||||
element?.classList.remove('visible')
|
||||
setTimeout(() => {
|
||||
element?.remove()
|
||||
}, 500)
|
||||
}
|
||||
}
|
14
tabby-core/src/directives/fastHtmlBind.directive.ts
Normal file
14
tabby-core/src/directives/fastHtmlBind.directive.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Directive, Input, ElementRef, OnChanges } from '@angular/core'
|
||||
|
||||
/** @hidden */
|
||||
@Directive({
|
||||
selector: '[fastHtmlBind]',
|
||||
})
|
||||
export class FastHtmlBindDirective implements OnChanges {
|
||||
@Input() fastHtmlBind: string
|
||||
constructor (private el: ElementRef) { }
|
||||
|
||||
ngOnChanges (): void {
|
||||
this.el.nativeElement.innerHTML = this.fastHtmlBind || ''
|
||||
}
|
||||
}
|
177
tabby-core/src/hotkeys.ts
Normal file
177
tabby-core/src/hotkeys.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class AppHotkeyProvider extends HotkeyProvider {
|
||||
hotkeys: HotkeyDescription[] = [
|
||||
{
|
||||
id: 'toggle-fullscreen',
|
||||
name: 'Toggle fullscreen mode',
|
||||
},
|
||||
{
|
||||
id: 'rename-tab',
|
||||
name: 'Rename Tab',
|
||||
},
|
||||
{
|
||||
id: 'close-tab',
|
||||
name: 'Close tab',
|
||||
},
|
||||
{
|
||||
id: 'reopen-tab',
|
||||
name: 'Reopen last tab',
|
||||
},
|
||||
{
|
||||
id: 'toggle-last-tab',
|
||||
name: 'Toggle last tab',
|
||||
},
|
||||
{
|
||||
id: 'next-tab',
|
||||
name: 'Next tab',
|
||||
},
|
||||
{
|
||||
id: 'previous-tab',
|
||||
name: 'Previous tab',
|
||||
},
|
||||
{
|
||||
id: 'move-tab-left',
|
||||
name: 'Move tab to the left',
|
||||
},
|
||||
{
|
||||
id: 'move-tab-right',
|
||||
name: 'Move tab to the right',
|
||||
},
|
||||
{
|
||||
id: 'tab-1',
|
||||
name: 'Tab 1',
|
||||
},
|
||||
{
|
||||
id: 'tab-2',
|
||||
name: 'Tab 2',
|
||||
},
|
||||
{
|
||||
id: 'tab-3',
|
||||
name: 'Tab 3',
|
||||
},
|
||||
{
|
||||
id: 'tab-4',
|
||||
name: 'Tab 4',
|
||||
},
|
||||
{
|
||||
id: 'tab-5',
|
||||
name: 'Tab 5',
|
||||
},
|
||||
{
|
||||
id: 'tab-6',
|
||||
name: 'Tab 6',
|
||||
},
|
||||
{
|
||||
id: 'tab-7',
|
||||
name: 'Tab 7',
|
||||
},
|
||||
{
|
||||
id: 'tab-8',
|
||||
name: 'Tab 8',
|
||||
},
|
||||
{
|
||||
id: 'tab-9',
|
||||
name: 'Tab 9',
|
||||
},
|
||||
{
|
||||
id: 'tab-10',
|
||||
name: 'Tab 10',
|
||||
},
|
||||
{
|
||||
id: 'tab-11',
|
||||
name: 'Tab 11',
|
||||
},
|
||||
{
|
||||
id: 'tab-12',
|
||||
name: 'Tab 12',
|
||||
},
|
||||
{
|
||||
id: 'tab-13',
|
||||
name: 'Tab 13',
|
||||
},
|
||||
{
|
||||
id: 'tab-14',
|
||||
name: 'Tab 14',
|
||||
},
|
||||
{
|
||||
id: 'tab-15',
|
||||
name: 'Tab 15',
|
||||
},
|
||||
{
|
||||
id: 'tab-16',
|
||||
name: 'Tab 16',
|
||||
},
|
||||
{
|
||||
id: 'tab-17',
|
||||
name: 'Tab 17',
|
||||
},
|
||||
{
|
||||
id: 'tab-18',
|
||||
name: 'Tab 18',
|
||||
},
|
||||
{
|
||||
id: 'tab-19',
|
||||
name: 'Tab 19',
|
||||
},
|
||||
{
|
||||
id: 'tab-20',
|
||||
name: 'Tab 20',
|
||||
},
|
||||
{
|
||||
id: 'split-right',
|
||||
name: 'Split to the right',
|
||||
},
|
||||
{
|
||||
id: 'split-bottom',
|
||||
name: 'Split to the bottom',
|
||||
},
|
||||
{
|
||||
id: 'split-left',
|
||||
name: 'Split to the left',
|
||||
},
|
||||
{
|
||||
id: 'split-top',
|
||||
name: 'Split to the top',
|
||||
},
|
||||
{
|
||||
id: 'pane-maximize',
|
||||
name: 'Maximize the active pane',
|
||||
},
|
||||
{
|
||||
id: 'pane-nav-up',
|
||||
name: 'Focus the pane above',
|
||||
},
|
||||
{
|
||||
id: 'pane-nav-down',
|
||||
name: 'Focus the pane below',
|
||||
},
|
||||
{
|
||||
id: 'pane-nav-left',
|
||||
name: 'Focus the pane on the left',
|
||||
},
|
||||
{
|
||||
id: 'pane-nav-right',
|
||||
name: 'Focus the pane on the right',
|
||||
},
|
||||
{
|
||||
id: 'pane-nav-previous',
|
||||
name: 'Focus previous pane',
|
||||
},
|
||||
{
|
||||
id: 'pane-nav-next',
|
||||
name: 'Focus next pane',
|
||||
},
|
||||
{
|
||||
id: 'close-pane',
|
||||
name: 'Close focused pane',
|
||||
},
|
||||
]
|
||||
|
||||
async provide (): Promise<HotkeyDescription[]> {
|
||||
return this.hotkeys
|
||||
}
|
||||
}
|
1
tabby-core/src/icons/download-solid.svg
Normal file
1
tabby-core/src/icons/download-solid.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z"></path></svg>
|
After Width: | Height: | Size: 529 B |
1
tabby-core/src/icons/download.svg
Normal file
1
tabby-core/src/icons/download.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fad" data-icon="download" class="svg-inline--fa fa-download fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="fa-group"><path class="fa-secondary" fill="currentColor" d="M320 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3a19.37 19.37 0 0 1-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24a23.94 23.94 0 0 1 24-24h80a23.94 23.94 0 0 1 24 24z" opacity="0.4"></path><path class="fa-primary" fill="currentColor" d="M488 352H341.3l-49 49a51.24 51.24 0 0 1-72.6 0l-49-49H24a23.94 23.94 0 0 0-24 24v112a23.94 23.94 0 0 0 24 24h464a23.94 23.94 0 0 0 24-24V376a23.94 23.94 0 0 0-24-24zm-120 96a16 16 0 1 1 16-16 16 16 0 0 1-16 16zm64 0a16 16 0 1 1 16-16 16 16 0 0 1-16 16z"></path></g></svg>
|
After Width: | Height: | Size: 784 B |
1
tabby-core/src/icons/gift.svg
Normal file
1
tabby-core/src/icons/gift.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M32 448c0 17.7 14.3 32 32 32h160V320H32v128zm448-288h-42.1c6.2-12.1 10.1-25.5 10.1-40 0-48.5-39.5-88-88-88-41.6 0-68.5 21.3-103 68.3-34.5-47-61.4-68.3-103-68.3-48.5 0-88 39.5-88 88 0 14.5 3.8 27.9 10.1 40H32c-17.7 0-32 14.3-32 32v80c0 8.8 7.2 16 16 16h480c8.8 0 16-7.2 16-16v-80c0-17.7-14.3-32-32-32zm-326.1 0c-22.1 0-40-17.9-40-40s17.9-40 40-40c19.9 0 34.6 3.3 86.1 80h-86.1zm206.1 0h-86.1c51.4-76.5 65.7-80 86.1-80 22.1 0 40 17.9 40 40s-17.9 40-40 40zm-72 320h160c17.7 0 32-14.3 32-32V320H288v160z"/></svg>
|
After Width: | Height: | Size: 579 B |
1
tabby-core/src/icons/times.svg
Normal file
1
tabby-core/src/icons/times.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="times" class="svg-inline--fa fa-times fa-w-10" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M193.94 256L296.5 153.44l21.15-21.15c3.12-3.12 3.12-8.19 0-11.31l-22.63-22.63c-3.12-3.12-8.19-3.12-11.31 0L160 222.06 36.29 98.34c-3.12-3.12-8.19-3.12-11.31 0L2.34 120.97c-3.12 3.12-3.12 8.19 0 11.31L126.06 256 2.34 379.71c-3.12 3.12-3.12 8.19 0 11.31l22.63 22.63c3.12 3.12 8.19 3.12 11.31 0L160 289.94 262.56 392.5l21.15 21.15c3.12 3.12 8.19 3.12 11.31 0l22.63-22.63c3.12-3.12 3.12-8.19 0-11.31L193.94 256z"></path></svg>
|
After Width: | Height: | Size: 637 B |
1
tabby-core/src/icons/upload.svg
Normal file
1
tabby-core/src/icons/upload.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fad" data-icon="upload" class="svg-inline--fa fa-upload fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="fa-group"><path class="fa-secondary" fill="currentColor" d="M488 351.92H352v8a56 56 0 0 1-56 56h-80a56 56 0 0 1-56-56v-8H24a23.94 23.94 0 0 0-24 24v112a23.94 23.94 0 0 0 24 24h464a23.94 23.94 0 0 0 24-24v-112a23.94 23.94 0 0 0-24-24zm-120 132a20 20 0 1 1 20-20 20.06 20.06 0 0 1-20 20zm64 0a20 20 0 1 1 20-20 20.06 20.06 0 0 1-20 20z" opacity="0.4"></path><path class="fa-primary" fill="currentColor" d="M192 359.93v-168h-87.7c-17.8 0-26.7-21.5-14.1-34.11L242.3 5.62a19.37 19.37 0 0 1 27.3 0l152.2 152.2c12.6 12.61 3.7 34.11-14.1 34.11H320v168a23.94 23.94 0 0 1-24 24h-80a23.94 23.94 0 0 1-24-24z"></path></g></svg>
|
After Width: | Height: | Size: 813 B |
134
tabby-core/src/index.ts
Normal file
134
tabby-core/src/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NgModule, ModuleWithProviders } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PerfectScrollbarModule, PERFECT_SCROLLBAR_CONFIG } from 'ngx-perfect-scrollbar'
|
||||
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||
import { DndModule } from 'ng2-dnd'
|
||||
|
||||
import { AppRootComponent } from './components/appRoot.component'
|
||||
import { CheckboxComponent } from './components/checkbox.component'
|
||||
import { TabBodyComponent } from './components/tabBody.component'
|
||||
import { SafeModeModalComponent } from './components/safeModeModal.component'
|
||||
import { StartPageComponent } from './components/startPage.component'
|
||||
import { TabHeaderComponent } from './components/tabHeader.component'
|
||||
import { TitleBarComponent } from './components/titleBar.component'
|
||||
import { ToggleComponent } from './components/toggle.component'
|
||||
import { WindowControlsComponent } from './components/windowControls.component'
|
||||
import { RenameTabModalComponent } from './components/renameTabModal.component'
|
||||
import { SelectorModalComponent } from './components/selectorModal.component'
|
||||
import { SplitTabComponent, SplitTabRecoveryProvider } from './components/splitTab.component'
|
||||
import { SplitTabSpannerComponent } from './components/splitTabSpanner.component'
|
||||
import { UnlockVaultModalComponent } from './components/unlockVaultModal.component'
|
||||
import { WelcomeTabComponent } from './components/welcomeTab.component'
|
||||
import { TransfersMenuComponent } from './components/transfersMenu.component'
|
||||
|
||||
import { AutofocusDirective } from './directives/autofocus.directive'
|
||||
import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
|
||||
import { DropZoneDirective } from './directives/dropZone.directive'
|
||||
|
||||
import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider } from './api'
|
||||
|
||||
import { AppService } from './services/app.service'
|
||||
import { ConfigService } from './services/config.service'
|
||||
import { VaultFileProvider } from './services/vault.service'
|
||||
|
||||
import { StandardTheme, StandardCompactTheme, PaperTheme } from './theme'
|
||||
import { CoreConfigProvider } from './config'
|
||||
import { AppHotkeyProvider } from './hotkeys'
|
||||
import { TaskCompletionContextMenu, CommonOptionsContextMenu, TabManagementContextMenu } from './tabContextMenu'
|
||||
import { LastCLIHandler } from './cli'
|
||||
|
||||
import 'perfect-scrollbar/css/perfect-scrollbar.css'
|
||||
import 'ng2-dnd/bundles/style.css'
|
||||
|
||||
const PROVIDERS = [
|
||||
{ provide: HotkeyProvider, useClass: AppHotkeyProvider, multi: true },
|
||||
{ provide: Theme, useClass: StandardTheme, multi: true },
|
||||
{ provide: Theme, useClass: StandardCompactTheme, multi: true },
|
||||
{ provide: Theme, useClass: PaperTheme, multi: true },
|
||||
{ provide: ConfigProvider, useClass: CoreConfigProvider, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: CommonOptionsContextMenu, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: TabManagementContextMenu, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: TaskCompletionContextMenu, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: SplitTabRecoveryProvider, multi: true },
|
||||
{ provide: CLIHandler, useClass: LastCLIHandler, multi: true },
|
||||
{ provide: PERFECT_SCROLLBAR_CONFIG, useValue: { suppressScrollX: true } },
|
||||
{ provide: FileProvider, useClass: VaultFileProvider, multi: true },
|
||||
]
|
||||
|
||||
/** @hidden */
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
FormsModule,
|
||||
NgbModule,
|
||||
NgxFilesizeModule,
|
||||
PerfectScrollbarModule,
|
||||
DndModule.forRoot(),
|
||||
],
|
||||
declarations: [
|
||||
AppRootComponent as any,
|
||||
CheckboxComponent,
|
||||
StartPageComponent,
|
||||
TabBodyComponent,
|
||||
TabHeaderComponent,
|
||||
TitleBarComponent,
|
||||
ToggleComponent,
|
||||
WindowControlsComponent,
|
||||
RenameTabModalComponent,
|
||||
SafeModeModalComponent,
|
||||
AutofocusDirective,
|
||||
FastHtmlBindDirective,
|
||||
SelectorModalComponent,
|
||||
SplitTabComponent,
|
||||
SplitTabSpannerComponent,
|
||||
UnlockVaultModalComponent,
|
||||
WelcomeTabComponent,
|
||||
TransfersMenuComponent,
|
||||
DropZoneDirective,
|
||||
],
|
||||
entryComponents: [
|
||||
RenameTabModalComponent,
|
||||
SafeModeModalComponent,
|
||||
SelectorModalComponent,
|
||||
SplitTabComponent,
|
||||
UnlockVaultModalComponent,
|
||||
WelcomeTabComponent,
|
||||
],
|
||||
exports: [
|
||||
CheckboxComponent,
|
||||
ToggleComponent,
|
||||
AutofocusDirective,
|
||||
DropZoneDirective,
|
||||
],
|
||||
})
|
||||
export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
|
||||
constructor (app: AppService, config: ConfigService, platform: PlatformService) {
|
||||
app.ready$.subscribe(() => {
|
||||
if (config.store.enableWelcomeTab) {
|
||||
app.openNewTabRaw(WelcomeTabComponent)
|
||||
}
|
||||
})
|
||||
|
||||
platform.setErrorHandler(err => {
|
||||
console.error('Unhandled exception:', err)
|
||||
})
|
||||
}
|
||||
|
||||
static forRoot (): ModuleWithProviders<AppModule> {
|
||||
return {
|
||||
ngModule: AppModule,
|
||||
providers: PROVIDERS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AppRootComponent as bootstrap }
|
||||
export * from './api'
|
||||
|
||||
// Deprecations
|
||||
export { ToolbarButton as IToolbarButton } from './api'
|
||||
export { HotkeyDescription as IHotkeyDescription } from './api'
|
372
tabby-core/src/services/app.service.ts
Normal file
372
tabby-core/src/services/app.service.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
|
||||
import { Observable, Subject, AsyncSubject } from 'rxjs'
|
||||
import { takeUntil } from 'rxjs/operators'
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { SplitTabComponent } from '../components/splitTab.component'
|
||||
import { SelectorOption } from '../api/selector'
|
||||
import { RecoveryToken } from '../api/tabRecovery'
|
||||
import { BootstrapData, BOOTSTRAP_DATA } from '../api/mainProcess'
|
||||
import { HostWindowService } from '../api/hostWindow'
|
||||
import { HostAppService } from '../api/hostApp'
|
||||
|
||||
import { ConfigService } from './config.service'
|
||||
import { TabRecoveryService } from './tabRecovery.service'
|
||||
import { TabsService, TabComponentType } from './tabs.service'
|
||||
import { SelectorService } from './selector.service'
|
||||
|
||||
class CompletionObserver {
|
||||
get done$ (): Observable<void> { return this.done }
|
||||
get destroyed$ (): Observable<void> { return this.destroyed }
|
||||
private done = new AsyncSubject<void>()
|
||||
private destroyed = new AsyncSubject<void>()
|
||||
private interval: number
|
||||
|
||||
constructor (private tab: BaseTabComponent) {
|
||||
this.interval = setInterval(() => this.tick(), 1000) as any
|
||||
this.tab.destroyed$.pipe(takeUntil(this.destroyed$)).subscribe(() => this.stop())
|
||||
}
|
||||
|
||||
async tick () {
|
||||
if (!await this.tab.getCurrentProcess()) {
|
||||
this.done.next()
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
stop () {
|
||||
clearInterval(this.interval)
|
||||
this.destroyed.next()
|
||||
this.destroyed.complete()
|
||||
this.done.complete()
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppService {
|
||||
tabs: BaseTabComponent[] = []
|
||||
|
||||
get activeTab (): BaseTabComponent|null { return this._activeTab ?? null }
|
||||
|
||||
private lastTabIndex = 0
|
||||
private _activeTab: BaseTabComponent | null = null
|
||||
private closedTabsStack: RecoveryToken[] = []
|
||||
|
||||
private activeTabChange = new Subject<BaseTabComponent|null>()
|
||||
private tabsChanged = new Subject<void>()
|
||||
private tabOpened = new Subject<BaseTabComponent>()
|
||||
private tabClosed = new Subject<BaseTabComponent>()
|
||||
private ready = new AsyncSubject<void>()
|
||||
|
||||
private completionObservers = new Map<BaseTabComponent, CompletionObserver>()
|
||||
|
||||
get activeTabChange$ (): Observable<BaseTabComponent|null> { return this.activeTabChange }
|
||||
get tabOpened$ (): Observable<BaseTabComponent> { return this.tabOpened }
|
||||
get tabsChanged$ (): Observable<void> { return this.tabsChanged }
|
||||
get tabClosed$ (): Observable<BaseTabComponent> { return this.tabClosed }
|
||||
|
||||
/** Fires once when the app is ready */
|
||||
get ready$ (): Observable<void> { return this.ready }
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private config: ConfigService,
|
||||
private hostApp: HostAppService,
|
||||
private hostWindow: HostWindowService,
|
||||
private tabRecovery: TabRecoveryService,
|
||||
private tabsService: TabsService,
|
||||
private selector: SelectorService,
|
||||
@Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData,
|
||||
) {
|
||||
this.tabsChanged$.subscribe(() => {
|
||||
this.tabRecovery.saveTabs(this.tabs)
|
||||
})
|
||||
setInterval(() => {
|
||||
this.tabRecovery.saveTabs(this.tabs)
|
||||
}, 30000)
|
||||
|
||||
config.ready$.toPromise().then(async () => {
|
||||
if (this.bootstrapData.isFirstWindow) {
|
||||
if (config.store.terminal.recoverTabs) {
|
||||
const tabs = await this.tabRecovery.recoverTabs()
|
||||
for (const tab of tabs) {
|
||||
this.openNewTabRaw(tab.type, tab.options)
|
||||
}
|
||||
}
|
||||
/** Continue to store the tabs even if the setting is currently off */
|
||||
this.tabRecovery.enabled = true
|
||||
}
|
||||
})
|
||||
|
||||
hostWindow.windowFocused$.subscribe(() => this._activeTab?.emitFocused())
|
||||
|
||||
this.tabClosed$.subscribe(async tab => {
|
||||
const token = await tabRecovery.getFullRecoveryToken(tab)
|
||||
if (token) {
|
||||
this.closedTabsStack.push(token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addTabRaw (tab: BaseTabComponent, index: number|null = null): void {
|
||||
if (index !== null) {
|
||||
this.tabs.splice(index, 0, tab)
|
||||
} else {
|
||||
this.tabs.push(tab)
|
||||
}
|
||||
|
||||
this.selectTab(tab)
|
||||
this.tabsChanged.next()
|
||||
this.tabOpened.next(tab)
|
||||
|
||||
if (this.bootstrapData.isFirstWindow) {
|
||||
tab.recoveryStateChangedHint$.subscribe(() => {
|
||||
this.tabRecovery.saveTabs(this.tabs)
|
||||
})
|
||||
}
|
||||
|
||||
tab.titleChange$.subscribe(title => {
|
||||
if (tab === this._activeTab) {
|
||||
this.hostWindow.setTitle(title)
|
||||
}
|
||||
})
|
||||
|
||||
tab.destroyed$.subscribe(() => {
|
||||
const newIndex = Math.max(0, this.tabs.indexOf(tab) - 1)
|
||||
this.tabs = this.tabs.filter((x) => x !== tab)
|
||||
if (tab === this._activeTab) {
|
||||
this.selectTab(this.tabs[newIndex])
|
||||
}
|
||||
this.tabsChanged.next()
|
||||
this.tabClosed.next(tab)
|
||||
})
|
||||
|
||||
if (tab instanceof SplitTabComponent) {
|
||||
tab.tabAdded$.subscribe(() => this.emitTabsChanged())
|
||||
tab.tabRemoved$.subscribe(() => this.emitTabsChanged())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new tab **without** wrapping it in a SplitTabComponent
|
||||
* @param inputs Properties to be assigned on the new tab component instance
|
||||
*/
|
||||
openNewTabRaw (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
|
||||
const tab = this.tabsService.create(type, inputs)
|
||||
this.addTabRaw(tab)
|
||||
return tab
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new tab while wrapping it in a SplitTabComponent
|
||||
* @param inputs Properties to be assigned on the new tab component instance
|
||||
*/
|
||||
openNewTab (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
|
||||
const splitTab = this.tabsService.create(SplitTabComponent) as SplitTabComponent
|
||||
const tab = this.tabsService.create(type, inputs)
|
||||
splitTab.addTab(tab, null, 'r')
|
||||
this.addTabRaw(splitTab)
|
||||
return tab
|
||||
}
|
||||
|
||||
async reopenLastTab (): Promise<BaseTabComponent|null> {
|
||||
const token = this.closedTabsStack.pop()
|
||||
if (token) {
|
||||
const recoveredTab = await this.tabRecovery.recoverTab(token)
|
||||
if (recoveredTab) {
|
||||
const tab = this.tabsService.create(recoveredTab.type, recoveredTab.options)
|
||||
if (this.activeTab) {
|
||||
this.addTabRaw(tab, this.tabs.indexOf(this.activeTab) + 1)
|
||||
} else {
|
||||
this.addTabRaw(tab)
|
||||
}
|
||||
return tab
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
selectTab (tab: BaseTabComponent|null): void {
|
||||
if (tab && this._activeTab === tab) {
|
||||
this._activeTab.emitFocused()
|
||||
return
|
||||
}
|
||||
if (this._activeTab && this.tabs.includes(this._activeTab)) {
|
||||
this.lastTabIndex = this.tabs.indexOf(this._activeTab)
|
||||
} else {
|
||||
this.lastTabIndex = 0
|
||||
}
|
||||
if (this._activeTab) {
|
||||
this._activeTab.clearActivity()
|
||||
this._activeTab.emitBlurred()
|
||||
}
|
||||
this._activeTab = tab
|
||||
this.activeTabChange.next(tab)
|
||||
setImmediate(() => {
|
||||
this._activeTab?.emitFocused()
|
||||
})
|
||||
this.hostWindow.setTitle(this._activeTab?.title)
|
||||
}
|
||||
|
||||
getParentTab (tab: BaseTabComponent): SplitTabComponent|null {
|
||||
for (const topLevelTab of this.tabs) {
|
||||
if (topLevelTab instanceof SplitTabComponent) {
|
||||
if (topLevelTab.getAllTabs().includes(tab)) {
|
||||
return topLevelTab
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Switches between the current tab and the previously active one */
|
||||
toggleLastTab (): void {
|
||||
if (!this.lastTabIndex || this.lastTabIndex >= this.tabs.length) {
|
||||
this.lastTabIndex = 0
|
||||
}
|
||||
this.selectTab(this.tabs[this.lastTabIndex])
|
||||
}
|
||||
|
||||
nextTab (): void {
|
||||
if (!this._activeTab) {
|
||||
return
|
||||
}
|
||||
if (this.tabs.length > 1) {
|
||||
const tabIndex = this.tabs.indexOf(this._activeTab)
|
||||
if (tabIndex < this.tabs.length - 1) {
|
||||
this.selectTab(this.tabs[tabIndex + 1])
|
||||
} else if (this.config.store.appearance.cycleTabs) {
|
||||
this.selectTab(this.tabs[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousTab (): void {
|
||||
if (!this._activeTab) {
|
||||
return
|
||||
}
|
||||
if (this.tabs.length > 1) {
|
||||
const tabIndex = this.tabs.indexOf(this._activeTab)
|
||||
if (tabIndex > 0) {
|
||||
this.selectTab(this.tabs[tabIndex - 1])
|
||||
} else if (this.config.store.appearance.cycleTabs) {
|
||||
this.selectTab(this.tabs[this.tabs.length - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveSelectedTabLeft (): void {
|
||||
if (!this._activeTab) {
|
||||
return
|
||||
}
|
||||
if (this.tabs.length > 1) {
|
||||
const tabIndex = this.tabs.indexOf(this._activeTab)
|
||||
if (tabIndex > 0) {
|
||||
this.swapTabs(this._activeTab, this.tabs[tabIndex - 1])
|
||||
} else if (this.config.store.appearance.cycleTabs) {
|
||||
this.tabs.push(this.tabs.shift()!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveSelectedTabRight (): void {
|
||||
if (!this._activeTab) {
|
||||
return
|
||||
}
|
||||
if (this.tabs.length > 1) {
|
||||
const tabIndex = this.tabs.indexOf(this._activeTab)
|
||||
if (tabIndex < this.tabs.length - 1) {
|
||||
this.swapTabs(this._activeTab, this.tabs[tabIndex + 1])
|
||||
} else if (this.config.store.appearance.cycleTabs) {
|
||||
this.tabs.unshift(this.tabs.pop()!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
swapTabs (a: BaseTabComponent, b: BaseTabComponent): void {
|
||||
const i1 = this.tabs.indexOf(a)
|
||||
const i2 = this.tabs.indexOf(b)
|
||||
this.tabs[i1] = b
|
||||
this.tabs[i2] = a
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
emitTabsChanged (): void {
|
||||
this.tabsChanged.next()
|
||||
}
|
||||
|
||||
async closeTab (tab: BaseTabComponent, checkCanClose?: boolean): Promise<void> {
|
||||
if (!this.tabs.includes(tab)) {
|
||||
return
|
||||
}
|
||||
if (checkCanClose && !await tab.canClose()) {
|
||||
return
|
||||
}
|
||||
tab.destroy()
|
||||
}
|
||||
|
||||
async duplicateTab (tab: BaseTabComponent): Promise<BaseTabComponent|null> {
|
||||
const dup = await this.tabsService.duplicate(tab)
|
||||
if (dup) {
|
||||
this.addTabRaw(dup, this.tabs.indexOf(tab) + 1)
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to close all tabs, returns false if one of the tabs blocked closure
|
||||
*/
|
||||
async closeAllTabs (): Promise<boolean> {
|
||||
for (const tab of this.tabs) {
|
||||
if (!await tab.canClose()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const tab of this.tabs) {
|
||||
tab.destroy(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async closeWindow (): Promise<void> {
|
||||
this.tabRecovery.enabled = false
|
||||
await this.tabRecovery.saveTabs(this.tabs)
|
||||
if (await this.closeAllTabs()) {
|
||||
this.hostWindow.close()
|
||||
} else {
|
||||
this.tabRecovery.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
emitReady (): void {
|
||||
this.ready.next()
|
||||
this.ready.complete()
|
||||
this.hostApp.emitReady()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that fires once
|
||||
* the tab's internal "process" (see [[BaseTabProcess]]) completes
|
||||
*/
|
||||
observeTabCompletion (tab: BaseTabComponent): Observable<void> {
|
||||
if (!this.completionObservers.has(tab)) {
|
||||
const observer = new CompletionObserver(tab)
|
||||
observer.destroyed$.subscribe(() => {
|
||||
this.stopObservingTabCompletion(tab)
|
||||
})
|
||||
this.completionObservers.set(tab, observer)
|
||||
}
|
||||
return this.completionObservers.get(tab)!.done$
|
||||
}
|
||||
|
||||
stopObservingTabCompletion (tab: BaseTabComponent): void {
|
||||
this.completionObservers.delete(tab)
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
showSelector <T> (name: string, options: SelectorOption<T>[]): Promise<T> {
|
||||
return this.selector.show(name, options)
|
||||
}
|
||||
}
|
317
tabby-core/src/services/config.service.ts
Normal file
317
tabby-core/src/services/config.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Observable, Subject, AsyncSubject } from 'rxjs'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { ConfigProvider } from '../api/configProvider'
|
||||
import { PlatformService } from '../api/platform'
|
||||
import { HostAppService } from '../api/hostApp'
|
||||
import { Vault, VaultService } from './vault.service'
|
||||
const deepmerge = require('deepmerge')
|
||||
|
||||
const configMerge = (a, b) => deepmerge(a, b, { arrayMerge: (_d, s) => s }) // eslint-disable-line @typescript-eslint/no-var-requires
|
||||
|
||||
const LATEST_VERSION = 1
|
||||
|
||||
function isStructuralMember (v) {
|
||||
return v instanceof Object && !(v instanceof Array) &&
|
||||
Object.keys(v).length > 0 && !v.__nonStructural
|
||||
}
|
||||
|
||||
function isNonStructuralObjectMember (v): boolean {
|
||||
return v instanceof Object && !(v instanceof Array) && v.__nonStructural
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class ConfigProxy {
|
||||
constructor (real: Record<string, any>, defaults: Record<string, any>) {
|
||||
for (const key in defaults) {
|
||||
if (isStructuralMember(defaults[key])) {
|
||||
if (!real[key]) {
|
||||
real[key] = {}
|
||||
}
|
||||
const proxy = new ConfigProxy(real[key], defaults[key])
|
||||
Object.defineProperty(
|
||||
this,
|
||||
key,
|
||||
{
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
get: () => proxy,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Object.defineProperty(
|
||||
this,
|
||||
key,
|
||||
{
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
get: () => this.getValue(key),
|
||||
set: (value) => {
|
||||
this.setValue(key, value)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.getValue = (key: string) => { // eslint-disable-line @typescript-eslint/unbound-method
|
||||
if (real[key] !== undefined) {
|
||||
return real[key]
|
||||
} else {
|
||||
if (isNonStructuralObjectMember(defaults[key])) {
|
||||
real[key] = { ...defaults[key] }
|
||||
delete real[key].__nonStructural
|
||||
return real[key]
|
||||
} else {
|
||||
return defaults[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setValue = (key: string, value: any) => { // eslint-disable-line @typescript-eslint/unbound-method
|
||||
real[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
||||
getValue (_key: string): any { }
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
||||
setValue (_key: string, _value: any) { }
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConfigService {
|
||||
/**
|
||||
* Contains the actual config values
|
||||
*/
|
||||
store: any
|
||||
|
||||
/**
|
||||
* Whether an app restart is required due to recent changes
|
||||
*/
|
||||
restartRequested: boolean
|
||||
|
||||
/** Fires once when the config is loaded */
|
||||
get ready$ (): Observable<boolean> { return this.ready }
|
||||
|
||||
private ready = new AsyncSubject<boolean>()
|
||||
private changed = new Subject<void>()
|
||||
private _store: any
|
||||
private defaults: any
|
||||
private servicesCache: Record<string, Function[]>|null = null // eslint-disable-line @typescript-eslint/ban-types
|
||||
|
||||
get changed$ (): Observable<void> { return this.changed }
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private hostApp: HostAppService,
|
||||
private platform: PlatformService,
|
||||
private vault: VaultService,
|
||||
@Inject(ConfigProvider) private configProviders: ConfigProvider[],
|
||||
) {
|
||||
this.defaults = this.mergeDefaults()
|
||||
setTimeout(() => this.init())
|
||||
vault.contentChanged$.subscribe(() => {
|
||||
this.store.vault = vault.store
|
||||
this.save()
|
||||
})
|
||||
}
|
||||
|
||||
mergeDefaults (): unknown {
|
||||
const providers = this.configProviders
|
||||
return providers.map(provider => {
|
||||
let defaults = provider.platformDefaults[this.hostApp.configPlatform] ?? {}
|
||||
defaults = configMerge(
|
||||
defaults,
|
||||
provider.platformDefaults[this.hostApp.platform] ?? {},
|
||||
)
|
||||
if (provider.defaults) {
|
||||
defaults = configMerge(provider.defaults, defaults)
|
||||
}
|
||||
return defaults
|
||||
}).reduce(configMerge)
|
||||
}
|
||||
|
||||
getDefaults (): Record<string, any> {
|
||||
const cleanup = o => {
|
||||
if (o instanceof Array) {
|
||||
return o.map(cleanup)
|
||||
} else if (o instanceof Object) {
|
||||
const r = {}
|
||||
for (const k of Object.keys(o)) {
|
||||
if (k !== '__nonStructural') {
|
||||
r[k] = cleanup(o[k])
|
||||
}
|
||||
}
|
||||
return r
|
||||
} else {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return cleanup(this.defaults)
|
||||
}
|
||||
|
||||
async load (): Promise<void> {
|
||||
const content = await this.platform.loadConfig()
|
||||
if (content) {
|
||||
this._store = yaml.load(content)
|
||||
} else {
|
||||
this._store = { version: LATEST_VERSION }
|
||||
}
|
||||
this._store = await this.maybeDecryptConfig(this._store)
|
||||
this.migrate(this._store)
|
||||
this.store = new ConfigProxy(this._store, this.defaults)
|
||||
this.vault.setStore(this.store.vault)
|
||||
}
|
||||
|
||||
async save (): Promise<void> {
|
||||
// Scrub undefined values
|
||||
let cleanStore = JSON.parse(JSON.stringify(this._store))
|
||||
cleanStore = await this.maybeEncryptConfig(cleanStore)
|
||||
await this.platform.saveConfig(yaml.dump(cleanStore))
|
||||
this.emitChange()
|
||||
this.hostApp.broadcastConfigChange(JSON.parse(JSON.stringify(this.store)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads config YAML as string
|
||||
*/
|
||||
readRaw (): string {
|
||||
return yaml.dump(this._store)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes config YAML as string
|
||||
*/
|
||||
writeRaw (data: string): void {
|
||||
this._store = yaml.load(data)
|
||||
this.save()
|
||||
this.load()
|
||||
this.emitChange()
|
||||
}
|
||||
|
||||
requestRestart (): void {
|
||||
this.restartRequested = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a list of Angular services to only include those provided
|
||||
* by plugins that are enabled
|
||||
*
|
||||
* @typeparam T Base provider type
|
||||
*/
|
||||
enabledServices<T extends object> (services: T[]): T[] { // eslint-disable-line @typescript-eslint/ban-types
|
||||
if (!this.servicesCache) {
|
||||
this.servicesCache = {}
|
||||
const ngModule = window['rootModule'].ɵinj
|
||||
for (const imp of ngModule.imports) {
|
||||
const module = imp.ngModule || imp
|
||||
if (module.ɵinj?.providers) {
|
||||
this.servicesCache[module.pluginName] = module.ɵinj.providers.map(provider => {
|
||||
return provider.useClass || provider
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return services.filter(service => {
|
||||
for (const pluginName in this.servicesCache) {
|
||||
if (this.servicesCache[pluginName].includes(service.constructor)) {
|
||||
return !this.store?.pluginBlacklist?.includes(pluginName)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
private async init () {
|
||||
await this.load()
|
||||
this.ready.next(true)
|
||||
this.ready.complete()
|
||||
|
||||
this.hostApp.configChangeBroadcast$.subscribe(() => {
|
||||
this.load()
|
||||
this.emitChange()
|
||||
})
|
||||
}
|
||||
|
||||
private emitChange (): void {
|
||||
this.changed.next()
|
||||
this.vault.setStore(this.store.vault)
|
||||
}
|
||||
|
||||
private migrate (config) {
|
||||
config.version ??= 0
|
||||
if (config.version < 1) {
|
||||
for (const connection of config.ssh?.connections ?? []) {
|
||||
if (connection.privateKey) {
|
||||
connection.privateKeys = [connection.privateKey]
|
||||
delete connection.privateKey
|
||||
}
|
||||
}
|
||||
config.version = 1
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeDecryptConfig (store) {
|
||||
if (!store.encrypted) {
|
||||
return store
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let decryptedVault: Vault
|
||||
while (true) {
|
||||
try {
|
||||
const passphrase = await this.vault.getPassphrase()
|
||||
decryptedVault = await this.vault.decrypt(store.vault, passphrase)
|
||||
break
|
||||
} catch (e) {
|
||||
let result = await this.platform.showMessageBox({
|
||||
type: 'error',
|
||||
message: 'Could not decrypt config',
|
||||
detail: e.toString(),
|
||||
buttons: ['Try again', 'Erase config', 'Quit'],
|
||||
defaultId: 0,
|
||||
})
|
||||
if (result.response === 2) {
|
||||
this.platform.quit()
|
||||
}
|
||||
if (result.response === 1) {
|
||||
result = await this.platform.showMessageBox({
|
||||
type: 'warning',
|
||||
message: 'Are you sure?',
|
||||
detail: e.toString(),
|
||||
buttons: ['Erase config', 'Quit'],
|
||||
defaultId: 1,
|
||||
})
|
||||
if (result.response === 1) {
|
||||
this.platform.quit()
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete decryptedVault.config.vault
|
||||
delete decryptedVault.config.encrypted
|
||||
return {
|
||||
...decryptedVault.config,
|
||||
vault: store.vault,
|
||||
encrypted: store.encrypted,
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeEncryptConfig (store) {
|
||||
if (!store.encrypted) {
|
||||
return store
|
||||
}
|
||||
const vault = await this.vault.load()
|
||||
if (!vault) {
|
||||
throw new Error('Vault not configured')
|
||||
}
|
||||
vault.config = { ...store }
|
||||
delete vault.config.vault
|
||||
delete vault.config.encrypted
|
||||
return {
|
||||
vault: await this.vault.encrypt(vault),
|
||||
encrypted: true,
|
||||
}
|
||||
}
|
||||
}
|
14
tabby-core/src/services/docking.service.ts
Normal file
14
tabby-core/src/services/docking.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
|
||||
export abstract class Screen {
|
||||
id: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export abstract class DockingService {
|
||||
get screensChanged$ (): Observable<void> { return this.screensChanged }
|
||||
protected screensChanged = new Subject<void>()
|
||||
|
||||
abstract dock (): void
|
||||
abstract getScreens (): Screen[]
|
||||
}
|
48
tabby-core/src/services/fileProviders.service.ts
Normal file
48
tabby-core/src/services/fileProviders.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { FileProvider, NotificationsService, SelectorService } from '../api'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileProvidersService {
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private selector: SelectorService,
|
||||
private notifications: NotificationsService,
|
||||
@Inject(FileProvider) private fileProviders: FileProvider[],
|
||||
) { }
|
||||
|
||||
async selectAndStoreFile (description: string): Promise<string> {
|
||||
const p = await this.selectProvider()
|
||||
return p.selectAndStoreFile(description)
|
||||
}
|
||||
|
||||
async retrieveFile (key: string): Promise<Buffer> {
|
||||
for (const p of this.fileProviders) {
|
||||
try {
|
||||
return await p.retrieveFile(key)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw new Error('Not found')
|
||||
}
|
||||
|
||||
async selectProvider (): Promise<FileProvider> {
|
||||
const providers: FileProvider[] = []
|
||||
await Promise.all(this.fileProviders.map(async p => {
|
||||
if (await p.isAvailable()) {
|
||||
providers.push(p)
|
||||
}
|
||||
}))
|
||||
if (!providers.length) {
|
||||
this.notifications.error('Vault master passphrase needs to be set to allow storing secrets')
|
||||
throw new Error('No available file providers')
|
||||
}
|
||||
if (providers.length === 1) {
|
||||
return providers[0]
|
||||
}
|
||||
return this.selector.show('Select file storage', providers.map(p => ({
|
||||
name: p.name,
|
||||
result: p,
|
||||
})))
|
||||
}
|
||||
}
|
67
tabby-core/src/services/homeBase.service.ts
Normal file
67
tabby-core/src/services/homeBase.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import * as mixpanel from 'mixpanel'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ConfigService } from './config.service'
|
||||
import { PlatformService, BOOTSTRAP_DATA, BootstrapData } from '../api'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HomeBaseService {
|
||||
appVersion: string
|
||||
mixpanel: any
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private config: ConfigService,
|
||||
private platform: PlatformService,
|
||||
@Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData,
|
||||
) {
|
||||
this.appVersion = platform.getAppVersion()
|
||||
|
||||
if (this.config.store.enableAnalytics && !this.config.store.enableWelcomeTab) {
|
||||
this.enableAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
openGitHub (): void {
|
||||
this.platform.openExternal('https://github.com/eugeny/terminus')
|
||||
}
|
||||
|
||||
reportBug (): void {
|
||||
let body = `Version: ${this.appVersion}\n`
|
||||
body += `Platform: ${process.platform} ${this.platform.getOSRelease()}\n`
|
||||
const label = {
|
||||
aix: 'OS: IBM AIX',
|
||||
android: 'OS: Android',
|
||||
darwin: 'OS: macOS',
|
||||
freebsd: 'OS: FreeBSD',
|
||||
linux: 'OS: Linux',
|
||||
openbsd: 'OS: OpenBSD',
|
||||
sunos: 'OS: Solaris',
|
||||
win32: 'OS: Windows',
|
||||
}[process.platform]
|
||||
const plugins = this.bootstrapData.installedPlugins.filter(x => !x.isBuiltin).map(x => x.name)
|
||||
body += `Plugins: ${plugins.join(', ') || 'none'}\n\n`
|
||||
this.platform.openExternal(`https://github.com/eugeny/terminus/issues/new?body=${encodeURIComponent(body)}&labels=${label}`)
|
||||
}
|
||||
|
||||
enableAnalytics (): void {
|
||||
if (!window.localStorage.analyticsUserID) {
|
||||
window.localStorage.analyticsUserID = uuidv4()
|
||||
}
|
||||
this.mixpanel = mixpanel.init('bb4638b0860eef14c04d4fbc5eb365fa')
|
||||
if (!window.localStorage.installEventSent) {
|
||||
this.mixpanel.track('freshInstall', this.getAnalyticsProperties())
|
||||
window.localStorage.installEventSent = true
|
||||
}
|
||||
this.mixpanel.track('launch', this.getAnalyticsProperties())
|
||||
}
|
||||
|
||||
getAnalyticsProperties (): Record<string, string> {
|
||||
return {
|
||||
distinct_id: window.localStorage.analyticsUserID,
|
||||
platform: process.platform,
|
||||
os: this.platform.getOSRelease(),
|
||||
version: this.appVersion,
|
||||
}
|
||||
}
|
||||
}
|
207
tabby-core/src/services/hotkeys.service.ts
Normal file
207
tabby-core/src/services/hotkeys.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider'
|
||||
import { stringifyKeySequence, EventData } from './hotkeys.util'
|
||||
import { ConfigService } from './config.service'
|
||||
|
||||
export interface PartialHotkeyMatch {
|
||||
id: string
|
||||
strokes: string[]
|
||||
matchedLength: number
|
||||
}
|
||||
|
||||
const KEY_TIMEOUT = 2000
|
||||
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HotkeysService {
|
||||
key = new EventEmitter<KeyboardEvent>()
|
||||
|
||||
/** @hidden */
|
||||
matchedHotkey = new EventEmitter<string>()
|
||||
|
||||
/**
|
||||
* Fired for each recognized hotkey
|
||||
*/
|
||||
get hotkey$ (): Observable<string> { return this._hotkey }
|
||||
|
||||
private _hotkey = new Subject<string>()
|
||||
private currentKeystrokes: EventData[] = []
|
||||
private disabledLevel = 0
|
||||
private hotkeyDescriptions: HotkeyDescription[] = []
|
||||
|
||||
private constructor (
|
||||
private zone: NgZone,
|
||||
private config: ConfigService,
|
||||
@Inject(HotkeyProvider) private hotkeyProviders: HotkeyProvider[],
|
||||
) {
|
||||
const events = ['keydown', 'keyup']
|
||||
events.forEach(event => {
|
||||
document.addEventListener(event, (nativeEvent: KeyboardEvent) => {
|
||||
if (document.querySelectorAll('input:focus').length === 0) {
|
||||
this.pushKeystroke(event, nativeEvent)
|
||||
this.processKeystrokes()
|
||||
this.emitKeyEvent(nativeEvent)
|
||||
}
|
||||
})
|
||||
})
|
||||
this.config.ready$.toPromise().then(() => {
|
||||
this.getHotkeyDescriptions().then(hotkeys => {
|
||||
this.hotkeyDescriptions = hotkeys
|
||||
})
|
||||
})
|
||||
|
||||
// deprecated
|
||||
this.hotkey$.subscribe(h => this.matchedHotkey.emit(h))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new key event to the buffer
|
||||
*
|
||||
* @param name DOM event name
|
||||
* @param nativeEvent event object
|
||||
*/
|
||||
pushKeystroke (name: string, nativeEvent: KeyboardEvent): void {
|
||||
(nativeEvent as any).event = name
|
||||
this.currentKeystrokes.push({
|
||||
ctrlKey: nativeEvent.ctrlKey,
|
||||
metaKey: nativeEvent.metaKey,
|
||||
altKey: nativeEvent.altKey,
|
||||
shiftKey: nativeEvent.shiftKey,
|
||||
code: nativeEvent.code,
|
||||
key: nativeEvent.key,
|
||||
eventName: name,
|
||||
time: performance.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the buffer for new complete keystrokes
|
||||
*/
|
||||
processKeystrokes (): void {
|
||||
if (this.isEnabled()) {
|
||||
this.zone.run(() => {
|
||||
const matched = this.getCurrentFullyMatchedHotkey()
|
||||
if (matched) {
|
||||
console.log('Matched hotkey', matched)
|
||||
this._hotkey.next(matched)
|
||||
this.clearCurrentKeystrokes()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitKeyEvent (nativeEvent: KeyboardEvent): void {
|
||||
this.zone.run(() => {
|
||||
this.key.emit(nativeEvent)
|
||||
})
|
||||
}
|
||||
|
||||
clearCurrentKeystrokes (): void {
|
||||
this.currentKeystrokes = []
|
||||
}
|
||||
|
||||
getCurrentKeystrokes (): string[] {
|
||||
this.currentKeystrokes = this.currentKeystrokes.filter(x => performance.now() - x.time < KEY_TIMEOUT)
|
||||
return stringifyKeySequence(this.currentKeystrokes)
|
||||
}
|
||||
|
||||
getCurrentFullyMatchedHotkey (): string|null {
|
||||
const currentStrokes = this.getCurrentKeystrokes()
|
||||
const config = this.getHotkeysConfig()
|
||||
for (const id in config) {
|
||||
for (const sequence of config[id]) {
|
||||
if (currentStrokes.length < sequence.length) {
|
||||
continue
|
||||
}
|
||||
if (sequence.every(
|
||||
(x: string, index: number) =>
|
||||
x.toLowerCase() ===
|
||||
currentStrokes[currentStrokes.length - sequence.length + index].toLowerCase()
|
||||
)) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
getCurrentPartiallyMatchedHotkeys (): PartialHotkeyMatch[] {
|
||||
const currentStrokes = this.getCurrentKeystrokes()
|
||||
const config = this.getHotkeysConfig()
|
||||
const result: PartialHotkeyMatch[] = []
|
||||
for (const id in config) {
|
||||
for (const sequence of config[id]) {
|
||||
for (let matchLength = Math.min(currentStrokes.length, sequence.length); matchLength > 0; matchLength--) {
|
||||
if (sequence.slice(0, matchLength).every(
|
||||
(x: string, index: number) =>
|
||||
x.toLowerCase() ===
|
||||
currentStrokes[currentStrokes.length - matchLength + index].toLowerCase()
|
||||
)) {
|
||||
result.push({
|
||||
matchedLength: matchLength,
|
||||
id,
|
||||
strokes: sequence,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getHotkeyDescription (id: string): HotkeyDescription {
|
||||
return this.hotkeyDescriptions.filter((x) => x.id === id)[0]
|
||||
}
|
||||
|
||||
enable (): void {
|
||||
this.disabledLevel--
|
||||
}
|
||||
|
||||
disable (): void {
|
||||
this.disabledLevel++
|
||||
}
|
||||
|
||||
isEnabled (): boolean {
|
||||
return this.disabledLevel === 0
|
||||
}
|
||||
|
||||
async getHotkeyDescriptions (): Promise<HotkeyDescription[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
this.config.enabledServices(this.hotkeyProviders)
|
||||
.map(async x => x.provide())
|
||||
)
|
||||
).reduce((a, b) => a.concat(b))
|
||||
}
|
||||
|
||||
private getHotkeysConfig () {
|
||||
return this.getHotkeysConfigRecursive(this.config.store.hotkeys)
|
||||
}
|
||||
|
||||
private getHotkeysConfigRecursive (branch: any) {
|
||||
const keys = {}
|
||||
for (const key in branch) {
|
||||
let value = branch[key]
|
||||
if (value instanceof Object && !(value instanceof Array)) {
|
||||
const subkeys = this.getHotkeysConfigRecursive(value)
|
||||
for (const subkey in subkeys) {
|
||||
keys[key + '.' + subkey] = subkeys[subkey]
|
||||
}
|
||||
} else {
|
||||
if (typeof value === 'string') {
|
||||
value = [value]
|
||||
}
|
||||
if (!(value instanceof Array)) {
|
||||
continue
|
||||
}
|
||||
if (value.length > 0) {
|
||||
value = value.map((item: string | string[]) => typeof item === 'string' ? [item] : item)
|
||||
keys[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
}
|
81
tabby-core/src/services/hotkeys.util.ts
Normal file
81
tabby-core/src/services/hotkeys.util.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export const metaKeyName = {
|
||||
darwin: '⌘',
|
||||
win32: 'Win',
|
||||
linux: 'Super',
|
||||
}[process.platform]
|
||||
|
||||
export const altKeyName = {
|
||||
darwin: '⌥',
|
||||
win32: 'Alt',
|
||||
linux: 'Alt',
|
||||
}[process.platform]
|
||||
|
||||
export interface EventData {
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
altKey: boolean
|
||||
shiftKey: boolean
|
||||
key: string
|
||||
code: string
|
||||
eventName: string
|
||||
time: number
|
||||
}
|
||||
|
||||
const REGEX_LATIN_KEYNAME = /^[A-Za-z]$/
|
||||
|
||||
export function stringifyKeySequence (events: EventData[]): string[] {
|
||||
const items: string[] = []
|
||||
events = events.slice()
|
||||
|
||||
while (events.length > 0) {
|
||||
const event = events.shift()!
|
||||
if (event.eventName === 'keydown') {
|
||||
const itemKeys: string[] = []
|
||||
if (event.ctrlKey) {
|
||||
itemKeys.push('Ctrl')
|
||||
}
|
||||
if (event.metaKey) {
|
||||
itemKeys.push(metaKeyName)
|
||||
}
|
||||
if (event.altKey) {
|
||||
itemKeys.push(altKeyName)
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
itemKeys.push('Shift')
|
||||
}
|
||||
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) {
|
||||
// TODO make this optional?
|
||||
continue
|
||||
}
|
||||
|
||||
let key = event.code
|
||||
if (REGEX_LATIN_KEYNAME.test(event.key)) {
|
||||
// Handle Dvorak etc via the reported "character" instead of the scancode
|
||||
key = event.key.toUpperCase()
|
||||
} else {
|
||||
key = key.replace('Key', '')
|
||||
key = key.replace('Arrow', '')
|
||||
key = key.replace('Digit', '')
|
||||
key = {
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
Backslash: '\\',
|
||||
IntlBackslash: '`',
|
||||
Backquote: '~', // Electron says it's the tilde
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
Semicolon: ';',
|
||||
Quote: '\'',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
}[key] ?? key
|
||||
}
|
||||
|
||||
itemKeys.push(key)
|
||||
items.push(itemKeys.join('-'))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
35
tabby-core/src/services/log.service.ts
Normal file
35
tabby-core/src/services/log.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export abstract class Logger {
|
||||
constructor (protected name: string) { }
|
||||
|
||||
debug (...args: any[]): void {
|
||||
this.doLog('debug', ...args)
|
||||
}
|
||||
|
||||
info (...args: any[]): void {
|
||||
this.doLog('info', ...args)
|
||||
}
|
||||
|
||||
warn (...args: any[]): void {
|
||||
this.doLog('warn', ...args)
|
||||
}
|
||||
|
||||
error (...args: any[]): void {
|
||||
this.doLog('error', ...args)
|
||||
}
|
||||
|
||||
log (...args: any[]): void {
|
||||
this.doLog('log', ...args)
|
||||
}
|
||||
|
||||
protected abstract doLog (level: string, ...args: any[]): void
|
||||
}
|
||||
|
||||
export class ConsoleLogger extends Logger {
|
||||
protected doLog (level: string, ...args: any[]): void {
|
||||
console[level](`%c[${this.name}]`, 'color: #aaa', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class LogService {
|
||||
abstract create (name: string): Logger
|
||||
}
|
23
tabby-core/src/services/notifications.service.ts
Normal file
23
tabby-core/src/services/notifications.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToastrService } from 'ngx-toastr'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationsService {
|
||||
private constructor (
|
||||
private toastr: ToastrService,
|
||||
) { }
|
||||
|
||||
notice (text: string): void {
|
||||
this.toastr.info(text, undefined, {
|
||||
timeOut: 1000,
|
||||
})
|
||||
}
|
||||
|
||||
info (text: string, details?: string): void {
|
||||
this.toastr.info(text, details)
|
||||
}
|
||||
|
||||
error (text: string, details?: string): void {
|
||||
this.toastr.error(text, details)
|
||||
}
|
||||
}
|
22
tabby-core/src/services/selector.service.ts
Normal file
22
tabby-core/src/services/selector.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import { Injectable } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
import { SelectorModalComponent } from '../components/selectorModal.component'
|
||||
import { SelectorOption } from '../api/selector'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SelectorService {
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private ngbModal: NgbModal,
|
||||
) { }
|
||||
|
||||
show <T> (name: string, options: SelectorOption<T>[]): Promise<T> {
|
||||
const modal = this.ngbModal.open(SelectorModalComponent)
|
||||
const instance: SelectorModalComponent<T> = modal.componentInstance
|
||||
instance.name = name
|
||||
instance.options = options
|
||||
return modal.result as Promise<T>
|
||||
}
|
||||
}
|
77
tabby-core/src/services/tabRecovery.service.ts
Normal file
77
tabby-core/src/services/tabRecovery.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from '../api/tabRecovery'
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { Logger, LogService } from '../services/log.service'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TabRecoveryService {
|
||||
logger: Logger
|
||||
enabled = false
|
||||
|
||||
private constructor (
|
||||
@Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[]|null,
|
||||
private config: ConfigService,
|
||||
log: LogService
|
||||
) {
|
||||
this.logger = log.create('tabRecovery')
|
||||
}
|
||||
|
||||
async saveTabs (tabs: BaseTabComponent[]): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
window.localStorage.tabsRecovery = JSON.stringify(
|
||||
(await Promise.all(
|
||||
tabs.map(async tab => this.getFullRecoveryToken(tab))
|
||||
)).filter(token => !!token)
|
||||
)
|
||||
}
|
||||
|
||||
async getFullRecoveryToken (tab: BaseTabComponent): Promise<RecoveryToken|null> {
|
||||
const token = await tab.getRecoveryToken()
|
||||
if (token) {
|
||||
token.tabTitle = tab.title
|
||||
if (tab.color) {
|
||||
token.tabColor = tab.color
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
async recoverTab (token: RecoveryToken, duplicate = false): Promise<RecoveredTab|null> {
|
||||
for (const provider of this.config.enabledServices(this.tabRecoveryProviders ?? [])) {
|
||||
try {
|
||||
if (!await provider.applicableTo(token)) {
|
||||
continue
|
||||
}
|
||||
if (duplicate) {
|
||||
token = provider.duplicate(token)
|
||||
}
|
||||
const tab = await provider.recover(token)
|
||||
tab.options = tab.options || {}
|
||||
tab.options.color = token.tabColor ?? null
|
||||
tab.options.title = token.tabTitle || ''
|
||||
return tab
|
||||
} catch (error) {
|
||||
this.logger.warn('Tab recovery crashed:', token, provider, error)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async recoverTabs (): Promise<RecoveredTab[]> {
|
||||
if (window.localStorage.tabsRecovery) {
|
||||
const tabs: RecoveredTab[] = []
|
||||
for (const token of JSON.parse(window.localStorage.tabsRecovery)) {
|
||||
const tab = await this.recoverTab(token)
|
||||
if (tab) {
|
||||
tabs.push(tab)
|
||||
}
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
43
tabby-core/src/services/tabs.service.ts
Normal file
43
tabby-core/src/services/tabs.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, ComponentFactoryResolver, Injector } from '@angular/core'
|
||||
import { BaseTabComponent } from '../components/baseTab.component'
|
||||
import { TabRecoveryService } from './tabRecovery.service'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-type-alias
|
||||
export type TabComponentType = new (...args: any[]) => BaseTabComponent
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TabsService {
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private componentFactoryResolver: ComponentFactoryResolver,
|
||||
private injector: Injector,
|
||||
private tabRecovery: TabRecoveryService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Instantiates a tab component and assigns given inputs
|
||||
*/
|
||||
create (type: TabComponentType, inputs?: Record<string, any>): BaseTabComponent {
|
||||
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(type)
|
||||
const componentRef = componentFactory.create(this.injector)
|
||||
const tab = componentRef.instance
|
||||
tab.hostView = componentRef.hostView
|
||||
Object.assign(tab, inputs ?? {})
|
||||
return tab
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing tab instance (using the tab recovery system)
|
||||
*/
|
||||
async duplicate (tab: BaseTabComponent): Promise<BaseTabComponent|null> {
|
||||
const token = await this.tabRecovery.getFullRecoveryToken(tab)
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
const dup = await this.tabRecovery.recoverTab(token, true)
|
||||
if (dup) {
|
||||
return this.create(dup.type, dup.options)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
49
tabby-core/src/services/themes.service.ts
Normal file
49
tabby-core/src/services/themes.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { ConfigService } from '../services/config.service'
|
||||
import { Theme } from '../api/theme'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemesService {
|
||||
get themeChanged$ (): Observable<Theme> { return this.themeChanged }
|
||||
private themeChanged = new Subject<Theme>()
|
||||
|
||||
private styleElement: HTMLElement|null = null
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private config: ConfigService,
|
||||
@Inject(Theme) private themes: Theme[],
|
||||
) {
|
||||
this.applyTheme(this.findTheme('Standard')!)
|
||||
config.ready$.toPromise().then(() => {
|
||||
this.applyCurrentTheme()
|
||||
config.changed$.subscribe(() => {
|
||||
this.applyCurrentTheme()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
findTheme (name: string): Theme|null {
|
||||
return this.config.enabledServices(this.themes).find(x => x.name === name) ?? null
|
||||
}
|
||||
|
||||
findCurrentTheme (): Theme {
|
||||
return this.findTheme(this.config.store.appearance.theme) ?? this.findTheme('Standard')!
|
||||
}
|
||||
|
||||
applyTheme (theme: Theme): void {
|
||||
if (!this.styleElement) {
|
||||
this.styleElement = document.createElement('style')
|
||||
this.styleElement.setAttribute('id', 'theme')
|
||||
document.querySelector('head')!.appendChild(this.styleElement)
|
||||
}
|
||||
this.styleElement.textContent = theme.css
|
||||
document.querySelector('style#custom-css')!.innerHTML = this.config.store?.appearance?.css
|
||||
this.themeChanged.next(theme)
|
||||
}
|
||||
|
||||
private applyCurrentTheme (): void {
|
||||
this.applyTheme(this.findCurrentTheme())
|
||||
}
|
||||
}
|
4
tabby-core/src/services/updater.service.ts
Normal file
4
tabby-core/src/services/updater.service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export abstract class UpdaterService {
|
||||
abstract check (): Promise<boolean>
|
||||
abstract update (): Promise<void>
|
||||
}
|
294
tabby-core/src/services/vault.service.ts
Normal file
294
tabby-core/src/services/vault.service.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import * as crypto from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
import { Injectable, NgZone } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { AsyncSubject, Subject, Observable } from 'rxjs'
|
||||
import { wrapPromise } from '../utils'
|
||||
import { UnlockVaultModalComponent } from '../components/unlockVaultModal.component'
|
||||
import { NotificationsService } from './notifications.service'
|
||||
import { SelectorService } from './selector.service'
|
||||
import { FileProvider } from '../api/fileProvider'
|
||||
import { PlatformService } from '../api/platform'
|
||||
|
||||
const PBKDF_ITERATIONS = 100000
|
||||
const PBKDF_DIGEST = 'sha512'
|
||||
const PBKDF_SALT_LENGTH = 64 / 8
|
||||
const CRYPT_ALG = 'aes-256-cbc'
|
||||
const CRYPT_KEY_LENGTH = 256 / 8
|
||||
const CRYPT_IV_LENGTH = 128 / 8
|
||||
|
||||
interface StoredVault {
|
||||
version: number
|
||||
contents: string
|
||||
keySalt: string
|
||||
iv: string
|
||||
}
|
||||
|
||||
export interface VaultSecret {
|
||||
type: string
|
||||
key: Record<string, any>
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
config: any
|
||||
secrets: VaultSecret[]
|
||||
}
|
||||
|
||||
function migrateVaultContent (content: any): Vault {
|
||||
return {
|
||||
config: content.config,
|
||||
secrets: content.secrets ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVaultKey (passphrase: string, salt: Buffer): Promise<Buffer> {
|
||||
return promisify(crypto.pbkdf2)(
|
||||
Buffer.from(passphrase),
|
||||
salt,
|
||||
PBKDF_ITERATIONS,
|
||||
CRYPT_KEY_LENGTH,
|
||||
PBKDF_DIGEST,
|
||||
)
|
||||
}
|
||||
|
||||
async function encryptVault (content: Vault, passphrase: string): Promise<StoredVault> {
|
||||
const keySalt = await promisify(crypto.randomBytes)(PBKDF_SALT_LENGTH)
|
||||
const iv = await promisify(crypto.randomBytes)(CRYPT_IV_LENGTH)
|
||||
const key = await deriveVaultKey(passphrase, keySalt)
|
||||
|
||||
const plaintext = JSON.stringify(content)
|
||||
const cipher = crypto.createCipheriv(CRYPT_ALG, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()])
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
contents: encrypted.toString('base64'),
|
||||
keySalt: keySalt.toString('hex'),
|
||||
iv: iv.toString('hex'),
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptVault (vault: StoredVault, passphrase: string): Promise<Vault> {
|
||||
if (vault.version !== 1) {
|
||||
throw new Error(`Unsupported vault format version ${vault.version}`)
|
||||
}
|
||||
const keySalt = Buffer.from(vault.keySalt, 'hex')
|
||||
const key = await deriveVaultKey(passphrase, keySalt)
|
||||
const iv = Buffer.from(vault.iv, 'hex')
|
||||
const encrypted = Buffer.from(vault.contents, 'base64')
|
||||
|
||||
const decipher = crypto.createDecipheriv(CRYPT_ALG, key, iv)
|
||||
const plaintext = decipher.update(encrypted, undefined, 'utf-8') + decipher.final('utf-8')
|
||||
return migrateVaultContent(JSON.parse(plaintext))
|
||||
}
|
||||
|
||||
export const VAULT_SECRET_TYPE_FILE = 'file'
|
||||
|
||||
// Don't make it accessible through VaultService fields
|
||||
let _rememberedPassphrase: string|null = null
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VaultService {
|
||||
/** Fires once when the config is loaded */
|
||||
get ready$ (): Observable<boolean> { return this.ready }
|
||||
|
||||
get contentChanged$ (): Observable<void> { return this.contentChanged }
|
||||
|
||||
store: StoredVault|null = null
|
||||
private ready = new AsyncSubject<boolean>()
|
||||
private contentChanged = new Subject<void>()
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private zone: NgZone,
|
||||
private notifications: NotificationsService,
|
||||
private ngbModal: NgbModal,
|
||||
) { }
|
||||
|
||||
async setEnabled (enabled: boolean, passphrase?: string): Promise<void> {
|
||||
if (enabled) {
|
||||
if (!this.store) {
|
||||
await this.save(migrateVaultContent({}), passphrase)
|
||||
}
|
||||
} else {
|
||||
this.store = null
|
||||
this.contentChanged.next()
|
||||
}
|
||||
}
|
||||
|
||||
isOpen (): boolean {
|
||||
return !!_rememberedPassphrase
|
||||
}
|
||||
|
||||
async decrypt (storage: StoredVault, passphrase?: string): Promise<Vault> {
|
||||
if (!passphrase) {
|
||||
passphrase = await this.getPassphrase()
|
||||
}
|
||||
try {
|
||||
return await wrapPromise(this.zone, decryptVault(storage, passphrase))
|
||||
} catch (e) {
|
||||
_rememberedPassphrase = null
|
||||
if (e.toString().includes('BAD_DECRYPT')) {
|
||||
this.notifications.error('Incorrect passphrase')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async load (passphrase?: string): Promise<Vault|null> {
|
||||
if (!this.store) {
|
||||
return null
|
||||
}
|
||||
return this.decrypt(this.store, passphrase)
|
||||
}
|
||||
|
||||
async encrypt (vault: Vault, passphrase?: string): Promise<StoredVault|null> {
|
||||
if (!passphrase) {
|
||||
passphrase = await this.getPassphrase()
|
||||
}
|
||||
if (_rememberedPassphrase) {
|
||||
_rememberedPassphrase = passphrase
|
||||
}
|
||||
return wrapPromise(this.zone, encryptVault(vault, passphrase))
|
||||
}
|
||||
|
||||
async save (vault: Vault, passphrase?: string): Promise<void> {
|
||||
await this.ready$.toPromise()
|
||||
this.store = await this.encrypt(vault, passphrase)
|
||||
this.contentChanged.next()
|
||||
}
|
||||
|
||||
async getPassphrase (): Promise<string> {
|
||||
if (!_rememberedPassphrase) {
|
||||
const modal = this.ngbModal.open(UnlockVaultModalComponent)
|
||||
const { passphrase, rememberFor } = await modal.result
|
||||
setTimeout(() => {
|
||||
_rememberedPassphrase = null
|
||||
// avoid multiple consequent prompts
|
||||
}, Math.max(1000, rememberFor * 60000))
|
||||
_rememberedPassphrase = passphrase
|
||||
}
|
||||
|
||||
return _rememberedPassphrase!
|
||||
}
|
||||
|
||||
async getSecret (type: string, key: Record<string, any>): Promise<VaultSecret|null> {
|
||||
await this.ready$.toPromise()
|
||||
const vault = await this.load()
|
||||
if (!vault) {
|
||||
return null
|
||||
}
|
||||
return vault.secrets.find(s => s.type === type && this.keyMatches(key, s)) ?? null
|
||||
}
|
||||
|
||||
async addSecret (secret: VaultSecret): Promise<void> {
|
||||
await this.ready$.toPromise()
|
||||
const vault = await this.load()
|
||||
if (!vault) {
|
||||
return
|
||||
}
|
||||
vault.secrets = vault.secrets.filter(s => s.type !== secret.type || !this.keyMatches(secret.key, s))
|
||||
vault.secrets.push(secret)
|
||||
await this.save(vault)
|
||||
}
|
||||
|
||||
async removeSecret (type: string, key: Record<string, any>): Promise<void> {
|
||||
await this.ready$.toPromise()
|
||||
const vault = await this.load()
|
||||
if (!vault) {
|
||||
return
|
||||
}
|
||||
vault.secrets = vault.secrets.filter(s => s.type !== type || !this.keyMatches(key, s))
|
||||
await this.save(vault)
|
||||
}
|
||||
|
||||
private keyMatches (key: Record<string, any>, secret: VaultSecret): boolean {
|
||||
return Object.keys(key).every(k => secret.key[k] === key[k])
|
||||
}
|
||||
|
||||
setStore (store: StoredVault): void {
|
||||
this.store = store
|
||||
this.ready.next(true)
|
||||
this.ready.complete()
|
||||
}
|
||||
|
||||
isEnabled (): boolean {
|
||||
return !!this.store
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class VaultFileProvider extends FileProvider {
|
||||
name = 'Vault'
|
||||
prefix = 'vault://'
|
||||
|
||||
constructor (
|
||||
private vault: VaultService,
|
||||
private platform: PlatformService,
|
||||
private selector: SelectorService,
|
||||
private zone: NgZone,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async isAvailable (): Promise<boolean> {
|
||||
return this.vault.isEnabled()
|
||||
}
|
||||
|
||||
async selectAndStoreFile (description: string): Promise<string> {
|
||||
const vault = await this.vault.load()
|
||||
if (!vault) {
|
||||
throw new Error('Vault is locked')
|
||||
}
|
||||
const files = vault.secrets.filter(x => x.type === VAULT_SECRET_TYPE_FILE)
|
||||
if (files.length) {
|
||||
const result = await this.selector.show<VaultSecret|null>('Select file', [
|
||||
{
|
||||
name: 'Add a new file',
|
||||
icon: 'plus',
|
||||
result: null,
|
||||
},
|
||||
...files.map(f => ({
|
||||
name: f.key.description,
|
||||
icon: 'file',
|
||||
result: f,
|
||||
})),
|
||||
])
|
||||
if (result) {
|
||||
return `${this.prefix}${result.key.id}`
|
||||
}
|
||||
}
|
||||
return this.addNewFile(description)
|
||||
}
|
||||
|
||||
async addNewFile (description: string): Promise<string> {
|
||||
const transfers = await this.platform.startUpload()
|
||||
if (!transfers.length) {
|
||||
throw new Error('Nothing selected')
|
||||
}
|
||||
const transfer = transfers[0]
|
||||
const id = (await wrapPromise(this.zone, promisify(crypto.randomBytes)(32))).toString('hex')
|
||||
this.vault.addSecret({
|
||||
type: VAULT_SECRET_TYPE_FILE,
|
||||
key: {
|
||||
id,
|
||||
description,
|
||||
},
|
||||
value: (await transfer.readAll()).toString('base64'),
|
||||
})
|
||||
return `${this.prefix}${id}`
|
||||
}
|
||||
|
||||
async retrieveFile (key: string): Promise<Buffer> {
|
||||
if (!key.startsWith(this.prefix)) {
|
||||
throw new Error('Incorrect type')
|
||||
}
|
||||
const secret = await this.vault.getSecret(VAULT_SECRET_TYPE_FILE, { id: key.substring(this.prefix.length) })
|
||||
if (!secret) {
|
||||
throw new Error('Not found')
|
||||
}
|
||||
return Buffer.from(secret.value, 'base64')
|
||||
}
|
||||
}
|
205
tabby-core/src/tabContextMenu.ts
Normal file
205
tabby-core/src/tabContextMenu.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { AppService } from './services/app.service'
|
||||
import { BaseTabComponent } from './components/baseTab.component'
|
||||
import { TabHeaderComponent } from './components/tabHeader.component'
|
||||
import { SplitTabComponent, SplitDirection } from './components/splitTab.component'
|
||||
import { TabContextMenuItemProvider } from './api/tabContextMenuProvider'
|
||||
import { MenuItemOptions } from './api/menu'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class TabManagementContextMenu extends TabContextMenuItemProvider {
|
||||
weight = 99
|
||||
|
||||
constructor (
|
||||
private app: AppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
let items: MenuItemOptions[] = [
|
||||
{
|
||||
label: 'Close',
|
||||
click: () => {
|
||||
if (this.app.tabs.includes(tab)) {
|
||||
this.app.closeTab(tab, true)
|
||||
} else {
|
||||
tab.destroy()
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
if (tabHeader) {
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
label: 'Close other tabs',
|
||||
click: () => {
|
||||
for (const t of this.app.tabs.filter(x => x !== tab)) {
|
||||
this.app.closeTab(t, true)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close tabs to the right',
|
||||
click: () => {
|
||||
for (const t of this.app.tabs.slice(this.app.tabs.indexOf(tab) + 1)) {
|
||||
this.app.closeTab(t, true)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close tabs to the left',
|
||||
click: () => {
|
||||
for (const t of this.app.tabs.slice(0, this.app.tabs.indexOf(tab))) {
|
||||
this.app.closeTab(t, true)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
} else {
|
||||
if (tab.parent instanceof SplitTabComponent) {
|
||||
const directions: SplitDirection[] = ['r', 'b', 'l', 't']
|
||||
items.push({
|
||||
label: 'Split',
|
||||
submenu: directions.map(dir => ({
|
||||
label: {
|
||||
r: 'Right',
|
||||
b: 'Down',
|
||||
l: 'Left',
|
||||
t: 'Up',
|
||||
}[dir],
|
||||
click: () => {
|
||||
(tab.parent as SplitTabComponent).splitTab(tab, dir)
|
||||
},
|
||||
})) as MenuItemOptions[],
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
{ name: 'No color', value: null },
|
||||
{ name: 'Blue', value: '#0275d8' },
|
||||
{ name: 'Green', value: '#5cb85c' },
|
||||
{ name: 'Orange', value: '#f0ad4e' },
|
||||
{ name: 'Purple', value: '#613d7c' },
|
||||
{ name: 'Red', value: '#d9534f' },
|
||||
{ name: 'Yellow', value: '#ffd500' },
|
||||
]
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class CommonOptionsContextMenu extends TabContextMenuItemProvider {
|
||||
weight = -1
|
||||
|
||||
constructor (
|
||||
private app: AppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
let items: MenuItemOptions[] = []
|
||||
if (tabHeader) {
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
label: 'Rename',
|
||||
click: () => tabHeader.showRenameTabModal(),
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
click: () => this.app.duplicateTab(tab),
|
||||
},
|
||||
{
|
||||
label: 'Color',
|
||||
sublabel: COLORS.find(x => x.value === tab.color)?.name,
|
||||
submenu: COLORS.map(color => ({
|
||||
label: color.name,
|
||||
type: 'radio',
|
||||
checked: tab.color === color.value,
|
||||
click: () => {
|
||||
tab.color = color.value
|
||||
},
|
||||
})) as MenuItemOptions[],
|
||||
},
|
||||
]
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class TaskCompletionContextMenu extends TabContextMenuItemProvider {
|
||||
constructor (
|
||||
private app: AppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent): Promise<MenuItemOptions[]> {
|
||||
const process = await tab.getCurrentProcess()
|
||||
const items: MenuItemOptions[] = []
|
||||
|
||||
const extTab: (BaseTabComponent & { __completionNotificationEnabled?: boolean, __outputNotificationSubscription?: Subscription|null }) = tab
|
||||
|
||||
if (process) {
|
||||
items.push({
|
||||
enabled: false,
|
||||
label: 'Current process: ' + process.name,
|
||||
})
|
||||
items.push({
|
||||
label: 'Notify when done',
|
||||
type: 'checkbox',
|
||||
checked: extTab.__completionNotificationEnabled,
|
||||
click: () => {
|
||||
extTab.__completionNotificationEnabled = !extTab.__completionNotificationEnabled
|
||||
|
||||
if (extTab.__completionNotificationEnabled) {
|
||||
this.app.observeTabCompletion(tab).subscribe(() => {
|
||||
new Notification('Process completed', {
|
||||
body: process.name,
|
||||
}).addEventListener('click', () => {
|
||||
this.app.selectTab(tab)
|
||||
})
|
||||
extTab.__completionNotificationEnabled = false
|
||||
})
|
||||
} else {
|
||||
this.app.stopObservingTabCompletion(tab)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
label: 'Notify on activity',
|
||||
type: 'checkbox',
|
||||
checked: !!extTab.__outputNotificationSubscription,
|
||||
click: () => {
|
||||
if (extTab.__outputNotificationSubscription) {
|
||||
extTab.__outputNotificationSubscription.unsubscribe()
|
||||
extTab.__outputNotificationSubscription = null
|
||||
} else {
|
||||
extTab.__outputNotificationSubscription = tab.activity$.subscribe(active => {
|
||||
if (extTab.__outputNotificationSubscription && active) {
|
||||
extTab.__outputNotificationSubscription.unsubscribe()
|
||||
extTab.__outputNotificationSubscription = null
|
||||
new Notification('Tab activity', {
|
||||
body: tab.title,
|
||||
}).addEventListener('click', () => {
|
||||
this.app.selectTab(tab)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
return items
|
||||
}
|
||||
}
|
36
tabby-core/src/theme.compact.scss
Normal file
36
tabby-core/src/theme.compact.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
@import './theme.scss';
|
||||
|
||||
app-root {
|
||||
.tabs-on-side .tab-bar {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
height: 27px !important;
|
||||
|
||||
.btn-tab-bar {
|
||||
line-height: 29px !important;
|
||||
height: 27px !important;
|
||||
align-items: center;
|
||||
svg {
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.inset {
|
||||
width: 70 !important;
|
||||
}
|
||||
}
|
||||
|
||||
terminaltab .content {
|
||||
margin: 5px !important;
|
||||
}
|
||||
|
||||
ssh-tab .content {
|
||||
margin: 5px !important;
|
||||
}
|
||||
|
||||
serial-tab .content {
|
||||
margin: 5px !important;
|
||||
}
|
||||
}
|
387
tabby-core/src/theme.paper.scss
Normal file
387
tabby-core/src/theme.paper.scss
Normal file
@@ -0,0 +1,387 @@
|
||||
$black: #002b36;
|
||||
$base02: #073642;
|
||||
$base01: #586e75;
|
||||
$base00: #657b83;
|
||||
$base0: #839496;
|
||||
$base1: #93a1a1;
|
||||
$base2: #eee8d5;
|
||||
$white: #fdf6e3;
|
||||
$yellow: #b58900;
|
||||
$orange: #cb4b16;
|
||||
$red: #dc322f;
|
||||
$pink: #d33682;
|
||||
$purple: #6c71c4;
|
||||
$blue: #268bd2;
|
||||
$teal: #2aa198;
|
||||
$green: #859900;
|
||||
|
||||
$tab-border-radius: 5px;
|
||||
$button-hover-bg: rgba(0, 0, 0, .125);
|
||||
$button-active-bg: rgba(0, 0, 0, .25);
|
||||
|
||||
$theme-colors: (
|
||||
"primary": $orange,
|
||||
"secondary": $base0
|
||||
);
|
||||
|
||||
$content-bg: rgba($white, 0.65);
|
||||
$content-bg-solid: $white;
|
||||
$body-bg: $base2;
|
||||
$body-bg2: $base1;
|
||||
|
||||
$body-color: $black;
|
||||
$font-family-sans-serif: "Source Sans Pro";
|
||||
$font-size-base: 14rem / 16;
|
||||
|
||||
$btn-border-radius: 0;
|
||||
|
||||
$nav-tabs-border-width: 0;
|
||||
$nav-tabs-border-radius: 0;
|
||||
$nav-tabs-link-hover-border-color: $body-bg;
|
||||
$nav-tabs-active-link-hover-color: $white;
|
||||
$nav-tabs-active-link-hover-bg: $blue;
|
||||
$nav-tabs-active-link-hover-border-color: darken($blue, 30%);
|
||||
$nav-pills-border-radius: 0;
|
||||
|
||||
$input-bg: $base2;
|
||||
$input-disabled-bg: $base1;
|
||||
|
||||
$input-color: $body-color;
|
||||
$input-color-placeholder: $base1;
|
||||
$input-border-color: $base1;
|
||||
//$input-box-shadow: inset 0 1px 1px rgba($black,.075);
|
||||
$input-border-radius: 0;
|
||||
$custom-select-border-radius: 0;
|
||||
$input-bg-focus: $input-bg;
|
||||
//$input-border-focus: lighten($brand-primary, 25%);
|
||||
//$input-box-shadow-focus: $input-box-shadow, rgba($input-border-focus, .6);
|
||||
$input-color-focus: $input-color;
|
||||
$input-group-addon-bg: $body-bg;
|
||||
$input-group-addon-border-color: $input-border-color;
|
||||
|
||||
$modal-content-bg: $content-bg-solid;
|
||||
$modal-content-border-color: $body-bg;
|
||||
$modal-header-border-color: transparent;
|
||||
$modal-footer-border-color: transparent;
|
||||
|
||||
$popover-bg: $body-bg;
|
||||
|
||||
$dropdown-bg: $body-bg;
|
||||
$dropdown-link-color: $body-color;
|
||||
$dropdown-link-hover-color: #333;
|
||||
$dropdown-link-hover-bg: $body-bg2;
|
||||
//$dropdown-link-active-color: $component-active-color;
|
||||
//$dropdown-link-active-bg: $component-active-bg;
|
||||
$dropdown-link-disabled-color: #333;
|
||||
$dropdown-header-color: #333;
|
||||
|
||||
$list-group-color: $body-color;
|
||||
$list-group-bg: rgba($black,.05);
|
||||
$list-group-border-color: rgba($black,.1);
|
||||
$list-group-hover-bg: rgba($black,.1);
|
||||
$list-group-link-active-bg: rgba($black,.2);
|
||||
|
||||
$list-group-action-color: $body-color;
|
||||
$list-group-action-bg: rgba($black,.05);
|
||||
$list-group-action-active-bg: $list-group-link-active-bg;
|
||||
|
||||
$list-group-border-radius: 0;
|
||||
|
||||
$pre-bg: $dropdown-bg;
|
||||
$pre-color: $dropdown-link-color;
|
||||
|
||||
$alert-danger-bg: $body-bg;
|
||||
$alert-danger-text: $red;
|
||||
$alert-danger-border: $red;
|
||||
|
||||
$headings-font-weight: lighter;
|
||||
$headings-color: $base0;
|
||||
|
||||
@import '~bootstrap/scss/bootstrap.scss';
|
||||
|
||||
|
||||
window-controls {
|
||||
svg {
|
||||
transition: 0.25s fill;
|
||||
fill: $base01;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: rgba($black, 0.125);
|
||||
|
||||
svg {
|
||||
fill: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: #8a2828;
|
||||
}
|
||||
}
|
||||
|
||||
$border-color: $base1;
|
||||
|
||||
body {
|
||||
background: $body-bg;
|
||||
|
||||
&.vibrant {
|
||||
background: rgba(255, 255, 255,.4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
app-root {
|
||||
&> .content {
|
||||
.tab-bar {
|
||||
.btn-tab-bar {
|
||||
background: transparent;
|
||||
line-height: 42px;
|
||||
align-items: center;
|
||||
svg, path {
|
||||
fill: $black;
|
||||
fill-opacity: 0.75;
|
||||
}
|
||||
|
||||
&:hover { background: rgba(0, 0, 0, .125) !important; }
|
||||
&:active { background: rgba(0, 0, 0, .25) !important; }
|
||||
}
|
||||
|
||||
&>.tabs {
|
||||
tab-header {
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
color: $base01;
|
||||
transition: 0.125s ease-out width;
|
||||
|
||||
.index {
|
||||
color: rgba($black, 0.4);
|
||||
}
|
||||
|
||||
button {
|
||||
color: $body-color;
|
||||
border: none;
|
||||
transition: 0.25s all;
|
||||
|
||||
&:hover { background: $button-hover-bg !important; }
|
||||
&:active { background: $button-active-bg !important; }
|
||||
}
|
||||
|
||||
.progressbar {
|
||||
background: $blue;
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
background:rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $black;
|
||||
background: $content-bg;
|
||||
border-left: 1px solid $border-color;
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.tabs-on-top .tab-bar {
|
||||
&>.background {
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
tab-header {
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&.active {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.tabs-on-top) .tab-bar {
|
||||
&>.background {
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
tab-header {
|
||||
border-top: 1px solid $border-color;
|
||||
|
||||
&.active {
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.platform-win32, &.platform-linux {
|
||||
border: 1px solid #111;
|
||||
&>.content .tab-bar .tabs tab-header:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tab-body {
|
||||
background: $content-bg;
|
||||
}
|
||||
|
||||
settings-tab > .content {
|
||||
& > .nav {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-right: 1px solid $body-bg;
|
||||
|
||||
& > .nav-item > .nav-link {
|
||||
border: none;
|
||||
padding: 10px 50px 10px 20px;
|
||||
font-size: 14px;
|
||||
|
||||
&:not(.active) {
|
||||
color: $body-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
multi-hotkey-input {
|
||||
.item {
|
||||
background: $body-bg2;
|
||||
border: 1px solid $blue;
|
||||
border-radius: 3px;
|
||||
margin-right: 5px;
|
||||
|
||||
.body {
|
||||
padding: 3px 0 2px;
|
||||
|
||||
.stroke {
|
||||
padding: 0 6px;
|
||||
border-right: 1px solid $content-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
padding: 3px 8px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
color: #777;
|
||||
padding: 4px 10px 0;
|
||||
}
|
||||
|
||||
.add, .item .body, .item .remove {
|
||||
&:hover { background: darken($body-bg2, 5%); }
|
||||
&:active { background: darken($body-bg2, 15%); }
|
||||
}
|
||||
}
|
||||
|
||||
hotkey-input-modal {
|
||||
.input {
|
||||
background: $input-bg;
|
||||
padding: 10px;
|
||||
font-size: 24px;
|
||||
line-height: 27px;
|
||||
height: 55px;
|
||||
|
||||
.stroke {
|
||||
background: $body-bg2;
|
||||
border: 1px solid $blue;
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeout {
|
||||
background: $input-bg;
|
||||
|
||||
div {
|
||||
background: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
.nav-link {
|
||||
transition: 0.25s all;
|
||||
border-bottom-color: $nav-tabs-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
[ngbradiogroup] > label.active {
|
||||
background: $blue;
|
||||
}
|
||||
|
||||
.btn {
|
||||
i + * {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&.btn-lg i + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-addon + .form-control {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.input-group > select.form-control {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
transition: 0.25s background;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
i + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='24' height='24' viewBox='0 0 24 24'><path fill='#444' d='M7.406 7.828l4.594 4.594 4.594-4.594 1.406 1.406-6 6-6-6z'></path></svg>");
|
||||
background-position: 100% 50%;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
checkbox i.on {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
toggle {
|
||||
.body {
|
||||
border-color: $base0 !important;
|
||||
|
||||
.toggle {
|
||||
background: $base0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active .body .toggle {
|
||||
background: theme-colors(primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item svg {
|
||||
fill: $black;
|
||||
}
|
||||
|
||||
.tabby-title {
|
||||
color: $base01;
|
||||
}
|
||||
|
||||
.tabby-logo {
|
||||
filter: saturate(0);
|
||||
}
|
||||
|
||||
start-page footer {
|
||||
background: $white !important;
|
||||
}
|
395
tabby-core/src/theme.scss
Normal file
395
tabby-core/src/theme.scss
Normal file
@@ -0,0 +1,395 @@
|
||||
@import "./theme.vars";
|
||||
|
||||
// ---------
|
||||
|
||||
|
||||
$button-hover-bg: rgba(0, 0, 0, .25);
|
||||
$button-active-bg: rgba(0, 0, 0, .5);
|
||||
|
||||
@import '~bootstrap/scss/bootstrap.scss';
|
||||
|
||||
window-controls {
|
||||
svg {
|
||||
transition: 0.25s fill;
|
||||
fill: #aaa;
|
||||
}
|
||||
|
||||
button:hover svg {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: #8a2828;
|
||||
}
|
||||
}
|
||||
|
||||
$border-color: #111;
|
||||
|
||||
body {
|
||||
background: $body-bg;
|
||||
|
||||
&.vibrant {
|
||||
background: rgba(0,0,0,.65);
|
||||
}
|
||||
}
|
||||
|
||||
app-root {
|
||||
&.no-tabs {
|
||||
background: rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
&> .content {
|
||||
.tab-bar {
|
||||
.btn-tab-bar {
|
||||
background: transparent;
|
||||
&:hover { background: rgba(0, 0, 0, .25) !important; }
|
||||
&:active, &[aria-expanded-true] { background: rgba(0, 0, 0, .5) !important; }
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&>.tabs {
|
||||
tab-header {
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
|
||||
transition: 0.125s ease-out width;
|
||||
|
||||
.index {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
button {
|
||||
color: $body-color;
|
||||
border: none;
|
||||
transition: 0.25s all;
|
||||
|
||||
right: 5px;
|
||||
|
||||
&:hover { background: $button-active-bg !important; }
|
||||
&:active { background: $button-active-bg !important; }
|
||||
}
|
||||
|
||||
.progressbar {
|
||||
background: $green;
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
background:rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: white;
|
||||
background: $content-bg;
|
||||
border-left: 1px solid $border-color;
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.tabs-on-top .tab-bar {
|
||||
&>.background {
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
tab-header {
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&.active {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.tabs-on-top) .tab-bar {
|
||||
&>.background {
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
tab-header {
|
||||
border-top: 1px solid $border-color;
|
||||
|
||||
&.active {
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.platform-win32, &.platform-linux {
|
||||
border: 1px solid #111;
|
||||
&>.content .tab-bar .tabs tab-header:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tab-body {
|
||||
background: $content-bg;
|
||||
}
|
||||
|
||||
multi-hotkey-input {
|
||||
.item {
|
||||
background: $body-bg2;
|
||||
border: 1px solid $blue;
|
||||
border-radius: 3px;
|
||||
margin-right: 5px;
|
||||
|
||||
.body {
|
||||
padding: 3px 0 2px;
|
||||
|
||||
.stroke {
|
||||
padding: 0 6px;
|
||||
border-right: 1px solid $content-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
padding: 3px 8px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
color: #777;
|
||||
padding: 4px 10px 0;
|
||||
}
|
||||
|
||||
.add, .item .body, .item .remove {
|
||||
&:hover { background: darken($body-bg2, 5%); }
|
||||
&:active { background: darken($body-bg2, 15%); }
|
||||
}
|
||||
}
|
||||
|
||||
hotkey-input-modal {
|
||||
.input {
|
||||
background: $input-bg;
|
||||
padding: 10px;
|
||||
font-size: 24px;
|
||||
line-height: 27px;
|
||||
height: 55px;
|
||||
|
||||
.stroke {
|
||||
background: $body-bg2;
|
||||
border: 1px solid $blue;
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeout {
|
||||
background: $input-bg;
|
||||
|
||||
div {
|
||||
background: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
[ngbradiogroup] > label.active {
|
||||
background: $blue;
|
||||
}
|
||||
|
||||
.btn {
|
||||
i + * {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&.btn-lg i + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-addon + .form-control {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.input-group > select.form-control {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
transition: 0.0625s background;
|
||||
|
||||
i + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group.list-group-flush .list-group-item:not(.list-group-item-action) {
|
||||
background: transparent;
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.list-group-light {
|
||||
.list-group-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, .1);
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&.list-group-item-action {
|
||||
&:hover, &.active {
|
||||
background: $list-group-hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkbox i.on {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.modal .modal-footer {
|
||||
background: rgba(0, 0, 0, .25);
|
||||
|
||||
.btn {
|
||||
font-weight: bold;
|
||||
padding: 0.375rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item svg {
|
||||
fill: white;
|
||||
fill-opacity: 0.75;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
background: rgba(0, 0, 0, .125);
|
||||
width: 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, .25);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner,
|
||||
*::-webkit-resizer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
search-panel {
|
||||
background: rgba(39, 49, 60, 0.95) !important;
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
|
||||
&.disabled,
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.btn-outline-secondary {
|
||||
@include button-outline-variant(#9badb9, #fff);
|
||||
&:hover:not([disabled]), &:active:not([disabled]), &.active:not([disabled]) {
|
||||
background-color: #3f484e;
|
||||
border-color: darken(#9badb9, 25%);
|
||||
}
|
||||
|
||||
border-color: darken(#9badb9, 25%);
|
||||
|
||||
&.disabled,
|
||||
&:disabled {
|
||||
color: #9badb9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-warning:not(:disabled):not(.disabled) {
|
||||
&.active, &:active {
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary:not(:disabled):not(.disabled) {
|
||||
&.active, &:active {
|
||||
background: #191e23;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
&:hover, &[aria-expanded=true], &:active, &.active {
|
||||
color: $link-hover-color;
|
||||
border-radius: $btn-border-radius;
|
||||
}
|
||||
|
||||
&[aria-expanded=true], &:active, &.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group .btn.active {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
margin-bottom: 10px;
|
||||
|
||||
&.nav-justified .nav-link {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border: none;
|
||||
border-bottom: $nav-tabs-border-width solid transparent;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
padding: 5px 0;
|
||||
margin-right: 20px;
|
||||
|
||||
uib-tab-heading > i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@include hover-focus {
|
||||
color: $nav-tabs-link-active-color;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: $nav-link-disabled-color;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item:last-child .nav-link {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.nav-item.show .nav-link {
|
||||
color: $nav-tabs-link-active-color;
|
||||
border-color: $nav-tabs-link-active-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: $list-group-border-color;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
box-shadow: $dropdown-box-shadow;
|
||||
}
|
28
tabby-core/src/theme.ts
Normal file
28
tabby-core/src/theme.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Theme } from './api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class StandardTheme extends Theme {
|
||||
name = 'Standard'
|
||||
css = require('./theme.scss')
|
||||
terminalBackground = '#222a33'
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class StandardCompactTheme extends Theme {
|
||||
name = 'Compact'
|
||||
css = require('./theme.compact.scss')
|
||||
terminalBackground = '#222a33'
|
||||
macOSWindowButtonsInsetX = 8
|
||||
macOSWindowButtonsInsetY = 6
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class PaperTheme extends Theme {
|
||||
name = 'Paper'
|
||||
css = require('./theme.paper.scss')
|
||||
terminalBackground = '#f7f1e0'
|
||||
}
|
195
tabby-core/src/theme.vars.scss
Normal file
195
tabby-core/src/theme.vars.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
$white: #fff;
|
||||
$gray-100: #f8f9fa;
|
||||
$gray-200: #e9ecef;
|
||||
$gray-300: #dee2e6;
|
||||
$gray-400: #ced4da;
|
||||
$gray-500: #adb5bd;
|
||||
$gray-600: #6c757d;
|
||||
$gray-700: #495057;
|
||||
$gray-800: #343a40;
|
||||
$gray-900: #212529;
|
||||
$black: #000;
|
||||
|
||||
|
||||
$red: #d9534f !default;
|
||||
$orange: #f0ad4e !default;
|
||||
$yellow: #ffd500 !default;
|
||||
$green: #5cb85c !default;
|
||||
$blue: #0275d8 !default;
|
||||
$teal: #5bc0de !default;
|
||||
$pink: #ff5b77 !default;
|
||||
$purple: #613d7c !default;
|
||||
|
||||
|
||||
@import "~bootstrap/scss/functions";
|
||||
|
||||
$content-bg: rgba(39, 49, 60, 0.65); //#1D272D;
|
||||
$content-bg-solid: #1D272D;
|
||||
|
||||
$table-bg: rgba(255,255,255,.05);
|
||||
$table-bg-hover: rgba(255,255,255,.1);
|
||||
$table-border-color: rgba(255,255,255,.1);
|
||||
|
||||
$theme-colors: (
|
||||
primary: $blue,
|
||||
secondary: #38434e,
|
||||
success: $green,
|
||||
info: $blue,
|
||||
warning: $orange,
|
||||
danger: $red,
|
||||
light: $gray-300,
|
||||
dark: #0e151d,
|
||||
rare: $purple
|
||||
);
|
||||
|
||||
$body-color: #ccc;
|
||||
$body-bg: #131d27;
|
||||
$body-bg2: #20333e;
|
||||
|
||||
$font-family-sans-serif: "Source Sans Pro";
|
||||
$font-family-monospace: "Source Code Pro";
|
||||
$font-size-base: 14rem / 16;
|
||||
$font-size-lg: 1.28rem;
|
||||
$font-size-sm: .85rem;
|
||||
|
||||
$line-height-base: 1.6;
|
||||
|
||||
$border-radius: .4rem;
|
||||
$border-radius-lg: .6rem;
|
||||
$border-radius-sm: .2rem;
|
||||
|
||||
// -----
|
||||
|
||||
$headings-color: #ced9e2;
|
||||
$headings-font-weight: lighter;
|
||||
|
||||
$input-btn-padding-y: .3rem;
|
||||
$input-btn-padding-x: .9rem;
|
||||
$input-btn-line-height: 1.6;
|
||||
$input-btn-line-height-sm: 1.8;
|
||||
$input-btn-line-height-lg: 1.8;
|
||||
|
||||
$btn-link-disabled-color: $gray-600;
|
||||
$btn-focus-box-shadow: none;
|
||||
|
||||
$h4-font-size: 18px;
|
||||
|
||||
$link-color: $gray-400;
|
||||
$link-hover-color: $white;
|
||||
$link-hover-decoration: none;
|
||||
|
||||
$component-active-color: $white;
|
||||
$component-active-bg: #2f3a42;
|
||||
|
||||
$list-group-bg: $table-bg;
|
||||
$list-group-border-color: $table-border-color;
|
||||
|
||||
$list-group-item-padding-y: 0.8rem;
|
||||
$list-group-item-padding-x: 1rem;
|
||||
|
||||
$list-group-hover-bg: $table-bg-hover;
|
||||
$list-group-active-bg: rgba(255,255,255,.2);
|
||||
$list-group-active-color: $component-active-color;
|
||||
$list-group-active-border-color: translate;
|
||||
|
||||
$list-group-action-color: $body-color;
|
||||
$list-group-action-hover-color: white;
|
||||
|
||||
$list-group-action-active-color: $component-active-color;
|
||||
$list-group-action-active-bg: $list-group-active-bg;
|
||||
|
||||
$alert-padding-y: 0.9rem;
|
||||
$alert-padding-x: 1.25rem;
|
||||
|
||||
$input-box-shadow: none;
|
||||
|
||||
$transition-base: all .15s ease-in-out;
|
||||
$transition-fade: opacity .1s linear;
|
||||
$transition-collapse: height .35s ease;
|
||||
$btn-transition: all .15s ease-in-out;
|
||||
|
||||
$popover-bg: $body-bg;
|
||||
$popover-body-color: $body-color;
|
||||
$popover-header-bg: $table-bg-hover;
|
||||
$popover-header-color: $headings-color;
|
||||
$popover-arrow-color: $popover-bg;
|
||||
$popover-max-width: 360px;
|
||||
|
||||
$btn-border-width: 2px;
|
||||
|
||||
$input-bg: #181e23;
|
||||
$input-disabled-bg: #2e3235;
|
||||
|
||||
$input-color: #ddd;
|
||||
$input-border-color: $input-bg;
|
||||
$input-border-width: 2px;
|
||||
|
||||
$input-focus-bg: $input-bg;
|
||||
$input-focus-border-color: rgba(171, 171, 171, 0.61);
|
||||
$input-focus-color: $input-color;
|
||||
|
||||
$input-btn-focus-color: var(--focus-color);
|
||||
$input-btn-focus-box-shadow: 0 0 0 2px $input-btn-focus-color;
|
||||
|
||||
$input-group-addon-color: $input-color;
|
||||
$input-group-addon-bg: $input-bg;
|
||||
$input-group-addon-border-color: transparent;
|
||||
$input-group-btn-border-color: $input-bg;
|
||||
|
||||
$nav-tabs-border-radius: 0;
|
||||
$nav-tabs-border-color: transparent;
|
||||
$nav-tabs-border-width: 2px;
|
||||
$nav-tabs-link-hover-border-color: transparent;
|
||||
$nav-tabs-link-active-color: #eee;
|
||||
$nav-tabs-link-active-bg: transparent;
|
||||
$nav-tabs-link-active-border-color: #eee;
|
||||
|
||||
$navbar-padding-y: 0;
|
||||
$navbar-padding-x: 0;
|
||||
|
||||
$dropdown-bg: $content-bg-solid;
|
||||
$dropdown-color: $body-color;
|
||||
$dropdown-border-width: 1px;
|
||||
$dropdown-box-shadow: 0 0 1rem rgba($black, .25), 0 1px 1px rgba($black, .12);
|
||||
$dropdown-header-color: $gray-500;
|
||||
|
||||
$dropdown-link-color: $body-color;
|
||||
$dropdown-link-hover-color: #eee;
|
||||
$dropdown-link-hover-bg: rgba(255,255,255,.04);
|
||||
$dropdown-link-active-color: white;
|
||||
$dropdown-link-active-bg: rgba(0, 0, 0, .2);
|
||||
$dropdown-item-padding-y: 0.5rem;
|
||||
$dropdown-item-padding-x: 1.5rem;
|
||||
|
||||
|
||||
$code-color: $orange;
|
||||
$code-bg: rgba(0, 0, 0, .25);
|
||||
$code-padding-y: 3px;
|
||||
$code-padding-x: 5px;
|
||||
$pre-bg: $dropdown-bg;
|
||||
$pre-color: $dropdown-link-color;
|
||||
|
||||
$badge-font-size: 0.75rem;
|
||||
$badge-font-weight: bold;
|
||||
$badge-padding-y: 4px;
|
||||
$badge-padding-x: 6px;
|
||||
|
||||
|
||||
$custom-control-indicator-size: 1.2rem;
|
||||
$custom-control-indicator-bg: $body-bg;
|
||||
$custom-control-indicator-border-color: lighten($body-bg, 25%);
|
||||
$custom-control-indicator-checked-bg: theme-color("primary");
|
||||
$custom-control-indicator-checked-color: $body-bg;
|
||||
$custom-control-indicator-checked-border-color: transparent;
|
||||
$custom-control-indicator-active-bg: rgba(255, 255, 0, 0.5);
|
||||
|
||||
|
||||
$modal-content-bg: $content-bg-solid;
|
||||
$modal-content-border-color: $body-bg;
|
||||
$modal-header-border-width: 0;
|
||||
$modal-footer-border-color: #222;
|
||||
$modal-footer-border-width: 1px;
|
||||
$modal-content-border-width: 0;
|
||||
|
||||
$progress-bg: $table-bg;
|
||||
$progress-height: 3px;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user