Skip to content
This repository has been archived by the owner on Nov 25, 2021. It is now read-only.

feat: document highlights #278

Merged
merged 20 commits into from Jul 2, 2020
Merged
21 changes: 20 additions & 1 deletion src/hoverifier.test.ts
Expand Up @@ -16,7 +16,12 @@ import {
} from './hoverifier'
import { findPositionsFromEvents, SupportedMouseEvent } from './positions'
import { CodeViewProps, DOM } from './testutils/dom'
import { createHoverAttachment, createStubActionsProvider, createStubHoverProvider } from './testutils/fixtures'
import {
createHoverAttachment,
createStubActionsProvider,
createStubHoverProvider,
createStubDocumentHighlightProvider,
} from './testutils/fixtures'
import { dispatchMouseEventAtPositionImpure } from './testutils/mouse'
import { HoverAttachment } from './types'
import { LOADING } from './loading'
Expand Down Expand Up @@ -53,6 +58,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider({ range: hoverRange }, LOADER_DELAY + delayTime),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: () => of(null),
pinningEnabled: true,
})
Expand Down Expand Up @@ -115,6 +121,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(hover, delayTime),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: createStubActionsProvider(['foo', 'bar'], delayTime),
pinningEnabled: true,
})
Expand Down Expand Up @@ -204,6 +211,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(hover, delayTime),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: createStubActionsProvider(['foo', 'bar'], delayTime),
pinningEnabled: false,
})
Expand Down Expand Up @@ -284,6 +292,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(hover, delayTime),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: createStubActionsProvider(['foo', 'bar'], delayTime),
pinningEnabled: false,
})
Expand Down Expand Up @@ -342,6 +351,7 @@ describe('Hoverifier', () => {
end: { line: 4, character: 9 },
},
}),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: createStubActionsProvider(['foo', 'bar']),
pinningEnabled: true,
})
Expand Down Expand Up @@ -396,6 +406,7 @@ describe('Hoverifier', () => {
position.line === 24
? createStubHoverProvider({}, delayTime)(position)
: of({ isLoading: false, result: null }),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: position =>
position.line === 24
? createStubActionsProvider(['foo', 'bar'], delayTime)(position)
Expand Down Expand Up @@ -473,6 +484,7 @@ describe('Hoverifier', () => {
position.line === 24
? createStubHoverProvider({})(position)
: of({ isLoading: false, result: null }),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: position =>
position.line === 24 ? createStubActionsProvider(['foo', 'bar'])(position) : of(null),
pinningEnabled: true,
Expand Down Expand Up @@ -555,6 +567,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(hover, LOADER_DELAY + hoverDelayTime),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: createStubActionsProvider(actions, LOADER_DELAY + actionsDelayTime),
pinningEnabled: true,
})
Expand Down Expand Up @@ -627,6 +640,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(hover),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: () => of(null),
pinningEnabled: true,
})
Expand Down Expand Up @@ -689,6 +703,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(hover),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: () => of(null),
pinningEnabled: true,
})
Expand Down Expand Up @@ -762,6 +777,7 @@ describe('Hoverifier', () => {
const adjustmentDirections = new Subject<AdjustmentDirection>()

const getHover = createStubHoverProvider({})
const getDocumentHighlights = createStubDocumentHighlightProvider()
const getActions = createStubActionsProvider(['foo', 'bar'])

const adjustPosition: PositionAdjuster<{}> = ({ direction, position }) => {
Expand All @@ -775,6 +791,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover,
getDocumentHighlights,
getActions,
pinningEnabled: true,
})
Expand Down Expand Up @@ -831,6 +848,7 @@ describe('Hoverifier', () => {
hoverOverlayRerenders: EMPTY,
// It's important that getHover() and getActions() emit something
getHover: createStubHoverProvider({}),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: () => of([{}]).pipe(delay(50)),
pinningEnabled: true,
})
Expand Down Expand Up @@ -870,6 +888,7 @@ describe('Hoverifier', () => {
hoverOverlayElements: of(null),
hoverOverlayRerenders: EMPTY,
getHover: createStubHoverProvider(),
getDocumentHighlights: createStubDocumentHighlightProvider(),
getActions: () => of(null),
pinningEnabled: true,
})
Expand Down
167 changes: 162 additions & 5 deletions src/hoverifier.ts
Expand Up @@ -29,6 +29,7 @@ import {
switchMap,
takeUntil,
withLatestFrom,
mergeMap,
} from 'rxjs/operators'
import { Key } from 'ts-key-enum'
import { asError, ErrorLike, isErrorLike } from './errors'
Expand All @@ -44,11 +45,14 @@ import {
getTokenAtPosition,
HoveredToken,
} from './token_position'
import { HoverAttachment, HoverOverlayProps, isPosition, LineOrPositionOrRange } from './types'
import { HoverAttachment, HoverOverlayProps, isPosition, LineOrPositionOrRange, DocumentHighlight } from './types'
import { emitLoading, MaybeLoadingResult, LOADING } from './loading'

export { HoveredToken }

const defaultSelectionHighlightClassName = 'selection-highlight'
const defaultDocumentHighlightClassName = 'sourcegraph-document-highlight'

/**
* @template C Extra context for the hovered token.
* @template D The type of the hover content data.
Expand Down Expand Up @@ -82,6 +86,11 @@ export interface HoverifierOptions<C extends object, D, A> {
*/
getHover: HoverProvider<C, D>

/**
* Called to get the set of ranges to highlight within the document.
*/
getDocumentHighlights: DocumentHighlightProvider<C>

/**
* Called to get the actions to display in the hover.
*/
Expand All @@ -96,6 +105,16 @@ export interface HoverifierOptions<C extends object, D, A> {
* Whether or not code views need to be tokenized. Defaults to true.
*/
tokenize?: boolean

/**
* The class name to apply to hovered tokens.
*/
selectionHighlightClassName?: string

/**
* The class name to apply to document highlight tokens.
*/
documentHighlightClassName?: string
}

/**
Expand Down Expand Up @@ -347,6 +366,17 @@ export type HoverProvider<C extends object, D> = (
position: HoveredToken & C
) => Subscribable<MaybeLoadingResult<(HoverAttachment & D) | null>> | PromiseLike<(HoverAttachment & D) | null>

/**
* Function that returns a Subscribable or PromiseLike of the ranges to be highlighted in the document.
* If a Subscribable is returned, it may emit more than once to update the content, and must indicate when
* it starts and stopped loading new content. It should emit a `null` result if the token has no highlights.
*
* @template C Extra context for the hovered token.
*/
export type DocumentHighlightProvider<C extends object> = (
position: HoveredToken & C
) => Subscribable<DocumentHighlight[]> | PromiseLike<DocumentHighlight[]>

/**
* @template C Extra context for the hovered token.
* @template A The type of an action.
Expand All @@ -370,9 +400,12 @@ export function createHoverifier<C extends object, D, A>({
closeButtonClicks,
hoverOverlayRerenders,
getHover,
getDocumentHighlights,
getActions,
pinningEnabled,
tokenize = true,
selectionHighlightClassName = defaultSelectionHighlightClassName,
documentHighlightClassName = defaultDocumentHighlightClassName,
}: HoverifierOptions<C, D, A>): Hoverifier<C, D, A> {
// Internal state that is not exposed to the caller
// Shared between all hoverified code views
Expand Down Expand Up @@ -731,7 +764,7 @@ export function createHoverifier<C extends object, D, A>({
}),
share()
)
// Highlight the hover range returned by the language server
// Highlight the hover range returned by the hover provider
subscription.add(
hoverObservables
.pipe(
Expand Down Expand Up @@ -801,12 +834,136 @@ export function createHoverifier<C extends object, D, A>({
})
// Ensure the previously highlighted range is not highlighted and the new highlightedRange (if any)
// is highlighted.
const currentHighlighted = codeView.querySelector('.selection-highlight')
const currentHighlighted = codeView.querySelector(`.${selectionHighlightClassName}`)
if (currentHighlighted) {
currentHighlighted.classList.remove('selection-highlight')
currentHighlighted.classList.remove(selectionHighlightClassName)
}
if (hoveredTokenElement) {
hoveredTokenElement.classList.add('selection-highlight')
hoveredTokenElement.classList.add(selectionHighlightClassName)
}
})
)

/**
* For every position, emits an Observable with new values for the `documentHighlightsOrError` state.
* This is a higher-order Observable (Observable that emits Observables).
*/
const documentHighlightObservables: Observable<Observable<{
eventType: SupportedMouseEvent | 'jump'
dom: DOMFunctions
target: HTMLElement
adjustPosition?: PositionAdjuster<C>
codeView: HTMLElement
codeViewId: symbol
scrollBoundaries?: HTMLElement[]
documentHighlightsOrError?: DocumentHighlight[]
efritz marked this conversation as resolved.
Show resolved Hide resolved
position?: HoveredToken & C
part?: DiffPart
}>> = resolvedPositions.pipe(
map(({ position, codeViewId, ...rest }) => {
if (!position) {
return of({
documentHighlightsOrError: [],
position: undefined,
part: undefined,
codeViewId,
...rest,
})
}
// Get the document highlights for that position
return from(getDocumentHighlights(position)).pipe(
catchError(error => {
console.error(error)
return []
}),
map(documentHighlightsOrError => ({
...rest,
codeViewId,
position,
documentHighlightsOrError,
part: position.part,
})),
// Do not emit anything after the code view this action came from got unhoverified
takeUntil(allUnhoverifies.pipe(filter(unhoverifiedCodeViewId => unhoverifiedCodeViewId === codeViewId)))
)
}),
share()
)

// Highlight the ranges returned by the document highlight provider
subscription.add(
documentHighlightObservables
.pipe(
switchMap(highlightObservable => highlightObservable),
switchMap(({ documentHighlightsOrError, position, adjustPosition, codeView, part, ...rest }) => {
efritz marked this conversation as resolved.
Show resolved Hide resolved
const highlights =
documentHighlightsOrError && !isErrorLike(documentHighlightsOrError)
? documentHighlightsOrError
: []
efritz marked this conversation as resolved.
Show resolved Hide resolved

if (highlights.length === 0 || !position) {
return of({ adjustPosition, codeView, part, ...rest, positions: of<Position[]>([]) })
efritz marked this conversation as resolved.
Show resolved Hide resolved
}

return of({
adjustPosition,
codeView,
part,
...rest,
// Adjust the position of each highlight range so that it can be resolved to a
// token in the current document in the next step. This currently on highlights the
// token that intersects with the start of the highlight range, but this is all we
// need in the majority of cases as we currently only highlight references.
//
// To expand this use case in the future, we should determine all intersecting tokens
// between the range start and end positions.
positions: combineLatest(
highlights.map(({ range }) => {
let pos = { ...position, ...range.start }

// The requested position is is 0-indexed; the code here is currently 1-indexed
const { line, character } = pos
pos = { ...pos, line: line + 1, character: character + 1 }

return adjustPosition
? from(
adjustPosition({
codeView,
direction: AdjustmentDirection.ActualToCodeView,
position: {
...pos,
part,
},
})
)
: of(pos)
})
),
})
}),
mergeMap(({ positions, codeView, dom, part }) =>
positions.pipe(
map(highlightedRanges =>
highlightedRanges.map(highlightedRange =>
getTokenAtPosition(codeView, highlightedRange, dom, part, tokenize)
)
),
map(elements => ({ elements, codeView, dom, part }))
)
)
)
.subscribe(({ codeView, elements }) => {
// Ensure the previously highlighted range is not highlighted and the new highlightedRange (if any)
// is highlighted.
const currentHighlighteds = codeView.querySelectorAll(`.${documentHighlightClassName}`)
for (const currentHighlighted of currentHighlighteds) {
currentHighlighted.classList.remove(documentHighlightClassName)
}

for (const element of elements) {
if (element) {
element.classList.add(documentHighlightClassName)
}
}
})
)
Expand Down