Compare commits

...

12 Commits

Author SHA1 Message Date
Eugene Pankov
0755ff291d docs 2017-07-28 09:18:37 +02:00
Eugene Pankov
c0c2b693f3 autoselect tmux 2017-07-27 12:54:51 +02:00
Eugene Pankov
23dabca2ab . 2017-07-26 19:59:05 +02:00
Eugene Pankov
98a5a95bec wip 2017-07-26 19:29:47 +02:00
Eugene Pankov
5045c4c82a wip 2017-07-26 16:14:18 +02:00
Eugene Pankov
feb4c5bcb6 tmux wip 2017-07-26 16:04:55 +02:00
Eugene Pankov
de29e34363 possible fix for docking sizing (#9) 2017-07-25 21:49:20 +02:00
Eugene Pankov
0fe2de591a cursor shape setting (fixes #55) 2017-07-25 19:36:28 +02:00
Eugene Pankov
9bee253dd0 reapply screen config on reattach 2017-07-24 16:04:17 +02:00
Eugene Pankov
a26b38f5ae . 2017-07-24 15:34:30 +02:00
Eugene Pankov
9312db1fc6 fixed #126 2017-07-24 14:48:16 +02:00
Eugene Pankov
932ed9b8f2 proper visual bell (fixes #131) 2017-07-23 20:55:41 +02:00
33 changed files with 496 additions and 100 deletions

View File

@@ -13,12 +13,12 @@
* Theming and color schemes
* Configurable hotkey schemes
* **GNU Screen** style hotkeys available by default
* Default Linux style hotkeys for Copy(`Ctrl`+`Shift`+`C`), and Paste(`Ctrl`+`Shift`+`V`)
* Full Unicode support including double-width characters
* Doesn't choke on fast-flowing outputs
* Tab persistence on macOS and Linux
* Proper shell-like experience on Windows including tab completion (thanks, Clink!)
* CMD, PowerShell, Cygwin, Git-Bash and Bash on Windows support
* Default Linux style hotkeys for copy (`Ctrl`+`Shift`+`C`) and paste (`Ctrl`+`Shift`+`V`)
---

View File

@@ -76,6 +76,8 @@ setupWindowManagement = () => {
electron.ipcMain.on('window-set-bounds', (event, bounds) => {
let actualBounds = app.window.getBounds()
actualBounds.width -= bounds.x - actualBounds.x
actualBounds.height -= bounds.y - actualBounds.y
actualBounds.x = bounds.x
actualBounds.y = bounds.y
app.window.setBounds(actualBounds)

View File

@@ -31,3 +31,7 @@ process.on('uncaughtException', (err) => {
Raven.captureException(err)
console.error(err)
})
const childProcess = require('child_process')
childProcess.spawn = require('electron').remote.require('child_process').spawn
childProcess.exec = require('electron').remote.require('child_process').exec

View File

@@ -55,6 +55,7 @@ module.exports = {
'@angular/forms': 'commonjs @angular/forms',
'@angular/common': 'commonjs @angular/common',
'@ng-bootstrap/ng-bootstrap': 'commonjs @ng-bootstrap/ng-bootstrap',
'child_process': 'commonjs child_process',
'electron': 'commonjs electron',
'electron-is-dev': 'commonjs electron-is-dev',
'module': 'commonjs module',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -1,5 +1,5 @@
export { BaseTabComponent } from '../components/baseTab.component'
export { TabRecoveryProvider } from './tabRecovery'
export { TabRecoveryProvider, RecoveredTab } from './tabRecovery'
export { ToolbarButtonProvider, IToolbarButton } from './toolbarButtonProvider'
export { ConfigProvider } from './configProvider'
export { HotkeyProvider, IHotkeyDescription } from './hotkeyProvider'

View File

@@ -1,3 +1,10 @@
export abstract class TabRecoveryProvider {
abstract async recover (recoveryToken: any): Promise<void>
import { TabComponentType } from '../services/app.service'
export interface RecoveredTab {
type: TabComponentType,
options?: any,
}
export abstract class TabRecoveryProvider {
abstract async recover (recoveryToken: any): Promise<RecoveredTab|null>
}

View File

@@ -19,7 +19,7 @@ title-bar(
[class.drag-region]='hostApp.platform == Platform.macOS',
@animateTab,
(click)='app.selectTab(tab)',
(closeClicked)='app.closeTab(tab)',
(closeClicked)='app.closeTab(tab, true)',
)
.btn-group

View File

@@ -79,7 +79,7 @@ export class AppRootComponent {
}
if (this.app.activeTab) {
if (hotkey === 'close-tab') {
this.app.closeTab(this.app.activeTab)
this.app.closeTab(this.app.activeTab, true)
}
if (hotkey === 'toggle-last-tab') {
this.app.toggleLastTab()
@@ -138,16 +138,6 @@ export class AppRootComponent {
}
}
private getToolbarButtons (aboveZero: boolean): IToolbarButton[] {
let buttons: IToolbarButton[] = []
this.toolbarButtonProviders.forEach((provider) => {
buttons = buttons.concat(provider.provide())
})
return buttons
.filter((button) => (button.weight > 0) === aboveZero)
.sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0))
}
@HostListener('dragover')
onDragOver () {
return false
@@ -157,4 +147,14 @@ export class AppRootComponent {
onDrop () {
return false
}
private getToolbarButtons (aboveZero: boolean): IToolbarButton[] {
let buttons: IToolbarButton[] = []
this.toolbarButtonProviders.forEach((provider) => {
buttons = buttons.concat(provider.provide())
})
return buttons
.filter((button) => (button.weight > 0) === aboveZero)
.sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0))
}
}

View File

@@ -31,6 +31,10 @@ export abstract class BaseTabComponent {
return null
}
async canClose (): Promise<boolean> {
return true
}
destroy (): void {
this.focused$.complete()
this.blurred$.complete()

View File

@@ -1,3 +1,3 @@
.index {{index + 1}}
.name {{tab.customTitle || tab.title}}
.name([title]='tab.customTitle || tab.title') {{tab.customTitle || tab.title}}
button((click)='closeClicked.emit()') &times;

View File

@@ -82,10 +82,16 @@ export class AppService {
}
}
closeTab (tab: BaseTabComponent) {
async closeTab (tab: BaseTabComponent, checkCanClose?: boolean): Promise<void> {
if (!this.tabs.includes(tab)) {
return
}
if (checkCanClose && !await tab.canClose()) {
return
}
this.tabs = this.tabs.filter((x) => x !== tab)
tab.destroy()
let newIndex = Math.max(0, this.tabs.indexOf(tab) - 1)
this.tabs = this.tabs.filter((x) => x !== tab)
if (tab === this.activeTab) {
this.selectTab(this.tabs[newIndex])
}

View File

@@ -40,12 +40,12 @@ export class DockingService {
newBounds.height = Math.round(fill * display.bounds.height)
}
if (dockSide === 'right') {
newBounds.x = display.bounds.x + Math.round(display.bounds.width * (1.0 - fill))
newBounds.x = display.bounds.x + display.bounds.width - newBounds.width
} else {
newBounds.x = display.bounds.x
}
if (dockSide === 'bottom') {
newBounds.y = display.bounds.y + Math.round(display.bounds.height * (1.0 - fill))
newBounds.y = display.bounds.y + display.bounds.height - newBounds.height
} else {
newBounds.y = display.bounds.y
}

View File

@@ -27,4 +27,8 @@ export class ElectronService {
remoteRequire (name: string): any {
return this.remote.require(name)
}
remoteRequirePluginModule (plugin: string, module: string, globals: any): any {
return this.remoteRequire(globals.require.resolve(`${plugin}/node_modules/${module}`))
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable, Inject } from '@angular/core'
import { TabRecoveryProvider } from '../api/tabRecovery'
import { TabRecoveryProvider, RecoveredTab } from '../api/tabRecovery'
import { BaseTabComponent } from '../components/baseTab.component'
import { Logger, LogService } from '../services/log.service'
import { AppService } from '../services/app.service'
@@ -10,7 +10,7 @@ export class TabRecoveryService {
constructor (
@Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[],
app: AppService,
private app: AppService,
log: LogService
) {
this.logger = log.create('tabRecovery')
@@ -29,15 +29,22 @@ export class TabRecoveryService {
async recoverTabs (): Promise<void> {
if (window.localStorage.tabsRecovery) {
let tabs: RecoveredTab[] = []
for (let token of JSON.parse(window.localStorage.tabsRecovery)) {
for (let provider of this.tabRecoveryProviders) {
try {
await provider.recover(token)
let tab = await provider.recover(token)
if (tab) {
tabs.push(tab)
}
} catch (error) {
this.logger.warn('Tab recovery crashed:', token, provider, error)
}
}
}
tabs.forEach(tab => {
this.app.openNewTab(tab.type, tab.options)
})
}
}

View File

@@ -1,19 +1,14 @@
import { Injectable } from '@angular/core'
import { TabRecoveryProvider, AppService } from 'terminus-core'
import { TabRecoveryProvider, RecoveredTab } from 'terminus-core'
import { SettingsTabComponent } from './components/settingsTab.component'
@Injectable()
export class RecoveryProvider extends TabRecoveryProvider {
constructor (
private app: AppService
) {
super()
}
async recover (recoveryToken: any): Promise<void> {
async recover (recoveryToken: any): Promise<RecoveredTab> {
if (recoveryToken.type === 'app:settings') {
this.app.openNewTab(SettingsTabComponent)
return { type: SettingsTabComponent }
}
return null
}
}

View File

@@ -37,10 +37,13 @@
"terminus-settings": "*"
},
"dependencies": {
"@types/async-lock": "0.0.19",
"async-lock": "^1.0.0",
"font-manager": "0.2.2",
"hterm-umdjs": "1.1.3",
"mz": "^2.6.0",
"node-pty": "0.6.8",
"ps-node": "^0.1.6",
"runes": "^0.4.2",
"winreg": "^1.2.3"
},

View File

@@ -1,6 +1,7 @@
import { Observable } from 'rxjs'
import { TerminalTabComponent } from './components/terminalTab.component'
export { TerminalTabComponent }
export { IChildProcess } from './services/sessions.service'
export abstract class TerminalDecorator {
// tslint:disable-next-line no-empty

Binary file not shown.

View File

@@ -174,26 +174,53 @@
[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 theme
label.btn.btn-secondary
input(
type='radio',
[value]='"colorScheme"'
)
| From colors
.d-flex
.form-group.mr-3
label Terminal background
br
div(
'[(ngModel)]'='config.store.terminal.background',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"theme"'
)
| From theme
label.btn.btn-secondary
input(
type='radio',
[value]='"colorScheme"'
)
| From colors
.form-group
label Cursor shape
br
div(
[(ngModel)]='config.store.terminal.cursor',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"block"'
)
| Block
label.btn.btn-secondary
input(
type='radio',
[value]='"underline"'
)
| Underline
label.btn.btn-secondary
input(
type='radio',
[value]='"beam"'
)
| Beam
.form-group
label Shell

View File

@@ -9,7 +9,7 @@
display: block;
overflow: hidden;
margin: 15px;
transition: opacity ease-out 0.1s;
transition: opacity ease-out 0.25s;
opacity: 0;
div[style]:last-child {

View File

@@ -1,4 +1,3 @@
const dataurl = require('dataurl')
import { BehaviorSubject, Subject, Subscription } from 'rxjs'
import 'rxjs/add/operator/bufferTime'
import { Component, NgZone, Inject, Optional, ViewChild, HostBinding, Input } from '@angular/core'
@@ -32,6 +31,7 @@ export class TerminalTabComponent extends BaseTabComponent {
alternateScreenActive$ = new BehaviorSubject(false)
mouseEvent$ = new Subject<Event>()
htermVisible = false
private bellPlayer: HTMLAudioElement
private io: any
constructor (
@@ -51,6 +51,9 @@ export class TerminalTabComponent extends BaseTabComponent {
this.session = this.sessions.addSession(
Object.assign({}, this.sessionOptions, resizeEvent)
)
setTimeout(() => {
this.session.resize(resizeEvent.width, resizeEvent.height)
}, 1000)
// this.session.output$.bufferTime(10).subscribe((datas) => {
this.session.output$.subscribe(data => {
// let data = datas.join('')
@@ -84,6 +87,8 @@ export class TerminalTabComponent extends BaseTabComponent {
this.resetZoom()
}
})
this.bellPlayer = document.createElement('audio')
this.bellPlayer.src = require<string>('../bell.ogg')
}
getRecoveryToken (): any {
@@ -126,13 +131,15 @@ export class TerminalTabComponent extends BaseTabComponent {
}, 1000)
this.bell$.subscribe(() => {
if (this.config.store.terminal.bell !== 'off') {
let bg = preferenceManager.get('background-color')
if (this.config.store.terminal.bell === 'visual') {
preferenceManager.set('background-color', 'rgba(128,128,128,.25)')
setTimeout(() => {
preferenceManager.set('background-color', bg)
this.configure()
}, 125)
}
if (this.config.store.terminal.bell === 'audible') {
this.bellPlayer.play()
}
// TODO audible
})
}
@@ -246,7 +253,7 @@ export class TerminalTabComponent extends BaseTabComponent {
preferenceManager.set('font-family', `"${config.terminal.font}", "monospace-fallback", monospace`)
this.setFontSize()
preferenceManager.set('enable-bold', true)
preferenceManager.set('audible-bell-sound', '')
// 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')
@@ -292,13 +299,14 @@ export class TerminalTabComponent extends BaseTabComponent {
`
}
css += config.appearance.css
preferenceManager.set('user-css', dataurl.convert({
data: css,
mimetype: 'text/css',
charset: 'utf8',
}))
this.hterm.setCSS(css)
this.hterm.setBracketedPaste(config.terminal.bracketedPaste)
this.hterm.defaultCursorShape = {
block: hterm.hterm.Terminal.cursorShape.BLOCK,
underline: hterm.hterm.Terminal.cursorShape.UNDERLINE,
beam: hterm.hterm.Terminal.cursorShape.BEAM,
}[config.terminal.cursor]
this.hterm.applyCursorShape()
}
zoomIn () {
@@ -340,6 +348,17 @@ export class TerminalTabComponent extends BaseTabComponent {
}
}
async canClose (): Promise<boolean> {
if (this.hostApp.platform === Platform.Windows) {
return true
}
let children = await this.session.getChildProcesses()
if (children.length === 0) {
return true
}
return confirm(`"${children[0].command}" is still running. Close?`)
}
private setFontSize () {
preferenceManager.set('font-size', this.config.store.terminal.fontSize * Math.pow(1.1, this.zoom))
}

View File

@@ -8,6 +8,7 @@ export class TerminalConfigProvider extends ConfigProvider {
bracketedPaste: false,
background: 'theme',
ligatures: false,
cursor: 'block',
colorScheme: {
__nonStructural: true,
name: 'Material',

View File

@@ -22,6 +22,16 @@ preferenceManager.set('color-palette-overrides', {
hterm.hterm.Terminal.prototype.showOverlay = () => null
hterm.hterm.Terminal.prototype.setCSS = function (css) {
const doc = this.scrollPort_.document_
if (!doc.querySelector('#user-css')) {
const node = doc.createElement('style')
node.id = 'user-css'
doc.head.appendChild(node)
}
doc.querySelector('#user-css').innerText = css
}
const oldCharWidthDisregardAmbiguous = hterm.lib.wc.charWidthDisregardAmbiguous
hterm.lib.wc.charWidthDisregardAmbiguous = codepoint => {
if ((codepoint >= 0x1f300 && codepoint <= 0x1f64f) ||
@@ -30,3 +40,36 @@ hterm.lib.wc.charWidthDisregardAmbiguous = codepoint => {
}
return oldCharWidthDisregardAmbiguous(codepoint)
}
hterm.hterm.Terminal.prototype.applyCursorShape = function () {
let modes = [
[hterm.hterm.Terminal.cursorShape.BLOCK, true],
[this.defaultCursorShape || hterm.hterm.Terminal.cursorShape.BLOCK, false],
[hterm.hterm.Terminal.cursorShape.BLOCK, false],
[hterm.hterm.Terminal.cursorShape.UNDERLINE, true],
[hterm.hterm.Terminal.cursorShape.UNDERLINE, false],
[hterm.hterm.Terminal.cursorShape.BEAM, true],
[hterm.hterm.Terminal.cursorShape.BEAM, false],
]
let modeNumber = this.cursorMode || 1
console.log('mode', modeNumber)
if (modeNumber >= modes.length) {
console.warn('Unknown cursor style: ' + modeNumber)
return
}
this.setCursorShape(modes[modeNumber][0])
this.setCursorBlink(modes[modeNumber][1])
}
hterm.hterm.VT.CSI[' q'] = function (parseState) {
const arg = parseState.args[0]
this.terminal.cursorMode = arg
this.terminal.applyCursorShape()
}
const _collapseToEnd = Selection.prototype.collapseToEnd
Selection.prototype.collapseToEnd = function () {
try {
_collapseToEnd.apply(this)
} catch (err) { ; }
}

View File

@@ -14,6 +14,7 @@ import { SessionsService } from './services/sessions.service'
import { ShellsService } from './services/shells.service'
import { ScreenPersistenceProvider } from './persistenceProviders'
import { TMuxPersistenceProvider } from './tmux'
import { ButtonProvider } from './buttonProvider'
import { RecoveryProvider } from './recoveryProvider'
import { SessionPersistenceProvider, TerminalColorSchemeProvider, TerminalDecorator } from './api'
@@ -34,18 +35,28 @@ import { hterm } from './hterm'
SessionsService,
ShellsService,
ScreenPersistenceProvider,
TMuxPersistenceProvider,
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
{
provide: SessionPersistenceProvider,
useFactory: (hostApp: HostAppService, screen: ScreenPersistenceProvider) => {
useFactory: (
hostApp: HostAppService,
screen: ScreenPersistenceProvider,
tmux: TMuxPersistenceProvider,
) => {
if (hostApp.platform === Platform.Windows) {
return null
} else {
return screen
if (tmux.isAvailable()) {
tmux.init()
return tmux
} else {
return screen
}
}
},
deps: [HostAppService, ScreenPersistenceProvider],
deps: [HostAppService, ScreenPersistenceProvider, TMuxPersistenceProvider],
},
{ provide: SettingsTabProvider, useClass: TerminalSettingsTabProvider, multi: true },
{ provide: ConfigProvider, useClass: TerminalConfigProvider, multi: true },

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'
import { TerminalDecorator } from './api'
import { TerminalTabComponent } from './components/terminalTab.component'
@Injectable()
export class PathDropDecorator extends TerminalDecorator {
attach (terminal: TerminalTabComponent): void {

View File

@@ -64,12 +64,13 @@ export class ScreenPersistenceProvider extends SessionPersistenceProvider {
recoveryId,
recoveredTruePID$: truePID$.asObservable(),
command: 'screen',
args: ['-r', recoveryId],
args: ['-d', '-r', recoveryId, '-c', await this.prepareConfig()],
}
}
async extractShellPID (screenPID: number): Promise<number> {
let child = (await listProcesses()).find(x => x.ppid === screenPID)
let processes = await listProcesses()
let child = processes.find(x => x.ppid === screenPID)
if (!child) {
throw new Error(`Could not find any children of the screen process (PID ${screenPID})!`)
@@ -77,33 +78,15 @@ export class ScreenPersistenceProvider extends SessionPersistenceProvider {
if (child.command === 'login') {
await delay(1000)
child = (await listProcesses()).find(x => x.ppid === child.pid)
child = processes.find(x => x.ppid === child.pid)
}
return child.pid
}
async startSession (options: SessionOptions): Promise<any> {
let configPath = '/tmp/.termScreenConfig'
await fs.writeFile(configPath, `
escape ^^^
vbell on
deflogin on
defflow 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
defutf8 on
defencoding utf8
`, 'utf-8')
let recoveryId = `term-tab-${Date.now()}`
let args = ['-d', '-m', '-c', configPath, '-U', '-S', recoveryId, '-T', 'xterm-256color', '--', '-' + options.command].concat(options.args || [])
let args = ['-d', '-m', '-c', await this.prepareConfig(), '-U', '-S', recoveryId, '-T', 'xterm-256color', '--', '-' + options.command].concat(options.args || [])
this.logger.debug('Spawning screen with', args.join(' '))
await spawn('screen', args, {
cwd: options.cwd,
@@ -119,4 +102,28 @@ export class ScreenPersistenceProvider extends SessionPersistenceProvider {
// screen has already quit
}
}
private async prepareConfig (): Promise<string> {
let configPath = '/tmp/.termScreenConfig'
await fs.writeFile(configPath, `
escape ^^^
vbell off
deflogin on
defflow off
term xterm-color
bindkey "^[OH" beginning-of-line
bindkey "^[OF" end-of-line
bindkey "^[[H" beginning-of-line
bindkey "^[[F" 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
defutf8 on
defencoding utf8
`, 'utf-8')
return configPath
}
}

View File

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

View File

@@ -1,12 +1,20 @@
import * as nodePTY from 'node-pty'
const psNode = require('ps-node')
// import * as nodePTY from 'node-pty'
let nodePTY
import * as fs from 'mz/fs'
import { Subject } from 'rxjs'
import { Injectable } from '@angular/core'
import { Logger, LogService } from 'terminus-core'
import { Logger, LogService, ElectronService } from 'terminus-core'
import { exec } from 'mz/child_process'
import { SessionOptions, SessionPersistenceProvider } from '../api'
export interface IChildProcess {
pid: number
ppid: number
command: string
}
export class Session {
open: boolean
name: string
@@ -101,6 +109,20 @@ export class Session {
this.pty.kill(signal)
}
async getChildProcesses (): Promise<IChildProcess[]> {
if (!this.truePID) {
return []
}
return new Promise<IChildProcess[]>((resolve, reject) => {
psNode.lookup({ ppid: this.truePID }, (err, processes) => {
if (err) {
return reject(err)
}
resolve(processes as IChildProcess[])
})
})
}
async gracefullyKillProcess (): Promise<void> {
if (process.platform === 'win32') {
this.kill()
@@ -157,8 +179,10 @@ export class SessionsService {
constructor (
private persistence: SessionPersistenceProvider,
electron: ElectronService,
log: LogService,
) {
nodePTY = electron.remoteRequirePluginModule('terminus-terminal', 'node-pty', global as any)
this.logger = log.create('sessions')
}

View File

@@ -0,0 +1,207 @@
import { Injectable } from '@angular/core'
import { execFileSync } from 'child_process'
import * as AsyncLock from 'async-lock'
import { ConnectableObservable, Subject } from 'rxjs'
import * as childProcess from 'child_process'
import { SessionOptions, SessionPersistenceProvider } from './api'
const TMUX_CONFIG = `
set -g status off
set -g focus-events on
set -g bell-action any
set -g bell-on-alert on
set -g visual-bell off
set -g set-titles on
set -g set-titles-string "#W"
set -g window-status-format '#I:#(pwd="#{pane_current_path}"; echo \${pwd####*/})#F'
set -g window-status-current-format '#I:#(pwd="#{pane_current_path}"; echo \${pwd####*/})#F'
set-option -g status-interval 1
`
export class TMuxBlock {
time: number
number: number
error: boolean
lines: string[]
constructor (line: string) {
this.time = parseInt(line.split(' ')[1])
this.number = parseInt(line.split(' ')[2])
this.lines = []
}
}
export class TMuxMessage {
type: string
content: string
constructor (line: string) {
this.type = line.substring(0, line.indexOf(' '))
this.content = line.substring(line.indexOf(' ') + 1)
}
}
export class TMuxCommandProcess {
private process: childProcess.ChildProcess
private rawOutput$ = new Subject<string>()
private line$ = new Subject<string>()
private message$ = new Subject<string>()
private block$ = new Subject<TMuxBlock>()
private response$: ConnectableObservable<TMuxBlock>
private lock = new AsyncLock({ timeout: 1000 })
constructor () {
this.process = childProcess.spawn('tmux', ['-C', '-f', '/dev/null', '-L', 'terminus', 'new-session', '-A', '-D', '-s', 'control'])
console.log('[tmux] started')
this.process.stdout.on('data', data => {
// console.debug('tmux says:', data.toString())
this.rawOutput$.next(data.toString())
})
let rawBuffer = ''
this.rawOutput$.subscribe(raw => {
rawBuffer += raw
if (rawBuffer.includes('\n')) {
let lines = rawBuffer.split('\n')
rawBuffer = lines.pop()
lines.forEach(line => this.line$.next(line))
}
})
let currentBlock = null
this.line$.subscribe(line => {
if (currentBlock) {
if (line.startsWith('%end ')) {
let block = currentBlock
currentBlock = null
setImmediate(() => {
this.block$.next(block)
})
} else if (line.startsWith('%error ')) {
let block = currentBlock
block.error = true
currentBlock = null
setImmediate(() => {
this.block$.next(block)
})
} else {
currentBlock.lines.push(line)
}
} else {
if (line.startsWith('%begin ')) {
currentBlock = new TMuxBlock(line)
} else {
this.message$.next(line)
}
}
})
this.response$ = this.block$.skip(1).publish()
this.response$.connect()
this.block$.subscribe(block => {
console.debug('[tmux] block:', block)
})
this.message$.subscribe(message => {
console.debug('[tmux] message:', message)
})
}
command (command: string): Promise<TMuxBlock> {
return this.lock.acquire('key', () => {
let p = this.response$.take(1).toPromise()
console.debug('[tmux] command:', command)
this.process.stdin.write(command + '\n')
return p
}).then(response => {
if (response.error) {
throw response
}
return response
}) as Promise<TMuxBlock>
}
destroy () {
this.rawOutput$.complete()
this.line$.complete()
this.block$.complete()
this.message$.complete()
this.process.kill('SIGTERM')
}
}
export class TMux {
private process: TMuxCommandProcess
constructor () {
this.process = new TMuxCommandProcess()
TMUX_CONFIG.split('\n').filter(x => x).forEach(async (line) => {
await this.process.command(line)
})
}
async create (id: string, options: SessionOptions): Promise<void> {
let args = [options.command].concat(options.args)
let cmd = args.map(x => `"${x.replace('"', '\\"')}"`)
await this.process.command(
`new-session -s "${id}" -d`
+ (options.cwd ? ` -c '${options.cwd.replace("'", "\\'")}'` : '')
+ ` '${cmd}'`
)
}
async list (): Promise<string[]> {
let block = await this.process.command('list-sessions -F "#{session_name}"')
return block.lines
}
async terminate (id: string): Promise<void> {
await this.process.command(`kill-session -t ${id}`)
}
}
@Injectable()
export class TMuxPersistenceProvider extends SessionPersistenceProvider {
private tmux: TMux
constructor () {
super()
}
isAvailable (): boolean {
try {
execFileSync('tmux', ['-V'])
return true
} catch (_) {
return false
}
}
init () {
this.tmux = new TMux()
}
async attachSession (recoveryId: any): Promise<SessionOptions> {
let sessions = await this.tmux.list()
if (!sessions.includes(recoveryId)) {
return null
}
return {
command: 'tmux',
args: ['-L', 'terminus', 'attach-session', '-d', '-t', recoveryId],
recoveryId,
}
}
async startSession (options: SessionOptions): Promise<any> {
// TODO env
let recoveryId = Date.now().toString()
await this.tmux.create(recoveryId, options)
return recoveryId
}
async terminateSession (recoveryId: string): Promise<void> {
await this.tmux.terminate(recoveryId)
}
}

View File

@@ -3,6 +3,10 @@
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"baseUrl": "src",
"declarationDir": "dist"
"declarationDir": "dist",
"paths": {
"terminus-*": ["terminus-*"],
"*": ["app/node_modules/*"]
}
}
}

View File

@@ -35,7 +35,7 @@ module.exports = {
{ test: /\.scss$/, use: ['to-string-loader', 'css-loader', 'sass-loader'] },
{ test: /\.css$/, use: ['to-string-loader', 'css-loader'] },
{
test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
test: /\.(ttf|eot|otf|woff|woff2|ogg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
loader: "url-loader",
options: {
limit: 999999999999,
@@ -44,6 +44,7 @@ module.exports = {
]
},
externals: [
'electron',
'fs',
'font-manager',
'path',

View File

@@ -32,6 +32,10 @@ big.js@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978"
connected-domain@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/connected-domain/-/connected-domain-1.0.0.tgz#bfe77238c74be453a79f0cb6058deeb4f2358e93"
dataurl@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dataurl/-/dataurl-0.1.0.tgz#1f4734feddec05ffe445747978d86759c4b33199"
@@ -98,10 +102,22 @@ object-assign@^4.0.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
ps-node@^0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/ps-node/-/ps-node-0.1.6.tgz#9af67a99d7b1d0132e51a503099d38a8d2ace2c3"
dependencies:
table-parser "^0.1.3"
runes@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/runes/-/runes-0.4.2.tgz#1ddc1ea41de769cb32fc068a64fbbc45cd21052e"
table-parser@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/table-parser/-/table-parser-0.1.3.tgz#0441cfce16a59481684c27d1b5a67ff15a43c7b0"
dependencies:
connected-domain "^1.0.0"
thenify-all@^1.0.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"