CLI Client-side Autocomplete

Executes silently various commands on CLI open. Parses the output
and build autocomplete cache. Autcomplete hints are displayed in
popup lists.
10.7.0-preview
Cleric-K 2019-03-24 01:21:52 +02:00
parent d912de75f1
commit f96fc0eec3
11 changed files with 627 additions and 11 deletions

View File

@ -2266,7 +2266,10 @@
"message": "<strong>Note:</strong> Leaving CLI tab or pressing Disconnect will <strong>automatically</strong> send \"<strong>exit</strong>\" to the board. With the latest firmware this will make the controller <strong>restart</strong> and unsaved changes will be <strong>lost</strong>.<p><strong><span class=\"message-negative\">Warning:</span></strong> Some commands in CLI can result in arbitrary signals being sent on the motor output pins. This can cause motors to spin up if a battery is connected. Therefore it is highly recommended to make sure that <strong>no battery is connected before entering commands in CLI</strong>."
},
"cliInputPlaceholder": {
"message": "Write your command here"
"message": "Write your command here. Press Tab for AutoComplete."
},
"cliInputPlaceholderBuilding": {
"message": "Please wait while building AutoComplete cache ..."
},
"cliEnter": {
"message": "CLI mode detected"
@ -4184,5 +4187,8 @@
},
"flashTab": {
"message": "Update Firmware"
},
"cliAutoComplete": {
"message": "Advanced CLI AutoComplete"
}
}

5
package-lock.json generated
View File

@ -3784,6 +3784,11 @@
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
"dev": true
},
"jquery-textcomplete": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/jquery-textcomplete/-/jquery-textcomplete-1.8.5.tgz",
"integrity": "sha512-WctSUxFk7GF5Tx2gHeVKrpkQ9tsV7mibBJ0AYNwEx+Zx3ZoUQgU5grkBXY3SCqpq/owMAMEvksN96DBSWT4PSg=="
},
"jquery-ui-npm": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/jquery-ui-npm/-/jquery-ui-npm-1.12.0.tgz",

View File

@ -40,6 +40,7 @@
"dependencies": {
"i18next": "^14.1.1",
"i18next-xhr-backend": "^2.0.0",
"jquery-textcomplete": "^1.8.5",
"lru_map": "^0.3.3",
"marked": "^0.6.2",
"object-hash": "^1.3.1",

View File

@ -81,3 +81,55 @@
}
/* AutoComplete */
.cli-textcomplete-dropdown {
border: 1px solid #ddd;
background-color: white;
border-radius: 5px;
max-height: 50%;
overflow: auto;
}
.cli-textcomplete-dropdown::-webkit-scrollbar {
width: 6px;
}
.cli-textcomplete-dropdown::-webkit-scrollbar-track {
background: lightgrey;
border-radius: 3px;
}
.cli-textcomplete-dropdown::-webkit-scrollbar-thumb {
background: grey;
border-radius: 3px;
}
.cli-textcomplete-dropdown li {
padding: 2px 5px;
}
.cli-textcomplete-dropdown li:hover,
.cli-textcomplete-dropdown .active {
background-color: rgb(255, 187, 0);
}
.cli-textcomplete-dropdown {
list-style: none;
padding: 0;
margin: 0;
}
.cli-textcomplete-dropdown a:hover {
cursor: pointer;
}
.cli-textcomplete-dropdown a {
font-family: monospace;
}
.cli-textcomplete-dropdown a b {
font-family: monospace;
font-weight: bold;
}
/* End AutoComplete */

481
src/js/CliAutoComplete.js Normal file
View File

@ -0,0 +1,481 @@
'use strict';
/**
* Encapsulates the AutoComplete logic
*
* Uses: https://github.com/yuku/jquery-textcomplete
* Check out the docs at https://github.com/yuku/jquery-textcomplete/tree/v1/doc
*/
var CliAutoComplete = {
configEnabled: false,
builder: { state: 'reset', numFails: 0 },
};
CliAutoComplete.isEnabled = function() {
return this.isBuilding() || (this.configEnabled && CONFIG.flightControllerIdentifier == "BTFL" && this.builder.state != 'fail');
};
CliAutoComplete.isBuilding = function() {
return this.builder.state != 'reset' && this.builder.state != 'done' && this.builder.state != 'fail';
};
CliAutoComplete.isOpen = function() {
return $('.cli-textcomplete-dropdown').is(':visible');
};
/**
* @param {boolean} force - Forces AutoComplete to be shown even if the matching strategy has less that minChars input
*/
CliAutoComplete.openLater = function(force) {
var self = this;
setTimeout(function() {
self.forceOpen = !!force;
self.$textarea.textcomplete('trigger');
self.forceOpen = false;
}, 0);
};
CliAutoComplete.setEnabled = function(enable) {
if (this.configEnabled != enable) {
this.configEnabled = enable;
if (CONFIGURATOR.cliActive && CONFIGURATOR.cliValid) {
// cli is already open
if (this.isEnabled()) {
this.builderStart();
} else if (!this.isEnabled() && !this.isBuilding()) {
this.cleanup();
}
}
}
};
CliAutoComplete.initialize = function($textarea, sendLine, writeToOutput) {
this.$textarea = $textarea;
this.forceOpen = false,
this.sendLine = sendLine;
this.writeToOutput = writeToOutput;
this.cleanup();
};
CliAutoComplete.cleanup = function() {
this.$textarea.textcomplete('destroy');
this.builder.state = 'reset';
this.builder.numFails = 0;
};
CliAutoComplete._builderWatchdogTouch = function() {
var self = this;
this._builderWatchdogStop();
GUI.timeout_add('autocomplete_builder_watchdog', function() {
if (self.builder.numFails++) {
self.builder.state = 'fail';
self.writeToOutput('Failed!<br># ');
$(self).trigger('build:stop');
} else {
// give it one more try
self.builder.state = 'reset';
self.builderStart();
}
}, 3000);
};
CliAutoComplete._builderWatchdogStop = function() {
GUI.timeout_remove('autocomplete_builder_watchdog');
};
CliAutoComplete.builderStart = function() {
if (this.builder.state == 'reset') {
this.cache = {
commands: [],
resources: [],
resourcesCount: {},
settings: [],
settingsAcceptedValues: {},
feature: [],
beeper: ['ALL'],
mixers: []
};
this.builder.commandSequence = ['help', 'dump', 'get', 'mixer list'];
this.builder.currentSetting = null;
this.builder.sentinel = '# ' + Math.random();
this.builder.state = 'init';
this.writeToOutput('<br># Building AutoComplete Cache ... ');
this.sendLine(this.builder.sentinel);
$(this).trigger('build:start');
}
};
CliAutoComplete.builderParseLine = function(line) {
var cache = this.cache;
var builder = this.builder;
var m;
this._builderWatchdogTouch();
if (line.indexOf(builder.sentinel) !== -1) {
// got sentinel
var command = builder.commandSequence.shift();
if (command && this.configEnabled) {
// next state
builder.state = 'parse-' + command;
this.sendLine(command);
this.sendLine(builder.sentinel);
} else {
// done
this._builderWatchdogStop();
if (!this.configEnabled) {
// disabled while we were building
this.writeToOutput('Cancelled!<br># ');
this.cleanup();
} else {
cache.settings.sort();
cache.commands.sort();
cache.feature.sort();
cache.beeper.sort();
cache.resources = Object.keys(cache.resourcesCount).sort();
this._initTextcomplete();
this.writeToOutput('Done!<br># ');
builder.state = 'done';
}
$(this).trigger('build:stop');
}
} else {
switch (builder.state) {
case 'parse-help':
if (m = line.match(/^(\w+)/)) {
cache.commands.push(m[1]);
}
break;
case 'parse-dump':
if (m = line.match(/^resource\s+(\w+)/i)) {
var r = m[1].toUpperCase(); // should alread be upper, but to be sure, since we depend on that later
cache.resourcesCount[r] = (cache.resourcesCount[r] || 0) + 1;
} else if (m = line.match(/^(feature|beeper)\s+-?(\w+)/i)) {
cache[m[1].toLowerCase()].push(m[2]);
}
break;
case 'parse-get':
if (m = line.match(/^(\w+)\s*=/)) {
// setting name
cache.settings.push(m[1]);
builder.currentSetting = m[1].toLowerCase();
} else if (builder.currentSetting && (m = line.match(/^(.*): (.*)/))) {
if (m[1].match(/values/i)) {
// Allowed Values
cache.settingsAcceptedValues[builder.currentSetting] = m[2].split(/\s*,\s*/).sort();
} else if (m[1].match(/range|length/i)){
// "Allowed range" or "Array length", store as string hint
cache.settingsAcceptedValues[builder.currentSetting] = m[0];
}
}
break;
case 'parse-mixer list':
if (m = line.match(/:(.+)/)) {
cache.mixers = ['list'].concat(m[1].trim().split(/\s+/));
}
break;
}
}
};
/**
* Initializes textcomplete with all the autocomplete strategies
*/
CliAutoComplete._initTextcomplete = function() {
var sendOnEnter = false;
var self = this;
var $textarea = this.$textarea;
var cache = self.cache;
// helper functions
var highlighter = function(anywhere) {
return function(value, term) {
return term ? value.replace(new RegExp((anywhere?'':'^') + '('+term+')', 'gi'), '<b>$1</b>') : value;
};
};
var highlighterAnywhere = highlighter(true);
var highlighterPrefix = highlighter(false);
var searcher = function(term, callback, array, minChars, matchPrefix) {
var res = [];
if ((minChars !== false && term.length >= minChars) || self.forceOpen || self.isOpen()) {
term = term.toLowerCase();
for (var i = 0; i < array.length; i++) {
var v = array[i].toLowerCase();
if (matchPrefix && v.startsWith(term) || !matchPrefix && v.indexOf(term) !== -1) {
res.push(array[i]);
}
}
}
callback(res);
if (self.forceOpen && res.length == 1) {
// hacky: if we came here because of Tab and there's only one match
// trigger Tab again, so that textcomplete should immediately select the only result
// instead of showing the menu
$textarea.trigger($.Event('keydown', {keyCode:9}))
}
};
var contexter = function(text) {
var val = $textarea.val();
if (val.length == text.length || val[text.length].match(/\s/)) {
return true;
}
return false; // do not show autocomplete if in the middle of a word
};
var basicReplacer = function(value) {
return '$1' + value + ' ';
};
// end helper functions
// init textcomplete
$textarea.textcomplete([],
{
maxCount: 10000,
debounce: 0,
className: 'cli-textcomplete-dropdown',
placement: 'top',
onKeydown: function(e) {
// some strategies may set sendOnEnter only at the replace stage, thus we call with timeout
// since this handler [onKeydown] is triggered before replace()
if (e.which == 13) {
setTimeout(function() {
if (sendOnEnter) {
// fake "enter" to run the textarea's handler
$textarea.trigger($.Event('keypress', {which:13}))
}
}, 0);
}
}
}
);
// textcomplete autocomplete strategies
// strategy builder helper
var strategy = function(s) {
return $.extend({
template: highlighterAnywhere,
replace: basicReplacer,
context: contexter,
index: 2
}, s);
};
$textarea.textcomplete('register', [
strategy({ // "command"
match: /^(\s*)(\w*)$/,
search: function(term, callback) {
sendOnEnter = false;
searcher(term, callback, cache.commands, false, true);
},
template: highlighterPrefix,
}),
strategy({ // "get"
match: /^(\s*get\s+)(\w*)$/i,
search: function(term, callback) {
sendOnEnter = true;
searcher(term, function(arr) {
if (arr.length > 1) {
// prepend the uncompleted term in the popup
arr = [term].concat(arr);
}
callback(arr);
}, cache.settings, 3);
}
}),
strategy({ // "set"
match: /^(\s*set\s+)(\w*)$/i,
search: function(term, callback) {
sendOnEnter = false;
searcher(term, callback, cache.settings, 3);
},
replace: function (value) {
self.openLater();
return '$1' + value + ' = ';
}
}),
strategy({ // "set with value"
match: /^(\s*set\s+(\w+)\s*=\s*)(\w*)$/i,
search: function(term, callback, match) {
var arr = [];
var settingName = match[2].toLowerCase();
this.isSettingValueArray = false;
sendOnEnter = !!term;
if (settingName in cache.settingsAcceptedValues) {
var val = cache.settingsAcceptedValues[settingName];
if (Array.isArray(val)) {
// setting uses lookup strings
this.isSettingValueArray = true
sendOnEnter = true;
searcher(term, callback, val, 0);
return;
}
// the settings uses a numeric value.
// Here we use a little trick - we use the autocomplete
// list as kind of a tooltip to display the Accepted Range hint
arr.push(val);
}
callback(arr);
},
template: highlighterAnywhere,
replace: function (value) {
if (this.isSettingValueArray) {
return basicReplacer(value);
}
},
index: 3,
isSettingValueArray: false
}),
strategy({ // "resource"
match: /^(\s*resource\s+)(\w*)$/i,
search: function(term, callback, match) {
sendOnEnter = false;
var arr = cache.resources;
if (semver.gte(CONFIG.flightControllerVersion, "4.0.0")) {
arr = ['show'].concat(arr);
} else {
arr = ['list'].concat(arr);
}
searcher(term, callback, arr, 1);
},
template: highlighterAnywhere,
replace: function(value) {
if (value in cache.resourcesCount) {
self.openLater();
} else if (value == 'list' || value == 'show') {
sendOnEnter = true;
}
return basicReplacer(value);
}
}),
strategy({ // "resource index"
match: /^(\s*resource\s+(\w+)\s+)(\d*)$/i,
search: function(term, callback, match) {
sendOnEnter = false;
this.savedTerm = term;
callback(['&lt;1-' + cache.resourcesCount[match[2].toUpperCase()] + '&gt;']);
},
replace: function(value) {
if (this.savedTerm) {
self.openLater();
return '$1$3 ';
}
},
context: function(text) {
var m;
// use this strategy only for resources with more than one index
if ((m = text.match(/^\s*resource\s+(\w+)\s/i)) && (cache.resourcesCount[m[1].toUpperCase()] || 0) > 1 ) {
return contexter(text);
}
return false;
},
index: 3,
savedTerm: null
}),
strategy({ // "resource pin"
match: /^(\s*resource\s+\w+\s+(\d*\s+)?)(\w*)$/i,
search: function(term, callback, match) {
sendOnEnter = !!term;
if (term) {
if ('none'.startsWith(term)) {
callback(['none']);
} else {
callback(['&lt;pin&gt;']);
}
} else {
callback(['&lt;pin&gt', 'none']);
}
},
template: function(value, term) {
if (value == 'none') {
return highlighterPrefix(value, term);
}
return value;
},
replace: function(value) {
if (value == 'none') {
sendOnEnter = true;
return '$1none ';
}
},
context: function(text) {
var m = text.match(/^\s*resource\s+(\w+)\s+(\d+\s)?/i);
if (m) {
// show pin/none for resources having only one index (it's not needed at the commend line)
// OR having more than one index and the index is supplied at the command line
var count = cache.resourcesCount[m[1].toUpperCase()] || 0;
if (count && (m[2] || count === 1)) {
return contexter(text);
}
}
return false;
},
index: 3
}),
strategy({ // "feature" and "beeper"
match: /^(\s*(feature|beeper)\s+(-?))(\w*)$/i,
search: function(term, callback, match) {
sendOnEnter = !!term;
var arr = cache[match[2].toLowerCase()];
if (!match[3]) {
arr = ['-', 'list'].concat(arr);
}
searcher(term, callback, arr, 1);
},
replace: function(value) {
if (value == '-') {
self.openLater(true);
return '$1-';
}
return basicReplacer(value);
},
index: 4
}),
strategy({ // "mixer"
match: /^(\s*mixer\s+)(\w*)$/i,
search: function(term, callback, match) {
sendOnEnter = true;
searcher(term, callback, cache.mixers, 1);
}
})
]);
if (semver.gte(CONFIG.flightControllerVersion, "4.0.0")) {
$textarea.textcomplete('register', [
strategy({ // "resource show all", from BF 4.0.0 onwards
match: /^(\s*resource\s+show\s+)(\w*)$/i,
search: function(term, callback, matches) {
sendOnEnter = true;
searcher(term, callback, ['all'], 1, true);
},
template: highlighterPrefix
}),
]);
}
};

View File

@ -370,6 +370,15 @@ function startProcess() {
}).change();
});
$('div.cliAutoComplete input')
.prop('checked', CliAutoComplete.configEnabled)
.change(function () {
var checked = $(this).is(':checked');
chrome.storage.local.set({'cliAutoComplete': checked});
CliAutoComplete.setEnabled(checked);
}).change();
chrome.storage.local.get('userLanguageSelect', function (result) {
var userLanguage_e = $('div.userLanguage select');
@ -530,6 +539,10 @@ function startProcess() {
}
}).change();
});
chrome.storage.local.get('cliAutoComplete', function (result) {
CliAutoComplete.setEnabled(typeof result.cliAutoComplete == 'undefined' || result.cliAutoComplete); // On by default
});
};
function checkForConfiguratorUpdates() {

View File

@ -104,6 +104,20 @@ TABS.cli.initialize = function (callback, nwGui) {
var textarea = $('.tab-cli textarea');
CliAutoComplete.initialize(textarea, self.sendLine.bind(self), writeToOutput);
$(CliAutoComplete).on('build:start', function() {
textarea
.val('')
.attr('placeholder', i18n.getMessage('cliInputPlaceholderBuilding'))
.prop('disabled', true);
});
$(CliAutoComplete).on('build:stop', function() {
textarea
.attr('placeholder', i18n.getMessage('cliInputPlaceholder'))
.prop('disabled', false)
.focus();
});
$('.tab-cli .save').click(function() {
var prefix = 'cli';
var suffix = 'txt';
@ -167,12 +181,20 @@ TABS.cli.initialize = function (callback, nwGui) {
if (event.which == tabKeyCode) {
// prevent default tabbing behaviour
event.preventDefault();
const outString = textarea.val();
const lastCommand = outString.split("\n").pop();
const command = getCliCommand(lastCommand, self.cliBuffer);
if (command) {
self.sendAutoComplete(command);
textarea.val('');
if (!CliAutoComplete.isEnabled()) {
// Native FC autoComplete
const outString = textarea.val();
const lastCommand = outString.split("\n").pop();
const command = getCliCommand(lastCommand, self.cliBuffer);
if (command) {
self.sendNativeAutoComplete(command);
textarea.val('');
}
}
else if (!CliAutoComplete.isOpen() && !CliAutoComplete.isBuilding()) {
// force show autocomplete on Tab
CliAutoComplete.openLater(true);
}
}
});
@ -182,6 +204,10 @@ TABS.cli.initialize = function (callback, nwGui) {
if (event.which == enterKeyCode) {
event.preventDefault(); // prevent the adding of new line
if (CliAutoComplete.isBuilding()) {
return; // silently ignore commands if autocomplete is still building
}
var out_string = textarea.val();
self.history.add(out_string.trim());
@ -212,6 +238,10 @@ TABS.cli.initialize = function (callback, nwGui) {
var keyUp = {38: true},
keyDown = {40: true};
if (CliAutoComplete.isOpen()) {
return; // disable history keys if autocomplete is open
}
if (event.keyCode in keyUp) {
textarea.val(self.history.prev());
}
@ -268,6 +298,11 @@ function writeToOutput(text) {
}
function writeLineToOutput(text) {
if (CliAutoComplete.isBuilding()) {
CliAutoComplete.builderParseLine(text);
return; // suppress output if in building state
}
if (text.startsWith("###ERROR: ")) {
writeToOutput('<span class="error_message">' + text + '</span><br>');
} else {
@ -336,13 +371,17 @@ TABS.cli.read = function (readInfo) {
break;
case backspaceCode:
this.cliBuffer = this.cliBuffer.slice(0, -1);
break;
this.outputHistory = this.outputHistory.slice(0, -1);
continue;
default:
this.cliBuffer += currentChar;
}
this.outputHistory += currentChar;
if (!CliAutoComplete.isBuilding()) {
// do not include the building dialog into the history
this.outputHistory += currentChar;
}
if (this.cliBuffer == 'Rebooting') {
CONFIGURATOR.cliActive = false;
@ -361,16 +400,23 @@ TABS.cli.read = function (readInfo) {
const lastLine = validateText.split("\n").pop();
this.outputHistory = lastLine;
validateText = "";
if (CliAutoComplete.isEnabled() && !CliAutoComplete.isBuilding()) {
// start building autoComplete
CliAutoComplete.builderStart();
}
}
setPrompt(removePromptHash(this.cliBuffer));
if (!CliAutoComplete.isEnabled())
// fallback to native autocomplete
setPrompt(removePromptHash(this.cliBuffer));
};
TABS.cli.sendLine = function (line, callback) {
this.send(line + '\n', callback);
};
TABS.cli.sendAutoComplete = function (line, callback) {
TABS.cli.sendNativeAutoComplete = function (line, callback) {
this.send(line + '\t', callback);
};
@ -405,4 +451,7 @@ TABS.cli.cleanup = function (callback) {
CONFIGURATOR.cliActive = false;
CONFIGURATOR.cliValid = false;
});
CliAutoComplete.cleanup();
$(CliAutoComplete).off();
};

View File

@ -110,6 +110,8 @@
<script type="text/javascript" src="./js/tabs/osd.js"></script>
<script type="text/javascript" src="./js/tabs/power.js"></script>
<script type="text/javascript" src="./js/tabs/transponder.js"></script>
<script type="text/javascript" src="./node_modules/jquery-textcomplete/dist/jquery.textcomplete.min.js"></script>
<script type="text/javascript" src="./js/CliAutoComplete.js"></script>
<title i18n="windowTitle"></title>
</head>
<body>

View File

@ -10,6 +10,9 @@
<div class="analyticsOptOut">
<label><input type="checkbox" /><span i18n="analyticsOptOut"></span></label>
</div>
<div class="cliAutoComplete">
<label><input type="checkbox" /><span i18n="cliAutoComplete"></span></label>
</div>
<div class="separator"></div>
<div class="userLanguage">
<label>

View File

@ -9,6 +9,8 @@ module.exports = function(config) {
'./src/js/data_storage.js',
'./src/js/localization.js',
'./src/js/gui.js',
'./node_modules/jquery-textcomplete/dist/jquery.textcomplete.min.js',
'./src/js/CliAutoComplete.js',
'./src/js/tabs/cli.js',
'./test/**/*.js'
],

View File

@ -19,6 +19,8 @@ describe('TABS.cli', () => {
cliTab.append($('<div>').addClass('window').append(cliOutput));
cliTab.append(cliPrompt);
CliAutoComplete.setEnabled(false); // not testing the client-side autocomplete
before(() => {
$('body')
.append(cliTab);