├── .nvmrc ├── .npmrc ├── typings ├── uuid.d.ts ├── dirStat.d.ts ├── child-process-promise.d.ts ├── mode-to-permissions.d.ts ├── Interfaces.d.ts └── Overrides.d.ts ├── README ├── cd.png ├── vim.png ├── emacs.png ├── error.png ├── htop.png ├── main.png ├── autocompletion.gif ├── json_decorator.png ├── npm_autocompletion.png ├── top_autocompletion.png └── history_autocompletion.png ├── housekeeping ├── start.sh └── deploy.sh ├── src ├── plugins │ ├── autocompletion_providers │ │ ├── Df.ts │ │ ├── Pwd.ts │ │ ├── Locate.ts │ │ ├── Shutdown.ts │ │ ├── Cp.ts │ │ ├── Mv.ts │ │ ├── Rm.ts │ │ ├── Cat.ts │ │ ├── Ls.ts │ │ ├── Mkdir.ts │ │ ├── Ln.ts │ │ ├── Rails.ts │ │ ├── Vagrant.ts │ │ ├── Cd.ts │ │ ├── Top.ts │ │ ├── NPM.ts │ │ └── Executable.ts │ ├── PWDOperatingSystemIntegrator.ts │ ├── autocompletion_utils │ │ └── Combine.ts │ ├── preexec │ │ └── AliasSuggestions.ts │ ├── DotEnvLoader.ts │ ├── Show.tsx │ ├── JSON.tsx │ ├── NVM.ts │ ├── Ls.tsx │ ├── RVM.ts │ ├── ManPage.tsx │ └── GitWatcher.ts ├── EmitterWithUniqueID.ts ├── references.d.ts ├── views │ ├── DecorationToggleComponent.tsx │ ├── css │ │ ├── functions.ts │ │ ├── definitions.ts │ │ └── colors.ts │ ├── index.html │ ├── StatusBarComponent.tsx │ ├── Main.tsx │ ├── ViewUtils.ts │ ├── SearchComponent.tsx │ ├── 2_SessionComponent.tsx │ ├── AutocompleteComponent.tsx │ ├── 3_JobComponent.tsx │ ├── BufferComponent.tsx │ ├── TabComponent.tsx │ ├── 1_ApplicationComponent.tsx │ └── UserEventsHander.ts ├── utils │ ├── OrderedSet.ts │ ├── ManPages.ts │ ├── Process.ts │ ├── ManPageParsingUtils.ts │ ├── Shell.ts │ ├── PaneTree.ts │ └── Git.ts ├── Decorators.ts ├── shell │ ├── Prompt.ts │ ├── Aliases.ts │ ├── History.ts │ ├── Environment.ts │ ├── Session.ts │ ├── CommandExecutor.ts │ ├── Command.ts │ ├── Scanner.ts │ └── Job.ts ├── Cursor.ts ├── Char.ts ├── Autocompletion.ts ├── main │ └── Main.ts ├── Interfaces.ts ├── PluginManager.ts ├── Enums.ts └── PTY.ts ├── test ├── utils │ ├── common_spec.ts │ ├── ordered_set_spec.ts │ └── ManPages_spec.ts ├── pty_spec.ts ├── e2e.js ├── environment_spec.ts ├── ansi_parser_spec.ts └── shell │ └── scanner_spec.ts ├── tsconfig.json ├── .gitignore ├── .travis.yml ├── LICENSE ├── CONTRIBUTING.md ├── README.md ├── package.json └── tslint.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v6.3.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | -------------------------------------------------------------------------------- /typings/uuid.d.ts: -------------------------------------------------------------------------------- 1 | declare module "uuid" { 2 | function v4(): string 3 | } 4 | -------------------------------------------------------------------------------- /README/cd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/cd.png -------------------------------------------------------------------------------- /README/vim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/vim.png -------------------------------------------------------------------------------- /README/emacs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/emacs.png -------------------------------------------------------------------------------- /README/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/error.png -------------------------------------------------------------------------------- /README/htop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/htop.png -------------------------------------------------------------------------------- /README/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/main.png -------------------------------------------------------------------------------- /README/autocompletion.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/autocompletion.gif -------------------------------------------------------------------------------- /README/json_decorator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/json_decorator.png -------------------------------------------------------------------------------- /README/npm_autocompletion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/npm_autocompletion.png -------------------------------------------------------------------------------- /README/top_autocompletion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/top_autocompletion.png -------------------------------------------------------------------------------- /README/history_autocompletion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/black-screen/master/README/history_autocompletion.png -------------------------------------------------------------------------------- /typings/dirStat.d.ts: -------------------------------------------------------------------------------- 1 | declare module "dirStat" { 2 | function dirStat(path: string, cb: (err: any, results: any) => void): void 3 | } 4 | -------------------------------------------------------------------------------- /housekeeping/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | node_modules/.bin/tsc --watch > /dev/tty & 4 | WATCH_PID=$! 5 | NODE_ENV=development npm run electron 6 | kill $WATCH_PID 7 | -------------------------------------------------------------------------------- /typings/child-process-promise.d.ts: -------------------------------------------------------------------------------- 1 | declare module "child-process-promise" { 2 | function execFile(file: string, args: string[], options: {}): Promise<{stdout: string, stderr: string}> 3 | } 4 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Df.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {manPageOptions} from "../../utils/ManPages"; 3 | 4 | PluginManager.registerAutocompletionProvider("df", manPageOptions("df")); 5 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Pwd.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {manPageOptions} from "../../utils/ManPages"; 3 | 4 | PluginManager.registerAutocompletionProvider("pwd", manPageOptions("pwd")); 5 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Locate.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {manPageOptions} from "../../utils/ManPages"; 3 | 4 | PluginManager.registerAutocompletionProvider("locate", manPageOptions("locate")); 5 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Shutdown.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {manPageOptions} from "../../utils/ManPages"; 3 | 4 | PluginManager.registerAutocompletionProvider("shutdown", manPageOptions("shutdown")); 5 | -------------------------------------------------------------------------------- /src/EmitterWithUniqueID.ts: -------------------------------------------------------------------------------- 1 | import * as events from "events"; 2 | 3 | export class EmitterWithUniqueID extends events.EventEmitter { 4 | public id: number; 5 | 6 | constructor() { 7 | super(); 8 | this.id = new Date().getTime(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Cp.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {anyFilesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | PluginManager.registerAutocompletionProvider("cp", combine([anyFilesSuggestionsProvider, manPageOptions("cp")])); 7 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Mv.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {anyFilesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | PluginManager.registerAutocompletionProvider("mv", combine([manPageOptions("mv"), anyFilesSuggestionsProvider])); 7 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Rm.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {anyFilesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | PluginManager.registerAutocompletionProvider("rm", combine([manPageOptions("rm"), anyFilesSuggestionsProvider])); 7 | -------------------------------------------------------------------------------- /test/utils/common_spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import {expect} from "chai"; 3 | import {commonPrefix} from "../../src/utils/Common"; 4 | 5 | 6 | describe("common utils", () => { 7 | describe("commonPrefix", () => { 8 | it("returns the whole string for the same strings", async() => { 9 | expect(commonPrefix("abc", "abc")).to.eql("abc"); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Cat.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {anyFilesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | PluginManager.registerAutocompletionProvider("cat", combine([anyFilesSuggestionsProvider, manPageOptions("cat")])); 7 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Ls.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {directoriesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | PluginManager.registerAutocompletionProvider("ls", combine([directoriesSuggestionsProvider, manPageOptions("ls")])); 7 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Mkdir.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {directoriesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | PluginManager.registerAutocompletionProvider("mkdir", combine([manPageOptions("mkdir"), directoriesSuggestionsProvider])); 7 | -------------------------------------------------------------------------------- /src/references.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | /// 5 | /// 6 | /// 7 | /// 8 | 9 | /// 10 | -------------------------------------------------------------------------------- /src/plugins/PWDOperatingSystemIntegrator.ts: -------------------------------------------------------------------------------- 1 | import {Session} from "../shell/Session"; 2 | import {PluginManager} from "../PluginManager"; 3 | import {remote} from "electron"; 4 | 5 | PluginManager.registerEnvironmentObserver({ 6 | presentWorkingDirectoryWillChange: () => { /* do nothing */ }, 7 | 8 | presentWorkingDirectoryDidChange: (session: Session, directory: string) => { 9 | remote.app.addRecentDocument(directory); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /typings/mode-to-permissions.d.ts: -------------------------------------------------------------------------------- 1 | interface PermittedGroups { 2 | owner: boolean; 3 | group: boolean; 4 | others: boolean; 5 | } 6 | 7 | interface Permissions { 8 | read: PermittedGroups; 9 | write: PermittedGroups; 10 | execute: PermittedGroups; 11 | } 12 | 13 | declare module "mode-to-permissions" { 14 | function modeToPermissions(mode: number): Permissions; 15 | namespace modeToPermissions {} 16 | export = modeToPermissions; 17 | } 18 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_utils/Combine.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import {AutocompletionContext, AutocompletionProvider} from "../../Interfaces"; 3 | import {Suggestion} from "./Common"; 4 | 5 | export default (providers: AutocompletionProvider[]): AutocompletionProvider => { 6 | return async(context: AutocompletionContext): Promise => { 7 | return _.flatten(await Promise.all(providers.map(provider => provider(context)))); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Ln.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {anyFilesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {manPageOptions} from "../../utils/ManPages"; 5 | 6 | const combinedOptions = combine([anyFilesSuggestionsProvider, manPageOptions("ln")]); 7 | PluginManager.registerAutocompletionProvider("ln", combinedOptions); 8 | PluginManager.registerAutocompletionProvider("link", combinedOptions); 9 | -------------------------------------------------------------------------------- /src/plugins/preexec/AliasSuggestions.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {Job} from "../../shell/Job"; 3 | 4 | PluginManager.registerPreexecPlugin(async function (job: Job): Promise { 5 | const input = job.prompt.value; 6 | const alias = job.session.aliases.getNameByValue(input); 7 | 8 | if (alias && alias.length < input.length) { 9 | /* tslint:disable:no-unused-expression */ 10 | new Notification("Alias Reminder", { body: `You have an alias "${alias}" for "${input}".` }); 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "removeComments": true, 6 | "preserveConstEnums": true, 7 | "moduleResolution": "node", 8 | "experimentalDecorators": true, 9 | "noImplicitAny": true, 10 | "noEmitOnError": true, 11 | "jsx": "react", 12 | "outDir": "compiled/src", 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "inlineSourceMap": true 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | "dist", 20 | "typings", 21 | "test" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/DotEnvLoader.ts: -------------------------------------------------------------------------------- 1 | import {Session} from "../shell/Session"; 2 | import {PluginManager} from "../PluginManager"; 3 | import * as Path from "path"; 4 | import {exists} from "../utils/Common"; 5 | import {sourceFile} from "../shell/Command"; 6 | 7 | PluginManager.registerEnvironmentObserver({ 8 | presentWorkingDirectoryWillChange: () => void 0, 9 | presentWorkingDirectoryDidChange: async(session: Session, directory: string) => { 10 | if (await exists(Path.join(directory, ".env"))) { 11 | sourceFile(session, ".env"); 12 | } 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/plugins/Show.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {PluginManager} from "../PluginManager"; 3 | import {Job} from "../shell/Job"; 4 | import * as css from "../views/css/main"; 5 | 6 | PluginManager.registerOutputDecorator({ 7 | decorate: (job: Job): React.ReactElement => { 8 | const rows = job.screenBuffer.toLines().map(path => ); 9 | 10 | return
{rows}
; 11 | }, 12 | 13 | isApplicable: (job: Job): boolean => { 14 | return job.hasOutput() && (job.prompt.commandName === "show"); 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/plugins/JSON.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Job} from "../shell/Job"; 3 | import {PluginManager} from "../PluginManager"; 4 | import JsonTree from "../utils/JSONTree"; 5 | 6 | PluginManager.registerOutputDecorator({ 7 | decorate: (job: Job): React.ReactElement => { 8 | return ; 9 | }, 10 | 11 | isApplicable: (job: Job): boolean => { 12 | try { 13 | const parseResult = JSON.parse(job.screenBuffer.toString()); 14 | return parseResult && typeof parseResult === "object"; 15 | } catch (exception) { 16 | return false; 17 | } 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/views/DecorationToggleComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as css from "./css/main"; 3 | import {fontAwesome} from "./css/FontAwesome"; 4 | 5 | interface Props { 6 | decorateToggler: () => void; 7 | isDecorated: boolean; 8 | } 9 | 10 | export class DecorationToggleComponent extends React.Component { 11 | constructor(props: Props) { 12 | super(props); 13 | } 14 | 15 | render() { 16 | return ( 17 | 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/views/css/functions.ts: -------------------------------------------------------------------------------- 1 | const tinyColor: any = require("tinycolor2"); 2 | 3 | export function lighten(color: string, percent: number) { 4 | return tinyColor(color).lighten(percent).toHexString(); 5 | } 6 | 7 | export function darken(color: string, percent: number) { 8 | return tinyColor(color).darken(percent).toHexString(); 9 | } 10 | 11 | export function alpha(color: string, percent: number) { 12 | return tinyColor(color).setAlpha(percent).toRgbString(); 13 | } 14 | 15 | export function failurize(color: string) { 16 | return tinyColor(color).spin(140).saturate(15).toHexString(); 17 | } 18 | 19 | export function toDOMString(pixels: number) { 20 | return `${pixels}px`; 21 | } 22 | -------------------------------------------------------------------------------- /test/pty_spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import {expect} from "chai"; 3 | import {PTY} from "../src/PTY"; 4 | import {scan} from "../src/shell/Scanner"; 5 | 6 | describe("PTY", () => { 7 | it("doesn't interpolate expressions inside single quotes", (done) => { 8 | let output = ""; 9 | const tokens = scan("echo '$('"); 10 | 11 | new PTY( 12 | tokens.map(token => token.escapedValue), 13 | process.env, 14 | {columns: 80, rows: 30}, 15 | (data: string) => output += data, 16 | (exitCode: number) => { 17 | expect(exitCode).to.eq(0); 18 | done(); 19 | } 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /compiled/src/* 3 | /tmp/* 4 | /npm-debug.log 5 | /dist/* 6 | 7 | # Created by https://www.gitignore.io/api/osx 8 | 9 | ### OSX ### 10 | .DS_Store 11 | .AppleDouble 12 | .LSOverride 13 | 14 | # Icon must end with two \r 15 | Icon 16 | 17 | 18 | # Thumbnails 19 | ._* 20 | 21 | # Files that might appear in the root of a volume 22 | .DocumentRevisions-V100 23 | .fseventsd 24 | .Spotlight-V100 25 | .TemporaryItems 26 | .Trashes 27 | .VolumeIcon.icns 28 | 29 | # Directories potentially created on remote AFP share 30 | .AppleDB 31 | .AppleDesktop 32 | Network Trash Folder 33 | Temporary Items 34 | .apdisk 35 | 36 | # Directory created by Visual Studio Code 37 | .vscode 38 | 39 | # Directory created by IDEA 40 | .idea 41 | -------------------------------------------------------------------------------- /test/utils/ordered_set_spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import {expect} from "chai"; 3 | import {OrderedSet} from "../../src/utils/OrderedSet"; 4 | 5 | describe("ordered set", () => { 6 | describe("prepend", () => { 7 | it("doesn't keep two elements with the same values", () => { 8 | const set = new OrderedSet(); 9 | 10 | set.prepend("foo"); 11 | set.prepend("foo"); 12 | 13 | expect(set.size).to.eq(1); 14 | }); 15 | 16 | it("moves an element to the beginning if it already exists", () => { 17 | const set = new OrderedSet(); 18 | 19 | set.prepend("foo"); 20 | set.prepend("bar"); 21 | set.prepend("foo"); 22 | 23 | expect(set.at(0)).to.eq("foo"); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/plugins/NVM.ts: -------------------------------------------------------------------------------- 1 | import {Session} from "../shell/Session"; 2 | import {PluginManager} from "../PluginManager"; 3 | import * as Path from "path"; 4 | import {homeDirectory, exists, readFile} from "../utils/Common"; 5 | 6 | async function withNvmPath(directory: string, callback: (path: string) => void) { 7 | const rcPath = Path.join(directory, ".nvmrc"); 8 | 9 | if (await exists(rcPath)) { 10 | const version = (await readFile(rcPath)).trim(); 11 | callback(Path.join(homeDirectory, ".nvm", "versions", "node", version, "bin")); 12 | } 13 | } 14 | 15 | PluginManager.registerEnvironmentObserver({ 16 | presentWorkingDirectoryWillChange: async(session: Session) => { 17 | withNvmPath(session.directory, path => session.environment.path.remove(path)); 18 | }, 19 | presentWorkingDirectoryDidChange: async(session: Session, directory: string) => { 20 | withNvmPath(directory, path => session.environment.path.prepend(path)); 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | osx_image: xcode7.3 2 | language: node_js 3 | os: 4 | - osx 5 | cache: 6 | directories: 7 | - node_modules 8 | deploy: 9 | skip_cleanup: true 10 | provider: script 11 | script: bash housekeeping/deploy.sh 12 | on: 13 | branch: master 14 | env: 15 | global: 16 | # GH_TOKEN 17 | secure: LkI4RAc08x0XsiMlK0cIKFy381qJ8v8WKepjhAGdwbNgPqDcMqLXSznTTaGTcN1hKonHBPJVL9l0G02oRjj/ulh/yeGV1Q/JRPBm2XLoG0EX6hcuDj/skaLO24fuYf/miYejZbJcRBj2F/OP7sR1SfQPl6dfzEnLj96IHCUxzEBbB6PPJdGRL35117HqD0Y/LGLPkHgdh53n/FEtsEbl6DwvkPr8+yDZxUCqTJW53ZoBJzf9znZE5gMZg6btkStWTUuM5n5doe446ipRouGtomOXkgsCtQbsd66cRzdlIlEGKUEDaEL/c4KDMIUUDw2MpxoUm2fDJ98krxRLhvANgN/rqQVBoYY45OT7SzK9TYcvqS36E6a9pdmFpt0M3w532f5E6simgJp1a6gBoSBBYoZL8hRscF2VgAvjJV0QVQzos6Ec01nGjAbpC/i2B6IR6tnI1L5C3YHR4xDvSqW3iDo3hpc+Y4INOMysMt3cK+oWx3bEsbH8G3JRbU6Edz5vUVQ5aeoyVgfr/vxhIohWq8NpGd8zqdyyfKq59DYugkDNpvKr07w+FQZLtexILKw6FzNEjm3gI9prz5a7+WzFLQcdgy6xFM7z3GN5e1kbX39BSMcmkueWk96kCYSpKOflAX5h+WOs3VjwVxoIaB5uYeQD10L+6703eSyXy3Ni3Sg= 18 | -------------------------------------------------------------------------------- /test/e2e.js: -------------------------------------------------------------------------------- 1 | const {Application} = require("spectron"); 2 | const {expect} = require("chai"); 3 | 4 | const timeout = 50000; 5 | 6 | describe("application launch", function () { 7 | this.timeout(timeout); 8 | 9 | let app; 10 | 11 | beforeEach(function () { 12 | app = new Application({path: "node_modules/.bin/electron", args: ["."]}); 13 | return app.start(); 14 | }); 15 | 16 | afterEach(function () { 17 | if (app && app.isRunning()) { 18 | return app.stop() 19 | } 20 | }); 21 | 22 | it("can execute a command", function () { 23 | return app.client. 24 | waitUntilWindowLoaded(). 25 | waitForExist(".prompt", timeout). 26 | setValue(".prompt", "echo expected-text\n"). 27 | waitForExist(".prompt[contenteditable=false]"). 28 | waitForExist(".prompt[contenteditable=true]"). 29 | getText(".job .output"). 30 | then((output) => { 31 | expect(output[0]).to.contain("expected-text"); 32 | }); 33 | }) 34 | }); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Volodymyr Shatsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /test/environment_spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import {expect} from "chai"; 3 | import {Environment, preprocessEnv} from "../src/shell/Environment"; 4 | 5 | describe("EnvironmentPath", () => { 6 | describe("input method", () => { 7 | it("prepend", async() => { 8 | const environment = new Environment({}); 9 | 10 | environment.path.prepend("/usr/bin"); 11 | environment.path.prepend("/usr/local/bin"); 12 | 13 | expect(environment.toObject()).to.eql({ 14 | PATH: "/usr/local/bin:/usr/bin", 15 | }); 16 | }); 17 | }); 18 | 19 | describe("environment preprocessor", () => { 20 | it("preprocesses bash functions", () => { 21 | expect(preprocessEnv([ 22 | "BASH_FUNC_foo%%=() if 0; then", 23 | " x", 24 | " else", 25 | " y", 26 | " fi", 27 | "}", 28 | "var=val", 29 | ])).to.eql([ 30 | "BASH_FUNC_foo%%=() if 0; then\n x\n else\n y\n fi\n}", 31 | "var=val", 32 | ]); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/utils/OrderedSet.ts: -------------------------------------------------------------------------------- 1 | export abstract class AbstractOrderedSet { 2 | constructor(private storageGetter: () => T[], private storageSetter: (t: T[]) => void) { 3 | } 4 | 5 | prepend(element: T) { 6 | this.remove(element); 7 | this.storageSetter([element].concat(this.storageGetter())); 8 | } 9 | 10 | remove(toRemove: T) { 11 | this.removeWhere(existing => existing === toRemove); 12 | } 13 | 14 | removeWhere(removePredicate: (existing: T) => boolean) { 15 | this.storageSetter(this.storageGetter().filter(path => !removePredicate(path))); 16 | } 17 | 18 | get size() { 19 | return this.storageGetter().length; 20 | } 21 | 22 | at(index: number): T | undefined { 23 | if (index >= this.size) { 24 | return undefined; 25 | } else { 26 | return this.storageGetter()[index]; 27 | } 28 | } 29 | 30 | toArray() { 31 | return this.storageGetter(); 32 | } 33 | } 34 | 35 | export class OrderedSet extends AbstractOrderedSet { 36 | constructor() { 37 | let storage: T[] = []; 38 | super(() => storage, updatedStorage => storage = updatedStorage); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /housekeeping/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git config user.name "Travis CI" 4 | git config user.email "travis@travis-ci.com" 5 | npm version patch -m "Bump version to %s. [ci skip]" 6 | npm run release 7 | git push --quiet "https://$GH_TOKEN:x-oauth-basic@github.com/shockone/black-screen.git" HEAD:master --tags > /dev/null 2>&1 8 | 9 | TAG_NAME=$(git describe --abbrev=0) 10 | echo "($?) Current tag: $TAG_NAME" 11 | 12 | PREVIOUS_TAG_NAME=$(git describe --abbrev=0 --tags "$TAG_NAME^") 13 | echo "($?) Previous tag: $PREVIOUS_TAG_NAME" 14 | 15 | LAST_DRAFT_ID=$(curl "https://$GH_TOKEN:x-oauth-basic@api.github.com/repos/shockone/black-screen/releases" | python -c "import json,sys; array=json.load(sys.stdin); print array[0]['id'];") 16 | echo "($?) Last draft ID: $LAST_DRAFT_ID" 17 | 18 | BODY=$(git log --oneline --no-merges $TAG_NAME...$PREVIOUS_TAG_NAME | python -c "import json,sys; print json.dumps(sys.stdin.read());") 19 | echo "($?) Body:" 20 | echo $BODY 21 | 22 | curl --request PATCH "https://$GH_TOKEN:x-oauth-basic@api.github.com/repos/shockone/black-screen/releases/$LAST_DRAFT_ID" \ 23 | -H "Content-Type: application/json" \ 24 | -d "{\"body\": $BODY, \"draft\": false, \"prerelease\": true, \"tag_name\": \"$TAG_NAME\"}" 25 | -------------------------------------------------------------------------------- /src/Decorators.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | export function memoize(resolver: Function | undefined = undefined) { 4 | if (typeof resolver !== "function") { 5 | resolver = (...args: any[]) => JSON.stringify(args); 6 | } 7 | 8 | return (target: any, name: string, descriptor: TypedPropertyDescriptor) => { 9 | descriptor.value = _.memoize(descriptor.value, resolver); 10 | 11 | return descriptor; 12 | }; 13 | } 14 | 15 | export const memoizeAccessor = (target: Object, name: string | symbol, descriptor: TypedPropertyDescriptor) => { 16 | const memoizedPropertyName = `__memoized_${name}`; 17 | const originalGetter = descriptor.get; 18 | 19 | descriptor.get = function (this: any) { 20 | if (!this[memoizedPropertyName]) { 21 | this[memoizedPropertyName] = originalGetter!.call(this); 22 | } 23 | 24 | return this[memoizedPropertyName]; 25 | }; 26 | 27 | return descriptor; 28 | }; 29 | 30 | export function debounce(wait: number = 0) { 31 | return (target: any, name: string, descriptor: PropertyDescriptor) => { 32 | descriptor.value = _.debounce(descriptor.value, wait); 33 | 34 | return descriptor; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Black Screen 6 | 7 | 8 | 9 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /typings/Interfaces.d.ts: -------------------------------------------------------------------------------- 1 | interface Size { 2 | height: number; 3 | width: number; 4 | } 5 | 6 | interface Dimensions { 7 | columns: number; 8 | rows: number; 9 | } 10 | 11 | interface Advancement { 12 | vertical?: number; 13 | horizontal?: number; 14 | } 15 | 16 | interface RowColumn { 17 | column: number; 18 | row: number; 19 | } 20 | interface PartialRowColumn { 21 | column?: number; 22 | row?: number; 23 | } 24 | 25 | type VcsStatus = "dirty" | "clean"; 26 | 27 | type VcsData = { kind: "repository", branch: string, push: string, pull: string; status: VcsStatus; } | { kind: "not-repository"; } 28 | 29 | interface Margins { 30 | top: number; 31 | bottom?: number; 32 | left: number; 33 | right?: number; 34 | } 35 | interface PartialMargins { 36 | top?: number; 37 | bottom?: number; 38 | left?: number; 39 | right?: number; 40 | } 41 | 42 | interface Dictionary { 43 | [index: string]: T; 44 | } 45 | 46 | interface ProcessEnvironment extends Dictionary { 47 | PWD: string; 48 | } 49 | 50 | type EscapedShellWord = string & {__isEscapedShellToken: any}; 51 | type FullPath = string & { __isFullPath: boolean }; 52 | type ExistingAlias = string & { __isExistingAlias: boolean }; 53 | type OneBasedPosition = number; 54 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Rails.ts: -------------------------------------------------------------------------------- 1 | import {styles, Suggestion} from "../autocompletion_utils/Common"; 2 | import {PluginManager} from "../../PluginManager"; 3 | 4 | const railsCommandConfig = [ 5 | { 6 | name: "runner", 7 | description: "Run a piece of code in the application environment", 8 | }, 9 | { 10 | name: "console", 11 | description: "Start the Rails console", 12 | }, 13 | { 14 | name: "server", 15 | description: "Start the Rails server", 16 | }, 17 | { 18 | name: "generate", 19 | description: "Generate new code'g')", 20 | }, 21 | { 22 | name: "destroy", 23 | description: "generate", 24 | }, 25 | { 26 | name: "dbconsole", 27 | description: "Start a console for the Rails database", 28 | }, 29 | { 30 | name: "new", 31 | description: "Create a new Rails application", 32 | }, 33 | { 34 | name: "plugin new", 35 | description: "Generates skeleton for developing a Rails plugin", 36 | }, 37 | ]; 38 | 39 | const railsCommand = railsCommandConfig.map(config => new Suggestion({value: config.name, description: config.description, style: styles.command})); 40 | 41 | PluginManager.registerAutocompletionProvider("rails", async() => railsCommand); 42 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Vagrant.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {linedOutputOf} from "../../PTY"; 3 | import {styles, Suggestion, contextIndependent} from "../autocompletion_utils/Common"; 4 | import {executablesInPaths} from "../../utils/Common"; 5 | 6 | const commands = contextIndependent(async() => { 7 | return (await linedOutputOf("vagrant", ["list-commands"], process.env.HOME)) 8 | .map(line => { 9 | const matches = line.match(/([\-a-zA-Z0-9]+) /); 10 | 11 | if (matches) { 12 | const name = matches[1]; 13 | const description = line.replace(matches[1], "").trim(); 14 | 15 | return new Suggestion({ 16 | value: name, 17 | description, 18 | style: styles.command, 19 | space: true, 20 | }); 21 | } 22 | }) 23 | .filter(suggestion => suggestion); 24 | }); 25 | 26 | PluginManager.registerAutocompletionProvider("vagrant", async (context) => { 27 | const executables = await executablesInPaths(context.environment.path); 28 | 29 | if (!executables.includes("vagrant")) { 30 | return []; 31 | } 32 | 33 | if (context.argument.position === 1) { 34 | return commands(); 35 | } 36 | 37 | return []; 38 | }); 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | I have found a bug! 2 | ------------------- 3 | 4 | Awesome, but before you [report it to us](issues/new), make sure to [check whether this has already been reported](issues?q=is%3Aissue). 5 | If not, before reporting the issue you'll need to gather some information by following these instructions: 6 | 7 | 1. Make sure you are using the latest version of the application: 8 | 9 | ```bash 10 | git pull 11 | rm -r "/Applications/Black Screen"* 12 | npm run pack 13 | ``` 14 | 2. If the bug is still present, [open an issue](issues/new). 15 | 3. Write steps to reproduce the bug. 16 | 4. Take some screenshots. 17 | 5. Gather debug logs. 18 | 19 | 5.1. Open developer tools (View -> Toggle Developer Tools). 20 | 21 | 5.2. Find Console. 22 | 23 | 5.3. Copy the output and paste it into the issue. 24 | 25 | I have some important changes! 26 | ------------------------------ 27 | 28 | 1. [Clone the repo](https://help.github.com/articles/importing-a-git-repository-using-the-command-line/). 29 | 2. [Create a separate branch](https://github.com/Kunena/Kunena-Forum/wiki/Create-a-new-branch-with-git-and-manage-branches) (to prevent unrelated updates). 30 | 3. Apply your changes. 31 | 4. [Create a pull request](https://help.github.com/articles/creating-a-pull-request/). 32 | 5. Describe what has been done. 33 | 34 | Test 35 | ---- 36 | 37 | * Install [selenium-standalone](https://github.com/vvo/selenium-standalone) 38 | * `selenium-standalone start` 39 | * `npm run test` 40 | -------------------------------------------------------------------------------- /src/shell/Prompt.ts: -------------------------------------------------------------------------------- 1 | import * as events from "events"; 2 | import {Job} from "./Job"; 3 | import {scan, Token, expandAliases} from "./Scanner"; 4 | import {ASTNode, CompleteCommand} from "./Parser"; 5 | 6 | export class Prompt extends events.EventEmitter { 7 | private _value = ""; 8 | private _ast: CompleteCommand; 9 | private _expandedAst: CompleteCommand; 10 | 11 | constructor(private job: Job) { 12 | super(); 13 | } 14 | 15 | get value(): string { 16 | return this._value; 17 | } 18 | 19 | setValue(value: string): void { 20 | this._value = value; 21 | 22 | const tokens = scan(this.value); 23 | this._ast = new CompleteCommand(tokens); 24 | this._expandedAst = new CompleteCommand(expandAliases(tokens, this.job.session.aliases)); 25 | } 26 | 27 | get ast(): ASTNode { 28 | return this._ast; 29 | } 30 | 31 | get expandedTokens(): Token[] { 32 | return this._expandedAst.tokens; 33 | } 34 | 35 | get commandName(): string { 36 | if (!this._expandedAst || !this._expandedAst.firstCommand.commandWord) { 37 | return ""; 38 | } 39 | return this._expandedAst.firstCommand.commandWord.value; 40 | } 41 | 42 | get arguments(): Token[] { 43 | const argumentList = this._expandedAst.firstCommand.argumentList; 44 | 45 | if (argumentList) { 46 | return argumentList.tokens; 47 | } else { 48 | return []; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Cursor.ts: -------------------------------------------------------------------------------- 1 | export class Cursor { 2 | private _show = true; 3 | private _blink = false; 4 | 5 | constructor(private position: RowColumn = { row: 0, column: 0 }) { 6 | } 7 | 8 | moveAbsolute(position: PartialRowColumn, homePosition: PartialRowColumn): this { 9 | if (typeof position.column === "number") { 10 | this.position.column = position.column + homePosition.column; 11 | } 12 | 13 | if (typeof position.row === "number") { 14 | this.position.row = position.row + homePosition.row; 15 | } 16 | 17 | return this; 18 | } 19 | 20 | moveRelative(advancement: Advancement): this { 21 | const row = Math.max(0, this.row + (advancement.vertical || 0)); 22 | const column = Math.max(0, this.column + (advancement.horizontal || 0)); 23 | 24 | this.moveAbsolute({ row: row, column: column }, { column: 0, row: 0 }); 25 | 26 | return this; 27 | } 28 | 29 | getPosition(): RowColumn { 30 | return this.position; 31 | } 32 | 33 | get column(): number { 34 | return this.position.column; 35 | } 36 | 37 | get row(): number { 38 | return this.position.row; 39 | } 40 | 41 | get blink(): boolean { 42 | return this._blink; 43 | } 44 | 45 | set blink(value: boolean) { 46 | this._blink = value; 47 | } 48 | 49 | get show(): boolean { 50 | return this._show; 51 | } 52 | 53 | set show(value: boolean) { 54 | this._show = value; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Cd.ts: -------------------------------------------------------------------------------- 1 | import {expandHistoricalDirectory} from "../../shell/Command"; 2 | import {styles, Suggestion, directoriesSuggestionsProvider} from "../autocompletion_utils/Common"; 3 | import * as _ from "lodash"; 4 | import {PluginManager} from "../../PluginManager"; 5 | 6 | PluginManager.registerAutocompletionProvider("cd", async(context) => { 7 | let suggestions: Suggestion[] = []; 8 | 9 | /** 10 | * Historical directories. 11 | */ 12 | if (context.argument.value.startsWith("-")) { 13 | const historicalDirectoryAliases = ["-", "-2", "-3", "-4", "-5", "-6", "-7", "-8", "-9"] 14 | .slice(0, context.historicalPresentDirectoriesStack.size) 15 | .map(alias => new Suggestion({ 16 | value: alias, 17 | description: expandHistoricalDirectory(alias, context.historicalPresentDirectoriesStack), 18 | style: styles.directory, 19 | })); 20 | 21 | suggestions.push(...historicalDirectoryAliases); 22 | } 23 | 24 | suggestions.push(...await directoriesSuggestionsProvider(context)); 25 | 26 | if (context.argument.value.length > 0) { 27 | const cdpathDirectories = _.flatten(await Promise.all(context.environment.cdpath 28 | .filter(directory => directory !== context.environment.pwd) 29 | .map(async(directory) => (await directoriesSuggestionsProvider(context, directory)).map(suggestion => suggestion.withDescription(`In ${directory}`))))); 30 | 31 | suggestions.push(...cdpathDirectories); 32 | } 33 | 34 | return suggestions; 35 | }); 36 | -------------------------------------------------------------------------------- /src/views/StatusBarComponent.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | import * as React from "react"; 3 | import * as css from "./css/main"; 4 | import {fontAwesome} from "./css/FontAwesome"; 5 | import {watchManager} from "../plugins/GitWatcher"; 6 | 7 | const PresentWorkingDirectory = ({presentWorkingDirectory}: { presentWorkingDirectory: string }) => 8 |
9 | 10 | {presentWorkingDirectory} 11 |
; 12 | 13 | const VcsDataComponent = ({data}: { data: VcsData }) => { 14 | if (data.kind === "repository") { 15 | return ( 16 |
17 |
18 | 19 | {data.pull} 20 | 21 | {data.push} 22 | 23 | {data.branch} 24 |
25 |
26 | ); 27 | } else { 28 | return
; 29 | } 30 | }; 31 | 32 | export const StatusBarComponent = ({presentWorkingDirectory}: { presentWorkingDirectory: string }) => 33 |
34 | 35 | 36 |
; 37 | -------------------------------------------------------------------------------- /src/utils/ManPages.ts: -------------------------------------------------------------------------------- 1 | import {execFile} from "child-process-promise"; 2 | import {Suggestion, contextIndependent, unique} from "../plugins/autocompletion_utils/Common"; 3 | import { 4 | preprocessManPage, 5 | extractManPageSections, 6 | extractManPageSectionParagraphs, 7 | suggestionFromFlagParagraph, 8 | } from "./ManPageParsingUtils"; 9 | 10 | // Note: this is still pretty experimental. If you want to do man page parsing 11 | // for a new command, expect to have to make some changes here. 12 | 13 | // TODO: Fix -l option for locate 14 | // TODO: Handle nested options. Unblocks: 15 | // dd 16 | 17 | const manPageToOptions = async (command: string): Promise => { 18 | // use execFile to prevent a command like "; echo test" from running the "echo test" 19 | const {stdout, stderr} = await execFile("man", [command], {}); 20 | if (stderr) { 21 | throw `Error in retrieving man page: ${command}`; 22 | } 23 | // "Apply" backspace literals 24 | const manContents = preprocessManPage(stdout); 25 | 26 | const manSections = extractManPageSections(manContents); 27 | 28 | // Split the description section (which contains the flags) into paragraphs 29 | /* tslint:disable:no-string-literal */ 30 | const manDescriptionParagraphs: string[][] = extractManPageSectionParagraphs(manSections["DESCRIPTION"]); 31 | /* tslint:enable:no-string-literal */ 32 | 33 | // Extract the paragraphs that describe flags, and parse out the flag data 34 | return manDescriptionParagraphs.map(suggestionFromFlagParagraph).filter((s: Suggestion | undefined) => s !== undefined) as Suggestion[]; 35 | }; 36 | 37 | export const manPageOptions = (command: string) => unique(contextIndependent(() => manPageToOptions(command))); 38 | -------------------------------------------------------------------------------- /src/views/Main.tsx: -------------------------------------------------------------------------------- 1 | import {handleUserEvent, UserEvent} from "./UserEventsHander"; 2 | process.env.NODE_ENV = process.env.NODE_ENV || "production"; 3 | process.env.LANG = process.env.LANG || "en_US.UTF-8"; 4 | 5 | import {loadAliasesFromConfig} from "../shell/Aliases"; 6 | const reactDOM = require("react-dom"); 7 | /* tslint:disable:no-unused-variable */ 8 | import * as React from "react"; 9 | import {ApplicationComponent} from "./1_ApplicationComponent"; 10 | import {loadAllPlugins} from "../PluginManager"; 11 | import {loadEnvironment} from "../shell/Environment"; 12 | 13 | document.addEventListener( 14 | "dragover", 15 | function(event) { 16 | event.preventDefault(); 17 | return false; 18 | }, 19 | false 20 | ); 21 | 22 | document.addEventListener( 23 | "drop", 24 | function(event) { 25 | event.preventDefault(); 26 | return false; 27 | }, 28 | false 29 | ); 30 | 31 | document.addEventListener( 32 | "DOMContentLoaded", 33 | () => { 34 | // FIXME: Remove loadAllPlugins after switching to Webpack (because all the files will be loaded at start anyway). 35 | Promise.all([loadAllPlugins(), loadEnvironment(), loadAliasesFromConfig()]) 36 | .then(() => { 37 | const application: ApplicationComponent = reactDOM.render(, document.body); 38 | 39 | const userEventHandler = (event: UserEvent) => handleUserEvent(application, window.focusedTab, window.focusedSession, window.focusedJob, window.focusedPrompt, window.search)(event); 40 | 41 | document.body.addEventListener("keydown", userEventHandler, true); 42 | document.body.addEventListener("paste", userEventHandler, true); 43 | }); 44 | }, 45 | false 46 | ); 47 | -------------------------------------------------------------------------------- /src/views/ViewUtils.ts: -------------------------------------------------------------------------------- 1 | import {KeyCode} from "../Enums"; 2 | import {writeFileCreatingParents, windowBoundsFilePath} from "../utils/Common"; 3 | 4 | export function stopBubblingUp(event: Event): Event { 5 | event.stopPropagation(); 6 | event.preventDefault(); 7 | 8 | return event; 9 | } 10 | 11 | export function isModifierKey(event: KeyboardEvent) { 12 | return [KeyCode.Shift, KeyCode.Ctrl, KeyCode.Alt].includes(event.keyCode); 13 | } 14 | 15 | export function setCaretPosition(node: Node, position: number) { 16 | const selection = window.getSelection(); 17 | const range = document.createRange(); 18 | 19 | if (node.childNodes.length) { 20 | range.setStart(node.childNodes[0], position); 21 | } else { 22 | range.setStart(node, 0); 23 | } 24 | range.collapse(true); 25 | selection.removeAllRanges(); 26 | selection.addRange(range); 27 | } 28 | 29 | /** 30 | * @link http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container/4812022#4812022 31 | */ 32 | export function getCaretPosition(element: Node): number { 33 | const selection = element.ownerDocument.defaultView.getSelection(); 34 | 35 | if (selection.rangeCount > 0) { 36 | const range = selection.getRangeAt(0); 37 | const preCaretRange = range.cloneRange(); 38 | preCaretRange.selectNodeContents(element); 39 | 40 | return preCaretRange.toString().length; 41 | } else { 42 | return 0; 43 | } 44 | } 45 | 46 | export function saveWindowBounds(browserWindow: Electron.BrowserWindow) { 47 | writeFileCreatingParents(windowBoundsFilePath, JSON.stringify(browserWindow.getBounds())).then( 48 | () => void 0, 49 | (error: any) => { if (error) throw error; } 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/views/SearchComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as css from "./css/main"; 3 | import {remote} from "electron"; 4 | import {fontAwesome} from "./css/FontAwesome"; 5 | 6 | export class SearchComponent extends React.Component<{}, {}> { 7 | private webContents: Electron.WebContents = remote.BrowserWindow.getAllWindows()[0].webContents; 8 | 9 | constructor() { 10 | super(); 11 | // FIXME: find a better design. 12 | window.search = this; 13 | } 14 | 15 | render() { 16 | return ( 17 |
18 | 19 | this.handleInput(event)} 23 | type="search"/> 24 |
25 | ); 26 | } 27 | 28 | get isFocused(): boolean { 29 | return document.activeElement === this.input; 30 | } 31 | 32 | clearSelection(): void { 33 | this.webContents.stopFindInPage("clearSelection"); 34 | this.input.value = ""; 35 | } 36 | 37 | private handleInput(event: React.KeyboardEvent) { 38 | const text = (event.target as HTMLInputElement).value; 39 | 40 | if (text) { 41 | this.webContents.findInPage(text); 42 | this.webContents.on("found-in-page", () => this.input.focus()); 43 | } else { 44 | this.clearSelection(); 45 | setTimeout(() => this.input.select(), 0); 46 | } 47 | } 48 | 49 | private get input(): HTMLInputElement { 50 | /* tslint:disable:no-string-literal */ 51 | return this.refs["input"] as HTMLInputElement; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/shell/Aliases.ts: -------------------------------------------------------------------------------- 1 | import {executeCommandWithShellConfig} from "../PTY"; 2 | import * as _ from "lodash"; 3 | 4 | export const aliasesFromConfig: Dictionary = {}; 5 | 6 | export async function loadAliasesFromConfig(): Promise { 7 | const lines = await executeCommandWithShellConfig("alias"); 8 | 9 | lines.map(parseAlias).forEach(parsed => aliasesFromConfig[parsed.name] = parsed.value); 10 | } 11 | 12 | export function parseAlias(line: string) { 13 | let [short, long] = line.split("="); 14 | 15 | if (short && long) { 16 | const nameCapture = /(alias )?(.*)/.exec(short); 17 | const valueCapture = /'?([^']*)'?/.exec(long); 18 | 19 | if (nameCapture && valueCapture) { 20 | return { 21 | name: nameCapture[2], 22 | value: valueCapture[1], 23 | }; 24 | } else { 25 | throw `Alias line is incorrect: ${line}`; 26 | } 27 | } else { 28 | throw `Can't parse alias line: ${line}`; 29 | } 30 | } 31 | 32 | 33 | export class Aliases { 34 | private storage: Dictionary; 35 | 36 | constructor(aliases: Dictionary) { 37 | this.storage = _.clone(aliases); 38 | } 39 | 40 | add(name: string, value: string) { 41 | this.storage[name] = value; 42 | } 43 | 44 | has(name: string): name is ExistingAlias { 45 | return name in this.storage; 46 | } 47 | 48 | get(name: ExistingAlias): string { 49 | return this.storage[name]; 50 | } 51 | 52 | getNameByValue(value: string): string | undefined { 53 | return _.findKey(this.storage, storageValue => storageValue === value); 54 | } 55 | 56 | remove(name: string) { 57 | delete this.storage[name]; 58 | } 59 | 60 | toObject(): Dictionary { 61 | return this.storage; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Char.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import {memoize} from "./Decorators"; 3 | import {Attributes} from "./Interfaces"; 4 | import {KeyCode, Brightness, Weight, Color} from "./Enums"; 5 | 6 | export const attributesFlyweight = _.memoize( 7 | (attributes: Attributes): Attributes => Object.freeze(Object.assign({}, attributes)), 8 | (attributes: Dictionary) => { 9 | const ordered: Dictionary = {}; 10 | Object.keys(attributes).sort().forEach(key => ordered[key] = attributes[key]); 11 | return JSON.stringify(ordered); 12 | } 13 | ); 14 | 15 | export const defaultAttributes = Object.freeze({ 16 | inverse: false, 17 | color: Color.White, 18 | backgroundColor: Color.Black, 19 | brightness: Brightness.Normal, 20 | weight: Weight.Normal, 21 | underline: false, 22 | crossedOut: false, 23 | blinking: false, 24 | cursor: false, 25 | }); 26 | 27 | export class Char { 28 | static empty = Char.flyweight(" ", defaultAttributes); 29 | 30 | @memoize() 31 | static flyweight(char: string, attributes: Attributes) { 32 | return new Char(char, attributesFlyweight(attributes)); 33 | 34 | } 35 | 36 | constructor(private char: string, private _attributes: Attributes) { 37 | if (char.length !== 1) { 38 | throw(`Char can be created only from a single character; passed ${char.length}: ${char}`); 39 | } 40 | } 41 | 42 | get keyCode(): KeyCode { 43 | return (KeyCode)[KeyCode[this.char.charCodeAt(0)]]; 44 | } 45 | 46 | get attributes(): Attributes { 47 | return this._attributes; 48 | } 49 | 50 | toString(): string { 51 | return this.char; 52 | } 53 | 54 | isSpecial(): boolean { 55 | // http://www.asciitable.com/index/asciifull.gif 56 | const charCode = this.char.charCodeAt(0); 57 | return charCode < 32; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Autocompletion.ts: -------------------------------------------------------------------------------- 1 | import {Job} from "./shell/Job"; 2 | import {leafNodeAt} from "./shell/Parser"; 3 | import * as _ from "lodash"; 4 | import {History} from "./shell/History"; 5 | import {Suggestion, styles, replaceAllPromptSerializer} from "./plugins/autocompletion_utils/Common"; 6 | 7 | export const suggestionsLimit = 9; 8 | 9 | export const getSuggestions = async(job: Job, caretPosition: number) => { 10 | const prefixMatchesInHistory = History.all.filter(line => line.startsWith(job.prompt.value)); 11 | const suggestionsFromHistory = prefixMatchesInHistory.map(match => new Suggestion({ 12 | value: match, 13 | promptSerializer: replaceAllPromptSerializer, 14 | style: styles.history, 15 | })); 16 | 17 | const firstThreeFromHistory = suggestionsFromHistory.slice(0, 3); 18 | const remainderFromHistory = suggestionsFromHistory.slice(3); 19 | 20 | const node = leafNodeAt(caretPosition, job.prompt.ast); 21 | const suggestions = await node.suggestions({ 22 | environment: job.environment, 23 | historicalPresentDirectoriesStack: job.session.historicalPresentDirectoriesStack, 24 | aliases: job.session.aliases, 25 | }); 26 | 27 | const applicableSuggestions = _.uniqBy([...firstThreeFromHistory, ...suggestions, ...remainderFromHistory], suggestion => suggestion.value).filter(suggestion => 28 | suggestion.value.toLowerCase().startsWith(node.value.toLowerCase()) 29 | ); 30 | 31 | if (applicableSuggestions.length === 1) { 32 | const suggestion = applicableSuggestions[0]; 33 | 34 | /** 35 | * The suggestion would simply duplicate the prompt value without providing no 36 | * additional information. Skipping it for clarity. 37 | */ 38 | if (node.value === suggestion.value && suggestion.description.length === 0 && suggestion.synopsis.length === 0) { 39 | return []; 40 | } 41 | } 42 | 43 | return applicableSuggestions.slice(0, suggestionsLimit); 44 | }; 45 | -------------------------------------------------------------------------------- /src/main/Main.ts: -------------------------------------------------------------------------------- 1 | import {app, ipcMain, nativeImage, BrowserWindow, screen} from "electron"; 2 | import {readFileSync} from "fs"; 3 | import {windowBoundsFilePath} from "../utils/Common"; 4 | 5 | if (app.dock) { 6 | app.dock.setIcon(nativeImage.createFromPath("build/icon.png")); 7 | } 8 | 9 | app.on("ready", () => { 10 | const bounds = windowBounds(); 11 | 12 | let options: Electron.BrowserWindowOptions = { 13 | webPreferences: { 14 | experimentalFeatures: true, 15 | experimentalCanvasFeatures: true, 16 | }, 17 | titleBarStyle: "hidden", 18 | resizable: true, 19 | minWidth: 500, 20 | minHeight: 300, 21 | width: bounds.width, 22 | height: bounds.height, 23 | x: bounds.x, 24 | y: bounds.y, 25 | show: false, 26 | }; 27 | const browserWindow = new BrowserWindow(options); 28 | 29 | if (process.env.REACT_EXTENSION_PATH) { 30 | BrowserWindow.addDevToolsExtension(process.env.REACT_EXTENSION_PATH); 31 | } 32 | 33 | browserWindow.loadURL("file://" + __dirname + "/../views/index.html"); 34 | 35 | browserWindow.on("focus", () => app.dock && app.dock.setBadge("")); 36 | 37 | browserWindow.webContents.on("did-finish-load", () => { 38 | browserWindow.show(); 39 | browserWindow.focus(); 40 | }); 41 | 42 | app.on("open-file", (event, file) => browserWindow.webContents.send("change-working-directory", file)); 43 | }); 44 | 45 | app.on("mainWindow-all-closed", () => process.platform === "darwin" || app.quit()); 46 | 47 | ipcMain.on("quit", app.quit); 48 | 49 | function windowBounds(): Electron.Rectangle { 50 | try { 51 | return JSON.parse(readFileSync(windowBoundsFilePath).toString()); 52 | } catch (error) { 53 | const workAreaSize = screen.getPrimaryDisplay().workAreaSize; 54 | 55 | return { 56 | width: workAreaSize.width, 57 | height: workAreaSize.height, 58 | x: 0, 59 | y: 0, 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/views/2_SessionComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as _ from "lodash"; 3 | import {Session} from "../shell/Session"; 4 | import {Job} from "../shell/Job"; 5 | import {JobComponent} from "./3_JobComponent"; 6 | import * as css from "./css/main"; 7 | 8 | interface Props { 9 | session: Session; 10 | isFocused: boolean; 11 | focus: () => void; 12 | updateStatusBar: (() => void) | undefined; // Only the focused session can update the status bar. 13 | } 14 | 15 | export class SessionComponent extends React.Component { 16 | RENDER_JOBS_COUNT = 25; 17 | 18 | constructor(props: Props) { 19 | super(props); 20 | 21 | // FIXME: find a better design to propagate events. 22 | if (this.props.isFocused) { 23 | window.focusedSession = this; 24 | } 25 | } 26 | 27 | componentDidMount() { 28 | this.props.session 29 | .on("job", () => this.props.updateStatusBar && this.props.updateStatusBar()) 30 | .on("vcs-data", () => this.props.updateStatusBar && this.props.updateStatusBar()); 31 | } 32 | 33 | componentDidUpdate() { 34 | // FIXME: find a better design to propagate events. 35 | if (this.props.isFocused) { 36 | window.focusedSession = this; 37 | } 38 | } 39 | 40 | render() { 41 | const jobs = _.takeRight(this.props.session.jobs, this.RENDER_JOBS_COUNT).map((job: Job, index: number) => 42 | 45 | ); 46 | 47 | return ( 48 |
51 | 52 |
{jobs}
53 |
54 |
55 | ); 56 | } 57 | 58 | private handleClick() { 59 | if (!this.props.isFocused) { 60 | this.props.focus(); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/views/css/definitions.ts: -------------------------------------------------------------------------------- 1 | export interface CSSObject { 2 | contain?: "strict" | "paint"; 3 | pointerEvents?: string; 4 | marginTop?: number; 5 | marginBottom?: number; 6 | padding?: string | number; 7 | paddingTop?: number; 8 | paddingBottom?: number; 9 | paddingLeft?: number; 10 | paddingRight?: number; 11 | minHeight?: number | string; 12 | minWidth?: number | string; 13 | height?: number | string; 14 | margin?: number | string; 15 | listStyleType?: "none"; 16 | backgroundColor?: string; 17 | cursor?: "pointer" | "help" | "progress"; 18 | color?: string; 19 | width?: string | number; 20 | flex?: number | "auto" | "none"; 21 | flexGrow?: number; 22 | flexBasis?: number; 23 | flexDirection?: "row" | "column"; 24 | overflow?: "hidden"; 25 | overflowX?: "auto" | "scroll"; 26 | overflowY?: "auto" | "scroll" | "hidden"; 27 | outline?: "none"; 28 | opacity?: number; 29 | boxShadow?: string; 30 | zoom?: number; 31 | position?: "fixed" | "relative" | "absolute"; 32 | top?: number | "auto"; 33 | bottom?: number | "auto"; 34 | left?: number; 35 | right?: number; 36 | whiteSpace?: "pre-wrap" | "nowrap"; 37 | zIndex?: number; 38 | gridArea?: string; 39 | display?: "grid" | "inline-block" | "flex"; 40 | gridTemplateAreas?: string; 41 | gridTemplateRows?: "auto" | string; 42 | gridTemplateColumns?: string; 43 | transition?: string; 44 | animation?: string; 45 | backgroundImage?: string; 46 | backgroundSize?: string | number; 47 | backgroundRepeat?: string; 48 | backgroundPosition?: string; 49 | content?: string; 50 | transformOrigin?: string; 51 | transform?: string; 52 | textDecoration?: "underline"; 53 | fontWeight?: "bold"; 54 | fontSize?: number; 55 | WebkitAppearance?: "none"; 56 | } 57 | 58 | abstract class Unit { 59 | abstract toCSS(): string; 60 | } 61 | 62 | export class Px extends Unit { 63 | constructor(private number: number) { 64 | super(); 65 | } 66 | 67 | toCSS(): string { 68 | return `${this.number}px`; 69 | } 70 | } 71 | 72 | export class Fr extends Unit { 73 | constructor(private number: number) { 74 | super(); 75 | } 76 | 77 | toCSS(): string { 78 | return `${this.number}fr`; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/views/css/colors.ts: -------------------------------------------------------------------------------- 1 | import {darken} from "./functions"; 2 | import * as _ from "lodash"; 3 | import {ColorCode} from "../../Interfaces"; 4 | 5 | export const colors = { 6 | black: "#292C33", 7 | red: "#BF6E7C", 8 | white: "#95A2BB", 9 | green: "#88B379", 10 | yellow: "#D9BD86", 11 | blue: "#66A5DF", 12 | magenta: "#C699C5", 13 | cyan: "#6EC6C6", 14 | 15 | brightBlack: "#484c54", 16 | brightRed: "#dd8494", 17 | brightWhite: "#adbcd7", 18 | brightGreen: "#9dcc8c", 19 | brightYellow: "#e9cc92", 20 | brightBlue: "#6cb2f0", 21 | brightMagenta: "#e8b6e7", 22 | brightCyan: "#7adada", 23 | }; 24 | 25 | const colorIndex = [ 26 | colors.black, 27 | colors.red, 28 | colors.green, 29 | colors.yellow, 30 | colors.blue, 31 | colors.magenta, 32 | colors.cyan, 33 | colors.white, 34 | 35 | colors.brightBlack, 36 | colors.brightRed, 37 | colors.brightGreen, 38 | colors.brightYellow, 39 | colors.brightBlue, 40 | colors.brightMagenta, 41 | colors.brightCyan, 42 | colors.brightWhite, 43 | 44 | ...generateIndexedColors(), 45 | ...generateGreyScaleColors(), 46 | ]; 47 | 48 | function toRgb(colorComponent: number) { 49 | if (colorComponent === 0) { 50 | return 0; 51 | } 52 | 53 | return 55 + colorComponent * 40; 54 | } 55 | 56 | function generateIndexedColors() { 57 | return _.range(0, 216).map(index => { 58 | const red = Math.floor(index / 36); 59 | const green = Math.floor((index % 36) / 6); 60 | const blue = Math.floor(index % 6); 61 | 62 | return `rgb(${toRgb(red)}, ${toRgb(green)}, ${toRgb(blue)})`; 63 | }); 64 | } 65 | 66 | function generateGreyScaleColors() { 67 | return _.range(0, 24).map(index => { 68 | const color = index * 10 + 8; 69 | return `rgb(${color}, ${color}, ${color})`; 70 | }); 71 | } 72 | 73 | export function colorValue(color: ColorCode, options = {isBright: false}) { 74 | if (Array.isArray(color)) { 75 | return `rgb(${color.join(", ")})`; 76 | } else { 77 | if (options.isBright && color < 8) { 78 | return colorIndex[color + 8]; 79 | } else { 80 | return colorIndex[color]; 81 | } 82 | } 83 | } 84 | 85 | export const background = colors.black; 86 | export const panel = darken(background, 3); 87 | -------------------------------------------------------------------------------- /src/views/AutocompleteComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Suggestion} from "../plugins/autocompletion_utils/Common"; 3 | import * as css from "./css/main"; 4 | 5 | interface SuggestionProps { 6 | suggestion: Suggestion; 7 | onHover: () => void; 8 | onClick: () => void; 9 | isHighlighted: boolean; 10 | } 11 | 12 | const SuggestionComponent = ({suggestion, onHover, onClick, isHighlighted}: SuggestionProps) => 13 |
  • 16 | 17 | 18 | {suggestion.displayValue} 19 | {suggestion.synopsis} 20 |
  • ; 21 | 22 | interface AutocompleteProps { 23 | offsetTop: number; 24 | caretPosition: number; 25 | suggestions: Suggestion[]; 26 | onSuggestionHover: (index: number) => void; 27 | onSuggestionClick: () => void; 28 | highlightedIndex: number; 29 | ref: string; 30 | } 31 | 32 | export class AutocompleteComponent extends React.Component { 33 | render() { 34 | const suggestionViews = this.props.suggestions.map((suggestion, index) => 35 | this.props.onSuggestionHover(index)} 37 | onClick={this.props.onSuggestionClick} 38 | key={index} 39 | isHighlighted={index === this.props.highlightedIndex}/> 40 | ); 41 | 42 | const suggestionDescription = this.props.suggestions[this.props.highlightedIndex].description; 43 | let descriptionElement: React.ReactElement | undefined; 44 | 45 | if (suggestionDescription) { 46 | descriptionElement =
    {suggestionDescription}
    ; 47 | } 48 | 49 | return ( 50 |
    51 |
      {suggestionViews}
    52 | {descriptionElement} 53 |
    54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/ansi_parser_spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import {expect} from "chai"; 3 | import {ScreenBuffer} from "../src/ScreenBuffer"; 4 | import {ANSIParser} from "../src/ANSIParser"; 5 | import {TerminalLikeDevice} from "../src/Interfaces"; 6 | 7 | class DummyTerminal implements TerminalLikeDevice { 8 | screenBuffer = new ScreenBuffer(); 9 | dimensions = {columns: 80, rows: 24}; 10 | written = ""; 11 | write = (input: string) => this.written += input; 12 | } 13 | 14 | type CSIFinalCharacter = "A" | "B" | "C" | "D" | "E" | "F" | "R" | "m" | "n"; 15 | 16 | const csi = (params: number[], final: CSIFinalCharacter) => { 17 | return `\x1b[${params.join(";")}${final}`; 18 | }; 19 | 20 | const sgr = (params: number[]) => { 21 | return csi(params, "m"); 22 | }; 23 | 24 | const output = (string: string) => string.slice(1, -1); 25 | 26 | describe("ANSI parser", () => { 27 | let terminal: DummyTerminal; 28 | let parser: ANSIParser; 29 | 30 | beforeEach(() => { 31 | terminal = new DummyTerminal(); 32 | parser = new ANSIParser(terminal); 33 | }); 34 | 35 | it("can parse an ASCII string", async() => { 36 | parser.parse("something"); 37 | 38 | expect(terminal.screenBuffer.toString()).to.eql("something"); 39 | }); 40 | 41 | describe("movements", () => { 42 | it("can move down", async() => { 43 | parser.parse(`first${csi([1], "B")}second`); 44 | 45 | expect(terminal.screenBuffer.toString()).to.eql(output(` 46 | first 47 | second 48 | `)); 49 | }); 50 | }); 51 | 52 | describe("true color", () => { 53 | it("sets the correct foreground color", async() => { 54 | parser.parse(`${sgr([38, 2, 255, 100, 0])}A${sgr([0])}`); 55 | 56 | expect(terminal.screenBuffer.toString()).to.eql("A"); 57 | const firstChar = terminal.screenBuffer.at({row: 0, column: 0}); 58 | expect(firstChar.attributes.color).to.eql([255, 100, 0]); 59 | }); 60 | }); 61 | 62 | describe("CSI", () => { 63 | describe("Device Status Report (DSR)", () => { 64 | describe("Report Cursor Position (CPR)", () => { 65 | it("report cursor position", async() => { 66 | parser.parse(`some text${csi([6], "n")}`); 67 | 68 | expect(terminal.screenBuffer.toString()).to.eql("some text"); 69 | expect(terminal.written).to.eql(`${csi([1, 10], "R")}`); 70 | }); 71 | }); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Join the chat at https://gitter.im/shockone/black-screen](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shockone/black-screen?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | [![Build Status](https://travis-ci.org/shockone/black-screen.svg?branch=master)](https://travis-ci.org/shockone/black-screen) 3 | 4 | What Is It? 5 | ----------- 6 | 7 | Black Screen is an IDE in the world of terminals. Strictly speaking, it's both a 8 | terminal emulator and an *interactive* shell based on [Electron](http://electron.atom.io/). 9 | Also, unlike most of the emulators you can meet nowadays it uses HTML and CSS for its UI (exactly as Atom does), 10 | which means we can [stop misusing unicode characters](https://github.com/vim-airline/vim-airline) 11 | and make a better looking terminal with appropriate tools. 12 | 13 | ![](README/main.png) 14 | 15 | ###### Autocompletion 16 | 17 | Black Screen shows the autocompletion box as you type and tries to be smart about what to suggest. 18 | Often you can find useful additional information on the right side of the autocompletion, e.g. expanded alias value, 19 | history substitutions for `!!`, command descriptions, value of the previous directory (`cd -`), etc. 20 | 21 | ###### Compatibility 22 | 23 | We aim to be compatible at least with [VT100](https://en.wikipedia.org/wiki/VT100). All the programs (emacs, ssh, vim) should work as expected. 24 | 25 | Install 26 | ------------ 27 | 28 | ```bash 29 | brew update 30 | brew cask install black-screen 31 | ``` 32 | 33 | Linux and Windows builds will be available after 1.0.0 release. Currently only OS X is supported to speed up the development process. 34 | 35 | Technologies 36 | ------------ 37 | 38 | * [Electron](http://electron.atom.io/) 39 | * [TypeScript](http://www.typescriptlang.org/) 40 | * [ReactJS](https://facebook.github.io/react/) 41 | 42 | 43 | More Screenshots 44 | ---------------- 45 | 46 | ![](README/npm_autocompletion.png) 47 | ![](README/error.png) 48 | ![](README/history_autocompletion.png) 49 | ![](README/top_autocompletion.png) 50 | ![](README/json_decorator.png) 51 | ![](README/vim.png) 52 | ![](README/emacs.png) 53 | ![](README/htop.png) 54 | ![](README/cd.png) 55 | 56 | Development Setup 57 | ------------ 58 | 59 | ```bash 60 | git clone https://github.com/shockone/black-screen.git && cd black-screen 61 | npm start 62 | ``` 63 | 64 | To create a standalone application, execute `npm run pack` in the project directory. 65 | 66 | Contributing 67 | ------------ 68 | 69 | See [Contributing Guide](CONTRIBUTING.md). 70 | 71 | License 72 | ------- 73 | 74 | [The MIT License](LICENSE). 75 | -------------------------------------------------------------------------------- /typings/Overrides.d.ts: -------------------------------------------------------------------------------- 1 | declare class Notification { 2 | constructor(str: string); 3 | constructor(title: string, options: { body: string }); 4 | } 5 | 6 | interface IntersectionObserverEntry { 7 | readonly time: number; 8 | readonly rootBounds: ClientRect; 9 | readonly boundingClientRect: ClientRect; 10 | readonly intersectionRect: ClientRect; 11 | readonly intersectionRatio: number; 12 | readonly target: Element; 13 | } 14 | 15 | interface IntersectionObserverInit { 16 | // The root to use for intersection. If not provided, use the top-level document’s viewport. 17 | root?: Element; 18 | // Same as margin, can be 1, 2, 3 or 4 components, possibly negative lengths. If an explicit 19 | // root element is specified, components may be percentages of the root element size. If no 20 | // explicit root element is specified, using a percentage here is an error. 21 | // "5px" 22 | // "10% 20%" 23 | // "-10px 5px 5px" 24 | // "-10px -10px 5px 5px" 25 | rootMargin?: string; 26 | // Threshold(s) at which to trigger callback, specified as a ratio, or list of ratios, 27 | // of (visible area / total area) of the observed element (hence all entries must be 28 | // in the range [0, 1]). Callback will be invoked when the visible ratio of the observed 29 | // element crosses a threshold in the list. 30 | threshold?: number | number[]; 31 | } 32 | 33 | declare class IntersectionObserver { 34 | constructor(handler: (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void, options?: IntersectionObserverInit); 35 | observe(target: Element): void; 36 | unobserve(target: Element): void; 37 | disconnect(): void; 38 | takeRecords(): IntersectionObserverEntry[]; 39 | } 40 | 41 | interface Window { 42 | DEBUG: boolean; 43 | application: any; 44 | focusedTab: any; 45 | focusedSession: any; 46 | focusedJob: any; 47 | focusedPrompt: any; 48 | search: any; 49 | } 50 | 51 | declare class AnsiParser { 52 | constructor(callbacks: Dictionary) 53 | 54 | parse(data: string): any; 55 | } 56 | 57 | declare module "fs-extra" { 58 | export function walk(dirPath: string): NodeJS.ReadableStream; 59 | } 60 | 61 | interface Array { 62 | includes(value: T): boolean; 63 | } 64 | 65 | interface NodeBuffer extends Uint8Array { 66 | fill(value: number, offset?: number, end?: number): this; 67 | } 68 | 69 | interface ObjectConstructor { 70 | assign(a: A, b: B, c: C, d: D, e: E, f: F): A & B & C & D & E & F; 71 | } 72 | 73 | interface HTMLElement { 74 | scrollIntoViewIfNeeded(top?: boolean): void; 75 | } 76 | -------------------------------------------------------------------------------- /src/shell/History.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync, statSync} from "fs"; 2 | import {flatten, orderBy} from "lodash"; 3 | import {loginShell} from "../utils/Shell"; 4 | import {historyFilePath} from "../utils/Common"; 5 | 6 | const readHistoryFileData = () => { 7 | try { 8 | return { 9 | lastModified: statSync(historyFilePath).mtime, 10 | commands: JSON.parse(readFileSync(historyFilePath).toString()), 11 | }; 12 | } catch (e) { 13 | return { 14 | lastModified: new Date(0), 15 | commands: [], 16 | }; 17 | } 18 | }; 19 | 20 | export class History { 21 | static pointer: number = 0; 22 | private static maxEntriesCount: number = 100; 23 | private static storage: string[] = []; 24 | private static defaultEntry = ""; 25 | 26 | static get all(): string[] { 27 | return this.storage; 28 | } 29 | 30 | static get latest(): string { 31 | return this.at(-1); 32 | } 33 | 34 | static at(position: number): string { 35 | if (position === 0) { 36 | return this.defaultEntry; 37 | } 38 | 39 | if (position < 0) { 40 | return this.storage[-(position + 1)] || this.defaultEntry; 41 | } 42 | 43 | return this.storage[this.count - 1] || this.defaultEntry; 44 | } 45 | 46 | static add(entry: string): void { 47 | this.remove(entry); 48 | this.storage.unshift(entry); 49 | 50 | if (this.count > this.maxEntriesCount) { 51 | this.storage.splice(this.maxEntriesCount - 1); 52 | } 53 | 54 | this.pointer = 0; 55 | } 56 | 57 | static getPrevious(): string { 58 | if (this.pointer < this.count) { 59 | this.pointer += 1; 60 | } 61 | 62 | return this.at(-this.pointer); 63 | } 64 | 65 | static getNext(): string { 66 | if (this.pointer > 0) { 67 | this.pointer -= 1; 68 | } 69 | 70 | return this.at(-this.pointer); 71 | } 72 | 73 | private static get count(): number { 74 | return this.storage.length; 75 | } 76 | 77 | static serialize(): string { 78 | return JSON.stringify(History.storage); 79 | } 80 | 81 | static deserialize(): void { 82 | this.storage = flatten(orderBy([loginShell.loadHistory(), readHistoryFileData()], history => history.lastModified, "desc").map(history => history.commands)); 83 | } 84 | 85 | private static remove(entry: string): void { 86 | const duplicateIndex = this.storage.indexOf(entry); 87 | if (duplicateIndex !== -1) { 88 | this.storage.splice(duplicateIndex, 1); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/views/3_JobComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Job} from "../shell/Job"; 3 | import {PromptComponent} from "./4_PromptComponent"; 4 | import {BufferComponent} from "./BufferComponent"; 5 | 6 | interface Props { 7 | job: Job; 8 | isFocused: boolean; 9 | } 10 | 11 | interface State { 12 | decorate: boolean; 13 | } 14 | 15 | export class JobComponent extends React.Component { 16 | constructor(props: Props) { 17 | super(props); 18 | 19 | this.state = { 20 | decorate: true, 21 | }; 22 | 23 | // FIXME: find a better design to propagate events. 24 | if (this.props.isFocused) { 25 | window.focusedJob = this; 26 | } 27 | } 28 | 29 | componentDidMount() { 30 | this.props.job 31 | .on("data", () => this.forceUpdate()) 32 | .on("status", () => this.forceUpdate()); 33 | } 34 | 35 | componentDidUpdate() { 36 | // FIXME: find a better design to propagate events. 37 | if (this.props.isFocused) { 38 | window.focusedJob = this; 39 | } 40 | } 41 | 42 | render() { 43 | let buffer: React.ReactElement; 44 | let canBeDecorated = this.props.job.canBeDecorated(); 45 | if (this.props.job.interceptionResult && this.state.decorate) { 46 | buffer = this.props.job.interceptionResult; 47 | } else if (canBeDecorated && this.state.decorate) { 48 | buffer = this.props.job.decorate(); 49 | } else { 50 | buffer = ; 51 | } 52 | 53 | return ( 54 |
    55 | { 60 | if (this.props.job.interceptionResult) { 61 | // Re-execute without intercepting 62 | this.props.job.execute({ allowInterception: false }); 63 | } 64 | // Show non-decorated output 65 | this.setState({decorate: !this.state.decorate}); 66 | }} 67 | isDecorated={this.state.decorate} /> 68 | {buffer} 69 |
    70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Interfaces.ts: -------------------------------------------------------------------------------- 1 | import {Weight, Brightness} from "./Enums"; 2 | import {Stats} from "fs"; 3 | import {ReactElement} from "react"; 4 | import {Job} from "./shell/Job"; 5 | import {Session} from "./shell/Session"; 6 | import {Suggestion} from "./plugins/autocompletion_utils/Common"; 7 | import {ScreenBuffer} from "./ScreenBuffer"; 8 | import {Environment} from "./shell/Environment"; 9 | import {OrderedSet} from "./utils/OrderedSet"; 10 | import {Argument} from "./shell/Parser"; 11 | import {Aliases} from "./shell/Aliases"; 12 | 13 | export type ColorCode = number | number[]; 14 | 15 | export interface Attributes { 16 | readonly inverse: boolean; 17 | readonly color: ColorCode; 18 | readonly backgroundColor: ColorCode; 19 | readonly brightness: Brightness; 20 | readonly weight: Weight; 21 | readonly underline: boolean; 22 | readonly crossedOut: boolean; 23 | readonly blinking: boolean; 24 | readonly cursor: boolean; 25 | } 26 | 27 | export interface PreliminaryAutocompletionContext { 28 | readonly environment: Environment; 29 | readonly historicalPresentDirectoriesStack: OrderedSet; 30 | readonly aliases: Aliases; 31 | } 32 | 33 | export interface AutocompletionContext extends PreliminaryAutocompletionContext { 34 | readonly argument: Argument; 35 | } 36 | 37 | export type AutocompletionProvider = (context: AutocompletionContext) => Promise; 38 | 39 | export interface FileInfo { 40 | name: string; 41 | stat: Stats; 42 | } 43 | 44 | export interface OutputDecorator { 45 | isApplicable: (job: Job) => boolean; 46 | decorate: (job: Job) => ReactElement; 47 | 48 | /** 49 | * @note Setting this property to `true` will result in rendering performance 50 | * decrease because the output will be re-decorated after each data chunk. 51 | */ 52 | shouldDecorateRunningPrograms?: boolean; 53 | } 54 | 55 | export interface EnvironmentObserverPlugin { 56 | presentWorkingDirectoryWillChange: (session: Session, directory: string) => void; 57 | presentWorkingDirectoryDidChange: (session: Session, directory: string) => void; 58 | } 59 | 60 | export interface PreexecPlugin { 61 | (job: Job): Promise; 62 | } 63 | 64 | export interface CommandInterceptorOptions { 65 | command: string[]; 66 | presentWorkingDirectory: string; 67 | } 68 | 69 | export interface CommandInterceptorPlugin { 70 | isApplicable: (options: CommandInterceptorOptions) => boolean; 71 | intercept: (options: CommandInterceptorOptions) => Promise>; 72 | } 73 | 74 | export interface TerminalLikeDevice { 75 | screenBuffer: ScreenBuffer; 76 | dimensions: Dimensions; 77 | write: (input: string | KeyboardEvent) => void; 78 | } 79 | -------------------------------------------------------------------------------- /src/PluginManager.ts: -------------------------------------------------------------------------------- 1 | import {OutputDecorator, EnvironmentObserverPlugin, AutocompletionProvider, PreexecPlugin, CommandInterceptorPlugin} from "./Interfaces"; 2 | import * as Path from "path"; 3 | import {recursiveFilesIn} from "./utils/Common"; 4 | import {environmentVariableSuggestions, anyFilesSuggestionsProvider} from "../src/plugins/autocompletion_utils/Common"; 5 | import combine from "../src/plugins/autocompletion_utils/Combine"; 6 | 7 | const defaultAutocompletionProvider = combine([environmentVariableSuggestions, anyFilesSuggestionsProvider]); 8 | 9 | // FIXME: Technical debt: register all the plugin types via single method. 10 | export class PluginManager { 11 | private static _outputDecorators: OutputDecorator[] = []; 12 | private static _environmentObservers: EnvironmentObserverPlugin[] = []; 13 | private static _autocompletionProviders: Dictionary = {}; 14 | private static _preexecPlugins: PreexecPlugin[] = []; 15 | private static _commandInterceptorPlugins: CommandInterceptorPlugin[] = []; 16 | 17 | static registerOutputDecorator(decorator: OutputDecorator): void { 18 | this._outputDecorators.push(decorator); 19 | } 20 | 21 | static get outputDecorators(): OutputDecorator[] { 22 | return this._outputDecorators; 23 | } 24 | 25 | static registerEnvironmentObserver(plugin: EnvironmentObserverPlugin): void { 26 | this._environmentObservers.push(plugin); 27 | } 28 | 29 | static get environmentObservers(): EnvironmentObserverPlugin[] { 30 | return this._environmentObservers; 31 | } 32 | 33 | static registerAutocompletionProvider(commandName: string, provider: AutocompletionProvider): void { 34 | this._autocompletionProviders[commandName] = provider; 35 | } 36 | 37 | static autocompletionProviderFor(commandName: string): AutocompletionProvider { 38 | return this._autocompletionProviders[commandName] || defaultAutocompletionProvider; 39 | } 40 | 41 | static registerPreexecPlugin(plugin: PreexecPlugin): void { 42 | this._preexecPlugins.push(plugin); 43 | } 44 | 45 | static get preexecPlugins(): PreexecPlugin[] { 46 | return this._preexecPlugins; 47 | } 48 | 49 | static registerCommandInterceptorPlugin(plugin: CommandInterceptorPlugin): void { 50 | this._commandInterceptorPlugins.push(plugin); 51 | } 52 | 53 | static get commandInterceptorPlugins(): CommandInterceptorPlugin[] { 54 | return this._commandInterceptorPlugins; 55 | } 56 | } 57 | 58 | 59 | export async function loadAllPlugins(): Promise { 60 | const pluginsDirectory = Path.join(__dirname, "plugins"); 61 | const filePaths = await recursiveFilesIn(pluginsDirectory); 62 | 63 | filePaths.map(require).map((module: any) => module.default); 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/Process.ts: -------------------------------------------------------------------------------- 1 | import {executeCommandWithShellConfig} from "../PTY"; 2 | import {intersection} from "lodash"; 3 | 4 | // http://linuxcommand.org/man_pages/ps1.html 5 | 6 | export interface User { 7 | ruserid: string; 8 | ruser: string; 9 | euserid: string; 10 | euser: string; 11 | } 12 | 13 | export interface Group { 14 | rgroupid: string; 15 | rgroup: string; 16 | egroupid: string; 17 | egroup: string; 18 | } 19 | 20 | export interface Terminal { 21 | name: string; 22 | ruser: string; 23 | } 24 | 25 | export interface Process { 26 | pid: string; 27 | time: string; 28 | ruser: string; 29 | cmd: string; 30 | } 31 | 32 | export interface Session { 33 | sid: string; 34 | ruser: string; 35 | rgroup: string; 36 | } 37 | 38 | const ignoreUsers: string[] = ["nobody"]; 39 | 40 | const ignoreGroups: string[] = ["nobody"]; 41 | 42 | const resultSet = (result: string[]) => { 43 | const numColumns = result[0].trim().replace(/ +(?= )/g, "").split(" ").length; 44 | return result.splice(1) 45 | .map(i => i.trim().replace(/ +(?= )/g, "").split(" ", numColumns)); 46 | }; 47 | 48 | export const users = 49 | async(): Promise => { 50 | const pInfo: string[][] = 51 | resultSet(await executeCommandWithShellConfig("ps -eo ruid,ruser,euid,euser")); 52 | return pInfo.map(p => {ruserid: p[0], ruser: p[1], euserid: p[2], euser: p[3]}) 53 | .filter(p => intersection(ignoreUsers, [p.ruser, p.euser]).length === 0); 54 | }; 55 | 56 | export const groups = 57 | async(): Promise => { 58 | const pInfo: string[][] = 59 | resultSet(await executeCommandWithShellConfig("ps -eo rgid,rgroup,egid,egroup")); 60 | return pInfo.map(p => {rgroupid: p[0], rgroup: p[1], egroupid: p[2], egroup: p[3]}) 61 | .filter(p => intersection(ignoreGroups, [p.rgroup, p.egroup]).length === 0); 62 | }; 63 | 64 | export const terminals = 65 | async(): Promise => { 66 | const pInfo: string[][] = 67 | resultSet(await executeCommandWithShellConfig("ps -eo tty,ruser")); 68 | return pInfo.filter(p => p[0] !== "?") 69 | .map(p => {name: p[0], ruser: p[1]}); 70 | }; 71 | 72 | export const processes = 73 | async(): Promise => { 74 | const pInfo: string[][] = 75 | resultSet(await executeCommandWithShellConfig("ps -eo pid,time,ruser,cmd")); 76 | return pInfo.map(p => {pid: p[0], time: p[1], ruser: p[2], cmd: p[3]}); 77 | }; 78 | 79 | export const sessions = 80 | async(): Promise => { 81 | const pInfo: string[][] = 82 | resultSet(await executeCommandWithShellConfig("ps -eo sid,ruser,rgroup")); 83 | return pInfo.map(p => {sid: p[0], rgroup: p[1]}); 84 | }; 85 | 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "black-screen", 3 | "productName": "Black Screen", 4 | "description": "A terminal emulator for the 21st century.", 5 | "version": "0.2.66", 6 | "main": "compiled/src/main/Main.js", 7 | "author": "Volodymyr Shatskyi ", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/shockone/black-screen.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/shockone/black-screen/issues" 14 | }, 15 | "engineStrict": true, 16 | "engines": { 17 | "node": ">= 6.0.0" 18 | }, 19 | "keywords": [ 20 | "terminal", 21 | "emulator", 22 | "shell", 23 | "console" 24 | ], 25 | "dependencies": { 26 | "@types/electron": "1.3.22", 27 | "@types/lodash": "4.14.36", 28 | "@types/node": "6.0.41", 29 | "@types/pty.js": "0.2.31", 30 | "@types/react": "0.14.37", 31 | "child-process-promise": "2.1.3", 32 | "dirStat": "0.0.2", 33 | "font-awesome": "4.6.3", 34 | "fs-extra": "0.30.0", 35 | "fuzzaldrin": "2.1.0", 36 | "immutable": "3.8.1", 37 | "lodash": "4.16.4", 38 | "mode-to-permissions": "0.0.2", 39 | "node-ansiparser": "2.2.0", 40 | "pty.js": "shockone/pty.js", 41 | "react": "15.3.2", 42 | "react-dom": "15.3.2", 43 | "tinycolor2": "1.4.1", 44 | "uuid": "2.0.3" 45 | }, 46 | "devDependencies": { 47 | "@types/chai": "3.4.34", 48 | "@types/mocha": "2.2.32", 49 | "chai": "3.5.0", 50 | "devtron": "1.3.0", 51 | "electron-builder": "6.3.4", 52 | "electron-prebuilt": "1.2.8", 53 | "electron-rebuild": "1.2.0", 54 | "mocha": "3.1.2", 55 | "npm-check-updates": "2.8.0", 56 | "spectron": "3.4.0", 57 | "ts-node": "1.6.0", 58 | "tslint": "3.15.1", 59 | "typescript": "2.0.3" 60 | }, 61 | "scripts": { 62 | "preinstall": "npm prune", 63 | "postinstall": "electron-rebuild", 64 | "pack": "npm run compile && build", 65 | "release": "build --publish always --prerelease", 66 | "electron": "electron .", 67 | "prestart": "npm install && npm run compile", 68 | "start": "bash housekeeping/start.sh", 69 | "test": "npm run lint && npm run unit-tests && npm run integration-tests && build --publish never", 70 | "unit-tests": "ELECTRON_RUN_AS_NODE=1 electron $(which mocha) --require ts-node/register $(find test -name '*_spec.ts')", 71 | "integration-tests": "npm run compile && mocha", 72 | "update-dependencies": "ncu -u", 73 | "lint": "tslint `find src -name '*.ts*'`", 74 | "cleanup": "rm -rf compiled/src", 75 | "copy-html": "mkdir -p compiled/src/views && cp src/views/index.html compiled/src/views", 76 | "compile": "npm run cleanup && npm run tsc && npm run copy-html", 77 | "tsc": "tsc" 78 | }, 79 | "license": "MIT", 80 | "directories": { 81 | "app": "." 82 | }, 83 | "build": { 84 | "appId": "com.github.shockone.black-screen", 85 | "app-category-type": "public.app-category.developer-tools" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": [ 4 | true, 5 | "parameters", 6 | "arguments", 7 | "statements" 8 | ], 9 | "ban": false, 10 | "class-name": true, 11 | "comment-format": [ 12 | true, 13 | "check-space" 14 | ], 15 | "eofline": true, 16 | "forin": true, 17 | "indent": [ 18 | true, 19 | "spaces" 20 | ], 21 | "interface-name": false, 22 | "jsdoc-format": true, 23 | "label-position": true, 24 | "label-undefined": true, 25 | "max-line-length": [ 26 | true, 27 | 200 28 | ], 29 | "member-access": false, 30 | "member-ordering": [ 31 | true, 32 | "public-before-private", 33 | "static-before-instance", 34 | "variables-before-functions" 35 | ], 36 | "no-any": false, 37 | "no-arg": true, 38 | "no-bitwise": true, 39 | "no-conditional-assignment": true, 40 | "no-consecutive-blank-lines": false, 41 | "no-console": [ 42 | true, 43 | "debug", 44 | "info", 45 | "time", 46 | "timeEnd", 47 | "trace" 48 | ], 49 | "no-construct": true, 50 | "no-constructor-vars": false, 51 | "no-debugger": true, 52 | "no-duplicate-key": true, 53 | "no-duplicate-variable": true, 54 | "no-empty": true, 55 | "no-eval": true, 56 | "no-inferrable-types": false, 57 | "no-internal-module": true, 58 | "no-null-keyword": true, 59 | "no-require-imports": false, 60 | "no-shadowed-variable": true, 61 | "no-string-literal": true, 62 | "no-switch-case-fall-through": true, 63 | "no-trailing-whitespace": true, 64 | "no-unreachable": true, 65 | "no-unused-expression": true, 66 | "no-unused-variable": true, 67 | "no-use-before-declare": true, 68 | "no-var-keyword": true, 69 | "no-var-requires": false, 70 | "object-literal-sort-keys": false, 71 | "one-line": [ 72 | true, 73 | "check-open-brace", 74 | "check-catch", 75 | "check-else", 76 | "check-whitespace" 77 | ], 78 | "quotemark": [ 79 | true, 80 | "double", 81 | "avoid-escape" 82 | ], 83 | "radix": true, 84 | "semicolon": true, 85 | "switch-default": true, 86 | "trailing-comma": [ 87 | true, 88 | { 89 | "multiline": "always", 90 | "singleline": "never" 91 | } 92 | ], 93 | "triple-equals": [ 94 | true, 95 | "allow-null-check" 96 | ], 97 | "typedef-whitespace": [ 98 | true, 99 | { 100 | "call-signature": "nospace", 101 | "index-signature": "nospace", 102 | "parameter": "nospace", 103 | "property-declaration": "nospace", 104 | "variable-declaration": "nospace" 105 | } 106 | ], 107 | "variable-name": false, 108 | "whitespace": [ 109 | true, 110 | "check-branch", 111 | "check-decl", 112 | "check-operator", 113 | "check-separator", 114 | "check-type" 115 | ] 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/plugins/Ls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {PluginManager} from "../PluginManager"; 3 | import {join, isAbsolute} from "path"; 4 | import {dirStat} from "dirStat"; 5 | import * as e from "electron"; 6 | import {CSSObject} from "../views/css/definitions"; 7 | import {isEqual} from "lodash"; 8 | import {colors} from "../views/css/colors"; 9 | 10 | type Props = { 11 | files: any[], 12 | } 13 | 14 | type State = { 15 | itemWidth: number | undefined, 16 | } 17 | 18 | const renderFile = (file: any, itemWidth = 0, key: number) => { 19 | const style: CSSObject = {display: "inline-block"}; 20 | if (itemWidth) { 21 | // TODO: respect LSCOLORS env var 22 | style.width = `${itemWidth}px`; 23 | style.cursor = "pointer"; 24 | style.margin = "2px 4px"; 25 | } 26 | return e.shell.openExternal(`file://${file.filePath}`)} 30 | className="underlineOnHover"> 31 | {file.fileName} 32 | {file.isDirectory() ? "/" : ""} 33 | ; 34 | }; 35 | 36 | class LSComponent extends React.Component { 37 | constructor(props: Props) { 38 | super(props); 39 | 40 | this.state = { 41 | itemWidth: undefined, 42 | }; 43 | } 44 | 45 | shouldComponentUpdate(nextProps: Props, nextState: State) { 46 | return !(isEqual(this.props, nextProps) && isEqual(this.state, nextState)); 47 | } 48 | 49 | render() { 50 | return
    { 53 | if (element) { 54 | const children = Array.prototype.slice.call(element.children); 55 | this.setState({ 56 | itemWidth: Math.max(...children.map((child: any) => child.offsetWidth)), 57 | } as State); 58 | } 59 | }} 60 | >{this.props.files.map((file: any, index: number) => renderFile(file, this.state.itemWidth, index))}
    ; 61 | } 62 | } 63 | 64 | PluginManager.registerCommandInterceptorPlugin({ 65 | intercept: async({ 66 | command, 67 | presentWorkingDirectory, 68 | }): Promise> => { 69 | const inputDir = command[1] || "."; 70 | const dir = isAbsolute(inputDir) ? inputDir : join(presentWorkingDirectory, inputDir); 71 | const files: any[] = await new Promise((resolve, reject) => { 72 | dirStat(dir, (err, results) => { 73 | if (err) { 74 | reject(err); 75 | } else { 76 | resolve(results); 77 | } 78 | }); 79 | }); 80 | 81 | return ; 82 | }, 83 | 84 | isApplicable: ({ command }): boolean => { 85 | const hasFlags = command.length === 2 && command[1].startsWith("-"); 86 | return [1, 2].includes(command.length) && !hasFlags && command[0] === "ls"; 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /src/Enums.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @link https://css-tricks.com/snippets/javascript/javascript-keycodes/ 3 | * @link https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html 4 | */ 5 | export enum KeyCode { 6 | Bell = 7, 7 | Backspace = 8, 8 | Tab = 9, 9 | NewLine = 10, 10 | CarriageReturn = 13, 11 | Shift = 16, 12 | Ctrl = 17, 13 | Alt = 18, 14 | Escape = 27, 15 | Space = 32, 16 | Left = 37, 17 | Up = 38, 18 | Right = 39, 19 | Down = 40, 20 | One = 49, 21 | Nine = 57, 22 | A = 65, 23 | B = 66, 24 | C = 67, 25 | D = 68, 26 | E = 69, 27 | F = 70, 28 | G = 71, 29 | H = 72, 30 | I = 73, 31 | J = 74, 32 | K = 75, 33 | L = 76, 34 | M = 77, 35 | N = 78, 36 | O = 79, 37 | P = 80, 38 | Q = 81, 39 | R = 82, 40 | S = 83, 41 | T = 84, 42 | U = 85, 43 | V = 86, 44 | W = 87, 45 | X = 88, 46 | Y = 89, 47 | Z = 90, 48 | Delete = 127, 49 | Underscore = 189, 50 | Period = 190, 51 | VerticalBar = 220, 52 | } 53 | 54 | export enum Color { 55 | Black, 56 | Red, 57 | Green, 58 | Yellow, 59 | Blue, 60 | Magenta, 61 | Cyan, 62 | White, 63 | } 64 | 65 | export enum Status { 66 | NotStarted, 67 | InProgress, 68 | Failure, 69 | Interrupted, 70 | Success, 71 | } 72 | 73 | export enum ScreenBufferType { 74 | Standard, 75 | Alternate 76 | } 77 | 78 | export enum Weight { 79 | Normal, 80 | Bold, 81 | Faint, 82 | } 83 | 84 | export enum Brightness { 85 | Normal, 86 | Bright, 87 | } 88 | 89 | export enum LogLevel { 90 | Info = "info", 91 | Debug = "debug", 92 | Log = "log", 93 | Error = "error", 94 | } 95 | 96 | export enum SplitDirection { 97 | Vertical, 98 | Horizontal, 99 | } 100 | 101 | export enum KeyboardAction { 102 | // CLI commands 103 | cliRunCommand, 104 | cliInterrupt, 105 | cliClearJobs, 106 | cliDeleteWord, 107 | cliClearText, 108 | cliAppendLastArgumentOfPreviousCommand, 109 | cliHistoryPrevious, 110 | cliHistoryNext, 111 | // autocomplete commands 112 | autocompleteInsertCompletion, 113 | autocompletePreviousSuggestion, 114 | autocompleteNextSuggestion, 115 | // tab commands 116 | tabNew, 117 | tabFocus, 118 | tabPrevious, 119 | tabNext, 120 | tabClose, 121 | // pane commands 122 | panePrevious, 123 | paneNext, 124 | paneClose, 125 | // edit/clipboard commands 126 | clipboardCopy, 127 | clipboardCut, 128 | clipboardPaste, 129 | editUndo, 130 | editRedo, 131 | editSelectAll, 132 | editFind, 133 | editFindClose, 134 | // window commands 135 | windowSplitHorizontally, 136 | windowSplitVertically, 137 | // view commands 138 | viewReload, 139 | viewToggleFullScreen, 140 | // black screen commands 141 | blackScreenHide, 142 | blackScreenQuit, 143 | blackScreenHideOthers, 144 | // developer 145 | developerToggleTools, 146 | developerToggleDebugMode, 147 | } 148 | -------------------------------------------------------------------------------- /src/PTY.ts: -------------------------------------------------------------------------------- 1 | import * as ChildProcess from "child_process"; 2 | import * as OS from "os"; 3 | import * as _ from "lodash"; 4 | import * as pty from "pty.js"; 5 | import {loginShell} from "./utils/Shell"; 6 | import {debug} from "./utils/Common"; 7 | 8 | export class PTY { 9 | private terminal: pty.Terminal; 10 | 11 | // TODO: write proper signatures. 12 | // TODO: use generators. 13 | // TODO: terminate. https://github.com/atom/atom/blob/v1.0.15/src/task.coffee#L151 14 | constructor(words: EscapedShellWord[], env: ProcessEnvironment, dimensions: Dimensions, dataHandler: (d: string) => void, exitHandler: (c: number) => void) { 15 | const shellArguments = [...loginShell.noConfigSwitches, "-i", "-c", words.join(" ")]; 16 | 17 | debug(`PTY: ${loginShell.executableName} ${JSON.stringify(shellArguments)}`); 18 | 19 | this.terminal = pty.fork(loginShell.executableName, shellArguments, { 20 | cols: dimensions.columns, 21 | rows: dimensions.rows, 22 | cwd: env.PWD, 23 | env: env, 24 | }); 25 | 26 | this.terminal.on("data", (data: string) => dataHandler(data)); 27 | this.terminal.on("exit", (code: number) => { 28 | exitHandler(code); 29 | }); 30 | } 31 | 32 | write(data: string): void { 33 | this.terminal.write(data); 34 | } 35 | 36 | set dimensions(dimensions: Dimensions) { 37 | this.terminal.resize(dimensions.columns, dimensions.rows); 38 | } 39 | 40 | kill(signal: string): void { 41 | /** 42 | * The if branch is necessary because pty.js doesn't handle SIGINT correctly. 43 | * You can test whether it works by executing 44 | * ruby -e "loop { puts 'yes'; sleep 1 }" 45 | * and trying to kill it with SIGINT. 46 | * 47 | * {@link https://github.com/chjj/pty.js/issues/58} 48 | */ 49 | if (signal === "SIGINT") { 50 | this.terminal.kill("SIGTERM"); 51 | } else { 52 | this.terminal.kill(signal); 53 | } 54 | } 55 | } 56 | 57 | export function executeCommand( 58 | command: string, 59 | args: string[] = [], 60 | directory: string, 61 | execOptions?: any 62 | ): Promise { 63 | return new Promise((resolve, reject) => { 64 | const options = Object.assign( 65 | {}, 66 | execOptions, 67 | { 68 | env: _.extend({PWD: directory}, process.env), 69 | cwd: directory, 70 | } 71 | ); 72 | 73 | ChildProcess.exec(`${command} ${args.join(" ")}`, options, (error, output) => { 74 | if (error) { 75 | reject(error); 76 | } else { 77 | resolve(output); 78 | } 79 | }); 80 | }); 81 | } 82 | 83 | export async function linedOutputOf(command: string, args: string[], directory: string): Promise { 84 | let output = await executeCommand(command, args, directory); 85 | return output.split("\\" + OS.EOL).join(" ").split(OS.EOL).filter(path => path.length > 0); 86 | } 87 | 88 | export async function executeCommandWithShellConfig(command: string): Promise { 89 | const sourceCommands = (await loginShell.existingConfigFiles()).map(fileName => `source ${fileName} &> /dev/null`); 90 | 91 | return await linedOutputOf(loginShell.executableName, ["-c", `'${[...sourceCommands, command].join("; ")}'`], process.env.HOME); 92 | } 93 | -------------------------------------------------------------------------------- /src/plugins/RVM.ts: -------------------------------------------------------------------------------- 1 | import {Session} from "../shell/Session"; 2 | import {PluginManager} from "../PluginManager"; 3 | import * as Path from "path"; 4 | import * as fs from "fs"; 5 | import {homeDirectory, exists, readFile} from "../utils/Common"; 6 | 7 | const rvmDirectory = Path.join(homeDirectory, ".rvm"); 8 | const rubyVersionFileName = ".ruby-version"; 9 | const gemSetNameFileName = ".ruby-gemset"; 10 | 11 | async function getRubyVersion(directory: string): Promise { 12 | if (await exists(Path.join(directory, rubyVersionFileName))) { 13 | return (await readFile(Path.join(directory, rubyVersionFileName))).trim(); 14 | } else { 15 | return new Promise((resolve, reject) => { 16 | fs.realpath(Path.join(rvmDirectory, "rubies", "default"), (err, resolvedPath) => { 17 | if (err) { 18 | reject(err); 19 | } else { 20 | resolve(resolvedPath.split("-")[1]); 21 | } 22 | }); 23 | }); 24 | } 25 | } 26 | 27 | async function getGemSetName(directory: string): Promise { 28 | const gemSetNameFilePath = Path.join(directory, gemSetNameFileName); 29 | 30 | if (await exists(gemSetNameFilePath)) { 31 | return (await readFile(gemSetNameFilePath)).trim(); 32 | } else { 33 | return "global"; 34 | } 35 | } 36 | 37 | /** 38 | * Contract: the non-global path should be first. 39 | */ 40 | function getGemSetPaths(rubyVersion: string, gemSetName: string): string[] { 41 | const suffixes = gemSetName === "global" ? ["", "@global"] : [`@${gemSetName}`, "@global"]; 42 | return suffixes.map(suffix => Path.join(rvmDirectory, "gems", `ruby-${rubyVersion}${suffix}`)); 43 | } 44 | 45 | function binPaths(rubyVersion: string, gemSetName: string): string[] { 46 | return [ 47 | Path.join(rvmDirectory, "bin"), 48 | Path.join(rvmDirectory, "rubies", `ruby-${rubyVersion}`, "bin"), 49 | ...getGemSetPaths(rubyVersion, gemSetName).map(path => Path.join(path, "bin")), 50 | ]; 51 | } 52 | 53 | async function withRvmData(directory: string, callback: (binPaths: string[], gemPaths: string[]) => void) { 54 | try { 55 | const rubyVersion = await getRubyVersion(directory); 56 | const gemSetName = await getGemSetName(directory); 57 | const gemPaths = getGemSetPaths(rubyVersion, gemSetName); 58 | 59 | callback(binPaths(rubyVersion, gemSetName), gemPaths); 60 | } catch (e) { 61 | if (e.code === "ENOENT") { 62 | // No RVM installed. Ignore exception. 63 | } else { 64 | throw e; 65 | } 66 | } 67 | } 68 | 69 | PluginManager.registerEnvironmentObserver({ 70 | presentWorkingDirectoryWillChange: () => async(session: Session, directory: string) => { 71 | withRvmData(directory, binPaths => { 72 | binPaths.forEach(path => session.environment.path.remove(path)); 73 | 74 | session.environment.setMany({ 75 | GEM_PATH: "", 76 | GEM_HOME: "", 77 | }); 78 | }); 79 | }, 80 | presentWorkingDirectoryDidChange: async(session: Session, directory: string) => { 81 | withRvmData(directory, (binPaths, gemPaths) => { 82 | binPaths.forEach(path => session.environment.path.prepend(path)); 83 | 84 | session.environment.setMany({ 85 | GEM_PATH: gemPaths.join(Path.delimiter), 86 | GEM_HOME: gemPaths[0], 87 | }); 88 | }); 89 | }, 90 | }); 91 | -------------------------------------------------------------------------------- /src/utils/ManPageParsingUtils.ts: -------------------------------------------------------------------------------- 1 | import {Suggestion, styles} from "../plugins/autocompletion_utils/Common"; 2 | 3 | export const combineManPageLines = (lines: string[]) => lines 4 | .map(line => line.trim()) 5 | .reduce( 6 | (memo, next) => { 7 | if (next.endsWith("-")) { 8 | return memo.concat(next.slice(0, -1)); 9 | } else { 10 | return memo.concat(next, " "); 11 | } 12 | }, 13 | "" 14 | ) 15 | .trim(); 16 | 17 | // Man pages have backspace literals, so "apply" them, and remove excess whitespace. 18 | export const preprocessManPage = (contents: string) => contents.replace(/.\x08/g, "").trim(); 19 | 20 | export const extractManPageSections = (contents: string) => { 21 | const lines = contents.split("\n"); 22 | 23 | let currentSection = ""; 24 | let sections: { [section: string]: string[] } = {}; 25 | lines.forEach((line: string) => { 26 | if (line.startsWith(" ") || line === "") { 27 | sections[currentSection].push(line); 28 | } else { 29 | currentSection = line; 30 | if (!sections[currentSection]) { 31 | sections[currentSection] = []; 32 | } 33 | } 34 | }); 35 | 36 | return sections; 37 | }; 38 | 39 | const isShortFlagWithoutArgument = (manPageLine: string) => /^ *-(\w) *(.*)$/.test(manPageLine); 40 | 41 | export const extractManPageSectionParagraphs = (contents: string[]) => { 42 | let filteredContents: string[] | undefined = undefined; 43 | const firstFlag = contents.find(isShortFlagWithoutArgument); 44 | if (firstFlag) { 45 | const flagMatch = firstFlag.match(/^( *-\w *)/); 46 | const flagIndentation = " ".repeat(((flagMatch || [""])[0]).length); 47 | filteredContents = contents.filter((line, index, array) => { 48 | if (index === 0 || index === array.length - 1) { 49 | return true; 50 | } 51 | if ( 52 | line === "" && 53 | array[index - 1].startsWith(flagIndentation) && 54 | array[index + 1].startsWith(flagIndentation) 55 | ) { 56 | return false; 57 | } 58 | return true; 59 | }); 60 | } 61 | 62 | return (filteredContents ? filteredContents : contents) 63 | .reduce( 64 | (memo, next) => { 65 | if (next === "") { 66 | memo.push([]); 67 | } else { 68 | memo[memo.length - 1].push(next); 69 | } 70 | return memo; 71 | }, 72 | [[]] 73 | ) 74 | .filter(lines => lines.length > 0); 75 | }; 76 | 77 | export const suggestionFromFlagParagraph = (paragraph: string[]): Suggestion | undefined => { 78 | const shortFlagWithArgument = paragraph[0].match(/^ *-(\w) (\w*)$/); 79 | const shortFlagWithoutArgument = paragraph[0].match(/^ *-(\w) *(.*)$/); 80 | if (shortFlagWithArgument) { 81 | const flag = shortFlagWithArgument[1]; 82 | const argument = shortFlagWithArgument[2]; 83 | const description = combineManPageLines(paragraph.slice(1)); 84 | 85 | return new Suggestion({ 86 | value: `-${flag}`, 87 | style: styles.option, 88 | description, 89 | displayValue: `-${flag} ${argument}`, 90 | space: true, 91 | }); 92 | } else if (shortFlagWithoutArgument) { 93 | const flag = shortFlagWithoutArgument[1]; 94 | const description = combineManPageLines([shortFlagWithoutArgument[2], ...paragraph.slice(1)]); 95 | 96 | return new Suggestion({ 97 | value: `-${flag}`, 98 | style: styles.option, 99 | description, 100 | }); 101 | } else { 102 | return undefined; 103 | } 104 | }; 105 | -------------------------------------------------------------------------------- /src/shell/Environment.ts: -------------------------------------------------------------------------------- 1 | import {delimiter} from "path"; 2 | import {executeCommandWithShellConfig} from "../PTY"; 3 | import {clone} from "lodash"; 4 | import {homeDirectory} from "../utils/Common"; 5 | import * as Path from "path"; 6 | import {AbstractOrderedSet} from "../utils/OrderedSet"; 7 | 8 | const ignoredEnvironmentVariables = [ 9 | "NODE_ENV", 10 | ]; 11 | 12 | const isIgnoredEnvironmentVariable = (varName: string) => { 13 | if (ignoredEnvironmentVariables.includes(varName)) { 14 | return true; 15 | } else { 16 | return false; 17 | } 18 | }; 19 | 20 | export const preprocessEnv = (lines: string[]) => { 21 | // Bash functions in the env have newlines in them, which need to be removed 22 | const joinedFunctionLines: string[] = []; 23 | for (let i = 0; i < lines.length; i++) { 24 | if (/^BASH_FUNC\w+%%/.test(lines[i])) { 25 | const finalLineOfFunction = lines.indexOf("}", i); 26 | joinedFunctionLines.push(lines.slice(i, finalLineOfFunction + 1).join("\n")); 27 | i = finalLineOfFunction; 28 | } else { 29 | joinedFunctionLines.push(lines[i]); 30 | } 31 | } 32 | return joinedFunctionLines; 33 | }; 34 | 35 | export const processEnvironment: Dictionary = {}; 36 | export async function loadEnvironment(): Promise { 37 | const lines = preprocessEnv(await executeCommandWithShellConfig("env")); 38 | 39 | lines.forEach(line => { 40 | const [key, ...valueComponents] = line.trim().split("="); 41 | const value = valueComponents.join("="); 42 | 43 | if (!isIgnoredEnvironmentVariable(key)) { 44 | processEnvironment[key] = value; 45 | } 46 | }); 47 | } 48 | 49 | export class Environment { 50 | private storage: Dictionary; 51 | 52 | constructor(environment: Dictionary) { 53 | this.storage = clone(environment); 54 | } 55 | 56 | set(key: string, value: string): void { 57 | this.storage[key] = value; 58 | } 59 | 60 | setMany(pairs: Dictionary): void { 61 | for (const key of Object.keys(pairs)) { 62 | this.set(key, pairs[key]); 63 | } 64 | } 65 | 66 | toObject(): ProcessEnvironment { 67 | return this.storage; 68 | } 69 | 70 | map(mapper: (key: string, value: string) => R): Array { 71 | const result: Array = []; 72 | 73 | for (const key of Object.keys(this.storage)) { 74 | result.push(mapper(key, this.storage[key])); 75 | } 76 | 77 | return result; 78 | } 79 | 80 | get(key: string): string { 81 | return this.storage[key]; 82 | } 83 | 84 | has(key: string): boolean { 85 | return key in this.storage; 86 | } 87 | 88 | get path(): EnvironmentPath { 89 | return new EnvironmentPath(this); 90 | } 91 | 92 | get cdpath(): string[] { 93 | return (this.get("CDPATH") || "").split(delimiter).map(path => path || this.pwd); 94 | } 95 | 96 | get pwd(): string { 97 | if (!this.get("PWD")) { 98 | this.pwd = homeDirectory; 99 | } 100 | 101 | return this.get("PWD"); 102 | } 103 | 104 | set pwd(value: string) { 105 | this.set("PWD", value); 106 | } 107 | } 108 | 109 | export class EnvironmentPath extends AbstractOrderedSet { 110 | constructor(private environment: Environment) { 111 | super( 112 | () => { 113 | const path = this.environment.get("PATH"); 114 | 115 | if (path) { 116 | return path.split(Path.delimiter); 117 | } else { 118 | return []; 119 | } 120 | }, 121 | updatedPaths => this.environment.set("PATH", updatedPaths.join(Path.delimiter)) 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/views/BufferComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {ScreenBuffer} from "../ScreenBuffer"; 3 | import {Char} from "../Char"; 4 | import {groupWhen} from "../utils/Common"; 5 | import {List} from "immutable"; 6 | import * as css from "./css/main"; 7 | import {fontAwesome} from "./css/FontAwesome"; 8 | import {Job} from "../shell/Job"; 9 | import {Status} from "../Enums"; 10 | 11 | const CharGroupComponent = ({job, group}: {job: Job, group: Char[]}) => 12 | {group.map(char => char.toString()).join("")}; 13 | 14 | interface CutProps { 15 | job: Job; 16 | clickHandler: React.EventHandler>; 17 | } 18 | 19 | interface CutState { 20 | isHovered: boolean; 21 | } 22 | 23 | class Cut extends React.Component { 24 | constructor() { 25 | super(); 26 | this.state = {isHovered: false}; 27 | } 28 | 29 | render() { 30 | return ( 31 |
    this.setState({isHovered: true})} 34 | onMouseLeave={() => this.setState({isHovered: false})}> 35 | 36 | {`Show all ${this.props.job.screenBuffer.size} rows.`} 37 |
    38 | ); 39 | } 40 | } 41 | interface RowProps { 42 | row: List; 43 | status: Status; 44 | job: Job; 45 | } 46 | 47 | const charGrouper = (a: Char, b: Char) => a.attributes === b.attributes; 48 | 49 | class RowComponent extends React.Component { 50 | shouldComponentUpdate(nextProps: RowProps) { 51 | return this.props.row !== nextProps.row || this.props.status !== nextProps.status; 52 | } 53 | 54 | render() { 55 | let rowWithoutHoles = this.props.row.toArray().map(char => char || Char.empty); 56 | let charGroups = groupWhen(charGrouper, rowWithoutHoles).map((charGroup: Char[], index: number) => 57 | 58 | ); 59 | 60 | return
    div && div.scrollIntoViewIfNeeded()}>{charGroups}
    ; 62 | } 63 | } 64 | 65 | interface Props { 66 | job: Job; 67 | } 68 | 69 | interface State { 70 | expandButtonPressed: boolean; 71 | } 72 | 73 | export class BufferComponent extends React.Component { 74 | constructor(props: Props) { 75 | super(props); 76 | this.state = { expandButtonPressed: false }; 77 | } 78 | 79 | render() { 80 | return ( 81 |
    83 | {this.shouldCutOutput ? this.setState({ expandButtonPressed: true })}/> : undefined} 84 | {this.renderableRows.map((row, index) => 85 | ()} 88 | status={this.props.job.status} 89 | job={this.props.job}/> 90 | )} 91 |
    92 | ); 93 | } 94 | 95 | private get shouldCutOutput(): boolean { 96 | return this.props.job.screenBuffer.size > ScreenBuffer.hugeOutputThreshold && !this.state.expandButtonPressed; 97 | }; 98 | 99 | private get renderableRows(): List> { 100 | return this.shouldCutOutput ? this.props.job.screenBuffer.toCutRenderable(this.props.job.status) : this.props.job.screenBuffer.toRenderable(this.props.job.status); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/Shell.ts: -------------------------------------------------------------------------------- 1 | import {basename} from "path"; 2 | import {readFileSync, statSync} from "fs"; 3 | import * as Path from "path"; 4 | import {EOL} from "os"; 5 | import {resolveFile, exists, filterAsync, homeDirectory} from "./Common"; 6 | import * as _ from "lodash"; 7 | 8 | abstract class Shell { 9 | abstract get executableName(): string; 10 | abstract get configFiles(): string[]; 11 | abstract get noConfigSwitches(): string[]; 12 | abstract get preCommandModifiers(): string[]; 13 | abstract get historyFileName(): string; 14 | 15 | async existingConfigFiles(): Promise { 16 | const resolvedConfigFiles = this.configFiles.map(fileName => resolveFile(process.env.HOME, fileName)); 17 | return await filterAsync(resolvedConfigFiles, exists); 18 | } 19 | 20 | loadHistory(): { lastModified: Date, commands: string[] } { 21 | const path = process.env.HISTFILE || Path.join(homeDirectory, this.historyFileName); 22 | try { 23 | return { 24 | lastModified: statSync(path).mtime, 25 | commands: readFileSync(path).toString().trim().split(EOL).reverse().map(line => _.last(line.split(";"))), 26 | }; 27 | } catch (error) { 28 | return { 29 | lastModified: new Date(0), 30 | commands: [], 31 | }; 32 | } 33 | } 34 | } 35 | 36 | class Bash extends Shell { 37 | get executableName() { 38 | return "bash"; 39 | } 40 | 41 | get configFiles() { 42 | return [ 43 | // List drawn from GNU bash 4.3 man page INVOCATION section. 44 | // ~/.bashrc is only supposed to be used for non-login shells 45 | // and ~/.bash_profile is only supposed to be used for login 46 | // shells, but load both anyway because that's what people expect. 47 | "/etc/profile", 48 | "~/.bash_profile", 49 | "~/.bash_login", 50 | "~/.profile", 51 | "~/.bashrc", 52 | ]; 53 | } 54 | 55 | get noConfigSwitches() { 56 | return ["--noprofile", "--norc"]; 57 | } 58 | 59 | get preCommandModifiers(): string[] { 60 | return []; 61 | } 62 | 63 | get historyFileName(): string { 64 | return ".bash_history"; 65 | } 66 | } 67 | 68 | class ZSH extends Shell { 69 | get executableName() { 70 | return "zsh"; 71 | } 72 | 73 | get configFiles() { 74 | return [ 75 | // List drawn from zhs 5.0.8 man page STARTUP/SHUTDOWN FILES section. 76 | "/etc/zshenv", 77 | Path.join(process.env.ZDOTDIR || "~", ".zshenv"), 78 | "/etc/zprofile", 79 | Path.join(process.env.ZDOTDIR || "~", ".zprofile"), 80 | "/etc/zshrc", 81 | Path.join(process.env.ZDOTDIR || "~", ".zshrc"), 82 | "/etc/zlogin", 83 | Path.join(process.env.ZDOTDIR || "~", ".zlogin"), 84 | // This one is not listed in the man pages, but some zsh installations do use it. 85 | "~/.zsh_profile", 86 | ]; 87 | } 88 | 89 | get noConfigSwitches() { 90 | return ["--no-globalrcs", "--no-rcs"]; 91 | } 92 | 93 | get preCommandModifiers() { 94 | return [ 95 | "-", 96 | "noglob", 97 | "nocorrect", 98 | "exec", 99 | "command", 100 | ]; 101 | } 102 | 103 | get historyFileName(): string { 104 | return ".zsh_history"; 105 | } 106 | } 107 | 108 | const supportedShells: Dictionary = { 109 | bash: new Bash(), 110 | zsh: new ZSH() , 111 | }; 112 | 113 | const shell = () => { 114 | const shellName = basename(process.env.SHELL); 115 | if (shellName in supportedShells) { 116 | return process.env.SHELL; 117 | } else { 118 | console.error(`${shellName} is not supported; defaulting to /bin/bash`); 119 | return "/bin/bash"; 120 | } 121 | }; 122 | 123 | export const loginShell: Shell = supportedShells[basename(shell())]; 124 | -------------------------------------------------------------------------------- /src/views/TabComponent.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | import * as React from "react"; 3 | import {Session} from "../shell/Session"; 4 | import {ApplicationComponent} from "./1_ApplicationComponent"; 5 | import * as css from "./css/main"; 6 | import {fontAwesome} from "./css/FontAwesome"; 7 | import {SplitDirection} from "../Enums"; 8 | import {Pane, RowList, PaneList} from "../utils/PaneTree"; 9 | 10 | export interface TabProps { 11 | isFocused: boolean; 12 | activate: () => void; 13 | position: number; 14 | closeHandler: React.EventHandler>; 15 | } 16 | 17 | export enum TabHoverState { 18 | Nothing, 19 | Tab, 20 | Close 21 | } 22 | 23 | interface TabState { 24 | hover: TabHoverState; 25 | } 26 | 27 | export class TabComponent extends React.Component { 28 | constructor() { 29 | super(); 30 | this.state = {hover: TabHoverState.Nothing}; 31 | } 32 | 33 | render() { 34 | return
  • this.setState({hover: TabHoverState.Tab})} 37 | onMouseLeave={() => this.setState({hover: TabHoverState.Nothing})}> 38 | this.setState({hover: TabHoverState.Close})} 42 | onMouseLeave={() => this.setState({hover: TabHoverState.Tab})}/> 43 | 44 | {this.props.position} 45 |
  • ; 46 | } 47 | } 48 | 49 | export class Tab { 50 | readonly panes: PaneList; 51 | private _focusedPane: Pane; 52 | 53 | constructor(private application: ApplicationComponent) { 54 | const pane = new Pane(new Session(this.application, this.contentDimensions)); 55 | 56 | this.panes = new RowList([pane]); 57 | this._focusedPane = pane; 58 | } 59 | 60 | addPane(direction: SplitDirection): void { 61 | const session = new Session(this.application, this.contentDimensions); 62 | const pane = new Pane(session); 63 | 64 | this.panes.add(pane, this.focusedPane, direction); 65 | 66 | this._focusedPane = pane; 67 | } 68 | 69 | closeFocusedPane(): void { 70 | this.focusedPane.session.jobs.forEach(job => { 71 | job.removeAllListeners(); 72 | job.interrupt(); 73 | }); 74 | this.focusedPane.session.removeAllListeners(); 75 | 76 | const focused = this.focusedPane; 77 | this._focusedPane = this.panes.previous(focused); 78 | this.panes.remove(focused); 79 | } 80 | 81 | closeAllPanes(): void { 82 | while (this.panes.size) { 83 | this.closeFocusedPane(); 84 | } 85 | } 86 | 87 | get focusedPane(): Pane { 88 | return this._focusedPane; 89 | } 90 | 91 | activatePane(pane: Pane): void { 92 | this._focusedPane = pane; 93 | } 94 | 95 | activatePreviousPane(): void { 96 | this._focusedPane = this.panes.previous(this.focusedPane); 97 | } 98 | 99 | activateNextPane(): void { 100 | this._focusedPane = this.panes.next(this.focusedPane); 101 | } 102 | 103 | updateAllPanesDimensions(): void { 104 | this.panes.forEach(pane => pane.session.dimensions = this.contentDimensions); 105 | } 106 | 107 | private get contentDimensions(): Dimensions { 108 | return { 109 | columns: Math.floor(this.contentSize.width / css.letterWidth), 110 | rows: Math.floor(this.contentSize.height / css.rowHeight), 111 | }; 112 | } 113 | 114 | private get contentSize(): Size { 115 | return { 116 | width: window.innerWidth, 117 | height: window.innerHeight - css.titleBarHeight - css.infoPanelHeight - css.outputPadding, 118 | }; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/utils/ManPages_spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import {expect} from "chai"; 3 | 4 | import { 5 | combineManPageLines, 6 | preprocessManPage, 7 | extractManPageSections, 8 | extractManPageSectionParagraphs, 9 | suggestionFromFlagParagraph, 10 | } from "../../src/utils/ManPageParsingUtils"; 11 | import {Suggestion, styles} from "../../src/plugins/autocompletion_utils/Common"; 12 | 13 | describe("man page line combiner", () => { 14 | it("combines lines with correct spacing", () => { 15 | expect(combineManPageLines([ 16 | " first line ", 17 | " second line ", 18 | ])).to.eql("first line second line"); 19 | }); 20 | 21 | it("correctly handles words split across lines", () => { 22 | expect(combineManPageLines([ 23 | "this is com-", 24 | "bined", 25 | ])).to.eql("this is combined"); 26 | }); 27 | }); 28 | 29 | describe("man page preprocessor", () => { 30 | it("strips whitespace and applies backspace literals", () => { 31 | expect(preprocessManPage(" ab\x08 ")).to.eql("a"); 32 | }); 33 | }); 34 | 35 | describe("man page section extractor", () => { 36 | it("extracts sections", () => { 37 | expect(extractManPageSections("DESCRIPTION\n desc\n\nNAME\n name")).to.eql({ 38 | DESCRIPTION: [" desc", ""], 39 | NAME: [" name"], 40 | }); 41 | }); 42 | }); 43 | 44 | describe("man page paragraph extraction", () => { 45 | it("extracts paragraphs", () => { 46 | expect(extractManPageSectionParagraphs([ 47 | "p1", 48 | "p1", 49 | "", 50 | "p2", 51 | "p2", 52 | ])).to.eql([ 53 | ["p1", "p1"], 54 | ["p2", "p2"], 55 | ]); 56 | }); 57 | 58 | it("doesn't output empty paragraphs", () => { 59 | expect(extractManPageSectionParagraphs([ 60 | "p1", 61 | "p1", 62 | "", 63 | "", 64 | "", 65 | "", 66 | "", 67 | "p2", 68 | "p2", 69 | ])).to.eql([ 70 | ["p1", "p1"], 71 | ["p2", "p2"], 72 | ]); 73 | }); 74 | 75 | it("can handle flag descriptions that have blank lines in the middle", () => { 76 | expect(extractManPageSectionParagraphs([ 77 | " -f1 line one", 78 | " line two", 79 | "", 80 | " line three", 81 | "", 82 | " -f2 line one", 83 | ])).to.eql([ 84 | [ 85 | " -f1 line one", 86 | " line two", 87 | " line three", 88 | ], 89 | [" -f2 line one"] 90 | ]); 91 | }); 92 | 93 | it("can handle flag descriptions that have indentation like df's -T option", () => { 94 | expect(extractManPageSectionParagraphs([ 95 | " -f line one", 96 | " line two", 97 | "", 98 | " indented", 99 | "", 100 | " line three", 101 | "", 102 | " -g line one", 103 | ])).to.eql([ 104 | [ 105 | " -f line one", 106 | " line two", 107 | " indented", 108 | " line three", 109 | ], 110 | [" -g line one"] 111 | ]); 112 | }); 113 | }); 114 | 115 | describe("suggestion parser", () => { 116 | it("can handle short flags without arguments", () => { 117 | expect(suggestionFromFlagParagraph([ 118 | " -f flag with", 119 | " description", 120 | ])).to.eql(new Suggestion({ 121 | value: "-f", 122 | style: styles.option, 123 | description: "flag with description", 124 | })); 125 | }); 126 | 127 | it("can handle short flags with arguments", () => { 128 | expect(suggestionFromFlagParagraph([ 129 | " -f arg", 130 | " flag with", 131 | " description", 132 | ])).to.eql(new Suggestion({ 133 | value: "-f", 134 | style: styles.option, 135 | description: "flag with description", 136 | displayValue: "-f arg", 137 | space: true, 138 | })); 139 | }); 140 | 141 | // DESCRIPTION section can contain things other than flags 142 | it("returns undefined if attempting to parse paragraph with no flag", () => { 143 | expect(suggestionFromFlagParagraph([ 144 | " no flag", 145 | ])).to.eql(undefined); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/shell/Session.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync} from "fs"; 2 | import {Job} from "./Job"; 3 | import {History} from "./History"; 4 | import {EmitterWithUniqueID} from "../EmitterWithUniqueID"; 5 | import {PluginManager} from "../PluginManager"; 6 | import {Status} from "../Enums"; 7 | import {ApplicationComponent} from "../views/1_ApplicationComponent"; 8 | import {Environment, processEnvironment} from "./Environment"; 9 | import { 10 | homeDirectory, normalizeDirectory, writeFileCreatingParents, 11 | presentWorkingDirectoryFilePath, historyFilePath, 12 | } from "../utils/Common"; 13 | import {remote} from "electron"; 14 | import {OrderedSet} from "../utils/OrderedSet"; 15 | import {Aliases, aliasesFromConfig} from "./Aliases"; 16 | import * as _ from "lodash"; 17 | 18 | export class Session extends EmitterWithUniqueID { 19 | jobs: Array = []; 20 | readonly environment = new Environment(processEnvironment); 21 | readonly aliases = new Aliases(aliasesFromConfig); 22 | history = History; 23 | historicalPresentDirectoriesStack = new OrderedSet(); 24 | 25 | constructor(private application: ApplicationComponent, private _dimensions: Dimensions) { 26 | super(); 27 | 28 | // TODO: We want to deserialize properties only for the first instance 29 | // TODO: of Session for the application. 30 | this.deserialize(); 31 | 32 | this.on("job", () => this.serialize()); 33 | 34 | this.clearJobs(); 35 | } 36 | 37 | createJob(): void { 38 | const job = new Job(this); 39 | 40 | job.once("end", () => { 41 | const electronWindow = remote.BrowserWindow.getAllWindows()[0]; 42 | 43 | if (remote.app.dock && !electronWindow.isFocused()) { 44 | remote.app.dock.bounce("informational"); 45 | remote.app.dock.setBadge(job.status === Status.Success ? "1" : "✕"); 46 | /* tslint:disable:no-unused-expression */ 47 | new Notification("Command has been completed", { body: job.prompt.value }); 48 | } 49 | 50 | this.createJob(); 51 | }); 52 | 53 | this.jobs = this.jobs.concat(job); 54 | this.emit("job"); 55 | } 56 | 57 | get dimensions(): Dimensions { 58 | return this._dimensions; 59 | } 60 | 61 | set dimensions(value: Dimensions) { 62 | this._dimensions = value; 63 | this.jobs.forEach(job => job.winch()); 64 | } 65 | 66 | get currentJob(): Job { 67 | return _.last(this.jobs); 68 | } 69 | 70 | clearJobs(): void { 71 | this.jobs = []; 72 | this.createJob(); 73 | } 74 | 75 | close(): void { 76 | // FIXME: executing `sleep 5 && exit` and switching to another pane will close an incorrect one. 77 | this.application.closeFocusedPane(); 78 | } 79 | 80 | get directory(): string { 81 | return this.environment.pwd; 82 | } 83 | 84 | set directory(value: string) { 85 | let normalizedDirectory = normalizeDirectory(value); 86 | if (normalizedDirectory === this.directory) { 87 | return; 88 | } 89 | 90 | PluginManager.environmentObservers.forEach(observer => 91 | observer.presentWorkingDirectoryWillChange(this, normalizedDirectory) 92 | ); 93 | 94 | this.environment.pwd = normalizedDirectory; 95 | this.historicalPresentDirectoriesStack.prepend(normalizedDirectory); 96 | 97 | PluginManager.environmentObservers.forEach(observer => 98 | observer.presentWorkingDirectoryDidChange(this, normalizedDirectory) 99 | ); 100 | } 101 | 102 | private serialize() { 103 | writeFileCreatingParents(presentWorkingDirectoryFilePath, JSON.stringify(this.directory)).then( 104 | () => void 0, 105 | (error: any) => { if (error) throw error; } 106 | ); 107 | 108 | writeFileCreatingParents(historyFilePath, this.history.serialize()).then( 109 | () => void 0, 110 | (error: any) => { if (error) throw error; } 111 | ); 112 | }; 113 | 114 | private deserialize(): void { 115 | this.directory = this.readSerialized(presentWorkingDirectoryFilePath, homeDirectory); 116 | History.deserialize(); 117 | } 118 | 119 | private readSerialized(file: string, defaultValue: T): T { 120 | try { 121 | return JSON.parse(readFileSync(file).toString()); 122 | } catch (error) { 123 | return defaultValue; 124 | } 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /src/utils/PaneTree.ts: -------------------------------------------------------------------------------- 1 | import {Session} from "../shell/Session"; 2 | import {SplitDirection} from "../Enums"; 3 | import * as _ from "lodash"; 4 | 5 | export type PaneTree = Pane | PaneList; 6 | 7 | export class Pane { 8 | readonly session: Session; 9 | readonly size = 1; 10 | 11 | constructor(session: Session) { 12 | this.session = session; 13 | } 14 | } 15 | 16 | export abstract class PaneList { 17 | readonly children: PaneTree[]; 18 | 19 | constructor(children: PaneTree[]) { 20 | this.children = children; 21 | } 22 | 23 | /** 24 | * Add a new pane after the existing one. 25 | */ 26 | add(newPane: Pane, existingPane: Pane, direction: SplitDirection) { 27 | const list = this.findListWithDirectChild(existingPane); 28 | 29 | if (!list) { 30 | throw `Couldn't find a list containing the pane.`; 31 | } 32 | 33 | const insertIndex = list.children.indexOf(existingPane); 34 | 35 | if (direction === SplitDirection.Horizontal) { 36 | list.insertBelow(insertIndex, newPane); 37 | } else { 38 | list.insertNextTo(insertIndex, newPane); 39 | } 40 | } 41 | 42 | remove(pane: Pane) { 43 | const list = this.findListWithDirectChild(pane); 44 | 45 | if (!list) { 46 | throw `Couldn't find a list containing the pane.`; 47 | } 48 | 49 | list.children.splice(list.children.indexOf(pane), 1); 50 | } 51 | 52 | get size(): number { 53 | return _.sum(this.children.map(child => child.size)); 54 | } 55 | 56 | forEach(callback: (pane: Pane, index: number) => void, counter = 0): void { 57 | for (const child of this.children) { 58 | if (child instanceof Pane) { 59 | callback(child, counter); 60 | ++counter; 61 | } else { 62 | child.forEach((pane) => { 63 | callback(pane, counter); 64 | ++counter; 65 | }); 66 | } 67 | } 68 | } 69 | 70 | previous(pane: Pane): Pane { 71 | let paneIndex = 0; 72 | 73 | this.forEach((current, index) => { 74 | if (pane === current) { 75 | paneIndex = index; 76 | } 77 | }); 78 | 79 | let previous = pane; 80 | 81 | this.forEach((current, index) => { 82 | if (index === paneIndex - 1) { 83 | previous = current; 84 | } 85 | }); 86 | 87 | return previous; 88 | } 89 | 90 | next(pane: Pane): Pane { 91 | let paneIndex = 0; 92 | 93 | this.forEach((current, index) => { 94 | if (pane === current) { 95 | paneIndex = index; 96 | } 97 | }); 98 | 99 | let next = pane; 100 | 101 | this.forEach((current, index) => { 102 | if (index === paneIndex + 1) { 103 | next = current; 104 | } 105 | }); 106 | 107 | return next; 108 | } 109 | 110 | protected abstract insertBelow(position: number, pane: Pane): void; 111 | protected abstract insertNextTo(position: number, pane: Pane): void; 112 | 113 | private findListWithDirectChild(tree: PaneTree): PaneList | undefined { 114 | const childContaining = this.children.find(child => child === tree); 115 | 116 | if (childContaining) { 117 | return this; 118 | } 119 | 120 | for (const child of this.children) { 121 | if (child instanceof PaneList) { 122 | const childListContaining = child.findListWithDirectChild(tree); 123 | if (childListContaining) { 124 | return childListContaining; 125 | } 126 | } 127 | } 128 | } 129 | } 130 | 131 | export class ColumnList extends PaneList { 132 | protected insertBelow(position: number, pane: Pane) { 133 | this.children.splice(position, 1, new RowList([this.children[position], pane])); 134 | } 135 | 136 | protected insertNextTo(position: number, pane: Pane) { 137 | this.children.splice(position + 1, 0, pane); 138 | } 139 | } 140 | 141 | export class RowList extends PaneList { 142 | protected insertBelow(position: number, pane: Pane) { 143 | this.children.splice(position + 1, 0, pane); 144 | } 145 | 146 | protected insertNextTo(position: number, pane: Pane) { 147 | this.children.splice(position, 1, new ColumnList([this.children[position], pane])); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/shell/CommandExecutor.ts: -------------------------------------------------------------------------------- 1 | import {Job} from "./Job"; 2 | import {Command} from "./Command"; 3 | import {PTY} from "../PTY"; 4 | import * as Path from "path"; 5 | import {executablesInPaths, resolveFile, isWindows, filterAsync, exists} from "../utils/Common"; 6 | import {loginShell} from "../utils/Shell"; 7 | 8 | export class NonZeroExitCodeError extends Error { 9 | } 10 | 11 | abstract class CommandExecutionStrategy { 12 | static async canExecute(job: Job): Promise { 13 | return false; 14 | } 15 | 16 | constructor(protected job: Job) { 17 | } 18 | 19 | abstract startExecution(): Promise<{}>; 20 | } 21 | 22 | class BuiltInCommandExecutionStrategy extends CommandExecutionStrategy { 23 | static async canExecute(job: Job) { 24 | return Command.isBuiltIn(job.prompt.commandName); 25 | } 26 | 27 | startExecution() { 28 | return new Promise((resolve, reject) => { 29 | try { 30 | Command.executor(this.job.prompt.commandName)(this.job, this.job.prompt.arguments.map(token => token.value)); 31 | resolve(); 32 | } catch (error) { 33 | reject(error.message); 34 | } 35 | }); 36 | } 37 | } 38 | 39 | class ShellExecutionStrategy extends CommandExecutionStrategy { 40 | static async canExecute(job: Job) { 41 | return loginShell.preCommandModifiers.includes(job.prompt.commandName) || 42 | await this.isExecutableFromPath(job) || 43 | await this.isPathOfExecutable(job) || 44 | this.isBashFunc(job); 45 | } 46 | 47 | private static isBashFunc(job: Job): boolean { 48 | return job.environment.has(`BASH_FUNC_${job.prompt.commandName}%%`); 49 | } 50 | 51 | private static async isExecutableFromPath(job: Job): Promise { 52 | return (await executablesInPaths(job.environment.path)).includes(job.prompt.commandName); 53 | } 54 | 55 | private static async isPathOfExecutable(job: Job): Promise { 56 | return await exists(resolveFile(job.session.directory, job.prompt.commandName)); 57 | } 58 | 59 | startExecution() { 60 | return new Promise((resolve, reject) => { 61 | this.job.command = new PTY( 62 | this.job.prompt.expandedTokens.map(token => token.escapedValue), 63 | this.job.environment.toObject(), 64 | this.job.dimensions, 65 | (data: string) => this.job.parser.parse(data), 66 | (exitCode: number) => exitCode === 0 ? resolve() : reject(new NonZeroExitCodeError(exitCode.toString())) 67 | ); 68 | }); 69 | } 70 | } 71 | 72 | class WindowsShellExecutionStrategy extends CommandExecutionStrategy { 73 | static async canExecute(job: Job) { 74 | return isWindows; 75 | } 76 | 77 | startExecution() { 78 | return new Promise((resolve) => { 79 | this.job.command = new PTY( 80 | [ 81 | this.cmdPath, 82 | "/s", 83 | "/c", 84 | ...this.job.prompt.expandedTokens.map(token => token.escapedValue), 85 | ], 86 | this.job.environment.toObject(), this.job.dimensions, 87 | (data: string) => this.job.parser.parse(data), 88 | (exitCode: number) => resolve() 89 | ); 90 | }); 91 | } 92 | 93 | private get cmdPath(): EscapedShellWord { 94 | if (this.job.environment.has("comspec")) { 95 | return this.job.environment.get("comspec"); 96 | } else if (this.job.environment.has("SystemRoot")) { 97 | return Path.join(this.job.environment.get("SystemRoot"), "System32", "cmd.exe"); 98 | } else { 99 | return "cmd.exe"; 100 | } 101 | } 102 | } 103 | 104 | export class CommandExecutor { 105 | private static executors = [ 106 | BuiltInCommandExecutionStrategy, 107 | WindowsShellExecutionStrategy, 108 | ShellExecutionStrategy, 109 | ]; 110 | 111 | static async execute(job: Job): Promise<{}> { 112 | const applicableExecutors = await filterAsync(this.executors, executor => executor.canExecute(job)); 113 | 114 | if (applicableExecutors.length) { 115 | return new applicableExecutors[0](job).startExecution(); 116 | } else { 117 | throw `Black Screen: command "${job.prompt.commandName}" not found.\n`; 118 | } 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /src/plugins/ManPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Job} from "../shell/Job"; 3 | import {PluginManager} from "../PluginManager"; 4 | import {executeCommand} from "../PTY"; 5 | import {shell} from "electron"; 6 | import {v4} from "uuid"; 7 | 8 | type Props = { 9 | man: string, 10 | } 11 | 12 | type State = { 13 | html: undefined | string, 14 | uniqueId: string, 15 | } 16 | 17 | const postprocesManHTML = (element: HTMLElement, uniqueId: string) => { 18 | Array.prototype.slice.call(element.getElementsByTagName("a")).forEach((link: any) => { 19 | // Apply color to links so they aren't blue on blue 20 | link.style.color = "rgb(149, 162, 255)"; 21 | const href = link.getAttribute("href"); 22 | if (href && !href.startsWith("#")) { 23 | // Make links to external websites open in the system's 24 | // Web browser 25 | link.onclick = (event: any) => { 26 | event.preventDefault(); 27 | shell.openExternal(link.href); 28 | }; 29 | } else if (href) { 30 | // Modify element ID's to not conflict with other man pages 31 | // That might be displayed from previous commands 32 | link.setAttribute("href", `#${uniqueId}${href.slice(1)}`); 33 | 34 | // The default scroll behaviour puts the section title underneath the 35 | // "propmt header" so we need to override it :( 36 | // TODO: make the prompt header be non-fixed, so this isn't an issue 37 | link.onclick = (event: any) => { 38 | event.preventDefault(); 39 | const sibling: any = document.getElementsByName(link.getAttribute("href").slice(1))[0].previousElementSibling; 40 | sibling.scrollIntoView(); 41 | }; 42 | } else if (link.hasAttribute("name")) { 43 | // Modify names to have the correct target for our modifies # links 44 | link.setAttribute("name", `${uniqueId}${link.getAttribute("name")}`); 45 | } 46 | }); 47 | 48 | // Remove font colors added by groff as we want to control the color ourselves 49 | Array.prototype.slice.call(element.getElementsByTagName("font")).forEach((font: any) => { 50 | font.removeAttribute("color"); 51 | }); 52 | 53 | // Remove
    tags if they are the first or last in the document 54 | const [firstHR, lastHR] = element.getElementsByTagName("hr"); 55 | if (firstHR && firstHR.previousElementSibling.tagName === "TITLE" && (firstHR.previousElementSibling as any).innerText === "") { 56 | firstHR.remove(); 57 | } 58 | if (lastHR && !lastHR.nextElementSibling) { 59 | lastHR.remove(); 60 | } 61 | }; 62 | 63 | class HTMLManPageComponent extends React.Component { 64 | constructor(props: Props) { 65 | super(props); 66 | 67 | this.state = { 68 | html: undefined, 69 | uniqueId: v4(), 70 | }; 71 | 72 | executeCommand("man", ["-w", props.man], "", { 73 | maxBuffer: 1024 * 1024 * 4, // 5mb 74 | }) 75 | .then(path => executeCommand("groff", ["-mandoc", "-Thtml", path], "", { 76 | maxBuffer: 1024 * 1024 * 4, // 5mb 77 | })) 78 | .then(html => this.setState({ html } as State)) 79 | .catch(() => this.setState({ html: `Failed to load HTML man page for ${props.man}` } as State)); 80 | } 81 | 82 | render() { 83 | if (this.state.html) { 84 | return
    { 93 | if (e) { 94 | postprocesManHTML(e, this.state.uniqueId); 95 | } 96 | }} 97 | />; 98 | } 99 | return
    loading HTML man page...
    ; 100 | } 101 | } 102 | 103 | PluginManager.registerOutputDecorator({ 104 | decorate: (job: Job): React.ReactElement => { 105 | const match = job.prompt.value.match(/^man\s+([a-zA-Z]\w*)\s*$/); 106 | return ; 107 | }, 108 | 109 | isApplicable: (job: Job): boolean => { 110 | // Matches man page with a single arg that isn't a flag. 111 | return /^man\s+[a-zA-Z]\w*\s*$/.test(job.prompt.value); 112 | }, 113 | }); 114 | -------------------------------------------------------------------------------- /src/shell/Command.ts: -------------------------------------------------------------------------------- 1 | import {Job} from "./Job"; 2 | import {existsSync, statSync} from "fs"; 3 | import {homeDirectory, pluralize, resolveDirectory, resolveFile, mapObject} from "../utils/Common"; 4 | import {readFileSync} from "fs"; 5 | import {EOL} from "os"; 6 | import {Session} from "./Session"; 7 | import {OrderedSet} from "../utils/OrderedSet"; 8 | import {parseAlias} from "./Aliases"; 9 | 10 | const executors: Dictionary<(i: Job, a: string[]) => void> = { 11 | cd: (job: Job, args: string[]): void => { 12 | let fullPath: string; 13 | 14 | if (!args.length) { 15 | fullPath = homeDirectory; 16 | } else { 17 | const enteredPath = args[0]; 18 | 19 | if (isHistoricalDirectory(enteredPath)) { 20 | fullPath = expandHistoricalDirectory(enteredPath, job.session.historicalPresentDirectoriesStack); 21 | } else { 22 | fullPath = job.environment.cdpath 23 | .map(path => resolveDirectory(path, enteredPath)) 24 | .filter(resolved => existsSync(resolved)) 25 | .filter(resolved => statSync(resolved).isDirectory())[0]; 26 | 27 | if (!fullPath) { 28 | throw new Error(`The directory "${enteredPath}" doesn't exist.`); 29 | } 30 | } 31 | } 32 | 33 | job.session.directory = fullPath; 34 | }, 35 | clear: (job: Job, args: string[]): void => { 36 | setTimeout(() => job.session.clearJobs(), 0); 37 | }, 38 | exit: (job: Job, args: string[]): void => { 39 | job.session.close(); 40 | }, 41 | export: (job: Job, args: string[]): void => { 42 | if (args.length === 0) { 43 | job.screenBuffer.writeMany(job.environment.map((key, value) => `${key}=${value}`).join("\r\n")); 44 | } else { 45 | args.forEach(argument => { 46 | const firstEqualIndex = argument.indexOf("="); 47 | const key = argument.slice(0, firstEqualIndex); 48 | const value = argument.slice(firstEqualIndex + 1); 49 | 50 | job.session.environment.set(key, value); 51 | }); 52 | } 53 | }, 54 | // FIXME: make the implementation more reliable. 55 | source: (job: Job, args: string[]): void => { 56 | sourceFile(job.session, args[0]); 57 | }, 58 | alias: (job: Job, args: string[]): void => { 59 | if (args.length === 0) { 60 | job.screenBuffer.writeMany(mapObject(job.session.aliases.toObject(), (key, value) => `${key}=${value}`).join("\r\n")); 61 | } else if (args.length === 1) { 62 | const parsed = parseAlias(args[0]); 63 | job.session.aliases.add(parsed.name, parsed.value); 64 | } else { 65 | throw `Don't know what to do with ${args.length} arguments.`; 66 | } 67 | }, 68 | unalias: (job: Job, args: string[]): void => { 69 | if (args.length === 1) { 70 | const name = args[0]; 71 | 72 | if (job.session.aliases.has(name)) { 73 | job.session.aliases.remove(args[0]); 74 | } else { 75 | throw `There is such alias: ${name}.`; 76 | } 77 | } else { 78 | throw `Don't know what to do with ${args.length} arguments.`; 79 | } 80 | }, 81 | show: (job: Job, args: string[]): void => { 82 | const imgs = args.map(argument => resolveFile(job.environment.pwd, argument)); 83 | job.screenBuffer.writeMany(imgs.join(EOL)); 84 | }, 85 | }; 86 | 87 | export function sourceFile(session: Session, fileName: string) { 88 | const content = readFileSync(resolveFile(session.directory, fileName)).toString(); 89 | 90 | content.split(EOL).forEach(line => { 91 | if (line.startsWith("export ")) { 92 | const [key, value] = line.split(" ")[1].split("="); 93 | session.environment.set(key, value); 94 | } 95 | }); 96 | } 97 | 98 | // A class representing built in commands 99 | export class Command { 100 | static allCommandNames = Object.keys(executors); 101 | 102 | static executor(command: string): (i: Job, args: string[]) => void { 103 | return executors[command]; 104 | } 105 | 106 | static isBuiltIn(command: string): boolean { 107 | return this.allCommandNames.includes(command); 108 | } 109 | } 110 | 111 | export function expandHistoricalDirectory(alias: string, historicalDirectories: OrderedSet): string { 112 | if (alias === "-") { 113 | alias = "-1"; 114 | } 115 | const index = historicalDirectories.size + parseInt(alias, 10); 116 | 117 | if (index < 0) { 118 | throw new Error(`Error: you only have ${historicalDirectories.size} ${pluralize("directory", historicalDirectories.size)} in the stack.`); 119 | } else { 120 | const directory = historicalDirectories.at(index); 121 | 122 | if (directory) { 123 | return directory; 124 | } else { 125 | throw `No directory with index ${index}`; 126 | } 127 | } 128 | } 129 | 130 | export function isHistoricalDirectory(directory: string): boolean { 131 | return /^-\d*$/.test(directory); 132 | } 133 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Top.ts: -------------------------------------------------------------------------------- 1 | import {PluginManager} from "../../PluginManager"; 2 | import {shortFlag, mapSuggestions} from "../autocompletion_utils/Common"; 3 | import combine from "../autocompletion_utils/Combine"; 4 | import {mapObject} from "../../utils/Common"; 5 | 6 | const options = combine(mapObject( 7 | { 8 | b: { 9 | short: "Batch mode operation", 10 | long: "Starts top in ’Batch mode’, which could be useful for sending\ 11 | output from top to other programs or to a file. In this mode, top\ 12 | will not accept input and runs until the iterations limit you’ve\ 13 | set with the ’-n’ command-line option or until killed.", 14 | }, 15 | 16 | c: { 17 | short: "Command line/Program name toggle", 18 | long: "Starts top with the last remembered ’c’ state reversed. Thus, if\ 19 | top was displaying command lines, now that field will show program\ 20 | names, and visa versa. See the ’c’ interactive command for\ 21 | additional information.", 22 | }, 23 | 24 | d: { 25 | short: "Delay time interval as: -d ss.tt (seconds.tenths)", 26 | long: "Specifies the delay between screen updates, and overrides the\ 27 | corresponding value in one’s personal configuration file or the\ 28 | startup default. Later this can be changed with the ’d’ or ’s’\ 29 | interactive commands.\ 30 | \ 31 | Fractional seconds are honored, but a negative number is not\ 32 | allowed. In all cases, however, such changes are prohibited if\ 33 | top is running in ’Secure mode’, except for root (unless the ’s’\ 34 | command-line option was used). For additional information on\ 35 | ’Secure mode’ see topic 5a. SYSTEM Configuration File.", 36 | }, 37 | 38 | h: { 39 | short: "Help", 40 | long: "Show library version and the usage prompt, then quit.", 41 | }, 42 | 43 | H: { 44 | short: "Threads toggle", 45 | long: "Starts top with the last remembered ’H’ state reversed. When this\ 46 | toggle is On, all individual threads will be displayed.\ 47 | Otherwise, top displays a summation of all threads in a process.", 48 | }, 49 | 50 | i: { 51 | short: "Idle Processes toggle", long: "Starts top with the last remembered ’i’ state reversed. When this\ 52 | toggle is Off, tasks that are idled or zombied will not be\ 53 | displayed.", 54 | }, 55 | 56 | n: { 57 | short: "Number of iterations limit as: -n number", 58 | long: "Specifies the maximum number of iterations, or frames, top should\ 59 | produce before ending.", 60 | }, 61 | 62 | u: { 63 | short: "Monitor by user as: -u somebody", 64 | long: "Monitor only processes with an effective UID or user name matching\ 65 | that given.", 66 | }, 67 | 68 | U: { 69 | short: "Monitor by user as: -U somebody", 70 | long: "Monitor only processes with a UID or user name matching that\ 71 | given. This matches real, effective, saved, and filesystem UIDs.", 72 | }, 73 | 74 | p: { 75 | short: "Monitor PIDs as: -pN1 -pN2 ... or -pN1, N2 [,...]", 76 | long: "Monitor only processes with specified process IDs. This option\ 77 | can be given up to 20 times, or you can provide a comma delimited\ 78 | list with up to 20 pids. Co-mingling both approaches is\ 79 | permitted.\ 80 | \ 81 | This is a command-line option only. And should you wish to return\ 82 | to normal operation, it is not necessary to quit and and restart\ 83 | top -- just issue the ’=’ interactive command.", 84 | }, 85 | 86 | s: { 87 | short: "Secure mode operation", 88 | long: "Starts top with secure mode forced, even for root. This mode is\ 89 | far better controlled through the system configuration file (see\ 90 | topic 5. FILES).", 91 | }, 92 | 93 | S: { 94 | short: "Cumulative time mode toggle", 95 | long: "Starts top with the last remembered ’S’ state reversed. When\ 96 | ’Cumulative mode’ is On, each process is listed with the cpu time\ 97 | that it and its dead children have used. See the ’S’ interactive\ 98 | command for additional information regarding this mode.", 99 | }, 100 | 101 | v: { 102 | short: "Version", 103 | long: "Show library version and the usage prompt, then quit.", 104 | }, 105 | }, 106 | (option, descriptions) => mapSuggestions(shortFlag(option), suggestion => suggestion.withSynopsis(descriptions.short).withDescription(descriptions.long)) 107 | )); 108 | 109 | 110 | PluginManager.registerAutocompletionProvider("top", options); 111 | -------------------------------------------------------------------------------- /src/utils/Git.ts: -------------------------------------------------------------------------------- 1 | import {linedOutputOf} from "../PTY"; 2 | import * as Path from "path"; 3 | import * as fs from "fs"; 4 | import * as _ from "lodash"; 5 | 6 | export class Branch { 7 | constructor(private refName: string, private _isCurrent: boolean) { 8 | } 9 | 10 | toString(): string { 11 | return _.last(this.refName.split("/")); 12 | } 13 | 14 | isCurrent(): boolean { 15 | return this._isCurrent; 16 | } 17 | } 18 | 19 | export interface ConfigVariable { 20 | name: string; 21 | value: string; 22 | } 23 | 24 | export type StatusCode = 25 | "Unmodified" | 26 | 27 | "UnstagedModified" | 28 | "UnstagedDeleted" | 29 | "StagedModified" | 30 | "StagedModifiedUnstagedModified" | 31 | "StagedModifiedUnstagedDeleted" | 32 | "StagedAdded" | 33 | "StagedAddedUnstagedModified" | 34 | "StagedAddedUnstagedDeleted" | 35 | "StagedDeleted" | 36 | "StagedDeletedUnstagedModified" | 37 | "StagedRenamed" | 38 | "StagedRenamedUnstagedModified" | 39 | "StagedRenamedUnstagedDeleted" | 40 | "StagedCopied" | 41 | "StagedCopiedUnstagedModified" | 42 | "StagedCopiedUnstagedDeleted" | 43 | 44 | "UnmergedBothDeleted" | 45 | "UnmergedAddedByUs" | 46 | "UnmergedDeletedByThem" | 47 | "UnmergedAddedByThem" | 48 | "UnmergedDeletedByUs" | 49 | "UnmergedBothAdded" | 50 | "UnmergedBothModified" | 51 | 52 | "Untracked" | 53 | "Ignored" | 54 | 55 | "Invalid" 56 | 57 | function lettersToStatusCode(letters: string): StatusCode { 58 | switch (letters) { 59 | case " ": return "Unmodified"; 60 | 61 | case " M": return "UnstagedModified"; 62 | case " D": return "UnstagedDeleted"; 63 | case "M ": return "StagedModified"; 64 | case "MM": return "StagedModifiedUnstagedModified"; 65 | case "MD": return "StagedModifiedUnstagedDeleted"; 66 | case "A ": return "StagedAdded"; 67 | case "AM": return "StagedAddedUnstagedModified"; 68 | case "AD": return "StagedAddedUnstagedDeleted"; 69 | case "D ": return "StagedDeleted"; 70 | case "DM": return "StagedDeletedUnstagedModified"; 71 | case "R ": return "StagedRenamed"; 72 | case "RM": return "StagedRenamedUnstagedModified"; 73 | case "RD": return "StagedRenamedUnstagedDeleted"; 74 | case "C ": return "StagedCopied"; 75 | case "CM": return "StagedCopiedUnstagedModified"; 76 | case "CD": return "StagedCopiedUnstagedDeleted"; 77 | 78 | case "DD": return "UnmergedBothDeleted"; 79 | case "AU": return "UnmergedAddedByUs"; 80 | case "UD": return "UnmergedDeletedByThem"; 81 | case "UA": return "UnmergedAddedByThem"; 82 | case "DU": return "UnmergedDeletedByUs"; 83 | case "AA": return "UnmergedBothAdded"; 84 | case "UU": return "UnmergedBothModified"; 85 | 86 | case "??": return "Untracked"; 87 | case "!!": return "Ignored"; 88 | 89 | default: return "Invalid"; 90 | } 91 | } 92 | 93 | export class FileStatus { 94 | constructor(private _line: string) { 95 | } 96 | 97 | get value(): string { 98 | return this._line.slice(3).trim(); 99 | } 100 | 101 | get code(): StatusCode { 102 | return lettersToStatusCode(this._line.slice(0, 2)); 103 | } 104 | } 105 | 106 | type GitDirectoryPath = string & { __isGitDirectoryPath: boolean }; 107 | 108 | export function isGitDirectory(directory: string): directory is GitDirectoryPath { 109 | return fs.existsSync(Path.join(directory, ".git") ); 110 | } 111 | 112 | export async function branches(directory: GitDirectoryPath): Promise { 113 | let lines = await linedOutputOf( 114 | "git", 115 | ["for-each-ref", "refs/tags", "refs/heads", "refs/remotes", "--format='%(HEAD)%(refname:short)'"], 116 | directory 117 | ); 118 | return lines.map(line => new Branch(line.slice(1), line[0] === "*")); 119 | } 120 | 121 | export async function configVariables(directory: string): Promise { 122 | const lines = await linedOutputOf( 123 | "git", 124 | ["config", "--list"], 125 | directory 126 | ); 127 | 128 | return lines.map(line => { 129 | const parts = line.split("="); 130 | 131 | return { 132 | name: parts[0].trim(), 133 | value: parts[1] ? parts[1].trim() : "", 134 | }; 135 | }); 136 | } 137 | 138 | export async function aliases(directory: string): Promise { 139 | const variables = await configVariables(directory); 140 | 141 | return variables 142 | .filter(variable => variable.name.indexOf("alias.") === 0) 143 | .map(variable => { 144 | return { 145 | name: variable.name.replace("alias.", ""), 146 | value: variable.value, 147 | }; 148 | }); 149 | } 150 | 151 | export async function remotes(directory: GitDirectoryPath): Promise { 152 | return await linedOutputOf("git", ["remote"], directory); 153 | } 154 | 155 | export async function status(directory: GitDirectoryPath): Promise { 156 | let lines = await linedOutputOf("git", ["status", "--porcelain"], directory); 157 | return lines.map(line => new FileStatus(line)); 158 | } 159 | -------------------------------------------------------------------------------- /src/plugins/GitWatcher.ts: -------------------------------------------------------------------------------- 1 | import {Session} from "../shell/Session"; 2 | import {PluginManager} from "../PluginManager"; 3 | import {EnvironmentObserverPlugin} from "../Interfaces"; 4 | import {watch, FSWatcher} from "fs"; 5 | import * as Path from "path"; 6 | import * as _ from "lodash"; 7 | import {EventEmitter} from "events"; 8 | import {executeCommand} from "../PTY"; 9 | import {debounce} from "../Decorators"; 10 | import * as Git from "../utils/Git"; 11 | 12 | const GIT_WATCHER_EVENT_NAME = "git-data-changed"; 13 | 14 | class GitWatcher extends EventEmitter { 15 | GIT_HEAD_FILE_NAME = Path.join(".git", "HEAD"); 16 | GIT_HEADS_DIRECTORY_NAME = Path.join(".git", "refs", "heads"); 17 | 18 | watcher: FSWatcher; 19 | gitDirectory: string; 20 | 21 | constructor(private directory: string) { 22 | super(); 23 | this.gitDirectory = Path.join(this.directory, ".git"); 24 | } 25 | 26 | stopWatching() { 27 | if (this.watcher) { 28 | this.watcher.close(); 29 | } 30 | } 31 | 32 | watch() { 33 | if (Git.isGitDirectory(this.directory)) { 34 | this.updateGitData(); 35 | this.watcher = watch(this.directory, { 36 | recursive: true, 37 | }); 38 | 39 | this.watcher.on( 40 | "change", 41 | (type: string, fileName: string) => { 42 | if (!fileName.startsWith(".git") || 43 | fileName === this.GIT_HEAD_FILE_NAME || 44 | fileName.startsWith(this.GIT_HEADS_DIRECTORY_NAME)) { 45 | this.updateGitData(); 46 | } 47 | } 48 | ); 49 | } else { 50 | const data: VcsData = { kind: "not-repository" }; 51 | this.emit(GIT_WATCHER_EVENT_NAME, data); 52 | } 53 | } 54 | 55 | @debounce(1000 / 60) 56 | private async updateGitData() { 57 | 58 | executeCommand("git", ["status", "-b", "--porcelain"], this.directory).then(changes => { 59 | const status: VcsStatus = changes.length ? "dirty" : "clean"; 60 | let head: string = changes.split(" ")[1]; 61 | let push: string = "0"; 62 | let pull: string = "0"; 63 | 64 | let secondSplit: Array = changes.split("["); 65 | if (secondSplit.length > 1) { 66 | let rawPushPull: string = secondSplit[1].slice(0, -2); 67 | let separatedPushPull: Array = rawPushPull.split(", "); 68 | 69 | 70 | if (separatedPushPull.length > 0) { 71 | for (let i in separatedPushPull) { 72 | if (separatedPushPull.hasOwnProperty(i)) { 73 | let splitAgain: Array = separatedPushPull[i].split(" "); 74 | switch (splitAgain[0]) { 75 | case "ahead": 76 | push = splitAgain[1]; 77 | break; 78 | case "behind": 79 | pull = splitAgain[1]; 80 | break; 81 | default: 82 | break; 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | const data: VcsData = { 90 | kind: "repository", 91 | branch: head, 92 | push: push, 93 | pull: pull, 94 | status: status, 95 | }; 96 | 97 | this.emit(GIT_WATCHER_EVENT_NAME, data); 98 | }); 99 | } 100 | } 101 | 102 | interface WatchesValue { 103 | listeners: Set; 104 | watcher: GitWatcher; 105 | data: VcsData; 106 | } 107 | 108 | class WatchManager implements EnvironmentObserverPlugin { 109 | directoryToDetails: Map = new Map(); 110 | 111 | presentWorkingDirectoryWillChange(session: Session, newDirectory: string) { 112 | const oldDirectory = session.directory; 113 | 114 | if (!this.directoryToDetails.has(oldDirectory)) { 115 | return; 116 | } 117 | 118 | const details = this.directoryToDetails.get(oldDirectory)!; 119 | details.listeners.delete(session); 120 | 121 | if (details.listeners.size === 0) { 122 | details.watcher.stopWatching(); 123 | this.directoryToDetails.delete(oldDirectory); 124 | } 125 | } 126 | 127 | presentWorkingDirectoryDidChange(session: Session, directory: string) { 128 | const existingDetails = this.directoryToDetails.get(directory); 129 | 130 | if (existingDetails) { 131 | existingDetails.listeners.add(session); 132 | } else { 133 | const watcher = new GitWatcher(directory); 134 | 135 | this.directoryToDetails.set(directory, { 136 | listeners: new Set([session]), 137 | watcher: watcher, 138 | data: { kind: "not-repository" }, 139 | }); 140 | 141 | watcher.watch(); 142 | 143 | watcher.on(GIT_WATCHER_EVENT_NAME, (data: VcsData) => { 144 | const details = this.directoryToDetails.get(directory); 145 | 146 | if (details && !_.isEqual(data, details.data)) { 147 | details.data = data; 148 | details.listeners.forEach(listeningSession => listeningSession.emit("vcs-data")); 149 | } 150 | }); 151 | } 152 | } 153 | 154 | vcsDataFor(directory: string): VcsData { 155 | const details = this.directoryToDetails.get(directory); 156 | 157 | if (details) { 158 | return details.data; 159 | } else { 160 | return { kind: "not-repository" }; 161 | } 162 | } 163 | } 164 | 165 | export const watchManager = new WatchManager(); 166 | 167 | PluginManager.registerEnvironmentObserver(watchManager); 168 | -------------------------------------------------------------------------------- /src/shell/Scanner.ts: -------------------------------------------------------------------------------- 1 | import {Aliases} from "./Aliases"; 2 | 3 | export abstract class Token { 4 | readonly raw: string; 5 | readonly fullStart: number; 6 | 7 | constructor(raw: string, fullStart: number) { 8 | this.raw = raw; 9 | this.fullStart = fullStart; 10 | } 11 | 12 | abstract get value(): string; 13 | 14 | /** 15 | * @deprecated 16 | */ 17 | abstract get escapedValue(): EscapedShellWord; 18 | } 19 | 20 | export class Empty extends Token { 21 | constructor() { 22 | super("", 0); 23 | } 24 | 25 | get value() { 26 | return ""; 27 | } 28 | 29 | get escapedValue() { 30 | return this.raw.trim(); 31 | } 32 | } 33 | 34 | export class Word extends Token { 35 | get value() { 36 | return this.raw.trim().replace(/\\\s/g, " "); 37 | } 38 | 39 | get escapedValue() { 40 | return this.raw.trim(); 41 | } 42 | } 43 | 44 | export class Pipe extends Token { 45 | get value() { 46 | return this.raw.trim(); 47 | } 48 | 49 | get escapedValue(): EscapedShellWord { 50 | return this.value; 51 | } 52 | } 53 | 54 | export class Semicolon extends Token { 55 | get value() { 56 | return this.raw.trim(); 57 | } 58 | 59 | get escapedValue(): EscapedShellWord { 60 | return this.value; 61 | } 62 | } 63 | 64 | export class And extends Token { 65 | get value() { 66 | return this.raw.trim(); 67 | } 68 | 69 | get escapedValue(): EscapedShellWord { 70 | return this.value; 71 | } 72 | } 73 | 74 | export class Or extends Token { 75 | get value() { 76 | return this.raw.trim(); 77 | } 78 | 79 | get escapedValue(): EscapedShellWord { 80 | return this.value; 81 | } 82 | } 83 | 84 | export class InputRedirectionSymbol extends Token { 85 | get value() { 86 | return this.raw.trim(); 87 | } 88 | 89 | get escapedValue(): EscapedShellWord { 90 | return this.value; 91 | } 92 | } 93 | 94 | export class OutputRedirectionSymbol extends Token { 95 | get value() { 96 | return this.raw.trim(); 97 | } 98 | 99 | get escapedValue(): EscapedShellWord { 100 | return this.value; 101 | } 102 | } 103 | 104 | export class AppendingOutputRedirectionSymbol extends Token { 105 | get value() { 106 | return this.raw.trim(); 107 | } 108 | 109 | get escapedValue(): EscapedShellWord { 110 | return this.value; 111 | } 112 | } 113 | 114 | export abstract class StringLiteral extends Token { 115 | get value() { 116 | return this.raw.trim().slice(1, -1); 117 | } 118 | } 119 | 120 | export class SingleQuotedStringLiteral extends StringLiteral { 121 | get escapedValue(): EscapedShellWord { 122 | return `'${this.value}'`; 123 | } 124 | } 125 | 126 | export class DoubleQuotedStringLiteral extends StringLiteral { 127 | get escapedValue(): EscapedShellWord { 128 | return `"${this.value}"`; 129 | } 130 | } 131 | 132 | export class Invalid extends Token { 133 | get value() { 134 | return this.raw.trim(); 135 | } 136 | 137 | get escapedValue(): EscapedShellWord { 138 | return this.value; 139 | } 140 | } 141 | 142 | const patterns = [ 143 | { 144 | regularExpression: /^(\s*\|)/, 145 | tokenConstructor: Pipe, 146 | }, 147 | { 148 | regularExpression: /^(\s*;)/, 149 | tokenConstructor: Semicolon, 150 | }, 151 | { 152 | regularExpression: /^(\s*&&)/, 153 | tokenConstructor: And, 154 | }, 155 | { 156 | regularExpression: /^(\s*\|\|)/, 157 | tokenConstructor: Or, 158 | }, 159 | { 160 | regularExpression: /^(\s*>>)/, 161 | tokenConstructor: AppendingOutputRedirectionSymbol, 162 | }, 163 | { 164 | regularExpression: /^(\s*<)/, 165 | tokenConstructor: InputRedirectionSymbol, 166 | }, 167 | { 168 | regularExpression: /^(\s*>)/, 169 | tokenConstructor: OutputRedirectionSymbol, 170 | }, 171 | { 172 | regularExpression: /^(\s*"(?:\\"|[^"])*")/, 173 | tokenConstructor: DoubleQuotedStringLiteral, 174 | }, 175 | { 176 | regularExpression: /^(\s*'(?:\\'|[^'])*')/, 177 | tokenConstructor : SingleQuotedStringLiteral, 178 | }, 179 | { 180 | regularExpression: /^(\s*(?:\\\s|[a-zA-Z0-9\u0080-\uFFFF+~!@#%^&*_=,.:/?\\-])+)/, 181 | tokenConstructor : Word, 182 | }, 183 | ]; 184 | 185 | export function scan(input: string): Token[] { 186 | const tokens: Token[] = []; 187 | 188 | let position = 0; 189 | 190 | while (true) { 191 | if (input.length === 0) { 192 | return tokens; 193 | } 194 | 195 | let foundMatch = false; 196 | for (const pattern of patterns) { 197 | const match = input.match(pattern.regularExpression); 198 | 199 | if (match) { 200 | const token = match[1]; 201 | tokens.push(new pattern.tokenConstructor(token, position)); 202 | position += token.length; 203 | input = input.slice(token.length); 204 | foundMatch = true; 205 | break; 206 | } 207 | } 208 | 209 | if (!foundMatch) { 210 | tokens.push(new Invalid(input, position)); 211 | return tokens; 212 | } 213 | } 214 | } 215 | 216 | function concatTokens(left: Token[], right: Token[]): Token[] { 217 | return left.concat(right); 218 | } 219 | 220 | export function expandAliases(tokens: Token[], aliases: Aliases): Token[] { 221 | if (tokens.length === 0) { 222 | return []; 223 | } 224 | 225 | const commandWordToken = tokens[0]; 226 | const argumentTokens = tokens.slice(1); 227 | 228 | if (aliases.has(commandWordToken.value)) { 229 | const alias = aliases.get(commandWordToken.value); 230 | const aliasTokens = scan(alias); 231 | const isRecursive = aliasTokens[0].value === commandWordToken.value; 232 | 233 | if (isRecursive) { 234 | return concatTokens(aliasTokens, argumentTokens); 235 | } else { 236 | return concatTokens(expandAliases(scan(alias), aliases), argumentTokens); 237 | } 238 | } else { 239 | return tokens; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /test/shell/scanner_spec.ts: -------------------------------------------------------------------------------- 1 | import {expect} from "chai"; 2 | import { 3 | scan, Word, DoubleQuotedStringLiteral, SingleQuotedStringLiteral, 4 | Pipe, OutputRedirectionSymbol, AppendingOutputRedirectionSymbol, InputRedirectionSymbol, Invalid, Semicolon, 5 | } from "../../src/shell/Scanner"; 6 | 7 | describe("scan", () => { 8 | it("returns no tokens on empty input", () => { 9 | const tokens = scan(""); 10 | 11 | expect(tokens.length).to.eq(0); 12 | }); 13 | 14 | it("returns an invalid token on input that consists only of spaces", () => { 15 | const tokens = scan(" "); 16 | 17 | expect(tokens.length).to.eq(1); 18 | expect(tokens[0]).to.be.an.instanceof(Invalid); 19 | }); 20 | 21 | it("splits on a space", () => { 22 | const tokens = scan("some words"); 23 | 24 | expect(tokens.length).to.eq(2); 25 | expect(tokens[0]).to.be.an.instanceof(Word); 26 | expect(tokens[1]).to.be.an.instanceof(Word); 27 | 28 | expect(tokens.map(token => token.value)).to.eql(["some", "words"]); 29 | }); 30 | 31 | it("doesn't split inside double quotes", () => { 32 | const tokens = scan('prefix "inside quotes"'); 33 | 34 | expect(tokens.length).to.eq(2); 35 | expect(tokens[0]).to.be.an.instanceof(Word); 36 | expect(tokens[1]).to.be.an.instanceof(DoubleQuotedStringLiteral); 37 | 38 | expect(tokens.map(token => token.value)).to.eql(["prefix", "inside quotes"]); 39 | }); 40 | 41 | it("doesn't split inside single quotes", () => { 42 | const tokens = scan("prefix 'inside quotes'"); 43 | 44 | expect(tokens.length).to.eq(2); 45 | expect(tokens[0]).to.be.an.instanceof(Word); 46 | expect(tokens[1]).to.be.an.instanceof(SingleQuotedStringLiteral); 47 | 48 | expect(tokens.map(token => token.value)).to.eql(["prefix", "inside quotes"]); 49 | }); 50 | 51 | it("doesn't split on an escaped space", () => { 52 | const tokens = scan("prefix single\\ token"); 53 | 54 | expect(tokens.length).to.eq(2); 55 | expect(tokens[0]).to.be.an.instanceof(Word); 56 | expect(tokens[1]).to.be.an.instanceof(Word); 57 | 58 | expect(tokens.map(token => token.value)).to.eql(["prefix", "single token"]); 59 | }); 60 | 61 | it("doesn't split on a colon", () => { 62 | const tokens = scan("curl http://www.example.com"); 63 | 64 | expect(tokens.length).to.eq(2); 65 | expect(tokens[0]).to.be.an.instanceof(Word); 66 | expect(tokens[1]).to.be.an.instanceof(Word); 67 | 68 | expect(tokens.map(token => token.value)).to.eql(["curl", "http://www.example.com"]); 69 | }); 70 | 71 | it("can handle special characters", () => { 72 | const tokens = scan("ls --color=tty -lh"); 73 | 74 | expect(tokens.length).to.eq(3); 75 | expect(tokens[0]).to.be.an.instanceof(Word); 76 | expect(tokens[1]).to.be.an.instanceof(Word); 77 | expect(tokens[2]).to.be.an.instanceof(Word); 78 | 79 | expect(tokens.map(token => token.value)).to.eql(["ls", "--color=tty", "-lh"]); 80 | }); 81 | 82 | it("recognizes a pipe", () => { 83 | const tokens = scan("cat file | grep word"); 84 | 85 | expect(tokens.length).to.eq(5); 86 | expect(tokens[0]).to.be.an.instanceof(Word); 87 | expect(tokens[1]).to.be.an.instanceof(Word); 88 | expect(tokens[2]).to.be.an.instanceof(Pipe); 89 | expect(tokens[3]).to.be.an.instanceof(Word); 90 | expect(tokens[4]).to.be.an.instanceof(Word); 91 | 92 | expect(tokens.map(token => token.value)).to.eql(["cat", "file", "|", "grep", "word"]); 93 | }); 94 | 95 | it("recognizes a semicolon", () => { 96 | const tokens = scan("cd directory; rm file"); 97 | 98 | expect(tokens.length).to.eq(5); 99 | expect(tokens[0]).to.be.an.instanceof(Word); 100 | expect(tokens[1]).to.be.an.instanceof(Word); 101 | expect(tokens[2]).to.be.an.instanceof(Semicolon); 102 | expect(tokens[3]).to.be.an.instanceof(Word); 103 | expect(tokens[4]).to.be.an.instanceof(Word); 104 | 105 | expect(tokens.map(token => token.value)).to.eql(["cd", "directory", ";", "rm", "file"]); 106 | }); 107 | 108 | it("recognizes input redirection", () => { 109 | const tokens = scan("cat < file"); 110 | 111 | expect(tokens.length).to.eq(3); 112 | expect(tokens[0]).to.be.an.instanceof(Word); 113 | expect(tokens[1]).to.be.an.instanceof(InputRedirectionSymbol); 114 | expect(tokens[2]).to.be.an.instanceof(Word); 115 | 116 | expect(tokens.map(token => token.value)).to.eql(["cat", "<", "file"]); 117 | }); 118 | 119 | it("recognizes output redirection", () => { 120 | const tokens = scan("cat file > another_file"); 121 | 122 | expect(tokens.length).to.eq(4); 123 | expect(tokens[0]).to.be.an.instanceof(Word); 124 | expect(tokens[1]).to.be.an.instanceof(Word); 125 | expect(tokens[2]).to.be.an.instanceof(OutputRedirectionSymbol); 126 | expect(tokens[3]).to.be.an.instanceof(Word); 127 | 128 | expect(tokens.map(token => token.value)).to.eql(["cat", "file", ">", "another_file"]); 129 | }); 130 | 131 | it("recognizes appending output redirection", () => { 132 | const tokens = scan("cat file >> another_file"); 133 | 134 | expect(tokens.length).to.eq(4); 135 | expect(tokens[0]).to.be.an.instanceof(Word); 136 | expect(tokens[1]).to.be.an.instanceof(Word); 137 | expect(tokens[2]).to.be.an.instanceof(AppendingOutputRedirectionSymbol); 138 | expect(tokens[3]).to.be.an.instanceof(Word); 139 | 140 | expect(tokens.map(token => token.value)).to.eql(["cat", "file", ">>", "another_file"]); 141 | }); 142 | 143 | it("can handle unicode é", () => { 144 | const tokens = scan("cd é/"); 145 | expect(tokens.map(token => token.value)).to.eql(["cd", "é/"]); 146 | }); 147 | 148 | it("can handle 'x+' (regression test for #753)", () => { 149 | const tokens = scan("cd x+"); 150 | expect(tokens.map(token => token.value)).to.eql(["cd", "x+"]); 151 | }); 152 | 153 | describe("invalid input", () => { 154 | it("adds an invalid token", async() => { 155 | const tokens = scan("cd '"); 156 | 157 | expect(tokens.length).to.eq(2); 158 | expect(tokens[0]).to.be.an.instanceof(Word); 159 | expect(tokens[1]).to.be.an.instanceof(Invalid); 160 | 161 | expect(tokens.map(token => token.value)).to.eql(["cd", "'"]); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/NPM.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "path"; 2 | import {Suggestion, styles} from "../autocompletion_utils/Common"; 3 | import {exists, readFile, mapObject} from "../../utils/Common"; 4 | import {PluginManager} from "../../PluginManager"; 5 | 6 | const npmCommandConfig = [ 7 | { 8 | name: "access", 9 | description: "Set access level on published packages", 10 | }, 11 | { 12 | name: "adduser", 13 | description: "Add a registry user account", 14 | }, 15 | { 16 | name: "bin", 17 | description: "Display npm bin folder", 18 | }, 19 | { 20 | name: "bugs", 21 | description: "Bugs for a package in a web browser maybe", 22 | }, 23 | { 24 | name: "build", 25 | description: "Build a package", 26 | }, 27 | { 28 | name: "bundle", 29 | description: "REMOVED", 30 | }, 31 | { 32 | name: "cache", 33 | description: "Manipulates packages cache", 34 | }, 35 | { 36 | name: "completion", 37 | description: "Tab Completion for npm", 38 | }, 39 | { 40 | name: "config", 41 | description: "Manage the npm configuration files", 42 | }, 43 | { 44 | name: "dedupe", 45 | description: "Reduce duplication", 46 | }, 47 | { 48 | name: "deprecate", 49 | description: "Deprecate a version of a package", 50 | }, 51 | { 52 | name: "dist-tag", 53 | description: "Modify package distribution tags", 54 | }, 55 | { 56 | name: "docs", 57 | description: "Docs for a package in a web browser maybe", 58 | }, 59 | { 60 | name: "edit", 61 | description: "Edit an installed package", 62 | }, 63 | { 64 | name: "explore", 65 | description: "Browse an installed package", 66 | }, 67 | { 68 | name: "help", 69 | description: "Get help on npm", 70 | }, 71 | { 72 | name: "help-search", 73 | description: "Search npm help documentation", 74 | }, 75 | { 76 | name: "init", 77 | description: "Interactively create a package.json file", 78 | }, 79 | { 80 | name: "install", 81 | description: "Install a package", 82 | }, 83 | { 84 | name: "install-test", 85 | description: "", 86 | }, 87 | { 88 | name: "link", 89 | description: "Symlink a package folder", 90 | }, 91 | { 92 | name: "logout", 93 | description: "Log out of the registry", 94 | }, 95 | { 96 | name: "ls", 97 | description: "List installed packages", 98 | }, 99 | { 100 | name: "npm", 101 | description: "javascript package manager", 102 | }, 103 | { 104 | name: "outdated", 105 | description: "Check for outdated packages", 106 | }, 107 | { 108 | name: "owner", 109 | description: "Manage package owners", 110 | }, 111 | { 112 | name: "pack", 113 | description: "Create a tarball from a package", 114 | }, 115 | { 116 | name: "ping", 117 | description: "Ping npm registry", 118 | }, 119 | { 120 | name: "prefix", 121 | description: "Display prefix", 122 | }, 123 | { 124 | name: "prune", 125 | description: "Remove extraneous packages", 126 | }, 127 | { 128 | name: "publish", 129 | description: "Publish a package", 130 | }, 131 | { 132 | name: "rebuild", 133 | description: "Rebuild a package", 134 | }, 135 | { 136 | name: "repo", 137 | description: "Open package repository page in the browser", 138 | }, 139 | { 140 | name: "restart", 141 | description: "Restart a package", 142 | }, 143 | { 144 | name: "root", 145 | description: "Display npm root", 146 | }, 147 | { 148 | name: "run", 149 | description: "Run arbitrary package scripts", 150 | }, 151 | { 152 | name: "search", 153 | description: "Search for packages", 154 | }, 155 | { 156 | name: "shrinkwrap", 157 | description: "Lock down dependency versions", 158 | }, 159 | { 160 | name: "star", 161 | description: "Mark your favorite packages", 162 | }, 163 | { 164 | name: "stars", 165 | description: "View packages marked as favorites", 166 | }, 167 | { 168 | name: "start", 169 | description: "Start a package", 170 | }, 171 | { 172 | name: "stop", 173 | description: "Stop a package", 174 | }, 175 | { 176 | name: "tag", 177 | description: "Tag a published version", 178 | }, 179 | { 180 | name: "team", 181 | description: "Manage organization teams and team memberships", 182 | }, 183 | { 184 | name: "test", 185 | description: "Test a package", 186 | }, 187 | { 188 | name: "uninstall", 189 | description: "Remove a package", 190 | }, 191 | { 192 | name: "unpublish", 193 | description: "Remove a package from the registry", 194 | }, 195 | { 196 | name: "update", 197 | description: "Update a package", 198 | }, 199 | { 200 | name: "version", 201 | description: "Bump a package version", 202 | }, 203 | { 204 | name: "view", 205 | description: "View registry info", 206 | }, 207 | { 208 | name: "whoami", 209 | description: "Display npm username", 210 | }, 211 | ]; 212 | 213 | const npmCommand = npmCommandConfig.map(config => new Suggestion({value: config.name, description: config.description, style: styles.command})); 214 | 215 | PluginManager.registerAutocompletionProvider("npm", async (context) => { 216 | if (context.argument.position === 1) { 217 | return npmCommand; 218 | } else if (context.argument.position === 2) { 219 | const firstArgument = context.argument.command.nthArgument(1); 220 | 221 | if (firstArgument && firstArgument.value === "run") { 222 | const packageFilePath = Path.join(context.environment.pwd, "package.json"); 223 | 224 | if (await exists(packageFilePath)) { 225 | const parsed = JSON.parse(await readFile(packageFilePath)).scripts || {}; 226 | return mapObject(parsed, (key: string, value: string) => new Suggestion({value: key, description: value, style: styles.command})); 227 | } else { 228 | return []; 229 | } 230 | } else { 231 | // TODO: handle npm sub commands other than "run" that can be 232 | // further auto-completed 233 | return []; 234 | } 235 | } else { 236 | return []; 237 | } 238 | }); 239 | -------------------------------------------------------------------------------- /src/shell/Job.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as i from "../Interfaces"; 3 | import * as React from "react"; 4 | import {Session} from "./Session"; 5 | import {ANSIParser} from "../ANSIParser"; 6 | import {Prompt} from "./Prompt"; 7 | import {ScreenBuffer} from "../ScreenBuffer"; 8 | import {CommandExecutor, NonZeroExitCodeError} from "./CommandExecutor"; 9 | import {PTY} from "../PTY"; 10 | import {PluginManager} from "../PluginManager"; 11 | import {EmitterWithUniqueID} from "../EmitterWithUniqueID"; 12 | import {Status} from "../Enums"; 13 | import {Environment} from "./Environment"; 14 | import {normalizeKey} from "../utils/Common"; 15 | import {TerminalLikeDevice} from "../Interfaces"; 16 | import {History} from "./History"; 17 | 18 | function makeThrottledDataEmitter(timesPerSecond: number, subject: EmitterWithUniqueID) { 19 | return _.throttle(() => subject.emit("data"), 1000 / timesPerSecond); 20 | } 21 | 22 | export class Job extends EmitterWithUniqueID implements TerminalLikeDevice { 23 | public command: PTY; 24 | public status: Status = Status.NotStarted; 25 | public readonly parser: ANSIParser; 26 | public interceptionResult: React.ReactElement | undefined; 27 | private readonly _prompt: Prompt; 28 | private readonly _screenBuffer: ScreenBuffer; 29 | private readonly rareDataEmitter: Function; 30 | private readonly frequentDataEmitter: Function; 31 | private executedWithoutInterceptor: boolean = false; 32 | 33 | constructor(private _session: Session) { 34 | super(); 35 | 36 | this._prompt = new Prompt(this); 37 | this._prompt.on("send", () => this.execute()); 38 | 39 | this.rareDataEmitter = makeThrottledDataEmitter(1, this); 40 | this.frequentDataEmitter = makeThrottledDataEmitter(60, this); 41 | 42 | this._screenBuffer = new ScreenBuffer(); 43 | this._screenBuffer.on("data", this.throttledDataEmitter); 44 | this.parser = new ANSIParser(this); 45 | } 46 | 47 | async executeWithoutInterceptor(): Promise { 48 | if (!this.executedWithoutInterceptor) { 49 | this.executedWithoutInterceptor = true; 50 | try { 51 | await CommandExecutor.execute(this); 52 | 53 | // Need to check the status here because it's 54 | // executed even after the process was interrupted. 55 | if (this.status === Status.InProgress) { 56 | this.setStatus(Status.Success); 57 | } 58 | this.emit("end"); 59 | } catch (exception) { 60 | this.handleError(exception); 61 | } 62 | } 63 | } 64 | 65 | async execute({allowInterception = true} = {}): Promise { 66 | History.add(this.prompt.value); 67 | 68 | if (this.status === Status.NotStarted) { 69 | this.setStatus(Status.InProgress); 70 | } 71 | 72 | const commandWords: string[] = this.prompt.expandedTokens.map(token => token.escapedValue); 73 | const interceptorOptions = { 74 | command: commandWords, 75 | presentWorkingDirectory: this.environment.pwd, 76 | }; 77 | const interceptor = PluginManager.commandInterceptorPlugins.find( 78 | potentialInterceptor => potentialInterceptor.isApplicable(interceptorOptions) 79 | ); 80 | 81 | await Promise.all(PluginManager.preexecPlugins.map(plugin => plugin(this))); 82 | if (interceptor && allowInterception) { 83 | if (!this.interceptionResult) { 84 | try { 85 | this.interceptionResult = await interceptor.intercept(interceptorOptions); 86 | this.setStatus(Status.Success); 87 | } catch (e) { 88 | await this.executeWithoutInterceptor(); 89 | } 90 | } 91 | } else { 92 | await this.executeWithoutInterceptor(); 93 | } 94 | this.emit("end"); 95 | } 96 | 97 | handleError(message: NonZeroExitCodeError | string): void { 98 | this.setStatus(Status.Failure); 99 | if (message) { 100 | if (message instanceof NonZeroExitCodeError) { 101 | // Do nothing. 102 | } else { 103 | this._screenBuffer.writeMany(message); 104 | } 105 | } 106 | this.emit("end"); 107 | } 108 | 109 | // Writes to the process' STDIN. 110 | write(input: string|KeyboardEvent) { 111 | let text: string; 112 | 113 | if (typeof input === "string") { 114 | text = input; 115 | } else { 116 | text = input.ctrlKey ? String.fromCharCode(input.keyCode - 64) : normalizeKey(input.key, this.screenBuffer.cursorKeysMode); 117 | } 118 | 119 | this.command.write(text); 120 | } 121 | 122 | get session(): Session { 123 | return this._session; 124 | } 125 | 126 | get dimensions(): Dimensions { 127 | return this.session.dimensions; 128 | } 129 | 130 | set dimensions(dimensions: Dimensions) { 131 | this.session.dimensions = dimensions; 132 | this.winch(); 133 | } 134 | 135 | hasOutput(): boolean { 136 | return !this._screenBuffer.isEmpty(); 137 | } 138 | 139 | interrupt(): void { 140 | if (this.command && this.status === Status.InProgress) { 141 | this.command.kill("SIGINT"); 142 | this.setStatus(Status.Interrupted); 143 | this.emit("end"); 144 | } 145 | } 146 | 147 | winch(): void { 148 | if (this.command && this.status === Status.InProgress) { 149 | this.command.dimensions = this.dimensions; 150 | } 151 | } 152 | 153 | canBeDecorated(): boolean { 154 | return !!this.firstApplicableDecorator; 155 | } 156 | 157 | decorate(): React.ReactElement { 158 | if (this.firstApplicableDecorator) { 159 | return this.firstApplicableDecorator.decorate(this); 160 | } else { 161 | throw "No applicable decorator found."; 162 | } 163 | } 164 | 165 | get environment(): Environment { 166 | // TODO: implement inline environment variable setting. 167 | return this.session.environment; 168 | } 169 | 170 | private get decorators(): i.OutputDecorator[] { 171 | return PluginManager.outputDecorators.filter(decorator => 172 | this.status === Status.InProgress ? decorator.shouldDecorateRunningPrograms : true 173 | ); 174 | } 175 | 176 | private get firstApplicableDecorator(): i.OutputDecorator | undefined { 177 | return this.decorators.find(decorator => decorator.isApplicable(this)); 178 | } 179 | 180 | get screenBuffer(): ScreenBuffer { 181 | return this._screenBuffer; 182 | } 183 | 184 | get prompt(): Prompt { 185 | return this._prompt; 186 | } 187 | 188 | setStatus(status: Status): void { 189 | this.status = status; 190 | this.emit("status", status); 191 | } 192 | 193 | private throttledDataEmitter = () => 194 | this._screenBuffer.size < ScreenBuffer.hugeOutputThreshold ? this.frequentDataEmitter() : this.rareDataEmitter(); 195 | } 196 | -------------------------------------------------------------------------------- /src/views/1_ApplicationComponent.tsx: -------------------------------------------------------------------------------- 1 | import {SessionComponent} from "./2_SessionComponent"; 2 | import {TabComponent, TabProps, Tab} from "./TabComponent"; 3 | import * as React from "react"; 4 | import * as _ from "lodash"; 5 | import {ipcRenderer} from "electron"; 6 | import {remote} from "electron"; 7 | import * as css from "./css/main"; 8 | import {saveWindowBounds} from "./ViewUtils"; 9 | import {StatusBarComponent} from "./StatusBarComponent"; 10 | import {PaneTree, Pane} from "../utils/PaneTree"; 11 | import {SearchComponent} from "./SearchComponent"; 12 | 13 | export class ApplicationComponent extends React.Component<{}, {}> { 14 | private tabs: Tab[] = []; 15 | private focusedTabIndex: number; 16 | 17 | constructor(props: {}) { 18 | super(props); 19 | const electronWindow = remote.BrowserWindow.getAllWindows()[0]; 20 | 21 | this.addTab(false); 22 | 23 | electronWindow 24 | .on("move", () => saveWindowBounds(electronWindow)) 25 | .on("resize", () => { 26 | saveWindowBounds(electronWindow); 27 | this.recalculateDimensions(); 28 | }) 29 | .webContents 30 | .on("devtools-opened", () => this.recalculateDimensions()) 31 | .on("devtools-closed", () => this.recalculateDimensions()); 32 | 33 | ipcRenderer.on("change-working-directory", (event: Electron.IpcRendererEvent, directory: string) => 34 | this.focusedTab.focusedPane.session.directory = directory 35 | ); 36 | 37 | window.onbeforeunload = () => { 38 | electronWindow 39 | .removeAllListeners() 40 | .webContents 41 | .removeAllListeners("devtools-opened") 42 | .removeAllListeners("devtools-closed") 43 | .removeAllListeners("found-in-page"); 44 | 45 | this.closeAllTabs(); 46 | }; 47 | 48 | window.application = this; 49 | } 50 | 51 | addTab(forceUpdate = true): void { 52 | if (this.tabs.length < 9) { 53 | this.tabs.push(new Tab(this)); 54 | this.focusedTabIndex = this.tabs.length - 1; 55 | if (forceUpdate) this.forceUpdate(); 56 | } else { 57 | remote.shell.beep(); 58 | } 59 | 60 | window.focusedTab = this.focusedTab; 61 | } 62 | 63 | focusTab(position: OneBasedPosition): void { 64 | const index = position === 9 ? this.tabs.length : position - 1; 65 | 66 | if (this.tabs.length > index) { 67 | this.focusedTabIndex = index; 68 | this.forceUpdate(); 69 | } else { 70 | remote.shell.beep(); 71 | } 72 | 73 | window.focusedTab = this.focusedTab; 74 | } 75 | 76 | closeFocusedTab() { 77 | this.closeTab(this.focusedTab); 78 | 79 | this.forceUpdate(); 80 | } 81 | 82 | activatePreviousTab() { 83 | let newPosition = this.focusedTabIndex - 1; 84 | 85 | if (newPosition < 0) { 86 | newPosition = this.tabs.length - 1; 87 | } 88 | 89 | this.focusTab(newPosition + 1); 90 | } 91 | 92 | activateNextTab() { 93 | let newPosition = this.focusedTabIndex + 1; 94 | 95 | if (newPosition >= this.tabs.length) { 96 | newPosition = 0; 97 | } 98 | 99 | this.focusTab(newPosition + 1); 100 | } 101 | 102 | // FIXME: this method should be private. 103 | closeFocusedPane() { 104 | this.focusedTab.closeFocusedPane(); 105 | 106 | if (this.focusedTab.panes.size === 0) { 107 | this.closeTab(this.focusedTab); 108 | } 109 | 110 | this.forceUpdate(); 111 | } 112 | 113 | render() { 114 | let tabs: React.ReactElement[] | undefined; 115 | 116 | if (this.tabs.length > 1) { 117 | tabs = this.tabs.map((tab: Tab, index: number) => 118 | { 122 | this.focusedTabIndex = index; 123 | this.forceUpdate(); 124 | }} 125 | closeHandler={(event: React.MouseEvent) => { 126 | this.closeTab(this.tabs[index]); 127 | this.forceUpdate(); 128 | 129 | event.stopPropagation(); 130 | event.preventDefault(); 131 | }}> 132 | 133 | ); 134 | } 135 | 136 | return ( 137 |
    138 |
    139 |
      {tabs}
    140 | 141 |
    142 | {this.renderPanes(this.focusedTab.panes)} 143 | 144 |
    145 | ); 146 | } 147 | 148 | private renderPanes(tree: PaneTree): JSX.Element { 149 | if (tree instanceof Pane) { 150 | const pane = tree; 151 | const session = pane.session; 152 | const isFocused = pane === this.focusedTab.focusedPane; 153 | 154 | return ( 155 | this.forceUpdate() : undefined} 159 | focus={() => { 160 | this.focusedTab.activatePane(pane); 161 | this.forceUpdate(); 162 | }}> 163 | 164 | ); 165 | } else { 166 | return
    {tree.children.map(child => this.renderPanes(child))}
    ; 167 | } 168 | } 169 | 170 | private recalculateDimensions() { 171 | for (const tab of this.tabs) { 172 | tab.updateAllPanesDimensions(); 173 | } 174 | } 175 | 176 | private get focusedTab(): Tab { 177 | return this.tabs[this.focusedTabIndex]; 178 | } 179 | 180 | private closeTab(tab: Tab, quit = true): void { 181 | tab.closeAllPanes(); 182 | _.pull(this.tabs, tab); 183 | 184 | if (this.tabs.length === 0 && quit) { 185 | ipcRenderer.send("quit"); 186 | } else if (this.tabs.length === this.focusedTabIndex) { 187 | this.focusedTabIndex -= 1; 188 | } 189 | 190 | window.focusedTab = this.focusedTab; 191 | } 192 | 193 | private closeAllTabs(): void { 194 | // Can't use forEach here because closeTab changes the array being iterated. 195 | while (this.tabs.length) { 196 | this.closeTab(this.tabs[0], false); 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/views/UserEventsHander.ts: -------------------------------------------------------------------------------- 1 | import {ApplicationComponent} from "./1_ApplicationComponent"; 2 | import {SessionComponent} from "./2_SessionComponent"; 3 | import {PromptComponent} from "./4_PromptComponent"; 4 | import {JobComponent} from "./3_JobComponent"; 5 | import {Tab} from "./TabComponent"; 6 | import {Status, KeyboardAction} from "../Enums"; 7 | import {isModifierKey} from "./ViewUtils"; 8 | import {SearchComponent} from "./SearchComponent"; 9 | import {remote} from "electron"; 10 | import {buildMenuTemplate} from "./menu/Menu"; 11 | import {isKeybindingForEvent} from "./keyevents/Keybindings"; 12 | 13 | export type UserEvent = KeyboardEvent | ClipboardEvent; 14 | 15 | export const handleUserEvent = (application: ApplicationComponent, 16 | tab: Tab, 17 | session: SessionComponent, 18 | job: JobComponent, 19 | prompt: PromptComponent, 20 | search: SearchComponent) => (event: UserEvent) => { 21 | if (event instanceof ClipboardEvent) { 22 | if (search.isFocused) { 23 | return; 24 | } 25 | 26 | if (!isInProgress(job)) { 27 | prompt.focus(); 28 | return; 29 | } 30 | 31 | job.props.job.write(event.clipboardData.getData("text/plain")); 32 | 33 | event.stopPropagation(); 34 | event.preventDefault(); 35 | 36 | return; 37 | } 38 | 39 | // Close focused pane 40 | if (isKeybindingForEvent(event, KeyboardAction.paneClose) && !isInProgress(job)) { 41 | application.closeFocusedPane(); 42 | 43 | application.forceUpdate(); 44 | 45 | event.stopPropagation(); 46 | event.preventDefault(); 47 | return; 48 | } 49 | 50 | // Change tab action 51 | if (isKeybindingForEvent(event, KeyboardAction.tabFocus)) { 52 | const position = parseInt(event.key, 10); 53 | application.focusTab(position); 54 | 55 | event.stopPropagation(); 56 | event.preventDefault(); 57 | return; 58 | } 59 | 60 | // Enable debug mode 61 | if (isKeybindingForEvent(event, KeyboardAction.developerToggleDebugMode)) { 62 | window.DEBUG = !window.DEBUG; 63 | 64 | require("devtron").install(); 65 | console.log(`Debugging mode has been ${window.DEBUG ? "enabled" : "disabled"}.`); 66 | 67 | application.forceUpdate(); 68 | 69 | event.stopPropagation(); 70 | event.preventDefault(); 71 | return; 72 | } 73 | 74 | // Console clear 75 | if (isKeybindingForEvent(event, KeyboardAction.cliClearJobs) && !isInProgress(job)) { 76 | session.props.session.clearJobs(); 77 | 78 | event.stopPropagation(); 79 | event.preventDefault(); 80 | return; 81 | } 82 | 83 | if (event.metaKey) { 84 | event.stopPropagation(); 85 | // Don't prevent default to be able to open developer tools and such. 86 | return; 87 | } 88 | 89 | if (search.isFocused) { 90 | // Search close 91 | if (isKeybindingForEvent(event, KeyboardAction.editFindClose)) { 92 | search.clearSelection(); 93 | setTimeout(() => prompt.focus(), 0); 94 | 95 | event.stopPropagation(); 96 | event.preventDefault(); 97 | return; 98 | } 99 | 100 | return; 101 | } 102 | 103 | 104 | if (isInProgress(job) && !isModifierKey(event)) { 105 | // CLI interrupt 106 | if (isKeybindingForEvent(event, KeyboardAction.cliInterrupt)) { 107 | job.props.job.interrupt(); 108 | } else { 109 | job.props.job.write(event); 110 | } 111 | 112 | event.stopPropagation(); 113 | event.preventDefault(); 114 | return; 115 | } 116 | 117 | prompt.focus(); 118 | 119 | // Append last argument to prompt 120 | if (isKeybindingForEvent(event, KeyboardAction.cliAppendLastArgumentOfPreviousCommand)) { 121 | prompt.appendLastLArgumentOfPreviousCommand(); 122 | 123 | event.stopPropagation(); 124 | event.preventDefault(); 125 | return; 126 | } 127 | 128 | if (!isInProgress(job)) { 129 | // CLI Delete word 130 | if (isKeybindingForEvent(event, KeyboardAction.cliDeleteWord)) { 131 | prompt.deleteWord(); 132 | 133 | event.stopPropagation(); 134 | event.preventDefault(); 135 | return; 136 | } 137 | 138 | // CLI execute command 139 | if (isKeybindingForEvent(event, KeyboardAction.cliRunCommand)) { 140 | prompt.execute((event.target as HTMLElement).innerText); 141 | 142 | event.stopPropagation(); 143 | event.preventDefault(); 144 | return; 145 | } 146 | 147 | // CLI clear 148 | if (isKeybindingForEvent(event, KeyboardAction.cliClearText)) { 149 | prompt.clear(); 150 | 151 | event.stopPropagation(); 152 | event.preventDefault(); 153 | return; 154 | } 155 | 156 | if (prompt.isAutocompleteShown()) { 157 | if (isKeybindingForEvent(event, KeyboardAction.autocompleteInsertCompletion)) { 158 | prompt.applySuggestion(); 159 | 160 | event.stopPropagation(); 161 | event.preventDefault(); 162 | return; 163 | } 164 | 165 | if (isKeybindingForEvent(event, KeyboardAction.autocompletePreviousSuggestion)) { 166 | prompt.focusPreviousSuggestion(); 167 | 168 | event.stopPropagation(); 169 | event.preventDefault(); 170 | return; 171 | } 172 | 173 | if (isKeybindingForEvent(event, KeyboardAction.autocompleteNextSuggestion)) { 174 | prompt.focusNextSuggestion(); 175 | 176 | event.stopPropagation(); 177 | event.preventDefault(); 178 | return; 179 | } 180 | } else { 181 | if (isKeybindingForEvent(event, KeyboardAction.cliHistoryPrevious)) { 182 | prompt.setPreviousHistoryItem(); 183 | 184 | event.stopPropagation(); 185 | event.preventDefault(); 186 | return; 187 | } 188 | 189 | if (isKeybindingForEvent(event, KeyboardAction.cliHistoryNext)) { 190 | prompt.setNextHistoryItem(); 191 | 192 | event.stopPropagation(); 193 | event.preventDefault(); 194 | return; 195 | } 196 | } 197 | } 198 | 199 | prompt.setPreviousKeyCode(event); 200 | }; 201 | 202 | function isInProgress(job: JobComponent): boolean { 203 | return job.props.job.status === Status.InProgress; 204 | } 205 | 206 | const app = remote.app; 207 | const browserWindow = remote.BrowserWindow.getAllWindows()[0]; 208 | const template = buildMenuTemplate(app, browserWindow); 209 | 210 | remote.Menu.setApplicationMenu(remote.Menu.buildFromTemplate(template)); 211 | -------------------------------------------------------------------------------- /src/plugins/autocompletion_providers/Executable.ts: -------------------------------------------------------------------------------- 1 | export const commandDescriptions: Dictionary = { 2 | admin: "Create and administer SCCS files", 3 | alias: "Define or display aliases", 4 | ar: "Create and maintain library archives", 5 | asa: "Interpret carriage-control characters", 6 | at: "Execute commands at a later time", 7 | awk: "Pattern scanning and processing language", 8 | basename: "Return non-directory portion of a pathname; see also dirname", 9 | batch: "Schedule commands to be executed in a batch queue", 10 | bc: "Arbitrary-precision arithmetic language", 11 | bg: "Run jobs in the background", 12 | cc: "Compile standard C programs", 13 | cal: "Print a calendar", 14 | cat: "Concatenate and print files", 15 | cflow: "Generate a C-language flowgraph", 16 | chgrp: "Change the file group ownership", 17 | chmod: "Change the file modes/attributes/permissions", 18 | chown: "Change the file ownership", 19 | cksum: "Write file checksums and sizes", 20 | cmp: "Compare two files; see also diff", 21 | comm: "Select or reject lines common to two files", 22 | command: "Execute a simple command", 23 | compress: "Compress data", 24 | cp: "Copy files", 25 | crontab: "Schedule periodic background work", 26 | csplit: "Split files based on context", 27 | ctags: "Create a tags file", 28 | cut: "Cut out selected fields of each line of a file", 29 | cxref: "Generate a C-language program cross-reference table", 30 | date: "Display the date and time", 31 | dd: "Convert and copy a file", 32 | delta: "Make a delta (change) to an SCCS file", 33 | df: "Report free disk space", 34 | diff: "Compare two files; see also cmp", 35 | dirname: "Return the directory portion of a pathname; see also basename", 36 | du: "Estimate file space usage", 37 | echo: "Write arguments to standard output", 38 | ed: "The standard text editor", 39 | env: "Set the environment for command job", 40 | ex: "Text editor", 41 | expand: "Convert tabs to spaces", 42 | expr: "Evaluate arguments as an expression", 43 | FALSE: "Return false value", 44 | fc: "Process the command history list", 45 | fg: "Run jobs in the foreground", 46 | file: "Determine file type", 47 | find: "Find files", 48 | fold: "Filter for folding lines", 49 | fort77: "FORTRAN compiler", 50 | fuser: "List process IDs of all processes that have one or more files open", 51 | gencat: "Generate a formatted message catalog", 52 | get: "Get a version of an SCCS file", 53 | getconf: "Get configuration values", 54 | getopts: "Parse utility options", 55 | grep: "Search text for a pattern", 56 | hash: "hash database access method", 57 | head: "Copy the first part of files", 58 | iconv: "Codeset conversion", 59 | id: "Return user identity", 60 | ipcrm: "Remove a message queue, semaphore set, or shared memory segment identifier", 61 | ipcs: "Report interprocess communication facilities status", 62 | jobs: "Display status of jobs in the current session", 63 | join: "Merges two sorted text files based on the presence of a common field", 64 | kill: "Terminate or signal processes", 65 | lex: "Generate programs for lexical tasks", 66 | link: "Create a hard link to a file", 67 | ln: "Link files", 68 | locale: "Get locale-specific information", 69 | localedef: "Define locale environment", 70 | logger: "Log messages", 71 | logname: "Return the user\"s login name", 72 | lp: "Send files to a printer", 73 | ls: "List directory contents", 74 | m4: "Macro processor", 75 | mailx: "Process messages", 76 | make: "Maintain, update, and regenerate groups of programs", 77 | man: "Display system documentation", 78 | mesg: "Permit or deny messages", 79 | mkdir: "Make directories", 80 | mkfifo: "Make FIFO special files", 81 | more: "Display files on a page-by-page basis", 82 | mv: "Move files", 83 | newgrp: "Change to a new group (functionaliy similar to sg[1])", 84 | nice: "Invoke a utility with an altered nice value", 85 | nl: "Line numbering filter", 86 | nm: "Write the name list of an object file", 87 | nohup: "Invoke a utility immune to hangups", 88 | od: "Dump files in various formats", 89 | paste: "Merge corresponding or subsequent lines of files", 90 | patch: "Apply changes to files", 91 | pathchk: "Check pathnames", 92 | pax: "Portable archive interchange", 93 | pr: "Print files", 94 | printf: "Write formatted output", 95 | prs: "Print an SCCS file", 96 | ps: "Report process status", 97 | pwd: "print working directory - Return working directory name", 98 | qalter: "Alter batch job", 99 | qdel: "Delete batch jobs", 100 | qhold: "Hold batch jobs", 101 | qmove: "Move batch jobs", 102 | qmsg: "Send message to batch jobs", 103 | qrerun: "Rerun batch jobs", 104 | qrls: "Release batch jobs", 105 | qselect: "Select batch jobs", 106 | qsig: "Signal batch jobs", 107 | qstat: "Show status of batch jobs", 108 | qsub: "Submit a script", 109 | read: "Read a line from standard input", 110 | renice: "Set nice values of running processes", 111 | rm: "Remove directory entries", 112 | rmdel: "Remove a delta from an SCCS file", 113 | rmdir: "Remove directories", 114 | sact: "Print current SCCS file-editing activity", 115 | sccs: "Front end for the SCCS subsystem", 116 | sed: "Stream editor", 117 | sh: "Shell, the standard command language interpreter", 118 | sleep: "Suspend execution for an interval", 119 | sort: "Sort, merge, or sequence check text files", 120 | split: "Split files into pieces", 121 | strings: "Find printable strings in files", 122 | strip: "Remove unnecessary information from executable files", 123 | stty: "Set the options for a terminal", 124 | tabs: "Set terminal tabs", 125 | tail: "Copy the last part of a file", 126 | talk: "Talk to another user", 127 | tee: "Duplicate the standard output", 128 | test: "Evaluate expression", 129 | time: "Time a simple command", 130 | touch: "Change file access and modification times", 131 | tput: "Change terminal characteristics", 132 | tr: "Translate characters", 133 | TRUE: "Return true value", 134 | tsort: "Topological sort", 135 | tty: "Return user\"s terminal name ", 136 | type: "Displays how a name would be interpreted if used as a command", 137 | ulimit: "Set or report file size limit", 138 | umask: "Get or set the file mode creation mask", 139 | unalias: "Remove alias definitions", 140 | uname: "Return system name", 141 | uncompress: "Expand compressed data", 142 | unexpand: "Convert spaces to tabs", 143 | unget: "Undo a previous get of an SCCS file", 144 | uniq: "Report or filter out repeated lines in a file", 145 | unlink: "Call the unlink function", 146 | uucp: "System-to-system copy", 147 | uudecode: "Decode a binary file", 148 | uuencode: "Encode a binary file", 149 | uustat: "uucp status inquiry and job control", 150 | uux: "Remote command execution", 151 | val: "Validate SCCS files", 152 | vi: "Screen-oriented (visual) display editor", 153 | wait: "Await process completion", 154 | wc: "Line, word and byte or character count", 155 | what: "Identify SCCS files", 156 | who: "Display who is on the system", 157 | write: "Write to another user\"s terminal", 158 | xargs: "Construct argument lists and invoke utility", 159 | yacc: "Yet another compiler compiler", 160 | zcat: "Expand and concatenate data", 161 | }; 162 | --------------------------------------------------------------------------------