Add (back) the ability to share links to code intel popover locations (#35658)

This commit is contained in:
Cesar Jimenez 2022-05-24 14:46:56 -04:00 committed by GitHub
parent baa263485e
commit 41faa7fcdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 436 additions and 113 deletions

View File

@ -188,6 +188,7 @@ export const bitbucketCloudCodeHost: CodeHost = {
},
hoverOverlayClassProps: {
className: styles.hoverOverlay,
closeButtonClassName: styles.close,
badgeClassName: styles.badge,
actionItemClassName: styles.hoverOverlayActionItem,
iconClassName: styles.icon,

View File

@ -744,6 +744,7 @@ export const githubCodeHost: GithubCodeHost = {
className: 'Box',
actionItemClassName: 'btn btn-sm btn-secondary',
actionItemPressedClassName: 'active',
closeButtonClassName: 'btn-octicon p-0 hover-overlay__close-button--github',
badgeClassName: classNames('label', styles.hoverOverlayBadge),
getAlertClassName: createNotificationClassNameGetter(notificationClassNames, 'flash-full'),
iconClassName,

View File

@ -269,6 +269,7 @@ export const gitlabCodeHost = subtypeOf<CodeHost>()({
className: classNames('card', styles.hoverOverlay),
actionItemClassName: 'btn btn-secondary',
actionItemPressedClassName: 'active',
closeButtonClassName: 'btn btn-transparent p-0 btn-icon--gitlab',
iconClassName: 'square s16',
getAlertClassName: createNotificationClassNameGetter(notificationClassNames),
},

View File

@ -199,6 +199,7 @@ export const phabricatorCodeHost: CodeHost = {
hoverOverlayClassProps: {
className: classNames('aphront-dialog-view', styles.hoverOverlay),
actionItemClassName: classNames('button grey', styles.hoverOverlayActionItem),
closeButtonClassName: 'button grey btn-icon--phabricator',
iconClassName: styles.hoverOverlayActionItemIcon,
getAlertClassName: createNotificationClassNameGetter(notificationClassNames),
},

View File

@ -33,7 +33,9 @@ import {
mergeMap,
delay,
startWith,
tap,
} from 'rxjs/operators'
import { Key } from 'ts-key-enum'
import { asError, ErrorLike, isErrorLike } from '@sourcegraph/common'
import { Position, Range } from '@sourcegraph/extension-api-types'
@ -47,6 +49,7 @@ import {
convertNode,
DiffPart,
DOMFunctions,
findElementWithOffset,
getCodeElementsInRange,
getTokenAtPositionOrRange,
HoveredToken,
@ -78,6 +81,14 @@ export interface HoverifierOptions<C extends object, D, A> {
relativeElement?: HTMLElement
}>
pinOptions?: {
/** Emit on this Observable to pin the popover. */
pins: Subscribable<void>
/** * Emit on this Observable when the close button in the HoverOverlay was clicked */
closeButtonClicks: Subscribable<void>
}
hoverOverlayElements: Subscribable<HTMLElement | null>
/**
@ -235,6 +246,9 @@ export interface HoverifyOptions<C extends object>
* @template A The type of an action.
*/
export interface HoverState<C extends object, D, A> {
/** The currently hovered token */
hoveredToken?: HoveredToken & C
/**
* The currently hovered and highlighted HTML element.
*/
@ -250,6 +264,8 @@ export interface HoverState<C extends object, D, A> {
*/
hoverOverlayProps?: Pick<HoverOverlayProps<C, D, A>, Exclude<keyof HoverOverlayProps<C, D, A>, 'actionComponent'>>
pinned?: boolean
/**
* The highlighted range, which is the range in the hover result or else the range of the hovered token.
*/
@ -271,6 +287,8 @@ export interface HoverState<C extends object, D, A> {
interface InternalHoverifierState<C extends object, D, A> {
hoverOrError?: typeof LOADING | (HoverAttachment & D) | null | ErrorLike
pinned: boolean
/** The desired position of the hover overlay */
hoverOverlayPosition?: { left: number } & ({ top: number } | { bottom: number })
@ -312,7 +330,7 @@ interface InternalHoverifierState<C extends object, D, A> {
* (because there is no content, or because it is still loading).
*/
const shouldRenderOverlay = (state: InternalHoverifierState<{}, {}, {}>): boolean =>
!state.mouseIsMoving &&
!(!state.pinned && state.mouseIsMoving) &&
((!!state.hoverOrError && state.hoverOrError !== LOADING) ||
(!!state.actionsOrError &&
state.actionsOrError !== LOADING &&
@ -328,6 +346,7 @@ const shouldRenderOverlay = (state: InternalHoverifierState<{}, {}, {}>): boolea
const internalToExternalState = <C extends object, D, A>(
internalState: InternalHoverifierState<C, D, A>
): HoverState<C, D, A> => ({
hoveredToken: internalState.hoveredToken,
hoveredTokenElement: internalState.hoveredTokenElement,
actionsOrError: internalState.actionsOrError,
selectedPosition: internalState.selectedPosition,
@ -340,6 +359,7 @@ const internalToExternalState = <C extends object, D, A>(
actionsOrError: internalState.actionsOrError,
}
: undefined,
pinned: internalState.pinned,
})
/** The time in ms after which to show a loader if the result has not returned yet */
@ -395,6 +415,7 @@ export type ContextResolver<C extends object> = (hoveredToken: HoveredToken) =>
* @template A The type of an action.
*/
export function createHoverifier<C extends object, D, A>({
pinOptions,
hoverOverlayElements,
hoverOverlayRerenders,
getHover,
@ -408,6 +429,7 @@ export function createHoverifier<C extends object, D, A>({
// Shared between all hoverified code views
const container = createObservableStateContainer<InternalHoverifierState<C, D, A>>({
hoveredTokenElement: undefined,
pinned: false,
hoveredToken: undefined,
hoverOrError: undefined,
hoverOverlayPosition: undefined,
@ -444,6 +466,7 @@ export function createHoverifier<C extends object, D, A>({
): event is MouseEventTrigger & { eventType: T } => event.eventType === type
const allCodeMouseMoves = allPositionsFromEvents.pipe(filter(isEventType('mousemove')), suppressWhileOverlayShown())
const allCodeMouseOvers = allPositionsFromEvents.pipe(filter(isEventType('mouseover')), suppressWhileOverlayShown())
const allCodeClicks = allPositionsFromEvents.pipe(filter(isEventType('click')))
const allPositionJumps = new Subject<PositionJump & EventOptions<C>>()
@ -484,6 +507,8 @@ export function createHoverifier<C extends object, D, A>({
...rest,
})),
debounceTime(MOUSEOVER_DELAY),
// Do not consider mouseovers while overlay is pinned
filter(() => !container.values.pinned),
switchMap(({ adjustPosition, codeView, resolveContext, position, ...rest }) =>
adjustPosition && position
? from(
@ -505,6 +530,51 @@ export function createHoverifier<C extends object, D, A>({
),
share()
)
/**
* Emits DOM elements at new positions found in the URL. When pinning is
* disabled, this does not emit at all because the tooltip doesn't get
* pinned at the jump target.
*/
const jumpTargets = allPositionJumps.pipe(
// Only use line and character for comparison
map(({ position: { line, character, part }, ...rest }) => ({
position: { line, character, part },
...rest,
})),
// Ignore same values
// It's important to do this before filtering otherwise navigating from
// a position, to a line-only position, back to the first position would get ignored
distinctUntilChanged((a, b) => isEqual(a, b)),
map(({ position, codeView, dom, overrideTokenize, ...rest }) => {
let cell: HTMLElement | null
let target: HTMLElement | undefined
let part: DiffPart | undefined
if (isPosition(position)) {
cell = dom.getCodeElementFromLineNumber(codeView, position.line, position.part)
if (cell) {
target = findElementWithOffset(
cell,
{ offsetStart: position.character },
shouldTokenize({ tokenize, overrideTokenize })
)
if (target) {
part = dom.getDiffCodePart?.(target)
} else {
console.warn('Could not find target for position in file', position)
}
}
}
return {
...rest,
eventType: 'jump' as const,
target,
position: { ...position, part },
codeView,
dom,
overrideTokenize,
}
})
)
// REPOSITIONING
// On every componentDidUpdate (after the component was rerendered, e.g. from a hover state update) resposition
@ -516,7 +586,7 @@ export function createHoverifier<C extends object, D, A>({
from(hoverOverlayRerenders)
.pipe(
// with the latest target that came from either a mouseover, click or location change (whatever was the most recent)
withLatestFrom(merge(codeMouseOverTargets)),
withLatestFrom(merge(codeMouseOverTargets, jumpTargets)),
map(
([
{ hoverOverlayElement, relativeElement },
@ -586,7 +656,7 @@ export function createHoverifier<C extends object, D, A>({
)
/** Emits new positions including context at which a tooltip needs to be shown from clicks, mouseovers and URL changes. */
const resolvedPositionEvents = merge(codeMouseOverTargets).pipe(
const resolvedPositionEvents = merge(codeMouseOverTargets, jumpTargets).pipe(
map(({ position, resolveContext, eventType, ...rest }) => ({
...rest,
eventType,
@ -650,9 +720,9 @@ export function createHoverifier<C extends object, D, A>({
hoveredTokenElement,
scrollBoundaries,
...rest
}: Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'hoverOverlayIsFixed'> &
}: Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'pinned'> &
Omit<EventOptions<C>, 'resolveContext' | 'dom'> & { codeView: HTMLElement }): Observable<
Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'hoverOverlayIsFixed'> & { codeView: HTMLElement }
Omit<InternalHoverifierState<C, D, A>, 'mouseIsMoving' | 'pinned'> & { codeView: HTMLElement }
> => {
const result = of({ hoveredTokenElement, ...rest })
if (!hoveredTokenElement || !scrollBoundaries) {
@ -666,7 +736,7 @@ export function createHoverifier<C extends object, D, A>({
mapTo({
...rest,
hoveredTokenElement,
hoverOverlayIsFixed: false,
pinned: false,
hoverOrError: undefined,
hoveredToken: undefined,
actionsOrError: undefined,
@ -960,12 +1030,28 @@ export function createHoverifier<C extends object, D, A>({
const resetHover = (): void => {
container.update({
hoverOverlayPosition: undefined,
pinned: false,
hoverOrError: undefined,
hoveredToken: undefined,
actionsOrError: undefined,
})
}
// Pin on request.
subscription.add(pinOptions?.pins.subscribe(() => container.update({ pinned: true })))
// Unpin on close, ESC, or click.
subscription.add(
merge(
pinOptions?.closeButtonClicks ?? EMPTY,
fromEvent<KeyboardEvent>(window, 'keydown').pipe(
filter(event => event.key === Key.Escape),
tap(event => event.preventDefault())
),
allCodeClicks
).subscribe(() => resetHover())
)
// LOCATION CHANGES
subscription.add(
allPositionJumps.subscribe(

View File

@ -0,0 +1,10 @@
import React from 'react'
export const CopyLinkIcon: React.FunctionComponent = () => (
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.6 3.99999C1.6 2.85999 2.52667 1.93332 3.66667 1.93332H6.33334V0.666656H3.66667C2.78261 0.666656 1.93477 1.01785 1.30965 1.64297C0.684525 2.26809 0.333336 3.11593 0.333336 3.99999C0.333336 4.88405 0.684525 5.73189 1.30965 6.35701C1.93477 6.98213 2.78261 7.33332 3.66667 7.33332H6.33334V6.06666H3.66667C2.52667 6.06666 1.6 5.13999 1.6 3.99999ZM4.33334 4.66666H9.66667V3.33332H4.33334V4.66666ZM10.3333 0.666656H7.66667V1.93332H10.3333C11.4733 1.93332 12.4 2.85999 12.4 3.99999C12.4 5.13999 11.4733 6.06666 10.3333 6.06666H7.66667V7.33332H10.3333C11.2174 7.33332 12.0652 6.98213 12.6904 6.35701C13.3155 5.73189 13.6667 4.88405 13.6667 3.99999C13.6667 3.11593 13.3155 2.26809 12.6904 1.64297C12.0652 1.01785 11.2174 0.666656 10.3333 0.666656Z"
fill="#0B70DB"
/>
</svg>
)

View File

@ -99,6 +99,24 @@
padding-right: var(--hover-overlay-horizontal-padding);
}
.actions-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.actions-copy-link {
margin-right: var(--hover-overlay-horizontal-padding);
font-size: 0.75rem;
color: var(--link-color);
border-style: none;
background: transparent;
> span:last-child {
padding-left: 0.2rem;
}
}
.actions-inner {
margin-right: auto;
white-space: nowrap;

View File

@ -1,9 +1,11 @@
import React, { CSSProperties } from 'react'
import classNames from 'classnames'
import CloseIcon from 'mdi-react/CloseIcon'
import { isErrorLike, sanitizeClass } from '@sourcegraph/common'
import { Card } from '@sourcegraph/wildcard'
// eslint-disable-next-line no-restricted-imports
import { Card, Icon } from '@sourcegraph/wildcard'
import { ActionItem, ActionItemComponentProps } from '../actions/ActionItem'
import { NotificationType } from '../api/extension/extensionHostApi'
@ -11,6 +13,8 @@ import { PlatformContextProps } from '../platform/context'
import { TelemetryProps } from '../telemetry/telemetryService'
import { ThemeProps } from '../theme'
import { CopyLinkIcon } from './CopyLinkIcon'
import { toNativeEvent } from './helpers'
import type { HoverContext, HoverOverlayBaseProps, GetAlertClassName, GetAlertVariant } from './HoverOverlay.types'
import { HoverOverlayAlerts, HoverOverlayAlertsProps } from './HoverOverlayAlerts'
import { HoverOverlayContents } from './HoverOverlayContents'
@ -22,11 +26,15 @@ import style from './HoverOverlayContents.module.scss'
const LOADING = 'loading' as const
const transformMouseEvent = (handler: (event: MouseEvent) => void) => (event: React.MouseEvent<HTMLElement>) =>
handler(toNativeEvent(event))
export type { HoverContext }
export interface HoverOverlayClassProps {
/** An optional class name to apply to the outermost element of the HoverOverlay */
className?: string
closeButtonClassName?: string
iconClassName?: string
badgeClassName?: string
@ -58,10 +66,23 @@ export interface HoverOverlayProps
/** A ref callback to get the root overlay element. Use this to calculate the position. */
hoverRef?: React.Ref<HTMLDivElement>
pinOptions?: PinOptions
/** Show Sourcegraph logo alongside prompt */
useBrandedLogo?: boolean
}
export interface PinOptions {
/** Whether to show the close button for the hover overlay */
showCloseButton: boolean
/** Called when the close button is clicked */
onCloseButtonClick?: () => void
/** Called when the copy link button is clicked */
onCopyLinkButtonClick?: () => void
}
const getOverlayStyle = (overlayPosition: HoverOverlayProps['overlayPosition']): CSSProperties => {
if (!overlayPosition) {
return {
@ -90,9 +111,11 @@ export const HoverOverlay: React.FunctionComponent<React.PropsWithChildren<Hover
platformContext,
telemetryService,
extensionsController,
pinOptions,
location,
className,
closeButtonClassName,
iconClassName,
badgeClassName,
actionItemClassName,
@ -127,9 +150,27 @@ export const HoverOverlay: React.FunctionComponent<React.PropsWithChildren<Hover
data-testid="hover-overlay-contents"
className={classNames(
style.hoverOverlayContents,
hoverOrError === LOADING && style.hoverOverlayContentsLoading
hoverOrError === LOADING && style.hoverOverlayContentsLoading,
pinOptions?.showCloseButton && style.hoverOverlayContentsWithCloseButton
)}
>
{pinOptions?.showCloseButton && (
<button
type="button"
onClick={
pinOptions.onCloseButtonClick
? transformMouseEvent(pinOptions.onCloseButtonClick)
: undefined
}
className={classNames(
hoverOverlayStyle.closeButton,
closeButtonClassName,
hoverOrError === LOADING && hoverOverlayStyle.closeButtonLoading
)}
>
<CloseIcon className={iconClassName} />
</button>
)}
<HoverOverlayContents
hoverOrError={hoverOrError}
iconClassName={iconClassName}
@ -152,40 +193,54 @@ export const HoverOverlay: React.FunctionComponent<React.PropsWithChildren<Hover
onAlertDismissed={onAlertDismissed}
/>
)}
{actionsOrError !== undefined &&
actionsOrError !== null &&
actionsOrError !== LOADING &&
!isErrorLike(actionsOrError) &&
actionsOrError.length > 0 && (
<div className={hoverOverlayStyle.actions}>
<div className={hoverOverlayStyle.actionsInner}>
{actionsOrError.map((action, index) => (
<ActionItem
key={index}
{...action}
className={classNames(
hoverOverlayStyle.action,
actionItemClassName,
`test-tooltip-${sanitizeClass(action.action.title || 'untitled')}`
)}
iconClassName={iconClassName}
pressedClassName={actionItemPressedClassName}
variant="actionItem"
disabledDuringExecution={true}
showLoadingSpinnerDuringExecution={true}
showInlineError={true}
platformContext={platformContext}
telemetryService={telemetryService}
extensionsController={extensionsController}
location={location}
actionItemStyleProps={actionItemStyleProps}
/>
))}
</div>
<div className={hoverOverlayStyle.actionsContainer}>
{actionsOrError !== undefined &&
actionsOrError !== null &&
actionsOrError !== LOADING &&
!isErrorLike(actionsOrError) &&
actionsOrError.length > 0 && (
<div className={hoverOverlayStyle.actions}>
<div className={hoverOverlayStyle.actionsInner}>
{actionsOrError.map((action, index) => (
<ActionItem
key={index}
{...action}
className={classNames(
hoverOverlayStyle.action,
actionItemClassName,
`test-tooltip-${sanitizeClass(action.action.title || 'untitled')}`
)}
iconClassName={iconClassName}
pressedClassName={actionItemPressedClassName}
variant="actionItem"
disabledDuringExecution={true}
showLoadingSpinnerDuringExecution={true}
showInlineError={true}
platformContext={platformContext}
telemetryService={telemetryService}
extensionsController={extensionsController}
location={location}
actionItemStyleProps={actionItemStyleProps}
/>
))}
</div>
{useBrandedLogo && <HoverOverlayLogo className={hoverOverlayStyle.overlayLogo} />}
</div>
{useBrandedLogo && <HoverOverlayLogo className={hoverOverlayStyle.overlayLogo} />}
</div>
)}
{pinOptions && (
<button
className={classNames('d-flex', 'align-items-center', hoverOverlayStyle.actionsCopyLink)}
onClick={pinOptions.onCopyLinkButtonClick}
onKeyPress={pinOptions.onCopyLinkButtonClick}
type="button"
>
<Icon className="mr-1" as={CopyLinkIcon} />
<span className="inline-block">Copy link</span>
</button>
)}
</div>
</Card>
)
}

View File

@ -17,6 +17,9 @@ exports[`HoverOverlay actions and hover empty 1`] = `
No hover information available.
</small>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -40,6 +43,9 @@ exports[`HoverOverlay actions and hover error 1`] = `
M2
</div>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -63,6 +69,9 @@ exports[`HoverOverlay actions and hover loading 1`] = `
/>
</div>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -90,19 +99,23 @@ exports[`HoverOverlay actions and hover present 1`] = `
</span>
</div>
<div
class="actions"
class="actionsContainer"
>
<div
class="actionsInner"
class="actions"
>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
<div
class="actionsInner"
>
</a>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
>
</a>
</div>
</div>
</div>
</div>
@ -122,6 +135,9 @@ exports[`HoverOverlay actions empty 1`] = `
class="hoverOverlayContents"
data-testid="hover-overlay-contents"
/>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -150,6 +166,9 @@ exports[`HoverOverlay actions error, hover present 1`] = `
</span>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -165,6 +184,9 @@ exports[`HoverOverlay actions loading 1`] = `
class="hoverOverlayContents"
data-testid="hover-overlay-contents"
/>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -181,19 +203,23 @@ exports[`HoverOverlay actions present 1`] = `
data-testid="hover-overlay-contents"
/>
<div
class="actions"
class="actionsContainer"
>
<div
class="actionsInner"
class="actions"
>
<a
class="test-action-item action test-tooltip-some-title"
href=""
role="button"
tabindex="0"
<div
class="actionsInner"
>
Some title
</a>
<a
class="test-action-item action test-tooltip-some-title"
href=""
role="button"
tabindex="0"
>
Some title
</a>
</div>
</div>
</div>
</div>
@ -220,19 +246,23 @@ exports[`HoverOverlay actions present, hover loading 1`] = `
</div>
</div>
<div
class="actions"
class="actionsContainer"
>
<div
class="actionsInner"
class="actions"
>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
<div
class="actionsInner"
>
</a>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
>
</a>
</div>
</div>
</div>
</div>
@ -299,19 +329,23 @@ exports[`HoverOverlay actions, hover and alert present 1`] = `
</div>
</div>
<div
class="actions"
class="actionsContainer"
>
<div
class="actionsInner"
class="actions"
>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
<div
class="actionsInner"
>
</a>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
>
</a>
</div>
</div>
</div>
</div>
@ -339,6 +373,9 @@ exports[`HoverOverlay hover error 1`] = `
M
</div>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -363,19 +400,23 @@ exports[`HoverOverlay hover error, actions present 1`] = `
</div>
</div>
<div
class="actions"
class="actionsContainer"
>
<div
class="actionsInner"
class="actions"
>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
<div
class="actionsInner"
>
</a>
<a
class="test-action-item action test-tooltip-untitled"
href=""
role="button"
tabindex="0"
>
</a>
</div>
</div>
</div>
</div>
@ -401,6 +442,9 @@ exports[`HoverOverlay hover loading 1`] = `
/>
</div>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -427,6 +471,9 @@ exports[`HoverOverlay hover present 1`] = `
</span>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -453,6 +500,9 @@ exports[`HoverOverlay hover present, actions loading 1`] = `
</span>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;
@ -490,6 +540,9 @@ exports[`HoverOverlay multiple hovers present 1`] = `
</span>
</div>
<div
class="actionsContainer"
/>
</div>
</DocumentFragment>
`;

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect } from 'react'
import classNames from 'classnames'
import { fromEvent } from 'rxjs'
import { finalize, tap } from 'rxjs/operators'
@ -121,6 +122,7 @@ export const WebHoverOverlay: React.FunctionComponent<React.PropsWithChildren<Pr
<HoverOverlay
{...propsToUse}
className={styles.webHoverOverlay}
closeButtonClassName={classNames('btn btn-icon', styles.webHoverOverlayCloseButton)}
actionItemClassName="border-0"
onAlertDismissed={onAlertDismissed}
getAlertVariant={getAlertVariant}

View File

@ -5,7 +5,18 @@ import { Remote } from 'comlink'
import * as H from 'history'
import iterate from 'iterare'
import { isEqual } from 'lodash'
import { BehaviorSubject, combineLatest, merge, EMPTY, from, fromEvent, of, ReplaySubject, Subscription } from 'rxjs'
import {
BehaviorSubject,
combineLatest,
merge,
EMPTY,
from,
fromEvent,
of,
ReplaySubject,
Subscription,
Subject,
} from 'rxjs'
import {
catchError,
concatMap,
@ -14,6 +25,8 @@ import {
first,
map,
mapTo,
pairwise,
share,
switchMap,
tap,
throttleTime,
@ -28,6 +41,7 @@ import {
locateTarget,
findPositionsFromEvents,
createHoverifier,
HoverState,
} from '@sourcegraph/codeintellify'
import {
asError,
@ -50,7 +64,7 @@ import { haveInitialExtensionsLoaded } from '@sourcegraph/shared/src/api/feature
import { ViewerId } from '@sourcegraph/shared/src/api/viewerTypes'
import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller'
import { getHoverActions } from '@sourcegraph/shared/src/hover/actions'
import { HoverContext } from '@sourcegraph/shared/src/hover/HoverOverlay'
import { HoverContext, PinOptions } from '@sourcegraph/shared/src/hover/HoverOverlay'
import { getModeFromPath } from '@sourcegraph/shared/src/languages'
import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context'
import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings'
@ -191,16 +205,26 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
[hoverOverlayElements]
)
const codeViewElements = useMemo(() => new ReplaySubject<HTMLElement | null>(1), [])
const codeViewElementsSubject = useMemo(() => new ReplaySubject<HTMLElement | null>(1), [])
const codeViewElements = useMemo(() => codeViewElementsSubject.pipe(share()), [codeViewElementsSubject])
const codeViewReference = useRef<HTMLElement | null>()
const nextCodeViewElement = useCallback(
(codeView: HTMLElement | null) => {
codeViewReference.current = codeView
codeViewElements.next(codeView)
codeViewElementsSubject.next(codeView)
},
[codeViewElements]
[codeViewElementsSubject]
)
// Emits on changes from URL search params
const urlSearchParameters = useMemo(() => new ReplaySubject<URLSearchParams>(1), [])
const nextUrlSearchParameters = useCallback((value: URLSearchParams) => urlSearchParameters.next(value), [
urlSearchParameters,
])
useEffect(() => {
nextUrlSearchParameters(new URLSearchParams(location.search))
}, [nextUrlSearchParameters, location.search])
// Emits on position changes from URL hash
const locationPositions = useMemo(() => new ReplaySubject<LineOrPositionOrRange>(1), [])
const nextLocationPosition = useCallback(
@ -253,9 +277,47 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
const [decorationsOrError, setDecorationsOrError] = useState<TextDocumentDecoration[] | Error | undefined>()
const popoverCloses = useMemo(() => new Subject<void>(), [])
const nextPopoverClose = useCallback((click: void) => popoverCloses.next(click), [popoverCloses])
useObservable(
useMemo(
() =>
popoverCloses.pipe(
withLatestFrom(urlSearchParameters),
tap(([, parameters]) => {
parameters.delete('popover')
props.history.push({
...location,
search: formatSearchParameters(parameters),
})
})
),
[location, popoverCloses, props.history, urlSearchParameters]
)
)
const popoverParameter = useMemo(() => urlSearchParameters.pipe(map(parameters => parameters.get('popover'))), [
urlSearchParameters,
])
const hoverifier = useMemo(
() =>
createHoverifier<HoverContext, HoverMerged, ActionItemAction>({
pinOptions: {
pins: popoverParameter.pipe(
filter(value => value === 'pinned'),
mapTo(undefined)
),
closeButtonClicks: merge(
popoverCloses,
popoverParameter.pipe(
pairwise(),
filter(([previous, next]) => previous === 'pinned' && next !== 'pinned'),
mapTo(undefined)
)
),
},
hoverOverlayElements,
hoverOverlayRerenders: rerenders.pipe(
withLatestFrom(hoverOverlayElements, blobElements),
@ -279,12 +341,13 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
getActions: context => getHoverActions({ extensionsController, platformContext }, context),
}),
[
// None of these dependencies are likely to change
popoverParameter,
popoverCloses,
hoverOverlayElements,
rerenders,
blobElements,
extensionsController,
platformContext,
hoverOverlayElements,
blobElements,
rerenders,
]
)
@ -326,26 +389,24 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
query = toPositionOrRangeQueryParameter({ position })
}
if (position && !('character' in position)) {
// Only change the URL when clicking on blank space on the line (not on
// characters). Otherwise, this would interfere with go to definition.
props.history.push({
...location,
search: formatSearchParameters(
addLineRangeQueryParameter(new URLSearchParams(location.search), query)
),
})
}
const parameters = new URLSearchParams(location.search)
parameters.delete('popover')
nextPopoverClose()
props.history.push({
...location,
search: formatSearchParameters(addLineRangeQueryParameter(parameters, query)),
})
}),
mapTo(undefined)
),
[codeViewElements, hoverifier, props.history, location]
[codeViewElements, hoverifier.hoverState.selectedPosition, location, nextPopoverClose, props.history]
)
)
// Trigger line highlighting after React has finished putting new lines into the DOM via
// `dangerouslySetInnerHTML`.
useEffect(() => codeViewElements.next(codeViewReference.current))
useEffect(() => codeViewElementsSubject.next(codeViewReference.current))
// Line highlighting when position in hash changes
useObservable(
@ -438,11 +499,11 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
filter(isDefined),
findPositionsFromEvents({ domFunctions })
),
positionJumps: locationPositions.pipe(
withLatestFrom(
codeViewElements.pipe(filter(isDefined)),
blobElements.pipe(filter(isDefined))
),
positionJumps: combineLatest([
locationPositions,
codeViewElements.pipe(filter(isDefined)),
blobElements.pipe(filter(isDefined)),
]).pipe(
map(([position, codeView, scrollElement]) => ({
position,
// locationPositions is derived from componentUpdates,
@ -540,7 +601,8 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
)
// Passed to HoverOverlay
const hoverState = useObservable(hoverifier.hoverStateUpdates) || {}
const hoverState: Readonly<HoverState<HoverContext, HoverMerged, ActionItemAction>> =
useObservable(hoverifier.hoverStateUpdates) || {}
// Status bar
const getStatusBarItems = useCallback(
@ -604,6 +666,38 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
)
)
const pinOptions = useMemo<PinOptions>(
() => ({
showCloseButton: true,
onCloseButtonClick: nextPopoverClose,
onCopyLinkButtonClick: async () => {
const line = hoverifier.hoverState.hoveredToken?.line
const character = hoverifier.hoverState.hoveredToken?.character
if (line === undefined || character === undefined) {
return
}
const point = { line, character }
const range = { start: point, end: point }
const context = { position: point, range }
const search = new URLSearchParams(location.search)
search.set('popover', 'pinned')
props.history.push({
search: formatSearchParameters(
addLineRangeQueryParameter(search, toPositionOrRangeQueryParameter(context))
),
})
await navigator.clipboard.writeText(window.location.href)
},
}),
[
hoverifier.hoverState.hoveredToken?.line,
hoverifier.hoverState.hoveredToken?.character,
location.search,
nextPopoverClose,
props.history,
]
)
return (
<>
<div className={classNames(props.className, styles.blob)} ref={nextBlobElement}>
@ -621,6 +715,7 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
nav={url => (props.nav ? props.nav(url) : props.history.push(url))}
hoveredTokenElement={hoverState.hoveredTokenElement}
hoverRef={nextOverlayElement}
pinOptions={pinOptions}
extensionsController={extensionsController}
/>
)}
@ -636,7 +731,7 @@ export const Blob: React.FunctionComponent<React.PropsWithChildren<BlobProps>> =
getCodeElementFromLineNumber={domFunctions.getCodeElementFromLineNumber}
line={line}
decorations={decorations}
codeViewElements={codeViewElements}
codeViewElements={codeViewElementsSubject}
/>
)
})