web: Add document highlight providers to API and extension host (#11822)

This commit is contained in:
Eric Fritz 2020-07-02 15:37:40 -05:00 committed by GitHub
parent 59ca65be4a
commit 2cc5e77410
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 309 additions and 27 deletions

View File

@ -26,6 +26,12 @@ $theme-colors-light: (
display: flex;
}
// Note that we define this before selection highlights so that it will override
// the styles of symbol that has both classes applied.
.sourcegraph-document-highlight {
background-color: var(--secondary);
}
.selection-highlight,
.selection-highlight span,
.selection-highlight-sticky,

View File

@ -408,6 +408,16 @@ function initCodeIntelligence({
)
)
),
getDocumentHighlights: ({ line, character, part, ...rest }) =>
from(extensionsController.extHostAPI).pipe(
switchMap(extensionHost =>
wrapRemoteObservable(
extensionHost.getDocumentHighlights(
toTextDocumentPositionParameters({ ...rest, position: { line, character } })
)
)
)
),
getActions: context => getHoverActions({ extensionsController, platformContext }, context),
pinningEnabled: true,
tokenize: codeHost.codeViewsRequireTokenization,

View File

@ -252,7 +252,7 @@
"@reach/menu-button": "0.10.2",
"@sentry/browser": "^5.19.0",
"@slimsag/react-shortcuts": "^1.2.1",
"@sourcegraph/codeintellify": "^7.0.0",
"@sourcegraph/codeintellify": "^7.1.0",
"@sourcegraph/extension-api-classes": "^1.0.3",
"@sourcegraph/extension-api-types": "link:packages/@sourcegraph/extension-api-types",
"@sourcegraph/react-loading-spinner": "0.0.7",

View File

@ -1370,6 +1370,51 @@ declare module 'sourcegraph' {
provideCompletionItems(document: TextDocument, position: Position): ProviderResult<CompletionList>
}
/**
* A document highlight is a range inside a text document which deserves special attention.
* Usually a document highlight is visualized by changing the background color of its range.
*/
export interface DocumentHighlight {
/**
* The range this highlight applies to.
*/
range: Range
/**
* The highlight kind, default is text.
*/
kind?: DocumentHighlightKind
}
/**
* A document highlight kind.
*/
export enum DocumentHighlightKind {
Text = 'text',
Read = 'read',
Write = 'write',
}
/**
* A document highlight provider provides ranges to highlight in the current document like all
* occurrences of a variable or all exit-points of a function.
*
* Providers are queried for document highlights on symbol hovers in any document matching
* the document selector specified at registration time.
*/
export interface DocumentHighlightProvider {
/**
* Provide document highlights for the given position and document.
*
* @param document The document in which the command was invoked.
* @param position The position at which the command was invoked.
*
* @returns An array of document highlights, or a thenable that resolves to document highlights.
* The lack of a result can be signaled by returning `undefined`, `null`, or an empty array.
*/
provideDocumentHighlights(document: TextDocument, position: Position): ProviderResult<DocumentHighlight[]>
}
export namespace languages {
/**
* Registers a hover provider, which returns a formatted hover message (intended for display in a tooltip)
@ -1452,6 +1497,22 @@ declare module 'sourcegraph' {
selector: DocumentSelector,
provider: CompletionItemProvider
): Unsubscribable
/**
* Registers a document highlight provider.
*
* Multiple providers can be registered with overlapping document selectors. In that case,
* providers are queried in parallel and the results are merged. A failing provider will not
* cause the whole operation to fail.
*
* @param selector A selector that defines the documents this provider applies to.
* @param provider A document hover provider.
* @returns An unsubscribable to unregister this provider.
*/
export function registerDocumentHighlightProvider(
selector: DocumentSelector,
provider: DocumentHighlightProvider
): Unsubscribable
}
/**

View File

@ -2,7 +2,7 @@ import { SettingsCascade } from '../settings/settings'
import { SettingsEdit } from './client/services/settings'
import * as clientType from '@sourcegraph/extension-api-types'
import { Remote, ProxyMarked } from 'comlink'
import { Unsubscribable } from 'sourcegraph'
import { Unsubscribable, DocumentHighlight } from 'sourcegraph'
import { ProxySubscribable } from './extension/api/common'
import { TextDocumentPositionParams } from './protocol'
import { MaybeLoadingResult } from '@sourcegraph/codeintellify'
@ -28,6 +28,7 @@ export interface FlatExtHostAPI {
// Languages
getHover: (parameters: TextDocumentPositionParams) => ProxySubscribable<MaybeLoadingResult<HoverMerged | null>>
getDocumentHighlights: (parameters: TextDocumentPositionParams) => ProxySubscribable<DocumentHighlight[]>
}
/**

View File

@ -0,0 +1,12 @@
import * as sourcegraph from 'sourcegraph'
/**
* The type of a document highlight.
* This is needed because if sourcegraph.DocumentHighlightKind enum values are referenced,
* the `sourcegraph` module import at the top of the file is emitted in the generated code.
*/
export const DocumentHighlightKind: typeof sourcegraph.DocumentHighlightKind = {
Text: 'text' as sourcegraph.DocumentHighlightKind.Text,
Read: 'read' as sourcegraph.DocumentHighlightKind.Read,
Write: 'write' as sourcegraph.DocumentHighlightKind.Write,
}

View File

@ -0,0 +1,106 @@
import { DocumentHighlight } from 'sourcegraph'
import { Range } from '@sourcegraph/extension-api-classes'
import { initNewExtensionAPI, mergeDocumentHighlightResults } from './flatExtensionApi'
import { pretendRemote } from '../util'
import { MainThreadAPI } from '../contract'
import { SettingsCascade } from '../../settings/settings'
import { Observer } from 'rxjs'
import { ProxyMarked, proxyMarker, Remote } from 'comlink'
import { ExtensionDocuments } from './api/documents'
import { LOADING } from '@sourcegraph/codeintellify'
const range1 = new Range(1, 2, 3, 4)
const range2 = new Range(2, 3, 4, 5)
const range3 = new Range(3, 4, 5, 6)
describe('mergeDocumentHighlightResults', () => {
it('merges non DocumentHighlight values into empty arrays', () => {
expect(mergeDocumentHighlightResults([LOADING])).toStrictEqual([])
expect(mergeDocumentHighlightResults([null])).toStrictEqual([])
expect(mergeDocumentHighlightResults([undefined])).toStrictEqual([])
// and yes, there can be several
expect(mergeDocumentHighlightResults([null, LOADING])).toStrictEqual([])
})
it('merges a DocumentHighlight into result', () => {
const highlight1: DocumentHighlight = { range: range1 }
const highlight2: DocumentHighlight = { range: range2 }
const highlight3: DocumentHighlight = { range: range3 }
const merged: DocumentHighlight[] = [highlight1, highlight2, highlight3]
expect(mergeDocumentHighlightResults([[highlight1], [highlight2, highlight3]])).toEqual(merged)
})
it('omits non DocumentHighlight values from document highlight result', () => {
const highlight: DocumentHighlight = { range: range1 }
const merged: DocumentHighlight[] = [highlight]
expect(mergeDocumentHighlightResults([[highlight], null, LOADING, undefined])).toEqual(merged)
})
})
describe('getDocumentHighlights from ExtensionHost API, it aims to have more e2e feel', () => {
// integration(ish) tests for scenarios not covered by providers tests
const noopMain = pretendRemote<MainThreadAPI>({})
const emptySettings: SettingsCascade<object> = {
subjects: [],
final: {},
}
const observe = <T>(onValue: (val: T) => void): Remote<Observer<T> & ProxyMarked> =>
pretendRemote({
next: onValue,
error: (error: any) => {
throw error
},
complete: () => {},
[proxyMarker]: Promise.resolve(true as const),
})
const documentHighlight = (value: number): DocumentHighlight => ({
range: new Range(value, value, value, value),
})
it('restarts document highlights call if a provider was added or removed', () => {
const typescriptFileUri = 'file:///f.ts'
const documents = new ExtensionDocuments(() => Promise.resolve())
documents.$acceptDocumentData([
{
type: 'added',
languageId: 'ts',
text: 'body',
uri: typescriptFileUri,
},
])
const { exposedToMain, languages } = initNewExtensionAPI(noopMain, emptySettings, documents)
let counter = 0
languages.registerDocumentHighlightProvider([{ pattern: '*.ts' }], {
provideDocumentHighlights: () => [documentHighlight(++counter)],
})
let results: DocumentHighlight[][] = []
exposedToMain
.getDocumentHighlights({
position: { line: 1, character: 2 },
textDocument: { uri: typescriptFileUri },
})
.subscribe(observe(value => results.push(value)))
// first provider results
expect(results).toEqual([[], [documentHighlight(1)]])
results = []
const subscription = languages.registerDocumentHighlightProvider([{ pattern: '*.ts' }], {
provideDocumentHighlights: () => [documentHighlight(0)],
})
// second and first
expect(results).toEqual([[], [2, 0].map(value => documentHighlight(value))])
results = []
subscription.unsubscribe()
// just first was queried for the third time
expect(results).toEqual([[], [documentHighlight(3)]])
})
})

View File

@ -10,6 +10,7 @@ import { ExtensionContent } from './api/content'
import { ExtensionContext } from './api/context'
import { createDecorationType } from './api/decorations'
import { ExtensionDocuments } from './api/documents'
import { DocumentHighlightKind } from './api/documentHighlights'
import { Extensions } from './api/extensions'
import { ExtensionLanguageFeatures } from './api/languageFeatures'
import { ExtensionViewsApi } from './api/views'
@ -32,7 +33,6 @@ export interface InitData {
/** fetched initial settings object */
initialSettings: Readonly<SettingsCascade<object>>
}
/**
* Starts the extension host, which runs extensions. It is a Web Worker or other similar isolated
* JavaScript execution context. There is exactly 1 extension host, and it has zero or more
@ -147,7 +147,7 @@ function createExtensionAPI(
state,
commands,
search,
languages: { registerHoverProvider },
languages: { registerHoverProvider, registerDocumentHighlightProvider },
} = initNewExtensionAPI(proxy, initData.initialSettings, documents)
// Expose the extension host API to the client (main thread)
@ -181,6 +181,7 @@ function createExtensionAPI(
Location,
MarkupKind,
NotificationType,
DocumentHighlightKind,
app: {
activeWindowChanges: windows.activeWindowChanges,
get activeWindow(): sourcegraph.Window | undefined {
@ -216,6 +217,7 @@ function createExtensionAPI(
languages: {
registerHoverProvider,
registerDocumentHighlightProvider,
registerDefinitionProvider: (
selector: sourcegraph.DocumentSelector,

View File

@ -35,6 +35,7 @@ export interface ExtState {
// Lang
hoverProviders: BehaviorSubject<RegisteredProvider<sourcegraph.HoverProvider>[]>
documentHighlightProviders: BehaviorSubject<RegisteredProvider<sourcegraph.DocumentHighlightProvider>[]>
}
export interface RegisteredProvider<T> {
@ -50,7 +51,7 @@ export interface InitResult {
state: Readonly<ExtState>
commands: typeof sourcegraph['commands']
search: typeof sourcegraph['search']
languages: Pick<typeof sourcegraph['languages'], 'registerHoverProvider'>
languages: Pick<typeof sourcegraph['languages'], 'registerHoverProvider' | 'registerDocumentHighlightProvider'>
}
/**
@ -78,6 +79,9 @@ export const initNewExtensionAPI = (
settings: initialSettings,
queryTransformers: new BehaviorSubject<sourcegraph.QueryTransformer[]>([]),
hoverProviders: new BehaviorSubject<RegisteredProvider<sourcegraph.HoverProvider>[]>([]),
documentHighlightProviders: new BehaviorSubject<RegisteredProvider<sourcegraph.DocumentHighlightProvider>[]>(
[]
),
}
const configChanges = new BehaviorSubject<void>(undefined)
@ -143,6 +147,19 @@ export const initNewExtensionAPI = (
)
)
},
getDocumentHighlights: (textParameters: TextDocumentPositionParams) => {
const document = textDocuments.get(textParameters.textDocument.uri)
const position = toPosition(textParameters.position)
return proxySubscribable(
callProviders(
state.documentHighlightProviders,
document,
provider => provider.provideDocumentHighlights(document, position),
mergeDocumentHighlightResults
).pipe(map(result => (result.isLoading ? [] : result.result)))
)
},
}
// Configuration
@ -181,6 +198,10 @@ export const initNewExtensionAPI = (
selector: sourcegraph.DocumentSelector,
provider: sourcegraph.HoverProvider
): sourcegraph.Unsubscribable => addWithRollback(state.hoverProviders, { selector, provider })
const registerDocumentHighlightProvider = (
selector: sourcegraph.DocumentSelector,
provider: sourcegraph.DocumentHighlightProvider
): sourcegraph.Unsubscribable => addWithRollback(state.documentHighlightProviders, { selector, provider })
return {
configuration: Object.assign(configChanges.asObservable(), {
@ -193,6 +214,7 @@ export const initNewExtensionAPI = (
search,
languages: {
registerHoverProvider,
registerDocumentHighlightProvider,
},
}
}
@ -297,3 +319,15 @@ export function callProviders<TProvider, TProviderResult, TMergedResult>(
export function mergeHoverResults(results: (typeof LOADING | Hover | null | undefined)[]): HoverMerged | null {
return fromHoverMerged(results.filter(isNot(isExactly(LOADING))))
}
/**
* merges latests results from document highlight providers into a form that is convenient to show
*
* @param results latests results from document highlight providers
* @returns a {@link DocumentHighlight} results if there are any actual document highlights or null in case of no results or loading
*/
export function mergeDocumentHighlightResults(
results: (typeof LOADING | sourcegraph.DocumentHighlight[] | null | undefined)[]
): sourcegraph.DocumentHighlight[] {
return results.filter(isNot(isExactly(LOADING))).flatMap(highlights => highlights || [])
}

View File

@ -143,23 +143,26 @@ body,
height: 100%;
}
// Document highlight is the background color for tokens which are matched with
// a result from a document highlight provider. e.g. for references of the token
// currently being hovered over.
//
// Note that we define this before selection highlights so that it will override
// the styles of symbol that has both classes applied.
.sourcegraph-document-highlight {
background-color: var(--secondary);
}
// Selection highlight is the background color for matched/highlighted tokens,
// e.g. for search results, for identifying the token currently being hovered over,
// or identifying the token the references panel is toggled for
.selection-highlight {
background-color: rgba(217, 72, 15, 0.5);
}
// Same as above, but indicates highlighting for a fixed hover (vs. e.g.
// an ephemeral mouseover on some token).
.selection-highlight,
.selection-highlight-sticky {
background-color: rgba(217, 72, 15, 0.5);
}
.theme-light {
.selection-highlight {
background-color: rgba(255, 192, 120, 0.5);
}
.selection-highlight,
.selection-highlight-sticky {
background-color: rgba(255, 192, 120, 0.5);
}

View File

@ -1,10 +1,11 @@
import { Observable, from, concat } from 'rxjs'
import { HoverMerged } from '../../../shared/src/api/client/types/hover'
import { ExtensionsControllerProps } from '../../../shared/src/extensions/controller'
import { FileSpec, UIPositionSpec, RepoSpec, ResolvedRevisionSpec } from '../../../shared/src/util/url'
import { FileSpec, UIPositionSpec, RepoSpec, ResolvedRevisionSpec, toURIWithPath } from '../../../shared/src/util/url'
import { MaybeLoadingResult } from '@sourcegraph/codeintellify'
import { switchMap } from 'rxjs/operators'
import { wrapRemoteObservable } from '../../../shared/src/api/client/api/common'
import { DocumentHighlight } from 'sourcegraph'
/**
* Fetches hover information for the given location.
@ -23,7 +24,37 @@ export function getHover(
wrapRemoteObservable(
extensionHost.getHover({
textDocument: {
uri: `git://${context.repoName}?${context.commitID}#${context.filePath}`,
uri: toURIWithPath(context),
},
position: {
character: context.position.character - 1,
line: context.position.line - 1,
},
})
)
)
)
)
}
/**
* Fetches document highlight information for the given location.
*
* @param context the location
* @returns document highlights for the location
*/
export function getDocumentHighlights(
context: RepoSpec & ResolvedRevisionSpec & FileSpec & UIPositionSpec,
{ extensionsController }: ExtensionsControllerProps
): Observable<DocumentHighlight[]> {
return concat(
[[]],
from(extensionsController.extHostAPI).pipe(
switchMap(extensionHost =>
wrapRemoteObservable(
extensionHost.getDocumentHighlights({
textDocument: {
uri: toURIWithPath(context),
},
position: {
character: context.position.character - 1,

View File

@ -24,7 +24,7 @@ import { ActionItemAction } from '../../../../../../shared/src/actions/ActionIte
import { getHoverActions } from '../../../../../../shared/src/hover/actions'
import { WebHoverOverlay } from '../../../../components/shared'
import { getModeFromPath } from '../../../../../../shared/src/languages'
import { getHover } from '../../../../backend/features'
import { getHover, getDocumentHighlights } from '../../../../backend/features'
import { PlatformContextProps } from '../../../../../../shared/src/platform/context'
import { TelemetryProps } from '../../../../../../shared/src/telemetry/telemetryService'
import { property, isDefined } from '../../../../../../shared/src/util/types'
@ -118,6 +118,8 @@ export const CampaignChangesets: React.FunctionComponent<Props> = ({
),
getHover: hoveredToken =>
getHover(getLSPTextDocumentPositionParameters(hoveredToken), { extensionsController }),
getDocumentHighlights: hoveredToken =>
getDocumentHighlights(getLSPTextDocumentPositionParameters(hoveredToken), { extensionsController }),
getActions: context => getHoverActions({ extensionsController, platformContext }, context),
pinningEnabled: true,
}),

View File

@ -31,7 +31,7 @@ import {
toPositionOrRangeHash,
toURIWithPath,
} from '../../../../shared/src/util/url'
import { getHover } from '../../backend/features'
import { getHover, getDocumentHighlights } from '../../backend/features'
import { WebHoverOverlay } from '../../components/shared'
import { ThemeProps } from '../../../../shared/src/theme'
import { EventLoggerProps } from '../../tracking/eventLogger'
@ -169,6 +169,8 @@ export class Blob extends React.Component<BlobProps, BlobState> {
filter(property('hoverOverlayElement', isDefined))
),
getHover: position => getHover(this.getLSPTextDocumentPositionParams(position), this.props),
getDocumentHighlights: position =>
getDocumentHighlights(this.getLSPTextDocumentPositionParams(position), this.props),
getActions: context => getHoverActions(this.props, context),
pinningEnabled: !singleClickGoToDefinition,
})

View File

@ -25,7 +25,7 @@ import {
ResolvedRevisionSpec,
RevisionSpec,
} from '../../../../shared/src/util/url'
import { getHover } from '../../backend/features'
import { getHover, getDocumentHighlights } from '../../backend/features'
import { queryGraphQL } from '../../backend/graphql'
import { PageTitle } from '../../components/PageTitle'
import { WebHoverOverlay } from '../../components/shared'
@ -131,6 +131,8 @@ export class RepositoryCommitPage extends React.Component<Props, State> {
filter(property('hoverOverlayElement', isDefined))
),
getHover: hoveredToken => getHover(this.getLSPTextDocumentPositionParams(hoveredToken), this.props),
getDocumentHighlights: hoveredToken =>
getDocumentHighlights(this.getLSPTextDocumentPositionParams(hoveredToken), this.props),
getActions: context => getHoverActions(this.props, context),
pinningEnabled: true,
})

View File

@ -24,7 +24,7 @@ import {
ResolvedRevisionSpec,
RevisionSpec,
} from '../../../../shared/src/util/url'
import { getHover } from '../../backend/features'
import { getHover, getDocumentHighlights } from '../../backend/features'
import { HeroPage } from '../../components/HeroPage'
import { WebHoverOverlay } from '../../components/shared'
import { EventLoggerProps } from '../../tracking/eventLogger'
@ -123,6 +123,8 @@ export class RepositoryCompareArea extends React.Component<RepositoryCompareArea
filter(property('hoverOverlayElement', isDefined))
),
getHover: hoveredToken => getHover(this.getLSPTextDocumentPositionParams(hoveredToken), this.props),
getDocumentHighlights: hoveredToken =>
getDocumentHighlights(this.getLSPTextDocumentPositionParams(hoveredToken), this.props),
getActions: context => getHoverActions(this.props, context),
pinningEnabled: true,
})

View File

@ -2151,15 +2151,16 @@
"@babel/helper-module-imports" "^7.0.0"
"@babel/traverse" "^7.4.4"
"@sourcegraph/codeintellify@^7.0.0":
version "7.0.0"
resolved "https://registry.npmjs.org/@sourcegraph/codeintellify/-/codeintellify-7.0.0.tgz#8a89d19b254388f7d592846918aa28ccc0101c45"
integrity sha512-VrkkXHri2GMpq3SkffeO4lYUEi3BIzkadN6scO6zmRY8BE10mtB51B5/yi1GcXmcYvhXZqx5bRFwnCk6umwTtg==
"@sourcegraph/codeintellify@^7.1.0":
version "7.1.0"
resolved "https://registry.npmjs.org/@sourcegraph/codeintellify/-/codeintellify-7.1.0.tgz#cef5b3498af4d56769119ab592884e010b75c4b3"
integrity sha512-KSr3k5dHmaoXNVo15S0FePSfIF92W6+/QpShKOi5FJIQkQl/hDFMafXQCt7uzPBLqFQd1vRuFUQVZKvtZf7h/g==
dependencies:
"@sourcegraph/event-positions" "^1.0.4"
"@sourcegraph/extension-api-types" "^2.0.0"
"@sourcegraph/extension-api-types" "^2.1.0"
lodash "^4.17.10"
rxjs "^6.5.5"
sourcegraph "^24.0.0"
ts-key-enum "^2.0.0"
"@sourcegraph/eslint-config@^0.19.4":
@ -2202,7 +2203,8 @@
integrity sha512-KWxkyphmlwam8kfYPSmoitKQRMGQCsr1ZRmNZgijT7ABKaVyk/+I5ezt2J213tM04Hi0vyg4L7iH1VCkNvm2Jw==
"@sourcegraph/extension-api-types@link:packages/@sourcegraph/extension-api-types":
version "2.1.0"
version "0.0.0"
uid ""
"@sourcegraph/prettierrc@^3.0.3":
version "3.0.3"
@ -19624,8 +19626,14 @@ source-map@^0.7.3:
resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
"sourcegraph@link:packages/sourcegraph-extension-api":
sourcegraph@^24.0.0:
version "24.5.0"
resolved "https://registry.npmjs.org/sourcegraph/-/sourcegraph-24.5.0.tgz#5ee53b1c933db99addfc801f958ce753afa850c3"
integrity sha512-VQw73xafvfZZkjNqJY0kZEXexHRbqut60ed/guDlncdZJwf6zr7jQXeqh3p5v1VH09ef23758s6u6Fqbqgmt9Q==
"sourcegraph@link:packages/sourcegraph-extension-api":
version "0.0.0"
uid ""
space-separated-tokens@^1.0.0:
version "1.1.2"