1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-09-11 13:13:59 +03:00
This commit is contained in:
Eugene Pankov 2016-12-26 23:21:50 +01:00
parent 4e451d0598
commit d7bae654eb
19 changed files with 326 additions and 71 deletions

View File

@ -13,7 +13,7 @@ setupWindowManagement = () => {
app.window.on('close', (e) => {
windowConfig.set('windowBoundaries', app.window.getBounds())
if (!windowCloseable) {
app.window.hide()
app.window.minimize()
e.preventDefault()
}
})
@ -31,6 +31,18 @@ setupWindowManagement = () => {
app.window.focus()
})
electron.ipcMain.on('window-maximize', () => {
if (app.window.isMaximized()) {
app.window.unmaximize()
} else {
app.window.maximize()
}
})
electron.ipcMain.on('window-minimize', () => {
app.window.minimize()
})
app.on('before-quit', () => windowCloseable = true)
}
@ -82,16 +94,15 @@ start = () => {
'web-preferences': {'web-security': false},
//- background to avoid the flash of unstyled window
backgroundColor: '#1D272D',
frame: false,
}
Object.assign(options, windowConfig.get('windowBoundaries'))
if (platform == 'darwin') {
options.titleBarStyle = 'hidden'
} else {
options.frame = false
}
app.commandLine.appendSwitch('--disable-http-cache')
app.commandLine.appendSwitch('disable-http-cache')
app.window = new electron.BrowserWindow(options)
app.window.loadURL(`file://${app.getAppPath()}/assets/webpack/index.html`, {extraHeaders: "pragma: no-cache\n"})

View File

@ -3,7 +3,7 @@
"version": "1.0.0",
"main": "main.js",
"dependencies": {
"child-process-promise": "^2.1.3",
"child-process-promise": "^2.2.0",
"devtron": "^1.4.0",
"electron-config": "^0.2.1",
"electron-debug": "^1.0.1",

View File

@ -9,6 +9,7 @@ import { ConfigService } from 'services/config'
import { ElectronService } from 'services/electron'
import { HostAppService } from 'services/hostApp'
import { LogService } from 'services/log'
import { HotkeysService } from 'services/hotkeys'
import { ModalService } from 'services/modal'
import { NotifyService } from 'services/notify'
import { QuitterService } from 'services/quitter'
@ -33,6 +34,7 @@ import { TerminalComponent } from 'components/terminal'
ConfigService,
ElectronService,
HostAppService,
HotkeysService,
LogService,
ModalService,
NotifyService,

View File

@ -3,20 +3,65 @@
:host {
display: flex;
width: 100vw;
height: 100vh;
width: ~"calc(100vw - 2px)";
height: ~"calc(100vh - 2px)";
flex-direction: column;
overflow: hidden;
-webkit-user-select: none;
-webkit-font-smoothing: antialiased;
cursor: default;
background: @body-bg;
}
@titlebar-height: 35px;
@tabs-height: 40px;
@tab-border-radius: 3px;
.button-states() {
transition: 0.125s all;
&:hover:not(.active) {
background: rgba(255, 255, 255, .033);
}
&:active:not(.active) {
background: rgba(0, 0, 0, .1);
}
}
.titlebar {
height: @titlebar-height;
background: #141c23;
flex: none;
display: flex;
flex-direction: row;
.title {
flex: auto;
padding-left: 15px;
line-height: @titlebar-height;
-webkit-app-region: drag;
}
.btn-minimize, .btn-maximize, .btn-close {
flex: none;
line-height: @titlebar-height - 2px;
padding: 0 15px;
font-size: 8px;
.button-states();
cursor: pointer;
}
.btn-close {
font-size: 12px;
}
}
.tabs {
flex: none;
height: @tabs-height;
background: #141c23;
display: flex;
flex-direction: row;
@ -41,13 +86,7 @@
margin-right: 10px;
}
&:hover {
background: rgba(255, 255, 255, .1);
}
&:active {
background: rgba(0, 0, 0, .1);
}
.button-states();
}
.tab {
@ -55,24 +94,41 @@
flex-basis: 0;
flex-grow: 1;
background: @body-bg;
display: flex;
flex-direction: row;
overflow: hidden;
min-width: 0;
div {
&.pre-selected, &:nth-last-child(2) {
border-top-right-radius: @tab-border-radius;
}
&.post-selected {
border-top-left-radius: @tab-border-radius;
}
div.index {
flex: none;
padding: 0 0 0 15px;
font-weight: bold;
color: #444;
}
div.name {
flex: auto;
padding: 0 15px;
margin: 0 15px 0 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
border-bottom: 2px solid transparent;
transition: 0.25s all;
&:hover:not(.active) {
background: rgba(255, 255, 255, .05);
}
&:active {
background: rgba(0, 0, 0, .1);
}
.button-states();
&.active {
background: #141c23;

View File

@ -1,6 +1,22 @@
.titlebar
.title((dblclick)='hostApp.maximizeWindow()') Term
.btn-minimize((click)='hostApp.minimizeWindow()')
i.fa.fa-window-minimize
.btn-maximize((click)='hostApp.maximizeWindow()')
i.fa.fa-window-maximize
.btn-close((click)='hostApp.quit()')
i.fa.fa-close
.tabs
.tab(*ngFor='let tab of tabs; trackBy: tab?.id', (click)='selectTab(tab)', [class.active]='tab == activeTab')
div {{tab.name}}
.tab(
*ngFor='let tab of tabs; let idx = index; trackBy: tab?.id',
(click)='selectTab(tab)',
[class.active]='tab == activeTab',
[class.pre-selected]='tabs[idx + 1] == activeTab',
[class.post-selected]='tabs[idx - 1] == activeTab',
)
div.index {{idx + 1}}
div.name {{tab.name || 'Terminal'}}
button((click)='closeTab(tab)') ×
.btn-new-tab((click)='newTab()')
i.fa.fa-plus

View File

@ -1,7 +1,8 @@
import { Component } from '@angular/core'
import { Component, ElementRef } from '@angular/core'
import { ModalService } from 'services/modal'
import { ElectronService } from 'services/electron'
import { HostAppService } from 'services/hostApp'
import { HotkeysService } from 'services/hotkeys'
import { LogService } from 'services/log'
import { QuitterService } from 'services/quitter'
import { ToasterConfig } from 'angular2-toaster'
@ -31,11 +32,13 @@ class Tab {
})
export class AppComponent {
constructor(
private hostApp: HostAppService,
private modal: ModalService,
private electron: ElectronService,
private elementRef: ElementRef,
private sessions: SessionsService,
public hostApp: HostAppService,
public hotkeys: HotkeysService,
log: LogService,
electron: ElectronService,
_quitter: QuitterService,
) {
console.timeStamp('AppComponent ctor')
@ -48,6 +51,22 @@ export class AppComponent {
preventDuplicates: true,
timeout: 4000,
})
this.hotkeys.key.subscribe((key) => {
if (key.event == 'keydown') {
if (key.alt && key.key >= '1' && key.key <= '9') {
let index = key.key.charCodeAt(0) - '0'.charCodeAt(0) - 1
if (index < this.tabs.length) {
this.selectTab(this.tabs[index])
}
}
if (key.alt && key.key == '0') {
if (this.tabs.length >= 10) {
this.selectTab(this.tabs[9])
}
}
}
})
}
toasterConfig: ToasterConfig
@ -55,23 +74,40 @@ export class AppComponent {
activeTab: Tab
newTab () {
const tab = new Tab(this.sessions.createSession({command: 'bash'}))
this.addSessionTab(this.sessions.createNewSession({command: 'bash'}))
}
addSessionTab (session) {
let tab = new Tab(session)
this.tabs.push(tab)
this.selectTab(tab)
}
selectTab (tab) {
this.activeTab = tab
setImmediate(() => {
this.elementRef.nativeElement.querySelector(':scope .tab.active iframe').focus()
})
}
closeTab (tab) {
tab.session.destroy()
tab.session.gracefullyDestroy()
this.tabs = this.tabs.filter((x) => x != tab)
this.selectTab(this.tabs[0])
if (tab == this.activeTab) {
this.selectTab(this.tabs[0])
}
}
ngOnInit () {
this.newTab()
this.sessions.recoverAll().then((recoveredSessions) => {
if (recoveredSessions.length > 0) {
recoveredSessions.forEach((session) => {
this.addSessionTab(session)
})
} else {
this.newTab()
}
})
}
ngOnDestroy () {

View File

@ -2,11 +2,8 @@ import { Component } from '@angular/core'
import { ElectronService } from 'services/electron'
import { HostAppService, PLATFORM_WINDOWS, PLATFORM_LINUX, PLATFORM_MAC } from 'services/hostApp'
import { ConfigService } from 'services/config'
import { QuitterService } from 'services/quitter'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import * as os from 'os'
@Component({
selector: 'settings-modal',
@ -16,10 +13,9 @@ import * as os from 'os'
export class SettingsModalComponent {
constructor(
private modalInstance: NgbActiveModal,
private hostApp: HostAppService,
private electron: ElectronService,
private quitter: QuitterService,
public config: ConfigService,
hostApp: HostAppService,
electron: ElectronService,
) {
this.isWindows = hostApp.platform == PLATFORM_WINDOWS
this.isMac = hostApp.platform == PLATFORM_MAC

View File

@ -1,5 +1,4 @@
import { Component, NgZone, Input, Output, EventEmitter, ElementRef } from '@angular/core'
import { ElectronService } from 'services/electron'
import { ConfigService } from 'services/config'
import { Session } from 'services/sessions'
@ -10,16 +9,25 @@ const hterm = require('hterm-commonjs')
hterm.hterm.VT.ESC['k'] = function(parseState) {
parseState.resetArguments();
function parseOSC(parseState) {
if (!this.parseUntilStringTerminator_(parseState) || parseState.func == parseOSC) {
function parseOSC(ps) {
if (!this.parseUntilStringTerminator_(ps) || ps.func == parseOSC) {
return
}
this.terminal.setWindowTitle(parseState.args[0])
this.terminal.setWindowTitle(ps.args[0])
}
parseState.func = parseOSC
}
hterm.hterm.defaultStorage = new hterm.lib.Storage.Memory()
hterm.hterm.PreferenceManager.defaultPreferences['user-css'] = ``
const oldDecorate = hterm.hterm.ScrollPort.prototype.decorate
hterm.hterm.ScrollPort.prototype.decorate = function (...args) {
oldDecorate.bind(this)(...args)
this.screen_.style.cssText += `; padding-right: ${this.screen_.offsetWidth - this.screen_.clientWidth}px;`
}
@Component({
selector: 'terminal',
template: '',
@ -33,7 +41,6 @@ export class TerminalComponent {
constructor(
private zone: NgZone,
private electron: ElectronService,
private elementRef: ElementRef,
public config: ConfigService,
) {
@ -41,7 +48,6 @@ export class TerminalComponent {
ngOnInit () {
let io
hterm.hterm.defaultStorage = new hterm.lib.Storage.Memory()
this.terminal = new hterm.hterm.Terminal()
this.terminal.setWindowTitle = (title) => {
this.zone.run(() => {
@ -72,5 +78,6 @@ export class TerminalComponent {
}
ngOnDestroy () {
;
}
}

View File

@ -19,7 +19,7 @@ if (nodeRequire('electron-is-dev')) {
}
console.timeStamp('angular bootstrap started')
platformBrowserDynamic().bootstrapModule(AppModule)
platformBrowserDynamic().bootstrapModule(AppModule);
process.emitWarning = function () { console.log(arguments) }
(<any>process).emitWarning = function () { console.log(arguments) }

View File

@ -62,7 +62,15 @@ export class HostAppService {
this.electron.ipcRenderer.send('window-focus')
}
quit() {
minimizeWindow () {
this.electron.ipcRenderer.send('window-minimize')
}
maximizeWindow () {
this.electron.ipcRenderer.send('window-maximize')
}
quit () {
this.logger.info('Quitting')
this.electron.app.quit()
}

View File

@ -0,0 +1,60 @@
import { Injectable, NgZone, EventEmitter } from '@angular/core'
const hterm = require('hterm-commonjs')
export interface Key {
event: string,
alt: boolean,
ctrl: boolean,
cmd: boolean,
shift: boolean,
key: string
}
@Injectable()
export class HotkeysService {
key = new EventEmitter<Key>()
constructor(private zone: NgZone) {
let events = [
{
name: 'keydown',
htermHandler: 'onKeyDown_',
},
{
name: 'keypress',
htermHandler: 'onKeyPress_',
},
{
name: 'keyup',
htermHandler: 'onKeyUp_',
},
]
events.forEach((event) => {
document.addEventListener(event.name, (nativeEvent) => {
this.emitNativeEvent(event.name, nativeEvent)
})
let oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler]
const __this = this
hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) {
__this.emitNativeEvent(event.name, nativeEvent)
oldHandler.bind(this)(nativeEvent)
}
})
}
emitNativeEvent (name, nativeEvent) {
console.debug('Key', nativeEvent)
this.zone.run(() => {
this.key.emit({
event: name,
alt: nativeEvent.altKey,
shift: nativeEvent.shiftKey,
cmd: nativeEvent.metaKey,
ctrl: nativeEvent.ctrlKey,
key: nativeEvent.key,
})
})
}
}

View File

@ -1,11 +1,10 @@
import { Injectable, NgZone } from '@angular/core';
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@Injectable()
export class ModalService {
constructor(
private zone: NgZone,
private ngbModal: NgbModal,
) {}

View File

@ -1,13 +1,11 @@
import { Injectable } from '@angular/core'
import { ToasterService } from 'angular2-toaster'
import { LogService } from 'services/log'
@Injectable()
export class NotifyService {
constructor(
private toaster: ToasterService,
private log: LogService,
) {}
pop(options) {

View File

@ -1,12 +1,10 @@
import { Injectable } from '@angular/core'
import { HostAppService } from 'services/hostApp'
import { ElectronService } from 'services/electron'
@Injectable()
export class QuitterService {
constructor(
private electron: ElectronService,
private hostApp: HostAppService,
) {
hostApp.quitRequested.subscribe(() => {

View File

@ -1,8 +1,52 @@
import { Injectable, NgZone, EventEmitter } from '@angular/core'
import { Logger, LogService } from 'services/log'
const exec = require('child-process-promise').exec
import * as crypto from 'crypto'
import * as ptyjs from 'pty.js'
export interface SessionRecoveryProvider {
list(): Promise<any[]>
getRecoveryCommand(item: any): string
getNewSessionCommand(command: string): string
}
export class NullSessionRecoveryProvider implements SessionRecoveryProvider {
list(): Promise<any[]> {
return Promise.resolve([])
}
getRecoveryCommand(_: any): string {
return null
}
getNewSessionCommand(command: string) {
return command
}
}
export class ScreenSessionRecoveryProvider implements SessionRecoveryProvider {
list(): Promise<any[]> {
return exec('screen -ls').then((result) => {
return result.stdout.split('\n')
.filter((line) => /\bterm-tab-/.exec(line))
.map((line) => line.trim().split('.')[0])
}).catch(() => {
return []
})
}
getRecoveryCommand(item: any): string {
return `screen -r ${item}`
}
getNewSessionCommand(command: string): string {
const id = crypto.randomBytes(8).toString('hex')
return `screen -U -S term-tab-${id} -- ${command}`
}
}
export interface SessionOptions {
name?: string,
command: string,
@ -20,9 +64,10 @@ export class Session {
constructor (options: SessionOptions) {
this.name = options.name
console.log('Spawning', options.command)
this.pty = ptyjs.spawn('sh', ['-c', options.command], {
name: 'xterm-color',
//name: 'screen-256color',
//name: 'xterm-color',
name: 'xterm-256color',
cols: 80,
rows: 30,
cwd: options.cwd || process.env.HOME,
@ -62,7 +107,7 @@ export class Session {
gracefullyDestroy () {
return new Promise((resolve) => {
this.sendSignal('SIGTERM')
if (!open) {
if (!this.open) {
resolve()
this.destroy()
} else {
@ -91,11 +136,20 @@ export class SessionsService {
sessions: {[id: string]: Session} = {}
logger: Logger
private lastID = 0
recoveryProvider: SessionRecoveryProvider
constructor(
private zone: NgZone,
log: LogService,
) {
this.logger = log.create('sessions')
this.recoveryProvider = new ScreenSessionRecoveryProvider()
//this.recoveryProvider = new NullSessionRecoveryProvider()
}
createNewSession (options: SessionOptions) : Session {
options.command = this.recoveryProvider.getNewSessionCommand(options.command)
return this.createSession(options)
}
createSession (options: SessionOptions) : Session {
@ -109,4 +163,15 @@ export class SessionsService {
this.sessions[session.name] = session
return session
}
recoverAll () : Promise<Session[]> {
return <Promise<Session[]>>(this.recoveryProvider.list().then((items) => {
return this.zone.run(() => {
return items.map((item) => {
const command = this.recoveryProvider.getRecoveryCommand(item)
return this.createSession({command})
})
})
}))
}
}

View File

@ -20,7 +20,7 @@
"raw-loader": "^0.5.1",
"style-loader": "^0.13.1",
"to-string-loader": "^1.1.5",
"tslint": "4.0.2",
"tslint": "4.2.0",
"typescript": "2.1.1",
"typings": "2.0.0",
"url-loader": "^0.5.7",

View File

@ -11,17 +11,20 @@
"sourceMap": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"noUnusedParameters": true,
"noUnusedLocals": true
},
"compileOnSave": false,
"exclude": [
"node_modules",
"platforms",
"platforms"
],
"files": [
"app/src/app.d.ts",
"app/src/entry.ts",
"typings/index.d.ts",
"filesGlob" : [
"app/src/*.ts",
"app/src/**/*.ts",
"!node_modules/**",
"!app/node_modules/**",
"node_modules/rxjs/Rx.d.ts"
]
}

View File

@ -5,16 +5,13 @@
"semicolon": false,
"no-inferrable-types": [true, "ignore-params"],
"curly": true,
"no-duplicate-key": true,
"no-duplicate-variable": true,
"no-empty": true,
"no-eval": true,
"no-invalid-this": true,
"no-shadowed-variable": true,
"no-unreachable": true,
"no-unused-expression": true,
"no-unused-new": true,
"no-unused-variable": true,
"no-use-before-declare": true,
"no-var-keyword": true,
"new-parens": true

View File

@ -67,16 +67,19 @@ module.exports = {
]
},
externals: {
'electron': 'require("electron")',
'fs': 'require("fs")',
'buffer': 'require("buffer")',
'system': '{}',
'file': '{}',
'net': 'require("net")',
'electron': 'require("electron")',
'remote': 'require("remote")',
'shell': 'require("shell")',
'ipc': 'require("ipc")',
'fs': 'require("fs")',
'buffer': 'require("buffer")',
'crypto': 'require("crypto")',
'pty.js': 'require("pty.js")',
'system': '{}',
'file': '{}'
'child-process-promise': 'require("child-process-promise")',
},
plugins: [
new webpack.ProvidePlugin({