Skip to content

Commit

Permalink
Add basic autocomplete support (#213)
Browse files Browse the repository at this point in the history
* Start basic autocomplete

* Basic autocomplete working

* Fix for height

* Fix for fmt

* Fix for nested fields

* Formats

* Bump coverage

* Duplicate ace type definition

* Add copyright
  • Loading branch information
eatonphil committed Apr 7, 2022
1 parent f9f4c0f commit 4b1641f
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 71 deletions.
29 changes: 0 additions & 29 deletions .github/workflows/docker.yml

This file was deleted.

3 changes: 2 additions & 1 deletion ee/tsconfig.json
Expand Up @@ -12,7 +12,8 @@
"typeRoots": [
"../node_modules/@types",
"../type-overrides",
"./node_modules/@types"
"./node_modules/@types",
"./type-overrides"
],
"resolveJsonModule": true
},
Expand Down
4 changes: 4 additions & 0 deletions ee/type-overrides/ace.d.ts
@@ -0,0 +1,4 @@
// Copyright 2022 Multiprocess Labs LLC

declare module 'ace-builds/src-min-noconflict/ace';
declare module 'ace-builds/src-min-noconflict/ext-language_tools';
6 changes: 3 additions & 3 deletions jest.config.js
Expand Up @@ -4,10 +4,10 @@ module.exports = {
process.platform === 'linux'
? {
global: {
statements: 55,
statements: 54,
branches: 41,
functions: 36,
lines: 55,
functions: 35,
lines: 54,
},
}
: undefined,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -28,7 +28,7 @@
"dependencies": {
"@tabler/icons": "^1.56.0",
"ace-builds": "^1.4.13",
"better-sqlite3": "multiprocessio/better-sqlite3#11866f1",
"better-sqlite3": "^7.5.1",
"chart.js": "^3.5.1",
"cookie-parser": "^1.4.5",
"core-js": "^3.21.1",
Expand Down
2 changes: 2 additions & 0 deletions type-overrides/ace.d.ts
@@ -0,0 +1,2 @@
declare module 'ace-builds/src-min-noconflict/ace';
declare module 'ace-builds/src-min-noconflict/ext-language_tools';
106 changes: 85 additions & 21 deletions ui/components/CodeEditor.tsx
@@ -1,7 +1,16 @@
import log from '../../shared/log';
import { useDebouncedCallback } from 'use-debounce';
// organize-imports-ignore
import * as React from 'react';
import { SettingsContext } from '../Settings';
import { Tooltip } from './Tooltip';
import { INPUT_SYNC_PERIOD } from './Input';

// Must be loaded before other ace-builds imports
import AceEditor from 'react-ace';
// organize-imports-ignore
import { Ace } from 'ace-builds';
import ace from 'ace-builds/src-min-noconflict/ace';
import langTools from 'ace-builds/src-min-noconflict/ext-language_tools';
// Enables Ctrl-f
import 'ace-builds/src-min-noconflict/ext-searchbox';
// Enables syntax highlighting
Expand All @@ -15,13 +24,25 @@ import 'ace-builds/src-min-noconflict/mode-sql';
// UI theme
import 'ace-builds/src-min-noconflict/theme-github';
import 'ace-builds/src-min-noconflict/theme-dracula';
import * as React from 'react';
// Shortcuts support, TODO: support non-emacs
// This steals Ctrl-a so this should not be a default
//import 'ace-builds/src-min-noconflict/keybinding-emacs';
import { SettingsContext } from '../Settings';
import { Tooltip } from './Tooltip';
import { INPUT_SYNC_PERIOD } from './Input';

export function skipWhitespaceBackward(it: Ace.TokenIterator) {
while (!it.getCurrentToken().value.trim()) {
if (!it.stepBackward()) {
return;
}
}
}

export function skipWhitespaceForward(it: Ace.TokenIterator) {
while (!it.getCurrentToken().value.trim()) {
if (!it.stepForward()) {
return;
}
}
}

export function CodeEditor({
value,
Expand All @@ -30,6 +51,7 @@ export function CodeEditor({
placeholder,
disabled,
onKeyDown,
autocomplete,
language,
id,
singleLine,
Expand All @@ -42,6 +64,10 @@ export function CodeEditor({
disabled?: boolean;
onKeyDown?: (e: React.KeyboardEvent) => void;
placeholder?: string;
autocomplete?: (
tokenIteratorFactory: () => Ace.TokenIterator,
prefix: string
) => Array<Ace.Completion>;
language: string;
id: string;
singleLine?: boolean;
Expand All @@ -52,7 +78,7 @@ export function CodeEditor({
state: { theme },
} = React.useContext(SettingsContext);

const [editorNode, editorRef] = React.useState<AceEditor>(null);
const [editorRef, setEditorRef] = React.useState<AceEditor>(null);
const debounced = useDebouncedCallback(onChange, INPUT_SYNC_PERIOD);
// Flush on unmount
React.useEffect(
Expand All @@ -65,35 +91,70 @@ export function CodeEditor({
// Make sure editor resizes if the overall panel changes size. For
// example this happens when the preview height changes.
React.useEffect(() => {
if (!editorNode) {
if (!editorRef) {
return;
}

const panel = editorNode.editor.container.closest('.panel');
const panel = editorRef.editor.container.closest('.panel');
const obs = new ResizeObserver(function handleEditorResize() {
editorNode.editor?.resize();
editorRef.editor?.resize();
});
obs.observe(panel);

return () => obs.disconnect();
}, [editorNode]);
}, [editorRef]);

// Resync value when outer changes
React.useEffect(() => {
if (!editorNode || value == editorNode.editor.getValue()) {
if (!editorRef || value == editorRef.editor.getValue()) {
return;
}

// Without this the clearSelection call below moves the cursor to the end of the textarea destroying in-action edits
if (editorNode.editor.container.contains(document.activeElement)) {
if (editorRef.editor.container.contains(document.activeElement)) {
return;
}

editorNode.editor.setValue(value);
editorRef.editor.setValue(value);
// setValue() also highlights the inserted values so this gets rid
// of the highlight. Kind of a weird API really
editorNode.editor.clearSelection();
}, [value, editorNode]);
editorRef.editor.clearSelection();
}, [value, editorRef]);

React.useEffect(() => {
if (!autocomplete) {
return;
}

const { TokenIterator } = ace.require('ace/token_iterator');

const completer = {
getCompletions: (
editor: AceEditor,
session: Ace.EditSession,
pos: Ace.Point,
prefix: string,
callback: Ace.CompleterCallback
) => {
// This gets registered globally which is kind of weird. //
// So it needs to check again that the currently editing editor
// is the one attached to this callback.
if (!autocomplete || (editorRef.editor as unknown) !== editor) {
return callback(null, []);
}

try {
const factory = () => new TokenIterator(session, pos.row, pos.column);
return callback(null, autocomplete(factory, prefix));
} catch (e) {
log.error(e);
return callback(null, []);
}
},
};

langTools.setCompleters([completer]);
}, [autocomplete, editorRef]);

return (
<div
Expand All @@ -103,15 +164,14 @@ export function CodeEditor({
>
{label && <label className="label input-label">{label}</label>}
<AceEditor
ref={editorRef}
ref={setEditorRef}
mode={language}
theme={theme === 'dark' ? 'dracula' : 'github'}
maxLines={singleLine ? 1 : undefined}
wrapEnabled={true}
onBlur={
() =>
debounced.flush() /* Simplifying this to onBlur={debounced.flush} doesn't work. */
}
onBlur={() => {
debounced.flush(); /* Simplifying this to onBlur={debounced.flush} doesn't work. */
}}
name={id}
defaultValue={String(value)}
onChange={(v) => debounced(v)}
Expand Down Expand Up @@ -155,7 +215,11 @@ export function CodeEditor({
setOptions={
singleLine
? { showLineNumbers: false, highlightActiveLine: false }
: undefined
: {
enableBasicAutocompletion: Boolean(autocomplete),
enableLiveAutocompletion: Boolean(autocomplete),
enableSnippets: Boolean(autocomplete),
}
}
/>
{tooltip && <Tooltip>{tooltip}</Tooltip>}
Expand Down
3 changes: 0 additions & 3 deletions ui/components/ContentTypePicker.tsx
Expand Up @@ -67,9 +67,6 @@ export function ContentTypePicker({
</option>
<option value="text/apache2error">Apache2 Error Logs</option>
<option value="text/nginxaccess">Nginx Access Logs</option>
<option value="application/jsonlines">
Newline-delimited JSON
</option>
<option value="text/regexplines">Newline-delimited Regex</option>
</optgroup>
</Select>
Expand Down
27 changes: 26 additions & 1 deletion ui/panels/DatabasePanel.tsx
@@ -1,3 +1,4 @@
import { Ace } from 'ace-builds';
import * as React from 'react';
import { DOCS_ROOT } from '../../shared/constants';
import { NoConnectorError } from '../../shared/errors';
Expand All @@ -6,6 +7,7 @@ import {
ConnectorInfo,
DatabaseConnectorInfo,
DatabasePanelInfo,
PanelInfo,
TimeSeriesRange as TimeSeriesRangeT,
} from '../../shared/state';
import { panelRPC } from '../asyncRPC';
Expand All @@ -16,6 +18,12 @@ import { ServerPicker } from '../components/ServerPicker';
import { TimeSeriesRange } from '../components/TimeSeriesRange';
import { VENDORS } from '../connectors';
import { ProjectContext } from '../state';
import {
builtinCompletions,
dotAccessPanelShapeCompletions,
panelNameCompletions,
stringPanelShapeCompletions,
} from './ProgramPanel';
import { PanelBodyProps, PanelDetailsProps, PanelUIDetails } from './types';

export async function evalDatabasePanel(
Expand Down Expand Up @@ -217,11 +225,13 @@ export function DatabasePanelDetails({
export function DatabasePanelBody({
updatePanel,
panel,
panels,
keyboardShortcuts,
}: PanelBodyProps<DatabasePanelInfo>) {
return (
<CodeEditor
id={panel.id}
autocomplete={makeAutocomplete(panels.filter((p) => p.id !== panel.id))}
id={'editor-' + panel.id}
onKeyDown={keyboardShortcuts}
value={panel.content}
onChange={(value: string) => {
Expand All @@ -234,6 +244,21 @@ export function DatabasePanelBody({
);
}

export function makeAutocomplete(panels: Array<PanelInfo>) {
return (tokenIteratorFactory: () => Ace.TokenIterator, prefix: string) => {
return [
...builtinCompletions(tokenIteratorFactory).filter(
(c) => !c.value.startsWith('DM_setPanel')
),
...panelNameCompletions(tokenIteratorFactory, panels),
...dotAccessPanelShapeCompletions(tokenIteratorFactory, panels),
...stringPanelShapeCompletions(tokenIteratorFactory, panels),
]
.flat()
.filter((c) => c && c.value.startsWith(prefix));
};
}

export function DatabaseInfo({ panel }: { panel: DatabasePanelInfo }) {
const { connectors } = React.useContext(ProjectContext).state;
const connector = connectors.find(
Expand Down
2 changes: 1 addition & 1 deletion ui/panels/FilterAggregatePanel.tsx
Expand Up @@ -137,7 +137,7 @@ export function FilterAggregatePanelDetails({
<div className="form-row">
<CodeEditor
singleLine
id={panel.id + 'filter'}
id={'filter-' + panel.id}
label="Filter"
placeholder="x LIKE '%town%' AND y IN (1, 2)"
value={panel.filagg.filter}
Expand Down
2 changes: 1 addition & 1 deletion ui/panels/HTTPPanel.tsx
Expand Up @@ -181,7 +181,7 @@ export function HTTPPanelBody({
}: PanelBodyProps<HTTPPanelInfo>) {
return (
<CodeEditor
id={panel.id}
id={'editor-' + panel.id}
onKeyDown={keyboardShortcuts}
value={panel.content}
onChange={(value: string) => {
Expand Down
2 changes: 1 addition & 1 deletion ui/panels/LiteralPanel.tsx
Expand Up @@ -74,7 +74,7 @@ export function LiteralPanelBody({

return (
<CodeEditor
id={panel.id}
id={'editor-' + panel.id}
onKeyDown={keyboardShortcuts}
value={panel.content}
onChange={(value: string) => {
Expand Down

0 comments on commit 4b1641f

Please sign in to comment.