Eugene Pankov 8 lat temu
rodzic
commit
86cb06e25e

+ 1 - 1
app/main.js

@@ -138,7 +138,7 @@ start = () => {
         //- background to avoid the flash of unstyled window
         backgroundColor: '#1D272D',
         frame: false,
-        type: 'toolbar',
+        //type: 'toolbar',
     }
     Object.assign(options, windowConfig.get('windowBoundaries'))
 

+ 7 - 1
app/src/app.module.ts

@@ -24,6 +24,8 @@ import { HotkeyDisplayComponent } from 'components/hotkeyDisplay'
 import { HotkeyHintComponent } from 'components/hotkeyHint'
 import { HotkeyInputModalComponent } from 'components/hotkeyInputModal'
 import { SettingsPaneComponent } from 'components/settingsPane'
+import { TabBodyComponent } from 'components/tabBody'
+import { TabHeaderComponent } from 'components/tabHeader'
 import { TerminalComponent } from 'components/terminal'
 
 
@@ -50,6 +52,8 @@ import { TerminalComponent } from 'components/terminal'
     ],
     entryComponents: [
         HotkeyInputModalComponent,
+        SettingsPaneComponent,
+        TerminalComponent,
     ],
     declarations: [
         AppComponent,
@@ -59,10 +63,12 @@ import { TerminalComponent } from 'components/terminal'
         HotkeyInputComponent,
         HotkeyInputModalComponent,
         SettingsPaneComponent,
+        TabBodyComponent,
+        TabHeaderComponent,
         TerminalComponent,
     ],
     bootstrap: [
-        AppComponent
+        AppComponent,
     ]
 })
 export class AppModule {

+ 12 - 121
app/src/components/app.less

@@ -29,7 +29,7 @@
     background: @body-bg;
 }
 
-@titlebar-height: 35px;
+@titlebar-height: 30px;
 @tabs-height: 40px;
 @tab-border-radius: 4px;
 
@@ -51,6 +51,10 @@
         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;
@@ -62,6 +66,11 @@
     }
 }
 
+:host > .spacer {
+    flex: 0 0 5px;
+    background: @title-bg;
+}
+
 .tabs {
     flex: none;
     height: @tabs-height;
@@ -69,12 +78,10 @@
     display: flex;
     flex-direction: row;
 
-    &>button, .tab {
+    &>button {
         line-height: @tabs-height - 2px;
         cursor: pointer;
-    }
 
-    &>button {
         padding: 0 15px;
         flex: 0 0 auto;
         border-bottom: 2px solid transparent;
@@ -96,130 +103,14 @@
         border-bottom-right-radius: @tab-border-radius;
     }
 
-    .tab.active + button {
+    tab-header.active + button {
         border-bottom-left-radius: @tab-border-radius;
     }
-
-    .tab {
-        flex: auto;
-        flex-basis: 0;
-        flex-grow: 1000;
-
-        display: flex;
-        overflow: hidden;
-
-        min-width: 0;
-        background: @body-bg;
-        transition: 0.25s all;
-
-        .button-states();
-
-        .content-wrapper {
-            display: flex;
-            flex-direction: row;
-            flex: auto;
-            min-width: 0;
-            background: @title-bg;
-            transition: 0.25s all;
-
-            div.index {
-                flex: none;
-                padding: 0 0 0 15px;
-                font-weight: bold;
-                color: #444;
-            }
-
-            div.name {
-                flex: auto;
-                margin: 0 1px 0 10px;
-                overflow: hidden;
-                white-space: nowrap;
-                text-overflow: ellipsis;
-                min-width: 0;
-            }
-
-            button {
-                flex: none;
-
-                background: transparent;
-                color: @text-color;
-
-                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;
-
-        &.pre-selected, &:nth-last-child(1) {
-            .content-wrapper {
-                border-bottom-right-radius: @tab-border-radius;
-            }
-        }
-
-        &.post-selected {
-            .content-wrapper {
-                border-bottom-left-radius: @tab-border-radius;
-            }
-        }
-
-        &.active {
-            background: @title-bg;
-            box-shadow: 0px -1px 0px 0px blue;
-
-            .content-wrapper {
-                //border-bottom: 2px solid #69bbea;
-                background: @body-bg;
-                border-top-left-radius: @tab-border-radius;
-                border-top-right-radius: @tab-border-radius;
-            }
-        }
-    }
 }
 
 .tabs-content {
     flex: auto;
     display: flex;
-
-    .tab {
-        display: none;
-        flex: auto;
-        position: relative;
-        padding: 15px;
-
-        overflow: hidden;
-        &.scrollable {
-            overflow-y: auto;
-        }
-
-        &.active {
-            display: flex;
-
-            >* {
-                flex: auto;
-            }
-        }
-    }
 }
 
 hotkey-hint {

+ 14 - 13
app/src/components/app.pug

@@ -7,32 +7,33 @@
     button.btn.btn-secondary.btn-close((click)='hostApp.quit()')
         i.fa.fa-close
 
+.spacer 
+
 .tabs(class='active-tab-{{tabs.indexOf(activeTab)}}')
     button.btn.btn-secondary.btn-new-tab((click)='newTab()')
         i.fa.fa-plus
-    .tab(
+    tab-header(
         *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',
+        [index]='idx',
+        [model]='tab',
+        [active]='tab == activeTab',
+        [hasActivity]='tab.hasActivity',
         @animateTab,
+        (click)='selectTab(tab)',
+        (closeClicked)='closeTab(tab)',
     )
-        .content-wrapper
-            div.index {{idx + 1}}
-            div.name {{tab.name || 'Terminal'}}
-            button((click)='closeTab(tab)') ×
     button.btn.btn-secondary.btn-settings((click)='showSettings()')
         i.fa.fa-cog
 
 .tabs-content
-    .tab(
+    tab-body(
         *ngFor='let tab of tabs; trackBy: tab?.id', 
-        [class.active]='tab == activeTab',
+        [active]='tab == activeTab',
+        [model]='tab',
         [class.scrollable]='tab.scrollable',
     )
-        terminal(*ngIf='tab.type == "terminal"', [session]='tab.session', '[(title)]'='tab.name')
-        settings-pane(*ngIf='tab.type == "settings"')
+        //-terminal(*ngIf='tab.type == "terminal"', [session]='tab.session', '[(title)]'='tab.name')
+        //-settings-pane(*ngIf='tab.type == "settings"')
 
 hotkey-hint
 

+ 15 - 28
app/src/components/app.ts

@@ -1,4 +1,4 @@
-import { Component, ElementRef, trigger, style, animate, transition, state } from '@angular/core'
+import { Component, ElementRef, Input, trigger, style, animate, transition, state } from '@angular/core'
 import { ToasterConfig } from 'angular2-toaster'
 
 import { ElectronService } from 'services/electron'
@@ -8,31 +8,15 @@ import { LogService } from 'services/log'
 import { QuitterService } from 'services/quitter'
 import { ConfigService } from 'services/config'
 import { DockingService } from 'services/docking'
-import { Session, SessionsService } from 'services/sessions'
+import { SessionsService } from 'services/sessions'
+
+import { Tab, SettingsTab, TerminalTab } from 'models/tab'
 
 import 'angular2-toaster/lib/toaster.css'
 import 'global.less'
 import 'theme.scss'
 
 
-const TYPE_TERMINAL = 'terminal'
-const TYPE_SETTINGS = 'settings'
-
-class Tab {
-    id: number
-    name: string
-    scrollable: boolean
-    static lastTabID = 0
-
-    constructor (public type: string, public session: Session) {
-        this.id = Tab.lastTabID++
-        if (type == TYPE_SETTINGS) {
-            this.name = 'Settings'
-        }
-    }
-}
-
-
 @Component({
     selector: 'app',
     template: require('./app.pug'),
@@ -58,8 +42,8 @@ class Tab {
 })
 export class AppComponent {
     toasterConfig: ToasterConfig
-    tabs: Tab[] = []
-    activeTab: Tab
+    @Input() tabs: Tab[] = []
+    @Input() activeTab: Tab
     lastTabIndex = 0
 
     constructor(
@@ -161,11 +145,11 @@ export class AppComponent {
     }
 
     newTab () {
-        this.addTerminalTab(this.sessions.createNewSession({shell: 'zsh'}))
+        this.addTerminalTab(this.sessions.createNewSession({command: 'zsh'}))
     }
 
     addTerminalTab (session) {
-        let tab = new Tab(TYPE_TERMINAL, session)
+        let tab = new TerminalTab(session)
         this.tabs.push(tab)
         this.selectTab(tab)
     }
@@ -176,6 +160,9 @@ export class AppComponent {
         } else {
             this.lastTabIndex = null
         }
+        if (this.activeTab) {
+            this.activeTab.hasActivity = false
+        }
         this.activeTab = tab
         setImmediate(() => {
             let iframe = this.elementRef.nativeElement.querySelector(':scope .tab.active iframe')
@@ -207,8 +194,9 @@ export class AppComponent {
     }
 
     closeTab (tab) {
+        tab.destroy()
         if (tab.session) {
-            tab.session.gracefullyDestroy()
+            this.sessions.destroySession(tab.session)
         }
         let newIndex = Math.max(0, this.tabs.indexOf(tab) - 1)
         this.tabs = this.tabs.filter((x) => x != tab)
@@ -231,10 +219,9 @@ export class AppComponent {
     }
 
     showSettings() {
-        let settingsTab = this.tabs.find((x) => x.type == TYPE_SETTINGS)
+        let settingsTab = this.tabs.find((x) => x instanceof SettingsTab)
         if (!settingsTab) {
-            settingsTab = new Tab(TYPE_SETTINGS, null)
-            settingsTab.scrollable = true
+            settingsTab = new SettingsTab()
             this.tabs.push(settingsTab)
         }
         this.selectTab(settingsTab)

+ 12 - 0
app/src/components/baseTab.ts

@@ -0,0 +1,12 @@
+import { Tab } from 'models/tab'
+
+export class BaseTabComponent<T extends Tab> {
+    protected model: T
+
+    initModel (model: T) {
+        this.model = model
+        this.initTab()
+    }
+
+    initTab () { }
+}

+ 3 - 0
app/src/components/settingsPane.less

@@ -1,4 +1,7 @@
 :host {
+    flex: auto;
+    margin: 15px;
+    
     >.btn-block {
         margin-bottom: 20px;
     }

+ 5 - 1
app/src/components/settingsPane.ts

@@ -9,13 +9,16 @@ import 'rxjs/add/operator/debounceTime'
 import 'rxjs/add/operator/distinctUntilChanged'
 const childProcessPromise = nodeRequire('child-process-promise')
 
+import { BaseTabComponent } from 'components/baseTab'
+import { SettingsTab } from 'models/tab'
+
 
 @Component({
   selector: 'settings-pane',
   template: require('./settingsPane.pug'),
   styles: [require('./settingsPane.less')],
 })
-export class SettingsPaneComponent {
+export class SettingsPaneComponent extends BaseTabComponent<SettingsTab> {
     isWindows: boolean
     isMac: boolean
     isLinux: boolean
@@ -31,6 +34,7 @@ export class SettingsPaneComponent {
         public docking: DockingService,
         hostApp: HostAppService,
     ) {
+        super()
         this.isWindows = hostApp.platform == PLATFORM_WINDOWS
         this.isMac = hostApp.platform == PLATFORM_MAC
         this.isLinux = hostApp.platform == PLATFORM_LINUX

+ 18 - 0
app/src/components/tabBody.scss

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

+ 29 - 0
app/src/components/tabBody.ts

@@ -0,0 +1,29 @@
+import { Component, Input, ViewContainerRef, ViewChild, HostBinding, ComponentFactoryResolver, ComponentRef } from '@angular/core'
+import { Tab } from 'models/tab'
+import { BaseTabComponent } from 'components/baseTab'
+
+@Component({
+  selector: 'tab-body',
+  template: '<template #placeholder></template>',
+  styles: [require('./tabBody.scss')],
+})
+export class TabBodyComponent {
+    @Input() @HostBinding('class.active') active: boolean
+    @Input() model: Tab
+    @ViewChild('placeholder', {read: ViewContainerRef}) placeholder: ViewContainerRef
+    private component: ComponentRef<BaseTabComponent<Tab>>
+
+    constructor (private componentFactoryResolver: ComponentFactoryResolver) {
+    }
+
+    ngAfterViewInit () {
+        // run after the change detection finishes
+        setImmediate(() => {
+            let componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.model.getComponentType())
+            this.component = this.placeholder.createComponent(componentFactory)
+            setImmediate(() => {
+                this.component.instance.initModel(this.model)
+            })
+        })
+    }
+}

+ 4 - 0
app/src/components/tabHeader.pug

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

+ 80 - 0
app/src/components/tabHeader.scss

@@ -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;
+}

+ 17 - 0
app/src/components/tabHeader.ts

@@ -0,0 +1,17 @@
+import { Component, Input, Output, EventEmitter, HostBinding } from '@angular/core'
+import { Tab } from 'models/tab'
+
+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() model: Tab
+    @Output() closeClicked = new EventEmitter()
+}

+ 2 - 0
app/src/components/terminal.scss

@@ -1,7 +1,9 @@
 :host {
+    flex: auto;
     position: relative;
     display: block;
     overflow: hidden;
+    margin: 15px;
 
     div[style]:last-child {
         background: black !important;

+ 16 - 11
app/src/components/terminal.ts

@@ -1,9 +1,12 @@
 import { Subscription } from 'rxjs'
-import { Component, NgZone, Input, Output, EventEmitter, ElementRef } from '@angular/core'
+import { Component, NgZone, Output, EventEmitter, ElementRef } from '@angular/core'
 
 import { ConfigService } from 'services/config'
 import { PluginDispatcherService } from 'services/pluginDispatcher'
-import { Session } from 'services/sessions'
+
+import { BaseTabComponent } from 'components/baseTab'
+import { TerminalTab } from 'models/tab'
+
 
 const hterm = require('hterm-commonjs')
 const dataurl = require('dataurl')
@@ -47,8 +50,7 @@ hterm.hterm.Terminal.prototype.showOverlay = () => null
   template: '',
   styles: [require('./terminal.scss')],
 })
-export class TerminalComponent {
-    @Input() session: Session
+export class TerminalComponent extends BaseTabComponent<TerminalTab> {
     title: string
     @Output() titleChange = new EventEmitter()
     terminal: any
@@ -60,12 +62,13 @@ export class TerminalComponent {
         public config: ConfigService,
         private pluginDispatcher: PluginDispatcherService,
     ) {
+        super()
         this.configSubscription = config.change.subscribe(() => {
             this.configure()
         })
     }
 
-    ngOnInit () {
+    initTab () {
         let io
         this.terminal = new hterm.hterm.Terminal()
         this.pluginDispatcher.emit('preTerminalInit', { terminal: this.terminal })
@@ -78,23 +81,23 @@ export class TerminalComponent {
         this.terminal.onTerminalReady = () => {
             this.terminal.installKeyboard()
             io = this.terminal.io.push()
-            const dataSubscription = this.session.dataAvailable.subscribe((data) => {
-                io.writeUTF16(data)
+            const dataSubscription = this.model.session.dataAvailable.subscribe((data) => {
+                io.writeUTF8(data)
             })
-            const closedSubscription = this.session.closed.subscribe(() => {
+            const closedSubscription = this.model.session.closed.subscribe(() => {
                 dataSubscription.unsubscribe()
                 closedSubscription.unsubscribe()
             })
 
             io.onVTKeystroke = io.sendString = (str) => {
-                this.session.write(str)
+                this.model.session.write(str)
             }
             io.onTerminalResize = (columns, rows) => {
                 console.log(`Resizing to ${columns}x${rows}`)
-                this.session.resize(columns, rows)
+                this.model.session.resize(columns, rows)
             }
 
-            this.session.releaseInitialDataBuffer()
+            this.model.session.releaseInitialDataBuffer()
         }
         this.terminal.decorate(this.elementRef.nativeElement)
         this.configure()
@@ -108,6 +111,8 @@ export class TerminalComponent {
         preferenceManager.set('audible-bell-sound', '')
         preferenceManager.set('desktop-notification-bell', config.terminal.bell == 'notification')
         preferenceManager.set('enable-clipboard-notice', false)
+        preferenceManager.set('receive-encoding', 'raw')
+        preferenceManager.set('send-encoding', 'raw')
     }
 
     ngOnDestroy () {

+ 64 - 0
app/src/models/tab.ts

@@ -0,0 +1,64 @@
+import { Subscription } from 'rxjs'
+import { Session } from 'services/sessions'
+
+
+export class Tab {
+    id: number
+    title: string
+    scrollable: boolean
+    hasActivity = false
+    static lastTabID = 0
+
+    constructor () {
+        this.id = Tab.lastTabID++
+    }
+
+    getComponentType (): (new (...args: any[])) {
+        return null
+    }
+
+    destroy (): void { }
+}
+
+
+import { SettingsPaneComponent } from 'components/settingsPane'
+
+export class SettingsTab extends Tab {
+    constructor () {
+        super()
+        this.title = 'Settings'
+        this.scrollable = true
+    }
+
+    getComponentType (): (new (...args: any[])) {
+        return SettingsPaneComponent
+    }
+}
+
+
+import { TerminalComponent } from 'components/terminal'
+
+export class TerminalTab extends Tab {
+    private activitySubscription: Subscription
+
+    constructor (public session: Session) {
+        super()
+        // ignore the initial refresh
+        setTimeout(() => {
+            this.activitySubscription = this.session.dataAvailable.subscribe(() => {
+                this.hasActivity = true
+            })
+        }, 500)
+    }
+
+    getComponentType (): (new (...args: any[])) {
+        return TerminalComponent
+    }
+
+    destroy () {
+        super.destroy()
+        if (this.activitySubscription) {
+            this.activitySubscription.unsubscribe()
+        }
+    }
+}

+ 59 - 28
app/src/services/sessions.ts

@@ -1,34 +1,38 @@
 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 nodePTY from 'node-pty'
 import * as fs from 'fs'
 
 
-export interface SessionRecoveryProvider {
-    list(): Promise<any[]>
-    getRecoveryCommand(item: any): string
-    getNewSessionCommand(command: string): string
+export interface ISessionRecoveryProvider {
+    list (): Promise<any[]>
+    getRecoverySession (recoveryId: any): SessionOptions
+    wrapNewSession (options: SessionOptions): SessionOptions
+    terminateSession (recoveryId: string): Promise<any>
 }
 
-export class NullSessionRecoveryProvider implements SessionRecoveryProvider {
-    list(): Promise<any[]> {
-        return Promise.resolve([])
+export class NullSessionRecoveryProvider implements ISessionRecoveryProvider {
+    async list (): Promise<any[]> {
+        return []
     }
 
-    getRecoveryCommand(_: any): string {
+    getRecoverySession (_recoveryId: any): SessionOptions {
         return null
     }
 
-    getNewSessionCommand(command: string) {
-        return command
+    wrapNewSession (options: SessionOptions): SessionOptions {
+        return options
+    }
+
+    async terminateSession (_recoveryId: string): Promise<any> {
+        return null
     }
 }
 
-export class ScreenSessionRecoveryProvider implements SessionRecoveryProvider {
+export class ScreenSessionRecoveryProvider implements ISessionRecoveryProvider {
     list(): Promise<any[]> {
-        return exec('screen -ls').then((result) => {
+        return exec('screen -list').then((result) => {
             return result.stdout.split('\n')
                 .filter((line) => /\bterm-tab-/.exec(line))
                 .map((line) => line.trim().split('.')[0])
@@ -37,12 +41,14 @@ export class ScreenSessionRecoveryProvider implements SessionRecoveryProvider {
         })
     }
 
-    getRecoveryCommand(item: any): string {
-        return `screen -r ${item}`
+    getRecoverySession (recoveryId: any): SessionOptions {
+        return {
+            command: 'screen',
+            args: ['-r', recoveryId],
+        }
     }
 
-    getNewSessionCommand(command: string): string {
-        const id = crypto.randomBytes(8).toString('hex')
+    wrapNewSession (options: SessionOptions): SessionOptions {
         // TODO
         let configPath = '/tmp/.termScreenConfig'
         fs.writeFileSync(configPath, `
@@ -51,8 +57,19 @@ export class ScreenSessionRecoveryProvider implements SessionRecoveryProvider {
             term xterm-color
             bindkey "^[OH" beginning-of-line
             bindkey "^[OF" end-of-line
+            termcapinfo xterm* 'hs:ts=\\E]0;:fs=\\007:ds=\\E]0;\\007'
+            defhstatus "^Et"
+            hardstatus off
         `, 'utf-8')
-        return `screen -c ${configPath} -U -S term-tab-${id} -- ${command}`
+        let recoveryId = `term-tab-${Date.now()}`
+        options.args = ['-c', configPath, '-U', '-S', recoveryId, '--', options.command].concat(options.args || [])
+        options.command = 'screen'
+        options.recoveryId = recoveryId
+        return options
+    }
+
+    async terminateSession (recoveryId: string): Promise<any> {
+        return exec(`screen -S ${recoveryId} -X quit`)
     }
 }
 
@@ -60,9 +77,10 @@ export class ScreenSessionRecoveryProvider implements SessionRecoveryProvider {
 export interface SessionOptions {
     name?: string,
     command?: string,
-    shell?: string,
+    args?: string[],
     cwd?: string,
     env?: any,
+    recoveryId?: string
 }
 
 export class Session {
@@ -71,6 +89,7 @@ export class Session {
     dataAvailable = new EventEmitter()
     closed = new EventEmitter()
     destroyed = new EventEmitter()
+    recoveryId: string
     private pty: any
     private initialDataBuffer = ''
     private initialDataBufferReleased = false
@@ -79,14 +98,16 @@ export class Session {
         this.name = options.name
         console.log('Spawning', options.command)
 
-        let binary = options.shell || 'sh'
-        let args = options.shell ? [] : ['-c', options.command]
         let env = {
             ...process.env,
             ...options.env,
             TERM: 'xterm-256color',
         }
-        this.pty = nodePTY.spawn(binary, args, {
+        if (options.command.includes(' ')) {
+            options.args = ['-c', options.command]
+            options.command = 'sh'
+        }
+        this.pty = nodePTY.spawn(options.command, options.args || [], {
             //name: 'screen-256color',
             name: 'xterm-256color',
             //name: 'xterm-color',
@@ -168,7 +189,7 @@ export class SessionsService {
     sessions: {[id: string]: Session} = {}
     logger: Logger
     private lastID = 0
-    recoveryProvider: SessionRecoveryProvider
+    recoveryProvider: ISessionRecoveryProvider
 
     constructor(
         private zone: NgZone,
@@ -180,8 +201,10 @@ export class SessionsService {
     }
 
     createNewSession (options: SessionOptions) : Session {
-        options.command = this.recoveryProvider.getNewSessionCommand(options.command)
-        return this.createSession(options)
+        options = this.recoveryProvider.wrapNewSession(options)
+        let session = this.createSession(options)
+        session.recoveryId = options.recoveryId
+        return session
     }
 
     createSession (options: SessionOptions) : Session {
@@ -196,12 +219,20 @@ export class SessionsService {
         return session
     }
 
+    async destroySession (session: Session): Promise<any> {
+        await session.gracefullyDestroy()
+        await this.recoveryProvider.terminateSession(session.recoveryId)
+        return null
+    }
+
     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})
+                return items.map((recoveryId) => {
+                    const options = this.recoveryProvider.getRecoverySession(recoveryId)
+                    let session = this.createSession(options)
+                    session.recoveryId = recoveryId
+                    return session
                 })
             })
         }))

+ 58 - 0
app/src/theme.scss

@@ -63,3 +63,61 @@ ngb-tabset .tab-content {
 [ngbradiogroup] > label.active {
     background: $blue;
 }
+
+$tab-border-radius: 5px;
+
+.tabs tab-header {
+    background: $body-bg;
+    .content-wrapper {
+        background: $body-bg2;
+
+        .index {
+            color: #444;
+        }
+
+        button {
+            color: $body-color;
+            border: none;
+            transition: 0.25s all;
+
+            &:hover {
+                background: rgba(0, 0, 0, .25) !important;
+            }
+
+            &:active {
+                background: rgba(0, 0, 0, .5) !important;
+            }
+        }
+    }
+
+    &.pre-selected, &:nth-last-child(1) {
+        .content-wrapper {
+            border-bottom-right-radius: $tab-border-radius;
+        }
+    }
+
+    &.post-selected {
+        .content-wrapper {
+            border-bottom-left-radius: $tab-border-radius;
+        }
+    }
+
+    &.active {
+        background: $body-bg2;
+
+        .content-wrapper {
+            border-top: 1px solid $blue;
+            background: $body-bg;
+            border-top-left-radius: $tab-border-radius;
+            border-top-right-radius: $tab-border-radius;
+        }
+    }
+
+    &.has-activity:not(.active) {
+        .content-wrapper .index {
+            background: $blue;
+            color: white;
+            text-shadow: 0 1px 1px rgba(0,0,0,.95);
+        }
+    }
+}

+ 1 - 0
app/src/variables.scss

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

+ 3 - 3
webpack.config.js

@@ -23,7 +23,7 @@ module.exports = {
         loaders: [
             {
                 test: /\.ts$/,
-                loader: 'awesome-typescript-loader'
+                loader: 'awesome-typescript-loader',
             },
             {
               test: /\.pug$/,
@@ -63,14 +63,14 @@ module.exports = {
             {
               test: /\.(png|svg)$/,
               loader: "file-loader",
-              query: {
+              options: {
                 name: 'images/[name].[hash:8].[ext]'
               }
             },
             {
                 test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
                 loader: "file-loader",
-                query: {
+                options: {
                   name: 'fonts/[name].[hash:8].[ext]'
                 }
             },