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:
Felix Kling 2023-01-18 14:16:13 +01:00 committed by GitHub
parent c9b27fb69a
commit 8d8456fd91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 838 additions and 352 deletions

View File

@ -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 }

View 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 }
)
}

View File

@ -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()
}),
]

View File

@ -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 {

View File

@ -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}
>
/

View File

@ -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>
)
}

View File

@ -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 }
),
]

View File

@ -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'

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 (

View File

@ -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}
/>
) : (

View File

@ -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
}

View File

@ -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",

View File

@ -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