This commit is contained in:
Eugene Pankov
2017-04-11 02:22:48 +02:00
parent 25615902ba
commit 0ea346a6ae
253 changed files with 7841 additions and 415 deletions

1
terminus-terminal/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist

40
terminus-terminal/api.ts Normal file
View File

@@ -0,0 +1,40 @@
import { TerminalTabComponent } from './components/terminalTab'
export { TerminalTabComponent }
export abstract class TerminalDecorator {
attach (_terminal: TerminalTabComponent): void { }
detach (_terminal: TerminalTabComponent): void { }
}
export interface ResizeEvent {
width: number
height: number
}
export interface SessionOptions {
name?: string
command?: string
args?: string[]
cwd?: string
env?: any
recoveryId?: string
recoveredTruePID?: number
}
export abstract class SessionPersistenceProvider {
abstract async attachSession (recoveryId: any): Promise<SessionOptions>
abstract async startSession (options: SessionOptions): Promise<any>
abstract async terminateSession (recoveryId: string): Promise<void>
}
export interface ITerminalColorScheme {
name: string
foreground: string
background: string
cursor: string
colors: string[]
}
export abstract class TerminalColorSchemeProvider {
abstract async getSchemes (): Promise<ITerminalColorScheme[]>
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core'
import { HotkeysService, ToolbarButtonProvider, IToolbarButton, AppService } from 'terminus-core'
import { SessionsService } from './services/sessions'
import { TerminalTabComponent } from './components/terminalTab'
@Injectable()
export class ButtonProvider extends ToolbarButtonProvider {
constructor (
private app: AppService,
private sessions: SessionsService,
hotkeys: HotkeysService,
) {
super()
hotkeys.matchedHotkey.subscribe(async (hotkey) => {
if (hotkey == 'new-tab') {
this.openNewTab()
}
})
}
async openNewTab (): Promise<void> {
let cwd = null
if (this.app.activeTab instanceof TerminalTabComponent) {
cwd = await this.app.activeTab.session.getWorkingDirectory()
}
this.app.openNewTab(
TerminalTabComponent,
{ session: await this.sessions.createNewSession({ command: 'zsh', cwd }) }
)
}
provide (): IToolbarButton[] {
return [{
icon: 'plus',
title: 'New terminal',
click: async () => {
this.openNewTab()
}
}]
}
}

View File

@@ -0,0 +1,51 @@
import * as fs from 'fs-promise'
import * as path from 'path'
import { Injectable } from '@angular/core'
import { TerminalColorSchemeProvider, ITerminalColorScheme } from './api'
@Injectable()
export class HyperColorSchemes extends TerminalColorSchemeProvider {
async getSchemes (): Promise<ITerminalColorScheme[]> {
let pluginsPath = path.join(process.env.HOME, '.hyper_plugins', 'node_modules')
if (!(await fs.exists(pluginsPath))) return []
let plugins = await fs.readdir(pluginsPath)
let themes: ITerminalColorScheme[] = []
plugins.forEach(plugin => {
let module = (<any>global).require(path.join(pluginsPath, plugin))
if (module.decorateConfig) {
let config = module.decorateConfig({})
if (config.colors) {
themes.push({
name: plugin,
foreground: config.foregroundColor,
background: config.backgroundColor,
cursor: config.cursorColor,
colors: config.colors.black ? [
config.colors.black,
config.colors.red,
config.colors.green,
config.colors.yellow,
config.colors.blue,
config.colors.magenta,
config.colors.cyan,
config.colors.white,
config.colors.lightBlack,
config.colors.lightRed,
config.colors.lightGreen,
config.colors.lightYellow,
config.colors.lightBlue,
config.colors.lightMagenta,
config.colors.lightCyan,
config.colors.lightWhite,
] : config.colors,
})
}
}
})
return themes
}
}

View File

@@ -0,0 +1,14 @@
template(#content)
.preview(
[style.width]='"100%"',
[style.background]='model',
)
input.form-control(type='text', '[(ngModel)]'='model', (ngModelChange)='onChange()', #input)
div(
[ngbPopover]='content',
[style.background]='model',
(click)='open()',
container='body',
#popover='ngbPopover',
) {{ title }}

View File

@@ -0,0 +1,15 @@
div {
width: 32px;
height: 32px;
box-shadow: 0 1px 1px rgba(0,0,0,.5);
border: none;
margin: 5px 10px 5px 0;
border-radius: 2px;
text-align: center;
font-weight: bold;
font-size: 17px;
display: inline-block;
color: #fff;
line-height: 31px;
text-shadow: 0 1px 1px rgba(0,0,0,.5);
}

View File

@@ -0,0 +1,40 @@
import { Component, Input, Output, EventEmitter, HostListener, ViewChild } from '@angular/core'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
@Component({
selector: 'color-picker',
template: require('./colorPicker.pug'),
styles: [require('./colorPicker.scss')],
})
export class ColorPickerComponent {
@Input() model: string
@Input() title: string
@Output() modelChange = new EventEmitter<string>()
@ViewChild('popover') popover: NgbPopover
@ViewChild('input') input
open () {
setImmediate(() => {
this.popover.open()
setImmediate(() => {
this.input.nativeElement.focus()
})
})
}
@HostListener('document:click', ['$event']) onOutsideClick ($event) {
let windowRef = (<any>this.popover)._windowRef
if (!windowRef) {
return
}
if ($event.target !== windowRef.location.nativeElement &&
!windowRef.location.nativeElement.contains($event.target)) {
this.popover.close()
}
}
onChange () {
this.modelChange.emit(this.model)
}
}

View File

@@ -0,0 +1,145 @@
.row
.col-lg-6
.form-group
label Preview
.appearance-preview(
[style.font-family]='config.full().terminal.font',
[style.font-size]='config.full().terminal.fontSize + "px"',
[style.background-color]='(config.full().terminal.background == "theme") ? null : config.full().terminal.colorScheme.background',
[style.color]='config.full().terminal.colorScheme.foreground',
)
div
span john@doe-pc
span([style.color]='config.full().terminal.colorScheme.colors[1]') $
span webpack
div
span Asset Size
div
span([style.color]='config.full().terminal.colorScheme.colors[2]') main.js
span 234 kB
span([style.color]='config.full().terminal.colorScheme.colors[2]') [emitted]
div
span([style.color]='config.full().terminal.colorScheme.colors[3]') big.js
span([style.color]='config.full().terminal.colorScheme.colors[3]') 1.2 MB
span([style.color]='config.full().terminal.colorScheme.colors[2]') [emitted]
span([style.color]='config.full().terminal.colorScheme.colors[3]') [big]
div
span
div
span john@doe-pc
span([style.color]='config.full().terminal.colorScheme.colors[1]') $
span ls -l
div
span drwxr-xr-x 1 root root
span([style.color]='config.full().terminal.colorScheme.colors[4]') directory
div
span -rw-r--r-- 1 root root file
div
span -rwxr-xr-x 1 root root
span([style.color]='config.full().terminal.colorScheme.colors[2]') executable
div
span -rwxr-xr-x 1 root root
span([style.color]='config.full().terminal.colorScheme.colors[6]') sym
span ->
span([style.color]='config.full().terminal.colorScheme.colors[1]') link
div
.col-lg-6
.form-group
label Font
.row
.col-8
input.form-control(
type='text',
[ngbTypeahead]='fontAutocomplete',
'[(ngModel)]'='config.store.terminal.font',
(ngModelChange)='config.save()',
)
.col-4
input.form-control(
type='number',
'[(ngModel)]'='config.store.terminal.fontSize',
(ngModelChange)='config.save()',
)
small.form-text.text-muted Font to be used in the terminal
.form-group
label Color scheme
select.form-control(
[compareWith]='equalComparator',
'[(ngModel)]'='config.store.terminal.colorScheme',
(ngModelChange)='config.save()',
)
option(*ngFor='let scheme of colorSchemes', [ngValue]='scheme') {{scheme.name}}
div(*ngIf='config.store.terminal.colorScheme.colors')
color-picker(
'[(model)]'='config.store.terminal.colorScheme.foreground',
(modelChange)='config.save()',
title='FG',
)
color-picker(
'[(model)]'='config.store.terminal.colorScheme.background',
(modelChange)='config.save()',
title='BG',
)
color-picker(
'[(model)]'='config.store.terminal.cursor.background',
(modelChange)='config.save()',
title='CU',
)
color-picker(
*ngFor='let _ of config.store.terminal.colorScheme.colors; let idx = index',
'[(model)]'='config.store.terminal.colorScheme.colors[idx]',
(modelChange)='config.save()',
[title]='idx',
)
.form-group
label Terminal background
br
div(
'[(ngModel)]'='config.store.terminal.background',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"theme"'
)
| From the app theme
label.btn.btn-secondary
input(
type='radio',
[value]='"colorScheme"'
)
| From the terminal colors
.form-group
label Terminal bell
br
div(
'[(ngModel)]'='config.store.terminal.bell',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"off"'
)
| Off
label.btn.btn-secondary
input(
type='radio',
[value]='"visual"'
)
| Visual
label.btn.btn-secondary
input(
type='radio',
[value]='"audible"'
)
| Audible

View File

@@ -0,0 +1,7 @@
.appearance-preview {
padding: 10px 20px;
margin: 0 0 10px;
span {
white-space: pre;
}
}

View File

@@ -0,0 +1,49 @@
import { Observable } from 'rxjs/Observable'
import 'rxjs/add/operator/map'
import 'rxjs/add/operator/debounceTime'
import 'rxjs/add/operator/distinctUntilChanged'
const equal = require('deep-equal')
import { Component, Inject } from '@angular/core'
import { ConfigService } from 'terminus-core'
const { exec } = require('child-process-promise')
import { TerminalColorSchemeProvider, ITerminalColorScheme } from '../api'
@Component({
template: require('./settings.pug'),
styles: [require('./settings.scss')],
})
export class SettingsComponent {
fonts: string[] = []
colorSchemes: ITerminalColorScheme[] = []
equalComparator = equal
constructor(
public config: ConfigService,
@Inject(TerminalColorSchemeProvider) private colorSchemeProviders: TerminalColorSchemeProvider[],
) { }
async ngOnInit () {
exec('fc-list :spacing=mono').then((result) => {
this.fonts = result.stdout
.split('\n')
.filter((x) => !!x)
.map((x) => x.split(':')[1].trim())
.map((x) => x.split(',')[0].trim())
this.fonts.sort()
})
this.colorSchemes = (await Promise.all(this.colorSchemeProviders.map(x => x.getSchemes()))).reduce((a, b) => a.concat(b))
}
fontAutocomplete = (text$: Observable<string>) => {
return text$
.debounceTime(200)
.distinctUntilChanged()
.map(query => this.fonts.filter(v => new RegExp(query, 'gi').test(v)))
.map(list => Array.from(new Set(list)))
}
}

View File

@@ -0,0 +1,15 @@
a {
cursor: pointer;
}
a:hover {
text-decoration: underline;
}
* {
font-feature-settings: "liga" 0; // disable ligatures (they break monospacing)
}
x-screen {
transition: 0.125s ease background;
}

View File

@@ -0,0 +1,18 @@
:host {
flex: auto;
display: flex;
overflow: hidden;
&> .content {
flex: auto;
position: relative;
display: block;
overflow: hidden;
margin: 15px;
div[style]:last-child {
background: black !important;
color: white !important;
}
}
}

View File

@@ -0,0 +1,230 @@
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'
import { Component, NgZone, Inject, ViewChild, HostBinding, Input } from '@angular/core'
import { AppService, ConfigService, BaseTabComponent } from 'terminus-core'
import { TerminalDecorator, ResizeEvent } from '../api'
import { Session } from '../services/sessions'
import { hterm, preferenceManager } from '../hterm'
@Component({
selector: 'terminalTab',
template: '<div #content class="content"></div>',
styles: [require('./terminalTab.scss')],
})
export class TerminalTabComponent extends BaseTabComponent {
hterm: any
configSubscription: Subscription
focusedSubscription: Subscription
bell$ = new Subject()
size$ = new ReplaySubject<ResizeEvent>(1)
input$ = new Subject<string>()
output$ = new Subject<string>()
contentUpdated$ = new Subject<void>()
alternateScreenActive$ = new BehaviorSubject(false)
mouseEvent$ = new Subject<Event>()
@Input() session: Session
@ViewChild('content') content
@HostBinding('style.background-color') backgroundColor: string
private io: any
constructor(
private zone: NgZone,
private app: AppService,
public config: ConfigService,
@Inject(TerminalDecorator) private decorators: TerminalDecorator[],
) {
super()
this.configSubscription = config.change.subscribe(() => {
this.configure()
})
}
getRecoveryToken (): any {
return {
type: 'app:terminal',
recoveryId: this.session.recoveryId,
}
}
ngOnInit () {
this.focusedSubscription = this.focused.subscribe(() => {
this.hterm.scrollPort_.focus()
})
this.hterm = new hterm.hterm.Terminal()
this.decorators.forEach((decorator) => {
decorator.attach(this)
})
this.attachHTermHandlers(this.hterm)
this.hterm.onTerminalReady = () => {
this.hterm.installKeyboard()
this.io = this.hterm.io.push()
this.attachIOHandlers(this.io)
this.session.output$.subscribe((data) => {
this.zone.run(() => {
this.output$.next(data)
})
this.write(data)
})
this.session.closed$.first().subscribe(() => {
this.app.closeTab(this)
})
this.session.releaseInitialDataBuffer()
}
this.hterm.decorate(this.content.nativeElement)
this.configure()
setTimeout(() => {
this.output$.subscribe(() => {
this.displayActivity()
})
}, 1000)
this.bell$.subscribe(() => {
if (this.config.full().terminal.bell != 'off') {
let bg = preferenceManager.get('background-color')
preferenceManager.set('background-color', 'rgba(128,128,128,.25)')
setTimeout(() => {
preferenceManager.set('background-color', bg)
}, 125)
}
// TODO audible
})
}
attachHTermHandlers (hterm: any) {
hterm.setWindowTitle = (title) => {
this.zone.run(() => {
this.title$.next(title)
})
}
const _decorate = hterm.scrollPort_.decorate.bind(hterm.scrollPort_)
hterm.scrollPort_.decorate = (...args) => {
_decorate(...args)
hterm.scrollPort_.screen_.style.cssText += `; padding-right: ${hterm.scrollPort_.screen_.offsetWidth - hterm.scrollPort_.screen_.clientWidth}px;`
}
const _setAlternateMode = hterm.setAlternateMode.bind(hterm)
hterm.setAlternateMode = (state) => {
_setAlternateMode(state)
this.alternateScreenActive$.next(state)
}
const _onPaste_ = hterm.scrollPort_.onPaste_.bind(hterm.scrollPort_)
hterm.scrollPort_.onPaste_ = (event) => {
hterm.scrollPort_.pasteTarget_.value = event.clipboardData.getData('text/plain').trim()
_onPaste_()
event.preventDefault()
}
const _onMouse_ = hterm.onMouse_.bind(hterm)
hterm.onMouse_ = (event) => {
this.mouseEvent$.next(event)
if ((event.ctrlKey || event.metaKey) && event.type === 'mousewheel') {
event.preventDefault()
let delta = Math.round(event.wheelDeltaY / 50)
this.sendInput(((delta > 0) ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta)))
}
_onMouse_(event)
}
hterm.ringBell = () => {
this.bell$.next()
}
for (let screen of [hterm.primaryScreen_, hterm.alternateScreen_]) {
const _insertString = screen.insertString.bind(screen)
screen.insertString = (data) => {
_insertString(data)
this.contentUpdated$.next()
}
const _deleteChars = screen.deleteChars.bind(screen)
screen.deleteChars = (count) => {
let ret = _deleteChars(count)
this.contentUpdated$.next()
return ret
}
}
}
attachIOHandlers (io: any) {
io.onVTKeystroke = io.sendString = (data) => {
this.sendInput(data)
this.zone.run(() => {
this.input$.next(data)
})
}
io.onTerminalResize = (columns, rows) => {
// console.log(`Resizing to ${columns}x${rows}`)
this.zone.run(() => {
this.session.resize(columns, rows)
this.size$.next({ width: columns, height: rows })
})
}
}
sendInput (data: string) {
this.session.write(data)
}
write (data: string) {
this.io.writeUTF8(data)
}
async configure (): Promise<void> {
let config = this.config.full()
preferenceManager.set('font-family', config.terminal.font)
preferenceManager.set('font-size', config.terminal.fontSize)
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')
if (config.terminal.colorScheme.foreground) {
preferenceManager.set('foreground-color', config.terminal.colorScheme.foreground)
}
if (config.terminal.background == 'colorScheme') {
if (config.terminal.colorScheme.background) {
this.backgroundColor = config.terminal.colorScheme.background
preferenceManager.set('background-color', config.terminal.colorScheme.background)
}
} else {
this.backgroundColor = null
// hterm can't parse "transparent"
preferenceManager.set('background-color', 'rgba(0,0,0,0)')
}
if (config.terminal.colorScheme.colors) {
preferenceManager.set('color-palette-overrides', config.terminal.colorScheme.colors)
}
if (config.terminal.colorScheme.cursor) {
preferenceManager.set('cursor-color', config.terminal.colorScheme.cursor)
}
this.hterm.setBracketedPaste(config.terminal.bracketedPaste)
}
ngOnDestroy () {
this.decorators.forEach((decorator) => {
decorator.detach(this)
})
this.focusedSubscription.unsubscribe()
this.configSubscription.unsubscribe()
this.title$.complete()
this.size$.complete()
this.input$.complete()
this.output$.complete()
this.contentUpdated$.complete()
this.alternateScreenActive$.complete()
this.mouseEvent$.complete()
this.bell$.complete()
this.session.gracefullyDestroy()
}
}

View File

@@ -0,0 +1,33 @@
import { ConfigProvider } from 'terminus-core'
export class TerminalConfigProvider extends ConfigProvider {
defaultConfigValues: any = {
terminal: {
font: 'monospace',
fontSize: 14,
bell: 'off',
bracketedPaste: true,
background: 'theme',
colorScheme: {
foreground: null,
background: null,
colors: null,
},
},
hotkeys: {
'new-tab': [
['Ctrl-A', 'C'],
['Ctrl-A', 'Ctrl-C'],
'Ctrl-Shift-T',
]
},
}
configStructure: any = {
terminal: {
colorScheme: {},
},
hotkeys: {},
}
}

View File

@@ -0,0 +1,30 @@
const dataurl = require('dataurl')
export const hterm = require('hterm-commonjs')
hterm.hterm.defaultStorage = new hterm.lib.Storage.Memory()
export const preferenceManager = new hterm.hterm.PreferenceManager('default')
hterm.hterm.VT.ESC['k'] = function(parseState) {
parseState.resetArguments();
function parseOSC(ps) {
if (!this.parseUntilStringTerminator_(ps) || ps.func == parseOSC) {
return
}
this.terminal.setWindowTitle(ps.args[0])
}
parseState.func = parseOSC
}
preferenceManager.set('user-css', dataurl.convert({
data: require('./components/terminal.userCSS.scss'),
mimetype: 'text/css',
charset: 'utf8',
}))
preferenceManager.set('background-color', '#1D272D')
preferenceManager.set('color-palette-overrides', {
0: '#1D272D',
})
hterm.hterm.Terminal.prototype.showOverlay = () => null

View File

@@ -0,0 +1,78 @@
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { FormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService } from 'terminus-core'
import { SettingsTabProvider } from 'terminus-settings'
import { TerminalTabComponent } from './components/terminalTab'
import { SettingsComponent } from './components/settings'
import { ColorPickerComponent } from './components/colorPicker'
import { SessionsService } from './services/sessions'
import { ScreenPersistenceProvider } from './persistenceProviders'
import { ButtonProvider } from './buttonProvider'
import { RecoveryProvider } from './recoveryProvider'
import { SessionPersistenceProvider, TerminalColorSchemeProvider } from './api'
import { TerminalSettingsProvider } from './settings'
import { TerminalConfigProvider } from './config'
import { HyperColorSchemes } from './colorSchemes'
import { hterm } from './hterm'
@NgModule({
imports: [
BrowserModule,
FormsModule,
NgbModule,
],
providers: [
SessionsService,
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
{ provide: SessionPersistenceProvider, useClass: ScreenPersistenceProvider },
// { provide: SessionPersistenceProvider, useValue: null },
{ provide: SettingsTabProvider, useClass: TerminalSettingsProvider, multi: true },
{ provide: ConfigProvider, useClass: TerminalConfigProvider, multi: true },
{ provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true },
],
entryComponents: [
TerminalTabComponent,
SettingsComponent,
],
declarations: [
ColorPickerComponent,
TerminalTabComponent,
SettingsComponent,
],
})
export default class TerminalModule {
constructor (hotkeys: HotkeysService) {
let events = [
{
name: 'keydown',
htermHandler: 'onKeyDown_',
},
{
name: 'keyup',
htermHandler: 'onKeyUp_',
},
]
events.forEach((event) => {
let oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler]
hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) {
hotkeys.pushKeystroke(event.name, nativeEvent)
if (hotkeys.getCurrentPartiallyMatchedHotkeys().length == 0) {
oldHandler.bind(this)(nativeEvent)
} else {
nativeEvent.stopPropagation()
nativeEvent.preventDefault()
}
hotkeys.processKeystrokes()
hotkeys.emitKeyEvent(nativeEvent)
}
})
}
}
export * from './api'

View File

@@ -0,0 +1,40 @@
{
"name": "terminus-terminal",
"version": "0.0.1",
"description": "Terminus' terminal emulation core",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"build": "webpack --progress --color",
"watch": "webpack --progress --color --watch"
},
"author": "Eugene Pankov",
"license": "MIT",
"devDependencies": {
"@types/deep-equal": "1.0.0",
"@types/node": "7.0.12",
"@types/webpack-env": "1.13.0",
"awesome-typescript-loader": "3.1.2",
"css-loader": "^0.28.0",
"pug": "^2.0.0-beta11",
"pug-loader": "^2.3.0",
"raw-loader": "^0.5.1",
"sass-loader": "^6.0.3",
"style-loader": "^0.16.1",
"webpack": "2.3.3"
},
"dependencies": {
"@angular/common": "4.0.1",
"@angular/core": "4.0.1",
"@angular/forms": "4.0.1",
"@angular/platform-browser": "4.0.1",
"@ng-bootstrap/ng-bootstrap": "1.0.0-alpha.22",
"child-process-promise": "2.2.1",
"dataurl": "0.1.0",
"deep-equal": "1.0.1",
"fs-promise": "2.0.2",
"hterm-commonjs": "1.0.0",
"node-pty": "0.6.2",
"rxjs": "5.3.0"
}
}

View File

@@ -0,0 +1,56 @@
import * as fs from 'fs-promise'
const { exec, spawn } = require('child-process-promise')
import { SessionOptions, SessionPersistenceProvider } from './api'
export class ScreenPersistenceProvider extends SessionPersistenceProvider {
async attachSession (recoveryId: any): Promise<SessionOptions> {
let lines = (await exec('screen -list')).stdout.split('\n')
let screenPID = lines
.filter(line => line.indexOf('.' + recoveryId) !== -1)
.map(line => parseInt(line.trim().split('.')[0]))[0]
if (!screenPID) {
return null
}
lines = (await exec(`ps -o pid --ppid ${screenPID}`)).stdout.split('\n')
let recoveredTruePID = parseInt(lines[1].split(/\s/).filter(x => !!x)[0])
return {
recoveryId,
recoveredTruePID,
command: 'screen',
args: ['-r', recoveryId],
}
}
async startSession (options: SessionOptions): Promise<any> {
let configPath = '/tmp/.termScreenConfig'
await fs.writeFile(configPath, `
escape ^^^
vbell off
term xterm-color
bindkey "^[OH" beginning-of-line
bindkey "^[OF" end-of-line
bindkey "\\027[?1049h" stuff ----alternate enter-----
bindkey "\\027[?1049l" stuff ----alternate leave-----
termcapinfo xterm* 'hs:ts=\\E]0;:fs=\\007:ds=\\E]0;\\007'
defhstatus "^Et"
hardstatus off
altscreen on
`, 'utf-8')
let recoveryId = `term-tab-${Date.now()}`
let args = ['-d', '-m', '-c', configPath, '-U', '-S', recoveryId, '--', options.command].concat(options.args || [])
await spawn('screen', args, {
cwd: options.cwd,
env: options.env || process.env,
})
return recoveryId
}
async terminateSession (recoveryId: string): Promise<void> {
await exec(`screen -S ${recoveryId} -X quit`)
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core'
import { TabRecoveryProvider, AppService } from 'terminus-core'
import { SessionsService } from './services/sessions'
import { TerminalTabComponent } from './components/terminalTab'
@Injectable()
export class RecoveryProvider extends TabRecoveryProvider {
constructor (
private sessions: SessionsService,
private app: AppService,
) {
super()
}
async recover (recoveryToken: any): Promise<void> {
if (recoveryToken.type == 'app:terminal') {
let session = await this.sessions.recover(recoveryToken.recoveryId)
if (!session) {
return
}
this.app.openNewTab(TerminalTabComponent, { session })
}
}
}

View File

@@ -0,0 +1,166 @@
import * as nodePTY from 'node-pty'
import * as fs from 'fs-promise'
import { Subject } from 'rxjs'
import { Injectable } from '@angular/core'
import { Logger, LogService } from 'terminus-core'
import { SessionOptions, SessionPersistenceProvider } from '../api'
export class Session {
open: boolean
name: string
output$ = new Subject<string>()
closed$ = new Subject<void>()
destroyed$ = new Subject<void>()
recoveryId: string
truePID: number
private pty: any
private initialDataBuffer = ''
private initialDataBufferReleased = false
constructor (options: SessionOptions) {
this.name = options.name
this.recoveryId = options.recoveryId
console.log('Spawning', options.command)
let env = {
...process.env,
...options.env,
TERM: 'xterm-256color',
}
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',
cols: 80,
rows: 30,
cwd: options.cwd || process.env.HOME,
env: env,
})
this.truePID = options.recoveredTruePID || (<any>this.pty).pid
this.open = true
this.pty.on('data', (data) => {
if (!this.initialDataBufferReleased) {
this.initialDataBuffer += data
} else {
this.output$.next(data)
}
})
this.pty.on('close', () => {
this.close()
})
}
releaseInitialDataBuffer () {
this.initialDataBufferReleased = true
this.output$.next(this.initialDataBuffer)
this.initialDataBuffer = null
}
resize (columns, rows) {
this.pty.resize(columns, rows)
}
write (data) {
this.pty.write(data)
}
sendSignal (signal) {
this.pty.kill(signal)
}
close () {
this.open = false
this.closed$.next()
this.pty.end()
}
gracefullyDestroy () {
return new Promise((resolve) => {
this.sendSignal('SIGTERM')
if (!this.open) {
resolve()
this.destroy()
} else {
setTimeout(() => {
if (this.open) {
this.sendSignal('SIGKILL')
this.destroy()
}
resolve()
}, 1000)
}
})
}
destroy () {
if (open) {
this.close()
}
this.destroyed$.next()
this.pty.destroy()
this.output$.complete()
}
async getWorkingDirectory (): Promise<string> {
return await fs.readlink(`/proc/${this.truePID}/cwd`)
}
}
@Injectable()
export class SessionsService {
sessions: {[id: string]: Session} = {}
logger: Logger
private lastID = 0
constructor(
private persistence: SessionPersistenceProvider,
log: LogService,
) {
this.logger = log.create('sessions')
}
async createNewSession (options: SessionOptions) : Promise<Session> {
if (this.persistence) {
let recoveryId = await this.persistence.startSession(options)
options = await this.persistence.attachSession(recoveryId)
}
let session = this.addSession(options)
return session
}
addSession (options: SessionOptions) : Session {
this.lastID++
options.name = `session-${this.lastID}`
let session = new Session(options)
session.destroyed$.first().subscribe(() => {
delete this.sessions[session.name]
if (this.persistence) {
this.persistence.terminateSession(session.recoveryId)
}
})
this.sessions[session.name] = session
return session
}
async recover (recoveryId: string) : Promise<Session> {
if (!this.persistence) {
return null
}
const options = await this.persistence.attachSession(recoveryId)
if (!options) {
return null
}
return this.addSession(options)
}
}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@angular/core'
import { SettingsTabProvider, ComponentType } from 'terminus-settings'
import { SettingsComponent } from './components/settings'
@Injectable()
export class TerminalSettingsProvider extends SettingsTabProvider {
title = 'Terminal'
getComponentType (): ComponentType {
return SettingsComponent
}
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "commonjs",
"target": "es5",
"declaration": false,
"noImplicitAny": false,
"removeComments": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"declaration": true,
"declarationDir": "dist",
"lib": [
"dom",
"es2015",
"es7"
],
"paths": {
"terminus-*": ["../terminus-*"]
}
},
"compileOnSave": false,
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,35 @@
module.exports = {
target: 'node',
entry: 'index.ts',
devtool: 'source-map',
output: {
filename: './dist/index.js',
pathinfo: true,
library: 'terminusTerminal',
libraryTarget: 'umd',
},
resolve: {
modules: ['.', 'node_modules', '..'],
extensions: ['.ts', '.js'],
},
module: {
loaders: [
{ test: /\.ts$/, use: 'awesome-typescript-loader' },
{ test: /schemes\/.*$/, use: "raw-loader" },
{ test: /\.pug$/, use: ['apply-loader', 'pug-loader'] },
{ test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
{ test: /\.css$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
]
},
externals: {
'fs': true,
'fs-promise': true,
'path': true,
'node-pty': true,
'child-process-promise': true,
'fs-promise': true,
'@angular/core': true,
'terminus-core': true,
'terminus-settings': true,
}
}