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

Commit 3b6e5b2

Browse files
committed
fix: delay highlighting the hovered token until the hover is shown
Fixes an issue where the token would be highlighted before the hover was shown (if the hover was taking a while to load). The reason behind not showing the hover loading immediately is to avoid UI jitter. In keeping with this principle, it makes sense to not show any changes in the UI (including highlighting the hovered range) until we know they will not need to be immediately redrawn. If the token is highlighted and then a few hundred msec later the hover is determined to be empty, the highlight will go away. That looks jittery, and that problem is fixed by this commit.
1 parent 46ed1a9 commit 3b6e5b2

File tree

3 files changed

+105
-11
lines changed

3 files changed

+105
-11
lines changed

src/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const propertyIsDefined = <T extends object, K extends keyof T>(key: K) =
2121
): val is K extends any ? ({ [k in Exclude<keyof T, K>]: T[k] } & { [k in K]: NonNullable<T[k]> }) : never =>
2222
isDefined(val[key])
2323

24-
export const isEmptyHover = (hover: HoverMerged | null): boolean =>
24+
const isEmptyHover = (hover: HoverMerged | null): boolean =>
2525
!hover ||
2626
!hover.contents ||
2727
(Array.isArray(hover.contents) && hover.contents.length === 0) ||

src/hoverifier.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { isEqual } from 'lodash'
2-
import { EMPTY, Observable, of, Subject, Subscription } from 'rxjs'
2+
import { EMPTY, NEVER, Observable, of, Subject, Subscription } from 'rxjs'
33
import { distinctUntilChanged, filter, map } from 'rxjs/operators'
44
import { TestScheduler } from 'rxjs/testing'
5-
import { Position } from 'vscode-languageserver-types'
5+
import { Position, Range } from 'vscode-languageserver-types'
66

77
import { noop } from 'lodash'
88
import { propertyIsDefined } from './helpers'
@@ -30,6 +30,74 @@ describe('Hoverifier', () => {
3030
testcases = dom.createCodeViews()
3131
})
3232

33+
it('highlights token when hover is fetched (not before)', () => {
34+
for (const codeView of testcases) {
35+
const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b))
36+
37+
const delayTime = 100
38+
const hoverRange = { start: { line: 1, character: 2 }, end: { line: 3, character: 4 } }
39+
40+
scheduler.run(({ cold, expectObservable }) => {
41+
const hoverifier = createHoverifier({
42+
closeButtonClicks: NEVER,
43+
goToDefinitionClicks: NEVER,
44+
hoverOverlayElements: of(null),
45+
hoverOverlayRerenders: EMPTY,
46+
fetchHover: createStubHoverFetcher({ range: hoverRange }, LOADER_DELAY + delayTime),
47+
fetchJumpURL: () => of(null),
48+
pushHistory: noop,
49+
getReferencesURL: () => null,
50+
})
51+
52+
const positionJumps = new Subject<{
53+
position: Position
54+
codeView: HTMLElement
55+
scrollElement: HTMLElement
56+
}>()
57+
58+
const positionEvents = of(codeView.codeView).pipe(findPositionsFromEvents(codeView))
59+
60+
const subscriptions = new Subscription()
61+
62+
subscriptions.add(hoverifier)
63+
subscriptions.add(
64+
hoverifier.hoverify({
65+
dom: codeView,
66+
positionEvents,
67+
positionJumps,
68+
resolveContext: () => codeView.revSpec,
69+
})
70+
)
71+
72+
const highlightedRangeUpdates = hoverifier.hoverStateUpdates.pipe(
73+
map(hoverOverlayProps => (hoverOverlayProps ? hoverOverlayProps.highlightedRange : null)),
74+
distinctUntilChanged((a, b) => isEqual(a, b))
75+
)
76+
77+
const inputDiagram = 'a'
78+
79+
const outputDiagram = `${MOUSEOVER_DELAY}ms a ${LOADER_DELAY + delayTime - 1}ms b`
80+
81+
const outputValues: {
82+
[key: string]: Range | undefined
83+
} = {
84+
a: undefined, // highlightedRange is undefined when the hover is loading
85+
b: hoverRange,
86+
}
87+
88+
// Hover over https://sourcegraph.sgdev.org/github.com/gorilla/mux@cb4698366aa625048f3b815af6a0dea8aef9280a/-/blob/mux.go#L24:6
89+
cold(inputDiagram).subscribe(() =>
90+
dispatchMouseEventAtPositionImpure('mouseover', codeView, {
91+
line: 24,
92+
character: 6,
93+
})
94+
)
95+
96+
expectObservable(highlightedRangeUpdates).toBe(outputDiagram, outputValues)
97+
})
98+
}
99+
})
100+
33101
it('emits loading and then state on click events', () => {
34102
for (const codeView of testcases) {
35103
const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b))

src/hoverifier.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ import {
2727
withLatestFrom,
2828
} from 'rxjs/operators'
2929
import { Key } from 'ts-key-enum'
30-
import { Position } from 'vscode-languageserver-types'
31-
import { asError, ErrorLike } from './errors'
30+
import { Position, Range } from 'vscode-languageserver-types'
31+
import { asError, ErrorLike, isErrorLike } from './errors'
3232
import { isDefined } from './helpers'
33-
import { isEmptyHover, overlayUIHasContent, scrollIntoCenterIfNeeded } from './helpers'
33+
import { overlayUIHasContent, scrollIntoCenterIfNeeded } from './helpers'
3434
import { HoverOverlayProps, isJumpURL } from './HoverOverlay'
3535
import { calculateOverlayPosition } from './overlay_position'
3636
import { DiffPart, PositionEvent, SupportedMouseEvent } from './positions'
@@ -193,6 +193,11 @@ export interface HoverState {
193193
*/
194194
hoverOverlayProps?: Pick<HoverOverlayProps, Exclude<keyof HoverOverlayProps, 'linkComponent'>>
195195

196+
/**
197+
* The highlighted range, which is the range in the hover result or else the range of the hovered token.
198+
*/
199+
highlightedRange?: Range
200+
196201
/**
197202
* The currently selected position, if any.
198203
* Can be a single line number or a line range.
@@ -223,6 +228,11 @@ interface InternalHoverifierState<C extends object> {
223228
/** The currently hovered token */
224229
hoveredToken?: HoveredToken & C
225230

231+
/**
232+
* The highlighted range, which is the range in the hoverOrError data or else the range of the hovered token.
233+
*/
234+
highlightedRange?: Range
235+
226236
mouseIsMoving: boolean
227237

228238
/**
@@ -244,6 +254,7 @@ const shouldRenderOverlay = (state: InternalHoverifierState<{}>): boolean =>
244254
*/
245255
const internalToExternalState = (internalState: InternalHoverifierState<{}>): HoverState => ({
246256
selectedPosition: internalState.selectedPosition,
257+
highlightedRange: shouldRenderOverlay(internalState) ? internalState.highlightedRange : undefined,
247258
hoverOverlayProps: shouldRenderOverlay(internalState)
248259
? {
249260
overlayPosition: internalState.hoverOverlayPosition,
@@ -537,7 +548,7 @@ export function createHoverifier<C extends object>({
537548
target: HTMLElement
538549
adjustPosition?: PositionAdjuster<C>
539550
codeView: HTMLElement
540-
hoverOrError?: typeof LOADING | HoverMerged | Error | null
551+
hoverOrError?: typeof LOADING | HoverMerged | ErrorLike | null
541552
position?: HoveredToken & C
542553
part?: DiffPart
543554
}>
@@ -558,7 +569,7 @@ export function createHoverifier<C extends object>({
558569
? hoverMergedOrNull
559570
: new Error(`Invalid hover response: ${JSON.stringify(hoverMergedOrNull)}`)
560571
),
561-
catchError(error => [asError(error)]),
572+
catchError((error): [ErrorLike] => [asError(error)]),
562573
share()
563574
)
564575
// 1. Reset the hover content, so no old hover content is displayed at the new position while fetching
@@ -618,20 +629,35 @@ export function createHoverifier<C extends object>({
618629
})
619630
)
620631
.subscribe(({ hoverOrError, position, codeView, dom, part }) => {
632+
// Update the highlighted token if the hover result is successful. If the hover result specifies a
633+
// range, use that; otherwise use the hover position (which will be expanded into a full token in
634+
// getTokenAtPosition).
635+
let highlightedRange: Range | undefined
636+
if (hoverOrError && !isErrorLike(hoverOrError) && hoverOrError !== LOADING) {
637+
if (hoverOrError.range) {
638+
highlightedRange = hoverOrError.range
639+
} else if (position) {
640+
highlightedRange = { start: position, end: position }
641+
}
642+
}
643+
621644
container.update({
622645
hoverOrError,
646+
highlightedRange,
623647
// Reset the hover position, it's gonna be repositioned after the hover was rendered
624648
hoverOverlayPosition: undefined,
625649
})
650+
651+
// Ensure the previously highlighted range is not highlighted and the new highlightedRange (if any)
652+
// is highlighted.
626653
const currentHighlighted = codeView.querySelector('.selection-highlight')
627654
if (currentHighlighted) {
628655
currentHighlighted.classList.remove('selection-highlight')
629656
}
630-
if (!position || !hoverOrError || (HoverMerged.is(hoverOrError) && isEmptyHover(hoverOrError))) {
657+
if (!highlightedRange) {
631658
return
632659
}
633-
634-
const token = getTokenAtPosition(codeView, position, dom, part)
660+
const token = getTokenAtPosition(codeView, highlightedRange.start, dom, part)
635661
if (!token) {
636662
return
637663
}

0 commit comments

Comments
 (0)