Eugene Pankov 9 years ago
parent
commit
d7bae654eb

+ 15 - 4
app/main.js

@@ -13,7 +13,7 @@ setupWindowManagement = () => {
     app.window.on('close', (e) => {
         windowConfig.set('windowBoundaries', app.window.getBounds())
         if (!windowCloseable) {
-            app.window.hide()
+            app.window.minimize()
             e.preventDefault()
         }
     })
@@ -31,6 +31,18 @@ setupWindowManagement = () => {
         app.window.focus()
     })
 
+    electron.ipcMain.on('window-maximize', () => {
+        if (app.window.isMaximized()) {
+            app.window.unmaximize()
+        } else {
+            app.window.maximize()
+        }
+    })
+
+    electron.ipcMain.on('window-minimize', () => {
+        app.window.minimize()
+    })
+
     app.on('before-quit', () => windowCloseable = true)
 }
 
@@ -82,16 +94,15 @@ start = () => {
         'web-preferences': {'web-security': false},
         //- background to avoid the flash of unstyled window
         backgroundColor: '#1D272D',
+        frame: false,
     }
     Object.assign(options, windowConfig.get('windowBoundaries'))
 
     if (platform == 'darwin') {
         options.titleBarStyle = 'hidden'
-    } else {
-        options.frame = false
     }
 
-    app.commandLine.appendSwitch('--disable-http-cache')
+    app.commandLine.appendSwitch('disable-http-cache')
 
     app.window = new electron.BrowserWindow(options)
     app.window.loadURL(`file://${app.getAppPath()}/assets/webpack/index.html`, {extraHeaders: "pragma: no-cache\n"})

+ 1 - 1
app/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "main": "main.js",
   "dependencies": {
-    "child-process-promise": "^2.1.3",
+    "child-process-promise": "^2.2.0",
     "devtron": "^1.4.0",
     "electron-config": "^0.2.1",
     "electron-debug": "^1.0.1",

+ 2 - 0
app/src/app.module.ts

@@ -9,6 +9,7 @@ import { ConfigService } from 'services/config'
 import { ElectronService } from 'services/electron'
 import { HostAppService } from 'services/hostApp'
 import { LogService } from 'services/log'
+import { HotkeysService } from 'services/hotkeys'
 import { ModalService } from 'services/modal'
 import { NotifyService } from 'services/notify'
 import { QuitterService } from 'services/quitter'
@@ -33,6 +34,7 @@ import { TerminalComponent } from 'components/terminal'
         ConfigService,
         ElectronService,
         HostAppService,
+        HotkeysService,
         LogService,
         ModalService,
         NotifyService,

+ 74 - 18
app/src/components/app.less

@@ -3,20 +3,65 @@
 
 :host {
     display: flex;
-    width: 100vw;
-    height: 100vh;
+    width: ~"calc(100vw - 2px)";
+    height: ~"calc(100vh - 2px)";
     flex-direction: column;
     overflow: hidden;
     -webkit-user-select: none;
     -webkit-font-smoothing: antialiased;
+    cursor: default;
     background: @body-bg;
 }
 
+@titlebar-height: 35px;
 @tabs-height: 40px;
+@tab-border-radius: 3px;
+
+.button-states() {
+    transition: 0.125s all;
+
+    &:hover:not(.active) {
+        background: rgba(255, 255, 255, .033);
+    }
+
+    &:active:not(.active) {
+        background: rgba(0, 0, 0, .1);
+    }
+}
+
+.titlebar {
+    height: @titlebar-height;
+    background: #141c23;
+    flex: none;
+    display: flex;
+    flex-direction: row;
+
+    .title {
+        flex: auto;
+        padding-left: 15px;
+        line-height: @titlebar-height;
+        -webkit-app-region: drag;
+    }
+
+    .btn-minimize, .btn-maximize, .btn-close {
+        flex: none;
+        line-height: @titlebar-height - 2px;
+        padding: 0 15px;
+        font-size: 8px;
+
+        .button-states();
+        cursor: pointer;
+    }
+
+    .btn-close {
+        font-size: 12px;
+    }
+}
 
 .tabs {
     flex: none;
     height: @tabs-height;
+    background: #141c23;
 
     display: flex;
     flex-direction: row;
@@ -41,13 +86,7 @@
             margin-right: 10px;
         }
 
-        &:hover {
-            background: rgba(255, 255, 255, .1);
-        }
-
-        &:active {
-            background: rgba(0, 0, 0, .1);
-        }
+        .button-states();
     }
 
     .tab {
@@ -55,25 +94,42 @@
         flex-basis: 0;
         flex-grow: 1;
 
+        background: @body-bg;
+
         display: flex;
         flex-direction: row;
+        overflow: hidden;
+        min-width: 0;
 
-        div {
-            flex: auto;
-            padding: 0 15px;
+        &.pre-selected, &:nth-last-child(2) {
+            border-top-right-radius: @tab-border-radius;
         }
 
-        border-bottom: 2px solid transparent;
-        transition: 0.25s all;
+        &.post-selected {
+            border-top-left-radius: @tab-border-radius;
+        }
 
-        &:hover:not(.active) {
-            background: rgba(255, 255, 255, .05);
+        div.index {
+            flex: none;
+            padding: 0 0 0 15px;
+            font-weight: bold;
+            color: #444;
         }
 
-        &:active {
-            background: rgba(0, 0, 0, .1);
+        div.name {
+            flex: auto;
+            margin: 0 15px 0 10px;
+            overflow: hidden;
+            white-space: nowrap;
+            text-overflow: ellipsis;
+            min-width: 0;
         }
 
+        border-bottom: 2px solid transparent;
+        transition: 0.25s all;
+
+        .button-states();
+
         &.active {
             background: #141c23;
             border-bottom: 2px solid #69bbea;

+ 18 - 2
app/src/components/app.pug

@@ -1,6 +1,22 @@
+.titlebar
+    .title((dblclick)='hostApp.maximizeWindow()') Term
+    .btn-minimize((click)='hostApp.minimizeWindow()')
+        i.fa.fa-window-minimize
+    .btn-maximize((click)='hostApp.maximizeWindow()')
+        i.fa.fa-window-maximize
+    .btn-close((click)='hostApp.quit()')
+        i.fa.fa-close
+
 .tabs
-    .tab(*ngFor='let tab of tabs; trackBy: tab?.id', (click)='selectTab(tab)', [class.active]='tab == activeTab')
-        div {{tab.name}}
+    .tab(
+        *ngFor='let tab of tabs; let idx = index; trackBy: tab?.id',
+        (click)='selectTab(tab)',
+        [class.active]='tab == activeTab',
+        [class.pre-selected]='tabs[idx + 1] == activeTab',
+        [class.post-selected]='tabs[idx - 1] == activeTab',
+    )
+        div.index {{idx + 1}}
+        div.name {{tab.name || 'Terminal'}}
         button((click)='closeTab(tab)') ×
     .btn-new-tab((click)='newTab()')
         i.fa.fa-plus

+ 43 - 7
app/src/components/app.ts

@@ -1,7 +1,8 @@
-import { Component } from '@angular/core'
+import { Component, ElementRef } from '@angular/core'
 import { ModalService } from 'services/modal'
 import { ElectronService } from 'services/electron'
 import { HostAppService } from 'services/hostApp'
+import { HotkeysService } from 'services/hotkeys'
 import { LogService } from 'services/log'
 import { QuitterService } from 'services/quitter'
 import { ToasterConfig } from 'angular2-toaster'
@@ -31,11 +32,13 @@ class Tab {
 })
 export class AppComponent {
     constructor(
-        private hostApp: HostAppService,
         private modal: ModalService,
-        private electron: ElectronService,
+        private elementRef: ElementRef,
         private sessions: SessionsService,
+        public hostApp: HostAppService,
+        public hotkeys: HotkeysService,
         log: LogService,
+        electron: ElectronService,
         _quitter: QuitterService,
     ) {
         console.timeStamp('AppComponent ctor')
@@ -48,6 +51,22 @@ export class AppComponent {
             preventDuplicates: true,
             timeout: 4000,
         })
+
+        this.hotkeys.key.subscribe((key) => {
+            if (key.event == 'keydown') {
+                if (key.alt && key.key >= '1' && key.key <= '9') {
+                    let index = key.key.charCodeAt(0) - '0'.charCodeAt(0) - 1
+                    if (index < this.tabs.length) {
+                        this.selectTab(this.tabs[index])
+                    }
+                }
+                if (key.alt && key.key == '0') {
+                    if (this.tabs.length >= 10) {
+                        this.selectTab(this.tabs[9])
+                    }
+                }
+            }
+        })
     }
 
     toasterConfig: ToasterConfig
@@ -55,23 +74,40 @@ export class AppComponent {
     activeTab: Tab
 
     newTab () {
-        const tab = new Tab(this.sessions.createSession({command: 'bash'}))
+        this.addSessionTab(this.sessions.createNewSession({command: 'bash'}))
+    }
+
+    addSessionTab (session) {
+        let tab = new Tab(session)
         this.tabs.push(tab)
         this.selectTab(tab)
     }
 
     selectTab (tab) {
         this.activeTab = tab
+        setImmediate(() => {
+            this.elementRef.nativeElement.querySelector(':scope .tab.active iframe').focus()
+        })
     }
 
     closeTab (tab) {
-        tab.session.destroy()
+        tab.session.gracefullyDestroy()
         this.tabs = this.tabs.filter((x) => x != tab)
-        this.selectTab(this.tabs[0])
+        if (tab == this.activeTab) {
+            this.selectTab(this.tabs[0])
+        }
     }
 
     ngOnInit () {
-        this.newTab()
+        this.sessions.recoverAll().then((recoveredSessions) => {
+            if (recoveredSessions.length > 0) {
+                recoveredSessions.forEach((session) => {
+                    this.addSessionTab(session)
+                })
+            } else {
+                this.newTab()
+            }
+        })
     }
 
     ngOnDestroy () {

+ 2 - 6
app/src/components/settingsModal.ts

@@ -2,11 +2,8 @@ import { Component } from '@angular/core'
 import { ElectronService } from 'services/electron'
 import { HostAppService, PLATFORM_WINDOWS, PLATFORM_LINUX, PLATFORM_MAC } from 'services/hostApp'
 import { ConfigService } from 'services/config'
-import { QuitterService } from 'services/quitter'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 
-import * as os from 'os'
-
 
 @Component({
   selector: 'settings-modal',
@@ -16,10 +13,9 @@ import * as os from 'os'
 export class SettingsModalComponent {
     constructor(
         private modalInstance: NgbActiveModal,
-        private hostApp: HostAppService,
-        private electron: ElectronService,
-        private quitter: QuitterService,
         public config: ConfigService,
+        hostApp: HostAppService,
+        electron: ElectronService,
     ) {
         this.isWindows = hostApp.platform == PLATFORM_WINDOWS
         this.isMac = hostApp.platform == PLATFORM_MAC

+ 13 - 6
app/src/components/terminal.ts

@@ -1,5 +1,4 @@
 import { Component, NgZone, Input, Output, EventEmitter, ElementRef } from '@angular/core'
-import { ElectronService } from 'services/electron'
 import { ConfigService } from 'services/config'
 
 import { Session } from 'services/sessions'
@@ -10,16 +9,25 @@ const hterm = require('hterm-commonjs')
 hterm.hterm.VT.ESC['k'] = function(parseState) {
     parseState.resetArguments();
 
-    function parseOSC(parseState) {
-        if (!this.parseUntilStringTerminator_(parseState) || parseState.func == parseOSC) {
+    function parseOSC(ps) {
+        if (!this.parseUntilStringTerminator_(ps) || ps.func == parseOSC) {
             return
         }
 
-        this.terminal.setWindowTitle(parseState.args[0])
+        this.terminal.setWindowTitle(ps.args[0])
     }
     parseState.func = parseOSC
 }
 
+hterm.hterm.defaultStorage = new hterm.lib.Storage.Memory()
+hterm.hterm.PreferenceManager.defaultPreferences['user-css'] = ``
+const oldDecorate = hterm.hterm.ScrollPort.prototype.decorate
+hterm.hterm.ScrollPort.prototype.decorate = function (...args) {
+    oldDecorate.bind(this)(...args)
+    this.screen_.style.cssText += `; padding-right: ${this.screen_.offsetWidth - this.screen_.clientWidth}px;`
+}
+
+
 @Component({
   selector: 'terminal',
   template: '',
@@ -33,7 +41,6 @@ export class TerminalComponent {
 
     constructor(
         private zone: NgZone,
-        private electron: ElectronService,
         private elementRef: ElementRef,
         public config: ConfigService,
     ) {
@@ -41,7 +48,6 @@ export class TerminalComponent {
 
     ngOnInit () {
         let io
-        hterm.hterm.defaultStorage = new hterm.lib.Storage.Memory()
         this.terminal = new hterm.hterm.Terminal()
         this.terminal.setWindowTitle = (title) => {
             this.zone.run(() => {
@@ -72,5 +78,6 @@ export class TerminalComponent {
     }
 
     ngOnDestroy () {
+        ;
     }
 }

+ 2 - 2
app/src/entry.ts

@@ -19,7 +19,7 @@ if (nodeRequire('electron-is-dev')) {
 }
 
 console.timeStamp('angular bootstrap started')
-platformBrowserDynamic().bootstrapModule(AppModule)
+platformBrowserDynamic().bootstrapModule(AppModule);
 
 
-process.emitWarning = function () { console.log(arguments) }
+(<any>process).emitWarning = function () { console.log(arguments) }

+ 9 - 1
app/src/services/hostApp.ts

@@ -62,7 +62,15 @@ export class HostAppService {
         this.electron.ipcRenderer.send('window-focus')
     }
 
-    quit() {
+    minimizeWindow () {
+        this.electron.ipcRenderer.send('window-minimize')
+    }
+
+    maximizeWindow () {
+        this.electron.ipcRenderer.send('window-maximize')
+    }
+
+    quit () {
         this.logger.info('Quitting')
         this.electron.app.quit()
     }

+ 60 - 0
app/src/services/hotkeys.ts

@@ -0,0 +1,60 @@
+import { Injectable, NgZone, EventEmitter } from '@angular/core'
+const hterm = require('hterm-commonjs')
+
+
+export interface Key {
+    event: string,
+    alt: boolean,
+    ctrl: boolean,
+    cmd: boolean,
+    shift: boolean,
+    key: string
+}
+
+@Injectable()
+export class HotkeysService {
+    key = new EventEmitter<Key>()
+
+    constructor(private zone: NgZone) {
+        let events = [
+            {
+                name: 'keydown',
+                htermHandler: 'onKeyDown_',
+            },
+            {
+                name: 'keypress',
+                htermHandler: 'onKeyPress_',
+            },
+            {
+                name: 'keyup',
+                htermHandler: 'onKeyUp_',
+            },
+        ]
+        events.forEach((event) => {
+            document.addEventListener(event.name, (nativeEvent) => {
+                this.emitNativeEvent(event.name, nativeEvent)
+            })
+
+            let oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler]
+            const __this = this
+            hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) {
+                __this.emitNativeEvent(event.name, nativeEvent)
+                oldHandler.bind(this)(nativeEvent)
+            }
+        })
+    }
+
+    emitNativeEvent (name, nativeEvent) {
+        console.debug('Key', nativeEvent)
+        this.zone.run(() => {
+            this.key.emit({
+                event: name,
+                alt: nativeEvent.altKey,
+                shift: nativeEvent.shiftKey,
+                cmd: nativeEvent.metaKey,
+                ctrl: nativeEvent.ctrlKey,
+                key: nativeEvent.key,
+            })
+        })
+    }
+}

+ 1 - 2
app/src/services/modal.ts

@@ -1,11 +1,10 @@
-import { Injectable, NgZone } from '@angular/core';
+import { Injectable } from '@angular/core';
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 
 
 @Injectable()
 export class ModalService {
     constructor(
-        private zone: NgZone,
         private ngbModal: NgbModal,
     ) {}
 

+ 0 - 2
app/src/services/notify.ts

@@ -1,13 +1,11 @@
 import { Injectable } from '@angular/core'
 import { ToasterService } from 'angular2-toaster'
-import { LogService } from 'services/log'
 
 
 @Injectable()
 export class NotifyService {
     constructor(
         private toaster: ToasterService,
-        private log: LogService,
     ) {}
 
     pop(options) {

+ 0 - 2
app/src/services/quitter.ts

@@ -1,12 +1,10 @@
 import { Injectable } from '@angular/core'
 import { HostAppService } from 'services/hostApp'
-import { ElectronService } from 'services/electron'
 
 
 @Injectable()
 export class QuitterService {
     constructor(
-        private electron: ElectronService,
         private hostApp: HostAppService,
     ) {
         hostApp.quitRequested.subscribe(() => {

+ 68 - 3
app/src/services/sessions.ts

@@ -1,8 +1,52 @@
 import { Injectable, NgZone, EventEmitter } from '@angular/core'
 import { Logger, LogService } from 'services/log'
+const exec = require('child-process-promise').exec
+import * as crypto from 'crypto'
 import * as ptyjs from 'pty.js'
 
 
+export interface SessionRecoveryProvider {
+    list(): Promise<any[]>
+    getRecoveryCommand(item: any): string
+    getNewSessionCommand(command: string): string
+}
+
+export class NullSessionRecoveryProvider implements SessionRecoveryProvider {
+    list(): Promise<any[]> {
+        return Promise.resolve([])
+    }
+
+    getRecoveryCommand(_: any): string {
+        return null
+    }
+
+    getNewSessionCommand(command: string) {
+        return command
+    }
+}
+
+export class ScreenSessionRecoveryProvider implements SessionRecoveryProvider {
+    list(): Promise<any[]> {
+        return exec('screen -ls').then((result) => {
+            return result.stdout.split('\n')
+                .filter((line) => /\bterm-tab-/.exec(line))
+                .map((line) => line.trim().split('.')[0])
+        }).catch(() => {
+            return []
+        })
+    }
+
+    getRecoveryCommand(item: any): string {
+        return `screen -r ${item}`
+    }
+
+    getNewSessionCommand(command: string): string {
+        const id = crypto.randomBytes(8).toString('hex')
+        return `screen -U -S term-tab-${id} -- ${command}`
+    }
+}
+
+
 export interface SessionOptions {
     name?: string,
     command: string,
@@ -20,9 +64,10 @@ export class Session {
 
     constructor (options: SessionOptions) {
         this.name = options.name
+        console.log('Spawning', options.command)
         this.pty = ptyjs.spawn('sh', ['-c', options.command], {
-            name: 'xterm-color',
-            //name: 'screen-256color',
+            //name: 'xterm-color',
+            name: 'xterm-256color',
             cols: 80,
             rows: 30,
             cwd: options.cwd || process.env.HOME,
@@ -62,7 +107,7 @@ export class Session {
     gracefullyDestroy () {
         return new Promise((resolve) => {
             this.sendSignal('SIGTERM')
-            if (!open) {
+            if (!this.open) {
                 resolve()
                 this.destroy()
             } else {
@@ -91,11 +136,20 @@ export class SessionsService {
     sessions: {[id: string]: Session} = {}
     logger: Logger
     private lastID = 0
+    recoveryProvider: SessionRecoveryProvider
 
     constructor(
+        private zone: NgZone,
         log: LogService,
     ) {
         this.logger = log.create('sessions')
+        this.recoveryProvider = new ScreenSessionRecoveryProvider()
+        //this.recoveryProvider = new NullSessionRecoveryProvider()
+    }
+
+    createNewSession (options: SessionOptions) : Session {
+        options.command = this.recoveryProvider.getNewSessionCommand(options.command)
+        return this.createSession(options)
     }
 
     createSession (options: SessionOptions) : Session {
@@ -109,4 +163,15 @@ export class SessionsService {
         this.sessions[session.name] = session
         return session
     }
+
+    recoverAll () : Promise<Session[]> {
+        return <Promise<Session[]>>(this.recoveryProvider.list().then((items) => {
+            return this.zone.run(() => {
+                return items.map((item) => {
+                    const command = this.recoveryProvider.getRecoveryCommand(item)
+                    return this.createSession({command})
+                })
+            })
+        }))
+    }
 }

+ 1 - 1
package.json

@@ -20,7 +20,7 @@
     "raw-loader": "^0.5.1",
     "style-loader": "^0.13.1",
     "to-string-loader": "^1.1.5",
-    "tslint": "4.0.2",
+    "tslint": "4.2.0",
     "typescript": "2.1.1",
     "typings": "2.0.0",
     "url-loader": "^0.5.7",

+ 9 - 6
tsconfig.json

@@ -11,17 +11,20 @@
         "sourceMap": true,
         "noUnusedParameters": true,
         "noImplicitReturns": true,
-        "noFallthroughCasesInSwitch": true
+        "noFallthroughCasesInSwitch": true,
+        "noUnusedParameters": true,
+        "noUnusedLocals": true
     },
     "compileOnSave": false,
     "exclude": [
         "node_modules",
-        "platforms",
+        "platforms"
     ],
-    "files": [
-        "app/src/app.d.ts",
-        "app/src/entry.ts",
-        "typings/index.d.ts",
+    "filesGlob" : [
+        "app/src/*.ts",
+        "app/src/**/*.ts",
+        "!node_modules/**",
+        "!app/node_modules/**",
         "node_modules/rxjs/Rx.d.ts"
     ]
 }

+ 0 - 3
tslint.json

@@ -5,16 +5,13 @@
         "semicolon": false,
         "no-inferrable-types": [true, "ignore-params"],
         "curly": true,
-        "no-duplicate-key": true,
         "no-duplicate-variable": true,
         "no-empty": true,
         "no-eval": true,
         "no-invalid-this": true,
         "no-shadowed-variable": true,
-        "no-unreachable": true,
         "no-unused-expression": true,
         "no-unused-new": true,
-        "no-unused-variable": true,
         "no-use-before-declare": true,
         "no-var-keyword": true,
         "new-parens": true

+ 8 - 5
webpack.config.js

@@ -67,16 +67,19 @@ module.exports = {
         ]
     },
     externals: {
-        'electron': 'require("electron")',
+        'fs': 'require("fs")',
+        'buffer': 'require("buffer")',
+        'system': '{}',
+        'file': '{}',
+
         'net': 'require("net")',
+        'electron': 'require("electron")',
         'remote': 'require("remote")',
         'shell': 'require("shell")',
         'ipc': 'require("ipc")',
-        'fs': 'require("fs")',
-        'buffer': 'require("buffer")',
+        'crypto': 'require("crypto")',
         'pty.js': 'require("pty.js")',
-        'system': '{}',
-        'file': '{}'
+        'child-process-promise': 'require("child-process-promise")',
     },
     plugins: [
         new webpack.ProvidePlugin({