This commit is contained in:
Eugene Pankov
2017-04-11 22:45:59 +02:00
parent 0ea346a6ae
commit dc513b427d
114 changed files with 454 additions and 374 deletions

View File

@@ -0,0 +1,4 @@
export abstract class ConfigProvider {
configStructure: any = {}
defaultConfigValues: any = {}
}

View File

@@ -0,0 +1,3 @@
export abstract class DefaultTabProvider {
abstract async openNewTab (): Promise<void>
}

View File

@@ -0,0 +1,8 @@
export interface IHotkeyDescription {
id: string,
name: string,
}
export abstract class HotkeyProvider {
hotkeys: IHotkeyDescription[] = []
}

View File

@@ -0,0 +1,14 @@
export { BaseTabComponent } from '../components/baseTab'
export { TabRecoveryProvider } from './tabRecovery'
export { ToolbarButtonProvider, IToolbarButton } from './toolbarButtonProvider'
export { ConfigProvider } from './configProvider'
export { HotkeyProvider, IHotkeyDescription } from './hotkeyProvider'
export { DefaultTabProvider } from './defaultTabProvider'
export { AppService } from '../services/app'
export { ConfigService } from '../services/config'
export { DockingService } from '../services/docking'
export { ElectronService } from '../services/electron'
export { Logger, LogService } from '../services/log'
export { HotkeysService } from '../services/hotkeys'
export { HostAppService, Platform } from '../services/hostApp'

View File

@@ -0,0 +1,3 @@
export abstract class TabRecoveryProvider {
abstract async recover (recoveryToken: any): Promise<void>
}

View File

@@ -0,0 +1,10 @@
export interface IToolbarButton {
icon: string
title: string
weight?: number
click: () => void
}
export abstract class ToolbarButtonProvider {
abstract provide (): IToolbarButton[]
}

8
terminus-core/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare interface Window {
require: any
process: any
__dirname: any
__platform: any
}
declare var window: Window

View File

@@ -0,0 +1,46 @@
title-bar(*ngIf='!config.full().appearance.useNativeFrame && config.full().appearance.dock == "off"')
.content(
[class.tabs-on-top]='config.full().appearance.tabsOnTop'
)
.tabs
button.btn.btn-secondary(
*ngFor='let button of getLeftToolbarButtons()',
[title]='button.title',
(click)='button.click()',
)
i.fa([class]='"fa fa-" + button.icon')
.tabs-container
tab-header(
*ngFor='let tab of app.tabs; let idx = index',
[class.pre-selected]='idx == app.tabs.indexOf(app.activeTab) - 1',
[class.post-selected]='idx == app.tabs.indexOf(app.activeTab) + 1',
[index]='idx',
[tab]='tab',
[active]='tab == app.activeTab',
[hasActivity]='tab.hasActivity',
@animateTab,
(click)='app.selectTab(tab)',
(closeClicked)='app.closeTab(tab)',
)
button.btn.btn-secondary(
*ngFor='let button of getRightToolbarButtons()',
[title]='button.title',
(click)='button.click()',
)
i.fa([class]='"fa fa-" + button.icon')
.tabs-content
tab-body(
*ngFor='let tab of app.tabs; trackBy: tab?.id',
[active]='tab == app.activeTab',
[tab]='tab',
[class.scrollable]='tab.scrollable',
)
toaster-container([toasterconfig]="toasterconfig")
ng-template(ngbModalContainer)
div.window-resizer.window-resizer-tl

View File

@@ -0,0 +1,64 @@
:host {
display: flex;
width: calc(100vw - 2px);
height: calc(100vh - 2px);
flex-direction: column;
overflow: hidden;
-webkit-user-select: none;
-webkit-font-smoothing: antialiased;
cursor: default;
}
$tabs-height: 40px;
$tab-border-radius: 4px;
.content {
flex: auto;
display: flex;
flex-direction: column-reverse;
&.tabs-on-top {
flex-direction: column;
}
}
.tabs {
flex: none;
height: $tabs-height;
display: flex;
&>button {
line-height: $tabs-height - 2px;
cursor: pointer;
padding: 0 15px;
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;
}
&>.tabs-container {
flex: auto;
display: flex;
}
}
.tabs-content {
flex: auto;
display: flex;
}
hotkey-hint {
position: absolute;
bottom: 0;
right: 0;
max-width: 300px;
}

View File

@@ -0,0 +1,154 @@
import { Component, Inject } from '@angular/core'
import { trigger, style, animate, transition, state } from '@angular/animations'
import { ToasterConfig } from 'angular2-toaster'
import { ElectronService } from '../services/electron'
import { HostAppService } from '../services/hostApp'
import { HotkeysService } from '../services/hotkeys'
import { Logger, LogService } from '../services/log'
import { QuitterService } from '../services/quitter'
import { ConfigService } from '../services/config'
import { DockingService } from '../services/docking'
import { TabRecoveryService } from '../services/tabRecovery'
import { AppService, IToolbarButton, ToolbarButtonProvider } from '../api'
import 'angular2-toaster/toaster.css'
import 'overrides.scss'
import 'theme.scss'
@Component({
selector: 'app-root',
template: require('./appRoot.pug'),
styles: [require('./appRoot.scss')],
animations: [
trigger('animateTab', [
state('in', style({
'flex-grow': '1000',
})),
transition(':enter', [
style({
'flex-grow': '1',
}),
animate('250ms ease-in-out')
]),
transition(':leave', [
animate('250ms ease-in-out', style({
'flex-grow': '1',
}))
])
])
]
})
export class AppRootComponent {
toasterConfig: ToasterConfig
logger: Logger
constructor(
private docking: DockingService,
private electron: ElectronService,
private tabRecovery: TabRecoveryService,
public hostApp: HostAppService,
public hotkeys: HotkeysService,
public config: ConfigService,
public app: AppService,
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
log: LogService,
_quitter: QuitterService,
) {
(<any>console).timeStamp('AppComponent ctor')
this.logger = log.create('main')
this.logger.info('v', electron.app.getVersion())
this.toasterConfig = new ToasterConfig({
mouseoverTimerStop: true,
preventDuplicates: true,
timeout: 4000,
})
this.hotkeys.matchedHotkey.subscribe((hotkey) => {
if (hotkey.startsWith('tab-')) {
let 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)
}
if (hotkey == 'toggle-last-tab') {
this.app.toggleLastTab()
}
if (hotkey == 'next-tab') {
this.app.nextTab()
}
if (hotkey == 'previous-tab') {
this.app.previousTab()
}
}
})
this.docking.dock()
this.hostApp.shown.subscribe(() => {
this.docking.dock()
})
this.hotkeys.registerHotkeys()
this.hostApp.secondInstance.subscribe(() => {
this.onGlobalHotkey()
})
this.hotkeys.globalHotkey.subscribe(() => {
this.onGlobalHotkey()
})
}
onGlobalHotkey () {
if (this.electron.app.window.isFocused()) {
// focused
this.electron.app.window.hide()
} else {
if (!this.electron.app.window.isVisible()) {
// unfocused, invisible
this.electron.app.window.show()
} else {
if (this.config.full().appearance.dock == 'off') {
// not docked, visible
setTimeout(() => {
this.electron.app.window.focus()
})
} else {
// docked, visible
this.electron.app.window.hide()
}
}
}
this.docking.dock()
}
getLeftToolbarButtons (): IToolbarButton[] { return this.getToolbarButtons(false) }
getRightToolbarButtons (): IToolbarButton[] { return this.getToolbarButtons(true) }
async ngOnInit () {
await this.tabRecovery.recoverTabs()
this.tabRecovery.saveTabs(this.app.tabs)
if (this.app.tabs.length == 0) {
this.app.openDefaultTab()
}
}
private getToolbarButtons (aboveZero: boolean): IToolbarButton[] {
let buttons: IToolbarButton[] = []
this.toolbarButtonProviders.forEach((provider) => {
buttons = buttons.concat(provider.provide())
})
return buttons
.filter((button) => (button.weight > 0) === aboveZero)
.sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0))
}
}

View File

@@ -0,0 +1,29 @@
import { BehaviorSubject } from 'rxjs'
import { EventEmitter, ViewRef } from '@angular/core'
export abstract class BaseTabComponent {
id: number
title$ = new BehaviorSubject<string>(null)
scrollable: boolean
hasActivity = false
focused = new EventEmitter<any>()
blurred = new EventEmitter<any>()
hostView: ViewRef
private static lastTabID = 0
constructor () {
this.id = BaseTabComponent.lastTabID++
}
displayActivity (): void {
this.hasActivity = true
}
getRecoveryToken (): any {
return null
}
destroy (): void {
}
}

View File

@@ -0,0 +1,18 @@
:host {
display: none;
flex: auto;
position: relative;
overflow: hidden;
&.scrollable {
overflow-y: auto;
}
&.active {
display: flex;
>* {
flex: auto;
}
}
}

View File

@@ -0,0 +1,19 @@
import { Component, Input, ViewChild, HostBinding, ViewContainerRef } from '@angular/core'
import { BaseTabComponent } from '../components/baseTab'
@Component({
selector: 'tab-body',
template: '<ng-template #placeholder></ng-template>',
styles: [require('./tabBody.scss')],
})
export class TabBodyComponent {
@Input() @HostBinding('class.active') active: boolean
@Input() tab: BaseTabComponent
@ViewChild('placeholder', {read: ViewContainerRef}) placeholder: ViewContainerRef
ngAfterViewInit () {
setImmediate(() => {
this.placeholder.insert(this.tab.hostView)
})
}
}

View File

@@ -0,0 +1,4 @@
.content-wrapper
.index {{index + 1}}
.name {{(tab.title$ || "Terminal") | async}}
button((click)='closeClicked.emit()') &times;

View File

@@ -0,0 +1,80 @@
@import '~variables.scss';
:host {
line-height: $tabs-height - 2px;
cursor: pointer;
flex: auto;
flex-basis: 0;
flex-grow: 1000;
display: flex;
overflow: hidden;
min-width: 0;
transition: 0.25s all;
//.button-states();
.content-wrapper {
display: flex;
flex-direction: row;
flex: auto;
min-width: 0;
transition: 0.25s all;
border-top: 1px solid transparent;
.index {
flex: none;
font-weight: bold;
align-self: center;
margin-left: 10px;
width: 20px;
height: 20px;
border-radius: 10px;
line-height: 20px;
text-align: center;
transition: 0.25s all;
}
.name {
flex: auto;
margin: 0 1px 0 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
button {
flex: none;
background: transparent;
display: block;
opacity: 0;
$button-size: $tabs-height * 0.6;
width: $button-size;
height: $button-size;
border-radius: $button-size / 2;
line-height: $button-size * 0.8;
margin-top: ($tabs-height - $button-size) * 0.4;
margin-right: 10px;
text-align: center;
font-size: 20px;
//.button-states();
}
&:hover button {
transition: 0.25s opacity;
display: block;
opacity: 1;
}
}
//border-bottom: 2px solid transparent;
transition: 0.25s all;
}

View File

@@ -0,0 +1,17 @@
import { Component, Input, Output, EventEmitter, HostBinding } from '@angular/core'
import { BaseTabComponent } from '../components/baseTab'
import './tabHeader.scss'
@Component({
selector: 'tab-header',
template: require('./tabHeader.pug'),
styles: [require('./tabHeader.scss')],
})
export class TabHeaderComponent {
@Input() index: number
@Input() @HostBinding('class.active') active: boolean
@Input() @HostBinding('class.has-activity') hasActivity: boolean
@Input() tab: BaseTabComponent
@Output() closeClicked = new EventEmitter()
}

View File

@@ -0,0 +1,7 @@
.title((dblclick)='hostApp.toggleMaximize()') Term
button.btn.btn-secondary.btn-minimize((click)='hostApp.minimize()')
i.fa.fa-window-minimize
button.btn.btn-secondary.btn-maximize((click)='hostApp.toggleMaximize()')
i.fa.fa-window-maximize
button.btn.btn-secondary.btn-close((click)='hostApp.quit()')
i.fa.fa-close

View File

@@ -0,0 +1,48 @@
@import '~variables.scss';
$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;
}
button {
flex: none;
border: none;
box-shadow: none;
border-radius: 0;
font-size: 8px;
width: 40px;
padding: 0;
line-height: $titlebar-height;
text-align: center;
&:not(:hover):not(:active) {
background: transparent;
}
}
.btn-close {
font-size: 12px;
}
&.inset-titlebar {
flex-basis: 36px;
.title {
padding-left: 80px;
line-height: 36px;
}
button {
display: none;
}
}
}

View File

@@ -0,0 +1,15 @@
import { Component, HostBinding } from '@angular/core'
import { HostAppService, Platform } from '../services/hostApp'
@Component({
selector: 'title-bar',
template: require('./titleBar.pug'),
styles: [require('./titleBar.scss')],
})
export class TitleBarComponent {
@HostBinding('class.inset-titlebar') insetTitlebar = false
constructor (public hostApp: HostAppService) {
this.insetTitlebar = hostApp.platform == Platform.macOS
}
}

View File

@@ -0,0 +1,3 @@
appearance: { }
hotkeys: { }
terminal: { }

View File

@@ -0,0 +1,49 @@
appearance:
dock: 'off'
dockScreen: 'current'
dockFill: 50
tabsOnTop: true
useNativeFrame: false
hotkeys:
close-tab:
- 'Ctrl-Shift-W'
- ['Ctrl-A', 'K']
toggle-last-tab:
- ['Ctrl-A', 'A']
- ['Ctrl-A', 'Ctrl-A']
next-tab:
- 'Ctrl-Shift-ArrowRight'
- ['Ctrl-A', 'N']
previous-tab:
- 'Ctrl-Shift-ArrowLeft'
- ['Ctrl-A', 'P']
tab-1:
- 'Alt-1'
- ['Ctrl-A', '1']
tab-2:
- 'Alt-2'
- ['Ctrl-A', '2']
tab-3:
- 'Alt-3'
- ['Ctrl-A', '3']
tab-4:
- 'Alt-4'
- ['Ctrl-A', '4']
tab-5:
- 'Alt-5'
- ['Ctrl-A', '5']
tab-6:
- 'Alt-6'
- ['Ctrl-A', '6']
tab-7:
- 'Alt-7'
- ['Ctrl-A', '7']
tab-8:
- 'Alt-8'
- ['Ctrl-A', '8']
tab-9:
- 'Alt-9'
- ['Ctrl-A', '9']
tab-10:
- 'Alt-0'
- ['Ctrl-A', '0']

View File

@@ -0,0 +1,97 @@
html.platform-win32 {
body.focused {
//border: 1px solid #9c9c00 !important;
}
}
body {
border: 1px solid #131313;
transition: 0.5s border;
overflow: hidden;
min-height: 100vh;
cursor: default;
}
.no-drag, a, button, checkbox, .form-control, #toast-container {
-webkit-app-region: no-drag;
outline: 0 !important;
* {
outline: 0 !important;
-webkit-app-region: no-drag;
}
}
.window-resizer {
-webkit-app-region: no-drag;
position: fixed;
width: 10px;
height: 10px;
}
.window-resizer-tl {
left: 0;
top: 0;
}
.no-wrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.word-wrap {
word-wrap: break-word;
word-break: break-all;
}
#toast-container.toast-top-full-width {
width: 100%;
top: 50px;
> div {
width: 100%;
border-radius: 0;
box-shadow: 0 0 2px rgba(0,0,0,.75);
opacity: 1;
filter: none;
}
}
.btn {
i + * {
margin-left: 5px;
}
}
.list-group-item {
margin: none;
> .btn {
float: right;
margin: -7px -11px 0 0;
background: transparent;
box-shadow: none;
&:hover {
background: rgba(0,0,0,.25);
}
}
}
ngb-typeahead-window {
max-height: 200px;
overflow-y: auto;
>button {
display: block;
width: 100%;
-webkit-appearance: none;
//border-bottom: 1px solid @dark-border;
}
}

View File

@@ -0,0 +1,74 @@
import { NgModule, ModuleWithProviders } from '@angular/core'
console.info((<any>global).require.resolve('@angular/core'))
import { BrowserModule } from '@angular/platform-browser'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { FormsModule } from '@angular/forms'
import { ToasterModule } from 'angular2-toaster'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { AppService } from './services/app'
import { ConfigService } from './services/config'
import { ElectronService } from './services/electron'
import { HostAppService } from './services/hostApp'
import { LogService } from './services/log'
import { HotkeysService, AppHotkeyProvider } from './services/hotkeys'
import { NotifyService } from './services/notify'
import { PluginsService } from './services/plugins'
import { QuitterService } from './services/quitter'
import { DockingService } from './services/docking'
import { TabRecoveryService } from './services/tabRecovery'
import { AppRootComponent } from './components/appRoot'
import { TabBodyComponent } from './components/tabBody'
import { TabHeaderComponent } from './components/tabHeader'
import { TitleBarComponent } from './components/titleBar'
import { HotkeyProvider } from './api/hotkeyProvider'
const PROVIDERS = [
AppService,
ConfigService,
DockingService,
ElectronService,
HostAppService,
HotkeysService,
LogService,
NotifyService,
PluginsService,
TabRecoveryService,
QuitterService,
{ provide: HotkeyProvider, useClass: AppHotkeyProvider, multi: true },
]
@NgModule({
imports: [
BrowserModule,
BrowserAnimationsModule,
FormsModule,
ToasterModule,
NgbModule,
],
providers: PROVIDERS,
declarations: [
AppRootComponent,
TabBodyComponent,
TabHeaderComponent,
TitleBarComponent,
],
})
export default class AppModule {
}
export class AppRootModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: AppModule,
providers: PROVIDERS,
}
}
}
export { AppRootComponent }
export * from './api'

View File

@@ -0,0 +1,3 @@
.form-group label {
margin-bottom: 2px;
}

View File

@@ -0,0 +1,95 @@
import { Subject } from 'rxjs'
import { Injectable, ComponentFactoryResolver, Injector, Optional } from '@angular/core'
import { Logger, LogService } from '../services/log'
import { DefaultTabProvider } from '../api/defaultTabProvider'
import { BaseTabComponent } from '../components/baseTab'
export declare type TabComponentType = new (...args: any[]) => BaseTabComponent
@Injectable()
export class AppService {
tabs: BaseTabComponent[] = []
activeTab: BaseTabComponent
lastTabIndex = 0
logger: Logger
tabsChanged$ = new Subject()
constructor (
private componentFactoryResolver: ComponentFactoryResolver,
@Optional() private defaultTabProvider: DefaultTabProvider,
private injector: Injector,
log: LogService,
) {
this.logger = log.create('app')
}
openNewTab (type: TabComponentType, inputs?: any): BaseTabComponent {
let componentFactory = this.componentFactoryResolver.resolveComponentFactory(type)
let componentRef = componentFactory.create(this.injector)
componentRef.instance.hostView = componentRef.hostView
Object.assign(componentRef.instance, inputs || {})
this.tabs.push(componentRef.instance)
this.selectTab(componentRef.instance)
this.tabsChanged$.next()
return componentRef.instance
}
openDefaultTab (): void {
if (this.defaultTabProvider) {
this.defaultTabProvider.openNewTab()
}
}
selectTab (tab: BaseTabComponent) {
if (this.tabs.includes(this.activeTab)) {
this.lastTabIndex = this.tabs.indexOf(this.activeTab)
} else {
this.lastTabIndex = null
}
if (this.activeTab) {
this.activeTab.hasActivity = false
this.activeTab.blurred.emit()
}
this.activeTab = tab
if (this.activeTab) {
this.activeTab.focused.emit()
}
}
toggleLastTab () {
if (!this.lastTabIndex || this.lastTabIndex >= this.tabs.length) {
this.lastTabIndex = 0
}
this.selectTab(this.tabs[this.lastTabIndex])
}
nextTab () {
let tabIndex = this.tabs.indexOf(this.activeTab)
if (tabIndex < this.tabs.length - 1) {
this.selectTab(this.tabs[tabIndex + 1])
}
}
previousTab () {
let tabIndex = this.tabs.indexOf(this.activeTab)
if (tabIndex > 0) {
this.selectTab(this.tabs[tabIndex - 1])
}
}
closeTab (tab: BaseTabComponent) {
tab.destroy()
/* if (tab.session) {
this.sessions.destroySession(tab.session)
} */
let 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()
}
}

View File

@@ -0,0 +1,54 @@
import * as yaml from 'js-yaml'
import * as path from 'path'
import * as fs from 'fs'
import { EventEmitter, Injectable, Inject } from '@angular/core'
import { ElectronService } from '../services/electron'
import { ConfigProvider } from '../api/configProvider'
const configMerge = (a, b) => require('deepmerge')(a, b, { arrayMerge: (_d, s) => s })
@Injectable()
export class ConfigService {
store: any
change = new EventEmitter()
restartRequested: boolean
private path: string
private configStructure: any = require('../defaultConfigStructure.yaml')
private defaultConfigValues: any = require('../defaultConfigValues.yaml')
constructor (
electron: ElectronService,
@Inject(ConfigProvider) configProviders: ConfigProvider[],
) {
this.path = path.join(electron.app.getPath('userData'), 'config.yaml')
this.configStructure = configProviders.map(x => x.configStructure).reduce(configMerge, this.configStructure)
this.defaultConfigValues = configProviders.map(x => x.defaultConfigValues).reduce(configMerge, this.defaultConfigValues)
this.load()
}
load (): void {
if (fs.existsSync(this.path)) {
this.store = configMerge(this.configStructure, yaml.safeLoad(fs.readFileSync(this.path, 'utf8')))
} else {
this.store = Object.assign({}, this.configStructure)
}
}
save (): void {
fs.writeFileSync(this.path, yaml.safeDump(this.store), 'utf8')
this.emitChange()
}
full (): any {
return configMerge(this.defaultConfigValues, this.store)
}
emitChange (): void {
this.change.emit()
}
requestRestart (): void {
this.restartRequested = true
}
}

View File

@@ -0,0 +1,74 @@
import { Injectable } from '@angular/core'
import { HostAppService } from '../services/hostApp'
import { ConfigService } from '../services/config'
import { ElectronService } from '../services/electron'
export interface IScreen {
id: string
name: string
}
@Injectable()
export class DockingService {
constructor(
private electron: ElectronService,
private config: ConfigService,
private hostApp: HostAppService,
) {}
dock () {
let display = this.electron.screen.getAllDisplays()
.filter((x) => x.id == this.config.full().appearance.dockScreen)[0]
if (!display) {
display = this.getCurrentScreen()
}
let dockSide = this.config.full().appearance.dock
let newBounds: Electron.Rectangle = { x: 0, y: 0, width: 0, height: 0 }
let fill = this.config.full().appearance.dockFill
if (dockSide == 'off') {
this.hostApp.setAlwaysOnTop(false)
return
}
if (dockSide == 'left' || dockSide == 'right') {
newBounds.width = Math.round(fill * display.bounds.width)
newBounds.height = display.bounds.height
}
if (dockSide == 'top' || dockSide == 'bottom') {
newBounds.width = display.bounds.width
newBounds.height = Math.round(fill * display.bounds.height)
}
if (dockSide == 'right') {
newBounds.x = display.bounds.x + display.bounds.width * (1.0 - fill)
} else {
newBounds.x = display.bounds.x
}
if (dockSide == 'bottom') {
newBounds.y = display.bounds.y + display.bounds.height * (1.0 - fill)
} else {
newBounds.y = display.bounds.y
}
this.hostApp.setAlwaysOnTop(true)
this.hostApp.unmaximize()
this.hostApp.setBounds(newBounds)
}
getCurrentScreen () {
return this.electron.screen.getDisplayNearestPoint(this.electron.screen.getCursorScreenPoint())
}
getScreens () {
return this.electron.screen.getAllDisplays().map((display, index) => {
return {
id: display.id,
name: {
0: 'Primary display',
1: 'Secondary display',
}[index] || `Display ${index + 1}`
}
})
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core'
@Injectable()
export class ElectronService {
constructor() {
if (process.env.TEST_ENV) {
this.initTest()
} else {
this.init()
}
}
init() {
this.electron = require('electron')
this.remoteElectron = this.remoteRequire('electron')
this.app = this.remoteElectron.app
this.screen = this.remoteElectron.screen
this.dialog = this.remoteElectron.dialog
this.shell = this.electron.shell
this.clipboard = this.electron.clipboard
this.ipcRenderer = this.electron.ipcRenderer
this.globalShortcut = this.remoteElectron.globalShortcut
}
initTest() {
;
}
remoteRequire(name: string): any {
return this.electron.remote.require(name)
}
app: any
ipcRenderer: any
shell: any
dialog: any
clipboard: any
globalShortcut: any
screen: any
private electron: any
private remoteElectron: any
}

View File

@@ -0,0 +1,114 @@
import { Injectable, NgZone, EventEmitter } from '@angular/core'
import { ElectronService } from '../services/electron'
import { Logger, LogService } from '../services/log'
export enum Platform {
Linux, macOS, Windows,
}
@Injectable()
export class HostAppService {
platform: Platform
nodePlatform: string
constructor(
private zone: NgZone,
private electron: ElectronService,
log: LogService,
) {
this.logger = log.create('hostApp')
this.nodePlatform = require('os').platform()
this.platform = {
win32: Platform.Windows,
darwin: Platform.macOS,
linux: Platform.Linux
}[this.nodePlatform]
electron.ipcRenderer.on('host:quit-request', () => this.zone.run(() => this.quitRequested.emit()))
electron.ipcRenderer.on('uncaughtException', function(err) {
console.error('Unhandled exception:', err)
})
electron.ipcRenderer.on('host:window-shown', () => {
this.shown.emit()
})
electron.ipcRenderer.on('host:second-instance', () => {
this.secondInstance.emit()
})
this.ready.subscribe(() => {
electron.ipcRenderer.send('app:ready')
})
}
quitRequested = new EventEmitter<any>()
ready = new EventEmitter<any>()
shown = new EventEmitter<any>()
secondInstance = new EventEmitter<any>()
private logger: Logger;
getWindow() {
return this.electron.app.window
}
getShell() {
return this.electron.shell
}
getAppPath() {
return this.electron.app.getAppPath()
}
getPath(type: string) {
return this.electron.app.getPath(type)
}
openDevTools() {
this.electron.app.webContents.openDevTools()
}
setCloseable(flag: boolean) {
this.electron.ipcRenderer.send('window-set-closeable', flag)
}
focusWindow() {
this.electron.ipcRenderer.send('window-focus')
}
toggleWindow() {
this.electron.ipcRenderer.send('window-toggle-focus')
}
minimize () {
this.electron.ipcRenderer.send('window-minimize')
}
maximize () {
this.electron.ipcRenderer.send('window-maximize')
}
unmaximize () {
this.electron.ipcRenderer.send('window-unmaximize')
}
toggleMaximize () {
this.electron.ipcRenderer.send('window-toggle-maximize')
}
setBounds (bounds: Electron.Rectangle) {
this.electron.ipcRenderer.send('window-set-bounds', bounds)
}
setAlwaysOnTop (flag: boolean) {
this.electron.ipcRenderer.send('window-set-always-on-top', flag)
}
quit () {
this.logger.info('Quitting')
this.electron.app.quit()
}
}

View File

@@ -0,0 +1,227 @@
import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core'
import { ElectronService } from '../services/electron'
import { ConfigService } from '../services/config'
import { NativeKeyEvent, stringifyKeySequence } from './hotkeys.util'
import { IHotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider'
export interface PartialHotkeyMatch {
id: string,
strokes: string[],
matchedLength: number,
}
const KEY_TIMEOUT = 2000
interface EventBufferEntry {
event: NativeKeyEvent,
time: number,
}
@Injectable()
export class HotkeysService {
key = new EventEmitter<NativeKeyEvent>()
matchedHotkey = new EventEmitter<string>()
globalHotkey = new EventEmitter()
private currentKeystrokes: EventBufferEntry[] = []
private disabledLevel = 0
private hotkeyDescriptions: IHotkeyDescription[]
constructor(
private zone: NgZone,
private electron: ElectronService,
private config: ConfigService,
@Inject(HotkeyProvider) hotkeyProviders: HotkeyProvider[],
) {
let events = ['keydown', 'keyup']
events.forEach((event) => {
document.addEventListener(event, (nativeEvent) => {
if (document.querySelectorAll('input:focus').length == 0) {
this.pushKeystroke(event, nativeEvent)
this.processKeystrokes()
this.emitKeyEvent(nativeEvent)
}
})
})
this.hotkeyDescriptions = hotkeyProviders.map(x => x.hotkeys).reduce((a, b) => a.concat(b))
}
pushKeystroke (name, nativeEvent) {
nativeEvent.event = name
this.currentKeystrokes.push({ event: nativeEvent, time: performance.now() })
}
processKeystrokes () {
if (this.isEnabled()) {
this.zone.run(() => {
let matched = this.getCurrentFullyMatchedHotkey()
if (matched) {
console.log('Matched hotkey', matched)
this.matchedHotkey.emit(matched)
this.clearCurrentKeystrokes()
}
})
}
}
emitKeyEvent (nativeEvent) {
this.zone.run(() => {
this.key.emit(nativeEvent)
})
}
clearCurrentKeystrokes () {
this.currentKeystrokes = []
}
getCurrentKeystrokes () : string[] {
this.currentKeystrokes = this.currentKeystrokes.filter((x) => performance.now() - x.time < KEY_TIMEOUT )
return stringifyKeySequence(this.currentKeystrokes.map((x) => x.event))
}
registerHotkeys () {
this.electron.globalShortcut.unregisterAll()
// TODO
this.electron.globalShortcut.register('Ctrl+Space', () => {
this.globalHotkey.emit()
})
}
getHotkeysConfig () {
let keys = {}
for (let key in this.config.full().hotkeys) {
let value = this.config.full().hotkeys[key]
if (typeof value == 'string') {
value = [value]
}
value = value.map((item) => (typeof item == 'string') ? [item] : item)
keys[key] = value
}
return keys
}
getCurrentFullyMatchedHotkey () : string {
for (let id in this.getHotkeysConfig()) {
for (let sequence of this.getHotkeysConfig()[id]) {
let currentStrokes = this.getCurrentKeystrokes()
if (currentStrokes.length < sequence.length) {
break
}
if (sequence.every((x, index) => {
return x.toLowerCase() == currentStrokes[currentStrokes.length - sequence.length + index].toLowerCase()
})) {
return id
}
}
}
return null
}
getCurrentPartiallyMatchedHotkeys () : PartialHotkeyMatch[] {
let result = []
for (let id in this.getHotkeysConfig()) {
for (let sequence of this.getHotkeysConfig()[id]) {
let currentStrokes = this.getCurrentKeystrokes()
for (let matchLength = Math.min(currentStrokes.length, sequence.length); matchLength > 0; matchLength--) {
//console.log(sequence, currentStrokes.slice(currentStrokes.length - sequence.length))
if (sequence.slice(0, matchLength).every((x, index) => {
return x.toLowerCase() == currentStrokes[currentStrokes.length - matchLength + index].toLowerCase()
})) {
result.push({
matchedLength: matchLength,
id,
strokes: sequence
})
break
}
}
}
}
return result
}
getHotkeyDescription (id: string) : IHotkeyDescription {
return this.hotkeyDescriptions.filter((x) => x.id == id)[0]
}
enable () {
this.disabledLevel--
}
disable () {
this.disabledLevel++
}
isEnabled () {
return this.disabledLevel == 0
}
}
@Injectable()
export class AppHotkeyProvider extends HotkeyProvider {
hotkeys: IHotkeyDescription[] = [
{
id: 'new-tab',
name: 'New tab',
},
{
id: 'close-tab',
name: 'Close tab',
},
{
id: 'toggle-last-tab',
name: 'Toggle last tab',
},
{
id: 'next-tab',
name: 'Next tab',
},
{
id: 'previous-tab',
name: 'Previous tab',
},
{
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',
},
]
}

View File

@@ -0,0 +1,62 @@
import * as os from 'os'
export const metaKeyName = {
darwin: '⌘',
win32: 'Win',
linux: 'Super',
}[os.platform()]
export const altKeyName = {
darwin: 'Option',
win32: 'Alt',
linux: 'Alt',
}[os.platform()]
export interface NativeKeyEvent {
event?: string,
altKey: boolean,
ctrlKey: boolean,
metaKey: boolean,
shiftKey: boolean,
key: string,
keyCode: string,
}
export function stringifyKeySequence(events: NativeKeyEvent[]): string[] {
let items: string[] = []
events = events.slice()
while (events.length > 0) {
let event = events.shift()
if (event.event == 'keydown') {
let 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', 'Command'].includes(event.key)) {
// TODO make this optional?
continue
}
if (event.key.length == 1) {
itemKeys.push(event.key.toUpperCase())
} else {
itemKeys.push(event.key)
}
items.push(itemKeys.join('-'))
}
}
return items
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core'
export class Logger {
constructor(
private name: string,
) {}
log (level: string, ...args: any[]) {
console[level](`%c[${this.name}]`, 'color: #aaa', ...args)
}
debug(...args: any[]) { this.log('debug', ...args) }
info(...args: any[]) { this.log('info', ...args) }
warn(...args: any[]) { this.log('warn', ...args) }
error(...args: any[]) { this.log('error', ...args) }
}
@Injectable()
export class LogService {
create (name: string): Logger {
return new Logger(name)
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@angular/core'
import { ToasterService } from 'angular2-toaster'
@Injectable()
export class NotifyService {
constructor(
private toaster: ToasterService,
) {}
pop(options) {
this.toaster.pop(options)
}
info(title: string, body: string = null) {
return this.pop({
type: 'info',
title, body,
timeout: 4000,
})
}
success(title: string, body: string = null) {
return this.pop({
type: 'success',
title, body,
timeout: 4000,
})
}
warning(title: string, body: string = null) {
return this.pop({
type: 'warning',
title, body,
timeout: 4000,
})
}
error(title: string, body: string = null) {
return this.pop({
type: 'error',
title, body,
timeout: 4000,
})
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core'
class Plugin {
ngModule: any
name: string
}
@Injectable()
export class PluginsService {
plugins: Plugin[] = []
register (plugin: Plugin): void {
this.plugins.push(plugin)
}
getModules (): any[] {
return this.plugins.map((plugin) => plugin.ngModule)
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core'
import { HostAppService } from '../services/hostApp'
@Injectable()
export class QuitterService {
constructor(
private hostApp: HostAppService,
) {
hostApp.quitRequested.subscribe(() => {
this.quit()
})
}
quit() {
this.hostApp.setCloseable(true)
this.hostApp.quit()
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable, Inject } from '@angular/core'
import { Logger, LogService } from '../services/log'
import { BaseTabComponent } from '../components/baseTab'
import { TabRecoveryProvider } from '../api/tabRecovery'
import { AppService } from '../services/app'
@Injectable()
export class TabRecoveryService {
logger: Logger
constructor(
@Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[],
app: AppService,
log: LogService
) {
this.logger = log.create('tabRecovery')
app.tabsChanged$.subscribe(() => {
this.saveTabs(app.tabs)
})
}
saveTabs (tabs: BaseTabComponent[]) {
window.localStorage.tabsRecovery = JSON.stringify(
tabs
.map((tab) => tab.getRecoveryToken())
.filter((token) => !!token)
)
}
async recoverTabs (): Promise<void> {
if (window.localStorage.tabsRecovery) {
for (let token of JSON.parse(window.localStorage.tabsRecovery)) {
for (let provider of this.tabRecoveryProviders) {
try {
await provider.recover(token)
} catch (error) {
this.logger.warn('Tab recovery crashed:', token, provider, error)
}
}
}
}
}
}

View File

@@ -0,0 +1,293 @@
$white: #fff !default;
$black: #000 !default;
$red: #d9534f !default;
$orange: #f0ad4e !default;
$yellow: #ffd500 !default;
$green: #5cb85c !default;
$blue: #0275d8 !default;
$teal: #5bc0de !default;
$pink: #ff5b77 !default;
$purple: #613d7c !default;
$body-bg: #1D272D;
$body-bg2: #131d27;
$body-bg3: #20333e;
$body-color: #aaa;
$font-family-sans-serif: "Source Sans Pro";
$font-size-base: 14rem / 16;
$btn-secondary-color: #ccc;
$btn-secondary-bg: #222;
$btn-secondary-border: #444;
//$btn-warning-bg: rgba($orange, .5);
$nav-tabs-border-color: $body-bg2;
$nav-tabs-border-width: 1px;
$nav-tabs-border-radius: 0;
$nav-tabs-link-hover-border-color: $body-bg2;
$nav-tabs-active-link-hover-color: $white;
$nav-tabs-active-link-hover-bg: $blue;
$nav-tabs-active-link-hover-border-color: darken($blue, 30%);
$input-bg: #111;
$input-bg-disabled: #333;
$input-color: $body-color;
//$input-border-color: rgba($black,.15);
//$input-box-shadow: inset 0 1px 1px rgba($black,.075);
$input-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;
$modal-content-bg: $body-bg;
$modal-content-border-color: $body-bg2;
$modal-header-border-color: $body-bg2;
$modal-footer-border-color: $body-bg2;
$popover-bg: $body-bg2;
@import '~bootstrap/scss/bootstrap.scss';
.nav-tabs {
background: $btn-secondary-bg;
.nav-link {
transition: 0.25s all;
border-bottom-color: $nav-tabs-border-color;
}
}
ngb-tabset .tab-content {
padding-top: 20px;
}
[ngbradiogroup] > label.active {
background: $blue;
}
$tab-border-radius: 5px;
$button-hover-bg: rgba(0, 0, 0, .25);
$button-active-bg: rgba(0, 0, 0, .5);
title-bar {
background: $body-bg2;
button {
&:hover { background: $button-hover-bg !important; }
&:active { background: $button-active-bg !important; }
}
}
app-root {
background: $body-bg;
&> .content {
background: $body-bg2;
.tabs {
background: $body-bg;
&>button {
&:not(:hover):not(:active) {
background: $body-bg2;
}
}
tab-header {
background: $body-bg;
.content-wrapper {
background: $body-bg2;
.index {
color: #555;
}
button {
color: $body-color;
border: none;
transition: 0.25s all;
&:hover { background: $button-hover-bg !important; }
&:active { background: $button-active-bg !important; }
}
}
&.active {
background: $body-bg2;
.content-wrapper {
background: $body-bg;
}
}
&.has-activity:not(.active) {
/*
.content-wrapper .index {
background: $blue;
color: white;
text-shadow: 0 1px 1px rgba(0,0,0,.95);
}
*/
}
}
}
&.tabs-on-top .tabs {
margin-top: 3px;
tab-header {
&.pre-selected {
.content-wrapper {
border-bottom-right-radius: $tab-border-radius;
}
}
&.post-selected {
.content-wrapper {
border-bottom-left-radius: $tab-border-radius;
}
}
.content-wrapper {
border-top: 1px solid transparent;
}
&.active .content-wrapper {
border-top: 1px solid $teal;
border-top-left-radius: $tab-border-radius;
border-top-right-radius: $tab-border-radius;
}
&.has-activity:not(.active) .content-wrapper {
border-top: 1px solid $green;
}
}
}
&:not(.tabs-on-top) .tabs {
margin-bottom: 3px;
tab-header {
&.pre-selected {
.content-wrapper {
border-top-right-radius: $tab-border-radius;
}
}
&.post-selected {
.content-wrapper {
border-top-left-radius: $tab-border-radius;
}
}
.content-wrapper {
border-bottom: 1px solid transparent;
}
&.active .content-wrapper {
border-bottom: 1px solid $teal;
border-bottom-left-radius: $tab-border-radius;
border-bottom-right-radius: $tab-border-radius;
}
&.has-activity:not(.active) .content-wrapper {
border-bottom: 1px solid $green;
}
}
}
}
}
tab-body {
background: $body-bg;
}
settings-tab > ngb-tabset {
border-right: 1px solid $body-bg2;
& > .nav {
background: $body-bg3;
& > .nav-item > .nav-link {
border-left: none;
border-right: none;
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
padding: 10px 50px 10px 20px;
font-size: 14px;
&.active {
border-top-color: $nav-tabs-active-link-hover-border-color;
border-bottom-color: $nav-tabs-active-link-hover-border-color;
}
}
}
}
multi-hotkey-input {
.item {
background: $body-bg3;
border: 1px solid $blue;
border-radius: 3px;
margin-right: 5px;
.body {
padding: 3px 0 2px;
.stroke {
padding: 0 6px;
border-right: 1px solid $body-bg;
}
}
.remove {
padding: 3px 8px 2px;
}
}
.add {
color: #777;
padding: 4px 10px 0;
}
.add, .item .body, .item .remove {
&:hover { background: darken($body-bg3, 5%); }
&:active { background: darken($body-bg3, 15%); }
}
}
hotkey-input-modal {
.input {
background: $input-bg;
padding: 10px;
font-size: 24px;
line-height: 27px;
height: 55px;
.stroke {
background: $body-bg3;
border: 1px solid $blue;
border-radius: 3px;
margin-right: 10px;
padding: 3px 10px;
}
}
.timeout {
background: $input-bg;
div {
background: $blue;
}
}
}

View File

@@ -0,0 +1 @@
$tabs-height: 40px;