mirror of
https://github.com/Eugeny/tabby.git
synced 2025-10-04 22:14:55 +00:00
done
This commit is contained in:
40
terminus-terminal/src/api.ts
Normal file
40
terminus-terminal/src/api.ts
Normal 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[]>
|
||||
}
|
49
terminus-terminal/src/buttonProvider.ts
Normal file
49
terminus-terminal/src/buttonProvider.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HotkeysService, ToolbarButtonProvider, IToolbarButton, AppService, HostAppService, Platform } 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,
|
||||
private hostApp: HostAppService,
|
||||
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()
|
||||
}
|
||||
let command = {
|
||||
[Platform.macOS]: 'zsh',
|
||||
[Platform.Linux]: 'zsh',
|
||||
[Platform.Windows]: 'cmd.exe',
|
||||
}[this.hostApp.platform]
|
||||
this.app.openNewTab(
|
||||
TerminalTabComponent,
|
||||
{ session: await this.sessions.createNewSession({ command, cwd }) }
|
||||
)
|
||||
}
|
||||
|
||||
provide (): IToolbarButton[] {
|
||||
return [{
|
||||
icon: 'plus',
|
||||
title: 'New terminal',
|
||||
click: async () => {
|
||||
this.openNewTab()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
51
terminus-terminal/src/colorSchemes.ts
Normal file
51
terminus-terminal/src/colorSchemes.ts
Normal 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
|
||||
}
|
||||
}
|
14
terminus-terminal/src/components/colorPicker.pug
Normal file
14
terminus-terminal/src/components/colorPicker.pug
Normal 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 }}
|
15
terminus-terminal/src/components/colorPicker.scss
Normal file
15
terminus-terminal/src/components/colorPicker.scss
Normal 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);
|
||||
}
|
40
terminus-terminal/src/components/colorPicker.ts
Normal file
40
terminus-terminal/src/components/colorPicker.ts
Normal 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)
|
||||
}
|
||||
}
|
151
terminus-terminal/src/components/settings.pug
Normal file
151
terminus-terminal/src/components/settings.pug
Normal file
@@ -0,0 +1,151 @@
|
||||
.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
|
||||
span
|
||||
div
|
||||
span john@doe-pc
|
||||
span([style.color]='config.full().terminal.colorScheme.colors[1]') $
|
||||
span rm -rf /
|
||||
span([style.background-color]='config.full().terminal.colorScheme.cursor')
|
||||
|
||||
|
||||
.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.colorScheme.cursor',
|
||||
(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
|
7
terminus-terminal/src/components/settings.scss
Normal file
7
terminus-terminal/src/components/settings.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.appearance-preview {
|
||||
padding: 10px 20px;
|
||||
margin: 0 0 10px;
|
||||
span {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
58
terminus-terminal/src/components/settings.ts
Normal file
58
terminus-terminal/src/components/settings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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')
|
||||
const fontManager = require('font-manager')
|
||||
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ConfigService, HostAppService, Platform } 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,
|
||||
private hostApp: HostAppService,
|
||||
@Inject(TerminalColorSchemeProvider) private colorSchemeProviders: TerminalColorSchemeProvider[],
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
if (this.hostApp.platform == Platform.Windows) {
|
||||
let fonts = await new Promise<any[]>((resolve) => fontManager.findFonts({ monospace: true }, resolve))
|
||||
this.fonts = fonts.map(x => x.family)
|
||||
this.fonts.sort()
|
||||
}
|
||||
if (this.hostApp.platform == Platform.Linux) {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
}
|
15
terminus-terminal/src/components/terminal.userCSS.scss
Normal file
15
terminus-terminal/src/components/terminal.userCSS.scss
Normal 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;
|
||||
}
|
18
terminus-terminal/src/components/terminalTab.scss
Normal file
18
terminus-terminal/src/components/terminalTab.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
230
terminus-terminal/src/components/terminalTab.ts
Normal file
230
terminus-terminal/src/components/terminalTab.ts
Normal 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()
|
||||
}
|
||||
}
|
34
terminus-terminal/src/config.ts
Normal file
34
terminus-terminal/src/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
cursor: null,
|
||||
colors: [],
|
||||
},
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
['Ctrl-A', 'C'],
|
||||
['Ctrl-A', 'Ctrl-C'],
|
||||
'Ctrl-Shift-T',
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
configStructure: any = {
|
||||
terminal: {
|
||||
colorScheme: {},
|
||||
},
|
||||
hotkeys: {},
|
||||
}
|
||||
}
|
30
terminus-terminal/src/hterm.ts
Normal file
30
terminus-terminal/src/hterm.ts
Normal 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
|
88
terminus-terminal/src/index.ts
Normal file
88
terminus-terminal/src/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
import { HostAppService, Platform, 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,
|
||||
ScreenPersistenceProvider,
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
|
||||
{
|
||||
provide: SessionPersistenceProvider,
|
||||
useFactory: (hostApp: HostAppService, screen: ScreenPersistenceProvider) => {
|
||||
if (hostApp.platform == Platform.Windows) {
|
||||
return null
|
||||
} else {
|
||||
return screen
|
||||
}
|
||||
},
|
||||
deps: [HostAppService, ScreenPersistenceProvider],
|
||||
},
|
||||
{ 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'
|
61
terminus-terminal/src/persistenceProviders.ts
Normal file
61
terminus-terminal/src/persistenceProviders.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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: string[]
|
||||
try {
|
||||
lines = (await exec('screen -list')).stdout.split('\n')
|
||||
} catch (result) {
|
||||
lines = result.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(`pgrep -P ${screenPID}`)).stdout.split('\n')
|
||||
let recoveredTruePID = parseInt(lines[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 on
|
||||
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`)
|
||||
}
|
||||
}
|
26
terminus-terminal/src/recoveryProvider.ts
Normal file
26
terminus-terminal/src/recoveryProvider.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
174
terminus-terminal/src/services/sessions.ts
Normal file
174
terminus-terminal/src/services/sessions.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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'
|
||||
const { exec } = require('child-process-promise')
|
||||
|
||||
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> {
|
||||
if (process.platform == 'darwin') {
|
||||
let lines = (await exec(`lsof -p ${this.truePID} -Fn`)).split('\n')
|
||||
return lines[2]
|
||||
}
|
||||
if (process.platform == 'linux') {
|
||||
return await fs.readlink(`/proc/${this.truePID}/cwd`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
14
terminus-terminal/src/settings.ts
Normal file
14
terminus-terminal/src/settings.ts
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user