diff --git a/locales/en/messages.json b/locales/en/messages.json index 22969762410..186139044f9 100644 --- a/locales/en/messages.json +++ b/locales/en/messages.json @@ -2266,7 +2266,10 @@ "message": "Note: Leaving CLI tab or pressing Disconnect will automatically send \"exit\" to the board. With the latest firmware this will make the controller restart and unsaved changes will be lost.
# ');
+ $(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('
# 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!
# ');
+ 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!
# ');
+ 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'), '$1') : 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) {
+ // trim string because of strange bug on F7 with trailing space
+ $textarea.val( $textarea.val().trim() )
+ // 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(['<1-' + cache.resourcesCount[match[2].toUpperCase()] + '>']);
+ },
+ 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(['<pin>']);
+ }
+ } else {
+ callback(['<pin>', '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
+ }),
+ ]);
+ }
+};
diff --git a/src/js/main.js b/src/js/main.js
index 5da7506e492..51ea3f7e1dd 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -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() {
diff --git a/src/js/tabs/cli.js b/src/js/tabs/cli.js
index 0352c3934de..0df2be01896 100644
--- a/src/js/tabs/cli.js
+++ b/src/js/tabs/cli.js
@@ -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('
');
} 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();
};
diff --git a/src/main.html b/src/main.html
index 19f1d33684f..8814e24c112 100644
--- a/src/main.html
+++ b/src/main.html
@@ -110,6 +110,8 @@
+
+