diff --git a/package.json b/package.json index ae60798..4f8e00e 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,11 @@ "archy": "~1.0.0", "chalk": "~2.3.2", "commander": "~2.15.0", + "figures": "~2.0.0", + "fuzzysort": "~1.1.1", "js-levenshtein": "~1.1.3", "lodash": "~4.17.5", + "log-update": "~2.3.0", "pkg-dir": "~2.0.0", "tabtab": "~2.2.2" }, diff --git a/src/actions/interactive.js b/src/actions/interactive.js new file mode 100644 index 0000000..f2c4cc1 --- /dev/null +++ b/src/actions/interactive.js @@ -0,0 +1,79 @@ +const Input = require('../interactive/input'); +const logUpdate = require('log-update'); +const sortBySimilarity = require('../interactive/sort-by-similarity'); +const getAction = require('./get'); +const chalk = require('chalk'); +const figures = require('figures'); + +module.exports = workspace => { + let results = []; + let currentResult = 0; + let currentInputValue = ''; + + const modulesNames = workspace.getModulesNames(); + + const renderResults = () => { + return results + .map((result, i) => { + if (i === results.length - currentResult - 1) { + return `${chalk.red(figures.pointer)} ${chalk.bold( + result.highlight, + )}`; + } + return ` ${result.highlight}`; + }) + .join('\n'); + }; + + const renderInputValue = () => { + return `${chalk.cyan(figures.pointer)} ${currentInputValue}`; + }; + + const renderAmountOfResults = () => { + return chalk.dim.yellow(`${results.length}/${modulesNames.length}`); + }; + + const renderUi = () => { + logUpdate( + `${renderResults()}\n${renderAmountOfResults()}\n${renderInputValue()}`, + ); + }; + + results = sortBySimilarity(modulesNames, ''); + renderUi(); + + const input = new Input({ + stdin: process.stdin, + }); + + input.on('change', obj => { + currentInputValue = obj.valueWithCursor; + results = sortBySimilarity(modulesNames, obj.value); + renderUi(); + }); + + input.on('up', () => { + if (currentResult < results.length - 1) { + currentResult++; + } + renderUi(); + }); + + input.on('down', () => { + if (currentResult > 0) { + currentResult--; + } + renderUi(); + }); + + input.on('choose', () => { + if (results.length === 0) { + return; + } + input.end(); + logUpdate(''); + const chosen = results[results.length - 1 - currentResult].value; + console.log(getAction(workspace, chosen)); + process.exit(0); + }); +}; diff --git a/src/cli.js b/src/cli.js index bffe007..1d11abd 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,7 @@ const setupCompletions = require('./completions/setup-completions'); const matchAction = require('./actions/match'); const getAction = require('./actions/get'); const listAction = require('./actions/list'); +const interactiveAction = require('./actions/interactive'); const handleError = require('./handler-error'); @@ -31,26 +32,25 @@ try { program.parse(process.argv); - // if no arguments specified, show help - if (program.args.length === 0) { - program.help(); - } - const preDefinedCommands = program.commands.map(c => c._name); setupCompletions(preDefinedCommands); - const firstArg = program.rawArgs[2]; + const workspace = Workspace.loadSync(); - if (!preDefinedCommands.includes(firstArg) && firstArg !== 'completion') { - const arg = program.args[0]; - const { match, why } = program; - - const workspace = Workspace.loadSync(); - - if (match) { - console.log(matchAction(workspace, arg)); - } else { - console.log(getAction(workspace, arg, { why })); + if (program.args.length === 0) { + interactiveAction(workspace); + } else { + const firstArg = program.rawArgs[2]; + + if (!preDefinedCommands.includes(firstArg) && firstArg !== 'completion') { + const arg = program.args[0]; + const { match, why } = program; + + if (match) { + console.log(matchAction(workspace, arg)); + } else { + console.log(getAction(workspace, arg, { why })); + } } } } catch (error) { diff --git a/src/interactive/input.js b/src/interactive/input.js new file mode 100644 index 0000000..b6a0edc --- /dev/null +++ b/src/interactive/input.js @@ -0,0 +1,108 @@ +const EventEmitter = require('events'); +const chalk = require('chalk'); +const { + ctrlC, + ctrlD, + esc, + left, + right, + up, + down, + enter, + del, + backspace, +} = require('./raw-key-codes'); + +module.exports = class Input extends EventEmitter { + constructor({ stdin }) { + super(); + + this._value = []; + this.cursorPos = 0; + + stdin.setRawMode(true); + stdin.setEncoding('utf8'); + + stdin.on('error', e => { + this.end(); + console.error(e); + }); + + stdin.on('data', this.onKeyPress.bind(this)); + } + + get value() { + return this._value.join(''); + } + + get valueWithCursor() { + if (this.cursorPos === this.value.length) { + return this.value + chalk.inverse(' '); + } + + const firstChunk = this.value.slice(0, this.cursorPos); + const corsurChar = this.value.slice(this.cursorPos, this.cursorPos + 1); + const secondChunk = this.value.slice(this.cursorPos + 1); + + return firstChunk + chalk.inverse(corsurChar) + secondChunk; + } + + onKeyPress(key) { + let changed = false; + + switch (key) { + case ctrlC: + case ctrlD: + case esc: + process.emit('SIGINT'); + process.exit(1); + break; + case left: + this.cursorPos = Math.max(0, this.cursorPos - 1); + changed = true; + break; + case right: + this.cursorPos = Math.min(this._value.length, this.cursorPos + 1); + changed = true; + break; + case backspace: + if (this.cursorPos !== 0) { + this._value.splice(this.cursorPos - 1, 1); + this.cursorPos = Math.max(0, this.cursorPos - 1); + changed = true; + } + break; + case del: + if (this._value.length > this.cursorPos) { + this._value.splice(this.cursorPos, 1); + changed = true; + } + break; + case up: + this.emit('up'); + break; + case down: + this.emit('down'); + break; + case enter: + this.emit('choose'); + break; + default: + this.insertChar(key); + changed = true; + } + + if (changed) { + this.emit('change', this); + } + } + + insertChar(char) { + this._value.splice(this.cursorPos, 0, char); + this.cursorPos++; + } + + end() { + this.removeAllListeners(); + } +}; diff --git a/src/interactive/raw-key-codes.js b/src/interactive/raw-key-codes.js new file mode 100644 index 0000000..8daa842 --- /dev/null +++ b/src/interactive/raw-key-codes.js @@ -0,0 +1,10 @@ +module.exports.ctrlC = '\u0003'; +module.exports.ctrlD = '\u0004'; +module.exports.esc = '\u001b'; +module.exports.left = '\u001b\u005b\u0044'; +module.exports.right = '\u001b\u005b\u0043'; +module.exports.up = '\u001b\u005b\u0041'; +module.exports.down = '\u001b\u005b\u0042'; +module.exports.enter = '\u000d'; +module.exports.del = '\u001b\u005b\u0033\u007e'; +module.exports.backspace = '\u007f'; diff --git a/src/interactive/sort-by-similarity.js b/src/interactive/sort-by-similarity.js new file mode 100644 index 0000000..9a00a50 --- /dev/null +++ b/src/interactive/sort-by-similarity.js @@ -0,0 +1,18 @@ +const fuzzysort = require('fuzzysort'); + +module.exports = (list, string) => { + return fuzzysort + .go(string, list, { + threshold: -20, // Don't return matches worse than this (higher is faster) + limit: Infinity, // Don't return more results than this (lower is faster) + allowTypo: true, // Allwos a snigle transpoes (false is faster) + }) + .sort((a, b) => a.score > b.score) + .map(result => { + return { + value: result.target, + score: result.score, + highlight: fuzzysort.highlight(result, '\u001b[32m', '\u001b[39m') // eslint-disable-line + }; + }); +};