mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:51:59 +00:00
Add (back) the ability to share links to code intel popover locations (#35658)
This commit is contained in:
parent
baa263485e
commit
41faa7fcdc
@ -188,6 +188,7 @@ export const bitbucketCloudCodeHost: CodeHost = {
|
||||
},
|
||||
hoverOverlayClassProps: {
|
||||
className: styles.hoverOverlay,
|
||||
closeButtonClassName: styles.close,
|
||||
badgeClassName: styles.badge,
|
||||
actionItemClassName: styles.hoverOverlayActionItem,
|
||||
iconClassName: styles.icon,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
10
client/shared/src/hover/CopyLinkIcon.tsx
Normal file
10
client/shared/src/hover/CopyLinkIcon.tsx
Normal 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>
|
||||
)
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user