This commit is contained in:
Eugene Pankov 2017-03-22 00:18:52 +01:00
parent f659a45532
commit 86cb06e25e
20 changed files with 420 additions and 207 deletions

View File

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

View File

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

View File

@ -29,7 +29,7 @@
background: @body-bg; background: @body-bg;
} }
@titlebar-height: 35px; @titlebar-height: 30px;
@tabs-height: 40px; @tabs-height: 40px;
@tab-border-radius: 4px; @tab-border-radius: 4px;
@ -51,6 +51,10 @@
box-shadow: none; box-shadow: none;
border-radius: 0; border-radius: 0;
font-size: 8px; font-size: 8px;
width: 40px;
padding: 0;
line-height: @titlebar-height;
text-align: center;
&:not(:hover):not(:active) { &:not(:hover):not(:active) {
background: transparent; background: transparent;
@ -62,6 +66,11 @@
} }
} }
:host > .spacer {
flex: 0 0 5px;
background: @title-bg;
}
.tabs { .tabs {
flex: none; flex: none;
height: @tabs-height; height: @tabs-height;
@ -69,12 +78,10 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
&>button, .tab { &>button {
line-height: @tabs-height - 2px; line-height: @tabs-height - 2px;
cursor: pointer; cursor: pointer;
}
&>button {
padding: 0 15px; padding: 0 15px;
flex: 0 0 auto; flex: 0 0 auto;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@ -96,130 +103,14 @@
border-bottom-right-radius: @tab-border-radius; border-bottom-right-radius: @tab-border-radius;
} }
.tab.active + button { tab-header.active + button {
border-bottom-left-radius: @tab-border-radius; 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 { .tabs-content {
flex: auto; flex: auto;
display: flex; display: flex;
.tab {
display: none;
flex: auto;
position: relative;
padding: 15px;
overflow: hidden;
&.scrollable {
overflow-y: auto;
}
&.active {
display: flex;
>* {
flex: auto;
}
}
}
} }
hotkey-hint { hotkey-hint {

View File

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

View File

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

View File

@ -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 () { }
}

View File

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

View File

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

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,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)
})
})
}
}

View File

@ -0,0 +1,4 @@
.content-wrapper
.index {{index + 1}}
.name {{model.title || "Terminal"}}
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 { 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()
}

View File

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

View File

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

64
app/src/models/tab.ts Normal file
View File

@ -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()
}
}
}

View File

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

View File

@ -63,3 +63,61 @@ ngb-tabset .tab-content {
[ngbradiogroup] > label.active { [ngbradiogroup] > label.active {
background: $blue; 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
app/src/variables.scss Normal file
View File

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

View File

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