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

Commit f6547cc

Browse files
ijsnowfelixfbecker
authored andcommitted
refactor: require positions to be passed from outside of hoverifier (#9)
BREAKING CHANGE: It is now required to pass `positionEvents` to hoverify instead of the individual `code*` Observables.
1 parent c374679 commit f6547cc

File tree

12 files changed

+273
-123
lines changed

12 files changed

+273
-123
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"author": "Felix Becker <felix@sourcegraph.com>",
2525
"dependencies": {
2626
"@sourcegraph/icons": "^1.15.0",
27+
"event-positions": "0.0.1",
2728
"highlight.js": "^9.12.0",
2829
"lodash": "^4.17.10",
2930
"marked": "^0.4.0",

src/HoverOverlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export interface HoverOverlayProps {
6969

7070
/** Returns true if the input is successful jump URL result */
7171
export const isJumpURL = (val: any): val is { jumpURL: string } =>
72-
val && typeof val === 'object' && typeof val.jumpURL === 'string'
72+
val !== null && typeof val === 'object' && typeof val.jumpURL === 'string'
7373

7474
export const HoverOverlay: React.StatelessComponent<HoverOverlayProps> = props => (
7575
<div

src/helpers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const isDefined = <T>(val: T): val is NonNullable<T> => val !== undefined
1717
*/
1818
export const propertyIsDefined = <T extends object, K extends keyof T>(key: K) => (
1919
val: T
20-
): val is K extends any ? T & { [k in K]: NonNullable<T[k]> } : never => isDefined(val[key])
20+
): val is K extends any ? ({ [k in Exclude<keyof T, K>]: T[k] } & { [k in K]: NonNullable<T[k]> }) : never =>
21+
isDefined(val[key])
2122

2223
const isEmptyHover = (hover: HoverMerged | null): boolean =>
2324
!hover ||
@@ -29,7 +30,7 @@ const isEmptyHover = (hover: HoverMerged | null): boolean =>
2930
* Returns true if the HoverOverlay would have anything to show according to the given hover and definition states.
3031
*/
3132
export const overlayUIHasContent = (state: Pick<HoverOverlayProps, 'hoverOrError' | 'definitionURLOrError'>): boolean =>
32-
(state.hoverOrError && !(HoverMerged.is(state.hoverOrError) && isEmptyHover(state.hoverOrError))) ||
33+
(!!state.hoverOrError && !(HoverMerged.is(state.hoverOrError) && isEmptyHover(state.hoverOrError))) ||
3334
isJumpURL(state.definitionURLOrError)
3435

3536
/**

src/hoverifier.test.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isEqual } from 'lodash'
2-
import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
2+
import { Observable, of, Subject, Subscription } from 'rxjs'
33
import { distinctUntilChanged, filter, map } from 'rxjs/operators'
44
import { TestScheduler } from 'rxjs/testing'
55
import { Position } from 'vscode-languageserver-types'
@@ -8,14 +8,15 @@ import { noop } from 'lodash'
88
import { propertyIsDefined } from './helpers'
99
import { createHoverifier, LOADER_DELAY, TOOLTIP_DISPLAY_DELAY } from './hoverifier'
1010
import { HoverOverlayProps } from './HoverOverlay'
11+
import { findPositionsFromEvents } from './positions'
1112
import { BlobProps, DOM } from './testutils/dom'
1213
import { createHoverMerged, createStubHoverFetcher, createStubJumpURLFetcher } from './testutils/lsp'
13-
import { clickPosition } from './testutils/mouse'
14+
import { clickPositionImpure } from './testutils/mouse'
1415
import { LOADING } from './types'
1516

1617
describe('Hoverifier', () => {
1718
const dom = new DOM()
18-
// after(dom.cleanup)
19+
after(dom.cleanup)
1920

2021
let testcases: BlobProps[] = []
2122
before(() => {
@@ -51,18 +52,14 @@ describe('Hoverifier', () => {
5152
scrollElement: HTMLElement
5253
}>()
5354

54-
const codeMouseMoves = fromEvent<MouseEvent>(blob.element, 'mousemove')
55-
const codeMouseOvers = fromEvent<MouseEvent>(blob.element, 'mouseover')
56-
const codeClicks = fromEvent<MouseEvent>(blob.element, 'click')
55+
const positionEvents = of(blob.element).pipe(findPositionsFromEvents())
5756

5857
const subscriptions = new Subscription()
5958

6059
subscriptions.add(hoverifier)
6160
subscriptions.add(
6261
hoverifier.hoverify({
63-
codeMouseMoves,
64-
codeMouseOvers,
65-
codeClicks,
62+
positionEvents,
6663
positionJumps,
6764
resolveContext: () => blob.revSpec,
6865
})
@@ -103,7 +100,7 @@ describe('Hoverifier', () => {
103100

104101
// Click https://sourcegraph.sgdev.org/github.com/gorilla/mux@cb4698366aa625048f3b815af6a0dea8aef9280a/-/blob/mux.go#L24:6
105102
cold(inputDiagram).subscribe(() =>
106-
clickPosition(blob, {
103+
clickPositionImpure(blob, {
107104
line: 23,
108105
character: 6,
109106
})

src/hoverifier.ts

Lines changed: 53 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,39 @@ import {
1010
share,
1111
switchMap,
1212
takeUntil,
13-
tap,
1413
withLatestFrom,
1514
} from 'rxjs/operators'
1615
import { Key } from 'ts-key-enum'
1716
import { Position } from 'vscode-languageserver-types'
1817
import { asError, ErrorLike } from './errors'
19-
import { propertyIsDefined } from './helpers'
18+
import { isDefined } from './helpers'
2019
import { overlayUIHasContent, scrollIntoCenterIfNeeded } from './helpers'
2120
import { HoverOverlayProps, isJumpURL } from './HoverOverlay'
2221
import { calculateOverlayPosition } from './overlay_position'
22+
import { PositionEvent, SupportedMouseEvent } from './positions'
2323
import { createObservableStateContainer } from './state'
2424
import {
25-
convertCodeCellIdempotent,
2625
convertNode,
2726
findElementWithOffset,
2827
getRowInCodeElement,
2928
getRowsInRange,
30-
getTableDataCell,
3129
getTokenAtPosition,
3230
HoveredToken,
3331
locateTarget,
3432
} from './token_position'
3533
import { EMODENOTFOUND, HoverMerged, LOADING } from './types'
3634
import { FileSpec, LineOrPositionOrRange, RepoSpec, ResolvedRevSpec, RevSpec } from './url'
3735

36+
export { HoveredToken }
37+
3838
interface HoverifierOptions {
3939
/**
4040
* Emit the HoverOverlay element on this after it was rerendered when its content changed and it needs to be repositioned.
4141
*/
42-
hoverOverlayRerenders: Observable<{ hoverOverlayElement: HTMLElement; scrollElement: HTMLElement }>
42+
hoverOverlayRerenders: Observable<{
43+
hoverOverlayElement: HTMLElement
44+
scrollElement: HTMLElement
45+
}>
4346

4447
/**
4548
* Emit on this Observable when the Go-To-Definition button in the HoverOverlay was clicked
@@ -92,9 +95,7 @@ export interface Hoverifier {
9295
}
9396

9497
export interface HoverifyOptions {
95-
codeMouseMoves: Observable<MouseEvent>
96-
codeMouseOvers: Observable<MouseEvent>
97-
codeClicks: Observable<MouseEvent>
98+
positionEvents: Observable<PositionEvent>
9899

99100
/**
100101
* Emit on this Observable to trigger the overlay on a position in this code view.
@@ -222,15 +223,20 @@ export const createHoverifier = ({
222223
selectedPosition: undefined,
223224
})
224225

225-
interface MouseEventTrigger {
226-
event: MouseEvent
226+
interface MouseEventTrigger extends PositionEvent {
227227
resolveContext: ContextResolver
228228
}
229229

230230
// These Subjects aggregate all events from all hoverified code views
231-
const allCodeMouseMoves = new Subject<MouseEventTrigger>()
232-
const allCodeMouseOvers = new Subject<MouseEventTrigger>()
233-
const allCodeClicks = new Subject<MouseEventTrigger>()
231+
const allPositionsFromEvents = new Subject<MouseEventTrigger>()
232+
233+
const isEventType = <T extends SupportedMouseEvent>(type: T) => (
234+
event: MouseEventTrigger
235+
): event is MouseEventTrigger & { eventType: T } => event.eventType === type
236+
const allCodeMouseMoves = allPositionsFromEvents.pipe(filter(isEventType('mousemove')))
237+
const allCodeMouseOvers = allPositionsFromEvents.pipe(filter(isEventType('mouseover')))
238+
const allCodeClicks = allPositionsFromEvents.pipe(filter(isEventType('click')))
239+
234240
const allPositionJumps = new Subject<{
235241
position: LineOrPositionOrRange
236242
codeElement: HTMLElement
@@ -270,21 +276,10 @@ export const createHoverifier = ({
270276
)
271277

272278
const codeMouseOverTargets = allCodeMouseOvers.pipe(
273-
filter(({ event }) => event.currentTarget !== null),
274279
map(({ event, ...rest }) => ({
275280
target: event.target as HTMLElement,
276-
codeElement: event.currentTarget as HTMLElement,
277281
...rest,
278282
})),
279-
// SIDE EFFECT (but idempotent)
280-
// If not done for this cell, wrap the tokens in this cell to enable finding the precise positioning.
281-
// This may be possible in other ways (looking at mouse position and rendering characters), but it works
282-
tap(({ target, codeElement }) => {
283-
const td = getTableDataCell(target, codeElement)
284-
if (td !== undefined) {
285-
convertCodeCellIdempotent(td)
286-
}
287-
}),
288283
debounceTime(50),
289284
// Do not consider mouseovers while overlay is pinned
290285
filter(() => !container.values.hoverOverlayIsFixed),
@@ -295,18 +290,13 @@ export const createHoverifier = ({
295290
filter(({ event }) => event.currentTarget !== null),
296291
map(({ event, ...rest }) => ({
297292
target: event.target as HTMLElement,
298-
codeElement: event.currentTarget as HTMLElement,
299293
...rest,
300294
})),
301295
share()
302296
)
303297

304298
/** Emits DOM elements at new positions found in the URL */
305-
const jumpTargets: Observable<{
306-
target: HTMLElement
307-
codeElement: HTMLElement
308-
resolveContext: ContextResolver
309-
}> = allPositionJumps.pipe(
299+
const jumpTargets = allPositionJumps.pipe(
310300
// Only use line and character for comparison
311301
map(({ position: { line, character }, ...rest }) => ({ position: { line, character }, ...rest })),
312302
// Ignore same values
@@ -318,16 +308,25 @@ export const createHoverifier = ({
318308
map(({ position, codeElement, ...rest }) => {
319309
const row = getRowInCodeElement(codeElement, position.line)
320310
if (!row) {
321-
return { target: undefined, codeElement, ...rest }
311+
return undefined
322312
}
323313
const cell = row.cells[1]!
324314
const target = findElementWithOffset(cell, position.character)
325315
if (!target) {
326316
console.warn('Could not find target for position in file', position)
317+
return undefined
318+
}
319+
// TODO locateTarget is purely needed here to get `hoveredToken.part` for diffs
320+
// We should define a function that takes care of _only_ figuring out the `part`
321+
// so we don't have to use locateTarget
322+
const hoveredToken = locateTarget(target, codeElement, false)
323+
if (!Position.is(hoveredToken)) {
324+
console.warn('Could not find target for position in file', position)
325+
return undefined
327326
}
328-
return { target, codeElement, ...rest }
327+
return { ...rest, eventType: 'jump' as 'jump', target, position: hoveredToken, codeElement }
329328
}),
330-
filter(propertyIsDefined('target'))
329+
filter(isDefined)
331330
)
332331

333332
// REPOSITIONING
@@ -349,46 +348,21 @@ export const createHoverifier = ({
349348
container.update({ hoverOverlayPosition })
350349
})
351350
)
352-
/** Emits new positions at which a tooltip needs to be shown from clicks, mouseovers and URL changes. */
353-
const positions: Observable<{
354-
/**
355-
* The 1-indexed position at which a new tooltip is to be shown,
356-
* or undefined when a target was hovered/clicked that does not correspond to a position (e.g. after the end of the line)
357-
*/
358-
position?: HoveredToken & HoveredTokenContext
359-
/**
360-
* True if the tooltip should be pinned once the hover came back and is non-empty.
361-
* This depends on what triggered the new position.
362-
* We remember it because the pinning is deferred to when we have a result,
363-
* so we don't pin empty (i.e. invisible) hovers.
364-
*/
365-
pinIfNonEmpty: boolean
366-
codeElement: HTMLElement
367-
}> = merge(
368-
// Should unpin the tooltip even if hover cames back non-empty
369-
codeMouseOverTargets.pipe(map(data => ({ ...data, pinIfNonEmpty: false }))),
370-
// When the location changes and includes a line/column pair, use that target
371-
// Should pin the tooltip if hover cames back non-empty
372-
jumpTargets.pipe(map(data => ({ ...data, pinIfNonEmpty: true }))),
373-
// Should pin the tooltip if hover cames back non-empty
374-
codeClickTargets.pipe(map(data => ({ ...data, pinIfNonEmpty: true })))
375-
).pipe(
376-
// Find out the position that was hovered over
377-
map(({ target, codeElement, resolveContext, ...rest }) => {
378-
const hoveredToken = locateTarget(target, codeElement, false)
379-
const position = Position.is(hoveredToken)
380-
? { ...hoveredToken, ...resolveContext(hoveredToken) }
381-
: undefined
382-
return { position, codeElement, ...rest }
383-
}),
351+
352+
/** Emits new positions including context at which a tooltip needs to be shown from clicks, mouseovers and URL changes. */
353+
const resolvedPositions = merge(codeMouseOverTargets, jumpTargets, codeClickTargets).pipe(
354+
map(({ position, resolveContext, ...rest }) => ({
355+
...rest,
356+
position: Position.is(position) ? { ...position, ...resolveContext(position) } : undefined,
357+
})),
384358
share()
385359
)
386360

387361
/**
388362
* For every position, emits an Observable with new values for the `hoverOrError` state.
389363
* This is a higher-order Observable (Observable that emits Observables).
390364
*/
391-
const hoverObservables = positions.pipe(
365+
const hoverObservables = resolvedPositions.pipe(
392366
map(({ position, codeElement }) => {
393367
if (!position) {
394368
return of({ codeElement, hoverOrError: undefined })
@@ -445,7 +419,7 @@ export const createHoverifier = ({
445419
)
446420
// Telemetry for hovers
447421
subscription.add(
448-
zip(positions, hoverObservables)
422+
zip(resolvedPositions, hoverObservables)
449423
.pipe(
450424
distinctUntilChanged(([positionA], [positionB]) => isEqual(positionA, positionB)),
451425
switchMap(([position, hoverObservable]) => hoverObservable),
@@ -460,7 +434,7 @@ export const createHoverifier = ({
460434
* For every position, emits an Observable that emits new values for the `definitionURLOrError` state.
461435
* This is a higher-order Observable (Observable that emits Observables).
462436
*/
463-
const definitionObservables = positions.pipe(
437+
const definitionObservables = resolvedPositions.pipe(
464438
// Fetch the definition location for that position
465439
map(({ position }) => {
466440
if (!position) {
@@ -506,11 +480,11 @@ export const createHoverifier = ({
506480
//
507481
// zip together a position and the hover and definition fetches it triggered
508482
subscription.add(
509-
zip(positions, hoverObservables, definitionObservables)
483+
zip(resolvedPositions, hoverObservables, definitionObservables)
510484
.pipe(
511-
switchMap(([{ pinIfNonEmpty }, hoverObservable, definitionObservable]) => {
485+
switchMap(([{ eventType }, hoverObservable, definitionObservable]) => {
512486
// If the position was triggered by a mouseover, never pin
513-
if (!pinIfNonEmpty) {
487+
if (eventType !== 'click' && eventType !== 'jump') {
514488
return [false]
515489
}
516490
// combine the latest values for them, so we have access to both values
@@ -521,8 +495,7 @@ export const createHoverifier = ({
521495
overlayUIHasContent({ hoverOrError, definitionURLOrError })
522496
)
523497
)
524-
}),
525-
share()
498+
})
526499
)
527500
.subscribe(hoverOverlayIsFixed => {
528501
container.update({ hoverOverlayIsFixed })
@@ -579,7 +552,7 @@ export const createHoverifier = ({
579552
})
580553
)
581554
subscription.add(
582-
positions.subscribe(({ position }) => {
555+
resolvedPositions.subscribe(({ position }) => {
583556
container.update({
584557
hoveredToken: position,
585558
// On every new target (from mouseover or click) hide the j2d loader/error/not found UI again
@@ -601,19 +574,14 @@ export const createHoverifier = ({
601574
map(internalToExternalState),
602575
distinctUntilChanged((a, b) => isEqual(a, b))
603576
),
604-
hoverify({
605-
codeMouseMoves,
606-
codeMouseOvers,
607-
codeClicks,
608-
positionJumps,
609-
resolveContext,
610-
}: HoverifyOptions): Subscription {
577+
hoverify({ positionEvents, positionJumps, resolveContext }: HoverifyOptions): Subscription {
611578
const subscription = new Subscription()
612-
const eventWithContextResolver = map((event: MouseEvent) => ({ event, resolveContext }))
579+
const eventWithContextResolver = map((event: PositionEvent) => ({
580+
...event,
581+
resolveContext,
582+
}))
613583
// Broadcast all events from this code view
614-
subscription.add(codeMouseMoves.pipe(eventWithContextResolver).subscribe(allCodeMouseMoves))
615-
subscription.add(codeMouseOvers.pipe(eventWithContextResolver).subscribe(allCodeMouseOvers))
616-
subscription.add(codeClicks.pipe(eventWithContextResolver).subscribe(allCodeClicks))
584+
subscription.add(positionEvents.pipe(eventWithContextResolver).subscribe(allPositionsFromEvents))
617585
subscription.add(positionJumps.pipe(map(jump => ({ ...jump, resolveContext }))).subscribe(allPositionJumps))
618586
return subscription
619587
},

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './hoverifier'
22
export * from './HoverOverlay'
3+
export * from './positions'

0 commit comments

Comments
 (0)