From c2acd5ba49d32f7117911272a78af28e5e768be7 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Tue, 5 Jun 2018 11:14:07 -0700 Subject: [PATCH 1/6] Display autocomplete hints on Ctrl-Space only ...as opposed to on every key up. Co-authored-by: Andrew Watkins Co-authored-by: Chris Henn --- ui/src/flux/components/TimeMachineEditor.tsx | 28 -------------------- 1 file changed, 28 deletions(-) diff --git a/ui/src/flux/components/TimeMachineEditor.tsx b/ui/src/flux/components/TimeMachineEditor.tsx index fb8d4b10a8..9506a067da 100644 --- a/ui/src/flux/components/TimeMachineEditor.tsx +++ b/ui/src/flux/components/TimeMachineEditor.tsx @@ -4,7 +4,6 @@ import {EditorChange} from 'codemirror' import 'src/external/codemirror' import {ErrorHandling} from 'src/shared/decorators/errors' import {OnChangeScript, OnSubmitScript} from 'src/types/flux' -import {editor} from 'src/flux/constants' interface Gutter { line: number @@ -31,7 +30,6 @@ interface EditorInstance extends IInstance { @ErrorHandling class TimeMachineEditor extends PureComponent { private editor: EditorInstance - private prevKey: string constructor(props) { super(props) @@ -77,7 +75,6 @@ class TimeMachineEditor extends PureComponent { autoCursor={true} value={script} options={options} - onKeyUp={this.handleKeyUp} onBeforeChange={this.updateCode} onTouchStart={this.onTouchStart} editorDidMount={this.handleMount} @@ -129,31 +126,6 @@ class TimeMachineEditor extends PureComponent { this.editor = instance } - private handleKeyUp = (instance: EditorInstance, e: KeyboardEvent) => { - const {key} = e - const prevKey = this.prevKey - - if ( - prevKey === 'Control' || - prevKey === 'Meta' || - (prevKey === 'Shift' && key === '.') - ) { - return (this.prevKey = key) - } - - this.prevKey = key - - if (editor.EXCLUDED_KEYS.includes(key)) { - return - } - - if (editor.EXCLUDED_KEYS.includes(key)) { - return - } - - instance.showHint({completeSingle: false}) - } - private onTouchStart = () => {} private updateCode = ( From e9fd63dc691a64f916a0de757e7dd08dfb877ecc Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Tue, 5 Jun 2018 14:23:20 -0700 Subject: [PATCH 2/6] Use server-provided completion suggestions Co-authored-by: Chris Henn Co-authored-by: Andrew Watkins --- ui/src/flux/components/TimeMachine.tsx | 1 + ui/src/flux/components/TimeMachineEditor.tsx | 35 ++++++++++---- ui/src/flux/helpers/autoComplete.ts | 49 ++++++++++++++++++++ ui/src/types/codemirror.ts | 35 ++++++++++++++ 4 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 ui/src/flux/helpers/autoComplete.ts create mode 100644 ui/src/types/codemirror.ts diff --git a/ui/src/flux/components/TimeMachine.tsx b/ui/src/flux/components/TimeMachine.tsx index 26a51fb583..d081455933 100644 --- a/ui/src/flux/components/TimeMachine.tsx +++ b/ui/src/flux/components/TimeMachine.tsx @@ -109,6 +109,7 @@ class TimeMachine extends PureComponent { status={status} script={script} visibility={visibility} + suggestions={suggestions} onChangeScript={onChangeScript} onSubmitScript={onSubmitScript} /> diff --git a/ui/src/flux/components/TimeMachineEditor.tsx b/ui/src/flux/components/TimeMachineEditor.tsx index 9506a067da..db5690a967 100644 --- a/ui/src/flux/components/TimeMachineEditor.tsx +++ b/ui/src/flux/components/TimeMachineEditor.tsx @@ -1,9 +1,10 @@ import React, {PureComponent} from 'react' -import {Controlled as CodeMirror, IInstance} from 'react-codemirror2' +import {Controlled as ReactCodeMirror, IInstance} from 'react-codemirror2' import {EditorChange} from 'codemirror' -import 'src/external/codemirror' +import {ShowHintOptions} from 'src/types/codemirror' import {ErrorHandling} from 'src/shared/decorators/errors' -import {OnChangeScript, OnSubmitScript} from 'src/types/flux' +import {OnChangeScript, OnSubmitScript, Suggestion} from 'src/types/flux' +import {getFluxCompletions} from 'src/flux/helpers/autoComplete' interface Gutter { line: number @@ -21,10 +22,11 @@ interface Props { status: Status onChangeScript: OnChangeScript onSubmitScript: OnSubmitScript + suggestions: Suggestion[] } interface EditorInstance extends IInstance { - showHint: (options?: any) => void + showHint: (options?: ShowHintOptions) => void } @ErrorHandling @@ -36,19 +38,21 @@ class TimeMachineEditor extends PureComponent { } public componentDidUpdate(prevProps) { - if (this.props.status.type === 'error') { + const {status, visibility} = this.props + + if (status.type === 'error') { this.makeError() } - if (this.props.status.type !== 'error') { + if (status.type !== 'error') { this.editor.clearGutter('error-gutter') } - if (prevProps.visibility === this.props.visibility) { + if (prevProps.visibility === visibility) { return } - if (this.props.visibility === 'visible') { + if (visibility === 'visible') { setTimeout(() => this.editor.refresh(), 60) } } @@ -61,7 +65,6 @@ class TimeMachineEditor extends PureComponent { theme: 'time-machine', tabIndex: 1, readonly: false, - extraKeys: {'Ctrl-Space': 'autocomplete'}, completeSingle: false, autoRefresh: true, mode: 'flux', @@ -70,7 +73,7 @@ class TimeMachineEditor extends PureComponent { return (
- { onTouchStart={this.onTouchStart} editorDidMount={this.handleMount} onBlur={this.handleBlur} + onKeyUp={this.handleKeyUp} />
) @@ -128,6 +132,17 @@ class TimeMachineEditor extends PureComponent { private onTouchStart = () => {} + private handleKeyUp = (editor: EditorInstance, e: KeyboardEvent) => { + const {suggestions} = this.props + const space = ' ' + + if (e.ctrlKey && e.key === space) { + editor.showHint({ + hint: () => getFluxCompletions(this.editor, suggestions), + }) + } + } + private updateCode = ( _: IInstance, __: EditorChange, diff --git a/ui/src/flux/helpers/autoComplete.ts b/ui/src/flux/helpers/autoComplete.ts new file mode 100644 index 0000000000..72223a96be --- /dev/null +++ b/ui/src/flux/helpers/autoComplete.ts @@ -0,0 +1,49 @@ +import CodeMirror from 'codemirror' +import {IInstance} from 'react-codemirror2' + +import {Suggestion} from 'src/types/flux' +import {Hints} from 'src/types/codemirror' + +export const getFluxCompletions = ( + editor: IInstance, + list: Suggestion[] +): Hints => { + const cursor = editor.getCursor() + const currentLine = editor.getLine(cursor.line) + const trailingWhitespace = /[\w$]+/ + + let start = cursor.ch + let end = start + + // Move end marker until a space or end of line is reached + while ( + end < currentLine.length && + trailingWhitespace.test(currentLine.charAt(end)) + ) { + end += 1 + } + + // Move start marker until a space or the beginning of line is reached + while (start && trailingWhitespace.test(currentLine.charAt(start - 1))) { + start -= 1 + } + + // If not completing inside a current word, return list of all possible suggestions + if (start === end) { + return { + from: CodeMirror.Pos(cursor.line, start), + to: CodeMirror.Pos(cursor.line, end), + list: list.map(s => s.name), + } + } + + const currentWord = currentLine.slice(start, end) + const listFilter = new RegExp(`^${currentWord}`, 'i') + + // Otherwise return suggestions that contain the current word as a substring + return { + from: CodeMirror.Pos(cursor.line, start), + to: CodeMirror.Pos(cursor.line, end), + list: list.filter(s => s.name.match(listFilter)).map(s => s.name), + } +} diff --git a/ui/src/types/codemirror.ts b/ui/src/types/codemirror.ts new file mode 100644 index 0000000000..4f56e7742a --- /dev/null +++ b/ui/src/types/codemirror.ts @@ -0,0 +1,35 @@ +import {Editor, Position} from 'codemirror' + +export interface Hints { + from: Position + to: Position + list: Array +} + +// Interface used by showHint.js Codemirror add-on +// When completions aren't simple strings, they should be objects with the following properties: + +export interface Hint { + text: string + className?: string + displayText?: string + from?: Position + /** Called if a completion is picked. If provided *you* are responsible for applying the completion */ + hint?: (cm: Editor, data: Hints, cur: Hint) => void + render?: (element: HTMLLIElement, data: Hints, cur: Hint) => void + to?: Position +} + +type HintFunction = (cm: Editor) => Hints + +interface AsyncHintFunction { + (cm: Editor, callback: (hints: Hints) => any): any + async?: boolean +} + +export interface ShowHintOptions { + completeSingle?: boolean + hint: HintFunction | AsyncHintFunction +} + +export type ShowHint = (cm: Editor, options: ShowHintOptions) => void From 48c19ffd234aa8c300213a7d6657b15e10e34a7d Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Tue, 5 Jun 2018 14:34:25 -0700 Subject: [PATCH 3/6] Remove unnecessary CodeMirror addon code Co-authored-by: Chris Henn Co-authored-by: Andrew Watkins --- ui/src/external/codemirror.js | 536 +------------------ ui/src/flux/components/TimeMachineEditor.tsx | 1 + 2 files changed, 3 insertions(+), 534 deletions(-) diff --git a/ui/src/external/codemirror.js b/ui/src/external/codemirror.js index 6f842c19b0..b726aed57f 100644 --- a/ui/src/external/codemirror.js +++ b/ui/src/external/codemirror.js @@ -1,4 +1,5 @@ import {modeFlux, modeTickscript} from 'src/shared/constants/codeMirrorModes' +import 'codemirror/addon/hint/show-hint' /* eslint-disable */ const CodeMirror = require('codemirror') @@ -313,537 +314,4 @@ function indentFunction(states, meta) { // Modes CodeMirror.defineSimpleMode('flux', modeFlux) -CodeMirror.defineSimpleMode('tickscript', modeTickscript) - -// CodeMirror Hints - -var HINT_ELEMENT_CLASS = "CodeMirror-hint"; -var ACTIVE_HINT_ELEMENT_CLASS = "CodeMirror-hint-active"; - -// This is the old interface, kept around for now to stay backwards-compatible. -CodeMirror.showHint = function (cm, getHints, options) { - if (!getHints) return cm.showHint(options); - if (options && options.async) getHints.async = true; - var newOpts = { - hint: getHints - }; - if (options) - for (var prop in options) newOpts[prop] = options[prop]; - return cm.showHint(newOpts); -}; - -CodeMirror.defineExtension("showHint", function (options) { - options = parseOptions(this, this.getCursor("start"), options); - var selections = this.listSelections() - if (selections.length > 1) return; - // By default, don't allow completion when something is selected. - // A hint function can have a `supportsSelection` property to - // indicate that it can handle selections. - if (this.somethingSelected()) { - if (!options.hint.supportsSelection) return; - // Don't try with cross-line selections - for (var i = 0; i < selections.length; i++) - if (selections[i].head.line != selections[i].anchor.line) return; - } - - if (this.state.completionActive) this.state.completionActive.close(); - var completion = this.state.completionActive = new Completion(this, options); - if (!completion.options.hint) return; - - CodeMirror.signal(this, "startCompletion", this); - completion.update(true); -}); - -function Completion(cm, options) { - this.cm = cm; - this.options = options; - this.widget = null; - this.debounce = 0; - this.tick = 0; - this.startPos = this.cm.getCursor("start"); - this.startLen = this.cm.getLine(this.startPos.line).length - this.cm.getSelection().length; - - var self = this; - cm.on("cursorActivity", this.activityFunc = function () { - self.cursorActivity(); - }); -} - -var requestAnimationFrame = window.requestAnimationFrame || function (fn) { - return setTimeout(fn, 1000 / 60); -}; -var cancelAnimationFrame = window.cancelAnimationFrame || clearTimeout; - -Completion.prototype = { - close: function () { - if (!this.active()) return; - this.cm.state.completionActive = null; - this.tick = null; - this.cm.off("cursorActivity", this.activityFunc); - - if (this.widget && this.data) CodeMirror.signal(this.data, "close"); - if (this.widget) this.widget.close(); - CodeMirror.signal(this.cm, "endCompletion", this.cm); - }, - - active: function () { - return this.cm.state.completionActive == this; - }, - - pick: function (data, i) { - var completion = data.list[i]; - if (completion.hint) completion.hint(this.cm, data, completion); - else this.cm.replaceRange(getText(completion), completion.from || data.from, - completion.to || data.to, "complete"); - CodeMirror.signal(data, "pick", completion); - this.close(); - }, - - cursorActivity: function () { - if (this.debounce) { - cancelAnimationFrame(this.debounce); - this.debounce = 0; - } - - var pos = this.cm.getCursor(), - line = this.cm.getLine(pos.line); - if (pos.line != this.startPos.line || line.length - pos.ch != this.startLen - this.startPos.ch || - pos.ch < this.startPos.ch || this.cm.somethingSelected() || - (pos.ch && this.options.closeCharacters.test(line.charAt(pos.ch - 1)))) { - this.close(); - } else { - var self = this; - this.debounce = requestAnimationFrame(function () { - self.update(); - }); - if (this.widget) this.widget.disable(); - } - }, - - update: function (first) { - if (this.tick == null) return - var self = this, - myTick = ++this.tick - fetchHints(this.options.hint, this.cm, this.options, function (data) { - if (self.tick == myTick) self.finishUpdate(data, first) - }) - }, - - finishUpdate: function (data, first) { - if (this.data) CodeMirror.signal(this.data, "update"); - - var picked = (this.widget && this.widget.picked) || (first && this.options.completeSingle); - if (this.widget) this.widget.close(); - - this.data = data; - - if (data && data.list.length) { - if (picked && data.list.length == 1) { - this.pick(data, 0); - } else { - this.widget = new Widget(this, data); - CodeMirror.signal(data, "shown"); - } - } - } -}; - -function parseOptions(cm, pos, options) { - var editor = cm.options.hintOptions; - var out = {}; - for (var prop in defaultOptions) out[prop] = defaultOptions[prop]; - if (editor) - for (var prop in editor) - if (editor[prop] !== undefined) out[prop] = editor[prop]; - if (options) - for (var prop in options) - if (options[prop] !== undefined) out[prop] = options[prop]; - if (out.hint.resolve) out.hint = out.hint.resolve(cm, pos) - return out; -} - -function getText(completion) { - if (typeof completion == "string") return completion; - else return completion.text; -} - -function buildKeyMap(completion, handle) { - var baseMap = { - Up: function () { - handle.moveFocus(-1); - }, - Down: function () { - handle.moveFocus(1); - }, - PageUp: function () { - handle.moveFocus(-handle.menuSize() + 1, true); - }, - PageDown: function () { - handle.moveFocus(handle.menuSize() - 1, true); - }, - Home: function () { - handle.setFocus(0); - }, - End: function () { - handle.setFocus(handle.length - 1); - }, - Enter: handle.pick, - Tab: handle.pick, - Esc: handle.close - }; - var custom = completion.options.customKeys; - var ourMap = custom ? {} : baseMap; - - function addBinding(key, val) { - var bound; - if (typeof val != "string") - bound = function (cm) { - return val(cm, handle); - }; - // This mechanism is deprecated - else if (baseMap.hasOwnProperty(val)) - bound = baseMap[val]; - else - bound = val; - ourMap[key] = bound; - } - if (custom) - for (var key in custom) - if (custom.hasOwnProperty(key)) - addBinding(key, custom[key]); - var extra = completion.options.extraKeys; - if (extra) - for (var key in extra) - if (extra.hasOwnProperty(key)) - addBinding(key, extra[key]); - return ourMap; -} - -function getHintElement(hintsElement, el) { - while (el && el != hintsElement) { - if (el.nodeName.toUpperCase() === "LI" && el.parentNode == hintsElement) return el; - el = el.parentNode; - } -} - -function Widget(completion, data) { - this.completion = completion; - this.data = data; - this.picked = false; - var widget = this, - cm = completion.cm; - - var hints = this.hints = document.createElement("ul"); - hints.className = "CodeMirror-hints"; - this.selectedHint = data.selectedHint || 0; - - var completions = data.list; - for (var i = 0; i < completions.length; ++i) { - var elt = hints.appendChild(document.createElement("li")), - cur = completions[i]; - var className = HINT_ELEMENT_CLASS + (i != this.selectedHint ? "" : " " + ACTIVE_HINT_ELEMENT_CLASS); - if (cur.className != null) className = cur.className + " " + className; - elt.className = className; - if (cur.render) cur.render(elt, data, cur); - else elt.appendChild(document.createTextNode(cur.displayText || getText(cur))); - elt.hintId = i; - } - - var pos = cm.cursorCoords(completion.options.alignWithWord ? data.from : null); - var left = pos.left, - top = pos.bottom, - below = true; - hints.style.left = left + "px"; - hints.style.top = top + "px"; - // If we're at the edge of the screen, then we want the menu to appear on the left of the cursor. - var winW = window.innerWidth || Math.max(document.body.offsetWidth, document.documentElement.offsetWidth); - var winH = window.innerHeight || Math.max(document.body.offsetHeight, document.documentElement.offsetHeight); - (completion.options.container || document.body).appendChild(hints); - var box = hints.getBoundingClientRect(), - overlapY = box.bottom - winH; - var scrolls = hints.scrollHeight > hints.clientHeight + 1 - var startScroll = cm.getScrollInfo(); - - if (overlapY > 0) { - var height = box.bottom - box.top, - curTop = pos.top - (pos.bottom - box.top); - if (curTop - height > 0) { // Fits above cursor - hints.style.top = (top = pos.top - height) + "px"; - below = false; - } else if (height > winH) { - hints.style.height = (winH - 5) + "px"; - hints.style.top = (top = pos.bottom - box.top) + "px"; - var cursor = cm.getCursor(); - if (data.from.ch != cursor.ch) { - pos = cm.cursorCoords(cursor); - hints.style.left = (left = pos.left) + "px"; - box = hints.getBoundingClientRect(); - } - } - } - var overlapX = box.right - winW; - if (overlapX > 0) { - if (box.right - box.left > winW) { - hints.style.width = (winW - 5) + "px"; - overlapX -= (box.right - box.left) - winW; - } - hints.style.left = (left = pos.left - overlapX) + "px"; - } - if (scrolls) - for (var node = hints.firstChild; node; node = node.nextSibling) - node.style.paddingRight = cm.display.nativeBarWidth + "px" - - cm.addKeyMap(this.keyMap = buildKeyMap(completion, { - moveFocus: function (n, avoidWrap) { - widget.changeActive(widget.selectedHint + n, avoidWrap); - }, - setFocus: function (n) { - widget.changeActive(n); - }, - menuSize: function () { - return widget.screenAmount(); - }, - length: completions.length, - close: function () { - completion.close(); - }, - pick: function () { - widget.pick(); - }, - data: data - })); - - if (completion.options.closeOnUnfocus) { - var closingOnBlur; - cm.on("blur", this.onBlur = function () { - closingOnBlur = setTimeout(function () { - completion.close(); - }, 100); - }); - cm.on("focus", this.onFocus = function () { - clearTimeout(closingOnBlur); - }); - } - - cm.on("scroll", this.onScroll = function () { - var curScroll = cm.getScrollInfo(), - editor = cm.getWrapperElement().getBoundingClientRect(); - var newTop = top + startScroll.top - curScroll.top; - var point = newTop - (window.pageYOffset || (document.documentElement || document.body).scrollTop); - if (!below) point += hints.offsetHeight; - if (point <= editor.top || point >= editor.bottom) return completion.close(); - hints.style.top = newTop + "px"; - hints.style.left = (left + startScroll.left - curScroll.left) + "px"; - }); - - CodeMirror.on(hints, "dblclick", function (e) { - var t = getHintElement(hints, e.target || e.srcElement); - if (t && t.hintId != null) { - widget.changeActive(t.hintId); - widget.pick(); - } - }); - - CodeMirror.on(hints, "click", function (e) { - var t = getHintElement(hints, e.target || e.srcElement); - if (t && t.hintId != null) { - widget.changeActive(t.hintId); - if (completion.options.completeOnSingleClick) widget.pick(); - } - }); - - CodeMirror.on(hints, "mousedown", function () { - setTimeout(function () { - cm.focus(); - }, 20); - }); - - CodeMirror.signal(data, "select", completions[this.selectedHint], hints.childNodes[this.selectedHint]); - return true; -} - -Widget.prototype = { - close: function () { - if (this.completion.widget != this) return; - this.completion.widget = null; - this.hints.parentNode.removeChild(this.hints); - this.completion.cm.removeKeyMap(this.keyMap); - - var cm = this.completion.cm; - if (this.completion.options.closeOnUnfocus) { - cm.off("blur", this.onBlur); - cm.off("focus", this.onFocus); - } - cm.off("scroll", this.onScroll); - }, - - disable: function () { - this.completion.cm.removeKeyMap(this.keyMap); - var widget = this; - this.keyMap = { - Enter: function () { - widget.picked = true; - } - }; - this.completion.cm.addKeyMap(this.keyMap); - }, - - pick: function () { - this.completion.pick(this.data, this.selectedHint); - }, - - changeActive: function (i, avoidWrap) { - if (i >= this.data.list.length) - i = avoidWrap ? this.data.list.length - 1 : 0; - else if (i < 0) - i = avoidWrap ? 0 : this.data.list.length - 1; - if (this.selectedHint == i) return; - var node = this.hints.childNodes[this.selectedHint]; - node.className = node.className.replace(" " + ACTIVE_HINT_ELEMENT_CLASS, ""); - node = this.hints.childNodes[this.selectedHint = i]; - node.className += " " + ACTIVE_HINT_ELEMENT_CLASS; - if (node.offsetTop < this.hints.scrollTop) - this.hints.scrollTop = node.offsetTop - 3; - else if (node.offsetTop + node.offsetHeight > this.hints.scrollTop + this.hints.clientHeight) - this.hints.scrollTop = node.offsetTop + node.offsetHeight - this.hints.clientHeight + 3; - CodeMirror.signal(this.data, "select", this.data.list[this.selectedHint], node); - }, - - screenAmount: function () { - return Math.floor(this.hints.clientHeight / this.hints.firstChild.offsetHeight) || 1; - } -}; - -function applicableHelpers(cm, helpers) { - if (!cm.somethingSelected()) return helpers - var result = [] - for (var i = 0; i < helpers.length; i++) - if (helpers[i].supportsSelection) result.push(helpers[i]) - return result -} - -function fetchHints(hint, cm, options, callback) { - if (hint.async) { - hint(cm, callback, options) - } else { - var result = hint(cm, options) - if (result && result.then) result.then(callback) - else callback(result) - } -} - -function resolveAutoHints(cm, pos) { - var helpers = cm.getHelpers(pos, "hint"), - words - if (helpers.length) { - var resolved = function (cm, callback, options) { - var app = applicableHelpers(cm, helpers); - - function run(i) { - if (i == app.length) return callback(null) - fetchHints(app[i], cm, options, function (result) { - if (result && result.list.length > 0) callback(result) - else run(i + 1) - }) - } - run(0) - } - resolved.async = true - resolved.supportsSelection = true - return resolved - } else if (words = cm.getHelper(cm.getCursor(), "hintWords")) { - return function (cm) { - return CodeMirror.hint.fromList(cm, { - words: words - }) - } - } else if (CodeMirror.hint.anyword) { - return function (cm, options) { - return CodeMirror.hint.anyword(cm, options) - } - } else { - return function () {} - } -} - -CodeMirror.registerHelper("hint", "auto", { - resolve: resolveAutoHints -}); - -CodeMirror.registerHelper("hint", "fromList", function (cm, options) { - var cur = cm.getCursor(), - token = cm.getTokenAt(cur) - var term, from = CodeMirror.Pos(cur.line, token.start), - to = cur - if (token.start < cur.ch && /\w/.test(token.string.charAt(cur.ch - token.start - 1))) { - term = token.string.substr(0, cur.ch - token.start) - } else { - term = "" - from = cur - } - var found = []; - for (var i = 0; i < options.words.length; i++) { - var word = options.words[i]; - if (word.slice(0, term.length) == term) - found.push(word); - } - - if (found.length) return { - list: found, - from: from, - to: to - }; -}); - -CodeMirror.commands.autocomplete = CodeMirror.showHint; - -var defaultOptions = { - hint: CodeMirror.hint.auto, - completeSingle: true, - alignWithWord: true, - closeCharacters: /[\s()\[\]{};:>,]/, - closeOnUnfocus: true, - completeOnSingleClick: true, - container: null, - customKeys: null, - extraKeys: null -}; - -CodeMirror.defineOption("hintOptions", null); -var WORD = /[\w$]+/, - RANGE = 500; - -CodeMirror.registerHelper("hint", "anyword", function (editor, options) { - var word = options && options.word || WORD; - var range = options && options.range || RANGE; - var cur = editor.getCursor(), - curLine = editor.getLine(cur.line); - var end = cur.ch, - start = end; - while (start && word.test(curLine.charAt(start - 1))) --start; - var curWord = start != end && curLine.slice(start, end); - - var list = options && options.list || [], - seen = {}; - var re = new RegExp(word.source, "g"); - for (var dir = -1; dir <= 1; dir += 2) { - var line = cur.line, - endLine = Math.min(Math.max(line + dir * range, editor.firstLine()), editor.lastLine()) + dir; - for (; line != endLine; line += dir) { - var text = editor.getLine(line), - m; - while (m = re.exec(text)) { - if (line == cur.line && m[0] === curWord) continue; - if ((!curWord || m[0].lastIndexOf(curWord, 0) == 0) && !Object.prototype.hasOwnProperty.call(seen, m[0])) { - seen[m[0]] = true; - list.push(m[0]); - } - } - } - } - return { - list: list, - from: CodeMirror.Pos(cur.line, start), - to: CodeMirror.Pos(cur.line, end) - }; -}); \ No newline at end of file +CodeMirror.defineSimpleMode('tickscript', modeTickscript) \ No newline at end of file diff --git a/ui/src/flux/components/TimeMachineEditor.tsx b/ui/src/flux/components/TimeMachineEditor.tsx index db5690a967..5bd97af2ef 100644 --- a/ui/src/flux/components/TimeMachineEditor.tsx +++ b/ui/src/flux/components/TimeMachineEditor.tsx @@ -5,6 +5,7 @@ import {ShowHintOptions} from 'src/types/codemirror' import {ErrorHandling} from 'src/shared/decorators/errors' import {OnChangeScript, OnSubmitScript, Suggestion} from 'src/types/flux' import {getFluxCompletions} from 'src/flux/helpers/autoComplete' +import 'src/external/codemirror' interface Gutter { line: number From 9d2181fc1a3061b70698109d1518970bbb6a36fb Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Tue, 5 Jun 2018 17:12:44 -0700 Subject: [PATCH 4/6] Improve function specific autocomplete Co-authored-by: Chris Henn Co-authored-by: Andrew Watkins --- ui/src/flux/components/TimeMachineEditor.tsx | 31 ++- ui/src/flux/helpers/autoComplete.ts | 191 +++++++++++++++++-- ui/test/flux/helpers/autoComplete.test.ts | 109 +++++++++++ 3 files changed, 303 insertions(+), 28 deletions(-) create mode 100644 ui/test/flux/helpers/autoComplete.test.ts diff --git a/ui/src/flux/components/TimeMachineEditor.tsx b/ui/src/flux/components/TimeMachineEditor.tsx index 5bd97af2ef..812e699c4a 100644 --- a/ui/src/flux/components/TimeMachineEditor.tsx +++ b/ui/src/flux/components/TimeMachineEditor.tsx @@ -4,7 +4,8 @@ import {EditorChange} from 'codemirror' import {ShowHintOptions} from 'src/types/codemirror' import {ErrorHandling} from 'src/shared/decorators/errors' import {OnChangeScript, OnSubmitScript, Suggestion} from 'src/types/flux' -import {getFluxCompletions} from 'src/flux/helpers/autoComplete' +import {EXCLUDED_KEYS} from 'src/flux/constants/editor' +import {getSuggestions} from 'src/flux/helpers/autoComplete' import 'src/external/codemirror' interface Gutter { @@ -133,15 +134,29 @@ class TimeMachineEditor extends PureComponent { private onTouchStart = () => {} - private handleKeyUp = (editor: EditorInstance, e: KeyboardEvent) => { - const {suggestions} = this.props - const space = ' ' + private handleKeyUp = (__, e: KeyboardEvent) => { + const {ctrlKey, metaKey, key} = e - if (e.ctrlKey && e.key === space) { - editor.showHint({ - hint: () => getFluxCompletions(this.editor, suggestions), - }) + if (ctrlKey && key === ' ') { + this.showAutoComplete() + + return } + + if (ctrlKey || metaKey || EXCLUDED_KEYS.includes(key)) { + return + } + + this.showAutoComplete() + } + + private showAutoComplete() { + const {suggestions} = this.props + + this.editor.showHint({ + hint: () => getSuggestions(this.editor, suggestions), + completeSingle: false, + }) } private updateCode = ( diff --git a/ui/src/flux/helpers/autoComplete.ts b/ui/src/flux/helpers/autoComplete.ts index 72223a96be..33cca2c1b1 100644 --- a/ui/src/flux/helpers/autoComplete.ts +++ b/ui/src/flux/helpers/autoComplete.ts @@ -1,49 +1,200 @@ -import CodeMirror from 'codemirror' import {IInstance} from 'react-codemirror2' import {Suggestion} from 'src/types/flux' import {Hints} from 'src/types/codemirror' -export const getFluxCompletions = ( +export const getSuggestions = ( editor: IInstance, - list: Suggestion[] + allSuggestions: Suggestion[] ): Hints => { const cursor = editor.getCursor() - const currentLine = editor.getLine(cursor.line) - const trailingWhitespace = /[\w$]+/ + const currentLineNumber = cursor.line + const currentLineText = editor.getLine(cursor.line) + const cursorPosition = cursor.ch - let start = cursor.ch + const {start, end, suggestions} = getSuggestionsHelper( + currentLineText, + cursorPosition, + allSuggestions + ) + + return { + from: {line: currentLineNumber, ch: start}, + to: {line: currentLineNumber, ch: end}, + list: suggestions, + } +} + +export const getSuggestionsHelper = ( + currentLineText: string, + cursorPosition: number, + allSuggestions: Suggestion[] +) => { + if (shouldCompleteParam(currentLineText, cursorPosition)) { + return getParamSuggestions(currentLineText, cursorPosition, allSuggestions) + } + + if (shouldCompleteFunction(currentLineText, cursorPosition)) { + return getFunctionSuggestions( + currentLineText, + cursorPosition, + allSuggestions + ) + } + + return { + start: -1, + end: -1, + suggestions: [], + } +} + +const shouldCompleteFunction = (currentLineText, cursorPosition) => { + const startOfFunc = '(' + const endOfFunc = ')' + const endOfParamKey = ':' + const endOfParam = ',' + const pipe = '|>' + + let i = cursorPosition + + // First travel left; the first special characters we should see are from a pipe + while (i) { + const char = currentLineText[i] + const charBefore = currentLineText[i - 1] + + if (char + charBefore === pipe || char === endOfFunc) { + break + } else if (char === startOfFunc || char === endOfParamKey) { + return false + } + i -= 1 + } + + i = cursorPosition + + // Then travel right; the first special character we should see is an opening paren '(' + while (i < currentLineText.length) { + const char = currentLineText[i] + + if (char === endOfParamKey || char === endOfFunc || char === endOfParam) { + return false + } + + i += 1 + } + + return true +} + +const shouldCompleteParam = (currentLineText, cursorPosition) => { + let i = cursorPosition + + while (i) { + const char = currentLineText[i] + const charBefore = currentLineText[i - 1] + + if (char === ':' || char === '>' || char === ')') { + return false + } + + if (char === '(' || charBefore + char === ', ') { + return true + } + + i -= 1 + } + + return false +} + +export const getParamSuggestions = ( + currentLineText: string, + cursorPosition: number, + allSuggestions: Suggestion[] +) => { + let end = cursorPosition + + while (end && currentLineText[end] !== '(') { + end -= 1 + } + + let start = end + + while (start && /[\w\(]/.test(currentLineText[start])) { + start -= 1 + } + + const functionName = currentLineText.slice(start, end).trim() + const func = allSuggestions.find(({name}) => name === functionName) + + if (!func) { + return {start, end, suggestions: []} + } + + let startOfParamKey = cursorPosition + + while (!['(', ' '].includes(currentLineText[startOfParamKey - 1])) { + startOfParamKey -= 1 + } + + return { + start: startOfParamKey, + end: cursorPosition, + suggestions: Object.entries(func.params).map(([paramName, paramType]) => { + let displayText = paramName + + // Work around a bug in Flux where types are sometimes returned as "invalid" + if (paramType !== 'invalid') { + displayText = `${paramName} <${paramType}>` + } + + return { + text: `${paramName}: `, + displayText, + } + }), + } +} + +export const getFunctionSuggestions = ( + currentLineText: string, + cursorPosition: number, + allSuggestions: Suggestion[] +) => { + const trailingWhitespace = /[\w]+/ + + let start = cursorPosition let end = start // Move end marker until a space or end of line is reached while ( - end < currentLine.length && - trailingWhitespace.test(currentLine.charAt(end)) + end < currentLineText.length && + trailingWhitespace.test(currentLineText.charAt(end)) ) { end += 1 } // Move start marker until a space or the beginning of line is reached - while (start && trailingWhitespace.test(currentLine.charAt(start - 1))) { + while (start && trailingWhitespace.test(currentLineText.charAt(start - 1))) { start -= 1 } // If not completing inside a current word, return list of all possible suggestions if (start === end) { - return { - from: CodeMirror.Pos(cursor.line, start), - to: CodeMirror.Pos(cursor.line, end), - list: list.map(s => s.name), - } + return {start, end, suggestions: allSuggestions.map(s => s.name)} } - const currentWord = currentLine.slice(start, end) + const currentWord = currentLineText.slice(start, end) const listFilter = new RegExp(`^${currentWord}`, 'i') // Otherwise return suggestions that contain the current word as a substring - return { - from: CodeMirror.Pos(cursor.line, start), - to: CodeMirror.Pos(cursor.line, end), - list: list.filter(s => s.name.match(listFilter)).map(s => s.name), - } + const names = allSuggestions.map(s => s.name) + const filtered = names.filter(name => name.match(listFilter)) + const suggestions = filtered.map(displayText => ({ + text: `${displayText}(`, + displayText, + })) + + return {start, end, suggestions} } diff --git a/ui/test/flux/helpers/autoComplete.test.ts b/ui/test/flux/helpers/autoComplete.test.ts new file mode 100644 index 0000000000..21e71273ac --- /dev/null +++ b/ui/test/flux/helpers/autoComplete.test.ts @@ -0,0 +1,109 @@ +import {getSuggestionsHelper} from 'src/flux/helpers/autoComplete' + +const ALL_SUGGESTIONS = [ + {name: 'filter', params: {foo: 'function', bux: 'string'}}, + {name: 'first', params: {baz: 'invalid'}}, + {name: 'baz', params: {bar: 'array'}}, +] + +describe('Flux.helpers.autoComplete', () => { + describe('function completion', () => { + it('can complete a function when partially typed', () => { + const lineText = ' |> fi' + const cursorPosition = lineText.length + const actual = getSuggestionsHelper( + lineText, + cursorPosition, + ALL_SUGGESTIONS + ) + const expected = { + start: 4, + end: 6, + suggestions: [ + {displayText: 'filter', text: 'filter('}, + {displayText: 'first', text: 'first('}, + ], + } + + expect(actual).toEqual(expected) + }) + + it('shows all completions when no function is typed', () => { + const lineText = ' |> ' + const cursorPosition = lineText.length + const actual = getSuggestionsHelper( + lineText, + cursorPosition, + ALL_SUGGESTIONS + ) + const expected = { + start: 4, + end: 4, + suggestions: ['filter', 'first', 'baz'], + } + + expect(actual).toEqual(expected) + }) + + it('shows all completions after a closing a function with no parameters', () => { + const lineText = ' |> filter() ' + const cursorPosition = lineText.length + const actual = getSuggestionsHelper( + lineText, + cursorPosition, + ALL_SUGGESTIONS + ) + const expected = { + start: 13, + end: 13, + suggestions: ['filter', 'first', 'baz'], + } + + expect(actual).toEqual(expected) + }) + + describe('parameter completion', () => { + it('shows all parameters for a function', () => { + const lineText = ' |> filter(' + const cursorPosition = lineText.length + const actual = getSuggestionsHelper( + lineText, + cursorPosition, + ALL_SUGGESTIONS + ) + + const expected = { + start: 11, + end: 11, + suggestions: [ + {displayText: 'foo ', text: 'foo: '}, + {displayText: 'bux ', text: 'bux: '}, + ], + } + + expect(actual).toEqual(expected) + }) + + it('shows parameters after the second position', () => { + const lineText = ' |> filter(foo: "bar", ' + const cursorPosition = lineText.length + const actual = getSuggestionsHelper( + lineText, + cursorPosition, + ALL_SUGGESTIONS + ) + + const expected = { + start: 23, + end: 23, + suggestions: [ + {displayText: 'foo ', text: 'foo: '}, + {displayText: 'bux ', text: 'bux: '}, + ], + } + + expect(actual).toEqual(expected) + }) + }) + }) +}) From bee3a4aa3e35ae67d8ea78ecb0d9ef4ff2038e94 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Wed, 6 Jun 2018 12:01:46 -0700 Subject: [PATCH 5/6] Don't insert newlines with default Flux script Co-authored-by: Chris Henn Co-authored-by: Andrew Watkins --- ui/src/flux/containers/FluxPage.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/src/flux/containers/FluxPage.tsx b/ui/src/flux/containers/FluxPage.tsx index 404902d502..62656d41ac 100644 --- a/ui/src/flux/containers/FluxPage.tsx +++ b/ui/src/flux/containers/FluxPage.tsx @@ -268,8 +268,15 @@ export class FluxPage extends PureComponent { private handleAppendFrom = (): void => { const {script} = this.props - const newScript = `${script.trim()}\n\n${builder.NEW_FROM}\n\n` + let newScript = script.trim() + const from = builder.NEW_FROM + if (!newScript) { + this.getASTResponse(from) + return + } + + newScript = `${script.trim()}\n\n${from}\n\n` this.getASTResponse(newScript) } From 5b5622cfb9443fcec24fc6bc623e506aa6575a75 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Wed, 6 Jun 2018 13:01:31 -0700 Subject: [PATCH 6/6] Fix autocomplete typo --- ui/src/flux/helpers/autoComplete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/flux/helpers/autoComplete.ts b/ui/src/flux/helpers/autoComplete.ts index 33cca2c1b1..2b1149afed 100644 --- a/ui/src/flux/helpers/autoComplete.ts +++ b/ui/src/flux/helpers/autoComplete.ts @@ -63,7 +63,7 @@ const shouldCompleteFunction = (currentLineText, cursorPosition) => { const char = currentLineText[i] const charBefore = currentLineText[i - 1] - if (char + charBefore === pipe || char === endOfFunc) { + if (charBefore + char === pipe || char === endOfFunc) { break } else if (char === startOfFunc || char === endOfParamKey) { return false