code-search: improve actions discoverability and UX on file page (#58122)

This commit is contained in:
Bolaji Olajide 2023-12-23 07:19:33 +01:00 committed by GitHub
parent f6a5abe6e0
commit 87d481544d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 507 additions and 277 deletions

View File

@ -1211,9 +1211,11 @@ ts_project(
"src/repo/DirectImportRepoAlert.tsx",
"src/repo/FilePathBreadcrumbs.tsx",
"src/repo/GitReference.tsx",
"src/repo/RepoActionInfo.tsx",
"src/repo/RepoContainer.tsx",
"src/repo/RepoContainerError.tsx",
"src/repo/RepoHeader.tsx",
"src/repo/RepoHeaderContextMenu.tsx",
"src/repo/RepoHeaderContributionPortal.tsx",
"src/repo/RepoLinkPicker.tsx",
"src/repo/RepoMetadataPage/AddMetadataForm.tsx",
@ -1249,8 +1251,8 @@ ts_project(
"src/repo/RevisionsPopover/components/ConnectionPopoverNodeLink/index.ts",
"src/repo/RevisionsPopover/components/index.ts",
"src/repo/RevisionsPopover/index.ts",
"src/repo/actions/CopyPermalinkAction.tsx",
"src/repo/actions/GoToCodeHostAction.tsx",
"src/repo/actions/GoToPermalinkAction.tsx",
"src/repo/actions/ToggleBlameAction.tsx",
"src/repo/backend.ts",
"src/repo/blame/useBlameHunks.ts",
@ -1259,9 +1261,9 @@ ts_project(
"src/repo/blob/BlobLoadingSpinner.tsx",
"src/repo/blob/BlobPage.tsx",
"src/repo/blob/CodeMirrorBlob.tsx",
"src/repo/blob/GoToRawAction.tsx",
"src/repo/blob/RenderedFile.tsx",
"src/repo/blob/RenderedNotebookMarkdown.tsx",
"src/repo/blob/actions/GoToRawAction.tsx",
"src/repo/blob/actions/ToggleHistoryPanel.tsx",
"src/repo/blob/actions/ToggleLineWrap.tsx",
"src/repo/blob/actions/ToggleRenderedFileMode.tsx",

View File

@ -1,28 +1,20 @@
import React from 'react'
import { mdiArrowRightThin, mdiBrain } from '@mdi/js'
import classNames from 'classnames'
import { mdiBrain } from '@mdi/js'
import {
Position,
Icon,
Link,
LoadingSpinner,
Menu,
MenuButton,
MenuDivider,
MenuHeader,
MenuList,
Tooltip,
RadioButton,
useSessionStorage,
Code,
Text,
} from '@sourcegraph/wildcard'
import { useVisibleIndexes } from '../hooks/useVisibleIndexes'
import styles from './BrainDot.module.scss'
export interface BrainDotProps {
repoName: string
commit: string
@ -30,29 +22,14 @@ export interface BrainDotProps {
}
export const BrainDot: React.FunctionComponent<BrainDotProps> = ({ repoName, commit, path }) => (
<Menu>
<Tooltip content="View code intelligence summary">
<MenuButton className={classNames('text-decoration-none', styles.braindot)} aria-label="Code graph">
<Icon aria-hidden={true} svgPath={mdiBrain} />
</MenuButton>
</Tooltip>
<MenuList position={Position.bottomEnd} className={styles.dropdownMenu}>
<MenuHeader>
Click to view code intelligence summary
<span className="float-right">
<Tooltip content="View code intelligence summary">
<Link to={`/${repoName}/-/code-graph/dashboard`}>
<Icon aria-hidden={true} svgPath={mdiArrowRightThin} />
</Link>
</Tooltip>
</span>
</MenuHeader>
<MenuDivider />
<BrainDotContent repoName={repoName} commit={commit} path={path} />
</MenuList>
</Menu>
<>
<MenuDivider />
<MenuHeader className="d-flex">
<Icon aria-hidden={true} svgPath={mdiBrain} fill="text-muted" />
<Text className="mb-0 ml-2">Code intelligence preview</Text>
</MenuHeader>
<BrainDotContent repoName={repoName} commit={commit} path={path} />
</>
)
const BrainDotContent: React.FunctionComponent<BrainDotProps> = ({ repoName, commit, path }) => {
@ -76,16 +53,13 @@ const BrainDotContent: React.FunctionComponent<BrainDotProps> = ({ repoName, com
{visibleIndexesLoading && <LoadingSpinner className="mx-2" />}
{visibleIndexes && visibleIndexes.length > 0 && (
<MenuHeader>
<Tooltip content="Not intended for regular use">
<span>Display debug information for uploaded index.</span>
</Tooltip>
{[
<RadioButton
id="none"
key="none"
name="none"
label="None"
wrapperClassName="py-1"
wrapperClassName="py-1 px-2"
checked={visibleIndexID === undefined}
onChange={() => {
delete indexIDsForSnapshotData[repoName]
@ -93,29 +67,28 @@ const BrainDotContent: React.FunctionComponent<BrainDotProps> = ({ repoName, com
}}
/>,
...visibleIndexes.map(index => (
<Tooltip content={`Uploaded at ${index.uploadedAt}`} key={index.id}>
<RadioButton
id={index.id}
name={index.id}
checked={visibleIndexID === index.id}
wrapperClassName="py-1"
label={
<>
Index at <Code>{index.inputCommit.slice(0, 7)}</Code>
</>
}
onChange={() => {
indexIDsForSnapshotData[repoName] = index.id
setIndexIDForSnapshotData(indexIDsForSnapshotData)
}}
/>
</Tooltip>
<RadioButton
key={index.id}
id={index.id}
name={index.id}
checked={visibleIndexID === index.id}
wrapperClassName="py-1 px-2"
label={
<>
Index at <Code>{index.inputCommit.slice(0, 7)}</Code>
</>
}
onChange={() => {
indexIDsForSnapshotData[repoName] = index.id
setIndexIDForSnapshotData(indexIDsForSnapshotData)
}}
/>
)),
]}
</MenuHeader>
)}
{(visibleIndexes?.length ?? 0) === 0 && !visibleIndexesLoading && (
<MenuHeader>No precise indexes to display debug information for.</MenuHeader>
<small className="px-2">No precise indexes to display debug information for.</small>
)}
</>
)

View File

@ -1,6 +1,7 @@
import type { ApolloError } from '@apollo/client'
import { useQuery } from '@sourcegraph/http-client'
import { TetherAPI } from '@sourcegraph/wildcard'
import type { VisibleIndexesResult, VisibleIndexesVariables } from '../../../../graphql-operations'
import { visibleIndexesQuery } from '../backend'
@ -20,6 +21,12 @@ export const useVisibleIndexes = (variables: VisibleIndexesVariables): UseVisibl
const { data, error, loading } = useQuery<VisibleIndexesResult, VisibleIndexesVariables>(visibleIndexesQuery, {
variables,
fetchPolicy: 'cache-first',
onCompleted() {
TetherAPI.update()
},
onError() {
TetherAPI.update()
},
})
const indexes = data?.repository?.commit?.blob?.lsif?.visibleIndexes

View File

@ -1,6 +1,6 @@
.icon {
width: 1rem;
height: 1rem;
width: 0.75rem;
height: 0.75rem;
}
.item {

View File

@ -20,6 +20,7 @@ import {
} from '@sourcegraph/wildcard'
import { RepoHeaderActionAnchor, RepoHeaderActionMenuLink } from '../repo/components/RepoHeaderActions'
import { RepoActionInfo } from '../repo/RepoActionInfo'
import { eventLogger } from '../tracking/eventLogger'
import { getEditorSettingsErrorMessage } from './build-url'
@ -189,6 +190,7 @@ interface EditorItemProps {
source?: 'repoHeader' | 'actionItemsBar'
actionType?: 'nav' | 'dropdown'
}
function EditorItem(props: EditorItemProps): JSX.Element {
if (props.source === 'actionItemsBar') {
return (
@ -210,7 +212,7 @@ function EditorItem(props: EditorItemProps): JSX.Element {
return (
<Tooltip content={props.tooltip}>
<RepoHeaderActionAnchor onSelect={props.onClick} className={styles.item}>
{props.icon}
<RepoActionInfo icon={props.icon} displayName="Editor" />
</RepoHeaderActionAnchor>
</Tooltip>
)

View File

@ -0,0 +1,13 @@
@import 'wildcard/src/global-styles/breakpoints';
.repo-action-label {
margin-bottom: 0;
margin-left: 0.25rem;
font-size: 0.75rem;
}
@media (--md-breakpoint-down) {
.repo-action-label {
display: none;
}
}

View File

@ -0,0 +1,20 @@
import type { ReactNode, FC } from 'react'
import classNames from 'classnames'
import { Text } from '@sourcegraph/wildcard'
import styles from './RepoActionInfo.module.scss'
interface RepoActionInfoProps {
displayName: string
icon: ReactNode
className?: string
}
export const RepoActionInfo: FC<RepoActionInfoProps> = ({ displayName, icon, className }) => (
<>
{icon}
<Text className={classNames(styles.repoActionLabel, className)}>{displayName}</Text>
</>
)

View File

@ -238,7 +238,6 @@ export const RepoContainer: FC<RepoContainerProps> = props => {
settingsCascade={props.settingsCascade}
authenticatedUser={authenticatedUser}
platformContext={props.platformContext}
telemetryService={props.telemetryService}
/>
<Suspense fallback={<LoadingSpinner />}>
@ -483,27 +482,27 @@ const RepoUserContainer: FC<RepoUserContainerProps> = ({
/>
))}
<RepoHeaderContributionPortal
position="right"
priority={1}
id="cody"
{...repoHeaderContributionsLifecycleProps}
>
{() =>
!isCodySidebarOpen ? (
{!isCodySidebarOpen && (
<RepoHeaderContributionPortal
position="right"
priority={1}
id="cody"
{...repoHeaderContributionsLifecycleProps}
>
{() => (
<AskCodyButton
onClick={() => {
logTranscriptEvent(EventName.CODY_SIDEBAR_CHAT_OPENED, { repo, path: filePath })
setIsCodySidebarOpen(true)
}}
/>
) : null
}
</RepoHeaderContributionPortal>
)}
</RepoHeaderContributionPortal>
)}
<RepoHeaderContributionPortal
position="right"
priority={2}
priority={3}
id="go-to-code-host"
{...repoHeaderContributionsLifecycleProps}
>
@ -530,8 +529,9 @@ const RepoUserContainer: FC<RepoUserContainerProps> = ({
{isBrainDotVisible && (
<RepoHeaderContributionPortal
position="right"
priority={110}
priority={7}
id="code-intelligence-status"
renderInContextMenu={true}
{...repoHeaderContributionsLifecycleProps}
>
{({ actionType }) =>

View File

@ -12,6 +12,11 @@
min-width: 800px;
}
@media (--sm-breakpoint-up) and (--lg-breakpoint-down) {
flex-direction: column;
align-items: flex-start;
}
:global(.navbar-nav) {
white-space: nowrap;
@ -36,6 +41,7 @@
.action-list {
gap: 0.625rem;
margin-left: auto;
overflow-x: hidden;
}
.action-list-item:not(:empty) {

View File

@ -9,7 +9,7 @@ import { BrandedStory } from '@sourcegraph/wildcard/src/stories'
import type { AuthenticatedUser } from '../auth'
import { GoToPermalinkAction } from './actions/GoToPermalinkAction'
import { CopyPermalinkAction } from './actions/CopyPermalinkAction'
import { FilePathBreadcrumbs } from './FilePathBreadcrumbs'
import { RepoHeader, type RepoHeaderContributionsLifecycleProps } from './RepoHeader'
import { RepoRevisionContainerBreadcrumb } from './RepoRevisionContainer'
@ -76,12 +76,12 @@ const onLifecyclePropsChange = (lifecycleProps: RepoHeaderContributionsLifecycle
id: 'go-to-permalink',
position: 'right',
children: () => (
<GoToPermalinkAction
telemetryService={NOOP_TELEMETRY_SERVICE}
<CopyPermalinkAction
revision="main"
commitID="123"
repoName="sourcegraph/sourcegraph"
actionType="nav"
telemetryService={NOOP_TELEMETRY_SERVICE}
/>
),
})
@ -147,7 +147,6 @@ const createProps = (path: string, forceWrap: boolean = false): React.ComponentP
settingsCascade: EMPTY_SETTINGS_CASCADE,
authenticatedUser: mockUser,
platformContext: {} as any,
telemetryService: NOOP_TELEMETRY_SERVICE,
forceWrap,
})

View File

@ -6,7 +6,6 @@ import { useLocation } from 'react-router-dom'
import type { PlatformContextProps } from '@sourcegraph/shared/src/platform/context'
import type { SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Menu, MenuList, Position, Icon } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '../auth'
@ -15,6 +14,7 @@ import { ErrorBoundary } from '../components/ErrorBoundary'
import { useBreakpoint } from '../util/dom'
import { RepoHeaderActionDropdownToggle } from './components/RepoHeaderActions'
import { RepoHeaderContextMenu } from './RepoHeaderContextMenu'
import styles from './RepoHeader.module.scss'
@ -81,6 +81,8 @@ export interface RepoHeaderContribution {
* Use `actionType` to determine how to render the component.
*/
children: (context: RepoHeaderContext) => JSX.Element | null
renderInContextMenu?: boolean
}
/**
@ -114,7 +116,7 @@ export interface RepoHeaderContext {
actionType: 'nav' | 'dropdown'
}
interface Props extends PlatformContextProps, TelemetryProps, BreadcrumbsProps {
interface Props extends PlatformContextProps, BreadcrumbsProps {
/** The repoName from the URL */
repoName: string
@ -177,7 +179,18 @@ export const RepoHeader: React.FunctionComponent<React.PropsWithChildren<Props>>
const rightActions = useMemo(
() =>
repoHeaderContributions
.filter(({ position }) => position === 'right')
.filter(({ position, renderInContextMenu }) => position === 'right' && !renderInContextMenu)
.map(({ children, ...rest }) => ({
...rest,
element: children({ ...context, actionType: isLarge ? 'nav' : 'dropdown' }),
})),
[context, repoHeaderContributions, isLarge]
)
const rightActionsInContextMenu = useMemo(
() =>
repoHeaderContributions
.filter(({ position, renderInContextMenu }) => position === 'right' && renderInContextMenu)
.map(({ children, ...rest }) => ({
...rest,
element: children({ ...context, actionType: isLarge ? 'nav' : 'dropdown' }),
@ -190,7 +203,7 @@ export const RepoHeader: React.FunctionComponent<React.PropsWithChildren<Props>>
<Breadcrumbs
breadcrumbs={props.breadcrumbs}
className={classNames(
'justify-content-start flex-grow-1',
'justify-content-start flex-grow-1 w-auto m-0',
!props.forceWrap ? styles.breadcrumbWrap : ''
)}
/>
@ -223,6 +236,9 @@ export const RepoHeader: React.FunctionComponent<React.PropsWithChildren<Props>>
{a.element}
</li>
))}
<li className={classNames('nav-item', styles.actionListItem)}>
<RepoHeaderContextMenu actions={rightActionsInContextMenu} />
</li>
</ul>
) : (
<ul className="navbar-nav">
@ -232,7 +248,7 @@ export const RepoHeader: React.FunctionComponent<React.PropsWithChildren<Props>>
<Icon aria-hidden={true} svgPath={mdiDotsVertical} />
</RepoHeaderActionDropdownToggle>
<MenuList position={Position.bottomEnd}>
{rightActions.map(a => (
{[...rightActionsInContextMenu, ...rightActions].map(a => (
<React.Fragment key={a.id}>{a.element}</React.Fragment>
))}
</MenuList>

View File

@ -0,0 +1,19 @@
import type { FC } from 'react'
import { Menu, MenuButton, MenuList, Position } from '@sourcegraph/wildcard'
import type { RepoHeaderContribution } from './RepoHeader'
interface RepoHeaderContextMenuProps {
actions: (Pick<RepoHeaderContribution, 'id' | 'priority'> & { element: JSX.Element | null })[]
}
export const RepoHeaderContextMenu: FC<RepoHeaderContextMenuProps> = ({ actions }) => (
<Menu>
<MenuButton variant="secondary" outline={true} className="py-0">
&hellip;
</MenuButton>
<MenuList position={Position.bottom}>{actions.map(action => action.element)}</MenuList>
</Menu>
)

View File

@ -31,7 +31,7 @@ import type { OwnConfigProps } from '../own/OwnConfigProps'
import type { SearchStreamingProps } from '../search'
import type { RouteV6Descriptor } from '../util/contributions'
import { GoToPermalinkAction } from './actions/GoToPermalinkAction'
import { CopyPermalinkAction } from './actions/CopyPermalinkAction'
import type { ResolvedRevision } from './backend'
import { RepoRevisionChevronDownIcon, RepoRevisionWrapper } from './components/RepoRevision'
import { isPackageServiceType } from './packages/isPackageServiceType'
@ -217,19 +217,19 @@ export const RepoRevisionContainer: FC<RepoRevisionContainerProps> = props => {
)
)}
</Routes>
{resolvedRevision && !isPackage && (
{!isPackage && (
<RepoHeaderContributionPortal
id="go-to-permalink"
priority={3}
position="right"
priority={2}
id="copy-permalink"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
>
{context => (
<GoToPermalinkAction
key="go-to-permalink"
<CopyPermalinkAction
key="copy-permalink"
telemetryService={props.telemetryService}
revision={props.revision}
commitID={resolvedRevision.commitID}
commitID={resolvedRevision?.commitID}
{...context}
/>
)}

View File

@ -0,0 +1,170 @@
import React, { useEffect, useMemo, useState } from 'react'
import { mdiLink, mdiChevronDown, mdiContentCopy, mdiCheckBold } from '@mdi/js'
import { VisuallyHidden } from '@reach/visually-hidden'
import classNames from 'classnames'
import copy from 'copy-to-clipboard'
import { useLocation, useNavigate } from 'react-router-dom'
import { fromEvent } from 'rxjs'
import { filter } from 'rxjs/operators'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { isInputElement } from '@sourcegraph/shared/src/util/dom'
import {
Position,
Icon,
Link,
Button,
Menu,
ButtonGroup,
MenuButton,
MenuList,
MenuItem,
screenReaderAnnounce,
} from '@sourcegraph/wildcard'
import { replaceRevisionInURL } from '../../util/url'
import { RepoHeaderActionMenuLink } from '../components/RepoHeaderActions'
import { RepoActionInfo } from '../RepoActionInfo'
import type { RepoHeaderContext } from '../RepoHeader'
import styles from './actions.module.scss'
interface CopyPermalinkActionProps extends RepoHeaderContext, TelemetryProps {
/**
* The current (possibly undefined or non-full-SHA) Git revision.
*/
revision?: string
/**
* The commit SHA for the revision in the current location (URL).
*/
commitID?: string
}
/**
* A repository header action that replaces the revision in the URL with the canonical 40-character
* Git commit SHA.
*/
export const CopyPermalinkAction: React.FunctionComponent<CopyPermalinkActionProps> = props => {
const { revision, commitID, actionType, repoName, telemetryService } = props
const navigate = useNavigate()
const location = useLocation()
const fullURL = location.pathname + location.search + location.hash
const permalinkURL = useMemo(() => replaceRevisionInURL(fullURL, commitID || ''), [fullURL, commitID])
const linkURL = useMemo(() => replaceRevisionInURL(fullURL, revision || ''), [fullURL, revision])
const [copiedPermalink, setCopiedPermalink] = useState<boolean>(false)
const [copiedLink, setCopiedLink] = useState<boolean>(false)
useEffect(() => {
// Trigger the user presses 'y'.
const subscription = fromEvent<KeyboardEvent>(window, 'keydown')
.pipe(
filter(
event =>
// 'y' shortcut (if no input element is focused)
event.key === 'y' && !!document.activeElement && !isInputElement(document.activeElement)
)
)
.subscribe(event => {
event.preventDefault()
// Replace the revision in the current URL with the new one and push to history.
navigate(permalinkURL)
})
return () => subscription.unsubscribe()
}, [navigate, permalinkURL])
const onClick = (): void => {
telemetryService.log('PermalinkClicked', { repoName, commitID })
}
if (actionType === 'dropdown') {
return (
<RepoHeaderActionMenuLink as={Link} file={true} to={permalinkURL} onSelect={onClick}>
<Icon aria-hidden={true} svgPath={mdiLink} />
<span>Permalink (with full Git commit SHA)</span>
</RepoHeaderActionMenuLink>
)
}
const copyPermalink = (): void => {
telemetryService.log('CopyPermalink')
copy(permalinkURL)
setCopiedPermalink(true)
screenReaderAnnounce('Permalink copied to clipboard')
setTimeout(() => setCopiedPermalink(false), 1000)
}
const copyLink = (): void => {
telemetryService.log('CopyLink')
copy(linkURL)
setCopiedLink(true)
screenReaderAnnounce('Link copied to clipboard')
setTimeout(() => setCopiedLink(false), 1000)
}
const isRevisionTheSameAsCommitID = revision === commitID
const copyLinkLabel = copiedLink ? 'Copied!' : isRevisionTheSameAsCommitID ? 'Copy Link' : 'Links'
const copyLinkIcon = copiedLink ? mdiCheckBold : mdiContentCopy
return (
<Menu>
<ButtonGroup>
<Button className={classNames('border', styles.permalinkBtn, 'pt-0 pb-0')} onClick={copyLink}>
<RepoActionInfo
displayName={copyLinkLabel}
icon={
<Icon
svgPath={copyLinkIcon}
aria-hidden={true}
className={classNames({
[styles.checkedIcon]: copiedLink,
[styles.repoActionIcon]: !copiedLink,
})}
/>
}
/>
</Button>
{!isRevisionTheSameAsCommitID && (
<MenuButton variant="secondary" className={styles.chevronBtn}>
<Icon
className={styles.chevronBtnIcon}
svgPath={mdiChevronDown}
inline={false}
aria-hidden={true}
/>
<VisuallyHidden>Actions</VisuallyHidden>
</MenuButton>
)}
{!isRevisionTheSameAsCommitID && (
<MenuList position={Position.bottomEnd}>
<MenuItem
onSelect={copyPermalink}
className={classNames(styles.dropdownItem, 'justify-content-start')}
>
<RepoActionInfo
displayName={copiedPermalink ? 'Copied' : 'Copy permalink'}
icon={
<Icon
aria-hidden={true}
svgPath={copiedPermalink ? mdiCheckBold : mdiContentCopy}
className={classNames({
[styles.checkedIcon]: copiedLink,
[styles.repoActionIcon]: !copiedLink,
})}
/>
}
className={styles.permalinkText}
/>
</MenuItem>
</MenuList>
)}
</ButtonGroup>
</Menu>
)
}

View File

@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from 'react'
import classNames from 'classnames'
import { toLower, upperFirst } from 'lodash'
import BitbucketIcon from 'mdi-react/BitbucketIcon'
import ExportIcon from 'mdi-react/ExportIcon'
@ -20,8 +21,11 @@ import { type ExternalLinkFields, ExternalServiceKind, type RepositoryFields } f
import { eventLogger } from '../../tracking/eventLogger'
import { fetchCommitMessage, fetchFileExternalLinks } from '../backend'
import { RepoHeaderActionAnchor, RepoHeaderActionMenuLink } from '../components/RepoHeaderActions'
import { RepoActionInfo } from '../RepoActionInfo'
import type { RepoHeaderContext } from '../RepoHeader'
import styles from './actions.module.scss'
interface Props extends RevisionSpec, Partial<FileSpec> {
repo?: Pick<RepositoryFields, 'name' | 'defaultBranch' | 'externalURLs' | 'externalRepository'> | null
filePath?: string
@ -171,8 +175,14 @@ export const GoToCodeHostAction: React.FunctionComponent<
return (
<Tooltip content={descriptiveText}>
<RepoHeaderActionAnchor {...commonProps}>
<Icon as={exportIcon} aria-hidden={true} />
<RepoHeaderActionAnchor
{...commonProps}
className={classNames(commonProps.className, 'd-flex justify-content-center align-items-center')}
>
<RepoActionInfo
displayName={displayName}
icon={<Icon as={exportIcon} aria-hidden={true} className={styles.repoActionIcon} />}
/>
</RepoHeaderActionAnchor>
</Tooltip>
)

View File

@ -1,84 +0,0 @@
import React, { useEffect, useMemo } from 'react'
import { mdiLink } from '@mdi/js'
import { useLocation, useNavigate } from 'react-router-dom'
import { fromEvent } from 'rxjs'
import { filter } from 'rxjs/operators'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { isInputElement } from '@sourcegraph/shared/src/util/dom'
import { Icon, Link, Tooltip } from '@sourcegraph/wildcard'
import { replaceRevisionInURL } from '../../util/url'
import { RepoHeaderActionButtonLink, RepoHeaderActionMenuLink } from '../components/RepoHeaderActions'
import type { RepoHeaderContext } from '../RepoHeader'
interface GoToPermalinkActionProps extends RepoHeaderContext, TelemetryProps {
/**
* The current (possibly undefined or non-full-SHA) Git revision.
*/
revision?: string
/**
* The commit SHA for the revision in the current location (URL).
*/
commitID: string
}
/**
* A repository header action that replaces the revision in the URL with the canonical 40-character
* Git commit SHA.
*/
export const GoToPermalinkAction: React.FunctionComponent<GoToPermalinkActionProps> = props => {
const { revision, commitID, actionType, repoName, telemetryService } = props
const navigate = useNavigate()
const location = useLocation()
const fullURL = location.pathname + location.search + location.hash
const permalinkURL = useMemo(() => replaceRevisionInURL(fullURL, commitID), [fullURL, commitID])
useEffect(() => {
// Trigger the user presses 'y'.
const subscription = fromEvent<KeyboardEvent>(window, 'keydown')
.pipe(
filter(
event =>
// 'y' shortcut (if no input element is focused)
event.key === 'y' && !!document.activeElement && !isInputElement(document.activeElement)
)
)
.subscribe(event => {
event.preventDefault()
// Replace the revision in the current URL with the new one and push to history.
navigate(permalinkURL)
})
return () => subscription.unsubscribe()
}, [navigate, permalinkURL])
if (revision === commitID) {
return null // already at the permalink destination
}
const onClick = (): void => {
telemetryService.log('PermalinkClicked', { repoName, commitID })
}
if (actionType === 'dropdown') {
return (
<RepoHeaderActionMenuLink as={Link} file={true} to={permalinkURL} onSelect={onClick}>
<Icon aria-hidden={true} svgPath={mdiLink} />
<span>Permalink (with full Git commit SHA)</span>
</RepoHeaderActionMenuLink>
)
}
return (
<Tooltip content="Permalink (with full Git commit SHA)">
<RepoHeaderActionButtonLink aria-label="Permalink" file={false} to={permalinkURL} onSelect={onClick}>
<Icon aria-hidden={true} svgPath={mdiLink} />
</RepoHeaderActionButtonLink>
</Tooltip>
)
}

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { mdiAccountDetails, mdiAccountDetailsOutline } from '@mdi/js'
import { mdiGit } from '@mdi/js'
import { SimpleActionItem } from '@sourcegraph/shared/src/actions/SimpleActionItem'
import type { RenderMode } from '@sourcegraph/shared/src/util/url'
@ -9,6 +9,9 @@ import { Button, Icon, Tooltip } from '@sourcegraph/wildcard'
import { eventLogger } from '../../tracking/eventLogger'
import { useBlameVisibility } from '../blame/useBlameVisibility'
import { RepoHeaderActionAnchor, RepoHeaderActionMenuLink } from '../components/RepoHeaderActions'
import { RepoActionInfo } from '../RepoActionInfo'
import styles from './actions.module.scss'
interface Props {
source?: 'repoHeader' | 'actionItemsBar'
@ -16,6 +19,7 @@ interface Props {
renderMode?: RenderMode
isPackage: boolean
}
export const ToggleBlameAction: React.FC<Props> = props => {
const [isBlameVisible, setIsBlameVisible] = useBlameVisibility(props.isPackage)
@ -37,9 +41,7 @@ export const ToggleBlameAction: React.FC<Props> = props => {
}
}, [isBlameVisible, setIsBlameVisible])
const icon = (
<Icon aria-hidden={true} svgPath={isBlameVisible && !disabled ? mdiAccountDetails : mdiAccountDetailsOutline} />
)
const icon = <Icon aria-hidden={true} svgPath={mdiGit} />
if (props.source === 'actionItemsBar') {
return (
@ -65,8 +67,15 @@ export const ToggleBlameAction: React.FC<Props> = props => {
return (
<Tooltip content={descriptiveText}>
<RepoHeaderActionAnchor onSelect={toggleBlameState} disabled={disabled}>
{icon}
<RepoHeaderActionAnchor
onSelect={toggleBlameState}
disabled={disabled}
className="d-flex justify-content-center align-items-center"
>
<RepoActionInfo
displayName="Blame"
icon={<Icon aria-hidden={true} svgPath={mdiGit} className={styles.repoActionIcon} />}
/>
</RepoHeaderActionAnchor>
</Tooltip>
)

View File

@ -0,0 +1,37 @@
.repo-action-icon {
fill: var(--icon-color) !important;
}
.permalink-text {
font-size: 0.85rem;
}
.permalink-btn {
display: flex;
justify-content: center;
align-items: center;
padding: 0 0.5rem;
}
.checked-icon {
fill: var(--green) !important;
}
.chevron-btn {
padding: 0;
margin: 0;
width: 1.5rem;
background-color: transparent;
&-icon {
width: 1rem;
height: 1rem;
}
}
.dropdown-item {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}

View File

@ -73,6 +73,7 @@ import type { HoverThresholdProps } from '../RepoContainer'
import type { RepoHeaderContributionsLifecycleProps } from '../RepoHeader'
import { RepoHeaderContributionPortal } from '../RepoHeaderContributionPortal'
import { GoToRawAction } from './actions/GoToRawAction'
import { ToggleHistoryPanel } from './actions/ToggleHistoryPanel'
import { ToggleLineWrap } from './actions/ToggleLineWrap'
import { ToggleRenderedFileMode } from './actions/ToggleRenderedFileMode'
@ -80,7 +81,6 @@ import { getModeFromURL } from './actions/utils'
import { fetchBlob } from './backend'
import { BlobLoadingSpinner } from './BlobLoadingSpinner'
import { CodeMirrorBlob, type BlobInfo } from './CodeMirrorBlob'
import { GoToRawAction } from './GoToRawAction'
import { HistoryAndOwnBar } from './own/HistoryAndOwnBar'
import { BlobPanel } from './panel/BlobPanel'
import { RenderedFile } from './RenderedFile'
@ -363,7 +363,7 @@ export const BlobPage: React.FunctionComponent<BlobPageProps> = ({ className, co
{window.context.isAuthenticatedUser && (
<RepoHeaderContributionPortal
position="right"
priority={112}
priority={6}
id="open-in-editor-action"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
>
@ -379,7 +379,7 @@ export const BlobPage: React.FunctionComponent<BlobPageProps> = ({ className, co
)}
<RepoHeaderContributionPortal
position="right"
priority={111}
priority={4}
id="toggle-blame-action"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
>
@ -410,7 +410,7 @@ export const BlobPage: React.FunctionComponent<BlobPageProps> = ({ className, co
)}
<RepoHeaderContributionPortal
position="right"
priority={20}
priority={5}
id="toggle-blob-panel"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
>
@ -427,9 +427,10 @@ export const BlobPage: React.FunctionComponent<BlobPageProps> = ({ className, co
{renderMode === 'code' && (
<RepoHeaderContributionPortal
position="right"
priority={99}
priority={9}
id="toggle-line-wrap"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
renderInContextMenu={true}
>
{context => <ToggleLineWrap {...context} key="toggle-line-wrap" onDidUpdate={setWrapCode} />}
</RepoHeaderContributionPortal>
@ -437,9 +438,10 @@ export const BlobPage: React.FunctionComponent<BlobPageProps> = ({ className, co
<RepoHeaderContributionPortal
position="right"
priority={30}
priority={8}
id="raw-action"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
renderInContextMenu={true}
>
{context => (
<GoToRawAction
@ -543,9 +545,10 @@ export const BlobPage: React.FunctionComponent<BlobPageProps> = ({ className, co
{blobInfoOrError.richHTML && (
<RepoHeaderContributionPortal
position="right"
priority={100}
priority={10}
id="toggle-rendered-file-mode"
repoHeaderContributionsLifecycleProps={props.repoHeaderContributionsLifecycleProps}
renderInContextMenu={true}
>
{({ actionType }) => (
<ToggleRenderedFileMode

View File

@ -1,59 +0,0 @@
import * as React from 'react'
import { mdiFileDownloadOutline } from '@mdi/js'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { encodeRepoRevision, type RepoSpec, type RevisionSpec, type FileSpec } from '@sourcegraph/shared/src/util/url'
import { Icon, Link, Tooltip } from '@sourcegraph/wildcard'
import { RepoHeaderActionAnchor, RepoHeaderActionMenuLink } from '../components/RepoHeaderActions'
import type { RepoHeaderContext } from '../RepoHeader'
interface Props extends RepoSpec, Partial<RevisionSpec>, FileSpec, RepoHeaderContext, TelemetryProps {}
/**
* A repository header action that replaces the blob in the URL with the raw URL.
*/
export class GoToRawAction extends React.PureComponent<Props> {
private onClick(): void {
this.props.telemetryService.log('RawFileDownload', {
repoName: this.props.repoName,
filePath: this.props.filePath,
})
}
public render(): JSX.Element {
const to = `/${encodeRepoRevision(this.props)}/-/raw/${this.props.filePath}`
const descriptiveText = 'Raw (download file)'
if (this.props.actionType === 'dropdown') {
return (
<RepoHeaderActionMenuLink
as={Link}
to={to}
target="_blank"
file={true}
onSelect={this.onClick.bind(this)}
download={true}
>
<Icon aria-hidden={true} svgPath={mdiFileDownloadOutline} />
<span>{descriptiveText}</span>
</RepoHeaderActionMenuLink>
)
}
return (
<Tooltip content={descriptiveText}>
<RepoHeaderActionAnchor
aria-label={descriptiveText}
to={to}
target="_blank"
onClick={this.onClick.bind(this)}
download={true}
>
<Icon aria-hidden={true} svgPath={mdiFileDownloadOutline} />
</RepoHeaderActionAnchor>
</Tooltip>
)
}
}

View File

@ -0,0 +1,44 @@
import * as React from 'react'
import { mdiFileDownloadOutline } from '@mdi/js'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { encodeRepoRevision, type RepoSpec, type RevisionSpec, type FileSpec } from '@sourcegraph/shared/src/util/url'
import { Icon, Link } from '@sourcegraph/wildcard'
import { RepoHeaderActionMenuLink } from '../../components/RepoHeaderActions'
import type { RepoHeaderContext } from '../../RepoHeader'
import styles from './actions.module.scss'
interface Props extends RepoSpec, Partial<RevisionSpec>, FileSpec, RepoHeaderContext, TelemetryProps {}
/**
* A repository header action that replaces the blob in the URL with the raw URL.
*/
export class GoToRawAction extends React.PureComponent<Props> {
private onClick(): void {
this.props.telemetryService.log('RawFileDownload', {
repoName: this.props.repoName,
filePath: this.props.filePath,
})
}
public render(): JSX.Element {
const to = `/${encodeRepoRevision(this.props)}/-/raw/${this.props.filePath}`
return (
<RepoHeaderActionMenuLink
as={Link}
to={to}
target="_blank"
file={true}
onSelect={this.onClick.bind(this)}
download={true}
className={styles.menuItem}
>
<Icon aria-hidden={true} svgPath={mdiFileDownloadOutline} className={styles.repoActionIcon} />
<span>Raw download</span>
</RepoHeaderActionMenuLink>
)
}
}

View File

@ -17,9 +17,12 @@ import { Icon, Tooltip } from '@sourcegraph/wildcard'
import { eventLogger } from '../../../tracking/eventLogger'
import { RepoHeaderActionButtonLink, RepoHeaderActionMenuItem } from '../../components/RepoHeaderActions'
import { RepoActionInfo } from '../../RepoActionInfo'
import type { RepoHeaderContext } from '../../RepoHeader'
import type { BlobPanelTabID } from '../panel/BlobPanel'
import styles from './actions.module.scss'
/**
* A repository header action that toggles the visibility of the history panel.
*/
@ -113,8 +116,12 @@ export class ToggleHistoryPanel extends React.PureComponent<
file={false}
onSelect={this.onClick}
disabled={disabled}
className="d-flex justify-content-center align-items-center"
>
<Icon aria-hidden={true} svgPath={mdiHistory} />
<RepoActionInfo
displayName="History"
icon={<Icon aria-hidden={true} svgPath={mdiHistory} className={styles.repoActionIcon} />}
/>
</RepoHeaderActionButtonLink>
</Tooltip>
)

View File

@ -1,16 +1,18 @@
import * as React from 'react'
import { mdiWrap, mdiWrapDisabled } from '@mdi/js'
import { mdiWrap } from '@mdi/js'
import { fromEvent, Subject, Subscription } from 'rxjs'
import { filter } from 'rxjs/operators'
import { WrapDisabledIcon } from '@sourcegraph/shared/src/components/icons'
import { Icon, Tooltip } from '@sourcegraph/wildcard'
import { Icon, Button } from '@sourcegraph/wildcard'
import { eventLogger } from '../../../tracking/eventLogger'
import { RepoHeaderActionButtonLink, RepoHeaderActionMenuItem } from '../../components/RepoHeaderActions'
import { RepoHeaderActionMenuItem } from '../../components/RepoHeaderActions'
import type { RepoHeaderContext } from '../../RepoHeader'
import styles from './actions.module.scss'
/**
* A repository header action that toggles the line wrapping behavior for long lines in code files.
*/
@ -72,29 +74,16 @@ export class ToggleLineWrap extends React.PureComponent<
}
public render(): JSX.Element | null {
if (this.props.actionType === 'dropdown') {
return (
<RepoHeaderActionMenuItem file={true} onSelect={this.onClick}>
<Icon
as={this.state.value ? WrapDisabledIcon : undefined}
svgPath={!this.state.value ? mdiWrap : undefined}
aria-hidden={true}
/>
<span>{this.state.value ? 'Disable' : 'Enable'} wrapping long lines (Alt+Z/Opt+Z)</span>
</RepoHeaderActionMenuItem>
)
}
return (
<Tooltip content={`${this.state.value ? 'Disable' : 'Enable'} wrapping long lines (Alt+Z/Opt+Z)`}>
<RepoHeaderActionButtonLink
aria-label={this.state.value ? 'Disable' : 'Enable'}
file={false}
onSelect={this.onClick}
>
<Icon svgPath={this.state.value ? mdiWrapDisabled : mdiWrap} aria-hidden={true} />
</RepoHeaderActionButtonLink>
</Tooltip>
<RepoHeaderActionMenuItem file={true} onSelect={this.onClick} as={Button} className={styles.menuItem}>
<Icon
as={this.state.value ? WrapDisabledIcon : undefined}
svgPath={!this.state.value ? mdiWrap : undefined}
aria-hidden={true}
className={styles.repoActionIcon}
/>
<span>{this.state.value ? 'Disable' : 'Enable'} wrapping long lines</span>
</RepoHeaderActionMenuItem>
)
}

View File

@ -0,0 +1,16 @@
@import 'wildcard/src/global-styles/breakpoints';
.repo-action-icon {
fill: var(--icon-color) !important;
}
.menu-item {
padding: 0.5rem;
}
@media (--xs-breakpoint-down) {
.menu-item {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}

View File

@ -9,3 +9,4 @@ export { createTether } from './services/tether-registry'
// Shared type interfaces
export type { Tether } from './services/types'
export type { TetherInstanceAPI } from './services/tether-registry'
export { TetherAPI } from './services/tether-registry'

View File

@ -1,6 +1,31 @@
import { render } from './tether-render'
import type { Tether } from './types'
interface TetherAPIObject {
tethers: TetherInstanceAPI[]
registerTether: (tether: TetherInstanceAPI) => void
unregisterTether: (tether: TetherInstanceAPI) => void
update: () => void
}
export const TetherAPI: TetherAPIObject = {
tethers: [],
registerTether(tether: TetherInstanceAPI) {
this.tethers.push(tether)
},
unregisterTether(tether: TetherInstanceAPI) {
TetherAPI.tethers = TetherAPI.tethers.filter(tetherAPI => tetherAPI !== tether)
},
update() {
for (const tetherAPI of this.tethers) {
tetherAPI.forceUpdate()
}
},
}
export interface TetherInstanceAPI {
unsubscribe: () => void
forceUpdate: () => void
@ -30,8 +55,9 @@ export function createTether(tether: Tether): TetherInstanceAPI {
// Synthetic runs without target for the initial tooltip positioning render
requestAnimationFrame(() => render(tether, null))
return {
const tetherInstanceAPI: TetherInstanceAPI = {
unsubscribe: () => {
TetherAPI.unregisterTether(tetherInstanceAPI)
window.removeEventListener('resize', eventHandler, true)
document.removeEventListener('scroll', eventHandler, true)
document.removeEventListener('click', eventHandler, true)
@ -40,4 +66,8 @@ export function createTether(tether: Tether): TetherInstanceAPI {
},
forceUpdate: () => requestAnimationFrame(() => render(tether, null)),
}
TetherAPI.registerTether(tetherInstanceAPI)
return tetherInstanceAPI
}

View File

@ -78,6 +78,7 @@ export {
Flipping,
Strategy,
Overlapping,
TetherAPI,
} from './Popover'
export { Collapse, CollapseHeader, CollapsePanel } from './Collapse'
export {
@ -109,11 +110,10 @@ export type { ButtonLinkProps } from './ButtonLink'
export type { SelectProps, InputProps } from './Form'
export type { Series, SeriesLikeChart, CategoricalLikeChart, LineChartProps, BarChartProps } from './Charts'
export type { LinkProps } from './Link'
export type { PopoverOpenEvent, Rectangle, TetherInstanceAPI } from './Popover'
export type { PopoverOpenEvent, Rectangle, TetherInstanceAPI, Point } from './Popover'
export type { MenuLinkProps, MenuItemProps } from './Menu'
export type { TabsProps, TabListProps, TabProps, TabPanelProps, TabPanelsProps } from './Tabs'
export type { IconProps, IconType } from './Icon'
export type { Point } from './Popover'
export type { TreeNode, TreeProps, TreeRef } from './Tree'
export type { TooltipProps, TooltipOpenEvent } from './Tooltip'
export type { HeadingProps, HeadingElement } from './Typography'