mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 12:51:55 +00:00
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.
This commit is contained in:
parent
c9b27fb69a
commit
8d8456fd91
@ -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 }
|
||||
|
||||
60
client/branded/src/search-ui/input/codemirror/placeholder.ts
Normal file
60
client/branded/src/search-ui/input/codemirror/placeholder.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
@ -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<Decoration>()
|
||||
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()
|
||||
}),
|
||||
]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<CodeMirrorQuer
|
||||
<div ref={setContainer} className="d-contents" />
|
||||
<button
|
||||
type="button"
|
||||
className={classNames({ [styles.showWhenFocused]: hasValue })}
|
||||
className={classNames(styles.inputButton, { [styles.showWhenFocused]: hasValue })}
|
||||
onClick={clear}
|
||||
>
|
||||
<Icon svgPath={mdiClose} aria-label="Clear" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.globalShortcut, styles.hideWhenFocused)}
|
||||
className={classNames(styles.inputButton, styles.globalShortcut, styles.hideWhenFocused)}
|
||||
onClick={focus}
|
||||
>
|
||||
/
|
||||
|
||||
@ -13,7 +13,7 @@ function getNote(option: Option): string {
|
||||
case 'completion':
|
||||
return 'Add'
|
||||
case 'target':
|
||||
return 'Jump to'
|
||||
return option.note ?? 'Jump to'
|
||||
case 'command':
|
||||
return option.note ?? ''
|
||||
}
|
||||
@ -75,50 +75,52 @@ export const Suggestions: React.FunctionComponent<SuggesionsProps> = ({
|
||||
onMouseDown={handleSelection}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{results.map((group, groupIndex) => (
|
||||
<ul role="rowgroup" key={group.title} keyaria-labelledby={`${id}-${groupIndex}-label`}>
|
||||
<li id={`${id}-${groupIndex}-label`} role="presentation">
|
||||
{group.title}
|
||||
</li>
|
||||
{group.options.map((option, rowIndex) => (
|
||||
<li
|
||||
role="row"
|
||||
key={rowIndex}
|
||||
id={`${id}-${groupIndex}x${rowIndex}`}
|
||||
aria-selected={focusedItem === option}
|
||||
>
|
||||
{option.icon && (
|
||||
<div className="pr-1">
|
||||
<Icon className={styles.icon} svgPath={option.icon} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<div role="gridcell">
|
||||
{option.render
|
||||
? option.render(option)
|
||||
: option.matches
|
||||
? [...option.value].map((char, index) =>
|
||||
option.matches!.has(index) ? (
|
||||
<span key={index} className={styles.match}>
|
||||
{char}
|
||||
</span>
|
||||
) : (
|
||||
char
|
||||
)
|
||||
)
|
||||
: option.value}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div role="gridcell" className={styles.description}>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
<div role="gridcell" className={styles.note}>
|
||||
{getNote(option)}
|
||||
</div>
|
||||
{results.map((group, groupIndex) =>
|
||||
group.options.length > 0 ? (
|
||||
<ul role="rowgroup" key={group.title} aria-labelledby={`${id}-${groupIndex}-label`}>
|
||||
<li id={`${id}-${groupIndex}-label`} role="presentation">
|
||||
{group.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
{group.options.map((option, rowIndex) => (
|
||||
<li
|
||||
role="row"
|
||||
key={rowIndex}
|
||||
id={`${id}-${groupIndex}x${rowIndex}`}
|
||||
aria-selected={focusedItem === option}
|
||||
>
|
||||
{option.icon && (
|
||||
<div className="pr-1">
|
||||
<Icon className={styles.icon} svgPath={option.icon} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<div role="gridcell">
|
||||
{option.render
|
||||
? option.render(option)
|
||||
: option.matches
|
||||
? [...option.value].map((char, index) =>
|
||||
option.matches!.has(index) ? (
|
||||
<span key={index} className={styles.match}>
|
||||
{char}
|
||||
</span>
|
||||
) : (
|
||||
char
|
||||
)
|
||||
)
|
||||
: option.value}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div role="gridcell" className={styles.description}>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
<div role="gridcell" className={styles.note}>
|
||||
{getNote(option)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
import { RangeSetBuilder } from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
PluginValue,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from '@codemirror/view'
|
||||
import { mdiClose } from '@mdi/js'
|
||||
import inRange from 'lodash/inRange'
|
||||
|
||||
import { Token } from '@sourcegraph/shared/src/search/query/token'
|
||||
import { createSVGIcon } from '@sourcegraph/shared/src/util/dom'
|
||||
|
||||
import { decoratedTokens, queryTokens } from '../../codemirror/parsedQuery'
|
||||
|
||||
const validFilter = Decoration.mark({ class: 'sg-filter', inclusive: false })
|
||||
const invalidFilter = Decoration.mark({ class: 'sg-filter sg-invalid-filter', inclusive: false })
|
||||
const contextFilter = Decoration.mark({ class: 'sg-context-filter', inclusive: true })
|
||||
const replaceContext = Decoration.replace({})
|
||||
class ClearTokenWidget extends WidgetType {
|
||||
constructor(private token: Token) {
|
||||
super()
|
||||
}
|
||||
|
||||
public toDOM(view: EditorView): HTMLElement {
|
||||
const wrapper = document.createElement('span')
|
||||
wrapper.setAttribute('aria-hidden', 'true')
|
||||
wrapper.className = 'sg-clear-filter'
|
||||
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.addEventListener('click', () => {
|
||||
view.dispatch({
|
||||
// -1/+1 to include possible leading and trailing whitespace
|
||||
changes: {
|
||||
from: Math.max(this.token.range.start - 1, 0),
|
||||
to: Math.min(this.token.range.end + 1, view.state.doc.length),
|
||||
},
|
||||
})
|
||||
if (!view.hasFocus) {
|
||||
view.focus()
|
||||
}
|
||||
})
|
||||
button.append(createSVGIcon(mdiClose))
|
||||
wrapper.append(button)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
}
|
||||
|
||||
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)',
|
||||
},
|
||||
'.sg-context-filter': {
|
||||
borderRadius: '3px',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '0.125rem 0',
|
||||
},
|
||||
'.sg-clear-filter > button': {
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
padding: 0,
|
||||
width: 'var(--icon-inline-size)',
|
||||
height: 'var(--icon-inline-size)',
|
||||
color: 'var(--icon-color)',
|
||||
},
|
||||
}),
|
||||
EditorView.decorations.compute([decoratedTokens, 'selection'], state => {
|
||||
const query = state.facet(queryTokens)
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
for (const token of query.tokens) {
|
||||
if (token.type === 'filter') {
|
||||
const withinRange = inRange(state.selection.main.head, token.range.start, token.range.end + 1) // or cursor is within field
|
||||
const isValid =
|
||||
token?.value?.value || // has non-empty value
|
||||
token?.value?.quoted || // or is quoted
|
||||
withinRange // or cursor is within field
|
||||
|
||||
// context: filters are handled by the view plugin defined below
|
||||
if (token.field.value !== 'context') {
|
||||
// +1 to include the colon (:)
|
||||
builder.add(token.range.start, token.field.range.end + 1, isValid ? validFilter : invalidFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.finish()
|
||||
}),
|
||||
// ViewPlugin handling decorating context: filters
|
||||
ViewPlugin.fromClass(
|
||||
class implements PluginValue {
|
||||
public decorations: DecorationSet
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.createDecorations(view)
|
||||
}
|
||||
|
||||
public update(update: ViewUpdate): void {
|
||||
if (update.focusChanged || update.selectionSet || update.docChanged) {
|
||||
this.decorations = this.createDecorations(update.view)
|
||||
}
|
||||
}
|
||||
|
||||
private createDecorations(view: EditorView): DecorationSet {
|
||||
const query = view.state.facet(queryTokens)
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
for (const token of query.tokens) {
|
||||
if (token.type === 'filter' && token.field.value === 'context') {
|
||||
const withinRange = inRange(
|
||||
view.state.selection.main.head,
|
||||
token.range.start,
|
||||
token.range.end + 1
|
||||
) // or cursor is within field
|
||||
builder.add(token.range.start, token.range.end, contextFilter)
|
||||
if (token.value?.value && (!withinRange || !view.hasFocus)) {
|
||||
// hide context: field name and show remove button
|
||||
builder.add(token.range.start, token.field.range.end + 1, replaceContext)
|
||||
builder.add(
|
||||
token.range.end,
|
||||
token.range.end,
|
||||
Decoration.widget({ widget: new ClearTokenWidget(token) })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.finish()
|
||||
}
|
||||
},
|
||||
{ decorations: plugin => plugin.decorations }
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,4 @@
|
||||
export { LazyCodeMirrorQueryInput } from './LazyCodeMirrorQueryInput'
|
||||
export type { Group, Option, Completion, Target, Command, Source } from './suggestionsExtension'
|
||||
export type { Group, Option, Completion, Target, Command, Source, SuggestionResult } from './suggestionsExtension'
|
||||
export { getEditorConfig } from './suggestionsExtension'
|
||||
export { FilterOption, QueryOption } from './Suggestions'
|
||||
|
||||
@ -71,6 +71,7 @@ export interface Target {
|
||||
icon?: string
|
||||
render?: CustomRenderer
|
||||
description?: string
|
||||
note?: string
|
||||
}
|
||||
export interface Completion {
|
||||
type: 'completion'
|
||||
@ -276,12 +277,16 @@ class RegisteredSource {
|
||||
}
|
||||
|
||||
public update(transaction: Transaction): RegisteredSource {
|
||||
// TODO: We probalby don't want to trigger fetches on every doc changed
|
||||
// TODO: We probably don't want to trigger fetches on every doc changed
|
||||
if (isUserInput(transaction) || transaction.docChanged) {
|
||||
return this.query(transaction.state)
|
||||
}
|
||||
|
||||
if (transaction.selection) {
|
||||
if (!transaction.selection.main.empty) {
|
||||
// Hide suggestions when the user selects a range in the input
|
||||
return new RegisteredSource(this.source, RegisteredSourceState.Inactive, this.result)
|
||||
}
|
||||
if (this.result.valid(transaction.state, transaction.newSelection.main.head)) {
|
||||
return this
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ describe('createSVGIcon', () => {
|
||||
expect(createSVGIcon('M 10 10')).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
style="fill: currentcolor;"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
@ -51,6 +52,7 @@ describe('createSVGIcon', () => {
|
||||
expect(createSVGIcon('M 10 10', 'open')).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
aria-label="open"
|
||||
style="fill: currentcolor;"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
@ -63,6 +65,7 @@ describe('createSVGIcon', () => {
|
||||
expect(createSVGIcon('M 10 10', '')).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
style="fill: currentcolor;"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
|
||||
@ -39,6 +39,7 @@ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'
|
||||
*/
|
||||
export function createSVGIcon(pathSpec: string, ariaLabel?: string): SVGElement {
|
||||
const svg = document.createElementNS(SVG_NAMESPACE, 'svg')
|
||||
svg.style.fill = 'currentcolor'
|
||||
svg.setAttribute('viewBox', '0 0 24 24')
|
||||
if (ariaLabel) {
|
||||
svg.setAttributeNS(HTML_NAMESPACE, 'aria-label', ariaLabel)
|
||||
|
||||
@ -291,6 +291,11 @@ export class SourcegraphWebApp extends React.Component<
|
||||
parsedSearchURLAndContext.searchContextSpec !== this.state.selectedSearchContextSpec
|
||||
) {
|
||||
this.setSelectedSearchContextSpec(parsedSearchURLAndContext.searchContextSpec)
|
||||
} else if (!parsedSearchURLAndContext.searchContextSpec) {
|
||||
// If no search context is present we have to fall back
|
||||
// to the global search context to match the server
|
||||
// behavior.
|
||||
this.setSelectedSearchContextSpec(GLOBAL_SEARCH_CONTEXT_SPEC)
|
||||
}
|
||||
|
||||
setQueryStateFromURL(parsedSearchURLAndContext, parsedSearchURLAndContext.processedQuery)
|
||||
|
||||
@ -54,12 +54,21 @@ export const SearchPage: React.FunctionComponent<React.PropsWithChildren<SearchP
|
||||
const showCollaborators = window.context.allowSignup && homepageUserInvitation && props.isSourcegraphDotCom
|
||||
const { width } = useWindowSize()
|
||||
const shouldShowAddCodeHostWidget = useShouldShowAddCodeHostWidget(props.authenticatedUser)
|
||||
const experimentalQueryInput = useExperimentalFeatures(features => features.searchQueryInput === 'experimental')
|
||||
|
||||
/** The value entered by the user in the query input */
|
||||
const [queryState, setQueryState] = useState<QueryState>({
|
||||
query: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentalQueryInput && props.selectedSearchContextSpec) {
|
||||
setQueryState(state =>
|
||||
state.query === '' ? { query: `context:${props.selectedSearchContextSpec} ` } : state
|
||||
)
|
||||
}
|
||||
}, [experimentalQueryInput, props.selectedSearchContextSpec])
|
||||
|
||||
useEffect(() => props.telemetryService.logViewEvent('Home'), [props.telemetryService])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
|
||||
import * as H from 'history'
|
||||
import { NavbarQueryState } from 'src/stores/navbarSearchQueryState'
|
||||
@ -37,7 +37,7 @@ import {
|
||||
} from '../../stores'
|
||||
import { ThemePreferenceProps } from '../../theme'
|
||||
import { submitSearch } from '../helpers'
|
||||
import { suggestions } from '../input/suggestions'
|
||||
import { createSuggestionsSource } from '../input/suggestions'
|
||||
import { useRecentSearches } from '../input/useRecentSearches'
|
||||
|
||||
import styles from './SearchPageInput.module.scss'
|
||||
@ -90,12 +90,22 @@ export const SearchPageInput: React.FunctionComponent<React.PropsWithChildren<Pr
|
||||
patternType,
|
||||
caseSensitive,
|
||||
searchMode,
|
||||
selectedSearchContextSpec: props.selectedSearchContextSpec,
|
||||
// In the new query input, context is either omitted (-> global)
|
||||
// or explicitly specified.
|
||||
selectedSearchContextSpec: experimentalQueryInput ? undefined : props.selectedSearchContextSpec,
|
||||
...parameters,
|
||||
})
|
||||
}
|
||||
},
|
||||
[props.queryState.query, props.selectedSearchContextSpec, props.history, patternType, caseSensitive, searchMode]
|
||||
[
|
||||
props.queryState.query,
|
||||
props.selectedSearchContextSpec,
|
||||
props.history,
|
||||
patternType,
|
||||
caseSensitive,
|
||||
searchMode,
|
||||
experimentalQueryInput,
|
||||
]
|
||||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
@ -113,6 +123,22 @@ export const SearchPageInput: React.FunctionComponent<React.PropsWithChildren<Pr
|
||||
const isTouchOnlyDevice =
|
||||
!window.matchMedia('(any-pointer:fine)').matches && window.matchMedia('(any-hover:none)').matches
|
||||
|
||||
const suggestionSource = useMemo(
|
||||
() =>
|
||||
createSuggestionsSource({
|
||||
platformContext: props.platformContext,
|
||||
authenticatedUser: props.authenticatedUser,
|
||||
fetchSearchContexts: props.fetchSearchContexts,
|
||||
getUserSearchContextNamespaces: props.getUserSearchContextNamespaces,
|
||||
}),
|
||||
[
|
||||
props.platformContext,
|
||||
props.authenticatedUser,
|
||||
props.fetchSearchContexts,
|
||||
props.getUserSearchContextNamespaces,
|
||||
]
|
||||
)
|
||||
|
||||
const input = experimentalQueryInput ? (
|
||||
<LazyCodeMirrorQueryInput
|
||||
patternType={patternType}
|
||||
@ -122,7 +148,7 @@ export const SearchPageInput: React.FunctionComponent<React.PropsWithChildren<Pr
|
||||
onSubmit={onSubmit}
|
||||
isLightTheme={props.isLightTheme}
|
||||
placeholder="Search for code or files..."
|
||||
suggestionSource={suggestions}
|
||||
suggestionSource={suggestionSource}
|
||||
history={props.history}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { mdiFilterOutline, mdiTextSearchVariant, mdiSourceRepository } from '@mdi/js'
|
||||
import { mdiFilterOutline, mdiTextSearchVariant, mdiSourceRepository, mdiStar } from '@mdi/js'
|
||||
import { extendedMatch, Fzf, FzfOptions, FzfResultItem } from 'fzf'
|
||||
import { AuthenticatedUser } from 'src/auth'
|
||||
import { SuggestionsRepoResult, SuggestionsRepoVariables } from 'src/graphql-operations'
|
||||
|
||||
import { tokenAt, tokens as queryTokens } from '@sourcegraph/branded'
|
||||
@ -17,31 +18,123 @@ import {
|
||||
FilterOption,
|
||||
QueryOption,
|
||||
getEditorConfig,
|
||||
SuggestionResult,
|
||||
} from '@sourcegraph/branded/src/search-ui/experimental'
|
||||
import { getDocumentNode, gql } from '@sourcegraph/http-client'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { PlatformContext } from '@sourcegraph/shared/src/platform/context'
|
||||
import { SearchContextProps } from '@sourcegraph/shared/src/search'
|
||||
import { regexInsertText } from '@sourcegraph/shared/src/search/query/completion-utils'
|
||||
import { FILTERS, FilterType, resolveFilter } from '@sourcegraph/shared/src/search/query/filters'
|
||||
import { FilterKind, findFilter } from '@sourcegraph/shared/src/search/query/query'
|
||||
import { Filter, Token } from '@sourcegraph/shared/src/search/query/token'
|
||||
import { omitFilter } from '@sourcegraph/shared/src/search/query/transformer'
|
||||
|
||||
import { getWebGraphQLClient } from '../../backend/graphql'
|
||||
/**
|
||||
* Used to organize the various sources that contribute to the final list of
|
||||
* suggestions.
|
||||
*/
|
||||
type InternalSource<T extends Token | undefined = Token | undefined> = (params: {
|
||||
token: T
|
||||
tokens: Token[]
|
||||
input: string
|
||||
position: number
|
||||
}) => SuggestionResult | null
|
||||
|
||||
const none: any[] = []
|
||||
|
||||
const filterRenderer = (option: Option): React.ReactElement => React.createElement(FilterOption, { option })
|
||||
const queryRenderer = (option: Option): React.ReactElement => React.createElement(QueryOption, { option })
|
||||
const none: any[] = []
|
||||
|
||||
const FILTER_SUGGESTIONS = new Fzf(Object.keys(FILTERS) as FilterType[], { match: extendedMatch })
|
||||
const DEFAULT_FILTERS: FilterType[] = [FilterType.repo, FilterType.lang, FilterType.type]
|
||||
const RELATED_FILTERS: Partial<Record<FilterType, (filter: Filter) => FilterType[]>> = {
|
||||
[FilterType.type]: filter => {
|
||||
switch (filter.value?.value) {
|
||||
case 'diff':
|
||||
case 'commit':
|
||||
return [FilterType.author, FilterType.before, FilterType.after, FilterType.message]
|
||||
}
|
||||
return []
|
||||
},
|
||||
function starTiebraker(a: { item: { stars: number } }, b: { item: { stars: number } }): number {
|
||||
return b.item.stars - a.item.stars
|
||||
}
|
||||
|
||||
function contextTiebraker(a: { item: Context }, b: { item: Context }): number {
|
||||
return (b.item.starred || b.item.default ? 1 : 0) - (a.item.starred || a.item.default ? 1 : 0)
|
||||
}
|
||||
|
||||
const REPOS_QUERY = gql`
|
||||
query SuggestionsRepo($query: String!) {
|
||||
search(patternType: regexp, query: $query) {
|
||||
results {
|
||||
repositories {
|
||||
name
|
||||
stars
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Repo {
|
||||
name: string
|
||||
stars: number
|
||||
}
|
||||
|
||||
interface Context {
|
||||
name: string
|
||||
spec: string
|
||||
default: boolean
|
||||
starred: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Repo value to a (jump) target suggestion.
|
||||
*/
|
||||
function toRepoTarget({ item, positions }: FzfResultItem<Repo>): Target {
|
||||
return {
|
||||
type: 'target',
|
||||
icon: mdiSourceRepository,
|
||||
value: item.name,
|
||||
url: `/${item.name}`,
|
||||
matches: positions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Repo value to a completion suggestion.
|
||||
*/
|
||||
function toRepoCompletion({ item, positions }: FzfResultItem<Repo>, from: number, to?: number): Completion {
|
||||
return {
|
||||
type: 'completion',
|
||||
icon: mdiSourceRepository,
|
||||
value: item.name,
|
||||
insertValue: regexInsertText(item.name, { globbing: false }) + ' ',
|
||||
matches: positions,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Context value to a completion suggestion.
|
||||
*/
|
||||
function toContextCompletion({ item, positions }: FzfResultItem<Context>, from: number, to?: number): Completion {
|
||||
let description = item.default ? 'Default' : ''
|
||||
if (item.description) {
|
||||
if (item.default) {
|
||||
description += '・'
|
||||
}
|
||||
description += item.description
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'completion',
|
||||
// Passing an empty string is a hack to draw an "empty" icon
|
||||
icon: item.starred ? mdiStar : ' ',
|
||||
value: item.spec,
|
||||
insertValue: item.spec + ' ',
|
||||
description,
|
||||
matches: positions,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a filter to a completion suggestion.
|
||||
*/
|
||||
function toFilterCompletion(filter: FilterType, from: number, to?: number): Completion {
|
||||
const definition = FILTERS[filter]
|
||||
const description =
|
||||
@ -58,7 +151,72 @@ function toFilterCompletion(filter: FilterType, from: number, to?: number): Comp
|
||||
}
|
||||
}
|
||||
|
||||
export const filterSuggestions = (tokens: Token[], token: Token | undefined, position: number): Option[] => {
|
||||
/**
|
||||
* If the query is not empty, this source will return a single command
|
||||
* suggestion which submits the query when selected.
|
||||
*/
|
||||
const currentQuery: InternalSource = ({ token, input }) => {
|
||||
if (token?.type === 'filter') {
|
||||
return null
|
||||
}
|
||||
|
||||
let value = input
|
||||
let note = 'Search everywhere'
|
||||
|
||||
const contextFilter = findFilter(input, FilterType.context, FilterKind.Global)
|
||||
|
||||
if (contextFilter) {
|
||||
value = omitFilter(input, contextFilter)
|
||||
if (contextFilter.value?.value !== 'global') {
|
||||
note = `Search '${contextFilter.value?.value ?? ''}'`
|
||||
}
|
||||
}
|
||||
|
||||
if (value.trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
result: [
|
||||
{
|
||||
title: '',
|
||||
options: [
|
||||
{
|
||||
type: 'command',
|
||||
icon: mdiTextSearchVariant,
|
||||
value,
|
||||
note,
|
||||
apply: view => {
|
||||
getEditorConfig(view.state).onSubmit()
|
||||
},
|
||||
render: queryRenderer,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_SUGGESTIONS = new Fzf(Object.keys(FILTERS) as FilterType[], { match: extendedMatch })
|
||||
const DEFAULT_FILTERS: FilterType[] = [FilterType.repo, FilterType.context, FilterType.lang, FilterType.type]
|
||||
const RELATED_FILTERS: Partial<Record<FilterType, (filter: Filter) => FilterType[]>> = {
|
||||
[FilterType.type]: filter => {
|
||||
switch (filter.value?.value) {
|
||||
case 'diff':
|
||||
case 'commit':
|
||||
return [FilterType.author, FilterType.before, FilterType.after, FilterType.message]
|
||||
}
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns filter completion suggestions for the current term at the cursor. If
|
||||
* there is no term a small list of suggested filters is returned.
|
||||
*/
|
||||
const filterSuggestions: InternalSource = ({ tokens, token, position }) => {
|
||||
let options: Group['options'] = []
|
||||
|
||||
if (!token || token.type === 'whitespace') {
|
||||
const filters = DEFAULT_FILTERS
|
||||
// Add related filters
|
||||
@ -70,20 +228,81 @@ export const filterSuggestions = (tokens: Token[], token: Token | undefined, pos
|
||||
// Remove existing filters
|
||||
.filter(filter => !tokens.some(token => token.type === 'filter' && token.field.value === filter))
|
||||
|
||||
return filters.map(filter => toFilterCompletion(filter, position))
|
||||
}
|
||||
|
||||
if (token?.type === 'pattern') {
|
||||
options = filters.map(filter => toFilterCompletion(filter, position))
|
||||
} else if (token?.type === 'pattern') {
|
||||
// ^ triggers a prefix match
|
||||
return FILTER_SUGGESTIONS.find('^' + token.value).map(entry => ({
|
||||
options = FILTER_SUGGESTIONS.find('^' + token.value).map(entry => ({
|
||||
...toFilterCompletion(entry.item, token.range.start, token.range.end),
|
||||
matches: entry.positions,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
|
||||
return options.length > 0 ? { result: [{ title: 'Narrow your search', options }] } : null
|
||||
}
|
||||
|
||||
export const staticFilterValueSuggestions = (token?: Token): Group | null => {
|
||||
/**
|
||||
* Returns static and dynamic completion suggestions for filters when completing
|
||||
* a filter value.
|
||||
*/
|
||||
function filterValueSuggestions(caches: Caches): InternalSource {
|
||||
return ({ token }) => {
|
||||
if (token?.type !== 'filter') {
|
||||
return null
|
||||
}
|
||||
const resolvedFilter = resolveFilter(token.field.value)
|
||||
const value = token.value?.value ?? ''
|
||||
const from = token.value?.range.start ?? token.range.end
|
||||
const to = token.value?.range.end
|
||||
|
||||
switch (resolvedFilter?.definition.suggestions) {
|
||||
case 'repo': {
|
||||
return caches.repo.query(value, entries => [
|
||||
{
|
||||
title: 'Repositories',
|
||||
options: entries.slice(0, 25).map(item => toRepoCompletion(item, from, to)),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
default: {
|
||||
switch (resolvedFilter?.type) {
|
||||
// Some filters are not defined to have dynamic suggestions,
|
||||
// we need to handle these here explicitly. We can't change
|
||||
// the filter definition without breaking the current
|
||||
// search input.
|
||||
case FilterType.context:
|
||||
return caches.context.query(value, entries => {
|
||||
entries = value.trim() === '' ? entries.slice(0, 10) : entries
|
||||
return [
|
||||
{
|
||||
title: 'Search contexts',
|
||||
options: entries.map(entry => toContextCompletion(entry, from, to)),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
options: [
|
||||
{
|
||||
type: 'target',
|
||||
value: 'Manage contexts',
|
||||
description: 'Add, edit, remove search contexts',
|
||||
note: 'Got to /contexts',
|
||||
url: '/contexts',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
default: {
|
||||
const suggestions = staticFilterValueSuggestions(token)
|
||||
return suggestions ? { result: [suggestions] } : null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function staticFilterValueSuggestions(token?: Token): Group | null {
|
||||
if (token?.type !== 'filter') {
|
||||
return null
|
||||
}
|
||||
@ -111,145 +330,192 @@ export const staticFilterValueSuggestions = (token?: Token): Group | null => {
|
||||
return options.length > 0 ? { title: '', options } : null
|
||||
}
|
||||
|
||||
function starTiebraker(a: { item: { stars: number } }, b: { item: { stars: number } }): number {
|
||||
return b.item.stars - a.item.stars
|
||||
}
|
||||
|
||||
const repoFzfOptions: FzfOptions<Repo> = {
|
||||
selector: item => item.name,
|
||||
tiebreakers: [starTiebraker],
|
||||
}
|
||||
|
||||
const REPOS_QUERY = gql`
|
||||
query SuggestionsRepo($query: String!) {
|
||||
search(patternType: regexp, query: $query) {
|
||||
results {
|
||||
repositories {
|
||||
name
|
||||
stars
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns repository (jump) target suggestions matching the term at the cursor,
|
||||
* but only if the query doens't already contain a 'repo:' filter.
|
||||
*/
|
||||
function repoSuggestions(cache: Cache<Repo, FzfResultItem<Repo>>): InternalSource {
|
||||
return ({ token, tokens }) => {
|
||||
const showRepoSuggestions =
|
||||
token?.type === 'pattern' && !tokens.some(token => token.type === 'filter' && token.field.value === 'repo')
|
||||
if (!showRepoSuggestions) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Repo {
|
||||
name: string
|
||||
stars: number
|
||||
}
|
||||
const repoCache: Map<string, Repo> = new Map()
|
||||
const queryCache: Map<string, Promise<Repo[]>> = new Map()
|
||||
|
||||
function cachedRepos<T>(value: string, mapper: (item: FzfResultItem<Repo>) => T): T[] {
|
||||
const fzf = new Fzf([...repoCache.values()], repoFzfOptions)
|
||||
return fzf.find(value).map(mapper)
|
||||
}
|
||||
|
||||
async function dynamicRepos<T>(value: string, mapper: (item: FzfResultItem<Repo>) => T): Promise<T[]> {
|
||||
const query = `type:repo count:50 repo:${value}`
|
||||
const repositories =
|
||||
queryCache.get(query) ??
|
||||
getWebGraphQLClient()
|
||||
.then(client =>
|
||||
client.query<SuggestionsRepoResult, SuggestionsRepoVariables>({
|
||||
query: getDocumentNode(REPOS_QUERY),
|
||||
variables: { query },
|
||||
})
|
||||
)
|
||||
.then(response =>
|
||||
(response.data?.search?.results?.repositories || []).map(({ name, stars }) => {
|
||||
const repo = { name, stars }
|
||||
if (!repoCache.has(name)) {
|
||||
repoCache.set(name, repo)
|
||||
}
|
||||
return repo
|
||||
})
|
||||
)
|
||||
|
||||
if (!queryCache.has(query)) {
|
||||
queryCache.set(query, repositories)
|
||||
}
|
||||
|
||||
await repositories
|
||||
// Remove common regex special characters
|
||||
const cleanValue = value.replace(/^\^|\\\.|\$$/g, '')
|
||||
return cachedRepos(cleanValue, mapper)
|
||||
}
|
||||
|
||||
function toRepoTarget(item: FzfResultItem<Repo>): Target {
|
||||
return {
|
||||
type: 'target',
|
||||
icon: mdiSourceRepository,
|
||||
value: item.item.name,
|
||||
url: `/${item.item.name}`,
|
||||
matches: item.positions,
|
||||
return cache.query(token.value, results => [
|
||||
{
|
||||
title: 'Repositories',
|
||||
options: results.slice(0, 5).map(toRepoTarget),
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function toRepoCompletion(item: FzfResultItem<Repo>, from: number, to?: number): Completion {
|
||||
return {
|
||||
type: 'completion',
|
||||
icon: mdiSourceRepository,
|
||||
value: item.item.name,
|
||||
insertValue: regexInsertText(item.item.name, { globbing: false }) + ' ',
|
||||
matches: item.positions,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
interface Caches {
|
||||
repo: Cache<Repo, FzfResultItem<Repo>>
|
||||
context: Cache<Context, FzfResultItem<Context>>
|
||||
}
|
||||
|
||||
export const dynamicRepoSuggestions = async (token?: Token): Promise<Target[]> => {
|
||||
if (token?.type !== 'pattern') {
|
||||
return []
|
||||
}
|
||||
|
||||
return dynamicRepos(token.value, toRepoTarget)
|
||||
}
|
||||
|
||||
const cachedRepoSuggestions = (token?: Token): Target[] => {
|
||||
if (token?.type !== 'pattern') {
|
||||
return []
|
||||
}
|
||||
|
||||
return cachedRepos(token.value, toRepoTarget)
|
||||
interface SuggestionsSourceConfig
|
||||
extends Pick<SearchContextProps, 'fetchSearchContexts' | 'getUserSearchContextNamespaces'> {
|
||||
platformContext: Pick<PlatformContext, 'requestGraphQL'>
|
||||
authenticatedUser?: AuthenticatedUser | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns dynamic suggestions for filter values.
|
||||
* Main function of this module. It creates a suggestion source which internally
|
||||
* delegates to other sources.
|
||||
*/
|
||||
function filterValueSuggestions(token: Token | undefined): ReturnType<Source> | null {
|
||||
if (token?.type === 'filter') {
|
||||
const resolvedFilter = resolveFilter(token.field.value)
|
||||
const value = token.value?.value ?? ''
|
||||
const from = token.value?.range.start ?? token.range.end
|
||||
const to = token.value?.range.end
|
||||
export const createSuggestionsSource = ({
|
||||
platformContext,
|
||||
authenticatedUser,
|
||||
fetchSearchContexts,
|
||||
getUserSearchContextNamespaces,
|
||||
}: SuggestionsSourceConfig): Source => {
|
||||
const cleanRegex = (value: string): string => value.replace(/^\^|\\\.|\$$/g, '')
|
||||
|
||||
const repoFzfOptions: FzfOptions<Repo> = {
|
||||
selector: item => item.name,
|
||||
tiebreakers: [starTiebraker],
|
||||
}
|
||||
|
||||
const contextFzfOptions: FzfOptions<Context> = {
|
||||
selector: item => item.spec,
|
||||
tiebreakers: [contextTiebraker],
|
||||
}
|
||||
|
||||
// TODO: Initialize outside to persist cache across page navigation
|
||||
const caches: Caches = {
|
||||
repo: new Cache({
|
||||
queryKey: value => `type:repo count:50 repo:${value}`,
|
||||
async query(query) {
|
||||
const response = await platformContext
|
||||
.requestGraphQL<SuggestionsRepoResult, SuggestionsRepoVariables>({
|
||||
request: REPOS_QUERY,
|
||||
variables: { query },
|
||||
mightContainPrivateInfo: true,
|
||||
})
|
||||
.toPromise()
|
||||
return (
|
||||
response.data?.search?.results?.repositories.map(repository => [repository.name, repository]) || []
|
||||
)
|
||||
},
|
||||
filter(repos, query) {
|
||||
const fzf = new Fzf(repos, repoFzfOptions)
|
||||
return fzf.find(cleanRegex(query))
|
||||
},
|
||||
}),
|
||||
|
||||
context: new Cache({
|
||||
queryKey: value => `context:${value}`,
|
||||
async query(_key, value) {
|
||||
if (!authenticatedUser) {
|
||||
return []
|
||||
}
|
||||
|
||||
const response = await fetchSearchContexts({
|
||||
first: 50,
|
||||
query: value,
|
||||
platformContext,
|
||||
namespaces: getUserSearchContextNamespaces(authenticatedUser),
|
||||
}).toPromise()
|
||||
return response.nodes.map(node => [
|
||||
node.name,
|
||||
{
|
||||
name: node.name,
|
||||
spec: node.spec,
|
||||
default: node.viewerHasAsDefault,
|
||||
starred: node.viewerHasStarred,
|
||||
description: node.description,
|
||||
},
|
||||
])
|
||||
},
|
||||
filter(contexts, query) {
|
||||
const fzf = new Fzf(contexts, contextFzfOptions)
|
||||
const results = fzf.find(cleanRegex(query))
|
||||
if (query.trim() === '') {
|
||||
// We need to manually sort results if the query is empty to
|
||||
// ensure that default and starred contexts are listed
|
||||
// first.
|
||||
results.sort(contextTiebraker)
|
||||
}
|
||||
return results
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const sources: InternalSource[] = [
|
||||
currentQuery,
|
||||
filterValueSuggestions(caches),
|
||||
filterSuggestions,
|
||||
repoSuggestions(caches.repo),
|
||||
]
|
||||
|
||||
return (state, position) => {
|
||||
const tokens = collapseOpenFilterValues(queryTokens(state), state.sliceDoc())
|
||||
const token = tokenAt(tokens, position)
|
||||
const input = state.sliceDoc()
|
||||
|
||||
function valid(state: EditorState, position: number): boolean {
|
||||
const tokens = collapseOpenFilterValues(queryTokens(state), state.sliceDoc())
|
||||
return token === tokenAt(tokens, position)
|
||||
}
|
||||
|
||||
switch (resolvedFilter?.definition.suggestions) {
|
||||
case 'repo': {
|
||||
const repos: Option[] = cachedRepos(value, item => toRepoCompletion(item, from, to)).slice(0, 25)
|
||||
return {
|
||||
valid,
|
||||
result: [{ title: 'Repositories', options: repos }],
|
||||
next: () =>
|
||||
dynamicRepos(value, item => toRepoCompletion(item, from, to)).then(entries => ({
|
||||
valid,
|
||||
result: [{ title: 'Repositories', options: entries.slice(0, 25) }],
|
||||
})),
|
||||
}
|
||||
const params = { token, tokens, input, position }
|
||||
const results = sources.map(source => source(params))
|
||||
const dummyResult = { result: [], valid }
|
||||
|
||||
return combineResults([dummyResult, ...results])
|
||||
}
|
||||
}
|
||||
|
||||
interface CacheConfig<T, U> {
|
||||
queryKey(value: string): string
|
||||
query(key: string, value: string): Promise<[string, T][]>
|
||||
filter(entries: T[], value: string): U[]
|
||||
}
|
||||
|
||||
/**
|
||||
* This class handles creating suggestion results that include cached values (if
|
||||
* available) and updates the cache with new results from new queries.
|
||||
*/
|
||||
class Cache<T, U> {
|
||||
private queryCache = new Map<string, Promise<void>>()
|
||||
private dataCache = new Map<string, T>()
|
||||
|
||||
constructor(private config: CacheConfig<T, U>) {}
|
||||
|
||||
public query(value: string, mapper: (values: U[]) => Group[]): ReturnType<InternalSource> {
|
||||
const next: SuggestionResult['next'] = () => {
|
||||
const key = this.config.queryKey(value)
|
||||
let result = this.queryCache.get(key)
|
||||
|
||||
if (!result) {
|
||||
result = this.config.query(key, value).then(entries => {
|
||||
for (const [key, entry] of entries) {
|
||||
if (!this.dataCache.has(key)) {
|
||||
this.dataCache.set(key, entry)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
default: {
|
||||
const suggestions = staticFilterValueSuggestions(token)
|
||||
return suggestions ? { result: [suggestions] } : null
|
||||
if (!this.queryCache.has(key)) {
|
||||
this.queryCache.set(key, result)
|
||||
}
|
||||
|
||||
return result.then(() => ({ result: mapper(this.cachedData(value)) }))
|
||||
}
|
||||
|
||||
return {
|
||||
result: mapper(this.cachedData(value)),
|
||||
next,
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
private cachedData(value: string): U[] {
|
||||
return this.config.filter(Array.from(this.dataCache.values()), value)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert filter values that start with a quote but are not
|
||||
@ -325,78 +591,43 @@ function collapseOpenFilterValues(tokens: Token[], input: string): Token[] {
|
||||
return result
|
||||
}
|
||||
|
||||
export const suggestions: Source = (state, pos) => {
|
||||
const tokens = collapseOpenFilterValues(queryTokens(state), state.sliceDoc())
|
||||
const token = tokenAt(tokens, pos)
|
||||
/**
|
||||
* Takes multiple suggestion results and combines the groups of each of them.
|
||||
* The order of items within a group is determined by the order of results.
|
||||
*/
|
||||
function combineResults(results: (SuggestionResult | null)[]): SuggestionResult {
|
||||
const options: Record<Group['title'], Group['options'][]> = {}
|
||||
let hasValid = false
|
||||
let hasNext = false
|
||||
|
||||
function valid(state: EditorState, position: number): boolean {
|
||||
const tokens = collapseOpenFilterValues(queryTokens(state), state.sliceDoc())
|
||||
return token === tokenAt(tokens, position)
|
||||
}
|
||||
|
||||
const result: Group[] = []
|
||||
|
||||
// Only show filter value completions if they are available
|
||||
const filterValueResult = filterValueSuggestions(token)
|
||||
if (filterValueResult) {
|
||||
return filterValueResult
|
||||
}
|
||||
|
||||
// Default options
|
||||
const showRepoSuggestions = !tokens.some(token => token.type === 'filter' && token.field.value === 'repo')
|
||||
|
||||
// Add running the current query as first/initial command
|
||||
const currentQuery = state.sliceDoc()
|
||||
if (currentQuery.trim() !== '') {
|
||||
result.push({
|
||||
title: '',
|
||||
options: [
|
||||
{
|
||||
type: 'command',
|
||||
icon: mdiTextSearchVariant,
|
||||
value: state.sliceDoc(),
|
||||
note: 'Search everywhere',
|
||||
apply: view => {
|
||||
getEditorConfig(view.state).onSubmit()
|
||||
},
|
||||
render: queryRenderer,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Completions
|
||||
const completions = [...filterSuggestions(tokens, token, pos)]
|
||||
|
||||
if (completions.length > 0) {
|
||||
result.push({ title: 'Narrow your search', options: completions.slice(0, 5) })
|
||||
}
|
||||
|
||||
// Cached repos
|
||||
if (showRepoSuggestions) {
|
||||
const repos = cachedRepoSuggestions(token)
|
||||
if (repos.length > 0) {
|
||||
result.push({ title: 'Repositories', options: repos.slice(0, 5) })
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue
|
||||
}
|
||||
for (const group of result.result) {
|
||||
if (!options[group.title]) {
|
||||
options[group.title] = []
|
||||
}
|
||||
options[group.title].push(group.options)
|
||||
}
|
||||
if (result.next) {
|
||||
hasNext = true
|
||||
}
|
||||
if (result.valid) {
|
||||
hasValid = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
result,
|
||||
next: showRepoSuggestions
|
||||
? () =>
|
||||
dynamicRepoSuggestions(token).then(suggestions => {
|
||||
if (suggestions.length > 0) {
|
||||
return {
|
||||
result: [
|
||||
...result.filter(group => group.title !== 'Repositories'),
|
||||
{ title: 'Repositories', options: suggestions.slice(0, 5) },
|
||||
],
|
||||
valid,
|
||||
}
|
||||
}
|
||||
return { result, valid }
|
||||
})
|
||||
: undefined,
|
||||
const staticResult: SuggestionResult = {
|
||||
result: Object.entries(options).map(([title, options]) => ({ title, options: options.flat() })),
|
||||
}
|
||||
|
||||
if (hasValid) {
|
||||
staticResult.valid = (...args) => results.every(result => result?.valid?.(...args) ?? false)
|
||||
}
|
||||
if (hasNext) {
|
||||
staticResult.next = () => Promise.all(results.map(result => result?.next?.() ?? result)).then(combineResults)
|
||||
}
|
||||
|
||||
return staticResult
|
||||
}
|
||||
|
||||
@ -351,8 +351,8 @@
|
||||
"@codemirror/language": "^6.2.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.1",
|
||||
"@codemirror/state": "^6.1.0",
|
||||
"@codemirror/view": "^6.4.0",
|
||||
"@codemirror/state": "^6.2.0",
|
||||
"@codemirror/view": "^6.7.2",
|
||||
"@graphiql/react": "^0.10.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
|
||||
@ -29,8 +29,8 @@ importers:
|
||||
'@codemirror/language': ^6.2.0
|
||||
'@codemirror/lint': ^6.0.0
|
||||
'@codemirror/search': ^6.0.1
|
||||
'@codemirror/state': ^6.1.0
|
||||
'@codemirror/view': ^6.4.0
|
||||
'@codemirror/state': ^6.2.0
|
||||
'@codemirror/view': ^6.7.2
|
||||
'@gql2ts/types': ^1.9.0
|
||||
'@graphiql/react': ^0.10.0
|
||||
'@graphql-codegen/cli': ^2.16.1
|
||||
@ -406,15 +406,15 @@ importers:
|
||||
zustand: ^3.6.9
|
||||
dependencies:
|
||||
'@apollo/client': 3.5.10_jztdkcoyb6t7sgjrqwrwvm7qta
|
||||
'@codemirror/autocomplete': 6.1.0_uspiuo4fymplpkahvcq3f4oddy
|
||||
'@codemirror/autocomplete': 6.1.0_42luzusa22g3gmuqmbrrjz6ypm
|
||||
'@codemirror/commands': 6.0.1
|
||||
'@codemirror/lang-json': 6.0.0
|
||||
'@codemirror/lang-markdown': 6.0.0
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/lint': 6.0.0
|
||||
'@codemirror/search': 6.0.1
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@graphiql/react': 0.10.0_bmpw7hwhcl43myn6zvyddat5se
|
||||
'@lezer/common': 1.0.0
|
||||
'@lezer/highlight': 1.0.0
|
||||
@ -2596,7 +2596,7 @@ packages:
|
||||
minimist: 1.2.6
|
||||
dev: true
|
||||
|
||||
/@codemirror/autocomplete/6.1.0_uspiuo4fymplpkahvcq3f4oddy:
|
||||
/@codemirror/autocomplete/6.1.0_42luzusa22g3gmuqmbrrjz6ypm:
|
||||
resolution: {integrity: sha512-wtO4O5WDyXhhCd4q4utDIDZxnQfmJ++3dGBCG9LMtI79+92OcA1DVk/n7BEupKmjIr8AzvptDz7YQ9ud6OkU+A==}
|
||||
peerDependencies:
|
||||
'@codemirror/language': ^6.0.0
|
||||
@ -2605,8 +2605,8 @@ packages:
|
||||
'@lezer/common': ^1.0.0
|
||||
dependencies:
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@lezer/common': 1.0.0
|
||||
dev: false
|
||||
|
||||
@ -2614,31 +2614,31 @@ packages:
|
||||
resolution: {integrity: sha512-iNHDByicYqQjs0Wo1MKGfqNbMYMyhS9WV6EwMVwsHXImlFemgEUC+c5X22bXKBStN3qnwg4fArNZM+gkv22baQ==}
|
||||
dependencies:
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@lezer/common': 1.0.0
|
||||
dev: false
|
||||
|
||||
/@codemirror/lang-css/6.0.0_cacfodzywf3qrisvqp5gh5vzku:
|
||||
/@codemirror/lang-css/6.0.0_jx5fg3i75w2lbyiehsxtkaxd2q:
|
||||
resolution: {integrity: sha512-jBqc+BTuwhNOTlrimFghLlSrN6iFuE44HULKWoR4qKYObhOIl9Lci1iYj6zMIte1XTQmZguNvjXMyr43LUKwSw==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.1.0_uspiuo4fymplpkahvcq3f4oddy
|
||||
'@codemirror/autocomplete': 6.1.0_42luzusa22g3gmuqmbrrjz6ypm
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@lezer/css': 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/view'
|
||||
- '@lezer/common'
|
||||
dev: false
|
||||
|
||||
/@codemirror/lang-html/6.1.0_@codemirror+view@6.4.0:
|
||||
/@codemirror/lang-html/6.1.0_@codemirror+view@6.7.3:
|
||||
resolution: {integrity: sha512-gA7NmJxqvnhwza05CvR7W/39Ap9r/4Vs9uiC0IeFYo1hSlJzc/8N6Evviz6vTW1x8SpHcRYyqKOf6rpl6LfWtg==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.1.0_uspiuo4fymplpkahvcq3f4oddy
|
||||
'@codemirror/lang-css': 6.0.0_cacfodzywf3qrisvqp5gh5vzku
|
||||
'@codemirror/autocomplete': 6.1.0_42luzusa22g3gmuqmbrrjz6ypm
|
||||
'@codemirror/lang-css': 6.0.0_jx5fg3i75w2lbyiehsxtkaxd2q
|
||||
'@codemirror/lang-javascript': 6.0.1
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@lezer/common': 1.0.0
|
||||
'@lezer/html': 1.0.0
|
||||
transitivePeerDependencies:
|
||||
@ -2648,11 +2648,11 @@ packages:
|
||||
/@codemirror/lang-javascript/6.0.1:
|
||||
resolution: {integrity: sha512-kjGbBEosl+ozDU5ruDV48w4v3H6KECTFiDjqMLT0KhVwESPfv3wOvnDrTT0uaMOg3YRGnBWsyiIoKHl/tNWWDg==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.1.0_uspiuo4fymplpkahvcq3f4oddy
|
||||
'@codemirror/autocomplete': 6.1.0_42luzusa22g3gmuqmbrrjz6ypm
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/lint': 6.0.0
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@lezer/common': 1.0.0
|
||||
'@lezer/javascript': 1.0.1
|
||||
dev: false
|
||||
@ -2667,10 +2667,10 @@ packages:
|
||||
/@codemirror/lang-markdown/6.0.0:
|
||||
resolution: {integrity: sha512-ozJaO1W4WgGlwWOoYCSYzbVhhM0YM/4lAWLrNsBbmhh5Ztpl0qm4CgEQRl3t8/YcylTZYBIXiskui8sHNGd4dg==}
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.1.0_@codemirror+view@6.4.0
|
||||
'@codemirror/lang-html': 6.1.0_@codemirror+view@6.7.3
|
||||
'@codemirror/language': 6.2.0
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@lezer/common': 1.0.0
|
||||
'@lezer/markdown': 1.0.1
|
||||
dev: false
|
||||
@ -2678,8 +2678,8 @@ packages:
|
||||
/@codemirror/language/6.2.0:
|
||||
resolution: {integrity: sha512-tabB0Ef/BflwoEmTB4a//WZ9P90UQyne9qWB9YFsmeS4bnEqSys7UpGk/da1URMXhyfuzWCwp+AQNMhvu8SfnA==}
|
||||
dependencies:
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@lezer/common': 1.0.0
|
||||
'@lezer/highlight': 1.0.0
|
||||
'@lezer/lr': 1.2.0
|
||||
@ -2689,27 +2689,27 @@ packages:
|
||||
/@codemirror/lint/6.0.0:
|
||||
resolution: {integrity: sha512-nUUXcJW1Xp54kNs+a1ToPLK8MadO0rMTnJB8Zk4Z8gBdrN0kqV7uvUraU/T2yqg+grDNR38Vmy/MrhQN/RgwiA==}
|
||||
dependencies:
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
crelt: 1.0.5
|
||||
dev: false
|
||||
|
||||
/@codemirror/search/6.0.1:
|
||||
resolution: {integrity: sha512-uOinkOrM+daMduCgMPomDfKLr7drGHB4jHl3Vq6xY2WRlL7MkNsBE0b+XHYa/Mee2npsJOgwvkW4n1lMFeBW2Q==}
|
||||
dependencies:
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/view': 6.4.0
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
crelt: 1.0.5
|
||||
dev: false
|
||||
|
||||
/@codemirror/state/6.1.0:
|
||||
resolution: {integrity: sha512-qbUr94DZTe6/V1VS7LDLz11rM/1t/nJxR1El4I6UaxDEdc0aZZvq6JCLJWiRmUf95NRAnDH6fhXn+PWp9wGCIg==}
|
||||
/@codemirror/state/6.2.0:
|
||||
resolution: {integrity: sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==}
|
||||
dev: false
|
||||
|
||||
/@codemirror/view/6.4.0:
|
||||
resolution: {integrity: sha512-Kv32b6Tn7QVwFbj/EDswTLSocjk5kgggF6zzBFAL4o4hZ/vmtFD155+EjH1pVlbfoDyVC2M6SedPsMrwYscgNg==}
|
||||
/@codemirror/view/6.7.3:
|
||||
resolution: {integrity: sha512-Lt+4POnhXrZFfHOdPzXEHxrzwdy7cjqYlMkOWvoFGi6/bAsjzlFfr0NY3B15B/PGx+cDFgM1hlc12wvYeZbGLw==}
|
||||
dependencies:
|
||||
'@codemirror/state': 6.1.0
|
||||
'@codemirror/state': 6.2.0
|
||||
style-mod: 4.0.0
|
||||
w3c-keyname: 2.2.4
|
||||
dev: false
|
||||
@ -10415,6 +10415,7 @@ packages:
|
||||
|
||||
/bindings/1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
dev: true
|
||||
@ -14702,6 +14703,7 @@ packages:
|
||||
|
||||
/file-uri-to-path/1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
@ -20082,6 +20084,7 @@ packages:
|
||||
|
||||
/nan/2.14.2:
|
||||
resolution: {integrity: sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==}
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user