From 8d8456fd91c2bec9829c022945b4b1f640f57d7a Mon Sep 17 00:00:00 2001 From: Felix Kling Date: Wed, 18 Jan 2023 14:16:13 +0100 Subject: [PATCH] search: Add search context support to experimental search input (#46417) The current version of the search input prototype does not include a separate input element for the search context. Instead the search context is treated like any other filter, except we style it slightly differently (see video). This diff does a couple of things: - Adds support for value completion for the context: filter. - Renders context: filters differently from other filters in the input. - Refactors the whole suggestions implementation so that it will be easier to add new suggestion sources in the future. - Introduces a custom version of the built-in placeholder extension that allows us to overwrite when/how the placeholder should be visible. This diff also fixes an issue where we did not update the selected search context correctly when it wasn't present in a query. I also had to update @codemirror/view and @codemirror/state to fix an issue with rendering decorations. This doesn't seem to have any effect on the current search input. Unfortunately it didn't fix all the issues (which I also point out in the video). NOTE: Keep in mind that this is still a prototype and we are still verifying our approach. You might not agree with how search contexts work in this version, but that should not be the focus of the code review here. --- .../src/search-ui/input/codemirror/index.ts | 1 + .../search-ui/input/codemirror/placeholder.ts | 60 ++ .../input/codemirror/syntax-highlighting.ts | 36 +- .../CodeMirrorQueryInputWrapper.module.scss | 16 +- .../CodeMirrorQueryInputWrapper.tsx | 59 +- .../input/experimental/Suggestions.tsx | 90 +-- .../codemirror/syntax-highlighting.ts | 141 ++++ .../src/search-ui/input/experimental/index.ts | 2 +- .../experimental/suggestionsExtension.ts | 7 +- client/shared/src/util/dom.test.ts | 3 + client/shared/src/util/dom.ts | 1 + client/web/src/SourcegraphWebApp.tsx | 5 + client/web/src/search/home/SearchPage.tsx | 9 + .../web/src/search/home/SearchPageInput.tsx | 36 +- client/web/src/search/input/suggestions.ts | 649 ++++++++++++------ package.json | 4 +- pnpm-lock.yaml | 71 +- 17 files changed, 838 insertions(+), 352 deletions(-) create mode 100644 client/branded/src/search-ui/input/codemirror/placeholder.ts create mode 100644 client/branded/src/search-ui/input/experimental/codemirror/syntax-highlighting.ts diff --git a/client/branded/src/search-ui/input/codemirror/index.ts b/client/branded/src/search-ui/input/codemirror/index.ts index 69833e61642..81e0617de5e 100644 --- a/client/branded/src/search-ui/input/codemirror/index.ts +++ b/client/branded/src/search-ui/input/codemirror/index.ts @@ -14,6 +14,7 @@ import { } from './completion' import { loadingIndicator } from './loading-indicator' export { tokenAt, tokens } from './parsedQuery' +export { placeholder } from './placeholder' export { createDefaultSuggestionSources, searchQueryAutocompletion } export type { StandardSuggestionSource } diff --git a/client/branded/src/search-ui/input/codemirror/placeholder.ts b/client/branded/src/search-ui/input/codemirror/placeholder.ts new file mode 100644 index 00000000000..0195a7515fd --- /dev/null +++ b/client/branded/src/search-ui/input/codemirror/placeholder.ts @@ -0,0 +1,60 @@ +/** + * This is an adaption of the built-in CodeMirror placeholder to make it + * configurable when the placeholder should be shown or not. + */ +import { EditorState, Extension } from '@codemirror/state' +import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view' + +class Placeholder extends WidgetType { + constructor(private readonly content: string) { + super() + } + + public toDOM(): HTMLElement { + const wrap = document.createElement('span') + wrap.className = 'cm-placeholder' + wrap.style.pointerEvents = 'none' + wrap.setAttribute('aria-hidden', 'true') + wrap.append(document.createTextNode(this.content)) + return wrap + } + + public ignoreEvent(): boolean { + return false + } +} + +function showWhenEmpty(state: EditorState): boolean { + return state.doc.length === 0 +} + +/** + * Extension that shows a placeholder when the provided condition is met. By + * default it will show the placeholder when the document is empty. + */ +export function placeholder(content: string, show: (state: EditorState) => boolean = showWhenEmpty): Extension { + return ViewPlugin.fromClass( + class { + private placeholderDecoration: Decoration + public decorations: DecorationSet + + constructor(view: EditorView) { + this.placeholderDecoration = Decoration.widget({ widget: new Placeholder(content), side: 1 }) + this.decorations = this.createDecorationSet(view.state) + } + + public update(update: ViewUpdate): void { + if (update.docChanged || update.selectionSet) { + this.decorations = this.createDecorationSet(update.view.state) + } + } + + private createDecorationSet(state: EditorState): DecorationSet { + return show(state) + ? Decoration.set([this.placeholderDecoration.range(state.doc.length)]) + : Decoration.none + } + }, + { decorations: plugin => plugin.decorations } + ) +} diff --git a/client/branded/src/search-ui/input/codemirror/syntax-highlighting.ts b/client/branded/src/search-ui/input/codemirror/syntax-highlighting.ts index 9567a0542c5..71e98e261a9 100644 --- a/client/branded/src/search-ui/input/codemirror/syntax-highlighting.ts +++ b/client/branded/src/search-ui/input/codemirror/syntax-highlighting.ts @@ -1,10 +1,9 @@ import { RangeSetBuilder } from '@codemirror/state' import { Decoration, EditorView } from '@codemirror/view' -import inRange from 'lodash/inRange' import { DecoratedToken, toCSSClassName } from '@sourcegraph/shared/src/search/query/decoratedToken' -import { decoratedTokens, queryTokens } from './parsedQuery' +import { decoratedTokens } from './parsedQuery' // Defines decorators for syntax highlighting const tokenDecorators: { [key: string]: Decoration } = {} @@ -30,36 +29,3 @@ export const querySyntaxHighlighting = [ return builder.finish() }), ] - -const validFilter = Decoration.mark({ class: 'sg-filter', inclusive: false }) -const invalidFilter = Decoration.mark({ class: 'sg-filter sg-invalid-filter', inclusive: false }) - -export const filterHighlight = [ - EditorView.baseTheme({ - '.sg-filter': { - backgroundColor: 'var(--oc-blue-0)', - borderRadius: '3px', - padding: '0px', - }, - '.sg-invalid-filter': { - backgroundColor: 'var(--oc-red-1)', - borderColor: 'var(--oc-red-2)', - }, - }), - EditorView.decorations.compute([decoratedTokens, 'selection'], state => { - const query = state.facet(queryTokens) - const builder = new RangeSetBuilder() - for (const token of query.tokens) { - if (token.type === 'filter') { - const isValid = - token?.value?.value || // has non-empty value - token?.value?.quoted || // or is quoted - inRange(state.selection.main.head, token.range.start, token.range.end + 1) // or cursor is within field - - // +1 to include the colon (:) - builder.add(token.range.start, token.field.range.end + 1, isValid ? validFilter : invalidFilter) - } - } - return builder.finish() - }), -] diff --git a/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.module.scss b/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.module.scss index c94b77ad42c..f02edf878be 100644 --- a/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.module.scss +++ b/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.module.scss @@ -49,14 +49,7 @@ } } - .global-shortcut { - display: block; - align-self: center; - border: 1px solid var(--border-color-2); - width: 1.5rem; - } - - button { + .input-button { display: none; align-self: flex-start; padding: 0.125rem 0.25rem; @@ -70,6 +63,13 @@ outline-offset: 0; } } + + .global-shortcut { + display: block; + align-self: center; + border: 1px solid var(--border-color-2); + width: 1.5rem; + } } .suggestions { diff --git a/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.tsx b/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.tsx index 8858d698457..09f884402ed 100644 --- a/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.tsx +++ b/client/branded/src/search-ui/input/experimental/CodeMirrorQueryInputWrapper.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { defaultKeymap, historyKeymap, history as codemirrorHistory } from '@codemirror/commands' import { Compartment, EditorState, Extension, Prec } from '@codemirror/state' -import { EditorView, keymap, placeholder as placeholderExtension } from '@codemirror/view' +import { EditorView, keymap } from '@codemirror/view' import { mdiClose } from '@mdi/js' import classNames from 'classnames' import { History } from 'history' +import inRange from 'lodash/inRange' import { useHistory } from 'react-router' import useResizeObserver from 'use-resize-observer' import * as uuid from 'uuid' @@ -15,10 +16,11 @@ import { Shortcut } from '@sourcegraph/shared/src/react-shortcuts' import { QueryChangeSource, QueryState } from '@sourcegraph/shared/src/search' import { Icon } from '@sourcegraph/wildcard' -import { singleLine } from '../codemirror' -import { parseInputAsQuery } from '../codemirror/parsedQuery' -import { filterHighlight, querySyntaxHighlighting } from '../codemirror/syntax-highlighting' +import { singleLine, placeholder as placeholderExtension } from '../codemirror' +import { parseInputAsQuery, tokens } from '../codemirror/parsedQuery' +import { querySyntaxHighlighting } from '../codemirror/syntax-highlighting' +import { filterHighlight } from './codemirror/syntax-highlighting' import { editorConfigFacet, Source, suggestions } from './suggestionsExtension' import styles from './CodeMirrorQueryInputWrapper.module.scss' @@ -36,6 +38,38 @@ interface ExtensionConfig { history: History } +// We want to show a placeholder also if the query only contains a context +// filter. +function showWhenEmptyWithoutContext(state: EditorState): boolean { + // Show placeholder when empty + if (state.doc.length === 0) { + return true + } + + const queryTokens = tokens(state) + + if (queryTokens.length > 2) { + return false + } + // Only show the placeholder if the cursor is at the end of the content + if (state.selection.main.from !== state.doc.length) { + return false + } + + // If there are two tokens, only show the placeholder if the second one is a + // whitespace. + if (queryTokens.length === 2 && queryTokens[1].type !== 'whitespace') { + return false + } + + return ( + queryTokens.length > 0 && + queryTokens[0].type === 'filter' && + queryTokens[0].field.value === 'context' && + !inRange(state.selection.main.from, queryTokens[0].range.start, queryTokens[0].range.end + 1) + ) +} + // For simplicity we will recompute all extensions when input changes using // this ocmpartment const extensionsCompartment = new Compartment() @@ -69,12 +103,7 @@ function configureExtensions({ ] if (placeholder) { - // Passing a DOM element instead of a string makes the CodeMirror - // extension set aria-hidden="true" on the placeholder, which is - // what we want. - const element = document.createElement('span') - element.append(document.createTextNode(placeholder)) - extensions.push(placeholderExtension(element)) + extensions.push(placeholderExtension(placeholder, showWhenEmptyWithoutContext)) } if (onSubmit) { @@ -117,6 +146,7 @@ function createEditor( return new EditorView({ state: EditorState.create({ doc: queryState.query, + selection: { anchor: queryState.query.length }, extensions: [ EditorView.lineWrapping, EditorView.contentAttributes.of({ @@ -161,7 +191,10 @@ function updateEditor(editor: EditorView | null, extensions: Extension): void { function updateValueIfNecessary(editor: EditorView | null, queryState: QueryState): void { if (editor && queryState.changeSource !== QueryChangeSource.userInput) { - editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: queryState.query } }) + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: queryState.query }, + selection: { anchor: queryState.query.length }, + }) } } @@ -269,14 +302,14 @@ export const CodeMirrorQueryInputWrapper: React.FunctionComponent