insomnia/app/ui/components/codemirror/extensions/environments-autocomplete.js

343 lines
9.6 KiB
JavaScript
Raw Normal View History

import CodeMirror from 'codemirror';
import 'codemirror/addon/mode/overlay';
const NAME_MATCH_FLEXIBLE = /[\w.\][\-/]+$/;
const NAME_MATCH = /[\w.\][]+$/;
const AFTER_VARIABLE_MATCH = /{{\s*[\w.\][]*$/;
const AFTER_TAG_MATCH = /{%\s*[\w.\][]*$/;
const COMPLETE_AFTER_VARIABLE_NAME = /[\w.\][]+/;
const COMPLETE_AFTER_CURLIES = /[^{]*\{[{%]\s*/;
const COMPLETION_CLOSE_KEYS = /[}|]/;
const ESCAPE_FORE_REGEX_MATCH = /[-[\]/{}()*+?.\\^$|]/g;
const MAX_HINT_LOOK_BACK = 100;
const HINT_DELAY_MILLIS = 100;
const TYPE_VARIABLE = 'variable';
const TYPE_TAG = 'tag';
const TYPE_CONSTANT = 'constant';
const MAX_CONSTANTS = -1;
const MAX_VARIABLES = -1;
const MAX_TAGS = -1;
const ICONS = {
[TYPE_CONSTANT]: '𝒄',
[TYPE_VARIABLE]: '𝑥',
[TYPE_TAG]: 'ƒ'
};
const TAGS = [
'uuid',
'now',
'response'
];
CodeMirror.defineExtension('isHintDropdownActive', function () {
return (
this.state.completionActive &&
this.state.completionActive.data &&
this.state.completionActive.data.list &&
this.state.completionActive.data.list.length
);
});
CodeMirror.defineOption('environmentAutocomplete', null, (cm, options) => {
if (!options) {
return;
}
function completeAfter (cm, fn, showAllOnNoMatch = false) {
// Bail early if didn't match the callback test
if (fn && !fn()) {
return CodeMirror.Pass;
}
// Bail early if completions are showing already
if (cm.isHintDropdownActive()) {
return CodeMirror.Pass;
}
// Put the hints in a container with class "dropdown__menu" (for themes)
let hintsContainer = document.querySelector('#hints-container');
if (!hintsContainer) {
const el = document.createElement('div');
el.id = 'hints-container';
el.className = 'dropdown__menu';
document.body.appendChild(el);
hintsContainer = el;
}
const constants = options.getConstants && options.getConstants();
// Actually show the hint
cm.showHint({
// Insomnia-specific options
extraConstants: constants || [],
// Codemirror native options
hint,
getContext: options.getContext,
showAllOnNoMatch,
container: hintsContainer,
closeCharacters: COMPLETION_CLOSE_KEYS,
completeSingle: false,
// closeOnUnfocus: false, // Good for debugging (inspector)
extraKeys: {
'Tab': (cm, widget) => {
// Override default behavior and don't select hint on Tab
widget.close();
return CodeMirror.Pass;
}
}
});
return CodeMirror.Pass;
}
function completeIfInVariableName (cm) {
return completeAfter(cm, () => {
const cur = cm.getCursor();
const pos = CodeMirror.Pos(cur.line, cur.ch - MAX_HINT_LOOK_BACK);
const range = cm.getRange(pos, cur);
return range.match(COMPLETE_AFTER_VARIABLE_NAME);
});
}
function completeIfAfterTagOrVarOpen (cm) {
return completeAfter(cm, () => {
const cur = cm.getCursor();
const pos = CodeMirror.Pos(cur.line, cur.ch - MAX_HINT_LOOK_BACK);
const range = cm.getRange(pos, cur);
return range.match(COMPLETE_AFTER_CURLIES);
}, true);
}
function completeForce (cm) {
return completeAfter(cm, null, true);
}
cm.on('keydown', (cm, e) => {
// Only operate on one-letter keys. This will filter out
// any special keys (Backspace, Enter, etc)
if (e.key.length > 1) {
return;
}
// In a timeout so it gives the editor chance to update first
setTimeout(() => {
completeIfInVariableName(cm);
}, HINT_DELAY_MILLIS);
});
// Add hot key triggers
cm.addKeyMap({
'Ctrl-Space': completeForce, // Force autocomplete on hotkey
"' '": completeIfAfterTagOrVarOpen
});
});
/**
* Function to retrieve the list items
* @param cm
* @param options
* @returns {Promise.<{list: Array, from, to}>}
*/
async function hint (cm, options) {
// Get the text from the cursor back
const cur = cm.getCursor();
const pos = CodeMirror.Pos(cur.line, cur.ch - MAX_HINT_LOOK_BACK);
const previousText = cm.getRange(pos, cur);
// See if we're allowed matching tags, vars, or both
const isInVariable = previousText.match(AFTER_VARIABLE_MATCH);
const isInTag = previousText.match(AFTER_TAG_MATCH);
const isInNothing = !isInVariable && !isInTag;
const allowMatchingVariables = isInNothing || isInVariable;
const allowMatchingTags = isInNothing || isInTag;
const allowMatchingConstants = isInNothing;
// Define fallback segment to match everything or nothing
const fallbackSegment = options.showAllOnNoMatch ? '' : '__will_not_match_anything__';
// See if we're completing a variable name
const nameMatch = previousText.match(NAME_MATCH);
const nameMatchLong = previousText.match(NAME_MATCH_FLEXIBLE);
const nameSegment = nameMatch ? nameMatch[0] : fallbackSegment;
const nameSegmentLong = nameMatchLong ? nameMatchLong[0] : fallbackSegment;
// Actually try to match the list of things
const context = await options.getContext();
const allMatches = [];
if (allowMatchingConstants) {
matchSegments(
options.extraConstants,
[nameSegmentLong, nameSegment],
TYPE_CONSTANT,
MAX_CONSTANTS
).map(m => allMatches.push(m));
}
if (allowMatchingVariables) {
matchSegments(
context.keys,
[nameSegmentLong, nameSegment],
TYPE_VARIABLE,
MAX_VARIABLES
).map(m => allMatches.push(m));
}
if (allowMatchingTags) {
matchSegments(
TAGS,
[nameSegmentLong, nameSegment],
TYPE_TAG,
MAX_TAGS
).map(m => allMatches.push(m));
}
return {
list: allMatches,
from: CodeMirror.Pos(cur.line, cur.ch - nameSegment.length),
to: CodeMirror.Pos(cur.line, cur.ch)
};
}
/**
* Replace the text in the editor when a hint is selected.
* This also makes sure there is whitespace surrounding it
* @param cm
* @param self
* @param data
*/
function replaceHintMatch (cm, self, data) {
const cur = cm.getCursor();
const from = CodeMirror.Pos(cur.line, cur.ch - data.segment.length);
const to = CodeMirror.Pos(cur.line, cur.ch);
const prevStart = CodeMirror.Pos(from.line, from.ch - 10);
const prevChars = cm.getRange(prevStart, from);
const nextEnd = CodeMirror.Pos(to.line, to.ch + 10);
const nextChars = cm.getRange(to, nextEnd);
let prefix = '';
let suffix = '';
if (data.type === TYPE_VARIABLE && !prevChars.match(/{{\s*$/)) {
prefix = '{{ '; // If no closer before
} else if (data.type === TYPE_VARIABLE && prevChars.match(/{{$/)) {
prefix = ' '; // If no space after opener
} else if (data.type === TYPE_TAG && !prevChars.match(/{%\s*$/)) {
prefix = '{% '; // If no closer before
} else if (data.type === TYPE_TAG && prevChars.match(/{%$/)) {
prefix = ' '; // If no space after opener
}
if (data.type === TYPE_VARIABLE && !nextChars.match(/^\s*}}/)) {
suffix = ' }}'; // If no closer after
} else if (data.type === TYPE_VARIABLE && nextChars.match(/^}}/)) {
suffix = ' '; // If no space before closer
} else if (data.type === TYPE_TAG && !nextChars.match(/^\s*%}/)) {
suffix = ' %}'; // If no closer after
} else if (data.type === TYPE_TAG && nextChars.match(/^%}/)) {
suffix = ' '; // If no space before closer
} else if (data.type === TYPE_TAG && nextChars.match(/^\s*}/)) {
// Edge case because "%" doesn't auto-close tags so sometimes you end
// up in the scenario of {% foo}
suffix = ' %';
}
cm.replaceRange(`${prefix}${data.text}${suffix}`, from, to);
}
/**
* Match against a list of things
* @param listOfThings - Can be list of strings or list of {name, value}
* @param segments - List of segments to match against
* @param type
* @param limit
* @returns {Array}
*/
function matchSegments (listOfThings, segments, type, limit = -1) {
const matches = [];
for (const t of listOfThings) {
const name = typeof t === 'string' ? t : t.name;
const value = typeof t === 'string' ? '' : t.value;
for (const segment of segments) {
const matchSegment = segment.toLowerCase();
const matchName = name.toLowerCase();
// Throw away exact matches (why would we want to complete those?)
if (matchName === matchSegment) {
continue;
}
// Throw away things that don't match
if (!matchName.includes(matchSegment)) {
continue;
}
matches.push({
// Custom Insomnia keys
type,
segment,
comment: value,
displayValue: value ? JSON.stringify(value) : '',
score: name.length, // In case we want to sort by this
// CodeMirror
text: name,
displayText: name,
render: renderHintMatch,
hint: replaceHintMatch
});
// Only return the first match (2nd would be a duplicate)
break;
}
}
if (limit >= 0) {
return matches.slice(0, limit);
} else {
return matches;
}
}
/**
* Replace all occurrences of string
* @param text
* @param find
* @param prefix
* @param suffix
* @returns string
*/
function replaceWithSurround (text, find, prefix, suffix) {
const escapedString = find.replace(ESCAPE_FORE_REGEX_MATCH, '\\$&');
const re = new RegExp(escapedString, 'gi');
return text.replace(re, matched => prefix + matched + suffix);
}
/**
* Render the autocomplete list entry
* @param li
* @param self
* @param data
*/
function renderHintMatch (li, self, data) {
// Bold the matched text
const {displayText, segment} = data;
const markedName = replaceWithSurround(displayText, segment, '<strong>', '</strong>');
li.innerHTML = `
<label class="label">${ICONS[data.type]}</label>
<div class="name">${markedName}</div>
<div class="value" title=${data.displayValue}>
${data.displayValue}
</div>
`;
li.className += ` type--${data.type}`;
}