Browse Source

Merge pull request #1 from Eugeny/master

Updating to newest
Austin Warren 6 years ago
parent
commit
d9559f2a8f
100 changed files with 3133 additions and 834 deletions
  1. 173 0
      .all-contributorsrc
  2. 82 0
      .eslintrc.yml
  3. 3 0
      .github/FUNDING.yml
  4. 1 1
      .github/stale.yml
  5. 7 0
      .gitignore
  6. BIN
      .travis.ssh.key.enc
  7. 1 0
      .travis.ssh.key.pub
  8. 26 22
      .travis.yml
  9. 2 2
      HACKING.md
  10. 34 12
      README.md
  11. BIN
      app/assets/activity.png
  12. 4 0
      app/dev-app-update.yml
  13. 12 1
      app/lib/app.ts
  14. 3 1
      app/lib/cli.ts
  15. 7 4
      app/lib/index.ts
  16. 31 17
      app/lib/window.ts
  17. 23 21
      app/package.json
  18. 5 4
      app/src/app.module.ts
  19. 9 7
      app/src/entry.preload.ts
  20. 15 12
      app/src/entry.ts
  21. 40 29
      app/src/plugins.ts
  22. 2 2
      app/src/root.component.ts
  23. 1 0
      app/src/toastr.scss
  24. 0 1
      app/tsconfig.json
  25. 2 2
      app/webpack.config.js
  26. 1 2
      app/webpack.main.config.js
  27. 1115 99
      app/yarn.lock
  28. 9 8
      appveyor.yml
  29. 209 0
      azure-pipelines.yml
  30. BIN
      build/icons/128x128.png
  31. BIN
      build/icons/16x16.png
  32. BIN
      build/icons/256x256.png
  33. BIN
      build/icons/32x32.png
  34. BIN
      build/icons/64x64.png
  35. 3 0
      build/installer.nsh
  36. 36 0
      build/mac/afterSignHook.js
  37. 12 0
      build/mac/entitlements.plist
  38. BIN
      build/windows/squirrel.gif
  39. BIN
      docs/background.jpeg
  40. BIN
      docs/dist/assets/background.jpeg
  41. BIN
      docs/dist/assets/terminal.png
  42. 0 0
      docs/dist/bundle.js
  43. BIN
      docs/dist/fonts/background.jpeg
  44. 0 9
      docs/index.html
  45. 0 1
      docs/index.js
  46. 0 75
      docs/index.pug
  47. 0 24
      docs/package.json
  48. BIN
      docs/readme.png
  49. 0 141
      docs/styles.scss
  50. BIN
      docs/terminal.png
  51. 0 27
      docs/webpack.config.js
  52. BIN
      extras/UAC.exe
  53. 82 71
      package.json
  54. 1 1
      scripts/build-linux.js
  55. 1 1
      scripts/build-macos.js
  56. 13 17
      scripts/build-native.js
  57. 2 2
      scripts/build-windows.js
  58. 0 1
      scripts/install-deps.js
  59. 9 3
      scripts/vars.js
  60. 26 0
      snap/snapcraft.yaml
  61. 4 5
      terminus-community-color-schemes/package.json
  62. 36 0
      terminus-community-color-schemes/schemes/Relaxed
  63. 8 8
      terminus-community-color-schemes/src/colorSchemes.ts
  64. 1 1
      terminus-community-color-schemes/src/index.ts
  65. 1 2
      terminus-community-color-schemes/tsconfig.json
  66. 1 1
      terminus-community-color-schemes/webpack.config.js
  67. 7 7
      terminus-community-color-schemes/yarn.lock
  68. 31 0
      terminus-core/README.md
  69. 15 13
      terminus-core/package.json
  70. 34 1
      terminus-core/src/api/configProvider.ts
  71. 9 5
      terminus-core/src/api/hotkeyProvider.ts
  72. 5 2
      terminus-core/src/api/index.ts
  73. 11 0
      terminus-core/src/api/tabContextMenuProvider.ts
  74. 32 4
      terminus-core/src/api/tabRecovery.ts
  75. 8 0
      terminus-core/src/api/theme.ts
  76. 29 4
      terminus-core/src/api/toolbarButtonProvider.ts
  77. 36 10
      terminus-core/src/components/appRoot.component.pug
  78. 13 1
      terminus-core/src/components/appRoot.component.scss
  79. 31 23
      terminus-core/src/components/appRoot.component.ts
  80. 71 12
      terminus-core/src/components/baseTab.component.ts
  81. 2 2
      terminus-core/src/components/checkbox.component.pug
  82. 4 0
      terminus-core/src/components/checkbox.component.scss
  83. 3 2
      terminus-core/src/components/checkbox.component.ts
  84. 2 0
      terminus-core/src/components/renameTabModal.component.ts
  85. 1 0
      terminus-core/src/components/safeModeModal.component.ts
  86. 5 0
      terminus-core/src/components/splitTab.component.scss
  87. 524 0
      terminus-core/src/components/splitTab.component.ts
  88. 22 0
      terminus-core/src/components/splitTabSpanner.component.scss
  89. 88 0
      terminus-core/src/components/splitTabSpanner.component.ts
  90. 2 2
      terminus-core/src/components/startPage.component.pug
  91. 0 5
      terminus-core/src/components/startPage.component.scss
  92. 5 3
      terminus-core/src/components/startPage.component.ts
  93. 2 1
      terminus-core/src/components/tabBody.component.ts
  94. 32 97
      terminus-core/src/components/tabHeader.component.ts
  95. 2 1
      terminus-core/src/components/titleBar.component.ts
  96. 2 1
      terminus-core/src/components/toggle.component.ts
  97. 19 0
      terminus-core/src/components/welcomeTab.component.pug
  98. 6 0
      terminus-core/src/components/welcomeTab.component.scss
  99. 26 0
      terminus-core/src/components/welcomeTab.component.ts
  100. 1 1
      terminus-core/src/components/windowControls.component.pug

+ 173 - 0
.all-contributorsrc

@@ -0,0 +1,173 @@
+{
+  "files": [
+    "README.md"
+  ],
+  "imageSize": 100,
+  "commit": false,
+  "contributors": [
+    {
+      "login": "mezner",
+      "name": "Russell Myers",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/184085?v=4",
+      "profile": "http://www.russellmyers.com",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "ehwarren",
+      "name": "Austin Warren",
+      "avatar_url": "https://avatars1.githubusercontent.com/u/3991658?v=4",
+      "profile": "http://www.morwire.com",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "Drachenkaetzchen",
+      "name": "Felicia Hummel",
+      "avatar_url": "https://avatars1.githubusercontent.com/u/162974?v=4",
+      "profile": "https://github.com/Drachenkaetzchen",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "mikemaccana",
+      "name": "Mike MacCana",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/172594?v=4",
+      "profile": "https://github.com/mikemaccana",
+      "contributions": [
+        "test",
+        "design"
+      ]
+    },
+    {
+      "login": "yxuko",
+      "name": "Yacine Kanzari",
+      "avatar_url": "https://avatars1.githubusercontent.com/u/1786317?v=4",
+      "profile": "https://github.com/yxuko",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "BBJip",
+      "name": "BBJip",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/32908927?v=4",
+      "profile": "https://github.com/BBJip",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "Futagirl",
+      "name": "Futagirl",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/33533958?v=4",
+      "profile": "https://github.com/Futagirl",
+      "contributions": [
+        "design"
+      ]
+    },
+    {
+      "login": "levrik",
+      "name": "Levin Rickert",
+      "avatar_url": "https://avatars3.githubusercontent.com/u/9491603?v=4",
+      "profile": "https://www.levrik.io",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "kwonoj",
+      "name": "OJ Kwon",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/1210596?v=4",
+      "profile": "https://kwonoj.github.io",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "Domain",
+      "name": "domain",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/903197?v=4",
+      "profile": "https://github.com/Domain",
+      "contributions": [
+        "plugin",
+        "code"
+      ]
+    },
+    {
+      "login": "kbjr",
+      "name": "James Brumond",
+      "avatar_url": "https://avatars1.githubusercontent.com/u/195127?v=4",
+      "profile": "http://www.jbrumond.me",
+      "contributions": [
+        "plugin"
+      ]
+    },
+    {
+      "login": "Tyriar",
+      "name": "Daniel Imms",
+      "avatar_url": "https://avatars0.githubusercontent.com/u/2193314?v=4",
+      "profile": "http://www.growingwiththeweb.com",
+      "contributions": [
+        "code",
+        "plugin",
+        "test"
+      ]
+    },
+    {
+      "login": "baflo",
+      "name": "Florian Bachmann",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/834350?v=4",
+      "profile": "https://github.com/baflo",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "mischah",
+      "name": "Michael Kühnel",
+      "avatar_url": "https://avatars2.githubusercontent.com/u/441011?v=4",
+      "profile": "http://michael-kuehnel.de",
+      "contributions": [
+        "code",
+        "design"
+      ]
+    },
+    {
+      "login": "NieLeben",
+      "name": "Tilmann Meyer",
+      "avatar_url": "https://avatars3.githubusercontent.com/u/47182955?v=4",
+      "profile": "https://github.com/NieLeben",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "PMExtra",
+      "name": "PM Extra",
+      "avatar_url": "https://avatars3.githubusercontent.com/u/11289158?v=4",
+      "profile": "http://www.jubeat.net",
+      "contributions": [
+        "bug"
+      ]
+    },
+    {
+      "login": "IgnusG",
+      "name": "Jonathan",
+      "avatar_url": "https://avatars1.githubusercontent.com/u/6438760?v=4",
+      "profile": "https://jjuhas.keybase.pub//",
+      "contributions": [
+        "code"
+      ]
+    }
+  ],
+  "contributorsPerLine": 7,
+  "projectName": "terminus",
+  "projectOwner": "Eugeny",
+  "repoType": "github",
+  "repoHost": "https://github.com",
+  "commitConvention": "none"
+}

+ 82 - 0
.eslintrc.yml

@@ -0,0 +1,82 @@
+parser: '@typescript-eslint/parser'
+parserOptions:
+  project: tsconfig.json
+extends:
+  - 'plugin:@typescript-eslint/all'
+plugins:
+  - '@typescript-eslint'
+env:
+  browser: true
+  es6: true
+  node: true
+  commonjs: true
+rules:
+  '@typescript-eslint/semi':
+  - error
+  - never
+  '@typescript-eslint/indent':
+  - error
+  - 4
+  '@typescript-eslint/explicit-member-accessibility':
+  - error
+  - accessibility: no-public
+    overrides:
+      parameterProperties: explicit
+  '@typescript-eslint/no-require-imports': off
+  '@typescript-eslint/no-parameter-properties': off
+  '@typescript-eslint/explicit-function-return-type': off
+  '@typescript-eslint/no-explicit-any': off
+  '@typescript-eslint/no-magic-numbers': off
+  '@typescript-eslint/member-delimiter-style': off
+  '@typescript-eslint/promise-function-async': off
+  '@typescript-eslint/no-unnecessary-type-assertion': off
+  '@typescript-eslint/require-array-sort-compare': off
+  '@typescript-eslint/no-use-before-define':
+  - error
+  - classes: false
+  no-duplicate-imports: error
+  array-bracket-spacing:
+  - error
+  - never
+  block-scoped-var: error
+  brace-style:
+  - error
+  - 1tbs
+  - allowSingleLine: true
+  computed-property-spacing:
+  - error
+  - never
+  comma-dangle:
+  - error
+  - always-multiline
+  curly: error
+  eol-last: error
+  eqeqeq:
+  - error
+  - smart
+  linebreak-style:
+  - error
+  - unix
+  max-depth:
+  - 1
+  - 5
+  max-statements:
+  - 1
+  - 80
+  no-multiple-empty-lines: error
+  no-mixed-spaces-and-tabs: error
+  no-trailing-spaces: error
+  '@typescript-eslint/no-unused-vars':
+    - error
+    - vars: all
+      args: after-used
+      argsIgnorePattern: ^_
+  no-undef: error
+  object-curly-spacing:
+  - error
+  - always
+  quote-props:
+  - warn
+  - as-needed
+  - keywords: true
+    numbers: true

+ 3 - 0
.github/FUNDING.yml

@@ -0,0 +1,3 @@
+github: eugeny
+open_collective: terminus
+ko_fi: eugeny

+ 1 - 1
.github/stale.yml

@@ -5,7 +5,7 @@ daysUntilClose: 14
 # Issues with these labels will never be considered stale
 exemptLabels:
   - "T: Enhancement"
-  - "S: Triaged"
+  - "S: Confirmed"
 # Label to use when marking an issue as stale
 staleLabel: "S: Stale"
 # Comment to post when marking an issue as stale. Set to `false` to disable

+ 7 - 0
.gitignore

@@ -5,6 +5,9 @@ node_modules
 
 build/files.wxs
 dist
+*/dist
+*/typings
+*.tsbuildinfo
 
 *.xcworkspacedata
 *.xcuserstate
@@ -17,3 +20,7 @@ npm-debug.log
 builtin-plugins
 package-lock.json
 yarn-error.log
+
+docs/api
+.travis.ssh.key
+*.code-workspace

BIN
.travis.ssh.key.enc


+ 1 - 0
.travis.ssh.key.pub

@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDDFM4nHSbET5V7EYNgjA8NeVfOxV0wVMdZ2YvsDzD+qPJ4+MYbvsL7ZPaSxQSn7n6ATkLHjKje5RpF/Rl9K3kucGs0P6cqJVeE0qryEteQ3Q+fYAk+bD2J9ZQ/hv/0NtLl8T+7lJUZ3WUxFH73sgph77Sw0z+kMpPaK7U2vqMBQD/7+6iJgya31wP0qW0XKDz1BjKeXgwTg10Pm4vcGsR4c2q7YIzSzBHffcyo0vJyFvOX/ZKHlZRcq/wnQMeOl/hPgf1xCENjQZmFVReQlYSw5cNNDT9HZPKekOAZFFez7/AbPiTIo/bnBYIv0mdUjr3nw8nXF505q8LiD3z/ksaaWDqe9CCLM4W0Bh7/dhP7IGPdfX0fVHLhOnYIOsG21D8rWJjMPkVRSLyEvWNAnVuObJNHoQu8VATnOxfPNnMun72IHyyFWVoADk5JcsMbzcP7gZB+5oJO7U1qpcdndtBOA3ZlF0Uz2jVZnqavoEBWT39tl3vs69hAA3aTPGclg7HMuAJOl4HsKmaUgDxqV2wCX/S4pDqmKMbmumDLX+MM0xl0gXj/zpVJp9BzdnrArkC40ivmC6TSA4wrdN0tNBlqApkH5/jxGWrcu2AXVn9PGF3+QrjW0iu+QMZCaKWDhLIQC835uFwzhnNGlx41B7uxMLuNFxKXdQ3f/cC9QMG8ew== TravisCIDeployKey

+ 26 - 22
.travis.yml

@@ -1,29 +1,25 @@
-matrix:
-  include:
-  - os: linux
-    env: BUILD_FOR=linux
-  - os: osx
-    env: BUILD_FOR=macos
-
 language: node_js
-node_js: 8
-
-cache:
-  directories:
-    - node_modules
-    - app/node_modules
+node_js: 11
 
-before_install:
-    - yarn install
-    - scripts/install-deps.js
+stages:
+  - Docs
 
-script:
-    - scripts/build-native.js
-    - yarn run build
-    - scripts/prepackage-plugins.js
-    - scripts/build-$BUILD_FOR.js
+jobs:
+  include:
+    - stage: 'Docs'
+      os: linux
+      if: branch = master
+      script:
+        - set -e
+        - openssl aes-256-cbc -K $encrypted_4e2fb4889ef8_key -iv $encrypted_4e2fb4889ef8_iv -in .travis.ssh.key.enc -out .travis.ssh.key -d
+        - eval "$(ssh-agent -s)"
+        - chmod 600 .travis.ssh.key
+        - ssh-add .travis.ssh.key
+        - yarn
+        - yarn run docs
+        - rsync -e "ssh -o StrictHostKeyChecking=no" -arv docs/api/ [email protected]:/srv/terminus-docs/
 
-dist: trusty
+dist: xenial
 sudo: false
 
 addons:
@@ -31,6 +27,14 @@ addons:
     packages:
     - rpm
     - yarn
+    - libsecret-1-dev
     sources:
     - sourceline: 'deb https://dl.yarnpkg.com/debian/ stable main'
       key_url: 'https://dl.yarnpkg.com/debian/pubkey.gpg'
+
+cache:
+  directories:
+  - 'terminus-*/node_modules'
+  - $HOME/.cache/yarn
+  - $HOME/.cache/electron
+  - $HOME/.cache/electron-builder

+ 2 - 2
HACKING.md

@@ -92,11 +92,11 @@ Plugins provide functionality by exporting singular or multi providers:
 
 ```javascript
 import { NgModule, Injectable } from '@angular/core'
-import { ToolbarButtonProvider, IToolbarButton } from 'terminus-core'
+import { ToolbarButtonProvider, ToolbarButton } from 'terminus-core'
 
 @Injectable()
 export class MyButtonProvider extends ToolbarButtonProvider {
-    provide (): IToolbarButton[] {
+    provide (): ToolbarButton[] {
         return [{
             icon: 'star',
             title: 'Foobar',

File diff suppressed because it is too large
+ 34 - 12
README.md


BIN
app/assets/activity.png


+ 4 - 0
app/dev-app-update.yml

@@ -0,0 +1,4 @@
+owner: eugeny
+repo: terminus
+provider: github
+updaterCacheDirName: terminus-updater

+ 12 - 1
app/lib/app.ts

@@ -1,4 +1,5 @@
 import { app, ipcMain, Menu, Tray, shell } from 'electron'
+import * as electron from 'electron'
 import { loadConfig } from './config'
 import { Window, WindowOptions } from './window'
 
@@ -18,6 +19,16 @@ export class Application {
         }
 
         app.commandLine.appendSwitch('disable-http-cache')
+        app.commandLine.appendSwitch('lang', 'EN')
+
+        for (const flag of configData.flags || [['force_discrete_gpu', '0']]) {
+            console.log('Setting Electron flag:', flag.join('='))
+            app.commandLine.appendSwitch(flag[0], flag[1])
+        }
+    }
+
+    init () {
+        electron.screen.on('display-metrics-changed', () => this.broadcast('host:display-metrics-changed'))
     }
 
     async newWindow (options?: WindowOptions): Promise<Window> {
@@ -103,7 +114,7 @@ export class Application {
                     {
                         label: 'Preferences',
                         accelerator: 'Cmd+,',
-                        async click () {
+                        click: async () => {
                             if (!this.hasWindows()) {
                                 await this.newWindow()
                             }

+ 3 - 1
app/lib/cli.ts

@@ -13,6 +13,9 @@ export function parseArgs (argv, cwd) {
         .command('run [command...]', 'run a command in the terminal', {
             command: { type: 'string' },
         })
+        .command('profile [profileName]', 'open a tab with specified profile', {
+            profileName: { type: 'string' },
+        })
         .command('paste [text]', 'paste stdin into the active tab', yargs => {
             return yargs.option('escape', {
                 alias: 'e',
@@ -38,6 +41,5 @@ export function parseArgs (argv, cwd) {
             type: 'boolean'
         })
         .help('help')
-        .strict()
         .parse(argv.slice(1))
 }

+ 7 - 4
app/lib/index.ts

@@ -1,9 +1,8 @@
 import './lru'
 import { app, ipcMain, Menu } from 'electron'
-import electronDebug = require('electron-debug')
 import { parseArgs } from './cli'
 import { Application } from './app'
-if (process.platform === 'win32' && require('electron-squirrel-startup')) process.exit(0)
+import electronDebug = require('electron-debug')
 
 if (!process.env.TERMINUS_PLUGINS) {
     process.env.TERMINUS_PLUGINS = ''
@@ -12,7 +11,6 @@ if (!process.env.TERMINUS_PLUGINS) {
 const application = new Application()
 
 ipcMain.on('app:new-window', () => {
-    console.log('new-window')
     application.newWindow()
 })
 
@@ -45,7 +43,11 @@ if (!app.requestSingleInstanceLock()) {
 }
 
 if (argv.d) {
-    electronDebug({ enabled: true, showDevTools: 'undocked' })
+    electronDebug({
+        isEnabled: true,
+        showDevTools: true,
+        devToolsMode: 'undocked'
+    })
 }
 
 app.on('ready', () => {
@@ -59,5 +61,6 @@ app.on('ready', () => {
             }
         ]))
     }
+    application.init()
     application.newWindow({ hidden: argv.hidden })
 })

+ 31 - 17
app/lib/window.ts

@@ -1,4 +1,5 @@
 import { Subject, Observable } from 'rxjs'
+import { debounceTime } from 'rxjs/operators'
 import { BrowserWindow, app, ipcMain, Rectangle } from 'electron'
 import ElectronConfig = require('electron-config')
 import * as os from 'os'
@@ -10,7 +11,7 @@ let AccentState: any
 let DwmEnableBlurBehindWindow: any
 if (process.platform === 'win32') {
     SetWindowCompositionAttribute = require('windows-swca').SetWindowCompositionAttribute
-    AccentState = require('windows-swca').AccentState
+    AccentState = require('windows-swca').ACCENT_STATE
     DwmEnableBlurBehindWindow = require('windows-blurbehind').DwmEnableBlurBehindWindow
 }
 
@@ -43,7 +44,9 @@ export class Window {
             title: 'Terminus',
             minWidth: 400,
             minHeight: 300,
-            webPreferences: { webSecurity: false },
+            webPreferences: {
+                nodeIntegration: true,
+            },
             frame: false,
             show: false,
             backgroundColor: '#00000000'
@@ -102,16 +105,14 @@ export class Window {
         if (process.platform === 'win32') {
             if (parseFloat(os.release()) >= 10) {
                 let attribValue = AccentState.ACCENT_DISABLED
-                let color = 0x00000000
                 if (enabled) {
                     if (parseInt(os.release().split('.')[2]) >= 17063 && type === 'fluent') {
-                        attribValue = AccentState.ACCENT_ENABLE_FLUENT
-                        color = 0x01000000 // using a small alpha because acrylic bugs out at full transparency.
+                        attribValue = AccentState.ACCENT_ENABLE_ACRYLICBLURBEHIND
                     } else {
                         attribValue = AccentState.ACCENT_ENABLE_BLURBEHIND
                     }
                 }
-                SetWindowCompositionAttribute(this.window, attribValue, color)
+                SetWindowCompositionAttribute(this.window.getNativeWindowHandle(), attribValue, 0x00000000)
             } else {
                 DwmEnableBlurBehindWindow(this.window, enabled)
             }
@@ -143,6 +144,16 @@ export class Window {
             this.visible.next(false)
         })
 
+        let moveSubscription = new Observable<void>(observer => {
+            this.window.on('move', () => observer.next())
+        }).pipe(debounceTime(250)).subscribe(() => {
+            this.window.webContents.send('host:window-moved')
+        })
+
+        this.window.on('closed', () => {
+            moveSubscription.unsubscribe()
+        })
+
         this.window.on('enter-full-screen', () => this.window.webContents.send('host:window-enter-full-screen'))
         this.window.on('leave-full-screen', () => this.window.webContents.send('host:window-leave-full-screen'))
 
@@ -173,28 +184,28 @@ export class Window {
         })
 
         ipcMain.on('window-focus', event => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.focus()
         })
 
         ipcMain.on('window-maximize', event => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.maximize()
         })
 
         ipcMain.on('window-unmaximize', event => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.unmaximize()
         })
 
         ipcMain.on('window-toggle-maximize', event => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             if (this.window.isMaximized()) {
@@ -205,42 +216,42 @@ export class Window {
         })
 
         ipcMain.on('window-minimize', event => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.minimize()
         })
 
         ipcMain.on('window-set-bounds', (event, bounds) => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.setBounds(bounds)
         })
 
         ipcMain.on('window-set-always-on-top', (event, flag) => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.setAlwaysOnTop(flag)
         })
 
         ipcMain.on('window-set-vibrancy', (event, enabled, type) => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.setVibrancy(enabled, type)
         })
 
         ipcMain.on('window-set-title', (event, title) => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             this.window.setTitle(title)
         })
 
         ipcMain.on('window-bring-to-front', event => {
-            if (event.sender !== this.window.webContents) {
+            if (!this.window || event.sender !== this.window.webContents) {
                 return
             }
             if (this.window.isMinimized()) {
@@ -250,7 +261,10 @@ export class Window {
             this.window.moveTop()
         })
 
-        ipcMain.on('window-close', () => {
+        ipcMain.on('window-close', event => {
+            if (!this.window || event.sender !== this.window.webContents) {
+                return
+            }
             this.closing = true
             this.window.close()
         })

+ 23 - 21
app/package.json

@@ -13,32 +13,34 @@
     "watch": "webpack --progress --color --watch"
   },
   "dependencies": {
-    "@angular/animations": "7.2.0-beta.1",
-    "@angular/common": "7.2.0-beta.1",
-    "@angular/compiler": "7.2.0-beta.1",
-    "@angular/core": "7.2.0-beta.1",
-    "@angular/forms": "7.2.0-beta.1",
-    "@angular/platform-browser": "7.2.0-beta.1",
-    "@angular/platform-browser-dynamic": "7.2.0-beta.1",
-    "@ng-bootstrap/ng-bootstrap": "^3.3.1",
+    "@angular/animations": "7.2.8",
+    "@angular/common": "7.2.8",
+    "@angular/compiler": "7.2.8",
+    "@angular/core": "7.2.8",
+    "@angular/forms": "7.2.8",
+    "@angular/platform-browser": "7.2.8",
+    "@angular/platform-browser-dynamic": "7.2.8",
+    "@ng-bootstrap/ng-bootstrap": "^4.2.0",
     "devtron": "1.4.0",
-    "electron-config": "0.2.1",
-    "electron-debug": "^2.0.0",
-    "electron-is-dev": "0.1.2",
-    "electron-squirrel-startup": "^1.0.0",
-    "js-yaml": "3.8.2",
-    "mz": "^2.6.0",
-    "ngx-toastr": "^9.1.1",
+    "electron-config": "2.0.0",
+    "electron-debug": "^3.0.0",
+    "electron-is-dev": "1.1.0",
+    "electron-updater": "^4.0.6",
+    "js-yaml": "3.13.1",
+    "mz": "^2.7.0",
+    "ngx-toastr": "^10.0.4",
+    "npm": "~6.9.0",
     "path": "0.12.7",
-    "rxjs": "^6.3.3",
-    "yargs": "^12.0.1",
-    "zone.js": "^0.8.26"
+    "rxjs": "^6.5.2",
+    "rxjs-compat": "^6.5.2",
+    "yargs": "^13.2.4",
+    "zone.js": "^0.8.29"
   },
   "optionalDependencies": {
-    "windows-blurbehind": "^1.0.0",
-    "windows-swca": "^1.1.1"
+    "windows-blurbehind": "^1.0.1",
+    "windows-swca": "^2.0.2"
   },
   "devDependencies": {
-    "@types/mz": "0.0.31"
+    "@types/mz": "0.0.32"
   }
 }

+ 5 - 4
app/src/app.module.ts

@@ -4,18 +4,19 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { ToastrModule } from 'ngx-toastr'
 
 export function getRootModule (plugins: any[]) {
-    let imports = [
+    const imports = [
         BrowserModule,
         ...plugins,
         NgbModule.forRoot(),
         ToastrModule.forRoot({
             positionClass: 'toast-bottom-center',
+            toastClass: 'toast',
             preventDuplicates: true,
             extendedTimeOut: 5000,
         }),
     ]
-    let bootstrap = [
-        ...(plugins.filter(x => x.bootstrap).map(x => x.bootstrap)),
+    const bootstrap = [
+        ...plugins.filter(x => x.bootstrap).map(x => x.bootstrap),
     ]
 
     if (bootstrap.length === 0) {
@@ -25,7 +26,7 @@ export function getRootModule (plugins: any[]) {
     @NgModule({
         imports,
         bootstrap,
-    }) class RootModule { }
+    }) class RootModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class
 
     return RootModule
 }

+ 9 - 7
app/src/entry.preload.ts

@@ -1,7 +1,9 @@
 import '../lib/lru'
-import 'source-sans-pro'
+import 'source-sans-pro/source-sans-pro.css'
 import 'source-code-pro/source-code-pro.css'
-import 'font-awesome/css/font-awesome.css'
+import '@fortawesome/fontawesome-free/css/solid.css'
+import '@fortawesome/fontawesome-free/css/brands.css'
+import '@fortawesome/fontawesome-free/css/fontawesome.css'
 import 'ngx-toastr/toastr.css'
 import './preload.scss'
 
@@ -14,20 +16,20 @@ Raven.config(
     {
         release: require('electron').remote.app.getVersion(),
         dataCallback: (data: any) => {
-            const normalize = (filename) => {
-                let splitArray = filename.split('/')
+            const normalize = (filename: string) => {
+                const splitArray = filename.split('/')
                 return splitArray[splitArray.length - 1]
             }
 
-            data.exception.values[0].stacktrace.frames.forEach(frame => {
+            data.exception.values[0].stacktrace.frames.forEach((frame: any) => {
                 frame.filename = normalize(frame.filename)
             })
 
             data.culprit = data.exception.values[0].stacktrace.frames[0].filename
 
             return data
-        }
-    }
+        },
+    },
 )
 
 process.on('uncaughtException' as any, (err) => {

+ 15 - 12
app/src/entry.ts

@@ -1,40 +1,43 @@
 import 'zone.js'
-import 'core-js/es7/reflect'
-import 'core-js/core/delay'
+import 'core-js/proposals/reflect-metadata'
 import 'rxjs'
 
+import * as isDev from 'electron-is-dev'
+
 import './global.scss'
 import './toastr.scss'
 
-// Always land on the start view
-location.hash = ''
-
 import { enableProdMode, NgModuleRef } from '@angular/core'
 import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
 
 import { getRootModule } from './app.module'
-import { findPlugins, loadPlugins, IPluginInfo } from './plugins'
+import { findPlugins, loadPlugins, PluginInfo } from './plugins'
+
+// Always land on the start view
+location.hash = ''
+
+;(process as any).enablePromiseAPI = true
 
 if (process.platform === 'win32') {
     process.env.HOME = process.env.HOMEDRIVE + process.env.HOMEPATH
 }
 
-if (require('electron-is-dev')) {
+if (isDev) {
     console.warn('Running in debug mode')
 } else {
     enableProdMode()
 }
 
-async function bootstrap (plugins: IPluginInfo[], safeMode = false): Promise<NgModuleRef<any>> {
+async function bootstrap (plugins: PluginInfo[], safeMode = false): Promise<NgModuleRef<any>> {
     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 + '%'
+    const pluginsModules = await loadPlugins(plugins, (current, total) => {
+        (document.querySelector('.progress .bar') as HTMLElement).style.width = `${100 * current / total}%` // eslint-disable-line
     })
-    let module = getRootModule(pluginsModules)
+    const module = getRootModule(pluginsModules)
     window['rootModule'] = module
-    return await platformBrowserDynamic().bootstrapModule(module)
+    return platformBrowserDynamic().bootstrapModule(module)
 }
 
 findPlugins().then(async plugins => {

+ 40 - 29
app/src/plugins.ts

@@ -1,10 +1,8 @@
 import * as fs from 'mz/fs'
 import * as path from 'path'
-const nodeModule = require('module')
+const nodeModule = require('module') // eslint-disable-line @typescript-eslint/no-var-requires
 const nodeRequire = (global as any).require
 
-declare function delay (ms: number): Promise<void>
-
 function normalizePath (path: string): string {
     const cygwinPrefix = '/cygdrive/'
     if (path.startsWith(cygwinPrefix)) {
@@ -14,13 +12,13 @@ function normalizePath (path: string): string {
     return path
 }
 
-nodeRequire.main.paths.map(x => nodeModule.globalPaths.push(normalizePath(x)))
+nodeRequire.main.paths.map((x: string) => nodeModule.globalPaths.push(normalizePath(x)))
 
-if (process.env.DEV) {
+if (process.env.TERMINUS_DEV) {
     nodeModule.globalPaths.unshift(path.dirname(require('electron').remote.app.getAppPath()))
 }
 
-const builtinPluginsPath = process.env.DEV ? path.dirname(require('electron').remote.app.getAppPath()) : path.join((process as any).resourcesPath, 'builtin-plugins')
+const builtinPluginsPath = process.env.TERMINUS_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'),
@@ -28,6 +26,10 @@ const userPluginsPath = path.join(
     'plugins',
 )
 
+if (!fs.existsSync(userPluginsPath)) {
+    fs.mkdir(userPluginsPath)
+}
+
 Object.assign(window, { builtinPluginsPath, userPluginsPath })
 nodeModule.globalPaths.unshift(builtinPluginsPath)
 nodeModule.globalPaths.unshift(path.join(userPluginsPath, 'node_modules'))
@@ -36,9 +38,9 @@ if (process.env.TERMINUS_PLUGINS) {
     process.env.TERMINUS_PLUGINS.split(':').map(x => nodeModule.globalPaths.push(normalizePath(x)))
 }
 
-export declare type ProgressCallback = (current, total) => void
+export type ProgressCallback = (current: number, total: number) => void // eslint-disable-line @typescript-eslint/no-type-alias
 
-export interface IPluginInfo {
+export interface PluginInfo {
     name: string
     description: string
     packageName: string
@@ -62,6 +64,7 @@ const builtinModules = [
     'ngx-toastr',
     'rxjs',
     'rxjs/operators',
+    'rxjs-compat/Subject',
     'terminus-core',
     'terminus-settings',
     'terminus-terminal',
@@ -70,47 +73,53 @@ const builtinModules = [
 
 const cachedBuiltinModules = {}
 builtinModules.forEach(m => {
+    const label = 'Caching ' + m
+    console.time(label)
     cachedBuiltinModules[m] = nodeRequire(m)
+    console.timeEnd(label)
 })
 
-const originalRequire = nodeRequire('module').prototype.require
-nodeRequire('module').prototype.require = function (query) {
+const originalRequire = (global as any).require
+;(global as any).require = function (query: string) {
     if (cachedBuiltinModules[query]) {
         return cachedBuiltinModules[query]
     }
     return originalRequire.apply(this, arguments)
 }
 
-export async function findPlugins (): Promise<IPluginInfo[]> {
-    let paths = nodeModule.globalPaths
-    let foundPlugins: IPluginInfo[] = []
-    let candidateLocations: { pluginDir: string, packageName: string }[] = []
+export async function findPlugins (): Promise<PluginInfo[]> {
+    const paths = nodeModule.globalPaths
+    let foundPlugins: PluginInfo[] = []
+    const candidateLocations: { pluginDir: string, packageName: string }[] = []
+    const PREFIX = 'terminus-'
 
     for (let pluginDir of paths) {
         pluginDir = normalizePath(pluginDir)
         if (!await fs.exists(pluginDir)) {
             continue
         }
-        let pluginNames = await fs.readdir(pluginDir)
+        const pluginNames = await fs.readdir(pluginDir)
         if (await fs.exists(path.join(pluginDir, 'package.json'))) {
             candidateLocations.push({
                 pluginDir: path.dirname(pluginDir),
-                packageName: path.basename(pluginDir)
+                packageName: path.basename(pluginDir),
             })
         }
-        for (let packageName of pluginNames) {
-            candidateLocations.push({ pluginDir, packageName })
+        for (const packageName of pluginNames) {
+            if (packageName.startsWith(PREFIX)) {
+                candidateLocations.push({ pluginDir, packageName })
+            }
         }
     }
 
-    for (let { pluginDir, packageName } of candidateLocations) {
-        let pluginPath = path.join(pluginDir, packageName)
-        let infoPath = path.join(pluginPath, 'package.json')
+    for (const { pluginDir, packageName } of candidateLocations) {
+        const pluginPath = path.join(pluginDir, packageName)
+        const infoPath = path.join(pluginPath, 'package.json')
         if (!await fs.exists(infoPath)) {
             continue
         }
 
-        let name = packageName.substring('terminus-'.length)
+        const name = packageName.substring(PREFIX.length)
 
         if (foundPlugins.some(x => x.name === name)) {
             console.info(`Plugin ${packageName} already exists, overriding`)
@@ -118,7 +127,7 @@ export async function findPlugins (): Promise<IPluginInfo[]> {
         }
 
         try {
-            let info = JSON.parse(await fs.readFile(infoPath, {encoding: 'utf-8'}))
+            const info = JSON.parse(await fs.readFile(infoPath, { encoding: 'utf-8' }))
             if (!info.keywords || !(info.keywords.includes('terminus-plugin') || info.keywords.includes('terminus-builtin-plugin'))) {
                 continue
             }
@@ -143,23 +152,25 @@ export async function findPlugins (): Promise<IPluginInfo[]> {
     return foundPlugins
 }
 
-export async function loadPlugins (foundPlugins: IPluginInfo[], progress: ProgressCallback): Promise<any[]> {
-    let plugins: any[] = []
+export async function loadPlugins (foundPlugins: PluginInfo[], progress: ProgressCallback): Promise<any[]> {
+    const plugins: any[] = []
     progress(0, 1)
     let index = 0
-    for (let foundPlugin of foundPlugins) {
+    for (const foundPlugin of foundPlugins) {
         console.info(`Loading ${foundPlugin.name}: ${nodeRequire.resolve(foundPlugin.path)}`)
         progress(index, foundPlugins.length)
         try {
-            let packageModule = nodeRequire(foundPlugin.path)
-            let pluginModule = packageModule.default.forRoot ? packageModule.default.forRoot() : packageModule.default
+            const label = 'Loading ' + foundPlugin.name
+            console.time(label)
+            const packageModule = nodeRequire(foundPlugin.path)
+            const pluginModule = packageModule.default.forRoot ? packageModule.default.forRoot() : packageModule.default
             pluginModule['pluginName'] = foundPlugin.name
             pluginModule['bootstrap'] = packageModule.bootstrap
             plugins.push(pluginModule)
+            console.timeEnd(label)
         } catch (error) {
             console.error(`Could not load ${foundPlugin.name}:`, error)
         }
-        await delay(1)
         index++
     }
     progress(1, 1)

+ 2 - 2
app/src/root.component.ts

@@ -1,6 +1,6 @@
 import { Component } from '@angular/core'
 
 @Component({
-    template: '<app-root></app-root>'
+    template: '<app-root></app-root>',
 })
-export class RootComponent { }
+export class RootComponent { } // eslint-disable-line @typescript-eslint/no-extraneous-class

+ 1 - 0
app/src/toastr.scss

@@ -2,6 +2,7 @@
   display: flex;
   flex-direction: column;
   align-items: center;
+  padding: 20px;
 
   .toast {
     box-shadow: 0 1px 0 rgba(0,0,0,.25);

+ 0 - 1
app/tsconfig.json

@@ -9,7 +9,6 @@
         "emitDecoratorMetadata": true,
         "experimentalDecorators": true,
         "sourceMap": true,
-        "noUnusedParameters": true,
         "noImplicitReturns": true,
         "noFallthroughCasesInSwitch": true,
         "noUnusedParameters": true,

+ 2 - 2
app/webpack.config.js

@@ -5,11 +5,11 @@ module.exports = {
   name: 'terminus',
   target: 'node',
   entry: {
-    'index.ignore': 'file-loader?name=index.html!val-loader!pug-html-loader!' + path.resolve(__dirname, './index.pug'),
+    'index.ignore': 'file-loader?name=index.html!pug-html-loader!' + path.resolve(__dirname, './index.pug'),
     preload: path.resolve(__dirname, 'src/entry.preload.ts'),
     bundle: path.resolve(__dirname, 'src/entry.ts'),
   },
-  mode: process.env.DEV ? 'development' : 'production',
+  mode: process.env.TERMINUS_DEV ? 'development' : 'production',
   optimization:{
      minimize: false,
   },

+ 1 - 2
app/webpack.main.config.js

@@ -7,7 +7,7 @@ module.exports = {
   entry: {
     main: path.resolve(__dirname, 'lib/index.ts'),
   },
-  mode: process.env.DEV ? 'development' : 'production',
+  mode: process.env.TERMINUS_DEV ? 'development' : 'production',
   context: __dirname,
   devtool: 'source-map',
   output: {
@@ -36,7 +36,6 @@ module.exports = {
     electron: 'commonjs electron',
     'electron-config': 'commonjs electron-config',
     'electron-vibrancy': 'commonjs electron-vibrancy',
-    'electron-squirrel-startup': 'commonjs electron-squirrel-startup',
     fs: 'commonjs fs',
     mz: 'commonjs mz',
     path: 'commonjs path',

File diff suppressed because it is too large
+ 1115 - 99
app/yarn.lock


+ 9 - 8
appveyor.yml

@@ -6,23 +6,24 @@ platform:
 environment:
   nodejs_version: "10"
 
-cache:
-  - '%USERPROFILE%\.electron'
-
 version: "{build}"
 
 install:
   - ps: Install-Product node $env:nodejs_version $env:platform
-  - npm install
-  - node scripts/install-deps.js
+  - yarn
   - node scripts/build-native.js
 
 build_script:
-  - npm run build
+  - yarn run build:typings
+  - yarn run build
   - node scripts/prepackage-plugins.js
   - node scripts/build-windows.js
 
 artifacts:
-  - path: 'dist\win\*.exe'
-  - path: 'dist\squirrel-windows\*.exe'
   - path: 'dist\*.exe'
+
+cache:
+  - node_modules
+  - "*\\node_modules"
+  - "%USERPROFILE%\\.electron"
+  - "%LOCALAPPDATA%\\Yarn"

+ 209 - 0
azure-pipelines.yml

@@ -0,0 +1,209 @@
+trigger:
+- master
+
+variables:
+- group: Vars
+
+jobs:
+- job: Windows
+  pool:
+    vmImage: 'vs2017-win2016'
+
+  steps:
+  - task: NodeTool@0
+    inputs:
+      versionSpec: '10.x'
+    displayName: 'Install Node.js'
+
+  - script: yarn
+    displayName: 'Install dependencies'
+
+  - script: node scripts/build-native.js
+    displayName: 'Rebuild native dependencies'
+
+  - script: yarn run build:typings
+    displayName: 'Build typings'
+
+  - script: yarn run build
+    displayName: 'Build'
+
+  - script: node scripts/prepackage-plugins.js
+    displayName: 'Prepackage plugins'
+
+  - script: node scripts/build-windows.js
+    displayName: 'Package'
+    env:
+      WIN_CSC_LINK: $(WIN_CSC_LINK)
+      WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD)
+      BT_TOKEN: $(BT_TOKEN)
+      GH_TOKEN: $(GH_TOKEN)
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist\\*-setup.exe'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: Windows - Installer
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist\\*-portable.exe'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: Windows - Portable build
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+- job: Linux
+  pool:
+    vmImage: 'ubuntu-16.04'
+
+  steps:
+  - task: NodeTool@0
+    inputs:
+      versionSpec: '10.x'
+    displayName: 'Install Node.js'
+
+  - script: yarn
+    displayName: 'Install dependencies'
+
+  - script: node scripts/build-native.js
+    displayName: 'Rebuild native dependencies'
+
+  - script: yarn run build:typings
+    displayName: 'Build typings'
+
+  - script: yarn run build
+    displayName: 'Build'
+
+  - script: node scripts/prepackage-plugins.js
+    displayName: 'Prepackage plugins'
+
+  - script: node scripts/build-linux.js
+    displayName: 'Package'
+    env:
+      BT_TOKEN: $(BT_TOKEN)
+      GH_TOKEN: $(GH_TOKEN)
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist/*.deb'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: Linux - DEB
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist/*.rpm'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: Linux - RPM
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist/*.snap'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: Linux - Snap
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist/*.tar.gz'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: Linux - tar.gz
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+- job: macOS
+  pool:
+    vmImage: 'macOS-10.14'
+
+  steps:
+  - task: NodeTool@0
+    inputs:
+      versionSpec: '10.x'
+    displayName: 'Install Node.js'
+
+  - script: yarn
+    displayName: 'Install dependencies'
+
+  - script: node scripts/build-native.js
+    displayName: 'Rebuild native dependencies'
+
+  - script: yarn run build:typings
+    displayName: 'Build typings'
+
+  - script: yarn run build
+    displayName: 'Build'
+
+  - script: node scripts/prepackage-plugins.js
+    displayName: 'Prepackage plugins'
+
+  - script: node scripts/build-macos.js
+    displayName: 'Package'
+    env:
+      CSC_LINK: $(CSC_LINK)
+      CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD)
+      BT_TOKEN: $(BT_TOKEN)
+      GH_TOKEN: $(GH_TOKEN)
+      APPSTORE_USERNAME: $(APPSTORE_USERNAME)
+      APPSTORE_PASSWORD: $(APPSTORE_PASSWORD)
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist/*.dmg'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: macOS - DMG
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')
+
+  - task: CopyFiles@2
+    inputs:
+      contents: 'dist/*.zip'
+      targetFolder: $(Build.ArtifactStagingDirectory)
+      flattenFolders: true
+      cleanTargetFolder: true
+
+  - task: PublishBuildArtifacts@1
+    inputs:
+      pathtoPublish: $(Build.ArtifactStagingDirectory)
+      artifactName: macOS - app.zip
+      condition: eq(variables['Build.SourceBranch'], 'refs/heads/master')

BIN
build/icons/128x128.png


BIN
build/icons/16x16.png


BIN
build/icons/256x256.png


BIN
build/icons/32x32.png


BIN
build/icons/64x64.png


+ 3 - 0
build/installer.nsh

@@ -0,0 +1,3 @@
+!macro customInit
+  nsExec::Exec '"$LOCALAPPDATA\terminus\Update.exe" --uninstall -s'
+!macroend

+ 36 - 0
build/mac/afterSignHook.js

@@ -0,0 +1,36 @@
+// See: https://medium.com/@TwitterArchiveEraser/notarize-electron-apps-7a5f988406db
+
+const fs = require('fs')
+const path = require('path')
+const notarizer = require('electron-notarize')
+
+module.exports = async function (params) {
+    console.log('env: ', process.env)
+    // notarize the app on Mac OS only.
+    if (process.platform !== 'darwin' || process.env.BUILD_SOURCEBRANCH !== 'refs/heads/master') {
+        return
+    }
+    console.log('afterSign hook triggered', params)
+
+    let appId = 'org.terminus'
+
+    let appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`)
+    if (!fs.existsSync(appPath)) {
+        throw new Error(`Cannot find application at: ${appPath}`)
+    }
+
+    console.log(`Notarizing ${appId} found at ${appPath}`)
+
+    try {
+        await notarizer.notarize({
+            appBundleId: appId,
+            appPath: appPath,
+            appleId: process.env.APPSTORE_USERNAME,
+            appleIdPassword: process.env.APPSTORE_PASSWORD,
+        })
+    } catch (error) {
+        console.error(error)
+    }
+
+    console.log(`Done notarizing ${appId}`)
+}

+ 12 - 0
build/mac/entitlements.plist

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>com.apple.security.cs.allow-jit</key>
+    <true/>
+    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
+    <true/>
+    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
+    <true/>
+  </dict>
+</plist>

BIN
build/windows/squirrel.gif


BIN
docs/background.jpeg


BIN
docs/dist/assets/background.jpeg


BIN
docs/dist/assets/terminal.png


File diff suppressed because it is too large
+ 0 - 0
docs/dist/bundle.js


BIN
docs/dist/fonts/background.jpeg


+ 0 - 9
docs/index.html

@@ -1,9 +0,0 @@
-<!DOCTYPE html><html><head><base href="dist/"><meta name="viewport" content="initial-scale=1, minimal-ui, shrink-to-fit=no"><link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400" rel="stylesheet"><script src="bundle.js"></script><title>Terminus</title></head><body><div class="mt-5 mb-5" id="header"><div class="text-center"><h1>Terminus</h1><div class="subtitle mb-3">A terminal for a more modern age</div><a class="btn btn-lg btn-outline-dark mt-4" href="https://github.com/Eugeny/terminus/releases/latest" target="_blank"><strong>DOWNLOAD</strong></a><a class="btn btn-lg btn-outline-secondary mt-4 ml-3" href="https://github.com/Eugeny/terminus" target="_blank"><strong>GITHUB</strong></a></div></div><div class="background-stripe"><div class="overlay overlay1"></div><div class="overlay overlay2"></div><div class="terminal"></div></div><div class="container mt-5 mb-5"><div class="d-flex flex-wrap flex-md-nowrap"><div class="w-100"><div class="feature">windows</div><div class="feature">linux</div><div class="feature">macos</div><br><div class="feature">powershell</div><div class="feature">wsl</div><div class="feature">cygwin</div><div class="feature">git-bash</div><div class="feature">cmder</div><div class="feature">clink</div></div><div class="w-100"><div class="feature">full unicode</div><div class="feature">global hotkey</div><div class="feature">plugins</div><div class="feature">tab recovery</div><div class="feature">custom css</div><div class="feature">themes</div><div class="feature">font ligatures</div><div class="feature">clickable paths</div><div class="feature">tabs on top/bottom</div><div class="feature">vibrancy</div><div class="feature">bracketed paste</div></div></div></div><div class="container mt-5 mb-5"><div class="text-center mt-5"><div class="mb-4 mt-5"><script type="text/javascript" src="https://ko-fi.com/widgets/widget_2.js"></script><script type="text/javascript">kofiwidget2.init('Buy me a coffee', '#46b798', 'J3J8KWTF')
-kofiwidget2.draw()
-</script></div><a class="btn btn-lg btn-outline-secondary mt-3" href="/terminus/#header"><strong>BEAM ME UP</strong></a></div></div><div class="background-stripe2"><div class="overlay overlay1"></div></div><script>(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
-(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
-m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
-})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
-
-ga('create', 'UA-3278102-18', 'auto');
-ga('send', 'pageview');</script></body></html>

+ 0 - 1
docs/index.js

@@ -1 +0,0 @@
-import './styles.scss'

+ 0 - 75
docs/index.pug

@@ -1,75 +0,0 @@
-doctype html
-html
-    head
-        base(href='dist/')
-        meta(name='viewport', content='initial-scale=1, minimal-ui, shrink-to-fit=no')
-        link(href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400", rel="stylesheet")
-        script(src='bundle.js')
-        title Terminus
-    body
-        .mt-5.mb-5#header
-            .text-center
-                h1 Terminus
-                .subtitle.mb-3 A terminal for a more modern age
-                
-                a.btn.btn-lg.btn-outline-dark.mt-4(href='https://github.com/Eugeny/terminus/releases/latest', target='_blank')
-                    strong DOWNLOAD
-
-                a.btn.btn-lg.btn-outline-secondary.mt-4.ml-3(href='https://github.com/Eugeny/terminus', target='_blank')
-                    strong GITHUB
-                    
-                
-        .background-stripe
-            .overlay.overlay1
-            .overlay.overlay2
-            .terminal
-
-        .container.mt-5.mb-5
-            .d-flex.flex-wrap.flex-md-nowrap
-                .w-100
-                    .feature windows
-                    .feature linux
-                    .feature macos
-                    br
-                    .feature powershell
-                    .feature wsl
-                    .feature cygwin
-                    .feature git-bash
-                    .feature cmder
-                    .feature clink
-                    
-                .w-100
-                    .feature full unicode
-                    .feature global hotkey
-                    .feature plugins
-                    .feature tab recovery
-                    .feature custom css
-                    .feature themes
-                    .feature font ligatures
-                    .feature clickable paths
-                    .feature tabs on top/bottom
-                    .feature vibrancy
-                    .feature bracketed paste
-                    
-        .container.mt-5.mb-5
-            .text-center.mt-5
-                .mb-4.mt-5
-                    script(type='text/javascript', src='https://ko-fi.com/widgets/widget_2.js')
-                    script(type='text/javascript').
-                        kofiwidget2.init('Buy me a coffee', '#46b798', 'J3J8KWTF')
-                        kofiwidget2.draw()
-                
-                a.btn.btn-lg.btn-outline-secondary.mt-3(href='/terminus/#header')
-                    strong BEAM ME UP
-
-        .background-stripe2
-            .overlay.overlay1
-                                        
-        script.
-          (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
-          (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
-          m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
-          })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
-
-          ga('create', 'UA-3278102-18', 'auto');
-          ga('send', 'pageview');

+ 0 - 24
docs/package.json

@@ -1,24 +0,0 @@
-{
-  "name": "docs",
-  "version": "1.0.0",
-  "main": "index.js",
-  "scripts": {
-    "build": "webpack --progress",
-    "watch": "webpack --progress --watch"
-  },
-  "private": true,
-  "devDependencies": {
-    "bootstrap": "^4.1.3",
-    "css-loader": "^1.0.0",
-    "file-loader": "^1.1.11",
-    "node-sass": "^4.9.3",
-    "pug": "^2.0.3",
-    "pug-cli": "^1.0.0-alpha6",
-    "pug-html-loader": "^1.1.5",
-    "sass-loader": "^7.1.0",
-    "style-loader": "^0.22.1",
-    "val-loader": "^1.1.1",
-    "webpack": "^4.16.5",
-    "webpack-cli": "^3.1.0"
-  }
-}

BIN
docs/readme.png


+ 0 - 141
docs/styles.scss

@@ -1,141 +0,0 @@
-$font-family-sans-serif: "Source Sans Pro";
-$border-radius-lg: 0;
-$btn-border-width: 3px;
-
-@import "node_modules/bootstrap/scss/bootstrap";
-
-
-h1 {
-    font-size: 10vw;
-    font-weight: 200;
-    margin: 0;
-}
-
-body {
-    overflow-x: hidden;
-}
-
-.subtitle {
-    font-style: italic;
-    color: #999;
-    font-size: 5vw;
-    font-weight: 300;
-}
-
-.background-stripe {
-    width: 100vw;
-    background-image: url('./background.jpeg');
-    background-size: cover;
-    height: 30vw;
-    margin: 200px 0 150px;
-    min-height: 1000px;
-    position: relative;
-
-    .overlay {
-        position: absolute;
-        width: 100vw;
-        width: 1px;
-        height: 1px;
-
-        &.overlay1 {
-            top: -1px;
-            left: 0;
-            border-top: 10vw solid white;
-            border-right: 100vw solid transparent;
-        }
-
-        &.overlay2 {
-            bottom: -1px;
-            right: 0;
-            border-bottom: 10vw solid white;
-            border-left: 100vw solid transparent;
-        }
-    }
-
-    .terminal {
-        position: absolute;
-        left: 50%;
-        top: 5vw;
-
-        width: 1304px;
-        margin-left: -652px;
-        height: 972px;
-        border-radius: 9px;
-
-        box-shadow: 0 0 100px black;
-        background: url('./terminal.png');
-        background-size: cover;
-
-        animation: slideIn ease-out 1s;
-        opacity: .95;
-    }
-
-    @media(max-width: 1500px) {
-        min-height: 500px;
-        margin: 200px 0 100px;
-
-        .terminal {
-            width: 652px;
-            top: -100px;
-            margin-left: -326px;
-            height: 486px;
-            border-radius: 5px;
-        }
-    }
-
-    @media(max-width: 750px) {
-        min-height: 250px;
-        margin: 100px 0 50px;
-
-        .terminal {
-            width: 326px;
-            top: -50px;
-            margin-left: -163px;
-            height: 243px;
-            border-radius: 3px;
-        }
-    }
-
-}
-
-.feature {
-    font-size: 45px;
-    line-height: 40px;
-    opacity: .5;
-    font-style: italic;
-}
-
-@keyframes slideIn {
-    from {
-        opacity: 0;
-        margin-top: 200px;
-    }
-    to {
-        opacity: .95;
-        margin-top: 0px;
-    }
-}
-
-
-.background-stripe2 {
-    width: 100vw;
-    background-image: url('./background.jpeg');
-    background-size: cover;
-    height: 30vw;
-    margin: 100px 0 0;
-    position: relative;
-
-    .overlay {
-        position: absolute;
-        width: 100vw;
-        width: 1px;
-        height: 1px;
-
-        &.overlay1 {
-            top: -1px;
-            right: 0;
-            border-top: 10vw solid white;
-            border-left: 100vw solid transparent;
-        }
-    }
-}

BIN
docs/terminal.png


+ 0 - 27
docs/webpack.config.js

@@ -1,27 +0,0 @@
-const path = require('path')
-
-module.exports = {
-  entry: {
-    'index.ignore': 'file-loader?name=../index.html!pug-html-loader!' + path.resolve(__dirname, './index.pug'),
-    'bundle': path.resolve(__dirname, 'index.js'),
-  },
-  context: __dirname,
-  output: {
-    path: path.join(__dirname, 'dist'),
-    filename: '[name].js'
-  },
-  module: {
-    rules: [
-      { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
-      {
-        test: /\.(jpeg|png)?$/,
-        use: {
-          loader: 'file-loader',
-          options: {
-            name: 'assets/[name].[ext]'
-          }
-        }
-      }
-    ]
-  },
-}

BIN
extras/UAC.exe


+ 82 - 71
package.json

@@ -1,64 +1,65 @@
 {
-  "name": "term",
   "devDependencies": {
-    "@types/electron-config": "^0.2.1",
-    "@types/electron-debug": "^1.1.0",
-    "@types/fs-promise": "1.0.1",
-    "@types/js-yaml": "^3.11.2",
-    "@types/node": "^10.11.5",
-    "@types/webpack-env": "1.13.0",
-    "app-builder-lib": "^20.28.4",
-    "apply-loader": "0.1.0",
+    "@fortawesome/fontawesome-free": "^5.9.0",
+    "@types/electron-config": "^3.2.2",
+    "@types/electron-debug": "^2.1.0",
+    "@types/fs-promise": "1.0.3",
+    "@types/js-yaml": "^3.12.1",
+    "@types/node": "^12.0.10",
+    "@types/webpack-env": "1.13.9",
+    "@typescript-eslint/eslint-plugin": "^1.11.0",
+    "@typescript-eslint/parser": "^1.11.0",
+    "app-builder-lib": "^21.0.3",
+    "apply-loader": "2.0.0",
     "awesome-typescript-loader": "^5.0.0",
-    "core-js": "2.4.1",
-    "cross-env": "4.0.0",
-    "css-loader": "0.28.0",
-    "electron": "4.0.0-beta.8",
-    "electron-builder": "^20.38.2",
-    "electron-builder-squirrel-windows": "^20.28.3",
-    "electron-installer-snap": "^3.0.0",
-    "electron-rebuild": "^1.8.2",
-    "file-loader": "^1.1.11",
-    "font-awesome": "4.7.0",
-    "graceful-fs": "^4.1.11",
-    "html-loader": "0.4.4",
-    "json-loader": "0.5.4",
-    "less": "2.7.1",
-    "less-loader": "2.2.3",
-    "node-abi": "^2.4.4",
-    "node-gyp": "^3.6.2",
-    "node-sass": "^4.5.3",
-    "npmlog": "4.1.0",
+    "core-js": "^3.1.4",
+    "cross-env": "5.2.0",
+    "css-loader": "3.0.0",
+    "electron": "^5.0.5",
+    "electron-builder": "^20.44.4",
+    "electron-installer-snap": "^4.0.0",
+    "electron-notarize": "^0.1.1",
+    "electron-rebuild": "^1.8.5",
+    "eslint": "^5.16.0",
+    "file-loader": "^4.0.0",
+    "graceful-fs": "^4.1.15",
+    "html-loader": "0.5.5",
+    "json-loader": "0.5.7",
+    "node-abi": "^2.9.0",
+    "node-gyp": "^5.0.0",
+    "node-sass": "^4.12.0",
+    "npmlog": "4.1.2",
     "npx": "^10.2.0",
-    "pug": "^2.0.3",
-    "pug-html-loader": "1.0.9",
-    "pug-lint": "^2.5.0",
+    "pug": "^2.0.4",
+    "pug-html-loader": "1.1.5",
+    "pug-lint": "^2.6.0",
     "pug-loader": "^2.4.0",
-    "pug-static-loader": "0.0.1",
-    "raven-js": "3.16.0",
-    "raw-loader": "0.5.1",
+    "pug-static-loader": "2.0.0",
+    "raven-js": "3.27.2",
+    "raw-loader": "3.0.0",
     "sass-loader": "^7.0.1",
-    "shelljs": "0.7.7",
+    "shelljs": "0.8.3",
     "source-code-pro": "^2.30.1",
-    "source-sans-pro": "2.0.10",
-    "style-loader": "0.13.1",
+    "source-sans-pro": "2.45.0",
+    "style-loader": "^0.23.1",
     "svg-inline-loader": "^0.8.0",
     "to-string-loader": "1.1.5",
-    "tslint": "5.1.0",
-    "tslint-config-standard": "5.0.2",
-    "tslint-eslint-rules": "4.0.0",
-    "typescript": "^3.1.3",
-    "url-loader": "^1.1.1",
-    "val-loader": "0.5.0",
-    "webpack": "^4.22.0",
-    "webpack-cli": "^3.1.2",
-    "yaml-loader": "0.4.0",
-    "yarn": "^1.10.1"
+    "typedoc": "^0.14.2",
+    "typescript": "^3.5.2",
+    "url-loader": "^2.0.0",
+    "val-loader": "1.1.1",
+    "webpack": "^4.35.0",
+    "webpack-cli": "^3.3.5",
+    "yaml-loader": "0.5.0"
+  },
+  "resolutions": {
+    "*/node-abi": "^2.8.0"
   },
   "build": {
     "appId": "org.terminus",
     "productName": "Terminus",
     "compression": "normal",
+    "afterSign": "./build/mac/afterSignHook.js",
     "files": [
       "**/*",
       "dist"
@@ -69,24 +70,33 @@
     ],
     "win": {
       "icon": "./build/windows/icon.ico",
-      "publish": [
-        "github"
-      ],
-      "artifactName": "terminus-${version}-setup.exe"
+      "artifactName": "terminus-${version}-setup.exe",
+      "rfc3161TimeStampServer": "http://sha256timestamp.ws.symantec.com/sha256/timestamp"
     },
-    "squirrelWindows": {
-      "iconUrl": "https://github.com/Eugeny/terminus/raw/master/build/windows/icon.ico",
-      "artifactName": "terminus-${version}-setup.exe"
+    "nsis": {
+      "oneClick": false,
+      "artifactName": "terminus-${version}-setup.${ext}",
+      "installerIcon": "./build/windows/icon.ico"
     },
+    "publish": [
+      {
+        "provider": "bintray",
+        "token": "d993c4faa708a4cba84fa3a8e822457e7298d75c",
+        "component": "main"
+      },
+      {
+        "provider": "github"
+      }
+    ],
     "portable": {
       "artifactName": "terminus-${version}-portable.exe"
     },
     "mac": {
       "category": "public.app-category.video",
       "icon": "./build/mac/icon.icns",
-      "publish": [
-        "github"
-      ],
+      "artifactName": "terminus-${version}-macos.${ext}",
+      "hardenedRuntime": true,
+      "entitlements": "./build/mac/entitlements.plist",
       "extendInfo": {
         "NSRequiresAquaSystemAppearance": false
       }
@@ -97,21 +107,17 @@
     "linux": {
       "category": "Utilities",
       "icon": "./build/icons",
-      "artifactName": "terminus-${version}-linux.${ext}",
-      "publish": [
-        "github"
-      ]
+      "artifactName": "terminus-${version}-linux.${ext}"
     },
     "deb": {
       "depends": [
-        "screen",
         "gconf2",
         "gconf-service",
         "libnotify4",
+        "libsecret-1-0",
         "libappindicator1",
         "libxtst6",
-        "libnss3",
-        "tmux"
+        "libnss3"
       ],
       "afterInstall": "build/linux/after-install.tpl"
     },
@@ -123,12 +129,17 @@
     }
   },
   "scripts": {
-    "build": "webpack --color --config app/webpack.main.config.js && webpack --color --config app/webpack.config.js && webpack --color --config terminus-core/webpack.config.js && webpack --color --config terminus-settings/webpack.config.js && webpack --color --config terminus-terminal/webpack.config.js && webpack --color --config terminus-settings/webpack.config.js && webpack --color --config terminus-plugin-manager/webpack.config.js && webpack --color --config terminus-community-color-schemes/webpack.config.js && webpack --color --config terminus-ssh/webpack.config.js",
-    "watch": "cross-env DEV=1 webpack --progress --color --watch",
-    "start": "cross-env DEV=1 electron app --debug",
-    "prod": "cross-env DEV=1 electron app",
-    "lint": "tslint -c tslint.json -t stylish terminus-*/src/**/*.ts terminus-*/src/*.ts app/src/*.ts",
-    "postinstall": "install-app-deps"
+    "build": "webpack --color --config app/webpack.main.config.js && webpack --color --config app/webpack.config.js && webpack --color --config terminus-core/webpack.config.js && webpack --color --config terminus-settings/webpack.config.js && webpack --color --config terminus-terminal/webpack.config.js && webpack --color --config terminus-plugin-manager/webpack.config.js && webpack --color --config terminus-community-color-schemes/webpack.config.js && webpack --color --config terminus-ssh/webpack.config.js",
+    "build:typings": "tsc --project terminus-core/tsconfig.typings.json && tsc --project terminus-settings/tsconfig.typings.json && tsc --project terminus-terminal/tsconfig.typings.json && tsc --project terminus-plugin-manager/tsconfig.typings.json && tsc --project terminus-ssh/tsconfig.typings.json",
+    "watch": "cross-env TERMINUS_DEV=1 webpack --progress --color --watch",
+    "start": "cross-env TERMINUS_DEV=1 electron app --debug",
+    "prod": "cross-env TERMINUS_DEV=1 electron app",
+    "docs": "typedoc --out docs/api terminus-core/src && typedoc --out docs/api/terminal --tsconfig terminus-terminal/tsconfig.typings.json terminus-terminal/src && typedoc --out docs/api/settings --tsconfig terminus-settings/tsconfig.typings.json terminus-settings/src",
+    "lint": "eslint --ext ts */src",
+    "postinstall": "node ./scripts/install-deps.js"
   },
-  "repository": "eugeny/terminus"
+  "repository": "eugeny/terminus",
+  "dependencies": {
+    "eslint-plugin-import": "^2.18.0"
+  }
 }

+ 1 - 1
scripts/build-linux.js

@@ -11,4 +11,4 @@ builder({
     },
   },
   publish: 'onTag',
-})
+}).catch(() => process.exit(1))

+ 1 - 1
scripts/build-macos.js

@@ -11,4 +11,4 @@ builder({
     },
   },
   publish: 'onTag',
-})
+}).catch(() => process.exit(1))

+ 13 - 17
scripts/build-native.js

@@ -4,24 +4,20 @@ const path = require('path')
 const vars = require('./vars')
 
 lifecycles = []
-lifecycles.push(rebuild({
-  buildPath: path.resolve(__dirname, '../app'),
-  electronVersion: vars.electronVersion,
-  force: true,
-}).lifecycle)
-lifecycles.push(rebuild({
-  buildPath: path.resolve(__dirname, '../terminus-ssh'),
-  electronVersion: vars.electronVersion,
-  force: true,
-}).lifecycle)
-lifecycles.push(rebuild({
-  buildPath: path.resolve(__dirname, '../terminus-terminal'),
-  electronVersion: vars.electronVersion,
-  force: true,
-}).lifecycle)
+for (let dir of ['app', 'terminus-core', 'terminus-ssh', 'terminus-terminal']) {
+  build = rebuild({
+    buildPath: path.resolve(__dirname, '../' + dir),
+    electronVersion: vars.electronVersion,
+    force: true,
+  })
+  build.catch(() => process.exit(1))
+  lifecycles.push([build.lifecycle, dir])
+}
+
+console.info('Building against Electron', vars.electronVersion)
 
-for (let lc of lifecycles) {
+for (let [lc, dir] of lifecycles) {
   lc.on('module-found', name => {
-    console.info('Rebuilding', name)
+    console.info('Rebuilding', dir + '/' + name)
   })
 }

+ 2 - 2
scripts/build-windows.js

@@ -4,11 +4,11 @@ const vars = require('./vars')
 
 builder({
   dir: true,
-  win: ['squirrel', 'portable'],
+  win: ['nsis', 'portable'],
   config: {
     extraMetadata: {
       version: vars.version,
     },
   },
   publish: 'onTag',
-})
+}).catch(() => process.exit(1))

+ 0 - 1
scripts/install-deps.js

@@ -8,7 +8,6 @@ const localBinPath = path.resolve(__dirname, '../node_modules/.bin');
 const npx = `${localBinPath}/npx`;
 
 log.info('deps', 'app')
-sh.exec(`${npx} yarn install`)
 
 sh.cd('app')
 sh.exec(`${npx} yarn install`)

+ 9 - 3
scripts/vars.js

@@ -1,12 +1,18 @@
 const path = require('path')
 const fs = require('fs')
+const semver = require('semver')
 const childProcess = require('child_process')
 
 const appInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../app/package.json')))
-const pkgInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json')))
+const electronInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../node_modules/electron/package.json')))
 
 exports.version = childProcess.execSync('git describe --tags', {encoding:'utf-8'})
-exports.version = exports.version.substring(1, exports.version.length - 1)
+exports.version = exports.version.substring(1).trim()
+exports.version = exports.version.replace('-', '-c')
+
+if (exports.version.includes('-c')) {
+  exports.version = semver.inc(exports.version, 'prepatch').replace('-0', '-nightly.0')
+}
 
 exports.builtinPlugins = [
   'terminus-core',
@@ -20,4 +26,4 @@ exports.bundledModules = [
   '@angular',
   '@ng-bootstrap',
 ]
-exports.electronVersion = pkgInfo.devDependencies.electron
+exports.electronVersion = electronInfo.version

+ 26 - 0
snap/snapcraft.yaml

@@ -0,0 +1,26 @@
+name: terminus
+version: '1.0.0'
+summary: A terminal for a modern age
+description: |
+  Terminus is a terminal heavily inspired by Hyper. It is, however, designed for people who need to get things done.
+
+grade: devel 
+confinement: devmode
+
+apps:
+  terminus:
+    command: opt/terminus/terminus
+    
+parts:
+  app:
+    plugin: nodejs
+    source: .
+    build-packages: 
+      - libfontconfig-dev
+    override-build: |
+      yarn
+      ./scripts/build-native.js
+      yarn run build
+      ./scripts/build-linux.js
+      mkdir -p $SNAPCRAFT_PART_INSTALL/opt/terminus || true
+      cp -ar dist/linux-unpacked/* $SNAPCRAFT_PART_INSTALL/opt/terminus/

+ 4 - 5
terminus-community-color-schemes/package.json

@@ -1,12 +1,12 @@
 {
   "name": "terminus-community-color-schemes",
-  "version": "1.0.0-alpha.55",
+  "version": "1.0.73-c4-ga7d62b0",
   "description": "Community color schemes for Terminus",
   "keywords": [
     "terminus-builtin-plugin"
   ],
   "main": "dist/index.js",
-  "typings": "dist/index.d.ts",
+  "typings": "typings/index.d.ts",
   "scripts": {
     "build": "webpack --progress --color",
     "watch": "webpack --progress --color --watch"
@@ -22,8 +22,7 @@
     "terminus-terminal": "*"
   },
   "devDependencies": {
-    "@types/node": "7.0.12",
+    "@types/node": "12.0.10",
     "@types/webpack-env": "^1.13.0"
-  },
-  "false": {}
+  }
 }

+ 36 - 0
terminus-community-color-schemes/schemes/Relaxed

@@ -0,0 +1,36 @@
+! special
+*.foreground:   #d8d8d8
+*.background:   #343a43
+*.cursorColor:  #d8d8d8
+
+! black
+*.color0:       #2c3037
+*.color8:       #626262
+
+! red
+*.color1:       #bb5653
+*.color9:       #c35956
+
+! green
+*.color2:       #909d62
+*.color10:      #9fab76
+
+! yellow
+*.color3:       #eac179
+*.color11:      #ecc179
+
+! blue
+*.color4:       #698698
+*.color12:      #7da9c7
+
+! magenta
+*.color5:       #b06597
+*.color13:      #ba6ca0
+
+! cyan
+*.color6:       #c9dfff
+*.color14:      #abbacf
+
+! white
+*.color7:       #d8d8d8
+*.color15:      #f7f7f7

+ 8 - 8
terminus-community-color-schemes/src/colorSchemes.ts

@@ -1,26 +1,26 @@
 import { Injectable } from '@angular/core'
-import { TerminalColorSchemeProvider, ITerminalColorScheme } from 'terminus-terminal'
+import { TerminalColorSchemeProvider, TerminalColorScheme } from 'terminus-terminal'
 
 const schemeContents = require.context('../schemes/', true, /.*/)
 
 @Injectable()
 export class ColorSchemes extends TerminalColorSchemeProvider {
-    async getSchemes (): Promise<ITerminalColorScheme[]> {
-        let schemes: ITerminalColorScheme[] = []
+    async getSchemes (): Promise<TerminalColorScheme[]> {
+        const schemes: TerminalColorScheme[] = []
 
         schemeContents.keys().forEach(schemeFile => {
-            let lines = (schemeContents(schemeFile) as string).split('\n')
+            const lines = (schemeContents(schemeFile).default as string).split('\n')
 
             // process #define variables
-            let variables: any = {}
+            const variables: any = {}
             lines
                 .filter(x => x.startsWith('#define'))
                 .map(x => x.split(' ').map(v => v.trim()))
-                .forEach(([ignore, variableName, variableValue]) => {
+                .forEach(([_, variableName, variableValue]) => {
                     variables[variableName] = variableValue
                 })
 
-            let values: any = {}
+            const values: any = {}
             lines
                 .filter(x => x.startsWith('*.'))
                 .map(x => x.substring(2))
@@ -29,7 +29,7 @@ export class ColorSchemes extends TerminalColorSchemeProvider {
                     values[key] = variables[value] ? variables[value] : value
                 })
 
-            let colors: string[] = []
+            const colors: string[] = []
             let colorIndex = 0
             while (values[`color${colorIndex}`]) {
                 colors.push(values[`color${colorIndex}`])

+ 1 - 1
terminus-community-color-schemes/src/index.ts

@@ -8,4 +8,4 @@ import { ColorSchemes } from './colorSchemes'
         { provide: TerminalColorSchemeProvider, useClass: ColorSchemes, multi: true },
     ],
 })
-export default class PopularThemesModule { }
+export default class PopularThemesModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class

+ 1 - 2
terminus-community-color-schemes/tsconfig.json

@@ -2,7 +2,6 @@
   "extends": "../tsconfig.json",
   "exclude": ["node_modules", "dist"],
   "compilerOptions": {
-    "baseUrl": "src",
-    "declarationDir": "dist"
+    "baseUrl": "src"
   }
 }

+ 1 - 1
terminus-community-color-schemes/webpack.config.js

@@ -13,7 +13,7 @@ module.exports = {
     libraryTarget: 'umd',
     devtoolModuleFilenameTemplate: 'webpack-terminus-community-color-schemes:///[resource-path]',
   },
-  mode: process.env.DEV ? 'development' : 'production',
+  mode: process.env.TERMINUS_DEV ? 'development' : 'production',
   optimization:{
      minimize: false,
   },

+ 7 - 7
terminus-community-color-schemes/yarn.lock

@@ -2,12 +2,12 @@
 # yarn lockfile v1
 
 
-"@types/node@7.0.12":
-  version "7.0.12"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.12.tgz#ae5f67a19c15f752148004db07cbbb372e69efc9"
-  integrity sha1-rl9noZwV91IUgATbB8u7Ny5p78k=
+"@types/node@12.0.10":
+  version "12.0.10"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-12.0.10.tgz#51babf9c7deadd5343620055fc8aff7995c8b031"
+  integrity sha512-LcsGbPomWsad6wmMNv7nBLw7YYYyfdYcz6xryKYQhx89c3XXan+8Q6AJ43G5XDIaklaVkK3mE4fCb0SBvMiPSQ==
 
 "@types/webpack-env@^1.13.0":
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.13.1.tgz#b45c222e24301bd006e3edfc762cc6b51bda236a"
-  integrity sha512-oHyg0NssP2RCpCvE35hhbSqMJRsc5lSW+GFe+Vc65JL+kHII1VMYM+0KeV/z4utFuUqPoQRmq8KMMp7ba0dj6Q==
+  version "1.13.9"
+  resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.13.9.tgz#a67287861c928ebf4159a908d1fb1a2a34d4097a"
+  integrity sha512-p8zp5xqkly3g4cCmo2mKOHI9+Z/kObmDj0BmjbDDJQlgDTiEGTbm17MEwTAusV6XceCy+bNw9q/ZHXHyKo3zkg==

+ 31 - 0
terminus-core/README.md

@@ -0,0 +1,31 @@
+Terminus Core Plugin
+--------------------
+
+See also: [Settings plugin API](./settings/), [Terminal plugin API](./settings/)
+
+* tabbed interface services
+* toolbar UI
+* config file management
+* hotkeys
+* tab recovery
+* logging
+* theming
+
+Using the API:
+
+```ts
+import { AppService, TabContextMenuItemProvider } from 'terminus-core'
+```
+
+Exporting your subclasses:
+
+```ts
+@NgModule({
+  ...
+  providers: [
+    ...
+    { provide: TabContextMenuItemProvider, useClass: MyContextMenu, multi: true },
+    ...
+  ]
+})
+```

+ 15 - 13
terminus-core/package.json

@@ -1,12 +1,12 @@
 {
   "name": "terminus-core",
-  "version": "1.0.0-alpha.55",
+  "version": "1.0.73-c4-ga7d62b0",
   "description": "Terminus core",
   "keywords": [
     "terminus-builtin-plugin"
   ],
   "main": "dist/index.js",
-  "typings": "dist/index.d.ts",
+  "typings": "typings/index.d.ts",
   "scripts": {
     "build": "webpack --progress --color --display-modules",
     "watch": "webpack --progress --color --watch"
@@ -18,18 +18,25 @@
   "license": "MIT",
   "devDependencies": {
     "@types/js-yaml": "^3.9.0",
-    "@types/node": "^7.0.37",
+    "@types/node": "^12.0.2",
+    "@types/shell-escape": "^0.2.0",
     "@types/webpack-env": "^1.13.0",
     "@types/winston": "^2.3.6",
-    "axios": "^0.18.0",
+    "axios": "^0.19.0",
     "bootstrap": "^4.1.3",
-    "core-js": "^2.4.1",
-    "electron-updater": "^2.8.9",
+    "core-js": "^3.1.2",
+    "deepmerge": "^3.2.0",
+    "electron-updater": "^4.0.6",
+    "js-yaml": "^3.9.0",
+    "mixpanel": "^0.10.2",
     "ng2-dnd": "^5.0.2",
     "ngx-perfect-scrollbar": "^6.0.0",
-    "rage-edit-tmp": "^1.1.0",
     "shell-escape": "^0.2.0",
-    "universal-analytics": "^0.4.17"
+    "uuid": "^3.3.2",
+    "winston": "^3.2.1"
+  },
+  "optionalDependencies": {
+    "windows-native-registry": "^1.0.14"
   },
   "peerDependencies": {
     "@angular/animations": "4.0.1",
@@ -40,10 +47,5 @@
     "@angular/platform-browser-dynamic": "4.0.1",
     "rxjs": "5.3.0",
     "zone.js": "0.8.4"
-  },
-  "dependencies": {
-    "deepmerge": "^1.5.0",
-    "js-yaml": "^3.9.0",
-    "winston": "^2.4.0"
   }
 }

+ 34 - 1
terminus-core/src/api/configProvider.ts

@@ -1,4 +1,37 @@
+/**
+ * Extend to add your own config options
+ */
 export abstract class ConfigProvider {
+    /**
+     * Default values, e.g.
+     *
+     * ```ts
+     * defaults = {
+     *   myPlugin: {
+     *     foo: 1
+     *   }
+     * }
+     * ```
+     */
     defaults: any = {}
-    platformDefaults: any = {}
+
+    /**
+     * [[Platform]] specific defaults, e.g.
+     *
+     * ```ts
+     * platformDefaults = {
+     *   [Platform.Windows]: {
+     *     myPlugin: {
+     *       bar: true
+     *     }
+     *   },
+     *   [Platform.macOS]: {
+     *     myPlugin: {
+     *       bar: false
+     *     }
+     *   },
+     * }
+     * ```
+     */
+    platformDefaults: {[platform: string]: any} = {}
 }

+ 9 - 5
terminus-core/src/api/hotkeyProvider.ts

@@ -1,10 +1,14 @@
-export interface IHotkeyDescription {
-    id: string,
-    name: string,
+export interface HotkeyDescription {
+    id: string
+    name: string
 }
 
+/**
+ * Extend to provide your own hotkeys. A corresponding [[ConfigProvider]]
+ * must also provide the `hotkeys.foo` config options with the default values
+ */
 export abstract class HotkeyProvider {
-    hotkeys: IHotkeyDescription[] = []
+    hotkeys: HotkeyDescription[] = []
 
-    abstract provide (): Promise<IHotkeyDescription[]>
+    abstract provide (): Promise<HotkeyDescription[]>
 }

+ 5 - 2
terminus-core/src/api/index.ts

@@ -1,9 +1,11 @@
 export { BaseTabComponent, BaseTabProcess } from '../components/baseTab.component'
+export { SplitTabComponent, SplitContainer } from '../components/splitTab.component'
 export { TabRecoveryProvider, RecoveredTab } from './tabRecovery'
-export { ToolbarButtonProvider, IToolbarButton } from './toolbarButtonProvider'
+export { ToolbarButtonProvider, ToolbarButton } from './toolbarButtonProvider'
 export { ConfigProvider } from './configProvider'
-export { HotkeyProvider, IHotkeyDescription } from './hotkeyProvider'
+export { HotkeyProvider, HotkeyDescription } from './hotkeyProvider'
 export { Theme } from './theme'
+export { TabContextMenuItemProvider } from './tabContextMenuProvider'
 
 export { AppService } from '../services/app.service'
 export { ConfigService } from '../services/config.service'
@@ -15,3 +17,4 @@ export { HotkeysService } from '../services/hotkeys.service'
 export { HostAppService, Platform } from '../services/hostApp.service'
 export { ShellIntegrationService } from '../services/shellIntegration.service'
 export { ThemesService } from '../services/themes.service'
+export { TabsService } from '../services/tabs.service'

+ 11 - 0
terminus-core/src/api/tabContextMenuProvider.ts

@@ -0,0 +1,11 @@
+import { BaseTabComponent } from '../components/baseTab.component'
+import { TabHeaderComponent } from '../components/tabHeader.component'
+
+/**
+ * Extend to add items to the tab header's context menu
+ */
+export abstract class TabContextMenuItemProvider {
+    weight = 0
+
+    abstract async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<Electron.MenuItemConstructorOptions[]>
+}

+ 32 - 4
terminus-core/src/api/tabRecovery.ts

@@ -1,10 +1,38 @@
-import { TabComponentType } from '../services/app.service'
+import { TabComponentType } from '../services/tabs.service'
 
 export interface RecoveredTab {
-    type: TabComponentType,
-    options?: any,
+    /**
+     * Component type to be instantiated
+     */
+    type: TabComponentType
+
+    /**
+     * Component instance inputs
+     */
+    options?: any
 }
 
+/**
+ * Extend to enable recovery for your custom tab.
+ * This works in conjunction with [[getRecoveryToken()]]
+ *
+ * Terminus will try to find any [[TabRecoveryProvider]] that is able to process
+ * the recovery token previously returned by [[getRecoveryToken]].
+ *
+ * Recommended token format:
+ *
+ * ```json
+ * {
+ *   type: 'my-tab-type',
+ *   foo: 'bar',
+ * }
+ * ```
+ */
 export abstract class TabRecoveryProvider {
-    abstract async recover (recoveryToken: any): Promise<RecoveredTab|null>
+    /**
+     * @param recoveryToken a recovery token found in the saved tabs list
+     * @returns [[RecoveredTab]] descriptor containing tab type and component inputs
+     *          or `null` if this token is from a different tab type or is not supported
+     */
+    abstract async recover (recoveryToken: any): Promise<RecoveredTab | null>
 }

+ 8 - 0
terminus-core/src/api/theme.ts

@@ -1,5 +1,13 @@
+/**
+ * Extend to add a custom CSS theme
+ */
 export abstract class Theme {
     name: string
+
+    /**
+     * Complete CSS stylesheet
+     */
     css: string
+
     terminalBackground: string
 }

+ 29 - 4
terminus-core/src/api/toolbarButtonProvider.ts

@@ -1,14 +1,39 @@
 import { SafeHtml } from '@angular/platform-browser'
 
-export interface IToolbarButton {
+/**
+ * See [[ToolbarButtonProvider]]
+ */
+export interface ToolbarButton {
+    /**
+     * Raw SVG icon code
+     */
     icon: SafeHtml
-    touchBarNSImage?: string
+
     title: string
+
+    /**
+     * Optional Touch Bar icon ID
+     */
+    touchBarNSImage?: string
+
+    /**
+     * Optional Touch Bar button label
+     */
     touchBarTitle?: string
+
     weight?: number
-    click: () => void
+
+    click?: () => void
+
+    submenu?: () => Promise<ToolbarButton[]>
+
+    /** @hidden */
+    submenuItems?: ToolbarButton[]
 }
 
+/**
+ * Extend to add buttons to the toolbar
+ */
 export abstract class ToolbarButtonProvider {
-    abstract provide (): IToolbarButton[]
+    abstract provide (): ToolbarButton[]
 }

+ 36 - 10
terminus-core/src/components/appRoot.component.pug

@@ -32,22 +32,48 @@ title-bar(
             )
 
         .btn-group.background
-            button.btn.btn-secondary.btn-tab-bar(
-                *ngFor='let button of leftToolbarButtons',
-                [title]='button.title',
-                (click)='button.click()',
-                [innerHTML]='button.icon',
+            .d-flex(
+                *ngFor='let button of leftToolbarButtons', 
+                ngbDropdown,
+                (openChange)='generateButtonSubmenu(button)',
             )
+                button.btn.btn-secondary.btn-tab-bar(
+                    [title]='button.title',
+                    (click)='button.click && button.click()',
+                    [innerHTML]='button.icon',
+                    ngbDropdownToggle,
+                )
+                div(*ngIf='button.submenu', ngbDropdownMenu)
+                    button.dropdown-item.d-flex.align-items-center(
+                        *ngFor='let item of button.submenuItems', 
+                        (click)='item.click()',
+                        ngbDropdownItem,
+                    ) 
+                        .icon-wrapper([innerHTML]='item.icon')
+                        .ml-3 {{item.title}}
 
         .drag-space.background([class.persistent]='config.store.appearance.frame == "thin" && hostApp.platform != Platform.macOS')
 
         .btn-group.background
-            button.btn.btn-secondary.btn-tab-bar(
-                *ngFor='let button of rightToolbarButtons',
-                [title]='button.title',
-                (click)='button.click()',
-                [innerHTML]='button.icon',
+            .d-flex(
+                *ngFor='let button of rightToolbarButtons', 
+                ngbDropdown,
+                (openChange)='generateButtonSubmenu(button)',
             )
+                button.btn.btn-secondary.btn-tab-bar(
+                    [title]='button.title',
+                    (click)='button.click && button.click()',
+                    [innerHTML]='button.icon',
+                    ngbDropdownToggle,
+                )
+                div(*ngIf='button.submenu', ngbDropdownMenu)
+                    button.dropdown-item.d-flex.align-items-center(
+                        *ngFor='let item of button.submenuItems', 
+                        (click)='item.click()',
+                        ngbDropdownItem,
+                    ) 
+                        .icon-wrapper([innerHTML]='item.icon')
+                        .ml-3 {{item.title}}
 
             button.btn.btn-secondary.btn-tab-bar.btn-update(
                 *ngIf='updatesAvailable',

+ 13 - 1
terminus-core/src/components/appRoot.component.scss

@@ -48,6 +48,10 @@ $tab-border-radius: 4px;
         color: #aaa;
         border: none;
         border-radius: 0;
+
+        &.dropdown-toggle::after {
+            display: none;
+        }
     }
 
     &>.tabs {
@@ -88,12 +92,20 @@ hotkey-hint {
     max-width: 300px;
 }
 
-::ng-deep .btn-tab-bar svg {
+::ng-deep .btn-tab-bar svg,
+::ng-deep .btn-tab-bar + .dropdown-menu svg {
+    width: 16px;
     height: 16px;
     fill: white;
     fill-opacity: 0.75;
 }
 
+.icon-wrapper {
+    display: flex;
+    width: 16px;
+    height: 17px;
+}
+
 ::ng-deep .btn-update svg {
     fill: cyan;
 }

+ 31 - 23
terminus-core/src/components/appRoot.component.ts

@@ -9,15 +9,15 @@ import { HotkeysService } from '../services/hotkeys.service'
 import { Logger, LogService } from '../services/log.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 { UpdaterService } from '../services/updater.service'
 import { TouchbarService } from '../services/touchbar.service'
 
 import { BaseTabComponent } from './baseTab.component'
 import { SafeModeModalComponent } from './safeModeModal.component'
-import { AppService, IToolbarButton, ToolbarButtonProvider } from '../api'
+import { AppService, ToolbarButton, ToolbarButtonProvider } from '../api'
 
+/** @hidden */
 @Component({
     selector: 'app-root',
     template: require('./appRoot.component.pug'),
@@ -26,36 +26,36 @@ import { AppService, IToolbarButton, ToolbarButtonProvider } from '../api'
         trigger('animateTab', [
             state('in', style({
                 'flex-basis': '200px',
-                'width': '200px',
+                width: '200px',
             })),
             transition(':enter', [
                 style({
                     'flex-basis': '1px',
-                    'width': '1px',
+                    width: '1px',
                 }),
                 animate('250ms ease-in-out', style({
                     'flex-basis': '200px',
-                    'width': '200px',
-                }))
+                    width: '200px',
+                })),
             ]),
             transition(':leave', [
                 style({
                     'flex-basis': '200px',
-                    'width': '200px',
+                    width: '200px',
                 }),
                 animate('250ms ease-in-out', style({
                     'flex-basis': '1px',
-                    'width': '1px',
-                }))
-            ])
-        ])
-    ]
+                    width: '1px',
+                })),
+            ]),
+        ]),
+    ],
 })
 export class AppRootComponent {
     Platform = Platform
     @Input() ready = false
-    @Input() leftToolbarButtons: IToolbarButton[]
-    @Input() rightToolbarButtons: IToolbarButton[]
+    @Input() leftToolbarButtons: ToolbarButton[]
+    @Input() rightToolbarButtons: ToolbarButton[]
     @HostBinding('class.platform-win32') platformClassWindows = process.platform === 'win32'
     @HostBinding('class.platform-darwin') platformClassMacOS = process.platform === 'darwin'
     @HostBinding('class.platform-linux') platformClassLinux = process.platform === 'linux'
@@ -69,7 +69,6 @@ export class AppRootComponent {
     constructor (
         private docking: DockingService,
         private electron: ElectronService,
-        private tabRecovery: TabRecoveryService,
         private hotkeys: HotkeysService,
         private updater: UpdaterService,
         private touchbar: TouchbarService,
@@ -90,9 +89,9 @@ export class AppRootComponent {
 
         this.updateIcon = domSanitizer.bypassSecurityTrustHtml(require('../icons/gift.svg')),
 
-        this.hotkeys.matchedHotkey.subscribe((hotkey) => {
+        this.hotkeys.matchedHotkey.subscribe((hotkey: string) => {
             if (hotkey.startsWith('tab-')) {
-                let index = parseInt(hotkey.split('-')[1])
+                const index = parseInt(hotkey.split('-')[1])
                 if (index <= this.app.tabs.length) {
                     this.app.selectTab(this.app.tabs[index - 1])
                 }
@@ -128,6 +127,11 @@ export class AppRootComponent {
             this.onGlobalHotkey()
         })
 
+        this.hostApp.windowCloseRequest$.subscribe(async () => {
+            await this.app.closeAllTabs()
+            this.hostApp.closeWindow()
+        })
+
         if (window['safeModeReason']) {
             ngbModal.open(SafeModeModalComponent)
         }
@@ -199,9 +203,7 @@ export class AppRootComponent {
     }
 
     async ngOnInit () {
-        await this.tabRecovery.recoverTabs()
         this.ready = true
-        this.tabRecovery.saveTabs(this.app.tabs)
 
         this.app.emitReady()
     }
@@ -231,14 +233,20 @@ export class AppRootComponent {
         })
     }
 
-    private getToolbarButtons (aboveZero: boolean): IToolbarButton[] {
-        let buttons: IToolbarButton[] = []
+    async generateButtonSubmenu (button: ToolbarButton) {
+        if (button.submenu) {
+            button.submenuItems = await button.submenu()
+        }
+    }
+
+    private getToolbarButtons (aboveZero: boolean): ToolbarButton[] {
+        let buttons: ToolbarButton[] = []
         this.config.enabledServices(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))
+            .filter(button => button.weight > 0 === aboveZero)
+            .sort((a: ToolbarButton, b: ToolbarButton) => (a.weight || 0) - (b.weight || 0))
     }
 
     private updateVibrancy () {

+ 71 - 12
terminus-core/src/components/baseTab.component.ts

@@ -1,27 +1,58 @@
 import { Observable, Subject } from 'rxjs'
 import { ViewRef } from '@angular/core'
 
+/**
+ * Represents an active "process" inside a tab,
+ * for example, a user process running inside a terminal tab
+ */
 export interface BaseTabProcess {
     name: string
 }
 
+/**
+ * Abstract base class for custom tab components
+ */
 export abstract class BaseTabComponent {
-    private static lastTabID = 0
-    id: number
+    /**
+     * Current tab title
+     */
     title: string
+
+    /**
+     * User-defined title override
+     */
     customTitle: string
-    hasFocus = false
+
+    /**
+     * Last tab activity state
+     */
     hasActivity = false
+
+    /**
+     * ViewRef to the tab DOM element
+     */
     hostView: ViewRef
+
+    /**
+     * CSS color override for the tab's header
+     */
     color: string = null
-    protected titleChange = new Subject<string>()
-    protected focused = new Subject<void>()
-    protected blurred = new Subject<void>()
-    protected progress = new Subject<number>()
-    protected activity = new Subject<boolean>()
-    protected destroyed = new Subject<void>()
+
+    protected hasFocus = false
+
+    /**
+     * Ping this if your recovery state has been changed and you want
+     * your tab state to be saved sooner
+     */
+    protected recoveryStateChangedHint = new Subject<void>()
 
     private progressClearTimeout: number
+    private titleChange = new Subject<string>()
+    private focused = new Subject<void>()
+    private blurred = new Subject<void>()
+    private progress = new Subject<number>()
+    private activity = new Subject<boolean>()
+    private destroyed = new Subject<void>()
 
     get focused$ (): Observable<void> { return this.focused }
     get blurred$ (): Observable<void> { return this.blurred }
@@ -29,9 +60,9 @@ export abstract class BaseTabComponent {
     get progress$ (): Observable<number> { return this.progress }
     get activity$ (): Observable<boolean> { return this.activity }
     get destroyed$ (): Observable<void> { return this.destroyed }
+    get recoveryStateChangedHint$ (): Observable<void> { return this.recoveryStateChangedHint }
 
     constructor () {
-        this.id = BaseTabComponent.lastTabID++
         this.focused$.subscribe(() => {
             this.hasFocus = true
         })
@@ -47,6 +78,11 @@ export abstract class BaseTabComponent {
         }
     }
 
+    /**
+     * Sets visual progressbar on the tab
+     *
+     * @param  {type} progress: value between 0 and 1, or `null` to remove
+     */
     setProgress (progress: number) {
         this.progress.next(progress)
         if (progress) {
@@ -55,28 +91,47 @@ export abstract class BaseTabComponent {
             }
             this.progressClearTimeout = setTimeout(() => {
                 this.setProgress(null)
-            }, 5000)
+            }, 5000) as any
         }
     }
 
+    /**
+     * Shows the acticity marker on the tab header
+     */
     displayActivity (): void {
         this.hasActivity = true
         this.activity.next(true)
     }
 
+    /**
+     * Removes the acticity marker from the tab header
+     */
     clearActivity (): void {
         this.hasActivity = false
         this.activity.next(false)
     }
 
-    getRecoveryToken (): any {
+    /**
+     * Override this and implement a [[TabRecoveryProvider]] to enable recovery
+     * for your custom tab
+     *
+     * @return JSON serializable tab state representation
+     *         for your [[TabRecoveryProvider]] to parse
+     */
+    async getRecoveryToken (): Promise<any> {
         return null
     }
 
+    /**
+     * Override this to enable task completion notifications for the tab
+     */
     async getCurrentProcess (): Promise<BaseTabProcess> {
         return null
     }
 
+    /**
+     * Return false to prevent the tab from being closed
+     */
     async canClose (): Promise<boolean> {
         return true
     }
@@ -89,11 +144,15 @@ export abstract class BaseTabComponent {
         this.blurred.next()
     }
 
+    /**
+     * Called before the tab is closed
+     */
     destroy (): void {
         this.focused.complete()
         this.blurred.complete()
         this.titleChange.complete()
         this.progress.complete()
+        this.recoveryStateChangedHint.complete()
         this.destroyed.next()
         this.destroyed.complete()
     }

+ 2 - 2
terminus-core/src/components/checkbox.component.pug

@@ -1,4 +1,4 @@
 .icon(tabindex='0', [class.active]='model', (keyup.space)='click()')
-    i.fa.fa-square-o.off
-    i.fa.fa-check-square.on
+    i.fas.fa-square.off
+    i.fas.fa-check-square.on
 .text {{text}}

+ 4 - 0
terminus-core/src/components/checkbox.component.scss

@@ -20,6 +20,10 @@
     flex-direction: row;
     align-items: center;
 
+    .off {
+        color: rgba(0, 0, 0, .5);
+    }
+
     .icon {
         position: relative;
         flex: none;

+ 3 - 2
terminus-core/src/components/checkbox.component.ts

@@ -1,13 +1,14 @@
 import { NgZone, Component, Input, HostBinding, HostListener } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 
+/** @hidden */
 @Component({
     selector: 'checkbox',
     template: require('./checkbox.component.pug'),
     styles: [require('./checkbox.component.scss')],
     providers: [
         { provide: NG_VALUE_ACCESSOR, useExisting: CheckboxComponent, multi: true },
-    ]
+    ],
 })
 export class CheckboxComponent implements ControlValueAccessor {
     @HostBinding('class.active') @Input() model: boolean
@@ -22,7 +23,7 @@ export class CheckboxComponent implements ControlValueAccessor {
         }
 
         this.model = !this.model
-        for (let fx of this.changed) {
+        for (const fx of this.changed) {
             fx(this.model)
         }
     }

+ 2 - 0
terminus-core/src/components/renameTabModal.component.ts

@@ -1,6 +1,7 @@
 import { Component, Input, ElementRef, ViewChild } from '@angular/core'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 
+/** @hidden */
 @Component({
     selector: 'rename-tab-modal',
     template: require('./renameTabModal.component.pug'),
@@ -16,6 +17,7 @@ export class RenameTabModalComponent {
     ngOnInit () {
         setTimeout(() => {
             this.input.nativeElement.focus()
+            this.input.nativeElement.select()
         }, 250)
     }
 

+ 1 - 0
terminus-core/src/components/safeModeModal.component.ts

@@ -1,6 +1,7 @@
 import { Component, Input } from '@angular/core'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 
+/** @hidden */
 @Component({
     template: require('./safeModeModal.component.pug'),
 })

+ 5 - 0
terminus-core/src/components/splitTab.component.scss

@@ -0,0 +1,5 @@
+:host {
+    display: block;
+    position: relative;
+    flex: auto;
+}

+ 524 - 0
terminus-core/src/components/splitTab.component.ts

@@ -0,0 +1,524 @@
+import { Observable, Subject, Subscription } from 'rxjs'
+import { Component, Injectable, ViewChild, ViewContainerRef, EmbeddedViewRef, OnInit, OnDestroy } from '@angular/core'
+import { BaseTabComponent, BaseTabProcess } from './baseTab.component'
+import { TabRecoveryProvider, RecoveredTab } from '../api/tabRecovery'
+import { TabsService } from '../services/tabs.service'
+import { HotkeysService } from '../services/hotkeys.service'
+import { TabRecoveryService } from '../services/tabRecovery.service'
+
+export type SplitOrientation = 'v' | 'h' // eslint-disable-line @typescript-eslint/no-type-alias
+export type SplitDirection = 'r' | 't' | 'b' | 'l' // eslint-disable-line @typescript-eslint/no-type-alias
+
+/**
+ * Describes a horizontal or vertical split row or column
+ */
+export class SplitContainer {
+    orientation: SplitOrientation = 'h'
+
+    /**
+     * Children could be tabs or other containers
+     */
+    children: (BaseTabComponent | SplitContainer)[] = []
+
+    /**
+     * Relative sizes of children, between 0 and 1. Total sum is 1
+     */
+    ratios: number[] = []
+
+    x: number
+    y: number
+    w: number
+    h: number
+
+    /**
+     * @return Flat list of all tabs inside this container
+     */
+    getAllTabs () {
+        let r = []
+        for (const child of this.children) {
+            if (child instanceof SplitContainer) {
+                r = r.concat(child.getAllTabs())
+            } else {
+                r.push(child)
+            }
+        }
+        return r
+    }
+
+    /**
+     * Remove unnecessarily nested child containers and renormalizes [[ratios]]
+     */
+    normalize () {
+        for (let i = 0; i < this.children.length; i++) {
+            const child = this.children[i]
+
+            if (child instanceof SplitContainer) {
+                child.normalize()
+
+                if (child.children.length === 0) {
+                    this.children.splice(i, 1)
+                    this.ratios.splice(i, 1)
+                    i--
+                    continue
+                } else if (child.children.length === 1) {
+                    this.children[i] = child.children[0]
+                } else if (child.orientation === this.orientation) {
+                    const ratio = this.ratios[i]
+                    this.children.splice(i, 1)
+                    this.ratios.splice(i, 1)
+                    for (let j = 0; j < child.children.length; j++) {
+                        this.children.splice(i, 0, child.children[j])
+                        this.ratios.splice(i, 0, child.ratios[j] * ratio)
+                        i++
+                    }
+                }
+            }
+        }
+
+        let s = 0
+        for (const x of this.ratios) {
+            s += x
+        }
+        this.ratios = this.ratios.map(x => x / s)
+    }
+
+    /**
+     * Gets the left/top side offset for the given element index (between 0 and 1)
+     */
+    getOffsetRatio (index: number): number {
+        let s = 0
+        for (let i = 0; i < index; i++) {
+            s += this.ratios[i]
+        }
+        return s
+    }
+
+    async serialize () {
+        const children = []
+        for (const child of this.children) {
+            if (child instanceof SplitContainer) {
+                children.push(await child.serialize())
+            } else {
+                children.push(await child.getRecoveryToken())
+            }
+        }
+        return {
+            type: 'app:split-tab',
+            ratios: this.ratios,
+            orientation: this.orientation,
+            children,
+        }
+    }
+}
+
+/**
+ * Represents a spanner (draggable border between two split areas)
+ */
+export interface SplitSpannerInfo {
+    container: SplitContainer
+
+    /**
+     * Number of the right/bottom split in the container
+     */
+    index: number
+}
+
+/**
+ * Split tab is a tab that contains other tabs and allows further splitting them
+ * You'll mainly encounter it inside [[AppService]].tabs
+ */
+@Component({
+    selector: 'split-tab',
+    template: `
+        <ng-container #vc></ng-container>
+        <split-tab-spanner
+            *ngFor='let spanner of _spanners'
+            [container]='spanner.container'
+            [index]='spanner.index'
+            (change)='onSpannerAdjusted(spanner)'
+        ></split-tab-spanner>
+    `,
+    styles: [require('./splitTab.component.scss')],
+})
+export class SplitTabComponent extends BaseTabComponent implements OnInit, OnDestroy {
+    /** @hidden */
+    @ViewChild('vc', { read: ViewContainerRef }) viewContainer: ViewContainerRef
+
+    /**
+     * Top-level split container
+     */
+    root: SplitContainer
+
+    /** @hidden */
+    _recoveredState: any
+
+    /** @hidden */
+    _spanners: SplitSpannerInfo[] = []
+
+    private focusedTab: BaseTabComponent
+    private hotkeysSubscription: Subscription
+    private viewRefs: Map<BaseTabComponent, EmbeddedViewRef<any>> = new Map()
+
+    private tabAdded = new Subject<BaseTabComponent>()
+    private tabRemoved = new Subject<BaseTabComponent>()
+    private splitAdjusted = new Subject<SplitSpannerInfo>()
+    private focusChanged = new Subject<BaseTabComponent>()
+
+    get tabAdded$ (): Observable<BaseTabComponent> { return this.tabAdded }
+    get tabRemoved$ (): Observable<BaseTabComponent> { return this.tabRemoved }
+
+    /**
+     * Fired when split ratio is changed for a given spanner
+     */
+    get splitAdjusted$ (): Observable<SplitSpannerInfo> { return this.splitAdjusted }
+
+    /**
+     * Fired when a different sub-tab gains focus
+     */
+    get focusChanged$ (): Observable<BaseTabComponent> { return this.focusChanged }
+
+    /** @hidden */
+    constructor (
+        private hotkeys: HotkeysService,
+        private tabsService: TabsService,
+        private tabRecovery: TabRecoveryService,
+    ) {
+        super()
+        this.root = new SplitContainer()
+        this.setTitle('')
+
+        this.focused$.subscribe(() => {
+            this.getAllTabs().forEach(x => x.emitFocused())
+            this.focus(this.focusedTab)
+        })
+        this.blurred$.subscribe(() => this.getAllTabs().forEach(x => x.emitBlurred()))
+
+        this.hotkeysSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => {
+            if (!this.hasFocus) {
+                return
+            }
+            switch (hotkey) {
+                case 'split-right':
+                    this.splitTab(this.focusedTab, 'r')
+                    break
+                case 'split-bottom':
+                    this.splitTab(this.focusedTab, 'b')
+                    break
+                case 'split-top':
+                    this.splitTab(this.focusedTab, 't')
+                    break
+                case 'split-left':
+                    this.splitTab(this.focusedTab, 'l')
+                    break
+                case 'pane-nav-left':
+                    this.navigate('l')
+                    break
+                case 'pane-nav-right':
+                    this.navigate('r')
+                    break
+                case 'pane-nav-up':
+                    this.navigate('t')
+                    break
+                case 'pane-nav-down':
+                    this.navigate('b')
+                    break
+                case 'close-pane':
+                    this.removeTab(this.focusedTab)
+                    break
+            }
+        })
+    }
+
+    /** @hidden */
+    async ngOnInit () {
+        if (this._recoveredState) {
+            await this.recoverContainer(this.root, this._recoveredState)
+            this.layout()
+            setImmediate(() => {
+                this.getAllTabs().forEach(x => x.emitFocused())
+                this.focusAnyIn(this.root)
+            })
+        }
+    }
+
+    /** @hidden */
+    ngOnDestroy () {
+        this.hotkeysSubscription.unsubscribe()
+    }
+
+    /** @returns Flat list of all sub-tabs */
+    getAllTabs () {
+        return this.root.getAllTabs()
+    }
+
+    getFocusedTab (): BaseTabComponent {
+        return this.focusedTab
+    }
+
+    focus (tab: BaseTabComponent) {
+        this.focusedTab = tab
+        for (const x of this.getAllTabs()) {
+            if (x !== tab) {
+                x.emitBlurred()
+            }
+        }
+        if (tab) {
+            tab.emitFocused()
+            this.focusChanged.next(tab)
+        }
+        this.layout()
+    }
+
+    /**
+     * Focuses the first available tab inside the given [[SplitContainer]]
+     */
+    focusAnyIn (parent: BaseTabComponent | SplitContainer) {
+        if (!parent) {
+            return
+        }
+        if (parent instanceof SplitContainer) {
+            this.focusAnyIn(parent.children[0])
+        } else {
+            this.focus(parent)
+        }
+    }
+
+    /**
+     * Inserts a new `tab` to the `side` of the `relative` tab
+     */
+    addTab (tab: BaseTabComponent, relative: BaseTabComponent, side: SplitDirection) {
+        let target = this.getParentOf(relative) || this.root
+        let insertIndex = target.children.indexOf(relative)
+
+        if (
+            target.orientation === 'v' && ['l', 'r'].includes(side) ||
+            target.orientation === 'h' && ['t', 'b'].includes(side)
+        ) {
+            const newContainer = new SplitContainer()
+            newContainer.orientation = target.orientation === 'v' ? 'h' : 'v'
+            newContainer.children = [relative]
+            newContainer.ratios = [1]
+            target.children[insertIndex] = newContainer
+            target = newContainer
+            insertIndex = 0
+        }
+
+        if (insertIndex === -1) {
+            insertIndex = 0
+        } else {
+            insertIndex += side === 'l' || side === 't' ? 0 : 1
+        }
+
+        for (let i = 0; i < target.children.length; i++) {
+            target.ratios[i] *= target.children.length / (target.children.length + 1)
+        }
+        target.ratios.splice(insertIndex, 0, 1 / (target.children.length + 1))
+        target.children.splice(insertIndex, 0, tab)
+
+        this.recoveryStateChangedHint.next()
+        this.attachTabView(tab)
+
+        setImmediate(() => {
+            this.layout()
+            this.tabAdded.next(tab)
+            this.focus(tab)
+        })
+    }
+
+    removeTab (tab: BaseTabComponent) {
+        const parent = this.getParentOf(tab)
+        const index = parent.children.indexOf(tab)
+        parent.ratios.splice(index, 1)
+        parent.children.splice(index, 1)
+
+        this.detachTabView(tab)
+
+        this.layout()
+
+        this.tabRemoved.next(tab)
+
+        if (this.root.children.length === 0) {
+            this.destroy()
+        } else {
+            this.focusAnyIn(parent)
+        }
+    }
+
+    /**
+     * Moves focus in the given direction
+     */
+    navigate (dir: SplitDirection) {
+        let rel: BaseTabComponent | SplitContainer = this.focusedTab
+        let parent = this.getParentOf(rel)
+        const orientation = ['l', 'r'].includes(dir) ? 'h' : 'v'
+
+        while (parent !== this.root && parent.orientation !== orientation) {
+            rel = parent
+            parent = this.getParentOf(rel)
+        }
+
+        if (parent.orientation !== orientation) {
+            return
+        }
+
+        const index = parent.children.indexOf(rel)
+        if (['l', 't'].includes(dir)) {
+            if (index > 0) {
+                this.focusAnyIn(parent.children[index - 1])
+            }
+        } else {
+            if (index < parent.children.length - 1) {
+                this.focusAnyIn(parent.children[index + 1])
+            }
+        }
+    }
+
+    async splitTab (tab: BaseTabComponent, dir: SplitDirection) {
+        const newTab = await this.tabsService.duplicate(tab)
+        this.addTab(newTab, tab, dir)
+    }
+
+    /**
+     * @returns the immediate parent of `tab`
+     */
+    getParentOf (tab: BaseTabComponent | SplitContainer, root?: SplitContainer): SplitContainer {
+        root = root || this.root
+        for (const child of root.children) {
+            if (child instanceof SplitContainer) {
+                const r = this.getParentOf(tab, child)
+                if (r) {
+                    return r
+                }
+            }
+            if (child === tab) {
+                return root
+            }
+        }
+        return null
+    }
+
+    /** @hidden */
+    async canClose (): Promise<boolean> {
+        return !(await Promise.all(this.getAllTabs().map(x => x.canClose()))).some(x => !x)
+    }
+
+    /** @hidden */
+    async getRecoveryToken (): Promise<any> {
+        return this.root.serialize()
+    }
+
+    /** @hidden */
+    async getCurrentProcess (): Promise<BaseTabProcess> {
+        return (await Promise.all(this.getAllTabs().map(x => x.getCurrentProcess()))).find(x => !!x)
+    }
+
+    /** @hidden */
+    onSpannerAdjusted (spanner: SplitSpannerInfo) {
+        this.layout()
+        this.splitAdjusted.next(spanner)
+    }
+
+    private attachTabView (tab: BaseTabComponent) {
+        const ref = this.viewContainer.insert(tab.hostView) as EmbeddedViewRef<any> // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
+        this.viewRefs.set(tab, ref)
+
+        ref.rootNodes[0].addEventListener('click', () => this.focus(tab))
+
+        tab.titleChange$.subscribe(t => this.setTitle(t))
+        tab.activity$.subscribe(a => a ? this.displayActivity() : this.clearActivity())
+        tab.progress$.subscribe(p => this.setProgress(p))
+        if (tab.title) {
+            this.setTitle(tab.title)
+        }
+        tab.destroyed$.subscribe(() => {
+            this.removeTab(tab)
+        })
+    }
+
+    private detachTabView (tab: BaseTabComponent) {
+        const ref = this.viewRefs.get(tab)
+        this.viewRefs.delete(tab)
+        this.viewContainer.remove(this.viewContainer.indexOf(ref))
+    }
+
+    private layout () {
+        this.root.normalize()
+        this._spanners = []
+        this.layoutInternal(this.root, 0, 0, 100, 100)
+    }
+
+    private layoutInternal (root: SplitContainer, x: number, y: number, w: number, h: number) {
+        const size = root.orientation === 'v' ? h : w
+        const sizes = root.ratios.map(x => x * size)
+
+        root.x = x
+        root.y = y
+        root.w = w
+        root.h = h
+
+        let offset = 0
+        root.children.forEach((child, i) => {
+            const childX = root.orientation === 'v' ? x : x + offset
+            const childY = root.orientation === 'v' ? y + offset : y
+            const childW = root.orientation === 'v' ? w : sizes[i]
+            const childH = root.orientation === 'v' ? sizes[i] : h
+            if (child instanceof SplitContainer) {
+                this.layoutInternal(child, childX, childY, childW, childH)
+            } else {
+                const element = this.viewRefs.get(child).rootNodes[0]
+                element.style.position = 'absolute'
+                element.style.left = `${childX}%`
+                element.style.top = `${childY}%`
+                element.style.width = `${childW}%`
+                element.style.height = `${childH}%`
+
+                element.style.opacity = child === this.focusedTab ? 1 : 0.75
+            }
+            offset += sizes[i]
+
+            if (i !== 0) {
+                this._spanners.push({
+                    container: root,
+                    index: i,
+                })
+            }
+        })
+    }
+
+    private async recoverContainer (root: SplitContainer, state: any) {
+        const children: (SplitContainer | BaseTabComponent)[] = []
+        root.orientation = state.orientation
+        root.ratios = state.ratios
+        root.children = children
+        for (const childState of state.children) {
+            if (childState.type === 'app:split-tab') {
+                const child = new SplitContainer()
+                await this.recoverContainer(child, childState)
+                children.push(child)
+            } else {
+                const recovered = await this.tabRecovery.recoverTab(childState)
+                if (recovered) {
+                    const tab = this.tabsService.create(recovered.type, recovered.options)
+                    children.push(tab)
+                    this.attachTabView(tab)
+                } else {
+                    state.ratios.splice(state.children.indexOf(childState), 0)
+                }
+            }
+        }
+    }
+}
+
+/** @hidden */
+@Injectable()
+export class SplitTabRecoveryProvider extends TabRecoveryProvider {
+    async recover (recoveryToken: any): Promise<RecoveredTab> {
+        if (recoveryToken && recoveryToken.type === 'app:split-tab') {
+            return {
+                type: SplitTabComponent,
+                options: { _recoveredState: recoveryToken },
+            }
+        }
+        return null
+    }
+}

+ 22 - 0
terminus-core/src/components/splitTabSpanner.component.scss

@@ -0,0 +1,22 @@
+:host {
+    display: block;
+    position: absolute;
+    z-index: 5;
+    transition: 0.125s background;
+
+    &.v {
+        cursor: ns-resize;
+        height: 10px;
+        margin-top: -5px;
+    }
+
+    &.h {
+        cursor: ew-resize;
+        width: 10px;
+        margin-left: -5px;
+    }
+
+    &:hover, &.active {
+        background: rgba(255, 255, 255, .125);
+    }
+}

+ 88 - 0
terminus-core/src/components/splitTabSpanner.component.ts

@@ -0,0 +1,88 @@
+import { Component, Input, HostBinding, ElementRef, Output, EventEmitter } from '@angular/core'
+import { SplitContainer } from './splitTab.component'
+
+/** @hidden */
+@Component({
+    selector: 'split-tab-spanner',
+    template: '',
+    styles: [require('./splitTabSpanner.component.scss')],
+})
+export class SplitTabSpannerComponent {
+    @Input() container: SplitContainer
+    @Input() index: number
+    @Output() change = new EventEmitter<void>()
+    @HostBinding('class.active') isActive = false
+    @HostBinding('class.h') isHorizontal = false
+    @HostBinding('class.v') isVertical = true
+    @HostBinding('style.left') cssLeft: string
+    @HostBinding('style.top') cssTop: string
+    @HostBinding('style.width') cssWidth: string
+    @HostBinding('style.height') cssHeight: string
+    private marginOffset = -5
+
+    constructor (private element: ElementRef) { }
+
+    ngAfterViewInit () {
+        this.element.nativeElement.addEventListener('mousedown', (e: MouseEvent) => {
+            this.isActive = true
+            const start = this.isVertical ? e.pageY : e.pageX
+            let current = start
+            const oldPosition: number = this.isVertical ? this.element.nativeElement.offsetTop : this.element.nativeElement.offsetLeft
+
+            const dragHandler = (e: MouseEvent) => {
+                current = this.isVertical ? e.pageY : e.pageX
+                const newPosition = oldPosition + (current - start)
+                if (this.isVertical) {
+                    this.element.nativeElement.style.top = `${newPosition - this.marginOffset}px`
+                } else {
+                    this.element.nativeElement.style.left = `${newPosition - this.marginOffset}px`
+                }
+            }
+
+            const offHandler = () => {
+                this.isActive = false
+                document.removeEventListener('mouseup', offHandler)
+                this.element.nativeElement.parentElement.removeEventListener('mousemove', dragHandler)
+
+                let diff = (current - start) / (this.isVertical ? this.element.nativeElement.parentElement.clientHeight : this.element.nativeElement.parentElement.clientWidth)
+
+                diff = Math.max(diff, -this.container.ratios[this.index - 1] + 0.1)
+                diff = Math.min(diff, this.container.ratios[this.index] - 0.1)
+
+                this.container.ratios[this.index - 1] += diff
+                this.container.ratios[this.index] -= diff
+                this.change.emit()
+            }
+
+            document.addEventListener('mouseup', offHandler)
+            this.element.nativeElement.parentElement.addEventListener('mousemove', dragHandler)
+        })
+    }
+
+    ngOnChanges () {
+        this.isHorizontal = this.container.orientation === 'h'
+        this.isVertical = this.container.orientation === 'v'
+        if (this.isVertical) {
+            this.setDimensions(
+                this.container.x,
+                this.container.y + this.container.h * this.container.getOffsetRatio(this.index),
+                this.container.w,
+                null
+            )
+        } else {
+            this.setDimensions(
+                this.container.x + this.container.w * this.container.getOffsetRatio(this.index),
+                this.container.y,
+                null,
+                this.container.h
+            )
+        }
+    }
+
+    private setDimensions (x: number, y: number, w: number, h: number) {
+        this.cssLeft = `${x}%`
+        this.cssTop = `${y}%`
+        this.cssWidth = w ? `${w}%` : null
+        this.cssHeight = h ? `${h}%` : null
+    }
+}

+ 2 - 2
terminus-core/src/components/startPage.component.pug

@@ -14,10 +14,10 @@ div
 footer.d-flex.align-items-center
     .btn-group.mr-auto
         button.btn.btn-secondary((click)='homeBase.openGitHub()')
-            i.fa.fa-github
+            i.fab.fa-github
             span GitHub
         button.btn.btn-secondary((click)='homeBase.reportBug()')
-            i.fa.fa-bug
+            i.fas.fa-bug
             span Report a problem
 
     .form-control-static.selectable.no-drag Version: {{homeBase.appVersion}}

+ 0 - 5
terminus-core/src/components/startPage.component.scss

@@ -2,7 +2,6 @@
     display: flex;
     flex-direction: column;
     flex: auto;
-    -webkit-app-region: drag;
     overflow-y: auto;
 }
 
@@ -25,10 +24,6 @@ footer {
     background: rgba(0,0,0,.5);
 }
 
-a, button {
-    -webkit-app-region: no-drag;
-}
-
 .list-group-item ::ng-deep svg {
     width: 16px;
     height: 16px;

+ 5 - 3
terminus-core/src/components/startPage.component.ts

@@ -1,8 +1,9 @@
 import { Component, Inject } from '@angular/core'
 import { ConfigService } from '../services/config.service'
 import { HomeBaseService } from '../services/homeBase.service'
-import { IToolbarButton, ToolbarButtonProvider } from '../api'
+import { ToolbarButton, ToolbarButtonProvider } from '../api'
 
+/** @hidden */
 @Component({
     selector: 'start-page',
     template: require('./startPage.component.pug'),
@@ -18,10 +19,11 @@ export class StartPageComponent {
     ) {
     }
 
-    getButtons (): IToolbarButton[] {
+    getButtons (): ToolbarButton[] {
         return this.config.enabledServices(this.toolbarButtonProviders)
             .map(provider => provider.provide())
             .reduce((a, b) => a.concat(b))
-            .sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0))
+            .filter(x => !!x.click)
+            .sort((a: ToolbarButton, b: ToolbarButton) => (a.weight || 0) - (b.weight || 0))
     }
 }

+ 2 - 1
terminus-core/src/components/tabBody.component.ts

@@ -1,6 +1,7 @@
 import { Component, Input, ViewChild, HostBinding, ViewContainerRef, OnChanges } from '@angular/core'
 import { BaseTabComponent } from '../components/baseTab.component'
 
+/** @hidden */
 @Component({
     selector: 'tab-body',
     template: `
@@ -17,7 +18,7 @@ import { BaseTabComponent } from '../components/baseTab.component'
 export class TabBodyComponent implements OnChanges {
     @Input() @HostBinding('class.active') active: boolean
     @Input() tab: BaseTabComponent
-    @ViewChild('placeholder', {read: ViewContainerRef}) placeholder: ViewContainerRef
+    @ViewChild('placeholder', { read: ViewContainerRef }) placeholder: ViewContainerRef
 
     ngOnChanges (changes) {
         if (changes.tab) {

+ 32 - 97
terminus-core/src/components/tabHeader.component.ts

@@ -1,22 +1,15 @@
-import { Component, Input, HostBinding, HostListener, NgZone, ViewChild, ElementRef } from '@angular/core'
+import { Component, Input, Optional, Inject, HostBinding, HostListener, ViewChild, ElementRef } from '@angular/core'
 import { SortableComponent } from 'ng2-dnd'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider'
 import { BaseTabComponent } from './baseTab.component'
 import { RenameTabModalComponent } from './renameTabModal.component'
+import { HotkeysService } from '../services/hotkeys.service'
 import { ElectronService } from '../services/electron.service'
 import { AppService } from '../services/app.service'
 import { HostAppService, Platform } from '../services/hostApp.service'
 
-const COLORS = [
-    { name: 'No color', value: null },
-    { name: 'Blue', value: '#0275d8' },
-    { name: 'Green', value: '#5cb85c' },
-    { name: 'Orange', value: '#f0ad4e' },
-    { name: 'Purple', value: '#613d7c' },
-    { name: 'Red', value: '#d9534f' },
-    { name: 'Yellow', value: '#ffd500' },
-]
-
+/** @hidden */
 @Component({
     selector: 'tab-header',
     template: require('./tabHeader.component.pug'),
@@ -30,16 +23,24 @@ export class TabHeaderComponent {
     @Input() progress: number
     @ViewChild('handle') handle: ElementRef
 
-    private completionNotificationEnabled = false
-
     constructor (
         public app: AppService,
         private electron: ElectronService,
-        private zone: NgZone,
         private hostApp: HostAppService,
         private ngbModal: NgbModal,
+        private hotkeys: HotkeysService,
         private parentDraggable: SortableComponent,
-    ) { }
+        @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[],
+    ) {
+        this.hotkeys.matchedHotkey.subscribe((hotkey) => {
+            if (this.app.activeTab === this.tab) {
+                if (hotkey === 'rename-tab') {
+                    this.showRenameTabModal()
+                }
+            }
+        })
+        this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
+    }
 
     ngOnInit () {
         if (this.hostApp.platform === Platform.macOS) {
@@ -50,8 +51,8 @@ export class TabHeaderComponent {
         })
     }
 
-    @HostListener('dblclick') onDoubleClick (): void {
-        let modal = this.ngbModal.open(RenameTabModalComponent)
+    showRenameTabModal (): void {
+        const modal = this.ngbModal.open(RenameTabModalComponent)
         modal.componentInstance.value = this.tab.customTitle || this.tab.title
         modal.result.then(result => {
             this.tab.setTitle(result)
@@ -59,6 +60,19 @@ export class TabHeaderComponent {
         }).catch(() => null)
     }
 
+    async buildContextMenu (): Promise<Electron.MenuItemConstructorOptions[]> {
+        let items: Electron.MenuItemConstructorOptions[] = []
+        for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this.tab, this)))) {
+            items.push({ type: 'separator' })
+            items = items.concat(section)
+        }
+        return items.slice(1)
+    }
+
+    @HostListener('dblclick') onDoubleClick (): void {
+        this.showRenameTabModal()
+    }
+
     @HostListener('auxclick', ['$event']) async onAuxClick ($event: MouseEvent) {
         if ($event.which === 2) {
             this.app.closeTab(this.tab, true)
@@ -66,90 +80,11 @@ export class TabHeaderComponent {
         if ($event.which === 3) {
             event.preventDefault()
 
-            let contextMenu = this.electron.remote.Menu.buildFromTemplate([
-                {
-                    label: 'Close',
-                    click: () => this.zone.run(() => {
-                        this.app.closeTab(this.tab, true)
-                    })
-                },
-                {
-                    label: 'Close other tabs',
-                    click: () => this.zone.run(() => {
-                        for (let tab of this.app.tabs.filter(x => x !== this.tab)) {
-                            this.app.closeTab(tab, true)
-                        }
-                    })
-                },
-                {
-                    label: 'Close tabs to the right',
-                    click: () => this.zone.run(() => {
-                        for (let tab of this.app.tabs.slice(this.app.tabs.indexOf(this.tab) + 1)) {
-                            this.app.closeTab(tab, true)
-                        }
-                    })
-                },
-                {
-                    label: 'Close tabs to the left',
-                    click: () => this.zone.run(() => {
-                        for (let tab of this.app.tabs.slice(0, this.app.tabs.indexOf(this.tab))) {
-                            this.app.closeTab(tab, true)
-                        }
-                    })
-                },
-                {
-                    label: 'Color',
-                    sublabel: COLORS.find(x => x.value === this.tab.color).name,
-                    submenu: COLORS.map(color => ({
-                        label: color.name,
-                        type: 'radio',
-                        checked: this.tab.color === color.value,
-                        click: () => this.zone.run(() => {
-                            this.tab.color = color.value
-                        }),
-                    })),
-                }
-            ])
-
-            let process = await this.tab.getCurrentProcess()
-            if (process) {
-                contextMenu.append(new this.electron.MenuItem({
-                    id: 'sep',
-                    type: 'separator',
-                }))
-                contextMenu.append(new this.electron.MenuItem({
-                    id: 'process-name',
-                    enabled: false,
-                    label: 'Current process: ' + process.name,
-                }))
-                contextMenu.append(new this.electron.MenuItem({
-                    id: 'completion',
-                    label: 'Notify when done',
-                    type: 'checkbox',
-                    checked: this.completionNotificationEnabled,
-                    click: () => this.zone.run(() => {
-                        this.completionNotificationEnabled = !this.completionNotificationEnabled
-
-                        if (this.completionNotificationEnabled) {
-                            this.app.observeTabCompletion(this.tab).subscribe(() => {
-                                new Notification('Process completed', {
-                                    body: process.name,
-                                }).addEventListener('click', () => {
-                                    this.app.selectTab(this.tab)
-                                })
-                                this.completionNotificationEnabled = false
-                            })
-                        } else {
-                            this.app.stopObservingTabCompletion(this.tab)
-                        }
-                    })
-                }))
-            }
+            const contextMenu = this.electron.remote.Menu.buildFromTemplate(await this.buildContextMenu())
 
             contextMenu.popup({
                 x: $event.pageX,
                 y: $event.pageY,
-                async: true,
             })
         }
     }

+ 2 - 1
terminus-core/src/components/titleBar.component.ts

@@ -1,8 +1,9 @@
 import { Component } from '@angular/core'
 
+/** @hidden */
 @Component({
     selector: 'title-bar',
     template: require('./titleBar.component.pug'),
     styles: [require('./titleBar.component.scss')],
 })
-export class TitleBarComponent { }
+export class TitleBarComponent { } // eslint-disable-line @typescript-eslint/no-extraneous-class

+ 2 - 1
terminus-core/src/components/toggle.component.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core'
 import { NG_VALUE_ACCESSOR } from '@angular/forms'
 import { CheckboxComponent } from './checkbox.component'
 
+/** @hidden */
 @Component({
     selector: 'toggle',
     template: `
@@ -16,7 +17,7 @@ import { CheckboxComponent } from './checkbox.component'
     styles: [require('./toggle.component.scss')],
     providers: [
         { provide: NG_VALUE_ACCESSOR, useExisting: ToggleComponent, multi: true },
-    ]
+    ],
 })
 export class ToggleComponent extends CheckboxComponent {
 }

+ 19 - 0
terminus-core/src/components/welcomeTab.component.pug

@@ -0,0 +1,19 @@
+.mb-4
+    .terminus-logo
+    h1.terminus-title Terminus
+        sup α 
+
+.container
+    .text-center.mb-5 Thank you for downloading Terminus!
+
+    .form-line
+        .header
+            .title Enable analytics
+            .description Help us track the number of Terminus installs across the world!
+        toggle(
+            [(ngModel)]='config.store.enableAnalytics',
+            (ngModelChange)='config.save(); config.requestRestart()',
+        )
+
+    .text-center.mt-5
+        button.btn.btn-primary((click)='closeAndDisable()') Close and never show again

+ 6 - 0
terminus-core/src/components/welcomeTab.component.scss

@@ -0,0 +1,6 @@
+:host {
+    display: flex;
+    flex-direction: column;
+    margin: auto;
+    flex: 0 1 500px;
+}

+ 26 - 0
terminus-core/src/components/welcomeTab.component.ts

@@ -0,0 +1,26 @@
+import { Component } from '@angular/core'
+import { BaseTabComponent } from './baseTab.component'
+import { ConfigService } from '../services/config.service'
+import { AppService } from '../services/app.service'
+
+/** @hidden */
+@Component({
+    selector: 'welcome-page',
+    template: require('./welcomeTab.component.pug'),
+    styles: [require('./welcomeTab.component.scss')],
+})
+export class WelcomeTabComponent extends BaseTabComponent {
+    constructor (
+        private app: AppService,
+        public config: ConfigService,
+    ) {
+        super()
+        this.setTitle('Welcome')
+    }
+
+    closeAndDisable () {
+        this.config.store.enableWelcomeTab = false
+        this.config.save()
+        this.app.closeTab(this)
+    }
+}

+ 1 - 1
terminus-core/src/components/windowControls.component.pug

@@ -9,7 +9,7 @@ button.btn.btn-secondary.btn-maximize(
     svg(version='1.1', width='10', height='10')
         path(d='M 0,0 0,10 10,10 10,0 Z M 1,1 9,1 9,9 1,9 Z')
 button.btn.btn-secondary.btn-close(
-    (click)='app.closeWindow()'
+    (click)='closeWindow()'
 )
     svg(version='1.1', width='10', height='10')
         path(d='M 0,0 0,0.7 4.3,5 0,9.3 0,10 0.7,10 5,5.7 9.3,10 10,10 10,9.3 5.7,5 10,0.7 10,0 9.3,0 5,4.3 0.7,0 Z')

Some files were not shown because too many files changed in this diff