UI: add handling for exec command-editing keys (#7601)

This is a minimal implementation that closes #7463. It doesn’t include
true support for moving around within the command to edit using arrow
keys because it gets too complex when managing wrapping at the edge of
the terminal. Instead, arrow keys are ignored. It also ignores ^A and
^E, which are cursor manipulations that pose similar problems to arrow
keys. It does support ^U, which deletes the entire command.

It also allows a command to be pasted, which was previously unsupported.
This is accomplished by migrating from Xterm.js’s onKey handler to
onData, which is recommended here:
https://github.com/xtermjs/xterm.js/issues/2673#issuecomment-574897733

onData is a higher-level handler that issues events with the final
interpreted data instead of the individual key events. That means the
processing in this PR has changed from inspecting DOM key events to
inspecting their ASCII equivalents, which I’ve extracted into a utility
dictionary for use in tests and implementation.

One consequence of ignoring most control characters is that if you paste
a string that includes a control character, that character will be
stripped. It’s somewhat strange for compound sequences like arrow keys; 
if you run copy('/bin/b' + '\x1b[D' + 'ash') in a Javascript console and
paste what’s on the clipboard, you get "/bin/b[Dash". That’s because
the left arrow key, as in that centre portion of the string,
is represented by the escape character and a coded sequence. Stripping
the control character leaves the coded sequence as part of the paste.
That seems like an acceptable compromise vs either ignoring any pasted
string with control characters (confusing UX) or trying to interpret and
strip all such compound control sequences (difficult to be exhaustive).
This commit is contained in:
Buck Doyle 2020-04-03 12:14:47 -05:00 committed by GitHub
parent 4d3686aa52
commit fbe40a5d36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 144 additions and 36 deletions

View File

@ -1,6 +1,11 @@
import KEYS from 'nomad-ui/utils/keys';
const REVERSE_WRAPAROUND_MODE = '\x1b[?45h';
const BACKSPACE_ONE_CHARACTER = '\x08 \x08';
// eslint-disable-next-line no-control-regex
const UNPRINTABLE_CHARACTERS_REGEX = /[\x00-\x1F]/g;
export default class ExecCommandEditorXtermAdapter {
constructor(terminal, setCommandCallback, command) {
this.terminal = terminal;
@ -8,34 +13,44 @@ export default class ExecCommandEditorXtermAdapter {
this.command = command;
this.keyListener = terminal.onKey(e => {
this.handleKeyEvent(e);
this.dataListener = terminal.onData(data => {
this.handleDataEvent(data);
});
// Allows tests to bypass synthetic keyboard event restrictions
terminal.simulateCommandKeyEvent = this.handleKeyEvent.bind(this);
terminal.simulateCommandDataEvent = this.handleDataEvent.bind(this);
terminal.write(REVERSE_WRAPAROUND_MODE);
}
handleKeyEvent(e) {
// Issue to handle arrow keys etc: https://github.com/hashicorp/nomad/issues/7463
if (e.domEvent.key === 'Enter') {
handleDataEvent(data) {
if (
data === KEYS.LEFT_ARROW ||
data === KEYS.UP_ARROW ||
data === KEYS.RIGHT_ARROW ||
data === KEYS.DOWN_ARROW
) {
// Ignore arrow keys
} else if (data === KEYS.CONTROL_U) {
this.terminal.write(BACKSPACE_ONE_CHARACTER.repeat(this.command.length));
this.command = '';
} else if (data === KEYS.ENTER) {
this.terminal.writeln('');
this.setCommandCallback(this.command);
this.keyListener.dispose();
} else if (e.domEvent.key === 'Backspace') {
this.dataListener.dispose();
} else if (data === KEYS.DELETE) {
if (this.command.length > 0) {
this.terminal.write(BACKSPACE_ONE_CHARACTER);
this.command = this.command.slice(0, -1);
}
} else if (e.key.length > 0) {
this.terminal.write(e.key);
this.command = `${this.command}${e.key}`;
} else if (data.length > 0) {
const strippedData = data.replace(UNPRINTABLE_CHARACTERS_REGEX, '');
this.terminal.write(strippedData);
this.command = `${this.command}${strippedData}`;
}
}
destroy() {
this.keyListener.dispose();
this.dataListener.dispose();
}
}

10
ui/app/utils/keys.js Normal file
View File

@ -0,0 +1,10 @@
export default {
LEFT_ARROW: '\x1b[D',
UP_ARROW: '\x1b[A',
RIGHT_ARROW: '\x1b[C',
DOWN_ARROW: '\x1b[B',
CONTROL_A: '\x01',
CONTROL_U: '\x15',
ENTER: '\r',
DELETE: '\x7F',
};

View File

@ -4,6 +4,7 @@ import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import Service from '@ember/service';
import Exec from 'nomad-ui/tests/pages/exec';
import KEYS from 'nomad-ui/utils/keys';
module('Acceptance | exec', function(hooks) {
setupApplicationTest(hooks);
@ -331,20 +332,20 @@ module('Acceptance | exec', function(hooks) {
await settled();
// Delete /bash
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
// Delete /bin and try to go beyond
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
await settled();
@ -356,9 +357,7 @@ module('Acceptance | exec', function(hooks) {
`$ nomad alloc exec -i -t -task ${task.name} ${allocation.id.split('-')[0]}`
);
await window.execTerminal.simulateCommandKeyEvent({ key: '/', domEvent: {} });
await window.execTerminal.simulateCommandKeyEvent({ key: 's', domEvent: {} });
await window.execTerminal.simulateCommandKeyEvent({ key: 'h', domEvent: {} });
await window.execTerminal.simulateCommandDataEvent('/sh');
await Exec.terminal.pressEnter();
await settled();

View File

@ -4,6 +4,7 @@ import { module, test } from 'qunit';
import { render, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { Terminal } from 'xterm-vendor';
import KEYS from 'nomad-ui/utils/keys';
module('Integration | Utility | exec-command-editor-xterm-adapter', function(hooks) {
setupRenderingTest(hooks);
@ -29,14 +30,14 @@ module('Integration | Utility | exec-command-editor-xterm-adapter', function(hoo
'/bin/long-command'
);
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Backspace' } });
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await terminal.simulateCommandDataEvent(KEYS.DELETE);
await settled();
@ -48,6 +49,89 @@ module('Integration | Utility | exec-command-editor-xterm-adapter', function(hoo
'/bin/long'
);
await terminal.simulateCommandKeyEvent({ domEvent: { key: 'Enter' } });
await terminal.simulateCommandDataEvent(KEYS.ENTER);
});
test('it ignores arrow keys and unprintable characters other than ^U', async function(assert) {
let done = assert.async();
await render(hbs`
<div id='terminal'></div>
`);
let terminal = new Terminal({ cols: 72 });
terminal.open(document.getElementById('terminal'));
terminal.write('/bin/bash');
new ExecCommandEditorXtermAdapter(
terminal,
command => {
assert.equal(command, '/bin/bash!');
done();
},
'/bin/bash'
);
await terminal.simulateCommandDataEvent(KEYS.RIGHT_ARROW);
await terminal.simulateCommandDataEvent(KEYS.RIGHT_ARROW);
await terminal.simulateCommandDataEvent(KEYS.LEFT_ARROW);
await terminal.simulateCommandDataEvent(KEYS.UP_ARROW);
await terminal.simulateCommandDataEvent(KEYS.UP_ARROW);
await terminal.simulateCommandDataEvent(KEYS.DOWN_ARROW);
await terminal.simulateCommandDataEvent(KEYS.CONTROL_A);
await terminal.simulateCommandDataEvent('!');
await settled();
assert.equal(terminal.buffer.cursorY, 0);
assert.equal(terminal.buffer.cursorX, 10);
assert.equal(
terminal.buffer
.getLine(0)
.translateToString()
.trim(),
'/bin/bash!'
);
await terminal.simulateCommandDataEvent(KEYS.ENTER);
});
test('it supports typing ^U to delete the entire command', async function(assert) {
let done = assert.async();
await render(hbs`
<div id='terminal'></div>
`);
let terminal = new Terminal({ cols: 10 });
terminal.open(document.getElementById('terminal'));
terminal.write('to-delete');
new ExecCommandEditorXtermAdapter(
terminal,
command => {
assert.equal(command, '!');
done();
},
'to-delete'
);
await terminal.simulateCommandDataEvent(KEYS.CONTROL_U);
await settled();
assert.equal(
terminal.buffer
.getLine(0)
.translateToString()
.trim(),
''
);
await terminal.simulateCommandDataEvent('!');
await terminal.simulateCommandDataEvent(KEYS.ENTER);
});
});