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 2017-03-25 21:00:16 +01:00
parent b7bac490d2
commit c9ead5e93d
20 changed files with 369 additions and 273 deletions

View File

@ -4,6 +4,7 @@ appearance:
dock: 'off'
dockScreen: 'current'
dockFill: 50
tabsOnTop: true
hotkeys:
new-tab:
- ['Ctrl-A', 'C']

View File

@ -8,5 +8,5 @@ html
window.nodeRequire = require
script(src='./preload.js')
script(src='./bundle.js', defer)
body(style='background: ; min-height: 100vh')
body(style='background: ; min-height: 100vh; overflow: hidden')
app-root

View File

@ -8,6 +8,7 @@
"electron-config": "0.2.1",
"electron-debug": "1.0.1",
"electron-is-dev": "0.1.2",
"fs-promise": "^2.0.1",
"node-pty": "0.6.3",
"path": "0.12.7"
},

View File

@ -16,7 +16,7 @@ export class Tab {
this.id = Tab.lastTabID++
}
displayActivity () {
displayActivity (): void {
this.hasActivity = true
}
@ -27,4 +27,7 @@ export class Tab {
getRecoveryToken (): any {
return null
}
destroy (): void {
}
}

View File

@ -1,5 +1,5 @@
import { Tab } from './tab'
export abstract class TabRecoveryProvider {
abstract recover (recoveryToken: any): Tab
abstract async recover (recoveryToken: any): Promise<Tab>
}

View File

@ -33,9 +33,15 @@
@tab-border-radius: 4px;
:host > .spacer {
flex: 0 0 5px;
.content {
flex: auto;
display: flex;
flex-direction: column-reverse;
background: @title-bg;
&.tabs-on-top {
flex-direction: column;
}
}
.tabs {

View File

@ -1,41 +1,44 @@
title-bar(*ngIf='!config.store.appearance.useNativeFrame && config.store.appearance.dock == "off"')
title-bar(*ngIf='!config.full().appearance.useNativeFrame && config.store.appearance.dock == "off"')
.spacer
.content(
[class.tabs-on-top]='config.full().appearance.tabsOnTop'
)
.tabs(
[class.active-tab-0]='app.tabs[0] == app.activeTab',
)
button.btn.btn-secondary(
*ngFor='let button of getToolbarButtons(false)',
[title]='button.title',
(click)='button.click()',
)
i.fa([class]='"fa fa-" + button.icon')
tab-header(
*ngFor='let tab of app.tabs; let idx = index; trackBy: tab?.id',
[index]='idx',
[model]='tab',
[active]='tab == app.activeTab',
[hasActivity]='tab.hasActivity',
@animateTab,
(click)='app.selectTab(tab)',
(closeClicked)='app.closeTab(tab)',
)
button.btn.btn-secondary(
*ngFor='let button of getToolbarButtons(true)',
[title]='button.title',
(click)='button.click()',
)
i.fa([class]='"fa fa-" + button.icon')
.tabs(class='active-tab-{{app.tabs.indexOf(app.activeTab)}}')
button.btn.btn-secondary(
*ngFor='let button of getToolbarButtons(false)',
[title]='button.title',
(click)='button.click()',
)
i.fa([class]='"fa fa-" + button.icon')
tab-header(
*ngFor='let tab of app.tabs; let idx = index; trackBy: tab?.id',
[index]='idx',
[model]='tab',
[active]='tab == app.activeTab',
[hasActivity]='tab.hasActivity',
@animateTab,
(click)='app.selectTab(tab)',
(closeClicked)='app.closeTab(tab)',
)
button.btn.btn-secondary(
*ngFor='let button of getToolbarButtons(true)',
[title]='button.title',
(click)='button.click()',
)
i.fa([class]='"fa fa-" + button.icon')
.tabs-content
tab-body(
*ngFor='let tab of app.tabs; trackBy: tab?.id',
[active]='tab == app.activeTab',
[model]='tab',
[class.scrollable]='tab.scrollable',
)
.tabs-content
tab-body(
*ngFor='let tab of app.tabs; trackBy: tab?.id',
[active]='tab == app.activeTab',
[model]='tab',
[class.scrollable]='tab.scrollable',
)
// TODO
//hotkey-hint
// TODO
//hotkey-hint
toaster-container([toasterconfig]="toasterconfig")
template(ngbModalContainer)

View File

@ -80,20 +80,26 @@ export class AppService {
)
}
restoreTabs () {
async restoreTabs (): Promise<void> {
if (window.localStorage.tabsRecovery) {
JSON.parse(window.localStorage.tabsRecovery).forEach((token) => {
for (let token of JSON.parse(window.localStorage.tabsRecovery)) {
let tab: Tab
for (let provider of this.tabRecoveryProviders) {
try {
let tab = provider.recover(token)
tab = await provider.recover(token)
if (tab) {
this.openTab(tab)
return
break
}
} catch (_) { }
} catch (error) {
this.logger.warn('Tab recovery crashed:', token, provider, error)
}
}
this.logger.warn('Cannot restore tab from the token:', token)
})
if (tab) {
this.openTab(tab)
} else {
this.logger.warn('Cannot restore tab from the token:', token)
}
}
this.saveTabs()
}
}

View File

@ -15,6 +15,7 @@ export interface IAppearanceData {
dock: string
dockScreen: string
dockFill: number
tabsOnTop: boolean
}
export interface ITerminalData {

View File

@ -5,101 +5,124 @@ ngb-tabset(type='tabs')
template(ngbTabTitle)
| Application
template(ngbTabContent)
.form-group
label Window frame
br
div(
'[(ngModel)]'='config.store.appearance.useNativeFrame'
'(ngModelChange)'='config.save(); requestRestart()'
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='true'
)
| Native
label.btn.btn-secondary
input(
type='radio',
[value]='false'
)
| Custom
small.form-text.text-muted Whether a custom window or an OS native window should be used
.row
.col.col-auto
.col.col-sm-6
.form-group
label Dock the terminal
label Show tabs
br
div(
'[(ngModel)]'='config.store.appearance.dock'
'(ngModelChange)'='config.save(); docking.dock()'
'[(ngModel)]'='config.store.appearance.tabsOnTop'
'(ngModelChange)'='config.save()'
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"off"'
[value]='true'
)
| Off
| On top
label.btn.btn-secondary
input(
type='radio',
[value]='"top"'
[value]='false'
)
| Top
| At the bottom
.col.col-sm-6
.form-group
label Window frame
br
div(
'[(ngModel)]'='config.store.appearance.useNativeFrame'
'(ngModelChange)'='config.save(); requestRestart()'
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"left"'
[value]='true'
)
| Left
| Native
label.btn.btn-secondary
input(
type='radio',
[value]='"right"'
[value]='false'
)
| Right
label.btn.btn-secondary
input(
type='radio',
[value]='"bottom"'
)
| Bottom
| Custom
small.form-text.text-muted Whether a custom window or an OS native window should be used
.form-group(*ngIf='config.store.appearance.dock != "off"')
label Display on
br
div(
'[(ngModel)]'='config.store.appearance.dockScreen'
'(ngModelChange)'='config.save(); docking.dock()'
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"current"'
.row
.col.col-auto
.form-group
label Dock the terminal
br
div(
'[(ngModel)]'='config.store.appearance.dock'
'(ngModelChange)'='config.save(); docking.dock()'
ngbRadioGroup
)
| Current
label.btn.btn-secondary(*ngFor='let screen of docking.getScreens()')
input(
type='radio',
[value]='screen.id'
label.btn.btn-secondary
input(
type='radio',
[value]='"off"'
)
| Off
label.btn.btn-secondary
input(
type='radio',
[value]='"top"'
)
| Top
label.btn.btn-secondary
input(
type='radio',
[value]='"left"'
)
| Left
label.btn.btn-secondary
input(
type='radio',
[value]='"right"'
)
| Right
label.btn.btn-secondary
input(
type='radio',
[value]='"bottom"'
)
| Bottom
.form-group(*ngIf='config.store.appearance.dock != "off"')
label Display on
br
div(
'[(ngModel)]'='config.store.appearance.dockScreen'
'(ngModelChange)'='config.save(); docking.dock()'
ngbRadioGroup
)
label.btn.btn-secondary
input(
type='radio',
[value]='"current"'
)
| Current
label.btn.btn-secondary(*ngFor='let screen of docking.getScreens()')
input(
type='radio',
[value]='screen.id'
)
| {{screen.name}}
.col.col-auto
.form-group(*ngIf='config.store.appearance.dock != "off"')
label Docked terminal size
br
input(
type='range',
'[(ngModel)]'='config.store.appearance.dockFill',
'(mouseup)'='config.save(); docking.dock()',
min='0.05',
max='1',
step='0.01'
)
| {{screen.name}}
.col.col-auto
.form-group(*ngIf='config.store.appearance.dock != "off"')
label Docked terminal size
br
input(
type='range',
'[(ngModel)]'='config.store.appearance.dockFill',
'(mouseup)'='config.save(); docking.dock()',
min='0.05',
max='1',
step='0.01'
)
ngb-tab
template(ngbTabTitle)

View File

@ -5,7 +5,7 @@ import { SettingsTab } from './tab'
@Injectable()
export class RecoveryProvider extends TabRecoveryProvider {
recover (recoveryToken: any): Tab {
async recover (recoveryToken: any): Promise<Tab> {
if (recoveryToken.type == 'app:settings') {
return new SettingsTab()
}

View File

@ -1,3 +1,18 @@
export abstract class TerminalDecorator {
abstract decorate (terminal): void
}
export interface SessionOptions {
name?: string,
command?: string,
args?: string[],
cwd?: string,
env?: any,
recoveryId?: string
}
export abstract class SessionPersistenceProvider {
abstract async recoverSession (recoveryId: any): Promise<SessionOptions>
abstract async createSession (options: SessionOptions): Promise<SessionOptions>
abstract async terminateSession (recoveryId: string): Promise<void>
}

View File

@ -17,8 +17,8 @@ export class ButtonProvider extends ToolbarButtonProvider {
return [{
icon: 'plus',
title: 'New terminal',
click: () => {
let session = this.sessions.createNewSession({ command: 'zsh' })
click: async () => {
let session = await this.sessions.createNewSession({ command: 'zsh' })
this.app.openTab(new TerminalTab(session))
}
}]

View File

@ -6,8 +6,10 @@ import { ToolbarButtonProvider, TabRecoveryProvider } from 'api'
import { TerminalTabComponent } from './components/terminalTab'
import { SessionsService } from './services/sessions'
import { ScreenPersistenceProvider } from './persistenceProviders'
import { ButtonProvider } from './buttonProvider'
import { RecoveryProvider } from './recoveryProvider'
import { SessionPersistenceProvider } from './api'
@NgModule({
@ -19,6 +21,7 @@ import { RecoveryProvider } from './recoveryProvider'
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
SessionsService,
{ provide: SessionPersistenceProvider, useClass: ScreenPersistenceProvider },
],
entryComponents: [
TerminalTabComponent,

View File

@ -0,0 +1,64 @@
import * as fs from 'fs-promise'
const exec = require('child-process-promise').exec
import { SessionOptions, SessionPersistenceProvider } from './api'
export class NullPersistenceProvider extends SessionPersistenceProvider {
async recoverSession (_recoveryId: any): Promise<SessionOptions> {
return null
}
async createSession (_options: SessionOptions): Promise<SessionOptions> {
return null
}
async terminateSession (_recoveryId: string): Promise<void> {
return
}
}
export class ScreenPersistenceProvider extends SessionPersistenceProvider {
list(): Promise<any[]> {
return exec('screen -list').then((result) => {
return result.stdout.split('\n')
.filter((line) => /\bterm-tab-/.exec(line))
.map((line) => line.trim().split('.')[0])
}).catch(() => {
return []
})
}
async recoverSession (recoveryId: any): Promise<SessionOptions> {
// TODO check
return {
recoveryId,
command: 'screen',
args: ['-r', recoveryId],
}
}
async createSession (options: SessionOptions): Promise<SessionOptions> {
let configPath = '/tmp/.termScreenConfig'
await fs.writeFile(configPath, `
escape ^^^
vbell off
term xterm-color
bindkey "^[OH" beginning-of-line
bindkey "^[OF" end-of-line
termcapinfo xterm* 'hs:ts=\\E]0;:fs=\\007:ds=\\E]0;\\007'
defhstatus "^Et"
hardstatus off
`, 'utf-8')
let recoveryId = `term-tab-${Date.now()}`
options.args = ['-c', configPath, '-U', '-S', recoveryId, '--', options.command].concat(options.args || [])
options.command = 'screen'
options.recoveryId = recoveryId
return options
}
async terminateSession (recoveryId: string): Promise<void> {
await exec(`screen -S ${recoveryId} -X quit`)
}
}

View File

@ -10,11 +10,12 @@ export class RecoveryProvider extends TabRecoveryProvider {
super()
}
recover (recoveryToken: any): Tab {
async recover (recoveryToken: any): Promise<Tab> {
if (recoveryToken.type == 'app:terminal') {
const options = this.sessions.recoveryProvider.getRecoverySession(recoveryToken.recoveryId)
let session = this.sessions.createSession(options)
session.recoveryId = recoveryToken.recoveryId
let session = await this.sessions.recover(recoveryToken.recoveryId)
if (!session) {
return null
}
return new TerminalTab(session)
}
return null

View File

@ -1,87 +1,9 @@
import { Injectable, NgZone, EventEmitter } from '@angular/core'
import { Logger, LogService } from 'services/log'
const exec = require('child-process-promise').exec
import * as nodePTY from 'node-pty'
import * as fs from 'fs'
import { Injectable, EventEmitter } from '@angular/core'
import { Logger, LogService } from 'services/log'
import { SessionOptions, SessionPersistenceProvider } from '../api'
export interface ISessionRecoveryProvider {
list (): Promise<any[]>
getRecoverySession (recoveryId: any): SessionOptions
wrapNewSession (options: SessionOptions): SessionOptions
terminateSession (recoveryId: string): Promise<any>
}
export class NullSessionRecoveryProvider implements ISessionRecoveryProvider {
async list (): Promise<any[]> {
return []
}
getRecoverySession (_recoveryId: any): SessionOptions {
return null
}
wrapNewSession (options: SessionOptions): SessionOptions {
return options
}
async terminateSession (_recoveryId: string): Promise<any> {
return null
}
}
export class ScreenSessionRecoveryProvider implements ISessionRecoveryProvider {
list(): Promise<any[]> {
return exec('screen -list').then((result) => {
return result.stdout.split('\n')
.filter((line) => /\bterm-tab-/.exec(line))
.map((line) => line.trim().split('.')[0])
}).catch(() => {
return []
})
}
getRecoverySession (recoveryId: any): SessionOptions {
return {
command: 'screen',
args: ['-r', recoveryId],
}
}
wrapNewSession (options: SessionOptions): SessionOptions {
// TODO
let configPath = '/tmp/.termScreenConfig'
fs.writeFileSync(configPath, `
escape ^^^
vbell off
term xterm-color
bindkey "^[OH" beginning-of-line
bindkey "^[OF" end-of-line
termcapinfo xterm* 'hs:ts=\\E]0;:fs=\\007:ds=\\E]0;\\007'
defhstatus "^Et"
hardstatus off
`, 'utf-8')
let recoveryId = `term-tab-${Date.now()}`
options.args = ['-c', configPath, '-U', '-S', recoveryId, '--', options.command].concat(options.args || [])
options.command = 'screen'
options.recoveryId = recoveryId
return options
}
async terminateSession (recoveryId: string): Promise<any> {
return exec(`screen -S ${recoveryId} -X quit`)
}
}
export interface SessionOptions {
name?: string,
command?: string,
args?: string[],
cwd?: string,
env?: any,
recoveryId?: string
}
export class Session {
open: boolean
@ -96,6 +18,7 @@ export class Session {
constructor (options: SessionOptions) {
this.name = options.name
this.recoveryId = options.recoveryId
console.log('Spawning', options.command)
let env = {
@ -184,57 +107,44 @@ export class Session {
}
}
@Injectable()
export class SessionsService {
sessions: {[id: string]: Session} = {}
logger: Logger
private lastID = 0
recoveryProvider: ISessionRecoveryProvider
constructor(
private zone: NgZone,
private persistence: SessionPersistenceProvider,
log: LogService,
) {
this.logger = log.create('sessions')
this.recoveryProvider = new ScreenSessionRecoveryProvider()
//this.recoveryProvider = new NullSessionRecoveryProvider()
}
createNewSession (options: SessionOptions) : Session {
options = this.recoveryProvider.wrapNewSession(options)
let session = this.createSession(options)
session.recoveryId = options.recoveryId
async createNewSession (options: SessionOptions) : Promise<Session> {
options = await this.persistence.createSession(options)
let session = this.addSession(options)
return session
}
createSession (options: SessionOptions) : Session {
addSession (options: SessionOptions) : Session {
this.lastID++
options.name = `session-${this.lastID}`
let session = new Session(options)
const destroySubscription = session.destroyed.subscribe(() => {
delete this.sessions[session.name]
this.persistence.terminateSession(session.recoveryId)
destroySubscription.unsubscribe()
})
this.sessions[session.name] = session
return session
}
async destroySession (session: Session): Promise<any> {
await session.gracefullyDestroy()
await this.recoveryProvider.terminateSession(session.recoveryId)
return null
}
recoverAll () : Promise<Session[]> {
return <Promise<Session[]>>(this.recoveryProvider.list().then((items) => {
return this.zone.run(() => {
return items.map((recoveryId) => {
const options = this.recoveryProvider.getRecoverySession(recoveryId)
let session = this.createSession(options)
session.recoveryId = recoveryId
return session
})
})
}))
async recover (recoveryId: string) : Promise<Session> {
const options = await this.persistence.recoverSession(recoveryId)
if (!options) {
return null
}
return this.addSession(options)
}
}

View File

@ -20,4 +20,8 @@ export class TerminalTab extends Tab {
recoveryId: this.session.recoveryId,
}
}
destroy (): void {
this.session.gracefullyDestroy()
}
}

View File

@ -78,53 +78,107 @@ title-bar {
}
.tabs tab-header {
background: $body-bg;
.content-wrapper {
background: $body-bg2;
app-root .content {
background: $body-bg2;
.index {
color: #444;
}
.tabs {
background: $body-bg;
button {
color: $body-color;
border: none;
transition: 0.25s all;
&:hover { background: $button-hover-bg !important; }
&:active { background: $button-active-bg !important; }
}
}
&.pre-selected, &:nth-last-child(1) {
.content-wrapper {
border-bottom-right-radius: $tab-border-radius;
}
}
&.post-selected {
.content-wrapper {
border-bottom-left-radius: $tab-border-radius;
}
}
&.active {
background: $body-bg2;
.content-wrapper {
border-top: 1px solid $blue;
tab-header {
background: $body-bg;
border-top-left-radius: $tab-border-radius;
border-top-right-radius: $tab-border-radius;
.content-wrapper {
background: $body-bg2;
.index {
color: #555;
}
button {
color: $body-color;
border: none;
transition: 0.25s all;
&:hover { background: $button-hover-bg !important; }
&:active { background: $button-active-bg !important; }
}
}
&.active {
background: $body-bg2;
.content-wrapper {
background: $body-bg;
}
}
&.has-activity:not(.active) {
.content-wrapper .index {
background: $blue;
color: white;
text-shadow: 0 1px 1px rgba(0,0,0,.95);
}
}
}
}
&.has-activity:not(.active) {
.content-wrapper .index {
background: $blue;
color: white;
text-shadow: 0 1px 1px rgba(0,0,0,.95);
&.tabs-on-top .tabs {
margin-top: 3px;
tab-header {
&.pre-selected, &:nth-last-child(1) {
.content-wrapper {
border-bottom-right-radius: $tab-border-radius;
}
}
&.post-selected {
.content-wrapper {
border-bottom-left-radius: $tab-border-radius;
}
}
.content-wrapper {
border-top: 1px solid transparent;
}
&.active .content-wrapper {
border-top: 1px solid $blue;
border-top-left-radius: $tab-border-radius;
border-top-right-radius: $tab-border-radius;
}
}
}
&:not(.tabs-on-top) .tabs {
margin-bottom: 3px;
tab-header {
&.pre-selected, &:nth-last-child(1) {
.content-wrapper {
border-top-right-radius: $tab-border-radius;
}
}
&.post-selected {
.content-wrapper {
border-top-left-radius: $tab-border-radius;
}
}
.content-wrapper {
border-bottom: 1px solid transparent;
}
&.active .content-wrapper {
border-bottom: 1px solid $blue;
border-bottom-left-radius: $tab-border-radius;
border-bottom-right-radius: $tab-border-radius;
}
}
}
}
tab-body {
background: $body-bg;
}

View File

@ -1,6 +1,7 @@
{
"name": "term",
"devDependencies": {
"@types/fs-promise": "^1.0.1",
"apply-loader": "^0.1.0",
"autoprefixer": "^6.7.7",
"awesome-typescript-loader": "3.0.8",