c9-core/plugins/c9.ide.terminal/predict_echo.js

821 wiersze
34 KiB
JavaScript

/**
* This implements speculative echo for the terminal, i.e.
* printing characters when they are typed without waiting for the server
* to echo them.
*
* @extends Plugin
* @singleton
*/
define(function(require, exports, module) {
"use strict";
main.consumes = [
"Plugin", "editors", "c9", "vfs.ping", "util", "error_handler"
];
main.provides = ["terminal.predict_echo"];
return main;
function main(options, imports, register) {
var Plugin = imports.Plugin;
var editors = imports.editors;
var c9 = imports.c9;
var BaseTerminal = require("./aceterm/libterm");
var assert = require("assert");
var vfsPing = imports["vfs.ping"];
var errorHandler = imports.error_handler;
var BASH_ONLY = true;
var DEBUG = false;
var MIN_PREDICTION_WAIT = 500;
var PING_DEVIATION = 500;
var INSERTABLE_CHARS = /^[A-Za-z0-9!"#$%&'()*+,-\.\/\\:;<=>?!@[\] ^_`{|}~]+$/;
var INPUT_BACKSPACE = "\u007F";
var ESC = "\u001B";
var OUTPUTS_BACKSPACE_ALL = ["\b" + ESC + "[K", "\b" + ESC + "[1K"];
var OUTPUTS_BACKSPACE_CHAR = ["\b" + ESC + "[P", "\b" + ESC + "[1P"];
var OUTPUTS_ERASE_BACK_CHAR = ["\b \b"];
var OUTPUTS_DELETE_CHAR = [ESC + "[P", ESC + "[1P"];
var OUTPUTS_DELETE_ALL = [ESC + "[K", ESC + "[1K"];
var OUTPUT_CURSOR_START = ESC + "[H";
var INPUT_LEFT = ESC + "[D";
var INPUT_DELETE = ESC + "[3~";
var OUTPUTS_LEFT = ["\b", ESC + "[D", ESC + "[1D"];
var INPUTS_HOME = [ESC + "[1~", ESC + "[~", "\u0001"];
var INPUT_RIGHT = ESC + "[C";
var OUTPUTS_RIGHT = [ESC + "[C", ESC + "[1C"];
var STATE_PREDICT = 0;
var STATE_WAIT_FOR_PROMPT_OR_ECHO = 1;
var STATE_WAIT_FOR_PROMPT = 2;
var STATE_INITING = 3;
var plugin = new Plugin("Ajax.org", main.consumes);
var emit = plugin.getEmitter();
var lastPings = [MIN_PREDICTION_WAIT, MIN_PREDICTION_WAIT, MIN_PREDICTION_WAIT];
var pendingPings = [];
var loaded = false;
function load() {
if (loaded) return false;
loaded = true;
editors.on("create", function(e) {
if (!e.editor || e.editor.type !== "terminal")
return;
var terminal = e.editor;
terminal.on("documentLoad", function(e) {
var session = e.doc.getSession();
var predictor = new Predictor(terminal, session);
session.$predictor = predictor;
}, plugin);
terminal.on("beforeWrite", function(e) {
return e.session.$predictor.onBeforeWrite(e);
}, plugin);
terminal.on("afterWrite", function(e) {
return e.session.$predictor.onAfterWrite(e);
}, plugin);
terminal.on("input", function(e) {
DEBUG && console.log(">", e.data.replace("\r", "\\r").replace("\u007F", "\\bs"));
return e.session.$predictor.onInput(e);
}, plugin);
}, plugin);
}
function Predictor(terminal, session) {
var predictions = [];
var predictLine = "";
var predictIndex = 0;
var predictStartX = 0;
var predictStartY = 0;
var nonPredictStartY = 0;
var state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
var lastInput = null;
// We maintain a copy of the terminal state without predictions
var nonPredictTerminal = new BaseTerminal(terminal.rows, terminal.cols);
nonPredictTerminal.scrollback = 1;
session.terminal.on("resize", function(e) {
nonPredictTerminal.resize(e.x, e.y);
});
session.terminal.on("clear", function() {
nonPredictTerminal.clear();
});
function onInput(e) {
if (!c9.has(c9.NETWORK) || !c9.connected) {
DEBUG && console.log("!", "nopredict: not connected");
return;
}
// Store last input to check later if we could predict it
lastInput = e.data.length === 1 && INSERTABLE_CHARS.test(e.data)
? e.data
: null;
if (session.terminal.cursorState !== 1 || state !== STATE_PREDICT) {
DEBUG && console.log("!", "nopredict: state =", state);
return;
}
if (isPossibleConnectionGone()) {
DEBUG && console.log("!", "nopredict: connection gone?");
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
return;
}
var prediction =
InsertCommand.tryCreate(e.data)
|| CursorLeftCommand.tryCreate(e.data)
|| CursorRightCommand.tryCreate(e.data)
|| BackspaceCommand.tryCreate(e.data)
|| DeleteCommand.tryCreate(e.data)
|| HomeCommand.tryCreate(e.data);
if (!prediction) {
DEBUG && console.log("!", "nopredict:", e.data.replace(/\r/g, "\\r"));
state = STATE_WAIT_FOR_PROMPT;
return;
}
// predictStartX = session.terminal.x - predictIndex;
// predictStartY = nonPredictTerminal.ybase + nonPredictTerminal.y;
if (Array.isArray(prediction))
return prediction.forEach(addCommand);
addCommand(prediction);
function addCommand(command) {
predictions.push(command);
pendingPings.unshift(Date.now());
command.before = { predict: predictLine, predictIndex: predictIndex };
command.do();
command.after = { predict: predictLine, predictIndex: predictIndex };
command.sent = Date.now();
if (DEBUG) {
var alreadyEchoed = predictions[0].before.predict;
console.log("!" + debugPromptSuffix()
+ predictLine.substr(0, alreadyEchoed.length)
+ "%c" + predictLine.substr(alreadyEchoed.length),
"color: lightblue"
);
}
// DEBUG && console.log("!="
// + session.terminal.$debugCharsAt(predictStartY - session.terminal.ybase).join(""));
command.timeout = setTimeout(function panic() {
if (!c9.has(c9.NETWORK) || !c9.connected) {
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
c9.once("connect", function() {
command.timeout = setTimeout(panic, MIN_PREDICTION_WAIT);
});
return;
}
if (isPossibleConnectionGone()) {
vfsPing.ping(function(err, result) {
// ignore err
pong();
panic();
});
return;
}
// Aww, prediction not confirmed in time :( Rollback
if (predictions.indexOf(command) > -1)
undoPredictions();
}, Math.max(MIN_PREDICTION_WAIT, getAveragePing() * 3 + PING_DEVIATION));
}
}
function debugPromptSuffix() {
return nonPredictTerminal.$debugCharsAt(nonPredictTerminal.y)
.slice(0, predictStartX).slice(-3)
.map(function(c) { return c || " "; }).join("");
}
function isPossibleConnectionGone() {
if (!pendingPings.length)
return;
var avgPing = getAveragePing();
var nextPing = Date.now() - pendingPings[0];
if (nextPing > avgPing * 3 + PING_DEVIATION)
return true;
}
function onBeforeWrite(e) {
if (!e.$startX) { // make sure we're reentrant for debuggers
e.$startY = nonPredictTerminal.y;
e.$startX = nonPredictTerminal.x;
nonPredictTerminal.write(e.data);
}
DEBUG && console.log(
"<"
+ (state == STATE_PREDICT
? debugPromptSuffix() +
nonPredictTerminal.$debugCharsAt(e.$startY).slice(predictStartX).join("")
: "")
+ "%c < " + e.data,
"color: lightblue"
);
if (!predictions.length) {
if (state == STATE_PREDICT && nonPredictStartY !== nonPredictTerminal.ybase + nonPredictTerminal.y) {
DEBUG && console.log(" ^ disabled predictions: (row changed)");
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
}
tryEnablePrediction(e.data);
emit("nopredict", { data: e.data, session: session });
return;
}
pong();
chopPredictions(e, predictions, function(err, results, line) {
if (err || !results) {
DEBUG && console.log("[predict_echo] mispredict?", e.data.replace(/\r/g, "\\r")
+ "\n!=" + session.terminal.$debugCharsAt(predictStartY - session.terminal.ybase).join("")
+ "\n<=" + nonPredictTerminal.$debugCharsAt(e.$startY).join(""));
emit("mispredict", {
data: e.data,
line: charsOf(line),
predictions: predictions,
session: session
});
undoPredictions();
}
// I would try to enable predictions here,
// but we may be doing that on a response that precedes
// data we couldn't predict, like a \r
//
// else if (!predictions.length) {
// tryEnablePrediction(e.data);
// }
});
writePredictData(e.data, e.$startX);
return true;
}
/**
* Temporarily restore the unpredict terminal state to allow
* writing incoming data, including small anomalies that may
* not have been predicted but still passed our sanity checks.
*/
function writePredictData(data, startX) {
var predictTerminal = session.terminal;
var predictX = predictTerminal.x;
var predictY = predictTerminal.ybase + predictTerminal.y;
var predictChars = predictTerminal.lines[predictY].slice();
predictTerminal.x = startX;
copyTerminalLineTo(predictTerminal);
session.terminal.write(data);
for (var x = predictStartX; predictChars[x]; x++) {
predictTerminal.lines[predictY][x] = predictChars[x];
}
predictTerminal.x = predictX;
}
/**
* Register a "pong" event; we just got some data form the server.
*/
function pong() {
if (pendingPings[0])
pendingPings[0] = Date.now();
}
function undoPredictions() {
DEBUG && console.log("Prediction undo");
DEBUG && chopPredictions(
{},
predictions.slice(),
function() {
console.log("chop result: ", arguments);
}
);
DEBUG && console.log("Restoring nonpredict line; from",
session.terminal.$debugCharsAt(predictStartY - session.terminal.ybase).join(""),
"to",
nonPredictTerminal.$debugCharsAt(nonPredictStartY - nonPredictTerminal.ybase).join("")
);
pendingPings = [];
predictions = [];
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
copyTerminalLineTo(terminal);
session.terminal.x = nonPredictTerminal.x;
lastInput = null; // avoid immediately enabling again
}
/**
* @param target The target terminal, i.e. terminal or nonPredictTerminal.
*/
function copyTerminalLineTo(target) {
var predictTerminal = session.terminal;
var predictChars = predictTerminal.lines[predictStartY];
var nonPredictChars = nonPredictTerminal.lines[nonPredictStartY];
var fromChars = target === nonPredictTerminal ? predictChars : nonPredictChars;
var toChars = target === nonPredictTerminal ? nonPredictChars : predictChars;
if (!predictChars) { // terminal likely just refreshed, never mind copying to it
state = STATE_WAIT_FOR_PROMPT;
return;
}
if (!fromChars || !toChars) {
state = STATE_WAIT_FOR_PROMPT;
errorHandler.reportError(new Error("Warning: can't copy terminal line: "), {
fromChars: fromChars, toChars: toChars
});
if (DEBUG) debugger;
return;
}
for (var i = predictStartX; i < toChars.length; i++) {
toChars[i] = fromChars[i] || toChars[i];
}
predictTerminal.updateRange(predictStartY - predictTerminal.ybase);
}
/**
* Perform a sanity text on the string that precedes the current
* prediction buffer.
* @return {Boolean} true if the sanity check passes
*/
function checkTextBeforePrediction() {
var predictTerminal = session.terminal;
var predictLine = predictTerminal.lines[predictStartY];
var line = nonPredictTerminal.lines[nonPredictStartY];
if (!predictLine || !line)
return false;
for (var i = 0; i < predictStartX; i++) {
assert(predictLine[i].length === 2);
if (predictLine[i][0] !== line[i][0] || predictLine[i][1] !== line[i][1])
return false;
}
return true;
}
/**
* Check if an event matches what we predicted. Any succesful
* predictions are removed (chopped) from the prediction array.
*
* @param {Object} e
* @param {Object[]} predictions
* @param {Function} callback
* @param {Error} callback.err
* @param {Object[]|Boolean} callback.results A list of matching predictions, or `false`
*/
function chopPredictions(e, predictions, callback) {
var line = nonPredictTerminal.lines[nonPredictStartY];
var rowChanged = nonPredictStartY !== nonPredictTerminal.y + nonPredictTerminal.ybase;
if (!checkTextBeforePrediction())
return done(null, false);
// Check if predictions became true
var matchedOneOff = false;
for (var i = predictions.length - 1; i >= 0; i--) {
if (rowChanged && i !== predictions.length - 1)
break; // satisfy all predictions or bail
var prediction = predictions[i];
if (matchPrediction(prediction)) {
DEBUG && console.log(" ^ confirmed:", prediction.after.predict + " (saved " + (Date.now() - prediction.sent) + "ms)");
lastPings.push(Date.now() - prediction.sent);
lastPings.shift();
// Cleanup timeouts
for (var j = 0; j <= i; j++) {
clearTimeout(predictions[j].timeout);
pendingPings.pop();
}
// Cleanup predictions array
var duplicate = getDuplicateIndex(prediction, predictions);
var predict;
if (duplicate !== -1) {
// We found a duplicate state. We can't be sure if we're
// in state predictions[i] or predictions[duplicate]
// (see "duplicate states" test)
for (var j = duplicate + 1; j <= i; j++) {
predictions[j].optional = true;
}
predict = predictions.splice(0, duplicate + 1);
}
else {
predict = predictions.splice(0, i + 1);
}
return done(null, predict);
}
}
// No matches. But one got really close.
if (matchedOneOff)
return done(null, []);
// No matches. Return if our predictions were optional.
if (isOptionalOnly(predictions))
return done(null, []);
// No matches. But it seems we got a noop input. Our predictions likely happen later.
if (matchPrediction(NoopCommand.tryCreate()))
return done(null, []);
// No matches for our predictions :( We likely made a mistake.
// Reporting false here ensures we catch mistakes early.
return done(null, false);
function done(err, result) {
if (result) {
emit("predict", {
data: e.data,
session: session,
predictions: predict
});
}
callback(err, result, line);
}
function matchPrediction(prediction) {
var predict = prediction.after.predict;
var oneOff = false;
if (nonPredictTerminal.x !== predictStartX + prediction.after.predictIndex
// If we changed to a new row, we don't care about the column
&& !rowChanged)
return false;
for (var i = predictStartX; i < line.length; i++) {
if (i < predictStartX + predict.length) {
if (line[i][1] !== predict[i - predictStartX]) {
if (line[i][1] === " " && !oneOff) {
// Sometimes applications will clear a character with a space
// before writing the desired char.
oneOff = true;
continue;
}
return false;
}
}
else {
if (line[i][1] !== "" && line[i][1] !== " ")
return false;
}
}
if (oneOff) {
matchedOneOff = true;
return false;
}
return true;
}
function getDuplicateIndex(prediction, predictions) {
for (var j = 0; j < i; j++) {
if (predictions[j].after.predict === prediction.after.predict
&& predictions[j].after.predictIndex === prediction.after.predictIndex)
return j;
}
return -1;
}
}
function isOptionalOnly(predictions) {
return predictions.reduce(function(prev, p) {
return prev && p.optional;
}, true);
}
function tryEnablePrediction(data) {
if (state === STATE_PREDICT ||
(predictions.length && !isOptionalOnly(predictions)))
return;
// Enable prediction when we see a prompt
if ((state == STATE_WAIT_FOR_PROMPT || state === STATE_WAIT_FOR_PROMPT_OR_ECHO)
&& data.match(/[$#] $/)) {
if (DEBUG) console.log(" ^ re-enabled predictions: (prompt)");
return startPredict();
}
// Enable prediction when we see echoing
if (lastInput
&& (state === STATE_WAIT_FOR_PROMPT_OR_ECHO)
&& lastInput === data.substr(data.length - lastInput.length)
&& (!BASH_ONLY || isBashActive())) {
if (DEBUG) console.log(" ^ re-enabled predictions:", lastInput);
return startPredict();
}
// Enable predictions when we see echoing *and* a prompt
if (lastInput
&& state == STATE_WAIT_FOR_PROMPT
&& lastInput === data.substr(data.length - lastInput.length)
&& isBashActive()) {
if (DEBUG) console.log(" ^ re-enabled predictions:", lastInput);
return startPredict();
}
}
function startPredict() {
predictIndex = 0;
predictLine = "";
predictStartX = nonPredictTerminal.x;
nonPredictStartY = nonPredictTerminal.y + nonPredictTerminal.ybase;
predictStartY = session.terminal.y + session.terminal.ybase;
state = STATE_INITING;
}
function onAfterWrite(e) {
if (state !== STATE_INITING)
return;
predictStartY = session.terminal.y + session.terminal.ybase;
state = STATE_PREDICT;
if (!checkTextBeforePrediction()) {
// Appears to happen when tmux or shell unexpectedly sends a new line
console.log("[predict_echo] Unable to init predictions; will try again later");
state = STATE_WAIT_FOR_PROMPT;
}
}
function isBashActive() {
var x = nonPredictTerminal.x - 1;
for (var y = nonPredictTerminal.y; y >= 0; y--) {
var line = nonPredictTerminal.lines[y + nonPredictTerminal.ybase];
if (!line) {
errorHandler.reportError(new Error("Warning: predict echo line is missing "), {
lines: nonPredictTerminal.lines.length,
ybase: nonPredictTerminal.ybase,
startY: nonPredictTerminal.y,
y: y
});
return false;
}
while (x >= 0) {
var char = line[x] && line[x][1];
if ((char === "$" || char === "#") && line[x + 1] && line[x + 1][1] === " ")
return true;
x--;
}
x = nonPredictTerminal.cols - 1;
var prevLineEnd = nonPredictTerminal.getCharAt(y - 1, x - 1);
if (!prevLineEnd || !prevLineEnd[1])
return false;
}
return false;
}
function peek(offset) {
offset = offset || 0;
var char = session.terminal.getCharAt(
session.terminal.y, session.terminal.x + offset);
return char && char[1];
}
function peekAttr(offset) {
offset = offset || 0;
var char = session.terminal.getCharAt(
session.terminal.y, session.terminal.x + offset);
return char && char[0];
}
function peekSuffix(offset) {
offset = offset || 0;
var result = "";
for (var x = session.terminal.x + offset; x < session.terminal.cols; x++) {
var char = session.terminal.getCharAt(
session.terminal.y, x);
result += (char && char[1]) || "";
}
return result;
}
function echo(data) {
session.terminal.write(data);
}
/**
* Character insertion command. Factory method: tryCreate().
*/
InsertCommand.tryCreate = function(inputText) {
var afterLength = predictLine.length - predictIndex;
if (INSERTABLE_CHARS.test(inputText)
// Avoid accidentally overwriting text
&& (peek(afterLength) === "" || peek(afterLength) === " " && peekSuffix(afterLength).match(/^[ │·]*$/))
// Cowardly refuse to predict a newline
&& session.terminal.x + inputText.length < session.terminal.cols
// Watch out with color codes
&& peekAttr() === session.terminal.curAttr) {
// Split into multiple commands to track partial predection successes
return inputText.split("").map(function(i) {
return new InsertCommand(i);
});
}
};
function InsertCommand(inputText) {
return {
$outputText: inputText,
do: function() {
var after = predictLine.substr(predictIndex);
var outputText =
inputText + (after
? after + getCursorLeft(after.length)
: "");
predictLine = predictLine.substr(0, predictIndex) + inputText + after;
predictIndex += inputText.length;
echo(outputText);
},
};
}
/**
* Backspace command. Factory method: tryCreate().
*/
BackspaceCommand.tryCreate = function(inputText) {
if (inputText === INPUT_BACKSPACE && predictIndex > 0
&& INSERTABLE_CHARS.test(peek(-1)))
return new BackspaceCommand();
};
function BackspaceCommand() {
var after = predictLine.substr(predictIndex);
var outputText = OUTPUTS_BACKSPACE_CHAR[0];
return {
$outputText: outputText,
do: function() {
predictLine = predictLine.substr(0, predictIndex - 1) + after;
predictIndex--;
echo(outputText);
}
};
}
/**
* Delete command. Factory method: tryCreate().
*/
DeleteCommand.tryCreate = function(inputText) {
if (inputText === INPUT_DELETE && predictIndex < predictLine.length)
return new DeleteCommand();
};
function DeleteCommand() {
var after = predictLine.substr(predictIndex + 1);
return {
$outputText: OUTPUTS_DELETE_CHAR[0],
do: function() {
predictLine = predictLine.substr(0, predictIndex) + after;
echo(OUTPUTS_DELETE_CHAR[0]);
}
};
}
/**
* Cursor left command. Factory method: tryCreate().
*/
CursorLeftCommand.tryCreate = function(inputText) {
if (inputText === INPUT_LEFT)
return new CursorLeftCommand();
};
function CursorLeftCommand() {
return {
$outputText: OUTPUTS_LEFT[0],
do: function() {
if (predictIndex === 0) {
clearTimeout(this.timeout);
return;
}
echo(OUTPUTS_LEFT[0]);
predictIndex--;
}
};
}
/**
* Cursor right command. Factory method: tryCreate().
*/
CursorRightCommand.tryCreate = function(inputText) {
if (inputText === INPUT_RIGHT && predictIndex < predictLine.length)
return new CursorRightCommand();
};
function CursorRightCommand() {
return {
$outputText: OUTPUTS_RIGHT[0],
do: function() {
echo(OUTPUTS_RIGHT[0]);
predictIndex++;
}
};
}
/**
* Home command. Factory method: tryCreate().
*/
HomeCommand.tryCreate = function(inputText) {
if (INPUTS_HOME.indexOf(inputText) > -1
// Only attempt home if we'd jump to the start of a prompt
&& (peek(-predictIndex - 1) === "$" || peek(-predictIndex - 2) === "$"))
return new HomeCommand();
};
function HomeCommand() {
var outputText = predictIndex ? getCursorLeft(predictIndex) : "";
return {
$outputText: outputText,
do: function() {
echo(outputText);
predictIndex = 0;
}
};
}
/**
* Noop command. Factory method: tryCreate().
*/
NoopCommand.tryCreate = function() {
var result = new NoopCommand();
result.before = { predict: predictLine, predictIndex: predictIndex };
result.after = { predict: predictLine, predictIndex: predictIndex };
return result;
};
function NoopCommand() {
var outputText = predictIndex ? getCursorLeft(predictIndex) : "";
return {
$outputText: outputText,
do: function() {
echo(outputText);
predictIndex = 0;
}
};
}
// Predictor API
return {
get state() { return state; },
set state(value) {
if (value === STATE_PREDICT && state !== STATE_PREDICT) {
startPredict();
state = STATE_PREDICT;
}
state = value;
},
get predictions() { return predictions; },
undoPredictions: undoPredictions,
onInput: onInput,
onBeforeWrite: onBeforeWrite,
onAfterWrite: onAfterWrite,
};
}
function charsOf(line) {
return line.map(function(c) { return c[1] }).join("");
}
function getCursorLeft(n) {
return ESC + "[" + n + "D";
}
function getAveragePing() {
return (lastPings[0] + lastPings[1] + lastPings[2]) / 3;
}
plugin.on("load", function() {
load();
});
plugin.on("unload", function() {
lastPings = [MIN_PREDICTION_WAIT, MIN_PREDICTION_WAIT, MIN_PREDICTION_WAIT];
pendingPings = [];
loaded = false;
});
/**
* @ignore
*/
plugin.freezePublicAPI({
$setTestTimeouts: function() {
PING_DEVIATION = 30000;
MIN_PREDICTION_WAIT = 10000;
},
set DEBUG(value) { DEBUG = value; },
get DEBUG() { return DEBUG; },
_events: [
"predict",
"mispredict",
"nopredict"
]
});
register(null, { "terminal.predict_echo": plugin });
}
});