diff --git a/README.md b/README.md index 4c789169..ae43fe03 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ -# Terminus α -*A terminal for a more modern age* +
+ +

Terminus α

+

+ A terminal for a more modern age +

+
+
+
+
[![Build Status](https://travis-ci.org/Eugeny/terminus.svg?branch=master)](https://travis-ci.org/Eugeny/terminus) [![Build status](https://ci.appveyor.com/api/projects/status/wnnq4hm5mbd9rgoy?svg=true)](https://ci.appveyor.com/project/Eugeny/terminus) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Eugeny/terminus/master/LICENSE) [![Downloads](https://img.shields.io/badge/downloads-latest_release-brightgreen.svg)](https://github.com/Eugeny/terminus/releases/latest) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FEugeny%2Fterminus.svg?type=shield)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FEugeny%2Fterminus?ref=badge_shield) ---- @@ -13,12 +22,12 @@ * Theming and color schemes * Configurable hotkey schemes * **GNU Screen** style hotkeys available by default - * Default Linux style hotkeys for Copy(`Ctrl`+`Shift`+`C`), and Paste(`Ctrl`+`Shift`+`V`) * Full Unicode support including double-width characters * Doesn't choke on fast-flowing outputs * Tab persistence on macOS and Linux * Proper shell-like experience on Windows including tab completion (thanks, Clink!) * CMD, PowerShell, Cygwin, Git-Bash and Bash on Windows support + * Default Linux style hotkeys for copy (`Ctrl`+`Shift`+`C`) and paste (`Ctrl`+`Shift`+`V`) --- @@ -28,6 +37,7 @@ Plugins can be installed directly from the Settings view inside Terminus. * [clickable-links](https://github.com/Eugeny/terminus-clickable-links) - makes paths and URLs in the terminal clickable * [theme-hype](https://github.com/Eugeny/terminus-theme-hype) - a Hyper inspired theme + * [shell-selector](https://github.com/Eugeny/terminus-shell-selector) - a quick shell selector pane --- @@ -36,3 +46,7 @@ Plugins can be installed directly from the Settings view inside Terminus. Pull requests and plugins are welcome! Publish your plugin on NPM with a `terminus-plugin` keyword to make them appear in the Plugin Manager. See [HACKING.md](https://github.com/Eugeny/terminus/blob/master/HACKING.md) for a very brief plugin development tutorial! + + +## License +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FEugeny%2Fterminus.svg?type=large)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FEugeny%2Fterminus?ref=badge_large) \ No newline at end of file diff --git a/app/index.pug b/app/index.pug index 1132e12c..82238d51 100644 --- a/app/index.pug +++ b/app/index.pug @@ -13,22 +13,9 @@ html app-root .preload-logo div - .terminus-logo.animated - .part(style='transform: rotateZ(0deg)') - div - .part(style='transform: rotateZ(51deg)') - div - .part(style='transform: rotateZ(102deg)') - div - .part(style='transform: rotateZ(154deg)') - div - .part(style='transform: rotateZ(205deg)') - div - .part(style='transform: rotateZ(257deg)') - div - .part(style='transform: rotateZ(308deg)') - div + .terminus-logo h1.terminus-title Terminus + sup α .progress .bar(style='width: 0%') diff --git a/app/main.js b/app/main.js index 09e5295a..5814d998 100644 --- a/app/main.js +++ b/app/main.js @@ -30,28 +30,18 @@ if (!process.env.TERMINUS_PLUGINS) { } setupWindowManagement = () => { - let windowCloseable - app.window.on('show', () => { app.window.webContents.send('host:window-shown') }) app.window.on('close', (e) => { windowConfig.set('windowBoundaries', app.window.getBounds()) - if (!windowCloseable) { - app.window.minimize() - e.preventDefault() - } }) app.window.on('closed', () => { app.window = null }) - electron.ipcMain.on('window-closeable', (event, flag) => { - windowCloseable = flag - }) - electron.ipcMain.on('window-focus', () => { app.window.focus() }) @@ -86,6 +76,8 @@ setupWindowManagement = () => { electron.ipcMain.on('window-set-bounds', (event, bounds) => { let actualBounds = app.window.getBounds() + actualBounds.width -= bounds.x - actualBounds.x + actualBounds.height -= bounds.y - actualBounds.y actualBounds.x = bounds.x actualBounds.y = bounds.y app.window.setBounds(actualBounds) @@ -102,8 +94,6 @@ setupWindowManagement = () => { electron.ipcMain.on('window-set-always-on-top', (event, flag) => { app.window.setAlwaysOnTop(flag) }) - - app.on('before-quit', () => windowCloseable = true) } @@ -131,7 +121,7 @@ setupMenu = () => { label: 'Quit', accelerator: 'Cmd+Q', click () { - app.window.webContents.send('host:quit-request') + app.quit() } } ] @@ -202,7 +192,6 @@ start = () => { let options = { width: 800, height: 600, - //icon: `${app.getAppPath()}/assets/img/icon.png`, title: 'Terminus', minWidth: 400, minHeight: 300, diff --git a/app/package.json b/app/package.json index fe991d5e..179e1e1a 100644 --- a/app/package.json +++ b/app/package.json @@ -12,14 +12,14 @@ "watch": "webpack --progress --color --watch" }, "dependencies": { - "@angular/animations": "4.0.1", - "@angular/common": "4.0.1", - "@angular/compiler": "4.0.1", - "@angular/core": "4.0.1", - "@angular/forms": "4.0.1", - "@angular/platform-browser": "4.0.1", - "@angular/platform-browser-dynamic": "4.0.1", - "@ng-bootstrap/ng-bootstrap": "1.0.0-alpha.22", + "@angular/animations": "4.3.0", + "@angular/common": "4.3.0", + "@angular/compiler": "4.3.0", + "@angular/core": "4.3.0", + "@angular/forms": "4.3.0", + "@angular/platform-browser": "4.3.0", + "@angular/platform-browser-dynamic": "4.3.0", + "@ng-bootstrap/ng-bootstrap": "^1.0.0-alpha.28", "devtron": "1.4.0", "electron-config": "0.2.1", "electron-debug": "^1.0.1", @@ -29,7 +29,7 @@ "mz": "^2.6.0", "path": "0.12.7", "rxjs": "5.3.0", - "zone.js": "0.8.4" + "zone.js": "0.8.12" }, "devDependencies": { "@types/mz": "0.0.31" diff --git a/app/src/app.module.ts b/app/src/app.module.ts index 6df9e91d..35f691fe 100644 --- a/app/src/app.module.ts +++ b/app/src/app.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core' import { BrowserModule } from '@angular/platform-browser' import { NgbModule } from '@ng-bootstrap/ng-bootstrap' -export async function getRootModule (plugins: any[]): Promise { +export function getRootModule (plugins: any[]) { let imports = [ BrowserModule, ...(plugins.map(x => x.default.forRoot ? x.default.forRoot() : x.default)), diff --git a/app/src/entry.preload.ts b/app/src/entry.preload.ts index 8478ea2f..9a2987a3 100644 --- a/app/src/entry.preload.ts +++ b/app/src/entry.preload.ts @@ -31,3 +31,7 @@ process.on('uncaughtException', (err) => { Raven.captureException(err) console.error(err) }) + +const childProcess = require('child_process') +childProcess.spawn = require('electron').remote.require('child_process').spawn +childProcess.exec = require('electron').remote.require('child_process').exec diff --git a/app/src/entry.ts b/app/src/entry.ts index a564f756..5f7e6c40 100644 --- a/app/src/entry.ts +++ b/app/src/entry.ts @@ -6,13 +6,13 @@ import 'rxjs' // Always land on the start view location.hash = '' -import { enableProdMode } from '@angular/core' +import { enableProdMode, NgModuleRef } from '@angular/core' import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' import { getRootModule } from './app.module' -import { findPlugins, loadPlugins } from './plugins' +import { findPlugins, loadPlugins, IPluginInfo } from './plugins' -if (process.platform == 'win32') { +if (process.platform === 'win32') { process.env.HOME = process.env.HOMEDRIVE + process.env.HOMEPATH } @@ -22,10 +22,29 @@ if (require('electron-is-dev')) { enableProdMode() } -findPlugins().then(async plugins => { +async function bootstrap (plugins: IPluginInfo[], safeMode = false): Promise> { + if (safeMode) { + plugins = plugins.filter(x => x.isBuiltin) + } let pluginsModules = await loadPlugins(plugins, (current, total) => { (document.querySelector('.progress .bar') as HTMLElement).style.width = 100 * current / total + '%' }) - let module = await getRootModule(pluginsModules) - platformBrowserDynamic().bootstrapModule(module) + let module = getRootModule(pluginsModules) + return await platformBrowserDynamic().bootstrapModule(module) +} + +findPlugins().then(async plugins => { + console.log('Starting with plugins:', plugins) + try { + await bootstrap(plugins) + } catch (error) { + console.error('Angular bootstrapping error:', error) + console.warn('Trying safe mode') + window['safeModeReason'] = error + try { + await bootstrap(plugins, true) + } catch (error) { + console.error('Bootstrap failed:', error) + } + } }) diff --git a/app/src/logo.svg b/app/src/logo.svg new file mode 100644 index 00000000..ca74f3ab --- /dev/null +++ b/app/src/logo.svg @@ -0,0 +1,89 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/app/src/plugins.ts b/app/src/plugins.ts index 60072267..21a4200f 100644 --- a/app/src/plugins.ts +++ b/app/src/plugins.ts @@ -20,7 +20,7 @@ if (process.env.DEV) { nodeModule.globalPaths.unshift(path.dirname(require('electron').remote.app.getAppPath())) } -const builtinPluginsPath = path.join((process as any).resourcesPath, 'builtin-plugins') +const builtinPluginsPath = process.env.DEV ? path.dirname(require('electron').remote.app.getAppPath()) : path.join((process as any).resourcesPath, 'builtin-plugins') const userPluginsPath = path.join( require('electron').remote.app.getPath('appData'), @@ -108,8 +108,9 @@ export async function findPlugins (): Promise { continue } - if (foundPlugins.some(x => x.name === pluginName)) { - console.info(`Plugin ${pluginName} already exists`) + if (foundPlugins.some(x => x.name === pluginName.substring('terminus-'.length))) { + console.info(`Plugin ${pluginName} already exists, overriding`) + foundPlugins = foundPlugins.filter(x => x.name !== pluginName.substring('terminus-'.length)) } try { diff --git a/app/src/preload.scss b/app/src/preload.scss index c5a1d0be..95742e57 100644 --- a/app/src/preload.scss +++ b/app/src/preload.scss @@ -1,6 +1,3 @@ -$color: rgba(66, 142, 173, 0.75); - - .preload-logo { -webkit-app-region: drag; position: fixed; @@ -24,7 +21,7 @@ $color: rgba(66, 142, 173, 0.75); .bar { transition: 1s ease-out width; - background: $color; + background: #a1c5e4; height: 3px; } } @@ -42,63 +39,22 @@ $color: rgba(66, 142, 173, 0.75); .terminus-logo { width: 160px; height: 160px; + background: url('./logo.svg'); + background-repeat: none; + background-size: contain; margin: auto; - position: relative; - transform: rotateZ(-14.5deg); - - .part { - position: absolute; - width: 160px; - height: 160px; - - div { - position: absolute; - top: 33px; - left: 24px; - width: 44px; - height: 44px; - background: $color; - transform: rotateX(52deg) rotateY(-42deg); - animation: terminusLogoPartOnce ease-out 1s; - } - } - - &.animated .part div { - animation: terminusLogoPart infinite ease-out 2s; - } } .terminus-title { - color: $color; + color: #a1c5e4; font-family: 'Source Sans Pro'; text-align: center; font-weight: normal; font-size: 42px; margin: 0; -} - -@keyframes terminusLogoPart { - 0% { - transform: rotateX(90deg) rotateY(-90deg); - } - 25% { - transform: rotateX(52deg) rotateY(-42deg); - } - 75% { - transform: rotateX(52deg) rotateY(-42deg); - } - 100% { - transform: rotateX(-90deg) rotateY(-90deg); - } -} - -@keyframes terminusLogoPartOnce { - 0% { - transform: rotateX(90deg) rotateY(-90deg); - } - 100% { - transform: rotateX(52deg) rotateY(-42deg); + sup { + color: #842fe0; } } diff --git a/app/webpack.config.js b/app/webpack.config.js index 0c44a674..8fccad2a 100644 --- a/app/webpack.config.js +++ b/app/webpack.config.js @@ -55,6 +55,7 @@ module.exports = { '@angular/forms': 'commonjs @angular/forms', '@angular/common': 'commonjs @angular/common', '@ng-bootstrap/ng-bootstrap': 'commonjs @ng-bootstrap/ng-bootstrap', + 'child_process': 'commonjs child_process', 'electron': 'commonjs electron', 'electron-is-dev': 'commonjs electron-is-dev', 'module': 'commonjs module', diff --git a/app/yarn.lock b/app/yarn.lock index 3da0d71c..b1c274f8 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2,37 +2,51 @@ # yarn lockfile v1 -"@angular/animations@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-4.0.1.tgz#154420c8ee5c22fbaf1434b6d156150cf5218da6" +"@angular/animations@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-4.3.0.tgz#56f34b84649379202ac359929b82eb0b915e9c72" + dependencies: + tslib "^1.7.1" -"@angular/common@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-4.0.1.tgz#df488eada842b2d841ded750712292b18387b5b0" +"@angular/common@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-4.3.0.tgz#13a54a6929dd52f9729b16ae446fad58fe163053" + dependencies: + tslib "^1.7.1" -"@angular/compiler@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-4.0.1.tgz#15721edb148167a2d83b6f9324817e658eac8280" +"@angular/compiler@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-4.3.0.tgz#55503bf27a1f062f71b9495393f3311903a8fc43" + dependencies: + tslib "^1.7.1" -"@angular/core@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-4.0.1.tgz#0b110a001012076ea696460ccd922707bcdf51ba" +"@angular/core@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-4.3.0.tgz#bd2249c3de1224a7c6536c4aba728d6565329334" + dependencies: + tslib "^1.7.1" -"@angular/forms@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-4.0.1.tgz#b9ebdbbb8ace0f9a3bf9e53c299eafdfab1d5041" +"@angular/forms@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-4.3.0.tgz#7d0c7a854737e9a30a5fd9665f8d4f56a1b91bd8" + dependencies: + tslib "^1.7.1" -"@angular/platform-browser-dynamic@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-4.0.1.tgz#fd5debb2d3f6474350965e71c2674e2170d7cfcb" +"@angular/platform-browser-dynamic@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-4.3.0.tgz#551fb18851b27ee8f3e4b0ee25aad10bd7b312e3" + dependencies: + tslib "^1.7.1" -"@angular/platform-browser@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-4.0.1.tgz#4b9efbeb2fbb900de188743b988802d3aa2b33ff" +"@angular/platform-browser@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-4.3.0.tgz#02389489185185c3becf06359346100e5479c7e1" + dependencies: + tslib "^1.7.1" -"@ng-bootstrap/ng-bootstrap@1.0.0-alpha.22": - version "1.0.0-alpha.22" - resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-1.0.0-alpha.22.tgz#aaad058cc39293ea6184e4b9b849f298c0b11a86" +"@ng-bootstrap/ng-bootstrap@^1.0.0-alpha.28": + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-1.0.0-alpha.28.tgz#30a6503bf7f94f9d3187591fb3267b59cc0cdaad" "@types/mz@0.0.31": version "0.0.31" @@ -41,8 +55,8 @@ "@types/node" "*" "@types/node@*": - version "8.0.7" - resolved "https://registry.yarnpkg.com/@types/node/-/node-8.0.7.tgz#fb0ad04b5b6f6eabe0372a32a8f1fbba5c130cae" + version "8.0.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-8.0.13.tgz#530f0f9254209b0335bf5cc6387822594ef47093" accessibility-developer-tools@^2.11.0: version "2.12.0" @@ -244,12 +258,16 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +tslib@^1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.7.1.tgz#bc8004164691923a79fe8378bbeb3da2017538ec" + util@^0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" dependencies: inherits "2.0.1" -zone.js@0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.8.4.tgz#cc40ae5a1c879601c5ebba2096b5c80f0c4c3602" +zone.js@0.8.12: + version "0.8.12" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.8.12.tgz#86ff5053c98aec291a0bf4bbac501d694a05cfbb" diff --git a/build/icons/128x128.png b/build/icons/128x128.png index 5776baf9..9e9f87a4 100644 Binary files a/build/icons/128x128.png and b/build/icons/128x128.png differ diff --git a/build/icons/16x16.png b/build/icons/16x16.png index e630b2a4..497c87ac 100644 Binary files a/build/icons/16x16.png and b/build/icons/16x16.png differ diff --git a/build/icons/256x256.png b/build/icons/256x256.png index f9eb7a65..909b2fd2 100644 Binary files a/build/icons/256x256.png and b/build/icons/256x256.png differ diff --git a/build/icons/32x32.png b/build/icons/32x32.png index ee79ae12..713bdee6 100644 Binary files a/build/icons/32x32.png and b/build/icons/32x32.png differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png index 26c650b8..3fb2fcd5 100644 Binary files a/build/icons/512x512.png and b/build/icons/512x512.png differ diff --git a/build/icons/64x64.png b/build/icons/64x64.png index bc990f65..2efed529 100644 Binary files a/build/icons/64x64.png and b/build/icons/64x64.png differ diff --git a/build/icons/icon.svg b/build/icons/icon.svg new file mode 100644 index 00000000..d1b5bc68 --- /dev/null +++ b/build/icons/icon.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/build/mac/icon.icns b/build/mac/icon.icns index fa396ca1..714ac05e 100644 Binary files a/build/mac/icon.icns and b/build/mac/icon.icns differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico index 2f63a466..a7ec161a 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/docs/linux.png b/docs/linux.png index 59568a0a..30d04f9f 100644 Binary files a/docs/linux.png and b/docs/linux.png differ diff --git a/package.json b/package.json index d26aab90..77134e4c 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,8 @@ "libnotify4", "libappindicator1", "libxtst6", - "libnss3" + "libnss3", + "tmux" ] }, "rpm": { diff --git a/scripts/install-deps.js b/scripts/install-deps.js index 8291250b..59351bbe 100755 --- a/scripts/install-deps.js +++ b/scripts/install-deps.js @@ -5,21 +5,18 @@ const vars = require('./vars') const log = require('npmlog') log.info('deps', 'app') -sh.exec('npm prune') -sh.exec('npm install') -sh.exec('npm update --dev') +sh.exec('yarn prune') +sh.exec('yarn install') sh.cd('app') -sh.exec('npm prune') -sh.exec('npm install') -sh.exec('npm update --dev') +sh.exec('yarn prune') +sh.exec('yarn install') sh.cd('..') vars.builtinPlugins.forEach(plugin => { log.info('deps', plugin) sh.cd(plugin) - sh.exec('npm prune') - sh.exec('npm install') - sh.exec('npm update --dev') + sh.exec('yarn prune') + sh.exec('yarn install') sh.cd('..') }) diff --git a/terminus-community-color-schemes/package.json b/terminus-community-color-schemes/package.json index 50cf28bf..0ca2a5d3 100644 --- a/terminus-community-color-schemes/package.json +++ b/terminus-community-color-schemes/package.json @@ -1,6 +1,6 @@ { "name": "terminus-community-color-schemes", - "version": "1.0.0-alpha.16-8-gfc060ac", + "version": "1.0.0-alpha.24", "description": "Community color schemes for Terminus", "keywords": [ "terminus-plugin" diff --git a/terminus-core/package.json b/terminus-core/package.json index 36c1d903..bec38558 100644 --- a/terminus-core/package.json +++ b/terminus-core/package.json @@ -1,6 +1,6 @@ { "name": "terminus-core", - "version": "1.0.0-alpha.16-8-gfc060ac", + "version": "1.0.0-alpha.24", "description": "Terminus core", "keywords": [ "terminus-plugin" @@ -17,14 +17,13 @@ "author": "Eugene Pankov", "license": "MIT", "devDependencies": { - "@ng-bootstrap/ng-bootstrap": "1.0.0-alpha.22", - "@types/js-yaml": "^3.5.29", - "@types/node": "^7.0.12", + "@types/js-yaml": "^3.9.0", + "@types/node": "^7.0.37", "@types/webpack-env": "^1.13.0", "bootstrap": "4.0.0-alpha.6", "core-js": "^2.4.1", "ngx-perfect-scrollbar": "4.0.0", - "typescript": "^2.4.0" + "typescript": "^2.4.1" }, "peerDependencies": { "@angular/animations": "4.0.1", @@ -37,8 +36,8 @@ "zone.js": "0.8.4" }, "dependencies": { - "deepmerge": "^1.4.4", - "js-yaml": "^3.8.4" + "deepmerge": "^1.5.0", + "js-yaml": "^3.9.0" }, "false": {} } diff --git a/terminus-core/src/api/index.ts b/terminus-core/src/api/index.ts index 195ab8f5..fb59cd26 100644 --- a/terminus-core/src/api/index.ts +++ b/terminus-core/src/api/index.ts @@ -1,5 +1,5 @@ export { BaseTabComponent } from '../components/baseTab.component' -export { TabRecoveryProvider } from './tabRecovery' +export { TabRecoveryProvider, RecoveredTab } from './tabRecovery' export { ToolbarButtonProvider, IToolbarButton } from './toolbarButtonProvider' export { ConfigProvider } from './configProvider' export { HotkeyProvider, IHotkeyDescription } from './hotkeyProvider' diff --git a/terminus-core/src/api/tabRecovery.ts b/terminus-core/src/api/tabRecovery.ts index d5f23220..f3473e77 100644 --- a/terminus-core/src/api/tabRecovery.ts +++ b/terminus-core/src/api/tabRecovery.ts @@ -1,3 +1,10 @@ -export abstract class TabRecoveryProvider { - abstract async recover (recoveryToken: any): Promise +import { TabComponentType } from '../services/app.service' + +export interface RecoveredTab { + type: TabComponentType, + options?: any, +} + +export abstract class TabRecoveryProvider { + abstract async recover (recoveryToken: any): Promise } diff --git a/terminus-core/src/components/appRoot.component.pug b/terminus-core/src/components/appRoot.component.pug index c9832dc3..6a63790f 100644 --- a/terminus-core/src/components/appRoot.component.pug +++ b/terminus-core/src/components/appRoot.component.pug @@ -19,7 +19,7 @@ title-bar( [class.drag-region]='hostApp.platform == Platform.macOS', @animateTab, (click)='app.selectTab(tab)', - (closeClicked)='app.closeTab(tab)', + (closeClicked)='app.closeTab(tab, true)', ) .btn-group diff --git a/terminus-core/src/components/appRoot.component.ts b/terminus-core/src/components/appRoot.component.ts index d57e51db..30940085 100644 --- a/terminus-core/src/components/appRoot.component.ts +++ b/terminus-core/src/components/appRoot.component.ts @@ -1,16 +1,17 @@ import { Component, Inject, Input, HostListener } from '@angular/core' import { trigger, style, animate, transition, state } from '@angular/animations' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { ElectronService } from '../services/electron.service' import { HostAppService, Platform } from '../services/hostApp.service' import { HotkeysService } from '../services/hotkeys.service' import { Logger, LogService } from '../services/log.service' -import { QuitterService } from '../services/quitter.service' import { ConfigService } from '../services/config.service' import { DockingService } from '../services/docking.service' import { TabRecoveryService } from '../services/tabRecovery.service' import { ThemesService } from '../services/themes.service' +import { SafeModeModalComponent } from './safeModeModal.component' import { AppService, IToolbarButton, ToolbarButtonProvider } from '../api' @Component({ @@ -28,9 +29,16 @@ import { AppService, IToolbarButton, ToolbarButtonProvider } from '../api' 'flex-basis': '1px', 'width': '1px', }), - animate('250ms ease-in-out') + animate('250ms ease-in-out', style({ + 'flex-basis': '200px', + 'width': '200px', + })) ]), transition(':leave', [ + style({ + 'flex-basis': '200px', + 'width': '200px', + }), animate('250ms ease-in-out', style({ 'flex-basis': '1px', 'width': '1px', @@ -56,8 +64,8 @@ export class AppRootComponent { public app: AppService, @Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[], log: LogService, + ngbModal: NgbModal, _themes: ThemesService, - _quitter: QuitterService, ) { this.logger = log.create('main') this.logger.info('v', electron.app.getVersion()) @@ -74,7 +82,7 @@ export class AppRootComponent { } if (this.app.activeTab) { if (hotkey === 'close-tab') { - this.app.closeTab(this.app.activeTab) + this.app.closeTab(this.app.activeTab, true) } if (hotkey === 'toggle-last-tab') { this.app.toggleLastTab() @@ -99,6 +107,10 @@ export class AppRootComponent { this.hotkeys.globalHotkey.subscribe(() => { this.onGlobalHotkey() }) + + if (window['safeModeReason']) { + ngbModal.open(SafeModeModalComponent) + } } onGlobalHotkey () { @@ -133,16 +145,6 @@ export class AppRootComponent { } } - private getToolbarButtons (aboveZero: boolean): IToolbarButton[] { - let buttons: IToolbarButton[] = [] - this.toolbarButtonProviders.forEach((provider) => { - buttons = buttons.concat(provider.provide()) - }) - return buttons - .filter((button) => (button.weight > 0) === aboveZero) - .sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0)) - } - @HostListener('dragover') onDragOver () { return false @@ -152,4 +154,14 @@ export class AppRootComponent { onDrop () { return false } + + private getToolbarButtons (aboveZero: boolean): IToolbarButton[] { + let buttons: IToolbarButton[] = [] + this.toolbarButtonProviders.forEach((provider) => { + buttons = buttons.concat(provider.provide()) + }) + return buttons + .filter((button) => (button.weight > 0) === aboveZero) + .sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0)) + } } diff --git a/terminus-core/src/components/baseTab.component.ts b/terminus-core/src/components/baseTab.component.ts index 1e24a7d1..77a15538 100644 --- a/terminus-core/src/components/baseTab.component.ts +++ b/terminus-core/src/components/baseTab.component.ts @@ -1,10 +1,11 @@ -import { Subject, BehaviorSubject } from 'rxjs' +import { Subject } from 'rxjs' import { ViewRef } from '@angular/core' export abstract class BaseTabComponent { private static lastTabID = 0 id: number - title$ = new BehaviorSubject(null) + title: string + customTitle: string scrollable: boolean hasActivity = false focused$ = new Subject() @@ -30,9 +31,12 @@ export abstract class BaseTabComponent { return null } + async canClose (): Promise { + return true + } + destroy (): void { this.focused$.complete() this.blurred$.complete() - this.title$.complete() } } diff --git a/terminus-core/src/components/renameTabModal.component.pug b/terminus-core/src/components/renameTabModal.component.pug new file mode 100644 index 00000000..901ceb22 --- /dev/null +++ b/terminus-core/src/components/renameTabModal.component.pug @@ -0,0 +1,6 @@ +.modal-body + input.form-control(type='text', [(ngModel)]='value', (keyup.enter)='save()', autofocus) + +.modal-footer + button.btn.btn-outline-primary((click)='save()') Save + button.btn.btn-outline-secondary((click)='close()') Cancel diff --git a/terminus-core/src/components/renameTabModal.component.ts b/terminus-core/src/components/renameTabModal.component.ts new file mode 100644 index 00000000..bbca5daa --- /dev/null +++ b/terminus-core/src/components/renameTabModal.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' + +@Component({ + selector: 'rename-tab-modal', + template: require('./renameTabModal.component.pug'), +}) +export class RenameTabModalComponent { + @Input() value: string + + constructor ( + private modalInstance: NgbActiveModal + ) { } + + save () { + this.modalInstance.close(this.value) + } + + close () { + this.modalInstance.dismiss() + } +} diff --git a/terminus-core/src/components/safeModeModal.component.pug b/terminus-core/src/components/safeModeModal.component.pug new file mode 100644 index 00000000..5d55cc7c --- /dev/null +++ b/terminus-core/src/components/safeModeModal.component.pug @@ -0,0 +1,7 @@ +.modal-body + .alert.alert-danger Terminus could not start with your plugins, so all third party plugins have been disabled in this session. The error was: + + pre {{error}} + +.modal-footer + button.btn.btn-outline-primary((click)='close()') Close diff --git a/terminus-core/src/components/safeModeModal.component.ts b/terminus-core/src/components/safeModeModal.component.ts new file mode 100644 index 00000000..33a5b605 --- /dev/null +++ b/terminus-core/src/components/safeModeModal.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' + +@Component({ + template: require('./safeModeModal.component.pug'), +}) +export class SafeModeModalComponent { + @Input() error: Error + + constructor ( + public modalInstance: NgbActiveModal, + ) { + this.error = window['safeModeReason'] + } + + close () { + this.modalInstance.dismiss() + } +} diff --git a/terminus-core/src/components/startPage.component.pug b/terminus-core/src/components/startPage.component.pug index 50ae8ef8..73e071f5 100644 --- a/terminus-core/src/components/startPage.component.pug +++ b/terminus-core/src/components/startPage.component.pug @@ -1,28 +1,15 @@ div .terminus-logo - .part(style='transform: rotateZ(0deg)') - div - .part(style='transform: rotateZ(51deg)') - div - .part(style='transform: rotateZ(102deg)') - div - .part(style='transform: rotateZ(154deg)') - div - .part(style='transform: rotateZ(205deg)') - div - .part(style='transform: rotateZ(257deg)') - div - .part(style='transform: rotateZ(308deg)') - div h1.terminus-title Terminus - span.text-muted α + sup α - button.btn.btn-primary.btn-lg.btn-block( - *ngFor='let button of getButtons()', - (click)='button.click()', - ) - i.fa([class]='"fa fa-" + button.icon') - span {{button.title}} + .list-group + a.list-group-item.list-group-item-action( + *ngFor='let button of getButtons()', + (click)='button.click()', + ) + i([class]='"fa fa-fw fa-" + button.icon') + span {{button.title}} footer .pull-right diff --git a/terminus-core/src/components/startPage.component.scss b/terminus-core/src/components/startPage.component.scss index 315e81de..13fcace1 100644 --- a/terminus-core/src/components/startPage.component.scss +++ b/terminus-core/src/components/startPage.component.scss @@ -24,6 +24,6 @@ footer { background: rgba(0,0,0,.5); } -button { +a, button { -webkit-app-region: no-drag; } diff --git a/terminus-core/src/components/tabHeader.component.pug b/terminus-core/src/components/tabHeader.component.pug index c4eaf3bc..7db00c35 100644 --- a/terminus-core/src/components/tabHeader.component.pug +++ b/terminus-core/src/components/tabHeader.component.pug @@ -1,3 +1,3 @@ .index {{index + 1}} -.name {{tab.title$ | async}} +.name([title]='tab.customTitle || tab.title') {{tab.customTitle || tab.title}} button((click)='closeClicked.emit()') × diff --git a/terminus-core/src/components/tabHeader.component.ts b/terminus-core/src/components/tabHeader.component.ts index f0c65524..0b47b1c8 100644 --- a/terminus-core/src/components/tabHeader.component.ts +++ b/terminus-core/src/components/tabHeader.component.ts @@ -1,5 +1,7 @@ import { Component, Input, Output, EventEmitter, HostBinding, HostListener } from '@angular/core' -import { BaseTabComponent } from '../components/baseTab.component' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { BaseTabComponent } from './baseTab.component' +import { RenameTabModalComponent } from './renameTabModal.component' @Component({ selector: 'tab-header', @@ -13,8 +15,20 @@ export class TabHeaderComponent { @Input() tab: BaseTabComponent @Output() closeClicked = new EventEmitter() - @HostListener('auxclick', ['$event']) onClick ($event: MouseEvent): void { - if ($event.which == 2) { + constructor ( + private ngbModal: NgbModal, + ) { } + + @HostListener('dblclick') onDoubleClick (): void { + let modal = this.ngbModal.open(RenameTabModalComponent) + modal.componentInstance.value = this.tab.customTitle || this.tab.title + modal.result.then(result => { + this.tab.customTitle = result + }).catch(() => null) + } + + @HostListener('auxclick', ['$event']) onAuxClick ($event: MouseEvent): void { + if ($event.which === 2) { this.closeClicked.emit() } } diff --git a/terminus-core/src/index.ts b/terminus-core/src/index.ts index e5a0d9ee..f222abdb 100644 --- a/terminus-core/src/index.ts +++ b/terminus-core/src/index.ts @@ -11,17 +11,18 @@ import { ElectronService } from './services/electron.service' import { HostAppService } from './services/hostApp.service' import { LogService } from './services/log.service' import { HotkeysService, AppHotkeyProvider } from './services/hotkeys.service' -import { QuitterService } from './services/quitter.service' import { DockingService } from './services/docking.service' import { TabRecoveryService } from './services/tabRecovery.service' import { ThemesService } from './services/themes.service' import { AppRootComponent } from './components/appRoot.component' import { TabBodyComponent } from './components/tabBody.component' +import { SafeModeModalComponent } from './components/safeModeModal.component' import { StartPageComponent } from './components/startPage.component' import { TabHeaderComponent } from './components/tabHeader.component' import { TitleBarComponent } from './components/titleBar.component' import { WindowControlsComponent } from './components/windowControls.component' +import { RenameTabModalComponent } from './components/renameTabModal.component' import { HotkeyProvider } from './api/hotkeyProvider' import { ConfigProvider } from './api/configProvider' @@ -42,7 +43,6 @@ const PROVIDERS = [ LogService, TabRecoveryService, ThemesService, - QuitterService, { provide: HotkeyProvider, useClass: AppHotkeyProvider, multi: true }, { provide: Theme, useClass: StandardTheme, multi: true }, { provide: ConfigProvider, useClass: CoreConfigProvider, multi: true }, @@ -65,7 +65,13 @@ const PROVIDERS = [ TabHeaderComponent, TitleBarComponent, WindowControlsComponent, + RenameTabModalComponent, + SafeModeModalComponent, ], + entryComponents: [ + RenameTabModalComponent, + SafeModeModalComponent, + ] }) export default class AppModule { static forRoot (): ModuleWithProviders { diff --git a/terminus-core/src/services/app.service.ts b/terminus-core/src/services/app.service.ts index fe72f0bb..757cae87 100644 --- a/terminus-core/src/services/app.service.ts +++ b/terminus-core/src/services/app.service.ts @@ -82,10 +82,16 @@ export class AppService { } } - closeTab (tab: BaseTabComponent) { + async closeTab (tab: BaseTabComponent, checkCanClose?: boolean): Promise { + if (!this.tabs.includes(tab)) { + return + } + if (checkCanClose && !await tab.canClose()) { + return + } + this.tabs = this.tabs.filter((x) => x !== tab) tab.destroy() let newIndex = Math.max(0, this.tabs.indexOf(tab) - 1) - this.tabs = this.tabs.filter((x) => x !== tab) if (tab === this.activeTab) { this.selectTab(this.tabs[newIndex]) } diff --git a/terminus-core/src/services/config.service.ts b/terminus-core/src/services/config.service.ts index bfe9e923..c261f1ee 100644 --- a/terminus-core/src/services/config.service.ts +++ b/terminus-core/src/services/config.service.ts @@ -38,7 +38,7 @@ export class ConfigProxy { { enumerable: true, configurable: false, - get: () => real[key] || defaults[key], + get: () => (real[key] !== undefined) ? real[key] : defaults[key], set: (value) => { real[key] = value } diff --git a/terminus-core/src/services/docking.service.ts b/terminus-core/src/services/docking.service.ts index 933232a7..c327cde6 100644 --- a/terminus-core/src/services/docking.service.ts +++ b/terminus-core/src/services/docking.service.ts @@ -40,12 +40,12 @@ export class DockingService { newBounds.height = Math.round(fill * display.bounds.height) } if (dockSide === 'right') { - newBounds.x = display.bounds.x + Math.round(display.bounds.width * (1.0 - fill)) + newBounds.x = display.bounds.x + display.bounds.width - newBounds.width } else { newBounds.x = display.bounds.x } if (dockSide === 'bottom') { - newBounds.y = display.bounds.y + Math.round(display.bounds.height * (1.0 - fill)) + newBounds.y = display.bounds.y + display.bounds.height - newBounds.height } else { newBounds.y = display.bounds.y } diff --git a/terminus-core/src/services/electron.service.ts b/terminus-core/src/services/electron.service.ts index 96214da3..3be9d648 100644 --- a/terminus-core/src/services/electron.service.ts +++ b/terminus-core/src/services/electron.service.ts @@ -27,4 +27,8 @@ export class ElectronService { remoteRequire (name: string): any { return this.remote.require(name) } + + remoteRequirePluginModule (plugin: string, module: string, globals: any): any { + return this.remoteRequire(globals.require.resolve(`${plugin}/node_modules/${module}`)) + } } diff --git a/terminus-core/src/services/hostApp.service.ts b/terminus-core/src/services/hostApp.service.ts index 5c066963..4eccd72c 100644 --- a/terminus-core/src/services/hostApp.service.ts +++ b/terminus-core/src/services/hostApp.service.ts @@ -18,7 +18,6 @@ export interface Bounds { export class HostAppService { platform: Platform nodePlatform: string - quitRequested = new EventEmitter() preferencesMenu$ = new Subject() ready = new EventEmitter() shown = new EventEmitter() @@ -39,7 +38,6 @@ export class HostAppService { linux: Platform.Linux }[this.nodePlatform] - electron.ipcRenderer.on('host:quit-request', () => this.zone.run(() => this.quitRequested.emit())) electron.ipcRenderer.on('host:preferences-menu', () => this.zone.run(() => this.preferencesMenu$.next())) electron.ipcRenderer.on('uncaughtException', ($event, err) => { @@ -79,10 +77,6 @@ export class HostAppService { this.getWindow().webContents.openDevTools() } - setCloseable (flag: boolean) { - this.electron.ipcRenderer.send('window-set-closeable', flag) - } - focusWindow () { this.electron.ipcRenderer.send('window-focus') } diff --git a/terminus-core/src/services/log.service.ts b/terminus-core/src/services/log.service.ts index a5931b50..fa3b4b10 100644 --- a/terminus-core/src/services/log.service.ts +++ b/terminus-core/src/services/log.service.ts @@ -5,14 +5,15 @@ export class Logger { private name: string, ) {} - log (level: string, ...args: any[]) { + doLog (level: string, ...args: any[]) { console[level](`%c[${this.name}]`, 'color: #aaa', ...args) } - debug (...args: any[]) { this.log('debug', ...args) } - info (...args: any[]) { this.log('info', ...args) } - warn (...args: any[]) { this.log('warn', ...args) } - error (...args: any[]) { this.log('error', ...args) } + debug (...args: any[]) { this.doLog('debug', ...args) } + info (...args: any[]) { this.doLog('info', ...args) } + warn (...args: any[]) { this.doLog('warn', ...args) } + error (...args: any[]) { this.doLog('error', ...args) } + log (...args: any[]) { this.doLog('log', ...args) } } @Injectable() diff --git a/terminus-core/src/services/quitter.service.ts b/terminus-core/src/services/quitter.service.ts deleted file mode 100644 index 5c8f8c65..00000000 --- a/terminus-core/src/services/quitter.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@angular/core' -import { HostAppService } from '../services/hostApp.service' - -@Injectable() -export class QuitterService { - constructor ( - private hostApp: HostAppService, - ) { - hostApp.quitRequested.subscribe(() => { - this.quit() - }) - } - - quit () { - this.hostApp.setCloseable(true) - this.hostApp.quit() - } -} diff --git a/terminus-core/src/services/tabRecovery.service.ts b/terminus-core/src/services/tabRecovery.service.ts index 2661fbe8..4ce8e37f 100644 --- a/terminus-core/src/services/tabRecovery.service.ts +++ b/terminus-core/src/services/tabRecovery.service.ts @@ -1,5 +1,5 @@ import { Injectable, Inject } from '@angular/core' -import { TabRecoveryProvider } from '../api/tabRecovery' +import { TabRecoveryProvider, RecoveredTab } from '../api/tabRecovery' import { BaseTabComponent } from '../components/baseTab.component' import { Logger, LogService } from '../services/log.service' import { AppService } from '../services/app.service' @@ -10,7 +10,7 @@ export class TabRecoveryService { constructor ( @Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[], - app: AppService, + private app: AppService, log: LogService ) { this.logger = log.create('tabRecovery') @@ -29,15 +29,22 @@ export class TabRecoveryService { async recoverTabs (): Promise { if (window.localStorage.tabsRecovery) { + let tabs: RecoveredTab[] = [] for (let token of JSON.parse(window.localStorage.tabsRecovery)) { for (let provider of this.tabRecoveryProviders) { try { - await provider.recover(token) + let tab = await provider.recover(token) + if (tab) { + tabs.push(tab) + } } catch (error) { this.logger.warn('Tab recovery crashed:', token, provider, error) } } } + tabs.forEach(tab => { + this.app.openNewTab(tab.type, tab.options) + }) } } diff --git a/terminus-core/src/services/themes.service.ts b/terminus-core/src/services/themes.service.ts index 6e052664..5b199150 100644 --- a/terminus-core/src/services/themes.service.ts +++ b/terminus-core/src/services/themes.service.ts @@ -13,7 +13,6 @@ export class ThemesService { this.applyCurrentTheme() config.changed$.subscribe(() => { this.applyCurrentTheme() - document.querySelector('style#custom-css').innerHTML = config.store.appearance.css }) } @@ -32,6 +31,7 @@ export class ThemesService { document.querySelector('head').appendChild(this.styleElement) } this.styleElement.textContent = theme.css + document.querySelector('style#custom-css').innerHTML = this.config.store.appearance.css } applyCurrentTheme (): void { diff --git a/terminus-core/src/theme.scss b/terminus-core/src/theme.scss index 2eb09bd7..93d13eee 100644 --- a/terminus-core/src/theme.scss +++ b/terminus-core/src/theme.scss @@ -23,6 +23,7 @@ $body-color: #aaa; $font-family-sans-serif: "Source Sans Pro"; $font-size-base: 14rem / 16; +$btn-border-radius: 0; $btn-secondary-color: #ccc; $btn-secondary-bg: #222; $btn-secondary-border: #444; @@ -70,7 +71,18 @@ $dropdown-link-disabled-color: #333; $dropdown-header-color: #333; $list-group-color: $body-color; -$list-group-bg: $body-bg2; +$list-group-bg: rgba(255,255,255,.05); +$list-group-border-color: rgba(255,255,255,.1); +$list-group-hover-bg: rgba(255,255,255,.1); +$list-group-link-active-bg: rgba(255,255,255,.2); + +$pre-bg: $dropdown-bg; +$pre-color: $dropdown-link-color; + +$alert-danger-bg: $body-bg2; +$alert-danger-text: $red; +$alert-danger-border: $red; + @import '~bootstrap/scss/bootstrap.scss'; @@ -270,12 +282,6 @@ hotkey-input-modal { } } -start-page { - .terminus-title { - color: $blue; - } -} - .form-group label { margin-bottom: 2px; } @@ -313,3 +319,11 @@ ngb-tabset .tab-content { .input-group > select.form-control { flex-direction: row; } + +.list-group-item { + transition: 0.25s background; + + i + * { + margin-left: 10px; + } +} diff --git a/terminus-plugin-manager/package.json b/terminus-plugin-manager/package.json index 4225e4ed..6a14dc2f 100644 --- a/terminus-plugin-manager/package.json +++ b/terminus-plugin-manager/package.json @@ -1,6 +1,6 @@ { "name": "terminus-plugin-manager", - "version": "1.0.0-alpha.16-8-gfc060ac", + "version": "1.0.0-alpha.24", "description": "Terminus' plugin manager", "keywords": [ "terminus-plugin" @@ -19,10 +19,10 @@ "devDependencies": { "@types/mz": "0.0.31", "@types/node": "7.0.12", - "@types/semver": "^5.3.31", + "@types/semver": "^5.3.32", "@types/webpack-env": "1.13.0", - "ngx-pipes": "^1.6.1", "css-loader": "^0.28.0", + "ngx-pipes": "^1.6.1", "semver": "^5.3.0" }, "peerDependencies": { diff --git a/terminus-settings/package.json b/terminus-settings/package.json index ae878982..2de0e6f0 100644 --- a/terminus-settings/package.json +++ b/terminus-settings/package.json @@ -1,6 +1,6 @@ { "name": "terminus-settings", - "version": "1.0.0-alpha.16-8-gfc060ac", + "version": "1.0.0-alpha.24", "description": "Terminus terminal settings page", "keywords": [ "terminus-plugin" diff --git a/terminus-settings/src/components/settingsTab.component.pug b/terminus-settings/src/components/settingsTab.component.pug index e20606a3..57d5a90a 100644 --- a/terminus-settings/src/components/settingsTab.component.pug +++ b/terminus-settings/src/components/settingsTab.component.pug @@ -2,9 +2,9 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic ngb-tabset.vertical(type='tabs') ngb-tab - template(ngbTabTitle) + ng-template(ngbTabTitle) | Application - template(ngbTabContent) + ng-template(ngbTabContent) .row .col.col-lg-6 .form-group @@ -153,9 +153,9 @@ ngb-tabset.vertical(type='tabs') ) ngb-tab - template(ngbTabTitle) + ng-template(ngbTabTitle) | Hotkeys - template(ngbTabContent) + ng-template(ngbTabContent) input.form-control(type='search', placeholder='Search hotkeys', [(ngModel)]='hotkeyFilter') .form-group table.hotkeys-table @@ -173,7 +173,7 @@ ngb-tabset.vertical(type='tabs') ) ngb-tab(*ngFor='let provider of settingsProviders') - template(ngbTabTitle) + ng-template(ngbTabTitle) | {{provider.title}} - template(ngbTabContent) + ng-template(ngbTabContent) settings-tab-body([provider]='provider') diff --git a/terminus-settings/src/components/settingsTab.component.ts b/terminus-settings/src/components/settingsTab.component.ts index e3733121..00b83c93 100644 --- a/terminus-settings/src/components/settingsTab.component.ts +++ b/terminus-settings/src/components/settingsTab.component.ts @@ -27,7 +27,7 @@ export class SettingsTabComponent extends BaseTabComponent { ) { super() this.hotkeyDescriptions = hotkeyProviders.map(x => x.hotkeys).reduce((a, b) => a.concat(b)) - this.title$.next('Settings') + this.title = 'Settings' this.scrollable = true this.screens = this.docking.getScreens() } diff --git a/terminus-settings/src/recoveryProvider.ts b/terminus-settings/src/recoveryProvider.ts index 297ef589..442e7e2f 100644 --- a/terminus-settings/src/recoveryProvider.ts +++ b/terminus-settings/src/recoveryProvider.ts @@ -1,19 +1,14 @@ import { Injectable } from '@angular/core' -import { TabRecoveryProvider, AppService } from 'terminus-core' +import { TabRecoveryProvider, RecoveredTab } from 'terminus-core' import { SettingsTabComponent } from './components/settingsTab.component' @Injectable() export class RecoveryProvider extends TabRecoveryProvider { - constructor ( - private app: AppService - ) { - super() - } - - async recover (recoveryToken: any): Promise { + async recover (recoveryToken: any): Promise { if (recoveryToken.type === 'app:settings') { - this.app.openNewTab(SettingsTabComponent) + return { type: SettingsTabComponent } } + return null } } diff --git a/terminus-terminal/package.json b/terminus-terminal/package.json index 55b49a12..46a39639 100644 --- a/terminus-terminal/package.json +++ b/terminus-terminal/package.json @@ -1,6 +1,6 @@ { "name": "terminus-terminal", - "version": "1.0.0-alpha.16-8-gfc060ac", + "version": "1.0.0-alpha.24", "description": "Terminus' terminal emulation core", "keywords": [ "terminus-plugin" @@ -23,7 +23,8 @@ "@types/webpack-env": "1.13.0", "@types/winreg": "^1.2.30", "dataurl": "0.1.0", - "deep-equal": "1.0.1" + "deep-equal": "1.0.1", + "file-loader": "^0.11.2" }, "peerDependencies": { "@angular/common": "4.0.1", @@ -31,15 +32,19 @@ "@angular/forms": "4.0.1", "@angular/platform-browser": "4.0.1", "@ng-bootstrap/ng-bootstrap": "1.0.0-alpha.22", + "rxjs": "5.3.0", "terminus-core": "*", - "terminus-settings": "*", - "rxjs": "5.3.0" + "terminus-settings": "*" }, "dependencies": { + "@types/async-lock": "0.0.19", + "async-lock": "^1.0.0", "font-manager": "0.2.2", - "hterm-umdjs": "1.2.0", + "hterm-umdjs": "1.1.3", "mz": "^2.6.0", "node-pty": "0.6.8", + "ps-node": "^0.1.6", + "runes": "^0.4.2", "winreg": "^1.2.3" }, "false": {} diff --git a/terminus-terminal/src/api.ts b/terminus-terminal/src/api.ts index 36163305..9e238ad8 100644 --- a/terminus-terminal/src/api.ts +++ b/terminus-terminal/src/api.ts @@ -1,6 +1,7 @@ import { Observable } from 'rxjs' import { TerminalTabComponent } from './components/terminalTab.component' export { TerminalTabComponent } +export { IChildProcess } from './services/sessions.service' export abstract class TerminalDecorator { // tslint:disable-next-line no-empty @@ -27,6 +28,10 @@ export interface SessionOptions { } export abstract class SessionPersistenceProvider { + abstract id: string + abstract displayName: string + + abstract isAvailable (): boolean abstract async attachSession (recoveryId: any): Promise abstract async startSession (options: SessionOptions): Promise abstract async terminateSession (recoveryId: string): Promise @@ -43,3 +48,15 @@ export interface ITerminalColorScheme { export abstract class TerminalColorSchemeProvider { abstract async getSchemes (): Promise } + +export interface IShell { + id: string + name: string + command: string + args?: string[] + env?: any +} + +export abstract class ShellProvider { + abstract async provide (): Promise +} diff --git a/terminus-terminal/src/bell.ogg b/terminus-terminal/src/bell.ogg new file mode 100644 index 00000000..d076186e Binary files /dev/null and b/terminus-terminal/src/bell.ogg differ diff --git a/terminus-terminal/src/buttonProvider.ts b/terminus-terminal/src/buttonProvider.ts index ae81f1c8..ec1c2f40 100644 --- a/terminus-terminal/src/buttonProvider.ts +++ b/terminus-terminal/src/buttonProvider.ts @@ -1,24 +1,32 @@ +import { AsyncSubject } from 'rxjs' import * as fs from 'mz/fs' import * as path from 'path' -import { Injectable } from '@angular/core' -import { HotkeysService, ToolbarButtonProvider, IToolbarButton, AppService, ConfigService, HostAppService, Platform, ElectronService } from 'terminus-core' +import { Injectable, Inject } from '@angular/core' +import { HotkeysService, ToolbarButtonProvider, IToolbarButton, ConfigService, HostAppService, ElectronService, Logger, LogService } from 'terminus-core' -import { SessionsService } from './services/sessions.service' -import { ShellsService } from './services/shells.service' -import { TerminalTabComponent } from './components/terminalTab.component' +import { IShell, ShellProvider } from './api' +import { TerminalService } from './services/terminal.service' @Injectable() export class ButtonProvider extends ToolbarButtonProvider { + private shells$ = new AsyncSubject() + private logger: Logger + constructor ( - private app: AppService, - private sessions: SessionsService, + private terminal: TerminalService, private config: ConfigService, - private shells: ShellsService, - private hostApp: HostAppService, + log: LogService, + hostApp: HostAppService, + @Inject(ShellProvider) shellProviders: ShellProvider[], electron: ElectronService, hotkeys: HotkeysService, ) { super() + this.logger = log.create('newTerminalButton') + Promise.all(shellProviders.map(x => x.provide())).then(shellLists => { + this.shells$.next(shellLists.reduce((a, b) => a.concat(b))) + this.shells$.complete() + }) hotkeys.matchedHotkey.subscribe(async (hotkey) => { if (hotkey === 'new-tab') { this.openNewTab() @@ -35,7 +43,7 @@ export class ButtonProvider extends ToolbarButtonProvider { if (!electron.remote.process.env.DEV) { setImmediate(async () => { let argv: string[] = electron.remote.process.argv - for (let arg of argv.slice(1)) { + for (let arg of argv.slice(1).concat([electron.remote.process.argv0])) { if (await fs.exists(arg)) { if ((await fs.stat(arg)).isDirectory()) { this.openNewTab(arg) @@ -47,31 +55,9 @@ export class ButtonProvider extends ToolbarButtonProvider { } async openNewTab (cwd?: string): Promise { - if (!cwd && this.app.activeTab instanceof TerminalTabComponent) { - cwd = await this.app.activeTab.session.getWorkingDirectory() - } - let command = this.config.store.terminal.shell - let env: any = {} - let args: string[] = [] - if (command === '~clink~') { - ({ command, args } = this.shells.getClinkOptions()) - } - if (command === '~default-shell~') { - command = await this.shells.getDefaultShell() - } - if (this.hostApp.platform === Platform.Windows) { - env.TERM = 'cygwin' - } - let sessionOptions = await this.sessions.prepareNewSession({ - command, - args, - cwd, - env, - }) - this.app.openNewTab( - TerminalTabComponent, - { sessionOptions } - ) + let shells = await this.shells$.first().toPromise() + let shell = shells.find(x => x.id === this.config.store.terminal.shell) || shells[0] + this.terminal.openTab(shell, cwd) } provide (): IToolbarButton[] { diff --git a/terminus-terminal/src/components/terminalSettingsTab.component.pug b/terminus-terminal/src/components/terminalSettingsTab.component.pug index ab915358..da2404fb 100644 --- a/terminus-terminal/src/components/terminalSettingsTab.component.pug +++ b/terminus-terminal/src/components/terminalSettingsTab.component.pug @@ -174,26 +174,53 @@ [title]='idx', ) - .form-group - label Terminal background - br - div( - '[(ngModel)]'='config.store.terminal.background', - (ngModelChange)='config.save()', - ngbRadioGroup - ) - label.btn.btn-secondary - input( - type='radio', - [value]='"theme"' - ) - | From theme - label.btn.btn-secondary - input( - type='radio', - [value]='"colorScheme"' - ) - | From colors + .d-flex + .form-group.mr-3 + label Terminal background + br + div( + '[(ngModel)]'='config.store.terminal.background', + (ngModelChange)='config.save()', + ngbRadioGroup + ) + label.btn.btn-secondary + input( + type='radio', + [value]='"theme"' + ) + | From theme + label.btn.btn-secondary + input( + type='radio', + [value]='"colorScheme"' + ) + | From colors + .form-group + label Cursor shape + br + div( + [(ngModel)]='config.store.terminal.cursor', + (ngModelChange)='config.save()', + ngbRadioGroup + ) + label.btn.btn-secondary + input( + type='radio', + [value]='"block"' + ) + | █ + label.btn.btn-secondary + input( + type='radio', + [value]='"beam"' + ) + | | + label.btn.btn-secondary + input( + type='radio', + [value]='"underline"' + ) + | ▁ .form-group label Shell @@ -203,7 +230,7 @@ ) option( *ngFor='let shell of shells', - [ngValue]='shell.command' + [ngValue]='shell.id' ) {{shell.name}} .form-group @@ -232,3 +259,15 @@ [value]='"audible"' ) | Audible + + .form-group + label Session persistence + select.form-control( + '[(ngModel)]'='config.store.terminal.persistence', + (ngModelChange)='config.save()', + ) + option([ngValue]='null') Off + option( + *ngFor='let provider of persistenceProviders', + [ngValue]='provider.id' + ) {{provider.displayName}} diff --git a/terminus-terminal/src/components/terminalSettingsTab.component.ts b/terminus-terminal/src/components/terminalSettingsTab.component.ts index 94a3d231..3e91edcd 100644 --- a/terminus-terminal/src/components/terminalSettingsTab.component.ts +++ b/terminus-terminal/src/components/terminalSettingsTab.component.ts @@ -1,23 +1,11 @@ import { Observable } from 'rxjs' -import * as fs from 'mz/fs' -import * as path from 'path' import { exec } from 'mz/child_process' const equal = require('deep-equal') const fontManager = require('font-manager') import { Component, Inject } from '@angular/core' import { ConfigService, HostAppService, Platform } from 'terminus-core' -import { TerminalColorSchemeProvider, ITerminalColorScheme } from '../api' - -let Registry = null -try { - Registry = require('winreg') -} catch (_) { } // tslint:disable-line no-empty - -interface IShell { - name: string - command: string -} +import { TerminalColorSchemeProvider, ITerminalColorScheme, IShell, ShellProvider, SessionPersistenceProvider } from '../api' @Component({ template: require('./terminalSettingsTab.component.pug'), @@ -26,6 +14,7 @@ interface IShell { export class TerminalSettingsTabComponent { fonts: string[] = [] shells: IShell[] = [] + persistenceProviders: SessionPersistenceProvider[] colorSchemes: ITerminalColorScheme[] = [] equalComparator = equal editingColorScheme: ITerminalColorScheme @@ -34,8 +23,12 @@ export class TerminalSettingsTabComponent { constructor ( public config: ConfigService, private hostApp: HostAppService, + @Inject(ShellProvider) private shellProviders: ShellProvider[], @Inject(TerminalColorSchemeProvider) private colorSchemeProviders: TerminalColorSchemeProvider[], - ) { } + @Inject(SessionPersistenceProvider) persistenceProviders: SessionPersistenceProvider[], + ) { + this.persistenceProviders = persistenceProviders.filter(x => x.isAvailable()) + } async ngOnInit () { if (this.hostApp.platform === Platform.Windows || this.hostApp.platform === Platform.macOS) { @@ -53,71 +46,8 @@ export class TerminalSettingsTabComponent { this.fonts.sort() }) } - if (this.hostApp.platform === Platform.Windows) { - this.shells = [ - { name: 'CMD (clink)', command: '~clink~' }, - { name: 'CMD (stock)', command: 'cmd.exe' }, - { name: 'PowerShell', command: 'powershell.exe' }, - ] - - // Detect whether BoW is installed - const wslPath = `${process.env.windir}\\system32\\bash.exe` - if (await fs.exists(wslPath)) { - this.shells.push({ name: 'Bash on Windows', command: wslPath }) - } - - // Detect Cygwin - let cygwinPath = await new Promise(resolve => { - let reg = new Registry({ hive: Registry.HKLM, key: '\\Software\\Cygwin\\setup', arch: 'x64' }) - reg.get('rootdir', (err, item) => { - if (err) { - return resolve(null) - } - resolve(item.value) - }) - }) - if (cygwinPath) { - this.shells.push({ name: 'Cygwin', command: path.join(cygwinPath, 'bin', 'bash.exe') }) - } - - // Detect 32-bit Cygwin - let cygwin32Path = await new Promise(resolve => { - let reg = new Registry({ hive: Registry.HKLM, key: '\\Software\\Cygwin\\setup', arch: 'x86' }) - reg.get('rootdir', (err, item) => { - if (err) { - return resolve(null) - } - resolve(item.value) - }) - }) - if (cygwin32Path) { - this.shells.push({ name: 'Cygwin (32 bit)', command: path.join(cygwin32Path, 'bin', 'bash.exe') }) - } - - // Detect Git-Bash - let gitBashPath = await new Promise(resolve => { - let reg = new Registry({ hive: Registry.HKLM, key: '\\Software\\GitForWindows' }) - reg.get('InstallPath', (err, item) => { - if (err) { - resolve(null) - return - } - resolve(item.value) - }) - }) - if (gitBashPath) { - this.shells.push({ name: 'Git-Bash', command: path.join(gitBashPath, 'bin', 'bash.exe') }) - } - } - if (this.hostApp.platform === Platform.Linux || this.hostApp.platform === Platform.macOS) { - this.shells = [{ name: 'Default shell', command: '~default-shell~' }] - this.shells = this.shells.concat((await fs.readFile('/etc/shells', { encoding: 'utf-8' })) - .split('\n') - .map(x => x.trim()) - .filter(x => x && !x.startsWith('#')) - .map(x => ({ name: x, command: x }))) - } this.colorSchemes = (await Promise.all(this.colorSchemeProviders.map(x => x.getSchemes()))).reduce((a, b) => a.concat(b)) + this.shells = (await Promise.all(this.shellProviders.map(x => x.provide()))).reduce((a, b) => a.concat(b)) } fontAutocomplete = (text$: Observable) => { diff --git a/terminus-terminal/src/components/terminalTab.component.scss b/terminus-terminal/src/components/terminalTab.component.scss index 5be43d0f..0556f84c 100644 --- a/terminus-terminal/src/components/terminalTab.component.scss +++ b/terminus-terminal/src/components/terminalTab.component.scss @@ -9,7 +9,7 @@ display: block; overflow: hidden; margin: 15px; - transition: opacity ease-out 0.1s; + transition: opacity ease-out 0.25s; opacity: 0; div[style]:last-child { diff --git a/terminus-terminal/src/components/terminalTab.component.ts b/terminus-terminal/src/components/terminalTab.component.ts index 9e506944..187a89ec 100644 --- a/terminus-terminal/src/components/terminalTab.component.ts +++ b/terminus-terminal/src/components/terminalTab.component.ts @@ -1,4 +1,3 @@ -const dataurl = require('dataurl') import { BehaviorSubject, Subject, Subscription } from 'rxjs' import 'rxjs/add/operator/bufferTime' import { Component, NgZone, Inject, Optional, ViewChild, HostBinding, Input } from '@angular/core' @@ -21,7 +20,6 @@ export class TerminalTabComponent extends BaseTabComponent { @ViewChild('content') content @HostBinding('style.background-color') backgroundColor: string hterm: any - configSubscription: Subscription sessionCloseSubscription: Subscription hotkeysSubscription: Subscription bell$ = new Subject() @@ -33,6 +31,7 @@ export class TerminalTabComponent extends BaseTabComponent { alternateScreenActive$ = new BehaviorSubject(false) mouseEvent$ = new Subject() htermVisible = false + private bellPlayer: HTMLAudioElement private io: any constructor ( @@ -47,14 +46,14 @@ export class TerminalTabComponent extends BaseTabComponent { ) { super() this.decorators = this.decorators || [] - this.title$.next('Terminal') - this.configSubscription = config.changed$.subscribe(() => { - this.configure() - }) + this.title = 'Terminal' this.resize$.first().subscribe(async (resizeEvent) => { this.session = this.sessions.addSession( Object.assign({}, this.sessionOptions, resizeEvent) ) + setTimeout(() => { + this.session.resize(resizeEvent.width, resizeEvent.height) + }, 1000) // this.session.output$.bufferTime(10).subscribe((datas) => { this.session.output$.subscribe(data => { // let data = datas.join('') @@ -88,6 +87,8 @@ export class TerminalTabComponent extends BaseTabComponent { this.resetZoom() } }) + this.bellPlayer = document.createElement('audio') + this.bellPlayer.src = require('../bell.ogg') } getRecoveryToken (): any { @@ -99,6 +100,7 @@ export class TerminalTabComponent extends BaseTabComponent { ngOnInit () { this.focused$.subscribe(() => { + this.configure() setTimeout(() => { this.hterm.scrollPort_.resize() this.hterm.scrollPort_.focus() @@ -129,13 +131,15 @@ export class TerminalTabComponent extends BaseTabComponent { }, 1000) this.bell$.subscribe(() => { - if (this.config.store.terminal.bell !== 'off') { - let bg = preferenceManager.get('background-color') + if (this.config.store.terminal.bell === 'visual') { preferenceManager.set('background-color', 'rgba(128,128,128,.25)') setTimeout(() => { - preferenceManager.set('background-color', bg) + this.configure() }, 125) } + if (this.config.store.terminal.bell === 'audible') { + this.bellPlayer.play() + } // TODO audible }) } @@ -143,7 +147,7 @@ export class TerminalTabComponent extends BaseTabComponent { attachHTermHandlers (hterm: any) { hterm.setWindowTitle = (title) => { this.zone.run(() => { - this.title$.next(title) + this.title = title }) } @@ -155,6 +159,8 @@ export class TerminalTabComponent extends BaseTabComponent { hterm.primaryScreen_.syncSelectionCaret = () => null hterm.alternateScreen_.syncSelectionCaret = () => null + hterm.primaryScreen_.terminal = hterm + hterm.alternateScreen_.terminal = hterm const _onPaste = hterm.scrollPort_.onPaste_.bind(hterm.scrollPort_) hterm.scrollPort_.onPaste_ = (event) => { @@ -208,6 +214,13 @@ export class TerminalTabComponent extends BaseTabComponent { return ret } } + + const _measureCharacterSize = hterm.scrollPort_.measureCharacterSize.bind(hterm.scrollPort_) + hterm.scrollPort_.measureCharacterSize = () => { + let size = _measureCharacterSize() + size.height += this.config.store.terminal.linePadding + return size + } } attachIOHandlers (io: any) { @@ -244,10 +257,10 @@ export class TerminalTabComponent extends BaseTabComponent { async configure (): Promise { let config = this.config.store - preferenceManager.set('font-family', config.terminal.font) + preferenceManager.set('font-family', `"${config.terminal.font}", "monospace-fallback", monospace`) this.setFontSize() preferenceManager.set('enable-bold', true) - preferenceManager.set('audible-bell-sound', '') + // preferenceManager.set('audible-bell-sound', '') preferenceManager.set('desktop-notification-bell', config.terminal.bell === 'notification') preferenceManager.set('enable-clipboard-notice', false) preferenceManager.set('receive-encoding', 'raw') @@ -294,13 +307,15 @@ export class TerminalTabComponent extends BaseTabComponent { } ` } - preferenceManager.set('user-css', dataurl.convert({ - data: css, - mimetype: 'text/css', - charset: 'utf8', - })) - + css += config.appearance.css + this.hterm.setCSS(css) this.hterm.setBracketedPaste(config.terminal.bracketedPaste) + this.hterm.defaultCursorShape = { + block: hterm.hterm.Terminal.cursorShape.BLOCK, + underline: hterm.hterm.Terminal.cursorShape.UNDERLINE, + beam: hterm.hterm.Terminal.cursorShape.BEAM, + }[config.terminal.cursor] + this.hterm.applyCursorShape() } zoomIn () { @@ -322,7 +337,6 @@ export class TerminalTabComponent extends BaseTabComponent { this.decorators.forEach(decorator => { decorator.detach(this) }) - this.configSubscription.unsubscribe() this.hotkeysSubscription.unsubscribe() if (this.sessionCloseSubscription) { this.sessionCloseSubscription.unsubscribe() @@ -343,6 +357,17 @@ export class TerminalTabComponent extends BaseTabComponent { } } + async canClose (): Promise { + if (this.hostApp.platform === Platform.Windows) { + return true + } + let children = await this.session.getChildProcesses() + if (children.length === 0) { + return true + } + return confirm(`"${children[0].command}" is still running. Close?`) + } + private setFontSize () { preferenceManager.set('font-size', this.config.store.terminal.fontSize * Math.pow(1.1, this.zoom)) } diff --git a/terminus-terminal/src/config.ts b/terminus-terminal/src/config.ts index f36103bb..e85c2a21 100644 --- a/terminus-terminal/src/config.ts +++ b/terminus-terminal/src/config.ts @@ -4,10 +4,12 @@ export class TerminalConfigProvider extends ConfigProvider { defaults = { terminal: { fontSize: 14, + linePadding: 0, bell: 'off', bracketedPaste: false, background: 'theme', ligatures: false, + cursor: 'block', colorScheme: { __nonStructural: true, name: 'Material', @@ -41,7 +43,8 @@ export class TerminalConfigProvider extends ConfigProvider { [Platform.macOS]: { terminal: { font: 'Menlo', - shell: '~default-shell~', + shell: 'default', + persistence: 'screen', }, hotkeys: { 'copy': [ @@ -72,7 +75,8 @@ export class TerminalConfigProvider extends ConfigProvider { [Platform.Windows]: { terminal: { font: 'Consolas', - shell: '~clink~', + shell: 'clink', + persistence: null, }, hotkeys: { 'copy': [ @@ -102,7 +106,8 @@ export class TerminalConfigProvider extends ConfigProvider { [Platform.Linux]: { terminal: { font: 'Liberation Mono', - shell: '~default-shell~', + shell: 'default', + persistence: 'tmux', }, hotkeys: { 'copy': [ diff --git a/terminus-terminal/src/fonts/Meslo.otf b/terminus-terminal/src/fonts/Meslo.otf new file mode 100644 index 00000000..710d5b1a Binary files /dev/null and b/terminus-terminal/src/fonts/Meslo.otf differ diff --git a/terminus-terminal/src/hterm.ts b/terminus-terminal/src/hterm.ts index f0e6617f..2c1454fa 100644 --- a/terminus-terminal/src/hterm.ts +++ b/terminus-terminal/src/hterm.ts @@ -22,6 +22,16 @@ preferenceManager.set('color-palette-overrides', { hterm.hterm.Terminal.prototype.showOverlay = () => null +hterm.hterm.Terminal.prototype.setCSS = function (css) { + const doc = this.scrollPort_.document_ + if (!doc.querySelector('#user-css')) { + const node = doc.createElement('style') + node.id = 'user-css' + doc.head.appendChild(node) + } + doc.querySelector('#user-css').innerText = css +} + const oldCharWidthDisregardAmbiguous = hterm.lib.wc.charWidthDisregardAmbiguous hterm.lib.wc.charWidthDisregardAmbiguous = codepoint => { if ((codepoint >= 0x1f300 && codepoint <= 0x1f64f) || @@ -30,3 +40,38 @@ hterm.lib.wc.charWidthDisregardAmbiguous = codepoint => { } return oldCharWidthDisregardAmbiguous(codepoint) } + +hterm.hterm.Terminal.prototype.applyCursorShape = function () { + let modes = [ + [hterm.hterm.Terminal.cursorShape.BLOCK, true], + [this.defaultCursorShape || hterm.hterm.Terminal.cursorShape.BLOCK, false], + [hterm.hterm.Terminal.cursorShape.BLOCK, false], + [hterm.hterm.Terminal.cursorShape.UNDERLINE, true], + [hterm.hterm.Terminal.cursorShape.UNDERLINE, false], + [hterm.hterm.Terminal.cursorShape.BEAM, true], + [hterm.hterm.Terminal.cursorShape.BEAM, false], + ] + let modeNumber = this.cursorMode || 1 + console.log('mode', modeNumber) + if (modeNumber >= modes.length) { + console.warn('Unknown cursor style: ' + modeNumber) + return + } + this.setCursorShape(modes[modeNumber][0]) + this.setCursorBlink(modes[modeNumber][1]) +} + +hterm.hterm.VT.CSI[' q'] = function (parseState) { + const arg = parseState.args[0] + this.terminal.cursorMode = arg + this.terminal.applyCursorShape() +} + +const _collapseToEnd = Selection.prototype.collapseToEnd +Selection.prototype.collapseToEnd = function () { + try { + _collapseToEnd.apply(this) + } catch (err) { + // tslint-disable-line + } +} diff --git a/terminus-terminal/src/hterm.userCSS.scss b/terminus-terminal/src/hterm.userCSS.scss index 662fef2c..a279dd58 100644 --- a/terminus-terminal/src/hterm.userCSS.scss +++ b/terminus-terminal/src/hterm.userCSS.scss @@ -9,3 +9,13 @@ a:hover { x-screen { transition: 0.125s ease background; } + +x-row > span { + display: inline-block; + height: inherit; +} + +@font-face { + font-family: "monospace-fallback"; + src: url(fonts/Meslo.otf) format("opentype"); +} diff --git a/terminus-terminal/src/index.ts b/terminus-terminal/src/index.ts index 68e97efa..714a07a4 100644 --- a/terminus-terminal/src/index.ts +++ b/terminus-terminal/src/index.ts @@ -3,7 +3,7 @@ import { BrowserModule } from '@angular/platform-browser' import { FormsModule } from '@angular/forms' import { NgbModule } from '@ng-bootstrap/ng-bootstrap' -import { HostAppService, Platform, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider } from 'terminus-core' +import { ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider } from 'terminus-core' import { SettingsTabProvider } from 'terminus-settings' import { TerminalTabComponent } from './components/terminalTab.component' @@ -11,17 +11,28 @@ import { TerminalSettingsTabComponent } from './components/terminalSettingsTab.c import { ColorPickerComponent } from './components/colorPicker.component' import { SessionsService } from './services/sessions.service' -import { ShellsService } from './services/shells.service' +import { TerminalService } from './services/terminal.service' -import { ScreenPersistenceProvider } from './persistenceProviders' +import { ScreenPersistenceProvider } from './persistence/screen' +import { TMuxPersistenceProvider } from './persistence/tmux' import { ButtonProvider } from './buttonProvider' import { RecoveryProvider } from './recoveryProvider' -import { SessionPersistenceProvider, TerminalColorSchemeProvider, TerminalDecorator } from './api' +import { SessionPersistenceProvider, TerminalColorSchemeProvider, TerminalDecorator, ShellProvider } from './api' import { TerminalSettingsTabProvider } from './settings' import { PathDropDecorator } from './pathDrop' import { TerminalConfigProvider } from './config' import { TerminalHotkeyProvider } from './hotkeys' import { HyperColorSchemes } from './colorSchemes' + +import { Cygwin32ShellProvider } from './shells/cygwin32' +import { Cygwin64ShellProvider } from './shells/cygwin64' +import { GitBashShellProvider } from './shells/gitBash' +import { LinuxDefaultShellProvider } from './shells/linuxDefault' +import { MacOSDefaultShellProvider } from './shells/macDefault' +import { POSIXShellsProvider } from './shells/posix' +import { WindowsStockShellsProvider } from './shells/windowsStock' +import { WSLShellProvider } from './shells/wsl' + import { hterm } from './hterm' @NgModule({ @@ -32,26 +43,27 @@ import { hterm } from './hterm' ], providers: [ SessionsService, - ShellsService, - ScreenPersistenceProvider, + TerminalService, + { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true }, { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true }, - { - provide: SessionPersistenceProvider, - useFactory: (hostApp: HostAppService, screen: ScreenPersistenceProvider) => { - if (hostApp.platform === Platform.Windows) { - return null - } else { - return screen - } - }, - deps: [HostAppService, ScreenPersistenceProvider], - }, { provide: SettingsTabProvider, useClass: TerminalSettingsTabProvider, multi: true }, { provide: ConfigProvider, useClass: TerminalConfigProvider, multi: true }, { provide: HotkeyProvider, useClass: TerminalHotkeyProvider, multi: true }, { provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true }, { provide: TerminalDecorator, useClass: PathDropDecorator, multi: true }, + + { provide: SessionPersistenceProvider, useClass: ScreenPersistenceProvider, multi: true }, + { provide: SessionPersistenceProvider, useClass: TMuxPersistenceProvider, multi: true }, + + { provide: ShellProvider, useClass: WindowsStockShellsProvider, multi: true }, + { provide: ShellProvider, useClass: MacOSDefaultShellProvider, multi: true }, + { provide: ShellProvider, useClass: LinuxDefaultShellProvider, multi: true }, + { provide: ShellProvider, useClass: Cygwin32ShellProvider, multi: true }, + { provide: ShellProvider, useClass: Cygwin64ShellProvider, multi: true }, + { provide: ShellProvider, useClass: GitBashShellProvider, multi: true }, + { provide: ShellProvider, useClass: POSIXShellsProvider, multi: true }, + { provide: ShellProvider, useClass: WSLShellProvider, multi: true }, ], entryComponents: [ TerminalTabComponent, @@ -93,3 +105,4 @@ export default class TerminalModule { } export * from './api' +export { TerminalService } diff --git a/terminus-terminal/src/pathDrop.ts b/terminus-terminal/src/pathDrop.ts index 8196c3cf..5514d602 100644 --- a/terminus-terminal/src/pathDrop.ts +++ b/terminus-terminal/src/pathDrop.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core' import { TerminalDecorator } from './api' import { TerminalTabComponent } from './components/terminalTab.component' - @Injectable() export class PathDropDecorator extends TerminalDecorator { attach (terminal: TerminalTabComponent): void { diff --git a/terminus-terminal/src/persistenceProviders.ts b/terminus-terminal/src/persistence/screen.ts similarity index 70% rename from terminus-terminal/src/persistenceProviders.ts rename to terminus-terminal/src/persistence/screen.ts index 141154ae..8f904d5f 100644 --- a/terminus-terminal/src/persistenceProviders.ts +++ b/terminus-terminal/src/persistence/screen.ts @@ -1,11 +1,12 @@ import * as fs from 'mz/fs' +import * as path from 'path' import { exec, spawn } from 'mz/child_process' -import { exec as execCallback } from 'child_process' +import { exec as execAsync, execFileSync } from 'child_process' import { AsyncSubject } from 'rxjs' import { Injectable } from '@angular/core' -import { Logger, LogService } from 'terminus-core' -import { SessionOptions, SessionPersistenceProvider } from './api' +import { Logger, LogService, ElectronService } from 'terminus-core' +import { SessionOptions, SessionPersistenceProvider } from '../api' declare function delay (ms: number): Promise @@ -29,18 +30,30 @@ async function listProcesses (): Promise { @Injectable() export class ScreenPersistenceProvider extends SessionPersistenceProvider { + id = 'screen' + displayName = 'GNU Screen' private logger: Logger constructor ( log: LogService, + private electron: ElectronService, ) { super() this.logger = log.create('main') } + isAvailable () { + try { + execFileSync('sh', ['-c', 'which screen']) + return true + } catch (_) { + return false + } + } + async attachSession (recoveryId: any): Promise { let lines = await new Promise(resolve => { - execCallback('screen -list', (_err, stdout) => { + execAsync('screen -list', (_err, stdout) => { // returns an error code on macOS resolve(stdout.split('\n')) }) @@ -64,12 +77,13 @@ export class ScreenPersistenceProvider extends SessionPersistenceProvider { recoveryId, recoveredTruePID$: truePID$.asObservable(), command: 'screen', - args: ['-r', recoveryId], + args: ['-d', '-r', recoveryId, '-c', await this.prepareConfig()], } } async extractShellPID (screenPID: number): Promise { - let child = (await listProcesses()).find(x => x.ppid === screenPID) + let processes = await listProcesses() + let child = processes.find(x => x.ppid === screenPID) if (!child) { throw new Error(`Could not find any children of the screen process (PID ${screenPID})!`) @@ -77,32 +91,15 @@ export class ScreenPersistenceProvider extends SessionPersistenceProvider { if (child.command === 'login') { await delay(1000) - child = (await listProcesses()).find(x => x.ppid === child.pid) + child = processes.find(x => x.ppid === child.pid) } return child.pid } async startSession (options: SessionOptions): Promise { - let configPath = '/tmp/.termScreenConfig' - await fs.writeFile(configPath, ` - escape ^^^ - vbell on - deflogin on - term xterm-color - bindkey "^[OH" beginning-of-line - bindkey "^[OF" end-of-line - bindkey "\\027[?1049h" stuff ----alternate enter----- - bindkey "\\027[?1049l" stuff ----alternate leave----- - termcapinfo xterm* 'hs:ts=\\E]0;:fs=\\007:ds=\\E]0;\\007' - defhstatus "^Et" - hardstatus off - altscreen on - defutf8 on - defencoding utf8 - `, 'utf-8') let recoveryId = `term-tab-${Date.now()}` - let args = ['-d', '-m', '-c', configPath, '-U', '-S', recoveryId, '-T', 'xterm-256color', '--', '-' + options.command].concat(options.args || []) + let args = ['-d', '-m', '-c', await this.prepareConfig(), '-U', '-S', recoveryId, '-T', 'xterm-256color', '--', '-' + options.command].concat(options.args || []) this.logger.debug('Spawning screen with', args.join(' ')) await spawn('screen', args, { cwd: options.cwd, @@ -118,4 +115,28 @@ export class ScreenPersistenceProvider extends SessionPersistenceProvider { // screen has already quit } } + + private async prepareConfig (): Promise { + let configPath = path.join(this.electron.app.getPath('userData'), 'screen-config.tmp') + await fs.writeFile(configPath, ` + escape ^^^ + vbell off + deflogin on + defflow off + term xterm-color + bindkey "^[OH" beginning-of-line + bindkey "^[OF" end-of-line + bindkey "^[[H" beginning-of-line + bindkey "^[[F" end-of-line + bindkey "\\027[?1049h" stuff ----alternate enter----- + bindkey "\\027[?1049l" stuff ----alternate leave----- + termcapinfo xterm* 'hs:ts=\\E]0;:fs=\\007:ds=\\E]0;\\007' + defhstatus "^Et" + hardstatus off + altscreen on + defutf8 on + defencoding utf8 + `, 'utf-8') + return configPath + } } diff --git a/terminus-terminal/src/persistence/tmux.ts b/terminus-terminal/src/persistence/tmux.ts new file mode 100644 index 00000000..56d36339 --- /dev/null +++ b/terminus-terminal/src/persistence/tmux.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@angular/core' +import { execFileSync } from 'child_process' +import * as AsyncLock from 'async-lock' +import { ConnectableObservable, AsyncSubject, Subject } from 'rxjs' +import * as childProcess from 'child_process' +import { SessionOptions, SessionPersistenceProvider } from '../api' + +const TMUX_CONFIG = ` + set -g status off + set -g focus-events on + set -g bell-action any + set -g bell-on-alert on + set -g visual-bell off + set -g set-titles on + set -g set-titles-string "#W" + set -g window-status-format '#I:#(pwd="#{pane_current_path}"; echo \${pwd####*/})#F' + set -g window-status-current-format '#I:#(pwd="#{pane_current_path}"; echo \${pwd####*/})#F' + set-option -g prefix C-^ + set-option -g status-interval 1 +` + +export class TMuxBlock { + time: number + number: number + error: boolean + lines: string[] + + constructor (line: string) { + this.time = parseInt(line.split(' ')[1]) + this.number = parseInt(line.split(' ')[2]) + this.lines = [] + } +} + +export class TMuxMessage { + type: string + content: string + + constructor (line: string) { + this.type = line.substring(0, line.indexOf(' ')) + this.content = line.substring(line.indexOf(' ') + 1) + } +} + +export class TMuxCommandProcess { + private process: childProcess.ChildProcess + private rawOutput$ = new Subject() + private line$ = new Subject() + private message$ = new Subject() + private block$ = new Subject() + private response$: ConnectableObservable + private lock = new AsyncLock({ timeout: 1000 }) + + constructor () { + this.process = childProcess.spawn('tmux', ['-C', '-f', '/dev/null', '-L', 'terminus', 'new-session', '-A', '-D', '-s', 'control']) + console.log('[tmux] started') + this.process.stdout.on('data', data => { + // console.debug('tmux says:', data.toString()) + this.rawOutput$.next(data.toString()) + }) + + let rawBuffer = '' + this.rawOutput$.subscribe(raw => { + rawBuffer += raw + if (rawBuffer.includes('\n')) { + let lines = rawBuffer.split('\n') + rawBuffer = lines.pop() + lines.forEach(line => this.line$.next(line)) + } + }) + + let currentBlock = null + this.line$.subscribe(line => { + if (currentBlock) { + if (line.startsWith('%end ')) { + let block = currentBlock + currentBlock = null + setImmediate(() => { + this.block$.next(block) + }) + } else if (line.startsWith('%error ')) { + let block = currentBlock + block.error = true + currentBlock = null + setImmediate(() => { + this.block$.next(block) + }) + } else { + currentBlock.lines.push(line) + } + } else { + if (line.startsWith('%begin ')) { + currentBlock = new TMuxBlock(line) + } else { + this.message$.next(line) + } + } + }) + + this.response$ = this.block$.skip(1).publish() + this.response$.connect() + + this.block$.subscribe(block => { + console.debug('[tmux] block:', block) + }) + + this.message$.subscribe(message => { + console.debug('[tmux] message:', message) + }) + } + + command (command: string): Promise { + return this.lock.acquire('key', () => { + let p = this.response$.take(1).toPromise() + console.debug('[tmux] command:', command) + this.process.stdin.write(command + '\n') + return p + }).then(response => { + if (response.error) { + throw response + } + return response + }) as Promise + } + + destroy () { + this.rawOutput$.complete() + this.line$.complete() + this.block$.complete() + this.message$.complete() + this.process.kill('SIGTERM') + } +} + +export class TMux { + private process: TMuxCommandProcess + + constructor () { + this.process = new TMuxCommandProcess() + TMUX_CONFIG.split('\n').filter(x => x).forEach(async (line) => { + await this.process.command(line) + }) + } + + async create (id: string, options: SessionOptions): Promise { + let args = [options.command].concat(options.args) + let cmd = args.map(x => `"${x.replace('"', '\\"')}"`) + await this.process.command( + `new-session -s "${id}" -d` + + (options.cwd ? ` -c '${options.cwd.replace("'", "\\'")}'` : '') + + ` '${cmd}'` + ) + } + + async list (): Promise { + let block = await this.process.command('list-sessions -F "#{session_name}"') + return block.lines + } + + async getPID (id: string): Promise { + let response = await this.process.command(`list-panes -t ${id} -F "#{pane_pid}"`) + if (response.lines.length === 0) { + return null + } else { + return parseInt(response.lines[0]) + } + } + + async terminate (id: string): Promise { + this.process.command(`kill-session -t ${id}`).catch(() => { + console.debug('Session already killed') + }) + } +} + +@Injectable() +export class TMuxPersistenceProvider extends SessionPersistenceProvider { + id = 'tmux' + displayName = 'Tmux' + private tmux: TMux + + constructor () { + super() + if (this.isAvailable()) { + this.tmux = new TMux() + } + } + + isAvailable (): boolean { + try { + execFileSync('tmux', ['-V']) + return true + } catch (_) { + return false + } + } + + async attachSession (recoveryId: any): Promise { + let sessions = await this.tmux.list() + if (!sessions.includes(recoveryId)) { + return null + } + let truePID$ = new AsyncSubject() + this.tmux.getPID(recoveryId).then(pid => { + truePID$.next(pid) + truePID$.complete() + }) + return { + command: 'tmux', + args: ['-L', 'terminus', 'attach-session', '-d', '-t', recoveryId, ';', 'refresh-client'], + recoveredTruePID$: truePID$.asObservable(), + recoveryId, + } + } + + async startSession (options: SessionOptions): Promise { + // TODO env + let recoveryId = Date.now().toString() + await this.tmux.create(recoveryId, options) + return recoveryId + } + + async terminateSession (recoveryId: string): Promise { + await this.tmux.terminate(recoveryId) + } +} diff --git a/terminus-terminal/src/recoveryProvider.ts b/terminus-terminal/src/recoveryProvider.ts index 3dc128ad..e141bbaf 100644 --- a/terminus-terminal/src/recoveryProvider.ts +++ b/terminus-terminal/src/recoveryProvider.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { TabRecoveryProvider, AppService } from 'terminus-core' +import { TabRecoveryProvider, RecoveredTab } from 'terminus-core' import { TerminalTabComponent } from './components/terminalTab.component' import { SessionsService } from './services/sessions.service' @@ -8,18 +8,21 @@ import { SessionsService } from './services/sessions.service' export class RecoveryProvider extends TabRecoveryProvider { constructor ( private sessions: SessionsService, - private app: AppService, ) { super() } - async recover (recoveryToken: any): Promise { + async recover (recoveryToken: any): Promise { if (recoveryToken.type === 'app:terminal') { let sessionOptions = await this.sessions.recover(recoveryToken.recoveryId) if (!sessionOptions) { - return + return null + } + return { + type: TerminalTabComponent, + options: { sessionOptions }, } - this.app.openNewTab(TerminalTabComponent, { sessionOptions }) } + return null } } diff --git a/terminus-terminal/src/services/sessions.service.ts b/terminus-terminal/src/services/sessions.service.ts index 79231a00..2d931c53 100644 --- a/terminus-terminal/src/services/sessions.service.ts +++ b/terminus-terminal/src/services/sessions.service.ts @@ -1,12 +1,20 @@ -import * as nodePTY from 'node-pty' +const psNode = require('ps-node') +// import * as nodePTY from 'node-pty' +let nodePTY import * as fs from 'mz/fs' import { Subject } from 'rxjs' -import { Injectable } from '@angular/core' -import { Logger, LogService } from 'terminus-core' +import { Injectable, Inject } from '@angular/core' +import { Logger, LogService, ElectronService, ConfigService } from 'terminus-core' import { exec } from 'mz/child_process' import { SessionOptions, SessionPersistenceProvider } from '../api' +export interface IChildProcess { + pid: number + ppid: number + command: string +} + export class Session { open: boolean name: string @@ -101,6 +109,20 @@ export class Session { this.pty.kill(signal) } + async getChildProcesses (): Promise { + if (!this.truePID) { + return [] + } + return new Promise((resolve, reject) => { + psNode.lookup({ ppid: this.truePID }, (err, processes) => { + if (err) { + return reject(err) + } + resolve(processes as IChildProcess[]) + }) + }) + } + async gracefullyKillProcess (): Promise { if (process.platform === 'win32') { this.kill() @@ -156,16 +178,21 @@ export class SessionsService { private lastID = 0 constructor ( - private persistence: SessionPersistenceProvider, + @Inject(SessionPersistenceProvider) private persistenceProviders: SessionPersistenceProvider[], + private config: ConfigService, + electron: ElectronService, log: LogService, ) { + nodePTY = electron.remoteRequirePluginModule('terminus-terminal', 'node-pty', global as any) this.logger = log.create('sessions') + this.persistenceProviders = this.persistenceProviders.filter(x => x.isAvailable()) } async prepareNewSession (options: SessionOptions): Promise { - if (this.persistence) { - let recoveryId = await this.persistence.startSession(options) - options = await this.persistence.attachSession(recoveryId) + let persistence = this.getPersistence() + if (persistence) { + let recoveryId = await persistence.startSession(options) + options = await persistence.attachSession(recoveryId) } return options } @@ -174,10 +201,11 @@ export class SessionsService { this.lastID++ options.name = `session-${this.lastID}` let session = new Session(options) + let persistence = this.getPersistence() session.destroyed$.first().subscribe(() => { delete this.sessions[session.name] - if (this.persistence) { - this.persistence.terminateSession(session.recoveryId) + if (persistence) { + persistence.terminateSession(session.recoveryId) } }) this.sessions[session.name] = session @@ -185,9 +213,17 @@ export class SessionsService { } async recover (recoveryId: string): Promise { - if (!this.persistence) { + let persistence = this.getPersistence() + if (persistence) { + return await persistence.attachSession(recoveryId) + } + return null + } + + private getPersistence (): SessionPersistenceProvider { + if (!this.config.store.terminal.persistence) { return null } - return await this.persistence.attachSession(recoveryId) + return this.persistenceProviders.find(x => x.id === this.config.store.terminal.persistence) || null } } diff --git a/terminus-terminal/src/services/shells.service.ts b/terminus-terminal/src/services/shells.service.ts deleted file mode 100644 index d63a7673..00000000 --- a/terminus-terminal/src/services/shells.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as path from 'path' -import { exec } from 'mz/child_process' -import * as fs from 'mz/fs' -import { Injectable } from '@angular/core' -import { ElectronService, HostAppService, Platform, Logger, LogService } from 'terminus-core' - -@Injectable() -export class ShellsService { - private logger: Logger - - constructor ( - log: LogService, - private electron: ElectronService, - private hostApp: HostAppService, - ) { - this.logger = log.create('shells') - } - - getClinkOptions (): { command, args } { - return { - command: 'cmd.exe', - args: [ - '/k', - path.join( - path.dirname(this.electron.app.getPath('exe')), - 'resources', - 'clink', - `clink_${process.arch}.exe`, - ), - 'inject', - ] - } - } - - async getDefaultShell (): Promise { - if (this.hostApp.platform === Platform.macOS) { - return this.getDefaultMacOSShell() - } else { - return this.getDefaultLinuxShell() - } - } - - async getDefaultMacOSShell (): Promise { - let shellEntry = (await exec(`dscl . -read /Users/${process.env.LOGNAME} UserShell`))[0].toString() - return shellEntry.split(' ')[1].trim() - } - - async getDefaultLinuxShell (): Promise { - let line = (await fs.readFile('/etc/passwd', { encoding: 'utf-8' })) - .split('\n').find(x => x.startsWith(process.env.LOGNAME + ':')) - if (!line) { - this.logger.warn('Could not detect user shell') - return '/bin/sh' - } else { - return line.split(':')[6] - } - } -} diff --git a/terminus-terminal/src/services/terminal.service.ts b/terminus-terminal/src/services/terminal.service.ts new file mode 100644 index 00000000..4e7d0fc9 --- /dev/null +++ b/terminus-terminal/src/services/terminal.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core' +import { AppService, Logger, LogService } from 'terminus-core' +import { IShell } from '../api' +import { SessionsService } from './sessions.service' +import { TerminalTabComponent } from '../components/terminalTab.component' + +@Injectable() +export class TerminalService { + private logger: Logger + + constructor ( + private app: AppService, + private sessions: SessionsService, + log: LogService, + ) { + this.logger = log.create('terminal') + } + + async openTab (shell: IShell, cwd?: string): Promise { + if (!cwd && this.app.activeTab instanceof TerminalTabComponent) { + cwd = await this.app.activeTab.session.getWorkingDirectory() + } + let env: any = Object.assign({}, process.env, shell.env || {}) + + this.logger.log(`Starting shell ${shell.name}`, shell) + let sessionOptions = await this.sessions.prepareNewSession({ + command: shell.command, + args: shell.args || [], + cwd, + env, + }) + + this.logger.log('Using session options:', sessionOptions) + + return this.app.openNewTab( + TerminalTabComponent, + { sessionOptions } + ) as TerminalTabComponent + } +} diff --git a/terminus-terminal/src/shells/cygwin32.ts b/terminus-terminal/src/shells/cygwin32.ts new file mode 100644 index 00000000..3c0ce996 --- /dev/null +++ b/terminus-terminal/src/shells/cygwin32.ts @@ -0,0 +1,48 @@ +import * as path from 'path' +import { Injectable } from '@angular/core' +import { HostAppService, Platform } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +let Registry = null +try { + Registry = require('winreg') +} catch (_) { } // tslint:disable-line no-empty + +@Injectable() +export class Cygwin32ShellProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.Windows) { + return [] + } + + let cygwinPath = await new Promise(resolve => { + let reg = new Registry({ hive: Registry.HKLM, key: '\\Software\\Cygwin\\setup', arch: 'x86' }) + reg.get('rootdir', (err, item) => { + if (err || !item) { + return resolve(null) + } + resolve(item.value) + }) + }) + + if (!cygwinPath) { + return [] + } + + return [{ + id: 'cygwin32', + name: 'Cygwin (32 bit)', + command: path.join(cygwinPath, 'bin', 'bash.exe'), + env: { + TERM: 'cygwin', + } + }] + } +} diff --git a/terminus-terminal/src/shells/cygwin64.ts b/terminus-terminal/src/shells/cygwin64.ts new file mode 100644 index 00000000..2d3f73cb --- /dev/null +++ b/terminus-terminal/src/shells/cygwin64.ts @@ -0,0 +1,48 @@ +import * as path from 'path' +import { Injectable } from '@angular/core' +import { HostAppService, Platform } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +let Registry = null +try { + Registry = require('winreg') +} catch (_) { } // tslint:disable-line no-empty + +@Injectable() +export class Cygwin64ShellProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.Windows) { + return [] + } + + let cygwinPath = await new Promise(resolve => { + let reg = new Registry({ hive: Registry.HKLM, key: '\\Software\\Cygwin\\setup', arch: 'x64' }) + reg.get('rootdir', (err, item) => { + if (err || !item) { + return resolve(null) + } + resolve(item.value) + }) + }) + + if (!cygwinPath) { + return [] + } + + return [{ + id: 'cygwin64', + name: 'Cygwin', + command: path.join(cygwinPath, 'bin', 'bash.exe'), + env: { + TERM: 'cygwin', + } + }] + } +} diff --git a/terminus-terminal/src/shells/gitBash.ts b/terminus-terminal/src/shells/gitBash.ts new file mode 100644 index 00000000..43f2995d --- /dev/null +++ b/terminus-terminal/src/shells/gitBash.ts @@ -0,0 +1,63 @@ +import * as path from 'path' +import { Injectable } from '@angular/core' +import { HostAppService, Platform } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +let Registry = null +try { + Registry = require('winreg') +} catch (_) { } // tslint:disable-line no-empty + +@Injectable() +export class GitBashShellProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.Windows) { + return [] + } + + let gitBashPath = await new Promise(resolve => { + let reg = new Registry({ hive: Registry.HKLM, key: '\\Software\\GitForWindows' }) + reg.get('InstallPath', (err, item) => { + if (err || !item) { + resolve(null) + return + } + resolve(item.value) + }) + }) + + if (!gitBashPath) { + gitBashPath = await new Promise(resolve => { + let reg = new Registry({ hive: Registry.HKCU, key: '\\Software\\GitForWindows' }) + reg.get('InstallPath', (err, item) => { + if (err || !item) { + resolve(null) + return + } + resolve(item.value) + }) + }) + } + + if (!gitBashPath) { + return [] + } + + return [{ + id: 'git-bash', + name: 'Git-Bash', + command: path.join(gitBashPath, 'bin', 'bash.exe'), + args: [ '--login', '-i' ], + env: { + TERM: 'cygwin', + } + }] + } +} diff --git a/terminus-terminal/src/shells/linuxDefault.ts b/terminus-terminal/src/shells/linuxDefault.ts new file mode 100644 index 00000000..375c1e41 --- /dev/null +++ b/terminus-terminal/src/shells/linuxDefault.ts @@ -0,0 +1,40 @@ +import * as fs from 'mz/fs' +import { Injectable } from '@angular/core' +import { HostAppService, Platform, LogService, Logger } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +@Injectable() +export class LinuxDefaultShellProvider extends ShellProvider { + private logger: Logger + + constructor ( + private hostApp: HostAppService, + log: LogService, + ) { + super() + this.logger = log.create('linuxDefaultShell') + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.Linux) { + return [] + } + let line = (await fs.readFile('/etc/passwd', { encoding: 'utf-8' })) + .split('\n').find(x => x.startsWith(process.env.LOGNAME + ':')) + if (!line) { + this.logger.warn('Could not detect user shell') + return [{ + id: 'default', + name: 'User default', + command: '/bin/sh' + }] + } else { + return [{ + id: 'default', + name: 'User default', + command: line.split(':')[6] + }] + } + } +} diff --git a/terminus-terminal/src/shells/macDefault.ts b/terminus-terminal/src/shells/macDefault.ts new file mode 100644 index 00000000..253a8231 --- /dev/null +++ b/terminus-terminal/src/shells/macDefault.ts @@ -0,0 +1,26 @@ +import { exec } from 'mz/child_process' +import { Injectable } from '@angular/core' +import { HostAppService, Platform } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +@Injectable() +export class MacOSDefaultShellProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.macOS) { + return [] + } + let shellEntry = (await exec(`dscl . -read /Users/${process.env.LOGNAME} UserShell`))[0].toString() + return [{ + id: 'default', + name: 'User default', + command: shellEntry.split(' ')[1].trim() + }] + } +} diff --git a/terminus-terminal/src/shells/posix.ts b/terminus-terminal/src/shells/posix.ts new file mode 100644 index 00000000..c9f858a7 --- /dev/null +++ b/terminus-terminal/src/shells/posix.ts @@ -0,0 +1,29 @@ +import * as fs from 'mz/fs' +import { Injectable } from '@angular/core' +import { HostAppService, Platform } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +@Injectable() +export class POSIXShellsProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform === Platform.Windows) { + return [] + } + return (await fs.readFile('/etc/shells', { encoding: 'utf-8' })) + .split('\n') + .map(x => x.trim()) + .filter(x => x && !x.startsWith('#')) + .map(x => ({ + id: x, + name: x, + command: x, + })) + } +} diff --git a/terminus-terminal/src/shells/windowsStock.ts b/terminus-terminal/src/shells/windowsStock.ts new file mode 100644 index 00000000..dd57339d --- /dev/null +++ b/terminus-terminal/src/shells/windowsStock.ts @@ -0,0 +1,40 @@ +import * as path from 'path' +import { Injectable } from '@angular/core' +import { HostAppService, Platform, ElectronService } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +@Injectable() +export class WindowsStockShellsProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + private electron: ElectronService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.Windows) { + return [] + } + return [ + { + id: 'clink', + name: 'CMD (clink)', + command: 'cmd.exe', + args: [ + '/k', + path.join( + path.dirname(this.electron.app.getPath('exe')), + 'resources', + 'clink', + `clink_${process.arch}.exe`, + ), + 'inject', + ] + }, + { id: 'cmd', name: 'CMD (stock)', command: 'cmd.exe' }, + { id: 'powershell', name: 'PowerShell', command: 'powershell.exe' }, + ] + } +} diff --git a/terminus-terminal/src/shells/wsl.ts b/terminus-terminal/src/shells/wsl.ts new file mode 100644 index 00000000..65980f53 --- /dev/null +++ b/terminus-terminal/src/shells/wsl.ts @@ -0,0 +1,31 @@ +import * as fs from 'mz/fs' +import { Injectable } from '@angular/core' +import { HostAppService, Platform } from 'terminus-core' + +import { ShellProvider, IShell } from '../api' + +@Injectable() +export class WSLShellProvider extends ShellProvider { + constructor ( + private hostApp: HostAppService, + ) { + super() + } + + async provide (): Promise { + if (this.hostApp.platform !== Platform.Windows) { + return [] + } + + const wslPath = `${process.env.windir}\\system32\\bash.exe` + if (!await fs.exists(wslPath)) { + return [] + } + + return [{ + id: 'wsl', + name: 'Bash on Windows', + command: wslPath + }] + } +} diff --git a/terminus-terminal/tsconfig.json b/terminus-terminal/tsconfig.json index 1d6cfcbf..d2383e0e 100644 --- a/terminus-terminal/tsconfig.json +++ b/terminus-terminal/tsconfig.json @@ -3,6 +3,10 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "baseUrl": "src", - "declarationDir": "dist" + "declarationDir": "dist", + "paths": { + "terminus-*": ["terminus-*"], + "*": ["app/node_modules/*"] + } } } diff --git a/terminus-terminal/webpack.config.js b/terminus-terminal/webpack.config.js index 6eead6e0..52546f03 100644 --- a/terminus-terminal/webpack.config.js +++ b/terminus-terminal/webpack.config.js @@ -34,9 +34,17 @@ module.exports = { { test: /\.pug$/, use: ['apply-loader', 'pug-loader'] }, { test: /\.scss$/, use: ['to-string-loader', 'css-loader', 'sass-loader'] }, { test: /\.css$/, use: ['to-string-loader', 'css-loader'] }, + { + test: /\.(ttf|eot|otf|woff|woff2|ogg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + loader: "url-loader", + options: { + limit: 999999999999, + } + }, ] }, externals: [ + 'electron', 'fs', 'font-manager', 'path', diff --git a/terminus-terminal/yarn.lock b/terminus-terminal/yarn.lock new file mode 100644 index 00000000..f145d65a --- /dev/null +++ b/terminus-terminal/yarn.lock @@ -0,0 +1,135 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/deep-equal@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/deep-equal/-/deep-equal-1.0.0.tgz#9ebeaa73d1fc4791f038a5f1440e0449ea968495" + +"@types/mz@0.0.31": + version "0.0.31" + resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52" + dependencies: + "@types/node" "*" + +"@types/node@*", "@types/node@7.0.12": + version "7.0.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.12.tgz#ae5f67a19c15f752148004db07cbbb372e69efc9" + +"@types/webpack-env@1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.13.0.tgz#3044381647e11ee973c5af2e925323930f691d80" + +"@types/winreg@^1.2.30": + version "1.2.30" + resolved "https://registry.yarnpkg.com/@types/winreg/-/winreg-1.2.30.tgz#91d6710e536d345b9c9b017c574cf6a8da64c518" + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + +big.js@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978" + +connected-domain@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/connected-domain/-/connected-domain-1.0.0.tgz#bfe77238c74be453a79f0cb6058deeb4f2358e93" + +dataurl@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/dataurl/-/dataurl-0.1.0.tgz#1f4734feddec05ffe445747978d86759c4b33199" + +deep-equal@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + +file-loader@^0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-0.11.2.tgz#4ff1df28af38719a6098093b88c82c71d1794a34" + dependencies: + loader-utils "^1.0.2" + +font-manager@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/font-manager/-/font-manager-0.2.2.tgz#18a1c5b6ec7f91e22a17c71cbbaa0ea4e68e3a44" + dependencies: + nan "~2.2.0" + +hterm-umdjs@1.1.3: + version "1.1.3+1.58.sha.15ed490" + resolved "https://registry.yarnpkg.com/hterm-umdjs/-/hterm-umdjs-1.1.3.tgz#8b57bcaded5ba9541d6c8e32a82b34abb93e885e" + +json5@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" + +loader-utils@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + +mz@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.6.0.tgz#c8b8521d958df0a4f2768025db69c719ee4ef1ce" + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.0.tgz#aa8f1e34531d807e9e27755b234b4a6ec0c152a8" + +nan@~2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.2.1.tgz#d68693f6b34bb41d66bc68b3a4f9defc79d7149b" + +node-pty@0.6.8: + version "0.6.8" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.6.8.tgz#a7b145397bef23a719128a75b20d4821726dfe90" + dependencies: + nan "2.5.0" + +object-assign@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + +ps-node@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/ps-node/-/ps-node-0.1.6.tgz#9af67a99d7b1d0132e51a503099d38a8d2ace2c3" + dependencies: + table-parser "^0.1.3" + +runes@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/runes/-/runes-0.4.2.tgz#1ddc1ea41de769cb32fc068a64fbbc45cd21052e" + +table-parser@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/table-parser/-/table-parser-0.1.3.tgz#0441cfce16a59481684c27d1b5a67ff15a43c7b0" + dependencies: + connected-domain "^1.0.0" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.0" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839" + dependencies: + any-promise "^1.0.0" + +winreg@^1.2.3: + version "1.2.4" + resolved "https://registry.yarnpkg.com/winreg/-/winreg-1.2.4.tgz#ba065629b7a925130e15779108cf540990e98d1b"