├── src ├── assets │ ├── .gitkeep │ └── fonts │ │ └── monaco.ttf ├── app │ ├── app.component.scss │ ├── components │ │ ├── terminal │ │ │ ├── terminal.component.sass │ │ │ ├── terminal.component.html │ │ │ ├── terminal.component.spec.ts │ │ │ └── terminal.component.ts │ │ └── title-bar │ │ │ ├── title-bar.component.sass │ │ │ ├── title-bar.component.spec.ts │ │ │ ├── title-bar.component.ts │ │ │ └── title-bar.component.html │ ├── app.component.html │ ├── app.component.spec.ts │ ├── providers │ │ ├── window.service.ts │ │ ├── electron.service.ts │ │ ├── config.service.ts │ │ └── terminal.service.ts │ ├── app.module.ts │ └── app.component.ts ├── favicon.icns ├── favicon.ico ├── favicon.png ├── favicon.256x256.png ├── favicon.512x512.png ├── favicon.1024x1024.png ├── environments │ ├── environment.ts │ ├── environment.prod.ts │ └── environment.dev.ts ├── typings.d.ts ├── styles │ ├── fonts.sass │ ├── terminal.sass │ ├── app.sass │ ├── reset.sass │ └── title-bar.sass ├── index.html ├── tsconfig.app.json ├── main.ts ├── tsconfig.spec.json ├── test.ts └── polyfills.ts ├── postcss.config.js ├── e2e ├── tsconfig.e2e.json └── app.e2e-spec.ts ├── .editorconfig ├── .abstruse.yml ├── tsconfig.json ├── postinstall-web.js ├── postinstall.js ├── electron-builder.json ├── .gitignore ├── LICENSE ├── keyboard-shortcuts.ts ├── README.md ├── menu.ts ├── tslint.json ├── package.json ├── main.ts └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /src/app/components/terminal/terminal.component.sass: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/title-bar/title-bar.component.sass: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/favicon.icns -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/favicon.png -------------------------------------------------------------------------------- /src/app/components/terminal/terminal.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/favicon.256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/favicon.256x256.png -------------------------------------------------------------------------------- /src/favicon.512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/favicon.512x512.png -------------------------------------------------------------------------------- /src/assets/fonts/monaco.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/assets/fonts/monaco.ttf -------------------------------------------------------------------------------- /src/favicon.1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bleenco/bterm/HEAD/src/favicon.1024x1024.png -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const AppConfig = { 2 | production: false, 3 | environment: 'LOCAL' 4 | }; 5 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const AppConfig = { 2 | production: true, 3 | environment: 'PROD' 4 | }; 5 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var nodeModule: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | 7 | declare var window: Window; 8 | interface Window { 9 | process: any; 10 | require: any; 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out", 5 | "module": "commonjs", 6 | "target": "es2015", 7 | "types":[ 8 | "jasmine", 9 | "node" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/fonts.sass: -------------------------------------------------------------------------------- 1 | =font-face($family, $path, $weight: normal, $style: normal) 2 | @font-face 3 | font-family: $family 4 | src: url('#{$path}.ttf') format('truetype') 5 | font-weight: $weight 6 | font-style: $style 7 | 8 | +font-face('Monaco', '../assets/fonts/monaco', 400, 'normal') 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bterm 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "es2015", 6 | "baseUrl": "", 7 | "types": [] 8 | }, 9 | "exclude": [ 10 | "src/test.ts", 11 | "**/*.spec.ts", 12 | "dist", 13 | "app-builds", 14 | "node_modules" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/terminal.sass: -------------------------------------------------------------------------------- 1 | .terminal 2 | width: 100% 3 | height: calc(100% - 36px) 4 | padding: 5px 2.5px 0 2.5px 5 | display: block 6 | position: relative 7 | 8 | .terminal-instance 9 | width: 100% 10 | height: 100% 11 | display: block 12 | position: absolute 13 | top: 0 14 | left: 0 15 | right: 0 16 | bottom: 0 17 | -------------------------------------------------------------------------------- /.abstruse.yml: -------------------------------------------------------------------------------- 1 | image: abstruse 2 | 3 | matrix: 4 | - env: SCRIPT=test NODE_VERSION=9 5 | 6 | before_install: 7 | - nvm install $NODE_VERSION 8 | - npm config set spin false 9 | - npm config set progress false 10 | 11 | install: 12 | - npm install 13 | 14 | script: 15 | - if [[ "$SCRIPT" ]]; then npm run-script $SCRIPT; fi 16 | 17 | cache: 18 | - node_modules 19 | -------------------------------------------------------------------------------- /src/styles/app.sass: -------------------------------------------------------------------------------- 1 | @charset 'utf8' 2 | 3 | @import 'fonts' 4 | @import 'reset' 5 | @import 'title-bar' 6 | @import 'terminal' 7 | @import '../../node_modules/xterm/dist/xterm.css' 8 | 9 | html, body 10 | height: 100% 11 | width: 100% 12 | font-family: Monaco, Menlo, 'DejaVu Sans Mono', 'Ubuntu Mono', monospace 13 | font-size: 12px 14 | border: 1px solid transparent 15 | -------------------------------------------------------------------------------- /src/environments/environment.dev.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `index.ts`, but if you do 3 | // `ng build --env=prod` then `index.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const AppConfig = { 7 | production: false, 8 | environment: 'DEV' 9 | }; 10 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { AppConfig } from './environments/environment'; 6 | 7 | if (AppConfig.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule, { 13 | preserveWhitespaces: false 14 | }) 15 | .catch(err => console.error(err)); 16 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "test.ts", 13 | "polyfills-test.ts" 14 | ], 15 | "include": [ 16 | "**/*.spec.ts", 17 | "**/*.d.ts" 18 | ], 19 | "exclude": [ 20 | "dist", 21 | "app-builds", 22 | "node_modules" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "importHelpers": true, 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es5", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2017", 17 | "es2016", 18 | "es2015", 19 | "dom" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /postinstall-web.js: -------------------------------------------------------------------------------- 1 | // Allow angular using electron module (native node modules) 2 | const fs = require('fs'); 3 | const f_angular = 'node_modules/@angular-devkit/build-angular/src/angular-cli-files/models/webpack-configs/browser.js'; 4 | 5 | fs.readFile(f_angular, 'utf8', function (err, data) { 6 | if (err) { 7 | return console.log(err); 8 | } 9 | var result = data.replace(/target: "electron-renderer",/g, ''); 10 | var result = result.replace(/target: "web",/g, ''); 11 | var result = result.replace(/return \{/g, 'return {target: "web",'); 12 | 13 | fs.writeFile(f_angular, result, 'utf8', function (err) { 14 | if (err) return console.log(err); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /postinstall.js: -------------------------------------------------------------------------------- 1 | // Allow angular using electron module (native node modules) 2 | const fs = require('fs'); 3 | const f_angular = 'node_modules/@angular-devkit/build-angular/src/angular-cli-files/models/webpack-configs/browser.js'; 4 | 5 | fs.readFile(f_angular, 'utf8', function (err, data) { 6 | if (err) { 7 | return console.log(err); 8 | } 9 | var result = data.replace(/target: "electron-renderer",/g, ''); 10 | var result = result.replace(/target: "web",/g, ''); 11 | var result = result.replace(/return \{/g, 'return {target: "electron-renderer",'); 12 | 13 | fs.writeFile(f_angular, result, 'utf8', function (err) { 14 | if (err) return console.log(err); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/components/terminal/terminal.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TerminalComponent } from './terminal.component'; 4 | 5 | describe('TerminalComponent', () => { 6 | let component: TerminalComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ TerminalComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(TerminalComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/components/title-bar/title-bar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TitleBarComponent } from './title-bar.component'; 4 | 5 | describe('TitleBarComponent', () => { 6 | let component: TitleBarComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ TitleBarComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(TitleBarComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "bterm", 3 | "directories": { 4 | "output": "app-builds" 5 | }, 6 | "files": [ 7 | "**/*", 8 | "!*.ts", 9 | "!*.code-workspace", 10 | "!LICENSE.md", 11 | "!package.json", 12 | "!package-lock.json", 13 | "!src/", 14 | "!e2e/", 15 | "!hooks/", 16 | "!.angular-cli.json", 17 | "!_config.yml", 18 | "!karma.conf.js", 19 | "!tsconfig.json", 20 | "!tslint.json" 21 | ], 22 | "win": { 23 | "icon": "dist", 24 | "target": [ 25 | "portable" 26 | ] 27 | }, 28 | "mac": { 29 | "icon": "dist", 30 | "target": [ 31 | "dmg" 32 | ], 33 | "identity": null 34 | }, 35 | "linux": { 36 | "icon": "dist", 37 | "target": [ 38 | "deb" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /app-builds 8 | /e2e/out 9 | main.js 10 | menu.js 11 | keyboard-shortcuts.js 12 | 13 | # dependencies 14 | /node_modules 15 | 16 | # IDEs and editors 17 | /.idea 18 | .project 19 | .classpath 20 | .c9/ 21 | *.launch 22 | .settings/ 23 | *.sublime-workspace 24 | 25 | # IDE - VSCode 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | 32 | # misc 33 | /.sass-cache 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | npm-debug.log 38 | testem.log 39 | /typings 40 | package-lock.json 41 | 42 | # e2e 43 | /e2e/*.js 44 | /e2e/*.map 45 | 46 | # System Files 47 | .DS_Store 48 | Thumbs.db 49 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | import { ElectronService } from './providers/electron.service'; 5 | 6 | describe('AppComponent', () => { 7 | beforeEach(async(() => { 8 | TestBed.configureTestingModule({ 9 | declarations: [ 10 | AppComponent 11 | ], 12 | providers : [ 13 | ElectronService 14 | ], 15 | imports: [RouterTestingModule] 16 | }).compileComponents(); 17 | })); 18 | 19 | it('should create the app', async(() => { 20 | const fixture = TestBed.createComponent(AppComponent); 21 | const app = fixture.debugElement.componentInstance; 22 | expect(app).toBeTruthy(); 23 | })); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/providers/window.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, fromEvent } from 'rxjs'; 3 | import { map, distinctUntilChanged } from 'rxjs/operators'; 4 | 5 | export interface WindowSize { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | @Injectable() 11 | export class WindowService { 12 | size: BehaviorSubject; 13 | 14 | constructor() { 15 | this.size = new BehaviorSubject(this.getWindowSize()); 16 | fromEvent(window, 'resize') 17 | .pipe( 18 | map((): WindowSize => this.getWindowSize()), 19 | distinctUntilChanged() 20 | ) 21 | .subscribe(this.size); 22 | } 23 | 24 | getWindowSize(): WindowSize { 25 | return { 26 | width: window.innerWidth, 27 | height: window.innerHeight 28 | }; 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/app/providers/electron.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | // If you import a module but never use any of the imported values other than as TypeScript types, 4 | // the resulting javascript file will look as if you never imported the module at all. 5 | import { ipcRenderer } from 'electron'; 6 | import * as childProcess from 'child_process'; 7 | 8 | @Injectable() 9 | export class ElectronService { 10 | 11 | ipcRenderer: typeof ipcRenderer; 12 | childProcess: typeof childProcess; 13 | 14 | constructor() { 15 | // Conditional imports 16 | if (this.isElectron()) { 17 | this.ipcRenderer = window.require('electron').ipcRenderer; 18 | this.childProcess = window.require('child_process'); 19 | } 20 | } 21 | 22 | isElectron = () => { 23 | return window && window.process && window.process.type; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { ElectronService } from './providers/electron.service'; 5 | import { TerminalService } from './providers/terminal.service'; 6 | import { WindowService } from './providers/window.service'; 7 | import { ConfigService } from './providers/config.service'; 8 | 9 | import { AppComponent } from './app.component'; 10 | import { TerminalComponent } from './components/terminal/terminal.component'; 11 | import { TitleBarComponent } from './components/title-bar/title-bar.component'; 12 | 13 | @NgModule({ 14 | declarations: [ 15 | AppComponent, 16 | TerminalComponent, 17 | TitleBarComponent 18 | ], 19 | imports: [ 20 | BrowserModule 21 | ], 22 | providers: [ElectronService, TerminalService, WindowService, ConfigService], 23 | bootstrap: [AppComponent] 24 | }) 25 | export class AppModule { } 26 | -------------------------------------------------------------------------------- /src/styles/reset.sass: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | p, 4 | ol, 5 | ul, 6 | li, 7 | dl, 8 | dt, 9 | dd, 10 | blockquote, 11 | figure, 12 | fieldset, 13 | legend, 14 | textarea, 15 | pre, 16 | iframe, 17 | hr, 18 | h1, 19 | h2, 20 | h3, 21 | h4, 22 | h5, 23 | h6 24 | margin: 0 25 | padding: 0 26 | 27 | // Headings 28 | h1, 29 | h2, 30 | h3, 31 | h4, 32 | h5, 33 | h6 34 | font-size: 100% 35 | font-weight: normal 36 | 37 | // List 38 | ul 39 | list-style: none 40 | 41 | // Form 42 | button, 43 | input, 44 | select, 45 | textarea 46 | margin: 0 47 | 48 | // Box sizing 49 | html 50 | box-sizing: border-box 51 | 52 | * 53 | box-sizing: inherit 54 | &:before, 55 | &:after 56 | box-sizing: inherit 57 | 58 | // Media 59 | img, 60 | embed, 61 | object, 62 | audio, 63 | video 64 | max-width: 100% 65 | 66 | // Iframe 67 | iframe 68 | border: 0 69 | 70 | // Table 71 | table 72 | border-collapse: collapse 73 | border-spacing: 0 74 | 75 | td, 76 | th 77 | padding: 0 78 | text-align: left 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Bleenco GmbH https://bleenco.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy.js'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | import { getTestBed } from '@angular/core/testing'; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from '@angular/platform-browser-dynamic/testing'; 14 | 15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 16 | declare const __karma__: any; 17 | declare const require: any; 18 | 19 | // Prevent Karma from running prematurely. 20 | __karma__.loaded = function () {}; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting() 26 | ); 27 | // Then we find all the tests. 28 | const context = require.context('./', true, /\.spec\.ts$/); 29 | // And load the modules. 30 | context.keys().map(context); 31 | // Finally, start Karma to run the tests. 32 | __karma__.start(); 33 | -------------------------------------------------------------------------------- /src/app/components/title-bar/title-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, Renderer2, OnInit } from '@angular/core'; 2 | import { TerminalService } from '../../providers/terminal.service'; 3 | import { ipcRenderer } from 'electron'; 4 | import { platform } from 'os'; 5 | 6 | @Component({ 7 | selector: 'app-title-bar', 8 | templateUrl: './title-bar.component.html' 9 | }) 10 | export class TitleBarComponent implements OnInit { 11 | constructor(public terminalService: TerminalService, private elementRef: ElementRef, private renderer: Renderer2) { } 12 | 13 | ngOnInit() { 14 | const header = this.elementRef.nativeElement.querySelector('.header'); 15 | const buttons = this.elementRef.nativeElement.querySelector('.window-buttons'); 16 | if (platform() === 'darwin') { 17 | this.renderer.addClass(header, 'is-draggable'); 18 | this.renderer.addClass(buttons, 'is-enabled'); 19 | } 20 | } 21 | 22 | closeTab(index: number): void { 23 | this.terminalService.destroy(index); 24 | } 25 | 26 | closeWindow(): void { 27 | this.terminalService.destroyAll(); 28 | } 29 | 30 | minimizeWindow(): void { 31 | ipcRenderer.send('minimize'); 32 | } 33 | 34 | maximizeWindow(): void { 35 | ipcRenderer.send('maximize'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/components/title-bar/title-bar.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 |
7 |
8 |
9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | {{ tab.title }} 18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, Renderer2, OnInit } from '@angular/core'; 2 | import { DOCUMENT } from '@angular/platform-browser'; 3 | import { ConfigService } from './providers/config.service'; 4 | import { TerminalService } from './providers/terminal.service'; 5 | import { filter } from 'rxjs/operators'; 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | templateUrl: './app.component.html', 10 | styleUrls: ['./app.component.scss'] 11 | }) 12 | export class AppComponent implements OnInit { 13 | constructor( 14 | @Inject(DOCUMENT) private document: any, 15 | private renderer: Renderer2, 16 | private config: ConfigService, 17 | private terminal: TerminalService 18 | ) { } 19 | 20 | ngOnInit() { 21 | this.config.body = this.document.querySelector('body'); 22 | 23 | this.config.configEvents.subscribe(config => { 24 | this.renderer.setStyle(this.config.body, 'color', config.colors.foreground); 25 | this.renderer.setStyle(this.config.body, 'background', config.colors.background); 26 | this.renderer.setStyle(this.config.body, 'border-color', config.borderColor); 27 | 28 | this.terminal.terminals.forEach(terminal => { 29 | terminal.term.setOption('fontFamily', config.fontFamily); 30 | terminal.term.setOption('fontSize', config.fontSize); 31 | terminal.term.setOption('theme', config.colors); 32 | }); 33 | }); 34 | 35 | this.terminal.events 36 | .pipe(filter(x => x.type === 'create')) 37 | .subscribe(() => this.config.getConfig()); 38 | 39 | this.config.initWatcher(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /keyboard-shortcuts.ts: -------------------------------------------------------------------------------- 1 | export const keyboardShortcuts = [ 2 | {keypress: 'CommandOrControl+Shift+Left', sctype: 'send', sckey: 'tabLeft', scvalue: true }, 3 | {keypress: 'CommandOrControl+Shift+Right', sctype: 'send', sckey: 'tabRight', scvalue: true }, 4 | {keypress: 'CommandOrControl+T', sctype: 'send', sckey: 'newTab', scvalue: true }, 5 | {keypress: 'CommandOrControl+W', sctype: 'send', sckey: 'closeTab', scvalue: true }, 6 | {keypress: 'CommandOrControl+K', sctype: 'send', sckey: 'clearTab', scvalue: true }, 7 | {keypress: 'CommandOrControl+1', sctype: 'send', sckey: 'switchTab', scvalue: 0 }, 8 | {keypress: 'CommandOrControl+2', sctype: 'send', sckey: 'switchTab', scvalue: 1 }, 9 | {keypress: 'CommandOrControl+3', sctype: 'send', sckey: 'switchTab', scvalue: 2 }, 10 | {keypress: 'CommandOrControl+4', sctype: 'send', sckey: 'switchTab', scvalue: 3 }, 11 | {keypress: 'CommandOrControl+5', sctype: 'send', sckey: 'switchTab', scvalue: 4 }, 12 | {keypress: 'CommandOrControl+6', sctype: 'send', sckey: 'switchTab', scvalue: 5 }, 13 | {keypress: 'CommandOrControl+7', sctype: 'send', sckey: 'switchTab', scvalue: 6 }, 14 | {keypress: 'CommandOrControl+8', sctype: 'send', sckey: 'switchTab', scvalue: 7 }, 15 | {keypress: 'CommandOrControl+9', sctype: 'send', sckey: 'switchTab', scvalue: 8 }, 16 | {keypress: 'CommandOrControl+0', sctype: 'send', sckey: 'switchTab', scvalue: 9 }, 17 | {keypress: 'CommandOrControl+V', sctype: 'send', sckey: 'paste', scvalue: null }, 18 | {keypress: 'CommandOrControl+N', sctype: 'createWindow', sckey: 'createWindow', scvalue: null }, 19 | {keypress: 'CommandOrControl+Shift+I', sctype: 'toggleDevTools', sckey: 'toggleDevTools', scvalue: null } 20 | ]; 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bterm — :warning: This project is no longer maintained. :warning: 2 | 3 |

4 | 5 |

6 | 7 | 8 | [![AbstruseCI](https://ci.bleenco.io/badge/2)](https://ci.bleenco.io/repo/2) 9 | 10 | ## Overview 11 | Fully customisable cross-platform terminal that works and feels the same way everywhere: MacOS, Linux and Windows. 12 | 13 | ## Download 14 | To download visit 15 | [http://bterm.bleenco.io](http://bterm.bleenco.io) and hit the right button to get your favourite OS installer. 16 | 17 | On the first run `~/.bterm2.json` (on Linux and MacOS) or `C:\Users\user\.bterm.json` (on Windows) configuration file is created. It contains the attributes in json format that define the layout of the terminal, i.e. changing the attribute `settings > fonts` will immediately update the type of fonts. 18 | 19 | ## Settings and customization 20 | 21 | The default theme is a combination of black and white visuals. 22 | Settings are stored in `.bterm2.json` configuration file where you can hack your terminals' visual settings. 23 | 24 | 25 | ## Hacking on bterm 26 | In order to run bterm locally in a development mode please use the following commands: 27 | 28 | ```sh 29 | git clone https://github.com/bleenco/bterm.git 30 | npm install 31 | npm start 32 | ``` 33 | 34 | ### Production build 35 | To generate bterm production build and installation package, run the following commands: 36 | 37 | ```sh 38 | npm run electron:[mac | linux | windows] 39 | ``` 40 | 41 | The executable installation package can be found in `app-builds` folder. 42 | 43 | ### LICENCE 44 | 45 | MIT 46 | -------------------------------------------------------------------------------- /menu.ts: -------------------------------------------------------------------------------- 1 | import { Menu, app } from 'electron'; 2 | 3 | export function getMenu(): Menu { 4 | const menu = Menu.buildFromTemplate(template as any); 5 | Menu.setApplicationMenu(menu); 6 | 7 | return menu; 8 | } 9 | 10 | const template = [ 11 | { 12 | label: app.getName(), 13 | submenu: [ 14 | {role: 'about'}, 15 | {type: 'separator'}, 16 | {role: 'services', submenu: []}, 17 | {type: 'separator'}, 18 | {role: 'hide'}, 19 | {role: 'hideothers'}, 20 | {role: 'unhide'}, 21 | {type: 'separator'}, 22 | {role: 'quit'} 23 | ] 24 | }, 25 | { 26 | label: 'Edit', 27 | submenu: [ 28 | {role: 'undo'}, 29 | {role: 'redo'}, 30 | {type: 'separator'}, 31 | {role: 'cut'}, 32 | {role: 'copy'}, 33 | {role: 'paste'}, 34 | {role: 'pasteandmatchstyle'}, 35 | {role: 'delete'}, 36 | {role: 'selectall'} 37 | ] 38 | }, 39 | { 40 | label: 'View', 41 | submenu: [ 42 | {role: 'togglefullscreen'}, 43 | {type: 'separator'}, 44 | {role: 'toggledevtools'}, 45 | {type: 'separator'}, 46 | {role: 'resetzoom'}, 47 | {role: 'zoomin'}, 48 | {role: 'zoomout'}, 49 | {type: 'separator'}, 50 | { label: 'Tab Left', accelerator: 'CommandOrControl+Shift+Left', selector: 'Left' }, 51 | { label: 'Tab Right', accelerator: 'CommandOrControl+Shift+Right', selector: 'Right' } 52 | ] 53 | }, 54 | { 55 | role: 'window', 56 | submenu: [ 57 | {role: 'minimize'}, 58 | {role: 'close'} 59 | ] 60 | }, 61 | { 62 | role: 'help', 63 | submenu: [ 64 | { 65 | label: 'Learn More', 66 | click () { require('electron').shell.openExternal('http://bterm.bleenco.io') } 67 | } 68 | ] 69 | } 70 | ]; 71 | -------------------------------------------------------------------------------- /src/styles/title-bar.sass: -------------------------------------------------------------------------------- 1 | .header 2 | height: 36px 3 | width: 100% 4 | position: relative 5 | border-top-left-radius: 4px 6 | border-top-right-radius: 4px 7 | 8 | &.is-draggable 9 | -webkit-app-region: drag 10 | -webkit-user-select: none 11 | 12 | .tabs 13 | display: flex 14 | 15 | .tab 16 | width: 100% 17 | min-width: 0 18 | margin-left: -1px 19 | position: relative 20 | height: 36px 21 | border: 1px solid #333 22 | border-top: none 23 | transition: border 50ms ease-out 24 | 25 | &:first-child 26 | border-left: none 27 | 28 | &:first-child:not(:last-child) 29 | .title 30 | padding-left: 65px 31 | 32 | &:last-child 33 | border-right: none 34 | 35 | &.is-active 36 | border-color: transparent 37 | 38 | svg 39 | width: 12px 40 | height: 12px 41 | position: absolute 42 | top: 12px 43 | right: 10px 44 | cursor: pointer 45 | opacity: 0 46 | transition: opacity 150ms ease-in 300ms 47 | 48 | .title 49 | display: block 50 | text-align: center 51 | width: calc(100%) 52 | height: 100% 53 | overflow: hidden 54 | text-overflow: ellipsis 55 | white-space: nowrap 56 | padding: 10px 25px 57 | 58 | &:hover 59 | 60 | svg 61 | opacity: 1 62 | 63 | .window-buttons 64 | position: absolute 65 | left: 10px 66 | top: 12px 67 | z-index: 10 68 | display: none 69 | 70 | &.is-enabled 71 | display: inline 72 | 73 | .circle-button 74 | width: 12px 75 | height: 12px 76 | display: block 77 | float: left 78 | border-radius: 50% 79 | margin-right: 7px 80 | cursor: pointer 81 | 82 | &.close 83 | background: #FF6157 84 | border: 0.5px solid #E24640 85 | 86 | &.minimize 87 | background: #FFC12F 88 | border: 0.5px solid #DFA023 89 | 90 | &.maximize 91 | background: #2ACB42 92 | border: 0.5px solid #1BAC2C 93 | 94 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/set'; 35 | 36 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 37 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 38 | 39 | /** IE10 and IE11 requires the following to support `@angular/animation`. */ 40 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 41 | 42 | 43 | /** Evergreen browsers require these. **/ 44 | import 'core-js/es6/reflect'; 45 | 46 | 47 | 48 | /** ALL Firefox browsers require the following to support `@angular/animation`. **/ 49 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 50 | 51 | 52 | 53 | /*************************************************************************************************** 54 | * Zone JS is required by Angular itself. 55 | */ 56 | import 'zone.js/dist/zone-mix'; // Included with Angular CLI. 57 | 58 | 59 | /*************************************************************************************************** 60 | * APPLICATION IMPORTS 61 | */ 62 | 63 | /** 64 | * Date, currency, decimal and percent pipes. 65 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 66 | */ 67 | // import 'intl'; // Run `npm install --save intl`. 68 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-use-before-declare": true, 76 | "no-var-keyword": true, 77 | "object-literal-sort-keys": false, 78 | "one-line": [ 79 | true, 80 | "check-open-brace", 81 | "check-catch", 82 | "check-else", 83 | "check-whitespace" 84 | ], 85 | "prefer-const": true, 86 | "quotemark": [ 87 | true, 88 | "single" 89 | ], 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always" 94 | ], 95 | "triple-equals": [ 96 | true, 97 | "allow-null-check" 98 | ], 99 | "typedef-whitespace": [ 100 | true, 101 | { 102 | "call-signature": "nospace", 103 | "index-signature": "nospace", 104 | "parameter": "nospace", 105 | "property-declaration": "nospace", 106 | "variable-declaration": "nospace" 107 | } 108 | ], 109 | "unified-signatures": true, 110 | "variable-name": false, 111 | "whitespace": [ 112 | true, 113 | "check-branch", 114 | "check-decl", 115 | "check-operator", 116 | "check-separator", 117 | "check-type" 118 | ], 119 | "no-output-on-prefix": true, 120 | "use-input-property-decorator": true, 121 | "use-output-property-decorator": true, 122 | "use-host-property-decorator": true, 123 | "no-input-rename": true, 124 | "no-output-rename": true, 125 | "use-life-cycle-interface": true, 126 | "use-pipe-transform-interface": true, 127 | "component-class-suffix": true, 128 | "directive-class-suffix": true 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bterm", 3 | "version": "2.0.0", 4 | "description": "bterm", 5 | "author": "Bleenco OSS Team ", 6 | "main": "main.js", 7 | "private": true, 8 | "scripts": { 9 | "postinstall": "npm run postinstall:electron && npx electron-builder install-app-deps", 10 | "postinstall:web": "node postinstall-web", 11 | "postinstall:electron": "node postinstall", 12 | "ng": "ng", 13 | "start": "npm run postinstall:electron && npm-run-all -p ng:serve electron:serve", 14 | "build": "npm run postinstall:electron && npm run electron:tsc && ng build", 15 | "build:dev": "npm run build -- -c dev", 16 | "build:prod": "npm run build -- -c production", 17 | "ng:serve": "ng serve", 18 | "ng:serve:web": "npm run postinstall:web && ng serve -o", 19 | "electron:tsc": "tsc main.ts", 20 | "electron:serve": "wait-on http-get://localhost:4200/ && npm run electron:tsc && electron . --serve", 21 | "electron:local": "npm run build:prod && electron .", 22 | "electron:linux": "npm run build:prod && npx electron-builder build --linux", 23 | "electron:windows": "npm run build:prod && npx electron-builder build --windows", 24 | "electron:mac": "npm run build:prod && npx electron-builder build --mac", 25 | "test": "npm run postinstall:web && ng test", 26 | "e2e": "npm run postinstall:web && ng e2e" 27 | }, 28 | "dependencies": { 29 | "node-pty": "^0.8.0", 30 | "shelljs": "^0.8.3", 31 | "tslib": "^1.9.3", 32 | "xterm": "^3.9.1" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "~0.11.4", 36 | "@angular/cli": "7.1.4", 37 | "@angular/common": "7.1.4", 38 | "@angular/compiler": "7.1.4", 39 | "@angular/compiler-cli": "7.1.4", 40 | "@angular/core": "7.1.4", 41 | "@angular/forms": "7.1.4", 42 | "@angular/http": "7.1.4", 43 | "@angular/language-service": "7.1.4", 44 | "@angular/platform-browser": "7.1.4", 45 | "@angular/platform-browser-dynamic": "7.1.4", 46 | "@angular/router": "7.1.4", 47 | "@types/jasmine": "3.3.5", 48 | "@types/node": "10.12.18", 49 | "@types/shelljs": "^0.8.1", 50 | "bulma": "^0.7.2", 51 | "circular-dependency-plugin": "5.0.2", 52 | "codelyzer": "4.5.0", 53 | "copy-webpack-plugin": "4.6.0", 54 | "copyfiles": "2.1.0", 55 | "core-js": "2.6.1", 56 | "cross-env": "5.2.0", 57 | "css-loader": "2.1.0", 58 | "cssnano": "4.1.8", 59 | "electron": "4.0.0", 60 | "electron-builder": "20.38.4", 61 | "electron-rebuild": "^1.8.2", 62 | "electron-reload": "1.4.0", 63 | "enhanced-resolve": "4.1.0", 64 | "exports-loader": "0.7.0", 65 | "file-loader": "3.0.1", 66 | "html-loader": "0.5.5", 67 | "html-webpack-plugin": "3.2.0", 68 | "jasmine": "^3.3.1", 69 | "jasmine-core": "3.3.0", 70 | "jasmine-spec-reporter": "4.2.1", 71 | "json-loader": "0.5.7", 72 | "less-loader": "4.1.0", 73 | "minimist": "1.2.0", 74 | "mkdirp": "0.5.1", 75 | "npm-run-all": "4.1.5", 76 | "npx": "10.2.0", 77 | "raw-loader": "1.0.0", 78 | "rxjs": "6.3.3", 79 | "sass-loader": "7.1.0", 80 | "script-loader": "0.7.2", 81 | "source-map-loader": "0.2.4", 82 | "spectron": "^5.0.0", 83 | "style-loader": "0.23.1", 84 | "stylus-loader": "3.0.2", 85 | "ts-node": "7.0.1", 86 | "tslint": "5.12.0", 87 | "typescript": "~3.1.6", 88 | "uglifyjs-webpack-plugin": "2.1.0", 89 | "url-loader": "1.1.2", 90 | "wait-on": "^3.2.0", 91 | "webdriver-manager": "12.1.1", 92 | "webpack": "4.28.2", 93 | "webpack-concat-plugin": "3.0.0", 94 | "webpack-dev-server": "3.1.14", 95 | "zone.js": "0.8.26" 96 | }, 97 | "license": "MIT" 98 | } 99 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, globalShortcut } from 'electron'; 2 | import { getMenu } from './menu'; 3 | import { keyboardShortcuts } from './keyboard-shortcuts'; 4 | import * as path from 'path'; 5 | import * as url from 'url'; 6 | import { platform } from 'os'; 7 | 8 | const args = process.argv.slice(1); 9 | const serve = args.some(val => val === '--serve'); 10 | const os = platform(); 11 | const showFrame = os === 'darwin' ? false : true; 12 | 13 | let currentWindow: BrowserWindow = null; 14 | let windows: BrowserWindow[] = []; 15 | 16 | if (serve) { 17 | require('electron-reload')(__dirname, { 18 | }); 19 | } 20 | 21 | app.on('ready', () => { 22 | createWindow(); 23 | }); 24 | 25 | app.on('window-all-closed', () => { 26 | if (process.platform !== 'darwin') { 27 | app.quit(); 28 | } 29 | }); 30 | 31 | app.on('activate', () => { 32 | if (!currentWindow) { 33 | createWindow(); 34 | } 35 | }); 36 | 37 | ipcMain.on('minimize', () => currentWindow.minimize()); 38 | ipcMain.on('tabMaximize', () => currentWindow.isMaximized() ? currentWindow.unmaximize() : currentWindow.maximize()); 39 | ipcMain.on('maximize', () => { 40 | const isMac = process.platform === 'darwin'; 41 | if (isMac) { 42 | currentWindow.setFullScreen(!currentWindow.isFullScreen()); 43 | } else { 44 | currentWindow.isMaximized() ? currentWindow.unmaximize() : currentWindow.maximize(); 45 | } 46 | }); 47 | ipcMain.on('close', (ev, id) => { 48 | if (!currentWindow) { 49 | return; 50 | } 51 | 52 | currentWindow.close(); 53 | }); 54 | 55 | function createWindow(): void { 56 | const width = 640; 57 | const height = 480; 58 | 59 | const win = new BrowserWindow({ 60 | width, 61 | height, 62 | center: true, 63 | frame: showFrame, 64 | show: false, 65 | transparent: true 66 | }); 67 | 68 | win.setMenu(getMenu()); 69 | 70 | win.loadURL(url.format({ 71 | protocol: 'file:', 72 | pathname: path.join(__dirname, '/index.html'), 73 | slashes: true 74 | })); 75 | 76 | if (serve) { 77 | require('electron-reload')(__dirname, { 78 | electron: require(`${__dirname}/node_modules/electron`) 79 | }); 80 | win.loadURL('http://localhost:4200'); 81 | } else { 82 | win.loadURL(url.format({ 83 | pathname: path.join(__dirname, 'dist/index.html'), 84 | protocol: 'file:', 85 | slashes: true 86 | })); 87 | } 88 | 89 | registerShortcuts(win); 90 | 91 | win.once('ready-to-show', () => win.show()); 92 | win.on('blur', () => { 93 | currentWindow = null; 94 | unregisterShortcuts(); 95 | }); 96 | win.on('focus', () => { 97 | currentWindow = win; 98 | registerShortcuts(win); 99 | }); 100 | win.on('move', event => win.webContents.send('move', event)); 101 | win.on('close', () => { 102 | windows = windows.filter(w => w.id !== currentWindow.id); 103 | currentWindow = null; 104 | unregisterShortcuts(); 105 | }); 106 | win.on('resize', event => win.webContents.send('resize', event)); 107 | win.on('restore', event => win.webContents.send('restore', event)); 108 | win.on('enter-full-screen', event => win.webContents.send('enter-full-screen', event)); 109 | win.on('leave-full-screen', event => win.webContents.send('leave-full-screen', event)); 110 | 111 | windows.push(win); 112 | } 113 | 114 | function registerShortcuts(window: any): void { 115 | const keypressFunctions = { 116 | send: (key, value) => window.webContents.send(key, value), 117 | toggleDevTools: () => window.webContents.toggleDevTools(), 118 | createWindow: () => createWindow() 119 | }; 120 | 121 | keyboardShortcuts.forEach(shortcut => { 122 | globalShortcut.register(shortcut.keypress, () => { 123 | keypressFunctions[shortcut.sctype](shortcut.sckey, shortcut.scvalue); 124 | }); 125 | }); 126 | } 127 | 128 | function unregisterShortcuts(): void { 129 | keyboardShortcuts.forEach(shortcut => { 130 | globalShortcut.unregister(shortcut.keypress); 131 | }); 132 | } 133 | -------------------------------------------------------------------------------- /src/app/providers/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, EventEmitter } from '@angular/core'; 2 | import { homedir } from 'os'; 3 | import { existsSync, writeFileSync, readFileSync, watchFile } from 'fs'; 4 | import { ITheme } from 'xterm'; 5 | 6 | export interface BtermConfig { 7 | fontFamily: string; 8 | fontSize: number; 9 | baseTheme: 'light' | 'dark'; 10 | themeOverrides: ITheme; 11 | borderColor: string; 12 | } 13 | 14 | export interface ParsedConfig { 15 | fontFamily: string; 16 | fontSize: number; 17 | borderColor: string; 18 | colors: { 19 | foreground?: string; 20 | background?: string; 21 | cursor?: string; 22 | cursorAccent?: string; 23 | selection?: string; 24 | black?: string; 25 | red?: string; 26 | green?: string; 27 | yellow?: string; 28 | blue?: string; 29 | magenta?: string; 30 | cyan?: string; 31 | white?: string; 32 | brightRed?: string; 33 | brightGreen?: string; 34 | brightYellow?: string; 35 | brightBlue?: string; 36 | brightMagenta?: string; 37 | brightCyan?: string; 38 | brightWhite?: string; 39 | }; 40 | } 41 | 42 | @Injectable() 43 | export class ConfigService { 44 | home: string; 45 | configPath: string; 46 | lightTheme: ITheme; 47 | darkTheme: ITheme; 48 | configEvents: EventEmitter; 49 | 50 | body: HTMLBodyElement; 51 | 52 | constructor() { 53 | this.home = homedir(); 54 | this.configPath = this.home + '/.bterm2.json'; 55 | this.configEvents = new EventEmitter(); 56 | 57 | this.lightTheme = { 58 | foreground: '#000000', 59 | background: '#ffffff', 60 | cursor: '#000000', 61 | cursorAccent: '#000000', 62 | selection: 'rgba(0, 0, 0, 0.1)', 63 | black: '#000000', 64 | red: '#de3e35', 65 | green: '#3f953a', 66 | yellow: '#d2b67c', 67 | blue: '#2f5af3', 68 | magenta: '#950095', 69 | cyan: '#3f953a', 70 | white: '#bbbbbb', 71 | brightBlack: '#000000', 72 | brightRed: '#de3e35', 73 | brightGreen: '#3f953a', 74 | brightYellow: '#d2b67c', 75 | brightBlue: '#2f5af3', 76 | brightMagenta: '#a00095', 77 | brightCyan: '#3f953a', 78 | brightWhite: '#ffffff' 79 | }; 80 | 81 | this.darkTheme = { 82 | foreground: '#F8F8F2', 83 | background: '#090E15', 84 | cursor: '#bd93f9', 85 | cursorAccent: '#bd93f9', 86 | selection: 'rgba(241, 250, 140, 0.3)', 87 | black: '#090E15', 88 | red: '#ff5555', 89 | green: '#50fa7b', 90 | yellow: '#f1fa8c', 91 | blue: '#96ECFD', 92 | magenta: '#bd93f9', 93 | cyan: '#8be9fd', 94 | white: '#ffffff', 95 | brightBlack: '#666666', 96 | brightRed: '#ff5555', 97 | brightGreen: '#50fa7b', 98 | brightYellow: '#f1fa8c', 99 | brightBlue: '#96ECFD', 100 | brightMagenta: '#bd93f9', 101 | brightCyan: '#8be9fd', 102 | brightWhite: '#ffffff' 103 | }; 104 | 105 | if (!existsSync(this.configPath)) { 106 | this.writeConfig(this.defaultConfig()); 107 | } 108 | } 109 | 110 | initWatcher(): void { 111 | watchFile(this.configPath, () => { 112 | if (!existsSync(this.configPath)) { 113 | this.writeConfig(this.defaultConfig()); 114 | } 115 | 116 | this.getConfig(); 117 | }); 118 | } 119 | 120 | getConfig(): void { 121 | const btermConfig = this.readConfig(); 122 | const parsedConfig: ParsedConfig = { 123 | fontFamily: btermConfig.fontFamily, 124 | fontSize: btermConfig.fontSize, 125 | borderColor: btermConfig.borderColor, 126 | colors: Object.assign({}, btermConfig.baseTheme === 'light' ? this.lightTheme : this.darkTheme, btermConfig.themeOverrides) 127 | }; 128 | 129 | this.configEvents.emit(parsedConfig); 130 | } 131 | 132 | defaultConfig(): BtermConfig { 133 | return { 134 | fontFamily: 'Monaco, Menlo, \'DejaVu Sans Mono\', \'Ubuntu Mono\', monospace', 135 | fontSize: 12, 136 | baseTheme: 'dark', 137 | themeOverrides: {}, 138 | borderColor: '#222222' 139 | }; 140 | } 141 | 142 | readConfig(): BtermConfig { 143 | return JSON.parse(readFileSync(this.configPath, { encoding: 'utf8' })); 144 | } 145 | 146 | writeConfig(config: BtermConfig): void { 147 | writeFileSync(this.configPath, JSON.stringify(config, null, 2), { encoding: 'utf8' }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "bterm": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "outputPath": "dist", 15 | "index": "src/index.html", 16 | "main": "src/main.ts", 17 | "tsConfig": "src/tsconfig.app.json", 18 | "polyfills": "src/polyfills.ts", 19 | "assets": [ 20 | "src/assets", 21 | "src/favicon.ico", 22 | "src/favicon.ico", 23 | "src/favicon.png", 24 | "src/favicon.icns", 25 | "src/favicon.256x256.png", 26 | "src/favicon.512x512.png", 27 | "src/favicon.1024x1024.png" 28 | ], 29 | "styles": [ 30 | "src/styles/app.sass" 31 | ], 32 | "scripts": [] 33 | }, 34 | "configurations": { 35 | "dev": { 36 | "optimization": false, 37 | "outputHashing": "all", 38 | "sourceMap": true, 39 | "extractCss": true, 40 | "namedChunks": false, 41 | "aot": false, 42 | "extractLicenses": true, 43 | "vendorChunk": false, 44 | "buildOptimizer": false, 45 | "fileReplacements": [ 46 | { 47 | "replace": "src/environments/environment.ts", 48 | "with": "src/environments/environment.dev.ts" 49 | } 50 | ] 51 | }, 52 | "production": { 53 | "optimization": true, 54 | "outputHashing": "all", 55 | "sourceMap": false, 56 | "extractCss": true, 57 | "namedChunks": false, 58 | "aot": true, 59 | "extractLicenses": true, 60 | "vendorChunk": false, 61 | "buildOptimizer": true, 62 | "fileReplacements": [ 63 | { 64 | "replace": "src/environments/environment.ts", 65 | "with": "src/environments/environment.prod.ts" 66 | } 67 | ] 68 | } 69 | } 70 | }, 71 | "serve": { 72 | "builder": "@angular-devkit/build-angular:dev-server", 73 | "options": { 74 | "browserTarget": "bterm:build" 75 | }, 76 | "configurations": { 77 | "prod": { 78 | "browserTarget": "bterm:build:prod" 79 | } 80 | } 81 | }, 82 | "extract-i18n": { 83 | "builder": "@angular-devkit/build-angular:extract-i18n", 84 | "options": { 85 | "browserTarget": "bterm:build" 86 | } 87 | }, 88 | "test": { 89 | "builder": "@angular-devkit/build-angular:karma", 90 | "options": { 91 | "main": "src/test.ts", 92 | "karmaConfig": "./karma.conf.js", 93 | "polyfills": "src/polyfills.ts", 94 | "tsConfig": "src/tsconfig.spec.json", 95 | "scripts": [], 96 | "styles": [ 97 | "src/styles/app.sass" 98 | ], 99 | "assets": [ 100 | "src/assets", 101 | "src/favicon.ico" 102 | ] 103 | } 104 | }, 105 | "lint": { 106 | "builder": "@angular-devkit/build-angular:tslint", 107 | "options": { 108 | "tsConfig": [ 109 | "src/tsconfig.app.json", 110 | "src/tsconfig.spec.json" 111 | ], 112 | "exclude": [] 113 | } 114 | } 115 | } 116 | }, 117 | "bterm-e2e": { 118 | "root": "", 119 | "sourceRoot": "", 120 | "projectType": "application", 121 | "architect": { 122 | "e2e": { 123 | "builder": "@angular-devkit/build-angular:protractor", 124 | "options": { 125 | "protractorConfig": "./protractor.conf.js", 126 | "devServerTarget": "bterm:serve" 127 | } 128 | }, 129 | "lint": { 130 | "builder": "@angular-devkit/build-angular:tslint", 131 | "options": { 132 | "tsConfig": [ 133 | "e2e/tsconfig.e2e.json" 134 | ], 135 | "exclude": [] 136 | } 137 | } 138 | } 139 | } 140 | }, 141 | "defaultProject": "bterm", 142 | "schematics": { 143 | "@schematics/angular:component": { 144 | "prefix": "app", 145 | "styleext": "sass" 146 | }, 147 | "@schematics/angular:directive": { 148 | "prefix": "app" 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'spectron'; 2 | import { resolve } from 'path'; 3 | 4 | const getElectronPath = () => { 5 | let electronPath = resolve(__dirname, '../../node_modules/.bin/electron'); 6 | if (process.platform === 'win32') { 7 | electronPath += '.cmd'; 8 | } 9 | return electronPath; 10 | } 11 | 12 | const startApplication = () => { 13 | return new Application({ 14 | path: getElectronPath(), 15 | args: [resolve(__dirname, '../../dist')], 16 | env: { SPECTRON: true } 17 | }).start(); 18 | } 19 | 20 | describe('bterm', () => { 21 | let app: Application; 22 | 23 | beforeAll(() => startApplication().then(startedApp => app = startedApp)); 24 | 25 | afterAll(() => { 26 | if (app && app.isRunning()) { 27 | return app.stop(); 28 | } 29 | }) 30 | 31 | it('should show an initial window', () => { 32 | return app.client.waitUntilWindowLoaded() 33 | .then(() => app.client.getWindowCount()) 34 | .then(count => expect(count).toEqual(1)); 35 | }); 36 | 37 | it('should be visible', () => { 38 | return app.client.waitUntilWindowLoaded() 39 | .then(() => app.browserWindow.isVisible()) 40 | .then(res => expect(res).toBeTruthy()); 41 | }); 42 | 43 | it('should not be minimized', () => { 44 | return app.client.waitUntilWindowLoaded() 45 | .then(() => app.browserWindow.isMinimized()) 46 | .then(res => expect(res).toBeFalsy()); 47 | }); 48 | 49 | it('should check if globalShortcuts are registered', () => { 50 | return app.client.waitUntilWindowLoaded() 51 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+Shift+O')) 52 | .then((result) => expect(result).toBeFalsy()) // First is not existing that shpuld be false 53 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+Shift+Left')) 54 | .then((result) => expect(result).toBeTruthy()) 55 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+Shift+Right')) 56 | .then((result) => expect(result).toBeTruthy()) 57 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+T')) 58 | .then((result) => expect(result).toBeTruthy()) 59 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+W')) 60 | .then((result) => expect(result).toBeTruthy()) 61 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+K')) 62 | .then((result) => expect(result).toBeTruthy()) 63 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+1')) 64 | .then((result) => expect(result).toBeTruthy()) 65 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+2')) 66 | .then((result) => expect(result).toBeTruthy()) 67 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+3')) 68 | .then((result) => expect(result).toBeTruthy()) 69 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+4')) 70 | .then((result) => expect(result).toBeTruthy()) 71 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+5')) 72 | .then((result) => expect(result).toBeTruthy()) 73 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+6')) 74 | .then((result) => expect(result).toBeTruthy()) 75 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+7')) 76 | .then((result) => expect(result).toBeTruthy()) 77 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+8')) 78 | .then((result) => expect(result).toBeTruthy()) 79 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+9')) 80 | .then((result) => expect(result).toBeTruthy()) 81 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+0')) 82 | .then((result) => expect(result).toBeTruthy()) 83 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+N')) 84 | .then((result) => expect(result).toBeTruthy()) 85 | .then(() => app.electron.remote.globalShortcut.isRegistered('CommandOrControl+Shift+I')) 86 | .then((result) => expect(result).toBeTruthy()) 87 | }); 88 | 89 | it('should have a width', () => { 90 | return app.client.waitUntilWindowLoaded() 91 | .then(() => app.browserWindow.getBounds()) 92 | .then(result => { 93 | expect(result.width).toBeDefined(); 94 | expect(result.width).toBeGreaterThan(0); 95 | expect(result.height).toBeDefined(); 96 | expect(result.height).toBeGreaterThan(0); 97 | }); 98 | }); 99 | 100 | it('should have the app title', () => { 101 | return app.client.waitUntilWindowLoaded() 102 | .then(() => app.browserWindow.getTitle()) 103 | .then(title => expect(title).toEqual('bterm')); 104 | }); 105 | 106 | }); 107 | -------------------------------------------------------------------------------- /src/app/components/terminal/terminal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, ElementRef, Renderer2 } from '@angular/core'; 2 | import { TerminalService } from '../../providers/terminal.service'; 3 | import { ipcRenderer, remote, clipboard } from 'electron'; 4 | import { Observable, Subscription, fromEvent, timer } from 'rxjs'; 5 | import { debounce } from 'rxjs/operators'; 6 | 7 | @Component({ 8 | selector: 'app-terminal', 9 | templateUrl: './terminal.component.html', 10 | styleUrls: ['./terminal.component.sass'] 11 | }) 12 | export class TerminalComponent implements OnInit, OnDestroy { 13 | el: HTMLMainElement; 14 | subs: Subscription[] = []; 15 | 16 | constructor( 17 | public elementRef: ElementRef, 18 | public terminalService: TerminalService, 19 | public renderer: Renderer2 20 | ) { } 21 | 22 | ngOnInit() { 23 | this.el = this.elementRef.nativeElement.querySelector('.terminal'); 24 | this.initIpcListeners(); 25 | this.initTerminalEvents(); 26 | this.terminalService.create(this.el); 27 | } 28 | 29 | ngOnDestroy() { 30 | this.subs.forEach(sub => sub.unsubscribe()); 31 | } 32 | 33 | initIpcListeners(): void { 34 | this.subs.push( 35 | fromEvent(ipcRenderer, 'move') 36 | .pipe(debounce(() => timer(100))) 37 | .subscribe(event => this.terminalService.focusCurrentTab()) 38 | ); 39 | 40 | this.subs.push( 41 | fromEvent(ipcRenderer, 'newTab') 42 | .subscribe(() => this.terminalService.create(this.el)) 43 | ); 44 | 45 | this.subs.push( 46 | fromEvent(ipcRenderer, 'resize') 47 | .pipe(debounce(() => timer(100))) 48 | .subscribe(() => { 49 | this.terminalService.terminals.forEach(terminal => { 50 | (terminal.term).fit(); 51 | terminal.term.focus(); 52 | }); 53 | }) 54 | ); 55 | 56 | this.subs.push( 57 | fromEvent(ipcRenderer, 'restore') 58 | .subscribe(() => this.terminalService.focusCurrentTab()) 59 | ); 60 | 61 | this.subs.push( 62 | fromEvent(ipcRenderer, 'enter-full-screen') 63 | .subscribe(() => this.terminalService.focusCurrentTab()) 64 | ); 65 | 66 | this.subs.push( 67 | fromEvent(ipcRenderer, 'leave-full-screen') 68 | .subscribe(() => this.terminalService.focusCurrentTab()) 69 | ); 70 | 71 | this.subs.push( 72 | fromEvent(ipcRenderer, 'tabLeft') 73 | .subscribe(() => this.previousTab()) 74 | ); 75 | 76 | this.subs.push( 77 | fromEvent(ipcRenderer, 'tabRight') 78 | .subscribe(() => this.nextTab()) 79 | ); 80 | 81 | this.subs.push( 82 | fromEvent(ipcRenderer, 'paste') 83 | .subscribe(() => this.paste()) 84 | ); 85 | 86 | this.subs.push( 87 | fromEvent(ipcRenderer, 'copy') 88 | .subscribe(() => this.copy()) 89 | ); 90 | } 91 | 92 | initTerminalEvents(): void { 93 | this.terminalService.events.subscribe(event => { 94 | const elements = this.el.querySelectorAll('.terminal-instance'); 95 | if (event.type === 'focusTab') { 96 | this.setActiveTab(event.index); 97 | } else if (event.type === 'destroy') { 98 | const element = elements[event.index]; 99 | this.renderer.removeChild(element.parentElement, element); 100 | this.terminalService.terminals = this.terminalService.terminals.filter(t => t.el !== element); 101 | const index = this.el.querySelectorAll('.terminal-instance').length - 1; 102 | if (index > -1) { 103 | this.setActiveTab(index); 104 | this.terminalService.focusTab(index); 105 | } else { 106 | ipcRenderer.send('close', remote.getCurrentWindow().id); 107 | } 108 | } 109 | }); 110 | } 111 | 112 | setActiveTab(i: number): void { 113 | const elements = this.el.querySelectorAll('.terminal-instance'); 114 | [].forEach.call(elements, el => this.renderer.setStyle(el, 'z-index', 0)); 115 | this.renderer.setStyle(elements[i], 'z-index', 10); 116 | (this.terminalService.terminals[i].term).focus(); 117 | (this.terminalService.terminals[i].term).fit(); 118 | } 119 | 120 | previousTab(): void { 121 | if (this.terminalService.terminals.length === 1) { 122 | return; 123 | } 124 | 125 | const currentIndex = this.terminalService.currentIndex; 126 | let index = null; 127 | if (currentIndex - 1 < 0) { 128 | index = this.terminalService.terminals.length - 1; 129 | } else { 130 | index = currentIndex - 1; 131 | } 132 | 133 | this.terminalService.currentIndex = index; 134 | this.setActiveTab(index); 135 | this.terminalService.focusTab(index); 136 | } 137 | 138 | nextTab(): void { 139 | if (this.terminalService.terminals.length === 1) { 140 | return; 141 | } 142 | 143 | const currentIndex = this.terminalService.currentIndex; 144 | let index = null; 145 | if (currentIndex + 1 > this.terminalService.terminals.length - 1) { 146 | index = 0; 147 | } else { 148 | index = currentIndex + 1; 149 | } 150 | 151 | this.terminalService.currentIndex = index; 152 | this.setActiveTab(index); 153 | this.terminalService.focusTab(index); 154 | } 155 | 156 | paste(): void { 157 | this.terminalService.terminals[this.terminalService.currentIndex].ptyProcess.write.next(clipboard.readText()); 158 | } 159 | 160 | copy(): void { 161 | const term = this.terminalService.terminals[this.terminalService.currentIndex].term; 162 | if (term.hasSelection()) { 163 | const selection = term.getSelection(); 164 | clipboard.writeText(selection); 165 | term.clearSelection(); 166 | } 167 | } 168 | 169 | } 170 | -------------------------------------------------------------------------------- /src/app/providers/terminal.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, EventEmitter } from '@angular/core'; 2 | import { WindowService } from './window.service'; 3 | import { Observable, Subject, Subscription, fromEvent } from 'rxjs'; 4 | import { share, filter, map } from 'rxjs/operators'; 5 | import * as os from 'os'; 6 | import { Terminal } from 'xterm'; 7 | import * as fit from 'xterm/lib/addons/fit/fit'; 8 | import { execSync } from 'child_process'; 9 | import { which } from 'shelljs'; 10 | import { StringDecoder } from 'string_decoder'; 11 | const spawn = window.require('node-pty').spawn; 12 | 13 | export interface PtyProcessType { 14 | shell: { shell: string, args: string[] }; 15 | process: any; 16 | onData: Observable; 17 | write: Subject; 18 | writeSub: Subscription; 19 | } 20 | 21 | export interface TerminalType { 22 | el: HTMLElement; 23 | ptyProcess: PtyProcess; 24 | term: Terminal; 25 | title: string; 26 | subscriptions: Subscription[]; 27 | } 28 | 29 | class PtyProcess implements PtyProcessType { 30 | shell: { shell: string, args: string[] }; 31 | process: any; 32 | onData: Observable; 33 | onError: Observable; 34 | onExit: Observable; 35 | write: Subject; 36 | writeSub: Subscription; 37 | 38 | constructor() { 39 | this.shell = this.getDefaultShell(); 40 | this.process = spawn(this.shell.shell, this.shell.args, { 41 | cols: 80, 42 | rows: 30, 43 | cwd: os.homedir() 44 | }); 45 | 46 | const decoder = new StringDecoder('utf8'); 47 | this.onData = fromEvent(this.process, 'data').pipe(map((x: Buffer) => decoder.write(x)), share()); 48 | this.onError = fromEvent(this.process, 'error').pipe(map(x => x.toString()), share()); 49 | this.onExit = fromEvent(this.process, 'exit').pipe(share()); 50 | this.write = new Subject(); 51 | this.writeSub = this.write.pipe(map(input => this.process.write(input))).subscribe(); 52 | } 53 | 54 | getDefaultShell(): { shell: string, args: string[] } { 55 | let shell = null; 56 | 57 | const exec = execSync('echo $SHELL', { encoding: 'utf8' }).toString(); 58 | if (exec && exec.includes('bin')) { 59 | shell = exec.trim(); 60 | } else { 61 | const platform = os.platform(); 62 | if (platform === 'darwin') { 63 | shell = process.env.SHELL || '/bin/bash'; 64 | } else if (platform === 'win32') { 65 | const bashPath: any = which('bash'); 66 | if (bashPath.code === 0 && bashPath.stdout) { 67 | shell = bashPath.stdout; 68 | } else { 69 | shell = process.env.SHELL || process.env.COMSPEC || 'cmd.exe'; 70 | } 71 | } else { 72 | shell = process.env.SHELL || '/bin/sh'; 73 | } 74 | } 75 | 76 | const args = process.env.SHELL_EXECUTE_FLAGS || '--login'; 77 | return { shell: shell, args: args.split(' ').filter(Boolean) }; 78 | } 79 | } 80 | 81 | @Injectable() 82 | export class TerminalService { 83 | terminals: TerminalType[]; 84 | currentIndex: number; 85 | events: EventEmitter<{ type: string, index: number }>; 86 | 87 | constructor(public windowService: WindowService) { 88 | this.terminals = []; 89 | Terminal.applyAddon(fit); 90 | this.events = new EventEmitter<{ type: string, index: number }>(); 91 | } 92 | 93 | create(el: HTMLMainElement): void { 94 | const doc: HTMLDocument = document; 95 | const element = doc.createElement('div'); 96 | element.classList.add('terminal-instance'); 97 | el.appendChild(element); 98 | 99 | const terminal: TerminalType = { 100 | el: element, 101 | ptyProcess: new PtyProcess(), 102 | term: new Terminal(), 103 | title: 'Shell', 104 | subscriptions: [] 105 | }; 106 | 107 | this.terminals.push(terminal); 108 | this.currentIndex = this.terminals.length - 1; 109 | 110 | terminal.term.open(element); 111 | this.events.next({ type: 'create', index: null }); 112 | this.focusCurrentTab(); 113 | 114 | terminal.subscriptions.push(terminal.ptyProcess.onData.subscribe(data => { 115 | terminal.term.write(data); 116 | (terminal.term).fit(); 117 | })); 118 | terminal.subscriptions.push(terminal.ptyProcess.onError.subscribe(data => { 119 | this.destroy(); 120 | })); 121 | terminal.subscriptions.push(terminal.ptyProcess.onExit.subscribe((exitCode) => { 122 | this.destroy(); 123 | })); 124 | terminal.subscriptions.push( 125 | fromEvent(terminal.term, 'title') 126 | .subscribe((title: string) => { 127 | terminal.title = title; 128 | }) 129 | ); 130 | terminal.subscriptions.push( 131 | fromEvent(terminal.term, 'data').subscribe((key: string) => { 132 | terminal.ptyProcess.write.next(key); 133 | (terminal.term).fit(); 134 | }) 135 | ); 136 | terminal.subscriptions.push( 137 | fromEvent(terminal.term, 'resize').subscribe((sizeData: any) => { 138 | terminal.ptyProcess.process.resize(sizeData.cols, sizeData.rows); 139 | }) 140 | ); 141 | terminal.subscriptions.push( 142 | this.windowService.size.subscribe(size => { 143 | terminal.ptyProcess.process.resize(terminal.term.cols, terminal.term.rows); 144 | (terminal.term).fit(); 145 | }) 146 | ); 147 | } 148 | 149 | focusTab(i: number): void { 150 | const terminal = this.terminals[i]; 151 | this.currentIndex = i; 152 | this.events.emit({ type: 'focusTab', index: i }); 153 | terminal.term.focus(); 154 | (terminal.term).fit(); 155 | terminal.term.scrollToBottom(); 156 | } 157 | 158 | focusCurrentTab(): void { 159 | const terminal = this.terminals[this.currentIndex]; 160 | this.events.emit({ type: 'focusTab', index: this.currentIndex }); 161 | terminal.term.focus(); 162 | (terminal.term).fit(); 163 | terminal.term.scrollToBottom(); 164 | } 165 | 166 | destroy(i?: number): void { 167 | const index = typeof i === 'undefined' ? this.currentIndex : i; 168 | const terminal = this.terminals[index]; 169 | terminal.subscriptions.forEach(sub => sub.unsubscribe()); 170 | terminal.ptyProcess.process.kill(); 171 | this.events.emit({ type: 'destroy', index: index }); 172 | } 173 | 174 | destroyAll(): void { 175 | this.terminals.forEach((term, index) => { 176 | const terminal = this.terminals[index]; 177 | terminal.subscriptions.forEach(sub => sub.unsubscribe()); 178 | terminal.ptyProcess.process.kill(); 179 | this.events.emit({ type: 'destroy', index: index }); 180 | }); 181 | } 182 | } 183 | --------------------------------------------------------------------------------