mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
42060df29a
Summary: Makes the following improvements to formula autocomplete: - When a user types `$RefCol` (or part of it), also show `$RefCol.VisibleCol` (replace actual column names) in the autocomplete even before the `.` is typed, to help users understand the difference between a raw reference/record and its visible column. - When a user types a table name, show `.lookupOne` and `.lookupRecords` in the autocomplete, again even before the `.` is typed. - For `.lookupRecords(` and `.lookupOne(`, once the `(` is entered, suggest each column name as a keyword argument. - Also suggest lookup arguments involving compatible reference columns, especially 'reverse reference' lookups like `refcol=$id` which are very common and difficult for users. - To support these features, the Ace editor autocomplete needs some patching to fetch fresh autocomplete options after typing `.` or `(`. This also improves unrelated behaviour that wasn't great before when one column name is contained in another. See the first added browser test. Discussions: - https://grist.slack.com/archives/CDHABLZJT/p1659707068383179 - https://grist.quip.com/HoSmAlvFax0j#MbTADAH5kgG - https://grist.quip.com/HoSmAlvFax0j/Formula-Improvements#temp:C:MbT3649fe964a184e8dada9bbebb Test Plan: Added Python and nbrowser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3580
225 lines
9.8 KiB
TypeScript
225 lines
9.8 KiB
TypeScript
import * as ace from 'brace';
|
|
|
|
// Suggestion may be a string, or a tuple [funcname, argSpec, isGrist], where:
|
|
// - funcname (e.g. "DATEADD") will be auto-completed with "(", AND linked to Grist
|
|
// documentation.
|
|
// - argSpec (e.g. "(start_date, days=0, ...)") is to be shown as autocomplete caption.
|
|
// - isGrist determines whether to tag this suggestion as "grist" or "python".
|
|
export type ISuggestion = string | [string, string, boolean];
|
|
|
|
export interface ICompletionOptions {
|
|
getSuggestions(prefix: string): Promise<ISuggestion[]>;
|
|
}
|
|
|
|
const completionOptions = new WeakMap<ace.Editor, ICompletionOptions>();
|
|
|
|
export function setupAceEditorCompletions(editor: ace.Editor, options: ICompletionOptions) {
|
|
initCustomCompleter();
|
|
completionOptions.set(editor, options);
|
|
|
|
// Create Autocomplete object at this point so we can turn autoSelect off.
|
|
// There doesn't seem to be any way to get ace to respect autoSelect otherwise.
|
|
// It is important for autoSelect to be off so that hitting enter doesn't automatically
|
|
// use a suggestion, a change of behavior that doesn't seem particularly desirable and
|
|
// which also breaks several existing tests.
|
|
const {Autocomplete} = ace.acequire('ace/autocomplete'); // lives in brace/ext/language_tools
|
|
const completer = new Autocomplete();
|
|
completer.autoSelect = false;
|
|
(editor as any).completer = completer;
|
|
|
|
// Patch updateCompletions and insertMatch so that fresh completions are fetched when the user types '.' or '('
|
|
const originalUpdate = completer.updateCompletions.bind(completer);
|
|
completer.updateCompletions = function(this: any, keepPopupPosition: boolean) {
|
|
// The next three lines are copied from updateCompletions() in the ace autocomplete source code.
|
|
if (keepPopupPosition && this.base && this.completions) {
|
|
const pos = this.editor.getCursorPosition();
|
|
const prefix = this.editor.session.getTextRange({start: this.base, end: pos});
|
|
// If the cursor is just after '.' or '(', prevent this same block from running
|
|
// in the original updateCompletions() function. Otherwise it will just keep any remaining completions that match,
|
|
// or not show any completions at all.
|
|
// But the last character implies that the set of completions is likely to have changed.
|
|
if (prefix.endsWith(".") || prefix.endsWith("(")) {
|
|
this.completions = null;
|
|
}
|
|
}
|
|
return originalUpdate(keepPopupPosition);
|
|
}.bind(completer);
|
|
|
|
// Similar patch to the above.
|
|
const originalInsertMatch = completer.insertMatch.bind(completer);
|
|
completer.insertMatch = function(this: any) {
|
|
const base = this.base; // this.base may become null after the next line, save it now.
|
|
const result = originalInsertMatch.apply(...arguments);
|
|
// Like in the above patch, get the current text in the editor to be completed.
|
|
const pos = this.editor.getCursorPosition();
|
|
const prefix = this.editor.session.getTextRange({start: base, end: pos});
|
|
// This patch is specifically for when a previous completion is inserted by pressing Enter/Tab,
|
|
// and such completions may end in '(', which can lead to more completions, e.g. for `.lookupRecords(`.
|
|
if (prefix.endsWith("(")) {
|
|
this.showPopup(this.editor);
|
|
}
|
|
return result;
|
|
}.bind(completer);
|
|
|
|
aceCompleterAddHelpLinks(completer);
|
|
|
|
// Explicitly destroy the auto-completer on disposal, since it doesn't not remove the element
|
|
// it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any
|
|
// interface for this, so this takes some hacking. (One reason for this is that Ace seems to
|
|
// expect that a single AutoCompleter would be used for all editor instances.)
|
|
editor.on('destroy', () => {
|
|
if (completer.editor) {
|
|
completer.detach();
|
|
}
|
|
if (completer.popup) {
|
|
completer.popup.destroy(); // This is not enough, but seems relevant to call.
|
|
completer.popup.container.remove(); // Removes the element from DOM.
|
|
}
|
|
});
|
|
}
|
|
|
|
let _initialized = false;
|
|
function initCustomCompleter() {
|
|
if (_initialized) { return; }
|
|
_initialized = true;
|
|
|
|
// Add some autocompletion with partial access to document
|
|
const aceLanguageTools = ace.acequire('ace/ext/language_tools');
|
|
aceLanguageTools.setCompleters([]);
|
|
aceLanguageTools.addCompleter({
|
|
// Default regexp stops at periods, which doesn't let autocomplete
|
|
// work on members. So we expand it to include periods.
|
|
// We also include $, which grist uses for column names,
|
|
// and '(' for the start of a function call, which may provide completions for arguments.
|
|
identifierRegexps: [/[a-zA-Z_0-9.$\u00A2-\uFFFF(]/],
|
|
|
|
// For autocompletion we ship text to the sandbox and run standard completion there.
|
|
async getCompletions(editor: ace.Editor, session: ace.IEditSession, pos: number, prefix: string, callback: any) {
|
|
const options = completionOptions.get(editor);
|
|
if (!options || prefix.length === 0) { callback(null, []); return; }
|
|
const suggestions = await options.getSuggestions(prefix);
|
|
// ACE autocompletions are very poorly documented. This is somewhat helpful:
|
|
// https://prog.world/implementing-code-completion-in-ace-editor/
|
|
callback(null, suggestions.map(suggestion => {
|
|
if (Array.isArray(suggestion)) {
|
|
const [funcname, argSpec, isGrist] = suggestion;
|
|
const meta = isGrist ? 'grist' : 'python';
|
|
return {value: funcname + '(', caption: funcname + argSpec, score: 1, meta, funcname};
|
|
} else {
|
|
return {value: suggestion, score: 1, meta: "python"};
|
|
}
|
|
}));
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* When autocompleting a known function (with funcname received from the server call), turn the
|
|
* function name into a link to Grist documentation.
|
|
*
|
|
* This is only applied for items returned from getCompletions() that include a our custom
|
|
* `funcname` attribute.
|
|
*
|
|
* ACE autocomplete is poorly documented, and poorly customizable, so this is accomplished by
|
|
* monkey-patching it. Further, the only text styling is done via styled tokens, but we can style
|
|
* them to look like links, and handle clicks to open the destination URL.
|
|
*
|
|
* This implementation relies a lot on the details of the implementation in
|
|
* node_modules/brace/ext/language_tools.js. Updates to brace module may easily break it.
|
|
*/
|
|
function aceCompleterAddHelpLinks(completer: any) {
|
|
// Replace the $init function in order to intercept the creation of the autocomplete popup.
|
|
const init = completer.$init;
|
|
completer.$init = function() {
|
|
const popup = init.apply(this, arguments);
|
|
customizeAceCompleterPopup(this, popup);
|
|
return popup;
|
|
};
|
|
}
|
|
|
|
function customizeAceCompleterPopup(completer: any, popup: any) {
|
|
// Replace the $tokenizeRow function to produce customized tokens to style the link part.
|
|
const origTokenize = popup.session.bgTokenizer.$tokenizeRow;
|
|
popup.session.bgTokenizer.$tokenizeRow = function(row: any) {
|
|
const tokens = origTokenize(row);
|
|
return retokenizeAceCompleterRow(popup.data[row], tokens);
|
|
};
|
|
|
|
// Replace the click handler with one that handles link clicks.
|
|
popup.removeAllListeners("click");
|
|
popup.on("click", function(e: any) {
|
|
if (!maybeAceCompleterLinkClick(e.domEvent)) {
|
|
completer.insertMatch();
|
|
}
|
|
e.stop();
|
|
});
|
|
}
|
|
|
|
interface TokenInfo extends ace.TokenInfo {
|
|
type: string;
|
|
}
|
|
|
|
function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo[] {
|
|
if (!rowData.funcname) {
|
|
// Not a special completion, pass through the result of ACE's original tokenizing.
|
|
return tokens;
|
|
}
|
|
|
|
// ACE's original tokenizer splits rowData.caption into tokens to highlight matching portions.
|
|
// We jump in, and further divide the tokens so that those that form the link get an extra CSS
|
|
// class. ACE's will turn token.type into CSS classes by splitting the type on "." and prefixing
|
|
// the resulting substrings with "ace_".
|
|
|
|
// Funcname may be the recognized name itself (e.g. "UPPER"), or a method (like
|
|
// "Table1.lookupOne"), in which case only the portion after the dot is the recognized name.
|
|
|
|
// Figure out the portion that should be linkified.
|
|
const dot = rowData.funcname.lastIndexOf(".");
|
|
const linkStart = dot < 0 ? 0 : dot + 1;
|
|
const linkEnd = rowData.funcname.length;
|
|
|
|
const newTokens = [];
|
|
|
|
// Include into new tokens a special token that will be hidden, but include the link URL. On
|
|
// click, we find it to know what URL to open.
|
|
const href = 'https://support.getgrist.com/functions/#' +
|
|
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
|
newTokens.push({value: href, type: 'grist_link_hidden'});
|
|
|
|
// Go through tokens, splitting them if needed, and modifying those that form the link part.
|
|
let position = 0;
|
|
for (const t of tokens) {
|
|
// lStart/lEnd are indices of the link within the token, possibly negative.
|
|
const lStart = linkStart - position, lEnd = linkEnd - position;
|
|
if (lStart > 0) {
|
|
const beforeLink = t.value.slice(0, lStart);
|
|
newTokens.push({value: beforeLink, type: t.type});
|
|
}
|
|
if (lEnd > 0) {
|
|
const inLink = t.value.slice(Math.max(0, lStart), lEnd);
|
|
const newType = t.type + (t.type ? '.' : '') + 'grist_link';
|
|
newTokens.push({value: inLink, type: newType});
|
|
}
|
|
if (lEnd < t.value.length) {
|
|
const afterLink = t.value.slice(lEnd);
|
|
newTokens.push({value: afterLink, type: t.type});
|
|
}
|
|
position += t.value.length;
|
|
}
|
|
return newTokens;
|
|
}
|
|
|
|
// On any click on AceCompleter popup, we check if we happened to click .ace_grist_link class. If
|
|
// so, we should be able to find the URL and open another window to it.
|
|
function maybeAceCompleterLinkClick(domEvent: Event) {
|
|
const tgt = domEvent.target as HTMLElement;
|
|
if (tgt && tgt.matches('.ace_grist_link')) {
|
|
const dest = tgt.parentElement?.querySelector('.ace_grist_link_hidden');
|
|
if (dest) {
|
|
window.open(dest.textContent!, "_blank");
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|